pretext-pdf 0.1.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 (56) hide show
  1. package/CHANGELOG.md +242 -0
  2. package/LICENSE +21 -0
  3. package/README.md +402 -0
  4. package/dist/assets.d.ts +14 -0
  5. package/dist/assets.d.ts.map +1 -0
  6. package/dist/assets.js +182 -0
  7. package/dist/assets.js.map +1 -0
  8. package/dist/builder.d.ts +53 -0
  9. package/dist/builder.d.ts.map +1 -0
  10. package/dist/builder.js +129 -0
  11. package/dist/builder.js.map +1 -0
  12. package/dist/errors.d.ts +7 -0
  13. package/dist/errors.d.ts.map +1 -0
  14. package/dist/errors.js +13 -0
  15. package/dist/errors.js.map +1 -0
  16. package/dist/fonts.d.ts +21 -0
  17. package/dist/fonts.d.ts.map +1 -0
  18. package/dist/fonts.js +310 -0
  19. package/dist/fonts.js.map +1 -0
  20. package/dist/index.d.ts +29 -0
  21. package/dist/index.d.ts.map +1 -0
  22. package/dist/index.js +154 -0
  23. package/dist/index.js.map +1 -0
  24. package/dist/measure.d.ts +53 -0
  25. package/dist/measure.d.ts.map +1 -0
  26. package/dist/measure.js +1029 -0
  27. package/dist/measure.js.map +1 -0
  28. package/dist/node-polyfill.d.ts +7 -0
  29. package/dist/node-polyfill.d.ts.map +1 -0
  30. package/dist/node-polyfill.js +82 -0
  31. package/dist/node-polyfill.js.map +1 -0
  32. package/dist/page-sizes.d.ts +13 -0
  33. package/dist/page-sizes.d.ts.map +1 -0
  34. package/dist/page-sizes.js +24 -0
  35. package/dist/page-sizes.js.map +1 -0
  36. package/dist/paginate.d.ts +15 -0
  37. package/dist/paginate.d.ts.map +1 -0
  38. package/dist/paginate.js +395 -0
  39. package/dist/paginate.js.map +1 -0
  40. package/dist/render.d.ts +12 -0
  41. package/dist/render.d.ts.map +1 -0
  42. package/dist/render.js +1028 -0
  43. package/dist/render.js.map +1 -0
  44. package/dist/rich-text.d.ts +14 -0
  45. package/dist/rich-text.d.ts.map +1 -0
  46. package/dist/rich-text.js +183 -0
  47. package/dist/rich-text.js.map +1 -0
  48. package/dist/types.d.ts +697 -0
  49. package/dist/types.d.ts.map +1 -0
  50. package/dist/types.js +2 -0
  51. package/dist/types.js.map +1 -0
  52. package/dist/validate.d.ts +3 -0
  53. package/dist/validate.d.ts.map +1 -0
  54. package/dist/validate.js +786 -0
  55. package/dist/validate.js.map +1 -0
  56. package/package.json +79 -0
