@vyaz/core 0.0.1 → 0.0.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vyaz/core",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -9,16 +9,20 @@
9
9
  "access": "public"
10
10
  },
11
11
  "scripts": {
12
- "build": "bun build ./src/index.ts --outdir ./dist --target bun"
12
+ "build": "bun build ./src/index.ts --outdir ./dist --target bun",
13
+ "test": "bun test"
13
14
  },
14
15
  "dependencies": {
15
16
  "@chenglou/pretext": "^0.0.8",
16
17
  "@clean-jsdoc-theme/typedoc": "^5.0.6",
17
- "@napi-rs/canvas": "^1.0.1",
18
18
  "fontkit": "^2.0.4",
19
+ "get-system-fonts": "^2.0.2",
19
20
  "js-yaml": "^5.0.0",
20
21
  "typedoc": "^0.28.19"
21
22
  },
23
+ "optionalDependencies": {
24
+ "@napi-rs/canvas": "^1.0.2"
25
+ },
22
26
  "devDependencies": {
23
27
  "@types/node": "^26.0.0"
24
28
  }
@@ -9,12 +9,40 @@
9
9
  */
10
10
 
11
11
  import type { Paragraph, TextRun } from '../types/Document.js';
12
+ import { DEFAULT_TEXT_STYLE } from '../types/Document.js';
13
+
14
+ // ── Font Weight normalization (matching react-pdf convention) ───────────
15
+
16
+ export const FONT_WEIGHTS: Record<string, number> = {
17
+ thin: 100,
18
+ hairline: 100,
19
+ ultralight: 200,
20
+ extralight: 200,
21
+ light: 300,
22
+ normal: 400,
23
+ medium: 500,
24
+ semibold: 600,
25
+ demibold: 600,
26
+ bold: 700,
27
+ ultrabold: 800,
28
+ extrabold: 800,
29
+ heavy: 900,
30
+ black: 900,
31
+ };
32
+
33
+ /** Normalize fontWeight to a numeric value (400 by default). */
34
+ export function normalizeFontWeight(weight: number | string | undefined): number {
35
+ if (weight == null) return FONT_WEIGHTS.normal;
36
+ if (typeof weight === 'number') return weight;
37
+ return FONT_WEIGHTS[weight.toLowerCase()] ?? FONT_WEIGHTS.normal;
38
+ }
12
39
 
13
40
  /** Font token for pretext: "${style}_${weight}_${fontSize}_${family}" */
