@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 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
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>${escapeHtml(options.title ?? "PDF document")}</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
- for await (const page of pages) await writePage(page, write, options);
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 ?? []) await write(visualImage(image, page.height));
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
- await write(
111
- `<path d="${path.d}" fill="${fill}" stroke="${stroke}"${strokeWidth}${fillOpacity}${strokeOpacity}${dasharray}${dashoffset}${linecap}${linejoin}${fillRule}/>`
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 ?? []) await write(visualImage(image, page.height));
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
- return `<image width="1" height="1" preserveAspectRatio="none" transform="matrix(${transform})" href="data:${mime};base64,${base64(data)}"${opacity}/>`;
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}">${escapeHtml(span.text)}</span>`;
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, import_structure.structurePage)(page);
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 line of structured.lines) {
208
- const table = tables.find((candidate) => containsY(candidate, line.bounds.y));
209
- if (table) {
210
- if (!emittedTables.has(table)) {
211
- await write((0, import_structure.tableToHtml)(table));
212
- emittedTables.add(table);
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
- continue;
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}"` : ""}>${escapeHtml(span.text)}</text>`;
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 escapeHtml(value).replaceAll("`", "&#96;");
770
+ return escapeHtml2(value).replaceAll("`", "&#96;");
382
771
  }
383
- function escapeHtml(value) {
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";