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