14
41
  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}`;
42
+ const fontStyle = run.fontStyle || DEFAULT_TEXT_STYLE.fontStyle || 'normal';
43
+ const fontWeight = normalizeFontWeight(run.fontWeight ?? DEFAULT_TEXT_STYLE.fontWeight);
44
+ const fontFamily = run.fontFamily || DEFAULT_TEXT_STYLE.fontFamily || 'Arial';
45
+ return `${fontStyle} ${fontWeight} ${effectiveFontSize}px ${fontFamily}`;
18
46
  }
19
47
 
20
48
  /** Compilation context (passed to pretext) */
@@ -47,20 +75,34 @@ export function compileParagraph(paragraph: Paragraph): PreparedRichInlineItem[]
47
75
  const run = paragraph.children[i];
48
76
 
49
77
  // Compute effective fontSize and baselineOffset
50
- let effectiveFontSize = run.fontSize;
78
+ const baseFontSize = run.fontSize ?? DEFAULT_TEXT_STYLE.fontSize ?? 12;
79
+ let effectiveFontSize = baseFontSize;
51
80
  let baselineOffset = 0;
52
81
 
53
82
  if (run.script === 'super') {
54
- effectiveFontSize = run.fontSize * SUPER_SUB_SCALE;
55
- baselineOffset = run.fontSize * SUPER_OFFSET_RATIO;
83
+ effectiveFontSize = baseFontSize * SUPER_SUB_SCALE;
84
+ baselineOffset = baseFontSize * SUPER_OFFSET_RATIO;
56
85
  } else if (run.script === 'sub') {
57
- effectiveFontSize = run.fontSize * SUPER_SUB_SCALE;
58
- baselineOffset = run.fontSize * SUB_OFFSET_RATIO;
86
+ effectiveFontSize = baseFontSize * SUPER_SUB_SCALE;
87
+ baselineOffset = baseFontSize * SUB_OFFSET_RATIO;
59
88
  }
60
89
 
61
90
  // Inline-box: text → \uFFFC
62
91
  const text = run.type === 'inline-box' ? '\uFFFC' : run.text;
63
92
 
93
+ // Normalize fontWeight to numeric value
94
+ const resolvedFontWeight = normalizeFontWeight(run.fontWeight ?? DEFAULT_TEXT_STYLE.fontWeight);
95
+
96
+ // Fill missing style fields from DEFAULT_TEXT_STYLE
97
+ const resolvedStyle: TextRun = {
98
+ ...DEFAULT_TEXT_STYLE,
99
+ ...run,
100
+ fontSize: effectiveFontSize,
101
+ fontWeight: resolvedFontWeight,
102
+ text: run.text,
103
+ type: run.type,
104
+ } as TextRun;
105
+
64
106
  const item: PreparedRichInlineItem = {
65
107
  text,
66
108
  font: makeFontToken(run, effectiveFontSize),
@@ -69,7 +111,7 @@ export function compileParagraph(paragraph: Paragraph): PreparedRichInlineItem[]
69
111
  originalRunIndex: i,
70
112
  baselineOffset,
71
113
  effectiveFontSize,
72
- style: { ...run, fontSize: effectiveFontSize },
114
+ style: resolvedStyle,
73
115
  inlineWidget: run.inlineWidget,
74
116
  },
75
117
  };
package/src/index.ts CHANGED
@@ -37,9 +37,9 @@ export {
37
37
  // ── Output types (Physical Box Model) ───────────────────────────────────
38
38
  export type {
39
39
  ParagraphLayoutResult,
40
- LineBox,
41
- FragmentBox,
42
- FragmentFontMetrics,
40
+ Line,
41
+ Span,
42
+ SpanFontMetrics,
43
43
  SemanticParagraph,
44
44
  SemanticLine,
45
45
  SemanticFragment,
@@ -54,8 +54,8 @@ export type {
54
54
 
55
55
  // ── Layout Engine ───────────────────────────────────────────────────────
56
56
  export { ParagraphLayoutEngine, paragraphLayoutEngine } from './layout/ParagraphLayoutEngine.js';
57
- export { positionLineBoxes } from './layout/PositioningEngine.js';
58
- export { assertLineBoxInvariants, lineBoxToYAML } from './layout/LineBoxValidator.js';
57
+ export { positionLines } from './layout/PositioningEngine.js';
58
+ export { assertLineInvariants, linesToYAML } from './layout/LineBoxValidator.js';
59
59
  export type { InvariantError } from './layout/LineBoxValidator.js';
60
60
 
61
61
  // ── TextFrame Layout Engine ──────────────────────────────────────────────
@@ -73,4 +73,8 @@ export type { PreparedRichInlineItem } from './compile/DocumentCompiler.js';
73
73
  // ── Font metrics ────────────────────────────────────────────────────────
74
74
  export { FontMetricsProvider, fontMetricsProvider } from './measure/FontMetricsProvider.js';
75
75
 
76
+ // ── System font registry ────────────────────────────────────────────────
77
+ export { SystemFontRegistry, systemFontRegistry } from './measure/SystemFontRegistry.js';
76
78
 
79
+ // ── Errors ──────────────────────────────────────────────────────────────
80
+ export { FontNotFoundError } from './measure/FontNotFoundError.js';
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { dump } from 'js-yaml';
11
- import type { LineBox, FragmentBox, SemanticParagraph } from '../types/LayoutTypes.js';
11
+ import type { Line, Span, SemanticParagraph } from '../types/LayoutTypes.js';
12
12
 
13
13
  const EPSILON = 0.5; // subpixel tolerance
14
14
 
@@ -21,11 +21,11 @@ export interface InvariantError {
21
21
  }
22
22
 
23
23
  /**
24
- * Check all 5 invariants for a LineBox array.
24
+ * Check all 5 invariants for a Line array.
25
25
  * Throws on first violation.
26
26
  */
27
- export function assertLineBoxInvariants(
28
- lines: LineBox[],
27
+ export function assertLineInvariants(
28
+ lines: Line[],
29
29
  originalText: string,
30
30
  maxWidth: number,
31
31
  ): void {
@@ -78,19 +78,19 @@ export function assertLineBoxInvariants(
78
78
  });
79
79
  }
80
80
 
81
- // 5. BASELINE_EQ: all fragments in a line have the same baseline
82
- if (line.fragments.length > 1) {
81
+ // 5. BASELINE_EQ: all spans in a line have the same baseline
82
+ if (line.spans.length > 1) {
83
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) {
84
+ for (let j = 0; j < line.spans.length; j++) {
85
+ const span = line.spans[j];
86
+ const spanBaseline = span.fontMetrics.ascent;
87
+ if (Math.abs(spanBaseline - firstBaseline) > EPSILON) {
88
88
  // NOTE: baseline may differ for super/sub — that's normal
89
89
  // So we only check that baseline is set
90
- if (fragBaseline <= 0) {
90
+ if (spanBaseline <= 0) {
91
91
  errors.push({
92
92
  invariant: 'BASELINE_EQ',
93
- message: `Line ${i}, fragment ${j}: baseline=${fragBaseline} is invalid`,
93
+ message: `Line ${i}, span ${j}: baseline=${spanBaseline} is invalid`,
94
94
  });
95
95
  }
96
96
  }
@@ -117,21 +117,21 @@ export function assertLineBoxInvariants(
117
117
 
118
118
  // ── YAML serialization ─────────────────────────────────────────────────
119
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;
120
+ /** Span style label for snapshot */
121
+ function spanStyleLabel(span: Span): 'bold' | 'italic' | 'normal' {
122
+ if (span.style.fontStyle === 'italic') return 'italic';
123
+ const w = span.style.fontWeight;
124
124
  if (w === 'bold' || w === 700) return 'bold';
125
125
  return 'normal';
126
126
  }
127
127
 
128
128
  /**
129
- * Convert LineBox[] to YAML string for snapshots.
129
+ * Convert Line[] to YAML string for snapshots.
130
130
  * Only semantic data: text, x, width, style.
131
131
  * No glyphAdvances, fontMetrics (noise), inlineWidget.
132
132
  */
133
- export function lineBoxToYAML(
134
- lines: LineBox[],
133
+ export function linesToYAML(
134
+ lines: Line[],
135
135
  paragraphWidth: number,
136
136
  paragraphHeight: number,
137
137
  ): string {
@@ -143,11 +143,11 @@ export function lineBoxToYAML(
143
143
  width: Math.round(line.width * 100) / 100,
144
144
  height: Math.round(line.height * 100) / 100,
145
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) } : {}),
146
+ fragments: line.spans.map(span => ({
147
+ text: span.text,
148
+ x: Math.round(span.x * 100) / 100,
149
+ width: Math.round(span.width * 100) / 100,
150
+ ...(spanStyleLabel(span) !== 'normal' ? { style: spanStyleLabel(span) } : {}),
151
151
  })),
152
152
  })),
153
153
  };
@@ -158,4 +158,5 @@ export function lineBoxToYAML(
158
158
  noRefs: true,
159
159
  sortKeys: false,
160
160
  });
161
- }
161
+ }
162
+
@@ -6,8 +6,8 @@
6
6
  * → compile (DocumentCompiler)
7
7
  * → prepareRichInline (pretext)
8
8
  * → walkRichInlineLineRanges + materializeRichInlineLineRange (pretext)
9
- * → positionLineBoxes (PositioningEngine)
10
- * → assertLineBoxInvariants (LineBoxValidator)
9
+ * → positionLines (PositioningEngine)
10
+ * → assertLineInvariants (LineInvariants)
11
11
  *
12
12
  * Supports autofit via AutoFitEngine.findScale.
13
13
  * Caches PreparedRichInline per paragraph key (Parley LayoutContext pattern).
@@ -23,8 +23,8 @@ import type { ParagraphLayoutResult } from '../types/LayoutTypes.js';
23
23
  import { compileParagraph, getParagraphText } from '../compile/DocumentCompiler.js';
24
24
  import type { PreparedRichInlineItem } from '../compile/DocumentCompiler.js';
25
25
  import { fontMetricsProvider } from '../measure/FontMetricsProvider.js';
26
- import { positionLineBoxes } from './PositioningEngine.js';
27
- import { assertLineBoxInvariants } from './LineBoxValidator.js';
26
+ import { positionLines } from './PositioningEngine.js';
27
+ import { assertLineInvariants } from './LineBoxValidator.js';
28
28
 
29
29
  // @ts-ignore
30
30
  import { prepareRichInline, materializeRichInlineLineRange, walkRichInlineLineRanges, type PreparedRichInline } from '@chenglou/pretext/rich-inline';
@@ -52,7 +52,7 @@ export class ParagraphLayoutEngine {
52
52
  * @param paragraph — input paragraph
53
53
  * @param maxWidth — available container width (px)
54
54
  * @param fontProvider — optional metrics provider (default: fontMetricsProvider)
55
- * @returns ParagraphLayoutResult with LineBox[]
55
+ * @returns ParagraphLayoutResult with Line[]
56
56
  */
57
57
  layout(
58
58
  paragraph: Paragraph,
@@ -89,7 +89,7 @@ export class ParagraphLayoutEngine {
89
89
 
90
90
  // Phase 4: Position
91
91
  const renderMode = provider.getMode();
92
- const { lines, contentWidth } = positionLineBoxes(
92
+ const { lines, contentWidth } = positionLines(
93
93
  materializedLines,
94
94
  items,
95
95
  (item) => {
@@ -111,7 +111,7 @@ export class ParagraphLayoutEngine {
111
111
  );
112
112
 
113
113
  // Phase 5: Validate
114
- assertLineBoxInvariants(lines, getParagraphText(paragraph), maxWidth);
114
+ assertLineInvariants(lines, getParagraphText(paragraph), maxWidth);
115
115
 
116
116
  // Phase 6: Compute height
117
117
  const totalHeight = lines.length > 0
@@ -129,8 +129,8 @@ export class ParagraphLayoutEngine {
129
129
  /**
130
130
  * Layout with per-glyph advance widths (for SVG glyph mode).
131
131
  *
132
- * After basic layout, fills FragmentBox.glyphAdvances
133
- * via fontkit for each text fragment.
132
+ * After basic layout, fills Span.glyphAdvances
133
+ * via fontkit for each text span.
134
134
  *
135
135
  * @param paragraph — input paragraph
136
136
  * @param maxWidth — available container width (px)
@@ -145,14 +145,14 @@ export class ParagraphLayoutEngine {
145
145
  const result = this.layout(paragraph, maxWidth, yOffset);
146
146
 
147
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',
148
+ for (const span of line.spans) {
149
+ if (span.type === 'text' && span.text.length > 0) {
150
+ span.glyphAdvances = this.computeGlyphAdvances(
151
+ span.text,
152
+ span.style.fontFamily,
153
+ span.fontMetrics.fontSize,
154
+ String(span.style.fontWeight || 400),
155
+ span.style.fontStyle || 'normal',
156
156
  );
157
157
  }
158
158
  }
@@ -1,24 +1,24 @@
1
1
  /**
2
2
  * PositioningEngine.ts — pure X/Y positioning math.
3
3
  *
4
- * Takes pretext output (lines with fragments) + font metrics +
5
- * paragraph style → returns LineBox[] with absolute coordinates.
4
+ * Takes pretext output (spans with fragments) + font metrics +
5
+ * paragraph style → returns Line[] with absolute coordinates.
6
6
  *
7
7
  * X: alignment (left/center/right/justify) + indent
8
8
  * Y: baseline + lineHeight + spaceBefore/After
9
- * Justify: fragmented approach (each space → separate FragmentBox)
9
+ * Justify: fragmented approach (each space → separate Span)
10
10
  *
11
11
  * Specs:
12
12
  * - CSS Text Module Level 3/4 (browser mode)
13
13
  * - ISO/IEC 29500 (Office Open XML / DrawingML, office mode)
14
14
  * - Parley alignment.rs (conceptually close, but here justify is simpler:
15
- * slack is divided equally among stretchable space-fragments,
15
+ * slack is divided equally among stretchable space-spans,
16
16
  * without mutating ClusterData.advance)
17
17
  */
18
18
 
19
19
  import type { ParagraphStyle } from '../types/Document.js';
20
20
  import type { FontMetrics } from '../types/FontTypes.js';
21
- import type { LineBox, FragmentBox, FragmentFontMetrics } from '../types/LayoutTypes.js';
21
+ import type { Line, Span, SpanFontMetrics } from '../types/LayoutTypes.js';
22
22
  import type { PreparedRichInlineItem } from '../compile/DocumentCompiler.js';
23
23
 
24
24
  // ── Helper types for pretext ───────────────────────────────────────────
@@ -41,18 +41,18 @@ interface PretextLine {
41
41
  // ── PositioningEngine ─────────────────────────────────────────────────
42
42
 
43
43
  /**
44
- * Build LineBox[] from pretext lines with alignment and metrics.
44
+ * Build Line[] from pretext lines with alignment and metrics.
45
45
  *
46
46
  * @param pretextLines — pretext result (materializeRichInlineLineRange)
47
47
  * @param items — original PreparedRichInlineItem[] (for metadata)
48
- * @param fontMetricsFn — function to get font metrics for a fragment
48
+ * @param fontMetricsFn — function to get font metrics for a span
49
49
  * @param style — paragraph style
50
50
  * @param maxWidth — available container width
51
51
  * @param startY — initial Y position
52
52
  * @param mode — metric mode ('browser' | 'office'), affects line height calculation
53
- * @returns { lines: LineBox[], contentWidth: number }
53
+ * @returns { lines: Line[], contentWidth: number }
54
54
  */
55
- export function positionLineBoxes(
55
+ export function positionLines(
56
56
  pretextLines: PretextLine[],
57
57
  items: PreparedRichInlineItem[],
58
58
  fontMetricsFn: (item: PreparedRichInlineItem) => FontMetrics,
@@ -61,8 +61,8 @@ export function positionLineBoxes(
61
61
  startY: number = 0,
62
62
  mode: 'browser' | 'office' = 'browser',
63
63
  paragraphId?: string,
64
- ): { lines: LineBox[]; contentWidth: number } {
65
- const lines: LineBox[] = [];
64
+ ): { lines: Line[]; contentWidth: number } {
65
+ const lines: Line[] = [];
66
66
  let currentY = startY + style.spaceBefore;
67
67
  let charIndex = 0;
68
68
  let isFirstLine = true;
@@ -71,11 +71,11 @@ export function positionLineBoxes(
71
71
  for (let lineIdx = 0; lineIdx < pretextLines.length; lineIdx++) {
72
72
  const ptLine = pretextLines[lineIdx];
73
73
 
74
- // ── Build FragmentBox[] ────────────────────────────
74
+ // ── Build Span[] ────────────────────────────
75
75
  let maxAscent = 0;
76
76
  let maxDescent = 0;
77
77
  let maxLineHeightBase = 0; // max(ascent + descent) — for Office mode
78
- const fragments: FragmentBox[] = [];
78
+ const spans: Span[] = [];
79
79
 
80
80
  for (const frag of ptLine.fragments) {
81
81
  const item = items[frag.itemIndex];
@@ -92,7 +92,7 @@ export function positionLineBoxes(
92
92
 
93
93
  // pretext: gapBefore — inter-word space BEFORE the word
94
94
  // occupiedWidth = gapBefore + textWidth
95
- // Split into two FragmentBox: space + word
95
+ // Split into two Span: space + word
96
96
  const gapWidth = frag.gapBefore || 0;
97
97
  const textWidth = frag.occupiedWidth;
98
98
 
@@ -100,10 +100,11 @@ export function positionLineBoxes(
100
100
  ascent: metrics.ascent,
101
101
  descent: metrics.descent,
102
102
  fontSize: item.metadata.effectiveFontSize,
103
+ baselineOffset: item.metadata.baselineOffset || undefined,
103
104
  };
104
105
 
105
106
  if (gapWidth > 0) {
106
- fragments.push({
107
+ spans.push({
107
108
  x: 0,
108
109
  width: gapWidth,
109
110
  text: ' ',
@@ -116,7 +117,7 @@ export function positionLineBoxes(
116
117
  });
117
118
  }
118
119
 
119
- // Split leading/trailing spaces from frag.text into separate space fragments
120
+ // Split leading/trailing spaces from frag.text into separate space spans
120
121
  // This is needed for SVG rendering to avoid xml:space="preserve" dependency
121
122
  const text = frag.text;
122
123
  const leadingMatch = text.match(/^(\s+)/);
@@ -142,9 +143,9 @@ export function positionLineBoxes(
142
143
  return totalChars > 0 ? (charCount / totalChars) * textWidth : 0;
143
144
  };
144
145
 
145
- // Leading space fragment
146
+ // Leading space span
146
147
  if (leadingSpaceChars > 0) {
147
- fragments.push({
148
+ spans.push({
148
149
  x: 0,
149
150
  width: computePartialWidth(leadingSpaceChars),
150
151
  text: text.slice(0, leadingSpaceChars),
@@ -157,7 +158,7 @@ export function positionLineBoxes(
157
158
  });
158
159
  }
159
160
 
160
- // Text fragment (trimmed)
161
+ // Text span (trimmed)
161
162
  if (remainingText.length > 0) {
162
163
  const textWidth = computePartialWidth(remainingText.length);
163
164
  // Compute per-glyph advances by distributing textWidth equally across glyphs.
@@ -170,7 +171,7 @@ export function positionLineBoxes(
170
171
  glyphAdvances.push(perGlyph);
171
172
  }
172
173
  }
173
- fragments.push({
174
+ spans.push({
174
175
  x: 0,
175
176
  width: textWidth,
176
177
  text: remainingText,
@@ -184,10 +185,10 @@ export function positionLineBoxes(
184
185
  });
185
186
  }
186
187
 
187
- // Trailing space fragment
188
+ // Trailing space span
188
189
  if (trailingSpaceChars > 0) {
189
190
  const trailingStart = leadingSpaceChars + remainingText.length;
190
- fragments.push({
191
+ spans.push({
191
192
  x: 0,
192
193
  width: computePartialWidth(trailingSpaceChars),
193
194
  text: text.slice(trailingStart, trailingStart + trailingSpaceChars),
@@ -201,34 +202,43 @@ export function positionLineBoxes(
201
202
  }
202
203
  }
203
204
 
204
- // ── Mark trailing whitespace fragments ───────────────────
205
+ // ── Inline-box width correction ─────────────────────────
206
+ // When a span has an inlineWidget, override its width
207
+ // to match the widget width (the \uFFFC advance is not included).
208
+ for (const s of spans) {
209
+ if (s.inlineWidget) {
210
+ s.width = s.inlineWidget.width;
211
+ }
212
+ }
213
+
214
+ // ── Mark trailing whitespace spans ───────────────────
205
215
  // CSS Text §4.1.3: end-of-line spaces have zero measure for line-advance calculations.
206
216
  // Parley: LineItemData.has_trailing_whitespace → trailing whitespace is excluded from advance.
207
217
  //
208
218
  // Find the last space(s) at the end of the line and mark them trailing.
209
- let trailingStartIndex = fragments.length;
210
- for (let i = fragments.length - 1; i >= 0; i--) {
211
- if (fragments[i].type === 'space') {
219
+ let trailingStartIndex = spans.length;
220
+ for (let i = spans.length - 1; i >= 0; i--) {
221
+ if (spans[i].type === 'space') {
212
222
  trailingStartIndex = i;
213
223
  } else {
214
224
  break;
215
225
  }
216
226
  }
217
227
 
218
- const trailingWidth = fragments
228
+ const trailingWidth = spans
219
229
  .slice(trailingStartIndex)
220
230
  .reduce((sum, f) => sum + f.width, 0);
221
231
 
222
- for (let i = trailingStartIndex; i < fragments.length; i++) {
223
- fragments[i].trailing = true;
232
+ for (let i = trailingStartIndex; i < spans.length; i++) {
233
+ spans[i].trailing = true;
224
234
  }
225
235
 
226
236
  // ── Compute effective line width (excluding trailing whitespace) ─
227
- // Use sum of actual fragment widths instead of ptLine.width, because
237
+ // Use sum of actual span widths instead of ptLine.width, because
228
238
  // ptLine.width may not include gapBefore spaces that were split into
229
- // separate FragmentBox (e.g. "AA" + " A" → [AA][ ][A]).
230
- const totalFragWidth = fragments.reduce((sum, f) => sum + f.width, 0);
231
- const effectiveLineWidth = totalFragWidth - trailingWidth;
239
+ // separate Span (e.g. "AA" + " A" → [AA][ ][A]).
240
+ const totalSpanWidth = spans.reduce((sum, f) => sum + f.width, 0);
241
+ const effectiveLineWidth = totalSpanWidth - trailingWidth;
232
242
 
233
243
  // ── X positioning ───────────────────────────────
234
244
  const indent = isFirstLine
@@ -246,7 +256,7 @@ export function positionLineBoxes(
246
256
  } else if (style.alignment === 'right') {
247
257
  xOffset = indent + slack;
248
258
  } else if (style.alignment === 'justify') {
249
- // Justify: distribute slack evenly among whitespace fragments
259
+ // Justify: distribute slack evenly among whitespace spans
250
260
  // (excluding trailing whitespace).
251
261
  //
252
262
  // CSS Text Module Level 3 §4.1.3: trailing spaces do not participate in justify.
@@ -259,7 +269,7 @@ export function positionLineBoxes(
259
269
  const isLastLine = lineIdx === pretextLines.length - 1;
260
270
 
261
271
  // Count only "stretchable" spaces: type === 'space' and !trailing
262
- const stretchableSpaces = fragments.filter(
272
+ const stretchableSpaces = spans.filter(
263
273
  (f) => f.type === 'space' && !f.trailing,
264
274
  );
265
275
  const spaceCount = stretchableSpaces.length;
@@ -278,7 +288,7 @@ export function positionLineBoxes(
278
288
 
279
289
  // Assign X positions
280
290
  let runX = xOffset;
281
- for (const frag of fragments) {
291
+ for (const frag of spans) {
282
292
  frag.x = Math.round(runX * 100) / 100;
283
293
  runX += frag.width;
284
294
  }
@@ -297,7 +307,7 @@ export function positionLineBoxes(
297
307
  // Line height strictly = ascent + descent (OS/2.usWinAscent + usWinDescent).
298
308
  // Baseline = Top + ascent without any half-leading additions.
299
309
  // See pixel-perfect-text-layout.md §1 and ECMA-376.
300
- const maxFontSize = fragments.reduce((max, f) => Math.max(max, f.fontMetrics.fontSize), 0);
310
+ const maxFontSize = spans.reduce((max, f) => Math.max(max, f.fontMetrics.fontSize), 0);
301
311
 
302
312
  const ascentRounded = Math.round(maxAscent);
303
313
  const descentRounded = Math.round(maxDescent);
@@ -345,24 +355,24 @@ export function positionLineBoxes(
345
355
 
346
356
  // Count characters in line (for INDEX_CONSIST)
347
357
  let lineCharCount = 0;
348
- for (const frag of fragments) {
358
+ for (const frag of spans) {
349
359
  lineCharCount += frag.text.length;
350
360
  }
351
361
  const endIdx = startIdx + lineCharCount;
352
362
  charIndex = endIdx;
353
363
 
354
- // ── Mark break type on the last fragment ─────────────
364
+ // ── Mark break type on the last span ─────────────
355
365
  // 'soft' — line wrap due to width constraint
356
366
  // 'hard' — explicit break (\n)
357
367
  // 'none'/undefined — not a line end (no break)
358
- if (fragments.length > 0) {
359
- const lastFrag = fragments[fragments.length - 1];
360
- const lastFragItem = items[lastFrag.itemIndex];
368
+ if (spans.length > 0) {
369
+ const lastSpan = spans[spans.length - 1];
370
+ const lastSpanItem = items[lastSpan.itemIndex];
361
371
  // If last character is \n, it's a hard break
362
- if (lastFragItem && lastFrag.text.endsWith('\n')) {
363
- lastFrag.breakType = 'hard';
372
+ if (lastSpanItem && lastSpan.text.endsWith('\n')) {
373
+ lastSpan.breakType = 'hard';
364
374
  } else if (lineIdx < pretextLines.length - 1) {
365
- lastFrag.breakType = 'soft';
375
+ lastSpan.breakType = 'soft';
366
376
  }
367
377
  }
368
378
 
@@ -377,7 +387,7 @@ export function positionLineBoxes(
377
387
  startIndex: startIdx,
378
388
  endIndex: endIdx,
379
389
  alignment: style.alignment,
380
- fragments,
390
+ spans,
381
391
  });
382
392
 
383
393
  currentY += lineBoxHeight;