diff --git a/packages/core/src/text/outlines.ts b/packages/core/src/text/outlines.ts index f50ef87d6..876e5c190 100644 --- a/packages/core/src/text/outlines.ts +++ b/packages/core/src/text/outlines.ts @@ -126,9 +126,47 @@ function hardTextLines(text: string): TextLine[] { return lines } +function glyphAdvance(node: SceneNode, absoluteIndex: number): number | null { + const char = node.text[absoluteIndex] + const style = resolvedGlyphStyle(textStyleAt(node, absoluteIndex), char) + if (!style) return null + const metrics = getGlyphOutlineMetricsSync(style.fontFamily, styleName(style), char, style.fontSize) + const glyph = metrics?.[0] + return glyph ? glyph.advance + style.letterSpacing : null +} + +function wrapStyledLine(node: SceneNode, line: TextLine): TextLine[] { + if (!line.text || node.width <= 0) return [line] + const result: TextLine[] = [] + let lineStart = 0 + let cursor = 0 + let lastBreak = -1 + + for (let index = 0; index < line.text.length; index++) { + const advance = glyphAdvance(node, line.start + index) + if (advance == null) return [line] + cursor += advance + if (/\s/.test(line.text[index])) { + lastBreak = index + 1 + } + if (cursor <= node.width || index === lineStart) continue + + const breakIndex = lastBreak > lineStart ? lastBreak : index + result.push({ text: line.text.slice(lineStart, breakIndex), start: line.start + lineStart }) + lineStart = breakIndex + index = breakIndex - 1 + cursor = 0 + lastBreak = -1 + } + + result.push({ text: line.text.slice(lineStart), start: line.start + lineStart }) + return result +} + function textLines(node: SceneNode): TextLine[] { const hardLines = hardTextLines(node.text) - if (node.textAutoResize === 'WIDTH_AND_HEIGHT' || node.styleRuns.length > 0) return hardLines + if (node.textAutoResize === 'WIDTH_AND_HEIGHT') return hardLines + if (node.styleRuns.length > 0) return hardLines.flatMap((line) => wrapStyledLine(node, line)) const result: TextLine[] = [] for (const hardLine of hardLines) { diff --git a/packages/docs/.vitepress/sidebars.ts b/packages/docs/.vitepress/sidebars.ts index bca0e9fe6..00f267d7b 100644 --- a/packages/docs/.vitepress/sidebars.ts +++ b/packages/docs/.vitepress/sidebars.ts @@ -93,6 +93,7 @@ export const developmentSidebar = (prefix: string, label: string): DefaultTheme. { text: 'Contributing', link: `${prefix}/development/contributing` }, { text: 'Testing', link: `${prefix}/development/testing` }, { text: 'Renderer Profiler', link: `${prefix}/development/renderer-profiler` }, + { text: 'Vector Conversion', link: `${prefix}/development/vector-conversion` }, { text: 'Variables UI Roadmap', link: `${prefix}/development/variables-ui-roadmap` }, { text: 'OpenSpec', link: `${prefix}/development/openspec` }, { text: 'Roadmap', link: `${prefix}/development/roadmap` }, diff --git a/packages/docs/development/vector-conversion.md b/packages/docs/development/vector-conversion.md new file mode 100644 index 000000000..e4b780c57 --- /dev/null +++ b/packages/docs/development/vector-conversion.md @@ -0,0 +1,33 @@ +# Vector conversion + +OpenPencil has a shared path-conversion pipeline for commands that turn scene nodes into vector geometry. + +## Commands + +- **Boolean operations** keep a live `BOOLEAN_OPERATION` container and render it with CanvasKit path operations. +- **Flatten** replaces selected supported nodes with one persistent `VECTOR` node. +- **Outline text** replaces selected supported text nodes with vector outlines. +- **Outline stroke** replaces selected supported stroked nodes with vector stroke outlines. + +The editor, renderer-backed Figma API, and menu command enablement all use the same source-path checks so unsupported nodes fail safely instead of being silently dropped. + +## Supported sources + +Supported sources include basic shapes, vectors, lines, nested boolean operations, and visual descendants inside groups, frames, components, and instances. Containers contribute their visible descendants, and their own fill/stroke if present. + +Text can be converted when all required font data is loaded. The outline engine supports multiline text, horizontal and vertical alignment, letter spacing, style runs, and loaded fallback glyphs for mixed-font text such as Latin plus CJK. + +## Unsupported sources + +The conversion pipeline rejects these cases: + +- visible image fills +- sections and component sets +- text with missing font data or missing fallback glyphs +- complex scripts that require shaping, such as Arabic, Hebrew, and Indic scripts + +Complex-script text stays unsupported until we can extract exact shaped glyph runs and positions from the rendering stack. + +## Figma API flatten + +`FigmaAPI.flatten()` produces real vector geometry when a `SkiaRenderer` is attached with `api.setRenderer(renderer)`. In headless compatibility mode without a renderer, it keeps the historical placeholder behavior: the source nodes are replaced by a vector-sized placeholder without `vectorNetwork` geometry. diff --git a/tests/e2e/context-menu/basic.spec.ts b/tests/e2e/context-menu/basic.spec.ts index a0e107f59..7e8d106dd 100644 --- a/tests/e2e/context-menu/basic.spec.ts +++ b/tests/e2e/context-menu/basic.spec.ts @@ -214,7 +214,20 @@ test('create component via context menu', async () => { editor.canvas.assertNoErrors() }) -test('Copy/Paste as submenu exists', async () => { +test('outline stroke is disabled for fill-only shapes', async () => { + await editor.canvas.clearCanvas() + await editor.canvas.drawRect(200, 200, 120, 80) + await editor.canvas.waitForRender() + + await rightClickShape(250, 230) + + const item = contextItem('context-outline-stroke') + await expect(item).toBeVisible() + await expect(item).toHaveAttribute('data-disabled', '') + await editor.page.keyboard.press('Escape') +}) + + test('Copy/Paste as submenu exists', async () => { await rightClickShape(130, 130) const submenuTrigger = contextItem('context-copy-paste-as') diff --git a/tests/engine/editor/structure/flatten.test.ts b/tests/engine/editor/structure/flatten.test.ts index e71640559..384500f3d 100644 --- a/tests/engine/editor/structure/flatten.test.ts +++ b/tests/engine/editor/structure/flatten.test.ts @@ -5,6 +5,7 @@ import { SkiaRenderer } from '#core/canvas' import { BLACK, TRANSPARENT } from '#core/constants' import { createEditor } from '#core/editor' import { fontManager } from '#core/text/fonts' +import { textNodeToOutlineLayout } from '#core/text/outlines' async function createEditorWithRenderer() { const ck = await initCanvasKit() @@ -161,6 +162,27 @@ describe('flattenSelected', () => { surface.delete() }) + test('wraps loaded text style runs as outlines', async () => { + await loadInterRegular() + await loadInterBold() + const editor = createEditor() + const pageId = editor.state.currentPageId + const text = editor.graph.createNode('TEXT', pageId, { + text: 'Hello world', + fontFamily: 'Inter', + fontWeight: 400, + fontSize: 32, + width: 100, + height: 100, + styleRuns: [{ start: 6, length: 5, style: { fontWeight: 700 } }] + }) + + const layout = textNodeToOutlineLayout(text) + + expect(layout).not.toBeNull() + expect(layout?.height).toBeGreaterThan(40) + }) + test('flattens loaded text style runs as outlines', async () => { await loadInterRegular() await loadInterBold() diff --git a/tests/engine/figma/api/flatten-renderer.test.ts b/tests/engine/figma/api/flatten-renderer.test.ts index 16c9b264a..8f35e3512 100644 --- a/tests/engine/figma/api/flatten-renderer.test.ts +++ b/tests/engine/figma/api/flatten-renderer.test.ts @@ -13,6 +13,21 @@ async function createRenderer() { } describe('FigmaAPI renderer-backed flatten', () => { + test('keeps headless flatten as a compatibility placeholder without geometry', () => { + const api = createAPI() + const rect = api.createRectangle() + rect.resize(50, 40) + + const vector = api.flatten([rect], api.currentPage) + const raw = api.graph.getNode(vector.id) + + expect(raw?.type).toBe('VECTOR') + expect(raw?.width).toBe(50) + expect(raw?.height).toBe(40) + expect(raw?.vectorNetwork).toBeNull() + expect(api.getNodeById(rect.id)).toBeNull() + }) + test('creates vector geometry when a renderer is attached', async () => { const api = createAPI() const { renderer, surface } = await createRenderer()