From 6ae425ee3badd0622e832afb1ab52d4769560db8 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 28 May 2026 09:57:46 +0300 Subject: [PATCH] fix(core): address compatibility review findings --- packages/core/src/canvas/fills.ts | 22 ++++-- packages/core/src/canvas/layout-grids.ts | 4 +- packages/core/src/canvas/shadows.ts | 6 +- packages/core/src/canvas/text-derived.ts | 30 +++++++- packages/core/src/kiwi/fig/import.ts | 3 + .../src/kiwi/fig/node-change/export-node.ts | 6 +- .../src/kiwi/fig/node-change/font/features.ts | 20 ++++- packages/docs/development/roadmap.md | 6 +- scripts/visual-compare.ts | 3 +- .../io/fig/export/font-variations.test.ts | 39 ++++++++++ .../io/fig/import/font-variations.test.ts | 28 +++++++ .../io/fig/roundtrip/source-metadata.test.ts | 11 ++- .../engine/render/canvas/derived-text.test.ts | 73 +++++++++++++++++++ .../render/canvas/effects/types.test.ts | 52 ++++++++++++- .../engine/render/canvas/layout-grids.test.ts | 10 +++ 15 files changed, 291 insertions(+), 22 deletions(-) diff --git a/packages/core/src/canvas/fills.ts b/packages/core/src/canvas/fills.ts index dee9638e3..9e220f19a 100644 --- a/packages/core/src/canvas/fills.ts +++ b/packages/core/src/canvas/fills.ts @@ -69,7 +69,8 @@ export function applyFill( fill: Fill, node: SceneNode, graph: SceneGraph, - fillIndex = 0 + fillIndex = 0, + patternStack = new Set() ): boolean { r.fillPaint.setShader(null) @@ -88,7 +89,7 @@ export function applyFill( return r.applyImageFill(fill, node, graph) } - if (fill.type === 'PATTERN' && applyPatternFill(r, fill, node, graph)) return true + if (fill.type === 'PATTERN' && applyPatternFill(r, fill, node, graph, patternStack)) return true if (fill.type === 'PATTERN' || fill.type === 'NOISE' || fill.type === 'CUSTOM') { const c = r.resolveFillColor(fill, fillIndex, node, graph) @@ -142,7 +143,8 @@ function recordPatternSource( r: SkiaRenderer, source: SceneNode, graph: SceneGraph, - layout: PatternTileLayout + layout: PatternTileLayout, + patternStack: Set ) { const bounds = r.ck.LTRBRect(0, 0, layout.rect.width, layout.rect.height) const recorder = new r.ck.PictureRecorder() @@ -156,7 +158,7 @@ function recordPatternSource( 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 + if (!applyFill(r, sourceFill, source, graph, 0, patternStack)) continue drawNodeFill(r, canvas, source, rect, hasRadius, sourceFill) } canvas.restore() @@ -180,15 +182,23 @@ function applyPatternFill( r: SkiaRenderer, fill: Fill, node: SceneNode, - graph: SceneGraph + graph: SceneGraph, + patternStack: Set ): boolean { const sourceId = fill.sourceNodeId if (!sourceId || sourceId === node.id || sourceId === node.source.id) return false const source = resolvePatternSource(graph, sourceId) if (!source || source.width <= 0 || source.height <= 0) return false + if (patternStack.has(source.id)) return false + patternStack.add(source.id) const layout = patternTileLayout(source, fill) - const picture = recordPatternSource(r, source, graph, layout) + let picture + try { + picture = recordPatternSource(r, source, graph, layout, patternStack) + } finally { + patternStack.delete(source.id) + } 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( diff --git a/packages/core/src/canvas/layout-grids.ts b/packages/core/src/canvas/layout-grids.ts index 47288be97..920fb558d 100644 --- a/packages/core/src/canvas/layout-grids.ts +++ b/packages/core/src/canvas/layout-grids.ts @@ -31,7 +31,8 @@ interface GridGeometry { } function rawLayoutGrids(node: SceneNode): RawLayoutGrid[] { - const grids = node.source.fig.rawNodeFields.layoutGrids + const source = (node as Partial).source + const grids = source?.fig.rawNodeFields.layoutGrids if (!Array.isArray(grids)) return [] return grids.filter((grid): grid is RawLayoutGrid => grid !== null && typeof grid === 'object') } @@ -56,6 +57,7 @@ function gridGeometry(grid: RawLayoutGrid): GridGeometry | null { const sectionSize = grid.sectionSize ?? 0 const alignment = rawGridAlignment(grid) if (!Number.isFinite(count) || count <= 0) return null + if (rawGridPattern(grid) === 'GRID' && sectionSize <= 0) return null if (alignment !== 'STRETCH' && sectionSize <= 0) return null return { pattern: rawGridPattern(grid), diff --git a/packages/core/src/canvas/shadows.ts b/packages/core/src/canvas/shadows.ts index 3e2cb6987..e0c512db1 100644 --- a/packages/core/src/canvas/shadows.ts +++ b/packages/core/src/canvas/shadows.ts @@ -13,6 +13,8 @@ import { nodeHasSmoothCorners } from './shapes' +const MAX_RAW_NOISE_CELLS = 12_000 + interface RawNoiseEffect { type: 'NOISE' visible?: boolean @@ -58,7 +60,9 @@ function renderNoiseEffect( effect: RawNoiseEffect ): void { const density = Math.max(0, Math.min(1, effect.density ?? 0.3)) - const step = Math.max(2, Math.round((effect.noiseSize?.x ?? 0.5) * 8)) + const requestedStep = Math.max(2, Math.round((effect.noiseSize?.x ?? 0.5) * 8)) + const boundedStep = Math.ceil(Math.sqrt((node.width * node.height) / MAX_RAW_NOISE_CELLS)) + const step = Math.max(requestedStep, boundedStep) const color = effect.color ?? BLACK const opacity = effect.opacity ?? color.a const paint = new r.ck.Paint() diff --git a/packages/core/src/canvas/text-derived.ts b/packages/core/src/canvas/text-derived.ts index 7a8ec4f73..4f44b3ad6 100644 --- a/packages/core/src/canvas/text-derived.ts +++ b/packages/core/src/canvas/text-derived.ts @@ -5,9 +5,12 @@ import { geometryBlobToPath } from '#core/vector' import type { SkiaRenderer } from './renderer' -interface DecorationSpan { +interface DecorationRange { x1: number x2: number +} + +interface DecorationSpan extends DecorationRange { style: TextDecorationStyle thickness: number offset: number @@ -41,6 +44,20 @@ function styleRunX(node: SceneNode, index: number): number { return (node.width * index) / node.text.length } +function styleRunDecorationRange(node: SceneNode, run: StyleRun): DecorationRange | null { + const hasDecorationOverride = + run.style.textDecoration !== undefined || + run.style.textDecorationStyle !== undefined || + run.style.textDecorationThickness !== undefined || + run.style.textDecorationFills !== undefined || + run.style.textUnderlineOffset !== undefined + if (!hasDecorationOverride) return null + return { + x1: styleRunX(node, run.start), + x2: styleRunX(node, run.start + run.length) + } +} + function styleRunDecorationSpan(node: SceneNode, run: StyleRun): DecorationSpan | null { const decoration = run.style.textDecoration ?? node.textDecoration const hasDecorationOverride = @@ -60,6 +77,10 @@ function styleRunDecorationSpan(node: SceneNode, run: StyleRun): DecorationSpan } } +function isDecorationRange(span: DecorationRange | null): span is DecorationRange { + return span !== null +} + function isDecorationSpan(span: DecorationSpan | null): span is DecorationSpan { return span !== null } @@ -79,7 +100,7 @@ function baseDecorationSpan(node: SceneNode): DecorationSpan | null { function splitBaseDecorationSpan( base: DecorationSpan, - overrides: DecorationSpan[] + overrides: DecorationRange[] ): DecorationSpan[] { const spans: DecorationSpan[] = [] let cursor = base.x1 @@ -92,11 +113,14 @@ function splitBaseDecorationSpan( } function derivedDecorationSpans(node: SceneNode): DecorationSpan[] { + const overrideRanges = node.styleRuns + .map((run) => styleRunDecorationRange(node, run)) + .filter(isDecorationRange) const overrides = node.styleRuns .map((run) => styleRunDecorationSpan(node, run)) .filter(isDecorationSpan) const base = baseDecorationSpan(node) - return base ? [...splitBaseDecorationSpan(base, overrides), ...overrides] : overrides + return base ? [...splitBaseDecorationSpan(base, overrideRanges), ...overrides] : overrides } function firstVisibleFillColor(fills: Fill[]) { diff --git a/packages/core/src/kiwi/fig/import.ts b/packages/core/src/kiwi/fig/import.ts index c1728671f..eef430ac4 100644 --- a/packages/core/src/kiwi/fig/import.ts +++ b/packages/core/src/kiwi/fig/import.ts @@ -27,6 +27,9 @@ function applyImportedCanvasMetadata( page.source.orderKey = canvasNc.parentIndex?.position ?? null if (canvasNc.backgroundColor) page.source.fig.rawNodeFields.backgroundColor = structuredClone(canvasNc.backgroundColor) + if (canvasNc.backgroundPaints) + page.source.fig.rawNodeFields.backgroundPaints = structuredClone(canvasNc.backgroundPaints) + if (canvasNc.guides) page.source.fig.rawNodeFields.guides = structuredClone(canvasNc.guides) page.source.fig.rawNodeFields.strokeJoin = canvasNc.strokeJoin page.source.fig.rawNodeFields.strokeWeight = canvasNc.strokeWeight if (canvasNc.pageType) page.source.fig.rawNodeFields.pageType = canvasNc.pageType diff --git a/packages/core/src/kiwi/fig/node-change/export-node.ts b/packages/core/src/kiwi/fig/node-change/export-node.ts index 2a0e35bed..ece309d6f 100644 --- a/packages/core/src/kiwi/fig/node-change/export-node.ts +++ b/packages/core/src/kiwi/fig/node-change/export-node.ts @@ -366,7 +366,11 @@ function applyRawFigmaNodeFields( continue } if (key === 'derivedTextData' && node.source.id) { - nc[key] = materialized[key] + nc.derivedTextData = materialized.derivedTextData + continue + } + if (key === 'textDecorationFillPaints' && node.source.id) { + nc.textDecorationFillPaints = materialized.textDecorationFillPaints continue } // Skip any key already set on nc — explicit serialization takes priority diff --git a/packages/core/src/kiwi/fig/node-change/font/features.ts b/packages/core/src/kiwi/fig/node-change/font/features.ts index 0ea2c672a..c9b262d2e 100644 --- a/packages/core/src/kiwi/fig/node-change/font/features.ts +++ b/packages/core/src/kiwi/fig/node-change/font/features.ts @@ -14,7 +14,17 @@ const ENUM_FEATURES = [ ['fontVariantNumericFigure', { LINING: 'LNUM', OLDSTYLE: 'ONUM' }], ['fontVariantNumericSpacing', { PROPORTIONAL: 'PNUM', TABULAR: 'TNUM' }], ['fontVariantNumericFraction', { DIAGONAL: 'FRAC', STACKED: 'AFRC' }], - ['fontVariantCaps', { SMALL: 'SMCP', PETITE: 'PCAP', UNICASE: 'UNIC', TITLING: 'TITL' }] + [ + 'fontVariantCaps', + { + SMALL: 'SMCP', + PETITE: 'PCAP', + ALL_SMALL: ['SMCP', 'C2SC'], + ALL_PETITE: ['PCAP', 'C2PC'], + UNICASE: 'UNIC', + TITLING: 'TITL' + } + ] ] as const const BOOLEAN_FEATURE_EXPORT = Object.fromEntries( @@ -32,6 +42,8 @@ const ENUM_FEATURE_EXPORT: Partial< AFRC: { field: 'fontVariantNumericFraction', value: 'STACKED' }, SMCP: { field: 'fontVariantCaps', value: 'SMALL' }, PCAP: { field: 'fontVariantCaps', value: 'PETITE' }, + C2SC: { field: 'fontVariantCaps', value: 'ALL_SMALL' }, + C2PC: { field: 'fontVariantCaps', value: 'ALL_PETITE' }, UNIC: { field: 'fontVariantCaps', value: 'UNICASE' }, TITL: { field: 'fontVariantCaps', value: 'TITLING' } } @@ -49,8 +61,10 @@ export function convertFontFeatures(nc: NodeChange): FontFeature[] { if (enabled !== undefined) addFeature(features, tag, enabled) } for (const [field, values] of ENUM_FEATURES) { - const tag = (values as Partial>)[String(nc[field])] - if (tag) addFeature(features, tag, true) + const tag = (values as Partial>)[String(nc[field])] + if (Array.isArray(tag)) { + for (const item of tag) addFeature(features, item, true) + } else if (tag) addFeature(features, tag, true) } for (const tag of nc.toggledOnOTFeatures ?? []) addFeature(features, tag, true) for (const tag of nc.toggledOffOTFeatures ?? []) addFeature(features, tag, false) diff --git a/packages/docs/development/roadmap.md b/packages/docs/development/roadmap.md index 3432892db..a3c3451f1 100644 --- a/packages/docs/development/roadmap.md +++ b/packages/docs/development/roadmap.md @@ -156,7 +156,7 @@ Figma's design documentation groups features into these areas: | Strokes included in layout | ✅ | ◐ | — | ✅ | ✅ | Stored/exported and used in layout paths, but no obvious panel control. | | Reverse z-index / align-content | ✅ | ◐ | — | ✅ | ✅ | Modeled and exported; UI is limited. | | Constraints | ✅ | ◐ | — | ✅ | ✅ | Tools/API expose constraints; main UI is limited. | -| Layout grids / guides | ↩ | — | — | ↩ | — | `styleIdForGrid` and `guides` are preserved only. | +| Layout grids / guides | ↩ | ◐ | — | ↩ | — | Imported layout grids and page guides render from preserved Figma metadata; style IDs round-trip, but editing UI is not exposed. | | Text styles | ↩ | ◐ | — | ↩ | — | Style IDs round-trip; no style management UI. Rich schema metadata such as derived text data, leading trim, decoration style/thickness/fill, and semantic font style/weight is preserved for round-trip. | | Rich style runs | ✅ | ✅ | ◐ | ✅ | ✅ | Import/render/export support; editing mixed runs is partial. | | Text auto resize | ✅ | ✅ | ◐ | ✅ | ✅ | Used by renderer/layout; UI does not expose every mode. | @@ -201,7 +201,7 @@ OpenPencil deliberately preserves many Figma/Kiwi fields even when they are not | State-group metadata | ↩ | — | — | Preserved only. | | 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. | +| Page fields: background, page type, guides | ↩ | ◐ | — | Background color, background paints, page type, and guides round-trip for imported pages. Guides render as editor overlays but are not 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/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, and raster export bounds account for decoration overflow. | | `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. | @@ -215,7 +215,7 @@ These are parsed or visible in Figma docs and most likely to cause visible diffe 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 and Figma-authored noise/texture/glass effect payloads; real `NOISE` / `CUSTOM` paint 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. +6. **Layout grids and guides** — add editing and fuller alignment/style parity for rendered imported page guides and Figma layout grids. 7. **Full component property and slot workflows** — support authoring, not just preserving imported payloads. 8. **Prototype/media/interaction metadata** — schema now includes more interaction, media runtime, animation, and slide fields; start by preserving flows/connections/runtime metadata before building playback. diff --git a/scripts/visual-compare.ts b/scripts/visual-compare.ts index b91710071..8b834f08f 100644 --- a/scripts/visual-compare.ts +++ b/scripts/visual-compare.ts @@ -90,7 +90,8 @@ async function runWithNodeId(nodeId: string) { console.log('📋 Exporting clipboard data from Figma…') // Select the node, copy, read clipboard, render with our engine - await $`figma-use eval ${`const n = figma.getNodeById('${nodeId}'); if (!n) return; let page = n.parent; while (page && page.type !== 'PAGE') page = page.parent; if (page) { await figma.setCurrentPageAsync(page); page.selection = [n]; }`}`.quiet() + const nodeIdLiteral = JSON.stringify(nodeId) + await $`figma-use eval ${`const n = figma.getNodeById(${nodeIdLiteral}); if (!n) return; let page = n.parent; while (page && page.type !== 'PAGE') page = page.parent; if (page) { await figma.setCurrentPageAsync(page); page.selection = [n]; }`}`.quiet() await Bun.sleep(200) await $`osascript -e 'tell application "Figma" to activate'`.quiet() await Bun.sleep(300) diff --git a/tests/engine/io/fig/export/font-variations.test.ts b/tests/engine/io/fig/export/font-variations.test.ts index 03fb6322a..06cca0e3f 100644 --- a/tests/engine/io/fig/export/font-variations.test.ts +++ b/tests/engine/io/fig/export/font-variations.test.ts @@ -85,4 +85,43 @@ describe('Figma font variation export', () => { units: 'PIXELS' }) }) + + test('exports all-caps OpenType variant fields', () => { + const graph = new SceneGraph() + const page = graph.getPages()[0] + const allSmall = graph.createNode('TEXT', page.id, { + text: 'Small', + fontFeatures: [ + { tag: 'SMCP', enabled: true }, + { tag: 'C2SC', enabled: true } + ] + }) + const allPetite = graph.createNode('TEXT', page.id, { + text: 'Petite', + fontFeatures: [ + { tag: 'PCAP', enabled: true }, + { tag: 'C2PC', enabled: true } + ] + }) + + const smallChange = sceneNodeToKiwi( + allSmall, + { sessionID: 1, localID: 1 }, + 0, + { value: 2 }, + graph, + [] + )[0] + const petiteChange = sceneNodeToKiwi( + allPetite, + { sessionID: 1, localID: 1 }, + 0, + { value: 2 }, + graph, + [] + )[0] + + expect(smallChange.fontVariantCaps).toBe('ALL_SMALL') + expect(petiteChange.fontVariantCaps).toBe('ALL_PETITE') + }) }) diff --git a/tests/engine/io/fig/import/font-variations.test.ts b/tests/engine/io/fig/import/font-variations.test.ts index 6169588ac..7d18a4de9 100644 --- a/tests/engine/io/fig/import/font-variations.test.ts +++ b/tests/engine/io/fig/import/font-variations.test.ts @@ -60,6 +60,34 @@ describe('Figma font variation import', () => { ]) }) + test('imports all-caps OpenType variant fields', () => { + const allSmall = nodeChangeToProps( + { + type: 'TEXT', + textData: { characters: 'Caps' }, + fontVariantCaps: 'ALL_SMALL' + } as NodeChange, + [] + ) + const allPetite = nodeChangeToProps( + { + type: 'TEXT', + textData: { characters: 'Caps' }, + fontVariantCaps: 'ALL_PETITE' + } as NodeChange, + [] + ) + + expect(allSmall.fontFeatures).toEqual([ + { tag: 'SMCP', enabled: true }, + { tag: 'C2SC', enabled: true } + ]) + expect(allPetite.fontFeatures).toEqual([ + { tag: 'PCAP', enabled: true }, + { tag: 'C2PC', enabled: true } + ]) + }) + test('imports text decoration style metadata', () => { const props = nodeChangeToProps( { diff --git a/tests/engine/io/fig/roundtrip/source-metadata.test.ts b/tests/engine/io/fig/roundtrip/source-metadata.test.ts index baa6ea0b9..789fa10bb 100644 --- a/tests/engine/io/fig/roundtrip/source-metadata.test.ts +++ b/tests/engine/io/fig/roundtrip/source-metadata.test.ts @@ -153,7 +153,12 @@ describe('fig roundtrip source metadata', () => { text.source.fig.rawNodeFields.leadingTrim = 'CAP_HEIGHT' text.source.fig.rawNodeFields.textDecorationStyle = 'WAVY' text.source.fig.rawNodeFields.textDecorationFillPaints = [ - { type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1 } + { + type: 'PATTERN', + color: { r: 1, g: 0, b: 0, a: 1 }, + opacity: 1, + sourceNodeId: { sessionID: 4, localID: 900 } + } ] text.source.fig.rawNodeFields.textUnderlineOffset = { value: 2, units: 'PIXELS' } text.source.fig.rawNodeFields.textDecorationThickness = { value: 1.5, units: 'PIXELS' } @@ -173,7 +178,9 @@ describe('fig roundtrip source metadata', () => { expect(exported?.leadingTrim).toBe('CAP_HEIGHT') expect(exported?.textDecorationStyle).toBe('WAVY') - expect(exported?.textDecorationFillPaints?.[0]?.type).toBe('SOLID') + expect(exported?.textDecorationFillPaints).toEqual( + text.source.fig.rawNodeFields.textDecorationFillPaints + ) expect(exported?.textUnderlineOffset).toEqual({ value: 2, units: 'PIXELS' }) expect(exported?.textDecorationThickness).toEqual({ value: 1.5, units: 'PIXELS' }) expect(exported?.toggledOnOTFeatures).toEqual(['DLIG']) diff --git a/tests/engine/render/canvas/derived-text.test.ts b/tests/engine/render/canvas/derived-text.test.ts index 85ac6b11d..477156a74 100644 --- a/tests/engine/render/canvas/derived-text.test.ts +++ b/tests/engine/render/canvas/derived-text.test.ts @@ -173,6 +173,79 @@ describe('derived text rendering', () => { } }) + test('does not draw base derived underlines through NONE style runs', async () => { + const graph = new SceneGraph() + const page = graph.getPages()[0] + const text = graph.createNode('TEXT', page.id, { + width: 60, + height: 28, + text: 'on off', + fontFamily: '__MissingFont__', + textDecoration: 'UNDERLINE', + styleRuns: [ + { + start: 3, + length: 3, + style: { textDecoration: 'NONE' } + } + ], + fills: [ + { + type: 'SOLID', + color: { r: 0, g: 0, b: 0, a: 1 }, + opacity: 1, + visible: true + } + ], + figmaDerivedTextGlyphs: [ + { + commandsBlob: squareCommandsBlob(), + x: 2, + y: 12, + fontSize: 10 + } + ] + }) + + const surface = expectDefined(ck.MakeSurface(1, 1), 'surface') + const renderer = new SkiaRenderer(ck, surface) + + try { + const png = expectDefined( + renderNodesToImage(ck, renderer, graph, page.id, [text.id], { + scale: 1, + format: 'PNG' + }), + 'png' + ) + const image = expectDefined(ck.MakeImageFromEncoded(png), 'image') + const pixels = expectDefined( + image.readPixels(0, 0, { + alphaType: ck.AlphaType.Unpremul, + colorType: ck.ColorType.RGBA_8888, + colorSpace: ck.ColorSpace.SRGB, + width: image.width(), + height: image.height() + }), + 'pixels' + ) + + const leftUnderlineAlpha = Math.max( + pixels[(15 * image.width() + 12) * 4 + 3] ?? 0, + pixels[(16 * image.width() + 12) * 4 + 3] ?? 0 + ) + const disabledUnderlineAlpha = Math.max( + pixels[(15 * image.width() + 45) * 4 + 3] ?? 0, + pixels[(16 * image.width() + 45) * 4 + 3] ?? 0 + ) + expect(leftUnderlineAlpha).toBeGreaterThan(0) + expect(disabledUnderlineAlpha).toBe(0) + image.delete() + } finally { + surface.delete() + } + }) + test('draws styled decoration runs for Figma-derived text', async () => { const graph = new SceneGraph() const page = graph.getPages()[0] diff --git a/tests/engine/render/canvas/effects/types.test.ts b/tests/engine/render/canvas/effects/types.test.ts index 5eb953be2..1fa698ae3 100644 --- a/tests/engine/render/canvas/effects/types.test.ts +++ b/tests/engine/render/canvas/effects/types.test.ts @@ -7,7 +7,7 @@ import { renderNode } from '#core/canvas/scene' import { renderEffects } from '#core/canvas/shadows' import type { SceneGraph, SceneNode } from '#core/scene-graph' -import { createMockCanvas, createMockRenderer } from './helpers' +import { createMockCanvas, createMockRenderer, mockCalls } from './helpers' describe('Renderer handles all effect types (Behavioral)', () => { test('handles DROP_SHADOW', () => { @@ -184,6 +184,56 @@ describe('Renderer handles all effect types (Behavioral)', () => { expect(canvas.drawRect).toHaveBeenCalled() }) + test('bounds raw noise effect draw calls for large nodes', () => { + const r = createMockRenderer() + const canvas = createMockCanvas() + const node: Partial = { + type: 'RECTANGLE', + width: 1000, + height: 1000, + fills: [], + childIds: [], + effects: [], + source: { + format: 'fig', + id: '1:1', + orderKey: null, + fig: { + rawSize: null, + rawTransform: null, + rawNodeFields: { + effects: [ + { + type: 'NOISE', + visible: true, + color: { r: 0, g: 0, b: 0, a: 1 }, + density: 1, + noiseSize: { x: 0.1, y: 0.1 } + } + ] + }, + layout: null, + symbolOverrides: [], + componentPropAssignments: [], + derivedSymbolData: [], + derivedSymbolDataLayoutVersion: null, + uniformScaleFactor: null + } + } + } + + renderEffects( + r, + canvas as Canvas, + node as SceneNode, + new Float32Array([0, 0, 1000, 1000]), + false, + 'front' + ) + + expect(mockCalls(canvas.drawRect).length).toBeLessThanOrEqual(12_000) + }) + test('handles BACKGROUND_BLUR', () => { const r = createMockRenderer() const canvas = createMockCanvas() diff --git a/tests/engine/render/canvas/layout-grids.test.ts b/tests/engine/render/canvas/layout-grids.test.ts index b10ef0389..b3a3e94d6 100644 --- a/tests/engine/render/canvas/layout-grids.test.ts +++ b/tests/engine/render/canvas/layout-grids.test.ts @@ -95,6 +95,16 @@ describe('layout grid rendering', () => { ]) }) + test('skips malformed square grids', () => { + const r = createMockRenderer() + const canvas = createMockCanvas() + const node = nodeWithLayoutGrids([{ pattern: 'GRID', alignment: 'STRETCH', sectionSize: 0 }]) + + drawLayoutGrids(r, canvas as Canvas, node) + + expect(canvas.drawRect).not.toHaveBeenCalled() + }) + test('skips hidden grids', () => { const r = createMockRenderer() const canvas = createMockCanvas()