@vyaz/core 0.0.5 → 0.0.7

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.
Files changed (45) hide show
  1. package/dist/compile/DocumentCompiler.d.ts +83 -0
  2. package/dist/index.browser.d.ts +28 -0
  3. package/dist/index.browser.js +18 -0
  4. package/dist/index.d.ts +30 -0
  5. package/dist/index.js +13 -147930
  6. package/dist/layout/AutoFitEngine.d.ts +44 -0
  7. package/dist/layout/LineBoxValidator.d.ts +25 -0
  8. package/dist/layout/ParagraphLayoutEngine.d.ts +46 -0
  9. package/dist/layout/PositioningEngine.d.ts +68 -0
  10. package/dist/layout/TextFrameLayoutEngine.d.ts +50 -0
  11. package/{src/layout/estimateWidth.ts → dist/layout/estimateWidth.d.ts} +2 -40
  12. package/dist/measure/FontEngine.d.ts +47 -0
  13. package/dist/measure/FontMetricsProvider.d.ts +49 -0
  14. package/dist/measure/FontNotFoundError.d.ts +6 -0
  15. package/dist/measure/SystemFontRegistry.d.ts +46 -0
  16. package/dist/measure/canvas-polyfill.d.ts +30 -0
  17. package/dist/types/Document.d.ts +599 -0
  18. package/dist/types/FontTypes.d.ts +61 -0
  19. package/dist/types/LayoutTypes.d.ts +137 -0
  20. package/{src/utils/env.ts → dist/utils/env.d.ts} +1 -8
  21. package/{src/utils/font.ts → dist/utils/font.d.ts} +1 -13
  22. package/{src/utils/groupLinesByParagraph.ts → dist/utils/groupLinesByParagraph.d.ts} +7 -37
  23. package/dist/utils/list.d.ts +41 -0
  24. package/dist/utils/textTransform.d.ts +32 -0
  25. package/package.json +13 -13
  26. package/src/compile/DocumentCompiler.ts +0 -144
  27. package/src/index.browser.ts +0 -90
  28. package/src/index.ts +0 -97
  29. package/src/layout/AutoFitEngine.ts +0 -101
  30. package/src/layout/LineBoxValidator.ts +0 -162
  31. package/src/layout/ParagraphLayoutEngine.ts +0 -264
  32. package/src/layout/PositioningEngine.ts +0 -615
  33. package/src/layout/TextFrameLayoutEngine.ts +0 -363
  34. package/src/measure/FontEngine.ts +0 -144
  35. package/src/measure/FontMetricsProvider.ts +0 -224
  36. package/src/measure/FontNotFoundError.ts +0 -16
  37. package/src/measure/SystemFontRegistry.ts +0 -152
  38. package/src/measure/canvas-polyfill.d.ts +0 -6
  39. package/src/measure/canvas-polyfill.ts +0 -243
  40. package/src/measure/fontkit.d.ts +0 -44
  41. package/src/types/Document.ts +0 -666
  42. package/src/types/FontTypes.ts +0 -74
  43. package/src/types/LayoutTypes.ts +0 -158
  44. package/src/utils/list.ts +0 -107
  45. package/src/utils/textTransform.ts +0 -96
