@vyaz/core 0.0.4 → 0.0.5

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.
@@ -16,10 +16,13 @@
16
16
  * without mutating ClusterData.advance)
17
17
  */
18
18
 
19
- import type { ParagraphStyle } from '../types/Document.js';
19
+ import type { ParagraphStyle, ListStyle, NumberFormat } from '../types/Document.js';
20
20
  import type { FontMetrics } from '../types/FontTypes.js';
21
21
  import type { Line, Span, SpanFontMetrics } from '../types/LayoutTypes.js';
22
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';
23
26
 
24
27
  // ── Helper types for pretext ───────────────────────────────────────────
25
28
 
@@ -38,6 +41,48 @@ interface PretextLine {
38
41
  end: { segmentIndex: number; graphemeIndex: number };
39
42
  }
40
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
+
41
86
  // ── PositioningEngine ─────────────────────────────────────────────────
42
87
 
43
88
  /**
@@ -50,6 +95,14 @@ interface PretextLine {
50
95
  * @param maxWidth — available container width
51
96
  * @param startY — initial Y position
52
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.
53
106
  * @returns { lines: Line[], contentWidth: number }
54
107
  */
55
108
  export function positionLines(
@@ -60,7 +113,11 @@ export function positionLines(
60
113
  maxWidth: number,
61
114
  startY: number = 0,
62
115
  mode: 'browser' | 'office' = 'browser',
63
- paragraphId?: string,
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,
64
121
  ): { lines: Line[]; contentWidth: number } {
65
122
  const lines: Line[] = [];
66
123
  let currentY = startY + style.spaceBefore;
@@ -68,6 +125,41 @@ export function positionLines(
68
125
  let isFirstLine = true;
69
126
  let contentWidth = 0;
70
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
+
71
163
  for (let lineIdx = 0; lineIdx < pretextLines.length; lineIdx++) {
72
164
  const ptLine = pretextLines[lineIdx];
73
165
 
@@ -82,8 +174,12 @@ export function positionLines(
82
174
  if (!item) continue;
83
175
 
84
176
  const metrics = fontMetricsFn(item);
85
- const effectiveAscent = metrics.ascent + (item.metadata.baselineOffset || 0);
86
- const effectiveDescent = metrics.descent - (item.metadata.baselineOffset || 0);
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);
87
183
 
88
184
  maxAscent = Math.max(maxAscent, effectiveAscent);
89
185
  maxDescent = Math.max(maxDescent, effectiveDescent);
@@ -109,7 +205,8 @@ export function positionLines(
109
205
  width: gapWidth,
110
206
  text: ' ',
111
207
  itemIndex: frag.itemIndex,
112
- paragraphId,
208
+ pIdx: 0,
209
+ tag,
113
210
  fontMetrics: baseFontMetrics,
114
211
  style: item.metadata.style,
115
212
  inlineWidget: item.metadata.inlineWidget,
@@ -122,7 +219,6 @@ export function positionLines(
122
219
  const text = frag.text;
123
220
  const leadingMatch = text.match(/^(\s+)/);
124
221
 
125
- const totalChars = text.length;
126
222
  let remainingText = text;
127
223
  let leadingSpaceChars = 0;
128
224
  let trailingSpaceChars = 0;
@@ -139,18 +235,37 @@ export function positionLines(
139
235
  }
140
236
  }
141
237
 
142
- const computePartialWidth = (charCount: number) => {
143
- return totalChars > 0 ? (charCount / totalChars) * textWidth : 0;
144
- };
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;
145
258
 
146
259
  // Leading space span
147
260
  if (leadingSpaceChars > 0) {
261
+ const leadingText = text.slice(0, leadingSpaceChars);
148
262
  spans.push({
149
263
  x: 0,
150
- width: computePartialWidth(leadingSpaceChars),
151
- text: text.slice(0, leadingSpaceChars),
264
+ width: leadingWidth,
265
+ text: leadingText,
152
266
  itemIndex: frag.itemIndex,
153
- paragraphId,
267
+ pIdx: 0,
268
+ tag,
154
269
  fontMetrics: baseFontMetrics,
155
270
  style: item.metadata.style,
156
271
  inlineWidget: item.metadata.inlineWidget,
@@ -160,40 +275,32 @@ export function positionLines(
160
275
 
161
276
  // Text span (trimmed)
162
277
  if (remainingText.length > 0) {
163
- const textWidth = computePartialWidth(remainingText.length);
164
- // Compute per-glyph advances by distributing textWidth equally across glyphs.
165
- // For monospace fonts this is exact; for proportional it's a linear approximation.
166
- // The FontMetricsProvider can later supply real glyph advances when available.
167
- const glyphAdvances: number[] = [];
168
- if (remainingText.length > 1) {
169
- const perGlyph = textWidth / remainingText.length;
170
- for (let i = 0; i < remainingText.length; i++) {
171
- glyphAdvances.push(perGlyph);
172
- }
173
- }
278
+ const actualTextWidth = trimmedWidth;
174
279
  spans.push({
175
280
  x: 0,
176
- width: textWidth,
281
+ width: actualTextWidth,
177
282
  text: remainingText,
178
283
  itemIndex: frag.itemIndex,
179
- paragraphId,
284
+ pIdx: 0,
285
+ tag,
180
286
  fontMetrics: baseFontMetrics,
181
287
  style: item.metadata.style,
182
288
  inlineWidget: item.metadata.inlineWidget,
183
289
  type: 'text',
184
- glyphAdvances: glyphAdvances.length > 0 ? glyphAdvances : undefined,
185
290
  });
186
291
  }
187
292
 
188
293
  // Trailing space span
189
294
  if (trailingSpaceChars > 0) {
190
295
  const trailingStart = leadingSpaceChars + remainingText.length;
296
+ const trailingText = text.slice(trailingStart, trailingStart + trailingSpaceChars);
191
297
  spans.push({
192
298
  x: 0,
193
- width: computePartialWidth(trailingSpaceChars),
194
- text: text.slice(trailingStart, trailingStart + trailingSpaceChars),
299
+ width: trailingWidthVal,
300
+ text: trailingText,
195
301
  itemIndex: frag.itemIndex,
196
- paragraphId,
302
+ pIdx: 0,
303
+ tag,
197
304
  fontMetrics: baseFontMetrics,
198
305
  style: item.metadata.style,
199
306
  inlineWidget: item.metadata.inlineWidget,
@@ -211,6 +318,80 @@ export function positionLines(
211
318
  }
212
319
  }
213
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
+
214
395
  // ── Mark trailing whitespace spans ───────────────────
215
396
  // CSS Text §4.1.3: end-of-line spaces have zero measure for line-advance calculations.
216
397
  // Parley: LineItemData.has_trailing_whitespace → trailing whitespace is excluded from advance.
@@ -242,8 +423,8 @@ export function positionLines(
242
423
 
243
424
  // ── X positioning ───────────────────────────────
244
425
  const indent = isFirstLine
245
- ? (style.leftIndent || 0) + (style.indent || 0)
246
- : (style.leftIndent || 0);
426
+ ? effectiveLeftIndent + (style.indent || 0)
427
+ : effectiveLeftIndent;
247
428
  const rightIndent = style.rightIndent || 0;
248
429
  const availableWidth = maxWidth - indent - rightIndent;
249
430
 
@@ -293,8 +474,37 @@ export function positionLines(
293
474
  runX += frag.width;
294
475
  }
295
476
 
296
- const lineWidth = runX - xOffset;
297
- contentWidth = Math.max(contentWidth, lineWidth);
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
+
298
508
 
299
509
  // ── Y positioning ───────────────────────────────
300
510
  // Line height algorithm depends on mode:
@@ -356,7 +566,10 @@ export function positionLines(
356
566
  // Count characters in line (for INDEX_CONSIST)
357
567
  let lineCharCount = 0;
358
568
  for (const frag of spans) {
359
- lineCharCount += frag.text.length;
569
+ // Marker spans don't count toward the paragraph's character index
570
+ if (frag.type !== 'marker') {
571
+ lineCharCount += frag.text.length;
572
+ }
360
573
  }
361
574
  const endIdx = startIdx + lineCharCount;
362
575
  charIndex = endIdx;
@@ -377,7 +590,7 @@ export function positionLines(
377
590
  }
378
591
 
379
592
  lines.push({
380
- x: Math.round(xOffset * 100) / 100,
593
+ x: Math.round(lineX * 100) / 100,
381
594
  y: Math.round(currentY * 100) / 100,
382
595
  width: Math.round(lineWidth * 100) / 100,
383
596
  height: Math.round(lineBoxHeight * 100) / 100,
@@ -390,6 +603,7 @@ export function positionLines(
390
603
  spans,
391
604
  });
392
605
 
606
+
393
607
  currentY += lineBoxHeight;
394
608
  isFirstLine = false;
395
609
  }