pretext-pdf 0.5.3 → 0.8.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 (57) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/README.md +366 -276
  3. package/dist/assets.d.ts +5 -0
  4. package/dist/assets.d.ts.map +1 -1
  5. package/dist/assets.js +248 -43
  6. package/dist/assets.js.map +1 -1
  7. package/dist/errors.d.ts +1 -1
  8. package/dist/errors.d.ts.map +1 -1
  9. package/dist/errors.js.map +1 -1
  10. package/dist/fonts.d.ts.map +1 -1
  11. package/dist/fonts.js +88 -16
  12. package/dist/fonts.js.map +1 -1
  13. package/dist/index.d.ts +29 -2
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +35 -2
  16. package/dist/index.js.map +1 -1
  17. package/dist/markdown.d.ts +28 -0
  18. package/dist/markdown.d.ts.map +1 -0
  19. package/dist/markdown.js +222 -0
  20. package/dist/markdown.js.map +1 -0
  21. package/dist/measure-blocks.d.ts.map +1 -1
  22. package/dist/measure-blocks.js +347 -62
  23. package/dist/measure-blocks.js.map +1 -1
  24. package/dist/measure-text.d.ts.map +1 -1
  25. package/dist/measure-text.js +1 -8
  26. package/dist/measure-text.js.map +1 -1
  27. package/dist/measure.d.ts.map +1 -1
  28. package/dist/measure.js +13 -21
  29. package/dist/measure.js.map +1 -1
  30. package/dist/render-blocks.d.ts +4 -1
  31. package/dist/render-blocks.d.ts.map +1 -1
  32. package/dist/render-blocks.js +227 -105
  33. package/dist/render-blocks.js.map +1 -1
  34. package/dist/render-extras.d.ts.map +1 -1
  35. package/dist/render-extras.js +72 -71
  36. package/dist/render-extras.js.map +1 -1
  37. package/dist/render-utils.d.ts +9 -2
  38. package/dist/render-utils.d.ts.map +1 -1
  39. package/dist/render-utils.js +24 -13
  40. package/dist/render-utils.js.map +1 -1
  41. package/dist/render.d.ts.map +1 -1
  42. package/dist/render.js +27 -3
  43. package/dist/render.js.map +1 -1
  44. package/dist/rich-text.d.ts +0 -4
  45. package/dist/rich-text.d.ts.map +1 -1
  46. package/dist/rich-text.js +15 -9
  47. package/dist/rich-text.js.map +1 -1
  48. package/dist/templates.d.ts +79 -0
  49. package/dist/templates.d.ts.map +1 -0
  50. package/dist/templates.js +201 -0
  51. package/dist/templates.js.map +1 -0
  52. package/dist/types.d.ts +139 -5
  53. package/dist/types.d.ts.map +1 -1
  54. package/dist/validate.d.ts.map +1 -1
  55. package/dist/validate.js +241 -28
  56. package/dist/validate.js.map +1 -1
  57. package/package.json +57 -12
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import { rgb, degrees } from '@cantoo/pdf-lib';
6
6
  import { PretextPdfError } from './errors.js';
7
- import { drawJustifiedLine, addLinkAnnotation, addStickyNoteAnnotation, drawTextDecoration, toPdfY, resolveX, resolveTokens, hexToRgb, drawTabularText, } from './render-utils.js';
7
+ import { drawJustifiedLine, addLinkAnnotation, addStickyNoteAnnotation, drawTextDecoration, toPdfY, resolveX, resolveTokens, hexToRgb, drawTabularText, LINE_HEIGHT_BODY, LINE_HEIGHT_COMPACT, } from './render-utils.js';
8
8
  import { buildFontKey } from './measure.js';
9
9
  // ─── Text block rendering (paragraph + heading) ───────────────────────────────
10
10
  export function renderTextBlock(pdfPage, pagedBlock, geo, fontMap, pdfDoc) {
@@ -47,10 +47,14 @@ export function renderTextBlock(pdfPage, pagedBlock, geo, fontMap, pdfDoc) {
47
47
  borderWidth: 0,
48
48
  });
49
49
  }
50
- // Multi-column layout
50
+ // Multi-column layout — mirrors single-column features (smallCaps, letterSpacing, justify, decoration)
51
51
  const columnData = measuredBlock.columnData;
52
52
  if (columnData) {
53
- const { columnCount, columnGap, columnWidth, linesPerColumn } = columnData;
53
+ const { columnGap, columnWidth, linesPerColumn } = columnData;
54
+ const hasSmallCaps = textElement?.smallCaps === true;
55
+ const mcFontSize = hasSmallCaps ? measuredBlock.fontSize * 0.8 : measuredBlock.fontSize;
56
+ const hasTabular = textElement?.tabularNumbers === true;
57
+ const letterSpacing = (textElement?.letterSpacing ?? 0) > 0 ? textElement.letterSpacing : 0;
54
58
  for (let i = 0; i < lines.length; i++) {
55
59
  const line = lines[i];
56
60
  if (line.text === '')
@@ -61,22 +65,53 @@ export function renderTextBlock(pdfPage, pagedBlock, geo, fontMap, pdfDoc) {
61
65
  const absoluteYFromTop = lineYFromTop + geo.margins.top + geo.headerHeight;
62
66
  const pdfY = toPdfY(absoluteYFromTop, fontHeight, geo.pageHeight);
63
67
  const colX = geo.margins.left + colIdx * (columnWidth + columnGap);
64
- const trimmedText = line.text.trimEnd();
65
- const alignWidth = pdfFont.widthOfTextAtSize(trimmedText, measuredBlock.fontSize);
66
- const x = resolveX(align, colX, columnWidth, alignWidth);
67
- pdfPage.drawText(trimmedText, {
68
- x,
69
- y: pdfY,
70
- size: measuredBlock.fontSize,
71
- font: pdfFont,
72
- color: rgb(r, g, b),
73
- });
74
- // Phase 8G: Wire paragraph.url and heading.url for clickable links (multi-column)
68
+ let trimmedText = line.text.trimEnd();
69
+ if (hasSmallCaps)
70
+ trimmedText = trimmedText.toUpperCase();
71
+ // Last line of each column should not be force-justified (left-align instead)
72
+ const isLastLineInCol = lineInCol === linesPerColumn - 1 || i === lines.length - 1;
73
+ let drawX;
74
+ if (alignRaw === 'justify' && letterSpacing === 0 && !hasTabular) {
75
+ drawJustifiedLine(pdfPage, trimmedText, isLastLineInCol, colX, pdfY, columnWidth, mcFontSize, pdfFont, rgb(r, g, b));
76
+ drawX = colX;
77
+ }
78
+ else if (letterSpacing > 0) {
79
+ const alignWidth = pdfFont.widthOfTextAtSize(trimmedText, mcFontSize) + letterSpacing * (trimmedText.length - 1);
80
+ drawX = resolveX(align, colX, columnWidth, alignWidth);
81
+ let cx = drawX;
82
+ for (const ch of trimmedText) {
83
+ pdfPage.drawText(ch, { x: cx, y: pdfY, size: mcFontSize, font: pdfFont, color: rgb(r, g, b) });
84
+ cx += pdfFont.widthOfTextAtSize(ch, mcFontSize) + letterSpacing;
85
+ }
86
+ }
87
+ else if (hasTabular) {
88
+ const alignWidth = pdfFont.widthOfTextAtSize(trimmedText, mcFontSize);
89
+ drawX = resolveX(align, colX, columnWidth, alignWidth);
90
+ drawTabularText(pdfPage, trimmedText, drawX, pdfY, mcFontSize, pdfFont, rgb(r, g, b));
91
+ }
92
+ else {
93
+ const alignWidth = pdfFont.widthOfTextAtSize(trimmedText, mcFontSize);
94
+ drawX = resolveX(align, colX, columnWidth, alignWidth);
95
+ pdfPage.drawText(trimmedText, { x: drawX, y: pdfY, size: mcFontSize, font: pdfFont, color: rgb(r, g, b) });
96
+ }
97
+ // Text decoration (underline, strikethrough)
98
+ if ((element.type === 'paragraph' || element.type === 'heading') && (element.underline || element.strikethrough)) {
99
+ const lineWidth = pdfFont.widthOfTextAtSize(trimmedText, mcFontSize) + (letterSpacing > 0 ? letterSpacing * (trimmedText.length - 1) : 0);
100
+ drawTextDecoration(pdfPage, drawX, lineWidth, pdfY, mcFontSize, pdfFont, [r, g, b], { underline: element.underline ?? false, strikethrough: element.strikethrough ?? false });
101
+ }
102
+ // Hyperlink annotation
75
103
  if ((element.type === 'paragraph' || element.type === 'heading') && element.url) {
76
- const lineWidth = pdfFont.widthOfTextAtSize(trimmedText, measuredBlock.fontSize);
77
- addLinkAnnotation(pdfDoc, pdfPage, x, pdfY, lineWidth, measuredBlock.fontSize, element.url);
104
+ const lineWidth = pdfFont.widthOfTextAtSize(trimmedText, mcFontSize) + (letterSpacing > 0 ? letterSpacing * (trimmedText.length - 1) : 0);
105
+ addLinkAnnotation(pdfDoc, pdfPage, drawX, pdfY, lineWidth, mcFontSize, element.url);
78
106
  }
79
107
  }
108
+ // Sticky note annotation (once per block, not per line)
109
+ if (textElement?.annotation) {
110
+ const ann = textElement.annotation;
111
+ const absY = yFromTop + geo.margins.top + geo.headerHeight;
112
+ const annotPdfY = geo.pageHeight - absY;
113
+ addStickyNoteAnnotation(pdfDoc, pdfPage, geo.margins.left, annotPdfY, ann.contents, ann.author, ann.color, ann.open);
114
+ }
80
115
  return; // skip standard single-column path
81
116
  }
82
117
  // Single-column layout (standard path)
@@ -238,14 +273,15 @@ export function renderTable(pdfPage, pagedBlock, geo, fontMap) {
238
273
  // ── Pass 1: Cell backgrounds ──────────────────────────────────────────────
239
274
  let rowAbsY = chunkStartAbsY;
240
275
  for (const row of chunkRows) {
241
- const rowPdfY = toPdfY(rowAbsY, row.height, geo.pageHeight);
242
276
  let cellX = geo.margins.left;
243
277
  for (const cell of row.cells) {
244
- const bgColorHex = cell.bgColor ?? (row.isHeader ? headerBgColor : undefined);
245
- if (bgColorHex) {
246
- const [r, g, b] = hexToRgb(bgColorHex);
247
- // Use mergedWidth for colspan support
248
- pdfPage.drawRectangle({ x: cellX, y: rowPdfY, width: cell.mergedWidth, height: row.height, color: rgb(r, g, b), borderWidth: 0 });
278
+ if (!cell.isSpanPlaceholder) {
279
+ const cellRenderHeight = cell.spanHeight ?? row.height;
280
+ const bgColorHex = cell.bgColor ?? (row.isHeader ? headerBgColor : undefined);
281
+ if (bgColorHex) {
282
+ const [r, g, b] = hexToRgb(bgColorHex);
283
+ pdfPage.drawRectangle({ x: cellX, y: toPdfY(rowAbsY, cellRenderHeight, geo.pageHeight), width: cell.mergedWidth, height: cellRenderHeight, color: rgb(r, g, b), borderWidth: 0 });
284
+ }
249
285
  }
250
286
  cellX += cell.mergedWidth;
251
287
  }
@@ -267,50 +303,56 @@ export function renderTable(pdfPage, pagedBlock, geo, fontMap) {
267
303
  borderWidth,
268
304
  });
269
305
  // Internal horizontal lines (row separators, between rows, not at edges)
306
+ // Suppressed after rows that have a spanning cell crossing into the next row
270
307
  let lineAbsY = chunkStartAbsY;
271
308
  for (let ri = 0; ri < chunkRows.length - 1; ri++) {
272
309
  lineAbsY += chunkRows[ri].height;
273
- const linePdfY = geo.pageHeight - lineAbsY;
274
- pdfPage.drawLine({
275
- start: { x: geo.margins.left, y: linePdfY },
276
- end: { x: geo.margins.left + totalTableWidth, y: linePdfY },
277
- thickness: borderWidth,
278
- color: borderRgb,
279
- });
280
- }
281
- // Internal vertical lines (column separators, between columns, not at edges)
282
- // With colspan support: only draw lines at boundaries that are NOT spanned by merged cells
283
- // Each row may have different active boundaries due to different colspan patterns
284
- let colBoundaryX = geo.margins.left;
285
- for (let ci = 0; ci < columnWidths.length; ci++) {
286
- colBoundaryX += columnWidths[ci];
287
- // Check if this boundary (between column ci and ci+1) is active in ANY row
288
- const boundaryIndex = ci; // boundary at index ci is between columns ci and ci+1
289
- let isActive = false;
290
- for (const row of chunkRows) {
291
- if (row.activeBoundaries.includes(boundaryIndex)) {
292
- isActive = true;
293
- break;
294
- }
295
- }
296
- if (isActive && ci < columnWidths.length - 1) {
297
- const chunkTopPdfY = geo.pageHeight - chunkStartAbsY;
298
- const chunkBottomPdfY = geo.pageHeight - (chunkStartAbsY + totalChunkHeight);
310
+ if (!chunkRows[ri].hasRowspan) {
311
+ const linePdfY = geo.pageHeight - lineAbsY;
299
312
  pdfPage.drawLine({
300
- start: { x: colBoundaryX, y: chunkTopPdfY },
301
- end: { x: colBoundaryX, y: chunkBottomPdfY },
313
+ start: { x: geo.margins.left, y: linePdfY },
314
+ end: { x: geo.margins.left + totalTableWidth, y: linePdfY },
302
315
  thickness: borderWidth,
303
316
  color: borderRgb,
304
317
  });
305
318
  }
306
319
  }
320
+ // Internal vertical lines (column separators, between columns, not at edges)
321
+ // Draw per-row segments: a boundary absent from a row's activeBoundaries means a merged cell
322
+ // spans it — a full-chunk line would cut through it. Per-row drawing preserves colspan correctness.
323
+ // Pre-compute X positions once; convert each row's activeBoundaries to a Set for O(1) lookup.
324
+ const colBoundaryXPositions = [];
325
+ let bx = geo.margins.left;
326
+ for (let ci = 0; ci < columnWidths.length - 1; ci++) {
327
+ bx += columnWidths[ci];
328
+ colBoundaryXPositions.push(bx);
329
+ }
330
+ const rowBoundarySets = chunkRows.map(row => new Set(row.activeBoundaries));
331
+ let vertRowAbsY = chunkStartAbsY;
332
+ for (let ri = 0; ri < chunkRows.length; ri++) {
333
+ const row = chunkRows[ri];
334
+ const rowBoundarySet = rowBoundarySets[ri];
335
+ const rowTopPdfY = geo.pageHeight - vertRowAbsY;
336
+ const rowBottomPdfY = geo.pageHeight - (vertRowAbsY + row.height);
337
+ for (let ci = 0; ci < colBoundaryXPositions.length; ci++) {
338
+ if (rowBoundarySet.has(ci)) {
339
+ pdfPage.drawLine({
340
+ start: { x: colBoundaryXPositions[ci], y: rowTopPdfY },
341
+ end: { x: colBoundaryXPositions[ci], y: rowBottomPdfY },
342
+ thickness: borderWidth,
343
+ color: borderRgb,
344
+ });
345
+ }
346
+ }
347
+ vertRowAbsY += row.height;
348
+ }
307
349
  }
308
350
  // ── Pass 3: Cell text ─────────────────────────────────────────────────────
309
351
  rowAbsY = chunkStartAbsY;
310
352
  for (const row of chunkRows) {
311
353
  let cellX = geo.margins.left;
312
354
  for (const cell of row.cells) {
313
- if (cell.lines.length > 0) {
355
+ if (!cell.isSpanPlaceholder && cell.lines.length > 0) {
314
356
  const pdfFont = fontMap.get(cell.fontKey);
315
357
  if (!pdfFont) {
316
358
  throw new PretextPdfError('FONT_NOT_LOADED', `Table cell font "${cell.fontKey}" not found in fontMap. This is a bug — font validation should have caught this.`);
@@ -318,13 +360,16 @@ export function renderTable(pdfPage, pagedBlock, geo, fontMap) {
318
360
  const fontHeight = pdfFont.heightAtSize(cell.fontSize);
319
361
  const [r, g, b] = hexToRgb(cell.color);
320
362
  const textAreaX = cellX + cellPaddingH;
321
- // Use mergedWidth for colspan support
322
363
  const textAreaWidth = cell.mergedWidth - 2 * cellPaddingH;
364
+ // For rowspan cells, vertically center within the full spanHeight
365
+ const cellRenderHeight = cell.spanHeight ?? row.height;
366
+ const totalTextHeight = cell.lines.length * cell.lineHeight;
367
+ const verticalOffset = Math.max(0, (cellRenderHeight - totalTextHeight - 2 * cellPaddingV) / 2);
323
368
  for (let li = 0; li < cell.lines.length; li++) {
324
369
  const line = cell.lines[li];
325
370
  if (line.text === '')
326
371
  continue;
327
- const lineYFromPageTop = rowAbsY + cellPaddingV + li * cell.lineHeight;
372
+ const lineYFromPageTop = rowAbsY + cellPaddingV + verticalOffset + li * cell.lineHeight;
328
373
  const pdfY = toPdfY(lineYFromPageTop, fontHeight, geo.pageHeight);
329
374
  const trimmedText = line.text.trimEnd();
330
375
  const lineWidth = pdfFont.widthOfTextAtSize(trimmedText, cell.fontSize);
@@ -378,26 +423,55 @@ export function renderFloatBlock(pdfPage, pagedBlock, geo, fontMap, imageMap, pd
378
423
  width: fd.imageRenderWidth,
379
424
  height: fd.imageRenderHeight,
380
425
  });
381
- // Draw text lines
382
- const pdfFont = fontMap.get(fd.textFontKey);
383
- if (!pdfFont)
384
- throw new PretextPdfError('FONT_NOT_LOADED', `Float text font key "${fd.textFontKey}" not found in fontMap. This is a bug — font loading should have caught this.`);
385
- const fontHeight = pdfFont.heightAtSize(fd.textFontSize);
386
- const [r, g, b] = hexToRgb(fd.textColor);
426
+ // Draw text lines (rich or plain)
387
427
  const textBaseX = geo.margins.left + fd.textColX;
388
- for (let i = 0; i < fd.textLines.length; i++) {
389
- const line = fd.textLines[i];
390
- if (!line.text || line.text === '')
391
- continue;
392
- const lineAbsY = baseAbsY + (i * fd.textLineHeight);
393
- const pdfY = toPdfY(lineAbsY, fontHeight, geo.pageHeight);
394
- pdfPage.drawText(line.text.trimEnd(), {
395
- x: textBaseX,
396
- y: pdfY,
397
- size: fd.textFontSize,
398
- font: pdfFont,
399
- color: rgb(r, g, b),
400
- });
428
+ if (fd.richFloatLines && fd.richFloatLines.length > 0) {
429
+ let cumY = 0;
430
+ for (const richLine of fd.richFloatLines) {
431
+ const lineAbsY = baseAbsY + cumY;
432
+ for (const fragment of richLine.fragments) {
433
+ if (!fragment.text || fragment.text.trim() === '')
434
+ continue;
435
+ const pdfFont = fontMap.get(fragment.fontKey);
436
+ if (!pdfFont)
437
+ throw new PretextPdfError('FONT_NOT_LOADED', `Float rich text font "${fragment.fontKey}" not found in fontMap.`);
438
+ const fontHeight = pdfFont.heightAtSize(fragment.fontSize);
439
+ const [r, g, b] = hexToRgb(fragment.color);
440
+ const drawX = textBaseX + fragment.x;
441
+ const pdfY = toPdfY(lineAbsY, fontHeight, geo.pageHeight) + (fragment.yOffset ?? 0);
442
+ const drawText = fragment.text.trimEnd();
443
+ if (fragment.letterSpacing && fragment.letterSpacing > 0) {
444
+ let cx = drawX;
445
+ for (const ch of drawText) {
446
+ pdfPage.drawText(ch, { x: cx, y: pdfY, size: fragment.fontSize, font: pdfFont, color: rgb(r, g, b) });
447
+ cx += pdfFont.widthOfTextAtSize(ch, fragment.fontSize) + fragment.letterSpacing;
448
+ }
449
+ }
450
+ else {
451
+ pdfPage.drawText(drawText, { x: drawX, y: pdfY, size: fragment.fontSize, font: pdfFont, color: rgb(r, g, b) });
452
+ }
453
+ const fragWidth = pdfFont.widthOfTextAtSize(drawText, fragment.fontSize);
454
+ drawTextDecoration(pdfPage, drawX, fragWidth, pdfY, fragment.fontSize, pdfFont, [r, g, b], { underline: fragment.underline ?? false, strikethrough: fragment.strikethrough ?? false });
455
+ if (fragment.url)
456
+ addLinkAnnotation(pdfDoc, pdfPage, drawX, pdfY, fragWidth, fragment.fontSize, fragment.url);
457
+ }
458
+ cumY += richLine.lineHeight;
459
+ }
460
+ }
461
+ else {
462
+ const pdfFont = fontMap.get(fd.textFontKey);
463
+ if (!pdfFont)
464
+ throw new PretextPdfError('FONT_NOT_LOADED', `Float text font key "${fd.textFontKey}" not found in fontMap. This is a bug — font loading should have caught this.`);
465
+ const fontHeight = pdfFont.heightAtSize(fd.textFontSize);
466
+ const [r, g, b] = hexToRgb(fd.textColor);
467
+ for (let i = 0; i < fd.textLines.length; i++) {
468
+ const line = fd.textLines[i];
469
+ if (line.text === '')
470
+ continue;
471
+ const lineAbsY = baseAbsY + (i * fd.textLineHeight);
472
+ const pdfY = toPdfY(lineAbsY, fontHeight, geo.pageHeight);
473
+ pdfPage.drawText(line.text.trimEnd(), { x: textBaseX, y: pdfY, size: fd.textFontSize, font: pdfFont, color: rgb(r, g, b) });
474
+ }
401
475
  }
402
476
  }
403
477
  // ─── Float group block rendering ──────────────────────────────────────────────
@@ -427,7 +501,7 @@ export function renderFloatGroup(pdfPage, pagedBlock, geo, fontMap, imageMap, pd
427
501
  // Draw plain lines (plain-text fallback for rich-paragraphs)
428
502
  for (let i = 0; i < textItem.lines.length; i++) {
429
503
  const line = textItem.lines[i];
430
- if (!line.text || line.text === '')
504
+ if (line.text === '')
431
505
  continue;
432
506
  const lineAbsY = baseAbsY + textItem.yOffsetFromTop + (i * textItem.lineHeight);
433
507
  const pdfY = toPdfY(lineAbsY, fontHeight, geo.pageHeight);
@@ -495,17 +569,46 @@ export function renderCodeBlock(pdfPage, pagedBlock, geo, fontMap) {
495
569
  const fontHeight = pdfFont.heightAtSize(fontSize);
496
570
  const [r, g, b] = hexToRgb(textColorHex);
497
571
  const textX = geo.margins.left + padding;
498
- for (let i = 0; i < lines.length; i++) {
499
- const line = lines[i];
500
- const lineYFromPageTop = boxAbsY + paddingTop + i * lineHeight;
501
- const pdfY = toPdfY(lineYFromPageTop, fontHeight, geo.pageHeight);
502
- pdfPage.drawText(line.text.trimEnd(), {
503
- x: textX,
504
- y: pdfY,
505
- size: fontSize,
506
- font: pdfFont,
507
- color: rgb(r, g, b),
508
- });
572
+ // Syntax highlighting: tokenize if language is set and highlight.js is available
573
+ const highlightTokens = measuredBlock.codeHighlightTokens;
574
+ if (highlightTokens && highlightTokens.length > 0) {
575
+ // Render with per-token colors
576
+ for (let i = 0; i < lines.length; i++) {
577
+ const lineYFromPageTop = boxAbsY + paddingTop + i * lineHeight;
578
+ const pdfY = toPdfY(lineYFromPageTop, fontHeight, geo.pageHeight);
579
+ const lineTokens = highlightTokens[startLine + i];
580
+ if (!lineTokens)
581
+ continue;
582
+ let curX = textX;
583
+ for (const token of lineTokens) {
584
+ if (!token.text)
585
+ continue;
586
+ const [tr, tg, tb] = hexToRgb(token.color);
587
+ pdfPage.drawText(token.text, {
588
+ x: curX,
589
+ y: pdfY,
590
+ size: fontSize,
591
+ font: pdfFont,
592
+ color: rgb(tr, tg, tb),
593
+ });
594
+ curX += pdfFont.widthOfTextAtSize(token.text, fontSize);
595
+ }
596
+ }
597
+ }
598
+ else {
599
+ // Plain text rendering (no highlighting)
600
+ for (let i = 0; i < lines.length; i++) {
601
+ const line = lines[i];
602
+ const lineYFromPageTop = boxAbsY + paddingTop + i * lineHeight;
603
+ const pdfY = toPdfY(lineYFromPageTop, fontHeight, geo.pageHeight);
604
+ pdfPage.drawText(line.text.trimEnd(), {
605
+ x: textX,
606
+ y: pdfY,
607
+ size: fontSize,
608
+ font: pdfFont,
609
+ color: rgb(r, g, b),
610
+ });
611
+ }
509
612
  }
510
613
  }
511
614
  // ─── Blockquote rendering ─────────────────────────────────────────────────────
@@ -639,7 +742,7 @@ export function renderCallout(pdfPage, pagedBlock, geo, fontMap) {
639
742
  const boldFontKey = measuredBlock.fontKey.replace(/-400-/, '-700-');
640
743
  const titleFont = fontMap.get(boldFontKey) ?? font;
641
744
  const [tR, tG, tB] = hexToRgb(titleColor);
642
- const titlePdfY = geo.pageHeight - currentAbsY - fontHeight - (fs * 1.4 - fs) / 2;
745
+ const titlePdfY = toPdfY(currentAbsY + (fs * LINE_HEIGHT_COMPACT - fs) / 2, fontHeight, geo.pageHeight);
643
746
  pdfPage.drawText(titleText, {
644
747
  x: geo.margins.left + paddingH,
645
748
  y: titlePdfY,
@@ -657,7 +760,7 @@ export function renderCallout(pdfPage, pagedBlock, geo, fontMap) {
657
760
  currentAbsY += lh;
658
761
  continue;
659
762
  }
660
- const linePdfY = geo.pageHeight - currentAbsY - fontHeight - (lh - fs) / 2;
763
+ const linePdfY = toPdfY(currentAbsY + (lh - fs) / 2, fontHeight, geo.pageHeight);
661
764
  pdfPage.drawText(line.text.trimEnd(), {
662
765
  x: geo.margins.left + paddingH,
663
766
  y: linePdfY,
@@ -672,6 +775,7 @@ export function renderCallout(pdfPage, pagedBlock, geo, fontMap) {
672
775
  export function renderRichParagraph(pdfPage, pagedBlock, geo, fontMap, pdfDoc, footnoteNumbering) {
673
776
  const { measuredBlock, startLine, endLine, yFromTop } = pagedBlock;
674
777
  const { element, richLines, lineHeight, fontSize } = measuredBlock;
778
+ const tabularNumbers = element.type === 'rich-paragraph' && element.tabularNumbers === true;
675
779
  if (!richLines || richLines.length === 0)
676
780
  return;
677
781
  // Only render the lines on this page chunk
@@ -734,14 +838,23 @@ export function renderRichParagraph(pdfPage, pagedBlock, geo, fontMap, pdfDoc, f
734
838
  }
735
839
  const fragmentPdfY = basePdfY + (fragment.yOffset ?? 0);
736
840
  const drawText = fragment.text.trimEnd();
737
- pdfPage.drawText(drawText, {
738
- x: drawX,
739
- y: fragmentPdfY,
740
- size: fragment.fontSize,
741
- font: pdfFont,
742
- color: rgb(r, g, b),
743
- });
744
- const fragWidth = pdfFont.widthOfTextAtSize(drawText, fragment.fontSize);
841
+ let fragWidth;
842
+ if (fragment.letterSpacing && fragment.letterSpacing > 0) {
843
+ let cx = drawX;
844
+ for (const ch of drawText) {
845
+ pdfPage.drawText(ch, { x: cx, y: fragmentPdfY, size: fragment.fontSize, font: pdfFont, color: rgb(r, g, b) });
846
+ cx += pdfFont.widthOfTextAtSize(ch, fragment.fontSize) + fragment.letterSpacing;
847
+ }
848
+ fragWidth = pdfFont.widthOfTextAtSize(drawText, fragment.fontSize) + fragment.letterSpacing * (drawText.length - 1);
849
+ }
850
+ else if (tabularNumbers) {
851
+ drawTabularText(pdfPage, drawText, drawX, fragmentPdfY, fragment.fontSize, pdfFont, rgb(r, g, b));
852
+ fragWidth = pdfFont.widthOfTextAtSize(drawText, fragment.fontSize);
853
+ }
854
+ else {
855
+ pdfPage.drawText(drawText, { x: drawX, y: fragmentPdfY, size: fragment.fontSize, font: pdfFont, color: rgb(r, g, b) });
856
+ fragWidth = pdfFont.widthOfTextAtSize(drawText, fragment.fontSize);
857
+ }
745
858
  drawTextDecoration(pdfPage, drawX, fragWidth, fragmentPdfY, fragment.fontSize, pdfFont, [r, g, b], { underline: fragment.underline ?? false, strikethrough: fragment.strikethrough ?? false });
746
859
  if (fragment.url) {
747
860
  addLinkAnnotation(pdfDoc, pdfPage, drawX, fragmentPdfY, fragWidth, fragment.fontSize, fragment.url);
@@ -786,14 +899,23 @@ export function renderRichParagraph(pdfPage, pagedBlock, geo, fontMap, pdfDoc, f
786
899
  }
787
900
  const fragmentPdfY = basePdfY + (fragment.yOffset ?? 0);
788
901
  const drawText = fragment.text.trimEnd();
789
- pdfPage.drawText(drawText, {
790
- x: drawX,
791
- y: fragmentPdfY,
792
- size: fragment.fontSize,
793
- font: pdfFont,
794
- color: rgb(r, g, b),
795
- });
796
- const fragWidth = pdfFont.widthOfTextAtSize(drawText, fragment.fontSize);
902
+ let fragWidth;
903
+ if (fragment.letterSpacing && fragment.letterSpacing > 0) {
904
+ let cx = drawX;
905
+ for (const ch of drawText) {
906
+ pdfPage.drawText(ch, { x: cx, y: fragmentPdfY, size: fragment.fontSize, font: pdfFont, color: rgb(r, g, b) });
907
+ cx += pdfFont.widthOfTextAtSize(ch, fragment.fontSize) + fragment.letterSpacing;
908
+ }
909
+ fragWidth = pdfFont.widthOfTextAtSize(drawText, fragment.fontSize) + fragment.letterSpacing * (drawText.length - 1);
910
+ }
911
+ else if (tabularNumbers) {
912
+ drawTabularText(pdfPage, drawText, drawX, fragmentPdfY, fragment.fontSize, pdfFont, rgb(r, g, b));
913
+ fragWidth = pdfFont.widthOfTextAtSize(drawText, fragment.fontSize);
914
+ }
915
+ else {
916
+ pdfPage.drawText(drawText, { x: drawX, y: fragmentPdfY, size: fragment.fontSize, font: pdfFont, color: rgb(r, g, b) });
917
+ fragWidth = pdfFont.widthOfTextAtSize(drawText, fragment.fontSize);
918
+ }
797
919
  drawTextDecoration(pdfPage, drawX, fragWidth, fragmentPdfY, fragment.fontSize, pdfFont, [r, g, b], { underline: fragment.underline ?? false, strikethrough: fragment.strikethrough ?? false });
798
920
  if (fragment.url) {
799
921
  addLinkAnnotation(pdfDoc, pdfPage, drawX, fragmentPdfY, fragWidth, fragment.fontSize, fragment.url);
@@ -821,7 +943,7 @@ export function renderFootnoteZone(pdfPage, footnoteItems, zoneHeight, fontMap,
821
943
  let currentPdfY = separatorY - SEPARATOR_PADDING;
822
944
  for (const { def, number } of footnoteItems) {
823
945
  const fontSize = def.fontSize ?? Math.max(8, defaultFontSize - 2);
824
- const lineHeight = fontSize * 1.5;
946
+ const lineHeight = fontSize * LINE_HEIGHT_BODY;
825
947
  const fontFamily = def.fontFamily ?? doc.defaultFont ?? 'Inter';
826
948
  const fontKey = buildFontKey(fontFamily, 400, 'normal');
827
949
  const pdfFont = fontMap.get(fontKey);
@@ -841,8 +963,8 @@ export function renderFootnoteZone(pdfPage, footnoteItems, zoneHeight, fontMap,
841
963
  }
842
964
  }
843
965
  // ─── Header / Footer rendering ────────────────────────────────────────────────
844
- export function renderHeaderFooter(pdfPage, spec, pageNumber, totalPages, geo, fontMap, position) {
845
- const text = resolveTokens(spec.text, pageNumber, totalPages);
966
+ export function renderHeaderFooter(pdfPage, spec, pageNumber, totalPages, geo, fontMap, position, extra) {
967
+ const text = resolveTokens(spec.text, pageNumber, totalPages, extra);
846
968
  const fontSize = spec.fontSize ?? 10;
847
969
  const align = spec.align ?? 'center';
848
970
  const fontKey = `${spec.fontFamily ?? 'Inter'}-${spec.fontWeight ?? 400}-normal`;