@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.cjs CHANGED
@@ -29,6 +29,59 @@ var import_structure2 = require("@boxpdf/reader/structure");
29
29
 
30
30
  // src/semantic-document.ts
31
31
  var import_structure = require("@boxpdf/reader/structure");
32
+
33
+ // src/semantic-inline.ts
34
+ function dominantTextColor(lines) {
35
+ const counts = /* @__PURE__ */ new Map();
36
+ for (const span of lines.flatMap((line) => line.spans)) {
37
+ const color = normalizedColor(span.color) ?? "#000000";
38
+ counts.set(color, (counts.get(color) ?? 0) + Math.max(1, [...span.text].length));
39
+ }
40
+ return [...counts].sort((left, right) => right[1] - left[1])[0]?.[0] ?? "#000000";
41
+ }
42
+ function semanticTextHtml(text, lines, defaultColor) {
43
+ const ranges = [];
44
+ let cursor = 0;
45
+ for (const span of lines.flatMap((line) => line.spans)) {
46
+ if (!span.text) continue;
47
+ const start = text.indexOf(span.text, cursor);
48
+ if (start < 0) continue;
49
+ cursor = start + span.text.length;
50
+ const color = normalizedColor(span.color);
51
+ if (color && color !== defaultColor) ranges.push({ start, end: cursor, color });
52
+ }
53
+ const merged = mergeRanges(ranges, text);
54
+ let html = "";
55
+ let offset = 0;
56
+ for (const range of merged) {
57
+ html += escapeHtml(text.slice(offset, range.start));
58
+ html += `<span style="color:${range.color}">${escapeHtml(text.slice(range.start, range.end))}</span>`;
59
+ offset = range.end;
60
+ }
61
+ return html + escapeHtml(text.slice(offset));
62
+ }
63
+ function mergeRanges(ranges, text) {
64
+ const merged = [];
65
+ for (const range of ranges) {
66
+ const previous = merged.at(-1);
67
+ if (previous && previous.color === range.color && /^\s*$/.test(text.slice(previous.end, range.start))) {
68
+ previous.end = range.end;
69
+ } else {
70
+ merged.push({ ...range });
71
+ }
72
+ }
73
+ return merged;
74
+ }
75
+ function normalizedColor(value) {
76
+ if (!value || !/^#[\da-f]{6}$/i.test(value)) return void 0;
77
+ const color = value.toLowerCase();
78
+ return color === "#000000" || color === "#000" ? "#000000" : color;
79
+ }
80
+ function escapeHtml(value) {
81
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
82
+ }
83
+
84
+ // src/semantic-document.ts
32
85
  async function writeSemanticDocument(pages, write, lookaheadPages) {
33
86
  const stats = {
34
87
  pagesProcessed: 0,
@@ -60,7 +113,7 @@ async function writeSemanticDocument(pages, write, lookaheadPages) {
60
113
  };
61
114
  const flushPendingParagraph = async () => {
62
115
  if (!pendingParagraph) return;
63
- await write(semanticBlockHtml(pendingParagraph.block));
116
+ await write(semanticBlockHtml(pendingParagraph.block, pendingParagraph.defaultColor));
64
117
  pendingParagraph = void 0;
65
118
  };
66
119
  const closeEmployment = async () => {
@@ -69,9 +122,11 @@ async function writeSemanticDocument(pages, write, lookaheadPages) {
69
122
  employmentOpen = false;
70
123
  };
71
124
  const emitPage = async (page, future) => {
125
+ const defaultColor = dominantTextColor(page.structured.lines);
72
126
  const futureFurniture = new Set(future.flatMap((candidate) => marginSignatures(candidate)));
73
127
  const repeatedFurniture = /* @__PURE__ */ new Set([...seenFurniture, ...futureFurniture]);
74
- for (const block of page.structured.blocks) {
128
+ for (const [blockIndex, block] of page.structured.blocks.entries()) {
129
+ const nextBlock = page.structured.blocks[blockIndex + 1];
75
130
  if (isRepeatedFurniture(block, page, repeatedFurniture)) {
76
131
  stats.suppressedFurniture += 1;
77
132
  continue;
@@ -79,19 +134,23 @@ async function writeSemanticDocument(pages, write, lookaheadPages) {
79
134
  await flushPendingParagraph();
80
135
  if (employmentOpen && block.type !== "list") await closeEmployment();
81
136
  if (!contentStarted && !headerOpen && block.type === "heading" && block.level === 1) {
82
- await write(`<header><h1>${escapeHtml(block.text)}</h1>`);
137
+ await write(`<header><h1>${semanticTextHtml(block.text, block.lines, defaultColor)}</h1>`);
83
138
  headerOpen = true;
84
139
  continue;
85
140
  }
86
141
  if (headerOpen) {
87
142
  if (block.type === "paragraph") {
88
143
  const tag = isContactBlock(block) ? "address" : "p";
89
- await write(`<${tag}>${escapeHtml(block.text)}</${tag}>`);
144
+ await write(
145
+ `<${tag}>${semanticTextHtml(block.text, block.lines, defaultColor)}</${tag}>`
146
+ );
90
147
  headerHasParagraph = true;
91
148
  continue;
92
149
  }
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}>`);
150
+ if (block.type === "heading" && !headerHasParagraph && (block.level === 1 || block.text.trimStart().startsWith("#") || block.level === 4 && nextBlock?.type === "paragraph" && isContactBlock(nextBlock))) {
151
+ await write(
152
+ `<h${block.level}>${semanticTextHtml(block.text, block.lines, defaultColor)}</h${block.level}>`
153
+ );
95
154
  continue;
96
155
  }
97
156
  await write("</header>");
@@ -128,7 +187,7 @@ async function writeSemanticDocument(pages, write, lookaheadPages) {
128
187
  const level = contentStarted && block.level === 1 ? 2 : block.level;
129
188
  await closeSections(level);
130
189
  await write(
131
- `<section data-level="${level}"><h${level}>${escapeHtml(block.text)}</h${level}>`
190
+ `<section data-level="${level}"><h${level}>${semanticTextHtml(block.text, block.lines, defaultColor)}</h${level}>`
132
191
  );
133
192
  sectionLevels.push(level);
134
193
  continue;
@@ -136,27 +195,27 @@ async function writeSemanticDocument(pages, write, lookaheadPages) {
136
195
  if (block.type === "paragraph") {
137
196
  if (isTitledRecord(block)) {
138
197
  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>`);
198
+ if (institution) await write(`<h3>${escapeHtml2(institution.text)}</h3>`);
199
+ for (const detail of details) await write(`<p>${escapeHtml2(detail.text)}</p>`);
141
200
  continue;
142
201
  }
143
202
  if (isUnmarkedList(block)) {
144
203
  await write(
145
- `<ul>${block.lines.map((line) => `<li>${escapeHtml(line.text)}</li>`).join("")}</ul>`
204
+ `<ul>${block.lines.map((line) => `<li>${escapeHtml2(line.text)}</li>`).join("")}</ul>`
146
205
  );
147
206
  continue;
148
207
  }
149
- pendingParagraph = { block, height: page.height };
208
+ pendingParagraph = { block, height: page.height, defaultColor };
150
209
  continue;
151
210
  }
152
211
  if (block.type === "employment") {
153
212
  await write(
154
- `<section><h3>${escapeHtml(block.role)}</h3><p>${escapeHtml(block.organization)}</p><p>${escapeHtml(block.date)}</p>`
213
+ `<section><h3>${escapeHtml2(block.role)}</h3><p>${escapeHtml2(block.organization)}</p><p>${escapeHtml2(block.date)}</p>`
155
214
  );
156
215
  employmentOpen = true;
157
216
  continue;
158
217
  }
159
- await write(semanticBlockHtml(block));
218
+ await write(semanticBlockHtml(block, defaultColor));
160
219
  }
161
220
  for (const signature of marginSignatures(page)) seenFurniture.add(signature);
162
221
  };
@@ -183,7 +242,9 @@ async function writeSemanticDocument(pages, write, lookaheadPages) {
183
242
  await closeEmployment();
184
243
  if (pendingParagraph && isFooterParagraph(pendingParagraph.block, pendingParagraph.height)) {
185
244
  await closeSections();
186
- await write(`<footer>${semanticBlockHtml(pendingParagraph.block)}</footer>`);
245
+ await write(
246
+ `<footer>${semanticBlockHtml(pendingParagraph.block, pendingParagraph.defaultColor)}</footer>`
247
+ );
187
248
  pendingParagraph = void 0;
188
249
  } else {
189
250
  await flushPendingParagraph();
@@ -273,7 +334,7 @@ function sameRow(left, right) {
273
334
  }
274
335
  function tableRow(row, header) {
275
336
  const cell = header ? "th" : "td";
276
- return `<tr>${row.map((value) => `<${cell}>${escapeHtml(value)}</${cell}>`).join("")}</tr>`;
337
+ return `<tr>${row.map((value) => `<${cell}>${escapeHtml2(value)}</${cell}>`).join("")}</tr>`;
277
338
  }
278
339
  function isFinancialSummary(block) {
279
340
  return block.entries.length > 0 && block.entries.every((entry) => isNumericValue(entry.description)) && block.entries.some((entry) => /\p{Sc}|%/u.test(entry.description));
@@ -283,20 +344,22 @@ function isNumericValue(value) {
283
344
  }
284
345
  function financialSummaryRow(entry, columns) {
285
346
  const colspan = columns > 2 ? ` colspan="${columns - 1}"` : "";
286
- return `<tr><th scope="row"${colspan}>${escapeHtml(entry.term)}</th><td>${escapeHtml(entry.description)}</td></tr>`;
347
+ return `<tr><th scope="row"${colspan}>${escapeHtml2(entry.term)}</th><td>${escapeHtml2(entry.description)}</td></tr>`;
287
348
  }
288
- function semanticBlockHtml(block) {
349
+ function semanticBlockHtml(block, defaultColor = "#000000") {
289
350
  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>`;
351
+ return `<h${block.level}>${semanticTextHtml(block.text, block.lines, defaultColor)}</h${block.level}>`;
352
+ if (block.type === "paragraph")
353
+ return `<p>${semanticTextHtml(block.text, block.lines, defaultColor)}</p>`;
354
+ if (block.type === "preformatted") return `<pre>${escapeHtml2(block.text)}</pre>`;
292
355
  if (block.type === "definitionList") {
293
356
  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
357
  return block.entries.map(
295
- (entry) => `<section><h2>${escapeHtml(titleCase(entry.term))}</h2><p>${escapeHtml(entry.description)}</p></section>`
358
+ (entry) => `<section><h2>${escapeHtml2(titleCase(entry.term))}</h2><p>${escapeHtml2(entry.description)}</p></section>`
296
359
  ).join("");
297
360
  }
298
361
  const list = `<dl>${block.entries.map(
299
- (entry) => `<div><dt>${escapeHtml(entry.term)}</dt><dd>${escapeHtml(entry.description)}</dd></div>`
362
+ (entry) => `<div><dt>${escapeHtml2(entry.term)}</dt><dd>${escapeHtml2(entry.description)}</dd></div>`
300
363
  ).join("")}</dl>`;
301
364
  return isFinancialSummary(block) ? `<section>${list}</section>` : list;
302
365
  }
@@ -307,42 +370,42 @@ function semanticBlockHtml(block) {
307
370
  return block.items.map(labeledSectionHtml).join("");
308
371
  }
309
372
  if (block.type === "employment") {
310
- return `<section><h3>${escapeHtml(block.role)}</h3><p>${escapeHtml(block.organization)}</p><p>${escapeHtml(block.date)}</p></section>`;
373
+ return `<section><h3>${escapeHtml2(block.role)}</h3><p>${escapeHtml2(block.organization)}</p><p>${escapeHtml2(block.date)}</p></section>`;
311
374
  }
312
375
  const tag = block.ordered ? "ol" : "ul";
313
- return `<${tag}>${block.items.map((item) => `<li>${escapeHtml(item.text)}</li>`).join("")}</${tag}>`;
376
+ return `<${tag}>${block.items.map((item) => `<li>${escapeHtml2(item.text)}</li>`).join("")}</${tag}>`;
314
377
  }
315
378
  function cardTableRow(item) {
316
379
  const trailing = item.details.at(-1) ?? "";
317
380
  const match = /^\s*[×x]\s*(\d+)\s+(.+)$/u.exec(trailing);
318
381
  const description = item.details.slice(0, -1).join(" ");
319
- const detail = description ? `<br><span>${escapeHtml(description)}</span>` : "";
382
+ const detail = description ? `<br><span>${escapeHtml2(description)}</span>` : "";
320
383
  const quantity = match?.[1] ?? "";
321
384
  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>`;
385
+ return `<tr><th scope="row">${escapeHtml2(item.title)}${detail}</th><td>${escapeHtml2(quantity)}</td><td>${escapeHtml2(amount)}</td></tr>`;
323
386
  }
324
387
  function labeledSectionHtml(item) {
325
388
  const heading = titleCase(item.label);
326
389
  const postal = /\b(?:ship|deliver|mail)(?:ed)?\b/i.test(item.label);
327
390
  if (postal) {
328
391
  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>`;
392
+ const content = [name ? `<strong>${escapeHtml2(name)}</strong>` : "", ...address.map(escapeHtml2)].filter(Boolean).join("<br>");
393
+ return `<section><h2>${escapeHtml2(heading)}</h2><address>${content}</address></section>`;
331
394
  }
332
- return `<section><h2>${escapeHtml(heading)}</h2>${item.content.map(
333
- (content, index) => `<p>${index === 0 ? `<strong>${escapeHtml(content)}</strong>` : escapeHtml(content)}</p>`
395
+ return `<section><h2>${escapeHtml2(heading)}</h2>${item.content.map(
396
+ (content, index) => `<p>${index === 0 ? `<strong>${escapeHtml2(content)}</strong>` : escapeHtml2(content)}</p>`
334
397
  ).join("")}</section>`;
335
398
  }
336
399
  function titleCase(value) {
337
400
  const normalized = value.trim().toLocaleLowerCase("en");
338
401
  return normalized.replace(/^\p{L}/u, (letter) => letter.toLocaleUpperCase("en"));
339
402
  }
340
- function escapeHtml(value) {
403
+ function escapeHtml2(value) {
341
404
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
342
405
  }
343
406
 
344
407
  // src/index.ts
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}`;
408
+ 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}`;
346
409
  async function writeHtmlDocument(pages, write, options = {}) {
347
410
  const includeDocument = options.includeDocument ?? true;
348
411
  if (includeDocument) {
@@ -351,7 +414,7 @@ async function writeHtmlDocument(pages, write, options = {}) {
351
414
  ` lang="${escapeAttribute(options.language ?? "en")}"><head><meta charset="utf-8">`
352
415
  );
353
416
  await write('<meta name="viewport" content="width=device-width,initial-scale=1">');
354
- await write(`<title>${escapeHtml2(options.title ?? "PDF document")}</title>`);
417
+ await write(`<title>${escapeHtml3(options.title ?? "PDF document")}</title>`);
355
418
  if (options.includeStyles ?? true) await write(`<style>${styles}</style>`);
356
419
  await write("</head><body>");
357
420
  }
@@ -560,53 +623,58 @@ function positionedSpan(span, fontAliases) {
560
623
  span.fontAssetId ? fontAliases.get(span.fontAssetId) : void 0
561
624
  )
562
625
  ].join(";");
563
- return `<span class="pdf-span"${direction} style="${style}">${escapeHtml2(span.text)}</span>`;
626
+ return `<span class="pdf-span"${direction} style="${style}">${escapeHtml3(span.text)}</span>`;
564
627
  }
565
628
  async function writeFlowPage(page, write) {
566
629
  const structured = (0, import_structure2.structurePage)(page);
630
+ const defaultColor = dominantTextColor(structured.lines);
567
631
  await write(
568
632
  `<section class="pdf-page pdf-page--semantic pdf-page--flow" data-page="${page.number}">`
569
633
  );
570
634
  for (const block of structured.blocks) {
571
635
  if (block.type === "table") await write((0, import_structure2.tableToHtml)(block.table));
572
636
  else if (block.type === "heading") {
573
- await write(`<h${block.level}>${escapeHtml2(block.text)}</h${block.level}>`);
637
+ await write(
638
+ `<h${block.level}>${semanticTextHtml(block.text, block.lines, defaultColor)}</h${block.level}>`
639
+ );
574
640
  } else if (block.type === "paragraph") {
575
641
  await write(
576
- `<p${directionAttribute(block.lines.flatMap((line) => line.spans))}>${escapeHtml2(block.text)}</p>`
642
+ `<p${directionAttribute(block.lines.flatMap((line) => line.spans))}>${semanticTextHtml(block.text, block.lines, defaultColor)}</p>`
577
643
  );
644
+ } else if (block.type === "preformatted") {
645
+ await write(`<pre>${escapeHtml3(block.text)}</pre>`);
578
646
  } else if (block.type === "definitionList") {
579
647
  await write("<dl>");
580
648
  for (const entry of block.entries) {
581
649
  await write(
582
- `<div><dt>${escapeHtml2(entry.term)}</dt><dd>${escapeHtml2(entry.description)}</dd></div>`
650
+ `<div><dt>${escapeHtml3(entry.term)}</dt><dd>${escapeHtml3(entry.description)}</dd></div>`
583
651
  );
584
652
  }
585
653
  await write("</dl>");
586
654
  } else if (block.type === "cardList") {
587
655
  await write('<div class="pdf-semantic-cards">');
588
656
  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>`);
657
+ await write(`<article><h3>${escapeHtml3(item.title)}</h3>`);
658
+ for (const detail of item.details) await write(`<p>${escapeHtml3(detail)}</p>`);
591
659
  await write("</article>");
592
660
  }
593
661
  await write("</div>");
594
662
  } else if (block.type === "sectionGroup") {
595
663
  await write('<div class="pdf-semantic-sections">');
596
664
  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>`);
665
+ await write(`<section><h3>${escapeHtml3(item.label)}</h3>`);
666
+ for (const content of item.content) await write(`<p>${escapeHtml3(content)}</p>`);
599
667
  await write("</section>");
600
668
  }
601
669
  await write("</div>");
602
670
  } else if (block.type === "employment") {
603
671
  await write(
604
- `<section><h3>${escapeHtml2(block.role)}</h3><p>${escapeHtml2(block.organization)}</p><p>${escapeHtml2(block.date)}</p></section>`
672
+ `<section><h3>${escapeHtml3(block.role)}</h3><p>${escapeHtml3(block.organization)}</p><p>${escapeHtml3(block.date)}</p></section>`
605
673
  );
606
674
  } else {
607
675
  const tag = block.ordered ? "ol" : "ul";
608
676
  await write(`<${tag}>`);
609
- for (const item of block.items) await write(`<li>${escapeHtml2(item.text)}</li>`);
677
+ for (const item of block.items) await write(`<li>${escapeHtml3(item.text)}</li>`);
610
678
  await write(`</${tag}>`);
611
679
  }
612
680
  }
@@ -650,7 +718,7 @@ function visualText(span, pageHeight, fontAliases, counterRotateReflectedText =
650
718
  const anchorX = span.bounds.x + basisX * rtlOffset;
651
719
  const anchorY = pageHeight - span.bounds.y + basisY * rtlOffset;
652
720
  const position = transformed ? ` x="0" y="0" transform="matrix(${transform?.map(number).join(" ")} ${number(anchorX)} ${number(anchorY)})"` : ` x="${number(anchorX)}" y="${number(anchorY)}"`;
653
- return `<text${direction}${position} font-size="${number(span.fontSize)}"${textLength}${style ? ` style="${style}"` : ""}>${escapeHtml2(span.text)}</text>`;
721
+ return `<text${direction}${position} font-size="${number(span.fontSize)}"${textLength}${style ? ` style="${style}"` : ""}>${escapeHtml3(span.text)}</text>`;
654
722
  }
655
723
  function isAdobeCjkFont(fontFamily) {
656
724
  return /^Adobe(?:Heiti|Song|Kaiti|Ming|Gothic|Mincho)Std-/i.test(fontFamily ?? "");
@@ -767,9 +835,9 @@ function number(value) {
767
835
  return Number.isFinite(value) ? String(Math.round(value * 1e3) / 1e3) : "0";
768
836
  }
769
837
  function escapeAttribute(value) {
770
- return escapeHtml2(value).replaceAll("`", "&#96;");
838
+ return escapeHtml3(value).replaceAll("`", "&#96;");
771
839
  }
772
- function escapeHtml2(value) {
840
+ function escapeHtml3(value) {
773
841
  return [...value].map((character) => {
774
842
  const codePoint = character.codePointAt(0) ?? 0;
775
843
  if (codePoint === 13) return "\n";