@boxpdf/html-writer 0.1.9 → 0.1.11
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/README.md +21 -0
- package/dist/index.cjs +420 -31
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +13 -1
- package/dist/index.d.ts +13 -1
- package/dist/index.js +424 -32
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,5 +1,324 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import { structurePage, tableToHtml } from "@boxpdf/reader/structure";
|
|
2
|
+
import { structurePage as structurePage2, tableToHtml } from "@boxpdf/reader/structure";
|
|
3
|
+
|
|
4
|
+
// src/semantic-document.ts
|
|
5
|
+
import {
|
|
6
|
+
structurePage,
|
|
7
|
+
tableToRows
|
|
8
|
+
} from "@boxpdf/reader/structure";
|
|
9
|
+
async function writeSemanticDocument(pages, write, lookaheadPages) {
|
|
10
|
+
const stats = {
|
|
11
|
+
pagesProcessed: 0,
|
|
12
|
+
peakBufferedPages: 0,
|
|
13
|
+
peakBufferedLines: 0,
|
|
14
|
+
mergedTables: 0,
|
|
15
|
+
suppressedFurniture: 0
|
|
16
|
+
};
|
|
17
|
+
const buffer = [];
|
|
18
|
+
const seenFurniture = /* @__PURE__ */ new Set();
|
|
19
|
+
const sectionLevels = [];
|
|
20
|
+
let activeTable;
|
|
21
|
+
let headerOpen = false;
|
|
22
|
+
let headerHasParagraph = false;
|
|
23
|
+
let contentStarted = false;
|
|
24
|
+
let employmentOpen = false;
|
|
25
|
+
let pendingParagraph;
|
|
26
|
+
await write('<article class="pdf-semantic-document">');
|
|
27
|
+
const closeTable = async () => {
|
|
28
|
+
if (!activeTable) return;
|
|
29
|
+
await write("</table>");
|
|
30
|
+
activeTable = void 0;
|
|
31
|
+
};
|
|
32
|
+
const closeSections = async (minimumLevel = 0) => {
|
|
33
|
+
while ((sectionLevels.at(-1) ?? -1) >= minimumLevel && sectionLevels.length > 0) {
|
|
34
|
+
await write("</section>");
|
|
35
|
+
sectionLevels.pop();
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
const flushPendingParagraph = async () => {
|
|
39
|
+
if (!pendingParagraph) return;
|
|
40
|
+
await write(semanticBlockHtml(pendingParagraph.block));
|
|
41
|
+
pendingParagraph = void 0;
|
|
42
|
+
};
|
|
43
|
+
const closeEmployment = async () => {
|
|
44
|
+
if (!employmentOpen) return;
|
|
45
|
+
await write("</section>");
|
|
46
|
+
employmentOpen = false;
|
|
47
|
+
};
|
|
48
|
+
const emitPage = async (page, future) => {
|
|
49
|
+
const futureFurniture = new Set(future.flatMap((candidate) => marginSignatures(candidate)));
|
|
50
|
+
const repeatedFurniture = /* @__PURE__ */ new Set([...seenFurniture, ...futureFurniture]);
|
|
51
|
+
for (const block of page.structured.blocks) {
|
|
52
|
+
if (isRepeatedFurniture(block, page, repeatedFurniture)) {
|
|
53
|
+
stats.suppressedFurniture += 1;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
await flushPendingParagraph();
|
|
57
|
+
if (employmentOpen && block.type !== "list") await closeEmployment();
|
|
58
|
+
if (!contentStarted && !headerOpen && block.type === "heading" && block.level === 1) {
|
|
59
|
+
await write(`<header><h1>${escapeHtml(block.text)}</h1>`);
|
|
60
|
+
headerOpen = true;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (headerOpen) {
|
|
64
|
+
if (block.type === "paragraph") {
|
|
65
|
+
const tag = isContactBlock(block) ? "address" : "p";
|
|
66
|
+
await write(`<${tag}>${escapeHtml(block.text)}</${tag}>`);
|
|
67
|
+
headerHasParagraph = true;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (block.type === "heading" && !headerHasParagraph && (block.level === 1 || block.text.trimStart().startsWith("#"))) {
|
|
71
|
+
await write(`<h${block.level}>${escapeHtml(block.text)}</h${block.level}>`);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
await write("</header>");
|
|
75
|
+
headerOpen = false;
|
|
76
|
+
contentStarted = true;
|
|
77
|
+
}
|
|
78
|
+
if (block.type === "table") {
|
|
79
|
+
const rows = tableToRows(block.table);
|
|
80
|
+
if (activeTable && tablesContinue(activeTable.table, block.table, page.width)) {
|
|
81
|
+
const continuationRows = sameRow(activeTable.header, rows[0]) ? rows.slice(1) : rows;
|
|
82
|
+
for (const row of continuationRows) await write(tableRow(row, false));
|
|
83
|
+
activeTable.table = block.table;
|
|
84
|
+
stats.mergedTables += 1;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
await closeTable();
|
|
88
|
+
const header = tableHeader(rows);
|
|
89
|
+
await write("<table>");
|
|
90
|
+
for (const [index, row] of rows.entries())
|
|
91
|
+
await write(tableRow(row, Boolean(header && index === 0)));
|
|
92
|
+
activeTable = { table: block.table, header };
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (activeTable && block.type === "definitionList" && isFinancialSummary(block)) {
|
|
96
|
+
const columns = activeTable.table.columns.length;
|
|
97
|
+
await write(
|
|
98
|
+
`<tfoot>${block.entries.map((entry) => financialSummaryRow(entry, columns)).join("")}</tfoot>`
|
|
99
|
+
);
|
|
100
|
+
await closeTable();
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
await closeTable();
|
|
104
|
+
if (block.type === "heading") {
|
|
105
|
+
const level = contentStarted && block.level === 1 ? 2 : block.level;
|
|
106
|
+
await closeSections(level);
|
|
107
|
+
await write(
|
|
108
|
+
`<section data-level="${level}"><h${level}>${escapeHtml(block.text)}</h${level}>`
|
|
109
|
+
);
|
|
110
|
+
sectionLevels.push(level);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (block.type === "paragraph") {
|
|
114
|
+
if (isTitledRecord(block)) {
|
|
115
|
+
const [institution, ...details] = block.lines;
|
|
116
|
+
if (institution) await write(`<h3>${escapeHtml(institution.text)}</h3>`);
|
|
117
|
+
for (const detail of details) await write(`<p>${escapeHtml(detail.text)}</p>`);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (isUnmarkedList(block)) {
|
|
121
|
+
await write(
|
|
122
|
+
`<ul>${block.lines.map((line) => `<li>${escapeHtml(line.text)}</li>`).join("")}</ul>`
|
|
123
|
+
);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
pendingParagraph = { block, height: page.height };
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (block.type === "employment") {
|
|
130
|
+
await write(
|
|
131
|
+
`<section><h3>${escapeHtml(block.role)}</h3><p>${escapeHtml(block.organization)}</p><p>${escapeHtml(block.date)}</p>`
|
|
132
|
+
);
|
|
133
|
+
employmentOpen = true;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
await write(semanticBlockHtml(block));
|
|
137
|
+
}
|
|
138
|
+
for (const signature of marginSignatures(page)) seenFurniture.add(signature);
|
|
139
|
+
};
|
|
140
|
+
for await (const page of pages) {
|
|
141
|
+
const structured = structurePage(page);
|
|
142
|
+
buffer.push({ width: page.width, height: page.height, structured });
|
|
143
|
+
stats.pagesProcessed += 1;
|
|
144
|
+
stats.peakBufferedPages = Math.max(stats.peakBufferedPages, buffer.length);
|
|
145
|
+
stats.peakBufferedLines = Math.max(
|
|
146
|
+
stats.peakBufferedLines,
|
|
147
|
+
buffer.reduce((total, item) => total + item.structured.lines.length, 0)
|
|
148
|
+
);
|
|
149
|
+
if (buffer.length >= lookaheadPages) {
|
|
150
|
+
const ready = buffer.shift();
|
|
151
|
+
if (ready) await emitPage(ready, buffer);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
while (buffer.length > 0) {
|
|
155
|
+
const ready = buffer.shift();
|
|
156
|
+
if (ready) await emitPage(ready, buffer);
|
|
157
|
+
}
|
|
158
|
+
if (headerOpen) await write("</header>");
|
|
159
|
+
await closeTable();
|
|
160
|
+
await closeEmployment();
|
|
161
|
+
if (pendingParagraph && isFooterParagraph(pendingParagraph.block, pendingParagraph.height)) {
|
|
162
|
+
await closeSections();
|
|
163
|
+
await write(`<footer>${semanticBlockHtml(pendingParagraph.block)}</footer>`);
|
|
164
|
+
pendingParagraph = void 0;
|
|
165
|
+
} else {
|
|
166
|
+
await flushPendingParagraph();
|
|
167
|
+
await closeSections();
|
|
168
|
+
}
|
|
169
|
+
await write("</article>");
|
|
170
|
+
return stats;
|
|
171
|
+
}
|
|
172
|
+
function isContactBlock(block) {
|
|
173
|
+
const text = block.text;
|
|
174
|
+
const signals = [
|
|
175
|
+
/@/.test(text),
|
|
176
|
+
/\+?\d[\d().\s-]{7,}/.test(text),
|
|
177
|
+
/\bhttps?:\/\//i.test(text),
|
|
178
|
+
/\b\w+\.\w{2,}\b/i.test(text)
|
|
179
|
+
];
|
|
180
|
+
return signals.filter(Boolean).length >= 2;
|
|
181
|
+
}
|
|
182
|
+
function isTitledRecord(block) {
|
|
183
|
+
if (block.lines.length < 2) return false;
|
|
184
|
+
const [first, ...rest] = block.lines;
|
|
185
|
+
if (!first || rest.length === 0) return false;
|
|
186
|
+
const firstSize = Math.max(...first.spans.map((span) => span.fontSize));
|
|
187
|
+
const restSize = Math.max(...rest.flatMap((line) => line.spans.map((span) => span.fontSize)));
|
|
188
|
+
const emphasized = first.spans.some(
|
|
189
|
+
(span) => /(?:bold|semibold|demi)/i.test(span.fontFamily ?? "")
|
|
190
|
+
);
|
|
191
|
+
return emphasized || firstSize >= restSize * 1.08;
|
|
192
|
+
}
|
|
193
|
+
function isUnmarkedList(block) {
|
|
194
|
+
if (block.lines.length < 3) return false;
|
|
195
|
+
const first = block.lines[0];
|
|
196
|
+
if (!first) return false;
|
|
197
|
+
const aligned = block.lines.every(
|
|
198
|
+
(line) => Math.abs(line.bounds.x - first.bounds.x) <= Math.max(8, first.bounds.height)
|
|
199
|
+
);
|
|
200
|
+
const separated = block.lines.slice(1).every((line, index) => {
|
|
201
|
+
const previous = block.lines[index];
|
|
202
|
+
if (!previous) return false;
|
|
203
|
+
const gap = previous.bounds.y - (line.bounds.y + line.bounds.height);
|
|
204
|
+
return gap >= Math.min(previous.bounds.height, line.bounds.height) * 0.55;
|
|
205
|
+
});
|
|
206
|
+
return aligned && separated;
|
|
207
|
+
}
|
|
208
|
+
function isFooterParagraph(block, pageHeight) {
|
|
209
|
+
const inBottomMargin = block.lines.every(
|
|
210
|
+
(line) => line.bounds.y + line.bounds.height <= pageHeight * 0.15
|
|
211
|
+
);
|
|
212
|
+
return inBottomMargin || /^(?:thanks|thank you)\b/i.test(block.text.trim());
|
|
213
|
+
}
|
|
214
|
+
function marginSignatures(page) {
|
|
215
|
+
return page.structured.blocks.flatMap(
|
|
216
|
+
(block) => blockLines(block).filter((line) => isMarginLine(line.bounds.y, line.bounds.height, page.height)).map((line) => furnitureSignature(line.text))
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
function isRepeatedFurniture(block, page, futureFurniture) {
|
|
220
|
+
const lines = blockLines(block);
|
|
221
|
+
return lines.length > 0 && lines.every(
|
|
222
|
+
(line) => isMarginLine(line.bounds.y, line.bounds.height, page.height) && futureFurniture.has(furnitureSignature(line.text))
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
function blockLines(block) {
|
|
226
|
+
return block.type === "list" ? block.items.flatMap((item) => item.lines) : block.lines;
|
|
227
|
+
}
|
|
228
|
+
function isMarginLine(y, height, pageHeight) {
|
|
229
|
+
return y + height <= pageHeight * 0.12 || y >= pageHeight * 0.88;
|
|
230
|
+
}
|
|
231
|
+
function furnitureSignature(value) {
|
|
232
|
+
return value.toLocaleLowerCase("en").replace(/\d+/g, "#").replace(/\s+/g, " ").trim();
|
|
233
|
+
}
|
|
234
|
+
function tablesContinue(previous, next, pageWidth) {
|
|
235
|
+
if (previous.columns.length !== next.columns.length || previous.columns.length < 2) return false;
|
|
236
|
+
return previous.columns.every(
|
|
237
|
+
(column, index) => Math.abs(column - (next.columns[index] ?? Number.POSITIVE_INFINITY)) / pageWidth <= 0.04
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
function tableHeader(rows) {
|
|
241
|
+
const first = rows[0];
|
|
242
|
+
if (!first) return void 0;
|
|
243
|
+
const later = rows.slice(1).flat();
|
|
244
|
+
return first.every((value) => /\p{L}/u.test(value) && !isNumericValue(value)) && later.some(isNumericValue) ? first : void 0;
|
|
245
|
+
}
|
|
246
|
+
function sameRow(left, right) {
|
|
247
|
+
return Boolean(
|
|
248
|
+
left && right && left.length === right.length && left.every((value, index) => value === right[index])
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
function tableRow(row, header) {
|
|
252
|
+
const cell = header ? "th" : "td";
|
|
253
|
+
return `<tr>${row.map((value) => `<${cell}>${escapeHtml(value)}</${cell}>`).join("")}</tr>`;
|
|
254
|
+
}
|
|
255
|
+
function isFinancialSummary(block) {
|
|
256
|
+
return block.entries.length > 0 && block.entries.every((entry) => isNumericValue(entry.description)) && block.entries.some((entry) => /\p{Sc}|%/u.test(entry.description));
|
|
257
|
+
}
|
|
258
|
+
function isNumericValue(value) {
|
|
259
|
+
return /^(?:\p{Sc}\s*)?[\d.,'’\s]+(?:\s*%)?$/u.test(value.trim());
|
|
260
|
+
}
|
|
261
|
+
function financialSummaryRow(entry, columns) {
|
|
262
|
+
const colspan = columns > 2 ? ` colspan="${columns - 1}"` : "";
|
|
263
|
+
return `<tr><th scope="row"${colspan}>${escapeHtml(entry.term)}</th><td>${escapeHtml(entry.description)}</td></tr>`;
|
|
264
|
+
}
|
|
265
|
+
function semanticBlockHtml(block) {
|
|
266
|
+
if (block.type === "heading")
|
|
267
|
+
return `<h${block.level}>${escapeHtml(block.text)}</h${block.level}>`;
|
|
268
|
+
if (block.type === "paragraph") return `<p>${escapeHtml(block.text)}</p>`;
|
|
269
|
+
if (block.type === "definitionList") {
|
|
270
|
+
if (block.entries.length <= 3 && block.entries.some((entry) => entry.description.trim().split(/\s+/).length >= 5) && block.entries.every((entry) => /^[A-Z][A-Z\s/-]*$/.test(entry.term.trim()))) {
|
|
271
|
+
return block.entries.map(
|
|
272
|
+
(entry) => `<section><h2>${escapeHtml(titleCase(entry.term))}</h2><p>${escapeHtml(entry.description)}</p></section>`
|
|
273
|
+
).join("");
|
|
274
|
+
}
|
|
275
|
+
const list = `<dl>${block.entries.map(
|
|
276
|
+
(entry) => `<div><dt>${escapeHtml(entry.term)}</dt><dd>${escapeHtml(entry.description)}</dd></div>`
|
|
277
|
+
).join("")}</dl>`;
|
|
278
|
+
return isFinancialSummary(block) ? `<section>${list}</section>` : list;
|
|
279
|
+
}
|
|
280
|
+
if (block.type === "cardList") {
|
|
281
|
+
return `<section><h2>Items ordered</h2><table><thead><tr><th scope="col">Item</th><th scope="col">Quantity</th><th scope="col">Amount</th></tr></thead><tbody>${block.items.map(cardTableRow).join("")}</tbody></table></section>`;
|
|
282
|
+
}
|
|
283
|
+
if (block.type === "sectionGroup") {
|
|
284
|
+
return block.items.map(labeledSectionHtml).join("");
|
|
285
|
+
}
|
|
286
|
+
if (block.type === "employment") {
|
|
287
|
+
return `<section><h3>${escapeHtml(block.role)}</h3><p>${escapeHtml(block.organization)}</p><p>${escapeHtml(block.date)}</p></section>`;
|
|
288
|
+
}
|
|
289
|
+
const tag = block.ordered ? "ol" : "ul";
|
|
290
|
+
return `<${tag}>${block.items.map((item) => `<li>${escapeHtml(item.text)}</li>`).join("")}</${tag}>`;
|
|
291
|
+
}
|
|
292
|
+
function cardTableRow(item) {
|
|
293
|
+
const trailing = item.details.at(-1) ?? "";
|
|
294
|
+
const match = /^\s*[×x]\s*(\d+)\s+(.+)$/u.exec(trailing);
|
|
295
|
+
const description = item.details.slice(0, -1).join(" ");
|
|
296
|
+
const detail = description ? `<br><span>${escapeHtml(description)}</span>` : "";
|
|
297
|
+
const quantity = match?.[1] ?? "";
|
|
298
|
+
const amount = match?.[2] ?? trailing;
|
|
299
|
+
return `<tr><th scope="row">${escapeHtml(item.title)}${detail}</th><td>${escapeHtml(quantity)}</td><td>${escapeHtml(amount)}</td></tr>`;
|
|
300
|
+
}
|
|
301
|
+
function labeledSectionHtml(item) {
|
|
302
|
+
const heading = titleCase(item.label);
|
|
303
|
+
const postal = /\b(?:ship|deliver|mail)(?:ed)?\b/i.test(item.label);
|
|
304
|
+
if (postal) {
|
|
305
|
+
const [name, ...address] = item.content;
|
|
306
|
+
const content = [name ? `<strong>${escapeHtml(name)}</strong>` : "", ...address.map(escapeHtml)].filter(Boolean).join("<br>");
|
|
307
|
+
return `<section><h2>${escapeHtml(heading)}</h2><address>${content}</address></section>`;
|
|
308
|
+
}
|
|
309
|
+
return `<section><h2>${escapeHtml(heading)}</h2>${item.content.map(
|
|
310
|
+
(content, index) => `<p>${index === 0 ? `<strong>${escapeHtml(content)}</strong>` : escapeHtml(content)}</p>`
|
|
311
|
+
).join("")}</section>`;
|
|
312
|
+
}
|
|
313
|
+
function titleCase(value) {
|
|
314
|
+
const normalized = value.trim().toLocaleLowerCase("en");
|
|
315
|
+
return normalized.replace(/^\p{L}/u, (letter) => letter.toLocaleUpperCase("en"));
|
|
316
|
+
}
|
|
317
|
+
function escapeHtml(value) {
|
|
318
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// src/index.ts
|
|
3
322
|
var styles = `.pdf-document{margin:0 auto}.pdf-page{box-sizing:border-box;margin:1rem auto;background:#fff;color:#000}.pdf-page--visual,.pdf-page--positioned{position:relative;overflow:hidden}.pdf-page-content{position:absolute;transform-origin:0 0}.pdf-span{position:absolute;white-space:pre;transform-origin:left bottom;unicode-bidi:isolate}.pdf-span[data-direction=ttb]{writing-mode:vertical-rl}.pdf-page--semantic,.pdf-page--flow{max-width:60rem;padding:1rem}.pdf-page--semantic p,.pdf-page--flow p{white-space:pre-wrap;unicode-bidi:plaintext}.pdf-page table{border-collapse:collapse}.pdf-page td{padding:.15rem .4rem;vertical-align:top}`;
|
|
4
323
|
async function writeHtmlDocument(pages, write, options = {}) {
|
|
5
324
|
const includeDocument = options.includeDocument ?? true;
|
|
@@ -9,15 +328,28 @@ async function writeHtmlDocument(pages, write, options = {}) {
|
|
|
9
328
|
` lang="${escapeAttribute(options.language ?? "en")}"><head><meta charset="utf-8">`
|
|
10
329
|
);
|
|
11
330
|
await write('<meta name="viewport" content="width=device-width,initial-scale=1">');
|
|
12
|
-
await write(`<title>${
|
|
331
|
+
await write(`<title>${escapeHtml2(options.title ?? "PDF document")}</title>`);
|
|
13
332
|
if (options.includeStyles ?? true) await write(`<style>${styles}</style>`);
|
|
14
333
|
await write("</head><body>");
|
|
15
334
|
}
|
|
16
335
|
await write('<main class="pdf-document">');
|
|
17
|
-
|
|
336
|
+
if (resolveProfile(options) === "semantic") {
|
|
337
|
+
const lookahead = semanticLookahead(options.semanticLookaheadPages);
|
|
338
|
+
const stats = await writeSemanticDocument(pages, write, lookahead);
|
|
339
|
+
options.onSemanticStats?.(stats);
|
|
340
|
+
} else {
|
|
341
|
+
for await (const page of pages) await writePage(page, write, options);
|
|
342
|
+
}
|
|
18
343
|
await write("</main>");
|
|
19
344
|
if (includeDocument) await write("</body></html>");
|
|
20
345
|
}
|
|
346
|
+
function semanticLookahead(value) {
|
|
347
|
+
const lookahead = value ?? 4;
|
|
348
|
+
if (!Number.isSafeInteger(lookahead) || lookahead < 1 || lookahead > 16) {
|
|
349
|
+
throw new RangeError("semanticLookaheadPages must be an integer between 1 and 16");
|
|
350
|
+
}
|
|
351
|
+
return lookahead;
|
|
352
|
+
}
|
|
21
353
|
async function writePage(page, write, options = {}) {
|
|
22
354
|
if (resolveProfile(options) === "semantic") await writeFlowPage(page, write);
|
|
23
355
|
else await writePositionedPage(page, write, options);
|
|
@@ -57,8 +389,12 @@ async function writePositionedPage(page, write, options) {
|
|
|
57
389
|
await write(
|
|
58
390
|
`<svg class="pdf-visual-text" xmlns="http://www.w3.org/2000/svg" width="${number(page.width)}pt" height="${number(page.height)}pt" viewBox="0 0 ${number(page.width)} ${number(page.height)}">`
|
|
59
391
|
);
|
|
392
|
+
const clipDefinitions = imageClipDefinitions(page.images ?? [], page.number, page.height) + pathClipDefinitions(page.paths ?? [], page.number);
|
|
393
|
+
if (clipDefinitions) await write(`<defs>${clipDefinitions}</defs>`);
|
|
60
394
|
if (reflectedOverlay) {
|
|
61
|
-
for (const image of page.images ?? [])
|
|
395
|
+
for (const [index, image] of (page.images ?? []).entries()) {
|
|
396
|
+
await write(visualImage(image, page.height, page.number, index));
|
|
397
|
+
}
|
|
62
398
|
}
|
|
63
399
|
for (const fill of page.fills ?? []) {
|
|
64
400
|
const points = fill.points.map(([x, y]) => `${number(x)},${number(page.height - y)}`).join(" ");
|
|
@@ -69,7 +405,7 @@ async function writePositionedPage(page, write, options) {
|
|
|
69
405
|
}
|
|
70
406
|
if (page.paths?.length) {
|
|
71
407
|
await write(`<g transform="translate(0 ${number(page.height)}) scale(1 -1)">`);
|
|
72
|
-
for (const path of page.paths) {
|
|
408
|
+
for (const [pathIndex, path] of page.paths.entries()) {
|
|
73
409
|
if (!isSvgPath(path.d)) continue;
|
|
74
410
|
const fill = isCssHexColor(path.fill) ? path.fill : "none";
|
|
75
411
|
const stroke = isCssHexColor(path.stroke) ? path.stroke : "none";
|
|
@@ -81,14 +417,18 @@ async function writePositionedPage(page, write, options) {
|
|
|
81
417
|
const dashoffset = Number.isFinite(path.strokeDashoffset) ? ` stroke-dashoffset="${number(path.strokeDashoffset ?? 0)}"` : "";
|
|
82
418
|
const linecap = path.strokeLinecap ? ` stroke-linecap="${path.strokeLinecap}"` : "";
|
|
83
419
|
const linejoin = path.strokeLinejoin ? ` stroke-linejoin="${path.strokeLinejoin}"` : "";
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
420
|
+
let output = `<path d="${path.d}" fill="${fill}" stroke="${stroke}"${strokeWidth}${fillOpacity}${strokeOpacity}${dasharray}${dashoffset}${linecap}${linejoin}${fillRule}/>`;
|
|
421
|
+
for (let index = (path.clips?.length ?? 0) - 1; index >= 0; index -= 1) {
|
|
422
|
+
output = `<g clip-path="url(#${pathClipId(page.number, pathIndex, index)})">${output}</g>`;
|
|
423
|
+
}
|
|
424
|
+
await write(output);
|
|
87
425
|
}
|
|
88
426
|
await write("</g>");
|
|
89
427
|
}
|
|
90
428
|
if (!reflectedOverlay) {
|
|
91
|
-
for (const image of page.images ?? [])
|
|
429
|
+
for (const [index, image] of (page.images ?? []).entries()) {
|
|
430
|
+
await write(visualImage(image, page.height, page.number, index));
|
|
431
|
+
}
|
|
92
432
|
}
|
|
93
433
|
for (const span of visualSpans) {
|
|
94
434
|
if (!usesPositionedSpan(span)) {
|
|
@@ -109,13 +449,41 @@ function usesReflectedVisualOverlay(page, spans) {
|
|
|
109
449
|
(span) => span.transform !== void 0 && Math.abs(span.transform[0] + 1) < 1e-6 && Math.abs(span.transform[1]) < 1e-6 && Math.abs(span.transform[2]) < 1e-6 && Math.abs(span.transform[3] - 1) < 1e-6
|
|
110
450
|
);
|
|
111
451
|
}
|
|
112
|
-
function visualImage(image, pageHeight) {
|
|
452
|
+
function visualImage(image, pageHeight, pageNumber, imageIndex) {
|
|
113
453
|
const [a, b, c, d, e, f] = image.transform;
|
|
114
454
|
const transform = [a, -b, -c, d, c + e, pageHeight - d - f].map(number).join(" ");
|
|
115
455
|
const opacity = isUnitInterval(image.opacity) ? ` opacity="${number(image.opacity)}"` : "";
|
|
116
456
|
const mime = image.format === "jpeg" ? "image/jpeg" : "image/bmp";
|
|
117
457
|
const data = image.format === "jpeg" ? image.data : rgbBmp(image);
|
|
118
|
-
|
|
458
|
+
let output = `<image width="1" height="1" preserveAspectRatio="none" transform="matrix(${transform})" href="data:${mime};base64,${base64(data)}"${opacity}/>`;
|
|
459
|
+
for (let index = (image.clips?.length ?? 0) - 1; index >= 0; index -= 1) {
|
|
460
|
+
output = `<g clip-path="url(#${imageClipId(pageNumber, imageIndex, index)})">${output}</g>`;
|
|
461
|
+
}
|
|
462
|
+
return output;
|
|
463
|
+
}
|
|
464
|
+
function imageClipDefinitions(images, pageNumber, pageHeight) {
|
|
465
|
+
return images.flatMap(
|
|
466
|
+
(image, imageIndex) => (image.clips ?? []).map((clip, clipIndex) => {
|
|
467
|
+
if (!isSvgPath(clip.d)) return "";
|
|
468
|
+
const fillRule = clip.fillRule ? ` clip-rule="${clip.fillRule}"` : "";
|
|
469
|
+
return `<clipPath id="${imageClipId(pageNumber, imageIndex, clipIndex)}" clipPathUnits="userSpaceOnUse"><path d="${clip.d}" transform="translate(0 ${number(pageHeight)}) scale(1 -1)"${fillRule}/></clipPath>`;
|
|
470
|
+
})
|
|
471
|
+
).join("");
|
|
472
|
+
}
|
|
473
|
+
function imageClipId(pageNumber, imageIndex, clipIndex) {
|
|
474
|
+
return `boxpdf-clip-${pageNumber}-${imageIndex}-${clipIndex}`;
|
|
475
|
+
}
|
|
476
|
+
function pathClipDefinitions(paths, pageNumber) {
|
|
477
|
+
return paths.flatMap(
|
|
478
|
+
(path, pathIndex) => (path.clips ?? []).map((clip, clipIndex) => {
|
|
479
|
+
if (!isSvgPath(clip.d)) return "";
|
|
480
|
+
const fillRule = clip.fillRule ? ` clip-rule="${clip.fillRule}"` : "";
|
|
481
|
+
return `<clipPath id="${pathClipId(pageNumber, pathIndex, clipIndex)}" clipPathUnits="userSpaceOnUse"><path d="${clip.d}"${fillRule}/></clipPath>`;
|
|
482
|
+
})
|
|
483
|
+
).join("");
|
|
484
|
+
}
|
|
485
|
+
function pathClipId(pageNumber, pathIndex, clipIndex) {
|
|
486
|
+
return `boxpdf-path-clip-${pageNumber}-${pathIndex}-${clipIndex}`;
|
|
119
487
|
}
|
|
120
488
|
function rgbBmp(image) {
|
|
121
489
|
const stride = Math.ceil(image.width * 3 / 4) * 4;
|
|
@@ -169,28 +537,55 @@ function positionedSpan(span, fontAliases) {
|
|
|
169
537
|
span.fontAssetId ? fontAliases.get(span.fontAssetId) : void 0
|
|
170
538
|
)
|
|
171
539
|
].join(";");
|
|
172
|
-
return `<span class="pdf-span"${direction} style="${style}">${
|
|
540
|
+
return `<span class="pdf-span"${direction} style="${style}">${escapeHtml2(span.text)}</span>`;
|
|
173
541
|
}
|
|
174
542
|
async function writeFlowPage(page, write) {
|
|
175
|
-
const structured =
|
|
176
|
-
const tables = [...structured.tables].sort((left, right) => right.bounds.y - left.bounds.y);
|
|
177
|
-
const emittedTables = /* @__PURE__ */ new Set();
|
|
543
|
+
const structured = structurePage2(page);
|
|
178
544
|
await write(
|
|
179
545
|
`<section class="pdf-page pdf-page--semantic pdf-page--flow" data-page="${page.number}">`
|
|
180
546
|
);
|
|
181
|
-
for (const
|
|
182
|
-
|
|
183
|
-
if (
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
547
|
+
for (const block of structured.blocks) {
|
|
548
|
+
if (block.type === "table") await write(tableToHtml(block.table));
|
|
549
|
+
else if (block.type === "heading") {
|
|
550
|
+
await write(`<h${block.level}>${escapeHtml2(block.text)}</h${block.level}>`);
|
|
551
|
+
} else if (block.type === "paragraph") {
|
|
552
|
+
await write(
|
|
553
|
+
`<p${directionAttribute(block.lines.flatMap((line) => line.spans))}>${escapeHtml2(block.text)}</p>`
|
|
554
|
+
);
|
|
555
|
+
} else if (block.type === "definitionList") {
|
|
556
|
+
await write("<dl>");
|
|
557
|
+
for (const entry of block.entries) {
|
|
558
|
+
await write(
|
|
559
|
+
`<div><dt>${escapeHtml2(entry.term)}</dt><dd>${escapeHtml2(entry.description)}</dd></div>`
|
|
560
|
+
);
|
|
187
561
|
}
|
|
188
|
-
|
|
562
|
+
await write("</dl>");
|
|
563
|
+
} else if (block.type === "cardList") {
|
|
564
|
+
await write('<div class="pdf-semantic-cards">');
|
|
565
|
+
for (const item of block.items) {
|
|
566
|
+
await write(`<article><h3>${escapeHtml2(item.title)}</h3>`);
|
|
567
|
+
for (const detail of item.details) await write(`<p>${escapeHtml2(detail)}</p>`);
|
|
568
|
+
await write("</article>");
|
|
569
|
+
}
|
|
570
|
+
await write("</div>");
|
|
571
|
+
} else if (block.type === "sectionGroup") {
|
|
572
|
+
await write('<div class="pdf-semantic-sections">');
|
|
573
|
+
for (const item of block.items) {
|
|
574
|
+
await write(`<section><h3>${escapeHtml2(item.label)}</h3>`);
|
|
575
|
+
for (const content of item.content) await write(`<p>${escapeHtml2(content)}</p>`);
|
|
576
|
+
await write("</section>");
|
|
577
|
+
}
|
|
578
|
+
await write("</div>");
|
|
579
|
+
} else if (block.type === "employment") {
|
|
580
|
+
await write(
|
|
581
|
+
`<section><h3>${escapeHtml2(block.role)}</h3><p>${escapeHtml2(block.organization)}</p><p>${escapeHtml2(block.date)}</p></section>`
|
|
582
|
+
);
|
|
583
|
+
} else {
|
|
584
|
+
const tag = block.ordered ? "ol" : "ul";
|
|
585
|
+
await write(`<${tag}>`);
|
|
586
|
+
for (const item of block.items) await write(`<li>${escapeHtml2(item.text)}</li>`);
|
|
587
|
+
await write(`</${tag}>`);
|
|
189
588
|
}
|
|
190
|
-
await write(`<p${directionAttribute(line.spans)}>${escapeHtml(line.text)}</p>`);
|
|
191
|
-
}
|
|
192
|
-
for (const table of tables) {
|
|
193
|
-
if (!emittedTables.has(table)) await write(tableToHtml(table));
|
|
194
589
|
}
|
|
195
590
|
await write("</section>");
|
|
196
591
|
}
|
|
@@ -232,7 +627,7 @@ function visualText(span, pageHeight, fontAliases, counterRotateReflectedText =
|
|
|
232
627
|
const anchorX = span.bounds.x + basisX * rtlOffset;
|
|
233
628
|
const anchorY = pageHeight - span.bounds.y + basisY * rtlOffset;
|
|
234
629
|
const position = transformed ? ` x="0" y="0" transform="matrix(${transform?.map(number).join(" ")} ${number(anchorX)} ${number(anchorY)})"` : ` x="${number(anchorX)}" y="${number(anchorY)}"`;
|
|
235
|
-
return `<text${direction}${position} font-size="${number(span.fontSize)}"${textLength}${style ? ` style="${style}"` : ""}>${
|
|
630
|
+
return `<text${direction}${position} font-size="${number(span.fontSize)}"${textLength}${style ? ` style="${style}"` : ""}>${escapeHtml2(span.text)}</text>`;
|
|
236
631
|
}
|
|
237
632
|
function isAdobeCjkFont(fontFamily) {
|
|
238
633
|
return /^Adobe(?:Heiti|Song|Kaiti|Ming|Gothic|Mincho)Std-/i.test(fontFamily ?? "");
|
|
@@ -345,16 +740,13 @@ function directionAttribute(spans) {
|
|
|
345
740
|
if (vertical > rtl && vertical * 2 >= spans.length) return ' data-direction="ttb"';
|
|
346
741
|
return rtl * 2 >= spans.length && spans.length > 0 ? ' dir="rtl"' : "";
|
|
347
742
|
}
|
|
348
|
-
function containsY(table, y) {
|
|
349
|
-
return y >= table.bounds.y && y <= table.bounds.y + table.bounds.height;
|
|
350
|
-
}
|
|
351
743
|
function number(value) {
|
|
352
744
|
return Number.isFinite(value) ? String(Math.round(value * 1e3) / 1e3) : "0";
|
|
353
745
|
}
|
|
354
746
|
function escapeAttribute(value) {
|
|
355
|
-
return
|
|
747
|
+
return escapeHtml2(value).replaceAll("`", "`");
|
|
356
748
|
}
|
|
357
|
-
function
|
|
749
|
+
function escapeHtml2(value) {
|
|
358
750
|
return [...value].map((character) => {
|
|
359
751
|
const codePoint = character.codePointAt(0) ?? 0;
|
|
360
752
|
if (codePoint === 13) return "\n";
|