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.
- package/CHANGELOG.md +351 -333
- package/README.md +3 -3
- package/dist/assets.d.ts +3 -0
- package/dist/assets.d.ts.map +1 -1
- package/dist/assets.js +119 -21
- package/dist/assets.js.map +1 -1
- package/dist/builder.d.ts +22 -1
- package/dist/builder.d.ts.map +1 -1
- package/dist/builder.js +38 -1
- package/dist/builder.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/fonts.d.ts.map +1 -1
- package/dist/fonts.js +36 -1
- package/dist/fonts.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +36 -15
- package/dist/index.js.map +1 -1
- package/dist/measure-blocks.d.ts +25 -0
- package/dist/measure-blocks.d.ts.map +1 -0
- package/dist/measure-blocks.js +1019 -0
- package/dist/measure-blocks.js.map +1 -0
- package/dist/measure-text.d.ts +53 -0
- package/dist/measure-text.d.ts.map +1 -0
- package/dist/measure-text.js +435 -0
- package/dist/measure-text.js.map +1 -0
- package/dist/measure.d.ts +15 -35
- package/dist/measure.d.ts.map +1 -1
- package/dist/measure.js +42 -1066
- package/dist/measure.js.map +1 -1
- package/dist/paginate.d.ts.map +1 -1
- package/dist/paginate.js +14 -12
- package/dist/paginate.js.map +1 -1
- package/dist/render-blocks.d.ts +24 -0
- package/dist/render-blocks.d.ts.map +1 -0
- package/dist/render-blocks.js +937 -0
- package/dist/render-blocks.js.map +1 -0
- package/dist/render-extras.d.ts +18 -0
- package/dist/render-extras.d.ts.map +1 -0
- package/dist/render-extras.js +325 -0
- package/dist/render-extras.js.map +1 -0
- package/dist/render-utils.d.ts +59 -0
- package/dist/render-utils.d.ts.map +1 -0
- package/dist/render-utils.js +219 -0
- package/dist/render-utils.js.map +1 -0
- package/dist/render.d.ts.map +1 -1
- package/dist/render.js +10 -1372
- package/dist/render.js.map +1 -1
- package/dist/rich-text.d.ts +4 -0
- package/dist/rich-text.d.ts.map +1 -1
- package/dist/rich-text.js +4 -0
- package/dist/rich-text.js.map +1 -1
- package/dist/types.d.ts +115 -5
- package/dist/types.d.ts.map +1 -1
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +195 -15
- package/dist/validate.js.map +1 -1
- package/docs/screenshots/showcase-invoice.png +0 -0
- package/docs/screenshots/showcase-report.png +0 -0
- package/docs/screenshots/showcase-resume.png +0 -0
- 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
|