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:
parent
c2f3338877
commit
22a160bfc3
|
|
@ -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
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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, '')
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
1112
packages/fig/src/node-change/convert.ts
Normal file
1112
packages/fig/src/node-change/convert.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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'
|
||||
|
|
|
|||
109
packages/fig/src/node-change/style-runs.ts
Normal file
109
packages/fig/src/node-change/style-runs.ts
Normal 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)
|
||||
}
|
||||
42
packages/scene-graph/src/font-style.ts
Normal file
42
packages/scene-graph/src/font-style.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Reference in a new issue