fix(core): address compatibility review findings
This commit is contained in:
parent
5fcd8e0e9c
commit
6ae425ee3b
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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[]) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue