pretext-pdf 0.4.6 → 0.5.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.
Files changed (63) hide show
  1. package/CHANGELOG.md +333 -333
  2. package/README.md +3 -3
  3. package/dist/assets.d.ts +3 -0
  4. package/dist/assets.d.ts.map +1 -1
  5. package/dist/assets.js +111 -21
  6. package/dist/assets.js.map +1 -1
  7. package/dist/builder.d.ts +22 -1
  8. package/dist/builder.d.ts.map +1 -1
  9. package/dist/builder.js +38 -1
  10. package/dist/builder.js.map +1 -1
  11. package/dist/errors.d.ts +1 -1
  12. package/dist/errors.d.ts.map +1 -1
  13. package/dist/errors.js.map +1 -1
  14. package/dist/fonts.d.ts.map +1 -1
  15. package/dist/fonts.js +36 -1
  16. package/dist/fonts.js.map +1 -1
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +36 -15
  20. package/dist/index.js.map +1 -1
  21. package/dist/measure-blocks.d.ts +25 -0
  22. package/dist/measure-blocks.d.ts.map +1 -0
  23. package/dist/measure-blocks.js +1019 -0
  24. package/dist/measure-blocks.js.map +1 -0
  25. package/dist/measure-text.d.ts +53 -0
  26. package/dist/measure-text.d.ts.map +1 -0
  27. package/dist/measure-text.js +435 -0
  28. package/dist/measure-text.js.map +1 -0
  29. package/dist/measure.d.ts +15 -35
  30. package/dist/measure.d.ts.map +1 -1
  31. package/dist/measure.js +42 -1066
  32. package/dist/measure.js.map +1 -1
  33. package/dist/paginate.d.ts.map +1 -1
  34. package/dist/paginate.js +14 -12
  35. package/dist/paginate.js.map +1 -1
  36. package/dist/render-blocks.d.ts +24 -0
  37. package/dist/render-blocks.d.ts.map +1 -0
  38. package/dist/render-blocks.js +937 -0
  39. package/dist/render-blocks.js.map +1 -0
  40. package/dist/render-extras.d.ts +18 -0
  41. package/dist/render-extras.d.ts.map +1 -0
  42. package/dist/render-extras.js +325 -0
  43. package/dist/render-extras.js.map +1 -0
  44. package/dist/render-utils.d.ts +59 -0
  45. package/dist/render-utils.d.ts.map +1 -0
  46. package/dist/render-utils.js +219 -0
  47. package/dist/render-utils.js.map +1 -0
  48. package/dist/render.d.ts.map +1 -1
  49. package/dist/render.js +10 -1372
  50. package/dist/render.js.map +1 -1
  51. package/dist/rich-text.d.ts +4 -0
  52. package/dist/rich-text.d.ts.map +1 -1
  53. package/dist/rich-text.js +4 -0
  54. package/dist/rich-text.js.map +1 -1
  55. package/dist/types.d.ts +108 -5
  56. package/dist/types.d.ts.map +1 -1
  57. package/dist/validate.d.ts.map +1 -1
  58. package/dist/validate.js +195 -15
  59. package/dist/validate.js.map +1 -1
  60. package/docs/screenshots/showcase-invoice.png +0 -0
  61. package/docs/screenshots/showcase-report.png +0 -0
  62. package/docs/screenshots/showcase-resume.png +0 -0
  63. package/package.json +130 -128
package/dist/measure.js CHANGED
@@ -1,1058 +1,17 @@
1
- import { PretextPdfError } from './errors.js';
2
- import { measureRichText } from './rich-text.js';
3
- /** Heading level size multipliers and defaults */
4
- const HEADING_DEFAULTS = {
5
- 1: { sizeMultiplier: 2.0, fontWeight: 700, spaceAfter: 16, spaceBefore: 28 },
6
- 2: { sizeMultiplier: 1.5, fontWeight: 700, spaceAfter: 12, spaceBefore: 24 },
7
- 3: { sizeMultiplier: 1.25, fontWeight: 700, spaceAfter: 8, spaceBefore: 20 },
8
- 4: { sizeMultiplier: 1.1, fontWeight: 700, spaceAfter: 6, spaceBefore: 16 },
9
- };
10
- /** Lazily-loaded Pretext module — must be imported AFTER polyfill is installed */
11
- let _pretext = null;
12
- async function getPretext() {
13
- if (!_pretext) {
14
- _pretext = await import('@chenglou/pretext');
15
- }
16
- return _pretext;
17
- }
18
- async function getHyphenator(language) {
19
- let dict;
20
- try {
21
- const mod = await import(`hyphenation.${language}`);
22
- dict = mod.default ?? mod;
23
- }
24
- catch {
25
- throw new PretextPdfError('UNSUPPORTED_LANGUAGE', `Hyphenation dictionary for "${language}" not found. Install it with: pnpm add hyphenation.${language}`);
26
- }
27
- // @ts-ignore hypher has no type definitions available
28
- const { default: Hypher } = await import('hypher');
29
- return new Hypher(dict);
30
- }
31
- // ─── RTL Text Support (Unicode Bidirectional Algorithm via bidi-js) ────────────
32
- /**
33
- * Detect text direction and apply Unicode Bidi Algorithm (TR9) for visual reordering.
34
- * Returns the visual-order text ready for measurement and rendering.
35
- *
36
- * @param text Logical-order input text (how user types it)
37
- * @param dirOverride Explicit direction override ('ltr', 'rtl', or 'auto')
38
- * @returns { visual, isRTL, logical } where visual is reordered text ready to render
39
- */
40
- async function detectAndReorderRTL(text, dirOverride) {
41
- // Step 1: Explicit override takes priority
42
- if (dirOverride === 'ltr') {
43
- return { visual: text, isRTL: false, logical: text };
44
- }
45
- if (dirOverride === 'rtl') {
46
- // Even with override, apply bidi algorithm to get correct visual order
47
- try {
48
- // @ts-ignore bidi-js has no type definitions
49
- const bidiFactory = (await import('bidi-js')).default;
50
- const bidi = typeof bidiFactory === 'function' ? bidiFactory() : bidiFactory;
51
- const { getEmbeddingLevels, getReorderedString } = bidi;
52
- const embedLevelsResult = getEmbeddingLevels(text, 'rtl');
53
- const visual = getReorderedString(text, embedLevelsResult);
54
- return { visual, isRTL: true, logical: text };
55
- }
56
- catch (err) {
57
- console.warn('bidi-js error during RTL reordering:', err, '— falling back to logical order');
58
- return { visual: text, isRTL: false, logical: text };
59
- }
60
- }
61
- // Step 2: Auto-detect dominant direction
62
- // Hebrew (U+0590–U+05FF), Arabic (U+0600–U+06FF), Syriac (U+0700–U+074F)
63
- const rtlRanges = /[\u0590-\u05FF\u0600-\u06FF\u0700-\u074F]/g;
64
- const rtlCount = (text.match(rtlRanges) ?? []).length;
65
- const ltrCount = (text.match(/[a-zA-Z0-9]/g) ?? []).length;
66
- const isRTL = rtlCount > 0 && rtlCount >= ltrCount;
67
- if (!isRTL) {
68
- return { visual: text, isRTL: false, logical: text };
69
- }
70
- // Step 3: Apply bidi algorithm (TR9) to reorder visually
71
- try {
72
- // @ts-ignore bidi-js has no type definitions
73
- const bidiFactory = (await import('bidi-js')).default;
74
- const bidi = typeof bidiFactory === 'function' ? bidiFactory() : bidiFactory;
75
- const { getEmbeddingLevels, getReorderedString } = bidi;
76
- const embedLevelsResult = getEmbeddingLevels(text, 'rtl');
77
- const visual = getReorderedString(text, embedLevelsResult);
78
- return { visual, isRTL: true, logical: text };
79
- }
80
- catch (err) {
81
- // Graceful fallback: if bidi-js throws (rare), use logical order
82
- console.warn('bidi-js error during RTL reordering:', err, '— falling back to logical order');
83
- return { visual: text, isRTL: false, logical: text };
84
- }
85
- }
86
- /**
87
- * Measure a single word's rendered width using pretext at maxWidth=99999.
88
- */
89
- async function measureWord(word, fontString) {
90
- const { prepareWithSegments, layoutWithLines } = await getPretext();
91
- if (!word)
92
- return 0;
93
- const prepared = prepareWithSegments(word, fontString, {});
94
- const result = layoutWithLines(prepared, 99999, 99999);
95
- const lines = result.lines ?? [];
96
- return lines[0]?.width ?? 0;
97
- }
98
- /**
99
- * Stage 3: Measure a single content element.
100
- *
101
- * Returns MeasuredBlock for most types.
102
- * Returns MeasuredBlock[] for 'list' (each item becomes an independent block).
103
- *
104
- * NOTE: Image elements cannot be measured here — use measureAllBlocks() instead,
105
- * which resolves the content-index-based imageMap key before calling measureImageWithKey().
106
- */
107
- export async function measureBlock(element, contentWidth, doc, hyphenatorOpts) {
108
- const baseFontSize = doc.defaultFontSize ?? 12;
109
- const baseFont = doc.defaultFont ?? 'Inter';
110
- switch (element.type) {
111
- case 'spacer': {
112
- return {
113
- element,
114
- height: element.height,
115
- lines: [],
116
- fontSize: 0,
117
- lineHeight: 0,
118
- fontKey: '',
119
- spaceAfter: 0,
120
- spaceBefore: 0,
121
- };
122
- }
123
- case 'page-break': {
124
- return {
125
- element,
126
- height: 0,
127
- lines: [],
128
- fontSize: 0,
129
- lineHeight: 0,
130
- fontKey: '',
131
- spaceAfter: 0,
132
- spaceBefore: 0,
133
- };
134
- }
135
- case 'comment': {
136
- return {
137
- element,
138
- height: 20,
139
- lines: [],
140
- fontSize: 0,
141
- lineHeight: 0,
142
- fontKey: '',
143
- spaceAfter: element.spaceAfter ?? 0,
144
- spaceBefore: 0,
145
- };
146
- }
147
- case 'form-field': {
148
- const el = element;
149
- const fs = el.fontSize ?? baseFontSize;
150
- const labelHeight = el.label ? fs * 1.5 + 4 : 0;
151
- let fieldHeight = el.height;
152
- if (!fieldHeight) {
153
- if (el.fieldType === 'text' && el.multiline)
154
- fieldHeight = 60;
155
- else if (el.fieldType === 'radio')
156
- fieldHeight = 20 * Math.max(1, el.options?.length ?? 1);
157
- else
158
- fieldHeight = 24;
159
- }
160
- return {
161
- element,
162
- height: labelHeight + fieldHeight + (el.spaceAfter ?? 8),
163
- lines: [],
164
- fontSize: fs,
165
- lineHeight: fieldHeight,
166
- fontKey: buildFontKey(baseFont, 400, 'normal'),
167
- spaceAfter: el.spaceAfter ?? 8,
168
- spaceBefore: el.spaceBefore ?? 0,
169
- formFieldData: { labelHeight, fieldHeight },
170
- };
171
- }
172
- case 'paragraph': {
173
- // NEW (Phase 7F): Detect and reorder RTL text
174
- const { visual: visualText, isRTL, logical: logicalText } = await detectAndReorderRTL(element.text, element.dir);
175
- const fontSize = element.fontSize ?? baseFontSize;
176
- const lineHeight = element.lineHeight ?? doc.defaultLineHeight ?? (fontSize * 1.5);
177
- const fontFamily = element.fontFamily ?? baseFont;
178
- const fontWeight = element.fontWeight ?? 400;
179
- const fontKey = buildFontKey(fontFamily, fontWeight, 'normal');
180
- const columns = element.columns ?? 1;
181
- const columnGap = element.columnGap ?? 24;
182
- let measureWidth = contentWidth;
183
- let columnData;
184
- // Multi-column layout
185
- if (columns > 1) {
186
- const columnWidth = (contentWidth - (columns - 1) * columnGap) / columns;
187
- if (columnWidth < 50) {
188
- throw new PretextPdfError('COLUMN_WIDTH_TOO_NARROW', `Column width would be ${columnWidth.toFixed(1)}pt, which is below the minimum 50pt. Reduce columns, increase columnGap, or increase page width.`);
189
- }
190
- measureWidth = columnWidth;
191
- }
192
- const opts = hyphenatorOpts && element.hyphenate !== false ? hyphenatorOpts : undefined;
193
- // CRITICAL (Phase 7F): Measure the VISUAL-ORDER text (what will actually be rendered)
194
- const lines = await measureText(visualText, fontSize, fontFamily, fontWeight, measureWidth, lineHeight, opts);
195
- if (columns > 1) {
196
- const columnWidth = (contentWidth - (columns - 1) * columnGap) / columns;
197
- const linesPerColumn = Math.ceil(lines.length / columns);
198
- columnData = { columnCount: columns, columnGap, columnWidth, linesPerColumn };
199
- }
200
- // Construct result with or without columnData depending on columns value
201
- if (columnData) {
202
- return {
203
- element,
204
- height: columnData.linesPerColumn * lineHeight,
205
- lines,
206
- fontSize,
207
- lineHeight,
208
- fontKey,
209
- spaceAfter: element.spaceAfter ?? 0,
210
- spaceBefore: element.spaceBefore ?? 0,
211
- columnData,
212
- isRTL, // NEW (Phase 7F)
213
- ...(isRTL && { logicalText }), // NEW: Only store logical text when RTL
214
- };
215
- }
216
- else {
217
- return {
218
- element,
219
- height: lines.length * lineHeight,
220
- lines,
221
- fontSize,
222
- lineHeight,
223
- fontKey,
224
- spaceAfter: element.spaceAfter ?? 0,
225
- spaceBefore: element.spaceBefore ?? 0,
226
- isRTL, // NEW (Phase 7F)
227
- ...(isRTL && { logicalText }), // NEW: Only store logical text when RTL
228
- };
229
- }
230
- }
231
- case 'heading': {
232
- // NEW (Phase 7F): Detect and reorder RTL text
233
- const { visual: visualText, isRTL, logical: logicalText } = await detectAndReorderRTL(element.text, element.dir);
234
- const defaults = HEADING_DEFAULTS[element.level];
235
- const fontSize = element.fontSize ?? (baseFontSize * defaults.sizeMultiplier);
236
- const lineHeight = element.lineHeight ?? doc.defaultLineHeight ?? (fontSize * 1.4); // tighter for headings
237
- const fontFamily = element.fontFamily ?? baseFont;
238
- const fontWeight = element.fontWeight ?? defaults.fontWeight;
239
- const fontKey = buildFontKey(fontFamily, fontWeight, 'normal');
240
- const opts = hyphenatorOpts && element.hyphenate !== false ? hyphenatorOpts : undefined;
241
- // CRITICAL (Phase 7F): Measure the VISUAL-ORDER text (what will actually be rendered)
242
- const lines = await measureText(visualText, fontSize, fontFamily, fontWeight, contentWidth, lineHeight, opts);
243
- return {
244
- element,
245
- height: lines.length * lineHeight,
246
- lines,
247
- fontSize,
248
- lineHeight,
249
- fontKey,
250
- spaceAfter: element.spaceAfter ?? defaults.spaceAfter,
251
- spaceBefore: element.spaceBefore ?? defaults.spaceBefore,
252
- isRTL, // NEW (Phase 7F)
253
- ...(isRTL && { logicalText }), // NEW: Only store logical text when RTL
254
- };
255
- }
256
- case 'hr': {
257
- const spaceAbove = element.spaceAbove ?? 12;
258
- const thickness = element.thickness ?? 0.5;
259
- const spaceBelow = element.spaceBelow ?? 12;
260
- return {
261
- element,
262
- height: spaceAbove + thickness + spaceBelow,
263
- lines: [],
264
- fontSize: 0,
265
- lineHeight: 0,
266
- fontKey: '',
267
- spaceAfter: 0,
268
- spaceBefore: 0,
269
- };
270
- }
271
- case 'image': {
272
- // Image elements must be measured via measureAllBlocks() — not measureBlock() directly.
273
- // measureAllBlocks() resolves the content-index-based imageMap key (img-N) before calling
274
- // measureImageWithKey(). measureBlock() doesn't have access to the content index.
275
- throw new PretextPdfError('VALIDATION_ERROR', 'Image elements cannot be measured via measureBlock() directly — use measureAllBlocks() which resolves the imageMap key correctly.');
276
- }
277
- case 'svg': {
278
- // SVG elements must be measured via measureAllBlocks() — not measureBlock() directly.
279
- // measureAllBlocks() resolves the content-index-based imageMap key (svg-N) before calling
280
- // measureImageWithKey(). measureBlock() doesn't have access to the content index.
281
- throw new PretextPdfError('VALIDATION_ERROR', 'SVG elements cannot be measured via measureBlock() directly — use measureAllBlocks() which resolves the imageMap key correctly.');
282
- }
283
- case 'list': {
284
- return measureList(element, contentWidth, doc, baseFontSize, hyphenatorOpts);
285
- }
286
- case 'table': {
287
- return measureTable(element, contentWidth, doc, baseFontSize, hyphenatorOpts);
288
- }
289
- case 'rich-paragraph': {
290
- // NEW (Phase 7F): Detect paragraph-level RTL direction (for alignment default)
291
- // Individual spans can override via span.dir, but paragraph.dir sets the default
292
- const fullText = element.spans.map(s => s.text).join('');
293
- const { isRTL } = await detectAndReorderRTL(fullText, element.dir);
294
- const fontSize = element.fontSize ?? baseFontSize;
295
- const lineHeight = element.lineHeight ?? doc.defaultLineHeight ?? (fontSize * 1.5);
296
- // 'justify' uses left alignment for measurement (justify is rendering-only)
297
- const alignRaw = element.align ?? 'left';
298
- const align = alignRaw === 'justify' ? 'left' : alignRaw;
299
- const columns = element.columns ?? 1;
300
- const columnGap = element.columnGap ?? 24;
301
- let measureWidth = contentWidth;
302
- let columnData;
303
- // Multi-column layout
304
- if (columns > 1) {
305
- const columnWidth = (contentWidth - (columns - 1) * columnGap) / columns;
306
- if (columnWidth < 50) {
307
- throw new PretextPdfError('COLUMN_WIDTH_TOO_NARROW', `Column width would be ${columnWidth.toFixed(1)}pt, which is below the minimum 50pt. Reduce columns, increase columnGap, or increase page width.`);
308
- }
309
- measureWidth = columnWidth;
310
- }
311
- const richLines = await measureRichText(element.spans, fontSize, lineHeight, measureWidth, align, doc);
312
- if (columns > 1) {
313
- const columnWidth = (contentWidth - (columns - 1) * columnGap) / columns;
314
- const linesPerColumn = Math.ceil(richLines.length / columns);
315
- columnData = { columnCount: columns, columnGap, columnWidth, linesPerColumn };
316
- }
317
- // For paginator/renderer compatibility, also produce a flat lines[] array
318
- // (used for orphan/widow logic and line-count-based pagination).
319
- // Each RichLine becomes one PretextLine with its totalWidth.
320
- const lines = richLines.map(rl => ({
321
- text: rl.fragments.map(f => f.text).join(''),
322
- width: rl.totalWidth,
323
- }));
324
- // Construct result with or without columnData depending on columns value
325
- // Phase 5B.4: Block height = sum of per-line heights (variable when spans have different fontSize)
326
- const blockHeight = richLines.reduce((sum, rl) => sum + rl.lineHeight, 0);
327
- if (columnData) {
328
- return {
329
- element,
330
- height: blockHeight, // richLines already have per-line heights
331
- lines,
332
- fontSize,
333
- lineHeight,
334
- fontKey: buildFontKey(doc.defaultFont ?? 'Inter', 400, 'normal'),
335
- spaceAfter: element.spaceAfter ?? 0,
336
- spaceBefore: element.spaceBefore ?? 0,
337
- richLines,
338
- columnData,
339
- isRTL, // NEW (Phase 7F)
340
- };
341
- }
342
- else {
343
- return {
344
- element,
345
- height: blockHeight, // richLines already have per-line heights
346
- lines,
347
- fontSize,
348
- lineHeight,
349
- fontKey: buildFontKey(doc.defaultFont ?? 'Inter', 400, 'normal'),
350
- spaceAfter: element.spaceAfter ?? 0,
351
- spaceBefore: element.spaceBefore ?? 0,
352
- richLines,
353
- isRTL, // NEW (Phase 7F)
354
- };
355
- }
356
- }
357
- case 'code': {
358
- const fontSize = element.fontSize ?? Math.max(baseFontSize - 2, 8);
359
- const lineHeight = element.lineHeight ?? (fontSize * 1.4);
360
- const padding = element.padding ?? 8;
361
- // Text area is narrower by padding on both sides
362
- const textWidth = contentWidth - 2 * padding;
363
- // Code blocks: never hyphenate — breaks would corrupt source code meaning
364
- // Code blocks: always measure in logical (LTR) order — reordering breaks syntax
365
- const lines = await measureText(element.text, fontSize, element.fontFamily, 400, Math.max(textWidth, 1), lineHeight);
366
- // height = lines * lineHeight + padding top + padding bottom
367
- const height = (lines.length || 1) * lineHeight + 2 * padding;
368
- return {
369
- element,
370
- height,
371
- lines,
372
- fontSize,
373
- lineHeight,
374
- fontKey: buildFontKey(element.fontFamily, 400, 'normal'),
375
- spaceAfter: element.spaceAfter ?? 12,
376
- spaceBefore: element.spaceBefore ?? 12,
377
- codePadding: padding,
378
- isRTL: false, // NEW (Phase 7F): Code blocks always LTR
379
- };
380
- }
381
- case 'blockquote': {
382
- // NEW (Phase 7F): Detect and reorder RTL text
383
- const { visual: visualText, isRTL, logical: logicalText } = await detectAndReorderRTL(element.text, element.dir);
384
- const fontSize = element.fontSize ?? baseFontSize;
385
- const lineHeight = element.lineHeight ?? doc.defaultLineHeight ?? (fontSize * 1.5);
386
- const fontFamily = element.fontFamily ?? baseFont;
387
- const fontWeight = element.fontWeight ?? 400;
388
- const fontStyle = element.fontStyle ?? 'normal';
389
- const fontKey = buildFontKey(fontFamily, fontWeight, fontStyle);
390
- const borderWidth = element.borderWidth ?? 3;
391
- const paddingH = element.paddingH ?? element.padding ?? 16;
392
- const paddingV = element.paddingV ?? element.padding ?? 10;
393
- // Text area excludes left border + horizontal padding on both sides
394
- const textWidth = contentWidth - borderWidth - 2 * paddingH;
395
- // CRITICAL (Phase 7F): Measure the VISUAL-ORDER text (what will actually be rendered)
396
- const lines = await measureText(visualText, fontSize, fontFamily, fontWeight, Math.max(textWidth, 1), lineHeight, hyphenatorOpts);
397
- // height = lines * lineHeight + padding top + padding bottom
398
- const height = (lines.length || 1) * lineHeight + 2 * paddingV;
399
- return {
400
- element,
401
- height,
402
- lines,
403
- fontSize,
404
- lineHeight,
405
- fontKey,
406
- spaceAfter: element.spaceAfter ?? 12,
407
- spaceBefore: element.spaceBefore ?? 0,
408
- blockquotePaddingV: paddingV,
409
- blockquotePaddingH: paddingH,
410
- blockquoteBorderWidth: borderWidth,
411
- isRTL, // NEW (Phase 7F)
412
- ...(isRTL && { logicalText }), // NEW: Only store logical text when RTL
413
- };
414
- }
415
- case 'callout': {
416
- const el = element;
417
- const fs = el.fontSize ?? baseFontSize;
418
- const lh = el.lineHeight ?? (fs * 1.5);
419
- const ph = el.paddingH ?? el.padding ?? 16;
420
- const pv = el.paddingV ?? el.padding ?? 10;
421
- const family = el.fontFamily ?? baseFont;
422
- const colors = resolveCalloutColors(el.style);
423
- const borderColor = el.borderColor ?? colors.border;
424
- const backgroundColor = el.backgroundColor ?? colors.bg;
425
- const color = el.color ?? '#1F2937';
426
- const titleColor = el.titleColor ?? borderColor;
427
- // Measure title height (one line assumed, bold)
428
- let titleHeight = 0;
429
- if (el.title) {
430
- titleHeight = fs * 1.4 + 4; // 1.4 line height + 4pt separator
431
- }
432
- // Measure content text
433
- const innerWidth = contentWidth - ph * 2;
434
- const lines = await measureText(el.content, fs, family, el.fontWeight ?? 400, Math.max(innerWidth, 1), lh, hyphenatorOpts);
435
- const contentTextHeight = lines.length * lh;
436
- const totalHeight = pv + titleHeight + contentTextHeight + pv + (el.spaceAfter ?? 12);
437
- return {
438
- element,
439
- height: totalHeight,
440
- lines,
441
- fontSize: fs,
442
- lineHeight: lh,
443
- fontKey: buildFontKey(family, el.fontWeight ?? 400, 'normal'),
444
- spaceAfter: el.spaceAfter ?? 12,
445
- spaceBefore: el.spaceBefore ?? 0,
446
- blockquotePaddingV: pv,
447
- blockquotePaddingH: ph,
448
- blockquoteBorderWidth: 3,
449
- calloutData: { titleHeight, paddingH: ph, paddingV: pv, borderColor, backgroundColor, titleColor, color, ...(el.title !== undefined ? { titleText: el.title } : {}) },
450
- };
451
- }
452
- case 'toc': {
453
- // Placeholder: zero height. Will be replaced by actual TOC entries in two-pass mode.
454
- return {
455
- element,
456
- height: 0,
457
- lines: [],
458
- fontSize: 0,
459
- lineHeight: 0,
460
- fontKey: '',
461
- spaceAfter: element.spaceAfter ?? 0,
462
- spaceBefore: element.spaceBefore ?? 0,
463
- };
464
- }
465
- case 'toc-entry': {
466
- // Internal type - should never be measured directly by user input
467
- throw new PretextPdfError('VALIDATION_ERROR', 'toc-entry is an internal type and cannot be used in document content');
468
- }
469
- case 'footnote-def': {
470
- const fn = element;
471
- const baseFontSize = doc.defaultFontSize ?? 12;
472
- const fontSize = fn.fontSize ?? Math.max(8, baseFontSize - 2);
473
- const lineHeight = fontSize * 1.5;
474
- const fontFamily = fn.fontFamily ?? doc.defaultFont ?? 'Inter';
475
- const fontKey = buildFontKey(fontFamily, 400, 'normal');
476
- // Measure the def text with a 20pt left indent (for the number prefix space)
477
- const textLines = await measureText(fn.text, fontSize, fontFamily, 400, contentWidth - 20, // leave space for "N. " prefix
478
- lineHeight, undefined);
479
- const height = textLines.length * lineHeight;
480
- return {
481
- element,
482
- height,
483
- lines: textLines,
484
- fontSize,
485
- lineHeight,
486
- fontKey,
487
- spaceAfter: fn.spaceAfter ?? 4,
488
- spaceBefore: 0,
489
- };
490
- }
491
- }
492
- }
493
- // ─── HR is trivial (handled inline above) ────────────────────────────────────
494
- // ─── Image measurement ────────────────────────────────────────────────────────
495
- /** Measure an image element with its known imageMap key */
496
- async function measureImageWithKey(element, imageKey, imageMap, contentWidth, pageContentHeight) {
497
- const pdfImage = imageMap.get(imageKey);
498
- if (!pdfImage) {
499
- throw new PretextPdfError('IMAGE_LOAD_FAILED', `Image "${imageKey}": not found in imageMap. This is an internal error — please report it.`);
500
- }
501
- // Natural dimensions (in original pixels — aspect ratio is what matters, not the pixel count)
502
- const naturalWidth = pdfImage.width;
503
- const naturalHeight = pdfImage.height;
504
- // Resolve render dimensions (in pt)
505
- let renderWidth;
506
- let renderHeight;
507
- if (element.width !== undefined && element.height !== undefined) {
508
- // Both provided: use as-is
509
- renderWidth = element.width;
510
- renderHeight = element.height;
511
- }
512
- else if (element.width !== undefined) {
513
- // Width only: scale height
514
- renderWidth = element.width;
515
- renderHeight = renderWidth * (naturalHeight / naturalWidth);
516
- }
517
- else if (element.height !== undefined) {
518
- // Height only: scale width
519
- renderHeight = element.height;
520
- renderWidth = renderHeight * (naturalWidth / naturalHeight);
521
- }
522
- else {
523
- // Neither: fit to content width
524
- renderWidth = contentWidth;
525
- renderHeight = renderWidth * (naturalHeight / naturalWidth);
526
- }
527
- // Clamp to content width
528
- if (renderWidth > contentWidth) {
529
- const scale = contentWidth / renderWidth;
530
- renderWidth = contentWidth;
531
- renderHeight = renderHeight * scale;
532
- }
533
- // Validate height doesn't exceed page
534
- if (renderHeight > pageContentHeight) {
535
- throw new PretextPdfError('IMAGE_TOO_TALL', `Image "${imageKey}" would render at ${renderHeight.toFixed(1)}pt tall, which exceeds the page content area (${pageContentHeight.toFixed(1)}pt). ` +
536
- `Reduce 'height' or increase page size/reduce margins.`);
537
- }
538
- const imageData = {
539
- imageKey,
540
- renderWidth,
541
- renderHeight,
542
- align: element.align ?? 'left',
543
- };
544
- return {
545
- element,
546
- height: renderHeight,
547
- lines: [],
548
- fontSize: 0,
549
- lineHeight: 0,
550
- fontKey: '',
551
- spaceAfter: element.spaceAfter ?? 0,
552
- spaceBefore: element.spaceBefore ?? 0,
553
- imageData,
554
- };
555
- }
556
- // ─── Float image block measurement ───────────────────────────────────────────
557
- async function measureFloatImageBlock(element, imageKey, imageMap, contentWidth, pageContentHeight, doc) {
558
- const floatWidth = element.floatWidth ?? (contentWidth * 0.35);
559
- const floatGap = element.floatGap ?? 12;
560
- const textColWidth = contentWidth - floatWidth - floatGap;
561
- if (textColWidth < 50) {
562
- throw new PretextPdfError('COLUMN_WIDTH_TOO_NARROW', `Float image: text column would be ${textColWidth.toFixed(1)}pt (minimum 50pt). ` +
563
- `Reduce floatWidth or increase page width.`);
564
- }
565
- // Measure the image at floatWidth using a synthetic element
566
- const syntheticEl = {
567
- type: 'image',
568
- src: element.src,
569
- ...(element.format !== undefined ? { format: element.format } : {}),
570
- width: floatWidth,
571
- align: 'left',
572
- spaceAfter: 0,
573
- spaceBefore: 0,
574
- };
575
- const imageBlock = await measureImageWithKey(syntheticEl, imageKey, imageMap, floatWidth, pageContentHeight);
576
- const imageRenderWidth = imageBlock.imageData.renderWidth;
577
- const imageRenderHeight = imageBlock.imageData.renderHeight;
578
- // Measure the float text
579
- const fontSize = element.floatFontSize ?? doc.defaultFontSize ?? 12;
580
- const lineHeight = fontSize * 1.5;
581
- const fontFamily = element.floatFontFamily ?? doc.defaultFont ?? 'Inter';
582
- const fontKey = buildFontKey(fontFamily, 400, 'normal');
583
- const textLines = await measureText(element.floatText, fontSize, fontFamily, 400, textColWidth, lineHeight, undefined);
584
- // Column X positions
585
- const imageColX = element.float === 'left' ? 0 : textColWidth + floatGap;
586
- const textColX = element.float === 'left' ? floatWidth + floatGap : 0;
587
- const textHeight = textLines.length * lineHeight;
588
- const compositeHeight = Math.max(imageRenderHeight, textHeight);
589
- return {
590
- element,
591
- height: compositeHeight,
592
- lines: [],
593
- fontSize: 0,
594
- lineHeight: 0,
595
- fontKey: '',
596
- spaceAfter: element.spaceAfter ?? 0,
597
- spaceBefore: element.spaceBefore ?? 0,
598
- floatData: {
599
- imageKey,
600
- imageRenderWidth,
601
- imageRenderHeight,
602
- imageColX,
603
- textColX,
604
- textColWidth,
605
- textLines,
606
- textFontKey: fontKey,
607
- textFontSize: fontSize,
608
- textLineHeight: lineHeight,
609
- textColor: element.floatColor ?? '#000000',
610
- },
611
- };
612
- }
613
- // ─── List measurement (returns MeasuredBlock[]) ───────────────────────────────
614
- async function measureList(element, contentWidth, doc, baseFontSize, hyphenatorOpts) {
615
- const baseFontFamily = doc.defaultFont ?? 'Inter';
616
- const fontSize = element.fontSize ?? baseFontSize;
617
- const lineHeight = element.lineHeight ?? doc.defaultLineHeight ?? (fontSize * 1.5);
618
- const indent = element.indent ?? 20;
619
- const markerWidth = element.markerWidth ?? 20;
620
- const itemSpaceAfter = element.itemSpaceAfter ?? 4;
621
- const fontKey = buildFontKey(baseFontFamily, 400, 'normal');
622
- // Width available for item text (after indent + marker column)
623
- const textWidth = contentWidth - indent - markerWidth;
624
- if (textWidth <= 0) {
625
- throw new PretextPdfError('VALIDATION_ERROR', `List indent (${indent}pt) + markerWidth (${markerWidth}pt) exceeds contentWidth (${contentWidth}pt). Reduce indent or markerWidth.`);
626
- }
627
- const blocks = [];
628
- // Flatten items and nested items
629
- const nestedStyle = element.nestedNumberingStyle ?? 'continue';
630
- let orderedIndex = 1;
631
- const allItems = [];
632
- for (let i = 0; i < element.items.length; i++) {
633
- const item = element.items[i];
634
- const isFirst = i === 0;
635
- const marker = element.style === 'ordered'
636
- ? `${orderedIndex}.`
637
- : (element.marker ?? '•');
638
- orderedIndex++;
639
- allItems.push({ text: item.text, marker, isNested: false, isFirstInList: isFirst, fontWeight: item.fontWeight ?? 400 });
640
- // Nested items (1 level deep)
641
- if (item.items && item.items.length > 0) {
642
- // 'restart': nested ordered items count from 1, parent counter unaffected
643
- // 'continue': nested items share the parent counter (existing behavior)
644
- let nestedIndex = nestedStyle === 'restart' ? 1 : orderedIndex;
645
- for (let ni = 0; ni < item.items.length; ni++) {
646
- const nested = item.items[ni];
647
- const nestedMarker = element.style === 'ordered'
648
- ? `${nestedIndex}.`
649
- : '◦'; // hollow bullet for nested unordered
650
- nestedIndex++;
651
- if (nestedStyle === 'continue')
652
- orderedIndex++;
653
- allItems.push({ text: nested.text, marker: nestedMarker, isNested: true, isFirstInList: false, fontWeight: nested.fontWeight ?? 400 });
654
- }
655
- }
656
- }
657
- for (let i = 0; i < allItems.length; i++) {
658
- const item = allItems[i];
659
- const isLast = i === allItems.length - 1;
660
- const nestedIndent = item.isNested ? indent + markerWidth : indent;
661
- const nestedTextWidth = item.isNested ? textWidth - markerWidth : textWidth;
662
- const lines = await measureText(item.text, fontSize, baseFontFamily, item.fontWeight, nestedTextWidth, lineHeight, hyphenatorOpts);
663
- const listItemData = {
664
- marker: item.marker,
665
- indent: nestedIndent,
666
- markerWidth,
667
- color: element.color ?? '#000000',
668
- fontWeight: item.fontWeight,
669
- };
670
- // spaceBefore: only the first item in the entire list gets the list's spaceBefore
671
- // spaceAfter: itemSpaceAfter between items, list.spaceAfter on the last item
672
- const spaceBefore = item.isFirstInList ? (element.spaceBefore ?? 0) : 0;
673
- const spaceAfter = isLast ? (element.spaceAfter ?? 0) : itemSpaceAfter;
674
- blocks.push({
675
- element, // All items share the parent ListElement (for type checking in renderer)
676
- height: Math.max(lines.length, 1) * lineHeight,
677
- lines,
678
- fontSize,
679
- lineHeight,
680
- fontKey,
681
- spaceAfter,
682
- spaceBefore,
683
- listItemData,
684
- });
685
- }
686
- return blocks;
687
- }
688
- // ─── Table measurement ────────────────────────────────────────────────────────
689
- async function measureTable(element, contentWidth, doc, baseFontSize, hyphenatorOpts) {
690
- const baseFontFamily = doc.defaultFont ?? 'Inter';
691
- const fontSize = element.fontSize ?? baseFontSize;
692
- const lineHeight = doc.defaultLineHeight ?? (fontSize * 1.5);
693
- const cellPaddingH = element.cellPaddingH ?? 8;
694
- const cellPaddingV = element.cellPaddingV ?? 6;
695
- const borderWidth = element.borderWidth ?? 0.5;
696
- const borderColor = element.borderColor ?? '#cccccc';
697
- const headerBgColor = element.headerBgColor ?? '#f5f5f5';
698
- // Pre-pass: measure natural widths for 'auto' columns
699
- // NOTE: with colspan, we map cells to their starting column index
700
- const hasAutoColumns = element.columns.some(c => c.width === 'auto');
701
- let naturalWidths;
702
- if (hasAutoColumns) {
703
- naturalWidths = new Array(element.columns.length).fill(0);
704
- for (const row of element.rows) {
705
- let colIdx = 0; // track column position accounting for colspan
706
- for (const cell of row.cells) {
707
- const cs = cell.colspan ?? 1;
708
- // For auto columns: measure natural width and distribute across spanned columns
709
- // For now, attribute full width to the first column of the span
710
- if (element.columns[colIdx]?.width === 'auto') {
711
- const fontWeight = cell.fontWeight ?? (row.isHeader ? 700 : 400);
712
- const cellFontSize = cell.fontSize ?? fontSize;
713
- const cellFamily = cell.fontFamily ?? baseFontFamily;
714
- const w = await measureNaturalTextWidth(cell.text, cellFontSize, cellFamily, fontWeight);
715
- const cellNaturalWidth = w + 2 * cellPaddingH;
716
- // Distribute across spanned columns
717
- const perColumn = cellNaturalWidth / cs;
718
- for (let si = colIdx; si < colIdx + cs && si < element.columns.length; si++) {
719
- if (element.columns[si]?.width === 'auto') {
720
- naturalWidths[si] = Math.max(naturalWidths[si], perColumn);
721
- }
722
- }
723
- }
724
- colIdx += cs;
725
- }
726
- }
727
- }
728
- // Resolve column widths (passes naturalWidths for 'auto' columns)
729
- const columnWidths = resolveColumnWidths(element.columns, contentWidth, cellPaddingH, borderWidth, naturalWidths);
730
- // Determine header row count
731
- const headerRowCount = element.headerRows !== undefined
732
- ? element.headerRows
733
- : element.rows.filter(r => r.isHeader).length;
734
- // Measure all rows
735
- const measuredRows = [];
736
- for (const row of element.rows) {
737
- const measuredCells = [];
738
- let maxCellHeight = 0;
739
- let colStart = 0; // track column position accounting for colspan
740
- for (const cell of row.cells) {
741
- const cs = cell.colspan ?? 1;
742
- const col = element.columns[colStart];
743
- // Calculate merged width: sum of spanned column widths + borders between them
744
- let mergedWidth = 0;
745
- for (let si = colStart; si < colStart + cs && si < columnWidths.length; si++) {
746
- mergedWidth += columnWidths[si];
747
- if (si < colStart + cs - 1)
748
- mergedWidth += borderWidth; // border between columns
749
- }
750
- const fontWeight = cell.fontWeight ?? (row.isHeader ? 700 : 400);
751
- const fontFamily = cell.fontFamily ?? baseFontFamily;
752
- const cellFontSize = cell.fontSize ?? fontSize;
753
- const cellLineHeight = doc.defaultLineHeight ?? (cellFontSize * 1.5);
754
- const cellFontKey = buildFontKey(fontFamily, fontWeight, 'normal');
755
- // Text area is narrower than merged cell (padding on both sides + border)
756
- const textWidth = mergedWidth - 2 * cellPaddingH - borderWidth;
757
- // RTL detection must happen BEFORE measuring — visual-order text is used for correct glyph layout
758
- const cellDir = cell.dir ?? 'auto';
759
- const { visual: cellVisualText, isRTL: cellIsRTL } = await detectAndReorderRTL(cell.text, cellDir);
760
- const lines = await measureText(cellVisualText, cellFontSize, fontFamily, fontWeight, Math.max(textWidth, 1), cellLineHeight, hyphenatorOpts);
761
- const cellContentHeight = Math.max(lines.length, 1) * cellLineHeight;
762
- maxCellHeight = Math.max(maxCellHeight, cellContentHeight);
763
- // Apply RTL alignment default: if align not explicitly set and text is RTL, default to right
764
- const align = cell.align ?? col.align ?? (cellIsRTL ? 'right' : 'left');
765
- const measuredCell = {
766
- lines,
767
- fontSize: cellFontSize,
768
- lineHeight: cellLineHeight,
769
- fontKey: cellFontKey,
770
- fontFamily,
771
- align,
772
- color: cell.color ?? '#000000',
773
- colspan: cs,
774
- mergedWidth,
775
- isRTL: cellIsRTL,
776
- };
777
- if (cell.bgColor !== undefined)
778
- measuredCell.bgColor = cell.bgColor;
779
- measuredCells.push(measuredCell);
780
- colStart += cs;
781
- }
782
- // Row height = tallest cell content + vertical padding on both sides
783
- const rowHeight = maxCellHeight + 2 * cellPaddingV;
784
- // Compute which column boundaries have active vertical lines (not spanned by merged cells)
785
- const activeBoundaries = computeActiveBoundaries(row.cells, element.columns.length);
786
- measuredRows.push({
787
- cells: measuredCells,
788
- height: rowHeight,
789
- isHeader: row.isHeader ?? false,
790
- activeBoundaries,
791
- });
792
- }
793
- // Header rows are the first N rows
794
- const headerRows = measuredRows.slice(0, headerRowCount);
795
- const headerRowHeight = headerRows.reduce((sum, r) => sum + r.height, 0);
796
- // Total table height = sum of all row heights
797
- const totalHeight = measuredRows.reduce((sum, r) => sum + r.height, 0);
798
- const tableData = {
799
- columnWidths,
800
- rows: measuredRows,
801
- headerRowCount,
802
- headerRowHeight,
803
- cellPaddingH,
804
- cellPaddingV,
805
- borderWidth,
806
- borderColor,
807
- headerBgColor,
808
- };
809
- return {
810
- element,
811
- height: totalHeight,
812
- lines: [],
813
- fontSize: 0,
814
- lineHeight: 0,
815
- fontKey: '',
816
- spaceAfter: element.spaceAfter ?? 0,
817
- spaceBefore: element.spaceBefore ?? 0,
818
- tableData,
819
- };
820
- }
821
- // ─── Column width resolution ──────────────────────────────────────────────────
822
- /**
823
- * Resolve column width definitions to concrete pt values.
824
- * Fixed widths are used as-is. Star widths ('2*', '*') share the remaining space.
825
- * 'auto' columns use naturalWidths[i] (measured content width) — caller must pre-compute these.
826
- *
827
- * naturalWidths is required if any column uses 'auto'. It maps column index → natural text width in pt
828
- * (the minimum width needed to display cell text on one line, including cellPaddingH on both sides).
829
- */
830
- export function resolveColumnWidths(columns, contentWidth, cellPaddingH, borderWidth, naturalWidths) {
831
- const MIN_COLUMN_WIDTH = cellPaddingH * 2 + borderWidth * 2 + 4; // minimum usable pt
832
- let totalFixed = 0;
833
- let totalStars = 0;
834
- let totalAutoNatural = 0;
835
- let autoCount = 0;
836
- for (let i = 0; i < columns.length; i++) {
837
- const col = columns[i];
838
- if (typeof col.width === 'number') {
839
- totalFixed += col.width;
840
- }
841
- else if (col.width === 'auto') {
842
- // Auto columns reserve their natural width from remaining space
843
- const natural = naturalWidths?.[i] ?? MIN_COLUMN_WIDTH;
844
- totalAutoNatural += natural;
845
- autoCount++;
846
- }
847
- else {
848
- // '*' → 1 star, '2*' → 2 stars, '1.5*' → 1.5 stars
849
- const match = col.width.match(/^(\d*\.?\d*)?\*$/);
850
- const stars = (match && match[1]) ? parseFloat(match[1]) : 1;
851
- totalStars += stars;
852
- }
853
- }
854
- const remaining = contentWidth - totalFixed;
855
- if (remaining < -0.01) {
856
- throw new PretextPdfError('TABLE_COLUMN_OVERFLOW', `Table fixed column widths (${totalFixed.toFixed(1)}pt) exceed content width (${contentWidth.toFixed(1)}pt). ` +
857
- `Reduce column widths or page margins.`);
858
- }
859
- // How much space is available after fixed columns
860
- const availableForFlexible = Math.max(0, remaining);
861
- // Auto columns claim their natural width (capped at available space).
862
- // Star columns share whatever remains after auto columns.
863
- // If auto columns overflow, they get proportional shares of available space.
864
- const autoFits = totalAutoNatural <= availableForFlexible;
865
- const autoUsed = autoFits ? totalAutoNatural : availableForFlexible;
866
- const availableForStars = availableForFlexible - autoUsed;
867
- const starUnit = totalStars > 0 ? Math.max(0, availableForStars) / totalStars : 0;
868
- return columns.map((col, i) => {
869
- let resolved;
870
- if (typeof col.width === 'number') {
871
- resolved = col.width;
872
- }
873
- else if (col.width === 'auto') {
874
- const natural = naturalWidths?.[i] ?? MIN_COLUMN_WIDTH;
875
- if (autoFits) {
876
- resolved = natural;
877
- }
878
- else {
879
- // Constrained: proportional share based on natural widths
880
- resolved = totalAutoNatural > 0
881
- ? (natural / totalAutoNatural) * availableForFlexible
882
- : MIN_COLUMN_WIDTH;
883
- }
884
- }
885
- else {
886
- const match = col.width.match(/^(\d*\.?\d*)?\*$/);
887
- const stars = (match && match[1]) ? parseFloat(match[1]) : 1;
888
- resolved = stars * starUnit;
889
- }
890
- if (resolved < MIN_COLUMN_WIDTH) {
891
- throw new PretextPdfError('TABLE_COLUMN_TOO_NARROW', `Table column ${i} resolved to ${resolved.toFixed(1)}pt, minimum is ${MIN_COLUMN_WIDTH.toFixed(1)}pt. ` +
892
- `Increase the column width or reduce cellPaddingH/borderWidth.`);
893
- }
894
- return resolved;
895
- });
896
- }
897
- /**
898
- * Compute which column boundaries have visible vertical lines.
899
- * A boundary is "active" (visible) if it's not spanned by any merged cell.
900
- * Returns array of boundary indices (0 = between col 0 and 1, 1 = between col 1 and 2, etc.)
901
- * where vertical lines should be drawn.
902
- *
903
- * Example: 3 columns with a cell spanning cols 0-1 → active boundaries are [1] (only between cols 1-2)
904
- */
905
- function computeActiveBoundaries(cells, colCount) {
906
- // Track which boundaries are "spanned" (internal to a merged cell)
907
- const spannedBoundaries = new Set();
908
- let colIdx = 0;
909
- for (const cell of cells) {
910
- const cs = cell.colspan ?? 1;
911
- // Boundaries internal to this cell's span are: colIdx to colIdx + cs - 1
912
- // The internal boundaries are colIdx, colIdx+1, ..., colIdx+cs-2
913
- for (let b = colIdx; b < colIdx + cs - 1; b++) {
914
- spannedBoundaries.add(b);
915
- }
916
- colIdx += cs;
917
- }
918
- // Active boundaries are all boundaries (0 to colCount-2) that are NOT spanned
919
- const activeBoundaries = [];
920
- for (let b = 0; b < colCount - 1; b++) {
921
- if (!spannedBoundaries.has(b)) {
922
- activeBoundaries.push(b);
923
- }
924
- }
925
- return activeBoundaries;
926
- }
927
- /**
928
- * Measure the natural (unwrapped) width of text in pt.
929
- * Uses a very large maxWidth so Pretext never wraps — returns the actual line width.
930
- */
931
- async function measureNaturalTextWidth(text, fontSize, fontFamily, fontWeight) {
932
- if (!text || text.trim() === '')
933
- return 0;
934
- const { prepareWithSegments, layoutWithLines } = await getPretext();
935
- const weightPrefix = fontWeight === 700 ? 'bold ' : '';
936
- const fontString = `${weightPrefix}${fontSize}px ${fontFamily}`;
937
- // Use a very large width to prevent wrapping; also handle multi-line text (\n)
938
- // by taking the max line width across all lines
939
- const prepared = prepareWithSegments(text, fontString, { whiteSpace: 'pre-wrap' });
940
- const result = layoutWithLines(prepared, 99999, fontSize * 1.5);
941
- const lines = result.lines ?? [];
942
- return lines.reduce((max, line) => Math.max(max, line.width), 0);
943
- }
944
- // ─── Text measurement (shared by all text-bearing elements) ──────────────────
945
1
  /**
946
- * Measure text with automatic word hyphenation (Liang's algorithm via hypher).
947
- * Splits on \n to preserve paragraph breaks; tokenizes words; greedily packs with hyphenation fallback.
2
+ * measure.ts Measurement orchestrator
3
+ * Entry point for the measurement pipeline. Exports public measurement functions.
948
4
  */
949
- async function measureTextWithHyphenation(text, fontString, maxWidth, opts) {
950
- const { instance: hypher, minWordLength, leftMin, rightMin } = opts;
951
- const widthCache = new Map();
952
- const measure = async (w) => {
953
- if (widthCache.has(w))
954
- return widthCache.get(w);
955
- const width = await measureWord(w, fontString);
956
- widthCache.set(w, width);
957
- return width;
958
- };
959
- let spaceWidth = await measure(' ');
960
- // Fallback if canvas returns 0 for space
961
- if (spaceWidth === 0) {
962
- const aWidth = await measure('a');
963
- const aaWidth = await measure('a a');
964
- spaceWidth = aaWidth - 2 * aWidth;
965
- if (spaceWidth <= 0)
966
- spaceWidth = aWidth * 0.3; // Reasonable estimate
967
- }
968
- const allLines = [];
969
- for (const para of text.split('\n')) {
970
- if (!para.trim()) {
971
- allLines.push({ text: '', width: 0 });
972
- continue;
973
- }
974
- const words = para.split(/\s+/).filter(w => w.length > 0);
975
- // Pre-measure all unique words in this paragraph
976
- const uniqueWords = new Set(words);
977
- for (const w of uniqueWords) {
978
- await measure(w);
979
- }
980
- const lines = [];
981
- let currentWords = [];
982
- let currentWidth = 0;
983
- const flush = () => {
984
- if (currentWords.length > 0) {
985
- lines.push({ text: currentWords.join(' '), width: currentWidth });
986
- currentWords = [];
987
- currentWidth = 0;
988
- }
989
- };
990
- for (const word of words) {
991
- const ww = widthCache.get(word);
992
- const addW = currentWords.length > 0 ? spaceWidth + ww : ww;
993
- if (currentWidth + addW <= maxWidth || currentWords.length === 0) {
994
- currentWords.push(word);
995
- currentWidth += addW;
996
- }
997
- else {
998
- // Try hyphenation
999
- let hyphenated = false;
1000
- if (word.length >= minWordLength) {
1001
- const sylls = hypher.hyphenate(word);
1002
- for (let split = sylls.length - 1; split >= 1; split--) {
1003
- const prefix = sylls.slice(0, split).join('');
1004
- const suffix = sylls.slice(split).join('');
1005
- if (prefix.length < leftMin || suffix.length < rightMin)
1006
- continue;
1007
- const hyphenPart = prefix + '-';
1008
- const hw = await measure(hyphenPart);
1009
- const addHW = currentWords.length > 0 ? spaceWidth + hw : hw;
1010
- if (currentWidth + addHW <= maxWidth) {
1011
- currentWords.push(hyphenPart);
1012
- currentWidth += addHW;
1013
- flush();
1014
- await measure(suffix);
1015
- currentWords = [suffix];
1016
- currentWidth = widthCache.get(suffix);
1017
- hyphenated = true;
1018
- break;
1019
- }
1020
- }
1021
- }
1022
- if (!hyphenated) {
1023
- flush();
1024
- currentWords = [word];
1025
- currentWidth = ww;
1026
- }
1027
- }
1028
- }
1029
- flush();
1030
- allLines.push(...lines);
1031
- }
1032
- return allLines;
1033
- }
5
+ import { measureBlock } from './measure-blocks.js';
6
+ import { getPretext, getHyphenator } from './measure-text.js';
7
+ // Re-export for backward compatibility with tests
8
+ export { measureBlock };
1034
9
  /**
1035
- * Measure text using Pretext and return an array of lines (text + width).
1036
- * Empty/whitespace-only string returns empty array (nothing to render).
1037
- * If hyphenatorOpts provided, delegates to measureTextWithHyphenation() for word-level hyphenation.
10
+ * Build the canonical font key: family-weight-style
11
+ * Used by both measure.ts and render.ts for font lookup
1038
12
  */