package/dist/render.js ADDED
@@ -0,0 +1,1028 @@
1
+ import { PDFName, PDFString, PDFNull, rgb, degrees } from 'pdf-lib';
2
+ import { PretextPdfError } from './errors.js';
3
+ /**
4
+ * Stage 5: Render.
5
+ * Takes the paginated document + pre-initialized pdfDoc (with fonts already embedded)
6
+ * and produces the final PDF bytes.
7
+ *
8
+ * pdfDoc is NOT created here — it comes from index.ts with fonts already embedded.
9
+ * imageMap contains pre-embedded PDFImage instances.
10
+ */
11
+ export async function renderDocument(paginatedDoc, doc, fontMap, imageMap, pdfDoc, geo) {
12
+ const { pageWidth, pageHeight, margins, contentWidth } = geo;
13
+ for (const renderedPage of paginatedDoc.pages) {
14
+ const pdfPage = pdfDoc.addPage([pageWidth, pageHeight]);
15
+ const pageNumber = renderedPage.pageIndex + 1;
16
+ const totalPages = paginatedDoc.totalPages;
17
+ // Render watermark (behind content)
18
+ renderWatermark(pdfPage, doc, fontMap, imageMap, geo);
19
+ // Render content blocks
20
+ for (const pagedBlock of renderedPage.blocks) {
21
+ renderBlock(pdfPage, pagedBlock, geo, fontMap, imageMap, pdfDoc);
22
+ }
23
+ // Render header
24
+ if (doc.header) {
25
+ renderHeaderFooter(pdfPage, doc.header, pageNumber, totalPages, geo, fontMap, 'header');
26
+ }
27
+ // Render footer
28
+ if (doc.footer) {
29
+ renderHeaderFooter(pdfPage, doc.footer, pageNumber, totalPages, geo, fontMap, 'footer');
30
+ }
31
+ }
32
+ if (doc.bookmarks !== false) {
33
+ buildOutlineTree(pdfDoc, paginatedDoc.headings, doc.bookmarks);
34
+ }
35
+ return pdfDoc.save({ useObjectStreams: false });
36
+ }
37
+ // ─── Block routing ────────────────────────────────────────────────────────────
38
+ function renderBlock(pdfPage, pagedBlock, geo, fontMap, imageMap, pdfDoc) {
39
+ const { measuredBlock } = pagedBlock;
40
+ const { element } = measuredBlock;
41
+ switch (element.type) {
42
+ case 'spacer':
43
+ return; // No visual output
44
+ case 'paragraph':
45
+ case 'heading':
46
+ renderTextBlock(pdfPage, pagedBlock, geo, fontMap, pdfDoc);
47
+ return;
48
+ case 'list':
49
+ // List items are flattened MeasuredBlocks with listItemData
50
+ if (measuredBlock.listItemData) {
51
+ renderListItem(pdfPage, pagedBlock, geo, fontMap);
52
+ }
53
+ return;
54
+ case 'table':
55
+ renderTable(pdfPage, pagedBlock, geo, fontMap);
56
+ return;
57
+ case 'svg':
58
+ case 'image':
59
+ renderImage(pdfPage, pagedBlock, geo, imageMap);
60
+ return;
61
+ case 'hr':
62
+ renderHR(pdfPage, pagedBlock, geo);
63
+ return;
64
+ case 'page-break':
65
+ return; // No visual output — page break is handled by paginator
66
+ case 'code':
67
+ renderCodeBlock(pdfPage, pagedBlock, geo, fontMap);
68
+ return;
69
+ case 'rich-paragraph':
70
+ renderRichParagraph(pdfPage, pagedBlock, geo, fontMap, pdfDoc);
71
+ return;
72
+ case 'blockquote':
73
+ renderBlockquote(pdfPage, pagedBlock, geo, fontMap);
74
+ return;
75
+ case 'toc-entry':
76
+ renderTocEntry(pdfPage, pagedBlock, geo, fontMap);
77
+ return;
78
+ }
79
+ }
80
+ // ─── Text block rendering (paragraph + heading) ───────────────────────────────
81
+ function renderTextBlock(pdfPage, pagedBlock, geo, fontMap, pdfDoc) {
82
+ const { measuredBlock, startLine, endLine, yFromTop } = pagedBlock;
83
+ const { element } = measuredBlock;
84
+ const lines = measuredBlock.lines.slice(startLine, endLine);
85
+ if (lines.length === 0)
86
+ return;
87
+ const pdfFont = fontMap.get(measuredBlock.fontKey);
88
+ if (!pdfFont) {
89
+ throw new PretextPdfError('FONT_NOT_LOADED', `Font "${measuredBlock.fontKey}" not found in fontMap. This is a bug — font validation should have caught this.`);
90
+ }
91
+ const colorHex = (element.type === 'paragraph' || element.type === 'heading')
92
+ ? (element.color ?? '#000000')
93
+ : '#000000';
94
+ const [r, g, b] = hexToRgb(colorHex);
95
+ const alignRaw = (element.type === 'paragraph' || element.type === 'heading')
96
+ ? (element.align ?? (measuredBlock.isRTL ? 'right' : 'left'))
97
+ : 'left';
98
+ // For resolveX, treat 'justify' as 'left' (justify is handled by drawJustifiedLine)
99
+ const align = alignRaw === 'justify' ? 'left' : alignRaw;
100
+ const fontHeight = pdfFont.heightAtSize(measuredBlock.fontSize);
101
+ // Draw background color for paragraph and heading (if set)
102
+ if ((element.type === 'paragraph' || element.type === 'heading') && element.bgColor) {
103
+ const columnData = measuredBlock.columnData;
104
+ const chunkHeight = columnData
105
+ ? columnData.linesPerColumn * measuredBlock.lineHeight
106
+ : lines.length * measuredBlock.lineHeight;
107
+ const boxAbsY = yFromTop + geo.margins.top + geo.headerHeight;
108
+ const boxPdfY = toPdfY(boxAbsY, chunkHeight, geo.pageHeight);
109
+ const [bgR, bgG, bgB] = hexToRgb(element.bgColor);
110
+ pdfPage.drawRectangle({
111
+ x: geo.margins.left,
112
+ y: boxPdfY,
113
+ width: geo.contentWidth,
114
+ height: chunkHeight,
115
+ color: rgb(bgR, bgG, bgB),
116
+ borderWidth: 0,
117
+ });
118
+ }
119
+ // Multi-column layout
120
+ const columnData = measuredBlock.columnData;
121
+ if (columnData) {
122
+ const { columnCount, columnGap, columnWidth, linesPerColumn } = columnData;
123
+ for (let i = 0; i < lines.length; i++) {
124
+ const line = lines[i];
125
+ if (line.text === '')
126
+ continue;
127
+ const colIdx = Math.floor(i / linesPerColumn);
128
+ const lineInCol = i % linesPerColumn;
129
+ const lineYFromTop = yFromTop + (lineInCol * measuredBlock.lineHeight);
130
+ const absoluteYFromTop = lineYFromTop + geo.margins.top + geo.headerHeight;
131
+ const pdfY = toPdfY(absoluteYFromTop, fontHeight, geo.pageHeight);
132
+ const colX = geo.margins.left + colIdx * (columnWidth + columnGap);
133
+ const trimmedText = line.text.trimEnd();
134
+ const alignWidth = pdfFont.widthOfTextAtSize(trimmedText, measuredBlock.fontSize);
135
+ const x = resolveX(align, colX, columnWidth, alignWidth);
136
+ pdfPage.drawText(trimmedText, {
137
+ x,
138
+ y: pdfY,
139
+ size: measuredBlock.fontSize,
140
+ font: pdfFont,
141
+ color: rgb(r, g, b),
142
+ });
143
+ // Phase 8G: Wire paragraph.url and heading.url for clickable links (multi-column)
144
+ if ((element.type === 'paragraph' || element.type === 'heading') && element.url) {
145
+ const lineWidth = pdfFont.widthOfTextAtSize(trimmedText, measuredBlock.fontSize);
146
+ addLinkAnnotation(pdfDoc, pdfPage, x, pdfY, lineWidth, measuredBlock.fontSize, element.url);
147
+ }
148
+ }
149
+ return; // skip standard single-column path
150
+ }
151
+ // Single-column layout (standard path)
152
+ for (let i = 0; i < lines.length; i++) {
153
+ const line = lines[i];
154
+ if (line.text === '')
155
+ continue; // empty lines from \n\n — occupy space, draw nothing
156
+ const lineYFromTop = yFromTop + (i * measuredBlock.lineHeight);
157
+ const absoluteYFromTop = lineYFromTop + geo.margins.top + geo.headerHeight;
158
+ const pdfY = toPdfY(absoluteYFromTop, fontHeight, geo.pageHeight);
159
+ const trimmedText = line.text.trimEnd();
160
+ const isLastLine = i === lines.length - 1;
161
+ let drawX;
162
+ if (alignRaw === 'justify') {
163
+ drawJustifiedLine(pdfPage, trimmedText, isLastLine, geo.margins.left, pdfY, geo.contentWidth, measuredBlock.fontSize, pdfFont, rgb(r, g, b));
164
+ drawX = geo.margins.left; // used for decoration below
165
+ }
166
+ else {
167
+ const alignWidth = pdfFont.widthOfTextAtSize(trimmedText, measuredBlock.fontSize);
168
+ drawX = resolveX(align, geo.margins.left, geo.contentWidth, alignWidth);
169
+ pdfPage.drawText(trimmedText, {
170
+ x: drawX,
171
+ y: pdfY,
172
+ size: measuredBlock.fontSize,
173
+ font: pdfFont,
174
+ color: rgb(r, g, b),
175
+ });
176
+ }
177
+ if ((element.type === 'paragraph' || element.type === 'heading') && (element.underline || element.strikethrough)) {
178
+ const lineWidth = pdfFont.widthOfTextAtSize(trimmedText, measuredBlock.fontSize);
179
+ drawTextDecoration(pdfPage, drawX, lineWidth, pdfY, measuredBlock.fontSize, pdfFont, [r, g, b], { underline: element.underline ?? false, strikethrough: element.strikethrough ?? false });
180
+ }
181
+ // Phase 8G: Wire paragraph.url and heading.url for clickable links
182
+ if ((element.type === 'paragraph' || element.type === 'heading') && element.url) {
183
+ const lineWidth = pdfFont.widthOfTextAtSize(trimmedText, measuredBlock.fontSize);
184
+ addLinkAnnotation(pdfDoc, pdfPage, drawX, pdfY, lineWidth, measuredBlock.fontSize, element.url);
185
+ }
186
+ }
187
+ }
188
+ // ─── List item rendering ──────────────────────────────────────────────────────
189
+ function renderListItem(pdfPage, pagedBlock, geo, fontMap) {
190
+ const { measuredBlock, startLine, endLine, yFromTop } = pagedBlock;
191
+ const listItemData = measuredBlock.listItemData;
192
+ const lines = measuredBlock.lines.slice(startLine, endLine);
193
+ if (lines.length === 0)
194
+ return;
195
+ const pdfFont = fontMap.get(measuredBlock.fontKey);
196
+ if (!pdfFont) {
197
+ throw new PretextPdfError('FONT_NOT_LOADED', `Font "${measuredBlock.fontKey}" not found in fontMap. This is a bug — font validation should have caught this.`);
198
+ }
199
+ const fontHeight = pdfFont.heightAtSize(measuredBlock.fontSize);
200
+ const [cr, cg, cb] = hexToRgb(listItemData.color);
201
+ // RTL support: mirror list layout if detected
202
+ const isRTL = measuredBlock.isRTL ?? false;
203
+ let textStartX;
204
+ let textAreaWidth;
205
+ if (isRTL) {
206
+ // RTL: marker on the right, text area on the left
207
+ textAreaWidth = geo.contentWidth - listItemData.indent - listItemData.markerWidth;
208
+ textStartX = geo.margins.left + listItemData.indent;
209
+ }
210
+ else {
211
+ // LTR: marker on the left, text area on the right
212
+ textStartX = geo.margins.left + listItemData.indent + listItemData.markerWidth;
213
+ textAreaWidth = geo.contentWidth - listItemData.indent - listItemData.markerWidth;
214
+ }
215
+ // Draw marker on the first line of this item (only if startLine === 0)
216
+ // If startLine > 0, the item continued from a previous page — no marker
217
+ if (startLine === 0) {
218
+ const markerText = listItemData.marker;
219
+ const markerMeasuredWidth = pdfFont.widthOfTextAtSize(markerText, measuredBlock.fontSize);
220
+ let markerX;
221
+ if (isRTL) {
222
+ // RTL: marker on the right, right-aligned within marker column
223
+ markerX = geo.margins.left + geo.contentWidth - listItemData.indent - markerMeasuredWidth;
224
+ }
225
+ else {
226
+ // LTR: marker on the left, right-aligned within marker column
227
+ markerX = geo.margins.left + listItemData.indent + listItemData.markerWidth - markerMeasuredWidth;
228
+ }
229
+ const firstLineAbsY = yFromTop + geo.margins.top + geo.headerHeight;
230
+ const markerPdfY = toPdfY(firstLineAbsY, fontHeight, geo.pageHeight);
231
+ pdfPage.drawText(markerText, {
232
+ x: markerX,
233
+ y: markerPdfY,
234
+ size: measuredBlock.fontSize,
235
+ font: pdfFont,
236
+ color: rgb(cr, cg, cb),
237
+ });
238
+ }
239
+ // Draw all text lines, indented to align with body text column
240
+ // RTL lists are right-aligned, LTR lists are left-aligned
241
+ const textAlign = isRTL ? 'right' : 'left';
242
+ for (let i = 0; i < lines.length; i++) {
243
+ const line = lines[i];
244
+ if (line.text === '')
245
+ continue;
246
+ const lineYFromTop = yFromTop + (i * measuredBlock.lineHeight);
247
+ const absoluteYFromTop = lineYFromTop + geo.margins.top + geo.headerHeight;
248
+ const pdfY = toPdfY(absoluteYFromTop, fontHeight, geo.pageHeight);
249
+ const trimmedText = line.text.trimEnd();
250
+ const lineWidth = pdfFont.widthOfTextAtSize(trimmedText, measuredBlock.fontSize);
251
+ const x = resolveX(textAlign, textStartX, textAreaWidth, lineWidth);
252
+ pdfPage.drawText(trimmedText, {
253
+ x,
254
+ y: pdfY,
255
+ size: measuredBlock.fontSize,
256
+ font: pdfFont,
257
+ color: rgb(cr, cg, cb),
258
+ });
259
+ }
260
+ }
261
+ // ─── Table rendering ──────────────────────────────────────────────────────────
262
+ function renderTable(pdfPage, pagedBlock, geo, fontMap) {
263
+ const { measuredBlock, yFromTop } = pagedBlock;
264
+ const tableData = measuredBlock.tableData;
265
+ const startRow = pagedBlock.startRow ?? 0;
266
+ const endRow = pagedBlock.endRow ?? tableData.rows.length - tableData.headerRowCount;
267
+ const { columnWidths, cellPaddingH, cellPaddingV, borderWidth, borderColor, headerBgColor } = tableData;
268
+ // Collect the rows to render for this chunk: headers (always) + body slice
269
+ const headerRows = tableData.rows.slice(0, tableData.headerRowCount);
270
+ const bodyRows = tableData.rows.slice(tableData.headerRowCount);
271
+ const chunkBodyRows = bodyRows.slice(startRow, endRow);
272
+ const chunkRows = [...headerRows, ...chunkBodyRows];
273
+ const chunkStartAbsY = yFromTop + geo.margins.top + geo.headerHeight;
274
+ const totalTableWidth = columnWidths.reduce((s, w) => s + w, 0);
275
+ const totalChunkHeight = chunkRows.reduce((s, r) => s + r.height, 0);
276
+ // ── Pass 1: Cell backgrounds ──────────────────────────────────────────────
277
+ let rowAbsY = chunkStartAbsY;
278
+ for (const row of chunkRows) {
279
+ const rowPdfY = toPdfY(rowAbsY, row.height, geo.pageHeight);
280
+ let cellX = geo.margins.left;
281
+ for (const cell of row.cells) {
282
+ const bgColorHex = cell.bgColor ?? (row.isHeader ? headerBgColor : undefined);
283
+ if (bgColorHex) {
284
+ const [r, g, b] = hexToRgb(bgColorHex);
285
+ // Use mergedWidth for colspan support
286
+ pdfPage.drawRectangle({ x: cellX, y: rowPdfY, width: cell.mergedWidth, height: row.height, color: rgb(r, g, b), borderWidth: 0 });
287
+ }
288
+ cellX += cell.mergedWidth;
289
+ }
290
+ rowAbsY += row.height;
291
+ }
292
+ // ── Pass 2: Grid (border-collapse model) ─────────────────────────────────
293
+ // Draw outer border + internal lines — single-thickness at every edge.
294
+ if (borderWidth > 0) {
295
+ const [br, bg, bb] = hexToRgb(borderColor);
296
+ const borderRgb = rgb(br, bg, bb);
297
+ const tableTopPdfY = toPdfY(chunkStartAbsY, totalChunkHeight, geo.pageHeight);
298
+ // Outer border rectangle (no fill)
299
+ pdfPage.drawRectangle({
300
+ x: geo.margins.left,
301
+ y: tableTopPdfY,
302
+ width: totalTableWidth,
303
+ height: totalChunkHeight,
304
+ borderColor: borderRgb,
305
+ borderWidth,
306
+ });
307
+ // Internal horizontal lines (row separators, between rows, not at edges)
308
+ let lineAbsY = chunkStartAbsY;
309
+ for (let ri = 0; ri < chunkRows.length - 1; ri++) {
310
+ lineAbsY += chunkRows[ri].height;
311
+ const linePdfY = geo.pageHeight - lineAbsY;
312
+ pdfPage.drawLine({
313
+ start: { x: geo.margins.left, y: linePdfY },
314
+ end: { x: geo.margins.left + totalTableWidth, y: linePdfY },
315
+ thickness: borderWidth,
316
+ color: borderRgb,
317
+ });
318
+ }
319
+ // Internal vertical lines (column separators, between columns, not at edges)
320
+ // With colspan support: only draw lines at boundaries that are NOT spanned by merged cells
321
+ // Each row may have different active boundaries due to different colspan patterns
322
+ let colBoundaryX = geo.margins.left;
323
+ for (let ci = 0; ci < columnWidths.length; ci++) {
324
+ colBoundaryX += columnWidths[ci];
325
+ // Check if this boundary (between column ci and ci+1) is active in ANY row
326
+ const boundaryIndex = ci; // boundary at index ci is between columns ci and ci+1
327
+ let isActive = false;
328
+ for (const row of chunkRows) {
329
+ if (row.activeBoundaries.includes(boundaryIndex)) {
330
+ isActive = true;
331
+ break;
332
+ }
333
+ }
334
+ if (isActive && ci < columnWidths.length - 1) {
335
+ const chunkTopPdfY = geo.pageHeight - chunkStartAbsY;
336
+ const chunkBottomPdfY = geo.pageHeight - (chunkStartAbsY + totalChunkHeight);
337
+ pdfPage.drawLine({
338
+ start: { x: colBoundaryX, y: chunkTopPdfY },
339
+ end: { x: colBoundaryX, y: chunkBottomPdfY },
340
+ thickness: borderWidth,
341
+ color: borderRgb,
342
+ });
343
+ }
344
+ }
345
+ }
346
+ // ── Pass 3: Cell text ─────────────────────────────────────────────────────
347
+ rowAbsY = chunkStartAbsY;
348
+ for (const row of chunkRows) {
349
+ let cellX = geo.margins.left;
350
+ for (const cell of row.cells) {
351
+ if (cell.lines.length > 0) {
352
+ const pdfFont = fontMap.get(cell.fontKey);
353
+ if (!pdfFont) {
354
+ 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.`);
355
+ }
356
+ const fontHeight = pdfFont.heightAtSize(cell.fontSize);
357
+ const [r, g, b] = hexToRgb(cell.color);
358
+ const textAreaX = cellX + cellPaddingH;
359
+ // Use mergedWidth for colspan support
360
+ const textAreaWidth = cell.mergedWidth - 2 * cellPaddingH;
361
+ for (let li = 0; li < cell.lines.length; li++) {
362
+ const line = cell.lines[li];
363
+ if (line.text === '')
364
+ continue;
365
+ const lineYFromPageTop = rowAbsY + cellPaddingV + li * cell.lineHeight;
366
+ const pdfY = toPdfY(lineYFromPageTop, fontHeight, geo.pageHeight);
367
+ const trimmedText = line.text.trimEnd();
368
+ const lineWidth = pdfFont.widthOfTextAtSize(trimmedText, cell.fontSize);
369
+ const x = resolveX(cell.align, textAreaX, textAreaWidth, lineWidth);
370
+ pdfPage.drawText(trimmedText, { x, y: pdfY, size: cell.fontSize, font: pdfFont, color: rgb(r, g, b) });
371
+ }
372
+ }
373
+ cellX += cell.mergedWidth;
374
+ }
375
+ rowAbsY += row.height;
376
+ }
377
+ }
378
+ // ─── Image rendering ──────────────────────────────────────────────────────────
379
+ function renderImage(pdfPage, pagedBlock, geo, imageMap) {
380
+ const { measuredBlock, yFromTop } = pagedBlock;
381
+ const imageData = measuredBlock.imageData;
382
+ const pdfImage = imageMap.get(imageData.imageKey);
383
+ if (!pdfImage) {
384
+ throw new PretextPdfError('IMAGE_LOAD_FAILED', `Image "${imageData.imageKey}" not found in imageMap. This is a bug — image loading should have caught this.`);
385
+ }
386
+ const absoluteYFromTop = yFromTop + geo.margins.top + geo.headerHeight;
387
+ // drawImage places the BOTTOM-LEFT corner at (x, y) — use toPdfY with renderHeight
388
+ const pdfY = toPdfY(absoluteYFromTop, imageData.renderHeight, geo.pageHeight);
389
+ const x = resolveX(imageData.align, geo.margins.left, geo.contentWidth, imageData.renderWidth);
390
+ pdfPage.drawImage(pdfImage, {
391
+ x,
392
+ y: pdfY,
393
+ width: imageData.renderWidth,
394
+ height: imageData.renderHeight,
395
+ });
396
+ }
397
+ // ─── Horizontal rule rendering ────────────────────────────────────────────────
398
+ function renderHR(pdfPage, pagedBlock, geo) {
399
+ const { measuredBlock, yFromTop } = pagedBlock;
400
+ const element = measuredBlock.element;
401
+ const spaceAbove = element.spaceAbove ?? 12;
402
+ const thickness = element.thickness ?? 0.5;
403
+ const colorHex = element.color ?? '#cccccc';
404
+ // Line sits at the middle of the HR element (after spaceAbove, before spaceBelow)
405
+ const lineYFromTop = yFromTop + spaceAbove + geo.margins.top + geo.headerHeight;
406
+ const pdfY = toPdfY(lineYFromTop, thickness / 2, geo.pageHeight);
407
+ const [r, g, b] = hexToRgb(colorHex);
408
+ pdfPage.drawLine({
409
+ start: { x: geo.margins.left, y: pdfY },
410
+ end: { x: geo.margins.left + geo.contentWidth, y: pdfY },
411
+ thickness,
412
+ color: rgb(r, g, b),
413
+ });
414
+ }
415
+ // ─── Code block rendering ─────────────────────────────────────────────────────
416
+ function renderCodeBlock(pdfPage, pagedBlock, geo, fontMap) {
417
+ const { measuredBlock, startLine, endLine, yFromTop } = pagedBlock;
418
+ const element = measuredBlock.element;
419
+ const padding = measuredBlock.codePadding ?? 8;
420
+ const bgColorHex = element.bgColor ?? '#f6f8fa';
421
+ const textColorHex = element.color ?? '#24292f';
422
+ // Slice the lines being rendered on this page chunk
423
+ const lines = measuredBlock.lines.slice(startLine, endLine);
424
+ const lineHeight = measuredBlock.lineHeight;
425
+ const fontSize = measuredBlock.fontSize;
426
+ // Compute per-chunk padding (only apply padding at the edge of the code block)
427
+ const isFirstChunk = startLine === 0;
428
+ const isLastChunk = endLine === measuredBlock.lines.length;
429
+ const paddingTop = isFirstChunk ? padding : 0;
430
+ const paddingBottom = isLastChunk ? padding : 0;
431
+ const visibleHeight = lines.length * lineHeight + paddingTop + paddingBottom;
432
+ // ── Background box ──────────────────────────────────────────────────────────
433
+ const boxAbsY = yFromTop + geo.margins.top + geo.headerHeight;
434
+ const boxPdfY = toPdfY(boxAbsY, visibleHeight, geo.pageHeight);
435
+ const [bgR, bgG, bgB] = hexToRgb(bgColorHex);
436
+ pdfPage.drawRectangle({
437
+ x: geo.margins.left,
438
+ y: boxPdfY,
439
+ width: geo.contentWidth,
440
+ height: visibleHeight,
441
+ color: rgb(bgR, bgG, bgB),
442
+ borderWidth: 0,
443
+ });
444
+ // ── Text lines ──────────────────────────────────────────────────────────────
445
+ const pdfFont = fontMap.get(measuredBlock.fontKey);
446
+ if (!pdfFont || lines.length === 0)
447
+ return;
448
+ const fontHeight = pdfFont.heightAtSize(fontSize);
449
+ const [r, g, b] = hexToRgb(textColorHex);
450
+ const textX = geo.margins.left + padding;
451
+ for (let i = 0; i < lines.length; i++) {
452
+ const line = lines[i];
453
+ const lineYFromPageTop = boxAbsY + paddingTop + i * lineHeight;
454
+ const pdfY = toPdfY(lineYFromPageTop, fontHeight, geo.pageHeight);
455
+ pdfPage.drawText(line.text.trimEnd(), {
456
+ x: textX,
457
+ y: pdfY,
458
+ size: fontSize,
459
+ font: pdfFont,
460
+ color: rgb(r, g, b),
461
+ });
462
+ }
463
+ }
464
+ // ─── Blockquote rendering ─────────────────────────────────────────────────────
465
+ function renderBlockquote(pdfPage, pagedBlock, geo, fontMap) {
466
+ const { measuredBlock, startLine, endLine, yFromTop } = pagedBlock;
467
+ const element = measuredBlock.element;
468
+ const paddingV = measuredBlock.blockquotePaddingV ?? 10;
469
+ const paddingH = measuredBlock.blockquotePaddingH ?? 16;
470
+ const borderWidth = measuredBlock.blockquoteBorderWidth ?? 3;
471
+ const bgColorHex = element.bgColor ?? '#f8f9fa';
472
+ const borderColorHex = element.borderColor ?? '#0070f3';
473
+ const textColorHex = element.color ?? '#333333';
474
+ const alignRaw = element.align ?? (measuredBlock.isRTL ? 'right' : 'left');
475
+ const align = alignRaw === 'justify' ? 'left' : alignRaw;
476
+ const lines = measuredBlock.lines.slice(startLine, endLine);
477
+ const lineHeight = measuredBlock.lineHeight;
478
+ const fontSize = measuredBlock.fontSize;
479
+ // Compute per-chunk padding (only at the edge of the block, like code)
480
+ const isFirstChunk = startLine === 0;
481
+ const isLastChunk = endLine === measuredBlock.lines.length;
482
+ const paddingTop = isFirstChunk ? paddingV : 0;
483
+ const paddingBottom = isLastChunk ? paddingV : 0;
484
+ const visibleHeight = lines.length * lineHeight + paddingTop + paddingBottom;
485
+ const boxAbsY = yFromTop + geo.margins.top + geo.headerHeight;
486
+ const boxPdfY = toPdfY(boxAbsY, visibleHeight, geo.pageHeight);
487
+ // ── Background box ──────────────────────────────────────────────────────────
488
+ const [bgR, bgG, bgB] = hexToRgb(bgColorHex);
489
+ pdfPage.drawRectangle({
490
+ x: geo.margins.left,
491
+ y: boxPdfY,
492
+ width: geo.contentWidth,
493
+ height: visibleHeight,
494
+ color: rgb(bgR, bgG, bgB),
495
+ borderWidth: 0,
496
+ });
497
+ // ── Left border stripe ──────────────────────────────────────────────────────
498
+ const [bdR, bdG, bdB] = hexToRgb(borderColorHex);
499
+ pdfPage.drawRectangle({
500
+ x: geo.margins.left,
501
+ y: boxPdfY,
502
+ width: borderWidth,
503
+ height: visibleHeight,
504
+ color: rgb(bdR, bdG, bdB),
505
+ borderWidth: 0,
506
+ });
507
+ // ── Text lines ──────────────────────────────────────────────────────────────
508
+ const pdfFont = fontMap.get(measuredBlock.fontKey);
509
+ if (!pdfFont || lines.length === 0)
510
+ return;
511
+ const fontHeight = pdfFont.heightAtSize(fontSize);
512
+ const [r, g, b] = hexToRgb(textColorHex);
513
+ const textStartX = geo.margins.left + borderWidth + paddingH;
514
+ const textAreaWidth = geo.contentWidth - borderWidth - 2 * paddingH;
515
+ for (let i = 0; i < lines.length; i++) {
516
+ const line = lines[i];
517
+ if (line.text === '')
518
+ continue;
519
+ const lineYFromPageTop = boxAbsY + paddingTop + i * lineHeight;
520
+ const pdfY = toPdfY(lineYFromPageTop, fontHeight, geo.pageHeight);
521
+ const trimmedText = line.text.trimEnd();
522
+ const isLastLine = i === lines.length - 1;
523
+ let drawX;
524
+ if (alignRaw === 'justify') {
525
+ drawJustifiedLine(pdfPage, trimmedText, isLastLine, textStartX, pdfY, textAreaWidth, fontSize, pdfFont, rgb(r, g, b));
526
+ drawX = textStartX;
527
+ }
528
+ else {
529
+ const lineWidth = pdfFont.widthOfTextAtSize(trimmedText, fontSize);
530
+ drawX = resolveX(align, textStartX, textAreaWidth, lineWidth);
531
+ pdfPage.drawText(trimmedText, {
532
+ x: drawX,
533
+ y: pdfY,
534
+ size: fontSize,
535
+ font: pdfFont,
536
+ color: rgb(r, g, b),
537
+ });
538
+ }
539
+ if (element.underline || element.strikethrough) {
540
+ const lineWidth = pdfFont.widthOfTextAtSize(trimmedText, fontSize);
541
+ drawTextDecoration(pdfPage, drawX, lineWidth, pdfY, fontSize, pdfFont, [r, g, b], { underline: element.underline ?? false, strikethrough: element.strikethrough ?? false });
542
+ }
543
+ }
544
+ }
545
+ // ─── Rich paragraph rendering ─────────────────────────────────────────────────
546
+ function renderRichParagraph(pdfPage, pagedBlock, geo, fontMap, pdfDoc) {
547
+ const { measuredBlock, startLine, endLine, yFromTop } = pagedBlock;
548
+ const { element, richLines, lineHeight, fontSize } = measuredBlock;
549
+ if (!richLines || richLines.length === 0)
550
+ return;
551
+ // Only render the lines on this page chunk
552
+ const visibleLines = richLines.slice(startLine, endLine);
553
+ // Draw background color if set
554
+ const columnData = measuredBlock.columnData;
555
+ if (element.type === 'rich-paragraph' && element.bgColor) {
556
+ // Phase 5B.4: Use sum of per-line heights (may vary with per-span fontSize)
557
+ const chunkHeight = columnData
558
+ ? visibleLines.slice(0, columnData.linesPerColumn).reduce((sum, rl) => sum + rl.lineHeight, 0)
559
+ : visibleLines.reduce((sum, rl) => sum + rl.lineHeight, 0);
560
+ const boxAbsY = yFromTop + geo.margins.top + geo.headerHeight;
561
+ const boxPdfY = toPdfY(boxAbsY, chunkHeight, geo.pageHeight);
562
+ const [bgR, bgG, bgB] = hexToRgb(element.bgColor);
563
+ pdfPage.drawRectangle({
564
+ x: geo.margins.left,
565
+ y: boxPdfY,
566
+ width: geo.contentWidth,
567
+ height: chunkHeight,
568
+ color: rgb(bgR, bgG, bgB),
569
+ borderWidth: 0,
570
+ });
571
+ }
572
+ // Multi-column layout
573
+ if (columnData) {
574
+ const { columnCount, columnGap, columnWidth, linesPerColumn } = columnData;
575
+ // Phase 5B.4: Track cumulative Y per column (per-line heights may vary)
576
+ const colCumY = new Array(columnCount).fill(0);
577
+ for (let i = 0; i < visibleLines.length; i++) {
578
+ const richLine = visibleLines[i];
579
+ const colIdx = Math.floor(i / linesPerColumn);
580
+ const colOffsetX = colIdx * (columnWidth + columnGap);
581
+ const lineYFromTop = yFromTop + colCumY[colIdx] + geo.margins.top + geo.headerHeight;
582
+ for (const fragment of richLine.fragments) {
583
+ if (!fragment.text || fragment.text.trim() === '')
584
+ continue;
585
+ const pdfFont = fontMap.get(fragment.fontKey);
586
+ if (!pdfFont) {
587
+ throw new PretextPdfError('FONT_NOT_LOADED', `Rich text fragment font "${fragment.fontKey}" not found in fontMap. This is a bug — font validation should have caught this.`);
588
+ }
589
+ const fontHeight = pdfFont.heightAtSize(fragment.fontSize);
590
+ const pdfY = toPdfY(lineYFromTop, fontHeight, geo.pageHeight);
591
+ const [r, g, b] = hexToRgb(fragment.color);
592
+ const drawX = geo.margins.left + colOffsetX + fragment.x;
593
+ const drawText = fragment.text.trimEnd();
594
+ pdfPage.drawText(drawText, {
595
+ x: drawX,
596
+ y: pdfY,
597
+ size: fragment.fontSize,
598
+ font: pdfFont,
599
+ color: rgb(r, g, b),
600
+ });
601
+ const fragWidth = pdfFont.widthOfTextAtSize(drawText, fragment.fontSize);
602
+ drawTextDecoration(pdfPage, drawX, fragWidth, pdfY, fragment.fontSize, pdfFont, [r, g, b], { underline: fragment.underline ?? false, strikethrough: fragment.strikethrough ?? false });
603
+ if (fragment.url) {
604
+ addLinkAnnotation(pdfDoc, pdfPage, drawX, pdfY, fragWidth, fragment.fontSize, fragment.url);
605
+ }
606
+ }
607
+ colCumY[colIdx] += richLine.lineHeight;
608
+ }
609
+ return; // skip standard single-column path
610
+ }
611
+ // Single-column layout (standard path)
612
+ // Phase 5B.4: Track cumulative Y (per-line heights may vary due to per-span fontSize)
613
+ let cumY = 0;
614
+ for (let i = 0; i < visibleLines.length; i++) {
615
+ const richLine = visibleLines[i];
616
+ const lineYFromTop = yFromTop + cumY + geo.margins.top + geo.headerHeight;
617
+ for (const fragment of richLine.fragments) {
618
+ if (!fragment.text || fragment.text.trim() === '')
619
+ continue;
620
+ const pdfFont = fontMap.get(fragment.fontKey);
621
+ if (!pdfFont) {
622
+ throw new PretextPdfError('FONT_NOT_LOADED', `Rich text fragment font "${fragment.fontKey}" not found in fontMap. This is a bug — font validation should have caught this.`);
623
+ }
624
+ const fontHeight = pdfFont.heightAtSize(fragment.fontSize);
625
+ const pdfY = toPdfY(lineYFromTop, fontHeight, geo.pageHeight);
626
+ const [r, g, b] = hexToRgb(fragment.color);
627
+ const drawX = geo.margins.left + fragment.x;
628
+ const drawText = fragment.text.trimEnd();
629
+ pdfPage.drawText(drawText, {
630
+ x: drawX,
631
+ y: pdfY,
632
+ size: fragment.fontSize,
633
+ font: pdfFont,
634
+ color: rgb(r, g, b),
635
+ });
636
+ const fragWidth = pdfFont.widthOfTextAtSize(drawText, fragment.fontSize);
637
+ drawTextDecoration(pdfPage, drawX, fragWidth, pdfY, fragment.fontSize, pdfFont, [r, g, b], { underline: fragment.underline ?? false, strikethrough: fragment.strikethrough ?? false });
638
+ if (fragment.url) {
639
+ addLinkAnnotation(pdfDoc, pdfPage, drawX, pdfY, fragWidth, fragment.fontSize, fragment.url);
640
+ }
641
+ }
642
+ cumY += richLine.lineHeight;
643
+ }
644
+ }
645
+ // ─── Header / Footer rendering ────────────────────────────────────────────────
646
+ function renderHeaderFooter(pdfPage, spec, pageNumber, totalPages, geo, fontMap, position) {
647
+ const text = resolveTokens(spec.text, pageNumber, totalPages);
648
+ const fontSize = spec.fontSize ?? 10;
649
+ const align = spec.align ?? 'center';
650
+ const fontKey = `${spec.fontFamily ?? 'Inter'}-${spec.fontWeight ?? 400}-normal`;
651
+ const pdfFont = fontMap.get(fontKey);
652
+ if (!pdfFont) {
653
+ throw new PretextPdfError('FONT_NOT_LOADED', `${position} font "${fontKey}" not found in fontMap. This is a bug — font validation should have caught this.`);
654
+ }
655
+ const fontHeight = pdfFont.heightAtSize(fontSize);
656
+ let yFromTop;
657
+ if (position === 'header') {
658
+ yFromTop = (geo.margins.top - fontHeight) / 2;
659
+ }
660
+ else {
661
+ yFromTop = geo.pageHeight - geo.margins.bottom + (geo.margins.bottom - fontHeight) / 2;
662
+ }
663
+ const pdfY = toPdfY(yFromTop, fontHeight, geo.pageHeight);
664
+ const textWidth = pdfFont.widthOfTextAtSize(text, fontSize);
665
+ const x = resolveX(align, geo.margins.left, geo.contentWidth, textWidth);
666
+ const [textR, textG, textB] = hexToRgb(spec.color ?? '#666666');
667
+ pdfPage.drawText(text, {
668
+ x,
669
+ y: pdfY,
670
+ size: fontSize,
671
+ font: pdfFont,
672
+ color: rgb(textR, textG, textB),
673
+ });
674
+ // Separator line
675
+ if (position === 'header') {
676
+ const lineY = toPdfY(geo.margins.top - 4, 1, geo.pageHeight);
677
+ pdfPage.drawLine({
678
+ start: { x: geo.margins.left, y: lineY },
679
+ end: { x: geo.margins.left + geo.contentWidth, y: lineY },
680
+ thickness: 0.5,
681
+ color: rgb(0.8, 0.8, 0.8),
682
+ });
683
+ }
684
+ else {
685
+ const lineY = toPdfY(geo.pageHeight - geo.margins.bottom + 4, 1, geo.pageHeight);
686
+ pdfPage.drawLine({
687
+ start: { x: geo.margins.left, y: lineY },
688
+ end: { x: geo.margins.left + geo.contentWidth, y: lineY },
689
+ thickness: 0.5,
690
+ color: rgb(0.8, 0.8, 0.8),
691
+ });
692
+ }
693
+ }
694
+ // ─── Watermark rendering ──────────────────────────────────────────────────
695
+ function renderWatermark(pdfPage, doc, fontMap, imageMap, geo) {
696
+ const wm = doc.watermark;
697
+ if (!wm)
698
+ return;
699
+ const opacity = wm.opacity ?? 0.3;
700
+ const rotation = wm.rotation ?? -45;
701
+ const { pageWidth, pageHeight } = geo;
702
+ if (wm.text) {
703
+ const fontKey = `${wm.fontFamily ?? doc.defaultFont ?? 'Inter'}-${wm.fontWeight ?? 400}-normal`;
704
+ const pdfFont = fontMap.get(fontKey);
705
+ if (!pdfFont) {
706
+ throw new PretextPdfError('FONT_NOT_LOADED', `Watermark font "${fontKey}" not found in fontMap. This is a bug.`);
707
+ }
708
+ // Auto-compute font size to span ~60% of page diagonal
709
+ const fontSize = wm.fontSize ?? (() => {
710
+ const diagonal = Math.sqrt(pageWidth ** 2 + pageHeight ** 2);
711
+ const widthAt100 = pdfFont.widthOfTextAtSize(wm.text, 100);
712
+ return Math.min(120, (diagonal * 0.6 / widthAt100) * 100);
713
+ })();
714
+ const [r, g, b] = hexToRgb(wm.color ?? '#CCCCCC');
715
+ pdfPage.drawText(wm.text, {
716
+ x: pageWidth / 2,
717
+ y: pageHeight / 2,
718
+ size: fontSize,
719
+ font: pdfFont,
720
+ color: rgb(r, g, b),
721
+ rotate: degrees(rotation),
722
+ opacity,
723
+ });
724
+ }
725
+ if (wm.image) {
726
+ const pdfImage = imageMap.get('watermark');
727
+ if (!pdfImage)
728
+ return;
729
+ const margin = 40;
730
+ pdfPage.drawImage(pdfImage, {
731
+ x: margin,
732
+ y: margin,
733
+ width: pageWidth - margin * 2,
734
+ height: pageHeight - margin * 2,
735
+ opacity,
736
+ });
737
+ }
738
+ }
739
+ // ─── Utilities ────────────────────────────────────────────────────────────────
740
+ /**
741
+ * Draw a single line of text with justified alignment.
742
+ * Spaces between words are stretched so the line fills availableWidth.
743
+ * The last line of a paragraph is left-aligned (standard typographic convention).
744
+ */
745
+ function drawJustifiedLine(pdfPage, lineText, isLastLine, x, pdfY, availableWidth, fontSize, pdfFont, color) {
746
+ const trimmed = lineText.trimEnd();
747
+ // Last line or single word: left-align (can't stretch)
748
+ if (isLastLine) {
749
+ pdfPage.drawText(trimmed, { x, y: pdfY, size: fontSize, font: pdfFont, color });
750
+ return;
751
+ }
752
+ const words = trimmed.split(' ').filter(w => w.length > 0);
753
+ if (words.length <= 1) {
754
+ pdfPage.drawText(trimmed, { x, y: pdfY, size: fontSize, font: pdfFont, color });
755
+ return;
756
+ }
757
+ const wordWidths = words.map(w => pdfFont.widthOfTextAtSize(w, fontSize));
758
+ const totalWordWidth = wordWidths.reduce((s, w) => s + w, 0);
759
+ const gapSize = (availableWidth - totalWordWidth) / (words.length - 1);
760
+ let curX = x;
761
+ for (let i = 0; i < words.length; i++) {
762
+ pdfPage.drawText(words[i], { x: curX, y: pdfY, size: fontSize, font: pdfFont, color });
763
+ curX += wordWidths[i] + gapSize;
764
+ }
765
+ }
766
+ /**
767
+ * Adds a clickable URI annotation over a rendered text region.
768
+ * Must be called after drawText() — annotation sits above the text layer.
769
+ */
770
+ function addLinkAnnotation(pdfDoc, pdfPage, x, pdfY, width, fontSize, url) {
771
+ const rectBottom = pdfY - fontSize * 0.2;
772
+ const rectTop = pdfY + fontSize * 0.8;
773
+ const linkAnnot = pdfDoc.context.register(pdfDoc.context.obj({
774
+ Type: 'Annot',
775
+ Subtype: 'Link',
776
+ Rect: [x, rectBottom, x + width, rectTop],
777
+ Border: [0, 0, 0],
778
+ A: pdfDoc.context.obj({
779
+ Type: 'Action',
780
+ S: 'URI',
781
+ URI: PDFString.of(url),
782
+ }),
783
+ }));
784
+ const existingAnnots = pdfPage.node.get(PDFName.of('Annots'));
785
+ if (existingAnnots) {
786
+ const annots = pdfDoc.context.lookup(existingAnnots);
787
+ annots.push(linkAnnot);
788
+ }
789
+ else {
790
+ pdfPage.node.set(PDFName.of('Annots'), pdfDoc.context.obj([linkAnnot]));
791
+ }
792
+ }
793
+ /**
794
+ * Adds a clickable internal anchor link (GoTo) annotation over a rendered text region.
795
+ * Jumps to a page with a named destination when clicked.
796
+ * Must be called after drawText() — annotation sits above the text layer.
797
+ */
798
+ function addGoToAnnotation(pdfDoc, pdfPage, x, pdfY, width, fontSize, destPageRef, destPdfY) {
799
+ const rectBottom = pdfY - fontSize * 0.2;
800
+ const rectTop = pdfY + fontSize * 0.8;
801
+ const goToAnnot = pdfDoc.context.register(pdfDoc.context.obj({
802
+ Type: 'Annot',
803
+ Subtype: 'Link',
804
+ Rect: [x, rectBottom, x + width, rectTop],
805
+ Border: [0, 0, 0],
806
+ Dest: pdfDoc.context.obj([destPageRef, PDFName.of('XYZ'), PDFNull, destPdfY, PDFNull]),
807
+ }));
808
+ const existingAnnots = pdfPage.node.get(PDFName.of('Annots'));
809
+ if (existingAnnots) {
810
+ const annots = pdfDoc.context.lookup(existingAnnots);
811
+ annots.push(goToAnnot);
812
+ }
813
+ else {
814
+ pdfPage.node.set(PDFName.of('Annots'), pdfDoc.context.obj([goToAnnot]));
815
+ }
816
+ }
817
+ /**
818
+ * Draws underline and/or strikethrough lines for a rendered text segment.
819
+ * Must be called AFTER drawText() so text renders on top of any decoration line.
820
+ */
821
+ function drawTextDecoration(pdfPage, x, width, pdfY, fontSize, pdfFont, color, decoration) {
822
+ if (!decoration.underline && !decoration.strikethrough)
823
+ return;
824
+ // Prefer font-designed metrics via fontkit embedder; fall back to height math
825
+ const embedder = pdfFont.embedder;
826
+ const fkFont = embedder?.font; // fontkit Font object (undefined for standard fonts)
827
+ const scale = embedder?.scale ?? 1;
828
+ const ascentPt = pdfFont.heightAtSize(fontSize, { descender: false });
829
+ const thickness = fkFont
830
+ ? Math.max(0.5, (fkFont.underlineThickness * scale / 1000) * fontSize)
831
+ : Math.max(0.5, fontSize / 14);
832
+ const [r, g, b] = color;
833
+ const lineColor = rgb(r, g, b);
834
+ if (decoration.underline) {
835
+ const ulY = fkFont
836
+ ? pdfY + (fkFont.underlinePosition * scale / 1000) * fontSize
837
+ : pdfY - ascentPt * 0.12;
838
+ pdfPage.drawLine({
839
+ start: { x, y: ulY },
840
+ end: { x: x + width, y: ulY },
841
+ thickness,
842
+ color: lineColor,
843
+ });
844
+ }
845
+ if (decoration.strikethrough) {
846
+ const strikeY = fkFont
847
+ ? pdfY + (fkFont.xHeight * scale / 1000) * fontSize * 0.5
848
+ : pdfY + ascentPt * 0.38;
849
+ pdfPage.drawLine({
850
+ start: { x, y: strikeY },
851
+ end: { x: x + width, y: strikeY },
852
+ thickness,
853
+ color: lineColor,
854
+ });
855
+ }
856
+ }
857
+ /**
858
+ * THE ONLY place where top-down coords are converted to pdf-lib bottom-up coords.
859
+ * @param yFromTop - distance from top of page in pt
860
+ * @param elementHeight - height of the element (font baseline offset, image height, etc.)
861
+ * @param pageHeight - total page height in pt
862
+ */
863
+ function toPdfY(yFromTop, elementHeight, pageHeight) {
864
+ return pageHeight - yFromTop - elementHeight;
865
+ }
866
+ /** Resolve text horizontal position based on alignment */
867
+ function resolveX(align, startX, availableWidth, lineWidth) {
868
+ switch (align) {
869
+ case 'left':
870
+ return startX;
871
+ case 'center':
872
+ return startX + (availableWidth - lineWidth) / 2;
873
+ case 'right':
874
+ return startX + availableWidth - lineWidth;
875
+ }
876
+ }
877
+ /** Replace {{pageNumber}} and {{totalPages}} tokens */
878
+ function resolveTokens(text, pageNumber, totalPages) {
879
+ return text
880
+ .replace('{{pageNumber}}', String(pageNumber))
881
+ .replace('{{totalPages}}', String(totalPages));
882
+ }
883
+ /** Parse a 6-digit hex color string to normalized RGB [0,1] triple */
884
+ function hexToRgb(hex) {
885
+ const clean = hex.startsWith('#') ? hex.slice(1) : hex;
886
+ const r = parseInt(clean.slice(0, 2), 16) / 255;
887
+ const g = parseInt(clean.slice(2, 4), 16) / 255;
888
+ const b = parseInt(clean.slice(4, 6), 16) / 255;
889
+ return [r, g, b];
890
+ }
891
+ // ─── Outline / Bookmarks ──────────────────────────────────────────────────────
892
+ /**
893
+ * Build PDF outline (bookmarks/TOC) from heading entries.
894
+ * Creates a doubly-linked tree in the PDF catalog.
895
+ * Must be called after all pages are rendered but before pdfDoc.save().
896
+ */
897
+ function buildOutlineTree(pdfDoc, headings, bookmarkConfig) {
898
+ if (bookmarkConfig === false || headings.length === 0)
899
+ return;
900
+ const cfg = typeof bookmarkConfig === 'object' ? bookmarkConfig : {};
901
+ const minLevel = cfg.minLevel ?? 1;
902
+ const maxLevel = cfg.maxLevel ?? 4;
903
+ const filtered = headings.filter(h => h.level >= minLevel && h.level <= maxLevel);
904
+ if (filtered.length === 0)
905
+ return;
906
+ const pageRefs = pdfDoc.getPages().map(p => p.ref);
907
+ const outlineRef = pdfDoc.context.nextRef();
908
+ const itemRefs = filtered.map(() => pdfDoc.context.nextRef());
909
+ // Returns index of nearest ancestor heading, or -1 (root-level)
910
+ function parentIdxOf(i) {
911
+ for (let j = i - 1; j >= 0; j--) {
912
+ if (filtered[j].level < filtered[i].level)
913
+ return j;
914
+ }
915
+ return -1;
916
+ }
917
+ for (let i = 0; i < filtered.length; i++) {
918
+ const h = filtered[i];
919
+ const pageRef = pageRefs[h.pageIndex] ?? pageRefs[pageRefs.length - 1];
920
+ const myParentIdx = parentIdxOf(i);
921
+ const myParentRef = myParentIdx === -1 ? outlineRef : itemRefs[myParentIdx];
922
+ const dest = pdfDoc.context.obj([pageRef, PDFName.of('XYZ'), PDFNull, PDFNull, PDFNull]);
923
+ let prevRef;
924
+ for (let j = i - 1; j >= 0; j--) {
925
+ if (filtered[j].level === h.level && parentIdxOf(j) === myParentIdx) {
926
+ prevRef = itemRefs[j];
927
+ break;
928
+ }
929
+ }
930
+ let nextRef;
931
+ for (let j = i + 1; j < filtered.length; j++) {
932
+ if (filtered[j].level === h.level && parentIdxOf(j) === myParentIdx) {
933
+ nextRef = itemRefs[j];
934
+ break;
935
+ }
936
+ }
937
+ let firstChildRef;
938
+ let lastChildRef;
939
+ let childCount = 0;
940
+ for (let j = i + 1; j < filtered.length; j++) {
941
+ if (filtered[j].level <= h.level)
942
+ break;
943
+ if (parentIdxOf(j) === i) {
944
+ if (!firstChildRef)
945
+ firstChildRef = itemRefs[j];
946
+ lastChildRef = itemRefs[j];
947
+ childCount++;
948
+ }
949
+ }
950
+ const entry = {
951
+ Title: PDFString.of(h.text),
952
+ Parent: myParentRef,
953
+ Dest: dest,
954
+ };
955
+ if (prevRef)
956
+ entry['Prev'] = prevRef;
957
+ if (nextRef)
958
+ entry['Next'] = nextRef;
959
+ if (firstChildRef)
960
+ entry['First'] = firstChildRef;
961
+ if (lastChildRef)
962
+ entry['Last'] = lastChildRef;
963
+ if (childCount > 0)
964
+ entry['Count'] = childCount;
965
+ pdfDoc.context.assign(itemRefs[i], pdfDoc.context.obj(entry));
966
+ }
967
+ const topIdxs = filtered.map((_, i) => i).filter(i => parentIdxOf(i) === -1);
968
+ const rootEntry = {
969
+ Type: PDFName.of('Outlines'),
970
+ Count: filtered.length,
971
+ };
972
+ if (topIdxs.length > 0) {
973
+ rootEntry['First'] = itemRefs[topIdxs[0]];
974
+ rootEntry['Last'] = itemRefs[topIdxs[topIdxs.length - 1]];
975
+ }
976
+ pdfDoc.context.assign(outlineRef, pdfDoc.context.obj(rootEntry));
977
+ pdfDoc.catalog.set(PDFName.of('Outlines'), outlineRef);
978
+ pdfDoc.catalog.set(PDFName.of('PageMode'), PDFName.of('UseOutlines'));
979
+ }
980
+ // ─── TOC Entry Rendering (Phase 7D) ────────────────────────────────────────────
981
+ function renderTocEntry(pdfPage, pagedBlock, geo, fontMap) {
982
+ const { measuredBlock, startLine, endLine, yFromTop } = pagedBlock;
983
+ const element = measuredBlock.element;
984
+ const tocData = measuredBlock.tocEntryData;
985
+ const lines = measuredBlock.lines.slice(startLine, endLine);
986
+ if (lines.length === 0)
987
+ return;
988
+ const pdfFont = fontMap.get(measuredBlock.fontKey);
989
+ if (!pdfFont)
990
+ throw new PretextPdfError('FONT_NOT_LOADED', `TOC font "${measuredBlock.fontKey}" not found.`);
991
+ const fontHeight = pdfFont.heightAtSize(measuredBlock.fontSize);
992
+ const entryX = geo.margins.left + tocData.entryX;
993
+ const rightEdge = geo.margins.left + geo.contentWidth;
994
+ for (let i = 0; i < lines.length; i++) {
995
+ const lineYFromTop = yFromTop + (i * measuredBlock.lineHeight);
996
+ const absY = lineYFromTop + geo.margins.top + geo.headerHeight;
997
+ const pdfY = toPdfY(absY, fontHeight, geo.pageHeight);
998
+ const text = lines[i].text.trimEnd();
999
+ if (!text)
1000
+ continue;
1001
+ // Draw entry text
1002
+ pdfPage.drawText(text, { x: entryX, y: pdfY, size: measuredBlock.fontSize, font: pdfFont, color: rgb(0, 0, 0) });
1003
+ // Title lines (pageStr === ''): no leader, no page number
1004
+ if (!tocData.pageStr)
1005
+ continue;
1006
+ // Only draw leader and page number on the last line of multi-line entries
1007
+ if (i < lines.length - 1)
1008
+ continue;
1009
+ // Draw page number (right-aligned)
1010
+ const pageStr = tocData.pageStr;
1011
+ const pageStrWidth = pdfFont.widthOfTextAtSize(pageStr, measuredBlock.fontSize);
1012
+ const pageX = rightEdge - pageStrWidth;
1013
+ pdfPage.drawText(pageStr, { x: pageX, y: pdfY, size: measuredBlock.fontSize, font: pdfFont, color: rgb(0, 0, 0) });
1014
+ // Draw dot leaders between text and page number
1015
+ if (tocData.leaderChar) {
1016
+ const textWidth = pdfFont.widthOfTextAtSize(text, measuredBlock.fontSize);
1017
+ const leaderCharWidth = pdfFont.widthOfTextAtSize(tocData.leaderChar, measuredBlock.fontSize);
1018
+ const gapStart = entryX + textWidth + 6; // 6pt gap after text
1019
+ const gapEnd = pageX - 6; // 6pt gap before page number
1020
+ let lx = gapStart;
1021
+ while (lx + leaderCharWidth <= gapEnd) {
1022
+ pdfPage.drawText(tocData.leaderChar, { x: lx, y: pdfY, size: measuredBlock.fontSize, font: pdfFont, color: rgb(0.5, 0.5, 0.5) });
1023
+ lx += leaderCharWidth + 1;
1024
+ }
1025
+ }
1026
+ }
1027
+ }
1028
+ //# sourceMappingURL=render.js.map