refactor(fig): own NodeChange import conversion

- Move NodeChange-to-SceneGraph property and style-run policy into @open-pencil/fig\n- Share normalized font-style parsing and model defaults through scene-graph instead of duplicating literals\n- Preserve core compatibility exports while conversion callers migrate
This commit is contained in:
Danila Poyarkov 2026-07-18 01:51:13 +03:00
parent c2f3338877
commit 22a160bfc3
11 changed files with 1283 additions and 1280 deletions

View file

@ -4,7 +4,7 @@
### Changed
- Move complete `.fig` archive parsing into `@open-pencil/fig`, keeping `@open-pencil/kiwi` focused on Kiwi schema, message, and raw container mechanics.
- Move complete `.fig` archive parsing and NodeChange-to-SceneGraph conversion policy into `@open-pencil/fig`, keeping `@open-pencil/kiwi` focused on Kiwi schema, message, and raw container mechanics.
- Add Figma-style page management in the Pages panel, including rename/delete actions and drag-and-drop page reordering.
- Add DOM/CSS import and authoring support so HTML, CSS, Tailwind, and JSX can be converted into editable OpenPencil documents from the app, CLI, and SDK.
- Add Tailwind class serialization for DOM/CSS HTML export in the SDK and CLI.

File diff suppressed because it is too large Load diff

View file

@ -1,113 +1 @@
import {
convertFontFeatures,
convertFontVariations,
convertFills,
convertLetterSpacing,
convertLineHeight,
mapTextDecoration
} from '@open-pencil/fig/node-change'
import type { NodeChange } from '@open-pencil/kiwi/fig/codec'
import type { CharacterStyleOverride, StyleRun } from '@open-pencil/scene-graph'
import { styleToWeight } from '#core/text/fonts'
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
): CharacterStyleOverride {
const style: CharacterStyleOverride = {}
if (override.fontName) {
style.fontFamily = override.fontName.family
style.fontWeight = styleToWeight(override.fontName.style)
style.italic = override.fontName.style.toLowerCase().includes('italic')
}
if (override.fontSize !== undefined) style.fontSize = override.fontSize
const fontVariations = convertFontVariations(override)
if (fontVariations.length > 0) style.fontVariations = fontVariations
const fontFeatures = convertFontFeatures(override)
if (fontFeatures.length > 0) style.fontFeatures = fontFeatures
if (override.letterSpacing) {
style.letterSpacing = convertLetterSpacing(
override.letterSpacing,
override.fontSize ?? fallbackFontSize
)
}
if (override.lineHeight) {
const lh = convertLineHeight(override.lineHeight, override.fontSize ?? fallbackFontSize)
if (lh != null) style.lineHeight = lh
}
applyTextDecorationOverride(style, override)
if (override.fillPaints) {
const fills = convertFills(override.fillPaints)
if (fills.length > 0) style.fills = fills
}
return style
}
function buildStyleMap(
table: NodeChange[],
fallbackFontSize: number | undefined
): Map<number, CharacterStyleOverride> {
const styleMap = new Map<number, CharacterStyleOverride>()
for (const override of table) {
const id = override.styleID as number | undefined
if (id === undefined) continue
const style = convertStyleOverride(override, fallbackFontSize)
if (Object.keys(style).length > 0) styleMap.set(id, style)
}
return styleMap
}
function collectStyleRuns(
ids: number[],
styleMap: Map<number, CharacterStyleOverride>
): StyleRun[] {
const runs: StyleRun[] = []
let currentId = ids[0]
let start = 0
for (let i = 1; i <= ids.length; i++) {
if (i === ids.length || ids[i] !== currentId) {
if (currentId !== 0) {
const style = styleMap.get(currentId)
if (style) runs.push({ start, length: i - start, style })
}
if (i < ids.length) {
currentId = ids[i]
start = i
}
}
}
return runs
}
export function importStyleRuns(nc: NodeChange): StyleRun[] {
const td = nc.textData
if (!td?.characterStyleIDs || !td.styleOverrideTable) return []
const ids = td.characterStyleIDs
if (ids.length === 0 || td.styleOverrideTable.length === 0) return []
const styleMap = buildStyleMap(td.styleOverrideTable, nc.fontSize)
if (styleMap.size === 0) return []
return collectStyleRuns(ids, styleMap)
}
export { importStyleRuns } from '@open-pencil/fig/node-change'

View file