1039
- async function measureText(text, fontSize, fontFamily, fontWeight, maxWidth, lineHeight, hyphenatorOpts) {
1040
- if (!text || text.trim() === '')
1041
- return [];
1042
- const weightPrefix = fontWeight === 700 ? 'bold ' : '';
1043
- const fontString = `${weightPrefix}${fontSize}px ${fontFamily}`;
1044
- // Delegate to hyphenation path if enabled
1045
- if (hyphenatorOpts) {
1046
- return measureTextWithHyphenation(text, fontString, maxWidth, hyphenatorOpts);
1047
- }
1048
- const { prepareWithSegments, layoutWithLines } = await getPretext();
1049
- // whiteSpace: 'pre-wrap' — preserves \n in multi-line text. CRITICAL per Phase 1 lessons.
1050
- const prepared = prepareWithSegments(text, fontString, { whiteSpace: 'pre-wrap' });
1051
- const result = layoutWithLines(prepared, maxWidth, lineHeight);
1052
- return (result.lines ?? []).map((line) => ({
1053
- text: line.text,
1054
- width: line.width,
1055
- }));
13
+ export function buildFontKey(family, weight = 400, style = 'normal') {
14
+ return `${family}-${weight}-${style}`;
1056
15
  }
1057
16
  /**
1058
17
  * Measure a short header/footer string — returns total height in pt.
@@ -1061,7 +20,6 @@ export async function measureHeaderFooterHeight(text, fontSize, fontFamily, cont
1061
20
  if (!text || text.trim() === '')
1062
21
  return 0;
1063
22
  const { prepareWithSegments, layoutWithLines } = await getPretext();
1064
- // Replace tokens with representative numbers for measurement
1065
23
  const sampleText = text
1066
24
  .replace('{{pageNumber}}', '99')
1067
25
  .replace('{{totalPages}}', '99');
@@ -1070,25 +28,24 @@ export async function measureHeaderFooterHeight(text, fontSize, fontFamily, cont
1070
28
  const result = layoutWithLines(prepared, contentWidth, lineHeight);
1071
29
  return result.lineCount * lineHeight;
1072
30
  }
1073
- /** Phase 8D: Resolve preset callout style colors */
31
+ /**
32
+ * Phase 8D: Resolve preset callout style colors
33
+ */
1074
34
  function resolveCalloutColors(style) {
1075
35
  switch (style) {
1076
36
  case 'info': return { bg: '#EFF6FF', border: '#3B82F6' };
1077
37
  case 'warning': return { bg: '#FFFBEB', border: '#F59E0B' };
1078
- case 'tip': return { bg: '#F0FDF4', border: '#22C55E' };
1079
- case 'note': return { bg: '#F9FAFB', border: '#9CA3AF' };
1080
- default: return { bg: '#F8F9FA', border: '#0070F3' };
38
+ case 'tip': return { bg: '#F0FDF4', border: '#10B981' };
39
+ case 'note': return { bg: '#F3F4F6', border: '#6B7280' };
40
+ default: return { bg: '#FFFFFF', border: '#D1D5DB' };
1081
41
  }
1082
42
  }
1083
- /** Build a font map key from family + weight + style */
1084
- export function buildFontKey(family, weight, style) {
1085
- return `${family}-${weight}-${style}`;
1086
- }
1087
43
  /**
1088
- * Build measured TOC entry blocks from collected headings.
1089
- * Called during two-pass mode after first pagination to generate the actual TOC content.
44
+ * Build measured TOC entry blocks from draft headings (two-pass TOC generation).
45
+ * Each entry is placed on a specific page.
1090
46
  */
1091
47
  export async function buildTocEntryBlocks(headings, tocElement, contentWidth, doc) {
48
+ const { measureText } = await import('./measure-text.js');
1092
49
  const minLevel = tocElement.minLevel ?? 1;
1093
50
  const maxLevel = tocElement.maxLevel ?? 3;
1094
51
  const fontSize = tocElement.fontSize ?? doc.defaultFontSize ?? 12;
@@ -1139,12 +96,13 @@ export async function buildTocEntryBlocks(headings, tocElement, contentWidth, do
1139
96
  return blocks;
1140
97
  }
1141
98
  /**
1142
- * Measure all content elements in a document.
1143
- * Handles list flattening (lists return MeasuredBlock[]).
1144
- * Handles image key resolution (images need their content-index-based key).
1145
- * Initializes hyphenator if doc.hyphenation is configured.
99
+ * Stage 3: Measure all document content elements.
100
+ * Handles image key resolution, list flattening, SVG wrapping, hyphenation initialization.
101
+ *
102
+ * Returns array of MeasuredBlock (includes flattened lists + SVG-wrapped images).
1146
103
  */
1147
104
  export async function measureAllBlocks(doc, contentWidth, imageMap, pageContentHeight) {
105
+ const { measureImageWithKey, measureFloatImageBlock } = await import('./measure-blocks.js');
1148
106
  const results = [];
1149
107
  // Initialize hyphenator if enabled
1150
108
  let hyphenatorOpts;
@@ -1158,6 +116,10 @@ export async function measureAllBlocks(doc, contentWidth, imageMap, pageContentH
1158
116
  if (el.type === 'image') {
1159
117
  // Images need their specific imageMap key (keyed by content index in assets.ts)
1160
118
  const imageKey = `img-${i}`;
119
+ // Skip images that failed to load (not in imageMap) — they were already logged as warnings
120
+ if (!imageMap.has(imageKey)) {
121
+ continue;
122
+ }
1161
123
  if (el.float) {
1162
124
  const block = await measureFloatImageBlock(el, imageKey, imageMap, contentWidth, pageContentHeight, doc);
1163
125
  results.push(block);
@@ -1169,6 +131,10 @@ export async function measureAllBlocks(doc, contentWidth, imageMap, pageContentH
1169
131
  }
1170
132
  else if (el.type === 'svg') {
1171
133
  const svgKey = `svg-${i}`;
134
+ // Skip SVGs that failed to load (not in imageMap)
135
+ if (!imageMap.has(svgKey)) {
136
+ continue;
137
+ }
1172
138
  // Synthetic ImageElement reuses existing measurement logic (aspect ratio, clamping, height validation)
1173
139
  const syntheticImage = {
1174
140
  type: 'image',
@@ -1183,6 +149,16 @@ export async function measureAllBlocks(doc, contentWidth, imageMap, pageContentH
1183
149
  block.element = el;
1184
150
  results.push(block);
1185
151
  }
152
+ else if (el.type === 'float-group') {
153
+ const { measureFloatGroup } = await import('./measure-blocks.js');
154
+ const imageKey = `float-group-${i}`;
155
+ // Skip float-groups if their image failed to load
156
+ if (!imageMap.has(imageKey)) {
157
+ continue;
158
+ }
159
+ const block = await measureFloatGroup(el, imageKey, imageMap, contentWidth, pageContentHeight, doc, hyphenatorOpts);
160
+ results.push(block);
161
+ }
1186
162
  else {
1187
163
  const result = await measureBlock(el, contentWidth, doc, hyphenatorOpts);
1188
164
  if (Array.isArray(result)) {