fix(canvas): tighten Figma text and pattern fidelity

This commit is contained in:
Danila Poyarkov 2026-05-26 18:30:08 +03:00
parent b95943697a
commit c1532610db
21 changed files with 244 additions and 47 deletions

View file

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

View file

@ -203,6 +203,7 @@ export interface Paint {
image?: { hash: string | Uint8Array }
imageScaleMode?: string
sourceNodeId?: GUID
scale?: number
spacing?: number
patternSpacing?: Vector
patternTileType?: string

View file

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

View file

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

View file

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

View file

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

View file

@ -12,6 +12,26 @@ export function fontVariationToKiwi(variation: SceneNode['fontVariations'][numbe
: { axisTag, axisName: variation.axis, value: variation.value }
}
function applyTextDecorationOverrideFields(
override: Record<string, unknown>,
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)
}

View file

@ -88,6 +88,8 @@ export function createDefaultNode(
textDecorationStyle: 'SOLID',
textDecorationThickness: null,
textDecorationFills: [],
textDecorationSkipInk: true,
textUnderlineOffset: null,
maxLines: null,
styleRuns: [],
fontVariations: [],

View file

@ -31,6 +31,8 @@ const RAW_NODE_FIELD_KEYS = new Set([
'textDecorationStyle',
'textDecorationThickness',
'textDecorationFills',
'textDecorationSkipInk',
'textUnderlineOffset',
'lineHeight',
'leadingTrim',
'letterSpacing',

View file

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

View file

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

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 50 KiB

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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