@boxpdf/html-writer 0.1.18 → 0.1.19

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
@@ -28,39 +28,150 @@ module.exports = __toCommonJS(index_exports);
28
28
  var import_structure2 = require("@boxpdf/reader/structure");
29
29
 
30
30
  // src/semantic-caption.ts
31
- function isClearMediaCaption(media, block, pageWidth, pageHeight, pageLines) {
32
- if (block.type !== "paragraph" || block.lines.length === 0) return false;
33
- const bounds2 = unionLines(block.lines);
34
- const lineHeight = median(block.lines.map((line) => line.bounds.height));
35
- if (media.bounds.width < pageWidth * 0.2 || media.bounds.height < lineHeight * 10) return false;
36
- if (media.bounds.x < -2 || media.bounds.y < -2 || media.bounds.x + media.bounds.width > pageWidth + 2 || media.bounds.y + media.bounds.height > pageHeight + 2)
37
- return false;
38
- const gap = media.bounds.y - (bounds2.y + bounds2.height);
39
- if (gap < -lineHeight * 0.15 || gap > lineHeight * 1.25) return false;
40
- const mediaCenter = media.bounds.x + media.bounds.width / 2;
41
- const captionCenter = bounds2.x + bounds2.width / 2;
42
- if (Math.abs(mediaCenter - captionCenter) > Math.max(3, media.bounds.width * 0.03)) return false;
43
- if (bounds2.width < media.bounds.width * 0.45 || bounds2.width > media.bounds.width * 1.06) {
44
- return false;
45
- }
46
- const first = block.lines.flatMap((line) => line.spans).find((span) => /\S/u.test(span.text));
47
- if (!first) return false;
48
- const otherLines = pageLines.filter((line) => !block.lines.includes(line));
49
- return fontSignature(first) !== dominantFontSignature(otherLines);
50
- }
31
+ var minimumCaptionScore = 0.72;
51
32
  function clearMediaCaptionAssociations(media, blocks, pageWidth, pageHeight, pageLines) {
33
+ const candidates = blocks.flatMap((block) => {
34
+ const candidate = captionCandidate(block);
35
+ return candidate ? [candidate] : [];
36
+ });
37
+ const preliminary = media.flatMap(
38
+ (item) => candidates.flatMap((candidate) => {
39
+ const evidence = scoreCaption(item, candidate, pageWidth, pageHeight, pageLines, 0);
40
+ return evidence ? [{ media: item, candidate, evidence }] : [];
41
+ })
42
+ );
43
+ const patterns = repeatedPatterns(preliminary);
44
+ const edges = preliminary.map((edge) => {
45
+ const evidence = scoreCaption(
46
+ edge.media,
47
+ edge.candidate,
48
+ pageWidth,
49
+ pageHeight,
50
+ pageLines,
51
+ patterns.get(patternKey(edge)) ?? 0
52
+ );
53
+ return evidence ? { ...edge, evidence } : void 0;
54
+ }).filter((edge) => Boolean(edge)).filter((edge) => edge.evidence.score >= minimumCaptionScore);
55
+ const bestForMedia = bestEdges(edges, (edge) => edge.media);
56
+ const bestForCaption = bestEdges(edges, (edge) => edge.candidate.block);
52
57
  const associations = /* @__PURE__ */ new Map();
53
- for (const item of media) {
54
- const candidates = blocks.filter((block) => isClearMediaCaption(item, block, pageWidth, pageHeight, pageLines)).filter((block) => !associations.has(block)).sort((left, right) => captionGap(item, left) - captionGap(item, right));
55
- const caption = candidates[0];
56
- if (caption) associations.set(caption, item);
58
+ for (const edge of edges) {
59
+ if (bestForMedia.get(edge.media) === edge && bestForCaption.get(edge.candidate.block) === edge) {
60
+ associations.set(edge.candidate.block, edge.media);
61
+ }
57
62
  }
58
63
  return associations;
59
64
  }
60
- function captionGap(media, block) {
61
- if (block.type !== "paragraph") return Number.POSITIVE_INFINITY;
62
- const bounds2 = unionLines(block.lines);
63
- return Math.abs(media.bounds.y - bounds2.y - bounds2.height);
65
+ function scoreCaption(media, candidate, pageWidth, pageHeight, pageLines, repeatedAlignment) {
66
+ if (!insidePage(media.bounds, pageWidth, pageHeight)) return void 0;
67
+ if (media.bounds.width < pageWidth * 0.06 || media.bounds.height < candidate.lineHeight * 1.5)
68
+ return void 0;
69
+ const relation = verticalRelation(media.bounds, candidate.bounds);
70
+ if (!relation) return void 0;
71
+ const maximumGap = Math.max(candidate.lineHeight * 3, pageHeight * 0.035);
72
+ if (relation.gap > maximumGap) return void 0;
73
+ const overlapWidth = overlap(
74
+ media.bounds.x,
75
+ media.bounds.width,
76
+ candidate.bounds.x,
77
+ candidate.bounds.width
78
+ );
79
+ const horizontalOverlap = overlapWidth / Math.max(1, Math.min(media.bounds.width, candidate.bounds.width));
80
+ const centerDistance = Math.abs(center(media.bounds) - center(candidate.bounds));
81
+ const centerAlignment = clamp01(
82
+ 1 - centerDistance / Math.max(media.bounds.width, candidate.bounds.width)
83
+ );
84
+ if (horizontalOverlap < 0.45 && centerAlignment < 0.82) return void 0;
85
+ const relativeWidth = Math.min(media.bounds.width, candidate.bounds.width) / Math.max(media.bounds.width, candidate.bounds.width);
86
+ const gapRatio = clamp01(1 - relation.gap / maximumGap);
87
+ const interveningContent = interveningScore(media.bounds, candidate, relation.side, pageLines);
88
+ if (interveningContent === 0) return void 0;
89
+ if (relation.side === "above" && (gapRatio < 0.6 || interveningContent < 1)) return void 0;
90
+ const fontContrast = captionFontContrast(candidate, pageLines);
91
+ const surroundingWhitespace = whitespaceScore(candidate, relation.side, relation.gap, pageLines);
92
+ const score = gapRatio * 0.22 + horizontalOverlap * 0.17 + centerAlignment * 0.13 + relativeWidth * 0.09 + fontContrast * 0.13 + surroundingWhitespace * 0.09 + interveningContent * 0.09 + repeatedAlignment * 0.08;
93
+ return {
94
+ score,
95
+ side: relation.side,
96
+ gapRatio,
97
+ horizontalOverlap,
98
+ centerAlignment,
99
+ relativeWidth,
100
+ fontContrast,
101
+ surroundingWhitespace,
102
+ interveningContent,
103
+ repeatedAlignment
104
+ };
105
+ }
106
+ function captionCandidate(block) {
107
+ if (block.type !== "paragraph" || block.lines.length === 0) return void 0;
108
+ const first = block.lines.flatMap((line) => line.spans).find((span) => /\S/u.test(span.text));
109
+ if (!first) return void 0;
110
+ return {
111
+ block,
112
+ bounds: unionLines(block.lines),
113
+ lineHeight: median(block.lines.map((line) => line.bounds.height)),
114
+ font: fontSignature(first)
115
+ };
116
+ }
117
+ function verticalRelation(media, caption) {
118
+ const belowGap = media.y - (caption.y + caption.height);
119
+ if (belowGap >= -caption.height * 0.15) return { side: "below", gap: Math.max(0, belowGap) };
120
+ const aboveGap = caption.y - (media.y + media.height);
121
+ if (aboveGap >= -caption.height * 0.15) return { side: "above", gap: Math.max(0, aboveGap) };
122
+ return void 0;
123
+ }
124
+ function interveningScore(media, candidate, side, pageLines) {
125
+ const lower = side === "below" ? candidate.bounds.y + candidate.bounds.height : media.y + media.height;
126
+ const upper = side === "below" ? media.y : candidate.bounds.y;
127
+ const blockers = pageLines.filter(
128
+ (line) => !candidate.block.lines.includes(line) && line.bounds.y < upper && line.bounds.y + line.bounds.height > lower && overlap(line.bounds.x, line.bounds.width, media.x, media.width) / Math.max(1, Math.min(line.bounds.width, media.width)) >= 0.25
129
+ );
130
+ return blockers.length === 0 ? 1 : blockers.length === 1 ? 0.35 : 0;
131
+ }
132
+ function captionFontContrast(candidate, pageLines) {
133
+ const otherLines = pageLines.filter((line) => !candidate.block.lines.includes(line));
134
+ if (candidate.font !== dominantFontSignature(otherLines)) return 1;
135
+ const candidateSize = median(
136
+ candidate.block.lines.flatMap((line) => line.spans.map((span) => span.fontSize))
137
+ );
138
+ const bodySize = median(otherLines.flatMap((line) => line.spans.map((span) => span.fontSize)));
139
+ return Math.abs(candidateSize - bodySize) >= 0.75 ? 0.65 : 0.15;
140
+ }
141
+ function whitespaceScore(candidate, side, mediaGap, pageLines) {
142
+ const awayGaps = pageLines.filter((line) => !candidate.block.lines.includes(line)).filter(
143
+ (line) => overlap(line.bounds.x, line.bounds.width, candidate.bounds.x, candidate.bounds.width) > 0
144
+ ).flatMap((line) => {
145
+ if (side === "below" && line.bounds.y + line.bounds.height <= candidate.bounds.y)
146
+ return [candidate.bounds.y - line.bounds.y - line.bounds.height];
147
+ if (side === "above" && line.bounds.y >= candidate.bounds.y + candidate.bounds.height)
148
+ return [line.bounds.y - candidate.bounds.y - candidate.bounds.height];
149
+ return [];
150
+ });
151
+ const awayGap = Math.min(...awayGaps, Number.POSITIVE_INFINITY);
152
+ return Number.isFinite(awayGap) ? clamp01((awayGap + candidate.lineHeight * 0.25) / (mediaGap + candidate.lineHeight)) : 1;
153
+ }
154
+ function repeatedPatterns(edges) {
155
+ const counts = /* @__PURE__ */ new Map();
156
+ for (const edge of edges.filter((item) => item.evidence.score >= minimumCaptionScore - 0.08)) {
157
+ counts.set(patternKey(edge), (counts.get(patternKey(edge)) ?? 0) + 1);
158
+ }
159
+ return new Map([...counts].map(([key, count]) => [key, count >= 2 ? 1 : 0]));
160
+ }
161
+ function patternKey(edge) {
162
+ const widthRatio = edge.candidate.bounds.width / Math.max(1, edge.media.bounds.width);
163
+ return `${edge.candidate.font}|${edge.evidence.side}|${Math.round(widthRatio * 4) / 4}`;
164
+ }
165
+ function bestEdges(edges, key) {
166
+ const output = /* @__PURE__ */ new Map();
167
+ for (const edge of edges) {
168
+ const existing = output.get(key(edge));
169
+ if (!existing || edge.evidence.score > existing.evidence.score) output.set(key(edge), edge);
170
+ }
171
+ return output;
172
+ }
173
+ function insidePage(bounds2, pageWidth, pageHeight) {
174
+ return bounds2.x >= -2 && bounds2.y >= -2 && bounds2.x + bounds2.width <= pageWidth + 2 && bounds2.y + bounds2.height <= pageHeight + 2;
64
175
  }
65
176
  function unionLines(lines) {
66
177
  const x = Math.min(...lines.map((line) => line.bounds.x));
@@ -80,6 +191,15 @@ function dominantFontSignature(lines) {
80
191
  function fontSignature(span) {
81
192
  return `${(span.fontFamily ?? span.fontName ?? "").toLocaleLowerCase("en")}|${Math.round(span.fontSize * 2) / 2}|${span.color ?? ""}`;
82
193
  }
194
+ function center(bounds2) {
195
+ return bounds2.x + bounds2.width / 2;
196
+ }
197
+ function overlap(left, leftSize, right, rightSize) {
198
+ return Math.max(0, Math.min(left + leftSize, right + rightSize) - Math.max(left, right));
199
+ }
200
+ function clamp01(value) {
201
+ return Math.max(0, Math.min(1, value));
202
+ }
83
203
  function median(values) {
84
204
  const ordered = [...values].sort((left, right) => left - right);
85
205
  return ordered[Math.floor(ordered.length / 2)] ?? 1;
@@ -286,7 +406,7 @@ function base64(bytes) {
286
406
  function semanticMedia(page) {
287
407
  const output = (page.images ?? []).map((image) => rasterMedia(image));
288
408
  output.push(...vectorMedia(page));
289
- return output.sort((left, right) => right.bounds.y - left.bounds.y);
409
+ return mediaComponents(output, page).sort((left, right) => right.bounds.y - left.bounds.y);
290
410
  }
291
411
  function rasterMedia(image) {
292
412
  const bounds2 = transformedUnitBounds(image.transform);
@@ -295,6 +415,7 @@ function rasterMedia(image) {
295
415
  const opacity = unitInterval(image.opacity) ? `;opacity:${number2(image.opacity)}` : "";
296
416
  return {
297
417
  bounds: bounds2,
418
+ kind: "raster",
298
419
  html: `<img class="pdf-semantic-media" src="data:${mime};base64,${base64(data)}" width="${number2(bounds2.width)}" height="${number2(bounds2.height)}" alt="" style="max-width:100%;height:auto${opacity}">`
299
420
  };
300
421
  }
@@ -335,11 +456,70 @@ function vectorMedia(page) {
335
456
  const fontFaces = (page.fonts ?? []).filter((font) => fontIds.has(font.id)).map((font) => visualFontFace(font, aliases)).join("");
336
457
  return {
337
458
  bounds: bounds2,
459
+ kind: "vector",
338
460
  html: `<svg class="pdf-semantic-media" xmlns="http://www.w3.org/2000/svg" viewBox="${number2(bounds2.x)} ${number2(page.height - bounds2.y - bounds2.height)} ${number2(bounds2.width)} ${number2(bounds2.height)}" style="display:block;max-width:100%;height:auto" aria-hidden="true">${fontFaces ? `<style>${fontFaces}</style>` : ""}${paths.length ? `<defs>${vectorPathClipDefinitions(paths, page.number)}</defs>` : ""}<g transform="translate(0 ${number2(page.height)}) scale(1 -1)">${fills.map(vectorFillSvg).join("") + paths.map(({ path, index }) => vectorPathSvg(path, page.number, index)).join("")}</g>${overlay.map((span) => vectorText(span, page.height, aliases)).join("")}</svg>`,
339
461
  ...consumedSpans.length > 0 ? { consumedSpans } : {}
340
462
  };
341
463
  });
342
464
  }
465
+ function mediaComponents(media, page) {
466
+ const components = [];
467
+ for (const item of media) {
468
+ if (isPageBackdrop(item.bounds, page)) {
469
+ components.push([item]);
470
+ continue;
471
+ }
472
+ const matches = components.filter(
473
+ (component) => !component.some((member) => isPageBackdrop(member.bounds, page)) && component.some((member) => mediaPiecesTouch(member.bounds, item.bounds))
474
+ );
475
+ if (matches.length === 0) {
476
+ components.push([item]);
477
+ continue;
478
+ }
479
+ const target = matches[0];
480
+ target.push(item);
481
+ for (const component of matches.slice(1)) {
482
+ target.push(...component);
483
+ components.splice(components.indexOf(component), 1);
484
+ }
485
+ }
486
+ return components.map((component) => compositeMedia(component));
487
+ }
488
+ function compositeMedia(items) {
489
+ if (items.length === 1) return items[0];
490
+ const bounds2 = unionBounds(items.map((item) => item.bounds));
491
+ const layers = items.map((item) => {
492
+ const left = (item.bounds.x - bounds2.x) / bounds2.width * 100;
493
+ const top = (bounds2.y + bounds2.height - item.bounds.y - item.bounds.height) / bounds2.height * 100;
494
+ const width = item.bounds.width / bounds2.width * 100;
495
+ const height = item.bounds.height / bounds2.height * 100;
496
+ return `<div style="position:absolute;left:${number2(left)}%;top:${number2(top)}%;width:${number2(width)}%;height:${number2(height)}%;overflow:hidden">${item.html}</div>`;
497
+ }).join("");
498
+ return {
499
+ bounds: bounds2,
500
+ kind: "composite",
501
+ html: `<div class="pdf-semantic-media pdf-semantic-media-composite" style="position:relative;max-width:100%;width:${number2(bounds2.width)}px;aspect-ratio:${number2(bounds2.width)}/${number2(bounds2.height)}">${layers}</div>`,
502
+ consumedSpans: items.flatMap((item) => item.consumedSpans ?? [])
503
+ };
504
+ }
505
+ function mediaPiecesTouch(left, right) {
506
+ const xOverlap = overlap2(left.x, left.width, right.x, right.width);
507
+ const yOverlap = overlap2(left.y, left.height, right.y, right.height);
508
+ if (xOverlap > 0 && yOverlap > 0) return true;
509
+ const horizontalGap = axisGap(left.x, left.width, right.x, right.width);
510
+ const verticalGap = axisGap(left.y, left.height, right.y, right.height);
511
+ if (horizontalGap <= 2 && yOverlap / Math.min(left.height, right.height) >= 0.65) return true;
512
+ return verticalGap <= 2 && xOverlap / Math.min(left.width, right.width) >= 0.65;
513
+ }
514
+ function isPageBackdrop(bounds2, page) {
515
+ return bounds2.width * bounds2.height >= page.width * page.height * 0.7;
516
+ }
517
+ function overlap2(left, leftSize, right, rightSize) {
518
+ return Math.max(0, Math.min(left + leftSize, right + rightSize) - Math.max(left, right));
519
+ }
520
+ function axisGap(left, leftSize, right, rightSize) {
521
+ return Math.max(0, right - left - leftSize, left - right - rightSize);
522
+ }
343
523
  function vectorComponents(primitives, padding) {
344
524
  const components = [];
345
525
  for (const primitive of primitives) {
@@ -510,12 +690,16 @@ async function writeSemanticDocument(pages, write, lookaheadPages) {
510
690
  page.structured.lines
511
691
  );
512
692
  const captionedMedia = new Set(captions.values());
693
+ const emittedMedia = /* @__PURE__ */ new Set();
513
694
  const futureFurniture = new Set(future.flatMap((candidate) => marginSignatures(candidate)));
514
695
  const repeatedFurniture = /* @__PURE__ */ new Set([...seenFurniture, ...futureFurniture]);
515
696
  for (const [blockIndex, block] of page.structured.blocks.entries()) {
516
697
  const nextBlock = page.structured.blocks[blockIndex + 1];
517
698
  const blockY = semanticBlockY(block);
518
699
  let emittedAsCaption = false;
700
+ while (page.media[mediaIndex] && emittedMedia.has(page.media[mediaIndex])) {
701
+ mediaIndex += 1;
702
+ }
519
703
  while ((page.media[mediaIndex]?.bounds.y ?? -Infinity) >= blockY) {
520
704
  await flushPendingParagraph();
521
705
  const item = page.media[mediaIndex];
@@ -523,6 +707,7 @@ async function writeSemanticDocument(pages, write, lookaheadPages) {
523
707
  const html2 = `<figure class="pdf-semantic-figure">${item.html}<figcaption>${semanticTextHtml(block.text, block.lines, defaultColor)}</figcaption></figure>`;
524
708
  if (activeTable) pendingMedia.push(html2);
525
709
  else await write(html2);
710
+ emittedMedia.add(item);
526
711
  mediaIndex += 1;
527
712
  emittedAsCaption = true;
528
713
  break;
@@ -533,6 +718,15 @@ async function writeSemanticDocument(pages, write, lookaheadPages) {
533
718
  else await write(html);
534
719
  mediaIndex += 1;
535
720
  }
721
+ const associatedMedia = captions.get(block);
722
+ if (!emittedAsCaption && associatedMedia && block.type === "paragraph") {
723
+ await flushPendingParagraph();
724
+ const html = `<figure class="pdf-semantic-figure">${associatedMedia.html}<figcaption>${semanticTextHtml(block.text, block.lines, defaultColor)}</figcaption></figure>`;
725
+ if (activeTable) pendingMedia.push(html);
726
+ else await write(html);
727
+ emittedMedia.add(associatedMedia);
728
+ emittedAsCaption = true;
729
+ }
536
730
  if (emittedAsCaption) continue;
537
731
  if (isRepeatedFurniture(block, page, repeatedFurniture)) {
538
732
  stats.suppressedFurniture += 1;
@@ -627,10 +821,13 @@ async function writeSemanticDocument(pages, write, lookaheadPages) {
627
821
  await write(semanticBlockHtml(block, defaultColor));
628
822
  }
629
823
  while (mediaIndex < page.media.length) {
630
- await flushPendingParagraph();
631
- const html = `<div class="pdf-semantic-visual">${page.media[mediaIndex]?.html}</div>`;
632
- if (activeTable) pendingMedia.push(html);
633
- else await write(html);
824
+ const item = page.media[mediaIndex];
825
+ if (item && !emittedMedia.has(item)) {
826
+ await flushPendingParagraph();
827
+ const html = `<div class="pdf-semantic-visual">${item.html}</div>`;
828
+ if (activeTable) pendingMedia.push(html);
829
+ else await write(html);
830
+ }
634
831
  mediaIndex += 1;
635
832
  }
636
833
  for (const signature of marginSignatures(page)) seenFurniture.add(signature);
@@ -1037,18 +1234,22 @@ async function writeFlowPage(page, write) {
1037
1234
  structured.lines
1038
1235
  );
1039
1236
  const captionedMedia = new Set(captions.values());
1237
+ const emittedMedia = /* @__PURE__ */ new Set();
1040
1238
  await write(
1041
1239
  `<section class="pdf-page pdf-page--semantic pdf-page--flow" data-page="${page.number}">`
1042
1240
  );
1043
1241
  for (const block of structured.blocks) {
1044
1242
  const blockY = semanticBlockY2(block);
1045
1243
  let emittedAsCaption = false;
1244
+ while (media[mediaIndex] && emittedMedia.has(media[mediaIndex]))
1245
+ mediaIndex += 1;
1046
1246
  while ((media[mediaIndex]?.bounds.y ?? -Infinity) >= blockY) {
1047
1247
  const item = media[mediaIndex];
1048
1248
  if (item && captions.get(block) === item && block.type === "paragraph") {
1049
1249
  await write(
1050
1250
  `<figure class="pdf-semantic-figure">${item.html}<figcaption>${semanticTextHtml(block.text, block.lines, defaultColor)}</figcaption></figure>`
1051
1251
  );
1252
+ emittedMedia.add(item);
1052
1253
  mediaIndex += 1;
1053
1254
  emittedAsCaption = true;
1054
1255
  break;
@@ -1057,6 +1258,14 @@ async function writeFlowPage(page, write) {
1057
1258
  await write(`<div class="pdf-semantic-visual">${item?.html}</div>`);
1058
1259
  mediaIndex += 1;
1059
1260
  }
1261
+ const associatedMedia = captions.get(block);
1262
+ if (!emittedAsCaption && associatedMedia && block.type === "paragraph") {
1263
+ await write(
1264
+ `<figure class="pdf-semantic-figure">${associatedMedia.html}<figcaption>${semanticTextHtml(block.text, block.lines, defaultColor)}</figcaption></figure>`
1265
+ );
1266
+ emittedMedia.add(associatedMedia);
1267
+ emittedAsCaption = true;
1268
+ }
1060
1269
  if (emittedAsCaption) continue;
1061
1270
  if (block.type === "table") await write((0, import_structure2.tableToHtml)(block.table));
1062
1271
  else if (block.type === "heading") {
@@ -1111,7 +1320,10 @@ async function writeFlowPage(page, write) {
1111
1320
  }
1112
1321
  }
1113
1322
  while (mediaIndex < media.length) {
1114
- await write(`<div class="pdf-semantic-visual">${media[mediaIndex]?.html}</div>`);
1323
+ const item = media[mediaIndex];
1324
+ if (item && !emittedMedia.has(item)) {
1325
+ await write(`<div class="pdf-semantic-visual">${item.html}</div>`);
1326
+ }
1115
1327
  mediaIndex += 1;
1116
1328
  }
1117
1329
  await write("</section>");