pretext-pdf 0.4.6 → 0.5.2

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 +351 -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 +119 -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 +115 -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
@@ -0,0 +1,937 @@
1
+ /**
2
+ * render-blocks.ts — Element-level rendering functions
3
+ * All the specific renderer functions for different content types.
4
+ */
5
+ import { rgb, degrees } from '@cantoo/pdf-lib';
6
+ import { PretextPdfError } from './errors.js';
7
+ import { drawJustifiedLine, addLinkAnnotation, addStickyNoteAnnotation, drawTextDecoration, toPdfY, resolveX, resolveTokens, hexToRgb, drawTabularText, } from './render-utils.js';
8
+ import { buildFontKey } from './measure.js';
9
+ // ─── Text block rendering (paragraph + heading) ───────────────────────────────
10
+ export function renderTextBlock(pdfPage, pagedBlock, geo, fontMap, pdfDoc) {
11
+ const { measuredBlock, startLine, endLine, yFromTop } = pagedBlock;
12
+ const { element } = measuredBlock;
13
+ const lines = measuredBlock.lines.slice(startLine, endLine);
14
+ if (lines.length === 0)
15
+ return;
16
+ const pdfFont = fontMap.get(measuredBlock.fontKey);
17
+ if (!pdfFont) {
18
+ throw new PretextPdfError('FONT_NOT_LOADED', `Font "${measuredBlock.fontKey}" not found in fontMap. This is a bug — font validation should have caught this.`);
19
+ }
20
+ const colorHex = (element.type === 'paragraph' || element.type === 'heading')
21
+ ? (element.color ?? '#000000')
22
+ : '#000000';
23
+ const [r, g, b] = hexToRgb(colorHex);
24
+ const alignRaw = (element.type === 'paragraph' || element.type === 'heading')
25
+ ? (element.align ?? (measuredBlock.isRTL ? 'right' : 'left'))
26
+ : 'left';
27
+ // For resolveX, treat 'justify' as 'left' (justify is handled by drawJustifiedLine)
28
+ const align = alignRaw === 'justify' ? 'left' : alignRaw;
29
+ const fontHeight = pdfFont.heightAtSize(measuredBlock.fontSize);
30
+ // Narrowed reference for paragraph/heading-only fields (smallCaps, tabularNumbers, letterSpacing, annotation)
31
+ const textElement = (element.type === 'paragraph' || element.type === 'heading') ? element : null;
32
+ // Draw background color for paragraph and heading (if set)
33
+ if ((element.type === 'paragraph' || element.type === 'heading') && element.bgColor) {
34
+ const columnData = measuredBlock.columnData;
35
+ const chunkHeight = columnData
36
+ ? columnData.linesPerColumn * measuredBlock.lineHeight
37
+ : lines.length * measuredBlock.lineHeight;
38
+ const boxAbsY = yFromTop + geo.margins.top + geo.headerHeight;
39
+ const boxPdfY = toPdfY(boxAbsY, chunkHeight, geo.pageHeight);
40
+ const [bgR, bgG, bgB] = hexToRgb(element.bgColor);
41
+ pdfPage.drawRectangle({
42
+ x: geo.margins.left,
43
+ y: boxPdfY,
44
+ width: geo.contentWidth,
45
+ height: chunkHeight,
46
+ color: rgb(bgR, bgG, bgB),
47
+ borderWidth: 0,
48
+ });
49
+ }
50
+ // Multi-column layout
51
+ const columnData = measuredBlock.columnData;
52
+ if (columnData) {
53
+ const { columnCount, columnGap, columnWidth, linesPerColumn } = columnData;
54
+ for (let i = 0; i < lines.length; i++) {
55
+ const line = lines[i];
56
+ if (line.text === '')
57
+ continue;
58
+ const colIdx = Math.floor(i / linesPerColumn);
59
+ const lineInCol = i % linesPerColumn;
60
+ const lineYFromTop = yFromTop + (lineInCol * measuredBlock.lineHeight);
61
+ const absoluteYFromTop = lineYFromTop + geo.margins.top + geo.headerHeight;
62
+ const pdfY = toPdfY(absoluteYFromTop, fontHeight, geo.pageHeight);
63
+ 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)
75
+ 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);
78
+ }
79
+ }
80
+ return; // skip standard single-column path
81
+ }
82
+ // Single-column layout (standard path)
83
+ for (let i = 0; i < lines.length; i++) {
84
+ const line = lines[i];
85
+ if (line.text === '')
86
+ continue; // empty lines from \n\n — occupy space, draw nothing
87
+ const lineYFromTop = yFromTop + (i * measuredBlock.lineHeight);
88
+ const absoluteYFromTop = lineYFromTop + geo.margins.top + geo.headerHeight;
89
+ const pdfY = toPdfY(absoluteYFromTop, fontHeight, geo.pageHeight);
90
+ let trimmedText = line.text.trimEnd();
91
+ const isLastLine = i === lines.length - 1;
92
+ // Phase 8H: smallCaps — uppercase text at 80% font size
93
+ const hasSmallCaps = textElement?.smallCaps === true;
94
+ const effectiveFontSize = hasSmallCaps ? measuredBlock.fontSize * 0.8 : measuredBlock.fontSize;
95
+ if (hasSmallCaps)
96
+ trimmedText = trimmedText.toUpperCase();
97
+ const hasTabular = textElement?.tabularNumbers === true;
98
+ // Phase 8H: letterSpacing — draw char by char
99
+ const letterSpacing = (textElement?.letterSpacing ?? 0) > 0
100
+ ? textElement.letterSpacing
101
+ : 0;
102
+ let drawX;
103
+ if (alignRaw === 'justify' && letterSpacing === 0 && !hasTabular) {
104
+ drawJustifiedLine(pdfPage, trimmedText, isLastLine, geo.margins.left, pdfY, geo.contentWidth, effectiveFontSize, pdfFont, rgb(r, g, b));
105
+ drawX = geo.margins.left; // used for decoration below
106
+ }
107
+ else if (letterSpacing > 0) {
108
+ const alignWidth = pdfFont.widthOfTextAtSize(trimmedText, effectiveFontSize) + letterSpacing * (trimmedText.length - 1);
109
+ drawX = resolveX(align, geo.margins.left, geo.contentWidth, alignWidth);
110
+ let cx = drawX;
111
+ for (const ch of trimmedText) {
112
+ pdfPage.drawText(ch, { x: cx, y: pdfY, size: effectiveFontSize, font: pdfFont, color: rgb(r, g, b) });
113
+ cx += pdfFont.widthOfTextAtSize(ch, effectiveFontSize) + letterSpacing;
114
+ }
115
+ }
116
+ else if (hasTabular) {
117
+ const alignWidth = pdfFont.widthOfTextAtSize(trimmedText, effectiveFontSize);
118
+ drawX = resolveX(align, geo.margins.left, geo.contentWidth, alignWidth);
119
+ drawTabularText(pdfPage, trimmedText, drawX, pdfY, effectiveFontSize, pdfFont, rgb(r, g, b));
120
+ }
121
+ else {
122
+ const alignWidth = pdfFont.widthOfTextAtSize(trimmedText, effectiveFontSize);
123
+ drawX = resolveX(align, geo.margins.left, geo.contentWidth, alignWidth);
124
+ pdfPage.drawText(trimmedText, {
125
+ x: drawX,
126
+ y: pdfY,
127
+ size: effectiveFontSize,
128
+ font: pdfFont,
129
+ color: rgb(r, g, b),
130
+ });
131
+ }
132
+ if ((element.type === 'paragraph' || element.type === 'heading') && (element.underline || element.strikethrough)) {
133
+ const lineWidth = pdfFont.widthOfTextAtSize(trimmedText, effectiveFontSize) + (letterSpacing > 0 ? letterSpacing * (trimmedText.length - 1) : 0);
134
+ drawTextDecoration(pdfPage, drawX, lineWidth, pdfY, effectiveFontSize, pdfFont, [r, g, b], { underline: element.underline ?? false, strikethrough: element.strikethrough ?? false });
135
+ }
136
+ // Phase 8G: Wire paragraph.url and heading.url for clickable links
137
+ if ((element.type === 'paragraph' || element.type === 'heading') && element.url) {
138
+ const lineWidth = pdfFont.widthOfTextAtSize(trimmedText, effectiveFontSize) + (letterSpacing > 0 ? letterSpacing * (trimmedText.length - 1) : 0);
139
+ addLinkAnnotation(pdfDoc, pdfPage, drawX, pdfY, lineWidth, effectiveFontSize, element.url);
140
+ }
141
+ }
142
+ // Phase 8A: annotation on paragraph/heading — attach sticky note at top of block
143
+ if (textElement?.annotation) {
144
+ const ann = textElement.annotation;
145
+ const absY = yFromTop + geo.margins.top + geo.headerHeight;
146
+ const annotPdfY = geo.pageHeight - absY;
147
+ addStickyNoteAnnotation(pdfDoc, pdfPage, geo.margins.left, annotPdfY, ann.contents, ann.author, ann.color, ann.open);
148
+ }
149
+ }
150
+ // ─── List item rendering ──────────────────────────────────────────────────────
151
+ export function renderListItem(pdfPage, pagedBlock, geo, fontMap) {
152
+ const { measuredBlock, startLine, endLine, yFromTop } = pagedBlock;
153
+ const listItemData = measuredBlock.listItemData;
154
+ const lines = measuredBlock.lines.slice(startLine, endLine);
155
+ if (lines.length === 0)
156
+ return;
157
+ const pdfFont = fontMap.get(measuredBlock.fontKey);
158
+ if (!pdfFont) {
159
+ throw new PretextPdfError('FONT_NOT_LOADED', `Font "${measuredBlock.fontKey}" not found in fontMap. This is a bug — font validation should have caught this.`);
160
+ }
161
+ const fontHeight = pdfFont.heightAtSize(measuredBlock.fontSize);
162
+ const [cr, cg, cb] = hexToRgb(listItemData.color);
163
+ // RTL support: mirror list layout if detected
164
+ const isRTL = measuredBlock.isRTL ?? false;
165
+ let textStartX;
166
+ let textAreaWidth;
167
+ if (isRTL) {
168
+ // RTL: marker on the right, text area on the left
169
+ textAreaWidth = geo.contentWidth - listItemData.indent - listItemData.markerWidth;
170
+ textStartX = geo.margins.left + listItemData.indent;
171
+ }
172
+ else {
173
+ // LTR: marker on the left, text area on the right
174
+ textStartX = geo.margins.left + listItemData.indent + listItemData.markerWidth;
175
+ textAreaWidth = geo.contentWidth - listItemData.indent - listItemData.markerWidth;
176
+ }
177
+ // Draw marker on the first line of this item (only if startLine === 0)
178
+ // If startLine > 0, the item continued from a previous page — no marker
179
+ if (startLine === 0) {
180
+ const markerText = listItemData.marker;
181
+ const markerMeasuredWidth = pdfFont.widthOfTextAtSize(markerText, measuredBlock.fontSize);
182
+ let markerX;
183
+ if (isRTL) {
184
+ // RTL: marker on the right, right-aligned within marker column
185
+ markerX = geo.margins.left + geo.contentWidth - listItemData.indent - markerMeasuredWidth;
186
+ }
187
+ else {
188
+ // LTR: marker on the left, right-aligned within marker column
189
+ markerX = geo.margins.left + listItemData.indent + listItemData.markerWidth - markerMeasuredWidth;
190
+ }
191
+ const firstLineAbsY = yFromTop + geo.margins.top + geo.headerHeight;
192
+ const markerPdfY = toPdfY(firstLineAbsY, fontHeight, geo.pageHeight);
193
+ pdfPage.drawText(markerText, {
194
+ x: markerX,
195
+ y: markerPdfY,
196
+ size: measuredBlock.fontSize,
197
+ font: pdfFont,
198
+ color: rgb(cr, cg, cb),
199
+ });
200
+ }
201
+ // Draw all text lines, indented to align with body text column
202
+ // RTL lists are right-aligned, LTR lists are left-aligned
203
+ const textAlign = isRTL ? 'right' : 'left';
204
+ for (let i = 0; i < lines.length; i++) {
205
+ const line = lines[i];
206
+ if (line.text === '')
207
+ continue;
208
+ const lineYFromTop = yFromTop + (i * measuredBlock.lineHeight);
209
+ const absoluteYFromTop = lineYFromTop + geo.margins.top + geo.headerHeight;
210
+ const pdfY = toPdfY(absoluteYFromTop, fontHeight, geo.pageHeight);
211
+ const trimmedText = line.text.trimEnd();
212
+ const lineWidth = pdfFont.widthOfTextAtSize(trimmedText, measuredBlock.fontSize);
213
+ const x = resolveX(textAlign, textStartX, textAreaWidth, lineWidth);
214
+ pdfPage.drawText(trimmedText, {
215
+ x,
216
+ y: pdfY,
217
+ size: measuredBlock.fontSize,
218
+ font: pdfFont,
219
+ color: rgb(cr, cg, cb),
220
+ });
221
+ }
222
+ }
223
+ // ─── Table rendering ──────────────────────────────────────────────────────────
224
+ export function renderTable(pdfPage, pagedBlock, geo, fontMap) {
225
+ const { measuredBlock, yFromTop } = pagedBlock;
226
+ const tableData = measuredBlock.tableData;
227
+ const startRow = pagedBlock.startRow ?? 0;
228
+ const endRow = pagedBlock.endRow ?? tableData.rows.length - tableData.headerRowCount;
229
+ const { columnWidths, cellPaddingH, cellPaddingV, borderWidth, borderColor, headerBgColor } = tableData;
230
+ // Collect the rows to render for this chunk: headers (always) + body slice
231
+ const headerRows = tableData.rows.slice(0, tableData.headerRowCount);
232
+ const bodyRows = tableData.rows.slice(tableData.headerRowCount);
233
+ const chunkBodyRows = bodyRows.slice(startRow, endRow);
234
+ const chunkRows = [...headerRows, ...chunkBodyRows];
235
+ const chunkStartAbsY = yFromTop + geo.margins.top + geo.headerHeight;
236
+ const totalTableWidth = columnWidths.reduce((s, w) => s + w, 0);
237
+ const totalChunkHeight = chunkRows.reduce((s, r) => s + r.height, 0);
238
+ // ── Pass 1: Cell backgrounds ──────────────────────────────────────────────
239
+ let rowAbsY = chunkStartAbsY;
240
+ for (const row of chunkRows) {
241
+ const rowPdfY = toPdfY(rowAbsY, row.height, geo.pageHeight);
242
+ let cellX = geo.margins.left;
243
+ 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 });
249
+ }
250
+ cellX += cell.mergedWidth;
251
+ }
252
+ rowAbsY += row.height;
253
+ }
254
+ // ── Pass 2: Grid (border-collapse model) ─────────────────────────────────
255
+ // Draw outer border + internal lines — single-thickness at every edge.
256
+ if (borderWidth > 0) {
257
+ const [br, bg, bb] = hexToRgb(borderColor);
258
+ const borderRgb = rgb(br, bg, bb);
259
+ const tableTopPdfY = toPdfY(chunkStartAbsY, totalChunkHeight, geo.pageHeight);
260
+ // Outer border rectangle (no fill)
261
+ pdfPage.drawRectangle({
262
+ x: geo.margins.left,
263
+ y: tableTopPdfY,
264
+ width: totalTableWidth,
265
+ height: totalChunkHeight,
266
+ borderColor: borderRgb,
267
+ borderWidth,
268
+ });
269
+ // Internal horizontal lines (row separators, between rows, not at edges)
270
+ let lineAbsY = chunkStartAbsY;
271
+ for (let ri = 0; ri < chunkRows.length - 1; ri++) {
272
+ 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);
299
+ pdfPage.drawLine({
300
+ start: { x: colBoundaryX, y: chunkTopPdfY },
301
+ end: { x: colBoundaryX, y: chunkBottomPdfY },
302
+ thickness: borderWidth,
303
+ color: borderRgb,
304
+ });
305
+ }
306
+ }
307
+ }
308
+ // ── Pass 3: Cell text ─────────────────────────────────────────────────────
309
+ rowAbsY = chunkStartAbsY;
310
+ for (const row of chunkRows) {
311
+ let cellX = geo.margins.left;
312
+ for (const cell of row.cells) {
313
+ if (cell.lines.length > 0) {
314
+ const pdfFont = fontMap.get(cell.fontKey);
315
+ if (!pdfFont) {
316
+ 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.`);
317
+ }
318
+ const fontHeight = pdfFont.heightAtSize(cell.fontSize);
319
+ const [r, g, b] = hexToRgb(cell.color);
320
+ const textAreaX = cellX + cellPaddingH;
321
+ // Use mergedWidth for colspan support
322
+ const textAreaWidth = cell.mergedWidth - 2 * cellPaddingH;
323
+ for (let li = 0; li < cell.lines.length; li++) {
324
+ const line = cell.lines[li];
325
+ if (line.text === '')
326
+ continue;
327
+ const lineYFromPageTop = rowAbsY + cellPaddingV + li * cell.lineHeight;
328
+ const pdfY = toPdfY(lineYFromPageTop, fontHeight, geo.pageHeight);
329
+ const trimmedText = line.text.trimEnd();
330
+ const lineWidth = pdfFont.widthOfTextAtSize(trimmedText, cell.fontSize);
331
+ const x = resolveX(cell.align, textAreaX, textAreaWidth, lineWidth);
332
+ if (cell.tabularNumbers) {
333
+ drawTabularText(pdfPage, trimmedText, x, pdfY, cell.fontSize, pdfFont, rgb(r, g, b));
334
+ }
335
+ else {
336
+ pdfPage.drawText(trimmedText, { x, y: pdfY, size: cell.fontSize, font: pdfFont, color: rgb(r, g, b) });
337
+ }
338
+ }
339
+ }
340
+ cellX += cell.mergedWidth;
341
+ }
342
+ rowAbsY += row.height;
343
+ }
344
+ }
345
+ // ─── Image rendering ──────────────────────────────────────────────────────────
346
+ export function renderImage(pdfPage, pagedBlock, geo, imageMap) {
347
+ const { measuredBlock, yFromTop } = pagedBlock;
348
+ const imageData = measuredBlock.imageData;
349
+ const pdfImage = imageMap.get(imageData.imageKey);
350
+ if (!pdfImage) {
351
+ throw new PretextPdfError('IMAGE_LOAD_FAILED', `Image "${imageData.imageKey}" not found in imageMap. This is a bug — image loading should have caught this.`);
352
+ }
353
+ const absoluteYFromTop = yFromTop + geo.margins.top + geo.headerHeight;
354
+ // drawImage places the BOTTOM-LEFT corner at (x, y) — use toPdfY with renderHeight
355
+ const pdfY = toPdfY(absoluteYFromTop, imageData.renderHeight, geo.pageHeight);
356
+ const x = resolveX(imageData.align, geo.margins.left, geo.contentWidth, imageData.renderWidth);
357
+ pdfPage.drawImage(pdfImage, {
358
+ x,
359
+ y: pdfY,
360
+ width: imageData.renderWidth,
361
+ height: imageData.renderHeight,
362
+ });
363
+ }
364
+ // ─── Float image block rendering ─────────────────────────────────────────────
365
+ export function renderFloatBlock(pdfPage, pagedBlock, geo, fontMap, imageMap, pdfDoc) {
366
+ const { measuredBlock, yFromTop } = pagedBlock;
367
+ const fd = measuredBlock.floatData;
368
+ const baseAbsY = yFromTop + geo.margins.top + geo.headerHeight;
369
+ // Draw image
370
+ const pdfImage = imageMap.get(fd.imageKey);
371
+ if (!pdfImage)
372
+ throw new PretextPdfError('IMAGE_LOAD_FAILED', `Float image key "${fd.imageKey}" not found in imageMap. This is a bug — image loading should have caught this.`);
373
+ const imgX = geo.margins.left + fd.imageColX;
374
+ const imgPdfY = toPdfY(baseAbsY, fd.imageRenderHeight, geo.pageHeight);
375
+ pdfPage.drawImage(pdfImage, {
376
+ x: imgX,
377
+ y: imgPdfY,
378
+ width: fd.imageRenderWidth,
379
+ height: fd.imageRenderHeight,
380
+ });
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);
387
+ 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
+ });
401
+ }
402
+ }
403
+ // ─── Float group block rendering ──────────────────────────────────────────────
404
+ export function renderFloatGroup(pdfPage, pagedBlock, geo, fontMap, imageMap, pdfDoc) {
405
+ const { measuredBlock, yFromTop } = pagedBlock;
406
+ const fd = measuredBlock.floatGroupData;
407
+ const baseAbsY = yFromTop + geo.margins.top + geo.headerHeight;
408
+ // Draw image
409
+ const pdfImage = imageMap.get(fd.imageKey);
410
+ if (!pdfImage)
411
+ throw new PretextPdfError('IMAGE_LOAD_FAILED', `Float group image key "${fd.imageKey}" not found in imageMap. This is a bug — image loading should have caught this.`);
412
+ const imgX = geo.margins.left + fd.imageColX;
413
+ const imgPdfY = toPdfY(baseAbsY, fd.imageRenderHeight, geo.pageHeight);
414
+ pdfPage.drawImage(pdfImage, {
415
+ x: imgX,
416
+ y: imgPdfY,
417
+ width: fd.imageRenderWidth,
418
+ height: fd.imageRenderHeight,
419
+ });
420
+ // Draw text items
421
+ const textBaseX = geo.margins.left + fd.textColX;
422
+ for (const textItem of fd.textItems) {
423
+ const pdfFont = fontMap.get(textItem.fontKey);
424
+ if (!pdfFont)
425
+ throw new PretextPdfError('FONT_NOT_LOADED', `Float group font key "${textItem.fontKey}" not found in fontMap. This is a bug — font loading should have caught this.`);
426
+ const fontHeight = pdfFont.heightAtSize(textItem.fontSize);
427
+ // Draw plain lines (plain-text fallback for rich-paragraphs)
428
+ for (let i = 0; i < textItem.lines.length; i++) {
429
+ const line = textItem.lines[i];
430
+ if (!line.text || line.text === '')
431
+ continue;
432
+ const lineAbsY = baseAbsY + textItem.yOffsetFromTop + (i * textItem.lineHeight);
433
+ const pdfY = toPdfY(lineAbsY, fontHeight, geo.pageHeight);
434
+ pdfPage.drawText(line.text.trimEnd(), {
435
+ x: textBaseX,
436
+ y: pdfY,
437
+ size: textItem.fontSize,
438
+ font: pdfFont,
439
+ color: rgb(0, 0, 0),
440
+ });
441
+ }
442
+ }
443
+ }
444
+ // ─── Horizontal rule rendering ────────────────────────────────────────────────
445
+ export function renderHR(pdfPage, pagedBlock, geo) {
446
+ const { measuredBlock, yFromTop } = pagedBlock;
447
+ const element = measuredBlock.element;
448
+ const spaceAbove = element.spaceAbove ?? 12;
449
+ const thickness = element.thickness ?? 0.5;
450
+ const colorHex = element.color ?? '#cccccc';
451
+ // Line sits at the middle of the HR element (after spaceAbove, before spaceBelow)
452
+ const lineYFromTop = yFromTop + spaceAbove + geo.margins.top + geo.headerHeight;
453
+ const pdfY = toPdfY(lineYFromTop, thickness / 2, geo.pageHeight);
454
+ const [r, g, b] = hexToRgb(colorHex);
455
+ pdfPage.drawLine({
456
+ start: { x: geo.margins.left, y: pdfY },
457
+ end: { x: geo.margins.left + geo.contentWidth, y: pdfY },
458
+ thickness,
459
+ color: rgb(r, g, b),
460
+ });
461
+ }
462
+ // ─── Code block rendering ─────────────────────────────────────────────────────
463
+ export function renderCodeBlock(pdfPage, pagedBlock, geo, fontMap) {
464
+ const { measuredBlock, startLine, endLine, yFromTop } = pagedBlock;
465
+ const element = measuredBlock.element;
466
+ const padding = measuredBlock.codePadding ?? 8;
467
+ const bgColorHex = element.bgColor ?? '#f6f8fa';
468
+ const textColorHex = element.color ?? '#24292f';
469
+ // Slice the lines being rendered on this page chunk
470
+ const lines = measuredBlock.lines.slice(startLine, endLine);
471
+ const lineHeight = measuredBlock.lineHeight;
472
+ const fontSize = measuredBlock.fontSize;
473
+ // Compute per-chunk padding (only apply padding at the edge of the code block)
474
+ const isFirstChunk = startLine === 0;
475
+ const isLastChunk = endLine === measuredBlock.lines.length;
476
+ const paddingTop = isFirstChunk ? padding : 0;
477
+ const paddingBottom = isLastChunk ? padding : 0;
478
+ const visibleHeight = lines.length * lineHeight + paddingTop + paddingBottom;
479
+ // ── Background box ──────────────────────────────────────────────────────────
480
+ const boxAbsY = yFromTop + geo.margins.top + geo.headerHeight;
481
+ const boxPdfY = toPdfY(boxAbsY, visibleHeight, geo.pageHeight);
482
+ const [bgR, bgG, bgB] = hexToRgb(bgColorHex);
483
+ pdfPage.drawRectangle({
484
+ x: geo.margins.left,
485
+ y: boxPdfY,
486
+ width: geo.contentWidth,
487
+ height: visibleHeight,
488
+ color: rgb(bgR, bgG, bgB),
489
+ borderWidth: 0,
490
+ });
491
+ // ── Text lines ──────────────────────────────────────────────────────────────
492
+ const pdfFont = fontMap.get(measuredBlock.fontKey);
493
+ if (!pdfFont || lines.length === 0)
494
+ return;
495
+ const fontHeight = pdfFont.heightAtSize(fontSize);
496
+ const [r, g, b] = hexToRgb(textColorHex);
497
+ 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
+ });
509
+ }
510
+ }
511
+ // ─── Blockquote rendering ─────────────────────────────────────────────────────
512
+ export function renderBlockquote(pdfPage, pagedBlock, geo, fontMap) {
513
+ const { measuredBlock, startLine, endLine, yFromTop } = pagedBlock;
514
+ const element = measuredBlock.element;
515
+ const paddingV = measuredBlock.blockquotePaddingV ?? 10;
516
+ const paddingH = measuredBlock.blockquotePaddingH ?? 16;
517
+ const borderWidth = measuredBlock.blockquoteBorderWidth ?? 3;
518
+ const bgColorHex = element.bgColor ?? '#f8f9fa';
519
+ const borderColorHex = element.borderColor ?? '#0070f3';
520
+ const textColorHex = element.color ?? '#333333';
521
+ const alignRaw = element.align ?? (measuredBlock.isRTL ? 'right' : 'left');
522
+ const align = alignRaw === 'justify' ? 'left' : alignRaw;
523
+ const lines = measuredBlock.lines.slice(startLine, endLine);
524
+ const lineHeight = measuredBlock.lineHeight;
525
+ const fontSize = measuredBlock.fontSize;
526
+ // Compute per-chunk padding (only at the edge of the block, like code)
527
+ const isFirstChunk = startLine === 0;
528
+ const isLastChunk = endLine === measuredBlock.lines.length;
529
+ const paddingTop = isFirstChunk ? paddingV : 0;
530
+ const paddingBottom = isLastChunk ? paddingV : 0;
531
+ const visibleHeight = lines.length * lineHeight + paddingTop + paddingBottom;
532
+ const boxAbsY = yFromTop + geo.margins.top + geo.headerHeight;
533
+ const boxPdfY = toPdfY(boxAbsY, visibleHeight, geo.pageHeight);
534
+ // ── Background box ──────────────────────────────────────────────────────────
535
+ const [bgR, bgG, bgB] = hexToRgb(bgColorHex);
536
+ pdfPage.drawRectangle({
537
+ x: geo.margins.left,
538
+ y: boxPdfY,
539
+ width: geo.contentWidth,
540
+ height: visibleHeight,
541
+ color: rgb(bgR, bgG, bgB),
542
+ borderWidth: 0,
543
+ });
544
+ // ── Left border stripe ──────────────────────────────────────────────────────
545
+ const [bdR, bdG, bdB] = hexToRgb(borderColorHex);
546
+ pdfPage.drawRectangle({
547
+ x: geo.margins.left,
548
+ y: boxPdfY,
549
+ width: borderWidth,
550
+ height: visibleHeight,
551
+ color: rgb(bdR, bdG, bdB),
552
+ borderWidth: 0,
553
+ });
554
+ // ── Text lines ──────────────────────────────────────────────────────────────
555
+ const pdfFont = fontMap.get(measuredBlock.fontKey);
556
+ if (!pdfFont || lines.length === 0)
557
+ return;
558
+ const fontHeight = pdfFont.heightAtSize(fontSize);
559
+ const [r, g, b] = hexToRgb(textColorHex);
560
+ const textStartX = geo.margins.left + borderWidth + paddingH;
561
+ const textAreaWidth = geo.contentWidth - borderWidth - 2 * paddingH;
562
+ for (let i = 0; i < lines.length; i++) {
563
+ const line = lines[i];
564
+ if (line.text === '')
565
+ continue;
566
+ const lineYFromPageTop = boxAbsY + paddingTop + i * lineHeight;
567
+ const pdfY = toPdfY(lineYFromPageTop, fontHeight, geo.pageHeight);
568
+ const trimmedText = line.text.trimEnd();
569
+ const isLastLine = i === lines.length - 1;
570
+ let drawX;
571
+ if (alignRaw === 'justify') {
572
+ drawJustifiedLine(pdfPage, trimmedText, isLastLine, textStartX, pdfY, textAreaWidth, fontSize, pdfFont, rgb(r, g, b));
573
+ drawX = textStartX;
574
+ }
575
+ else {
576
+ const lineWidth = pdfFont.widthOfTextAtSize(trimmedText, fontSize);
577
+ drawX = resolveX(align, textStartX, textAreaWidth, lineWidth);
578
+ pdfPage.drawText(trimmedText, {
579
+ x: drawX,
580
+ y: pdfY,
581
+ size: fontSize,
582
+ font: pdfFont,
583
+ color: rgb(r, g, b),
584
+ });
585
+ }
586
+ if (element.underline || element.strikethrough) {
587
+ const lineWidth = pdfFont.widthOfTextAtSize(trimmedText, fontSize);
588
+ drawTextDecoration(pdfPage, drawX, lineWidth, pdfY, fontSize, pdfFont, [r, g, b], { underline: element.underline ?? false, strikethrough: element.strikethrough ?? false });
589
+ }
590
+ }
591
+ }
592
+ // ─── Callout rendering (Phase 8D) ────────────────────────────────────────────
593
+ export function renderCallout(pdfPage, pagedBlock, geo, fontMap) {
594
+ const { measuredBlock, startLine, endLine, yFromTop } = pagedBlock;
595
+ const el = measuredBlock.element;
596
+ const cd = measuredBlock.calloutData;
597
+ if (!cd)
598
+ return;
599
+ const { paddingH, paddingV, borderColor, backgroundColor, titleColor, color, titleText } = cd;
600
+ const isFirstChunk = startLine === 0;
601
+ const isLastChunk = endLine === measuredBlock.lines.length;
602
+ const lines = measuredBlock.lines.slice(startLine, endLine);
603
+ const fs = measuredBlock.fontSize;
604
+ const lh = measuredBlock.lineHeight;
605
+ const font = fontMap.get(measuredBlock.fontKey) ?? [...fontMap.values()][0];
606
+ if (!font)
607
+ return;
608
+ const titleH = isFirstChunk && titleText ? cd.titleHeight : 0;
609
+ const topPad = isFirstChunk ? paddingV : 0;
610
+ const bottomPad = isLastChunk ? paddingV : 0;
611
+ const chunkHeight = topPad + titleH + lines.length * lh + bottomPad;
612
+ const boxAbsY = yFromTop + geo.margins.top + geo.headerHeight;
613
+ const boxPdfY = toPdfY(boxAbsY, chunkHeight, geo.pageHeight);
614
+ // Background
615
+ const [bgR, bgG, bgB] = hexToRgb(backgroundColor);
616
+ pdfPage.drawRectangle({
617
+ x: geo.margins.left,
618
+ y: boxPdfY,
619
+ width: geo.contentWidth,
620
+ height: chunkHeight,
621
+ color: rgb(bgR, bgG, bgB),
622
+ borderWidth: 0,
623
+ });
624
+ // Left border stripe (3pt wide)
625
+ const [bdR, bdG, bdB] = hexToRgb(borderColor);
626
+ pdfPage.drawRectangle({
627
+ x: geo.margins.left,
628
+ y: boxPdfY,
629
+ width: 3,
630
+ height: chunkHeight,
631
+ color: rgb(bdR, bdG, bdB),
632
+ borderWidth: 0,
633
+ });
634
+ const fontHeight = font.heightAtSize(fs);
635
+ let currentAbsY = boxAbsY + topPad;
636
+ // Draw title if first chunk
637
+ if (isFirstChunk && titleText) {
638
+ // Try to get bold font variant by modifying the fontKey
639
+ const boldFontKey = measuredBlock.fontKey.replace(/-400-/, '-700-');
640
+ const titleFont = fontMap.get(boldFontKey) ?? font;
641
+ const [tR, tG, tB] = hexToRgb(titleColor);
642
+ const titlePdfY = geo.pageHeight - currentAbsY - fontHeight - (fs * 1.4 - fs) / 2;
643
+ pdfPage.drawText(titleText, {
644
+ x: geo.margins.left + paddingH,
645
+ y: titlePdfY,
646
+ size: fs,
647
+ font: titleFont,
648
+ color: rgb(tR, tG, tB),
649
+ });
650
+ currentAbsY += titleH;
651
+ }
652
+ // Draw content lines
653
+ const [tR, tG, tB] = hexToRgb(color);
654
+ for (let i = 0; i < lines.length; i++) {
655
+ const line = lines[i];
656
+ if (line.text === '') {
657
+ currentAbsY += lh;
658
+ continue;
659
+ }
660
+ const linePdfY = geo.pageHeight - currentAbsY - fontHeight - (lh - fs) / 2;
661
+ pdfPage.drawText(line.text.trimEnd(), {
662
+ x: geo.margins.left + paddingH,
663
+ y: linePdfY,
664
+ size: fs,
665
+ font,
666
+ color: rgb(tR, tG, tB),
667
+ });
668
+ currentAbsY += lh;
669
+ }
670
+ }
671
+ // ─── Rich paragraph rendering ─────────────────────────────────────────────────
672
+ export function renderRichParagraph(pdfPage, pagedBlock, geo, fontMap, pdfDoc, footnoteNumbering) {
673
+ const { measuredBlock, startLine, endLine, yFromTop } = pagedBlock;
674
+ const { element, richLines, lineHeight, fontSize } = measuredBlock;
675
+ if (!richLines || richLines.length === 0)
676
+ return;
677
+ // Only render the lines on this page chunk
678
+ const visibleLines = richLines.slice(startLine, endLine);
679
+ // Draw background color if set
680
+ const columnData = measuredBlock.columnData;
681
+ if (element.type === 'rich-paragraph' && element.bgColor) {
682
+ // Phase 5B.4: Use sum of per-line heights (may vary with per-span fontSize)
683
+ const chunkHeight = columnData
684
+ ? visibleLines.slice(0, columnData.linesPerColumn).reduce((sum, rl) => sum + rl.lineHeight, 0)
685
+ : visibleLines.reduce((sum, rl) => sum + rl.lineHeight, 0);
686
+ const boxAbsY = yFromTop + geo.margins.top + geo.headerHeight;
687
+ const boxPdfY = toPdfY(boxAbsY, chunkHeight, geo.pageHeight);
688
+ const [bgR, bgG, bgB] = hexToRgb(element.bgColor);
689
+ pdfPage.drawRectangle({
690
+ x: geo.margins.left,
691
+ y: boxPdfY,
692
+ width: geo.contentWidth,
693
+ height: chunkHeight,
694
+ color: rgb(bgR, bgG, bgB),
695
+ borderWidth: 0,
696
+ });
697
+ }
698
+ // Multi-column layout
699
+ if (columnData) {
700
+ const { columnCount, columnGap, columnWidth, linesPerColumn } = columnData;
701
+ // Phase 5B.4: Track cumulative Y per column (per-line heights may vary)
702
+ const colCumY = new Array(columnCount).fill(0);
703
+ for (let i = 0; i < visibleLines.length; i++) {
704
+ const richLine = visibleLines[i];
705
+ const colIdx = Math.floor(i / linesPerColumn);
706
+ const colOffsetX = colIdx * (columnWidth + columnGap);
707
+ const lineYFromTop = yFromTop + colCumY[colIdx] + geo.margins.top + geo.headerHeight;
708
+ for (const fragment of richLine.fragments) {
709
+ if (!fragment.text || fragment.text.trim() === '')
710
+ continue;
711
+ const pdfFont = fontMap.get(fragment.fontKey);
712
+ if (!pdfFont) {
713
+ 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.`);
714
+ }
715
+ const fontHeight = pdfFont.heightAtSize(fragment.fontSize);
716
+ const basePdfY = toPdfY(lineYFromTop, fontHeight, geo.pageHeight);
717
+ const [r, g, b] = hexToRgb(fragment.color);
718
+ const drawX = geo.margins.left + colOffsetX + fragment.x;
719
+ // Footnote ref spans render as superscript number, replacing the original text
720
+ if (fragment.footnoteRef) {
721
+ const num = footnoteNumbering?.get(fragment.footnoteRef) ?? '?';
722
+ const superText = String(num);
723
+ const superSize = fragment.fontSize * 0.65;
724
+ const superYOffset = fragment.fontSize * 0.4;
725
+ const superPdfY = basePdfY + superYOffset;
726
+ pdfPage.drawText(superText, {
727
+ x: drawX,
728
+ y: superPdfY,
729
+ size: superSize,
730
+ font: pdfFont,
731
+ color: rgb(r, g, b),
732
+ });
733
+ continue;
734
+ }
735
+ const fragmentPdfY = basePdfY + (fragment.yOffset ?? 0);
736
+ 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);
745
+ drawTextDecoration(pdfPage, drawX, fragWidth, fragmentPdfY, fragment.fontSize, pdfFont, [r, g, b], { underline: fragment.underline ?? false, strikethrough: fragment.strikethrough ?? false });
746
+ if (fragment.url) {
747
+ addLinkAnnotation(pdfDoc, pdfPage, drawX, fragmentPdfY, fragWidth, fragment.fontSize, fragment.url);
748
+ }
749
+ }
750
+ colCumY[colIdx] += richLine.lineHeight;
751
+ }
752
+ return; // skip standard single-column path
753
+ }
754
+ // Single-column layout (standard path)
755
+ // Phase 5B.4: Track cumulative Y (per-line heights may vary due to per-span fontSize)
756
+ let cumY = 0;
757
+ for (let i = 0; i < visibleLines.length; i++) {
758
+ const richLine = visibleLines[i];
759
+ const lineYFromTop = yFromTop + cumY + geo.margins.top + geo.headerHeight;
760
+ for (const fragment of richLine.fragments) {
761
+ if (!fragment.text || fragment.text.trim() === '')
762
+ continue;
763
+ const pdfFont = fontMap.get(fragment.fontKey);
764
+ if (!pdfFont) {
765
+ 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.`);
766
+ }
767
+ const fontHeight = pdfFont.heightAtSize(fragment.fontSize);
768
+ const basePdfY = toPdfY(lineYFromTop, fontHeight, geo.pageHeight);
769
+ const [r, g, b] = hexToRgb(fragment.color);
770
+ const drawX = geo.margins.left + fragment.x;
771
+ // Footnote ref spans render as superscript number, replacing the original text
772
+ if (fragment.footnoteRef) {
773
+ const num = footnoteNumbering?.get(fragment.footnoteRef) ?? '?';
774
+ const superText = String(num);
775
+ const superSize = fragment.fontSize * 0.65;
776
+ const superYOffset = fragment.fontSize * 0.4;
777
+ const superPdfY = basePdfY + superYOffset;
778
+ pdfPage.drawText(superText, {
779
+ x: drawX,
780
+ y: superPdfY,
781
+ size: superSize,
782
+ font: pdfFont,
783
+ color: rgb(r, g, b),
784
+ });
785
+ continue;
786
+ }
787
+ const fragmentPdfY = basePdfY + (fragment.yOffset ?? 0);
788
+ 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);
797
+ drawTextDecoration(pdfPage, drawX, fragWidth, fragmentPdfY, fragment.fontSize, pdfFont, [r, g, b], { underline: fragment.underline ?? false, strikethrough: fragment.strikethrough ?? false });
798
+ if (fragment.url) {
799
+ addLinkAnnotation(pdfDoc, pdfPage, drawX, fragmentPdfY, fragWidth, fragment.fontSize, fragment.url);
800
+ }
801
+ }
802
+ cumY += richLine.lineHeight;
803
+ }
804
+ }
805
+ // ─── Footnote zone rendering ──────────────────────────────────────────────────
806
+ export function renderFootnoteZone(pdfPage, footnoteItems, zoneHeight, fontMap, doc, geo) {
807
+ const { pageHeight, margins, footerHeight, contentWidth } = geo;
808
+ const SEPARATOR_PADDING = 6; // pt above and below the separator line
809
+ // Zone top in PDF coords (Y=0 at bottom of page)
810
+ const zoneTopPdfY = margins.bottom + footerHeight + zoneHeight;
811
+ const separatorY = zoneTopPdfY - SEPARATOR_PADDING;
812
+ // Draw separator line: 1/3 content width, max 120pt
813
+ const lineLength = Math.min(contentWidth * 0.33, 120);
814
+ pdfPage.drawLine({
815
+ start: { x: margins.left, y: separatorY },
816
+ end: { x: margins.left + lineLength, y: separatorY },
817
+ thickness: 0.5,
818
+ color: rgb(0.5, 0.5, 0.5),
819
+ });
820
+ const defaultFontSize = doc.defaultFontSize ?? 12;
821
+ let currentPdfY = separatorY - SEPARATOR_PADDING;
822
+ for (const { def, number } of footnoteItems) {
823
+ const fontSize = def.fontSize ?? Math.max(8, defaultFontSize - 2);
824
+ const lineHeight = fontSize * 1.5;
825
+ const fontFamily = def.fontFamily ?? doc.defaultFont ?? 'Inter';
826
+ const fontKey = buildFontKey(fontFamily, 400, 'normal');
827
+ const pdfFont = fontMap.get(fontKey);
828
+ if (!pdfFont)
829
+ continue;
830
+ currentPdfY -= lineHeight;
831
+ const prefix = `${number}. `;
832
+ const fullText = prefix + def.text;
833
+ pdfPage.drawText(fullText, {
834
+ x: margins.left,
835
+ y: currentPdfY,
836
+ size: fontSize,
837
+ font: pdfFont,
838
+ color: rgb(0.2, 0.2, 0.2),
839
+ });
840
+ currentPdfY -= (def.spaceAfter ?? 4);
841
+ }
842
+ }
843
+ // ─── Header / Footer rendering ────────────────────────────────────────────────
844
+ export function renderHeaderFooter(pdfPage, spec, pageNumber, totalPages, geo, fontMap, position) {
845
+ const text = resolveTokens(spec.text, pageNumber, totalPages);
846
+ const fontSize = spec.fontSize ?? 10;
847
+ const align = spec.align ?? 'center';
848
+ const fontKey = `${spec.fontFamily ?? 'Inter'}-${spec.fontWeight ?? 400}-normal`;
849
+ const pdfFont = fontMap.get(fontKey);
850
+ if (!pdfFont) {
851
+ throw new PretextPdfError('FONT_NOT_LOADED', `${position} font "${fontKey}" not found in fontMap. This is a bug — font validation should have caught this.`);
852
+ }
853
+ const fontHeight = pdfFont.heightAtSize(fontSize);
854
+ let yFromTop;
855
+ if (position === 'header') {
856
+ yFromTop = (geo.margins.top - fontHeight) / 2;
857
+ }
858
+ else {
859
+ yFromTop = geo.pageHeight - geo.margins.bottom + (geo.margins.bottom - fontHeight) / 2;
860
+ }
861
+ const pdfY = toPdfY(yFromTop, fontHeight, geo.pageHeight);
862
+ const textWidth = pdfFont.widthOfTextAtSize(text, fontSize);
863
+ const x = resolveX(align, geo.margins.left, geo.contentWidth, textWidth);
864
+ const [textR, textG, textB] = hexToRgb(spec.color ?? '#666666');
865
+ pdfPage.drawText(text, {
866
+ x,
867
+ y: pdfY,
868
+ size: fontSize,
869
+ font: pdfFont,
870
+ color: rgb(textR, textG, textB),
871
+ });
872
+ // Separator line
873
+ if (position === 'header') {
874
+ const lineY = toPdfY(geo.margins.top - 4, 1, geo.pageHeight);
875
+ pdfPage.drawLine({
876
+ start: { x: geo.margins.left, y: lineY },
877
+ end: { x: geo.margins.left + geo.contentWidth, y: lineY },
878
+ thickness: 0.5,
879
+ color: rgb(0.8, 0.8, 0.8),
880
+ });
881
+ }
882
+ else {
883
+ const lineY = toPdfY(geo.pageHeight - geo.margins.bottom + 4, 1, geo.pageHeight);
884
+ pdfPage.drawLine({
885
+ start: { x: geo.margins.left, y: lineY },
886
+ end: { x: geo.margins.left + geo.contentWidth, y: lineY },
887
+ thickness: 0.5,
888
+ color: rgb(0.8, 0.8, 0.8),
889
+ });
890
+ }
891
+ }
892
+ // ─── Watermark rendering ──────────────────────────────────────────────────
893
+ export function renderWatermark(pdfPage, doc, fontMap, imageMap, geo) {
894
+ const wm = doc.watermark;
895
+ if (!wm)
896
+ return;
897
+ const opacity = wm.opacity ?? 0.3;
898
+ const rotation = wm.rotation ?? -45;
899
+ const { pageWidth, pageHeight } = geo;
900
+ if (wm.text) {
901
+ const fontKey = `${wm.fontFamily ?? doc.defaultFont ?? 'Inter'}-${wm.fontWeight ?? 400}-normal`;
902
+ const pdfFont = fontMap.get(fontKey);
903
+ if (!pdfFont) {
904
+ throw new PretextPdfError('FONT_NOT_LOADED', `Watermark font "${fontKey}" not found in fontMap. This is a bug.`);
905
+ }
906
+ // Auto-compute font size to span ~60% of page diagonal
907
+ const fontSize = wm.fontSize ?? (() => {
908
+ const diagonal = Math.sqrt(pageWidth ** 2 + pageHeight ** 2);
909
+ const widthAt100 = pdfFont.widthOfTextAtSize(wm.text, 100);
910
+ return Math.min(120, (diagonal * 0.6 / widthAt100) * 100);
911
+ })();
912
+ const [r, g, b] = hexToRgb(wm.color ?? '#CCCCCC');
913
+ pdfPage.drawText(wm.text, {
914
+ x: pageWidth / 2,
915
+ y: pageHeight / 2,
916
+ size: fontSize,
917
+ font: pdfFont,
918
+ color: rgb(r, g, b),
919
+ rotate: degrees(rotation),
920
+ opacity,
921
+ });
922
+ }
923
+ if (wm.image) {
924
+ const pdfImage = imageMap.get('watermark');
925
+ if (!pdfImage)
926
+ return;
927
+ const margin = 40;
928
+ pdfPage.drawImage(pdfImage, {
929
+ x: margin,
930
+ y: margin,
931
+ width: pageWidth - margin * 2,
932
+ height: pageHeight - margin * 2,
933
+ opacity,
934
+ });
935
+ }
936
+ }
937
+ //# sourceMappingURL=render-blocks.js.map