@ -1,7 +1,8 @@
export interface ParsedFontStyle {
weight: number
italic: boolean
}
import { parseFontStyle } from '@open-pencil/scene-graph'
import type { ParsedFontStyle } from '@open-pencil/scene-graph'
export { normalizeFontStyleName, parseFontStyle, styleToWeight } from '@open-pencil/scene-graph'
export type { ParsedFontStyle } from '@open-pencil/scene-graph'
export interface FontFaceRef extends ParsedFontStyle {
family: string
@ -9,39 +10,6 @@ export interface FontFaceRef extends ParsedFontStyle {
postscriptName?: string
}
const FONT_WEIGHT_ALIASES = [
{ weight: 100, names: ['thin', 'hairline', 'extrathin', 'ultrathin'] },
{ weight: 200, names: ['extralight', 'ultralight'] },
{ weight: 300, names: ['light'] },
{ weight: 400, names: ['regular', 'normal', 'book', 'roman', 'plain'] },
{ weight: 500, names: ['medium'] },
{ weight: 600, names: ['semibold', 'demibold'] },
{ weight: 700, names: ['bold'] },
{ weight: 800, names: ['extrabold', 'ultrabold'] },
{ weight: 900, names: ['black', 'heavy'] }
] as const
const FONT_WEIGHT_BY_STYLE: ReadonlyMap<string, number> = new Map(
FONT_WEIGHT_ALIASES.flatMap(({ names, weight }) => names.map((name) => [name, weight] as const))
)
export function normalizeFontStyleName(style: string): string {
return style
.toLowerCase()
.replace(/italic|oblique/u, '')
.replace(/[^a-z0-9]+/gu, '')
}
export function parseFontStyle(style: string | undefined): ParsedFontStyle {
const raw = style ?? ''
const italic = /(?:italic|oblique)/iu.test(raw)
const normalized = normalizeFontStyleName(raw)
const numericWeight = normalized.match(/(?:^|[^0-9])([1-9]00)(?:[^0-9]|$)/u)?.[1]
if (numericWeight) return { weight: Number(numericWeight), italic }
return { weight: FONT_WEIGHT_BY_STYLE.get(normalized) ?? 400, italic }
}
export function fontFaceFromFigmaFontName(fontName: {
family?: string
style?: string

View file

@ -1,5 +1,9 @@
import { styleToWeight } from '@open-pencil/scene-graph'
import { parseFontStyle } from '#core/text/face'
export { styleToWeight }
interface LocalFontMatch {
family: string
style: string
@ -79,10 +83,6 @@ export function isVariableFont(data: ArrayBuffer): boolean {
return false
}
export function styleToWeight(style: string): number {
return parseFontStyle(style).weight
}
export function weightToStyle(weight: number, italic = false): string {
const rounded = Math.round(weight / 100) * 100
const label = (FONT_WEIGHT_NAMES[rounded] ?? 'Regular').replace(/ /g, '')

View file

@ -13,14 +13,15 @@ Current ownership:
- Canvas payload and image resource handling
- `readFigContainer()` / `writeFigContainer()` helpers for raw `fig-kiwi` payloads
- `.fig` source and archive result types
- Dependency-free NodeChange policy helpers for styles, plugin metadata, text values, and font axes/features through `@open-pencil/fig/node-change`
- NodeChange-to-SceneGraph property conversion, including styles, plugin metadata, text, paint, vector, and font policy, through `@open-pencil/fig/node-change`
- Package-local archive, conversion, and dist smoke tests
Planned ownership:
- SceneGraph ⇄ Figma `NodeChange` conversion
- Remaining SceneGraph-to-`NodeChange` export conversion and document orchestration
- Raw Figma metadata precedence and invalidation policy
- Component and instance interpretation
- Oracle-backed `.fig` fixtures and package-local tests
- Oracle-backed `.fig` fixtures
Non-goals:

File diff suppressed because it is too large Load diff

View file

@ -1,9 +1,11 @@
export * from './convert'
export * from './derived-text-glyphs'
export * from './font/features'
export * from './font/variations'
export * from './paint'
export * from './plugin-data'
export * from './style-refs'
export * from './style-runs'
export * from './text-values'
export * from './vector-geometry'
export * from './vector-network'

View file

@ -0,0 +1,109 @@
import type { NodeChange } from '@open-pencil/kiwi/fig/codec'
import { styleToWeight } from '@open-pencil/scene-graph'
import type { CharacterStyleOverride, StyleRun } from '@open-pencil/scene-graph'
import { convertFontFeatures } from './font/features'
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
): CharacterStyleOverride {
const style: CharacterStyleOverride = {}
if (override.fontName) {
style.fontFamily = override.fontName.family
style.fontWeight = styleToWeight(override.fontName.style)
style.italic = override.fontName.style.toLowerCase().includes('italic')
}
if (override.fontSize !== undefined) style.fontSize = override.fontSize
const fontVariations = convertFontVariations(override)
if (fontVariations.length > 0) style.fontVariations = fontVariations
const fontFeatures = convertFontFeatures(override)
if (fontFeatures.length > 0) style.fontFeatures = fontFeatures
if (override.letterSpacing) {
style.letterSpacing = convertLetterSpacing(
override.letterSpacing,
override.fontSize ?? fallbackFontSize
)
}
if (override.lineHeight) {
const lh = convertLineHeight(override.lineHeight, override.fontSize ?? fallbackFontSize)
if (lh != null) style.lineHeight = lh
}
applyTextDecorationOverride(style, override)
if (override.fillPaints) {
const fills = convertFills(override.fillPaints)
if (fills.length > 0) style.fills = fills
}
return style
}
function buildStyleMap(
table: NodeChange[],
fallbackFontSize: number | undefined
): Map<number, CharacterStyleOverride> {
const styleMap = new Map<number, CharacterStyleOverride>()
for (const override of table) {
const id = override.styleID as number | undefined
if (id === undefined) continue
const style = convertStyleOverride(override, fallbackFontSize)
if (Object.keys(style).length > 0) styleMap.set(id, style)
}
return styleMap
}
function collectStyleRuns(
ids: number[],
styleMap: Map<number, CharacterStyleOverride>
): StyleRun[] {
const runs: StyleRun[] = []
let currentId = ids[0]
let start = 0
for (let i = 1; i <= ids.length; i++) {
if (i === ids.length || ids[i] !== currentId) {
if (currentId !== 0) {
const style = styleMap.get(currentId)
if (style) runs.push({ start, length: i - start, style })
}
if (i < ids.length) {
currentId = ids[i]
start = i
}
}
}
return runs
}
export function importStyleRuns(nc: NodeChange): StyleRun[] {
const td = nc.textData
if (!td?.characterStyleIDs || !td.styleOverrideTable) return []
const ids = td.characterStyleIDs
if (ids.length === 0 || td.styleOverrideTable.length === 0) return []
const styleMap = buildStyleMap(td.styleOverrideTable, nc.fontSize)
if (styleMap.size === 0) return []
return collectStyleRuns(ids, styleMap)
}

View file

@ -0,0 +1,42 @@
export interface ParsedFontStyle {
weight: number
italic: boolean
}
const FONT_WEIGHT_ALIASES = [
{ weight: 100, names: ['thin', 'hairline', 'extrathin', 'ultrathin'] },
{ weight: 200, names: ['extralight', 'ultralight'] },
{ weight: 300, names: ['light'] },
{ weight: 400, names: ['regular', 'normal', 'book', 'roman', 'plain'] },
{ weight: 500, names: ['medium'] },
{ weight: 600, names: ['semibold', 'demibold'] },
{ weight: 700, names: ['bold'] },
{ weight: 800, names: ['extrabold', 'ultrabold'] },
{ weight: 900, names: ['black', 'heavy'] }
] as const
const FONT_WEIGHT_BY_STYLE: ReadonlyMap<string, number> = new Map(
FONT_WEIGHT_ALIASES.flatMap(({ names, weight }) => names.map((name) => [name, weight] as const))
)
export function normalizeFontStyleName(style: string): string {
return style
.toLowerCase()
.replace(/italic|oblique/u, '')
.replace(/[^a-z0-9]+/gu, '')
}
export function parseFontStyle(style: string | undefined): ParsedFontStyle {
const raw = style ?? ''
const italic = /(?:italic|oblique)/iu.test(raw)
const normalized = normalizeFontStyleName(raw)
const numericWeight = normalized.match(/(?:^|[^0-9])([1-9]00)(?:[^0-9]|$)/u)?.[1]
return {
weight: numericWeight ? Number(numericWeight) : (FONT_WEIGHT_BY_STYLE.get(normalized) ?? 400),
italic
}
}
export function styleToWeight(style: string | undefined): number {
return parseFontStyle(style).weight
}

View file

@ -3,7 +3,9 @@ export * from './copy'
export * from './snap'
export * from './export-scale'
export * from './coordinate'
export * from './constants'
export * from './geometry'
export * from './font-style'
export * from './shared-styles'
export { default as TransformMatrix } from './matrix'
export type { Mat3 } from './matrix'