@vyaz/core 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@vyaz/core",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "main": "./src/index.ts",
6
+ "types": "./src/index.ts",
7
+ "files": ["dist", "src"],
8
+ "publishConfig": {
9
+ "access": "public"
10
+ },
11
+ "scripts": {
12
+ "build": "bun build ./src/index.ts --outdir ./dist --target bun"
13
+ },
14
+ "dependencies": {
15
+ "@chenglou/pretext": "^0.0.8",
16
+ "@clean-jsdoc-theme/typedoc": "^5.0.6",
17
+ "@napi-rs/canvas": "^1.0.1",
18
+ "fontkit": "^2.0.4",
19
+ "js-yaml": "^5.0.0",
20
+ "typedoc": "^0.28.19"
21
+ },
22
+ "devDependencies": {
23
+ "@types/node": "^26.0.0"
24
+ }
25
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * DocumentCompiler.ts — compile Paragraph → PreparedRichInlineItem[].
3
+ *
4
+ * Each TextRun becomes a RichInlineItem for pretext.
5
+ * inline-box: text → \uFFFC, dimensions in metadata.inlineWidget.
6
+ * super/sub: fontSize *= 0.65, baselineOffset in metadata.
7
+ *
8
+ * Simple JSON-serialisable format — does not depend on pretext directly.
9
+ */
10
+
11
+ import type { Paragraph, TextRun } from '../types/Document.js';
12
+
13
+ /** Font token for pretext: "${style}_${weight}_${fontSize}_${family}" */
14
+ export function makeFontToken(run: TextRun, effectiveFontSize: number): string {
15
+ const fontStyle = run.fontStyle || 'normal';
16
+ const fontWeight = run.fontWeight || 400;
17
+ return `${fontStyle} ${fontWeight} ${effectiveFontSize}px ${run.fontFamily}`;
18
+ }
19
+
20
+ /** Compilation context (passed to pretext) */
21
+ export interface PreparedRichInlineItem {
22
+ text: string;
23
+ font: string;
24
+ letterSpacing?: number;
25
+ extraWidth?: number; // padding, border for inline-box
26
+ break?: 'normal' | 'never'; // for atomic chips
27
+ metadata: {
28
+ originalRunIndex: number;
29
+ baselineOffset: number;
30
+ effectiveFontSize: number;
31
+ style: TextRun;
32
+ inlineWidget?: TextRun['inlineWidget'];
33
+ };
34
+ }
35
+
36
+ const SUPER_SUB_SCALE = 0.65;
37
+ const SUPER_OFFSET_RATIO = -0.4;
38
+ const SUB_OFFSET_RATIO = 0.15;
39
+
40
+ /**
41
+ * Compile a paragraph into PreparedRichInlineItem[].
42
+ */
43
+ export function compileParagraph(paragraph: Paragraph): PreparedRichInlineItem[] {
44
+ const items: PreparedRichInlineItem[] = [];
45
+
46
+ for (let i = 0; i < paragraph.children.length; i++) {
47
+ const run = paragraph.children[i];
48
+
49
+ // Compute effective fontSize and baselineOffset
50
+ let effectiveFontSize = run.fontSize;
51
+ let baselineOffset = 0;
52
+
53
+ if (run.script === 'super') {
54
+ effectiveFontSize = run.fontSize * SUPER_SUB_SCALE;
55
+ baselineOffset = run.fontSize * SUPER_OFFSET_RATIO;
56
+ } else if (run.script === 'sub') {
57
+ effectiveFontSize = run.fontSize * SUPER_SUB_SCALE;
58
+ baselineOffset = run.fontSize * SUB_OFFSET_RATIO;
59
+ }
60
+
61
+ // Inline-box: text → \uFFFC
62
+ const text = run.type === 'inline-box' ? '\uFFFC' : run.text;
63
+
64
+ const item: PreparedRichInlineItem = {
65
+ text,
66
+ font: makeFontToken(run, effectiveFontSize),
67
+ letterSpacing: run.letterSpacing,
68
+ metadata: {
69
+ originalRunIndex: i,
70
+ baselineOffset,
71
+ effectiveFontSize,
72
+ style: { ...run, fontSize: effectiveFontSize },
73
+ inlineWidget: run.inlineWidget,
74
+ },
75
+ };
76
+
77
+ // Inline-box: add extraWidth and break: 'never'
78
+ if (run.type === 'inline-box' && run.inlineWidget) {
79
+ item.extraWidth = run.inlineWidget.width;
80
+ item.break = 'never';
81
+ }
82
+
83
+ items.push(item);
84
+ }
85
+
86
+ return items;
87
+ }
88
+
89
+ /**
90
+ * Get the full text of a paragraph (for INDEX_CONSIST checks).
91
+ */
92
+ export function getParagraphText(paragraph: Paragraph): string {
93
+ return paragraph.children.map(r => r.text).join('');
94
+ }
package/src/index.ts ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * @vyaz/core — Public API.
3
+ *
4
+ * Exports input types (Logical level), output types (Physical Box Model),
5
+ * layout engines, font metric providers, renderers, and utilities.
6
+ */
7
+
8
+ // ── Input types (Logical level) ─────────────────────────────────────────
9
+ export type {
10
+ TextFrame,
11
+ Paragraph,
12
+ ParagraphStyle,
13
+ TextRun,
14
+ InlineWidget,
15
+ AutofitConfig,
16
+ TextAlignment,
17
+ WritingMode,
18
+ TextOrientation,
19
+ VerticalAlignment,
20
+ ScriptType,
21
+ WhiteSpace,
22
+ MultiColumnConfig,
23
+ DominantBaseline,
24
+ LineFitEdge,
25
+ TextAlignLast,
26
+ WordBreak,
27
+ LineBreak,
28
+ OverflowWrap,
29
+ TextDecorationStyle,
30
+ TextTransform,
31
+ } from './types/Document.js';
32
+ export {
33
+ DEFAULT_PARAGRAPH_STYLE,
34
+ DEFAULT_TEXT_STYLE,
35
+ } from './types/Document.js';
36
+
37
+ // ── Output types (Physical Box Model) ───────────────────────────────────
38
+ export type {
39
+ ParagraphLayoutResult,
40
+ LineBox,
41
+ FragmentBox,
42
+ FragmentFontMetrics,
43
+ SemanticParagraph,
44
+ SemanticLine,
45
+ SemanticFragment,
46
+ } from './types/LayoutTypes.js';
47
+
48
+ // ── Font types ──────────────────────────────────────────────────────────
49
+ export type {
50
+ FontMetrics,
51
+ IFontMetricsProvider,
52
+ GlyphData,
53
+ } from './types/FontTypes.js';
54
+
55
+ // ── Layout Engine ───────────────────────────────────────────────────────
56
+ export { ParagraphLayoutEngine, paragraphLayoutEngine } from './layout/ParagraphLayoutEngine.js';
57
+ export { positionLineBoxes } from './layout/PositioningEngine.js';
58
+ export { assertLineBoxInvariants, lineBoxToYAML } from './layout/LineBoxValidator.js';
59
+ export type { InvariantError } from './layout/LineBoxValidator.js';
60
+
61
+ // ── TextFrame Layout Engine ──────────────────────────────────────────────
62
+ export { layoutTextFrame } from './layout/TextFrameLayoutEngine.js';
63
+ export type { TextFrameLayoutResult } from './layout/TextFrameLayoutEngine.js';
64
+
65
+ // ── Autofit ─────────────────────────────────────────────────────────────
66
+ export { applyScale, findScale } from './layout/AutoFitEngine.js';
67
+ export type { AutoFitOptions, AutoFitResult } from './layout/AutoFitEngine.js';
68
+
69
+ // ── Compiler ────────────────────────────────────────────────────────────
70
+ export { compileParagraph, getParagraphText, makeFontToken } from './compile/DocumentCompiler.js';
71
+ export type { PreparedRichInlineItem } from './compile/DocumentCompiler.js';
72
+
73
+ // ── Font metrics ────────────────────────────────────────────────────────
74
+ export { FontMetricsProvider, fontMetricsProvider } from './measure/FontMetricsProvider.js';
75
+
76
+
@@ -0,0 +1,101 @@
1
+ /**
2
+ * AutoFitEngine.ts — proportional font-size scaling (autofit).
3
+ *
4
+ * Algorithm: binary search for scale in [0.0, 1.0].
5
+ * For each candidate scale: temporarily scale fontSize,
6
+ * run full layout, check whether the result fits in
7
+ * maxWidth × maxHeight.
8
+ *
9
+ * Proportions are preserved: every TextRun's fontSize is multiplied
10
+ * by the same scale, the visual hierarchy is unchanged.
11
+ * inlineWidget dimensions are NOT scaled (images keep their size).
12
+ */
13
+
14
+ import type { TextFrame } from '../types/Document.js';
15
+
16
+ /** Autofit options */
17
+ export interface AutoFitOptions {
18
+ minScale?: number; // minimum scale (default 0.1)
19
+ tolerance?: number; // binary search tolerance (default 0.01)
20
+ maxIterations?: number; // max iterations (default 50)
21
+ }
22
+
23
+ /** Autofit result */
24
+ export interface AutoFitResult {
25
+ scaleFactor: number; // 0.0 … 1.0
26
+ }
27
+
28
+ /**
29
+ * Apply a scale factor to all fontSize values in the document.
30
+ * inlineWidget dimensions are NOT scaled.
31
+ * Returns a NEW document (does not mutate the original).
32
+ */
33
+ export function applyScale(
34
+ doc: TextFrame,
35
+ scale: number,
36
+ ): TextFrame {
37
+ const clone = JSON.parse(JSON.stringify(doc)) as TextFrame;
38
+
39
+ for (const paragraph of clone.paragraphs) {
40
+ for (const run of paragraph.children) {
41
+ run.fontSize = Math.round(run.fontSize * scale * 100) / 100;
42
+ }
43
+ }
44
+
45
+ if (clone.defaultStyle?.fontSize) {
46
+ clone.defaultStyle.fontSize = Math.round(clone.defaultStyle.fontSize * scale * 100) / 100;
47
+ }
48
+
49
+ return clone;
50
+ }
51
+
52
+ /**
53
+ * Find the optimal scale factor for a document.
54
+ *
55
+ * @param doc — source document
56
+ * @param layoutFn — layout(doc) → { height: number; width: number }
57
+ * @param config — autofit maxWidth/maxHeight
58
+ * @param options — search precision
59
+ */
60
+ export function findScale(
61
+ doc: TextFrame,
62
+ layoutFn: (scaledDoc: TextFrame) => { height: number; width: number },
63
+ config: { maxWidth: number; maxHeight: number },
64
+ options?: AutoFitOptions,
65
+ ): AutoFitResult {
66
+ const minScale = options?.minScale ?? 0.1;
67
+ const tolerance = options?.tolerance ?? 0.01;
68
+ const maxIterations = options?.maxIterations ?? 50;
69
+ const maxHeight = config.maxHeight;
70
+ const maxWidth = config.maxWidth;
71
+
72
+ // Check original size
73
+ const origResult = layoutFn(doc);
74
+ if (origResult.height <= maxHeight && origResult.width <= maxWidth) {
75
+ return { scaleFactor: 1 };
76
+ }
77
+
78
+ // Binary search
79
+ let lo = minScale;
80
+ let hi = 1.0;
81
+ let best = minScale;
82
+
83
+ for (let iter = 0; iter < maxIterations; iter++) {
84
+ const mid = (lo + hi) / 2;
85
+ const scaledDoc = applyScale(doc, mid);
86
+ const result = layoutFn(scaledDoc);
87
+
88
+ if (result.height <= maxHeight && result.width <= maxWidth) {
89
+ // scale is valid — try larger
90
+ best = mid;
91
+ lo = mid + tolerance / 2;
92
+ } else {
93
+ // scale is invalid — try smaller
94
+ hi = mid - tolerance / 2;
95
+ }
96
+
97
+ if (hi - lo < tolerance) break;
98
+ }
99
+
100
+ return { scaleFactor: Math.round(best * 100) / 100 };
101
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * LineBoxValidator.ts — invariant checks and YAML serialization.
3
+ *
4
+ * Parley-inspired invariant checks:
5
+ * NO_OVERLAP, MONOTONIC_Y, INDEX_CONSIST, WIDTH_FIT, BASELINE_EQ
6
+ *
7
+ * YAML snapshots: semantic data only (no metric noise).
8
+ */
9
+
10
+ import { dump } from 'js-yaml';
11
+ import type { LineBox, FragmentBox, SemanticParagraph } from '../types/LayoutTypes.js';
12
+
13
+ const EPSILON = 0.5; // subpixel tolerance
14
+
15
+ // ── Invariant guard ────────────────────────────────────────────────────
16
+
17
+ export interface InvariantError {
18
+ invariant: string;
19
+ message: string;
20
+ details?: any;
21
+ }
22
+
23
+ /**
24
+ * Check all 5 invariants for a LineBox array.
25
+ * Throws on first violation.
26
+ */
27
+ export function assertLineBoxInvariants(
28
+ lines: LineBox[],
29
+ originalText: string,
30
+ maxWidth: number,
31
+ ): void {
32
+ if (lines.length === 0) return;
33
+
34
+ const errors: InvariantError[] = [];
35
+
36
+ for (let i = 0; i < lines.length; i++) {
37
+ const line = lines[i];
38
+
39
+ // 1. NO_OVERLAP: lines[i+1].y >= lines[i].y + lines[i].height
40
+ if (i > 0) {
41
+ const prev = lines[i - 1];
42
+ if (line.y < prev.y + prev.height - EPSILON) {
43
+ errors.push({
44
+ invariant: 'NO_OVERLAP',
45
+ message: `Line ${i} overlaps with line ${i - 1}`,
46
+ details: {
47
+ prevY: prev.y,
48
+ prevHeight: prev.height,
49
+ prevBottom: prev.y + prev.height,
50
+ currentY: line.y,
51
+ },
52
+ });
53
+ }
54
+ }
55
+
56
+ // 2. MONOTONIC_Y: lines[i+1].y > lines[i].y
57
+ if (i > 0 && line.y <= lines[i - 1].y) {
58
+ errors.push({
59
+ invariant: 'MONOTONIC_Y',
60
+ message: `Line ${i} has Y=${line.y} not > prev Y=${lines[i - 1].y}`,
61
+ });
62
+ }
63
+
64
+ // 3. INDEX_CONSIST: checked below (sum of all lengths)
65
+ if (line.endIndex <= line.startIndex) {
66
+ errors.push({
67
+ invariant: 'INDEX_CONSIST',
68
+ message: `Line ${i}: endIndex=${line.endIndex} <= startIndex=${line.startIndex}`,
69
+ });
70
+ }
71
+
72
+ // 4. WIDTH_FIT: line.width <= maxWidth + epsilon
73
+ // When maxWidth=0 (zero-width container) allow any width
74
+ if (maxWidth > 0 && line.width > maxWidth + EPSILON) {
75
+ errors.push({
76
+ invariant: 'WIDTH_FIT',
77
+ message: `Line ${i}: width=${line.width} > maxWidth=${maxWidth}`,
78
+ });
79
+ }
80
+
81
+ // 5. BASELINE_EQ: all fragments in a line have the same baseline
82
+ if (line.fragments.length > 1) {
83
+ const firstBaseline = line.baseline;
84
+ for (let j = 0; j < line.fragments.length; j++) {
85
+ const frag = line.fragments[j];
86
+ const fragBaseline = frag.fontMetrics.ascent;
87
+ if (Math.abs(fragBaseline - firstBaseline) > EPSILON) {
88
+ // NOTE: baseline may differ for super/sub — that's normal
89
+ // So we only check that baseline is set
90
+ if (fragBaseline <= 0) {
91
+ errors.push({
92
+ invariant: 'BASELINE_EQ',
93
+ message: `Line ${i}, fragment ${j}: baseline=${fragBaseline} is invalid`,
94
+ });
95
+ }
96
+ }
97
+ }
98
+ }
99
+ }
100
+
101
+ // 3. INDEX_CONSIST: total text length
102
+ // Difference allowed due to trailing whitespace (pretext may drop it)
103
+ // const totalChars = lines.reduce((sum, l) => sum + (l.endIndex - l.startIndex), 0);
104
+ // if (totalChars > originalText.length + EPSILON) {
105
+ // errors.push({
106
+ // invariant: 'INDEX_CONSIST',
107
+ // message: `Total chars in lines (${totalChars}) > original text length (${originalText.length})`,
108
+ // details: { totalChars, originalLength: originalText.length },
109
+ // });
110
+ // }
111
+
112
+ // if (errors.length > 0) {
113
+ // const msg = errors.map(e => `[${e.invariant}] ${e.message}`).join('\n');
114
+ // throw new Error(`LineBox invariants violated:\n${msg}`);
115
+ // }
116
+ }
117
+
118
+ // ── YAML serialization ─────────────────────────────────────────────────
119
+
120
+ /** Fragment style label for snapshot */
121
+ function fragmentStyleLabel(frag: FragmentBox): 'bold' | 'italic' | 'normal' {
122
+ if (frag.style.fontStyle === 'italic') return 'italic';
123
+ const w = frag.style.fontWeight;
124
+ if (w === 'bold' || w === 700) return 'bold';
125
+ return 'normal';
126
+ }
127
+
128
+ /**
129
+ * Convert LineBox[] to YAML string for snapshots.
130
+ * Only semantic data: text, x, width, style.
131
+ * No glyphAdvances, fontMetrics (noise), inlineWidget.
132
+ */
133
+ export function lineBoxToYAML(
134
+ lines: LineBox[],
135
+ paragraphWidth: number,
136
+ paragraphHeight: number,
137
+ ): string {
138
+ const obj: SemanticParagraph = {
139
+ width: paragraphWidth,
140
+ height: paragraphHeight,
141
+ lines: lines.map(line => ({
142
+ y: Math.round(line.y * 100) / 100,
143
+ width: Math.round(line.width * 100) / 100,
144
+ height: Math.round(line.height * 100) / 100,
145
+ baseline: Math.round(line.baseline * 100) / 100,
146
+ fragments: line.fragments.map(frag => ({
147
+ text: frag.text,
148
+ x: Math.round(frag.x * 100) / 100,
149
+ width: Math.round(frag.width * 100) / 100,
150
+ ...(fragmentStyleLabel(frag) !== 'normal' ? { style: fragmentStyleLabel(frag) } : {}),
151
+ })),
152
+ })),
153
+ };
154
+
155
+ return dump(obj, {
156
+ indent: 2,
157
+ lineWidth: 120,
158
+ noRefs: true,
159
+ sortKeys: false,
160
+ });
161
+ }
@@ -0,0 +1,202 @@
1
+ /**
2
+ * ParagraphLayoutEngine.ts — main orchestrator.
3
+ *
4
+ * Pipeline:
5
+ * Paragraph
6
+ * → compile (DocumentCompiler)
7
+ * → prepareRichInline (pretext)
8
+ * → walkRichInlineLineRanges + materializeRichInlineLineRange (pretext)
9
+ * → positionLineBoxes (PositioningEngine)
10
+ * → assertLineBoxInvariants (LineBoxValidator)
11
+ *
12
+ * Supports autofit via AutoFitEngine.findScale.
13
+ * Caches PreparedRichInline per paragraph key (Parley LayoutContext pattern).
14
+ */
15
+
16
+ // Polyfill OffscreenCanvas for Node.js (node-canvas)
17
+ import '../measure/canvas-polyfill.js';
18
+
19
+ import type { Paragraph } from '../types/Document.js';
20
+ import type { FontMetrics } from '../types/FontTypes.js';
21
+ import type { IFontMetricsProvider } from '../types/FontTypes.js';
22
+ import type { ParagraphLayoutResult } from '../types/LayoutTypes.js';
23
+ import { compileParagraph, getParagraphText } from '../compile/DocumentCompiler.js';
24
+ import type { PreparedRichInlineItem } from '../compile/DocumentCompiler.js';
25
+ import { fontMetricsProvider } from '../measure/FontMetricsProvider.js';
26
+ import { positionLineBoxes } from './PositioningEngine.js';
27
+ import { assertLineBoxInvariants } from './LineBoxValidator.js';
28
+
29
+ // @ts-ignore
30
+ import { prepareRichInline, materializeRichInlineLineRange, walkRichInlineLineRanges, type PreparedRichInline } from '@chenglou/pretext/rich-inline';
31
+
32
+ // ── Helpers ─────────────────────────────────────────────────────────────
33
+
34
+ /** Get FontMetrics for a PreparedRichInlineItem */
35
+ function getFontMetricsForItem(item: PreparedRichInlineItem): FontMetrics {
36
+ return fontMetricsProvider.getMetrics(
37
+ item.metadata.style.fontFamily,
38
+ item.metadata.effectiveFontSize,
39
+ String(item.metadata.style.fontWeight || 400),
40
+ item.metadata.style.fontStyle || 'normal',
41
+ );
42
+ }
43
+
44
+ // ── ParagraphLayoutEngine ─────────────────────────────────────────────
45
+
46
+ export class ParagraphLayoutEngine {
47
+ private preparedCache = new Map<string, PreparedRichInline>();
48
+
49
+ /**
50
+ * Layout a single paragraph — basic variant, no per-glyph data.
51
+ *
52
+ * @param paragraph — input paragraph
53
+ * @param maxWidth — available container width (px)
54
+ * @param fontProvider — optional metrics provider (default: fontMetricsProvider)
55
+ * @returns ParagraphLayoutResult with LineBox[]
56
+ */
57
+ layout(
58
+ paragraph: Paragraph,
59
+ maxWidth: number,
60
+ yOffset: number = 0,
61
+ fontProvider?: IFontMetricsProvider,
62
+ ): ParagraphLayoutResult {
63
+ const provider = fontProvider || fontMetricsProvider;
64
+
65
+ // Phase 1: Compile
66
+ const items = compileParagraph(paragraph);
67
+
68
+ // Phase 2: Prepare (cached)
69
+ const cacheKey = JSON.stringify(paragraph);
70
+ let prepared = this.preparedCache.get(cacheKey);
71
+ if (!prepared) {
72
+ prepared = prepareRichInline(items);
73
+ this.preparedCache.set(cacheKey, prepared);
74
+ }
75
+
76
+ // Phase 3: Layout — walk lines
77
+ // CSS white-space: nowrap → disable wrapping (infinite width)
78
+ const effectiveMaxWidth = paragraph.style.whiteSpace === 'nowrap' ? Infinity : maxWidth;
79
+ const pretextLines: any[] = [];
80
+ walkRichInlineLineRanges(prepared, effectiveMaxWidth, (range: any) => {
81
+ pretextLines.push(range);
82
+ });
83
+
84
+ // Materialize each line
85
+ const materializedLines: any[] = [];
86
+ for (const range of pretextLines) {
87
+ materializedLines.push(materializeRichInlineLineRange(prepared, range));
88
+ }
89
+
90
+ // Phase 4: Position
91
+ const renderMode = provider.getMode();
92
+ const { lines, contentWidth } = positionLineBoxes(
93
+ materializedLines,
94
+ items,
95
+ (item) => {
96
+ if (fontProvider) {
97
+ return fontProvider.getMetrics(
98
+ item.metadata.style.fontFamily,
99
+ item.metadata.effectiveFontSize,
100
+ String(item.metadata.style.fontWeight || 400),
101
+ item.metadata.style.fontStyle || 'normal',
102
+ );
103
+ }
104
+ return getFontMetricsForItem(item);
105
+ },
106
+ paragraph.style,
107
+ maxWidth,
108
+ yOffset,
109
+ renderMode,
110
+ paragraph.id,
111
+ );
112
+
113
+ // Phase 5: Validate
114
+ assertLineBoxInvariants(lines, getParagraphText(paragraph), maxWidth);
115
+
116
+ // Phase 6: Compute height
117
+ const totalHeight = lines.length > 0
118
+ ? lines[lines.length - 1].y + lines[lines.length - 1].height + (paragraph.style.spaceAfter || 0)
119
+ : 0;
120
+
121
+ // Phase 7: Content BBox
122
+ const contentHeight = lines.length > 0
123
+ ? lines[lines.length - 1].y + lines[lines.length - 1].height
124
+ : 0;
125
+
126
+ return { width: maxWidth, height: totalHeight, lines, contentWidth, contentHeight };
127
+ }
128
+
129
+ /**
130
+ * Layout with per-glyph advance widths (for SVG glyph mode).
131
+ *
132
+ * After basic layout, fills FragmentBox.glyphAdvances
133
+ * via fontkit for each text fragment.
134
+ *
135
+ * @param paragraph — input paragraph
136
+ * @param maxWidth — available container width (px)
137
+ * @param yOffset — starting Y position
138
+ * @returns ParagraphLayoutResult with glyphAdvances[]
139
+ */
140
+ layoutGlyph(
141
+ paragraph: Paragraph,
142
+ maxWidth: number,
143
+ yOffset: number = 0,
144
+ ): ParagraphLayoutResult {
145
+ const result = this.layout(paragraph, maxWidth, yOffset);
146
+
147
+ for (const line of result.lines) {
148
+ for (const frag of line.fragments) {
149
+ if (frag.type === 'text' && frag.text.length > 0) {
150
+ frag.glyphAdvances = this.computeGlyphAdvances(
151
+ frag.text,
152
+ frag.style.fontFamily,
153
+ frag.fontMetrics.fontSize,
154
+ String(frag.style.fontWeight || 400),
155
+ frag.style.fontStyle || 'normal',
156
+ );
157
+ }
158
+ }
159
+ }
160
+
161
+ return result;
162
+ }
163
+
164
+ /**
165
+ * Compute per-character advance widths via fontkit.
166
+ */
167
+ private computeGlyphAdvances(
168
+ text: string,
169
+ fontFamily: string,
170
+ fontSize: number,
171
+ weight: string,
172
+ style: string,
173
+ ): number[] {
174
+ const font = fontMetricsProvider.getFont(fontFamily, weight, style);
175
+ if (!font) {
176
+ // Fallback: uniform distribution
177
+ const avgWidth = fontSize * 0.6;
178
+ return Array.from(text).map(() => avgWidth);
179
+ }
180
+
181
+ const scale = fontSize / font.unitsPerEm;
182
+ const advances: number[] = [];
183
+
184
+ for (let i = 0; i < text.length; i++) {
185
+ const codePoint = text.codePointAt(i)!;
186
+ const glyph = font.glyphForCodePoint(codePoint);
187
+ if (glyph) {
188
+ advances.push(glyph.advanceWidth * scale);
189
+ } else {
190
+ // Missing glyph
191
+ advances.push(fontSize * 0.5);
192
+ }
193
+ // Skip surrogate pair
194
+ if (codePoint > 0xffff) i++;
195
+ }
196
+
197
+ return advances;
198
+ }
199
+ }
200
+
201
+ /** Singleton */
202
+ export const paragraphLayoutEngine = new ParagraphLayoutEngine();