diff --git a/packages/core/src/canvas/fills.ts b/packages/core/src/canvas/fills.ts index 91c744160..da8d7865e 100644 --- a/packages/core/src/canvas/fills.ts +++ b/packages/core/src/canvas/fills.ts @@ -1,7 +1,7 @@ import type { Canvas, Paint } from 'canvaskit-wasm' import type { SceneNode, SceneGraph, Fill } from '#core/scene-graph' -import type { Rect } from '#core/types' +import type { Rect, Vector } from '#core/types' import type { SkiaRenderer } from './renderer' import { makeSmoothRRectPath, nodeHasSmoothCorners } from './shapes' @@ -99,27 +99,62 @@ export function applyFill( return false } -function patternTileRect(source: SceneNode, fill: Fill): Rect { - const spacing = fill.patternSpacing ?? { x: fill.spacing ?? 0, y: fill.spacing ?? 0 } - return { - x: 0, - y: 0, - width: source.width * (1 + spacing.x), - height: source.height * (1 + spacing.y) - } +interface PatternTileLayout { + rect: Rect + scale: number + positions: Vector[] } -function recordPatternSource(r: SkiaRenderer, source: SceneNode, graph: SceneGraph) { - const bounds = r.ck.LTRBRect(0, 0, source.width, source.height) +function patternAlignmentOffset(alignment: Fill['horizontalAlignment'], gap: number): number { + if (alignment === 'CENTER') return gap / 2 + if (alignment === 'END') return gap + return 0 +} + +export function patternTileLayout(source: SceneNode, fill: Fill): PatternTileLayout { + const scale = fill.scale && fill.scale > 0 ? fill.scale : 1 + const spacing = fill.patternSpacing ?? { x: 0, y: 0 } + const scaledWidth = source.width * scale + const scaledHeight = source.height * scale + const gapX = scaledWidth * spacing.x + const gapY = scaledHeight * spacing.y + const width = scaledWidth + gapX + const height = scaledHeight + gapY + const x = patternAlignmentOffset(fill.horizontalAlignment, gapX) + const y = patternAlignmentOffset(fill.verticalAlignment, gapY) + const positions = [{ x, y }] + + if (fill.patternTileType === 'HORIZONTAL_HEXAGONAL') { + positions.push({ x: x + width / 2, y: y + height / 2 }) + } else if (fill.patternTileType === 'VERTICAL_HEXAGONAL') { + positions.push({ x: x + width / 2, y: y - height / 2 }) + } + + return { rect: { x: 0, y: 0, width, height }, scale, positions } +} + +function recordPatternSource( + r: SkiaRenderer, + source: SceneNode, + graph: SceneGraph, + layout: PatternTileLayout +) { + const bounds = r.ck.LTRBRect(0, 0, layout.rect.width, layout.rect.height) const recorder = new r.ck.PictureRecorder() const canvas = recorder.beginRecording(bounds) const rect = r.ck.LTRBRect(0, 0, source.width, source.height) const hasRadius = nodeHasSmoothCorners(source) || source.cornerRadius > 0 - for (const sourceFill of source.fills.filter((item) => item.visible)) { - if (sourceFill.type === 'PATTERN' && sourceFill.sourceNodeId === source.id) continue - if (!applyFill(r, sourceFill, source, graph)) continue - drawNodeFill(r, canvas, source, rect, hasRadius, sourceFill) + for (const position of layout.positions) { + canvas.save() + canvas.translate(position.x, position.y) + canvas.scale(layout.scale, layout.scale) + for (const sourceFill of source.fills.filter((item) => item.visible)) { + if (sourceFill.type === 'PATTERN' && sourceFill.sourceNodeId === source.id) continue + if (!applyFill(r, sourceFill, source, graph)) continue + drawNodeFill(r, canvas, source, rect, hasRadius, sourceFill) + } + canvas.restore() } const picture = recorder.finishRecordingAsPicture() @@ -138,8 +173,9 @@ function applyPatternFill( const source = graph.getNode(sourceId) if (!source || source.width <= 0 || source.height <= 0) return false - const picture = recordPatternSource(r, source, graph) - const tile = patternTileRect(source, fill) + const layout = patternTileLayout(source, fill) + const picture = recordPatternSource(r, source, graph, layout) + const tile = layout.rect const tileRect = r.ck.LTRBRect(tile.x, tile.y, tile.x + tile.width, tile.y + tile.height) const shader = picture.makeShader( r.ck.TileMode.Repeat, diff --git a/packages/core/src/kiwi/fig/codec/index.ts b/packages/core/src/kiwi/fig/codec/index.ts index d72dcbf24..6b4a0113c 100644 --- a/packages/core/src/kiwi/fig/codec/index.ts +++ b/packages/core/src/kiwi/fig/codec/index.ts @@ -203,6 +203,7 @@ export interface Paint { image?: { hash: string | Uint8Array } imageScaleMode?: string sourceNodeId?: GUID + scale?: number spacing?: number patternSpacing?: Vector patternTileType?: string diff --git a/packages/core/src/kiwi/fig/node-change/convert.ts b/packages/core/src/kiwi/fig/node-change/convert.ts index 40a80a77d..02d8c7893 100644 --- a/packages/core/src/kiwi/fig/node-change/convert.ts +++ b/packages/core/src/kiwi/fig/node-change/convert.ts @@ -307,13 +307,20 @@ function convertTextDecorationProps( nc: NodeChange ): Pick< SceneNode, - 'textDecoration' | 'textDecorationStyle' | 'textDecorationThickness' | 'textDecorationFills' + | 'textDecoration' + | 'textDecorationStyle' + | 'textDecorationThickness' + | 'textDecorationFills' + | 'textDecorationSkipInk' + | 'textUnderlineOffset' > { return { textDecoration: mapTextDecoration(nc.textDecoration as string), textDecorationStyle: (nc.textDecorationStyle ?? 'SOLID') as SceneNode['textDecorationStyle'], textDecorationThickness: nc.textDecorationThickness?.value ?? null, - textDecorationFills: convertFills(nc.textDecorationFillPaints) + textDecorationFills: convertFills(nc.textDecorationFillPaints), + textDecorationSkipInk: nc.textDecorationSkipInk ?? true, + textUnderlineOffset: nc.textUnderlineOffset?.value ?? null } } diff --git a/packages/core/src/kiwi/fig/node-change/paint.ts b/packages/core/src/kiwi/fig/node-change/paint.ts index 14199032c..915ef5d72 100644 --- a/packages/core/src/kiwi/fig/node-change/paint.ts +++ b/packages/core/src/kiwi/fig/node-change/paint.ts @@ -79,6 +79,7 @@ function applyImagePaintFields(fill: Fill, p: Paint): void { function applySchemaPaintFields(fill: Fill, p: Paint): void { if (p.sourceNodeId) fill.sourceNodeId = guidToString(p.sourceNodeId) + if (p.scale) fill.scale = p.scale if (p.spacing) fill.spacing = p.spacing if (p.patternSpacing) fill.patternSpacing = p.patternSpacing if (p.patternTileType) fill.patternTileType = p.patternTileType as Fill['patternTileType'] diff --git a/packages/core/src/kiwi/fig/node-change/serialize.ts b/packages/core/src/kiwi/fig/node-change/serialize.ts index fb0816997..46d51dc2d 100644 --- a/packages/core/src/kiwi/fig/node-change/serialize.ts +++ b/packages/core/src/kiwi/fig/node-change/serialize.ts @@ -218,6 +218,7 @@ function fillToKiwiPaint(f: SceneNode['fills'][number]): Paint { if (f.imageScaleMode) paint.imageScaleMode = f.imageScaleMode if (f.imageTransform) paint.transform = f.imageTransform if (f.sourceNodeId) paint.sourceNodeId = stringToGuid(f.sourceNodeId) + if (f.scale) paint.scale = f.scale if (f.spacing) paint.spacing = f.spacing if (f.patternSpacing) paint.patternSpacing = f.patternSpacing if (f.patternTileType) paint.patternTileType = f.patternTileType @@ -302,7 +303,7 @@ function serializeTextProps( nc.textUserLayoutVersion = 4 nc.textExplicitLayoutVersion = 1 nc.textBidiVersion = 1 - nc.textDecorationSkipInk = true + nc.textDecorationSkipInk = node.textDecorationSkipInk nc.fontVariantCommonLigatures = true nc.fontVariantContextualLigatures = true applyFontFeaturesToKiwi(nc, node.fontFeatures) @@ -322,6 +323,9 @@ function serializeTextProps( if (node.textDecorationThickness != null) { nc.textDecorationThickness = { value: node.textDecorationThickness, units: 'PIXELS' } } + if (node.textUnderlineOffset != null) { + nc.textUnderlineOffset = { value: node.textUnderlineOffset, units: 'PIXELS' } + } if (node.textDecorationFills.length > 0) { nc.textDecorationFillPaints = node.textDecorationFills.map(fillToKiwiPaint) } diff --git a/packages/core/src/kiwi/fig/node-change/style-runs.ts b/packages/core/src/kiwi/fig/node-change/style-runs.ts index 1ef720737..a3ad48761 100644 --- a/packages/core/src/kiwi/fig/node-change/style-runs.ts +++ b/packages/core/src/kiwi/fig/node-change/style-runs.ts @@ -7,6 +7,24 @@ import { convertFontVariations } from './font/variations' import { convertFills } from './paint' import { convertLetterSpacing, convertLineHeight, mapTextDecoration } from './text-values' +function applyTextDecorationOverride(style: CharacterStyleOverride, override: NodeChange): void { + const deco = override.textDecoration + if (deco) style.textDecoration = mapTextDecoration(deco) + if (override.textDecorationStyle) + style.textDecorationStyle = + override.textDecorationStyle as CharacterStyleOverride['textDecorationStyle'] + if (override.textDecorationThickness) + style.textDecorationThickness = override.textDecorationThickness.value ?? null + if (override.textDecorationSkipInk !== undefined) + style.textDecorationSkipInk = override.textDecorationSkipInk + if (override.textUnderlineOffset) + style.textUnderlineOffset = override.textUnderlineOffset.value ?? null + if (override.textDecorationFillPaints) { + const decorationFills = convertFills(override.textDecorationFillPaints) + if (decorationFills.length > 0) style.textDecorationFills = decorationFills + } +} + function convertStyleOverride( override: NodeChange, fallbackFontSize: number | undefined @@ -32,17 +50,7 @@ function convertStyleOverride( const lh = convertLineHeight(override.lineHeight, override.fontSize ?? fallbackFontSize) if (lh != null) style.lineHeight = lh } - const deco = override.textDecoration - if (deco) style.textDecoration = mapTextDecoration(deco) - if (override.textDecorationStyle) - style.textDecorationStyle = - override.textDecorationStyle as CharacterStyleOverride['textDecorationStyle'] - if (override.textDecorationThickness) - style.textDecorationThickness = override.textDecorationThickness.value ?? null - if (override.textDecorationFillPaints) { - const decorationFills = convertFills(override.textDecorationFillPaints) - if (decorationFills.length > 0) style.textDecorationFills = decorationFills - } + applyTextDecorationOverride(style, override) if (override.fillPaints) { const fills = convertFills(override.fillPaints) if (fills.length > 0) style.fills = fills diff --git a/packages/core/src/kiwi/fig/node-change/text-data-export.ts b/packages/core/src/kiwi/fig/node-change/text-data-export.ts index 6e1e9953b..0d93aae5d 100644 --- a/packages/core/src/kiwi/fig/node-change/text-data-export.ts +++ b/packages/core/src/kiwi/fig/node-change/text-data-export.ts @@ -12,6 +12,26 @@ export function fontVariationToKiwi(variation: SceneNode['fontVariations'][numbe : { axisTag, axisName: variation.axis, value: variation.value } } +function applyTextDecorationOverrideFields( + override: Record, + style: CharacterStyleOverride, + fillToKiwiPaint: (fill: SceneNode['fills'][number]) => Paint +): void { + if (style.textDecoration) override.textDecoration = style.textDecoration + if (style.textDecorationStyle) override.textDecorationStyle = style.textDecorationStyle + if (style.textDecorationThickness != null) { + override.textDecorationThickness = { value: style.textDecorationThickness, units: 'PIXELS' } + } + if (style.textDecorationSkipInk !== undefined) + override.textDecorationSkipInk = style.textDecorationSkipInk + if (style.textUnderlineOffset != null) { + override.textUnderlineOffset = { value: style.textUnderlineOffset, units: 'PIXELS' } + } + if (style.textDecorationFills && style.textDecorationFills.length > 0) { + override.textDecorationFillPaints = style.textDecorationFills.map(fillToKiwiPaint) + } +} + function textStyleOverrideToKiwi( id: number, style: CharacterStyleOverride, @@ -39,14 +59,7 @@ function textStyleOverrideToKiwi( if (style.lineHeight !== undefined && style.lineHeight !== null) { override.lineHeight = { value: style.lineHeight, units: 'PIXELS' } } - if (style.textDecoration) override.textDecoration = style.textDecoration - if (style.textDecorationStyle) override.textDecorationStyle = style.textDecorationStyle - if (style.textDecorationThickness != null) { - override.textDecorationThickness = { value: style.textDecorationThickness, units: 'PIXELS' } - } - if (style.textDecorationFills && style.textDecorationFills.length > 0) { - override.textDecorationFillPaints = style.textDecorationFills.map(fillToKiwiPaint) - } + applyTextDecorationOverrideFields(override, style, fillToKiwiPaint) if (style.fills && style.fills.length > 0) { override.fillPaints = style.fills.map(fillToKiwiPaint) } diff --git a/packages/core/src/scene-graph/node-defaults.ts b/packages/core/src/scene-graph/node-defaults.ts index a5240c635..4715d1d56 100644 --- a/packages/core/src/scene-graph/node-defaults.ts +++ b/packages/core/src/scene-graph/node-defaults.ts @@ -88,6 +88,8 @@ export function createDefaultNode( textDecorationStyle: 'SOLID', textDecorationThickness: null, textDecorationFills: [], + textDecorationSkipInk: true, + textUnderlineOffset: null, maxLines: null, styleRuns: [], fontVariations: [], diff --git a/packages/core/src/scene-graph/source-metadata.ts b/packages/core/src/scene-graph/source-metadata.ts index 26b263018..4f7df88cd 100644 --- a/packages/core/src/scene-graph/source-metadata.ts +++ b/packages/core/src/scene-graph/source-metadata.ts @@ -31,6 +31,8 @@ const RAW_NODE_FIELD_KEYS = new Set([ 'textDecorationStyle', 'textDecorationThickness', 'textDecorationFills', + 'textDecorationSkipInk', + 'textUnderlineOffset', 'lineHeight', 'leadingTrim', 'letterSpacing', diff --git a/packages/core/src/scene-graph/types.ts b/packages/core/src/scene-graph/types.ts index dee350ac6..c1d954768 100644 --- a/packages/core/src/scene-graph/types.ts +++ b/packages/core/src/scene-graph/types.ts @@ -145,6 +145,7 @@ export interface Fill { imageScaleMode?: ImageScaleMode imageTransform?: GradientTransform sourceNodeId?: string + scale?: number spacing?: number patternSpacing?: Vector patternTileType?: PatternTileType @@ -209,6 +210,8 @@ export interface CharacterStyleOverride { textDecorationStyle?: TextDecorationStyle textDecorationThickness?: number | null textDecorationFills?: Fill[] + textDecorationSkipInk?: boolean + textUnderlineOffset?: number | null fontSize?: number fontFamily?: string letterSpacing?: number @@ -358,6 +361,8 @@ export interface SceneNode { textDecorationStyle: TextDecorationStyle textDecorationThickness: number | null textDecorationFills: Fill[] + textDecorationSkipInk: boolean + textUnderlineOffset: number | null leadingTrim: LeadingTrim lineHeight: number | null letterSpacing: number diff --git a/packages/docs/development/roadmap.md b/packages/docs/development/roadmap.md index 40cd3b6e1..3712f2223 100644 --- a/packages/docs/development/roadmap.md +++ b/packages/docs/development/roadmap.md @@ -129,7 +129,7 @@ Figma's design documentation groups features into these areas: | Solid fills | ✅ | ✅ | ✅ | ✅ | ✅ | Color variables supported for common fill cases. | | Gradients | ✅ | ✅ | ✅ | ✅ | ✅ | Linear/radial/angular/diamond support; Figma edge cases may differ. | | Image fills | ✅ | ✅ | ◐ | ✅ | ✅ | Fill/fit/crop/tile support exists; imported crop/tile affine transforms are applied, but exact Figma parity is still partial. | -| Pattern / noise / custom fills | ✅ | ◐ | — | ✅ | — | Schema metadata imports/exports; Figma pattern fills with a referenced source node render as repeated source tiles. Noise/custom paints still render with a solid fallback pending real payload samples. | +| Pattern / noise / custom fills | ✅ | ◐ | — | ✅ | — | Schema metadata imports/exports; Figma pattern fills with a referenced source node render as repeated source tiles with scale, spacing, alignment, and basic hex offsets. Noise/custom paints still render with a solid fallback pending real payload samples. | | Video/GIF/media fills | ↩ | — | — | ↩ | — | Kiwi schema includes media paint/export enums, but OpenPencil has no video/GIF playback or media layer support. | | Layer/fill/effect blend modes | ✅ | ◐ | — | ✅ | ✅ | Canvas applies node, fill, and common shadow effect blend modes; Figma isolation edge cases remain partial. | | Opacity | ✅ | ✅ | ✅ | ✅ | ✅ | Node opacity uses save layers in the renderer. | @@ -195,7 +195,7 @@ OpenPencil deliberately preserves many Figma/Kiwi fields even when they are not | Version/sort/publish/library metadata | ↩ | — | ◐ | Assets UI shows a subset; publish/update workflow is missing. | | Variable and parameter consumption maps | ✅ | ◐ | ◐ | Filtered/preserved for safe round-trip; normalized bindings cover common cases. | | Page fields: background, page type, guides | ↩ | ◐ | — | Background color and background paints round-trip for imported pages; 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. Imported derived text data, leading trim, decoration style, underline decoration paint/offset/thickness, semantic font metadata, and raw OpenType feature toggles are preserved for round-trip when safe; decoration style/thickness/color and leading trim now render through CanvasKit. | +| Text internals: `textData`, layout versions, font version, derived data | ✅ | ✅ | — | Important for text fidelity; most internals are not editable. Imported derived text data, leading trim, decoration style, underline decoration paint/offset/thickness/skip-ink, semantic font metadata, and raw OpenType feature toggles are preserved for round-trip when safe; decoration style/thickness/color and leading trim now render through CanvasKit. | | `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, including mask, background paint, layout grid, export setting, and prototype interaction metadata where safe. | @@ -205,8 +205,8 @@ These are parsed or visible in Figma docs and most likely to cause visible diffe 1. **Masks** — tune remaining exact Figma stack semantics beyond common alpha/vector/luminance and consecutive-mask paths. `tests/fixtures/figma-oracles/masks.json` records live Figma API values for alpha, vector, and luminance masks. 2. **Corner smoothing** — expand Figma fixture comparisons and tune remaining stroke/effect edge cases. -3. **Pattern/noise/custom fills** — tune first-class pattern rendering for hexagonal tiles, scale, and alignment. `tests/fixtures/figma-oracles/pattern-noise-custom-paints.json` captures a real async Figma `PATTERN` payload; real `NOISE` / `CUSTOM` payloads remain blocked on Figma-authored samples. -4. **Variable-font and rich text fixtures** — broaden real-file coverage for variable axes, derived text data, leading trim, decoration style, semantic font metadata, and raw OpenType feature metadata; `tests/fixtures/figma-oracles/rich-text-decoration.json` captures the first live Figma rich-text oracle. +3. **Pattern/noise/custom fills** — tune first-class pattern rendering for nested/effectful pattern sources and exact Figma hex spacing. `tests/fixtures/figma-oracles/pattern-noise-custom-paints.json` captures a real async Figma `PATTERN` payload; real `NOISE` / `CUSTOM` payloads remain blocked on Figma-authored samples. +4. **Variable-font and rich text fixtures** — broaden real-file coverage for variable axes, derived text data, leading trim, decoration style, underline offset/skip-ink, semantic font metadata, and raw OpenType feature metadata; `tests/fixtures/figma-oracles/rich-text-decoration.json` captures the first live Figma rich-text oracle. 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. diff --git a/tests/e2e/canvas/renderer-visuals.spec.ts b/tests/e2e/canvas/renderer-visuals.spec.ts index f929c4f7b..68837c671 100644 --- a/tests/e2e/canvas/renderer-visuals.spec.ts +++ b/tests/e2e/canvas/renderer-visuals.spec.ts @@ -295,7 +295,8 @@ test('pattern fills from source nodes', async () => { { type: 'PATTERN', sourceNodeId: source.id, - patternTileType: 'RECTANGULAR', + patternTileType: 'HORIZONTAL_HEXAGONAL', + scale: 1.25, patternSpacing: { x: 0.45, y: 0.35 }, horizontalAlignment: 'CENTER', verticalAlignment: 'CENTER', diff --git a/tests/e2e/canvas/renderer-visuals.spec.ts-snapshots/pattern-fills-from-source-nodes-openpencil-darwin.png b/tests/e2e/canvas/renderer-visuals.spec.ts-snapshots/pattern-fills-from-source-nodes-openpencil-darwin.png index 4f70a3862..ac131ff73 100644 Binary files a/tests/e2e/canvas/renderer-visuals.spec.ts-snapshots/pattern-fills-from-source-nodes-openpencil-darwin.png and b/tests/e2e/canvas/renderer-visuals.spec.ts-snapshots/pattern-fills-from-source-nodes-openpencil-darwin.png differ diff --git a/tests/engine/io/fig/export/paint-schema-fields.test.ts b/tests/engine/io/fig/export/paint-schema-fields.test.ts index 26d18760b..6d7753c57 100644 --- a/tests/engine/io/fig/export/paint-schema-fields.test.ts +++ b/tests/engine/io/fig/export/paint-schema-fields.test.ts @@ -15,6 +15,7 @@ describe('Figma paint schema field export', () => { opacity: 0.75, visible: true, sourceNodeId: '12:34', + scale: 1.5, spacing: 6, patternSpacing: { x: 8, y: 12 }, patternTileType: 'VERTICAL_HEXAGONAL', @@ -46,6 +47,7 @@ describe('Figma paint schema field export', () => { expect(fills?.[0]).toMatchObject({ type: 'PATTERN', sourceNodeId: { sessionID: 12, localID: 34 }, + scale: 1.5, spacing: 6, patternSpacing: { x: 8, y: 12 }, patternTileType: 'VERTICAL_HEXAGONAL', diff --git a/tests/engine/io/fig/import/mask-oracle.test.ts b/tests/engine/io/fig/import/mask-oracle.test.ts index 8488e8fa3..d5828481e 100644 --- a/tests/engine/io/fig/import/mask-oracle.test.ts +++ b/tests/engine/io/fig/import/mask-oracle.test.ts @@ -13,6 +13,8 @@ interface MaskOracleEntry { interface MaskOracle { masks: MaskOracleEntry[] + nested: { children: MaskOracleEntry[] } + maskIsOutline: { pluginApiReadable: boolean; note: string } } function readOracle(): MaskOracle { @@ -57,4 +59,14 @@ describe('Figma mask oracle', () => { expect(changes[0].maskType).toBe(mask.maskType) } }) + + test('records nested live Figma mask stack order and maskIsOutline API gap', () => { + const oracle = readOracle() + + expect(oracle.nested.children.map(({ isMask, maskType }) => ({ isMask, maskType }))).toEqual([ + { isMask: true, maskType: 'LUMINANCE' }, + { isMask: false, maskType: 'ALPHA' } + ]) + expect(oracle.maskIsOutline.pluginApiReadable).toBe(false) + }) }) diff --git a/tests/engine/io/fig/import/paint-schema-fields.test.ts b/tests/engine/io/fig/import/paint-schema-fields.test.ts index 5c3195d0d..ca7774353 100644 --- a/tests/engine/io/fig/import/paint-schema-fields.test.ts +++ b/tests/engine/io/fig/import/paint-schema-fields.test.ts @@ -18,6 +18,7 @@ describe('Figma paint schema field import', () => { opacity: 0.75, visible: true, sourceNodeId, + scale: 1.5, spacing: 6, patternSpacing: { x: 8, y: 12 }, patternTileType: 'HORIZONTAL_HEXAGONAL', @@ -33,6 +34,7 @@ describe('Figma paint schema field import', () => { type: 'PATTERN', opacity: 0.75, sourceNodeId: '12:34', + scale: 1.5, spacing: 6, patternSpacing: { x: 8, y: 12 }, patternTileType: 'HORIZONTAL_HEXAGONAL', diff --git a/tests/engine/io/fig/import/rich-text-oracle.test.ts b/tests/engine/io/fig/import/rich-text-oracle.test.ts index 939ed958a..ce39aea77 100644 --- a/tests/engine/io/fig/import/rich-text-oracle.test.ts +++ b/tests/engine/io/fig/import/rich-text-oracle.test.ts @@ -3,6 +3,8 @@ import { readFileSync } from 'node:fs' import type { NodeChange, Paint } from '#core/kiwi/fig/codec' import { nodeChangeToProps } from '#core/kiwi/fig/node-change/convert' +import { sceneNodeToKiwi } from '#core/kiwi/fig/node-change/serialize' +import { SceneGraph } from '#core/scene-graph' interface OracleColor { r: number @@ -29,6 +31,8 @@ interface OracleRange { interface RichTextOracle { characters: string leadingTrim: string + textDecorationOffset: { unit: string; value: number } + textDecorationSkipInk: boolean ranges: OracleRange[] } @@ -79,6 +83,8 @@ describe('Figma rich text oracle', () => { value: secondRange.textDecorationThickness.value, units: 'PIXELS' }, + textUnderlineOffset: { value: oracle.textDecorationOffset.value, units: 'PIXELS' }, + textDecorationSkipInk: oracle.textDecorationSkipInk, textDecorationFillPaints: [oracleColorToPaint(secondRange.textDecorationColor)] } ] @@ -90,6 +96,8 @@ describe('Figma rich text oracle', () => { value: firstRange.textDecorationThickness.value, units: 'PIXELS' }, + textUnderlineOffset: { value: oracle.textDecorationOffset.value, units: 'PIXELS' }, + textDecorationSkipInk: oracle.textDecorationSkipInk, textDecorationFillPaints: [oracleColorToPaint(firstRange.textDecorationColor)], leadingTrim: oracle.leadingTrim } @@ -101,6 +109,8 @@ describe('Figma rich text oracle', () => { expect(props.textDecorationStyle).toBe('WAVY') expect(props.textDecorationThickness).toBe(2) expect(props.textDecorationFills[0]?.color).toEqual({ r: 1, g: 0, b: 0, a: 1 }) + expect(props.textUnderlineOffset).toBe(5) + expect(props.textDecorationSkipInk).toBe(false) expect(props.leadingTrim).toBe('CAP_HEIGHT') expect(props.styleRuns).toEqual([ { @@ -110,6 +120,8 @@ describe('Figma rich text oracle', () => { textDecoration: 'UNDERLINE', textDecorationStyle: 'DOTTED', textDecorationThickness: 3, + textDecorationSkipInk: false, + textUnderlineOffset: 5, textDecorationFills: [ { type: 'SOLID', @@ -123,4 +135,21 @@ describe('Figma rich text oracle', () => { } ]) }) + + test('exports captured Figma text decoration offset and skip-ink fields', () => { + const oracle = readOracle() + const graph = new SceneGraph() + const page = graph.getPages()[0] + const text = graph.createNode('TEXT', page.id, { + text: oracle.characters, + textDecoration: 'UNDERLINE', + textUnderlineOffset: oracle.textDecorationOffset.value, + textDecorationSkipInk: oracle.textDecorationSkipInk + }) + + const changes = sceneNodeToKiwi(text, { sessionID: 1, localID: 1 }, 0, { value: 2 }, graph, []) + + expect(changes[0].textUnderlineOffset).toEqual({ value: 5, units: 'PIXELS' }) + expect(changes[0].textDecorationSkipInk).toBe(false) + }) }) diff --git a/tests/engine/render/canvas/image-fill.test.ts b/tests/engine/render/canvas/image-fill.test.ts index a673a26d9..6d3536014 100644 --- a/tests/engine/render/canvas/image-fill.test.ts +++ b/tests/engine/render/canvas/image-fill.test.ts @@ -1,6 +1,6 @@ import { describe, expect, mock, test } from 'bun:test' -import { makeImageFillLocalMatrix } from '#core/canvas/fills' +import { makeImageFillLocalMatrix, patternTileLayout } from '#core/canvas/fills' import type { SkiaRenderer } from '#core/canvas/renderer' import type { Fill, SceneNode } from '#core/scene-graph' @@ -23,6 +23,49 @@ const node = { height: 80 } as SceneNode +describe('canvas pattern fills', () => { + test('uses scale, spacing, and alignment for rectangular pattern tiles', () => { + const layout = patternTileLayout( + { width: 20, height: 10 } as SceneNode, + { + type: 'PATTERN', + scale: 2, + patternSpacing: { x: 0.25, y: 0.5 }, + horizontalAlignment: 'CENTER', + verticalAlignment: 'END', + color: { r: 0, g: 0, b: 0, a: 1 }, + opacity: 1, + visible: true + } as Fill + ) + + expect(layout).toEqual({ + rect: { x: 0, y: 0, width: 50, height: 30 }, + scale: 2, + positions: [{ x: 5, y: 10 }] + }) + }) + + test('adds an offset source copy for hexagonal pattern tiles', () => { + const layout = patternTileLayout( + { width: 20, height: 10 } as SceneNode, + { + type: 'PATTERN', + patternTileType: 'HORIZONTAL_HEXAGONAL', + patternSpacing: { x: 0, y: 0 }, + color: { r: 0, g: 0, b: 0, a: 1 }, + opacity: 1, + visible: true + } as Fill + ) + + expect(layout.positions).toEqual([ + { x: 0, y: 0 }, + { x: 10, y: 5 } + ]) + }) +}) + describe('canvas image fills', () => { test('keeps untransformed tile fills in image pixel space', () => { const renderer = createRenderer() diff --git a/tests/engine/render/canvas/schema-fill-fallback.test.ts b/tests/engine/render/canvas/schema-fill-fallback.test.ts index 2be9d83ac..230aa26b1 100644 --- a/tests/engine/render/canvas/schema-fill-fallback.test.ts +++ b/tests/engine/render/canvas/schema-fill-fallback.test.ts @@ -13,7 +13,11 @@ function createRenderer() { const recorder = { beginRecording: mock(() => ({ drawRect: mock(() => undefined), - drawRRect: mock(() => undefined) + drawRRect: mock(() => undefined), + restore: mock(() => undefined), + save: mock(() => undefined), + scale: mock(() => undefined), + translate: mock(() => undefined) })), finishRecordingAsPicture: mock(() => picture), delete: mock(() => undefined) diff --git a/tests/fixtures/figma-oracles/masks.json b/tests/fixtures/figma-oracles/masks.json index 7a131b141..b7179dd6c 100644 --- a/tests/fixtures/figma-oracles/masks.json +++ b/tests/fixtures/figma-oracles/masks.json @@ -9,5 +9,27 @@ { "id": "296500:2240", "name": "Mask ALPHA", "isMask": true, "maskType": "ALPHA" }, { "id": "296500:2241", "name": "Mask VECTOR", "isMask": true, "maskType": "VECTOR" }, { "id": "296500:2242", "name": "Mask LUMINANCE", "isMask": true, "maskType": "LUMINANCE" } - ] + ], + "nested": { + "page": "OpenPencil nested mask oracle 2026-05-22", + "frameId": "296616:2249", + "children": [ + { + "id": "296616:2250", + "name": "Nested luminance mask", + "isMask": true, + "maskType": "LUMINANCE" + }, + { + "id": "296616:2251", + "name": "Nested masked content", + "isMask": false, + "maskType": "ALPHA" + } + ] + }, + "maskIsOutline": { + "pluginApiReadable": false, + "note": "The Kiwi schema has maskIsOutline and OpenPencil preserves it, but Figma Plugin API did not expose a readable maskIsOutline property in the live oracle." + } } diff --git a/tests/fixtures/figma-oracles/rich-text-decoration.json b/tests/fixtures/figma-oracles/rich-text-decoration.json index 01ce246b7..47463761d 100644 --- a/tests/fixtures/figma-oracles/rich-text-decoration.json +++ b/tests/fixtures/figma-oracles/rich-text-decoration.json @@ -8,6 +8,9 @@ }, "characters": "wavy red | dotted blue | cap trim", "leadingTrim": "CAP_HEIGHT", + "textDecorationOffset": { "unit": "PIXELS", "value": 5 }, + "textDecorationSkipInk": false, + "textDecorationOffsetOracleNodeId": "296619:2252", "ranges": [ { "start": 0,