@@ -1,615 +0,0 @@
1
- /**
2
- * PositioningEngine.ts — pure X/Y positioning math.
3
- *
4
- * Takes pretext output (spans with fragments) + font metrics +
5
- * paragraph style → returns Line[] with absolute coordinates.
6
- *
7
- * X: alignment (left/center/right/justify) + indent
8
- * Y: baseline + lineHeight + spaceBefore/After
9
- * Justify: fragmented approach (each space → separate Span)
10
- *
11
- * Specs:
12
- * - CSS Text Module Level 3/4 (browser mode)
13
- * - ISO/IEC 29500 (Office Open XML / DrawingML, office mode)
14
- * - Parley alignment.rs (conceptually close, but here justify is simpler:
15
- * slack is divided equally among stretchable space-spans,
16
- * without mutating ClusterData.advance)
17
- */
18
-
19
- import type { ParagraphStyle, ListStyle, NumberFormat } from '../types/Document.js';
20
- import type { FontMetrics } from '../types/FontTypes.js';
21
- import type { Line, Span, SpanFontMetrics } from '../types/LayoutTypes.js';
22
- import type { PreparedRichInlineItem } from '../compile/DocumentCompiler.js';
23
- import type { MeasureFn } from './estimateWidth.js';
24
- import { resolveFragmentWidths } from './estimateWidth.js';
25
- import { formatListNumber, defaultBulletChar } from '../utils/list.js';
26
-
27
- // ── Helper types for pretext ───────────────────────────────────────────
28
-
29
- interface PretextFragment {
30
- itemIndex: number;
31
- text: string;
32
- gapBefore: number;
33
- occupiedWidth: number;
34
- start: { segmentIndex: number; graphemeIndex: number };
35
- end: { segmentIndex: number; graphemeIndex: number };
36
- }
37
-
38
- interface PretextLine {
39
- fragments: PretextFragment[];
40
- width: number; // natural width (without stretching)
41
- end: { segmentIndex: number; graphemeIndex: number };
42
- }
43
-
44
- // ── List marker helpers ─────────────────────────────────────────────────
45
-
46
- /**
47
- * Resolve the marker text for a list item.
48
- */
49
- function getMarkerText(listStyle: ListStyle, listIndex: number): string {
50
- if (listStyle.type === 'bullet') {
51
- return listStyle.bulletChar ?? defaultBulletChar(listStyle.level ?? 0);
52
- }
53
- if (listStyle.type === 'number') {
54
- const fmt = listStyle.numberFormat ?? 'decimal';
55
- return formatListNumber(listIndex, fmt) + '.';
56
- }
57
- return '';
58
- }
59
-
60
- /**
61
- * Resolve the paragraph-level font size for marker sizing.
62
- * Uses the first child run's fontSize, or DEFAULT_TEXT_STYLE.fontSize.
63
- */
64
- function getParagraphFontSize(items: PreparedRichInlineItem[]): number {
65
- if (items.length === 0) return 12;
66
- return items[0].metadata.style.fontSize ?? 12;
67
- }
68
-
69
- /**
70
- * Resolve the effective bulletIndent for a paragraph.
71
- * Uses listStyle.indents[level] if available, otherwise default * (level + 1).
72
- */
73
- function resolveBulletIndent(
74
- listStyle: ListStyle,
75
- paraFontSize: number,
76
- ): number {
77
- const level = listStyle.level ?? 0;
78
- const indents = listStyle.indents;
79
- if (indents && indents[level] !== undefined) {
80
- return indents[level];
81
- }
82
- const defaultIndent = listStyle.bulletIndent ?? (paraFontSize * 1.5);
83
- return defaultIndent * (level + 1);
84
- }
85
-
86
- // ── PositioningEngine ─────────────────────────────────────────────────
87
-
88
- /**
89
- * Build Line[] from pretext lines with alignment and metrics.
90
- *
91
- * @param pretextLines — pretext result (materializeRichInlineLineRange)
92
- * @param items — original PreparedRichInlineItem[] (for metadata)
93
- * @param fontMetricsFn — function to get font metrics for a span
94
- * @param style — paragraph style
95
- * @param maxWidth — available container width
96
- * @param startY — initial Y position
97
- * @param mode — metric mode ('browser' | 'office'), affects line height calculation
98
- * @param tag — optional tag for debugging
99
- * @param measureText — function to measure text width accurately via fontkit.
100
- * The function accepts (text, fontSize, fontFamily, fontWeight, fontStyle)
101
- * and returns width in px. Throws FontNotFoundError if font not registered.
102
- * @param listStyle — optional list configuration (bullet / numbered)
103
- * @param listIndex — current index in the list (for numbered lists). 1-based.
104
- * @param listMarkerWidth — pre-computed width of the widest marker in the list group.
105
- * When provided, bulletIndent is expanded to this value if needed.
106
- * @returns { lines: Line[], contentWidth: number }
107
- */
108
- export function positionLines(
109
- pretextLines: PretextLine[],
110
- items: PreparedRichInlineItem[],
111
- fontMetricsFn: (item: PreparedRichInlineItem) => FontMetrics,
112
- style: ParagraphStyle,
113
- maxWidth: number,
114
- startY: number = 0,
115
- mode: 'browser' | 'office' = 'browser',
116
- measureText: (text: string, fontSize: number, fontFamily?: string, fontWeight?: string, fontStyle?: string) => number,
117
- tag?: string,
118
- listStyle?: ListStyle,
119
- listIndex?: number,
120
- listMarkerWidth?: number,
121
- ): { lines: Line[]; contentWidth: number } {
122
- const lines: Line[] = [];
123
- let currentY = startY + style.spaceBefore;
124
- let charIndex = 0;
125
- let isFirstLine = true;
126
- let contentWidth = 0;
127
-
128
- // ── Pre-compute marker-related values ──────────────────────────
129
- const isListItem = listStyle && listStyle.type !== 'none' && listIndex !== undefined;
130
- let markerText = '';
131
- let markerWidth = 0;
132
- let bulletZoneIndent = 0;
133
- let effectiveLeftIndent = style.leftIndent ?? 0;
134
-
135
- if (isListItem) {
136
- markerText = getMarkerText(listStyle!, listIndex!);
137
- const paraFontSize = getParagraphFontSize(items);
138
- bulletZoneIndent = resolveBulletIndent(listStyle!, paraFontSize);
139
-
140
- // Expand bulletIndent to fit the widest marker in the list group
141
- if (listMarkerWidth !== undefined && listMarkerWidth > bulletZoneIndent) {
142
- bulletZoneIndent = listMarkerWidth;
143
- }
144
-
145
- // Measure marker width using paragraph's first run font
146
- const firstRunFontFamily = items[0]?.metadata.style.fontFamily ?? 'Arial';
147
- const firstRunFontWeight = String(items[0]?.metadata.style.fontWeight ?? 400);
148
- const firstRunFontStyle = items[0]?.metadata.style.fontStyle ?? 'normal';
149
-
150
- // Marker font-size: same as paragraph font size for numbered,
151
- // slightly smaller (0.9x) for bullets (PowerPoint convention)
152
- const markerFontSize = listStyle!.type === 'bullet'
153
- ? paraFontSize * 0.9
154
- : paraFontSize;
155
- markerWidth = measureText(markerText, markerFontSize, firstRunFontFamily, firstRunFontWeight, firstRunFontStyle);
156
-
157
- // For 'outside': text block is shifted right by bulletZoneIndent
158
- if (listStyle!.position !== 'inside') {
159
- effectiveLeftIndent += bulletZoneIndent;
160
- }
161
- }
162
-
163
- for (let lineIdx = 0; lineIdx < pretextLines.length; lineIdx++) {
164
- const ptLine = pretextLines[lineIdx];
165
-
166
- // ── Build Span[] ────────────────────────────
167
- let maxAscent = 0;
168
- let maxDescent = 0;
169
- let maxLineHeightBase = 0; // max(ascent + descent) — for Office mode
170
- const spans: Span[] = [];
171
-
172
- for (const frag of ptLine.fragments) {
173
- const item = items[frag.itemIndex];
174
- if (!item) continue;
175
-
176
- const metrics = fontMetricsFn(item);
177
- // baselineOffset: superscript → negative (glyph moves up).
178
- // ascent must increase (glyph above baseline), descent must decrease (less overhang below).
179
- // subscript → positive (glyph moves down).
180
- // ascent must decrease, descent must increase.
181
- const effectiveAscent = metrics.ascent - (item.metadata.baselineOffset || 0);
182
- const effectiveDescent = metrics.descent + (item.metadata.baselineOffset || 0);
183
-
184
- maxAscent = Math.max(maxAscent, effectiveAscent);
185
- maxDescent = Math.max(maxDescent, effectiveDescent);
186
- // Office: line height base = ascent + descent (OS/2 usWinAscent + usWinDescent, scaled)
187
- maxLineHeightBase = Math.max(maxLineHeightBase, metrics.ascent + metrics.descent);
188
-
189
- // pretext: gapBefore — inter-word space BEFORE the word
190
- // occupiedWidth = gapBefore + textWidth
191
- // Split into two Span: space + word
192
- const gapWidth = frag.gapBefore || 0;
193
- const textWidth = frag.occupiedWidth;
194
-
195
- const baseFontMetrics = {
196
- ascent: metrics.ascent,
197
- descent: metrics.descent,
198
- fontSize: item.metadata.effectiveFontSize,
199
- baselineOffset: item.metadata.baselineOffset || undefined,
200
- };
201
-
202
- if (gapWidth > 0) {
203
- spans.push({
204
- x: 0,
205
- width: gapWidth,
206
- text: ' ',
207
- itemIndex: frag.itemIndex,
208
- pIdx: 0,
209
- tag,
210
- fontMetrics: baseFontMetrics,
211
- style: item.metadata.style,
212
- inlineWidget: item.metadata.inlineWidget,
213
- type: 'space',
214
- });
215
- }
216
-
217
- // Split leading/trailing spaces from frag.text into separate space spans
218
- // This is needed for SVG rendering to avoid xml:space="preserve" dependency
219
- const text = frag.text;
220
- const leadingMatch = text.match(/^(\s+)/);
221
-
222
- let remainingText = text;
223
- let leadingSpaceChars = 0;
224
- let trailingSpaceChars = 0;
225
-
226
- if (leadingMatch) {
227
- leadingSpaceChars = leadingMatch[1].length;
228
- remainingText = remainingText.slice(leadingSpaceChars);
229
- }
230
- if (remainingText.length > 0) {
231
- const trailMatch = remainingText.match(/(\s+)$/);
232
- if (trailMatch) {
233
- trailingSpaceChars = trailMatch[1].length;
234
- remainingText = remainingText.slice(0, -trailingSpaceChars);
235
- }
236
- }
237
-
238
- // Resolve widths via resolveFragmentWidths:
239
- // exact measurement (measureText) when available,
240
- // weight-based fallback otherwise,
241
- // then correctToSumInvariant to preserve line-breaking invariant.
242
- const fragments: string[] = [];
243
- if (leadingSpaceChars > 0) fragments.push(text.slice(0, leadingSpaceChars));
244
- if (remainingText.length > 0) fragments.push(remainingText);
245
- if (trailingSpaceChars > 0) fragments.push(text.slice(leadingSpaceChars + remainingText.length));
246
-
247
- // Build measure function with font parameters baked in
248
- const { fontFamily, fontWeight, fontStyle } = item.metadata.style;
249
- const fsWeight = String(fontWeight || 400);
250
- const fsStyle = fontStyle || 'normal';
251
- const fragmentMeasureFn: MeasureFn = (t: string) => measureText(t, baseFontMetrics.fontSize, fontFamily, fsWeight, fsStyle);
252
-
253
- const resolvedWidths = resolveFragmentWidths(fragments, text, textWidth, fragmentMeasureFn);
254
- let resolvedIdx = 0;
255
- const leadingWidth = leadingSpaceChars > 0 ? resolvedWidths[resolvedIdx++] : 0;
256
- const trimmedWidth = remainingText.length > 0 ? resolvedWidths[resolvedIdx++] : 0;
257
- const trailingWidthVal = trailingSpaceChars > 0 ? resolvedWidths[resolvedIdx++] : 0;
258
-
259
- // Leading space span
260
- if (leadingSpaceChars > 0) {
261
- const leadingText = text.slice(0, leadingSpaceChars);
262
- spans.push({
263
- x: 0,
264
- width: leadingWidth,
265
- text: leadingText,
266
- itemIndex: frag.itemIndex,
267
- pIdx: 0,
268
- tag,
269
- fontMetrics: baseFontMetrics,
270
- style: item.metadata.style,
271
- inlineWidget: item.metadata.inlineWidget,
272
- type: 'space',
273
- });
274
- }
275
-
276
- // Text span (trimmed)
277
- if (remainingText.length > 0) {
278
- const actualTextWidth = trimmedWidth;
279
- spans.push({
280
- x: 0,
281
- width: actualTextWidth,
282
- text: remainingText,
283
- itemIndex: frag.itemIndex,
284
- pIdx: 0,
285
- tag,
286
- fontMetrics: baseFontMetrics,
287
- style: item.metadata.style,
288
- inlineWidget: item.metadata.inlineWidget,
289
- type: 'text',
290
- });
291
- }
292
-
293
- // Trailing space span
294
- if (trailingSpaceChars > 0) {
295
- const trailingStart = leadingSpaceChars + remainingText.length;
296
- const trailingText = text.slice(trailingStart, trailingStart + trailingSpaceChars);
297
- spans.push({
298
- x: 0,
299
- width: trailingWidthVal,
300
- text: trailingText,
301
- itemIndex: frag.itemIndex,
302
- pIdx: 0,
303
- tag,
304
- fontMetrics: baseFontMetrics,
305
- style: item.metadata.style,
306
- inlineWidget: item.metadata.inlineWidget,
307
- type: 'space',
308
- });
309
- }
310
- }
311
-
312
- // ── Inline-box width correction ─────────────────────────
313
- // When a span has an inlineWidget, override its width
314
- // to match the widget width (the \uFFFC advance is not included).
315
- for (const s of spans) {
316
- if (s.inlineWidget) {
317
- s.width = s.inlineWidget.width;
318
- }
319
- }
320
-
321
- // ── Prepare marker data (only on first line) ──────────────────
322
- // For 'inside': marker is inserted into the span flow before X positioning.
323
- // For 'outside': marker is added after X positioning with a custom x in the bullet zone.
324
- let outsideMarkerSpan: Span | null = null;
325
-
326
- if (isListItem && isFirstLine) {
327
- const paraFontSize = getParagraphFontSize(items);
328
- const firstRun = items[0];
329
- const markerFontSize = listStyle!.type === 'bullet' ? paraFontSize * 0.9 : paraFontSize;
330
- const markerAscent = markerFontSize * 0.8; // approximate ascent for marker
331
- const markerDescent = markerFontSize * 0.2;
332
-
333
- maxAscent = Math.max(maxAscent, markerAscent);
334
- maxDescent = Math.max(maxDescent, markerDescent);
335
- maxLineHeightBase = Math.max(maxLineHeightBase, markerAscent + markerDescent);
336
-
337
- // Create a style object for the marker span
338
- const markerStyle = {
339
- ...(firstRun?.metadata.style ?? {
340
- fontFamily: 'Arial',
341
- fontSize: markerFontSize,
342
- fontWeight: 400,
343
- fontStyle: 'normal' as const,
344
- color: '#000000',
345
- type: 'text' as const,
346
- text: markerText,
347
- }),
348
- fontSize: markerFontSize,
349
- text: markerText,
350
- };
351
-
352
- const markerSpanBase: Span = {
353
- x: 0,
354
- width: markerWidth,
355
- text: markerText,
356
- itemIndex: 0,
357
- pIdx: 0,
358
- tag,
359
- fontMetrics: {
360
- ascent: markerAscent,
361
- descent: markerDescent,
362
- fontSize: markerFontSize,
363
- },
364
- style: markerStyle,
365
- type: 'marker',
366
- };
367
-
368
- // For 'inside': marker is part of text flow — insert at beginning of spans
369
- // For 'outside': store for later (after X positioning)
370
- if (listStyle!.position === 'inside') {
371
- spans.unshift(markerSpanBase);
372
- // Add a small gap after the marker (0.5em)
373
- const gapWidth = markerFontSize * 0.5;
374
- spans.splice(1, 0, {
375
- x: 0,
376
- width: gapWidth,
377
- text: ' ',
378
- itemIndex: 0,
379
- pIdx: 0,
380
- tag,
381
- fontMetrics: {
382
- ascent: 0,
383
- descent: 0,
384
- fontSize: markerFontSize,
385
- },
386
- style: markerStyle,
387
- type: 'space',
388
- });
389
- } else {
390
- // 'outside' (default): marker sits in the bullet zone, left of text
391
- outsideMarkerSpan = markerSpanBase;
392
- }
393
- }
394
-
395
- // ── Mark trailing whitespace spans ───────────────────
396
- // CSS Text §4.1.3: end-of-line spaces have zero measure for line-advance calculations.
397
- // Parley: LineItemData.has_trailing_whitespace → trailing whitespace is excluded from advance.
398
- //
399
- // Find the last space(s) at the end of the line and mark them trailing.
400
- let trailingStartIndex = spans.length;
401
- for (let i = spans.length - 1; i >= 0; i--) {
402
- if (spans[i].type === 'space') {
403
- trailingStartIndex = i;
404
- } else {
405
- break;
406
- }
407
- }
408
-
409
- const trailingWidth = spans
410
- .slice(trailingStartIndex)
411
- .reduce((sum, f) => sum + f.width, 0);
412
-
413
- for (let i = trailingStartIndex; i < spans.length; i++) {
414
- spans[i].trailing = true;
415
- }
416
-
417
- // ── Compute effective line width (excluding trailing whitespace) ─
418
- // Use sum of actual span widths instead of ptLine.width, because
419
- // ptLine.width may not include gapBefore spaces that were split into
420
- // separate Span (e.g. "AA" + " A" → [AA][ ][A]).
421
- const totalSpanWidth = spans.reduce((sum, f) => sum + f.width, 0);
422
- const effectiveLineWidth = totalSpanWidth - trailingWidth;
423
-
424
- // ── X positioning ───────────────────────────────
425
- const indent = isFirstLine
426
- ? effectiveLeftIndent + (style.indent || 0)
427
- : effectiveLeftIndent;
428
- const rightIndent = style.rightIndent || 0;
429
- const availableWidth = maxWidth - indent - rightIndent;
430
-
431
- const slack = Math.max(0, availableWidth - effectiveLineWidth);
432
-
433
- let xOffset = indent;
434
-
435
- if (style.alignment === 'center') {
436
- xOffset = indent + slack / 2;
437
- } else if (style.alignment === 'right') {
438
- xOffset = indent + slack;
439
- } else if (style.alignment === 'justify') {
440
- // Justify: distribute slack evenly among whitespace spans
441
- // (excluding trailing whitespace).
442
- //
443
- // CSS Text Module Level 3 §4.1.3: trailing spaces do not participate in justify.
444
- // CSS text-align-last: last line is not justify, but start-align.
445
- // OOXML (ISO 29500): last line of paragraph is not stretched.
446
- // Parley alignment.rs: excludes last line (line.break_reason == None/Explicit)
447
- // and lines with num_spaces == 0.
448
- //
449
- // Determine if this is the last line in the paragraph.
450
- const isLastLine = lineIdx === pretextLines.length - 1;
451
-
452
- // Count only "stretchable" spaces: type === 'space' and !trailing
453
- const stretchableSpaces = spans.filter(
454
- (f) => f.type === 'space' && !f.trailing,
455
- );
456
- const spaceCount = stretchableSpaces.length;
457
-
458
- if (!isLastLine && spaceCount > 0 && slack > 0) {
459
- const extraPerSpace = slack / spaceCount;
460
- for (const sf of stretchableSpaces) {
461
- sf.width += extraPerSpace;
462
- }
463
- }
464
-
465
- // If this is the last line or no spaces — fallback to start-align
466
- // (LTR → left, RTL → right — always left for now, Bidi in Phase 2)
467
- xOffset = indent;
468
- }
469
-
470
- // Assign X positions
471
- let runX = xOffset;
472
- for (const frag of spans) {
473
- frag.x = Math.round(runX * 100) / 100;
474
- runX += frag.width;
475
- }
476
-
477
- // ── Outside marker: place in the bullet zone (left of text) ──
478
- // Marker is right-aligned within the bullet zone so multi-digit
479
- // numbers ("9." vs "10.") share the same right edge.
480
- // CSS list-style-position: outside — marker is outside the principal box.
481
- // OOXML a:buFont / a:buChar — bullet sits in the indent zone.
482
- if (outsideMarkerSpan) {
483
- // Right edge of the bullet zone = xOffset (start of text)
484
- // Marker right-aligned: x = xOffset - markerWidth - small gap
485
- const gap = outsideMarkerSpan.fontMetrics.fontSize * 0.25; // 0.25em gap
486
- outsideMarkerSpan.x = Math.round((xOffset - outsideMarkerSpan.width - gap) * 100) / 100;
487
- // Prepend so it appears first in the spans array
488
- spans.unshift(outsideMarkerSpan);
489
- }
490
-
491
- // Line box must cover ALL spans, including outside markers that sit
492
- // left of the principal text box. Otherwise contentWidth / computeBBox
493
- // miss the marker and content sizing clips it.
494
- let lineX = xOffset;
495
- let lineWidth = runX - xOffset;
496
- if (spans.length > 0) {
497
- const minSpanX = Math.min(...spans.map(s => s.x));
498
- const maxSpanRight = Math.max(...spans.map(s => s.x + s.width));
499
- lineX = minSpanX;
500
- lineWidth = maxSpanRight - minSpanX;
501
- }
502
- // contentWidth is the absolute right edge of content (not just line.width).
503
- // When line.x > 0 (outside marker, indent, center/right), consumers that
504
- // size the canvas as contentWidth with viewBox origin 0 need this value.
505
- contentWidth = Math.max(contentWidth, lineX + lineWidth);
506
-
507
-
508
-
509
- // ── Y positioning ───────────────────────────────
510
- // Line height algorithm depends on mode:
511
- //
512
- // 'browser' (CSS-compatible, parley/Chrome matching):
513
- // 1. lineHeightPx = maxFontSize * style.lineHeight
514
- //
515
- // 'office' (MS Office / DrawingML pixel-perfect):
516
- // PowerPoint has no line-height multiplier for single lines.
517
- // Line height strictly = ascent + descent (OS/2.usWinAscent + usWinDescent).
518
- // Baseline = Top + ascent without any half-leading additions.
519
- // See pixel-perfect-text-layout.md §1 and ECMA-376.
520
- const maxFontSize = spans.reduce((max, f) => Math.max(max, f.fontMetrics.fontSize), 0);
521
-
522
- const ascentRounded = Math.round(maxAscent);
523
- const descentRounded = Math.round(maxDescent);
524
-
525
- let lineBoxHeight: number;
526
- let baseline: number;
527
-
528
- if (mode === 'office') {
529
- // DrawingML: lineHeight = ascent + descent, no lineHeight ×1.15 and no leading.
530
- // DrawingML: base line height = OS/2 (usWinAscent + usWinDescent), without
531
- // sum of rounded ascent/descent — that formula caused pixel-perfect
532
- // mismatch with PowerPoint, so we use maxLineHeightBase.
533
- lineBoxHeight = maxLineHeightBase;
534
- baseline = ascentRounded;
535
- } else {
536
- // Browser: CSS-compatible with leading distribution.
537
- const lineHeightPx = maxFontSize * style.lineHeight;
538
- const ascentDescentRounded = ascentRounded + descentRounded;
539
-
540
- const rawLineBoxHeight = Math.round(lineHeightPx);
541
- lineBoxHeight = Math.max(rawLineBoxHeight, ascentDescentRounded);
542
- const leading = lineBoxHeight - ascentDescentRounded; // always integer
543
-
544
- if (leading <= 0) {
545
- // Negative or zero leading: don't shrink the line
546
- baseline = ascentRounded;
547
- } else {
548
- // Positive leading: distribute as integers with above_leading < below_leading
549
- const ascentDescent = maxAscent + maxDescent;
550
- const aboveLeadingFloat = ascentDescent > 0 ? leading * maxAscent / ascentDescent : leading / 2;
551
- let aboveLeading = Math.round(aboveLeadingFloat);
552
- let belowLeading = leading - aboveLeading;
553
-
554
- // Ensure above_leading < below_leading (parley/Chrome heuristic)
555
- if (aboveLeading >= belowLeading) {
556
- aboveLeading = Math.floor((leading - 1) / 2);
557
- belowLeading = leading - aboveLeading;
558
- }
559
-
560
- baseline = ascentRounded + aboveLeading;
561
- }
562
- }
563
-
564
- const startIdx = charIndex;
565
-
566
- // Count characters in line (for INDEX_CONSIST)
567
- let lineCharCount = 0;
568
- for (const frag of spans) {
569
- // Marker spans don't count toward the paragraph's character index
570
- if (frag.type !== 'marker') {
571
- lineCharCount += frag.text.length;
572
- }
573
- }
574
- const endIdx = startIdx + lineCharCount;
575
- charIndex = endIdx;
576
-
577
- // ── Mark break type on the last span ─────────────
578
- // 'soft' — line wrap due to width constraint
579
- // 'hard' — explicit break (\n)
580
- // 'none'/undefined — not a line end (no break)
581
- if (spans.length > 0) {
582
- const lastSpan = spans[spans.length - 1];
583
- const lastSpanItem = items[lastSpan.itemIndex];
584
- // If last character is \n, it's a hard break
585
- if (lastSpanItem && lastSpan.text.endsWith('\n')) {
586
- lastSpan.breakType = 'hard';
587
- } else if (lineIdx < pretextLines.length - 1) {
588
- lastSpan.breakType = 'soft';
589
- }
590
- }
591
-
592
- lines.push({
593
- x: Math.round(lineX * 100) / 100,
594
- y: Math.round(currentY * 100) / 100,
595
- width: Math.round(lineWidth * 100) / 100,
596
- height: Math.round(lineBoxHeight * 100) / 100,
597
- baseline: Math.round(baseline * 100) / 100,
598
- ascent: Math.round(maxAscent * 100) / 100,
599
- descent: Math.round(maxDescent * 100) / 100,
600
- startIndex: startIdx,
601
- endIndex: endIdx,
602
- alignment: style.alignment,
603
- spans,
604
- });
605
-
606
-
607
- currentY += lineBoxHeight;
608
- isFirstLine = false;
609
- }
610
-
611
- // Add spaceAfter to last line height
612
- // (return as is — ParagraphLayoutEngine will add spaceAfter to total height)
613
-
614
- return { lines, contentWidth };
615
- }