feat(text): wrap styled outline text

This commit is contained in:
Danila Poyarkov 2026-05-17 20:23:55 +03:00
parent 1c8fe1ee8b
commit 28f7ef165b
6 changed files with 124 additions and 2 deletions

View file

@ -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) {

View file

@ -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` },

View file

@ -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.

View file

@ -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')

View file

@ -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()

View file

@ -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()