fix(core): address compatibility review findings

This commit is contained in:
Danila Poyarkov 2026-05-28 09:57:46 +03:00
parent 5fcd8e0e9c
commit 6ae425ee3b
15 changed files with 291 additions and 22 deletions

View file

@ -69,7 +69,8 @@ export function applyFill(
fill: Fill,
node: SceneNode,
graph: SceneGraph,
fillIndex = 0
fillIndex = 0,
patternStack = new Set<string>()
): 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<string>
) {
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<string>
): 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(

View file

@ -31,7 +31,8 @@ interface GridGeometry {
}
function rawLayoutGrids(node: SceneNode): RawLayoutGrid[] {
const grids = node.source.fig.rawNodeFields.layoutGrids
const source = (node as Partial<SceneNode>).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),

View file

@ -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()

View file

@ -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[]) {

View file

@ -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

View file

@ -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

View file

@ -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<Record<string, string>>)[String(nc[field])]
if (tag) addFeature(features, tag, true)
const tag = (values as Partial<Record<string, string | string[]>>)[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)

View file

@ -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.

View file

@ -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)

View file

@ -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')
})
})

View file

@ -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(
{

View file

@ -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'])

View file

@ -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]

View file

@ -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<SceneNode> = {
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()

View file

@ -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()