@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/README.md
CHANGED
|
@@ -29,6 +29,27 @@ lines, nesting, and tables to produce reflowable HTML. The visual model comes
|
|
|
29
29
|
first: semantic structure is derived from the complete page evidence rather
|
|
30
30
|
than inferred after presentation information has been discarded.
|
|
31
31
|
|
|
32
|
+
Document-level semantic output uses a bounded page window to merge tables that
|
|
33
|
+
continue across page boundaries, preserve section nesting across pages, and
|
|
34
|
+
suppress repeated margin furniture. It also expresses aligned product cards,
|
|
35
|
+
address groups, and financial summaries as useful HTML rather than loose text.
|
|
36
|
+
The default window retains at most four extracted text models; configure it
|
|
37
|
+
from one to sixteen pages without buffering PDF bytes, fonts, or raster images:
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
await writeHtmlDocument(pdf.pages(), write, {
|
|
41
|
+
profile: "semantic",
|
|
42
|
+
semanticLookaheadPages: 4,
|
|
43
|
+
onSemanticStats(stats) {
|
|
44
|
+
console.log(stats.peakBufferedPages, stats.mergedTables);
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The reported statistics also include processed pages, peak buffered lines, and
|
|
50
|
+
suppressed furniture. Page-level `pageToHtml()` remains available when no
|
|
51
|
+
cross-page inference is wanted.
|
|
52
|
+
|
|
32
53
|
The legacy `layout: "positioned" | "flow"` option remains as an alias for
|
|
33
54
|
`profile: "visual" | "semantic"`. The current visual output surface excludes
|
|
34
55
|
images, vector graphics, and exact font reproduction; the PDFium parity report
|
package/dist/index.cjs
CHANGED
|
@@ -25,7 +25,323 @@ __export(index_exports, {
|
|
|
25
25
|
writePage: () => writePage
|
|
26
26
|
});
|
|
27
27
|
module.exports = __toCommonJS(index_exports);
|
|
28
|
+
var import_structure2 = require("@boxpdf/reader/structure");
|
|
29
|
+
|
|
30
|
+
// src/semantic-document.ts
|
|
28
31
|
var import_structure = require("@boxpdf/reader/structure");
|
|
32
|
+
async function writeSemanticDocument(pages, write, lookaheadPages) {
|
|
33
|
+
const stats = {
|
|
34
|
+
pagesProcessed: 0,
|
|
35
|
+
peakBufferedPages: 0,
|
|
36
|
+
peakBufferedLines: 0,
|
|
37
|
+
mergedTables: 0,
|
|
38
|
+
suppressedFurniture: 0
|
|
39
|
+
};
|
|
40
|
+
const buffer = [];
|
|
41
|
+
const seenFurniture = /* @__PURE__ */ new Set();
|
|
42
|
+
const sectionLevels = [];
|
|
43
|
+
let activeTable;
|
|
44
|
+
let headerOpen = false;
|
|
45
|
+
let headerHasParagraph = false;
|
|
46
|
+
let contentStarted = false;
|
|
47
|
+
let employmentOpen = false;
|
|
48
|
+
let pendingParagraph;
|
|
49
|
+
await write('<article class="pdf-semantic-document">');
|
|
50
|
+
const closeTable = async () => {
|
|
51
|
+
if (!activeTable) return;
|
|
52
|
+
await write("</table>");
|
|
53
|
+
activeTable = void 0;
|
|
54
|
+
};
|
|
55
|
+
const closeSections = async (minimumLevel = 0) => {
|
|
56
|
+
while ((sectionLevels.at(-1) ?? -1) >= minimumLevel && sectionLevels.length > 0) {
|
|
57
|
+
await write("</section>");
|
|
58
|
+
sectionLevels.pop();
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
const flushPendingParagraph = async () => {
|
|
62
|
+
if (!pendingParagraph) return;
|
|
63
|
+
await write(semanticBlockHtml(pendingParagraph.block));
|
|
64
|
+
pendingParagraph = void 0;
|
|
65
|
+
};
|
|
66
|
+
const closeEmployment = async () => {
|
|
67
|
+
if (!employmentOpen) return;
|
|
68
|
+
await write("</section>");
|
|
69
|
+
employmentOpen = false;
|
|
70
|
+
};
|
|
71
|
+
const emitPage = async (page, future) => {
|
|
72
|
+
const futureFurniture = new Set(future.flatMap((candidate) => marginSignatures(candidate)));
|
|
73
|
+
const repeatedFurniture = /* @__PURE__ */ new Set([...seenFurniture, ...futureFurniture]);
|
|
74
|
+
for (const block of page.structured.blocks) {
|
|
75
|
+
if (isRepeatedFurniture(block, page, repeatedFurniture)) {
|
|
76
|
+
stats.suppressedFurniture += 1;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
await flushPendingParagraph();
|
|
80
|
+
if (employmentOpen && block.type !== "list") await closeEmployment();
|
|
81
|
+
if (!contentStarted && !headerOpen && block.type === "heading" && block.level === 1) {
|
|
82
|
+
await write(`<header><h1>${escapeHtml(block.text)}</h1>`);
|
|
83
|
+
headerOpen = true;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (headerOpen) {
|
|
87
|
+
if (block.type === "paragraph") {
|
|
88
|
+
const tag = isContactBlock(block) ? "address" : "p";
|
|
89
|
+
await write(`<${tag}>${escapeHtml(block.text)}</${tag}>`);
|
|
90
|
+
headerHasParagraph = true;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (block.type === "heading" && !headerHasParagraph && (block.level === 1 || block.text.trimStart().startsWith("#"))) {
|
|
94
|
+
await write(`<h${block.level}>${escapeHtml(block.text)}</h${block.level}>`);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
await write("</header>");
|
|
98
|
+
headerOpen = false;
|
|
99
|
+
contentStarted = true;
|
|
100
|
+
}
|
|
101
|
+
if (block.type === "table") {
|
|
102
|
+
const rows = (0, import_structure.tableToRows)(block.table);
|
|
103
|
+
if (activeTable && tablesContinue(activeTable.table, block.table, page.width)) {
|
|
104
|
+
const continuationRows = sameRow(activeTable.header, rows[0]) ? rows.slice(1) : rows;
|
|
105
|
+
for (const row of continuationRows) await write(tableRow(row, false));
|
|
106
|
+
activeTable.table = block.table;
|
|
107
|
+
stats.mergedTables += 1;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
await closeTable();
|
|
111
|
+
const header = tableHeader(rows);
|
|
112
|
+
await write("<table>");
|
|
113
|
+
for (const [index, row] of rows.entries())
|
|
114
|
+
await write(tableRow(row, Boolean(header && index === 0)));
|
|
115
|
+
activeTable = { table: block.table, header };
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (activeTable && block.type === "definitionList" && isFinancialSummary(block)) {
|
|
119
|
+
const columns = activeTable.table.columns.length;
|
|
120
|
+
await write(
|
|
121
|
+
`<tfoot>${block.entries.map((entry) => financialSummaryRow(entry, columns)).join("")}</tfoot>`
|
|
122
|
+
);
|
|
123
|
+
await closeTable();
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
await closeTable();
|
|
127
|
+
if (block.type === "heading") {
|
|
128
|
+
const level = contentStarted && block.level === 1 ? 2 : block.level;
|
|
129
|
+
await closeSections(level);
|
|
130
|
+
await write(
|
|
131
|
+
`<section data-level="${level}"><h${level}>${escapeHtml(block.text)}</h${level}>`
|
|
132
|
+
);
|
|
133
|
+
sectionLevels.push(level);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (block.type === "paragraph") {
|
|
137
|
+
if (isTitledRecord(block)) {
|
|
138
|
+
const [institution, ...details] = block.lines;
|
|
139
|
+
if (institution) await write(`<h3>${escapeHtml(institution.text)}</h3>`);
|
|
140
|
+
for (const detail of details) await write(`<p>${escapeHtml(detail.text)}</p>`);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (isUnmarkedList(block)) {
|
|
144
|
+
await write(
|
|
145
|
+
`<ul>${block.lines.map((line) => `<li>${escapeHtml(line.text)}</li>`).join("")}</ul>`
|
|
146
|
+
);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
pendingParagraph = { block, height: page.height };
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (block.type === "employment") {
|
|
153
|
+
await write(
|
|
154
|
+
`<section><h3>${escapeHtml(block.role)}</h3><p>${escapeHtml(block.organization)}</p><p>${escapeHtml(block.date)}</p>`
|
|
155
|
+
);
|
|
156
|
+
employmentOpen = true;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
await write(semanticBlockHtml(block));
|
|
160
|
+
}
|
|
161
|
+
for (const signature of marginSignatures(page)) seenFurniture.add(signature);
|
|
162
|
+
};
|
|
163
|
+
for await (const page of pages) {
|
|
164
|
+
const structured = (0, import_structure.structurePage)(page);
|
|
165
|
+
buffer.push({ width: page.width, height: page.height, structured });
|
|
166
|
+
stats.pagesProcessed += 1;
|
|
167
|
+
stats.peakBufferedPages = Math.max(stats.peakBufferedPages, buffer.length);
|
|
168
|
+
stats.peakBufferedLines = Math.max(
|
|
169
|
+
stats.peakBufferedLines,
|
|
170
|
+
buffer.reduce((total, item) => total + item.structured.lines.length, 0)
|
|
171
|
+
);
|
|
172
|
+
if (buffer.length >= lookaheadPages) {
|
|
173
|
+
const ready = buffer.shift();
|
|
174
|
+
if (ready) await emitPage(ready, buffer);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
while (buffer.length > 0) {
|
|
178
|
+
const ready = buffer.shift();
|
|
179
|
+
if (ready) await emitPage(ready, buffer);
|
|
180
|
+
}
|
|
181
|
+
if (headerOpen) await write("</header>");
|
|
182
|
+
await closeTable();
|
|
183
|
+
await closeEmployment();
|
|
184
|
+
if (pendingParagraph && isFooterParagraph(pendingParagraph.block, pendingParagraph.height)) {
|
|
185
|
+
await closeSections();
|
|
186
|
+
await write(`<footer>${semanticBlockHtml(pendingParagraph.block)}</footer>`);
|
|
187
|
+
pendingParagraph = void 0;
|
|
188
|
+
} else {
|
|
189
|
+
await flushPendingParagraph();
|
|
190
|
+
await closeSections();
|
|
191
|
+
}
|
|
192
|
+
await write("</article>");
|
|
193
|
+
return stats;
|
|
194
|
+
}
|
|
195
|
+
function isContactBlock(block) {
|
|
196
|
+
const text = block.text;
|
|
197
|
+
const signals = [
|
|
198
|
+
/@/.test(text),
|
|
199
|
+
/\+?\d[\d().\s-]{7,}/.test(text),
|
|
200
|
+
/\bhttps?:\/\//i.test(text),
|
|
201
|
+
/\b\w+\.\w{2,}\b/i.test(text)
|
|
202
|
+
];
|
|
203
|
+
return signals.filter(Boolean).length >= 2;
|
|
204
|
+
}
|
|
205
|
+
function isTitledRecord(block) {
|
|
206
|
+
if (block.lines.length < 2) return false;
|
|
207
|
+
const [first, ...rest] = block.lines;
|
|
208
|
+
if (!first || rest.length === 0) return false;
|
|
209
|
+
const firstSize = Math.max(...first.spans.map((span) => span.fontSize));
|
|
210
|
+
const restSize = Math.max(...rest.flatMap((line) => line.spans.map((span) => span.fontSize)));
|
|
211
|
+
const emphasized = first.spans.some(
|
|
212
|
+
(span) => /(?:bold|semibold|demi)/i.test(span.fontFamily ?? "")
|
|
213
|
+
);
|
|
214
|
+
return emphasized || firstSize >= restSize * 1.08;
|
|
215
|
+
}
|
|
216
|
+
function isUnmarkedList(block) {
|
|
217
|
+
if (block.lines.length < 3) return false;
|
|
218
|
+
const first = block.lines[0];
|
|
219
|
+
if (!first) return false;
|
|
220
|
+
const aligned = block.lines.every(
|
|
221
|
+
(line) => Math.abs(line.bounds.x - first.bounds.x) <= Math.max(8, first.bounds.height)
|
|
222
|
+
);
|
|
223
|
+
const separated = block.lines.slice(1).every((line, index) => {
|
|
224
|
+
const previous = block.lines[index];
|
|
225
|
+
if (!previous) return false;
|
|
226
|
+
const gap = previous.bounds.y - (line.bounds.y + line.bounds.height);
|
|
227
|
+
return gap >= Math.min(previous.bounds.height, line.bounds.height) * 0.55;
|
|
228
|
+
});
|
|
229
|
+
return aligned && separated;
|
|
230
|
+
}
|
|
231
|
+
function isFooterParagraph(block, pageHeight) {
|
|
232
|
+
const inBottomMargin = block.lines.every(
|
|
233
|
+
(line) => line.bounds.y + line.bounds.height <= pageHeight * 0.15
|
|
234
|
+
);
|
|
235
|
+
return inBottomMargin || /^(?:thanks|thank you)\b/i.test(block.text.trim());
|
|
236
|
+
}
|
|
237
|
+
function marginSignatures(page) {
|
|
238
|
+
return page.structured.blocks.flatMap(
|
|
239
|
+
(block) => blockLines(block).filter((line) => isMarginLine(line.bounds.y, line.bounds.height, page.height)).map((line) => furnitureSignature(line.text))
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
function isRepeatedFurniture(block, page, futureFurniture) {
|
|
243
|
+
const lines = blockLines(block);
|
|
244
|
+
return lines.length > 0 && lines.every(
|
|
245
|
+
(line) => isMarginLine(line.bounds.y, line.bounds.height, page.height) && futureFurniture.has(furnitureSignature(line.text))
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
function blockLines(block) {
|
|
249
|
+
return block.type === "list" ? block.items.flatMap((item) => item.lines) : block.lines;
|
|
250
|
+
}
|
|
251
|
+
function isMarginLine(y, height, pageHeight) {
|
|
252
|
+
return y + height <= pageHeight * 0.12 || y >= pageHeight * 0.88;
|
|
253
|
+
}
|
|
254
|
+
function furnitureSignature(value) {
|
|
255
|
+
return value.toLocaleLowerCase("en").replace(/\d+/g, "#").replace(/\s+/g, " ").trim();
|
|
256
|
+
}
|
|
257
|
+
function tablesContinue(previous, next, pageWidth) {
|
|
258
|
+
if (previous.columns.length !== next.columns.length || previous.columns.length < 2) return false;
|
|
259
|
+
return previous.columns.every(
|
|
260
|
+
(column, index) => Math.abs(column - (next.columns[index] ?? Number.POSITIVE_INFINITY)) / pageWidth <= 0.04
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
function tableHeader(rows) {
|
|
264
|
+
const first = rows[0];
|
|
265
|
+
if (!first) return void 0;
|
|
266
|
+
const later = rows.slice(1).flat();
|
|
267
|
+
return first.every((value) => /\p{L}/u.test(value) && !isNumericValue(value)) && later.some(isNumericValue) ? first : void 0;
|
|
268
|
+
}
|
|
269
|
+
function sameRow(left, right) {
|
|
270
|
+
return Boolean(
|
|
271
|
+
left && right && left.length === right.length && left.every((value, index) => value === right[index])
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
function tableRow(row, header) {
|
|
275
|
+
const cell = header ? "th" : "td";
|
|
276
|
+
return `<tr>${row.map((value) => `<${cell}>${escapeHtml(value)}</${cell}>`).join("")}</tr>`;
|
|
277
|
+
}
|
|
278
|
+
function isFinancialSummary(block) {
|
|
279
|
+
return block.entries.length > 0 && block.entries.every((entry) => isNumericValue(entry.description)) && block.entries.some((entry) => /\p{Sc}|%/u.test(entry.description));
|
|
280
|
+
}
|
|
281
|
+
function isNumericValue(value) {
|
|
282
|
+
return /^(?:\p{Sc}\s*)?[\d.,'’\s]+(?:\s*%)?$/u.test(value.trim());
|
|
283
|
+
}
|
|
284
|
+
function financialSummaryRow(entry, columns) {
|
|
285
|
+
const colspan = columns > 2 ? ` colspan="${columns - 1}"` : "";
|
|
286
|
+
return `<tr><th scope="row"${colspan}>${escapeHtml(entry.term)}</th><td>${escapeHtml(entry.description)}</td></tr>`;
|
|
287
|
+
}
|
|
288
|
+
function semanticBlockHtml(block) {
|
|
289
|
+
if (block.type === "heading")
|
|
290
|
+
return `<h${block.level}>${escapeHtml(block.text)}</h${block.level}>`;
|
|
291
|
+
if (block.type === "paragraph") return `<p>${escapeHtml(block.text)}</p>`;
|
|
292
|
+
if (block.type === "definitionList") {
|
|
293
|
+
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()))) {
|
|
294
|
+
return block.entries.map(
|
|
295
|
+
(entry) => `<section><h2>${escapeHtml(titleCase(entry.term))}</h2><p>${escapeHtml(entry.description)}</p></section>`
|
|
296
|
+
).join("");
|
|
297
|
+
}
|
|
298
|
+
const list = `<dl>${block.entries.map(
|
|
299
|
+
(entry) => `<div><dt>${escapeHtml(entry.term)}</dt><dd>${escapeHtml(entry.description)}</dd></div>`
|
|
300
|
+
).join("")}</dl>`;
|
|
301
|
+
return isFinancialSummary(block) ? `<section>${list}</section>` : list;
|
|
302
|
+
}
|
|
303
|
+
if (block.type === "cardList") {
|
|
304
|
+
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>`;
|
|
305
|
+
}
|
|
306
|
+
if (block.type === "sectionGroup") {
|
|
307
|
+
return block.items.map(labeledSectionHtml).join("");
|
|
308
|
+
}
|
|
309
|
+
if (block.type === "employment") {
|
|
310
|
+
return `<section><h3>${escapeHtml(block.role)}</h3><p>${escapeHtml(block.organization)}</p><p>${escapeHtml(block.date)}</p></section>`;
|
|
311
|
+
}
|
|
312
|
+
const tag = block.ordered ? "ol" : "ul";
|
|
313
|
+
return `<${tag}>${block.items.map((item) => `<li>${escapeHtml(item.text)}</li>`).join("")}</${tag}>`;
|
|
314
|
+
}
|
|
315
|
+
function cardTableRow(item) {
|
|
316
|
+
const trailing = item.details.at(-1) ?? "";
|
|
317
|
+
const match = /^\s*[×x]\s*(\d+)\s+(.+)$/u.exec(trailing);
|
|
318
|
+
const description = item.details.slice(0, -1).join(" ");
|
|
319
|
+
const detail = description ? `<br><span>${escapeHtml(description)}</span>` : "";
|
|
320
|
+
const quantity = match?.[1] ?? "";
|
|
321
|
+
const amount = match?.[2] ?? trailing;
|
|
322
|
+
return `<tr><th scope="row">${escapeHtml(item.title)}${detail}</th><td>${escapeHtml(quantity)}</td><td>${escapeHtml(amount)}</td></tr>`;
|
|
323
|
+
}
|
|
324
|
+
function labeledSectionHtml(item) {
|
|
325
|
+
const heading = titleCase(item.label);
|
|
326
|
+
const postal = /\b(?:ship|deliver|mail)(?:ed)?\b/i.test(item.label);
|
|
327
|
+
if (postal) {
|
|
328
|
+
const [name, ...address] = item.content;
|
|
329
|
+
const content = [name ? `<strong>${escapeHtml(name)}</strong>` : "", ...address.map(escapeHtml)].filter(Boolean).join("<br>");
|
|
330
|
+
return `<section><h2>${escapeHtml(heading)}</h2><address>${content}</address></section>`;
|
|
331
|
+
}
|
|
332
|
+
return `<section><h2>${escapeHtml(heading)}</h2>${item.content.map(
|
|
333
|
+
(content, index) => `<p>${index === 0 ? `<strong>${escapeHtml(content)}</strong>` : escapeHtml(content)}</p>`
|
|
334
|
+
).join("")}</section>`;
|
|
335
|
+
}
|
|
336
|
+
function titleCase(value) {
|
|
337
|
+
const normalized = value.trim().toLocaleLowerCase("en");
|
|
338
|
+
return normalized.replace(/^\p{L}/u, (letter) => letter.toLocaleUpperCase("en"));
|
|
339
|
+
}
|
|
340
|
+
function escapeHtml(value) {
|
|
341
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// src/index.ts
|
|
29
345
|
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}`;
|
|
30
346
|
async function writeHtmlDocument(pages, write, options = {}) {
|
|
31
347
|
const includeDocument = options.includeDocument ?? true;
|
|
@@ -35,15 +351,28 @@ async function writeHtmlDocument(pages, write, options = {}) {
|
|
|
35
351
|
` lang="${escapeAttribute(options.language ?? "en")}"><head><meta charset="utf-8">`
|
|
36
352
|
);
|
|
37
353
|
await write('<meta name="viewport" content="width=device-width,initial-scale=1">');
|
|
38
|
-
await write(`<title>${
|
|
354
|
+
await write(`<title>${escapeHtml2(options.title ?? "PDF document")}</title>`);
|
|
39
355
|
if (options.includeStyles ?? true) await write(`<style>${styles}</style>`);
|
|
40
356
|
await write("</head><body>");
|
|
41
357
|
}
|
|
42
358
|
await write('<main class="pdf-document">');
|
|
43
|
-
|
|
359
|
+
if (resolveProfile(options) === "semantic") {
|
|
360
|
+
const lookahead = semanticLookahead(options.semanticLookaheadPages);
|
|
361
|
+
const stats = await writeSemanticDocument(pages, write, lookahead);
|
|
362
|
+
options.onSemanticStats?.(stats);
|
|
363
|
+
} else {
|
|
364
|
+
for await (const page of pages) await writePage(page, write, options);
|
|
365
|
+
}
|
|
44
366
|
await write("</main>");
|
|
45
367
|
if (includeDocument) await write("</body></html>");
|
|
46
368
|
}
|
|
369
|
+
function semanticLookahead(value) {
|
|
370
|
+
const lookahead = value ?? 4;
|
|
371
|
+
if (!Number.isSafeInteger(lookahead) || lookahead < 1 || lookahead > 16) {
|
|
372
|
+
throw new RangeError("semanticLookaheadPages must be an integer between 1 and 16");
|
|
373
|
+
}
|
|
374
|
+
return lookahead;
|
|
375
|
+
}
|
|
47
376
|
async function writePage(page, write, options = {}) {
|
|
48
377
|
if (resolveProfile(options) === "semantic") await writeFlowPage(page, write);
|
|
49
378
|
else await writePositionedPage(page, write, options);
|
|
@@ -83,8 +412,12 @@ async function writePositionedPage(page, write, options) {
|
|
|
83
412
|
await write(
|
|
84
413
|
`<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)}">`
|
|
85
414
|
);
|
|
415
|
+
const clipDefinitions = imageClipDefinitions(page.images ?? [], page.number, page.height) + pathClipDefinitions(page.paths ?? [], page.number);
|
|
416
|
+
if (clipDefinitions) await write(`<defs>${clipDefinitions}</defs>`);
|
|
86
417
|
if (reflectedOverlay) {
|
|
87
|
-
for (const image of page.images ?? [])
|
|
418
|
+
for (const [index, image] of (page.images ?? []).entries()) {
|
|
419
|
+
await write(visualImage(image, page.height, page.number, index));
|
|
420
|
+
}
|
|
88
421
|
}
|
|
89
422
|
for (const fill of page.fills ?? []) {
|
|
90
423
|
const points = fill.points.map(([x, y]) => `${number(x)},${number(page.height - y)}`).join(" ");
|
|
@@ -95,7 +428,7 @@ async function writePositionedPage(page, write, options) {
|
|
|
95
428
|
}
|
|
96
429
|
if (page.paths?.length) {
|
|
97
430
|
await write(`<g transform="translate(0 ${number(page.height)}) scale(1 -1)">`);
|
|
98
|
-
for (const path of page.paths) {
|
|
431
|
+
for (const [pathIndex, path] of page.paths.entries()) {
|
|
99
432
|
if (!isSvgPath(path.d)) continue;
|
|
100
433
|
const fill = isCssHexColor(path.fill) ? path.fill : "none";
|
|
101
434
|
const stroke = isCssHexColor(path.stroke) ? path.stroke : "none";
|
|
@@ -107,14 +440,18 @@ async function writePositionedPage(page, write, options) {
|
|
|
107
440
|
const dashoffset = Number.isFinite(path.strokeDashoffset) ? ` stroke-dashoffset="${number(path.strokeDashoffset ?? 0)}"` : "";
|
|
108
441
|
const linecap = path.strokeLinecap ? ` stroke-linecap="${path.strokeLinecap}"` : "";
|
|
109
442
|
const linejoin = path.strokeLinejoin ? ` stroke-linejoin="${path.strokeLinejoin}"` : "";
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
443
|
+
let output = `<path d="${path.d}" fill="${fill}" stroke="${stroke}"${strokeWidth}${fillOpacity}${strokeOpacity}${dasharray}${dashoffset}${linecap}${linejoin}${fillRule}/>`;
|
|
444
|
+
for (let index = (path.clips?.length ?? 0) - 1; index >= 0; index -= 1) {
|
|
445
|
+
output = `<g clip-path="url(#${pathClipId(page.number, pathIndex, index)})">${output}</g>`;
|
|
446
|
+
}
|
|
447
|
+
await write(output);
|
|
113
448
|
}
|
|
114
449
|
await write("</g>");
|
|
115
450
|
}
|
|
116
451
|
if (!reflectedOverlay) {
|
|
117
|
-
for (const image of page.images ?? [])
|
|
452
|
+
for (const [index, image] of (page.images ?? []).entries()) {
|
|
453
|
+
await write(visualImage(image, page.height, page.number, index));
|
|
454
|
+
}
|
|
118
455
|
}
|
|
119
456
|
for (const span of visualSpans) {
|
|
120
457
|
if (!usesPositionedSpan(span)) {
|
|
@@ -135,13 +472,41 @@ function usesReflectedVisualOverlay(page, spans) {
|
|
|
135
472
|
(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
|
|
136
473
|
);
|
|
137
474
|
}
|
|
138
|
-
function visualImage(image, pageHeight) {
|
|
475
|
+
function visualImage(image, pageHeight, pageNumber, imageIndex) {
|
|
139
476
|
const [a, b, c, d, e, f] = image.transform;
|
|
140
477
|
const transform = [a, -b, -c, d, c + e, pageHeight - d - f].map(number).join(" ");
|
|
141
478
|
const opacity = isUnitInterval(image.opacity) ? ` opacity="${number(image.opacity)}"` : "";
|
|
142
479
|
const mime = image.format === "jpeg" ? "image/jpeg" : "image/bmp";
|
|
143
480
|
const data = image.format === "jpeg" ? image.data : rgbBmp(image);
|
|
144
|
-
|
|
481
|
+
let output = `<image width="1" height="1" preserveAspectRatio="none" transform="matrix(${transform})" href="data:${mime};base64,${base64(data)}"${opacity}/>`;
|
|
482
|
+
for (let index = (image.clips?.length ?? 0) - 1; index >= 0; index -= 1) {
|
|
483
|
+
output = `<g clip-path="url(#${imageClipId(pageNumber, imageIndex, index)})">${output}</g>`;
|
|
484
|
+
}
|
|
485
|
+
return output;
|
|
486
|
+
}
|
|
487
|
+
function imageClipDefinitions(images, pageNumber, pageHeight) {
|
|
488
|
+
return images.flatMap(
|
|
489
|
+
(image, imageIndex) => (image.clips ?? []).map((clip, clipIndex) => {
|
|
490
|
+
if (!isSvgPath(clip.d)) return "";
|
|
491
|
+
const fillRule = clip.fillRule ? ` clip-rule="${clip.fillRule}"` : "";
|
|
492
|
+
return `<clipPath id="${imageClipId(pageNumber, imageIndex, clipIndex)}" clipPathUnits="userSpaceOnUse"><path d="${clip.d}" transform="translate(0 ${number(pageHeight)}) scale(1 -1)"${fillRule}/></clipPath>`;
|
|
493
|
+
})
|
|
494
|
+
).join("");
|
|
495
|
+
}
|
|
496
|
+
function imageClipId(pageNumber, imageIndex, clipIndex) {
|
|
497
|
+
return `boxpdf-clip-${pageNumber}-${imageIndex}-${clipIndex}`;
|
|
498
|
+
}
|
|
499
|
+
function pathClipDefinitions(paths, pageNumber) {
|
|
500
|
+
return paths.flatMap(
|
|
501
|
+
(path, pathIndex) => (path.clips ?? []).map((clip, clipIndex) => {
|
|
502
|
+
if (!isSvgPath(clip.d)) return "";
|
|
503
|
+
const fillRule = clip.fillRule ? ` clip-rule="${clip.fillRule}"` : "";
|
|
504
|
+
return `<clipPath id="${pathClipId(pageNumber, pathIndex, clipIndex)}" clipPathUnits="userSpaceOnUse"><path d="${clip.d}"${fillRule}/></clipPath>`;
|
|
505
|
+
})
|
|
506
|
+
).join("");
|
|
507
|
+
}
|
|
508
|
+
function pathClipId(pageNumber, pathIndex, clipIndex) {
|
|
509
|
+
return `boxpdf-path-clip-${pageNumber}-${pathIndex}-${clipIndex}`;
|
|
145
510
|
}
|
|
146
511
|
function rgbBmp(image) {
|
|
147
512
|
const stride = Math.ceil(image.width * 3 / 4) * 4;
|
|
@@ -195,28 +560,55 @@ function positionedSpan(span, fontAliases) {
|
|
|
195
560
|
span.fontAssetId ? fontAliases.get(span.fontAssetId) : void 0
|
|
196
561
|
)
|
|
197
562
|
].join(";");
|
|
198
|
-
return `<span class="pdf-span"${direction} style="${style}">${
|
|
563
|
+
return `<span class="pdf-span"${direction} style="${style}">${escapeHtml2(span.text)}</span>`;
|
|
199
564
|
}
|
|
200
565
|
async function writeFlowPage(page, write) {
|
|
201
|
-
const structured = (0,
|
|
202
|
-
const tables = [...structured.tables].sort((left, right) => right.bounds.y - left.bounds.y);
|
|
203
|
-
const emittedTables = /* @__PURE__ */ new Set();
|
|
566
|
+
const structured = (0, import_structure2.structurePage)(page);
|
|
204
567
|
await write(
|
|
205
568
|
`<section class="pdf-page pdf-page--semantic pdf-page--flow" data-page="${page.number}">`
|
|
206
569
|
);
|
|
207
|
-
for (const
|
|
208
|
-
|
|
209
|
-
if (
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
570
|
+
for (const block of structured.blocks) {
|
|
571
|
+
if (block.type === "table") await write((0, import_structure2.tableToHtml)(block.table));
|
|
572
|
+
else if (block.type === "heading") {
|
|
573
|
+
await write(`<h${block.level}>${escapeHtml2(block.text)}</h${block.level}>`);
|
|
574
|
+
} else if (block.type === "paragraph") {
|
|
575
|
+
await write(
|
|
576
|
+
`<p${directionAttribute(block.lines.flatMap((line) => line.spans))}>${escapeHtml2(block.text)}</p>`
|
|
577
|
+
);
|
|
578
|
+
} else if (block.type === "definitionList") {
|
|
579
|
+
await write("<dl>");
|
|
580
|
+
for (const entry of block.entries) {
|
|
581
|
+
await write(
|
|
582
|
+
`<div><dt>${escapeHtml2(entry.term)}</dt><dd>${escapeHtml2(entry.description)}</dd></div>`
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
await write("</dl>");
|
|
586
|
+
} else if (block.type === "cardList") {
|
|
587
|
+
await write('<div class="pdf-semantic-cards">');
|
|
588
|
+
for (const item of block.items) {
|
|
589
|
+
await write(`<article><h3>${escapeHtml2(item.title)}</h3>`);
|
|
590
|
+
for (const detail of item.details) await write(`<p>${escapeHtml2(detail)}</p>`);
|
|
591
|
+
await write("</article>");
|
|
213
592
|
}
|
|
214
|
-
|
|
593
|
+
await write("</div>");
|
|
594
|
+
} else if (block.type === "sectionGroup") {
|
|
595
|
+
await write('<div class="pdf-semantic-sections">');
|
|
596
|
+
for (const item of block.items) {
|
|
597
|
+
await write(`<section><h3>${escapeHtml2(item.label)}</h3>`);
|
|
598
|
+
for (const content of item.content) await write(`<p>${escapeHtml2(content)}</p>`);
|
|
599
|
+
await write("</section>");
|
|
600
|
+
}
|
|
601
|
+
await write("</div>");
|
|
602
|
+
} else if (block.type === "employment") {
|
|
603
|
+
await write(
|
|
604
|
+
`<section><h3>${escapeHtml2(block.role)}</h3><p>${escapeHtml2(block.organization)}</p><p>${escapeHtml2(block.date)}</p></section>`
|
|
605
|
+
);
|
|
606
|
+
} else {
|
|
607
|
+
const tag = block.ordered ? "ol" : "ul";
|
|
608
|
+
await write(`<${tag}>`);
|
|
609
|
+
for (const item of block.items) await write(`<li>${escapeHtml2(item.text)}</li>`);
|
|
610
|
+
await write(`</${tag}>`);
|
|
215
611
|
}
|
|
216
|
-
await write(`<p${directionAttribute(line.spans)}>${escapeHtml(line.text)}</p>`);
|
|
217
|
-
}
|
|
218
|
-
for (const table of tables) {
|
|
219
|
-
if (!emittedTables.has(table)) await write((0, import_structure.tableToHtml)(table));
|
|
220
612
|
}
|
|
221
613
|
await write("</section>");
|
|
222
614
|
}
|
|
@@ -258,7 +650,7 @@ function visualText(span, pageHeight, fontAliases, counterRotateReflectedText =
|
|
|
258
650
|
const anchorX = span.bounds.x + basisX * rtlOffset;
|
|
259
651
|
const anchorY = pageHeight - span.bounds.y + basisY * rtlOffset;
|
|
260
652
|
const position = transformed ? ` x="0" y="0" transform="matrix(${transform?.map(number).join(" ")} ${number(anchorX)} ${number(anchorY)})"` : ` x="${number(anchorX)}" y="${number(anchorY)}"`;
|
|
261
|
-
return `<text${direction}${position} font-size="${number(span.fontSize)}"${textLength}${style ? ` style="${style}"` : ""}>${
|
|
653
|
+
return `<text${direction}${position} font-size="${number(span.fontSize)}"${textLength}${style ? ` style="${style}"` : ""}>${escapeHtml2(span.text)}</text>`;
|
|
262
654
|
}
|
|
263
655
|
function isAdobeCjkFont(fontFamily) {
|
|
264
656
|
return /^Adobe(?:Heiti|Song|Kaiti|Ming|Gothic|Mincho)Std-/i.test(fontFamily ?? "");
|
|
@@ -371,16 +763,13 @@ function directionAttribute(spans) {
|
|
|
371
763
|
if (vertical > rtl && vertical * 2 >= spans.length) return ' data-direction="ttb"';
|
|
372
764
|
return rtl * 2 >= spans.length && spans.length > 0 ? ' dir="rtl"' : "";
|
|
373
765
|
}
|
|
374
|
-
function containsY(table, y) {
|
|
375
|
-
return y >= table.bounds.y && y <= table.bounds.y + table.bounds.height;
|
|
376
|
-
}
|
|
377
766
|
function number(value) {
|
|
378
767
|
return Number.isFinite(value) ? String(Math.round(value * 1e3) / 1e3) : "0";
|
|
379
768
|
}
|
|
380
769
|
function escapeAttribute(value) {
|
|
381
|
-
return
|
|
770
|
+
return escapeHtml2(value).replaceAll("`", "`");
|
|
382
771
|
}
|
|
383
|
-
function
|
|
772
|
+
function escapeHtml2(value) {
|
|
384
773
|
return [...value].map((character) => {
|
|
385
774
|
const codePoint = character.codePointAt(0) ?? 0;
|
|
386
775
|
if (codePoint === 13) return "\n";
|