fix(canvas): improve Figma mask and text fidelity
This commit is contained in:
parent
5b12b1597f
commit
8c85d2b953
|
|
@ -14,9 +14,11 @@
|
|||
- Fix MCP startup in the browser.
|
||||
- Fix CanvasKit loading outside the browser when project paths contain spaces.
|
||||
- Render imported Figma layer and fill blend modes such as multiply, screen, overlay, difference, hue, saturation, color, and luminosity.
|
||||
- Render common imported Figma mask stacks so visible layers above alpha, vector, or luminance masks are clipped by the mask shape.
|
||||
- Render common imported Figma mask stacks so visible layers above alpha, vector, or luminance masks are clipped by the mask shape, including consecutive mask layers.
|
||||
- Render Figma-style smoothed rectangle corners, including independent corner radii, and effect blend modes from imported Figma files.
|
||||
- Improve imported tiled image fills by applying Figma image transforms when repeating image patterns.
|
||||
- Keep imported Figma boolean operations editable as boolean-operation nodes instead of flattening them to vectors.
|
||||
- Apply imported variable font axes from Figma `fontVariations` when rendering text.
|
||||
|
||||
### Performance
|
||||
|
||||
|
|
|
|||
|
|
@ -20,18 +20,32 @@ export function renderMaskedChildIds(
|
|||
): void {
|
||||
for (let index = 0; index < childIds.length; index++) {
|
||||
const childId = childIds[index]
|
||||
const maskType = getVisibleMaskType(childId)
|
||||
if (!maskType) {
|
||||
const firstMaskType = getVisibleMaskType(childId)
|
||||
if (!firstMaskType) {
|
||||
renderChild(childId)
|
||||
continue
|
||||
}
|
||||
|
||||
const start = index + 1
|
||||
const masks: Array<{ id: string; type: MaskType }> = []
|
||||
let maskIndex = index
|
||||
while (maskIndex < childIds.length) {
|
||||
const maskType = getVisibleMaskType(childIds[maskIndex])
|
||||
if (!maskType) break
|
||||
masks.push({ id: childIds[maskIndex], type: maskType })
|
||||
maskIndex++
|
||||
}
|
||||
|
||||
const start = maskIndex
|
||||
let end = start
|
||||
while (end < childIds.length && !getVisibleMaskType(childIds[end])) end++
|
||||
if (start === end) continue
|
||||
if (start === end) {
|
||||
index = maskIndex - 1
|
||||
continue
|
||||
}
|
||||
|
||||
const lumaFilter = maskType === 'LUMINANCE' ? r.ck.ColorFilter.MakeLuma() : null
|
||||
const lumaFilter = masks.some((mask) => mask.type === 'LUMINANCE')
|
||||
? r.ck.ColorFilter.MakeLuma()
|
||||
: null
|
||||
try {
|
||||
resetMaskPaint(r)
|
||||
canvas.save()
|
||||
|
|
@ -41,9 +55,18 @@ export function renderMaskedChildIds(
|
|||
|
||||
resetMaskPaint(r)
|
||||
r.effectLayerPaint.setBlendMode(r.ck.BlendMode.DstIn)
|
||||
if (lumaFilter) r.effectLayerPaint.setColorFilter(lumaFilter)
|
||||
canvas.saveLayer(r.effectLayerPaint)
|
||||
renderMask(childId)
|
||||
for (const mask of masks) {
|
||||
if (mask.type === 'LUMINANCE' && lumaFilter) {
|
||||
resetMaskPaint(r)
|
||||
r.effectLayerPaint.setColorFilter(lumaFilter)
|
||||
canvas.saveLayer(r.effectLayerPaint)
|
||||
renderMask(mask.id)
|
||||
canvas.restore()
|
||||
continue
|
||||
}
|
||||
renderMask(mask.id)
|
||||
}
|
||||
canvas.restore()
|
||||
|
||||
canvas.restore()
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
import type { CanvasKit, FontWeight, Paragraph, TypefaceFontProvider } from 'canvaskit-wasm'
|
||||
import type {
|
||||
CanvasKit,
|
||||
FontWeight,
|
||||
Paragraph,
|
||||
TextFontVariations,
|
||||
TypefaceFontProvider
|
||||
} from 'canvaskit-wasm'
|
||||
import { uniq } from 'es-toolkit/array'
|
||||
|
||||
import { getCanvasKit } from '#core/canvaskit'
|
||||
|
|
@ -160,6 +166,13 @@ function getParagraphTextAlign(
|
|||
}
|
||||
}
|
||||
|
||||
export function textFontVariations(
|
||||
variations: SceneNode['fontVariations'] | undefined
|
||||
): TextFontVariations[] | undefined {
|
||||
if (!variations || variations.length === 0) return undefined
|
||||
return variations.map((variation) => ({ axis: variation.axis, value: variation.value }))
|
||||
}
|
||||
|
||||
function textDecorationValue(ck: CanvasKit, decoration: string): number {
|
||||
switch (decoration) {
|
||||
case 'UNDERLINE':
|
||||
|
|
@ -214,6 +227,7 @@ function addStyledRuns(
|
|||
weight: { value: s.fontWeight ?? node.fontWeight } as FontWeight,
|
||||
slant: (s.italic ?? node.italic) ? ck.FontSlant.Italic : ck.FontSlant.Upright
|
||||
},
|
||||
fontVariations: textFontVariations(s.fontVariations ?? node.fontVariations),
|
||||
letterSpacing: s.letterSpacing ?? (node.letterSpacing || 0),
|
||||
decoration: textDecorationValue(ck, s.textDecoration ?? node.textDecoration),
|
||||
heightMultiplier: runLineHeight ? runLineHeight / runFontSize : undefined,
|
||||
|
|
@ -269,6 +283,7 @@ export function buildParagraph(
|
|||
weight: { value: node.fontWeight } as FontWeight,
|
||||
slant: node.italic ? ck.FontSlant.Italic : ck.FontSlant.Upright
|
||||
},
|
||||
fontVariations: textFontVariations(node.fontVariations),
|
||||
letterSpacing: node.letterSpacing || 0,
|
||||
decoration: textDecorationValue(ck, node.textDecoration),
|
||||
heightMultiplier: node.lineHeight ? node.lineHeight / baseFontSize : undefined,
|
||||
|
|
|
|||
|
|
@ -136,7 +136,9 @@ export function encodeMessage(message: FigmaMessage): Uint8Array {
|
|||
const ncHex = Buffer.from(ncBytes).toString('hex')
|
||||
const finalHex = beforeArray + ncHex + afterArray
|
||||
|
||||
const finalBytes = new Uint8Array(finalHex.match(/.{2}/g)?.map((b) => Number.parseInt(b, 16)) ?? [])
|
||||
const finalBytes = new Uint8Array(
|
||||
finalHex.match(/.{2}/g)?.map((b) => Number.parseInt(b, 16)) ?? []
|
||||
)
|
||||
return compress(finalBytes)
|
||||
}
|
||||
|
||||
|
|
@ -306,6 +308,7 @@ export interface NodeChange {
|
|||
frameMaskDisabled?: boolean
|
||||
resizeToFit?: boolean
|
||||
// Vector
|
||||
booleanOperation?: 'UNION' | 'SUBTRACT' | 'INTERSECT' | 'EXCLUDE'
|
||||
vectorData?: unknown
|
||||
fillGeometry?: Array<{ windingRule?: string; commandsBlob?: number }>
|
||||
strokeGeometry?: Array<{ windingRule?: string; commandsBlob?: number }>
|
||||
|
|
@ -362,6 +365,7 @@ export interface NodeChange {
|
|||
textBidiVersion?: number
|
||||
textDecoration?: string
|
||||
textDecorationSkipInk?: boolean
|
||||
fontVariations?: Array<{ axisTag?: number; axisName?: string; value?: number }>
|
||||
fontVariantCommonLigatures?: boolean
|
||||
fontVariantContextualLigatures?: boolean
|
||||
fontVersion?: string
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { convertEffects, convertFills, convertStrokes } from './paint'
|
|||
import { importStyleRuns } from './style-runs'
|
||||
export { importStyleRuns } from './style-runs'
|
||||
import { convertFigmaDerivedTextGlyphs } from './derived-text-glyphs'
|
||||
import { convertFontVariations } from './font-variations'
|
||||
import { convertLetterSpacing, convertLineHeight, mapTextDecoration } from './text-values'
|
||||
export { convertEffects, convertFills, convertStrokes, setVariableColorResolver } from './paint'
|
||||
export { convertLetterSpacing, convertLineHeight, mapTextDecoration } from './text-values'
|
||||
|
|
@ -89,7 +90,7 @@ const NODE_TYPE_MAP: Record<string, NodeType | 'DOCUMENT' | 'VARIABLE'> = {
|
|||
STAR: 'STAR',
|
||||
REGULAR_POLYGON: 'POLYGON',
|
||||
VECTOR: 'VECTOR',
|
||||
BOOLEAN_OPERATION: 'VECTOR',
|
||||
BOOLEAN_OPERATION: 'BOOLEAN_OPERATION',
|
||||
GROUP: 'GROUP',
|
||||
SECTION: 'SECTION',
|
||||
COMPONENT: 'COMPONENT',
|
||||
|
|
@ -105,6 +106,18 @@ function mapNodeType(type?: string): NodeType | 'DOCUMENT' | 'VARIABLE' {
|
|||
return 'RECTANGLE'
|
||||
}
|
||||
|
||||
function mapBooleanOperation(nc: NodeChange): SceneNode['booleanOperation'] {
|
||||
if (nc.type !== 'BOOLEAN_OPERATION') return undefined
|
||||
switch (nc.booleanOperation) {
|
||||
case 'SUBTRACT':
|
||||
case 'INTERSECT':
|
||||
case 'EXCLUDE':
|
||||
return nc.booleanOperation
|
||||
default:
|
||||
return 'UNION'
|
||||
}
|
||||
}
|
||||
|
||||
function mapStackMode(mode?: string): LayoutMode {
|
||||
switch (mode) {
|
||||
case 'HORIZONTAL':
|
||||
|
|
@ -280,6 +293,7 @@ function convertTextProps(
|
|||
| 'letterSpacing'
|
||||
| 'maxLines'
|
||||
| 'styleRuns'
|
||||
| 'fontVariations'
|
||||
| 'textTruncation'
|
||||
| 'textDirection'
|
||||
| 'figmaDerivedLayout'
|
||||
|
|
@ -304,6 +318,7 @@ function convertTextProps(
|
|||
letterSpacing: convertLetterSpacing(nc.letterSpacing, nc.fontSize),
|
||||
maxLines: (nc.maxLines ?? null) as number | null,
|
||||
styleRuns: importStyleRuns(nc),
|
||||
fontVariations: convertFontVariations(nc),
|
||||
textTruncation: (nc.textTruncation as string) === 'ENDING' ? 'ENDING' : 'DISABLED',
|
||||
textDirection:
|
||||
(getOpenPencilPluginValue(nc, TEXT_DIRECTION_PLUGIN_KEY) as
|
||||
|
|
@ -466,6 +481,7 @@ export function nodeChangeToProps(
|
|||
visible: nc.visible ?? true,
|
||||
locked: nc.locked ?? false,
|
||||
blendMode: (nc.blendMode as Fill['blendMode']) ?? 'PASS_THROUGH',
|
||||
booleanOperation: mapBooleanOperation(nc),
|
||||
fills: convertFills(nc.fillPaints),
|
||||
strokes: convertStrokes(
|
||||
nc.strokePaints,
|
||||
|
|
@ -707,7 +723,9 @@ function preserveFigmaPayloadBlobs(value: unknown, blobs: Uint8Array[]): unknown
|
|||
} else {
|
||||
result[key] = {
|
||||
__openPencilFigmaBlob:
|
||||
blob instanceof Uint8Array ? blob : new Uint8Array(Object.values(blob as Record<string, number>))
|
||||
blob instanceof Uint8Array
|
||||
? blob
|
||||
: new Uint8Array(Object.values(blob as Record<string, number>))
|
||||
} satisfies PreservedFigmaBlob
|
||||
}
|
||||
} else {
|
||||
|
|
@ -798,10 +816,15 @@ function extractFigmaSymbolMetadata(
|
|||
| undefined
|
||||
return {
|
||||
symbolOverrides: preserveFigmaPayloadBlobs(sd?.symbolOverrides ?? [], blobs) as unknown[],
|
||||
componentPropAssignments: preserveFigmaPayloadBlobs(nc.componentPropAssignments ?? [], blobs) as unknown[],
|
||||
componentPropAssignments: preserveFigmaPayloadBlobs(
|
||||
nc.componentPropAssignments ?? [],
|
||||
blobs
|
||||
) as unknown[],
|
||||
derivedSymbolData: preserveFigmaPayloadBlobs(nc.derivedSymbolData ?? [], blobs) as unknown[],
|
||||
derivedSymbolDataLayoutVersion:
|
||||
typeof nc.derivedSymbolDataLayoutVersion === 'number' ? nc.derivedSymbolDataLayoutVersion : null,
|
||||
typeof nc.derivedSymbolDataLayoutVersion === 'number'
|
||||
? nc.derivedSymbolDataLayoutVersion
|
||||
: null,
|
||||
uniformScaleFactor: typeof sd?.uniformScaleFactor === 'number' ? sd.uniformScaleFactor : null
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,7 +102,10 @@ function parseGuidOrNull(value: string) {
|
|||
return /^\d+:\d+$/.test(value) ? stringToGuid(value) : null
|
||||
}
|
||||
|
||||
const FIGMA_PAYLOAD_VARIABLE_MAP_FIELDS = new Set(['variableConsumptionMap', 'parameterConsumptionMap'])
|
||||
const FIGMA_PAYLOAD_VARIABLE_MAP_FIELDS = new Set([
|
||||
'variableConsumptionMap',
|
||||
'parameterConsumptionMap'
|
||||
])
|
||||
const FIGMA_PAYLOAD_PAINT_VARIABLE_FIELDS = new Set(['colorVar', 'opacityVar'])
|
||||
|
||||
const SUPPORTED_VARIABLE_DATA_TYPES = new Set([
|
||||
|
|
@ -118,14 +121,21 @@ const SUPPORTED_VARIABLE_DATA_TYPES = new Set([
|
|||
|
||||
function isSupportedVariableMapEntry(value: unknown): boolean {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const entry = value as { variableData?: { dataType?: string; value?: { propRefValue?: unknown } } }
|
||||
const entry = value as {
|
||||
variableData?: { dataType?: string; value?: { propRefValue?: unknown } }
|
||||
}
|
||||
const dataType = entry.variableData?.dataType
|
||||
return (typeof dataType === 'string' && SUPPORTED_VARIABLE_DATA_TYPES.has(dataType)) || !!entry.variableData?.value?.propRefValue
|
||||
return (
|
||||
(typeof dataType === 'string' && SUPPORTED_VARIABLE_DATA_TYPES.has(dataType)) ||
|
||||
!!entry.variableData?.value?.propRefValue
|
||||
)
|
||||
}
|
||||
|
||||
function isPropRefVariableMapEntry(value: unknown): boolean {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const entry = value as { variableData?: { dataType?: string; value?: { propRefValue?: unknown } } }
|
||||
const entry = value as {
|
||||
variableData?: { dataType?: string; value?: { propRefValue?: unknown } }
|
||||
}
|
||||
return entry.variableData?.dataType === 'PROP_REF' || !!entry.variableData?.value?.propRefValue
|
||||
}
|
||||
|
||||
|
|
@ -143,8 +153,9 @@ function materializeSafeVariableMap(
|
|||
|
||||
function paintVariableKey(value: unknown): string | null {
|
||||
if (!value || typeof value !== 'object') return null
|
||||
const assetRef = (value as { value?: { alias?: { assetRef?: { key?: unknown; version?: unknown } } } })
|
||||
.value?.alias?.assetRef
|
||||
const assetRef = (
|
||||
value as { value?: { alias?: { assetRef?: { key?: unknown; version?: unknown } } } }
|
||||
).value?.alias?.assetRef
|
||||
return typeof assetRef?.key === 'string'
|
||||
? `${assetRef.key}:${typeof assetRef.version === 'string' ? assetRef.version : ''}`
|
||||
: null
|
||||
|
|
@ -192,7 +203,8 @@ function materializeFigmaPayload(
|
|||
options: MaterializeFigmaPayloadOptions = {}
|
||||
): unknown {
|
||||
if (value instanceof Uint8Array) return value
|
||||
if (Array.isArray(value)) return value.map((item) => materializeFigmaPayload(item, blobs, options))
|
||||
if (Array.isArray(value))
|
||||
return value.map((item) => materializeFigmaPayload(item, blobs, options))
|
||||
if (!value || typeof value !== 'object') return value
|
||||
if ('__openPencilFigmaBlob' in value) {
|
||||
return materializeFigmaBlob(
|
||||
|
|
@ -326,11 +338,15 @@ function applyInstancePayload(
|
|||
if (symbolID) {
|
||||
const symbolData: Record<string, unknown> = { symbolID }
|
||||
if (node.source.fig.symbolOverrides.length > 0) {
|
||||
symbolData.symbolOverrides = materializeFigmaPayload(node.source.fig.symbolOverrides, context.blobs, {
|
||||
blobIndexByHex: context.blobIndexByHex,
|
||||
includeVariableMaps: true,
|
||||
paintVariableColorMap: context.paintVariableColorMap
|
||||
})
|
||||
symbolData.symbolOverrides = materializeFigmaPayload(
|
||||
node.source.fig.symbolOverrides,
|
||||
context.blobs,
|
||||
{
|
||||
blobIndexByHex: context.blobIndexByHex,
|
||||
includeVariableMaps: true,
|
||||
paintVariableColorMap: context.paintVariableColorMap
|
||||
}
|
||||
)
|
||||
}
|
||||
if (node.source.fig.uniformScaleFactor != null) {
|
||||
symbolData.uniformScaleFactor = node.source.fig.uniformScaleFactor
|
||||
|
|
@ -349,11 +365,15 @@ function applyInstancePayload(
|
|||
)
|
||||
}
|
||||
if (node.source.fig.derivedSymbolData.length > 0) {
|
||||
nc.derivedSymbolData = materializeFigmaPayload(node.source.fig.derivedSymbolData, context.blobs, {
|
||||
blobIndexByHex: context.blobIndexByHex,
|
||||
includeVariableMaps: true,
|
||||
paintVariableColorMap: context.paintVariableColorMap
|
||||
})
|
||||
nc.derivedSymbolData = materializeFigmaPayload(
|
||||
node.source.fig.derivedSymbolData,
|
||||
context.blobs,
|
||||
{
|
||||
blobIndexByHex: context.blobIndexByHex,
|
||||
includeVariableMaps: true,
|
||||
paintVariableColorMap: context.paintVariableColorMap
|
||||
}
|
||||
)
|
||||
}
|
||||
if (node.source.fig.derivedSymbolDataLayoutVersion != null) {
|
||||
nc.derivedSymbolDataLayoutVersion = node.source.fig.derivedSymbolDataLayoutVersion
|
||||
|
|
@ -400,15 +420,22 @@ function applyComponentMetadata(node: SceneNode, nc: KiwiNodeChange): void {
|
|||
}
|
||||
|
||||
function exportNodeSize(node: SceneNode): Vector {
|
||||
return node.source.fig.rawSize ? { ...node.source.fig.rawSize } : { x: node.width, y: node.height }
|
||||
return node.source.fig.rawSize
|
||||
? { ...node.source.fig.rawSize }
|
||||
: { x: node.width, y: node.height }
|
||||
}
|
||||
|
||||
function exportNodeTransform(context: SceneNodeToKiwiContext, node: SceneNode): Matrix {
|
||||
return node.source.fig.rawTransform ? { ...node.source.fig.rawTransform } : context.computeExportTransform(node)
|
||||
return node.source.fig.rawTransform
|
||||
? { ...node.source.fig.rawTransform }
|
||||
: context.computeExportTransform(node)
|
||||
}
|
||||
|
||||
function hasRawGeometryPayload(node: SceneNode): boolean {
|
||||
return 'fillGeometry' in node.source.fig.rawNodeFields || 'strokeGeometry' in node.source.fig.rawNodeFields
|
||||
return (
|
||||
'fillGeometry' in node.source.fig.rawNodeFields ||
|
||||
'strokeGeometry' in node.source.fig.rawNodeFields
|
||||
)
|
||||
}
|
||||
|
||||
function hasRawVectorPayload(node: SceneNode): boolean {
|
||||
|
|
@ -527,6 +554,7 @@ export function sceneNodeToKiwiWithContext(
|
|||
applyInstancePayload(context, node, nc, localIdCounter)
|
||||
if (node.type === 'COMPONENT_SET') upsertPluginData(node, NODE_TYPE_PLUGIN_KEY, node.type)
|
||||
if (nc.type === 'CANVAS') nc.pageType = 'DESIGN'
|
||||
if (node.type === 'BOOLEAN_OPERATION') nc.booleanOperation = node.booleanOperation ?? 'UNION'
|
||||
if (strokePaints.length > 0) nc.strokePaints = strokePaints
|
||||
|
||||
context.serializeLayoutProps(node, nc)
|
||||
|
|
|
|||
23
packages/core/src/kiwi/fig/node-change/font-variations.ts
Normal file
23
packages/core/src/kiwi/fig/node-change/font-variations.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import type { NodeChange } from '#core/kiwi/fig/codec'
|
||||
import type { FontVariation } from '#core/scene-graph'
|
||||
|
||||
function figmaAxisTagToString(axisTag: number): string {
|
||||
return String.fromCharCode(
|
||||
(axisTag >> 24) & 0xff,
|
||||
(axisTag >> 16) & 0xff,
|
||||
(axisTag >> 8) & 0xff,
|
||||
axisTag & 0xff
|
||||
)
|
||||
}
|
||||
|
||||
export function convertFontVariations(nc: NodeChange): FontVariation[] {
|
||||
const result: FontVariation[] = []
|
||||
for (const variation of nc.fontVariations ?? []) {
|
||||
if (typeof variation.value !== 'number') continue
|
||||
const axis =
|
||||
variation.axisName ||
|
||||
(typeof variation.axisTag === 'number' ? figmaAxisTagToString(variation.axisTag) : '')
|
||||
if (axis) result.push({ axis, value: variation.value })
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
@ -46,6 +46,8 @@ export function mapToFigmaType(type: SceneNode['type']): string {
|
|||
return 'REGULAR_POLYGON'
|
||||
case 'VECTOR':
|
||||
return 'VECTOR'
|
||||
case 'BOOLEAN_OPERATION':
|
||||
return 'BOOLEAN_OPERATION'
|
||||
case 'GROUP':
|
||||
return 'FRAME'
|
||||
case 'SECTION':
|
||||
|
|
@ -137,12 +139,13 @@ function buildDerivedTextData(
|
|||
: glyphAdvance,
|
||||
rotation: 0
|
||||
}))
|
||||
: (getGlyphOutlineMetricsSync(
|
||||
node.fontFamily,
|
||||
weightToStyle(node.fontWeight, node.italic),
|
||||
node.text,
|
||||
node.fontSize
|
||||
) ?? []
|
||||
: (
|
||||
getGlyphOutlineMetricsSync(
|
||||
node.fontFamily,
|
||||
weightToStyle(node.fontWeight, node.italic),
|
||||
node.text,
|
||||
node.fontSize
|
||||
) ?? []
|
||||
).map((glyph, index) => ({
|
||||
commandsBlob: appendGlyphBlob(
|
||||
blobs,
|
||||
|
|
@ -173,6 +176,10 @@ function buildDerivedTextData(
|
|||
})
|
||||
}
|
||||
|
||||
function fontVariationToKiwi(variation: SceneNode['fontVariations'][number]) {
|
||||
return { axisName: variation.axis, value: variation.value }
|
||||
}
|
||||
|
||||
function exportTextData(node: SceneNode): NodeChange['textData'] {
|
||||
const runs = node.styleRuns
|
||||
if (runs.length === 0) {
|
||||
|
|
@ -206,6 +213,9 @@ function exportTextData(node: SceneNode): NodeChange['textData'] {
|
|||
postscript: ''
|
||||
}
|
||||
if (style.fontSize !== undefined) override.fontSize = style.fontSize
|
||||
if (style.fontVariations && style.fontVariations.length > 0) {
|
||||
override.fontVariations = style.fontVariations.map(fontVariationToKiwi)
|
||||
}
|
||||
if (style.letterSpacing !== undefined) {
|
||||
override.letterSpacing = { value: style.letterSpacing, units: 'PIXELS' }
|
||||
}
|
||||
|
|
@ -300,6 +310,9 @@ function serializeTextProps(
|
|||
postscript: ''
|
||||
}
|
||||
nc.textData = exportTextData(node)
|
||||
if (node.fontVariations.length > 0) {
|
||||
nc.fontVariations = node.fontVariations.map(fontVariationToKiwi)
|
||||
}
|
||||
const autoResize = resolveTextAutoResize(node, graph)
|
||||
nc.textAutoResize = autoResize
|
||||
nc.textAlignHorizontal = node.textAlignHorizontal
|
||||
|
|
@ -322,16 +335,14 @@ function serializeTextProps(
|
|||
}
|
||||
}
|
||||
|
||||
function normalizeStackMode(
|
||||
value: string | undefined
|
||||
): KiwiNodeChange['stackMode'] {
|
||||
function normalizeStackMode(value: string | undefined): KiwiNodeChange['stackMode'] {
|
||||
return value === 'HORIZONTAL' || value === 'VERTICAL' || value === 'NONE' ? value : undefined
|
||||
}
|
||||
|
||||
function normalizeStackSizing(
|
||||
value: string | undefined
|
||||
): KiwiNodeChange['stackPrimarySizing'] {
|
||||
return value === 'FIXED' || value === 'RESIZE_TO_FIT' || value === 'RESIZE_TO_FIT_WITH_IMPLICIT_SIZE'
|
||||
function normalizeStackSizing(value: string | undefined): KiwiNodeChange['stackPrimarySizing'] {
|
||||
return value === 'FIXED' ||
|
||||
value === 'RESIZE_TO_FIT' ||
|
||||
value === 'RESIZE_TO_FIT_WITH_IMPLICIT_SIZE'
|
||||
? value
|
||||
: undefined
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { NodeChange } from '#core/kiwi/fig/codec'
|
|||
import type { CharacterStyleOverride, StyleRun } from '#core/scene-graph'
|
||||
import { styleToWeight } from '#core/text/fonts'
|
||||
|
||||
import { convertFontVariations } from './font-variations'
|
||||
import { convertFills } from './paint'
|
||||
import { convertLetterSpacing, convertLineHeight, mapTextDecoration } from './text-values'
|
||||
|
||||
|
|
@ -16,6 +17,8 @@ function convertStyleOverride(
|
|||
style.italic = override.fontName.style.toLowerCase().includes('italic')
|
||||
}
|
||||
if (override.fontSize !== undefined) style.fontSize = override.fontSize
|
||||
const fontVariations = convertFontVariations(override)
|
||||
if (fontVariations.length > 0) style.fontVariations = fontVariations
|
||||
if (override.letterSpacing) {
|
||||
style.letterSpacing = convertLetterSpacing(
|
||||
override.letterSpacing,
|
||||
|
|
|
|||
|
|
@ -36,9 +36,7 @@ export function createDefaultNode(
|
|||
},
|
||||
figmaDerivedLayout: null,
|
||||
fills:
|
||||
type === 'TEXT'
|
||||
? [{ type: 'SOLID' as const, color: BLACK, opacity: 1, visible: true }]
|
||||
: [],
|
||||
type === 'TEXT' ? [{ type: 'SOLID' as const, color: BLACK, opacity: 1, visible: true }] : [],
|
||||
strokes: [],
|
||||
effects: [],
|
||||
opacity: 1,
|
||||
|
|
@ -88,6 +86,7 @@ export function createDefaultNode(
|
|||
textDecoration: 'NONE',
|
||||
maxLines: null,
|
||||
styleRuns: [],
|
||||
fontVariations: [],
|
||||
horizontalConstraint: 'MIN',
|
||||
verticalConstraint: 'MIN',
|
||||
strokeCap: 'NONE',
|
||||
|
|
|
|||
|
|
@ -173,6 +173,11 @@ export type TextDecoration = 'NONE' | 'UNDERLINE' | 'STRIKETHROUGH'
|
|||
export type TextDirection = 'AUTO' | 'LTR' | 'RTL'
|
||||
export type LayoutDirection = 'AUTO' | 'LTR' | 'RTL'
|
||||
|
||||
export interface FontVariation {
|
||||
axis: string
|
||||
value: number
|
||||
}
|
||||
|
||||
export interface CharacterStyleOverride {
|
||||
fontWeight?: number
|
||||
italic?: boolean
|
||||
|
|
@ -182,6 +187,7 @@ export interface CharacterStyleOverride {
|
|||
letterSpacing?: number
|
||||
lineHeight?: number | null
|
||||
fills?: Fill[]
|
||||
fontVariations?: FontVariation[]
|
||||
}
|
||||
|
||||
export interface StyleRun {
|
||||
|
|
@ -326,6 +332,7 @@ export interface SceneNode {
|
|||
maxLines: number | null
|
||||
|
||||
styleRuns: StyleRun[]
|
||||
fontVariations: FontVariation[]
|
||||
|
||||
horizontalConstraint: ConstraintType
|
||||
verticalConstraint: ConstraintType
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@ OpenPencil is moving toward production-grade Figma compatibility while keeping d
|
|||
|
||||
- Preserve and round-trip more Figma metadata safely.
|
||||
- Add visual regression coverage for full multi-page `.fig` documents.
|
||||
- Close high-impact renderer gaps: masks, blend modes, corner smoothing, pattern fills, and variable font axes.
|
||||
- Improve boolean operation import so Figma `BOOLEAN_OPERATION` nodes remain editable where possible.
|
||||
- Close high-impact renderer gaps: remaining mask edge cases, blend isolation, pattern fills, and broader variable-font fixtures.
|
||||
- Improve boolean operation editing/export now that imported Figma `BOOLEAN_OPERATION` nodes remain boolean operations.
|
||||
|
||||
### Editor depth
|
||||
|
||||
|
|
@ -117,7 +117,7 @@ Figma's design documentation groups features into these areas:
|
|||
| Polygons / stars | ✅ | ✅ | ◐ | ✅ | ✅ | `pointCount` and `starInnerRadius` modeled. |
|
||||
| Text | ✅ | ✅ | ◐ | ✅ | ✅ | Derived Figma glyphs improve fidelity; advanced typography is partial. |
|
||||
| Vectors / vector networks | ✅ | ✅ | ◐ | ✅ | ✅ | Vector edit support exists; Figma Draw tools are not fully replicated. |
|
||||
| Boolean operations | ◐ | ✅ | ◐ | ◐ | ✅ | Engine/renderer support exists, but `.fig` import currently maps Figma `BOOLEAN_OPERATION` nodes to `VECTOR`. |
|
||||
| Boolean operations | ✅ | ✅ | ◐ | ✅ | ✅ | Figma `BOOLEAN_OPERATION` nodes import/export as boolean operations; inspector editing remains limited. |
|
||||
| Components | ✅ | ✅ | ◐ | ✅ | ✅ | Component metadata, descriptions, links, and publish fields mostly round-trip. |
|
||||
| Component sets / variants | ✅ | ✅ | ◐ | ✅ | ✅ | Variant values are usable; full component property authoring is incomplete. |
|
||||
| Instances / overrides | ✅ | ✅ | ◐ | ✅ | ✅ | Raw symbol overrides and derived symbol data are preserved for fidelity. |
|
||||
|
|
@ -139,7 +139,7 @@ Figma's design documentation groups features into these areas:
|
|||
| Effect styles | ↩ | — | — | ↩ | — | Style IDs round-trip; no style manager. |
|
||||
| Corner radius | ✅ | ✅ | ✅ | ✅ | ✅ | Uniform and independent radii supported. |
|
||||
| Corner smoothing | ✅ | ✅ | — | ✅ | ✅ | Figma-style smoothed corners render for common uniform and independent-radius rectangles; exact parity still needs broader fixture tuning. |
|
||||
| Masks | ✅ | ◐ | — | ✅ | ✅ | Common sibling alpha/vector/luminance mask stacks render; UI controls and edge-case Figma semantics remain incomplete. |
|
||||
| Masks | ✅ | ◐ | — | ✅ | ✅ | Common sibling alpha/vector/luminance mask stacks render, including consecutive mask layers; UI controls and deeper Figma edge cases remain incomplete. |
|
||||
| Auto layout: vertical/horizontal | ✅ | ✅ | ✅ | ✅ | ✅ | Yoga-backed layout. |
|
||||
| Auto layout: wrap | ✅ | ✅ | ✅ | ✅ | ✅ | UI toggle exists. |
|
||||
| Auto layout: grid | ✅ | ◐ | ◐ | ✅ | ✅ | CSS-grid-like support is partial. |
|
||||
|
|
@ -157,7 +157,7 @@ Figma's design documentation groups features into these areas:
|
|||
| Text case | ✅ | ◐ | — | ✅ | ✅ | Model/export/JSX support; UI missing. |
|
||||
| Vertical text alignment | ✅ | ◐ | — | ✅ | ✅ | Modeled; UI/render parity needs more coverage. |
|
||||
| Justified text | ✅ | ◐ | — | ✅ | ✅ | Modeled; UI does not expose it. |
|
||||
| Font variations / OpenType features | ↩ | — | — | ↩ | — | `fontVariations` are preserved only. |
|
||||
| Font variations / OpenType features | ✅ | ✅ | — | ✅ | — | Imported `fontVariations` are applied to CanvasKit text styles and exported; OpenType feature controls are not exposed. |
|
||||
| Variables: collections/modes/aliases | ✅ | ◐ | ◐ | ✅ | ✅ | Color/number/string/boolean model exists; inspector coverage is still incomplete. |
|
||||
| Variables bound to fills/strokes | ✅ | ✅ | ✅ | ✅ | ✅ | Common color bindings render and edit. |
|
||||
| Variables bound to text/layout/visibility/effects | ◐ | ◐ | ◐ | ◐ | ✅ | Some bindings exist; not full Figma property coverage. |
|
||||
|
|
@ -196,18 +196,18 @@ OpenPencil deliberately preserves many Figma/Kiwi fields even when they are not
|
|||
| Variable and parameter consumption maps | ✅ | ◐ | ◐ | Filtered/preserved for safe round-trip; normalized bindings cover common cases. |
|
||||
| Page fields: background, page type, guides | ↩ | ◐ | — | Background/page type/guides mostly round-trip. Guides are not rendered/editable. |
|
||||
| Text internals: `textData`, layout versions, font version, derived data | ✅ | ✅ | — | Important for text fidelity; most internals are not editable. |
|
||||
| `fontVariations` | ↩ | — | — | Variable font data is preserved, not rendered. |
|
||||
| `fontVariations` | ✅ | ✅ | — | Variable font axes are imported, rendered, and exported for text nodes and style runs. |
|
||||
| Raw paint/effect/vector/geometry payloads | ✅ | ✅ | ◐ | Converted fields render; raw payloads preserve Figma import/export details. |
|
||||
|
||||
## Highest-priority visual gaps
|
||||
|
||||
These are parsed or visible in Figma docs and most likely to cause visible differences in real design files:
|
||||
|
||||
1. **Masks** — tune multi-mask stacks and exact Figma edge cases beyond the common alpha/vector/luminance path.
|
||||
1. **Masks** — tune remaining exact Figma stack semantics beyond common alpha/vector/luminance and consecutive-mask paths.
|
||||
2. **Corner smoothing** — expand Figma fixture comparisons and tune remaining stroke/effect edge cases.
|
||||
3. **Pattern fills/strokes** — support Figma pattern paint objects and transforms beyond image tile fills.
|
||||
4. **Font variations** — apply variable-font axes from imported Figma metadata.
|
||||
5. **Boolean operation import** — keep Figma `BOOLEAN_OPERATION` nodes as boolean operations where possible instead of importing them as vectors.
|
||||
4. **Variable-font fixtures** — broaden real-file coverage for variable axes and OpenType feature metadata.
|
||||
5. **Boolean operation editing** — improve inspector/tooling workflows for imported boolean-operation nodes.
|
||||
6. **Layout grids and guides** — render/edit page guides and Figma layout grids, or clearly keep them round-trip-only.
|
||||
7. **Full component property and slot workflows** — support authoring, not just preserving imported payloads.
|
||||
8. **Prototype metadata** — start by preserving prototype flows/connections even before building playback.
|
||||
|
|
|
|||
|
|
@ -104,7 +104,9 @@ test('gradients and image fill modes', async () => {
|
|||
width: 700,
|
||||
height: 250,
|
||||
cornerRadius: 22,
|
||||
fills: [{ type: 'SOLID', color: { r: 0.95, g: 0.95, b: 0.97, a: 1 }, visible: true, opacity: 1 }]
|
||||
fills: [
|
||||
{ type: 'SOLID', color: { r: 0.95, g: 0.95, b: 0.97, a: 1 }, visible: true, opacity: 1 }
|
||||
]
|
||||
})
|
||||
|
||||
const gradientStops = [
|
||||
|
|
@ -112,7 +114,12 @@ test('gradients and image fill modes', async () => {
|
|||
{ color: { r: 0.58, g: 0.27, b: 0.95, a: 1 }, position: 0.55 },
|
||||
{ color: { r: 0.08, g: 0.73, b: 0.73, a: 1 }, position: 1 }
|
||||
]
|
||||
const gradientTypes = ['GRADIENT_LINEAR', 'GRADIENT_RADIAL', 'GRADIENT_ANGULAR', 'GRADIENT_DIAMOND'] as const
|
||||
const gradientTypes = [
|
||||
'GRADIENT_LINEAR',
|
||||
'GRADIENT_RADIAL',
|
||||
'GRADIENT_ANGULAR',
|
||||
'GRADIENT_DIAMOND'
|
||||
] as const
|
||||
for (const [index, type] of gradientTypes.entries()) {
|
||||
store.graph.createNode('RECTANGLE', pageId, {
|
||||
name: `${type} visual`,
|
||||
|
|
@ -121,7 +128,9 @@ test('gradients and image fill modes', async () => {
|
|||
width: 92,
|
||||
height: 72,
|
||||
cornerRadius: 16,
|
||||
fills: [{ type, color: { r: 0, g: 0, b: 0, a: 1 }, visible: true, opacity: 1, gradientStops }]
|
||||
fills: [
|
||||
{ type, color: { r: 0, g: 0, b: 0, a: 1 }, visible: true, opacity: 1, gradientStops }
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -233,7 +242,9 @@ test('luminance masks and transformed tile fills', async () => {
|
|||
width: 240,
|
||||
height: 130,
|
||||
cornerRadius: 18,
|
||||
fills: [{ type: 'SOLID', color: { r: 0.08, g: 0.1, b: 0.18, a: 1 }, visible: true, opacity: 1 }]
|
||||
fills: [
|
||||
{ type: 'SOLID', color: { r: 0.08, g: 0.1, b: 0.18, a: 1 }, visible: true, opacity: 1 }
|
||||
]
|
||||
})
|
||||
store.graph.createNode('RECTANGLE', frame.id, {
|
||||
name: 'Luminance mask gradient',
|
||||
|
|
@ -281,6 +292,57 @@ test('luminance masks and transformed tile fills', async () => {
|
|||
})
|
||||
}
|
||||
|
||||
const multiMaskFrame = store.graph.createNode('FRAME', pageId, {
|
||||
name: 'Consecutive mask stack visual',
|
||||
x: 620,
|
||||
y: 76,
|
||||
width: 220,
|
||||
height: 130,
|
||||
cornerRadius: 18,
|
||||
fills: [
|
||||
{ type: 'SOLID', color: { r: 0.08, g: 0.1, b: 0.18, a: 1 }, visible: true, opacity: 1 }
|
||||
]
|
||||
})
|
||||
store.graph.createNode('ELLIPSE', multiMaskFrame.id, {
|
||||
name: 'First combined mask',
|
||||
x: 18,
|
||||
y: 16,
|
||||
width: 112,
|
||||
height: 98,
|
||||
isMask: true,
|
||||
fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, visible: true, opacity: 1 }]
|
||||
})
|
||||
store.graph.createNode('ELLIPSE', multiMaskFrame.id, {
|
||||
name: 'Second combined mask',
|
||||
x: 90,
|
||||
y: 16,
|
||||
width: 112,
|
||||
height: 98,
|
||||
isMask: true,
|
||||
fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, visible: true, opacity: 1 }]
|
||||
})
|
||||
store.graph.createNode('RECTANGLE', multiMaskFrame.id, {
|
||||
name: 'Consecutively masked content',
|
||||
x: 18,
|
||||
y: 16,
|
||||
width: 184,
|
||||
height: 98,
|
||||
fills: [
|
||||
{
|
||||
type: 'GRADIENT_LINEAR',
|
||||
color: { r: 0, g: 0, b: 0, a: 1 },
|
||||
visible: true,
|
||||
opacity: 1,
|
||||
gradientStops: [
|
||||
{ color: { r: 0.96, g: 0.35, b: 0.35, a: 1 }, position: 0 },
|
||||
{ color: { r: 0.08, g: 0.73, b: 0.73, a: 1 }, position: 0.5 },
|
||||
{ color: { r: 0.58, g: 0.27, b: 0.95, a: 1 }, position: 1 }
|
||||
],
|
||||
gradientTransform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
store.clearSelection()
|
||||
store.requestRender()
|
||||
})
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 32 KiB |
19
tests/engine/io/fig/export/boolean-operation.test.ts
Normal file
19
tests/engine/io/fig/export/boolean-operation.test.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { sceneNodeToKiwi } from '#core/kiwi/fig/node-change/serialize'
|
||||
import { SceneGraph } from '#core/scene-graph'
|
||||
|
||||
describe('Figma boolean operation export', () => {
|
||||
test('exports boolean operation node type and operation', () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = graph.getPages()[0]
|
||||
const node = graph.createNode('BOOLEAN_OPERATION', page.id, {
|
||||
booleanOperation: 'INTERSECT'
|
||||
})
|
||||
|
||||
const changes = sceneNodeToKiwi(node, { sessionID: 1, localID: 1 }, 0, { value: 2 }, graph, [])
|
||||
|
||||
expect(changes[0].type).toBe('BOOLEAN_OPERATION')
|
||||
expect(changes[0].booleanOperation).toBe('INTERSECT')
|
||||
})
|
||||
})
|
||||
30
tests/engine/io/fig/export/font-variations.test.ts
Normal file
30
tests/engine/io/fig/export/font-variations.test.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { sceneNodeToKiwi } from '#core/kiwi/fig/node-change/serialize'
|
||||
import { SceneGraph } from '#core/scene-graph'
|
||||
|
||||
describe('Figma font variation export', () => {
|
||||
test('exports base and styled-run variable font axes', () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = graph.getPages()[0]
|
||||
const text = graph.createNode('TEXT', page.id, {
|
||||
text: 'Axis',
|
||||
fontVariations: [{ axis: 'wght', value: 650 }],
|
||||
styleRuns: [
|
||||
{
|
||||
start: 0,
|
||||
length: 2,
|
||||
style: { fontVariations: [{ axis: 'wdth', value: 88 }] }
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const changes = sceneNodeToKiwi(text, { sessionID: 1, localID: 1 }, 0, { value: 2 }, graph, [])
|
||||
const nodeChange = changes[0]
|
||||
|
||||
expect(nodeChange.fontVariations).toEqual([{ axisName: 'wght', value: 650 }])
|
||||
expect(nodeChange.textData?.styleOverrideTable?.[0]?.fontVariations).toEqual([
|
||||
{ axisName: 'wdth', value: 88 }
|
||||
])
|
||||
})
|
||||
})
|
||||
33
tests/engine/io/fig/import/boolean-operation.test.ts
Normal file
33
tests/engine/io/fig/import/boolean-operation.test.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import type { NodeChange } from '#core/kiwi/fig/codec'
|
||||
import { nodeChangeToProps } from '#core/kiwi/fig/node-change/convert'
|
||||
|
||||
describe('Figma boolean operation import', () => {
|
||||
test('preserves boolean operation nodes', () => {
|
||||
const props = nodeChangeToProps(
|
||||
{
|
||||
type: 'BOOLEAN_OPERATION',
|
||||
name: 'Imported boolean',
|
||||
booleanOperation: 'SUBTRACT'
|
||||
} as NodeChange,
|
||||
[]
|
||||
)
|
||||
|
||||
expect(props.nodeType).toBe('BOOLEAN_OPERATION')
|
||||
expect(props.booleanOperation).toBe('SUBTRACT')
|
||||
})
|
||||
|
||||
test('defaults missing boolean operations to union', () => {
|
||||
const props = nodeChangeToProps(
|
||||
{
|
||||
type: 'BOOLEAN_OPERATION',
|
||||
name: 'Imported boolean'
|
||||
} as NodeChange,
|
||||
[]
|
||||
)
|
||||
|
||||
expect(props.nodeType).toBe('BOOLEAN_OPERATION')
|
||||
expect(props.booleanOperation).toBe('UNION')
|
||||
})
|
||||
})
|
||||
46
tests/engine/io/fig/import/font-variations.test.ts
Normal file
46
tests/engine/io/fig/import/font-variations.test.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import type { NodeChange } from '#core/kiwi/fig/codec'
|
||||
import { nodeChangeToProps, importStyleRuns } from '#core/kiwi/fig/node-change/convert'
|
||||
|
||||
describe('Figma font variation import', () => {
|
||||
test('imports base text variable font axes', () => {
|
||||
const props = nodeChangeToProps(
|
||||
{
|
||||
type: 'TEXT',
|
||||
textData: { characters: 'Axis' },
|
||||
fontVariations: [
|
||||
{ axisTag: 0x77676874, value: 650 },
|
||||
{ axisName: 'wdth', value: 88 }
|
||||
]
|
||||
} as NodeChange,
|
||||
[]
|
||||
)
|
||||
|
||||
expect(props.fontVariations).toEqual([
|
||||
{ axis: 'wght', value: 650 },
|
||||
{ axis: 'wdth', value: 88 }
|
||||
])
|
||||
})
|
||||
|
||||
test('imports styled-run variable font axes', () => {
|
||||
const runs = importStyleRuns({
|
||||
type: 'TEXT',
|
||||
fontSize: 16,
|
||||
textData: {
|
||||
characters: 'Axis',
|
||||
characterStyleIDs: [1, 1, 0, 0],
|
||||
styleOverrideTable: [
|
||||
{
|
||||
styleID: 1,
|
||||
fontVariations: [{ axisName: 'GRAD', value: -50 }]
|
||||
} as NodeChange
|
||||
]
|
||||
}
|
||||
} as NodeChange)
|
||||
|
||||
expect(runs).toEqual([
|
||||
{ start: 0, length: 2, style: { fontVariations: [{ axis: 'GRAD', value: -50 }] } }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
@ -85,6 +85,42 @@ describe('canvas masks', () => {
|
|||
expect(canvas.saveLayer).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
test('combines consecutive mask nodes before clipping following siblings', () => {
|
||||
const graph = new SceneGraph()
|
||||
const frame = graph.createNode('FRAME', pageId(graph), { width: 200, height: 200 })
|
||||
const firstMask = graph.createNode('RECTANGLE', frame.id, {
|
||||
width: 80,
|
||||
height: 80,
|
||||
isMask: true
|
||||
})
|
||||
const secondMask = graph.createNode('ELLIPSE', frame.id, {
|
||||
width: 80,
|
||||
height: 80,
|
||||
isMask: true
|
||||
})
|
||||
const clipped = graph.createNode('RECTANGLE', frame.id, { width: 200, height: 200 })
|
||||
const nextMask = graph.createNode('RECTANGLE', frame.id, {
|
||||
width: 40,
|
||||
height: 40,
|
||||
isMask: true
|
||||
})
|
||||
const nextClipped = graph.createNode('RECTANGLE', frame.id, { width: 100, height: 100 })
|
||||
const { renderer, rendered } = createRenderer()
|
||||
const canvas = createCanvas()
|
||||
|
||||
renderNode(renderer, canvas as Canvas, graph, frame.id, {})
|
||||
|
||||
expect(rendered).toEqual([
|
||||
frame.id,
|
||||
clipped.id,
|
||||
firstMask.id,
|
||||
secondMask.id,
|
||||
nextClipped.id,
|
||||
nextMask.id
|
||||
])
|
||||
expect(canvas.saveLayer).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
test('applies luminance masks through a luma color filter', () => {
|
||||
const graph = new SceneGraph()
|
||||
const frame = graph.createNode('FRAME', pageId(graph), { width: 200, height: 200 })
|
||||
|
|
|
|||
22
tests/engine/render/canvas/text-font-variations.test.ts
Normal file
22
tests/engine/render/canvas/text-font-variations.test.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { textFontVariations } from '#core/canvas/text'
|
||||
|
||||
describe('canvas text font variations', () => {
|
||||
test('passes imported variable font axes to CanvasKit text styles', () => {
|
||||
expect(
|
||||
textFontVariations([
|
||||
{ axis: 'wght', value: 650 },
|
||||
{ axis: 'wdth', value: 88 }
|
||||
])
|
||||
).toEqual([
|
||||
{ axis: 'wght', value: 650 },
|
||||
{ axis: 'wdth', value: 88 }
|
||||
])
|
||||
})
|
||||
|
||||
test('omits font variations when no axes are set', () => {
|
||||
expect(textFontVariations([])).toBeUndefined()
|
||||
expect(textFontVariations(undefined)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue