fix(canvas): tighten Figma text and pattern fidelity
This commit is contained in:
parent
b95943697a
commit
c1532610db
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -203,6 +203,7 @@ export interface Paint {
|
|||
image?: { hash: string | Uint8Array }
|
||||
imageScaleMode?: string
|
||||
sourceNodeId?: GUID
|
||||
scale?: number
|
||||
spacing?: number
|
||||
patternSpacing?: Vector
|
||||
patternTileType?: string
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,6 +88,8 @@ export function createDefaultNode(
|
|||
textDecorationStyle: 'SOLID',
|
||||
textDecorationThickness: null,
|
||||
textDecorationFills: [],
|
||||
textDecorationSkipInk: true,
|
||||
textUnderlineOffset: null,
|
||||
maxLines: null,
|
||||
styleRuns: [],
|
||||
fontVariations: [],
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ const RAW_NODE_FIELD_KEYS = new Set([
|
|||
'textDecorationStyle',
|
||||
'textDecorationThickness',
|
||||
'textDecorationFills',
|
||||
'textDecorationSkipInk',
|
||||
'textUnderlineOffset',
|
||||
'lineHeight',
|
||||
'leadingTrim',
|
||||
'letterSpacing',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
24
tests/fixtures/figma-oracles/masks.json
vendored
24
tests/fixtures/figma-oracles/masks.json
vendored
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue