@boxpdf/html-writer 0.1.11 → 0.1.12

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/dist/index.js CHANGED
@@ -6,6 +6,59 @@ import {
6
6
  structurePage,
7
7
  tableToRows
8
8
  } from "@boxpdf/reader/structure";
9
+
10
+ // src/semantic-inline.ts
11
+ function dominantTextColor(lines) {
12
+ const counts = /* @__PURE__ */ new Map();
13
+ for (const span of lines.flatMap((line) => line.spans)) {
14
+ const color = normalizedColor(span.color) ?? "#000000";
15
+ counts.set(color, (counts.get(color) ?? 0) + Math.max(1, [...span.text].length));
16
+ }
17
+ return [...counts].sort((left, right) => right[1] - left[1])[0]?.[0] ?? "#000000";
18
+ }
19
+ function semanticTextHtml(text, lines, defaultColor) {
20
+ const ranges = [];
21
+ let cursor = 0;
22
+ for (const span of lines.flatMap((line) => line.spans)) {
23
+ if (!span.text) continue;
24
+ const start = text.indexOf(span.text, cursor);
25
+ if (start < 0) continue;
26
+ cursor = start + span.text.length;
27
+ const color = normalizedColor(span.color);
28
+ if (color && color !== defaultColor) ranges.push({ start, end: cursor, color });
29
+ }
30
+ const merged = mergeRanges(ranges, text);
31
+ let html = "";
32
+ let offset = 0;
33
+ for (const range of merged) {
34
+ html += escapeHtml(text.slice(offset, range.start));
35
+ html += `<span style="color:${range.color}">${escapeHtml(text.slice(range.start, range.end))}</span>`;
36
+ offset = range.end;
37
+ }
38
+ return html + escapeHtml(text.slice(offset));
39
+ }
40
+ function mergeRanges(ranges, text) {
41
+ const merged = [];
42
+ for (const range of ranges) {
43
+ const previous = merged.at(-1);
44
+ if (previous && previous.color === range.color && /^\s*$/.test(text.slice(previous.end, range.start))) {
45
+ previous.end = range.end;
46
+ } else {
47
+ merged.push({ ...range });
48
+ }
49
+ }
50
+ return merged;
51
+ }
52
+ function normalizedColor(value) {
53
+ if (!value || !/^#[\da-f]{6}$/i.test(value)) return void 0;
54
+ const color = value.toLowerCase();
55
+ return color === "#000000" || color === "#000" ? "#000000" : color;
56
+ }
57
+ function escapeHtml(value) {
58
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
59
+ }
60
+
61
+ // src/semantic-document.ts
9
62
  async function writeSemanticDocument(pages, write, lookaheadPages) {
10
63
  const stats = {
11
64
  pagesProcessed: 0,
@@ -37,7 +90,7 @@ async function writeSemanticDocument(pages, write, lookaheadPages) {
37
90
  };
38
91
  const flushPendingParagraph = async () => {
39
92
  if (!pendingParagraph) return;
40
- await write(semanticBlockHtml(pendingParagraph.block));
93
+ await write(semanticBlockHtml(pendingParagraph.block, pendingParagraph.defaultColor));
41
94
  pendingParagraph = void 0;
42
95
  };
43
96
  const closeEmployment = async () => {
@@ -46,9 +99,11 @@ async function writeSemanticDocument(pages, write, lookaheadPages) {
46
99
  employmentOpen = false;
47
100
  };
48
101
  const emitPage = async (page, future) => {
102
+ const defaultColor = dominantTextColor(page.structured.lines);
49
103
  const futureFurniture = new Set(future.flatMap((candidate) => marginSignatures(candidate)));
50
104
  const repeatedFurniture = /* @__PURE__ */ new Set([...seenFurniture, ...futureFurniture]);
51
- for (const block of page.structured.blocks) {
105
+ for (const [blockIndex, block] of page.structured.blocks.entries()) {
106
+ const nextBlock = page.structured.blocks[blockIndex + 1];
52
107
  if (isRepeatedFurniture(block, page, repeatedFurniture)) {
53
108
  stats.suppressedFurniture += 1;
54
109
  continue;
@@ -56,19 +111,23 @@ async function writeSemanticDocument(pages, write, lookaheadPages) {
56
111
  await flushPendingParagraph();
57
112
  if (employmentOpen && block.type !== "list") await closeEmployment();
58
113
  if (!contentStarted && !headerOpen && block.type === "heading" && block.level === 1) {
59
- await write(`<header><h1>${escapeHtml(block.text)}</h1>`);
114
+ await write(`<header><h1>${semanticTextHtml(block.text, block.lines, defaultColor)}</h1>`);
60
115
  headerOpen = true;
61
116
  continue;
62
117
  }
63
118
  if (headerOpen) {
64
119
  if (block.type === "paragraph") {
65
120
  const tag = isContactBlock(block) ? "address" : "p";
66
- await write(`<${tag}>${escapeHtml(block.text)}</${tag}>`);
121
+ await write(
122
+ `<${tag}>${semanticTextHtml(block.text, block.lines, defaultColor)}</${tag}>`
123
+ );
67
124
  headerHasParagraph = true;
68
125
  continue;
69
126
  }
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}>`);
127
+ if (block.type === "heading" && !headerHasParagraph && (block.level === 1 || block.text.trimStart().startsWith("#") || block.level === 4 && nextBlock?.type === "paragraph" && isContactBlock(nextBlock))) {
128
+ await write(
129
+ `<h${block.level}>${semanticTextHtml(block.text, block.lines, defaultColor)}</h${block.level}>`
130
+ );
72
131
  continue;
73
132
  }
74
133
  await write("</header>");
@@ -105,7 +164,7 @@ async function writeSemanticDocument(pages, write, lookaheadPages) {
105
164
  const level = contentStarted && block.level === 1 ? 2 : block.level;
106
165
  await closeSections(level);
107
166
  await write(
108
- `<section data-level="${level}"><h${level}>${escapeHtml(block.text)}</h${level}>`
167
+ `<section data-level="${level}"><h${level}>${semanticTextHtml(block.text, block.lines, defaultColor)}</h${level}>`
109
168
  );
110
169
  sectionLevels.push(level);
111
170
  continue;
@@ -113,27 +172,27 @@ async function writeSemanticDocument(pages, write, lookaheadPages) {
113
172
  if (block.type === "paragraph") {
114
173
  if (isTitledRecord(block)) {
115
174
  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>`);
175
+ if (institution) await write(`<h3>${escapeHtml2(institution.text)}</h3>`);
176
+ for (const detail of details) await write(`<p>${escapeHtml2(detail.text)}</p>`);
118
177
  continue;
119
178
  }
120
179
  if (isUnmarkedList(block)) {
121
180
  await write(
122
- `<ul>${block.lines.map((line) => `<li>${escapeHtml(line.text)}</li>`).join("")}</ul>`
181
+ `<ul>${block.lines.map((line) => `<li>${escapeHtml2(line.text)}</li>`).join("")}</ul>`
123
182
  );
124
183
  continue;
125
184
  }
126
- pendingParagraph = { block, height: page.height };
185
+ pendingParagraph = { block, height: page.height, defaultColor };
127
186
  continue;
128
187
  }
129
188
  if (block.type === "employment") {
130
189
  await write(
131
- `<section><h3>${escapeHtml(block.role)}</h3><p>${escapeHtml(block.organization)}</p><p>${escapeHtml(block.date)}</p>`
190
+ `<section><h3>${escapeHtml2(block.role)}</h3><p>${escapeHtml2(block.organization)}</p><p>${escapeHtml2(block.date)}</p>`
132
191
  );
133
192
  employmentOpen = true;
134
193
  continue;
135
194
  }
136
- await write(semanticBlockHtml(block));
195
+ await write(semanticBlockHtml(block, defaultColor));
137
196
  }
138
197
  for (const signature of marginSignatures(page)) seenFurniture.add(signature);
139
198
  };
@@ -160,7 +219,9 @@ async function writeSemanticDocument(pages, write, lookaheadPages) {
160
219
  await closeEmployment();
161
220
  if (pendingParagraph && isFooterParagraph(pendingParagraph.block, pendingParagraph.height)) {
162
221
  await closeSections();
163
- await write(`<footer>${semanticBlockHtml(pendingParagraph.block)}</footer>`);
222
+ await write(
223
+ `<footer>${semanticBlockHtml(pendingParagraph.block, pendingParagraph.defaultColor)}</footer>`
224
+ );
164
225
  pendingParagraph = void 0;
165
226
  } else {
166
227
  await flushPendingParagraph();
@@ -250,7 +311,7 @@ function sameRow(left, right) {
250
311
  }
251
312
  function tableRow(row, header) {
252
313
  const cell = header ? "th" : "td";
253
- return `<tr>${row.map((value) => `<${cell}>${escapeHtml(value)}</${cell}>`).join("")}</tr>`;
314
+ return `<tr>${row.map((value) => `<${cell}>${escapeHtml2(value)}</${cell}>`).join("")}</tr>`;
254
315
  }
255
316
  function isFinancialSummary(block) {
256
317
  return block.entries.length > 0 && block.entries.every((entry) => isNumericValue(entry.description)) && block.entries.some((entry) => /\p{Sc}|%/u.test(entry.description));
@@ -260,20 +321,22 @@ function isNumericValue(value) {
260
321
  }
261
322
  function financialSummaryRow(entry, columns) {
262
323
  const colspan = columns > 2 ? ` colspan="${columns - 1}"` : "";
263
- return `<tr><th scope="row"${colspan}>${escapeHtml(entry.term)}</th><td>${escapeHtml(entry.description)}</td></tr>`;
324
+ return `<tr><th scope="row"${colspan}>${escapeHtml2(entry.term)}</th><td>${escapeHtml2(entry.description)}</td></tr>`;
264
325
  }
265
- function semanticBlockHtml(block) {
326
+ function semanticBlockHtml(block, defaultColor = "#000000") {
266
327
  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>`;
328
+ return `<h${block.level}>${semanticTextHtml(block.text, block.lines, defaultColor)}</h${block.level}>`;
329
+ if (block.type === "paragraph")
330
+ return `<p>${semanticTextHtml(block.text, block.lines, defaultColor)}</p>`;
331
+ if (block.type === "preformatted") return `<pre>${escapeHtml2(block.text)}</pre>`;
269
332
  if (block.type === "definitionList") {
270
333
  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
334
  return block.entries.map(
272
- (entry) => `<section><h2>${escapeHtml(titleCase(entry.term))}</h2><p>${escapeHtml(entry.description)}</p></section>`
335
+ (entry) => `<section><h2>${escapeHtml2(titleCase(entry.term))}</h2><p>${escapeHtml2(entry.description)}</p></section>`
273
336
  ).join("");
274
337
  }
275
338
  const list = `<dl>${block.entries.map(
276
- (entry) => `<div><dt>${escapeHtml(entry.term)}</dt><dd>${escapeHtml(entry.description)}</dd></div>`
339
+ (entry) => `<div><dt>${escapeHtml2(entry.term)}</dt><dd>${escapeHtml2(entry.description)}</dd></div>`
277
340
  ).join("")}</dl>`;
278
341
  return isFinancialSummary(block) ? `<section>${list}</section>` : list;
279
342
  }
@@ -284,42 +347,42 @@ function semanticBlockHtml(block) {
284
347
  return block.items.map(labeledSectionHtml).join("");
285
348
  }
286
349
  if (block.type === "employment") {
287
- return `<section><h3>${escapeHtml(block.role)}</h3><p>${escapeHtml(block.organization)}</p><p>${escapeHtml(block.date)}</p></section>`;
350
+ return `<section><h3>${escapeHtml2(block.role)}</h3><p>${escapeHtml2(block.organization)}</p><p>${escapeHtml2(block.date)}</p></section>`;
288
351
  }
289
352
  const tag = block.ordered ? "ol" : "ul";
290
- return `<${tag}>${block.items.map((item) => `<li>${escapeHtml(item.text)}</li>`).join("")}</${tag}>`;
353
+ return `<${tag}>${block.items.map((item) => `<li>${escapeHtml2(item.text)}</li>`).join("")}</${tag}>`;
291
354
  }
292
355
  function cardTableRow(item) {
293
356
  const trailing = item.details.at(-1) ?? "";
294
357
  const match = /^\s*[×x]\s*(\d+)\s+(.+)$/u.exec(trailing);
295
358
  const description = item.details.slice(0, -1).join(" ");
296
- const detail = description ? `<br><span>${escapeHtml(description)}</span>` : "";
359
+ const detail = description ? `<br><span>${escapeHtml2(description)}</span>` : "";
297
360
  const quantity = match?.[1] ?? "";
298
361
  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>`;
362
+ return `<tr><th scope="row">${escapeHtml2(item.title)}${detail}</th><td>${escapeHtml2(quantity)}</td><td>${escapeHtml2(amount)}</td></tr>`;
300
363
  }
301
364
  function labeledSectionHtml(item) {
302
365
  const heading = titleCase(item.label);
303
366
  const postal = /\b(?:ship|deliver|mail)(?:ed)?\b/i.test(item.label);
304
367
  if (postal) {
305
368
  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>`;
369
+ const content = [name ? `<strong>${escapeHtml2(name)}</strong>` : "", ...address.map(escapeHtml2)].filter(Boolean).join("<br>");
370
+ return `<section><h2>${escapeHtml2(heading)}</h2><address>${content}</address></section>`;
308
371
  }
309
- return `<section><h2>${escapeHtml(heading)}</h2>${item.content.map(
310
- (content, index) => `<p>${index === 0 ? `<strong>${escapeHtml(content)}</strong>` : escapeHtml(content)}</p>`
372
+ return `<section><h2>${escapeHtml2(heading)}</h2>${item.content.map(
373
+ (content, index) => `<p>${index === 0 ? `<strong>${escapeHtml2(content)}</strong>` : escapeHtml2(content)}</p>`
311
374
  ).join("")}</section>`;
312
375
  }
313
376
  function titleCase(value) {
314
377
  const normalized = value.trim().toLocaleLowerCase("en");
315
378
  return normalized.replace(/^\p{L}/u, (letter) => letter.toLocaleUpperCase("en"));
316
379
  }
317
- function escapeHtml(value) {
380
+ function escapeHtml2(value) {
318
381
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
319
382
  }
320
383
 
321
384
  // src/index.ts
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}`;
385
+ 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-semantic-document h1,.pdf-page--semantic h1{font-size:1.7em}.pdf-semantic-document h2,.pdf-page--semantic h2{font-size:1.5em}.pdf-semantic-document h3,.pdf-page--semantic h3{font-size:1.35em}.pdf-semantic-document h4,.pdf-page--semantic h4{font-size:1.1em}.pdf-page table{border-collapse:collapse}.pdf-page td{padding:.15rem .4rem;vertical-align:top}`;
323
386
  async function writeHtmlDocument(pages, write, options = {}) {
324
387
  const includeDocument = options.includeDocument ?? true;
325
388
  if (includeDocument) {
@@ -328,7 +391,7 @@ async function writeHtmlDocument(pages, write, options = {}) {
328
391
  ` lang="${escapeAttribute(options.language ?? "en")}"><head><meta charset="utf-8">`
329
392
  );
330
393
  await write('<meta name="viewport" content="width=device-width,initial-scale=1">');
331
- await write(`<title>${escapeHtml2(options.title ?? "PDF document")}</title>`);
394
+ await write(`<title>${escapeHtml3(options.title ?? "PDF document")}</title>`);
332
395
  if (options.includeStyles ?? true) await write(`<style>${styles}</style>`);
333
396
  await write("</head><body>");
334
397
  }
@@ -537,53 +600,58 @@ function positionedSpan(span, fontAliases) {
537
600
  span.fontAssetId ? fontAliases.get(span.fontAssetId) : void 0
538
601
  )
539
602
  ].join(";");
540
- return `<span class="pdf-span"${direction} style="${style}">${escapeHtml2(span.text)}</span>`;
603
+ return `<span class="pdf-span"${direction} style="${style}">${escapeHtml3(span.text)}</span>`;
541
604
  }
542
605
  async function writeFlowPage(page, write) {
543
606
  const structured = structurePage2(page);
607
+ const defaultColor = dominantTextColor(structured.lines);
544
608
  await write(
545
609
  `<section class="pdf-page pdf-page--semantic pdf-page--flow" data-page="${page.number}">`
546
610
  );
547
611
  for (const block of structured.blocks) {
548
612
  if (block.type === "table") await write(tableToHtml(block.table));
549
613
  else if (block.type === "heading") {
550
- await write(`<h${block.level}>${escapeHtml2(block.text)}</h${block.level}>`);
614
+ await write(
615
+ `<h${block.level}>${semanticTextHtml(block.text, block.lines, defaultColor)}</h${block.level}>`
616
+ );
551
617
  } else if (block.type === "paragraph") {
552
618
  await write(
553
- `<p${directionAttribute(block.lines.flatMap((line) => line.spans))}>${escapeHtml2(block.text)}</p>`
619
+ `<p${directionAttribute(block.lines.flatMap((line) => line.spans))}>${semanticTextHtml(block.text, block.lines, defaultColor)}</p>`
554
620
  );
621
+ } else if (block.type === "preformatted") {
622
+ await write(`<pre>${escapeHtml3(block.text)}</pre>`);
555
623
  } else if (block.type === "definitionList") {
556
624
  await write("<dl>");
557
625
  for (const entry of block.entries) {
558
626
  await write(
559
- `<div><dt>${escapeHtml2(entry.term)}</dt><dd>${escapeHtml2(entry.description)}</dd></div>`
627
+ `<div><dt>${escapeHtml3(entry.term)}</dt><dd>${escapeHtml3(entry.description)}</dd></div>`
560
628
  );
561
629
  }
562
630
  await write("</dl>");
563
631
  } else if (block.type === "cardList") {
564
632
  await write('<div class="pdf-semantic-cards">');
565
633
  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>`);
634
+ await write(`<article><h3>${escapeHtml3(item.title)}</h3>`);
635
+ for (const detail of item.details) await write(`<p>${escapeHtml3(detail)}</p>`);
568
636
  await write("</article>");
569
637
  }
570
638
  await write("</div>");
571
639
  } else if (block.type === "sectionGroup") {
572
640
  await write('<div class="pdf-semantic-sections">');
573
641
  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>`);
642
+ await write(`<section><h3>${escapeHtml3(item.label)}</h3>`);
643
+ for (const content of item.content) await write(`<p>${escapeHtml3(content)}</p>`);
576
644
  await write("</section>");
577
645
  }
578
646
  await write("</div>");
579
647
  } else if (block.type === "employment") {
580
648
  await write(
581
- `<section><h3>${escapeHtml2(block.role)}</h3><p>${escapeHtml2(block.organization)}</p><p>${escapeHtml2(block.date)}</p></section>`
649
+ `<section><h3>${escapeHtml3(block.role)}</h3><p>${escapeHtml3(block.organization)}</p><p>${escapeHtml3(block.date)}</p></section>`
582
650
  );
583
651
  } else {
584
652
  const tag = block.ordered ? "ol" : "ul";
585
653
  await write(`<${tag}>`);
586
- for (const item of block.items) await write(`<li>${escapeHtml2(item.text)}</li>`);
654
+ for (const item of block.items) await write(`<li>${escapeHtml3(item.text)}</li>`);
587
655
  await write(`</${tag}>`);
588
656
  }
589
657
  }
@@ -627,7 +695,7 @@ function visualText(span, pageHeight, fontAliases, counterRotateReflectedText =
627
695
  const anchorX = span.bounds.x + basisX * rtlOffset;
628
696
  const anchorY = pageHeight - span.bounds.y + basisY * rtlOffset;
629
697
  const position = transformed ? ` x="0" y="0" transform="matrix(${transform?.map(number).join(" ")} ${number(anchorX)} ${number(anchorY)})"` : ` x="${number(anchorX)}" y="${number(anchorY)}"`;
630
- return `<text${direction}${position} font-size="${number(span.fontSize)}"${textLength}${style ? ` style="${style}"` : ""}>${escapeHtml2(span.text)}</text>`;
698
+ return `<text${direction}${position} font-size="${number(span.fontSize)}"${textLength}${style ? ` style="${style}"` : ""}>${escapeHtml3(span.text)}</text>`;
631
699
  }
632
700
  function isAdobeCjkFont(fontFamily) {
633
701
  return /^Adobe(?:Heiti|Song|Kaiti|Ming|Gothic|Mincho)Std-/i.test(fontFamily ?? "");
@@ -744,9 +812,9 @@ function number(value) {
744
812
  return Number.isFinite(value) ? String(Math.round(value * 1e3) / 1e3) : "0";
745
813
  }
746
814
  function escapeAttribute(value) {
747
- return escapeHtml2(value).replaceAll("`", "&#96;");
815
+ return escapeHtml3(value).replaceAll("`", "&#96;");
748
816
  }
749
- function escapeHtml2(value) {
817
+ function escapeHtml3(value) {
750
818
  return [...value].map((character) => {
751
819
  const codePoint = character.codePointAt(0) ?? 0;
752
820
  if (codePoint === 13) return "\n";