feat(text): wrap styled outline text
This commit is contained in:
parent
1c8fe1ee8b
commit
28f7ef165b
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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` },
|
||||
|
|
|
|||
33
packages/docs/development/vector-conversion.md
Normal file
33
packages/docs/development/vector-conversion.md
Normal 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.
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue