From 8fa94cbdb79a4b8c881fcbc79c985ad096416942 Mon Sep 17 00:00:00 2001 From: tqdat410 Date: Wed, 1 Apr 2026 11:05:54 +0700 Subject: [PATCH 01/12] feat: add text and shape styling API Add textStyle and shapeStyle inputs, keep legacy aliases, and cover the new SVG styling behavior in unit and Playwright tests. Co-authored-by: Codex --- e2e/fixtures/index.html | 60 +++++- e2e/fixtures/shape-text-e2e-app.js | 87 +++++++- e2e/shape-text-local-e2e.spec.ts | 58 +++++- src/index.ts | 7 + src/layout/layout-text-in-compiled-shape.ts | 10 +- src/layout/layout-text-in-shape.test.ts | 27 +++ src/layout/layout-text-in-shape.ts | 15 +- src/render/normalize-shape-decoration.ts | 59 ++++++ src/render/render-layout-to-svg.test.ts | 185 +++++++++++++++++- src/render/render-layout-to-svg.ts | 109 +++++++++-- src/render/render-svg-shadow-filter.ts | 32 +++ src/text/normalize-text-style-to-font.test.ts | 41 ++++ src/text/normalize-text-style-to-font.ts | 41 ++++ src/types.ts | 71 ++++++- 14 files changed, 767 insertions(+), 35 deletions(-) create mode 100644 src/render/normalize-shape-decoration.ts create mode 100644 src/render/render-svg-shadow-filter.ts create mode 100644 src/text/normalize-text-style-to-font.test.ts create mode 100644 src/text/normalize-text-style-to-font.ts diff --git a/e2e/fixtures/index.html b/e2e/fixtures/index.html index ba6344e..a5de73d 100644 --- a/e2e/fixtures/index.html +++ b/e2e/fixtures/index.html @@ -117,6 +117,13 @@ margin-top: 14px; } + .controls-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + margin-top: 14px; + } + label { display: grid; gap: 6px; @@ -138,6 +145,10 @@ .panel-grid { grid-template-columns: 1fr; } + + .controls-grid { + grid-template-columns: 1fr; + } } @@ -146,14 +157,57 @@

shape-text demo

-

Preview the current library in a real browser UI. Switch between polygon flow and glyph-mask flow, edit text, tweak line height, then render again.

+

Preview the current library in a real browser UI. Switch between polygon flow and glyph-mask flow, edit text, tweak text style and shape decoration, then render again.

-
+
- Current: 22px + + + + + + +
+
+ Line: 22px + Text: 18px + Border: 2px + + +
diff --git a/e2e/fixtures/shape-text-e2e-app.js b/e2e/fixtures/shape-text-e2e-app.js index d4edf95..70b346b 100644 --- a/e2e/fixtures/shape-text-e2e-app.js +++ b/e2e/fixtures/shape-text-e2e-app.js @@ -10,6 +10,17 @@ const summary = document.querySelector('#summary') const textInput = document.querySelector('#text-input') const lineHeightInput = document.querySelector('#line-height-input') const lineHeightValue = document.querySelector('#line-height-value') +const textSizeInput = document.querySelector('#text-size-input') +const textSizeValue = document.querySelector('#text-size-value') +const textWeightSelect = document.querySelector('#text-weight-select') +const textItalicInput = document.querySelector('#text-italic-input') +const textColorInput = document.querySelector('#text-color-input') +const shapeFillInput = document.querySelector('#shape-fill-input') +const shapeBorderWidthInput = document.querySelector('#shape-border-width-input') +const shapeBorderWidthValue = document.querySelector('#shape-border-width-value') +const shapeBorderColorInput = document.querySelector('#shape-border-color-input') +const shapeShadowInput = document.querySelector('#shape-shadow-input') +const showShapeInput = document.querySelector('#show-shape-input') const renderButton = document.querySelector('#render-button') const measurer = createCanvasTextMeasurer() @@ -57,7 +68,6 @@ const scenarios = { }, autoFill: true, text: 'ONE', - showShape: false, }, 'digit-two-wide': { shape: { kind: 'polygon', points: createDigitTwoPolygon(340, 460) }, @@ -99,6 +109,41 @@ const state = { svg: '', text: 'ONE', lineHeight: 22, + textSize: 18, + textWeight: 700, + textItalic: false, + textColor: '#111827', + showShape: true, + shapeFill: '#dbeafe', + shapeBorderWidth: 2, + shapeBorderColor: '#94a3b8', + shapeShadow: true, +} + +function getTextStyle() { + return { + family: '"Helvetica Neue", Arial, sans-serif', + size: state.textSize, + weight: state.textWeight, + style: state.textItalic ? 'italic' : 'normal', + color: state.textColor, + } +} + +function getShapeStyle() { + return { + backgroundColor: state.shapeFill, + borderColor: state.shapeBorderColor, + borderWidth: state.shapeBorderWidth, + shadow: state.shapeShadow + ? { + color: 'rgba(15, 23, 42, 0.22)', + blur: 6, + offsetX: 0, + offsetY: 6, + } + : undefined, + } } function renderScenario(name) { @@ -109,7 +154,7 @@ function renderScenario(name) { const layout = layoutTextInShape({ text: state.text, - font: '16px "Helvetica Neue", Arial, sans-serif', + textStyle: getTextStyle(), lineHeight: state.lineHeight, shape: scenario.shape, measurer, @@ -119,10 +164,8 @@ function renderScenario(name) { const svg = renderLayoutToSvg(layout, { background: '#fffdf7', - textFill: '#111827', - shapeStroke: '#94a3b8', - shapeFill: 'rgba(191, 219, 254, 0.18)', - showShape: scenario.showShape ?? true, + shapeStyle: getShapeStyle(), + showShape: state.showShape, padding: 12, }) @@ -137,6 +180,8 @@ function renderScenario(name) { shapeKind: scenario.shape.kind, autoFill: Boolean(scenario.autoFill), lineHeight: state.lineHeight, + textStyle: getTextStyle(), + shapeStyle: getShapeStyle(), lineCount: layout.lines.length, exhausted: layout.exhausted, firstLine: layout.lines[0]?.text ?? null, @@ -200,12 +245,34 @@ function syncControls() { textInput.value = state.text lineHeightInput.value = String(state.lineHeight) lineHeightValue.textContent = String(state.lineHeight) + textSizeInput.value = String(state.textSize) + textSizeValue.textContent = String(state.textSize) + textWeightSelect.value = String(state.textWeight) + textItalicInput.checked = state.textItalic + textColorInput.value = state.textColor + showShapeInput.checked = state.showShape + shapeFillInput.value = state.shapeFill + shapeBorderWidthInput.value = String(state.shapeBorderWidth) + shapeBorderWidthValue.textContent = String(state.shapeBorderWidth) + shapeBorderColorInput.value = state.shapeBorderColor + shapeShadowInput.checked = state.shapeShadow } renderButton.addEventListener('click', () => { state.text = textInput.value state.lineHeight = Number(lineHeightInput.value) + state.textSize = Number(textSizeInput.value) + state.textWeight = Number(textWeightSelect.value) + state.textItalic = textItalicInput.checked + state.textColor = textColorInput.value + state.showShape = showShapeInput.checked + state.shapeFill = shapeFillInput.value + state.shapeBorderWidth = Number(shapeBorderWidthInput.value) + state.shapeBorderColor = shapeBorderColorInput.value + state.shapeShadow = shapeShadowInput.checked lineHeightValue.textContent = String(state.lineHeight) + textSizeValue.textContent = String(state.textSize) + shapeBorderWidthValue.textContent = String(state.shapeBorderWidth) renderScenario(state.scenario || 'digit-two-wide') }) @@ -213,6 +280,14 @@ lineHeightInput.addEventListener('input', () => { lineHeightValue.textContent = lineHeightInput.value }) +textSizeInput.addEventListener('input', () => { + textSizeValue.textContent = textSizeInput.value +}) + +shapeBorderWidthInput.addEventListener('input', () => { + shapeBorderWidthValue.textContent = shapeBorderWidthInput.value +}) + document.querySelectorAll('[data-scenario]').forEach(button => { button.addEventListener('click', () => { renderScenario(button.getAttribute('data-scenario')) diff --git a/e2e/shape-text-local-e2e.spec.ts b/e2e/shape-text-local-e2e.spec.ts index 5a6831d..d82443b 100644 --- a/e2e/shape-text-local-e2e.spec.ts +++ b/e2e/shape-text-local-e2e.spec.ts @@ -24,6 +24,15 @@ declare global { }> } text: string + textSize: number + textWeight: number + textItalic: boolean + textColor: string + showShape: boolean + shapeFill: string + shapeBorderWidth: number + shapeBorderColor: string + shapeShadow: boolean svg: string } } @@ -34,6 +43,22 @@ async function getState(page: import('@playwright/test').Page) { return page.evaluate(() => window.shapeTextTestApi.getState()) } +async function setRangeValue( + page: import('@playwright/test').Page, + selector: string, + value: string, +) { + await page.locator(selector).evaluate((element, nextValue) => { + if (!(element instanceof HTMLInputElement)) { + throw new Error('Expected an input element') + } + + element.value = nextValue + element.dispatchEvent(new Event('input', { bubbles: true })) + element.dispatchEvent(new Event('change', { bubbles: true })) + }, value) +} + test('renders the glyph text-mask fixture into SVG', async ({ page }) => { await page.goto('/') await expect(page.locator('#stage svg')).toBeVisible() @@ -46,7 +71,12 @@ test('renders the glyph text-mask fixture into SVG', async ({ page }) => { expect(state.layout.autoFill).toBe(true) expect(state.layout.exhausted).toBe(false) expect(state.layout.lines[0]?.text).toContain('ONE') - await expect(page.locator('#stage text')).toHaveCount(state.layout.lines.length) + expect(state.textSize).toBe(18) + const expectedTextNodeCount = + state.showShape && state.scenario === 'glyph-two-repeat' + ? state.layout.lines.length + 1 + : state.layout.lines.length + await expect(page.locator('#stage text')).toHaveCount(expectedTextNodeCount) }) test('reuses the cached compiled glyph shape for repeated text-mask requests', async ({ @@ -72,6 +102,32 @@ test('rejects invalid text-mask alpha thresholds with a clear error', async ({ p expect(errorMessage).toContain('alphaThreshold') }) +test('applies text and shape style controls to the rendered SVG', async ({ page }) => { + await page.goto('/') + + await setRangeValue(page, '#text-size-input', '28') + await page.locator('#text-weight-select').selectOption('700') + await page.locator('#text-italic-input').check() + await page.locator('#text-color-input').fill('#ef4444') + await page.locator('#shape-fill-input').fill('#fde68a') + await setRangeValue(page, '#shape-border-width-input', '6') + await page.locator('#shape-border-color-input').fill('#f59e0b') + await page.locator('#shape-shadow-input').check() + await page.locator('#render-button').click() + + const state = await getState(page) + + expect(state.textSize).toBe(28) + expect(state.textItalic).toBe(true) + expect(state.shapeBorderWidth).toBe(6) + expect(state.svg).toContain('font:italic 700 28px') + expect(state.svg).toContain('fill="#ef4444"') + expect(state.svg).toContain('stroke="#f59e0b"') + expect(state.svg).toContain('stroke-width="6"') + expect(state.svg).toContain('fill="#fde68a"') + expect(state.svg).toContain('shape-text-shape-shadow') +}) + test('reflows into more lines in the narrow rectangle scenario', async ({ page }) => { await page.goto('/') diff --git a/src/index.ts b/src/index.ts index becfd5c..d467df8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,16 +11,23 @@ export type { PreparedLayoutText, PreparedLayoutToken, RenderLayoutToSvgOptions, + ResolvedShapeShadow, + ResolvedShapeStyle, + ResolvedTextStyle, ShapeInput, ShapeBounds, + ShapeShadowInput, + ShapeStyleInput, ShapeTextLayout, ShapeTextLine, ShapeTextPoint, TextMaskShape, + TextStyleInput, TextMeasurer, } from './types.js' export { createCanvasTextMeasurer } from './text/create-canvas-text-measurer.js' +export { normalizeTextStyleToFont, resolveLayoutTextStyle } from './text/normalize-text-style-to-font.js' export { prepareTextForLayout } from './text/prepare-text-for-layout.js' export { layoutNextLineFromPreparedText } from './text/layout-next-line-from-prepared-text.js' export { layoutNextLineFromRepeatedText } from './text/layout-next-line-from-repeated-text.js' diff --git a/src/layout/layout-text-in-compiled-shape.ts b/src/layout/layout-text-in-compiled-shape.ts index 1110b6d..9003368 100644 --- a/src/layout/layout-text-in-compiled-shape.ts +++ b/src/layout/layout-text-in-compiled-shape.ts @@ -7,6 +7,7 @@ import type { } from '../types.js' import { layoutNextLineFromPreparedText } from '../text/layout-next-line-from-prepared-text.js' import { layoutNextLineFromRepeatedText } from '../text/layout-next-line-from-repeated-text.js' +import { resolveLayoutTextStyle } from '../text/normalize-text-style-to-font.js' import { prepareTextForLayout } from '../text/prepare-text-for-layout.js' function pickWidestInterval(intervals: Interval[]): Interval { @@ -25,7 +26,11 @@ function pickWidestInterval(intervals: Interval[]): Interval { export function layoutTextInCompiledShape( options: LayoutTextInCompiledShapeOptions, ): ShapeTextLayout { - const prepared = prepareTextForLayout(options.text, options.font, options.measurer) + const resolvedTextStyle = resolveLayoutTextStyle({ + font: options.font, + textStyle: options.textStyle, + }) + const prepared = prepareTextForLayout(options.text, resolvedTextStyle.font, options.measurer) const autoFill = options.autoFill ?? false const align = options.align ?? 'left' const baselineRatio = options.baselineRatio ?? 0.8 @@ -64,7 +69,8 @@ export function layoutTextInCompiledShape( } return { - font: options.font, + font: resolvedTextStyle.font, + textStyle: resolvedTextStyle, lineHeight: options.compiledShape.bandHeight, shape: options.compiledShape.source, compiledShape: options.compiledShape, diff --git a/src/layout/layout-text-in-shape.test.ts b/src/layout/layout-text-in-shape.test.ts index c501baf..f5a6797 100644 --- a/src/layout/layout-text-in-shape.test.ts +++ b/src/layout/layout-text-in-shape.test.ts @@ -103,4 +103,31 @@ describe('layoutTextInShape', () => { expect(layout.autoFill).toBe(true) expect(layout.exhausted).toBe(false) }) + + it('normalizes textStyle into the resolved layout font and color', () => { + const layout = layoutTextInShape({ + text: 'xin chao', + textStyle: { + family: 'Test Sans', + size: 18, + weight: 700, + style: 'italic', + color: '#2563eb', + }, + lineHeight: 20, + shape: { + kind: 'polygon', + points: [ + { x: 0, y: 0 }, + { x: 120, y: 0 }, + { x: 120, y: 40 }, + { x: 0, y: 40 }, + ], + }, + measurer: createFixedWidthTextMeasurer(), + }) + + expect(layout.font).toBe('italic 700 18px Test Sans') + expect(layout.textStyle?.color).toBe('#2563eb') + }) }) diff --git a/src/layout/layout-text-in-shape.ts b/src/layout/layout-text-in-shape.ts index 245859b..529204f 100644 --- a/src/layout/layout-text-in-shape.ts +++ b/src/layout/layout-text-in-shape.ts @@ -9,9 +9,22 @@ export function layoutTextInShape(options: LayoutTextInShapeOptions): ShapeTextL minSlotWidth: options.minSlotWidth, }) + if (options.textStyle !== undefined) { + return layoutTextInCompiledShape({ + text: options.text, + font: options.font, + textStyle: options.textStyle, + compiledShape, + measurer: options.measurer, + align: options.align, + baselineRatio: options.baselineRatio, + autoFill: options.autoFill, + }) + } + return layoutTextInCompiledShape({ text: options.text, - font: options.font, + font: options.font!, compiledShape, measurer: options.measurer, align: options.align, diff --git a/src/render/normalize-shape-decoration.ts b/src/render/normalize-shape-decoration.ts new file mode 100644 index 0000000..cb3f1ab --- /dev/null +++ b/src/render/normalize-shape-decoration.ts @@ -0,0 +1,59 @@ +import type { + RenderLayoutToSvgOptions, + ResolvedShapeStyle, + ShapeShadowInput, +} from '../types.js' + +function assertFiniteNonNegative(value: number, message: string): void { + if (!Number.isFinite(value) || value < 0) { + throw new Error(message) + } +} + +function normalizeShadow(shadow: ShapeShadowInput | undefined): ResolvedShapeStyle['shadow'] { + if (shadow === undefined) { + return undefined + } + + assertFiniteNonNegative(shadow.blur, 'shapeStyle.shadow.blur must be a finite non-negative number') + + const offsetX = shadow.offsetX ?? 0 + const offsetY = shadow.offsetY ?? 0 + if (!Number.isFinite(offsetX) || !Number.isFinite(offsetY)) { + throw new Error('shapeStyle.shadow offsets must be finite numbers') + } + + return { + color: shadow.color ?? 'rgba(15, 23, 42, 0.24)', + blur: shadow.blur, + offsetX, + offsetY, + } +} + +export function normalizeShapeDecoration( + options: RenderLayoutToSvgOptions, +): ResolvedShapeStyle { + const explicitBorderWidth = options.shapeStyle?.borderWidth + if (explicitBorderWidth !== undefined) { + assertFiniteNonNegative( + explicitBorderWidth, + 'shapeStyle.borderWidth must be a finite non-negative number', + ) + } + + const borderWidth = + explicitBorderWidth ?? + (options.shapeStyle?.borderColor !== undefined + ? 1 + : options.shapeStyle === undefined && (options.shapeStroke !== undefined || options.showShape === true) + ? 1 + : 0) + + return { + backgroundColor: options.shapeStyle?.backgroundColor ?? options.shapeFill, + borderColor: options.shapeStyle?.borderColor ?? options.shapeStroke ?? '#d1d5db', + borderWidth, + shadow: normalizeShadow(options.shapeStyle?.shadow), + } +} diff --git a/src/render/render-layout-to-svg.test.ts b/src/render/render-layout-to-svg.test.ts index 54e5a9c..1a37e3b 100644 --- a/src/render/render-layout-to-svg.test.ts +++ b/src/render/render-layout-to-svg.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { layoutTextInShape } from '../layout/layout-text-in-shape.js' import { renderLayoutToSvg } from './render-layout-to-svg.js' -import type { TextMeasurer } from '../types.js' +import type { ShapeTextLayout, TextMeasurer } from '../types.js' const measurer: TextMeasurer = { measureText(text) { @@ -35,4 +35,187 @@ describe('renderLayoutToSvg', () => { expect(svg).toContain(' { + const layout = layoutTextInShape({ + text: 'shape text', + textStyle: { + family: 'Test Sans', + size: 20, + weight: 700, + style: 'italic', + color: '#dc2626', + }, + lineHeight: 20, + shape: { + kind: 'polygon', + points: [ + { x: 0, y: 0 }, + { x: 200, y: 0 }, + { x: 200, y: 40 }, + { x: 0, y: 40 }, + ], + }, + measurer, + }) + + const svg = renderLayoutToSvg(layout, { + shapeStyle: { + backgroundColor: '#fef3c7', + borderColor: '#f59e0b', + borderWidth: 4, + shadow: { + color: 'rgba(15, 23, 42, 0.25)', + blur: 6, + offsetX: 2, + offsetY: 3, + }, + }, + }) + + expect(svg).toContain('fill="#dc2626"') + expect(svg).toContain('stroke="#f59e0b"') + expect(svg).toContain('stroke-width="4"') + expect(svg).toContain('fill="#fef3c7"') + expect(svg).toContain(' { + const layout = layoutTextInShape({ + text: 'override', + textStyle: { + family: 'Test Sans', + size: 16, + color: '#2563eb', + }, + lineHeight: 20, + shape: { + kind: 'polygon', + points: [ + { x: 0, y: 0 }, + { x: 200, y: 0 }, + { x: 200, y: 40 }, + { x: 0, y: 40 }, + ], + }, + measurer, + }) + + const svg = renderLayoutToSvg(layout, { + textFill: '#16a34a', + }) + + expect(svg).toContain('fill="#16a34a"') + expect(svg).not.toContain('fill="#2563eb"') + }) + + it('does not inject a border when shapeStyle only sets background color', () => { + const layout = layoutTextInShape({ + text: 'fill only', + font: '16px Test Sans', + lineHeight: 20, + shape: { + kind: 'polygon', + points: [ + { x: 0, y: 0 }, + { x: 200, y: 0 }, + { x: 200, y: 40 }, + { x: 0, y: 40 }, + ], + }, + measurer, + }) + + const svg = renderLayoutToSvg(layout, { + shapeStyle: { + backgroundColor: '#fef3c7', + }, + }) + + expect(svg).toContain('stroke-width="0"') + expect(svg).toContain('fill="#fef3c7"') + }) + + it('expands the viewport for borders and shadows without manual padding', () => { + const layout = layoutTextInShape({ + text: 'shadow', + font: '16px Test Sans', + lineHeight: 20, + shape: { + kind: 'polygon', + points: [ + { x: 0, y: 0 }, + { x: 200, y: 0 }, + { x: 200, y: 40 }, + { x: 0, y: 40 }, + ], + }, + measurer, + }) + + const svg = renderLayoutToSvg(layout, { + padding: 0, + shapeStyle: { + borderColor: '#0f172a', + borderWidth: 8, + shadow: { + blur: 10, + offsetX: 6, + offsetY: 4, + }, + }, + }) + + expect(svg).toContain('viewBox="-34 -34 274 112"') + }) + + it('renders a legacy layout object without textStyle metadata', () => { + const svg = renderLayoutToSvg({ + font: '16px Test Sans', + lineHeight: 20, + shape: { + kind: 'polygon', + points: [ + { x: 0, y: 0 }, + { x: 200, y: 0 }, + { x: 200, y: 40 }, + { x: 0, y: 40 }, + ], + }, + compiledShape: { + kind: 'polygon', + source: { + kind: 'polygon', + points: [ + { x: 0, y: 0 }, + { x: 200, y: 0 }, + { x: 200, y: 40 }, + { x: 0, y: 40 }, + ], + }, + bounds: { left: 0, top: 0, right: 200, bottom: 40 }, + bandHeight: 20, + minSlotWidth: 16, + bands: [], + debugView: { + kind: 'polygon', + points: [ + { x: 0, y: 0 }, + { x: 200, y: 0 }, + { x: 200, y: 40 }, + { x: 0, y: 40 }, + ], + }, + }, + bounds: { left: 0, top: 0, right: 200, bottom: 40 }, + lines: [], + exhausted: true, + autoFill: false, + } satisfies ShapeTextLayout) + + expect(svg).toContain(' `${point.x},${point.y}`).join(' ') } -function renderDebugShape( +function getEffectPadding(options: { + strokeWidth: number + shadow?: { + blur: number + offsetX: number + offsetY: number + } +}) { + const strokePadding = options.strokeWidth / 2 + const shadowBlurPadding = options.shadow === undefined ? 0 : options.shadow.blur * 3 + + return { + left: + strokePadding + + (options.shadow === undefined ? 0 : shadowBlurPadding + Math.max(0, -options.shadow.offsetX)), + right: + strokePadding + + (options.shadow === undefined ? 0 : shadowBlurPadding + Math.max(0, options.shadow.offsetX)), + top: + strokePadding + + (options.shadow === undefined ? 0 : shadowBlurPadding + Math.max(0, -options.shadow.offsetY)), + bottom: + strokePadding + + (options.shadow === undefined ? 0 : shadowBlurPadding + Math.max(0, options.shadow.offsetY)), + } +} + +function renderShapeAttributes( debugView: CompiledShapeDebugView, - shapeFill: string, - shapeStroke: string, + options: { + fill: string + stroke: string + strokeWidth: number + filterUrl?: string + }, ): string { + const filter = options.filterUrl === undefined ? '' : ` filter="${options.filterUrl}"` + const fill = escapeXmlText(options.fill) + const stroke = escapeXmlText(options.stroke) + if (debugView.kind === 'polygon') { - return `` + return `` } - return `${escapeXmlText(debugView.text)}` + return `${escapeXmlText(debugView.text)}` } export function renderLayoutToSvg( layout: ShapeTextLayout, options: RenderLayoutToSvgOptions = {}, ): string { - const padding = options.padding ?? 0 - const width = layout.bounds.right - layout.bounds.left + padding * 2 - const height = layout.bounds.bottom - layout.bounds.top + padding * 2 - const viewBoxLeft = layout.bounds.left - padding - const viewBoxTop = layout.bounds.top - padding - const textFill = options.textFill ?? '#111827' - const shapeStroke = options.shapeStroke ?? '#d1d5db' - const shapeFill = options.shapeFill ?? 'none' + const userPadding = options.padding ?? 0 + const textFill = options.textFill ?? layout.textStyle?.color ?? '#111827' + const shapeStyle = normalizeShapeDecoration(options) const background = options.background - const showShape = options.showShape ?? false + const hasVisibleCanonicalShapeDecoration = + options.shapeStyle !== undefined && + (shapeStyle.backgroundColor !== undefined || + shapeStyle.borderWidth > 0 || + shapeStyle.shadow !== undefined) + const showShape = options.showShape ?? hasVisibleCanonicalShapeDecoration + const effectPadding = showShape + ? getEffectPadding({ + strokeWidth: shapeStyle.borderWidth, + shadow: shapeStyle.shadow, + }) + : { left: 0, right: 0, top: 0, bottom: 0 } + const totalPadding = { + left: userPadding + effectPadding.left, + right: userPadding + effectPadding.right, + top: userPadding + effectPadding.top, + bottom: userPadding + effectPadding.bottom, + } + const width = layout.bounds.right - layout.bounds.left + totalPadding.left + totalPadding.right + const height = layout.bounds.bottom - layout.bounds.top + totalPadding.top + totalPadding.bottom + const viewBoxLeft = layout.bounds.left - totalPadding.left + const viewBoxTop = layout.bounds.top - totalPadding.top + const shadowFilter = + showShape && shapeStyle.shadow !== undefined + ? createSvgShadowFilter(shapeStyle.shadow, { + x: viewBoxLeft, + y: viewBoxTop, + width, + height, + }) + : undefined const pieces = [ ``, ] + if (shadowFilter !== undefined) { + pieces.push(shadowFilter.markup) + } + if (background !== undefined) { - pieces.push(``) + pieces.push( + ``, + ) } if (showShape) { - pieces.push(renderDebugShape(layout.compiledShape.debugView, shapeFill, shapeStroke)) + pieces.push( + renderShapeAttributes(layout.compiledShape.debugView, { + fill: shapeStyle.backgroundColor ?? 'none', + stroke: shapeStyle.borderColor, + strokeWidth: shapeStyle.borderWidth, + filterUrl: shadowFilter === undefined ? undefined : `url(#${shadowFilter.filterId})`, + }), + ) } for (let index = 0; index < layout.lines.length; index++) { const line = layout.lines[index]! pieces.push( - `${escapeXmlText(line.text)}`, + `${escapeXmlText(line.text)}`, ) } diff --git a/src/render/render-svg-shadow-filter.ts b/src/render/render-svg-shadow-filter.ts new file mode 100644 index 0000000..bfb26ec --- /dev/null +++ b/src/render/render-svg-shadow-filter.ts @@ -0,0 +1,32 @@ +import type { ResolvedShapeShadow } from '../types.js' +import { escapeXmlText } from './escape-xml-text.js' + +function hashString(value: string): string { + let hash = 0 + + for (let index = 0; index < value.length; index++) { + hash = (hash * 31 + value.charCodeAt(index)) >>> 0 + } + + return hash.toString(36) +} + +export function createSvgShadowFilter( + shadow: ResolvedShapeShadow, + filterRegion: { + x: number + y: number + width: number + height: number + }, +): { + filterId: string + markup: string +} { + const filterId = `shape-text-shape-shadow-${hashString(JSON.stringify(shadow))}` + + return { + filterId, + markup: ``, + } +} diff --git a/src/text/normalize-text-style-to-font.test.ts b/src/text/normalize-text-style-to-font.test.ts new file mode 100644 index 0000000..3b508c3 --- /dev/null +++ b/src/text/normalize-text-style-to-font.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' + +import { normalizeTextStyleToFont, resolveLayoutTextStyle } from './normalize-text-style-to-font.js' + +describe('normalizeTextStyleToFont', () => { + it('builds a canonical CSS font string from text style input', () => { + expect( + normalizeTextStyleToFont({ + family: '"Helvetica Neue", Arial, sans-serif', + size: 18, + weight: 700, + style: 'italic', + color: '#0f172a', + }), + ).toEqual({ + family: '"Helvetica Neue", Arial, sans-serif', + size: 18, + weight: 700, + style: 'italic', + color: '#0f172a', + font: 'italic 700 18px "Helvetica Neue", Arial, sans-serif', + }) + }) + + it('uses the new textStyle API over the legacy font string', () => { + const resolved = resolveLayoutTextStyle({ + font: '16px Old Font', + textStyle: { + family: 'Test Sans', + size: 24, + style: 'oblique', + }, + }) + + expect(resolved.font).toBe('oblique 400 24px Test Sans') + }) + + it('rejects missing font inputs', () => { + expect(() => resolveLayoutTextStyle({})).toThrow('font or textStyle') + }) +}) diff --git a/src/text/normalize-text-style-to-font.ts b/src/text/normalize-text-style-to-font.ts new file mode 100644 index 0000000..63563b8 --- /dev/null +++ b/src/text/normalize-text-style-to-font.ts @@ -0,0 +1,41 @@ +import type { ResolvedTextStyle, TextStyleInput } from '../types.js' + +function assertFinitePositiveSize(size: number): void { + if (!Number.isFinite(size) || size <= 0) { + throw new Error('textStyle.size must be a finite positive number') + } +} + +export function normalizeTextStyleToFont(textStyle: TextStyleInput): ResolvedTextStyle { + if (textStyle.family.trim().length === 0) { + throw new Error('textStyle.family must be a non-empty string') + } + + assertFinitePositiveSize(textStyle.size) + + return { + family: textStyle.family, + size: textStyle.size, + weight: textStyle.weight ?? 400, + style: textStyle.style ?? 'normal', + color: textStyle.color, + font: `${textStyle.style ?? 'normal'} ${String(textStyle.weight ?? 400)} ${textStyle.size}px ${textStyle.family}`, + } +} + +export function resolveLayoutTextStyle(options: { + font?: string + textStyle?: TextStyleInput +}): ResolvedTextStyle { + if (options.textStyle !== undefined) { + return normalizeTextStyleToFont(options.textStyle) + } + + if (options.font !== undefined && options.font.trim().length > 0) { + return { + font: options.font, + } + } + + throw new Error('layout text requires either font or textStyle') +} diff --git a/src/types.ts b/src/types.ts index 0fccc0e..0e6fca7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -30,6 +30,51 @@ export type TextMeasurer = { measureText(text: string, font: string): number } +export type TextStyleInput = { + family: string + size: number + weight?: number | string + style?: 'normal' | 'italic' | 'oblique' + color?: string +} + +export type ResolvedTextStyle = { + font: string + family?: string + size?: number + weight?: number | string + style?: 'normal' | 'italic' | 'oblique' + color?: string +} + +export type ShapeShadowInput = { + color?: string + blur: number + offsetX?: number + offsetY?: number +} + +export type ResolvedShapeShadow = { + color: string + blur: number + offsetX: number + offsetY: number +} + +export type ShapeStyleInput = { + backgroundColor?: string + borderColor?: string + borderWidth?: number + shadow?: ShapeShadowInput +} + +export type ResolvedShapeStyle = { + backgroundColor?: string + borderColor: string + borderWidth: number + shadow?: ResolvedShapeShadow +} + export type PreparedLayoutToken = { kind: 'word' | 'newline' text: string @@ -101,6 +146,7 @@ export type CompiledShapeBands = { export type ShapeTextLayout = { font: string + textStyle?: ResolvedTextStyle lineHeight: number shape: ShapeInput compiledShape: CompiledShapeBands @@ -118,17 +164,24 @@ export type CompileShapeForLayoutOptions = { export type LayoutTextInCompiledShapeOptions = { text: string - font: string compiledShape: CompiledShapeBands measurer: TextMeasurer align?: 'left' | 'center' baselineRatio?: number autoFill?: boolean -} +} & ( + | { + font: string + textStyle?: TextStyleInput + } + | { + font?: string + textStyle: TextStyleInput + } +) export type LayoutTextInShapeOptions = { text: string - font: string lineHeight: number shape: ShapeInput measurer: TextMeasurer @@ -136,7 +189,16 @@ export type LayoutTextInShapeOptions = { minSlotWidth?: number baselineRatio?: number autoFill?: boolean -} +} & ( + | { + font: string + textStyle?: TextStyleInput + } + | { + font?: string + textStyle: TextStyleInput + } +) export type RenderLayoutToSvgOptions = { padding?: number @@ -144,5 +206,6 @@ export type RenderLayoutToSvgOptions = { textFill?: string shapeStroke?: string shapeFill?: string + shapeStyle?: ShapeStyleInput showShape?: boolean } From 095528a6691315f7108f0d200d5f92bbc923f74d Mon Sep 17 00:00:00 2001 From: tqdat410 Date: Wed, 1 Apr 2026 11:06:10 +0700 Subject: [PATCH 02/12] docs: document style and decoration API Document textStyle and shapeStyle usage, architecture notes, and changelog updates for the new rendering options. Co-authored-by: Codex --- README.md | 30 +++++++++++++++++++++++++----- docs/code-standards.md | 2 ++ docs/project-changelog.md | 7 +++++++ docs/project-overview-pdr.md | 6 ++++-- docs/system-architecture.md | 4 +++- 5 files changed, 41 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index a20586f..ecfa224 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,13 @@ const measurer = createCanvasTextMeasurer() const layout = layoutTextInShape({ text: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', - font: '16px "Helvetica Neue", Arial, sans-serif', + textStyle: { + family: '"Helvetica Neue", Arial, sans-serif', + size: 16, + weight: 700, + style: 'italic', + color: '#111827', + }, lineHeight: 22, shape: { kind: 'polygon', @@ -47,9 +53,15 @@ const layout = layoutTextInShape({ const svg = renderLayoutToSvg(layout, { background: '#fffdf7', - textFill: '#111827', - shapeStroke: '#d1d5db', - showShape: true, + shapeStyle: { + backgroundColor: '#dbeafe', + borderColor: '#94a3b8', + borderWidth: 2, + shadow: { + blur: 6, + offsetY: 6, + }, + }, }) ``` @@ -58,7 +70,12 @@ const svg = renderLayoutToSvg(layout, { ```ts const layout = layoutTextInShape({ text: 'ONE', - font: '16px Arial', + textStyle: { + family: 'Arial, sans-serif', + size: 16, + weight: 700, + color: '#0f172a', + }, lineHeight: 20, autoFill: true, shape: { @@ -77,6 +94,7 @@ const layout = layoutTextInShape({ - `createCanvasTextMeasurer()` - `compileShapeForLayout()` +- `normalizeTextStyleToFont()` - `prepareTextForLayout()` - `layoutNextLineFromPreparedText()` - `layoutNextLineFromRepeatedText()` @@ -91,6 +109,8 @@ const layout = layoutTextInShape({ - The project takes inspiration from `pretext` for the `prepare -> layout` split and streaming line iteration, but owns its geometry, slot policy, and public API. - `text-mask` shapes are raster-compiled into reusable line bands. This is the default path for browser fonts such as `Arial`, and it is designed so callers can precompile `0-9` and `:` for clock-like UIs. - `autoFill: true` repeats the source text until the available shape bands are full. +- `textStyle` is the new data-driven API for size, weight, italic/oblique, family, and default text color. Legacy `font` string input still works. +- `shapeStyle` lives in `renderLayoutToSvg()` because fill, border, and shadow do not affect line breaking or shape compilation. - For late-loading web fonts, compile after the font is ready if you want immediate cache reuse. The compiler skips cache writes until `document.fonts.check()` reports the font as ready. ## Local E2E diff --git a/docs/code-standards.md b/docs/code-standards.md index 10da2fc..904c658 100644 --- a/docs/code-standards.md +++ b/docs/code-standards.md @@ -14,6 +14,8 @@ - V1 is browser-first - Advanced i18n fidelity can be added later without breaking the public shape-first API - Keep shape compilation separate from content flow so hot paths can reuse cached bands +- Keep text formatting data-driven; do not pass framework components into the core API +- Keep shape decoration renderer-only unless it changes geometry ## Testing Rules diff --git a/docs/project-changelog.md b/docs/project-changelog.md index d7a0af5..40cff79 100644 --- a/docs/project-changelog.md +++ b/docs/project-changelog.md @@ -14,3 +14,10 @@ - Added compiler guards for invalid alpha thresholds and oversized raster requests - Added cache safety for late-loading fonts and frozen compiled shapes - Added Vitest coverage support and direct text-mask compiler tests + +## 2026-04-01 + +- Added structured `textStyle` API for text size, family, weight, style, and default color +- Added renderer-side `shapeStyle` API for shape fill, border, and shadow +- Kept legacy `font`, `textFill`, `shapeFill`, and `shapeStroke` compatibility paths +- Extended local demo and Playwright coverage for style and decoration controls diff --git a/docs/project-overview-pdr.md b/docs/project-overview-pdr.md index 9a10d4b..e5d0701 100644 --- a/docs/project-overview-pdr.md +++ b/docs/project-overview-pdr.md @@ -14,6 +14,8 @@ Web CSS can wrap text around floated shapes, but it does not provide a solid, po - Accept text-mask input from glyph text and font - Measure and stream paragraph lines through shape bands - Support repeat-fill mode for dense glyph fills +- Support structured text formatting params for size, family, weight, style, and color +- Support renderer-only shape decoration for fill, border, and shadow - Return deterministic line geometry - Render to SVG - Support Latin/Vietnamese first @@ -28,9 +30,9 @@ Web CSS can wrap text around floated shapes, but it does not provide a solid, po ## Acceptance Criteria -- A caller can pass text, font, line height, shape input, and measurer +- A caller can pass text, text formatting, line height, shape input, and measurer - The library can compile reusable shape bands for polygon and text-mask shapes - The library can repeat source text until the target shape bands are full - The library returns line positions inside the target shape -- The library renders those lines to SVG +- The library renders those lines to SVG with optional shape decoration - Build and tests pass diff --git a/docs/system-architecture.md b/docs/system-architecture.md index 7eec384..a90202a 100644 --- a/docs/system-architecture.md +++ b/docs/system-architecture.md @@ -10,7 +10,7 @@ ## Data Flow -1. Normalize and prepare text +1. Normalize text formatting into a canonical font string 2. Compile the input shape into reusable line bands 3. Compute allowed intervals for each band 4. Pick the widest interval @@ -21,6 +21,8 @@ ## Boundary Decisions - Text measurement stays replaceable through `TextMeasurer` +- Layout-affecting text style stays in the layout API - Shape compilation stays separate from content flow so glyph shapes can be cached - Geometry stays shape-specific, not DOM-specific +- Renderer-only decoration stays out of compile/layout caching - Renderer consumes compiled layout output only From 9be89c6a2540df9adca0b04ce21774a62aaa6676 Mon Sep 17 00:00:00 2001 From: tqdat410 Date: Wed, 1 Apr 2026 11:29:29 +0700 Subject: [PATCH 03/12] fix(demo): preserve editable autofill text state Keep glyph autofill text editable across rerenders and scenario switches, and add Playwright coverage for the demo state regressions. Co-authored-by: Codex --- e2e/fixtures/index.html | 2 +- e2e/fixtures/shape-text-e2e-app.js | 18 ++++++++- e2e/shape-text-local-e2e.spec.ts | 62 ++++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 3 deletions(-) diff --git a/e2e/fixtures/index.html b/e2e/fixtures/index.html index a5de73d..fe10740 100644 --- a/e2e/fixtures/index.html +++ b/e2e/fixtures/index.html @@ -220,7 +220,7 @@

shape-text demo

-

Use the shape buttons to compare flow behavior. The glyph scenario uses a real text mask and repeats ONE until the shape is full.

+

Use the shape buttons to compare flow behavior. The glyph scenario uses a real text mask and repeats whatever text is currently in the editor until the shape is full.

diff --git a/e2e/fixtures/shape-text-e2e-app.js b/e2e/fixtures/shape-text-e2e-app.js index 70b346b..c649833 100644 --- a/e2e/fixtures/shape-text-e2e-app.js +++ b/e2e/fixtures/shape-text-e2e-app.js @@ -119,6 +119,7 @@ const state = { shapeBorderColor: '#94a3b8', shapeShadow: true, } +let hasUserEditedText = false function getTextStyle() { return { @@ -146,11 +147,19 @@ function getShapeStyle() { } } -function renderScenario(name) { +function resolveScenarioText(name) { const scenario = scenarios[name] if (!scenario) throw new Error(`Unknown scenario: ${name}`) - state.text = scenario.text ?? state.text + if (!hasUserEditedText && scenario.text !== undefined) { + state.text = scenario.text + } + + return scenario +} + +function renderScenario(name) { + const scenario = resolveScenarioText(name) const layout = layoutTextInShape({ text: state.text, @@ -276,6 +285,11 @@ renderButton.addEventListener('click', () => { renderScenario(state.scenario || 'digit-two-wide') }) +textInput.addEventListener('input', () => { + state.text = textInput.value + hasUserEditedText = true +}) + lineHeightInput.addEventListener('input', () => { lineHeightValue.textContent = lineHeightInput.value }) diff --git a/e2e/shape-text-local-e2e.spec.ts b/e2e/shape-text-local-e2e.spec.ts index d82443b..bff3a6f 100644 --- a/e2e/shape-text-local-e2e.spec.ts +++ b/e2e/shape-text-local-e2e.spec.ts @@ -128,6 +128,68 @@ test('applies text and shape style controls to the rendered SVG', async ({ page expect(state.svg).toContain('shape-text-shape-shadow') }) +test('preserves edited glyph autofill text without forcing uppercase', async ({ page }) => { + await page.goto('/') + + await page.locator('#text-input').fill('one') + await page.locator('#render-button').click() + + const state = await getState(page) + + await expect(page.locator('#text-input')).toHaveValue('one') + expect(state.text).toBe('one') + expect(state.layout.lines[0]?.text).toContain('one') + expect(state.layout.lines[0]?.text).not.toContain('ONE') + expect(state.svg).toContain('>one<') +}) + +test('keeps edited text when switching away from and back to the glyph scenario', async ({ + page, +}) => { + await page.goto('/') + + await page.locator('#text-input').fill('one') + await page.locator('#render-button').click() + await page.evaluate(() => window.shapeTextTestApi.renderScenario('rectangle-wide')) + await page.evaluate(() => window.shapeTextTestApi.renderScenario('glyph-two-repeat')) + + const state = await getState(page) + + await expect(page.locator('#text-input')).toHaveValue('one') + expect(state.scenario).toBe('glyph-two-repeat') + expect(state.text).toBe('one') + expect(state.layout.lines[0]?.text).toContain('one') + expect(state.layout.lines[0]?.text).not.toContain('ONE') +}) + +test('keeps unsaved textarea edits when switching scenarios', async ({ page }) => { + await page.goto('/') + + await page.locator('#text-input').fill('draft') + await page.evaluate(() => window.shapeTextTestApi.renderScenario('rectangle-wide')) + + const state = await getState(page) + + await expect(page.locator('#text-input')).toHaveValue('draft') + expect(state.scenario).toBe('rectangle-wide') + expect(state.text).toBe('draft') + expect(state.layout.lines[0]?.text).toContain('draft') +}) + +test('keeps scenario defaults when rerender changes only style controls', async ({ page }) => { + await page.goto('/') + + await setRangeValue(page, '#text-size-input', '24') + await page.locator('#render-button').click() + await page.evaluate(() => window.shapeTextTestApi.renderScenario('rectangle-wide')) + + const state = await getState(page) + + expect(state.scenario).toBe('rectangle-wide') + expect(state.text).toContain('Shape text lets a paragraph travel inside a silhouette') + expect(state.layout.lines[0]?.text).toContain('Shape text') +}) + test('reflows into more lines in the narrow rectangle scenario', async ({ page }) => { await page.goto('/') From 12616b3a66dcf667f56b56f79857ebb77d6d2746 Mon Sep 17 00:00:00 2001 From: tqdat410 Date: Wed, 1 Apr 2026 12:57:10 +0700 Subject: [PATCH 04/12] feat(layout): add max-fill stream strategy Co-authored-by: Codex --- e2e/fixtures/index.html | 10 +- e2e/fixtures/shape-text-e2e-app.js | 26 +++- e2e/shape-text-local-e2e.spec.ts | 46 ++++++ src/index.ts | 2 + src/layout/layout-dense-fill-pass.ts | 106 +++++++++++++ src/layout/layout-text-in-compiled-shape.ts | 81 +--------- src/layout/layout-text-in-shape.test.ts | 144 ++++++++++++++++++ src/layout/layout-text-in-shape.ts | 18 ++- src/layout/resolve-flow-layout.ts | 122 +++++++++++++++ src/layout/resolve-max-fill-layout.ts | 68 +++++++++ src/render/render-layout-to-svg.test.ts | 76 +++++++++ src/render/render-layout-to-svg.ts | 2 +- ...yout-next-line-from-dense-repeated-text.ts | 85 +++++++++++ src/text/prepare-dense-repeat-fill-pattern.ts | 21 +++ .../prepare-stream-repeat-fill-pattern.ts | 25 +++ src/types.ts | 11 ++ 16 files changed, 766 insertions(+), 77 deletions(-) create mode 100644 src/layout/layout-dense-fill-pass.ts create mode 100644 src/layout/resolve-flow-layout.ts create mode 100644 src/layout/resolve-max-fill-layout.ts create mode 100644 src/text/layout-next-line-from-dense-repeated-text.ts create mode 100644 src/text/prepare-dense-repeat-fill-pattern.ts create mode 100644 src/text/prepare-stream-repeat-fill-pattern.ts diff --git a/e2e/fixtures/index.html b/e2e/fixtures/index.html index fe10740..7a2457a 100644 --- a/e2e/fixtures/index.html +++ b/e2e/fixtures/index.html @@ -179,6 +179,14 @@

shape-text demo

Text color +
-

Use the shape buttons to compare flow behavior. The glyph scenario uses a real text mask and repeats whatever text is currently in the editor until the shape is full.

+

Use the shape buttons to compare flow behavior. The glyph scenario uses a real text mask and can repeat by words, dense grapheme flow, or max fill with extra slot coverage while still keeping spaces in the text stream.

diff --git a/e2e/fixtures/shape-text-e2e-app.js b/e2e/fixtures/shape-text-e2e-app.js index c649833..4473dac 100644 --- a/e2e/fixtures/shape-text-e2e-app.js +++ b/e2e/fixtures/shape-text-e2e-app.js @@ -15,6 +15,7 @@ const textSizeValue = document.querySelector('#text-size-value') const textWeightSelect = document.querySelector('#text-weight-select') const textItalicInput = document.querySelector('#text-italic-input') const textColorInput = document.querySelector('#text-color-input') +const autoFillModeSelect = document.querySelector('#auto-fill-mode-select') const shapeFillInput = document.querySelector('#shape-fill-input') const shapeBorderWidthInput = document.querySelector('#shape-border-width-input') const shapeBorderWidthValue = document.querySelector('#shape-border-width-value') @@ -113,6 +114,8 @@ const state = { textWeight: 700, textItalic: false, textColor: '#111827', + autoFillMode: 'words', + fillStrategy: 'flow', showShape: true, shapeFill: '#dbeafe', shapeBorderWidth: 2, @@ -121,6 +124,21 @@ const state = { } let hasUserEditedText = false +function getFillSelectValue() { + return state.fillStrategy === 'max' ? 'max' : state.autoFillMode +} + +function applyFillSelection(value) { + if (value === 'max') { + state.autoFillMode = 'stream' + state.fillStrategy = 'max' + return + } + + state.autoFillMode = value + state.fillStrategy = 'flow' +} + function getTextStyle() { return { family: '"Helvetica Neue", Arial, sans-serif', @@ -167,8 +185,10 @@ function renderScenario(name) { lineHeight: state.lineHeight, shape: scenario.shape, measurer, - minSlotWidth: 24, + minSlotWidth: state.fillStrategy === 'max' ? 8 : 24, autoFill: scenario.autoFill, + autoFillMode: state.autoFillMode, + fillStrategy: state.fillStrategy, }) const svg = renderLayoutToSvg(layout, { @@ -188,6 +208,8 @@ function renderScenario(name) { scenario: name, shapeKind: scenario.shape.kind, autoFill: Boolean(scenario.autoFill), + autoFillMode: layout.autoFillMode, + fillStrategy: layout.fillStrategy, lineHeight: state.lineHeight, textStyle: getTextStyle(), shapeStyle: getShapeStyle(), @@ -259,6 +281,7 @@ function syncControls() { textWeightSelect.value = String(state.textWeight) textItalicInput.checked = state.textItalic textColorInput.value = state.textColor + autoFillModeSelect.value = getFillSelectValue() showShapeInput.checked = state.showShape shapeFillInput.value = state.shapeFill shapeBorderWidthInput.value = String(state.shapeBorderWidth) @@ -274,6 +297,7 @@ renderButton.addEventListener('click', () => { state.textWeight = Number(textWeightSelect.value) state.textItalic = textItalicInput.checked state.textColor = textColorInput.value + applyFillSelection(autoFillModeSelect.value) state.showShape = showShapeInput.checked state.shapeFill = shapeFillInput.value state.shapeBorderWidth = Number(shapeBorderWidthInput.value) diff --git a/e2e/shape-text-local-e2e.spec.ts b/e2e/shape-text-local-e2e.spec.ts index bff3a6f..38747a4 100644 --- a/e2e/shape-text-local-e2e.spec.ts +++ b/e2e/shape-text-local-e2e.spec.ts @@ -15,12 +15,16 @@ declare global { scenario: string layout: { autoFill: boolean + autoFillMode: 'words' | 'dense' | 'stream' + fillStrategy: 'flow' | 'max' exhausted: boolean lines: Array<{ text: string x: number + top: number width: number slot: { left: number; right: number } + fillPass?: 1 | 2 }> } text: string @@ -28,6 +32,8 @@ declare global { textWeight: number textItalic: boolean textColor: string + autoFillMode: 'words' | 'dense' | 'stream' + fillStrategy: 'flow' | 'max' showShape: boolean shapeFill: string shapeBorderWidth: number @@ -69,6 +75,8 @@ test('renders the glyph text-mask fixture into SVG', async ({ page }) => { expect(state.text).toBe('ONE') expect(state.layout.lines.length).toBeGreaterThan(4) expect(state.layout.autoFill).toBe(true) + expect(state.layout.autoFillMode).toBe('words') + expect(state.layout.fillStrategy).toBe('flow') expect(state.layout.exhausted).toBe(false) expect(state.layout.lines[0]?.text).toContain('ONE') expect(state.textSize).toBe(18) @@ -128,6 +136,44 @@ test('applies text and shape style controls to the rendered SVG', async ({ page expect(state.svg).toContain('shape-text-shape-shadow') }) +test('packs glyph autofill more tightly in dense mode', async ({ page }) => { + await page.goto('/') + + const wordsState = await getState(page) + + await page.locator('#auto-fill-mode-select').selectOption('dense') + await page.locator('#render-button').click() + + const denseState = await getState(page) + + expect(wordsState.layout.autoFillMode).toBe('words') + expect(denseState.layout.autoFillMode).toBe('dense') + expect(denseState.autoFillMode).toBe('dense') + expect(denseState.layout.lines[0]?.text).not.toContain(' ') + expect(denseState.layout.lines[0]?.width).toBeGreaterThan(wordsState.layout.lines[0]?.width ?? 0) +}) + +test('keeps spaces and fills multiple slots in max mode without mini-font fallback', async ({ page }) => { + await page.goto('/') + + await page.locator('#text-input').fill('ONE ONE') + await page.locator('#auto-fill-mode-select').selectOption('dense') + await page.locator('#render-button').click() + const denseState = await getState(page) + + await page.locator('#auto-fill-mode-select').selectOption('max') + await page.locator('#render-button').click() + const maxState = await getState(page) + + expect(maxState.fillStrategy).toBe('max') + expect(maxState.layout.fillStrategy).toBe('max') + expect(maxState.layout.autoFillMode).toBe('stream') + expect(maxState.layout.lines.some(line => line.text.includes(' '))).toBe(true) + expect(maxState.layout.lines.some((line, index, lines) => lines.some(other => other !== line && other.top === line.top))).toBe(true) + expect(maxState.layout.lines.length).toBeGreaterThanOrEqual(denseState.layout.lines.length) + expect(maxState.svg).not.toMatch(/font:[^"]*12\./) +}) + test('preserves edited glyph autofill text without forcing uppercase', async ({ page }) => { await page.goto('/') diff --git a/src/index.ts b/src/index.ts index d467df8..ed87596 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,9 @@ export type { + AutoFillMode, CompiledShapeBand, CompiledShapeBands, CompileShapeForLayoutOptions, + FillStrategy, Interval, LayoutCursor, LayoutLineRange, diff --git a/src/layout/layout-dense-fill-pass.ts b/src/layout/layout-dense-fill-pass.ts new file mode 100644 index 0000000..94eab90 --- /dev/null +++ b/src/layout/layout-dense-fill-pass.ts @@ -0,0 +1,106 @@ +import type { CompiledShapeBands, Interval, PreparedLayoutToken, ShapeTextLine } from '../types.js' +import { layoutNextLineFromDenseRepeatedText } from '../text/layout-next-line-from-dense-repeated-text.js' + +export type DenseFillOccupiedRect = { + left: number + right: number + top: number + bottom: number +} + +type LayoutDenseFillPassOptions = { + compiledShape: CompiledShapeBands + densePattern: PreparedLayoutToken + startOffset: number + align: 'left' | 'center' + baselineRatio: number + allSlots: boolean + font?: string + fillPass?: 1 | 2 +} + +function pickWidestInterval(intervals: Interval[]): Interval { + let best = intervals[0]! + + for (let index = 1; index < intervals.length; index++) { + const candidate = intervals[index]! + if (candidate.right - candidate.left > best.right - best.left) { + best = candidate + } + } + + return best +} + +function getOrderedSlots(intervals: Interval[], allSlots: boolean): Interval[] { + if (intervals.length === 0) { + return [] + } + + if (!allSlots) { + return [pickWidestInterval(intervals)] + } + + return [...intervals].sort((left, right) => left.left - right.left) +} + +export function layoutDenseFillPass(options: LayoutDenseFillPassOptions): { + lines: ShapeTextLine[] + endOffset: number + occupiedRects: DenseFillOccupiedRect[] +} { + const lines: ShapeTextLine[] = [] + const occupiedRects: DenseFillOccupiedRect[] = [] + let offset = options.startOffset + + for (let bandIndex = 0; bandIndex < options.compiledShape.bands.length; bandIndex++) { + const band = options.compiledShape.bands[bandIndex]! + const slots = getOrderedSlots(band.intervals, options.allSlots) + + for (let slotIndex = 0; slotIndex < slots.length; slotIndex++) { + const slot = slots[slotIndex]! + const line = layoutNextLineFromDenseRepeatedText( + options.densePattern, + offset, + slot.right - slot.left, + ) + + if (line === null) { + continue + } + + const x = + options.align === 'center' + ? slot.left + Math.max(0, (slot.right - slot.left - line.width) / 2) + : slot.left + const occupiedRight = Math.min(slot.right, x + Math.max(0, line.width)) + + lines.push({ + ...line, + x, + top: band.top, + baseline: band.top + options.compiledShape.bandHeight * options.baselineRatio, + slot, + font: options.font, + fillPass: options.fillPass, + }) + + if (occupiedRight > x) { + occupiedRects.push({ + left: x, + right: occupiedRight, + top: band.top, + bottom: band.bottom, + }) + } + + offset = line.end.tokenIndex + } + } + + return { + lines, + endOffset: offset, + occupiedRects, + } +} diff --git a/src/layout/layout-text-in-compiled-shape.ts b/src/layout/layout-text-in-compiled-shape.ts index 9003368..20ac17f 100644 --- a/src/layout/layout-text-in-compiled-shape.ts +++ b/src/layout/layout-text-in-compiled-shape.ts @@ -1,82 +1,17 @@ -import type { - Interval, - LayoutCursor, - LayoutTextInCompiledShapeOptions, - ShapeTextLayout, - ShapeTextLine, -} from '../types.js' -import { layoutNextLineFromPreparedText } from '../text/layout-next-line-from-prepared-text.js' -import { layoutNextLineFromRepeatedText } from '../text/layout-next-line-from-repeated-text.js' -import { resolveLayoutTextStyle } from '../text/normalize-text-style-to-font.js' -import { prepareTextForLayout } from '../text/prepare-text-for-layout.js' - -function pickWidestInterval(intervals: Interval[]): Interval { - let best = intervals[0]! - - for (let index = 1; index < intervals.length; index++) { - const candidate = intervals[index]! - if (candidate.right - candidate.left > best.right - best.left) { - best = candidate - } - } - - return best -} +import type { AutoFillMode, FillStrategy, LayoutTextInCompiledShapeOptions, ShapeTextLayout } from '../types.js' +import { resolveFlowLayout } from './resolve-flow-layout.js' +import { resolveMaxFillLayout } from './resolve-max-fill-layout.js' export function layoutTextInCompiledShape( options: LayoutTextInCompiledShapeOptions, ): ShapeTextLayout { - const resolvedTextStyle = resolveLayoutTextStyle({ - font: options.font, - textStyle: options.textStyle, - }) - const prepared = prepareTextForLayout(options.text, resolvedTextStyle.font, options.measurer) const autoFill = options.autoFill ?? false - const align = options.align ?? 'left' - const baselineRatio = options.baselineRatio ?? 0.8 - const lines: ShapeTextLine[] = [] - let cursor: LayoutCursor = { tokenIndex: 0, graphemeIndex: 0 } - - for (let index = 0; index < options.compiledShape.bands.length; index++) { - const band = options.compiledShape.bands[index]! - if (band.intervals.length === 0) { - continue - } + const autoFillMode: AutoFillMode = autoFill ? (options.autoFillMode ?? 'words') : 'words' + const fillStrategy: FillStrategy = autoFill ? (options.fillStrategy ?? 'flow') : 'flow' - const slot = pickWidestInterval(band.intervals) - const line = autoFill - ? layoutNextLineFromRepeatedText(prepared, cursor, slot.right - slot.left) - : layoutNextLineFromPreparedText(prepared, cursor, slot.right - slot.left) - - if (line === null) { - break - } - - const x = - align === 'center' - ? slot.left + Math.max(0, (slot.right - slot.left - line.width) / 2) - : slot.left - - lines.push({ - ...line, - x, - top: band.top, - baseline: band.top + options.compiledShape.bandHeight * baselineRatio, - slot, - }) - - cursor = line.end + if (fillStrategy === 'max') { + return resolveMaxFillLayout(options) } - return { - font: resolvedTextStyle.font, - textStyle: resolvedTextStyle, - lineHeight: options.compiledShape.bandHeight, - shape: options.compiledShape.source, - compiledShape: options.compiledShape, - bounds: options.compiledShape.bounds, - lines, - exhausted: autoFill ? prepared.tokens.length === 0 : cursor.tokenIndex >= prepared.tokens.length, - autoFill, - } + return resolveFlowLayout(options, autoFillMode) } diff --git a/src/layout/layout-text-in-shape.test.ts b/src/layout/layout-text-in-shape.test.ts index f5a6797..b0cb0a0 100644 --- a/src/layout/layout-text-in-shape.test.ts +++ b/src/layout/layout-text-in-shape.test.ts @@ -101,9 +101,153 @@ describe('layoutTextInShape', () => { expect(layout.lines.map(line => line.text)).toEqual(['ONE ONE', 'ONE ONE', 'ONE ONE']) expect(layout.autoFill).toBe(true) + expect(layout.autoFillMode).toBe('words') expect(layout.exhausted).toBe(false) }) + it('supports dense auto-fill by ignoring spaces and word boundaries', () => { + const layout = layoutTextInShape({ + text: 'ONE', + font: '16px Test Sans', + lineHeight: 20, + autoFill: true, + autoFillMode: 'dense', + shape: { + kind: 'polygon', + points: [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 60 }, + { x: 0, y: 60 }, + ], + }, + measurer: createFixedWidthTextMeasurer(), + }) + + expect(layout.autoFillMode).toBe('dense') + expect(layout.lines[0]?.text).toBe('ONEONEONEO') + expect(layout.lines[0]?.width).toBe(100) + expect(layout.lines[0]?.text).not.toContain(' ') + }) + + it('supports max fill by sweeping every slot in a band', () => { + const layout = layoutTextInCompiledShape({ + text: 'A B', + font: '16px Test Sans', + autoFill: true, + fillStrategy: 'max', + compiledShape: { + kind: 'polygon', + source: { + kind: 'polygon', + points: [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 8 }, + { x: 0, y: 8 }, + ], + }, + bounds: { left: 0, top: 0, right: 100, bottom: 8 }, + bandHeight: 8, + minSlotWidth: 1, + bands: [ + { + top: 0, + bottom: 8, + intervals: [ + { left: 0, right: 30 }, + { left: 70, right: 100 }, + ], + }, + ], + debugView: { + kind: 'polygon', + points: [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 8 }, + { x: 0, y: 8 }, + ], + }, + }, + measurer: createFixedWidthTextMeasurer(), + }) + + expect(layout.fillStrategy).toBe('max') + expect(layout.autoFillMode).toBe('stream') + expect(layout.lines.map(line => [line.text, line.x, line.fillPass])).toEqual([ + ['A B', 0, 1], + ['A B', 70, 1], + ]) + }) + + it('strips whitespace and continues dense fill across line boundaries', () => { + const layout = layoutTextInShape({ + text: 'A B', + font: '16px Test Sans', + lineHeight: 20, + autoFill: true, + autoFillMode: 'dense', + shape: { + kind: 'polygon', + points: [ + { x: 0, y: 0 }, + { x: 50, y: 0 }, + { x: 50, y: 40 }, + { x: 0, y: 40 }, + ], + }, + measurer: createFixedWidthTextMeasurer(), + }) + + expect(layout.lines.map(line => line.text)).toEqual(['ABABA', 'BABAB']) + }) + + it('rejects dense auto-fill when the source becomes whitespace-only', () => { + expect(() => + layoutTextInShape({ + text: ' \n\t ', + font: '16px Test Sans', + lineHeight: 20, + autoFill: true, + autoFillMode: 'dense', + shape: { + kind: 'polygon', + points: [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 60 }, + { x: 0, y: 60 }, + ], + }, + measurer: createFixedWidthTextMeasurer(), + }), + ).toThrow('dense autoFill requires at least one non-whitespace grapheme') + }) + + it('keeps empty word auto-fill exhausted state unchanged', () => { + const layout = layoutTextInShape({ + text: '', + font: '16px Test Sans', + lineHeight: 20, + autoFill: true, + shape: { + kind: 'polygon', + points: [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 60 }, + { x: 0, y: 60 }, + ], + }, + measurer: createFixedWidthTextMeasurer(), + }) + + expect(layout.lines).toHaveLength(0) + expect(layout.exhausted).toBe(true) + expect(layout.autoFillMode).toBe('words') + }) + it('normalizes textStyle into the resolved layout font and color', () => { const layout = layoutTextInShape({ text: 'xin chao', diff --git a/src/layout/layout-text-in-shape.ts b/src/layout/layout-text-in-shape.ts index 529204f..3007f89 100644 --- a/src/layout/layout-text-in-shape.ts +++ b/src/layout/layout-text-in-shape.ts @@ -2,11 +2,23 @@ import type { LayoutTextInShapeOptions, ShapeTextLayout } from '../types.js' import { compileShapeForLayout } from '../shape/compile-shape-for-layout.js' import { layoutTextInCompiledShape } from './layout-text-in-compiled-shape.js' +function resolveCompileMinSlotWidth(options: LayoutTextInShapeOptions): number | undefined { + if (options.fillStrategy !== 'max' || options.autoFill !== true) { + return options.minSlotWidth + } + + if (options.minSlotWidth !== undefined) { + return options.minSlotWidth + } + + return Math.max(6, Math.round(options.lineHeight * 0.45)) +} + export function layoutTextInShape(options: LayoutTextInShapeOptions): ShapeTextLayout { const compiledShape = compileShapeForLayout({ shape: options.shape, lineHeight: options.lineHeight, - minSlotWidth: options.minSlotWidth, + minSlotWidth: resolveCompileMinSlotWidth(options), }) if (options.textStyle !== undefined) { @@ -19,6 +31,8 @@ export function layoutTextInShape(options: LayoutTextInShapeOptions): ShapeTextL align: options.align, baselineRatio: options.baselineRatio, autoFill: options.autoFill, + autoFillMode: options.autoFillMode, + fillStrategy: options.fillStrategy, }) } @@ -30,5 +44,7 @@ export function layoutTextInShape(options: LayoutTextInShapeOptions): ShapeTextL align: options.align, baselineRatio: options.baselineRatio, autoFill: options.autoFill, + autoFillMode: options.autoFillMode, + fillStrategy: options.fillStrategy, }) } diff --git a/src/layout/resolve-flow-layout.ts b/src/layout/resolve-flow-layout.ts new file mode 100644 index 0000000..5438d6c --- /dev/null +++ b/src/layout/resolve-flow-layout.ts @@ -0,0 +1,122 @@ +import type { + AutoFillMode, + Interval, + LayoutCursor, + LayoutTextInCompiledShapeOptions, + ShapeTextLayout, + ShapeTextLine, +} from '../types.js' +import { layoutNextLineFromPreparedText } from '../text/layout-next-line-from-prepared-text.js' +import { layoutNextLineFromRepeatedText } from '../text/layout-next-line-from-repeated-text.js' +import { resolveLayoutTextStyle } from '../text/normalize-text-style-to-font.js' +import { prepareDenseRepeatFillPattern } from '../text/prepare-dense-repeat-fill-pattern.js' +import { prepareTextForLayout } from '../text/prepare-text-for-layout.js' +import { layoutDenseFillPass } from './layout-dense-fill-pass.js' + +function pickWidestInterval(intervals: Interval[]): Interval { + let best = intervals[0]! + + for (let index = 1; index < intervals.length; index++) { + const candidate = intervals[index]! + if (candidate.right - candidate.left > best.right - best.left) { + best = candidate + } + } + + return best +} + +export function resolveFlowLayout( + options: LayoutTextInCompiledShapeOptions, + autoFillMode: AutoFillMode, +): ShapeTextLayout { + const resolvedTextStyle = resolveLayoutTextStyle({ + font: options.font, + textStyle: options.textStyle, + }) + const align = options.align ?? 'left' + const baselineRatio = options.baselineRatio ?? 0.8 + const autoFill = options.autoFill ?? false + const prepared = + autoFillMode === 'dense' + ? undefined + : prepareTextForLayout(options.text, resolvedTextStyle.font, options.measurer) + const densePattern = + autoFillMode === 'dense' + ? prepareDenseRepeatFillPattern(options.text, resolvedTextStyle.font, options.measurer) + : undefined + const lines: ShapeTextLine[] = [] + let cursor: LayoutCursor = { tokenIndex: 0, graphemeIndex: 0 } + + for (let index = 0; index < options.compiledShape.bands.length; index++) { + const band = options.compiledShape.bands[index]! + if (band.intervals.length === 0) { + continue + } + + const slot = pickWidestInterval(band.intervals) + if (autoFill && autoFillMode === 'dense') { + const denseLine = + layoutDenseFillPass({ + compiledShape: { + ...options.compiledShape, + bands: [{ ...band, intervals: [slot] }], + }, + densePattern: densePattern!, + startOffset: cursor.tokenIndex, + align, + baselineRatio, + allSlots: false, + }).lines[0] ?? null + + if (denseLine === null) { + break + } + + lines.push(denseLine) + cursor = denseLine.end + continue + } + + const line = autoFill + ? layoutNextLineFromRepeatedText(prepared!, cursor, slot.right - slot.left) + : layoutNextLineFromPreparedText(prepared!, cursor, slot.right - slot.left) + + if (line === null) { + break + } + + const x = + align === 'center' + ? slot.left + Math.max(0, (slot.right - slot.left - line.width) / 2) + : slot.left + + lines.push({ + ...line, + x, + top: band.top, + baseline: band.top + options.compiledShape.bandHeight * baselineRatio, + slot, + }) + + cursor = line.end + } + + return { + font: resolvedTextStyle.font, + textStyle: resolvedTextStyle, + lineHeight: options.compiledShape.bandHeight, + shape: options.compiledShape.source, + compiledShape: options.compiledShape, + bounds: options.compiledShape.bounds, + lines, + exhausted: autoFill + ? autoFillMode === 'dense' + ? false + : prepared!.tokens.length === 0 + : cursor.tokenIndex >= prepared!.tokens.length, + autoFill, + autoFillMode, + fillStrategy: 'flow', + } +} diff --git a/src/layout/resolve-max-fill-layout.ts b/src/layout/resolve-max-fill-layout.ts new file mode 100644 index 0000000..fa108b8 --- /dev/null +++ b/src/layout/resolve-max-fill-layout.ts @@ -0,0 +1,68 @@ +import type { LayoutTextInCompiledShapeOptions, ShapeTextLayout } from '../types.js' +import { compileShapeForLayout } from '../shape/compile-shape-for-layout.js' +import { resolveLayoutTextStyle } from '../text/normalize-text-style-to-font.js' +import { prepareStreamRepeatFillPattern } from '../text/prepare-stream-repeat-fill-pattern.js' +import { layoutDenseFillPass } from './layout-dense-fill-pass.js' + +const MAX_FILL_BASE_MIN_SLOT_WIDTH_FACTOR = 0.45 +const MIN_MAX_FILL_SLOT_WIDTH = 6 + +function resolveMaxFillMinSlotWidth(lineHeight: number, factor: number): number { + return Math.max(MIN_MAX_FILL_SLOT_WIDTH, Math.round(lineHeight * factor)) +} + +function ensureMaxFillCompiledShape(options: LayoutTextInCompiledShapeOptions) { + const recommendedMinSlotWidth = resolveMaxFillMinSlotWidth( + options.compiledShape.bandHeight, + MAX_FILL_BASE_MIN_SLOT_WIDTH_FACTOR, + ) + if (options.compiledShape.minSlotWidth <= recommendedMinSlotWidth) { + return options.compiledShape + } + + return compileShapeForLayout({ + shape: options.compiledShape.source, + lineHeight: options.compiledShape.bandHeight, + minSlotWidth: recommendedMinSlotWidth, + }) +} + +export function resolveMaxFillLayout( + options: LayoutTextInCompiledShapeOptions, +): ShapeTextLayout { + const resolvedTextStyle = resolveLayoutTextStyle({ + font: options.font, + textStyle: options.textStyle, + }) + const baselineRatio = options.baselineRatio ?? 0.8 + const maxCompiledShape = ensureMaxFillCompiledShape(options) + const streamPattern = prepareStreamRepeatFillPattern( + options.text, + resolvedTextStyle.font, + options.measurer, + ) + + const pass = layoutDenseFillPass({ + compiledShape: maxCompiledShape, + densePattern: streamPattern, + startOffset: 0, + align: 'left', + baselineRatio, + allSlots: true, + fillPass: 1, + }) + + return { + font: resolvedTextStyle.font, + textStyle: resolvedTextStyle, + lineHeight: maxCompiledShape.bandHeight, + shape: maxCompiledShape.source, + compiledShape: maxCompiledShape, + bounds: maxCompiledShape.bounds, + lines: pass.lines, + exhausted: false, + autoFill: true, + autoFillMode: 'stream', + fillStrategy: 'max', + } +} diff --git a/src/render/render-layout-to-svg.test.ts b/src/render/render-layout-to-svg.test.ts index 1a37e3b..f38a61e 100644 --- a/src/render/render-layout-to-svg.test.ts +++ b/src/render/render-layout-to-svg.test.ts @@ -111,6 +111,80 @@ describe('renderLayoutToSvg', () => { expect(svg).not.toContain('fill="#2563eb"') }) + it('renders line-level font overrides for residual max-fill passes', () => { + const svg = renderLayoutToSvg({ + font: '16px Test Sans', + lineHeight: 20, + shape: { + kind: 'polygon', + points: [ + { x: 0, y: 0 }, + { x: 120, y: 0 }, + { x: 120, y: 40 }, + { x: 0, y: 40 }, + ], + }, + compiledShape: { + kind: 'polygon', + source: { + kind: 'polygon', + points: [ + { x: 0, y: 0 }, + { x: 120, y: 0 }, + { x: 120, y: 40 }, + { x: 0, y: 40 }, + ], + }, + bounds: { left: 0, top: 0, right: 120, bottom: 40 }, + bandHeight: 20, + minSlotWidth: 16, + bands: [], + debugView: { + kind: 'polygon', + points: [ + { x: 0, y: 0 }, + { x: 120, y: 0 }, + { x: 120, y: 40 }, + { x: 0, y: 40 }, + ], + }, + }, + bounds: { left: 0, top: 0, right: 120, bottom: 40 }, + lines: [ + { + text: 'BASE', + width: 40, + start: { tokenIndex: 0, graphemeIndex: 0 }, + end: { tokenIndex: 4, graphemeIndex: 0 }, + x: 0, + top: 0, + baseline: 16, + slot: { left: 0, right: 60 }, + fillPass: 1, + }, + { + text: 'mini', + width: 24, + start: { tokenIndex: 4, graphemeIndex: 0 }, + end: { tokenIndex: 8, graphemeIndex: 0 }, + x: 60, + top: 12, + baseline: 24, + slot: { left: 60, right: 96 }, + font: '11.52px Test Sans', + fillPass: 2, + }, + ], + exhausted: false, + autoFill: true, + autoFillMode: 'dense', + fillStrategy: 'max', + } satisfies ShapeTextLayout) + + expect(svg).toContain('style="font:16px Test Sans;"') + expect(svg).toContain('style="font:11.52px Test Sans;"') + }) + it('does not inject a border when shapeStyle only sets background color', () => { const layout = layoutTextInShape({ text: 'fill only', @@ -213,6 +287,8 @@ describe('renderLayoutToSvg', () => { lines: [], exhausted: true, autoFill: false, + autoFillMode: 'words', + fillStrategy: 'flow', } satisfies ShapeTextLayout) expect(svg).toContain('${escapeXmlText(line.text)}`, + `${escapeXmlText(line.text)}`, ) } diff --git a/src/text/layout-next-line-from-dense-repeated-text.ts b/src/text/layout-next-line-from-dense-repeated-text.ts new file mode 100644 index 0000000..f37718e --- /dev/null +++ b/src/text/layout-next-line-from-dense-repeated-text.ts @@ -0,0 +1,85 @@ +import type { LayoutLineRange, PreparedLayoutToken } from '../types.js' +import { getWordSliceText, getWordSliceWidth } from './layout-text-line-helpers.js' + +function findMaxSliceEnd( + pattern: PreparedLayoutToken, + start: number, + maxWidth: number, +): number { + let low = start + let high = pattern.graphemes.length + + while (low < high) { + const mid = Math.ceil((low + high) / 2) + const width = getWordSliceWidth(pattern, start, mid) + + if (width <= maxWidth) { + low = mid + continue + } + + high = mid - 1 + } + + return low +} + +export function layoutNextLineFromDenseRepeatedText( + pattern: PreparedLayoutToken, + startOffset: number, + maxWidth: number, +): LayoutLineRange | null { + if (pattern.graphemes.length === 0) { + return null + } + + const availableWidth = Math.max(0, maxWidth) + const start = { tokenIndex: startOffset, graphemeIndex: 0 } + const textParts: string[] = [] + let width = 0 + let consumed = 0 + + const appendSlice = (sliceStart: number, sliceEnd: number) => { + if (sliceEnd <= sliceStart) { + return + } + + textParts.push(getWordSliceText(pattern, sliceStart, sliceEnd)) + width += getWordSliceWidth(pattern, sliceStart, sliceEnd) + consumed += sliceEnd - sliceStart + } + + const tailEnd = findMaxSliceEnd(pattern, startOffset, availableWidth) + appendSlice(startOffset, tailEnd) + + if (tailEnd === pattern.graphemes.length) { + const remainingAfterTail = availableWidth - width + if (remainingAfterTail >= pattern.width) { + const fullCycles = Math.floor(remainingAfterTail / pattern.width) + if (fullCycles > 0) { + textParts.push(pattern.text.repeat(fullCycles)) + width += pattern.width * fullCycles + consumed += pattern.graphemes.length * fullCycles + } + } + + const remainingAfterCycles = availableWidth - width + const prefixEnd = findMaxSliceEnd(pattern, 0, remainingAfterCycles) + appendSlice(0, prefixEnd) + } + + if (consumed === 0) { + const forcedEnd = Math.min(startOffset + 1, pattern.graphemes.length) + appendSlice(startOffset, forcedEnd) + } + + return { + text: textParts.join(''), + width, + start, + end: { + tokenIndex: (startOffset + consumed) % pattern.graphemes.length, + graphemeIndex: 0, + }, + } +} diff --git a/src/text/prepare-dense-repeat-fill-pattern.ts b/src/text/prepare-dense-repeat-fill-pattern.ts new file mode 100644 index 0000000..e8a6126 --- /dev/null +++ b/src/text/prepare-dense-repeat-fill-pattern.ts @@ -0,0 +1,21 @@ +import type { PreparedLayoutToken, TextMeasurer } from '../types.js' +import { createMeasuredWordToken } from './segment-text-for-layout.js' + +export function prepareDenseRepeatFillPattern( + text: string, + font: string, + measurer: TextMeasurer, +): PreparedLayoutToken { + const normalized = text.replace(/\s+/gu, '') + + if (normalized.length === 0) { + throw new Error('dense autoFill requires at least one non-whitespace grapheme') + } + + const pattern = createMeasuredWordToken(normalized, value => measurer.measureText(value, font)) + if (pattern.width <= 0) { + throw new Error('dense autoFill requires measurable graphemes') + } + + return pattern +} diff --git a/src/text/prepare-stream-repeat-fill-pattern.ts b/src/text/prepare-stream-repeat-fill-pattern.ts new file mode 100644 index 0000000..ce02d58 --- /dev/null +++ b/src/text/prepare-stream-repeat-fill-pattern.ts @@ -0,0 +1,25 @@ +import type { PreparedLayoutToken, TextMeasurer } from '../types.js' +import { createMeasuredWordToken } from './segment-text-for-layout.js' + +function normalizeRepeatStreamText(text: string): string { + return text.replace(/\r\n?/gu, '\n').replace(/[\n\t\f\v]+/gu, ' ') +} + +export function prepareStreamRepeatFillPattern( + text: string, + font: string, + measurer: TextMeasurer, +): PreparedLayoutToken { + const normalized = normalizeRepeatStreamText(text) + + if (normalized.length === 0) { + throw new Error('stream autoFill requires at least one grapheme') + } + + const pattern = createMeasuredWordToken(normalized, value => measurer.measureText(value, font)) + if (pattern.width <= 0) { + throw new Error('stream autoFill requires measurable graphemes') + } + + return pattern +} diff --git a/src/types.ts b/src/types.ts index 0e6fca7..c5a920f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -68,6 +68,9 @@ export type ShapeStyleInput = { shadow?: ShapeShadowInput } +export type AutoFillMode = 'words' | 'dense' | 'stream' +export type FillStrategy = 'flow' | 'max' + export type ResolvedShapeStyle = { backgroundColor?: string borderColor: string @@ -106,6 +109,8 @@ export type ShapeTextLine = LayoutLineRange & { top: number baseline: number slot: Interval + font?: string + fillPass?: 1 | 2 } export type ShapeBounds = { @@ -154,6 +159,8 @@ export type ShapeTextLayout = { lines: ShapeTextLine[] exhausted: boolean autoFill: boolean + autoFillMode?: AutoFillMode + fillStrategy?: FillStrategy } export type CompileShapeForLayoutOptions = { @@ -169,6 +176,8 @@ export type LayoutTextInCompiledShapeOptions = { align?: 'left' | 'center' baselineRatio?: number autoFill?: boolean + autoFillMode?: AutoFillMode + fillStrategy?: FillStrategy } & ( | { font: string @@ -189,6 +198,8 @@ export type LayoutTextInShapeOptions = { minSlotWidth?: number baselineRatio?: number autoFill?: boolean + autoFillMode?: AutoFillMode + fillStrategy?: FillStrategy } & ( | { font: string From 64a9d374d4a371899eb80d7bc4202b370f2703d1 Mon Sep 17 00:00:00 2001 From: tqdat410 Date: Wed, 1 Apr 2026 12:57:30 +0700 Subject: [PATCH 05/12] docs: update max-fill stream notes Co-authored-by: Codex --- README.md | 3 +++ docs/project-changelog.md | 2 ++ 2 files changed, 5 insertions(+) diff --git a/README.md b/README.md index ecfa224..bbba2b8 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ const layout = layoutTextInShape({ }, lineHeight: 20, autoFill: true, + fillStrategy: 'max', shape: { kind: 'text-mask', text: '2', @@ -109,6 +110,8 @@ const layout = layoutTextInShape({ - The project takes inspiration from `pretext` for the `prepare -> layout` split and streaming line iteration, but owns its geometry, slot policy, and public API. - `text-mask` shapes are raster-compiled into reusable line bands. This is the default path for browser fonts such as `Arial`, and it is designed so callers can precompile `0-9` and `:` for clock-like UIs. - `autoFill: true` repeats the source text until the available shape bands are full. +- `autoFillMode: 'words'` is the default readable repeat behavior. `autoFillMode: 'dense'` strips whitespace and breaks at grapheme boundaries to pack shapes harder for decorative fills. +- `fillStrategy: 'max'` switches to an all-slots pass that fills every usable interval in reading order. It keeps spaces as normal graphemes instead of stripping them, and it does not fall back to smaller text for leftover pockets. - `textStyle` is the new data-driven API for size, weight, italic/oblique, family, and default text color. Legacy `font` string input still works. - `shapeStyle` lives in `renderLayoutToSvg()` because fill, border, and shadow do not affect line breaking or shape compilation. - For late-loading web fonts, compile after the font is ready if you want immediate cache reuse. The compiler skips cache writes until `document.fonts.check()` reports the font as ready. diff --git a/docs/project-changelog.md b/docs/project-changelog.md index 40cff79..97141a7 100644 --- a/docs/project-changelog.md +++ b/docs/project-changelog.md @@ -19,5 +19,7 @@ - Added structured `textStyle` API for text size, family, weight, style, and default color - Added renderer-side `shapeStyle` API for shape fill, border, and shadow +- Added `autoFillMode: 'dense'` for whitespace-stripped grapheme repeat fill inside compiled shape bands +- Added `fillStrategy: 'max'` for all-slot glyph coverage without mini-font fallback, while preserving spaces in the repeat stream - Kept legacy `font`, `textFill`, `shapeFill`, and `shapeStroke` compatibility paths - Extended local demo and Playwright coverage for style and decoration controls From 104f4ec4dac0efeecbaab063a2e7b67517867b0c Mon Sep 17 00:00:00 2001 From: tqdat410 Date: Thu, 2 Apr 2026 18:26:04 +0700 Subject: [PATCH 06/12] fix(dev): run demo and playwright directly on windows Co-authored-by: Codex --- package.json | 16 ++++++++-------- playwright.config.ts | 2 +- scripts/run-e2e-serve.mjs | 36 ++++++++++++++++++++++++++++++++++++ vitest.config.ts | 2 +- 4 files changed, 46 insertions(+), 10 deletions(-) create mode 100644 scripts/run-e2e-serve.mjs diff --git a/package.json b/package.json index da1ba6c..3b63e85 100644 --- a/package.json +++ b/package.json @@ -22,14 +22,14 @@ "test": "vitest run", "test:coverage": "vitest run --coverage", "check": "npm run typecheck && npm run test", - "e2e": "playwright test", - "e2e:ui": "playwright test --ui", - "e2e:headed": "playwright test --headed", - "e2e:debug": "playwright test --debug", - "e2e:serve": "npm run build && node ./scripts/serve-static-e2e.mjs", - "e2e:serve:dev": "node ./scripts/serve-static-e2e.mjs", - "demo": "npm run e2e:serve", - "demo:dev": "npm run e2e:serve:dev" + "e2e": "node ./node_modules/@playwright/test/cli.js test", + "e2e:ui": "node ./node_modules/@playwright/test/cli.js test --ui", + "e2e:headed": "node ./node_modules/@playwright/test/cli.js test --headed", + "e2e:debug": "node ./node_modules/@playwright/test/cli.js test --debug", + "e2e:serve": "node ./scripts/run-e2e-serve.mjs", + "e2e:serve:dev": "node ./scripts/run-e2e-serve.mjs --skip-build", + "demo": "node ./scripts/run-e2e-serve.mjs", + "demo:dev": "node ./scripts/run-e2e-serve.mjs --skip-build" }, "keywords": [ "svg", diff --git a/playwright.config.ts b/playwright.config.ts index 65560fe..f382c44 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -13,7 +13,7 @@ export default defineConfig({ trace: 'on-first-retry', }, webServer: { - command: 'npm run e2e:serve', + command: 'node ./scripts/run-e2e-serve.mjs', url: `${baseURL}/`, reuseExistingServer: !process.env.CI, timeout: 120_000, diff --git a/scripts/run-e2e-serve.mjs b/scripts/run-e2e-serve.mjs new file mode 100644 index 0000000..bcafb69 --- /dev/null +++ b/scripts/run-e2e-serve.mjs @@ -0,0 +1,36 @@ +import { spawn } from 'node:child_process' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)) +const rootDir = path.resolve(scriptDir, '..') +const skipBuild = process.argv.includes('--skip-build') + +function runBuild() { + return new Promise((resolve, reject) => { + const child = spawn( + process.execPath, + ['./node_modules/typescript/lib/tsc.js', '-p', 'tsconfig.build.json'], + { + cwd: rootDir, + stdio: 'inherit', + }, + ) + + child.once('error', reject) + child.once('exit', (code) => { + if (code === 0) { + resolve() + return + } + + reject(new Error(`Build failed with exit code ${code ?? 'unknown'}`)) + }) + }) +} + +if (!skipBuild) { + await runBuild() +} + +await import('./serve-static-e2e.mjs') diff --git a/vitest.config.ts b/vitest.config.ts index ef3f442..065f4ac 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,7 +3,7 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { environment: 'node', - exclude: ['e2e/**', 'node_modules/**', 'dist/**'], + exclude: ['e2e/**', 'node_modules/**', 'dist/**', '.claude/**'], coverage: { provider: 'v8', include: ['src/**/*.ts'], From f484c62c1cdb497a819a687e757cf352a5cc41db Mon Sep 17 00:00:00 2001 From: tqdat410 Date: Thu, 2 Apr 2026 18:26:34 +0700 Subject: [PATCH 07/12] feat(text-mask): add fit-content sizing and demo controls Co-authored-by: Codex --- e2e/fixtures/demo-fill-presets.js | 52 +++ e2e/fixtures/demo-payload-helpers.js | 268 ++++++++++++ e2e/fixtures/demo-scenarios.js | 80 ++++ e2e/fixtures/demo-svg-viewport-controller.js | 138 ++++++ e2e/fixtures/demo-ui-controller.js | 277 ++++++++++++ e2e/fixtures/index.html | 110 ++++- e2e/fixtures/shape-text-e2e-app.js | 401 ++++++------------ e2e/shape-text-local-e2e.spec.ts | 307 ++++++++++++++ src/index.ts | 6 + .../layout-flow-lines-in-compiled-shape.ts | 104 +++++ src/layout/layout-text-in-compiled-shape.ts | 8 + src/layout/layout-text-in-shape.test.ts | 112 +++++ src/layout/resolve-flow-layout.ts | 94 +--- src/layout/resolve-max-fill-helpers.ts | 22 + src/layout/resolve-max-fill-layout.ts | 27 +- .../resolve-sequential-shape-regions.ts | 144 +++++++ src/shape/build-text-mask-bands-from-alpha.ts | 106 +++++ ...compile-text-mask-shape-for-layout.test.ts | 139 +++++- .../compile-text-mask-shape-for-layout.ts | 295 ++++++------- src/shape/render-text-mask-raster.ts | 54 +++ src/shape/resolve-text-mask-shape-size.ts | 112 +++++ src/shape/segment-text-mask-graphemes.ts | 33 ++ src/types.ts | 31 +- 23 files changed, 2344 insertions(+), 576 deletions(-) create mode 100644 e2e/fixtures/demo-fill-presets.js create mode 100644 e2e/fixtures/demo-payload-helpers.js create mode 100644 e2e/fixtures/demo-scenarios.js create mode 100644 e2e/fixtures/demo-svg-viewport-controller.js create mode 100644 e2e/fixtures/demo-ui-controller.js create mode 100644 src/layout/layout-flow-lines-in-compiled-shape.ts create mode 100644 src/layout/resolve-max-fill-helpers.ts create mode 100644 src/layout/resolve-sequential-shape-regions.ts create mode 100644 src/shape/build-text-mask-bands-from-alpha.ts create mode 100644 src/shape/render-text-mask-raster.ts create mode 100644 src/shape/resolve-text-mask-shape-size.ts create mode 100644 src/shape/segment-text-mask-graphemes.ts diff --git a/e2e/fixtures/demo-fill-presets.js b/e2e/fixtures/demo-fill-presets.js new file mode 100644 index 0000000..cd29369 --- /dev/null +++ b/e2e/fixtures/demo-fill-presets.js @@ -0,0 +1,52 @@ +function createSeededRandom(seed) { + let value = seed >>> 0 + return () => { + value = (value * 1664525 + 1013904223) >>> 0 + return value / 0x100000000 + } +} + +function createRandomText(alphabet, length, seed) { + const random = createSeededRandom(seed) + let text = '' + + for (let index = 0; index < length; index++) { + const nextIndex = Math.floor(random() * alphabet.length) + text += alphabet[nextIndex] + } + + return text +} + +export const fillTextPresets = [ + { id: 'custom', label: 'Custom' }, + { id: 'ascii-random', label: 'ASCII', alphabet: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()[]{}<>?/|+-=', length: 96, seed: 0x41534349 }, + { id: 'binary-random', label: 'BINARY', alphabet: '01', length: 128, seed: 0x42494e41 }, + { id: 'hex-random', label: 'HEX', alphabet: '0123456789ABCDEF', length: 112, seed: 0x48455821 }, + { id: 'octal-random', label: 'OCTAL', alphabet: '01234567', length: 120, seed: 0x4f435441 }, + { id: 'symbol-random', label: 'SYMBOL', alphabet: '<>[]{}()/\\|+-=_*#@~', length: 96, seed: 0x53594d42 }, +] + +export function findFillTextPresetById(id) { + return fillTextPresets.find(preset => preset.id === id) ?? fillTextPresets[0] +} + +export function identifyFillTextPresetId(text) { + for (let index = 0; index < fillTextPresets.length; index++) { + const preset = fillTextPresets[index] + if (preset.id !== 'custom' && resolveFillTextPresetText(preset.id) === text) { + return preset.id + } + } + + return 'custom' +} + +export function resolveFillTextPresetText(id) { + const preset = findFillTextPresetById(id) + if (preset.id === 'custom') { + return null + } + + return createRandomText(preset.alphabet, preset.length, preset.seed) +} diff --git a/e2e/fixtures/demo-payload-helpers.js b/e2e/fixtures/demo-payload-helpers.js new file mode 100644 index 0000000..92ac7bb --- /dev/null +++ b/e2e/fixtures/demo-payload-helpers.js @@ -0,0 +1,268 @@ +function clonePoints(points) { + return points.map(point => ({ x: point.x, y: point.y })) +} + +function cloneDemoState(state) { + return { + ...state, + polygonPoints: clonePoints(state.polygonPoints), + } +} + +function assertFiniteNumber(value, label) { + if (!Number.isFinite(value)) { + throw new Error(`${label} must be a finite number`) + } + + return value +} + +function assertPositiveFiniteNumber(value, label) { + if (!Number.isFinite(value) || value <= 0) { + throw new Error(`${label} must be a finite positive number`) + } + + return value +} + +function hasOwnProperty(value, key) { + return Object.prototype.hasOwnProperty.call(value, key) +} + +function normalizeTextMaskSize(size) { + if (size === undefined) { + return { mode: 'fit-content' } + } + + if (size === null || typeof size !== 'object' || Array.isArray(size)) { + throw new Error('layout.shape.size must be an object') + } + + const mode = size.mode ?? 'fit-content' + if (mode !== 'fit-content' && mode !== 'fixed') { + throw new Error('layout.shape.size.mode must be fit-content or fixed') + } + + const padding = + size.padding === undefined + ? undefined + : assertFiniteNumber(Number(size.padding), 'layout.shape.size.padding') + + if (padding !== undefined && padding < 0) { + throw new Error('layout.shape.size.padding must be a finite non-negative number') + } + + if (mode === 'fixed') { + return { + mode, + width: assertPositiveFiniteNumber(Number(size.width), 'layout.shape.size.width'), + height: assertPositiveFiniteNumber(Number(size.height), 'layout.shape.size.height'), + padding, + } + } + + return { + mode, + padding, + } +} + +function normalizeShape(shape) { + if (shape.kind === 'text-mask') { + if (hasOwnProperty(shape, 'width') || hasOwnProperty(shape, 'height') || hasOwnProperty(shape, 'padding')) { + throw new Error('layout.shape.width, height, and padding moved to layout.shape.size') + } + + const shapeTextMode = shape.shapeTextMode ?? 'whole-text' + if (shapeTextMode !== 'whole-text' && shapeTextMode !== 'per-character') { + throw new Error('layout.shape.shapeTextMode must be whole-text or per-character') + } + + return { + kind: 'text-mask', + text: String(shape.text), + font: String(shape.font), + size: normalizeTextMaskSize(shape.size), + shapeTextMode, + maskScale: + shape.maskScale === undefined + ? undefined + : assertFiniteNumber(Number(shape.maskScale), 'layout.shape.maskScale'), + alphaThreshold: + shape.alphaThreshold === undefined + ? undefined + : assertFiniteNumber(Number(shape.alphaThreshold), 'layout.shape.alphaThreshold'), + } + } + + if (!Array.isArray(shape.points) || shape.points.length < 3) { + throw new Error('layout.shape.points must contain at least 3 points') + } + + return { + kind: 'polygon', + points: shape.points.map((point, index) => ({ + x: assertFiniteNumber(Number(point.x), `layout.shape.points[${index}].x`), + y: assertFiniteNumber(Number(point.y), `layout.shape.points[${index}].y`), + })), + } +} + +function buildTextMaskSize(state) { + if (state.shapeSizeMode === 'fixed') { + return { + mode: 'fixed', + width: state.shapeWidth, + height: state.shapeHeight, + padding: state.shapePadding, + } + } + + return { + mode: 'fit-content', + padding: state.shapePadding, + } +} + +export function buildDemoPayload(state) { + const shape = + state.shapeKind === 'text-mask' + ? { + kind: 'text-mask', + text: state.shapeText, + font: state.shapeFont, + size: buildTextMaskSize(state), + shapeTextMode: state.shapeTextMode, + maskScale: state.shapeMaskScale, + alphaThreshold: state.shapeAlphaThreshold, + } + : { + kind: 'polygon', + points: clonePoints(state.polygonPoints), + } + + return { + layout: { + text: state.text, + textStyle: { + family: '"Helvetica Neue", Arial, sans-serif', + size: state.textSize, + weight: state.textWeight, + style: state.textItalic ? 'italic' : 'normal', + color: state.textColor, + }, + lineHeight: state.lineHeight, + shape, + minSlotWidth: state.minSlotWidth ?? (state.fillStrategy === 'max' ? 8 : 24), + autoFill: state.autoFill, + autoFillMode: state.autoFillMode, + fillStrategy: state.fillStrategy, + }, + render: { + background: state.background, + shapeStyle: { + backgroundColor: state.shapeFill, + borderColor: state.shapeBorderColor, + borderWidth: state.shapeBorderWidth, + shadow: state.shapeShadow + ? { + color: 'rgba(15, 23, 42, 0.22)', + blur: 6, + offsetX: 0, + offsetY: 6, + } + : undefined, + }, + showShape: state.showShape, + padding: state.renderPadding, + }, + } +} + +export function serializeDemoPayload(state) { + return JSON.stringify(buildDemoPayload(state), null, 2) +} + +export function applyDemoPayloadToState(state, payload) { + if (payload === null || typeof payload !== 'object') { + throw new Error('payload must be an object') + } + + const layout = payload.layout + const render = payload.render + if (layout === null || typeof layout !== 'object') { + throw new Error('payload.layout must be an object') + } + if (render === null || typeof render !== 'object') { + throw new Error('payload.render must be an object') + } + + const nextState = cloneDemoState(state) + + nextState.text = String(layout.text ?? nextState.text) + nextState.lineHeight = assertFiniteNumber(Number(layout.lineHeight ?? nextState.lineHeight), 'layout.lineHeight') + nextState.minSlotWidth = + layout.minSlotWidth === undefined + ? nextState.minSlotWidth + : assertFiniteNumber(Number(layout.minSlotWidth), 'layout.minSlotWidth') + nextState.autoFill = Boolean(layout.autoFill ?? nextState.autoFill) + nextState.fillStrategy = + layout.fillStrategy === undefined ? nextState.fillStrategy : layout.fillStrategy === 'max' ? 'max' : 'flow' + nextState.autoFillMode = + layout.autoFillMode === undefined + ? nextState.autoFillMode + : layout.autoFillMode === 'dense' || layout.autoFillMode === 'stream' + ? layout.autoFillMode + : 'words' + + if (layout.textStyle !== null && typeof layout.textStyle === 'object') { + nextState.textSize = assertFiniteNumber( + Number(layout.textStyle.size ?? nextState.textSize), + 'layout.textStyle.size', + ) + nextState.textWeight = Number(layout.textStyle.weight ?? nextState.textWeight) + nextState.textItalic = + layout.textStyle.style === undefined + ? nextState.textItalic + : String(layout.textStyle.style) !== 'normal' + nextState.textColor = String(layout.textStyle.color ?? nextState.textColor) + } + + const shape = normalizeShape(layout.shape) + nextState.shapeKind = shape.kind + if (shape.kind === 'text-mask') { + nextState.shapeText = shape.text + nextState.shapeFont = shape.font + nextState.shapeSizeMode = shape.size.mode === 'fixed' ? 'fixed' : 'fit-content' + if (shape.size.mode === 'fixed') { + nextState.shapeWidth = shape.size.width + nextState.shapeHeight = shape.size.height + } + nextState.shapeTextMode = shape.shapeTextMode + nextState.shapePadding = shape.size.padding ?? 0 + nextState.shapeMaskScale = shape.maskScale ?? 2 + nextState.shapeAlphaThreshold = shape.alphaThreshold + } else { + nextState.polygonPoints = shape.points + } + + nextState.background = String(render.background ?? nextState.background) + nextState.showShape = Boolean(render.showShape ?? nextState.showShape) + nextState.renderPadding = assertFiniteNumber( + Number(render.padding ?? nextState.renderPadding), + 'render.padding', + ) + + if (render.shapeStyle !== null && typeof render.shapeStyle === 'object') { + nextState.shapeFill = String(render.shapeStyle.backgroundColor ?? nextState.shapeFill) + nextState.shapeBorderColor = String(render.shapeStyle.borderColor ?? nextState.shapeBorderColor) + nextState.shapeBorderWidth = assertFiniteNumber( + Number(render.shapeStyle.borderWidth ?? nextState.shapeBorderWidth), + 'render.shapeStyle.borderWidth', + ) + nextState.shapeShadow = render.shapeStyle.shadow !== undefined + } + + Object.assign(state, nextState) + state.polygonPoints = nextState.polygonPoints +} diff --git a/e2e/fixtures/demo-scenarios.js b/e2e/fixtures/demo-scenarios.js new file mode 100644 index 0000000..516ff98 --- /dev/null +++ b/e2e/fixtures/demo-scenarios.js @@ -0,0 +1,80 @@ +const defaultParagraph = [ + 'Shape text lets a paragraph travel inside a silhouette thay vi chi wrap quanh float.', + 'Ban co the dung no de render layout hinh so 2, badge, logo block, hoac poster typography.', + 'E2E local o day uu tien deterministic browser path: build dist, import module, render SVG, assert lai state.', +].join(' ') + +function createDigitTwoPolygon(width, height) { + return [ + { x: width * 0.12, y: height * 0.1 }, + { x: width * 0.34, y: 0 }, + { x: width * 0.72, y: height * 0.02 }, + { x: width * 0.9, y: height * 0.16 }, + { x: width * 0.88, y: height * 0.32 }, + { x: width * 0.74, y: height * 0.44 }, + { x: width * 0.5, y: height * 0.57 }, + { x: width * 0.26, y: height * 0.72 }, + { x: width * 0.14, y: height * 0.86 }, + { x: width * 0.88, y: height * 0.86 }, + { x: width * 0.86, y: height }, + { x: 0, y: height }, + { x: 0, y: height * 0.78 }, + { x: width * 0.12, y: height * 0.64 }, + { x: width * 0.35, y: height * 0.5 }, + { x: width * 0.62, y: height * 0.35 }, + { x: width * 0.74, y: height * 0.26 }, + { x: width * 0.72, y: height * 0.14 }, + { x: width * 0.58, y: height * 0.08 }, + { x: width * 0.34, y: height * 0.08 }, + ] +} + +export const demoScenarios = { + 'glyph-two-repeat': { + shape: { + kind: 'text-mask', + text: '23', + font: '700 420px Arial', + size: { + mode: 'fit-content', + padding: 10, + }, + shapeTextMode: 'whole-text', + maskScale: 2, + }, + autoFill: true, + text: 'ONE', + }, + 'digit-two-wide': { + shape: { kind: 'polygon', points: createDigitTwoPolygon(340, 460) }, + text: defaultParagraph, + }, + 'digit-two-narrow': { + shape: { kind: 'polygon', points: createDigitTwoPolygon(240, 460) }, + text: defaultParagraph, + }, + 'rectangle-wide': { + shape: { + kind: 'polygon', + points: [ + { x: 0, y: 0 }, + { x: 340, y: 0 }, + { x: 340, y: 360 }, + { x: 0, y: 360 }, + ], + }, + text: defaultParagraph, + }, + 'rectangle-narrow': { + shape: { + kind: 'polygon', + points: [ + { x: 0, y: 0 }, + { x: 220, y: 0 }, + { x: 220, y: 360 }, + { x: 0, y: 360 }, + ], + }, + text: defaultParagraph, + }, +} diff --git a/e2e/fixtures/demo-svg-viewport-controller.js b/e2e/fixtures/demo-svg-viewport-controller.js new file mode 100644 index 0000000..faf021d --- /dev/null +++ b/e2e/fixtures/demo-svg-viewport-controller.js @@ -0,0 +1,138 @@ +const MIN_ZOOM = 0.25 +const MAX_ZOOM = 4 +const ZOOM_STEP = 1.25 + +function clampZoom(value) { + return Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, value)) +} + +function parseSvgDimension(svg, attribute) { + const attributeValue = Number(svg.getAttribute(attribute)) + if (Number.isFinite(attributeValue) && attributeValue > 0) { + return attributeValue + } + + const viewBox = svg.getAttribute('viewBox') + if (!viewBox) { + return 0 + } + + const parts = viewBox.split(/\s+/).map(Number) + return Number.isFinite(parts[attribute === 'width' ? 2 : 3]) ? parts[attribute === 'width' ? 2 : 3] : 0 +} + +function getContentBounds(svg) { + const width = parseSvgDimension(svg, 'width') + const height = parseSvgDimension(svg, 'height') + let left = 0 + let top = 0 + let right = width + let bottom = height + + try { + const box = svg.getBBox() + if (Number.isFinite(box.x) && Number.isFinite(box.width)) { + left = Math.min(left, box.x) + right = Math.max(right, box.x + box.width) + } + if (Number.isFinite(box.y) && Number.isFinite(box.height)) { + top = Math.min(top, box.y) + bottom = Math.max(bottom, box.y + box.height) + } + } catch { + // Ignore SVG bbox failures and fall back to declared dimensions. + } + + return { + left, + top, + width: right - left, + height: bottom - top, + } +} + +export function createDemoSvgViewportController({ state, elements }) { + let intrinsicSvgWidth = 0 + let intrinsicSvgHeight = 0 + let contentOffsetX = 0 + let contentOffsetY = 0 + + function syncZoomLabel() { + elements.zoomValue.textContent = `${Math.round(state.stageZoom * 100)}%` + } + + function applyZoom() { + const svg = elements.stage.querySelector('svg') + if (svg instanceof SVGSVGElement) { + svg.style.width = `${intrinsicSvgWidth * state.stageZoom}px` + svg.style.height = `${intrinsicSvgHeight * state.stageZoom}px` + svg.style.marginLeft = `${contentOffsetX * state.stageZoom}px` + svg.style.marginTop = `${contentOffsetY * state.stageZoom}px` + svg.style.transform = 'none' + } + + elements.stage.style.width = `${state.stageSvgWidth * state.stageZoom}px` + elements.stage.style.height = `${state.stageSvgHeight * state.stageZoom}px` + syncZoomLabel() + } + + function syncStageMetrics() { + const svg = elements.stage.querySelector('svg') + if (!(svg instanceof SVGSVGElement)) { + intrinsicSvgWidth = 0 + intrinsicSvgHeight = 0 + contentOffsetX = 0 + contentOffsetY = 0 + state.stageSvgWidth = 0 + state.stageSvgHeight = 0 + elements.stage.style.width = '0px' + elements.stage.style.height = '0px' + return + } + + const bounds = getContentBounds(svg) + intrinsicSvgWidth = parseSvgDimension(svg, 'width') + intrinsicSvgHeight = parseSvgDimension(svg, 'height') + contentOffsetX = Math.max(0, -bounds.left) + contentOffsetY = Math.max(0, -bounds.top) + state.stageSvgWidth = bounds.width + state.stageSvgHeight = bounds.height + svg.style.display = 'block' + svg.style.transformOrigin = 'top left' + } + + return { + renderSvg(svgMarkup) { + elements.stage.innerHTML = svgMarkup + syncStageMetrics() + applyZoom() + }, + zoomIn() { + state.stageZoom = clampZoom(state.stageZoom * ZOOM_STEP) + applyZoom() + }, + zoomOut() { + state.stageZoom = clampZoom(state.stageZoom / ZOOM_STEP) + applyZoom() + }, + resetZoom() { + state.stageZoom = 1 + applyZoom() + }, + fitZoom() { + const width = state.stageSvgWidth + const height = state.stageSvgHeight + if (width <= 0 || height <= 0) { + return + } + + const viewportWidth = Math.max(1, elements.stageViewport.clientWidth - 24) + const viewportHeight = Math.max(1, elements.stageViewport.clientHeight - 24) + state.stageZoom = clampZoom(Math.min(viewportWidth / width, viewportHeight / height)) + elements.stageViewport.scrollLeft = 0 + elements.stageViewport.scrollTop = 0 + applyZoom() + }, + syncZoomLabel, + } +} diff --git a/e2e/fixtures/demo-ui-controller.js b/e2e/fixtures/demo-ui-controller.js new file mode 100644 index 0000000..3c67085 --- /dev/null +++ b/e2e/fixtures/demo-ui-controller.js @@ -0,0 +1,277 @@ +import { layoutTextInShape, renderLayoutToSvg } from '/dist/index.js' +import { + applyDemoPayloadToState, + buildDemoPayload, + serializeDemoPayload, +} from './demo-payload-helpers.js' +import { + fillTextPresets, + findFillTextPresetById, + identifyFillTextPresetId, + resolveFillTextPresetText, +} from './demo-fill-presets.js' +import { demoScenarios } from './demo-scenarios.js' + +export function createDemoUiController({ state, elements, measurer, viewport }) { + let hasUserEditedText = false + let hasUserEditedShapeText = false + let hasPayloadDraftChanges = false + let lastCommittedState = cloneStateSnapshot(state) + let lastRenderedState = null + + function setPayloadError(message = '') { + state.payloadError = message + elements.payloadError.textContent = message + elements.payloadError.hidden = message.length === 0 + } + + function syncPresetSelection() { + state.fillPresetId = identifyFillTextPresetId(state.text) + } + + function cloneStateSnapshot(source) { + return { + ...source, + polygonPoints: source.polygonPoints.map(point => ({ ...point })), + } + } + + function updateSummary() { + const resolvedShapeWidth = + state.layout === null ? null : state.layout.compiledShape.bounds.right - state.layout.compiledShape.bounds.left + const resolvedShapeHeight = + state.layout === null ? null : state.layout.compiledShape.bounds.bottom - state.layout.compiledShape.bounds.top + + elements.summary.textContent = JSON.stringify( + { + scenario: state.scenario, + shapeKind: state.shapeKind, + shapeText: state.shapeText, + shapeTextMode: state.shapeTextMode, + shapeSizeMode: state.shapeSizeMode, + resolvedShapeWidth, + resolvedShapeHeight, + fillPresetId: state.fillPresetId, + autoFill: state.layout?.autoFill ?? false, + autoFillMode: state.layout?.autoFillMode ?? null, + fillStrategy: state.layout?.fillStrategy ?? null, + lineCount: state.layout?.lines.length ?? 0, + exhausted: state.layout?.exhausted ?? null, + stageZoom: state.stageZoom, + stageSvgWidth: state.stageSvgWidth, + stageSvgHeight: state.stageSvgHeight, + payloadError: state.payloadError, + firstLine: state.layout?.lines[0]?.text ?? null, + lastLine: state.layout?.lines.at(-1)?.text ?? null, + }, + null, + 2, + ) + } + + function renderStateSnapshot(snapshot) { + const payload = buildDemoPayload(snapshot) + const layout = layoutTextInShape({ ...payload.layout, measurer }) + const svg = renderLayoutToSvg(layout, payload.render) + + return { layout, svg } + } + + function commitRenderedState(nextState, rendered) { + const nextPolygonPoints = nextState.polygonPoints.map(point => ({ ...point })) + + Object.assign(state, nextState) + state.polygonPoints = nextPolygonPoints + state.layout = rendered.layout + state.svg = rendered.svg + syncPresetSelection() + syncControls() + syncPayloadInput() + setPayloadError('') + viewport.renderSvg(rendered.svg) + updateSummary() + lastCommittedState = cloneStateSnapshot(state) + lastRenderedState = rendered + } + + function restoreCommittedState(message) { + if (lastRenderedState === null) { + throw new Error(message) + } + + const restoredState = cloneStateSnapshot(lastCommittedState) + const restoredPolygonPoints = restoredState.polygonPoints.map(point => ({ ...point })) + + Object.assign(state, restoredState) + state.polygonPoints = restoredPolygonPoints + state.layout = lastRenderedState.layout + state.svg = lastRenderedState.svg + syncPresetSelection() + syncControls() + syncPayloadInput() + setPayloadError(message) + viewport.renderSvg(state.svg) + updateSummary() + } + + function syncPayloadInput(force = false) { + const nextDraft = serializeDemoPayload(state) + if (force || !hasPayloadDraftChanges) { + state.payloadDraft = nextDraft + elements.payloadInput.value = nextDraft + return + } + + state.payloadDraft = elements.payloadInput.value + } + + function syncControls() { + const isTextMask = state.shapeKind === 'text-mask' + const showFixedSizeFields = isTextMask && state.shapeSizeMode === 'fixed' + + elements.textInput.value = state.text + elements.shapeTextInput.value = state.shapeText + elements.shapeTextInput.disabled = !isTextMask + elements.shapeTextModeSelect.value = state.shapeTextMode + elements.shapeTextModeSelect.disabled = !isTextMask + elements.shapeSizeModeSelect.value = state.shapeSizeMode + elements.shapeSizeModeSelect.disabled = !isTextMask + elements.shapeFixedWidthField.hidden = !showFixedSizeFields + elements.shapeFixedHeightField.hidden = !showFixedSizeFields + elements.shapeFixedWidthInput.disabled = !showFixedSizeFields + elements.shapeFixedHeightInput.disabled = !showFixedSizeFields + elements.shapeFixedWidthInput.value = String(state.shapeWidth) + elements.shapeFixedHeightInput.value = String(state.shapeHeight) + elements.fillPresetSelect.value = state.fillPresetId + elements.lineHeightInput.value = String(state.lineHeight) + elements.lineHeightValue.textContent = String(state.lineHeight) + elements.textSizeInput.value = String(state.textSize) + elements.textSizeValue.textContent = String(state.textSize) + elements.textWeightSelect.value = String(state.textWeight) + elements.textItalicInput.checked = state.textItalic + elements.textColorInput.value = state.textColor + elements.autoFillModeSelect.value = state.fillStrategy === 'max' ? 'max' : state.autoFillMode + elements.showShapeInput.checked = state.showShape + elements.shapeFillInput.value = state.shapeFill + elements.shapeBorderWidthInput.value = String(state.shapeBorderWidth) + elements.shapeBorderWidthValue.textContent = String(state.shapeBorderWidth) + elements.shapeBorderColorInput.value = state.shapeBorderColor + elements.shapeShadowInput.checked = state.shapeShadow + } + + function applyScenario(name) { + const scenario = demoScenarios[name] + if (!scenario) throw new Error(`Unknown scenario: ${name}`) + + state.scenario = name + state.shapeKind = scenario.shape.kind + state.autoFill = Boolean(scenario.autoFill) + if (!hasUserEditedText && scenario.text !== undefined) { + state.text = scenario.text + } + + if (scenario.shape.kind === 'text-mask') { + const size = scenario.shape.size ?? {} + if (!hasUserEditedShapeText) { + state.shapeText = scenario.shape.text + } + state.shapeFont = scenario.shape.font + state.shapeSizeMode = size.mode === 'fixed' ? 'fixed' : 'fit-content' + if (size.mode === 'fixed') { + state.shapeWidth = size.width + state.shapeHeight = size.height + } + state.shapeTextMode = scenario.shape.shapeTextMode ?? 'whole-text' + state.shapePadding = size.padding ?? 0 + state.shapeMaskScale = scenario.shape.maskScale ?? 2 + state.shapeAlphaThreshold = scenario.shape.alphaThreshold + return + } + + state.polygonPoints = scenario.shape.points.map(point => ({ ...point })) + } + + function renderCurrentState() { + const nextState = cloneStateSnapshot(state) + + try { + commitRenderedState(nextState, renderStateSnapshot(nextState)) + } catch (error) { + restoreCommittedState(error instanceof Error ? error.message : String(error)) + } + } + + return { + populateFillPresetOptions() { + fillTextPresets.forEach(preset => { + const option = document.createElement('option') + option.value = preset.id + option.textContent = preset.label + elements.fillPresetSelect.append(option) + }) + }, + applyScenario, + renderCurrentState, + syncControls, + syncPayloadInput, + applyFillSelection(value) { + if (value === 'max') { + state.autoFillMode = 'stream' + state.fillStrategy = 'max' + return + } + + state.autoFillMode = value + state.fillStrategy = 'flow' + }, + handleTextEdited(value) { + state.text = value + hasUserEditedText = true + state.fillPresetId = 'custom' + syncPayloadInput() + }, + handleShapeTextEdited(value) { + state.shapeText = value + hasUserEditedShapeText = true + syncPayloadInput() + }, + handleShapeTextModeSelected(value) { + state.shapeTextMode = value === 'per-character' ? 'per-character' : 'whole-text' + syncPayloadInput() + }, + handleShapeSizeModeSelected(value) { + state.shapeSizeMode = value === 'fixed' ? 'fixed' : 'fit-content' + syncControls() + syncPayloadInput() + }, + handlePresetSelected(id) { + const preset = findFillTextPresetById(id) + const nextText = resolveFillTextPresetText(preset.id) + if (nextText === null) return + state.text = nextText + state.fillPresetId = preset.id + hasUserEditedText = true + syncControls() + syncPayloadInput() + }, + handlePayloadDraftEdited(value) { + state.payloadDraft = value + hasPayloadDraftChanges = true + }, + applyPayloadText() { + const nextState = cloneStateSnapshot(state) + applyDemoPayloadToState(nextState, JSON.parse(elements.payloadInput.value)) + const rendered = renderStateSnapshot(nextState) + hasPayloadDraftChanges = false + hasUserEditedText = true + hasUserEditedShapeText = true + commitRenderedState(nextState, rendered) + }, + resetPayloadDraft() { + hasPayloadDraftChanges = false + syncPayloadInput(true) + setPayloadError('') + }, + setPayloadError, + } +} diff --git a/e2e/fixtures/index.html b/e2e/fixtures/index.html index 7a2457a..c003aee 100644 --- a/e2e/fixtures/index.html +++ b/e2e/fixtures/index.html @@ -70,6 +70,11 @@ background: #ffffff; } + .payload-textarea { + min-height: 220px; + font: 12px/1.6 ui-monospace, SFMono-Regular, Consolas, monospace; + } + button { border: 1px solid #cbd5e1; background: white; @@ -90,12 +95,36 @@ gap: 16px; } - #stage { + .stage-card { + display: grid; + gap: 12px; + } + + .stage-toolbar { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; + } + + #stage-viewport { min-height: 520px; + max-height: 72vh; border-radius: 24px; background: rgba(255, 255, 255, 0.8); border: 1px solid rgba(148, 163, 184, 0.35); padding: 16px; + overflow: auto; + } + + #stage { + width: max-content; + height: max-content; + transform-origin: top left; + } + + #stage svg { + display: block; } #summary { @@ -131,6 +160,10 @@ color: #334155; } + [hidden] { + display: none !important; + } + input[type="range"] { width: 220px; } @@ -140,6 +173,24 @@ color: #0f172a; } + .muted { + font-size: 12px; + color: #64748b; + } + + .error-box { + margin-top: 10px; + border-radius: 16px; + background: #fef2f2; + color: #b91c1c; + padding: 12px; + white-space: pre-wrap; + } + + details { + margin-top: 16px; + } + @media (max-width: 960px) { .hero, .panel-grid { @@ -160,6 +211,36 @@

shape-text demo

Preview the current library in a real browser UI. Switch between polygon flow and glyph-mask flow, edit text, tweak text style and shape decoration, then render again.

+ + + + + +
+
+ Payload editor +

Edit the live demo payload directly. Use Apply to re-parse and render. Invalid JSON should keep the previous SVG.

+ +
+ + +
+ +

Scenarios

- +
-

Use the shape buttons to compare flow behavior. The glyph scenario uses a real text mask and can repeat by words, dense grapheme flow, or max fill with extra slot coverage while still keeping spaces in the text stream.

+

Use the shape buttons to compare flow behavior. The glyph scenario now defaults to fit-content sizing so multi-character text masks such as 23 render fully before repeat-fill kicks in.

-
+
+
+ + + + + Zoom: 100% +
+
+
+
+

       
diff --git a/e2e/fixtures/shape-text-e2e-app.js b/e2e/fixtures/shape-text-e2e-app.js index 4473dac..7fc4f45 100644 --- a/e2e/fixtures/shape-text-e2e-app.js +++ b/e2e/fixtures/shape-text-e2e-app.js @@ -1,245 +1,97 @@ -import { - compileShapeForLayout, - createCanvasTextMeasurer, - layoutTextInShape, - renderLayoutToSvg, -} from '/dist/index.js' - -const stage = document.querySelector('#stage') -const summary = document.querySelector('#summary') -const textInput = document.querySelector('#text-input') -const lineHeightInput = document.querySelector('#line-height-input') -const lineHeightValue = document.querySelector('#line-height-value') -const textSizeInput = document.querySelector('#text-size-input') -const textSizeValue = document.querySelector('#text-size-value') -const textWeightSelect = document.querySelector('#text-weight-select') -const textItalicInput = document.querySelector('#text-italic-input') -const textColorInput = document.querySelector('#text-color-input') -const autoFillModeSelect = document.querySelector('#auto-fill-mode-select') -const shapeFillInput = document.querySelector('#shape-fill-input') -const shapeBorderWidthInput = document.querySelector('#shape-border-width-input') -const shapeBorderWidthValue = document.querySelector('#shape-border-width-value') -const shapeBorderColorInput = document.querySelector('#shape-border-color-input') -const shapeShadowInput = document.querySelector('#shape-shadow-input') -const showShapeInput = document.querySelector('#show-shape-input') -const renderButton = document.querySelector('#render-button') -const measurer = createCanvasTextMeasurer() - -const defaultParagraph = [ - 'Shape text lets a paragraph travel inside a silhouette thay vi chi wrap quanh float.', - 'Ban co the dung no de render layout hinh so 2, badge, logo block, hoac poster typography.', - 'E2E local o day uu tien deterministic browser path: build dist, import module, render SVG, assert lai state.', -].join(' ') - -function createDigitTwoPolygon(width, height) { - return [ - { x: width * 0.12, y: height * 0.1 }, - { x: width * 0.34, y: 0 }, - { x: width * 0.72, y: height * 0.02 }, - { x: width * 0.9, y: height * 0.16 }, - { x: width * 0.88, y: height * 0.32 }, - { x: width * 0.74, y: height * 0.44 }, - { x: width * 0.5, y: height * 0.57 }, - { x: width * 0.26, y: height * 0.72 }, - { x: width * 0.14, y: height * 0.86 }, - { x: width * 0.88, y: height * 0.86 }, - { x: width * 0.86, y: height }, - { x: 0, y: height }, - { x: 0, y: height * 0.78 }, - { x: width * 0.12, y: height * 0.64 }, - { x: width * 0.35, y: height * 0.5 }, - { x: width * 0.62, y: height * 0.35 }, - { x: width * 0.74, y: height * 0.26 }, - { x: width * 0.72, y: height * 0.14 }, - { x: width * 0.58, y: height * 0.08 }, - { x: width * 0.34, y: height * 0.08 }, - ] -} - -const scenarios = { - 'glyph-two-repeat': { - shape: { - kind: 'text-mask', - text: '2', - font: '700 420px Arial', - width: 340, - height: 460, - padding: 10, - maskScale: 2, - }, - autoFill: true, - text: 'ONE', - }, - 'digit-two-wide': { - shape: { kind: 'polygon', points: createDigitTwoPolygon(340, 460) }, - text: defaultParagraph, - }, - 'digit-two-narrow': { - shape: { kind: 'polygon', points: createDigitTwoPolygon(240, 460) }, - text: defaultParagraph, - }, - 'rectangle-wide': { - shape: { - kind: 'polygon', - points: [ - { x: 0, y: 0 }, - { x: 340, y: 0 }, - { x: 340, y: 360 }, - { x: 0, y: 360 }, - ], - }, - text: defaultParagraph, - }, - 'rectangle-narrow': { - shape: { - kind: 'polygon', - points: [ - { x: 0, y: 0 }, - { x: 220, y: 0 }, - { x: 220, y: 360 }, - { x: 0, y: 360 }, - ], - }, - text: defaultParagraph, - }, +import { compileShapeForLayout, createCanvasTextMeasurer } from '/dist/index.js' +import { createDemoSvgViewportController } from './demo-svg-viewport-controller.js' +import { createDemoUiController } from './demo-ui-controller.js' +import { demoScenarios } from './demo-scenarios.js' + +const $ = selector => document.querySelector(selector) +const elements = { + stage: $('#stage'), + summary: $('#summary'), + stageViewport: $('#stage-viewport'), + textInput: $('#text-input'), + shapeTextInput: $('#shape-text-input'), + shapeTextModeSelect: $('#shape-text-mode-select'), + shapeSizeModeSelect: $('#shape-size-mode-select'), + shapeFixedWidthField: $('#shape-fixed-width-field'), + shapeFixedWidthInput: $('#shape-fixed-width-input'), + shapeFixedHeightField: $('#shape-fixed-height-field'), + shapeFixedHeightInput: $('#shape-fixed-height-input'), + fillPresetSelect: $('#fill-preset-select'), + payloadInput: $('#payload-input'), + payloadError: $('#payload-error'), + lineHeightInput: $('#line-height-input'), + lineHeightValue: $('#line-height-value'), + textSizeInput: $('#text-size-input'), + textSizeValue: $('#text-size-value'), + textWeightSelect: $('#text-weight-select'), + textItalicInput: $('#text-italic-input'), + textColorInput: $('#text-color-input'), + autoFillModeSelect: $('#auto-fill-mode-select'), + shapeFillInput: $('#shape-fill-input'), + shapeBorderWidthInput: $('#shape-border-width-input'), + shapeBorderWidthValue: $('#shape-border-width-value'), + shapeBorderColorInput: $('#shape-border-color-input'), + shapeShadowInput: $('#shape-shadow-input'), + showShapeInput: $('#show-shape-input'), + zoomOutButton: $('#zoom-out-button'), + zoomInButton: $('#zoom-in-button'), + zoomResetButton: $('#zoom-reset-button'), + zoomFitButton: $('#zoom-fit-button'), + zoomValue: $('#zoom-value'), } - const state = { - scenario: '', + scenario: 'glyph-two-repeat', layout: null, svg: '', + payloadDraft: '', + payloadError: '', text: 'ONE', + fillPresetId: 'custom', lineHeight: 22, textSize: 18, textWeight: 700, textItalic: false, textColor: '#111827', + autoFill: true, autoFillMode: 'words', fillStrategy: 'flow', + minSlotWidth: undefined, + background: '#fffdf7', + renderPadding: 12, showShape: true, shapeFill: '#dbeafe', shapeBorderWidth: 2, shapeBorderColor: '#94a3b8', shapeShadow: true, + shapeKind: 'text-mask', + shapeText: '23', + shapeTextMode: 'whole-text', + shapeSizeMode: 'fit-content', + shapeFont: '700 420px Arial', + shapeWidth: 340, + shapeHeight: 460, + shapePadding: 10, + shapeMaskScale: 2, + shapeAlphaThreshold: undefined, + polygonPoints: [], + stageZoom: 1, + stageSvgWidth: 0, + stageSvgHeight: 0, } -let hasUserEditedText = false - -function getFillSelectValue() { - return state.fillStrategy === 'max' ? 'max' : state.autoFillMode -} - -function applyFillSelection(value) { - if (value === 'max') { - state.autoFillMode = 'stream' - state.fillStrategy = 'max' - return - } - - state.autoFillMode = value - state.fillStrategy = 'flow' -} - -function getTextStyle() { - return { - family: '"Helvetica Neue", Arial, sans-serif', - size: state.textSize, - weight: state.textWeight, - style: state.textItalic ? 'italic' : 'normal', - color: state.textColor, - } -} - -function getShapeStyle() { - return { - backgroundColor: state.shapeFill, - borderColor: state.shapeBorderColor, - borderWidth: state.shapeBorderWidth, - shadow: state.shapeShadow - ? { - color: 'rgba(15, 23, 42, 0.22)', - blur: 6, - offsetX: 0, - offsetY: 6, - } - : undefined, - } -} - -function resolveScenarioText(name) { - const scenario = scenarios[name] - if (!scenario) throw new Error(`Unknown scenario: ${name}`) - - if (!hasUserEditedText && scenario.text !== undefined) { - state.text = scenario.text - } - - return scenario -} - -function renderScenario(name) { - const scenario = resolveScenarioText(name) - - const layout = layoutTextInShape({ - text: state.text, - textStyle: getTextStyle(), - lineHeight: state.lineHeight, - shape: scenario.shape, - measurer, - minSlotWidth: state.fillStrategy === 'max' ? 8 : 24, - autoFill: scenario.autoFill, - autoFillMode: state.autoFillMode, - fillStrategy: state.fillStrategy, - }) - - const svg = renderLayoutToSvg(layout, { - background: '#fffdf7', - shapeStyle: getShapeStyle(), - showShape: state.showShape, - padding: 12, - }) - - state.scenario = name - state.layout = layout - state.svg = svg - textInput.value = state.text - stage.innerHTML = svg - summary.textContent = JSON.stringify( - { - scenario: name, - shapeKind: scenario.shape.kind, - autoFill: Boolean(scenario.autoFill), - autoFillMode: layout.autoFillMode, - fillStrategy: layout.fillStrategy, - lineHeight: state.lineHeight, - textStyle: getTextStyle(), - shapeStyle: getShapeStyle(), - lineCount: layout.lines.length, - exhausted: layout.exhausted, - firstLine: layout.lines[0]?.text ?? null, - lastLine: layout.lines.at(-1)?.text ?? null, - }, - null, - 2, - ) -} +const measurer = createCanvasTextMeasurer() +const viewport = createDemoSvgViewportController({ state, elements }) +const controller = createDemoUiController({ state, elements, measurer, viewport }) window.shapeTextTestApi = { - renderScenario, + renderScenario(name) { + controller.applyScenario(name) + controller.renderCurrentState() + }, compileScenarioTwice(name) { - const scenario = scenarios[name] + const scenario = demoScenarios[name] if (!scenario) throw new Error(`Unknown scenario: ${name}`) - - const first = compileShapeForLayout({ - shape: scenario.shape, - lineHeight: state.lineHeight, - minSlotWidth: 24, - }) - const second = compileShapeForLayout({ - shape: scenario.shape, - lineHeight: state.lineHeight, - minSlotWidth: 24, - }) - + const first = compileShapeForLayout({ shape: scenario.shape, lineHeight: state.lineHeight, minSlotWidth: 24 }) + const second = compileShapeForLayout({ shape: scenario.shape, lineHeight: state.lineHeight, minSlotWidth: 24 }) return { sameReference: first === second, isFrozen: Object.isFrozen(first) && Object.isFrozen(first.bands), @@ -252,16 +104,18 @@ window.shapeTextTestApi = { compileShapeForLayout({ shape: { kind: 'text-mask', - text: '2', + text: '23', font: '700 420px Arial', - width: 340, - height: 460, + size: { + mode: 'fixed', + width: 340, + height: 460, + }, alphaThreshold: 999, }, lineHeight: state.lineHeight, minSlotWidth: 24, }) - return null } catch (error) { return error instanceof Error ? error.message : String(error) @@ -272,65 +126,52 @@ window.shapeTextTestApi = { }, } -function syncControls() { - textInput.value = state.text - lineHeightInput.value = String(state.lineHeight) - lineHeightValue.textContent = String(state.lineHeight) - textSizeInput.value = String(state.textSize) - textSizeValue.textContent = String(state.textSize) - textWeightSelect.value = String(state.textWeight) - textItalicInput.checked = state.textItalic - textColorInput.value = state.textColor - autoFillModeSelect.value = getFillSelectValue() - showShapeInput.checked = state.showShape - shapeFillInput.value = state.shapeFill - shapeBorderWidthInput.value = String(state.shapeBorderWidth) - shapeBorderWidthValue.textContent = String(state.shapeBorderWidth) - shapeBorderColorInput.value = state.shapeBorderColor - shapeShadowInput.checked = state.shapeShadow -} - -renderButton.addEventListener('click', () => { - state.text = textInput.value - state.lineHeight = Number(lineHeightInput.value) - state.textSize = Number(textSizeInput.value) - state.textWeight = Number(textWeightSelect.value) - state.textItalic = textItalicInput.checked - state.textColor = textColorInput.value - applyFillSelection(autoFillModeSelect.value) - state.showShape = showShapeInput.checked - state.shapeFill = shapeFillInput.value - state.shapeBorderWidth = Number(shapeBorderWidthInput.value) - state.shapeBorderColor = shapeBorderColorInput.value - state.shapeShadow = shapeShadowInput.checked - lineHeightValue.textContent = String(state.lineHeight) - textSizeValue.textContent = String(state.textSize) - shapeBorderWidthValue.textContent = String(state.shapeBorderWidth) - renderScenario(state.scenario || 'digit-two-wide') -}) - -textInput.addEventListener('input', () => { - state.text = textInput.value - hasUserEditedText = true -}) - -lineHeightInput.addEventListener('input', () => { - lineHeightValue.textContent = lineHeightInput.value -}) - -textSizeInput.addEventListener('input', () => { - textSizeValue.textContent = textSizeInput.value -}) - -shapeBorderWidthInput.addEventListener('input', () => { - shapeBorderWidthValue.textContent = shapeBorderWidthInput.value +controller.populateFillPresetOptions() +elements.textInput.addEventListener('input', () => controller.handleTextEdited(elements.textInput.value)) +elements.shapeTextInput.addEventListener('input', () => controller.handleShapeTextEdited(elements.shapeTextInput.value)) +elements.shapeTextModeSelect.addEventListener('change', () => + controller.handleShapeTextModeSelected(elements.shapeTextModeSelect.value), +) +elements.shapeSizeModeSelect.addEventListener('change', () => + controller.handleShapeSizeModeSelected(elements.shapeSizeModeSelect.value), +) +elements.fillPresetSelect.addEventListener('change', () => controller.handlePresetSelected(elements.fillPresetSelect.value)) +elements.payloadInput.addEventListener('input', () => controller.handlePayloadDraftEdited(elements.payloadInput.value)) +elements.lineHeightInput.addEventListener('input', () => { state.lineHeight = Number(elements.lineHeightInput.value); controller.syncControls(); controller.syncPayloadInput() }) +elements.textSizeInput.addEventListener('input', () => { state.textSize = Number(elements.textSizeInput.value); controller.syncControls(); controller.syncPayloadInput() }) +elements.textWeightSelect.addEventListener('change', () => { state.textWeight = Number(elements.textWeightSelect.value); controller.syncPayloadInput() }) +elements.textItalicInput.addEventListener('change', () => { state.textItalic = elements.textItalicInput.checked; controller.syncPayloadInput() }) +elements.textColorInput.addEventListener('input', () => { state.textColor = elements.textColorInput.value; controller.syncPayloadInput() }) +elements.autoFillModeSelect.addEventListener('change', () => { controller.applyFillSelection(elements.autoFillModeSelect.value); controller.syncPayloadInput() }) +elements.shapeFillInput.addEventListener('input', () => { state.shapeFill = elements.shapeFillInput.value; controller.syncPayloadInput() }) +elements.shapeBorderWidthInput.addEventListener('input', () => { state.shapeBorderWidth = Number(elements.shapeBorderWidthInput.value); controller.syncControls(); controller.syncPayloadInput() }) +elements.shapeBorderColorInput.addEventListener('input', () => { state.shapeBorderColor = elements.shapeBorderColorInput.value; controller.syncPayloadInput() }) +elements.shapeShadowInput.addEventListener('change', () => { state.shapeShadow = elements.shapeShadowInput.checked; controller.syncPayloadInput() }) +elements.showShapeInput.addEventListener('change', () => { state.showShape = elements.showShapeInput.checked; controller.syncPayloadInput() }) +elements.shapeFixedWidthInput.addEventListener('input', () => { state.shapeWidth = Number(elements.shapeFixedWidthInput.value); controller.syncPayloadInput() }) +elements.shapeFixedHeightInput.addEventListener('input', () => { state.shapeHeight = Number(elements.shapeFixedHeightInput.value); controller.syncPayloadInput() }) +elements.zoomOutButton.addEventListener('click', () => viewport.zoomOut()) +elements.zoomInButton.addEventListener('click', () => viewport.zoomIn()) +elements.zoomResetButton.addEventListener('click', () => viewport.resetZoom()) +elements.zoomFitButton.addEventListener('click', () => viewport.fitZoom()) +$('#render-button').addEventListener('click', () => controller.renderCurrentState()) +$('#apply-payload-button').addEventListener('click', () => { + try { + controller.applyPayloadText() + } catch (error) { + controller.setPayloadError(error instanceof Error ? error.message : String(error)) + } }) - +$('#reset-payload-button').addEventListener('click', () => controller.resetPayloadDraft()) document.querySelectorAll('[data-scenario]').forEach(button => { button.addEventListener('click', () => { - renderScenario(button.getAttribute('data-scenario')) + controller.applyScenario(button.getAttribute('data-scenario')) + controller.renderCurrentState() }) }) -syncControls() -renderScenario('glyph-two-repeat') +controller.applyScenario('glyph-two-repeat') +controller.syncControls() +controller.syncPayloadInput(true) +viewport.syncZoomLabel() +controller.renderCurrentState() diff --git a/e2e/shape-text-local-e2e.spec.ts b/e2e/shape-text-local-e2e.spec.ts index 38747a4..f4ef701 100644 --- a/e2e/shape-text-local-e2e.spec.ts +++ b/e2e/shape-text-local-e2e.spec.ts @@ -18,6 +18,15 @@ declare global { autoFillMode: 'words' | 'dense' | 'stream' fillStrategy: 'flow' | 'max' exhausted: boolean + compiledShape?: { + bounds: { + left: number + top: number + right: number + bottom: number + } + regions?: Array<{ grapheme: string }> + } lines: Array<{ text: string x: number @@ -28,17 +37,27 @@ declare global { }> } text: string + fillPresetId: string textSize: number textWeight: number textItalic: boolean textColor: string autoFillMode: 'words' | 'dense' | 'stream' fillStrategy: 'flow' | 'max' + shapeKind: 'polygon' | 'text-mask' + shapeText: string + shapeTextMode: 'whole-text' | 'per-character' + shapeSizeMode: 'fit-content' | 'fixed' showShape: boolean shapeFill: string shapeBorderWidth: number shapeBorderColor: string shapeShadow: boolean + stageZoom: number + stageSvgWidth: number + stageSvgHeight: number + payloadDraft: string + payloadError: string svg: string } } @@ -79,6 +98,8 @@ test('renders the glyph text-mask fixture into SVG', async ({ page }) => { expect(state.layout.fillStrategy).toBe('flow') expect(state.layout.exhausted).toBe(false) expect(state.layout.lines[0]?.text).toContain('ONE') + expect(state.shapeText).toBe('23') + expect(state.shapeSizeMode).toBe('fit-content') expect(state.textSize).toBe(18) const expectedTextNodeCount = state.showShape && state.scenario === 'glyph-two-repeat' @@ -136,6 +157,292 @@ test('applies text and shape style controls to the rendered SVG', async ({ page expect(state.svg).toContain('shape-text-shape-shadow') }) +test('lets the demo edit glyph shape text directly', async ({ page }) => { + await page.goto('/') + + await page.locator('#shape-text-input').fill('8') + await page.locator('#render-button').click() + + const state = await getState(page) + + await expect(page.locator('#shape-text-input')).toHaveValue('8') + expect(state.shapeKind).toBe('text-mask') + expect(state.shapeText).toBe('8') + expect(state.svg).toContain('>8<') +}) + +test('restores the last good state when direct render controls produce an invalid shape', async ({ + page, +}) => { + await page.goto('/') + + const before = await getState(page) + await page.locator('#shape-text-input').fill('') + await page.locator('#render-button').click() + + const after = await getState(page) + + await expect(page.locator('#shape-text-input')).toHaveValue(before.shapeText) + expect(after.shapeText).toBe(before.shapeText) + expect(after.svg).toBe(before.svg) + expect(after.payloadError.length).toBeGreaterThan(0) +}) + +test('keeps wide output inspectable inside the zoomable stage viewport', async ({ page }) => { + await page.goto('/') + + const initialState = await getState(page) + const initialWidth = + (initialState.layout.compiledShape?.bounds.right ?? 0) - + (initialState.layout.compiledShape?.bounds.left ?? 0) + const payload = JSON.parse(await page.locator('#payload-input').inputValue()) + payload.layout.shape.text = '20252025' + payload.layout.shape.shapeTextMode = 'whole-text' + + await page.locator('#payload-input').fill(JSON.stringify(payload, null, 2)) + await page.locator('#apply-payload-button').click() + + const beforeZoom = await getState(page) + const widenedShapeWidth = + (beforeZoom.layout.compiledShape?.bounds.right ?? 0) - + (beforeZoom.layout.compiledShape?.bounds.left ?? 0) + const viewportMetrics = await page.locator('#stage-viewport').evaluate(element => ({ + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + })) + + await page.locator('#zoom-in-button').click() + const zoomedIn = await getState(page) + await page.locator('#zoom-out-button').click() + const zoomedOut = await getState(page) + await page.locator('#zoom-fit-button').click() + const fitState = await getState(page) + const fitViewportMetrics = await page.locator('#stage-viewport').evaluate(element => ({ + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + })) + await page.locator('#zoom-reset-button').click() + const resetState = await getState(page) + + expect(widenedShapeWidth).toBeGreaterThan(initialWidth) + expect(viewportMetrics.scrollWidth).toBeGreaterThan(viewportMetrics.clientWidth) + expect(zoomedIn.stageZoom).toBeGreaterThan(beforeZoom.stageZoom) + expect(zoomedOut.stageZoom).toBeLessThan(zoomedIn.stageZoom) + expect(fitState.stageZoom).toBeGreaterThan(0) + expect(fitViewportMetrics.scrollWidth).toBeLessThanOrEqual(fitViewportMetrics.clientWidth + 12) + expect(resetState.stageZoom).toBe(1) + await expect(page.locator('#stage svg')).toBeVisible() +}) + +test('applies payload editor changes to the live demo state', async ({ page }) => { + await page.goto('/') + + const payload = JSON.parse(await page.locator('#payload-input').inputValue()) + payload.layout.text = 'DATA' + payload.layout.textStyle.size = 30 + payload.layout.shape.text = '8' + + await page.locator('#payload-input').fill(JSON.stringify(payload, null, 2)) + await page.locator('#apply-payload-button').click() + + const state = await getState(page) + + expect(state.text).toBe('DATA') + expect(state.textSize).toBe(30) + expect(state.shapeText).toBe('8') + expect(state.payloadError).toBe('') + expect(state.svg).toContain('font:normal 700 30px') + expect(state.svg).toContain('>8<') +}) + +test('switches text-mask sizing between fit-content and fixed', async ({ page }) => { + await page.goto('/') + + await expect(page.locator('#shape-fixed-width-field')).toBeHidden() + await expect(page.locator('#shape-fixed-height-field')).toBeHidden() + + await page.locator('#shape-size-mode-select').selectOption('fixed') + await expect(page.locator('#shape-fixed-width-field')).toBeVisible() + await expect(page.locator('#shape-fixed-height-field')).toBeVisible() + + await page.locator('#shape-fixed-width-input').fill('520') + await page.locator('#shape-fixed-height-input').fill('260') + await page.locator('#render-button').click() + + const fixedState = await getState(page) + const fixedWidth = + (fixedState.layout.compiledShape?.bounds.right ?? 0) - + (fixedState.layout.compiledShape?.bounds.left ?? 0) + + expect(fixedState.shapeSizeMode).toBe('fixed') + expect(fixedWidth).toBe(520) + expect(fixedState.payloadDraft).toContain('"mode": "fixed"') + + await page.locator('#shape-size-mode-select').selectOption('fit-content') + await page.locator('#render-button').click() + await expect(page.locator('#shape-fixed-width-field')).toBeHidden() + await expect(page.locator('#shape-fixed-height-field')).toBeHidden() + + const fitState = await getState(page) + const fitWidth = + (fitState.layout.compiledShape?.bounds.right ?? 0) - + (fitState.layout.compiledShape?.bounds.left ?? 0) + + expect(fitState.shapeSizeMode).toBe('fit-content') + expect(fitState.payloadDraft).toContain('"mode": "fit-content"') + expect(fitWidth).toBeLessThan(fixedWidth) +}) + +test('switches text-mask rendering between whole-text and per-character regions', async ({ + page, +}) => { + await page.goto('/') + + const payload = JSON.parse(await page.locator('#payload-input').inputValue()) + payload.layout.autoFill = false + payload.layout.text = 'ABCDEFGHIJ' + payload.layout.lineHeight = 18 + payload.layout.minSlotWidth = 1 + payload.layout.textStyle.size = 14 + payload.layout.shape.text = 'AB' + payload.layout.shape.font = '700 260px Arial' + payload.layout.shape.size = { + mode: 'fixed', + width: 520, + height: 260, + } + payload.layout.shape.shapeTextMode = 'whole-text' + + await page.locator('#payload-input').fill(JSON.stringify(payload, null, 2)) + await page.locator('#apply-payload-button').click() + const wholeTextState = await getState(page) + + await page.locator('#shape-text-mode-select').selectOption('per-character') + await page.locator('#render-button').click() + const perCharacterState = await getState(page) + + expect(wholeTextState.shapeTextMode).toBe('whole-text') + expect(perCharacterState.shapeTextMode).toBe('per-character') + expect(perCharacterState.payloadDraft).toContain('"shapeTextMode": "per-character"') + expect(wholeTextState.layout.compiledShape?.regions ?? []).toHaveLength(0) + expect(perCharacterState.layout.compiledShape?.regions?.map(region => region.grapheme)).toEqual(['A', 'B']) + expect(perCharacterState.layout.lines.length).toBeGreaterThan(0) +}) + +test('keeps the last successful SVG when payload parsing fails', async ({ page }) => { + await page.goto('/') + + const before = await getState(page) + await page.locator('#payload-input').fill('{"layout":') + await page.locator('#apply-payload-button').click() + + const after = await getState(page) + + expect(after.payloadError.length).toBeGreaterThan(0) + expect(after.svg).toBe(before.svg) + expect(after.text).toBe(before.text) + expect(after.shapeText).toBe(before.shapeText) + await expect(page.locator('#payload-error')).toBeVisible() +}) + +test('keeps state unchanged when payload validation fails mid-apply', async ({ page }) => { + await page.goto('/') + + const before = await getState(page) + const payload = JSON.parse(await page.locator('#payload-input').inputValue()) + payload.layout.text = 'BROKEN' + payload.layout.shape.text = '8' + payload.render.padding = 'oops' + + await page.locator('#payload-input').fill(JSON.stringify(payload, null, 2)) + await page.locator('#apply-payload-button').click() + + const after = await getState(page) + + expect(after.payloadError).toContain('render.padding') + expect(after.text).toBe(before.text) + expect(after.shapeText).toBe(before.shapeText) + expect(after.svg).toBe(before.svg) +}) + +test('keeps state unchanged when payload render fails after JSON parsing succeeds', async ({ + page, +}) => { + await page.goto('/') + + const before = await getState(page) + const payload = JSON.parse(await page.locator('#payload-input').inputValue()) + payload.layout.text = 'BROKEN-BUT-VALID' + payload.layout.shape.text = '' + + await page.locator('#payload-input').fill(JSON.stringify(payload, null, 2)) + await page.locator('#apply-payload-button').click() + + const after = await getState(page) + + expect(after.payloadError.length).toBeGreaterThan(0) + expect(after.text).toBe(before.text) + expect(after.shapeText).toBe(before.shapeText) + expect(after.svg).toBe(before.svg) +}) + +test('preserves omitted payload fields instead of resetting styling and fill mode', async ({ + page, +}) => { + await page.goto('/') + + await page.locator('#text-italic-input').check() + await page.locator('#auto-fill-mode-select').selectOption('max') + await page.locator('#render-button').click() + + const payload = JSON.parse(await page.locator('#payload-input').inputValue()) + delete payload.layout.fillStrategy + delete payload.layout.autoFillMode + payload.layout.textStyle = { size: 22 } + + await page.locator('#payload-input').fill(JSON.stringify(payload, null, 2)) + await page.locator('#apply-payload-button').click() + + const state = await getState(page) + + expect(state.textSize).toBe(22) + expect(state.textItalic).toBe(true) + expect(state.fillStrategy).toBe('max') + expect(state.autoFillMode).toBe('stream') +}) + +test('keeps payloadDraft aligned with the visible textarea while draft edits are dirty', async ({ + page, +}) => { + await page.goto('/') + + const payload = JSON.parse(await page.locator('#payload-input').inputValue()) + payload.layout.text = 'DIRTY' + const dirtyDraft = JSON.stringify(payload, null, 2) + + await page.locator('#payload-input').fill(dirtyDraft) + await setRangeValue(page, '#text-size-input', '24') + + const state = await getState(page) + + expect(state.payloadDraft).toBe(await page.locator('#payload-input').inputValue()) + expect(state.payloadDraft).toBe(dirtyDraft) +}) + +test('syncs predefined fill presets into text input and payload draft', async ({ page }) => { + await page.goto('/') + + await page.locator('#fill-preset-select').selectOption('binary-random') + + const state = await getState(page) + + await expect(page.locator('#text-input')).toHaveValue(state.text) + expect(state.fillPresetId).toBe('binary-random') + expect(state.text).toMatch(/^[01]+$/) + expect(state.text.length).toBeGreaterThan(100) + expect(state.payloadDraft).toContain(`"text": "${state.text}"`) +}) + test('packs glyph autofill more tightly in dense mode', async ({ page }) => { await page.goto('/') diff --git a/src/index.ts b/src/index.ts index ed87596..e6d7007 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ export type { AutoFillMode, CompiledShapeBand, CompiledShapeBands, + CompiledShapeRegion, CompileShapeForLayoutOptions, FillStrategy, Interval, @@ -24,6 +25,11 @@ export type { ShapeTextLine, ShapeTextPoint, TextMaskShape, + TextMaskShapeFixedSize, + TextMaskShapeFitContentSize, + TextMaskShapeSize, + TextMaskShapeSizeMode, + TextMaskShapeTextMode, TextStyleInput, TextMeasurer, } from './types.js' diff --git a/src/layout/layout-flow-lines-in-compiled-shape.ts b/src/layout/layout-flow-lines-in-compiled-shape.ts new file mode 100644 index 0000000..d19680b --- /dev/null +++ b/src/layout/layout-flow-lines-in-compiled-shape.ts @@ -0,0 +1,104 @@ +import type { + AutoFillMode, + CompiledShapeBands, + LayoutCursor, + PreparedLayoutText, + PreparedLayoutToken, + ShapeTextLine, +} from '../types.js' +import { layoutNextLineFromPreparedText } from '../text/layout-next-line-from-prepared-text.js' +import { layoutNextLineFromRepeatedText } from '../text/layout-next-line-from-repeated-text.js' +import { layoutDenseFillPass } from './layout-dense-fill-pass.js' + +function pickWidestInterval(intervals: CompiledShapeBands['bands'][number]['intervals']) { + let best = intervals[0]! + + for (let index = 1; index < intervals.length; index++) { + const candidate = intervals[index]! + if (candidate.right - candidate.left > best.right - best.left) { + best = candidate + } + } + + return best +} + +type LayoutFlowLinesInCompiledShapeOptions = { + compiledShape: CompiledShapeBands + prepared?: PreparedLayoutText + densePattern?: PreparedLayoutToken + autoFill: boolean + autoFillMode: AutoFillMode + align: 'left' | 'center' + baselineRatio: number + startCursor: LayoutCursor +} + +export function layoutFlowLinesInCompiledShape( + options: LayoutFlowLinesInCompiledShapeOptions, +): { + lines: ShapeTextLine[] + endCursor: LayoutCursor +} { + const lines: ShapeTextLine[] = [] + let cursor: LayoutCursor = options.startCursor + + for (let index = 0; index < options.compiledShape.bands.length; index++) { + const band = options.compiledShape.bands[index]! + if (band.intervals.length === 0) { + continue + } + + const slot = pickWidestInterval(band.intervals) + if (options.autoFill && options.autoFillMode === 'dense') { + const denseLine = + layoutDenseFillPass({ + compiledShape: { + ...options.compiledShape, + bands: [{ ...band, intervals: [slot] }], + }, + densePattern: options.densePattern!, + startOffset: cursor.tokenIndex, + align: options.align, + baselineRatio: options.baselineRatio, + allSlots: false, + }).lines[0] ?? null + + if (denseLine === null) { + break + } + + lines.push(denseLine) + cursor = denseLine.end + continue + } + + const line = options.autoFill + ? layoutNextLineFromRepeatedText(options.prepared!, cursor, slot.right - slot.left) + : layoutNextLineFromPreparedText(options.prepared!, cursor, slot.right - slot.left) + + if (line === null) { + break + } + + const x = + options.align === 'center' + ? slot.left + Math.max(0, (slot.right - slot.left - line.width) / 2) + : slot.left + + lines.push({ + ...line, + x, + top: band.top, + baseline: band.top + options.compiledShape.bandHeight * options.baselineRatio, + slot, + }) + + cursor = line.end + } + + return { + lines, + endCursor: cursor, + } +} diff --git a/src/layout/layout-text-in-compiled-shape.ts b/src/layout/layout-text-in-compiled-shape.ts index 20ac17f..047bb37 100644 --- a/src/layout/layout-text-in-compiled-shape.ts +++ b/src/layout/layout-text-in-compiled-shape.ts @@ -1,6 +1,10 @@ import type { AutoFillMode, FillStrategy, LayoutTextInCompiledShapeOptions, ShapeTextLayout } from '../types.js' import { resolveFlowLayout } from './resolve-flow-layout.js' import { resolveMaxFillLayout } from './resolve-max-fill-layout.js' +import { + hasSequentialShapeRegions, + resolveSequentialShapeRegions, +} from './resolve-sequential-shape-regions.js' export function layoutTextInCompiledShape( options: LayoutTextInCompiledShapeOptions, @@ -9,6 +13,10 @@ export function layoutTextInCompiledShape( const autoFillMode: AutoFillMode = autoFill ? (options.autoFillMode ?? 'words') : 'words' const fillStrategy: FillStrategy = autoFill ? (options.fillStrategy ?? 'flow') : 'flow' + if (hasSequentialShapeRegions(options.compiledShape)) { + return resolveSequentialShapeRegions(options) + } + if (fillStrategy === 'max') { return resolveMaxFillLayout(options) } diff --git a/src/layout/layout-text-in-shape.test.ts b/src/layout/layout-text-in-shape.test.ts index b0cb0a0..d7767cc 100644 --- a/src/layout/layout-text-in-shape.test.ts +++ b/src/layout/layout-text-in-shape.test.ts @@ -181,6 +181,118 @@ describe('layoutTextInShape', () => { ]) }) + it('fills per-character text-mask regions sequentially while preserving region order', () => { + const layout = layoutTextInCompiledShape({ + text: 'ABCD', + font: '16px Test Sans', + compiledShape: { + kind: 'text-mask', + source: { + kind: 'text-mask', + text: 'A B', + font: '700 160px Test Sans', + size: { + mode: 'fixed', + width: 80, + height: 20, + }, + shapeTextMode: 'per-character', + }, + bounds: { left: 0, top: 0, right: 80, bottom: 20 }, + bandHeight: 20, + minSlotWidth: 1, + bands: [ + { + top: 0, + bottom: 20, + intervals: [ + { left: 0, right: 20 }, + { left: 40, right: 60 }, + ], + }, + ], + regions: [ + { + index: 0, + grapheme: 'A', + bounds: { left: 0, top: 0, right: 80, bottom: 20 }, + bands: [{ top: 0, bottom: 20, intervals: [{ left: 0, right: 20 }] }], + debugView: { + kind: 'text', + text: 'A', + font: '700 160px Test Sans', + x: 0, + baseline: 16, + }, + }, + { + index: 1, + grapheme: 'B', + bounds: { left: 0, top: 0, right: 80, bottom: 20 }, + bands: [{ top: 0, bottom: 20, intervals: [{ left: 40, right: 60 }] }], + debugView: { + kind: 'text', + text: 'B', + font: '700 160px Test Sans', + x: 40, + baseline: 16, + }, + }, + ], + debugView: { + kind: 'text', + text: 'A B', + font: '700 160px Test Sans', + x: 0, + baseline: 16, + }, + }, + measurer: createFixedWidthTextMeasurer(), + }) + + expect(layout.lines.map(line => [line.text, line.x])).toEqual([ + ['AB', 0], + ['CD', 40], + ]) + }) + + it('falls back to whole-shape flow when per-character regions are empty', () => { + const layout = layoutTextInCompiledShape({ + text: 'ABCD', + font: '16px Test Sans', + compiledShape: { + kind: 'text-mask', + source: { + kind: 'text-mask', + text: 'III', + font: '700 160px Test Sans', + size: { + mode: 'fixed', + width: 60, + height: 20, + }, + shapeTextMode: 'per-character', + }, + bounds: { left: 0, top: 0, right: 60, bottom: 20 }, + bandHeight: 20, + minSlotWidth: 50, + bands: [{ top: 0, bottom: 20, intervals: [{ left: 0, right: 60 }] }], + regions: [], + debugView: { + kind: 'text', + text: 'III', + font: '700 160px Test Sans', + x: 0, + baseline: 16, + }, + }, + measurer: createFixedWidthTextMeasurer(), + }) + + expect(layout.lines.map(line => line.text)).toEqual(['ABCD']) + expect(layout.fillStrategy).toBe('flow') + }) + it('strips whitespace and continues dense fill across line boundaries', () => { const layout = layoutTextInShape({ text: 'A B', diff --git a/src/layout/resolve-flow-layout.ts b/src/layout/resolve-flow-layout.ts index 5438d6c..12dd1bb 100644 --- a/src/layout/resolve-flow-layout.ts +++ b/src/layout/resolve-flow-layout.ts @@ -1,30 +1,8 @@ -import type { - AutoFillMode, - Interval, - LayoutCursor, - LayoutTextInCompiledShapeOptions, - ShapeTextLayout, - ShapeTextLine, -} from '../types.js' -import { layoutNextLineFromPreparedText } from '../text/layout-next-line-from-prepared-text.js' -import { layoutNextLineFromRepeatedText } from '../text/layout-next-line-from-repeated-text.js' +import type { AutoFillMode, LayoutTextInCompiledShapeOptions, ShapeTextLayout } from '../types.js' import { resolveLayoutTextStyle } from '../text/normalize-text-style-to-font.js' import { prepareDenseRepeatFillPattern } from '../text/prepare-dense-repeat-fill-pattern.js' import { prepareTextForLayout } from '../text/prepare-text-for-layout.js' -import { layoutDenseFillPass } from './layout-dense-fill-pass.js' - -function pickWidestInterval(intervals: Interval[]): Interval { - let best = intervals[0]! - - for (let index = 1; index < intervals.length; index++) { - const candidate = intervals[index]! - if (candidate.right - candidate.left > best.right - best.left) { - best = candidate - } - } - - return best -} +import { layoutFlowLinesInCompiledShape } from './layout-flow-lines-in-compiled-shape.js' export function resolveFlowLayout( options: LayoutTextInCompiledShapeOptions, @@ -45,62 +23,16 @@ export function resolveFlowLayout( autoFillMode === 'dense' ? prepareDenseRepeatFillPattern(options.text, resolvedTextStyle.font, options.measurer) : undefined - const lines: ShapeTextLine[] = [] - let cursor: LayoutCursor = { tokenIndex: 0, graphemeIndex: 0 } - - for (let index = 0; index < options.compiledShape.bands.length; index++) { - const band = options.compiledShape.bands[index]! - if (band.intervals.length === 0) { - continue - } - - const slot = pickWidestInterval(band.intervals) - if (autoFill && autoFillMode === 'dense') { - const denseLine = - layoutDenseFillPass({ - compiledShape: { - ...options.compiledShape, - bands: [{ ...band, intervals: [slot] }], - }, - densePattern: densePattern!, - startOffset: cursor.tokenIndex, - align, - baselineRatio, - allSlots: false, - }).lines[0] ?? null - - if (denseLine === null) { - break - } - - lines.push(denseLine) - cursor = denseLine.end - continue - } - - const line = autoFill - ? layoutNextLineFromRepeatedText(prepared!, cursor, slot.right - slot.left) - : layoutNextLineFromPreparedText(prepared!, cursor, slot.right - slot.left) - - if (line === null) { - break - } - - const x = - align === 'center' - ? slot.left + Math.max(0, (slot.right - slot.left - line.width) / 2) - : slot.left - - lines.push({ - ...line, - x, - top: band.top, - baseline: band.top + options.compiledShape.bandHeight * baselineRatio, - slot, - }) - - cursor = line.end - } + const { lines, endCursor } = layoutFlowLinesInCompiledShape({ + compiledShape: options.compiledShape, + prepared, + densePattern, + autoFill, + autoFillMode, + align, + baselineRatio, + startCursor: { tokenIndex: 0, graphemeIndex: 0 }, + }) return { font: resolvedTextStyle.font, @@ -114,7 +46,7 @@ export function resolveFlowLayout( ? autoFillMode === 'dense' ? false : prepared!.tokens.length === 0 - : cursor.tokenIndex >= prepared!.tokens.length, + : endCursor.tokenIndex >= prepared!.tokens.length, autoFill, autoFillMode, fillStrategy: 'flow', diff --git a/src/layout/resolve-max-fill-helpers.ts b/src/layout/resolve-max-fill-helpers.ts new file mode 100644 index 0000000..91212f9 --- /dev/null +++ b/src/layout/resolve-max-fill-helpers.ts @@ -0,0 +1,22 @@ +import type { CompiledShapeBands } from '../types.js' +import { compileShapeForLayout } from '../shape/compile-shape-for-layout.js' + +const MAX_FILL_BASE_MIN_SLOT_WIDTH_FACTOR = 0.45 +const MIN_MAX_FILL_SLOT_WIDTH = 6 + +export function resolveMaxFillMinSlotWidth(lineHeight: number): number { + return Math.max(MIN_MAX_FILL_SLOT_WIDTH, Math.round(lineHeight * MAX_FILL_BASE_MIN_SLOT_WIDTH_FACTOR)) +} + +export function ensureMaxFillCompiledShape(compiledShape: CompiledShapeBands): CompiledShapeBands { + const recommendedMinSlotWidth = resolveMaxFillMinSlotWidth(compiledShape.bandHeight) + if (compiledShape.minSlotWidth <= recommendedMinSlotWidth) { + return compiledShape + } + + return compileShapeForLayout({ + shape: compiledShape.source, + lineHeight: compiledShape.bandHeight, + minSlotWidth: recommendedMinSlotWidth, + }) +} diff --git a/src/layout/resolve-max-fill-layout.ts b/src/layout/resolve-max-fill-layout.ts index fa108b8..b406d40 100644 --- a/src/layout/resolve-max-fill-layout.ts +++ b/src/layout/resolve-max-fill-layout.ts @@ -1,31 +1,8 @@ import type { LayoutTextInCompiledShapeOptions, ShapeTextLayout } from '../types.js' -import { compileShapeForLayout } from '../shape/compile-shape-for-layout.js' import { resolveLayoutTextStyle } from '../text/normalize-text-style-to-font.js' import { prepareStreamRepeatFillPattern } from '../text/prepare-stream-repeat-fill-pattern.js' import { layoutDenseFillPass } from './layout-dense-fill-pass.js' - -const MAX_FILL_BASE_MIN_SLOT_WIDTH_FACTOR = 0.45 -const MIN_MAX_FILL_SLOT_WIDTH = 6 - -function resolveMaxFillMinSlotWidth(lineHeight: number, factor: number): number { - return Math.max(MIN_MAX_FILL_SLOT_WIDTH, Math.round(lineHeight * factor)) -} - -function ensureMaxFillCompiledShape(options: LayoutTextInCompiledShapeOptions) { - const recommendedMinSlotWidth = resolveMaxFillMinSlotWidth( - options.compiledShape.bandHeight, - MAX_FILL_BASE_MIN_SLOT_WIDTH_FACTOR, - ) - if (options.compiledShape.minSlotWidth <= recommendedMinSlotWidth) { - return options.compiledShape - } - - return compileShapeForLayout({ - shape: options.compiledShape.source, - lineHeight: options.compiledShape.bandHeight, - minSlotWidth: recommendedMinSlotWidth, - }) -} +import { ensureMaxFillCompiledShape } from './resolve-max-fill-helpers.js' export function resolveMaxFillLayout( options: LayoutTextInCompiledShapeOptions, @@ -35,7 +12,7 @@ export function resolveMaxFillLayout( textStyle: options.textStyle, }) const baselineRatio = options.baselineRatio ?? 0.8 - const maxCompiledShape = ensureMaxFillCompiledShape(options) + const maxCompiledShape = ensureMaxFillCompiledShape(options.compiledShape) const streamPattern = prepareStreamRepeatFillPattern( options.text, resolvedTextStyle.font, diff --git a/src/layout/resolve-sequential-shape-regions.ts b/src/layout/resolve-sequential-shape-regions.ts new file mode 100644 index 0000000..e6252d1 --- /dev/null +++ b/src/layout/resolve-sequential-shape-regions.ts @@ -0,0 +1,144 @@ +import type { + AutoFillMode, + CompiledShapeBands, + CompiledShapeRegion, + FillStrategy, + LayoutCursor, + LayoutTextInCompiledShapeOptions, + ShapeTextLayout, +} from '../types.js' +import { resolveLayoutTextStyle } from '../text/normalize-text-style-to-font.js' +import { prepareDenseRepeatFillPattern } from '../text/prepare-dense-repeat-fill-pattern.js' +import { prepareStreamRepeatFillPattern } from '../text/prepare-stream-repeat-fill-pattern.js' +import { prepareTextForLayout } from '../text/prepare-text-for-layout.js' +import { layoutDenseFillPass } from './layout-dense-fill-pass.js' +import { layoutFlowLinesInCompiledShape } from './layout-flow-lines-in-compiled-shape.js' +import { ensureMaxFillCompiledShape } from './resolve-max-fill-helpers.js' + +function createCompiledShapeFromRegion( + compiledShape: CompiledShapeBands, + region: CompiledShapeRegion, +): CompiledShapeBands { + return { + kind: compiledShape.kind, + source: compiledShape.source, + bounds: region.bounds, + bandHeight: compiledShape.bandHeight, + minSlotWidth: compiledShape.minSlotWidth, + bands: region.bands, + debugView: region.debugView, + } +} + +export function hasSequentialShapeRegions(compiledShape: CompiledShapeBands): boolean { + return ( + compiledShape.source.kind === 'text-mask' && + (compiledShape.source.shapeTextMode ?? 'whole-text') === 'per-character' && + (compiledShape.regions?.some(region => + region.bands.some(band => band.intervals.length > 0), + ) ?? false) + ) +} + +export function resolveSequentialShapeRegions( + options: LayoutTextInCompiledShapeOptions, +): ShapeTextLayout { + const autoFill = options.autoFill ?? false + const autoFillMode: AutoFillMode = autoFill ? (options.autoFillMode ?? 'words') : 'words' + const fillStrategy: FillStrategy = autoFill ? (options.fillStrategy ?? 'flow') : 'flow' + const resolvedTextStyle = resolveLayoutTextStyle({ + font: options.font, + textStyle: options.textStyle, + }) + const align = options.align ?? 'left' + const baselineRatio = options.baselineRatio ?? 0.8 + const compiledShape = + fillStrategy === 'max' ? ensureMaxFillCompiledShape(options.compiledShape) : options.compiledShape + const regions = compiledShape.regions ?? [] + + if (fillStrategy === 'max') { + const streamPattern = prepareStreamRepeatFillPattern( + options.text, + resolvedTextStyle.font, + options.measurer, + ) + const lines = [] + let offset = 0 + + for (let index = 0; index < regions.length; index++) { + const regionShape = createCompiledShapeFromRegion(compiledShape, regions[index]!) + const pass = layoutDenseFillPass({ + compiledShape: regionShape, + densePattern: streamPattern, + startOffset: offset, + align: 'left', + baselineRatio, + allSlots: true, + fillPass: 1, + }) + + lines.push(...pass.lines) + offset = pass.endOffset + } + + return { + font: resolvedTextStyle.font, + textStyle: resolvedTextStyle, + lineHeight: compiledShape.bandHeight, + shape: compiledShape.source, + compiledShape, + bounds: compiledShape.bounds, + lines, + exhausted: false, + autoFill: true, + autoFillMode: 'stream', + fillStrategy: 'max', + } + } + + const prepared = + autoFillMode === 'dense' + ? undefined + : prepareTextForLayout(options.text, resolvedTextStyle.font, options.measurer) + const densePattern = + autoFillMode === 'dense' + ? prepareDenseRepeatFillPattern(options.text, resolvedTextStyle.font, options.measurer) + : undefined + const lines = [] + let cursor: LayoutCursor = { tokenIndex: 0, graphemeIndex: 0 } + + for (let index = 0; index < regions.length; index++) { + const regionShape = createCompiledShapeFromRegion(compiledShape, regions[index]!) + const result = layoutFlowLinesInCompiledShape({ + compiledShape: regionShape, + prepared, + densePattern, + autoFill, + autoFillMode, + align, + baselineRatio, + startCursor: cursor, + }) + + lines.push(...result.lines) + cursor = result.endCursor + + if (!autoFill && prepared !== undefined && cursor.tokenIndex >= prepared.tokens.length) { + break + } + } + + return { + font: resolvedTextStyle.font, + textStyle: resolvedTextStyle, + lineHeight: compiledShape.bandHeight, + shape: compiledShape.source, + compiledShape, + bounds: compiledShape.bounds, + lines, + exhausted: autoFill ? autoFillMode === 'dense' ? false : prepared!.tokens.length === 0 : cursor.tokenIndex >= prepared!.tokens.length, + autoFill, + autoFillMode, + fillStrategy: 'flow', + } +} diff --git a/src/shape/build-text-mask-bands-from-alpha.ts b/src/shape/build-text-mask-bands-from-alpha.ts new file mode 100644 index 0000000..503cf7c --- /dev/null +++ b/src/shape/build-text-mask-bands-from-alpha.ts @@ -0,0 +1,106 @@ +import type { CompiledShapeBand, Interval } from '../types.js' + +export type TextMaskAlphaBandsOptions = { + width: number + height: number + maskScale: number + alphaThreshold: number +} + +function intersectIntervalSets(leftSet: Interval[], rightSet: Interval[]): Interval[] { + const intersections: Interval[] = [] + let leftIndex = 0 + let rightIndex = 0 + + while (leftIndex < leftSet.length && rightIndex < rightSet.length) { + const left = leftSet[leftIndex]! + const right = rightSet[rightIndex]! + const overlapLeft = Math.max(left.left, right.left) + const overlapRight = Math.min(left.right, right.right) + + if (overlapRight > overlapLeft) { + intersections.push({ left: overlapLeft, right: overlapRight }) + } + + if (left.right < right.right) { + leftIndex += 1 + } else { + rightIndex += 1 + } + } + + return intersections +} + +function getRowIntervals( + alpha: Uint8ClampedArray, + row: number, + width: number, + alphaThreshold: number, +): Interval[] { + const intervals: Interval[] = [] + let start = -1 + const rowOffset = row * width * 4 + + for (let x = 0; x < width; x++) { + const isSolid = alpha[rowOffset + x * 4 + 3] >= alphaThreshold + + if (isSolid && start < 0) { + start = x + continue + } + + if (!isSolid && start >= 0) { + intervals.push({ left: start, right: x }) + start = -1 + } + } + + if (start >= 0) { + intervals.push({ left: start, right: width }) + } + + return intervals +} + +export function buildTextMaskBandsFromAlpha( + options: TextMaskAlphaBandsOptions, + alpha: Uint8ClampedArray, + lineHeight: number, + minSlotWidth: number, +): CompiledShapeBand[] { + const pixelWidth = Math.max(1, Math.ceil(options.width * options.maskScale)) + const bands: CompiledShapeBand[] = [] + + for (let bandTop = 0; bandTop + lineHeight <= options.height; bandTop += lineHeight) { + const startRow = Math.floor(bandTop * options.maskScale) + const endRow = Math.max(startRow, Math.ceil((bandTop + lineHeight) * options.maskScale) - 1) + let intervals: Interval[] | null = null + + for (let row = startRow; row <= endRow; row++) { + const rowIntervals = getRowIntervals(alpha, row, pixelWidth, options.alphaThreshold) + if (rowIntervals.length === 0) { + intervals = [] + break + } + + intervals = intervals === null ? rowIntervals : intersectIntervalSets(intervals, rowIntervals) + if (intervals.length === 0) { + break + } + } + + bands.push({ + top: bandTop, + bottom: bandTop + lineHeight, + intervals: (intervals ?? []) + .map(interval => ({ + left: interval.left / options.maskScale, + right: interval.right / options.maskScale, + })) + .filter(interval => interval.right - interval.left >= minSlotWidth), + }) + } + + return bands +} diff --git a/src/shape/compile-text-mask-shape-for-layout.test.ts b/src/shape/compile-text-mask-shape-for-layout.test.ts index 1646193..41e6de7 100644 --- a/src/shape/compile-text-mask-shape-for-layout.test.ts +++ b/src/shape/compile-text-mask-shape-for-layout.test.ts @@ -2,11 +2,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { compileShapeForLayout } from './compile-shape-for-layout.js' -function createMaskData(width: number, height: number): Uint8ClampedArray { +function createMaskData(width: number, height: number, left: number, right: number): Uint8ClampedArray { const data = new Uint8ClampedArray(width * height * 4) for (let y = 0; y < height; y++) { - for (let x = 10; x < width - 10; x++) { + for (let x = left; x < right; x++) { const alphaOffset = (y * width + x) * 4 + 3 data[alphaOffset] = 255 } @@ -19,6 +19,8 @@ class FakeCanvasContext { font = '' fillStyle = '#000000' textBaseline: CanvasTextBaseline = 'alphabetic' + private lastDrawX = 0 + private lastText = '' constructor( private readonly width: number, @@ -27,10 +29,15 @@ class FakeCanvasContext { clearRect(): void {} - fillText(): void {} + fillText(text: string, x: number): void { + this.lastText = text + this.lastDrawX = x + } getImageData(): ImageData { - return { data: createMaskData(this.width, this.height) } as ImageData + const left = Math.max(0, Math.floor(this.lastDrawX)) + const right = Math.min(this.width, Math.ceil(this.lastDrawX + this.lastText.length * 40)) + return { data: createMaskData(this.width, this.height, left, right) } as ImageData } measureText(text: string): TextMetrics { @@ -61,12 +68,22 @@ class FakeOffscreenCanvas { } } -const baseShape = { +const fitContentShape = { + kind: 'text-mask' as const, + text: '23', + font: '700 160px Test Sans', + maskScale: 1, +} + +const fixedShape = { kind: 'text-mask' as const, text: '2', font: '700 160px Test Sans', - width: 120, - height: 160, + size: { + mode: 'fixed' as const, + width: 120, + height: 160, + }, maskScale: 1, } @@ -75,7 +92,7 @@ afterEach(() => { }) describe('compileTextMaskShapeForLayout', () => { - it('compiles a text mask into reusable frozen bands', () => { + it('compiles a fixed text mask into reusable frozen bands', () => { vi.stubGlobal('OffscreenCanvas', FakeOffscreenCanvas) vi.stubGlobal('document', { fonts: { @@ -84,24 +101,90 @@ describe('compileTextMaskShapeForLayout', () => { }) const first = compileShapeForLayout({ - shape: baseShape, + shape: fixedShape, lineHeight: 20, minSlotWidth: 12, }) const second = compileShapeForLayout({ - shape: baseShape, + shape: fixedShape, lineHeight: 20, minSlotWidth: 12, }) expect(first.kind).toBe('text-mask') expect(first.bands).toHaveLength(8) - expect(first.bands[0]?.intervals[0]).toEqual({ left: 10, right: 110 }) + expect(first.bands[0]?.intervals[0]).toEqual({ left: 40, right: 80 }) + expect(first.regions).toEqual([]) expect(first).toBe(second) expect(Object.isFrozen(first)).toBe(true) expect(Object.isFrozen(first.bands)).toBe(true) }) + it('resolves fit-content bounds from the measured glyph text', () => { + vi.stubGlobal('OffscreenCanvas', FakeOffscreenCanvas) + vi.stubGlobal('document', { + fonts: { + check: () => true, + }, + }) + + const compiled = compileShapeForLayout({ + shape: fitContentShape, + lineHeight: 20, + minSlotWidth: 12, + }) + + expect(compiled.bounds).toEqual({ left: 0, top: 0, right: 80, bottom: 40 }) + expect(compiled.bands).toHaveLength(2) + expect(compiled.bands[0]?.intervals[0]).toEqual({ left: 0, right: 80 }) + }) + + it('compiles per-character text-mask regions and skips spaces', () => { + vi.stubGlobal('OffscreenCanvas', FakeOffscreenCanvas) + vi.stubGlobal('document', { + fonts: { + check: () => true, + }, + }) + + const compiled = compileShapeForLayout({ + shape: { + ...fitContentShape, + text: 'A B', + shapeTextMode: 'per-character', + }, + lineHeight: 20, + minSlotWidth: 12, + }) + + expect(compiled.regions).toHaveLength(2) + expect(compiled.regions?.map(region => region.grapheme)).toEqual(['A', 'B']) + expect(compiled.regions?.[0]?.bands[0]?.intervals[0]).toEqual({ left: 0, right: 40 }) + expect(compiled.regions?.[1]?.bands[0]?.intervals[0]).toEqual({ left: 80, right: 120 }) + }) + + it('drops per-character regions that become empty after minSlotWidth filtering', () => { + vi.stubGlobal('OffscreenCanvas', FakeOffscreenCanvas) + vi.stubGlobal('document', { + fonts: { + check: () => true, + }, + }) + + const compiled = compileShapeForLayout({ + shape: { + ...fitContentShape, + text: 'III', + shapeTextMode: 'per-character', + }, + lineHeight: 20, + minSlotWidth: 50, + }) + + expect(compiled.bands[0]?.intervals[0]).toEqual({ left: 0, right: 120 }) + expect(compiled.regions).toHaveLength(0) + }) + it('rejects invalid alpha thresholds early', () => { vi.stubGlobal('OffscreenCanvas', FakeOffscreenCanvas) vi.stubGlobal('document', { @@ -113,7 +196,7 @@ describe('compileTextMaskShapeForLayout', () => { expect(() => compileShapeForLayout({ shape: { - ...baseShape, + ...fixedShape, alphaThreshold: -1, }, lineHeight: 20, @@ -122,6 +205,30 @@ describe('compileTextMaskShapeForLayout', () => { ).toThrow('alphaThreshold') }) + it('rejects invalid fixed sizes early', () => { + vi.stubGlobal('OffscreenCanvas', FakeOffscreenCanvas) + vi.stubGlobal('document', { + fonts: { + check: () => true, + }, + }) + + expect(() => + compileShapeForLayout({ + shape: { + ...fitContentShape, + size: { + mode: 'fixed', + width: 0, + height: 160, + }, + }, + lineHeight: 20, + minSlotWidth: 12, + }), + ).toThrow('fixed width') + }) + it('skips cache writes until the font is ready', () => { vi.stubGlobal('OffscreenCanvas', FakeOffscreenCanvas) const check = vi.fn(() => false) @@ -132,12 +239,12 @@ describe('compileTextMaskShapeForLayout', () => { }) const beforeReadyA = compileShapeForLayout({ - shape: baseShape, + shape: fixedShape, lineHeight: 20, minSlotWidth: 12, }) const beforeReadyB = compileShapeForLayout({ - shape: baseShape, + shape: fixedShape, lineHeight: 20, minSlotWidth: 12, }) @@ -147,12 +254,12 @@ describe('compileTextMaskShapeForLayout', () => { check.mockReturnValue(true) const afterReadyA = compileShapeForLayout({ - shape: baseShape, + shape: fixedShape, lineHeight: 20, minSlotWidth: 12, }) const afterReadyB = compileShapeForLayout({ - shape: baseShape, + shape: fixedShape, lineHeight: 20, minSlotWidth: 12, }) diff --git a/src/shape/compile-text-mask-shape-for-layout.ts b/src/shape/compile-text-mask-shape-for-layout.ts index 5b81325..e5e3450 100644 --- a/src/shape/compile-text-mask-shape-for-layout.ts +++ b/src/shape/compile-text-mask-shape-for-layout.ts @@ -1,25 +1,30 @@ -import type { CompiledShapeBand, CompiledShapeBands, Interval, TextMaskShape } from '../types.js' +import type { CompiledShapeBands, CompiledShapeRegion, TextMaskShape } from '../types.js' import { createBrowserCanvas2DContext } from '../text/create-browser-canvas-2d-context.js' - -type TextMaskPlacement = { - x: number - baseline: number -} +import { buildTextMaskBandsFromAlpha } from './build-text-mask-bands-from-alpha.js' +import { + getTextMaskPlacement, + renderTextMaskRaster, + type TextMaskCanvas, + type TextMaskPlacement, +} from './render-text-mask-raster.js' +import { + measureTextMaskContent, + resolveTextMaskShapeSize, + type ResolvedTextMaskSize, +} from './resolve-text-mask-shape-size.js' +import { segmentTextMaskGraphemes } from './segment-text-mask-graphemes.js' const MAX_MASK_PIXELS = 4_000_000 const compiledShapeCache = new Map() -function validateTextMaskShape(shape: TextMaskShape): void { +function validateTextMaskShape(shape: TextMaskShape, size: ResolvedTextMaskSize): void { if (shape.text.length === 0) { throw new Error('text-mask shape needs a non-empty text value') } - if (!Number.isFinite(shape.width) || shape.width <= 0) { - throw new Error('text-mask width must be a finite positive number') - } - - if (!Number.isFinite(shape.height) || shape.height <= 0) { - throw new Error('text-mask height must be a finite positive number') + const shapeTextMode = shape.shapeTextMode ?? 'whole-text' + if (shapeTextMode !== 'whole-text' && shapeTextMode !== 'per-character') { + throw new Error('text-mask shapeTextMode must be whole-text or per-character') } const maskScale = shape.maskScale ?? 2 @@ -27,8 +32,8 @@ function validateTextMaskShape(shape: TextMaskShape): void { throw new Error('text-mask maskScale must be a finite positive number') } - const pixelWidth = Math.max(1, Math.ceil(shape.width * maskScale)) - const pixelHeight = Math.max(1, Math.ceil(shape.height * maskScale)) + const pixelWidth = Math.max(1, Math.ceil(size.width * maskScale)) + const pixelHeight = Math.max(1, Math.ceil(size.height * maskScale)) if (pixelWidth * pixelHeight > MAX_MASK_PIXELS) { throw new Error('text-mask raster request is too large') } @@ -41,6 +46,7 @@ function validateTextMaskShape(shape: TextMaskShape): void { function buildCacheKey( shape: TextMaskShape, + size: ResolvedTextMaskSize, lineHeight: number, minSlotWidth: number, ): string { @@ -48,9 +54,11 @@ function buildCacheKey( kind: shape.kind, text: shape.text, font: shape.font, - width: shape.width, - height: shape.height, - padding: shape.padding ?? 0, + sizeMode: size.mode, + width: size.width, + height: size.height, + shapeTextMode: shape.shapeTextMode ?? 'whole-text', + padding: size.padding, maskScale: shape.maskScale ?? 2, alphaThreshold: shape.alphaThreshold ?? 16, lineHeight, @@ -58,86 +66,6 @@ function buildCacheKey( }) } -function intersectIntervalSets(leftSet: Interval[], rightSet: Interval[]): Interval[] { - const intersections: Interval[] = [] - let leftIndex = 0 - let rightIndex = 0 - - while (leftIndex < leftSet.length && rightIndex < rightSet.length) { - const left = leftSet[leftIndex]! - const right = rightSet[rightIndex]! - const overlapLeft = Math.max(left.left, right.left) - const overlapRight = Math.min(left.right, right.right) - - if (overlapRight > overlapLeft) { - intersections.push({ left: overlapLeft, right: overlapRight }) - } - - if (left.right < right.right) { - leftIndex += 1 - } else { - rightIndex += 1 - } - } - - return intersections -} - -function getRowIntervals( - alpha: Uint8ClampedArray, - row: number, - width: number, - alphaThreshold: number, -): Interval[] { - const intervals: Interval[] = [] - let start = -1 - const rowOffset = row * width * 4 - - for (let x = 0; x < width; x++) { - const isSolid = alpha[rowOffset + x * 4 + 3] >= alphaThreshold - - if (isSolid && start < 0) { - start = x - continue - } - - if (!isSolid && start >= 0) { - intervals.push({ left: start, right: x }) - start = -1 - } - } - - if (start >= 0) { - intervals.push({ left: start, right: width }) - } - - return intervals -} - -function getTextMaskPlacement(shape: TextMaskShape, context: CanvasText['context']): TextMaskPlacement { - const padding = shape.padding ?? 0 - context.font = shape.font - const metrics = context.measureText(shape.text) - const contentWidth = - (metrics.actualBoundingBoxLeft ?? 0) + (metrics.actualBoundingBoxRight ?? metrics.width) - const contentHeight = - (metrics.actualBoundingBoxAscent ?? 0) + (metrics.actualBoundingBoxDescent ?? 0) - const innerWidth = Math.max(0, shape.width - padding * 2) - const innerHeight = Math.max(0, shape.height - padding * 2) - const left = padding + Math.max(0, (innerWidth - contentWidth) / 2) - const top = padding + Math.max(0, (innerHeight - contentHeight) / 2) - - return { - x: left + (metrics.actualBoundingBoxLeft ?? 0), - baseline: top + (metrics.actualBoundingBoxAscent ?? 0), - } -} - -type CanvasText = { - context: ReturnType - imageData: ImageData -} - function isFontReadyForShape(shape: TextMaskShape): boolean { if (typeof document === 'undefined' || document.fonts === undefined) { return true @@ -151,87 +79,106 @@ function isFontReadyForShape(shape: TextMaskShape): boolean { } function freezeCompiledShape(compiledShape: CompiledShapeBands): CompiledShapeBands { - for (let bandIndex = 0; bandIndex < compiledShape.bands.length; bandIndex++) { - const band = compiledShape.bands[bandIndex]! - for (let intervalIndex = 0; intervalIndex < band.intervals.length; intervalIndex++) { - Object.freeze(band.intervals[intervalIndex]!) - } - - Object.freeze(band.intervals) - Object.freeze(band) - } + freezeBands(compiledShape.bands) + freezeRegions(compiledShape.regions ?? []) Object.freeze(compiledShape.bounds) Object.freeze(compiledShape.source) Object.freeze(compiledShape.debugView) Object.freeze(compiledShape.bands) + if (compiledShape.regions !== undefined) { + Object.freeze(compiledShape.regions) + } return Object.freeze(compiledShape) } -function renderTextMask(shape: TextMaskShape): CanvasText { - const maskScale = shape.maskScale ?? 2 - const pixelWidth = Math.max(1, Math.ceil(shape.width * maskScale)) - const pixelHeight = Math.max(1, Math.ceil(shape.height * maskScale)) - const context = createBrowserCanvas2DContext(pixelWidth, pixelHeight) - - context.setTransform(1, 0, 0, 1, 0, 0) - context.clearRect(0, 0, pixelWidth, pixelHeight) - context.setTransform(maskScale, 0, 0, maskScale, 0, 0) - context.font = shape.font - context.fillStyle = '#000000' - context.textBaseline = 'alphabetic' +function freezeBands(bands: CompiledShapeBands['bands']): void { + for (let bandIndex = 0; bandIndex < bands.length; bandIndex++) { + const band = bands[bandIndex]! + for (let intervalIndex = 0; intervalIndex < band.intervals.length; intervalIndex++) { + Object.freeze(band.intervals[intervalIndex]!) + } - const placement = getTextMaskPlacement(shape, context) - context.fillText(shape.text, placement.x, placement.baseline) + Object.freeze(band.intervals) + Object.freeze(band) + } +} - return { - context, - imageData: context.getImageData(0, 0, pixelWidth, pixelHeight), +function freezeRegions(regions: CompiledShapeRegion[]): void { + for (let index = 0; index < regions.length; index++) { + const region = regions[index]! + freezeBands(region.bands) + Object.freeze(region.bounds) + Object.freeze(region.debugView) + Object.freeze(region.bands) + Object.freeze(region) } } -function buildBands( +function buildRegion( shape: TextMaskShape, - alpha: Uint8ClampedArray, + size: ResolvedTextMaskSize, lineHeight: number, minSlotWidth: number, -): CompiledShapeBand[] { - const maskScale = shape.maskScale ?? 2 - const alphaThreshold = shape.alphaThreshold ?? 16 - const pixelWidth = Math.max(1, Math.ceil(shape.width * maskScale)) - const bands: CompiledShapeBand[] = [] - - for (let bandTop = 0; bandTop + lineHeight <= shape.height; bandTop += lineHeight) { - const startRow = Math.floor(bandTop * maskScale) - const endRow = Math.max(startRow, Math.ceil((bandTop + lineHeight) * maskScale) - 1) - let intervals: Interval[] | null = null - - for (let row = startRow; row <= endRow; row++) { - const rowIntervals = getRowIntervals(alpha, row, pixelWidth, alphaThreshold) - if (rowIntervals.length === 0) { - intervals = [] - break - } - - intervals = intervals === null ? rowIntervals : intersectIntervalSets(intervals, rowIntervals) - if (intervals.length === 0) { - break - } - } + index: number, + grapheme: string, + drawX: number, + baseline: number, +): CompiledShapeRegion { + const { imageData } = renderTextMaskRaster(shape, size, grapheme, drawX, baseline) - bands.push({ - top: bandTop, - bottom: bandTop + lineHeight, - intervals: (intervals ?? []) - .map(interval => ({ - left: interval.left / maskScale, - right: interval.right / maskScale, - })) - .filter(interval => interval.right - interval.left >= minSlotWidth), - }) + return { + index, + grapheme, + bounds: { left: 0, top: 0, right: size.width, bottom: size.height }, + bands: buildTextMaskBandsFromAlpha( + { + width: size.width, + height: size.height, + maskScale: shape.maskScale ?? 2, + alphaThreshold: shape.alphaThreshold ?? 16, + }, + imageData.data, + lineHeight, + minSlotWidth, + ), + debugView: { + kind: 'text', + text: grapheme, + font: shape.font, + x: drawX, + baseline, + }, } +} - return bands +function buildRegions( + shape: TextMaskShape, + size: ResolvedTextMaskSize, + context: TextMaskCanvas['context'], + placement: TextMaskPlacement, + lineHeight: number, + minSlotWidth: number, +): CompiledShapeRegion[] { + if ((shape.shapeTextMode ?? 'whole-text') !== 'per-character') { + return [] + } + + return segmentTextMaskGraphemes(shape.text, placement.x, value => context.measureText(value).width) + .filter(placementEntry => !placementEntry.isWhitespace) + .map((placementEntry, index) => + buildRegion( + shape, + size, + lineHeight, + minSlotWidth, + index, + placementEntry.grapheme, + placementEntry.drawX, + placement.baseline, + ), + ) + .filter(region => region.bands.some(band => band.intervals.length > 0)) } export function compileTextMaskShapeForLayout( @@ -239,24 +186,38 @@ export function compileTextMaskShapeForLayout( lineHeight: number, minSlotWidth: number, ): CompiledShapeBands { - validateTextMaskShape(shape) - const fontReadyForCache = isFontReadyForShape(shape) + const context = createBrowserCanvas2DContext(1, 1) + const contentMetrics = measureTextMaskContent(context, shape) + const size = resolveTextMaskShapeSize(shape, contentMetrics) + validateTextMaskShape(shape, size) - const cacheKey = buildCacheKey(shape, lineHeight, minSlotWidth) + const fontReadyForCache = isFontReadyForShape(shape) + const cacheKey = buildCacheKey(shape, size, lineHeight, minSlotWidth) const cachedShape = fontReadyForCache ? compiledShapeCache.get(cacheKey) : undefined if (cachedShape !== undefined) { return cachedShape } - const { context, imageData } = renderTextMask(shape) - const placement = getTextMaskPlacement(shape, context) + const placement = getTextMaskPlacement(size, contentMetrics) + const { imageData } = renderTextMaskRaster(shape, size, shape.text, placement.x, placement.baseline) const compiledShape: CompiledShapeBands = { kind: shape.kind, source: shape, - bounds: { left: 0, top: 0, right: shape.width, bottom: shape.height }, + bounds: { left: 0, top: 0, right: size.width, bottom: size.height }, bandHeight: lineHeight, minSlotWidth, - bands: buildBands(shape, imageData.data, lineHeight, minSlotWidth), + bands: buildTextMaskBandsFromAlpha( + { + width: size.width, + height: size.height, + maskScale: shape.maskScale ?? 2, + alphaThreshold: shape.alphaThreshold ?? 16, + }, + imageData.data, + lineHeight, + minSlotWidth, + ), + regions: buildRegions(shape, size, context, placement, lineHeight, minSlotWidth), debugView: { kind: 'text', text: shape.text, diff --git a/src/shape/render-text-mask-raster.ts b/src/shape/render-text-mask-raster.ts new file mode 100644 index 0000000..fd6d9d5 --- /dev/null +++ b/src/shape/render-text-mask-raster.ts @@ -0,0 +1,54 @@ +import type { TextMaskShape } from '../types.js' +import { createBrowserCanvas2DContext } from '../text/create-browser-canvas-2d-context.js' +import type { ResolvedTextMaskSize, TextMaskContentMetrics } from './resolve-text-mask-shape-size.js' + +export type TextMaskPlacement = { + x: number + baseline: number +} + +export type TextMaskCanvas = { + context: ReturnType + imageData: ImageData +} + +export function getTextMaskPlacement( + size: ResolvedTextMaskSize, + metrics: TextMaskContentMetrics, +): TextMaskPlacement { + const innerWidth = Math.max(0, size.width - size.padding * 2) + const innerHeight = Math.max(0, size.height - size.padding * 2) + const left = size.padding + Math.max(0, (innerWidth - metrics.width) / 2) + const top = size.padding + Math.max(0, (innerHeight - metrics.height) / 2) + + return { + x: left + metrics.left, + baseline: top + metrics.ascent, + } +} + +export function renderTextMaskRaster( + shape: TextMaskShape, + size: ResolvedTextMaskSize, + text = shape.text, + drawX?: number, + baseline?: number, +): TextMaskCanvas { + const maskScale = shape.maskScale ?? 2 + const pixelWidth = Math.max(1, Math.ceil(size.width * maskScale)) + const pixelHeight = Math.max(1, Math.ceil(size.height * maskScale)) + const context = createBrowserCanvas2DContext(pixelWidth, pixelHeight) + + context.setTransform(1, 0, 0, 1, 0, 0) + context.clearRect(0, 0, pixelWidth, pixelHeight) + context.setTransform(maskScale, 0, 0, maskScale, 0, 0) + context.font = shape.font + context.fillStyle = '#000000' + context.textBaseline = 'alphabetic' + context.fillText(text, drawX ?? 0, baseline ?? 0) + + return { + context, + imageData: context.getImageData(0, 0, pixelWidth, pixelHeight), + } +} diff --git a/src/shape/resolve-text-mask-shape-size.ts b/src/shape/resolve-text-mask-shape-size.ts new file mode 100644 index 0000000..8b5b586 --- /dev/null +++ b/src/shape/resolve-text-mask-shape-size.ts @@ -0,0 +1,112 @@ +import type { BrowserCanvas2DContext } from '../text/create-browser-canvas-2d-context.js' +import type { TextMaskShape, TextMaskShapeSizeMode } from '../types.js' + +type TextMaskShapeWithLegacySizeFields = TextMaskShape & { + width?: unknown + height?: unknown + padding?: unknown +} + +export type TextMaskContentMetrics = { + left: number + right: number + ascent: number + descent: number + width: number + height: number +} + +export type ResolvedTextMaskSize = { + mode: TextMaskShapeSizeMode + width: number + height: number + padding: number +} + +function assertFiniteNumber(value: number, label: string): number { + if (!Number.isFinite(value)) { + throw new Error(`${label} must be a finite number`) + } + + return value +} + +function assertPositiveFiniteNumber(value: number, label: string): number { + if (!Number.isFinite(value) || value <= 0) { + throw new Error(`${label} must be a finite positive number`) + } + + return value +} + +function assertNoLegacySizeFields(shape: TextMaskShapeWithLegacySizeFields): void { + if ('width' in shape || 'height' in shape || 'padding' in shape) { + throw new Error('text-mask width, height, and padding moved to shape.size') + } +} + +function resolveMetric(value: number | undefined, fallback = 0): number { + return value !== undefined && Number.isFinite(value) ? value : fallback +} + +export function measureTextMaskContent( + context: BrowserCanvas2DContext, + shape: TextMaskShape, + text = shape.text, +): TextMaskContentMetrics { + context.font = shape.font + const metrics = context.measureText(text) + const left = resolveMetric(metrics.actualBoundingBoxLeft, 0) + const right = resolveMetric(metrics.actualBoundingBoxRight, metrics.width) + const ascent = resolveMetric(metrics.actualBoundingBoxAscent, 0) + const descent = resolveMetric(metrics.actualBoundingBoxDescent, 0) + + return { + left, + right, + ascent, + descent, + width: left + right, + height: ascent + descent, + } +} + +export function resolveTextMaskShapeSize( + shape: TextMaskShape, + contentMetrics: TextMaskContentMetrics, +): ResolvedTextMaskSize { + assertNoLegacySizeFields(shape as TextMaskShapeWithLegacySizeFields) + + if (shape.size !== undefined && (shape.size === null || typeof shape.size !== 'object')) { + throw new Error('text-mask size must be an object') + } + + const size = shape.size ?? {} + const mode = size.mode ?? 'fit-content' + if (mode !== 'fit-content' && mode !== 'fixed') { + throw new Error('text-mask size.mode must be fit-content or fixed') + } + + const padding = size.padding === undefined ? 0 : assertFiniteNumber(size.padding, 'text-mask padding') + if (padding < 0) { + throw new Error('text-mask padding must be a finite non-negative number') + } + + if (mode === 'fixed') { + const fixedSize = size as { width: number; height: number } + + return { + mode, + width: assertPositiveFiniteNumber(fixedSize.width, 'text-mask fixed width'), + height: assertPositiveFiniteNumber(fixedSize.height, 'text-mask fixed height'), + padding, + } + } + + return { + mode, + width: Math.max(1, contentMetrics.width + padding * 2), + height: Math.max(1, contentMetrics.height + padding * 2), + padding, + } +} diff --git a/src/shape/segment-text-mask-graphemes.ts b/src/shape/segment-text-mask-graphemes.ts new file mode 100644 index 0000000..39527cd --- /dev/null +++ b/src/shape/segment-text-mask-graphemes.ts @@ -0,0 +1,33 @@ +const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }) + +export type TextMaskGraphemePlacement = { + grapheme: string + drawX: number + isWhitespace: boolean +} + +function isWhitespaceGrapheme(grapheme: string): boolean { + return grapheme.trim().length === 0 +} + +export function segmentTextMaskGraphemes( + text: string, + originX: number, + measureAdvance: (value: string) => number, +): TextMaskGraphemePlacement[] { + const segments = Array.from(graphemeSegmenter.segment(text), segment => segment.segment) + const placements: TextMaskGraphemePlacement[] = [] + let prefix = '' + + for (let index = 0; index < segments.length; index++) { + const grapheme = segments[index]! + placements.push({ + grapheme, + drawX: originX + measureAdvance(prefix), + isWhitespace: isWhitespaceGrapheme(grapheme), + }) + prefix += grapheme + } + + return placements +} diff --git a/src/types.ts b/src/types.ts index c5a920f..4f0b391 100644 --- a/src/types.ts +++ b/src/types.ts @@ -8,6 +8,23 @@ export type Interval = { right: number } +export type TextMaskShapeTextMode = 'whole-text' | 'per-character' +export type TextMaskShapeSizeMode = 'fit-content' | 'fixed' + +export type TextMaskShapeFitContentSize = { + mode?: 'fit-content' + padding?: number +} + +export type TextMaskShapeFixedSize = { + mode: 'fixed' + width: number + height: number + padding?: number +} + +export type TextMaskShapeSize = TextMaskShapeFitContentSize | TextMaskShapeFixedSize + export type PolygonShape = { kind: 'polygon' points: ShapeTextPoint[] @@ -17,9 +34,8 @@ export type TextMaskShape = { kind: 'text-mask' text: string font: string - width: number - height: number - padding?: number + size?: TextMaskShapeSize + shapeTextMode?: TextMaskShapeTextMode maskScale?: number alphaThreshold?: number } @@ -139,6 +155,14 @@ export type CompiledShapeDebugView = baseline: number } +export type CompiledShapeRegion = { + index: number + grapheme: string + bounds: ShapeBounds + bands: CompiledShapeBand[] + debugView: CompiledShapeDebugView +} + export type CompiledShapeBands = { kind: ShapeInput['kind'] source: ShapeInput @@ -146,6 +170,7 @@ export type CompiledShapeBands = { bandHeight: number minSlotWidth: number bands: CompiledShapeBand[] + regions?: CompiledShapeRegion[] debugView: CompiledShapeDebugView } From 8e1cd47f6ea0058395d952b2aacaed53d2f55a3d Mon Sep 17 00:00:00 2001 From: tqdat410 Date: Thu, 2 Apr 2026 18:26:55 +0700 Subject: [PATCH 08/12] docs: update text-mask sizing and demo usage Co-authored-by: Codex --- README.md | 48 ++++++++++++++++++++++++++++++++++--- docs/project-changelog.md | 4 ++++ docs/system-architecture.md | 21 +++++++++------- 3 files changed, 61 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index bbba2b8..dde49de 100644 --- a/README.md +++ b/README.md @@ -83,14 +83,45 @@ const layout = layoutTextInShape({ kind: 'text-mask', text: '2', font: '700 420px Arial', - width: 340, - height: 460, - padding: 10, + size: { + mode: 'fit-content', + padding: 10, + }, + }, + measurer, +}) +``` + +## Sequential text-mask regions + +```ts +const layout = layoutTextInShape({ + text: 'ABCDEFGHIJ', + textStyle: { + family: 'Arial, sans-serif', + size: 14, + weight: 700, + }, + lineHeight: 18, + shape: { + kind: 'text-mask', + text: 'AB', + font: '700 160px Arial', + size: { + mode: 'fixed', + width: 260, + height: 180, + }, + shapeTextMode: 'per-character', }, measurer, }) ``` +`shape.size` defaults to `{ mode: 'fit-content', padding: 0 }`. Use `mode: 'fixed'` only when you need to force the glyph mask into an explicit raster box. + +`shape.shapeTextMode` defaults to `'whole-text'`. Set it to `'per-character'` to compile one ordered region per non-space grapheme and flow layout through those regions in shape-text order. + ## Public API - `createCanvasTextMeasurer()` @@ -112,6 +143,8 @@ const layout = layoutTextInShape({ - `autoFill: true` repeats the source text until the available shape bands are full. - `autoFillMode: 'words'` is the default readable repeat behavior. `autoFillMode: 'dense'` strips whitespace and breaks at grapheme boundaries to pack shapes harder for decorative fills. - `fillStrategy: 'max'` switches to an all-slots pass that fills every usable interval in reading order. It keeps spaces as normal graphemes instead of stripping them, and it does not fall back to smaller text for leftover pockets. +- `text-mask` sizing now lives under `shape.size`. The default `fit-content` mode measures the text mask first and grows the raster box to avoid clipping multi-character shapes such as `23`. +- `shape.shapeTextMode: 'per-character'` keeps the full text-mask debug view, but also compiles ordered per-character regions for sequential fill across multi-character shape text. - `textStyle` is the new data-driven API for size, weight, italic/oblique, family, and default text color. Legacy `font` string input still works. - `shapeStyle` lives in `renderLayoutToSvg()` because fill, border, and shadow do not affect line breaking or shape compilation. - For late-loading web fonts, compile after the font is ready if you want immediate cache reuse. The compiler skips cache writes until `document.fonts.check()` reports the font as ready. @@ -166,3 +199,12 @@ For a fast local loop without rebuilding first: npm run build npm run demo:dev ``` + +The demo now includes: + +- direct `shape.text` editing for text-mask scenarios +- `shape.size.mode` switching between `fit-content` and `fixed` +- `shapeTextMode` switching between `whole-text` and sequential `per-character` text-mask regions +- a payload JSON editor for the live `layout` + `render` request +- a scrollable full-output SVG viewport with `Zoom out`, `Zoom in`, `100%`, and `Fit` controls +- predefined random character-pattern fill presets for quick repeat-fill experiments diff --git a/docs/project-changelog.md b/docs/project-changelog.md index 97141a7..0a88440 100644 --- a/docs/project-changelog.md +++ b/docs/project-changelog.md @@ -21,5 +21,9 @@ - Added renderer-side `shapeStyle` API for shape fill, border, and shadow - Added `autoFillMode: 'dense'` for whitespace-stripped grapheme repeat fill inside compiled shape bands - Added `fillStrategy: 'max'` for all-slot glyph coverage without mini-font fallback, while preserving spaces in the repeat stream +- Added `shape.shapeTextMode: 'per-character'` for ordered non-space text-mask regions and sequential per-region layout flow +- Replaced top-level text-mask `width` / `height` / `padding` with `shape.size`, defaulting text-mask sizing to `fit-content` +- Added demo payload editing, freeform glyph-shape text input, and predefined random character-pattern fill presets +- Added a scrollable demo full-output SVG viewport with zoom in/out, reset, and fit controls for wide or tall renders - Kept legacy `font`, `textFill`, `shapeFill`, and `shapeStroke` compatibility paths - Extended local demo and Playwright coverage for style and decoration controls diff --git a/docs/system-architecture.md b/docs/system-architecture.md index a90202a..6cf4eca 100644 --- a/docs/system-architecture.md +++ b/docs/system-architecture.md @@ -4,25 +4,28 @@ - `text/*`: text preparation and streamed line breaking - `geometry/*`: polygon band sampling and interval extraction -- `shape/*`: shape compilation and cacheable band generation -- `layout/*`: shape-aware line placement and repeat-fill policies +- `shape/*`: shape compilation, text-mask size resolution, cacheable band generation, and optional per-character text-mask region extraction +- `layout/*`: shape-aware line placement, repeat-fill policies, and sequential region flow for per-character text masks - `render/*`: SVG serialization +- `e2e/fixtures/*`: browser demo UI, payload editor, and SVG viewport zoom controller ## Data Flow 1. Normalize text formatting into a canonical font string -2. Compile the input shape into reusable line bands -3. Compute allowed intervals for each band -4. Pick the widest interval -5. Stream the next line into that width -6. Optionally repeat the source text until bands are full +2. Resolve text-mask sizing into either `fit-content` or fixed bounds, then compile the input shape into reusable line bands +3. For `text-mask` shapes with `shapeTextMode: 'per-character'`, also compile ordered non-space grapheme regions from the same mask source +4. Compute allowed intervals for each band or per-character region +5. Route layout through sequential regions when they exist; otherwise use the normal whole-shape flow or max-fill path +6. Optionally repeat the source text until the active shape bands are full 7. Project positioned lines into SVG +8. In the demo, mount the SVG into a scrollable viewport and apply bounded zoom or fit-to-viewport scaling ## Boundary Decisions -- Text measurement stays replaceable through `TextMeasurer` +- Text measurement stays replaceable through the layout measurer interface - Layout-affecting text style stays in the layout API -- Shape compilation stays separate from content flow so glyph shapes can be cached +- Shape compilation stays separate from content flow so resolved text-mask bounds, glyph shapes, and per-character regions can be cached - Geometry stays shape-specific, not DOM-specific - Renderer-only decoration stays out of compile/layout caching - Renderer consumes compiled layout output only +- Demo zoom is a presentation concern only; it never changes compile or layout results From f83f26af65bd0f24a30c8e6c2656e5fb48f1a236 Mon Sep 17 00:00:00 2001 From: tqdat410 Date: Thu, 2 Apr 2026 20:52:12 +0700 Subject: [PATCH 09/12] feat(shape-paragraph): add react workbench and ship-ready runtime Co-authored-by: Codex --- .gitignore | 1 + LICENSE | 21 + demo/index.html | 12 + demo/src/app.tsx | 90 +++ demo/src/base.css | 64 ++ demo/src/components/payload-editor-panel.tsx | 73 +++ demo/src/components/render-summary-panel.tsx | 39 ++ demo/src/components/shape-controls-panel.tsx | 154 +++++ demo/src/components/svg-output-viewport.tsx | 86 +++ demo/src/components/text-controls-panel.tsx | 138 ++++ demo/src/demo-model.ts | 116 ++++ demo/src/demo-payload-primitives.ts | 20 + demo/src/demo-payload.test.ts | 71 ++ demo/src/demo-payload.ts | 22 + demo/src/demo-presets.test.ts | 43 ++ demo/src/demo-presets.ts | 125 ++++ demo/src/main.tsx | 18 + demo/src/merge-demo-layout-payload.ts | 147 +++++ demo/src/merge-demo-render-payload.ts | 59 ++ demo/src/use-rendered-demo-request.ts | 49 ++ demo/src/use-shape-paragraph-workbench.ts | 193 ++++++ demo/src/workbench.css | 167 +++++ demo/vite.config.ts | 32 + e2e/fixtures/demo-fill-presets.js | 52 -- e2e/fixtures/demo-payload-helpers.js | 268 -------- e2e/fixtures/demo-scenarios.js | 80 --- e2e/fixtures/demo-svg-viewport-controller.js | 138 ---- e2e/fixtures/demo-ui-controller.js | 277 -------- e2e/fixtures/index.html | 343 ---------- e2e/fixtures/shape-text-e2e-app.js | 177 ----- e2e/shape-paragraph-demo.spec.ts | 77 +++ e2e/shape-paragraph-workbench.spec.ts | 175 +++++ e2e/shape-text-local-e2e.spec.ts | 571 ---------------- package-lock.json | 617 ++++++++++++++++++ package.json | 58 +- playwright.config.ts | 9 +- scripts/run-demo-app.mjs | 71 ++ scripts/run-e2e-serve.mjs | 36 - scripts/run-library-build.mjs | 33 + scripts/run-library-ship-check.mjs | 167 +++++ scripts/run-node-script-from-package-root.cjs | 35 + scripts/run-project-check.mjs | 31 + scripts/serve-static-e2e.mjs | 57 -- src/index.ts | 2 - .../layout-flow-lines-in-compiled-shape.ts | 40 +- src/layout/layout-text-in-compiled-shape.ts | 10 +- src/layout/layout-text-in-shape.test.ts | 51 +- src/layout/layout-text-in-shape.ts | 6 +- src/layout/resolve-flow-layout.ts | 31 +- src/layout/resolve-max-fill-layout.ts | 2 - .../resolve-sequential-shape-regions.ts | 36 +- src/render/render-layout-to-svg.test.ts | 6 +- src/types.ts | 9 - tsconfig.build.json | 4 +- tsconfig.json | 16 +- vitest.config.ts | 6 +- 56 files changed, 3054 insertions(+), 2177 deletions(-) create mode 100644 LICENSE create mode 100644 demo/index.html create mode 100644 demo/src/app.tsx create mode 100644 demo/src/base.css create mode 100644 demo/src/components/payload-editor-panel.tsx create mode 100644 demo/src/components/render-summary-panel.tsx create mode 100644 demo/src/components/shape-controls-panel.tsx create mode 100644 demo/src/components/svg-output-viewport.tsx create mode 100644 demo/src/components/text-controls-panel.tsx create mode 100644 demo/src/demo-model.ts create mode 100644 demo/src/demo-payload-primitives.ts create mode 100644 demo/src/demo-payload.test.ts create mode 100644 demo/src/demo-payload.ts create mode 100644 demo/src/demo-presets.test.ts create mode 100644 demo/src/demo-presets.ts create mode 100644 demo/src/main.tsx create mode 100644 demo/src/merge-demo-layout-payload.ts create mode 100644 demo/src/merge-demo-render-payload.ts create mode 100644 demo/src/use-rendered-demo-request.ts create mode 100644 demo/src/use-shape-paragraph-workbench.ts create mode 100644 demo/src/workbench.css create mode 100644 demo/vite.config.ts delete mode 100644 e2e/fixtures/demo-fill-presets.js delete mode 100644 e2e/fixtures/demo-payload-helpers.js delete mode 100644 e2e/fixtures/demo-scenarios.js delete mode 100644 e2e/fixtures/demo-svg-viewport-controller.js delete mode 100644 e2e/fixtures/demo-ui-controller.js delete mode 100644 e2e/fixtures/index.html delete mode 100644 e2e/fixtures/shape-text-e2e-app.js create mode 100644 e2e/shape-paragraph-demo.spec.ts create mode 100644 e2e/shape-paragraph-workbench.spec.ts delete mode 100644 e2e/shape-text-local-e2e.spec.ts create mode 100644 scripts/run-demo-app.mjs delete mode 100644 scripts/run-e2e-serve.mjs create mode 100644 scripts/run-library-build.mjs create mode 100644 scripts/run-library-ship-check.mjs create mode 100644 scripts/run-node-script-from-package-root.cjs create mode 100644 scripts/run-project-check.mjs delete mode 100644 scripts/serve-static-e2e.mjs diff --git a/.gitignore b/.gitignore index c9310e7..206f449 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ dist/ +demo/dist/ coverage/ playwright-report/ test-results/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b62b2f8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Admin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/demo/index.html b/demo/index.html new file mode 100644 index 0000000..de0d944 --- /dev/null +++ b/demo/index.html @@ -0,0 +1,12 @@ + + + + + + shape-text shape paragraph demo + + +
+ + + diff --git a/demo/src/app.tsx b/demo/src/app.tsx new file mode 100644 index 0000000..de8dbc4 --- /dev/null +++ b/demo/src/app.tsx @@ -0,0 +1,90 @@ +import { PayloadEditorPanel } from './components/payload-editor-panel' +import { RenderSummaryPanel } from './components/render-summary-panel' +import { ShapeControlsPanel } from './components/shape-controls-panel' +import { SvgOutputViewport } from './components/svg-output-viewport' +import { TextControlsPanel } from './components/text-controls-panel' +import { useRenderedDemoRequest } from './use-rendered-demo-request' +import { useShapeParagraphWorkbench } from './use-shape-paragraph-workbench' + +export function App() { + const workbench = useShapeParagraphWorkbench() + const rendered = useRenderedDemoRequest(workbench.request) + const isTextMask = workbench.request.layout.shape.kind === 'text-mask' + const textWeight = String(workbench.request.layout.textStyle.weight ?? '700') + const textItalic = (workbench.request.layout.textStyle.style ?? 'normal') !== 'normal' + const textColor = workbench.request.layout.textStyle.color ?? '#0f172a' + const shapeFill = workbench.request.render.shapeStyle?.backgroundColor ?? '#dbeafe' + + return ( +
+
+

shape paragraph

+

Shape paragraph workbench

+

+ One library, two shape sources: explicit geometry input or value-derived input. The demo is now the real browser workbench and the future E2E target. +

+
+ +
+
+ + + + + +
+ +
+ + + + {isTextMask ? ( +

+ Value-derived mode keeps `text-mask` as the low-level API term, but frames it in the UI as a shape source, not a one-off feature. +

+ ) : ( +

+ Geometry mode uses the same layout engine, only the compiled shape source changes. +

+ )} +
+
+
+ ) +} diff --git a/demo/src/base.css b/demo/src/base.css new file mode 100644 index 0000000..1c455d0 --- /dev/null +++ b/demo/src/base.css @@ -0,0 +1,64 @@ +:root { + color-scheme: light; + font-family: "Instrument Sans", "Segoe UI", sans-serif; + background: #eff3f6; + color: #10243c; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 320px; + background: + radial-gradient(circle at top left, rgba(174, 239, 220, 0.38), transparent 32%), + linear-gradient(180deg, #f7f5ee 0%, #e8eef4 100%); +} + +button, +input, +select, +textarea { + font: inherit; +} + +h1, +h2, +p { + margin: 0; +} + +textarea, +input[type="text"], +input[type="number"], +select { + width: 100%; + padding: 12px 14px; + border-radius: 18px; + border: 1px solid #cad5e0; + background: rgba(255, 255, 255, 0.92); +} + +textarea { + min-height: 160px; + resize: vertical; +} + +input[type="range"] { + width: 100%; +} + +button { + border: 1px solid #c1cfda; + border-radius: 999px; + background: white; + padding: 8px 14px; + cursor: pointer; +} + +details summary { + cursor: pointer; + font-weight: 700; +} diff --git a/demo/src/components/payload-editor-panel.tsx b/demo/src/components/payload-editor-panel.tsx new file mode 100644 index 0000000..4491f16 --- /dev/null +++ b/demo/src/components/payload-editor-panel.tsx @@ -0,0 +1,73 @@ +import { useEffect, useState } from 'react' + +import type { DemoRequest } from '../demo-model' +import { parseDemoRequest, serializeDemoRequest } from '../demo-payload' + +type PayloadEditorPanelProps = { + request: DemoRequest + onApply: (value: DemoRequest) => void +} + +export function PayloadEditorPanel(props: PayloadEditorPanelProps) { + const [draft, setDraft] = useState(() => serializeDemoRequest(props.request)) + const [isDirty, setIsDirty] = useState(false) + const [error, setError] = useState('') + + useEffect(() => { + if (!isDirty) { + setDraft(serializeDemoRequest(props.request)) + } + }, [isDirty, props.request]) + + function handleApply() { + try { + props.onApply(parseDemoRequest(draft, props.request)) + setError('') + setIsDirty(false) + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : String(nextError)) + } + } + + function handleReset() { + setDraft(serializeDemoRequest(props.request)) + setError('') + setIsDirty(false) + } + + return ( +
+ Advanced payload editor + +

Edit the raw `layoutTextInShape()` and `renderLayoutToSvg()` request only when you need full control.

+ +