pretext-pdf 0.4.6 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +333 -333
  2. package/README.md +3 -3
  3. package/dist/assets.d.ts +3 -0
  4. package/dist/assets.d.ts.map +1 -1
  5. package/dist/assets.js +111 -21
  6. package/dist/assets.js.map +1 -1
  7. package/dist/builder.d.ts +22 -1
  8. package/dist/builder.d.ts.map +1 -1
  9. package/dist/builder.js +38 -1
  10. package/dist/builder.js.map +1 -1
  11. package/dist/errors.d.ts +1 -1
  12. package/dist/errors.d.ts.map +1 -1
  13. package/dist/errors.js.map +1 -1
  14. package/dist/fonts.d.ts.map +1 -1
  15. package/dist/fonts.js +36 -1
  16. package/dist/fonts.js.map +1 -1
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +36 -15
  20. package/dist/index.js.map +1 -1
  21. package/dist/measure-blocks.d.ts +25 -0
  22. package/dist/measure-blocks.d.ts.map +1 -0
  23. package/dist/measure-blocks.js +1019 -0
  24. package/dist/measure-blocks.js.map +1 -0
  25. package/dist/measure-text.d.ts +53 -0
  26. package/dist/measure-text.d.ts.map +1 -0
  27. package/dist/measure-text.js +435 -0
  28. package/dist/measure-text.js.map +1 -0
  29. package/dist/measure.d.ts +15 -35
  30. package/dist/measure.d.ts.map +1 -1
  31. package/dist/measure.js +42 -1066
  32. package/dist/measure.js.map +1 -1
  33. package/dist/paginate.d.ts.map +1 -1
  34. package/dist/paginate.js +14 -12
  35. package/dist/paginate.js.map +1 -1
  36. package/dist/render-blocks.d.ts +24 -0
  37. package/dist/render-blocks.d.ts.map +1 -0
  38. package/dist/render-blocks.js +937 -0
  39. package/dist/render-blocks.js.map +1 -0
  40. package/dist/render-extras.d.ts +18 -0
  41. package/dist/render-extras.d.ts.map +1 -0
  42. package/dist/render-extras.js +325 -0
  43. package/dist/render-extras.js.map +1 -0
  44. package/dist/render-utils.d.ts +59 -0
  45. package/dist/render-utils.d.ts.map +1 -0
  46. package/dist/render-utils.js +219 -0
  47. package/dist/render-utils.js.map +1 -0
  48. package/dist/render.d.ts.map +1 -1
  49. package/dist/render.js +10 -1372
  50. package/dist/render.js.map +1 -1
  51. package/dist/rich-text.d.ts +4 -0
  52. package/dist/rich-text.d.ts.map +1 -1
  53. package/dist/rich-text.js +4 -0
  54. package/dist/rich-text.js.map +1 -1
  55. package/dist/types.d.ts +108 -5
  56. package/dist/types.d.ts.map +1 -1
  57. package/dist/validate.d.ts.map +1 -1
  58. package/dist/validate.js +195 -15
  59. package/dist/validate.js.map +1 -1
  60. package/docs/screenshots/showcase-invoice.png +0 -0
  61. package/docs/screenshots/showcase-report.png +0 -0
  62. package/docs/screenshots/showcase-resume.png +0 -0
  63. package/package.json +130 -128
package/dist/render.js CHANGED
@@ -1,6 +1,7 @@
1
- import { PDFName, PDFString, PDFNull, rgb, degrees } from '@cantoo/pdf-lib';
2
- import { buildFontKey } from './measure.js';
3
1
  import { PretextPdfError } from './errors.js';
2
+ import { renderTextBlock, renderListItem, renderTable, renderImage, renderFloatBlock, renderFloatGroup, renderHR, renderCodeBlock, renderRichParagraph, renderBlockquote, renderCallout, renderWatermark, renderFootnoteZone, renderHeaderFooter, } from './render-blocks.js';
3
+ import { buildOutlineTree, renderTocEntry, renderFormField, renderSignaturePlaceholder, } from './render-extras.js';
4
+ import { addStickyNoteAnnotation } from './render-utils.js';
4
5
  /**
5
6
  * Stage 5: Render.
6
7
  * Takes the paginated document + pre-initialized pdfDoc (with fonts already embedded)
@@ -44,8 +45,8 @@ export async function renderDocument(paginatedDoc, doc, fontMap, imageMap, pdfDo
44
45
  if (doc.bookmarks !== false) {
45
46
  buildOutlineTree(pdfDoc, paginatedDoc.headings, doc.bookmarks);
46
47
  }
47
- // Phase 8E: render signature placeholder if configured
48
- if (doc.signature) {
48
+ // Phase 8E: render signature placeholder if configured (skip if invisible)
49
+ if (doc.signature && !doc.signature.invisible) {
49
50
  renderSignaturePlaceholder(doc.signature, pdfDoc, fontMap, geo);
50
51
  }
51
52
  // Phase 8B: finalize form field appearances
@@ -128,6 +129,8 @@ function renderBlock(pdfPage, pagedBlock, geo, fontMap, imageMap, pdfDoc, footno
128
129
  case 'comment': {
129
130
  const commentEl = element;
130
131
  const absY = pagedBlock.yFromTop + geo.margins.top + geo.headerHeight;
132
+ // Sticky-note annotations are point annotations (no height), so we don't subtract
133
+ // element height — toPdfY(absY, 0, pageHeight) = pageHeight - absY is correct.
131
134
  const pdfY = geo.pageHeight - absY;
132
135
  addStickyNoteAnnotation(pdfDoc, pdfPage, geo.margins.left, pdfY, commentEl.contents, commentEl.author, commentEl.color, commentEl.open);
133
136
  return;
@@ -136,1376 +139,11 @@ function renderBlock(pdfPage, pagedBlock, geo, fontMap, imageMap, pdfDoc, footno
136
139
  renderFormField(pagedBlock, pdfPage, pdfDoc, fontMap, geo, pagedBlock.yFromTop);
137
140
  return;
138
141
  }
142
+ case 'float-group':
143
+ renderFloatGroup(pdfPage, pagedBlock, geo, fontMap, imageMap, pdfDoc);
144
+ return;
139
145
  case 'footnote-def':
140
146
  return; // footnote defs are rendered via renderFootnoteZone, not inline
141
147
  }
142
148
  }
143
- // ─── Text block rendering (paragraph + heading) ───────────────────────────────
144
- function renderTextBlock(pdfPage, pagedBlock, geo, fontMap, pdfDoc) {
145
- const { measuredBlock, startLine, endLine, yFromTop } = pagedBlock;
146
- const { element } = measuredBlock;
147
- const lines = measuredBlock.lines.slice(startLine, endLine);
148
- if (lines.length === 0)
149
- return;
150
- const pdfFont = fontMap.get(measuredBlock.fontKey);
151
- if (!pdfFont) {
152
- throw new PretextPdfError('FONT_NOT_LOADED', `Font "${measuredBlock.fontKey}" not found in fontMap. This is a bug — font validation should have caught this.`);
153
- }
154
- const colorHex = (element.type === 'paragraph' || element.type === 'heading')
155
- ? (element.color ?? '#000000')
156
- : '#000000';
157
- const [r, g, b] = hexToRgb(colorHex);
158
- const alignRaw = (element.type === 'paragraph' || element.type === 'heading')
159
- ? (element.align ?? (measuredBlock.isRTL ? 'right' : 'left'))
160
- : 'left';
161
- // For resolveX, treat 'justify' as 'left' (justify is handled by drawJustifiedLine)
162
- const align = alignRaw === 'justify' ? 'left' : alignRaw;
163
- const fontHeight = pdfFont.heightAtSize(measuredBlock.fontSize);
164
- // Draw background color for paragraph and heading (if set)
165
- if ((element.type === 'paragraph' || element.type === 'heading') && element.bgColor) {
166
- const columnData = measuredBlock.columnData;
167
- const chunkHeight = columnData
168
- ? columnData.linesPerColumn * measuredBlock.lineHeight
169
- : lines.length * measuredBlock.lineHeight;
170
- const boxAbsY = yFromTop + geo.margins.top + geo.headerHeight;
171
- const boxPdfY = toPdfY(boxAbsY, chunkHeight, geo.pageHeight);
172
- const [bgR, bgG, bgB] = hexToRgb(element.bgColor);
173
- pdfPage.drawRectangle({
174
- x: geo.margins.left,
175
- y: boxPdfY,
176
- width: geo.contentWidth,
177
- height: chunkHeight,
178
- color: rgb(bgR, bgG, bgB),
179
- borderWidth: 0,
180
- });
181
- }
182
- // Multi-column layout
183
- const columnData = measuredBlock.columnData;
184
- if (columnData) {
185
- const { columnCount, columnGap, columnWidth, linesPerColumn } = columnData;
186
- for (let i = 0; i < lines.length; i++) {
187
- const line = lines[i];
188
- if (line.text === '')
189
- continue;
190
- const colIdx = Math.floor(i / linesPerColumn);
191
- const lineInCol = i % linesPerColumn;
192
- const lineYFromTop = yFromTop + (lineInCol * measuredBlock.lineHeight);
193
- const absoluteYFromTop = lineYFromTop + geo.margins.top + geo.headerHeight;
194
- const pdfY = toPdfY(absoluteYFromTop, fontHeight, geo.pageHeight);
195
- const colX = geo.margins.left + colIdx * (columnWidth + columnGap);
196
- const trimmedText = line.text.trimEnd();
197
- const alignWidth = pdfFont.widthOfTextAtSize(trimmedText, measuredBlock.fontSize);
198
- const x = resolveX(align, colX, columnWidth, alignWidth);
199
- pdfPage.drawText(trimmedText, {
200
- x,
201
- y: pdfY,
202
- size: measuredBlock.fontSize,
203
- font: pdfFont,
204
- color: rgb(r, g, b),
205
- });
206
- // Phase 8G: Wire paragraph.url and heading.url for clickable links (multi-column)
207
- if ((element.type === 'paragraph' || element.type === 'heading') && element.url) {
208
- const lineWidth = pdfFont.widthOfTextAtSize(trimmedText, measuredBlock.fontSize);
209
- addLinkAnnotation(pdfDoc, pdfPage, x, pdfY, lineWidth, measuredBlock.fontSize, element.url);
210
- }
211
- }
212
- return; // skip standard single-column path
213
- }
214
- // Single-column layout (standard path)
215
- for (let i = 0; i < lines.length; i++) {
216
- const line = lines[i];
217
- if (line.text === '')
218
- continue; // empty lines from \n\n — occupy space, draw nothing
219
- const lineYFromTop = yFromTop + (i * measuredBlock.lineHeight);
220
- const absoluteYFromTop = lineYFromTop + geo.margins.top + geo.headerHeight;
221
- const pdfY = toPdfY(absoluteYFromTop, fontHeight, geo.pageHeight);
222
- let trimmedText = line.text.trimEnd();
223
- const isLastLine = i === lines.length - 1;
224
- // Phase 8H: smallCaps — uppercase text at 80% font size
225
- const hasSmallCaps = (element.type === 'paragraph' || element.type === 'heading') && element.smallCaps === true;
226
- const effectiveFontSize = hasSmallCaps ? measuredBlock.fontSize * 0.8 : measuredBlock.fontSize;
227
- if (hasSmallCaps)
228
- trimmedText = trimmedText.toUpperCase();
229
- // Phase 8H: letterSpacing — draw char by char
230
- const letterSpacing = ((element.type === 'paragraph' || element.type === 'heading') && element.letterSpacing > 0)
231
- ? element.letterSpacing
232
- : 0;
233
- let drawX;
234
- if (alignRaw === 'justify' && letterSpacing === 0) {
235
- drawJustifiedLine(pdfPage, trimmedText, isLastLine, geo.margins.left, pdfY, geo.contentWidth, effectiveFontSize, pdfFont, rgb(r, g, b));
236
- drawX = geo.margins.left; // used for decoration below
237
- }
238
- else if (letterSpacing > 0) {
239
- const alignWidth = pdfFont.widthOfTextAtSize(trimmedText, effectiveFontSize) + letterSpacing * (trimmedText.length - 1);
240
- drawX = resolveX(align, geo.margins.left, geo.contentWidth, alignWidth);
241
- let cx = drawX;
242
- for (const ch of trimmedText) {
243
- pdfPage.drawText(ch, { x: cx, y: pdfY, size: effectiveFontSize, font: pdfFont, color: rgb(r, g, b) });
244
- cx += pdfFont.widthOfTextAtSize(ch, effectiveFontSize) + letterSpacing;
245
- }
246
- }
247
- else {
248
- const alignWidth = pdfFont.widthOfTextAtSize(trimmedText, effectiveFontSize);
249
- drawX = resolveX(align, geo.margins.left, geo.contentWidth, alignWidth);
250
- pdfPage.drawText(trimmedText, {
251
- x: drawX,
252
- y: pdfY,
253
- size: effectiveFontSize,
254
- font: pdfFont,
255
- color: rgb(r, g, b),
256
- });
257
- }
258
- if ((element.type === 'paragraph' || element.type === 'heading') && (element.underline || element.strikethrough)) {
259
- const lineWidth = pdfFont.widthOfTextAtSize(trimmedText, effectiveFontSize) + (letterSpacing > 0 ? letterSpacing * (trimmedText.length - 1) : 0);
260
- drawTextDecoration(pdfPage, drawX, lineWidth, pdfY, effectiveFontSize, pdfFont, [r, g, b], { underline: element.underline ?? false, strikethrough: element.strikethrough ?? false });
261
- }
262
- // Phase 8G: Wire paragraph.url and heading.url for clickable links
263
- if ((element.type === 'paragraph' || element.type === 'heading') && element.url) {
264
- const lineWidth = pdfFont.widthOfTextAtSize(trimmedText, effectiveFontSize) + (letterSpacing > 0 ? letterSpacing * (trimmedText.length - 1) : 0);
265
- addLinkAnnotation(pdfDoc, pdfPage, drawX, pdfY, lineWidth, effectiveFontSize, element.url);
266
- }
267
- }
268
- // Phase 8A: annotation on paragraph/heading — attach sticky note at top of block
269
- if ((element.type === 'paragraph' || element.type === 'heading') && element.annotation) {
270
- const ann = element.annotation;
271
- const absY = yFromTop + geo.margins.top + geo.headerHeight;
272
- const annotPdfY = geo.pageHeight - absY;
273
- addStickyNoteAnnotation(pdfDoc, pdfPage, geo.margins.left, annotPdfY, ann.contents, ann.author, ann.color, ann.open);
274
- }
275
- }
276
- // ─── List item rendering ──────────────────────────────────────────────────────
277
- function renderListItem(pdfPage, pagedBlock, geo, fontMap) {
278
- const { measuredBlock, startLine, endLine, yFromTop } = pagedBlock;
279
- const listItemData = measuredBlock.listItemData;
280
- const lines = measuredBlock.lines.slice(startLine, endLine);
281
- if (lines.length === 0)
282
- return;
283
- const pdfFont = fontMap.get(measuredBlock.fontKey);
284
- if (!pdfFont) {
285
- throw new PretextPdfError('FONT_NOT_LOADED', `Font "${measuredBlock.fontKey}" not found in fontMap. This is a bug — font validation should have caught this.`);
286
- }
287
- const fontHeight = pdfFont.heightAtSize(measuredBlock.fontSize);
288
- const [cr, cg, cb] = hexToRgb(listItemData.color);
289
- // RTL support: mirror list layout if detected
290
- const isRTL = measuredBlock.isRTL ?? false;
291
- let textStartX;
292
- let textAreaWidth;
293
- if (isRTL) {
294
- // RTL: marker on the right, text area on the left
295
- textAreaWidth = geo.contentWidth - listItemData.indent - listItemData.markerWidth;
296
- textStartX = geo.margins.left + listItemData.indent;
297
- }
298
- else {
299
- // LTR: marker on the left, text area on the right
300
- textStartX = geo.margins.left + listItemData.indent + listItemData.markerWidth;
301
- textAreaWidth = geo.contentWidth - listItemData.indent - listItemData.markerWidth;
302
- }
303
- // Draw marker on the first line of this item (only if startLine === 0)
304
- // If startLine > 0, the item continued from a previous page — no marker
305
- if (startLine === 0) {
306
- const markerText = listItemData.marker;
307
- const markerMeasuredWidth = pdfFont.widthOfTextAtSize(markerText, measuredBlock.fontSize);
308
- let markerX;
309
- if (isRTL) {
310
- // RTL: marker on the right, right-aligned within marker column
311
- markerX = geo.margins.left + geo.contentWidth - listItemData.indent - markerMeasuredWidth;
312
- }
313
- else {
314
- // LTR: marker on the left, right-aligned within marker column
315
- markerX = geo.margins.left + listItemData.indent + listItemData.markerWidth - markerMeasuredWidth;
316
- }
317
- const firstLineAbsY = yFromTop + geo.margins.top + geo.headerHeight;
318
- const markerPdfY = toPdfY(firstLineAbsY, fontHeight, geo.pageHeight);
319
- pdfPage.drawText(markerText, {
320
- x: markerX,
321
- y: markerPdfY,
322
- size: measuredBlock.fontSize,
323
- font: pdfFont,
324
- color: rgb(cr, cg, cb),
325
- });
326
- }
327
- // Draw all text lines, indented to align with body text column
328
- // RTL lists are right-aligned, LTR lists are left-aligned
329
- const textAlign = isRTL ? 'right' : 'left';
330
- for (let i = 0; i < lines.length; i++) {
331
- const line = lines[i];
332
- if (line.text === '')
333
- continue;
334
- const lineYFromTop = yFromTop + (i * measuredBlock.lineHeight);
335
- const absoluteYFromTop = lineYFromTop + geo.margins.top + geo.headerHeight;
336
- const pdfY = toPdfY(absoluteYFromTop, fontHeight, geo.pageHeight);
337
- const trimmedText = line.text.trimEnd();
338
- const lineWidth = pdfFont.widthOfTextAtSize(trimmedText, measuredBlock.fontSize);
339
- const x = resolveX(textAlign, textStartX, textAreaWidth, lineWidth);
340
- pdfPage.drawText(trimmedText, {
341
- x,
342
- y: pdfY,
343
- size: measuredBlock.fontSize,
344
- font: pdfFont,
345
- color: rgb(cr, cg, cb),
346
- });
347
- }
348
- }
349
- // ─── Table rendering ──────────────────────────────────────────────────────────
350
- function renderTable(pdfPage, pagedBlock, geo, fontMap) {
351
- const { measuredBlock, yFromTop } = pagedBlock;
352
- const tableData = measuredBlock.tableData;
353
- const startRow = pagedBlock.startRow ?? 0;
354
- const endRow = pagedBlock.endRow ?? tableData.rows.length - tableData.headerRowCount;
355
- const { columnWidths, cellPaddingH, cellPaddingV, borderWidth, borderColor, headerBgColor } = tableData;
356
- // Collect the rows to render for this chunk: headers (always) + body slice
357
- const headerRows = tableData.rows.slice(0, tableData.headerRowCount);
358
- const bodyRows = tableData.rows.slice(tableData.headerRowCount);
359
- const chunkBodyRows = bodyRows.slice(startRow, endRow);
360
- const chunkRows = [...headerRows, ...chunkBodyRows];
361
- const chunkStartAbsY = yFromTop + geo.margins.top + geo.headerHeight;
362
- const totalTableWidth = columnWidths.reduce((s, w) => s + w, 0);
363
- const totalChunkHeight = chunkRows.reduce((s, r) => s + r.height, 0);
364
- // ── Pass 1: Cell backgrounds ──────────────────────────────────────────────
365
- let rowAbsY = chunkStartAbsY;
366
- for (const row of chunkRows) {
367
- const rowPdfY = toPdfY(rowAbsY, row.height, geo.pageHeight);
368
- let cellX = geo.margins.left;
369
- for (const cell of row.cells) {
370
- const bgColorHex = cell.bgColor ?? (row.isHeader ? headerBgColor : undefined);
371
- if (bgColorHex) {
372
- const [r, g, b] = hexToRgb(bgColorHex);
373
- // Use mergedWidth for colspan support
374
- pdfPage.drawRectangle({ x: cellX, y: rowPdfY, width: cell.mergedWidth, height: row.height, color: rgb(r, g, b), borderWidth: 0 });
375
- }
376
- cellX += cell.mergedWidth;
377
- }
378
- rowAbsY += row.height;
379
- }
380
- // ── Pass 2: Grid (border-collapse model) ─────────────────────────────────
381
- // Draw outer border + internal lines — single-thickness at every edge.
382
- if (borderWidth > 0) {
383
- const [br, bg, bb] = hexToRgb(borderColor);
384
- const borderRgb = rgb(br, bg, bb);
385
- const tableTopPdfY = toPdfY(chunkStartAbsY, totalChunkHeight, geo.pageHeight);
386
- // Outer border rectangle (no fill)
387
- pdfPage.drawRectangle({
388
- x: geo.margins.left,
389
- y: tableTopPdfY,
390
- width: totalTableWidth,
391
- height: totalChunkHeight,
392
- borderColor: borderRgb,
393
- borderWidth,
394
- });
395
- // Internal horizontal lines (row separators, between rows, not at edges)
396
- let lineAbsY = chunkStartAbsY;
397
- for (let ri = 0; ri < chunkRows.length - 1; ri++) {
398
- lineAbsY += chunkRows[ri].height;
399
- const linePdfY = geo.pageHeight - lineAbsY;
400
- pdfPage.drawLine({
401
- start: { x: geo.margins.left, y: linePdfY },
402
- end: { x: geo.margins.left + totalTableWidth, y: linePdfY },
403
- thickness: borderWidth,
404
- color: borderRgb,
405
- });
406
- }
407
- // Internal vertical lines (column separators, between columns, not at edges)
408
- // With colspan support: only draw lines at boundaries that are NOT spanned by merged cells
409
- // Each row may have different active boundaries due to different colspan patterns
410
- let colBoundaryX = geo.margins.left;
411
- for (let ci = 0; ci < columnWidths.length; ci++) {
412
- colBoundaryX += columnWidths[ci];
413
- // Check if this boundary (between column ci and ci+1) is active in ANY row
414
- const boundaryIndex = ci; // boundary at index ci is between columns ci and ci+1
415
- let isActive = false;
416
- for (const row of chunkRows) {
417
- if (row.activeBoundaries.includes(boundaryIndex)) {
418
- isActive = true;
419
- break;
420
- }
421
- }
422
- if (isActive && ci < columnWidths.length - 1) {
423
- const chunkTopPdfY = geo.pageHeight - chunkStartAbsY;
424
- const chunkBottomPdfY = geo.pageHeight - (chunkStartAbsY + totalChunkHeight);
425
- pdfPage.drawLine({
426
- start: { x: colBoundaryX, y: chunkTopPdfY },
427
- end: { x: colBoundaryX, y: chunkBottomPdfY },
428
- thickness: borderWidth,
429
- color: borderRgb,
430
- });
431
- }
432
- }
433
- }
434
- // ── Pass 3: Cell text ─────────────────────────────────────────────────────
435
- rowAbsY = chunkStartAbsY;
436
- for (const row of chunkRows) {
437
- let cellX = geo.margins.left;
438
- for (const cell of row.cells) {
439
- if (cell.lines.length > 0) {
440
- const pdfFont = fontMap.get(cell.fontKey);
441
- if (!pdfFont) {
442
- 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.`);
443
- }
444
- const fontHeight = pdfFont.heightAtSize(cell.fontSize);
445
- const [r, g, b] = hexToRgb(cell.color);
446
- const textAreaX = cellX + cellPaddingH;
447
- // Use mergedWidth for colspan support
448
- const textAreaWidth = cell.mergedWidth - 2 * cellPaddingH;
449
- for (let li = 0; li < cell.lines.length; li++) {
450
- const line = cell.lines[li];
451
- if (line.text === '')
452
- continue;
453
- const lineYFromPageTop = rowAbsY + cellPaddingV + li * cell.lineHeight;
454
- const pdfY = toPdfY(lineYFromPageTop, fontHeight, geo.pageHeight);
455
- const trimmedText = line.text.trimEnd();
456
- const lineWidth = pdfFont.widthOfTextAtSize(trimmedText, cell.fontSize);
457
- const x = resolveX(cell.align, textAreaX, textAreaWidth, lineWidth);
458
- pdfPage.drawText(trimmedText, { x, y: pdfY, size: cell.fontSize, font: pdfFont, color: rgb(r, g, b) });
459
- }
460
- }
461
- cellX += cell.mergedWidth;
462
- }
463
- rowAbsY += row.height;
464
- }
465
- }
466
- // ─── Image rendering ──────────────────────────────────────────────────────────
467
- function renderImage(pdfPage, pagedBlock, geo, imageMap) {
468
- const { measuredBlock, yFromTop } = pagedBlock;
469
- const imageData = measuredBlock.imageData;
470
- const pdfImage = imageMap.get(imageData.imageKey);
471
- if (!pdfImage) {
472
- throw new PretextPdfError('IMAGE_LOAD_FAILED', `Image "${imageData.imageKey}" not found in imageMap. This is a bug — image loading should have caught this.`);
473
- }
474
- const absoluteYFromTop = yFromTop + geo.margins.top + geo.headerHeight;
475
- // drawImage places the BOTTOM-LEFT corner at (x, y) — use toPdfY with renderHeight
476
- const pdfY = toPdfY(absoluteYFromTop, imageData.renderHeight, geo.pageHeight);
477
- const x = resolveX(imageData.align, geo.margins.left, geo.contentWidth, imageData.renderWidth);
478
- pdfPage.drawImage(pdfImage, {
479
- x,
480
- y: pdfY,
481
- width: imageData.renderWidth,
482
- height: imageData.renderHeight,
483
- });
484
- }
485
- // ─── Float image block rendering ─────────────────────────────────────────────
486
- function renderFloatBlock(pdfPage, pagedBlock, geo, fontMap, imageMap, pdfDoc) {
487
- const { measuredBlock, yFromTop } = pagedBlock;
488
- const fd = measuredBlock.floatData;
489
- const baseAbsY = yFromTop + geo.margins.top + geo.headerHeight;
490
- // Draw image
491
- const pdfImage = imageMap.get(fd.imageKey);
492
- if (!pdfImage)
493
- 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.`);
494
- const imgX = geo.margins.left + fd.imageColX;
495
- const imgPdfY = toPdfY(baseAbsY, fd.imageRenderHeight, geo.pageHeight);
496
- pdfPage.drawImage(pdfImage, {
497
- x: imgX,
498
- y: imgPdfY,
499
- width: fd.imageRenderWidth,
500
- height: fd.imageRenderHeight,
501
- });
502
- // Draw text lines
503
- const pdfFont = fontMap.get(fd.textFontKey);
504
- if (!pdfFont)
505
- 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.`);
506
- const fontHeight = pdfFont.heightAtSize(fd.textFontSize);
507
- const [r, g, b] = hexToRgb(fd.textColor);
508
- const textBaseX = geo.margins.left + fd.textColX;
509
- for (let i = 0; i < fd.textLines.length; i++) {
510
- const line = fd.textLines[i];
511
- if (!line.text || line.text === '')
512
- continue;
513
- const lineAbsY = baseAbsY + (i * fd.textLineHeight);
514
- const pdfY = toPdfY(lineAbsY, fontHeight, geo.pageHeight);
515
- pdfPage.drawText(line.text.trimEnd(), {
516
- x: textBaseX,
517
- y: pdfY,
518
- size: fd.textFontSize,
519
- font: pdfFont,
520
- color: rgb(r, g, b),
521
- });
522
- }
523
- }
524
- // ─── Horizontal rule rendering ────────────────────────────────────────────────
525
- function renderHR(pdfPage, pagedBlock, geo) {
526
- const { measuredBlock, yFromTop } = pagedBlock;
527
- const element = measuredBlock.element;
528
- const spaceAbove = element.spaceAbove ?? 12;
529
- const thickness = element.thickness ?? 0.5;
530
- const colorHex = element.color ?? '#cccccc';
531
- // Line sits at the middle of the HR element (after spaceAbove, before spaceBelow)
532
- const lineYFromTop = yFromTop + spaceAbove + geo.margins.top + geo.headerHeight;
533
- const pdfY = toPdfY(lineYFromTop, thickness / 2, geo.pageHeight);
534
- const [r, g, b] = hexToRgb(colorHex);
535
- pdfPage.drawLine({
536
- start: { x: geo.margins.left, y: pdfY },
537
- end: { x: geo.margins.left + geo.contentWidth, y: pdfY },
538
- thickness,
539
- color: rgb(r, g, b),
540
- });
541
- }
542
- // ─── Code block rendering ─────────────────────────────────────────────────────
543
- function renderCodeBlock(pdfPage, pagedBlock, geo, fontMap) {
544
- const { measuredBlock, startLine, endLine, yFromTop } = pagedBlock;
545
- const element = measuredBlock.element;
546
- const padding = measuredBlock.codePadding ?? 8;
547
- const bgColorHex = element.bgColor ?? '#f6f8fa';
548
- const textColorHex = element.color ?? '#24292f';
549
- // Slice the lines being rendered on this page chunk
550
- const lines = measuredBlock.lines.slice(startLine, endLine);
551
- const lineHeight = measuredBlock.lineHeight;
552
- const fontSize = measuredBlock.fontSize;
553
- // Compute per-chunk padding (only apply padding at the edge of the code block)
554
- const isFirstChunk = startLine === 0;
555
- const isLastChunk = endLine === measuredBlock.lines.length;
556
- const paddingTop = isFirstChunk ? padding : 0;
557
- const paddingBottom = isLastChunk ? padding : 0;
558
- const visibleHeight = lines.length * lineHeight + paddingTop + paddingBottom;
559
- // ── Background box ──────────────────────────────────────────────────────────
560
- const boxAbsY = yFromTop + geo.margins.top + geo.headerHeight;
561
- const boxPdfY = toPdfY(boxAbsY, visibleHeight, geo.pageHeight);
562
- const [bgR, bgG, bgB] = hexToRgb(bgColorHex);
563
- pdfPage.drawRectangle({
564
- x: geo.margins.left,
565
- y: boxPdfY,
566
- width: geo.contentWidth,
567
- height: visibleHeight,
568
- color: rgb(bgR, bgG, bgB),
569
- borderWidth: 0,
570
- });
571
- // ── Text lines ──────────────────────────────────────────────────────────────
572
- const pdfFont = fontMap.get(measuredBlock.fontKey);
573
- if (!pdfFont || lines.length === 0)
574
- return;
575
- const fontHeight = pdfFont.heightAtSize(fontSize);
576
- const [r, g, b] = hexToRgb(textColorHex);
577
- const textX = geo.margins.left + padding;
578
- for (let i = 0; i < lines.length; i++) {
579
- const line = lines[i];
580
- const lineYFromPageTop = boxAbsY + paddingTop + i * lineHeight;
581
- const pdfY = toPdfY(lineYFromPageTop, fontHeight, geo.pageHeight);
582
- pdfPage.drawText(line.text.trimEnd(), {
583
- x: textX,
584
- y: pdfY,
585
- size: fontSize,
586
- font: pdfFont,
587
- color: rgb(r, g, b),
588
- });
589
- }
590
- }
591
- // ─── Blockquote rendering ─────────────────────────────────────────────────────
592
- function renderBlockquote(pdfPage, pagedBlock, geo, fontMap) {
593
- const { measuredBlock, startLine, endLine, yFromTop } = pagedBlock;
594
- const element = measuredBlock.element;
595
- const paddingV = measuredBlock.blockquotePaddingV ?? 10;
596
- const paddingH = measuredBlock.blockquotePaddingH ?? 16;
597
- const borderWidth = measuredBlock.blockquoteBorderWidth ?? 3;
598
- const bgColorHex = element.bgColor ?? '#f8f9fa';
599
- const borderColorHex = element.borderColor ?? '#0070f3';
600
- const textColorHex = element.color ?? '#333333';
601
- const alignRaw = element.align ?? (measuredBlock.isRTL ? 'right' : 'left');
602
- const align = alignRaw === 'justify' ? 'left' : alignRaw;
603
- const lines = measuredBlock.lines.slice(startLine, endLine);
604
- const lineHeight = measuredBlock.lineHeight;
605
- const fontSize = measuredBlock.fontSize;
606
- // Compute per-chunk padding (only at the edge of the block, like code)
607
- const isFirstChunk = startLine === 0;
608
- const isLastChunk = endLine === measuredBlock.lines.length;
609
- const paddingTop = isFirstChunk ? paddingV : 0;
610
- const paddingBottom = isLastChunk ? paddingV : 0;
611
- const visibleHeight = lines.length * lineHeight + paddingTop + paddingBottom;
612
- const boxAbsY = yFromTop + geo.margins.top + geo.headerHeight;
613
- const boxPdfY = toPdfY(boxAbsY, visibleHeight, geo.pageHeight);
614
- // ── Background box ──────────────────────────────────────────────────────────
615
- const [bgR, bgG, bgB] = hexToRgb(bgColorHex);
616
- pdfPage.drawRectangle({
617
- x: geo.margins.left,
618
- y: boxPdfY,
619
- width: geo.contentWidth,
620
- height: visibleHeight,
621
- color: rgb(bgR, bgG, bgB),
622
- borderWidth: 0,
623
- });
624
- // ── Left border stripe ──────────────────────────────────────────────────────
625
- const [bdR, bdG, bdB] = hexToRgb(borderColorHex);
626
- pdfPage.drawRectangle({
627
- x: geo.margins.left,
628
- y: boxPdfY,
629
- width: borderWidth,
630
- height: visibleHeight,
631
- color: rgb(bdR, bdG, bdB),
632
- borderWidth: 0,
633
- });
634
- // ── Text lines ──────────────────────────────────────────────────────────────
635
- const pdfFont = fontMap.get(measuredBlock.fontKey);
636
- if (!pdfFont || lines.length === 0)
637
- return;
638
- const fontHeight = pdfFont.heightAtSize(fontSize);
639
- const [r, g, b] = hexToRgb(textColorHex);
640
- const textStartX = geo.margins.left + borderWidth + paddingH;
641
- const textAreaWidth = geo.contentWidth - borderWidth - 2 * paddingH;
642
- for (let i = 0; i < lines.length; i++) {
643
- const line = lines[i];
644
- if (line.text === '')
645
- continue;
646
- const lineYFromPageTop = boxAbsY + paddingTop + i * lineHeight;
647
- const pdfY = toPdfY(lineYFromPageTop, fontHeight, geo.pageHeight);
648
- const trimmedText = line.text.trimEnd();
649
- const isLastLine = i === lines.length - 1;
650
- let drawX;
651
- if (alignRaw === 'justify') {
652
- drawJustifiedLine(pdfPage, trimmedText, isLastLine, textStartX, pdfY, textAreaWidth, fontSize, pdfFont, rgb(r, g, b));
653
- drawX = textStartX;
654
- }
655
- else {
656
- const lineWidth = pdfFont.widthOfTextAtSize(trimmedText, fontSize);
657
- drawX = resolveX(align, textStartX, textAreaWidth, lineWidth);
658
- pdfPage.drawText(trimmedText, {
659
- x: drawX,
660
- y: pdfY,
661
- size: fontSize,
662
- font: pdfFont,
663
- color: rgb(r, g, b),
664
- });
665
- }
666
- if (element.underline || element.strikethrough) {
667
- const lineWidth = pdfFont.widthOfTextAtSize(trimmedText, fontSize);
668
- drawTextDecoration(pdfPage, drawX, lineWidth, pdfY, fontSize, pdfFont, [r, g, b], { underline: element.underline ?? false, strikethrough: element.strikethrough ?? false });
669
- }
670
- }
671
- }
672
- // ─── Callout rendering (Phase 8D) ────────────────────────────────────────────
673
- function renderCallout(pdfPage, pagedBlock, geo, fontMap) {
674
- const { measuredBlock, startLine, endLine, yFromTop } = pagedBlock;
675
- const el = measuredBlock.element;
676
- const cd = measuredBlock.calloutData;
677
- if (!cd)
678
- return;
679
- const { paddingH, paddingV, borderColor, backgroundColor, titleColor, color, titleText } = cd;
680
- const isFirstChunk = startLine === 0;
681
- const isLastChunk = endLine === measuredBlock.lines.length;
682
- const lines = measuredBlock.lines.slice(startLine, endLine);
683
- const fs = measuredBlock.fontSize;
684
- const lh = measuredBlock.lineHeight;
685
- const font = fontMap.get(measuredBlock.fontKey) ?? [...fontMap.values()][0];
686
- if (!font)
687
- return;
688
- const titleH = isFirstChunk && titleText ? cd.titleHeight : 0;
689
- const topPad = isFirstChunk ? paddingV : 0;
690
- const bottomPad = isLastChunk ? paddingV : 0;
691
- const chunkHeight = topPad + titleH + lines.length * lh + bottomPad;
692
- const boxAbsY = yFromTop + geo.margins.top + geo.headerHeight;
693
- const boxPdfY = geo.pageHeight - boxAbsY - chunkHeight;
694
- // Background
695
- const [bgR, bgG, bgB] = hexToRgb(backgroundColor);
696
- pdfPage.drawRectangle({
697
- x: geo.margins.left,
698
- y: boxPdfY,
699
- width: geo.contentWidth,
700
- height: chunkHeight,
701
- color: rgb(bgR, bgG, bgB),
702
- borderWidth: 0,
703
- });
704
- // Left border stripe (3pt wide)
705
- const [bdR, bdG, bdB] = hexToRgb(borderColor);
706
- pdfPage.drawRectangle({
707
- x: geo.margins.left,
708
- y: boxPdfY,
709
- width: 3,
710
- height: chunkHeight,
711
- color: rgb(bdR, bdG, bdB),
712
- borderWidth: 0,
713
- });
714
- const fontHeight = font.heightAtSize(fs);
715
- let currentAbsY = boxAbsY + topPad;
716
- // Draw title if first chunk
717
- if (isFirstChunk && titleText) {
718
- // Try to get bold font variant by modifying the fontKey
719
- const boldFontKey = measuredBlock.fontKey.replace(/-400-/, '-700-');
720
- const titleFont = fontMap.get(boldFontKey) ?? font;
721
- const [tR, tG, tB] = hexToRgb(titleColor);
722
- const titlePdfY = geo.pageHeight - currentAbsY - fontHeight - (fs * 1.4 - fs) / 2;
723
- pdfPage.drawText(titleText, {
724
- x: geo.margins.left + paddingH,
725
- y: titlePdfY,
726
- size: fs,
727
- font: titleFont,
728
- color: rgb(tR, tG, tB),
729
- });
730
- currentAbsY += titleH;
731
- }
732
- // Draw content lines
733
- const [tR, tG, tB] = hexToRgb(color);
734
- for (let i = 0; i < lines.length; i++) {
735
- const line = lines[i];
736
- if (line.text === '') {
737
- currentAbsY += lh;
738
- continue;
739
- }
740
- const linePdfY = geo.pageHeight - currentAbsY - fontHeight - (lh - fs) / 2;
741
- pdfPage.drawText(line.text.trimEnd(), {
742
- x: geo.margins.left + paddingH,
743
- y: linePdfY,
744
- size: fs,
745
- font,
746
- color: rgb(tR, tG, tB),
747
- });
748
- currentAbsY += lh;
749
- }
750
- }
751
- // ─── Rich paragraph rendering ─────────────────────────────────────────────────
752
- function renderRichParagraph(pdfPage, pagedBlock, geo, fontMap, pdfDoc, footnoteNumbering) {
753
- const { measuredBlock, startLine, endLine, yFromTop } = pagedBlock;
754
- const { element, richLines, lineHeight, fontSize } = measuredBlock;
755
- if (!richLines || richLines.length === 0)
756
- return;
757
- // Only render the lines on this page chunk
758
- const visibleLines = richLines.slice(startLine, endLine);
759
- // Draw background color if set
760
- const columnData = measuredBlock.columnData;
761
- if (element.type === 'rich-paragraph' && element.bgColor) {
762
- // Phase 5B.4: Use sum of per-line heights (may vary with per-span fontSize)
763
- const chunkHeight = columnData
764
- ? visibleLines.slice(0, columnData.linesPerColumn).reduce((sum, rl) => sum + rl.lineHeight, 0)
765
- : visibleLines.reduce((sum, rl) => sum + rl.lineHeight, 0);
766
- const boxAbsY = yFromTop + geo.margins.top + geo.headerHeight;
767
- const boxPdfY = toPdfY(boxAbsY, chunkHeight, geo.pageHeight);
768
- const [bgR, bgG, bgB] = hexToRgb(element.bgColor);
769
- pdfPage.drawRectangle({
770
- x: geo.margins.left,
771
- y: boxPdfY,
772
- width: geo.contentWidth,
773
- height: chunkHeight,
774
- color: rgb(bgR, bgG, bgB),
775
- borderWidth: 0,
776
- });
777
- }
778
- // Multi-column layout
779
- if (columnData) {
780
- const { columnCount, columnGap, columnWidth, linesPerColumn } = columnData;
781
- // Phase 5B.4: Track cumulative Y per column (per-line heights may vary)
782
- const colCumY = new Array(columnCount).fill(0);
783
- for (let i = 0; i < visibleLines.length; i++) {
784
- const richLine = visibleLines[i];
785
- const colIdx = Math.floor(i / linesPerColumn);
786
- const colOffsetX = colIdx * (columnWidth + columnGap);
787
- const lineYFromTop = yFromTop + colCumY[colIdx] + geo.margins.top + geo.headerHeight;
788
- for (const fragment of richLine.fragments) {
789
- if (!fragment.text || fragment.text.trim() === '')
790
- continue;
791
- const pdfFont = fontMap.get(fragment.fontKey);
792
- if (!pdfFont) {
793
- 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.`);
794
- }
795
- const fontHeight = pdfFont.heightAtSize(fragment.fontSize);
796
- const basePdfY = toPdfY(lineYFromTop, fontHeight, geo.pageHeight);
797
- const [r, g, b] = hexToRgb(fragment.color);
798
- const drawX = geo.margins.left + colOffsetX + fragment.x;
799
- // Footnote ref spans render as superscript number, replacing the original text
800
- if (fragment.footnoteRef) {
801
- const num = footnoteNumbering?.get(fragment.footnoteRef) ?? '?';
802
- const superText = String(num);
803
- const superSize = fragment.fontSize * 0.65;
804
- const superYOffset = fragment.fontSize * 0.4;
805
- const superPdfY = basePdfY + superYOffset;
806
- pdfPage.drawText(superText, {
807
- x: drawX,
808
- y: superPdfY,
809
- size: superSize,
810
- font: pdfFont,
811
- color: rgb(r, g, b),
812
- });
813
- continue;
814
- }
815
- const fragmentPdfY = basePdfY + (fragment.yOffset ?? 0);
816
- const drawText = fragment.text.trimEnd();
817
- pdfPage.drawText(drawText, {
818
- x: drawX,
819
- y: fragmentPdfY,
820
- size: fragment.fontSize,
821
- font: pdfFont,
822
- color: rgb(r, g, b),
823
- });
824
- const fragWidth = pdfFont.widthOfTextAtSize(drawText, fragment.fontSize);
825
- drawTextDecoration(pdfPage, drawX, fragWidth, fragmentPdfY, fragment.fontSize, pdfFont, [r, g, b], { underline: fragment.underline ?? false, strikethrough: fragment.strikethrough ?? false });
826
- if (fragment.url) {
827
- addLinkAnnotation(pdfDoc, pdfPage, drawX, fragmentPdfY, fragWidth, fragment.fontSize, fragment.url);
828
- }
829
- }
830
- colCumY[colIdx] += richLine.lineHeight;
831
- }
832
- return; // skip standard single-column path
833
- }
834
- // Single-column layout (standard path)
835
- // Phase 5B.4: Track cumulative Y (per-line heights may vary due to per-span fontSize)
836
- let cumY = 0;
837
- for (let i = 0; i < visibleLines.length; i++) {
838
- const richLine = visibleLines[i];
839
- const lineYFromTop = yFromTop + cumY + geo.margins.top + geo.headerHeight;
840
- for (const fragment of richLine.fragments) {
841
- if (!fragment.text || fragment.text.trim() === '')
842
- continue;
843
- const pdfFont = fontMap.get(fragment.fontKey);
844
- if (!pdfFont) {
845
- 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.`);
846
- }
847
- const fontHeight = pdfFont.heightAtSize(fragment.fontSize);
848
- const basePdfY = toPdfY(lineYFromTop, fontHeight, geo.pageHeight);
849
- const [r, g, b] = hexToRgb(fragment.color);
850
- const drawX = geo.margins.left + fragment.x;
851
- // Footnote ref spans render as superscript number, replacing the original text
852
- if (fragment.footnoteRef) {
853
- const num = footnoteNumbering?.get(fragment.footnoteRef) ?? '?';
854
- const superText = String(num);
855
- const superSize = fragment.fontSize * 0.65;
856
- const superYOffset = fragment.fontSize * 0.4;
857
- const superPdfY = basePdfY + superYOffset;
858
- pdfPage.drawText(superText, {
859
- x: drawX,
860
- y: superPdfY,
861
- size: superSize,
862
- font: pdfFont,
863
- color: rgb(r, g, b),
864
- });
865
- continue;
866
- }
867
- const fragmentPdfY = basePdfY + (fragment.yOffset ?? 0);
868
- const drawText = fragment.text.trimEnd();
869
- pdfPage.drawText(drawText, {
870
- x: drawX,
871
- y: fragmentPdfY,
872
- size: fragment.fontSize,
873
- font: pdfFont,
874
- color: rgb(r, g, b),
875
- });
876
- const fragWidth = pdfFont.widthOfTextAtSize(drawText, fragment.fontSize);
877
- drawTextDecoration(pdfPage, drawX, fragWidth, fragmentPdfY, fragment.fontSize, pdfFont, [r, g, b], { underline: fragment.underline ?? false, strikethrough: fragment.strikethrough ?? false });
878
- if (fragment.url) {
879
- addLinkAnnotation(pdfDoc, pdfPage, drawX, fragmentPdfY, fragWidth, fragment.fontSize, fragment.url);
880
- }
881
- }
882
- cumY += richLine.lineHeight;
883
- }
884
- }
885
- // ─── Footnote zone rendering ──────────────────────────────────────────────────
886
- function renderFootnoteZone(pdfPage, footnoteItems, zoneHeight, fontMap, doc, geo) {
887
- const { pageHeight, margins, footerHeight, contentWidth } = geo;
888
- const SEPARATOR_PADDING = 6; // pt above and below the separator line
889
- // Zone top in PDF coords (Y=0 at bottom of page)
890
- const zoneTopPdfY = margins.bottom + footerHeight + zoneHeight;
891
- const separatorY = zoneTopPdfY - SEPARATOR_PADDING;
892
- // Draw separator line: 1/3 content width, max 120pt
893
- const lineLength = Math.min(contentWidth * 0.33, 120);
894
- pdfPage.drawLine({
895
- start: { x: margins.left, y: separatorY },
896
- end: { x: margins.left + lineLength, y: separatorY },
897
- thickness: 0.5,
898
- color: rgb(0.5, 0.5, 0.5),
899
- });
900
- const defaultFontSize = doc.defaultFontSize ?? 12;
901
- let currentPdfY = separatorY - SEPARATOR_PADDING;
902
- for (const { def, number } of footnoteItems) {
903
- const fontSize = def.fontSize ?? Math.max(8, defaultFontSize - 2);
904
- const lineHeight = fontSize * 1.5;
905
- const fontFamily = def.fontFamily ?? doc.defaultFont ?? 'Inter';
906
- const fontKey = buildFontKey(fontFamily, 400, 'normal');
907
- const pdfFont = fontMap.get(fontKey);
908
- if (!pdfFont)
909
- continue;
910
- currentPdfY -= lineHeight;
911
- const prefix = `${number}. `;
912
- const fullText = prefix + def.text;
913
- pdfPage.drawText(fullText, {
914
- x: margins.left,
915
- y: currentPdfY,
916
- size: fontSize,
917
- font: pdfFont,
918
- color: rgb(0.2, 0.2, 0.2),
919
- });
920
- currentPdfY -= (def.spaceAfter ?? 4);
921
- }
922
- }
923
- // ─── Header / Footer rendering ────────────────────────────────────────────────
924
- function renderHeaderFooter(pdfPage, spec, pageNumber, totalPages, geo, fontMap, position) {
925
- const text = resolveTokens(spec.text, pageNumber, totalPages);
926
- const fontSize = spec.fontSize ?? 10;
927
- const align = spec.align ?? 'center';
928
- const fontKey = `${spec.fontFamily ?? 'Inter'}-${spec.fontWeight ?? 400}-normal`;
929
- const pdfFont = fontMap.get(fontKey);
930
- if (!pdfFont) {
931
- throw new PretextPdfError('FONT_NOT_LOADED', `${position} font "${fontKey}" not found in fontMap. This is a bug — font validation should have caught this.`);
932
- }
933
- const fontHeight = pdfFont.heightAtSize(fontSize);
934
- let yFromTop;
935
- if (position === 'header') {
936
- yFromTop = (geo.margins.top - fontHeight) / 2;
937
- }
938
- else {
939
- yFromTop = geo.pageHeight - geo.margins.bottom + (geo.margins.bottom - fontHeight) / 2;
940
- }
941
- const pdfY = toPdfY(yFromTop, fontHeight, geo.pageHeight);
942
- const textWidth = pdfFont.widthOfTextAtSize(text, fontSize);
943
- const x = resolveX(align, geo.margins.left, geo.contentWidth, textWidth);
944
- const [textR, textG, textB] = hexToRgb(spec.color ?? '#666666');
945
- pdfPage.drawText(text, {
946
- x,
947
- y: pdfY,
948
- size: fontSize,
949
- font: pdfFont,
950
- color: rgb(textR, textG, textB),
951
- });
952
- // Separator line
953
- if (position === 'header') {
954
- const lineY = toPdfY(geo.margins.top - 4, 1, geo.pageHeight);
955
- pdfPage.drawLine({
956
- start: { x: geo.margins.left, y: lineY },
957
- end: { x: geo.margins.left + geo.contentWidth, y: lineY },
958
- thickness: 0.5,
959
- color: rgb(0.8, 0.8, 0.8),
960
- });
961
- }
962
- else {
963
- const lineY = toPdfY(geo.pageHeight - geo.margins.bottom + 4, 1, geo.pageHeight);
964
- pdfPage.drawLine({
965
- start: { x: geo.margins.left, y: lineY },
966
- end: { x: geo.margins.left + geo.contentWidth, y: lineY },
967
- thickness: 0.5,
968
- color: rgb(0.8, 0.8, 0.8),
969
- });
970
- }
971
- }
972
- // ─── Watermark rendering ──────────────────────────────────────────────────
973
- function renderWatermark(pdfPage, doc, fontMap, imageMap, geo) {
974
- const wm = doc.watermark;
975
- if (!wm)
976
- return;
977
- const opacity = wm.opacity ?? 0.3;
978
- const rotation = wm.rotation ?? -45;
979
- const { pageWidth, pageHeight } = geo;
980
- if (wm.text) {
981
- const fontKey = `${wm.fontFamily ?? doc.defaultFont ?? 'Inter'}-${wm.fontWeight ?? 400}-normal`;
982
- const pdfFont = fontMap.get(fontKey);
983
- if (!pdfFont) {
984
- throw new PretextPdfError('FONT_NOT_LOADED', `Watermark font "${fontKey}" not found in fontMap. This is a bug.`);
985
- }
986
- // Auto-compute font size to span ~60% of page diagonal
987
- const fontSize = wm.fontSize ?? (() => {
988
- const diagonal = Math.sqrt(pageWidth ** 2 + pageHeight ** 2);
989
- const widthAt100 = pdfFont.widthOfTextAtSize(wm.text, 100);
990
- return Math.min(120, (diagonal * 0.6 / widthAt100) * 100);
991
- })();
992
- const [r, g, b] = hexToRgb(wm.color ?? '#CCCCCC');
993
- pdfPage.drawText(wm.text, {
994
- x: pageWidth / 2,
995
- y: pageHeight / 2,
996
- size: fontSize,
997
- font: pdfFont,
998
- color: rgb(r, g, b),
999
- rotate: degrees(rotation),
1000
- opacity,
1001
- });
1002
- }
1003
- if (wm.image) {
1004
- const pdfImage = imageMap.get('watermark');
1005
- if (!pdfImage)
1006
- return;
1007
- const margin = 40;
1008
- pdfPage.drawImage(pdfImage, {
1009
- x: margin,
1010
- y: margin,
1011
- width: pageWidth - margin * 2,
1012
- height: pageHeight - margin * 2,
1013
- opacity,
1014
- });
1015
- }
1016
- }
1017
- // ─── Utilities ────────────────────────────────────────────────────────────────
1018
- /**
1019
- * Draw a single line of text with justified alignment.
1020
- * Spaces between words are stretched so the line fills availableWidth.
1021
- * The last line of a paragraph is left-aligned (standard typographic convention).
1022
- */
1023
- function drawJustifiedLine(pdfPage, lineText, isLastLine, x, pdfY, availableWidth, fontSize, pdfFont, color) {
1024
- const trimmed = lineText.trimEnd();
1025
- // Last line or single word: left-align (can't stretch)
1026
- if (isLastLine) {
1027
- pdfPage.drawText(trimmed, { x, y: pdfY, size: fontSize, font: pdfFont, color });
1028
- return;
1029
- }
1030
- const words = trimmed.split(' ').filter(w => w.length > 0);
1031
- if (words.length <= 1) {
1032
- pdfPage.drawText(trimmed, { x, y: pdfY, size: fontSize, font: pdfFont, color });
1033
- return;
1034
- }
1035
- const wordWidths = words.map(w => pdfFont.widthOfTextAtSize(w, fontSize));
1036
- const totalWordWidth = wordWidths.reduce((s, w) => s + w, 0);
1037
- const gapSize = (availableWidth - totalWordWidth) / (words.length - 1);
1038
- let curX = x;
1039
- for (let i = 0; i < words.length; i++) {
1040
- pdfPage.drawText(words[i], { x: curX, y: pdfY, size: fontSize, font: pdfFont, color });
1041
- curX += wordWidths[i] + gapSize;
1042
- }
1043
- }
1044
- /**
1045
- * Adds a clickable URI annotation over a rendered text region.
1046
- * Must be called after drawText() — annotation sits above the text layer.
1047
- */
1048
- function addLinkAnnotation(pdfDoc, pdfPage, x, pdfY, width, fontSize, url) {
1049
- const rectBottom = pdfY - fontSize * 0.2;
1050
- const rectTop = pdfY + fontSize * 0.8;
1051
- const linkAnnot = pdfDoc.context.register(pdfDoc.context.obj({
1052
- Type: 'Annot',
1053
- Subtype: 'Link',
1054
- Rect: [x, rectBottom, x + width, rectTop],
1055
- Border: [0, 0, 0],
1056
- A: pdfDoc.context.obj({
1057
- Type: 'Action',
1058
- S: 'URI',
1059
- URI: PDFString.of(url),
1060
- }),
1061
- }));
1062
- const existingAnnots = pdfPage.node.get(PDFName.of('Annots'));
1063
- if (existingAnnots) {
1064
- const annots = pdfDoc.context.lookup(existingAnnots);
1065
- annots.push(linkAnnot);
1066
- }
1067
- else {
1068
- pdfPage.node.set(PDFName.of('Annots'), pdfDoc.context.obj([linkAnnot]));
1069
- }
1070
- }
1071
- /**
1072
- * Adds a clickable internal anchor link (GoTo) annotation over a rendered text region.
1073
- * Jumps to a page with a named destination when clicked.
1074
- * Must be called after drawText() — annotation sits above the text layer.
1075
- */
1076
- function addGoToAnnotation(pdfDoc, pdfPage, x, pdfY, width, fontSize, destPageRef, destPdfY) {
1077
- const rectBottom = pdfY - fontSize * 0.2;
1078
- const rectTop = pdfY + fontSize * 0.8;
1079
- const goToAnnot = pdfDoc.context.register(pdfDoc.context.obj({
1080
- Type: 'Annot',
1081
- Subtype: 'Link',
1082
- Rect: [x, rectBottom, x + width, rectTop],
1083
- Border: [0, 0, 0],
1084
- Dest: pdfDoc.context.obj([destPageRef, PDFName.of('XYZ'), PDFNull, destPdfY, PDFNull]),
1085
- }));
1086
- const existingAnnots = pdfPage.node.get(PDFName.of('Annots'));
1087
- if (existingAnnots) {
1088
- const annots = pdfDoc.context.lookup(existingAnnots);
1089
- annots.push(goToAnnot);
1090
- }
1091
- else {
1092
- pdfPage.node.set(PDFName.of('Annots'), pdfDoc.context.obj([goToAnnot]));
1093
- }
1094
- }
1095
- /**
1096
- * Adds a sticky-note (Text) annotation at the given position.
1097
- */
1098
- function addStickyNoteAnnotation(pdfDoc, pdfPage, x, pdfY, contents, author, color, open) {
1099
- const hex = (color ?? '#FFFF00').replace('#', '');
1100
- const r = parseInt(hex.substring(0, 2), 16) / 255;
1101
- const g = parseInt(hex.substring(2, 4), 16) / 255;
1102
- const b = parseInt(hex.substring(4, 6), 16) / 255;
1103
- const annotRef = pdfDoc.context.register(pdfDoc.context.obj({
1104
- Type: 'Annot',
1105
- Subtype: 'Text',
1106
- Rect: [x, pdfY - 16, x + 16, pdfY],
1107
- Contents: PDFString.of(contents),
1108
- T: author ? PDFString.of(author) : PDFNull,
1109
- Open: open === true,
1110
- Name: 'Comment',
1111
- C: [r, g, b],
1112
- }));
1113
- const existingAnnots = pdfPage.node.get(PDFName.of('Annots'));
1114
- if (existingAnnots) {
1115
- const annots = pdfDoc.context.lookup(existingAnnots);
1116
- annots.push(annotRef);
1117
- }
1118
- else {
1119
- pdfPage.node.set(PDFName.of('Annots'), pdfDoc.context.obj([annotRef]));
1120
- }
1121
- }
1122
- /**
1123
- * Draws underline and/or strikethrough lines for a rendered text segment.
1124
- * Must be called AFTER drawText() so text renders on top of any decoration line.
1125
- */
1126
- function drawTextDecoration(pdfPage, x, width, pdfY, fontSize, pdfFont, color, decoration) {
1127
- if (!decoration.underline && !decoration.strikethrough)
1128
- return;
1129
- // Prefer font-designed metrics via fontkit embedder; fall back to height math
1130
- const embedder = pdfFont.embedder;
1131
- const fkFont = embedder?.font; // fontkit Font object (undefined for standard fonts)
1132
- const scale = embedder?.scale ?? 1;
1133
- const ascentPt = pdfFont.heightAtSize(fontSize, { descender: false });
1134
- const thickness = fkFont
1135
- ? Math.max(0.5, (fkFont.underlineThickness * scale / 1000) * fontSize)
1136
- : Math.max(0.5, fontSize / 14);
1137
- const [r, g, b] = color;
1138
- const lineColor = rgb(r, g, b);
1139
- if (decoration.underline) {
1140
- const ulY = fkFont
1141
- ? pdfY + (fkFont.underlinePosition * scale / 1000) * fontSize
1142
- : pdfY - ascentPt * 0.12;
1143
- pdfPage.drawLine({
1144
- start: { x, y: ulY },
1145
- end: { x: x + width, y: ulY },
1146
- thickness,
1147
- color: lineColor,
1148
- });
1149
- }
1150
- if (decoration.strikethrough) {
1151
- const strikeY = fkFont
1152
- ? pdfY + (fkFont.xHeight * scale / 1000) * fontSize * 0.5
1153
- : pdfY + ascentPt * 0.38;
1154
- pdfPage.drawLine({
1155
- start: { x, y: strikeY },
1156
- end: { x: x + width, y: strikeY },
1157
- thickness,
1158
- color: lineColor,
1159
- });
1160
- }
1161
- }
1162
- /**
1163
- * THE ONLY place where top-down coords are converted to pdf-lib bottom-up coords.
1164
- * @param yFromTop - distance from top of page in pt
1165
- * @param elementHeight - height of the element (font baseline offset, image height, etc.)
1166
- * @param pageHeight - total page height in pt
1167
- */
1168
- function toPdfY(yFromTop, elementHeight, pageHeight) {
1169
- return pageHeight - yFromTop - elementHeight;
1170
- }
1171
- /** Resolve text horizontal position based on alignment */
1172
- function resolveX(align, startX, availableWidth, lineWidth) {
1173
- switch (align) {
1174
- case 'left':
1175
- return startX;
1176
- case 'center':
1177
- return startX + (availableWidth - lineWidth) / 2;
1178
- case 'right':
1179
- return startX + availableWidth - lineWidth;
1180
- }
1181
- }
1182
- /** Replace {{pageNumber}} and {{totalPages}} tokens */
1183
- function resolveTokens(text, pageNumber, totalPages) {
1184
- return text
1185
- .replace('{{pageNumber}}', String(pageNumber))
1186
- .replace('{{totalPages}}', String(totalPages));
1187
- }
1188
- /** Parse a 6-digit hex color string to normalized RGB [0,1] triple */
1189
- function hexToRgb(hex) {
1190
- const clean = hex.startsWith('#') ? hex.slice(1) : hex;
1191
- const r = parseInt(clean.slice(0, 2), 16) / 255;
1192
- const g = parseInt(clean.slice(2, 4), 16) / 255;
1193
- const b = parseInt(clean.slice(4, 6), 16) / 255;
1194
- return [r, g, b];
1195
- }
1196
- // ─── Outline / Bookmarks ──────────────────────────────────────────────────────
1197
- /**
1198
- * Build PDF outline (bookmarks/TOC) from heading entries.
1199
- * Creates a doubly-linked tree in the PDF catalog.
1200
- * Must be called after all pages are rendered but before pdfDoc.save().
1201
- */
1202
- function buildOutlineTree(pdfDoc, headings, bookmarkConfig) {
1203
- if (bookmarkConfig === false || headings.length === 0)
1204
- return;
1205
- const cfg = typeof bookmarkConfig === 'object' ? bookmarkConfig : {};
1206
- const minLevel = cfg.minLevel ?? 1;
1207
- const maxLevel = cfg.maxLevel ?? 4;
1208
- const filtered = headings.filter(h => h.level >= minLevel && h.level <= maxLevel);
1209
- if (filtered.length === 0)
1210
- return;
1211
- const pageRefs = pdfDoc.getPages().map(p => p.ref);
1212
- const outlineRef = pdfDoc.context.nextRef();
1213
- const itemRefs = filtered.map(() => pdfDoc.context.nextRef());
1214
- // Returns index of nearest ancestor heading, or -1 (root-level)
1215
- function parentIdxOf(i) {
1216
- for (let j = i - 1; j >= 0; j--) {
1217
- if (filtered[j].level < filtered[i].level)
1218
- return j;
1219
- }
1220
- return -1;
1221
- }
1222
- for (let i = 0; i < filtered.length; i++) {
1223
- const h = filtered[i];
1224
- const pageRef = pageRefs[h.pageIndex] ?? pageRefs[pageRefs.length - 1];
1225
- const myParentIdx = parentIdxOf(i);
1226
- const myParentRef = myParentIdx === -1 ? outlineRef : itemRefs[myParentIdx];
1227
- const dest = pdfDoc.context.obj([pageRef, PDFName.of('XYZ'), PDFNull, PDFNull, PDFNull]);
1228
- let prevRef;
1229
- for (let j = i - 1; j >= 0; j--) {
1230
- if (filtered[j].level === h.level && parentIdxOf(j) === myParentIdx) {
1231
- prevRef = itemRefs[j];
1232
- break;
1233
- }
1234
- }
1235
- let nextRef;
1236
- for (let j = i + 1; j < filtered.length; j++) {
1237
- if (filtered[j].level === h.level && parentIdxOf(j) === myParentIdx) {
1238
- nextRef = itemRefs[j];
1239
- break;
1240
- }
1241
- }
1242
- let firstChildRef;
1243
- let lastChildRef;
1244
- let childCount = 0;
1245
- for (let j = i + 1; j < filtered.length; j++) {
1246
- if (filtered[j].level <= h.level)
1247
- break;
1248
- if (parentIdxOf(j) === i) {
1249
- if (!firstChildRef)
1250
- firstChildRef = itemRefs[j];
1251
- lastChildRef = itemRefs[j];
1252
- childCount++;
1253
- }
1254
- }
1255
- const entry = {
1256
- Title: PDFString.of(h.text),
1257
- Parent: myParentRef,
1258
- Dest: dest,
1259
- };
1260
- if (prevRef)
1261
- entry['Prev'] = prevRef;
1262
- if (nextRef)
1263
- entry['Next'] = nextRef;
1264
- if (firstChildRef)
1265
- entry['First'] = firstChildRef;
1266
- if (lastChildRef)
1267
- entry['Last'] = lastChildRef;
1268
- if (childCount > 0)
1269
- entry['Count'] = childCount;
1270
- pdfDoc.context.assign(itemRefs[i], pdfDoc.context.obj(entry));
1271
- }
1272
- const topIdxs = filtered.map((_, i) => i).filter(i => parentIdxOf(i) === -1);
1273
- const rootEntry = {
1274
- Type: PDFName.of('Outlines'),
1275
- Count: filtered.length,
1276
- };
1277
- if (topIdxs.length > 0) {
1278
- rootEntry['First'] = itemRefs[topIdxs[0]];
1279
- rootEntry['Last'] = itemRefs[topIdxs[topIdxs.length - 1]];
1280
- }
1281
- pdfDoc.context.assign(outlineRef, pdfDoc.context.obj(rootEntry));
1282
- pdfDoc.catalog.set(PDFName.of('Outlines'), outlineRef);
1283
- pdfDoc.catalog.set(PDFName.of('PageMode'), PDFName.of('UseOutlines'));
1284
- }
1285
- // ─── TOC Entry Rendering (Phase 7D) ────────────────────────────────────────────
1286
- function renderTocEntry(pdfPage, pagedBlock, geo, fontMap) {
1287
- const { measuredBlock, startLine, endLine, yFromTop } = pagedBlock;
1288
- const element = measuredBlock.element;
1289
- const tocData = measuredBlock.tocEntryData;
1290
- const lines = measuredBlock.lines.slice(startLine, endLine);
1291
- if (lines.length === 0)
1292
- return;
1293
- const pdfFont = fontMap.get(measuredBlock.fontKey);
1294
- if (!pdfFont)
1295
- throw new PretextPdfError('FONT_NOT_LOADED', `TOC font "${measuredBlock.fontKey}" not found.`);
1296
- const fontHeight = pdfFont.heightAtSize(measuredBlock.fontSize);
1297
- const entryX = geo.margins.left + tocData.entryX;
1298
- const rightEdge = geo.margins.left + geo.contentWidth;
1299
- for (let i = 0; i < lines.length; i++) {
1300
- const lineYFromTop = yFromTop + (i * measuredBlock.lineHeight);
1301
- const absY = lineYFromTop + geo.margins.top + geo.headerHeight;
1302
- const pdfY = toPdfY(absY, fontHeight, geo.pageHeight);
1303
- const text = lines[i].text.trimEnd();
1304
- if (!text)
1305
- continue;
1306
- // Draw entry text
1307
- pdfPage.drawText(text, { x: entryX, y: pdfY, size: measuredBlock.fontSize, font: pdfFont, color: rgb(0, 0, 0) });
1308
- // Title lines (pageStr === ''): no leader, no page number
1309
- if (!tocData.pageStr)
1310
- continue;
1311
- // Only draw leader and page number on the last line of multi-line entries
1312
- if (i < lines.length - 1)
1313
- continue;
1314
- // Draw page number (right-aligned)
1315
- const pageStr = tocData.pageStr;
1316
- const pageStrWidth = pdfFont.widthOfTextAtSize(pageStr, measuredBlock.fontSize);
1317
- const pageX = rightEdge - pageStrWidth;
1318
- pdfPage.drawText(pageStr, { x: pageX, y: pdfY, size: measuredBlock.fontSize, font: pdfFont, color: rgb(0, 0, 0) });
1319
- // Draw dot leaders between text and page number
1320
- if (tocData.leaderChar) {
1321
- const textWidth = pdfFont.widthOfTextAtSize(text, measuredBlock.fontSize);
1322
- const leaderCharWidth = pdfFont.widthOfTextAtSize(tocData.leaderChar, measuredBlock.fontSize);
1323
- const gapStart = entryX + textWidth + 6; // 6pt gap after text
1324
- const gapEnd = pageX - 6; // 6pt gap before page number
1325
- let lx = gapStart;
1326
- while (lx + leaderCharWidth <= gapEnd) {
1327
- pdfPage.drawText(tocData.leaderChar, { x: lx, y: pdfY, size: measuredBlock.fontSize, font: pdfFont, color: rgb(0.5, 0.5, 0.5) });
1328
- lx += leaderCharWidth + 1;
1329
- }
1330
- }
1331
- }
1332
- }
1333
- // ─── Phase 8B: Form Fields ────────────────────────────────────────────────────
1334
- /** Phase 8B: Render an interactive AcroForm field. */
1335
- function renderFormField(block, pdfPage, pdfDoc, fontMap, geo, yFromTop) {
1336
- const el = block.measuredBlock.element;
1337
- const { labelHeight, fieldHeight } = block.measuredBlock.formFieldData ?? { labelHeight: 0, fieldHeight: 24 };
1338
- const form = pdfDoc.getForm();
1339
- const x = geo.margins.left;
1340
- const fieldWidth = el.width ?? geo.contentWidth;
1341
- const absYTop = yFromTop + geo.margins.top + geo.headerHeight;
1342
- const fieldBottomPdfY = geo.pageHeight - absYTop - labelHeight - fieldHeight;
1343
- // Draw label if set
1344
- if (el.label && labelHeight > 0) {
1345
- const font = fontMap.get(block.measuredBlock.fontKey);
1346
- if (font) {
1347
- const labelPdfY = geo.pageHeight - absYTop;
1348
- pdfPage.drawText(el.label, {
1349
- x,
1350
- y: labelPdfY,
1351
- size: el.fontSize ?? 12,
1352
- font,
1353
- color: rgb(0, 0, 0),
1354
- });
1355
- }
1356
- }
1357
- const borderRgb = hexToRgb(el.borderColor ?? '#999999');
1358
- const bgRgb = hexToRgb(el.backgroundColor ?? '#FFFFFF');
1359
- const fieldOpts = {
1360
- x,
1361
- y: fieldBottomPdfY,
1362
- width: fieldWidth,
1363
- height: fieldHeight,
1364
- borderColor: rgb(borderRgb[0], borderRgb[1], borderRgb[2]),
1365
- backgroundColor: rgb(bgRgb[0], bgRgb[1], bgRgb[2]),
1366
- };
1367
- try {
1368
- switch (el.fieldType) {
1369
- case 'text': {
1370
- const field = form.createTextField(el.name);
1371
- if (el.defaultValue)
1372
- field.setText(el.defaultValue);
1373
- if (el.multiline)
1374
- field.enableMultiline();
1375
- if (el.maxLength)
1376
- field.setMaxLength(el.maxLength);
1377
- field.addToPage(pdfPage, fieldOpts);
1378
- break;
1379
- }
1380
- case 'checkbox': {
1381
- const field = form.createCheckBox(el.name);
1382
- if (el.checked)
1383
- field.check();
1384
- field.addToPage(pdfPage, {
1385
- x,
1386
- y: fieldBottomPdfY,
1387
- width: fieldHeight,
1388
- height: fieldHeight,
1389
- borderColor: rgb(borderRgb[0], borderRgb[1], borderRgb[2]),
1390
- backgroundColor: rgb(bgRgb[0], bgRgb[1], bgRgb[2]),
1391
- });
1392
- break;
1393
- }
1394
- case 'radio': {
1395
- const group = form.createRadioGroup(el.name);
1396
- const opts = el.options ?? [];
1397
- const optHeight = Math.max(16, Math.floor(fieldHeight / Math.max(1, opts.length)));
1398
- for (let i = 0; i < opts.length; i++) {
1399
- group.addOptionToPage(opts[i].value, pdfPage, {
1400
- x,
1401
- y: fieldBottomPdfY + fieldHeight - optHeight * (i + 1),
1402
- width: optHeight,
1403
- height: optHeight,
1404
- borderColor: rgb(borderRgb[0], borderRgb[1], borderRgb[2]),
1405
- });
1406
- }
1407
- if (el.defaultSelected) {
1408
- try {
1409
- group.select(el.defaultSelected);
1410
- }
1411
- catch { /* option may not exist */ }
1412
- }
1413
- break;
1414
- }
1415
- case 'dropdown': {
1416
- const field = form.createDropdown(el.name);
1417
- const opts = (el.options ?? []).map(o => o.value);
1418
- if (opts.length > 0)
1419
- field.addOptions(opts);
1420
- if (el.defaultSelected) {
1421
- try {
1422
- field.select(el.defaultSelected);
1423
- }
1424
- catch { /* option may not exist */ }
1425
- }
1426
- field.addToPage(pdfPage, fieldOpts);
1427
- break;
1428
- }
1429
- case 'button': {
1430
- const field = form.createButton(el.name);
1431
- field.addToPage(el.label ?? el.name, pdfPage, fieldOpts);
1432
- break;
1433
- }
1434
- }
1435
- }
1436
- catch {
1437
- // Non-fatal: if pdf-lib throws for an edge case, skip the field rather than crashing
1438
- }
1439
- }
1440
- // ─── Phase 8E: Signature Placeholder ──────────────────────────────────────────
1441
- /** Phase 8E: Draw a visual signature placeholder box on the specified page. */
1442
- function renderSignaturePlaceholder(sig, pdfDoc, fontMap, geo) {
1443
- const pages = pdfDoc.getPages();
1444
- if (pages.length === 0)
1445
- return;
1446
- const pageIndex = sig.page !== undefined
1447
- ? Math.min(sig.page, pages.length - 1)
1448
- : pages.length - 1;
1449
- const page = pages[pageIndex];
1450
- if (!page)
1451
- return;
1452
- const boxWidth = sig.width ?? 200;
1453
- const boxHeight = sig.height ?? 60;
1454
- const x = sig.x ?? geo.margins.left;
1455
- const yFromTop = sig.y ?? (geo.pageHeight - geo.margins.bottom - boxHeight);
1456
- const pdfY = geo.pageHeight - yFromTop - boxHeight;
1457
- const fs = sig.fontSize ?? 8;
1458
- const borderRgb = hexToRgb(sig.borderColor ?? '#000000');
1459
- const borderColor = rgb(borderRgb[0], borderRgb[1], borderRgb[2]);
1460
- const grayColor = rgb(0.5, 0.5, 0.5);
1461
- const font = fontMap.get('Inter-400-normal') ?? [...fontMap.values()][0];
1462
- if (!font)
1463
- return;
1464
- // Draw outer border rectangle (white fill)
1465
- page.drawRectangle({
1466
- x,
1467
- y: pdfY,
1468
- width: boxWidth,
1469
- height: boxHeight,
1470
- borderColor,
1471
- borderWidth: 0.5,
1472
- color: rgb(1, 1, 1),
1473
- });
1474
- let lineY = pdfY + boxHeight - fs - 6;
1475
- // Signer name line
1476
- if (sig.signerName) {
1477
- page.drawText(`Signed by: ${sig.signerName}`, {
1478
- x: x + 6, y: lineY, size: fs, font, color: rgb(0, 0, 0),
1479
- });
1480
- lineY -= fs + 4;
1481
- }
1482
- // Signature underline
1483
- page.drawLine({
1484
- start: { x: x + 6, y: lineY },
1485
- end: { x: x + boxWidth - 12, y: lineY },
1486
- thickness: 0.3,
1487
- color: grayColor,
1488
- });
1489
- page.drawText('Signature', {
1490
- x: x + 6, y: lineY - fs, size: fs - 1, font, color: grayColor,
1491
- });
1492
- lineY -= fs + 8;
1493
- // Date underline (half width)
1494
- page.drawLine({
1495
- start: { x: x + 6, y: lineY },
1496
- end: { x: x + boxWidth / 2, y: lineY },
1497
- thickness: 0.3,
1498
- color: grayColor,
1499
- });
1500
- page.drawText('Date', {
1501
- x: x + 6, y: lineY - fs, size: fs - 1, font, color: grayColor,
1502
- });
1503
- // Reason / location at bottom
1504
- if (sig.reason || sig.location) {
1505
- const bottomText = [sig.reason, sig.location].filter(Boolean).join(' — ');
1506
- page.drawText(bottomText, {
1507
- x: x + 6, y: pdfY + 3, size: fs - 1, font, color: rgb(0.4, 0.4, 0.4),
1508
- });
1509
- }
1510
- }
1511
149
  //# sourceMappingURL=render.js.map