@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.js CHANGED
@@ -2,39 +2,150 @@
2
2
  import { structurePage as structurePage2, tableToHtml } from "@boxpdf/reader/structure";
3
3
 
4
4
  // src/semantic-caption.ts
5
- function isClearMediaCaption(media, block, pageWidth, pageHeight, pageLines) {
6
- if (block.type !== "paragraph" || block.lines.length === 0) return false;
7
- const bounds2 = unionLines(block.lines);
8
- const lineHeight = median(block.lines.map((line) => line.bounds.height));
9
- if (media.bounds.width < pageWidth * 0.2 || media.bounds.height < lineHeight * 10) return false;
10
- 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)
11
- return false;
12
- const gap = media.bounds.y - (bounds2.y + bounds2.height);
13
- if (gap < -lineHeight * 0.15 || gap > lineHeight * 1.25) return false;
14
- const mediaCenter = media.bounds.x + media.bounds.width / 2;
15
- const captionCenter = bounds2.x + bounds2.width / 2;
16
- if (Math.abs(mediaCenter - captionCenter) > Math.max(3, media.bounds.width * 0.03)) return false;
17
- if (bounds2.width < media.bounds.width * 0.45 || bounds2.width > media.bounds.width * 1.06) {
18
- return false;
19
- }
20
- const first = block.lines.flatMap((line) => line.spans).find((span) => /\S/u.test(span.text));
21
- if (!first) return false;
22
- const otherLines = pageLines.filter((line) => !block.lines.includes(line));
23
- return fontSignature(first) !== dominantFontSignature(otherLines);
24
- }
5
+ var minimumCaptionScore = 0.72;
25
6
  function clearMediaCaptionAssociations(media, blocks, pageWidth, pageHeight, pageLines) {
7
+ const candidates = blocks.flatMap((block) => {
8
+ const candidate = captionCandidate(block);
9
+ return candidate ? [candidate] : [];
10
+ });
11
+ const preliminary = media.flatMap(
12
+ (item) => candidates.flatMap((candidate) => {
13
+ const evidence = scoreCaption(item, candidate, pageWidth, pageHeight, pageLines, 0);
14
+ return evidence ? [{ media: item, candidate, evidence }] : [];
15
+ })
16
+ );
17
+ const patterns = repeatedPatterns(preliminary);
18
+ const edges = preliminary.map((edge) => {
19
+ const evidence = scoreCaption(
20
+ edge.media,
21
+ edge.candidate,
22
+ pageWidth,
23
+ pageHeight,
24
+ pageLines,
25
+ patterns.get(patternKey(edge)) ?? 0
26
+ );
27
+ return evidence ? { ...edge, evidence } : void 0;
28
+ }).filter((edge) => Boolean(edge)).filter((edge) => edge.evidence.score >= minimumCaptionScore);
29
+ const bestForMedia = bestEdges(edges, (edge) => edge.media);
30
+ const bestForCaption = bestEdges(edges, (edge) => edge.candidate.block);
26
31
  const associations = /* @__PURE__ */ new Map();
27
- for (const item of media) {
28
- 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));
29
- const caption = candidates[0];
30
- if (caption) associations.set(caption, item);
32
+ for (const edge of edges) {
33
+ if (bestForMedia.get(edge.media) === edge && bestForCaption.get(edge.candidate.block) === edge) {
34
+ associations.set(edge.candidate.block, edge.media);
35
+ }
31
36
  }
32
37
  return associations;
33
38
  }
34
- function captionGap(media, block) {
35
- if (block.type !== "paragraph") return Number.POSITIVE_INFINITY;
36
- const bounds2 = unionLines(block.lines);
37
- return Math.abs(media.bounds.y - bounds2.y - bounds2.height);
39
+ function scoreCaption(media, candidate, pageWidth, pageHeight, pageLines, repeatedAlignment) {
40
+ if (!insidePage(media.bounds, pageWidth, pageHeight)) return void 0;
41
+ if (media.bounds.width < pageWidth * 0.06 || media.bounds.height < candidate.lineHeight * 1.5)
42
+ return void 0;
43
+ const relation = verticalRelation(media.bounds, candidate.bounds);
44
+ if (!relation) return void 0;
45
+ const maximumGap = Math.max(candidate.lineHeight * 3, pageHeight * 0.035);
46
+ if (relation.gap > maximumGap) return void 0;
47
+ const overlapWidth = overlap(
48
+ media.bounds.x,
49
+ media.bounds.width,
50
+ candidate.bounds.x,
51
+ candidate.bounds.width
52
+ );
53
+ const horizontalOverlap = overlapWidth / Math.max(1, Math.min(media.bounds.width, candidate.bounds.width));
54
+ const centerDistance = Math.abs(center(media.bounds) - center(candidate.bounds));
55
+ const centerAlignment = clamp01(
56
+ 1 - centerDistance / Math.max(media.bounds.width, candidate.bounds.width)
57
+ );
58
+ if (horizontalOverlap < 0.45 && centerAlignment < 0.82) return void 0;
59
+ const relativeWidth = Math.min(media.bounds.width, candidate.bounds.width) / Math.max(media.bounds.width, candidate.bounds.width);
60
+ const gapRatio = clamp01(1 - relation.gap / maximumGap);
61
+ const interveningContent = interveningScore(media.bounds, candidate, relation.side, pageLines);
62
+ if (interveningContent === 0) return void 0;
63
+ if (relation.side === "above" && (gapRatio < 0.6 || interveningContent < 1)) return void 0;
64
+ const fontContrast = captionFontContrast(candidate, pageLines);
65
+ const surroundingWhitespace = whitespaceScore(candidate, relation.side, relation.gap, pageLines);
66
+ 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;
67
+ return {
68
+ score,
69
+ side: relation.side,
70
+ gapRatio,
71
+ horizontalOverlap,
72
+ centerAlignment,
73
+ relativeWidth,
74
+ fontContrast,
75
+ surroundingWhitespace,
76
+ interveningContent,
77
+ repeatedAlignment
78
+ };
79
+ }
80
+ function captionCandidate(block) {
81
+ if (block.type !== "paragraph" || block.lines.length === 0) return void 0;
82
+ const first = block.lines.flatMap((line) => line.spans).find((span) => /\S/u.test(span.text));
83
+ if (!first) return void 0;
84
+ return {
85
+ block,
86
+ bounds: unionLines(block.lines),
87
+ lineHeight: median(block.lines.map((line) => line.bounds.height)),
88
+ font: fontSignature(first)
89
+ };
90
+ }
91
+ function verticalRelation(media, caption) {
92
+ const belowGap = media.y - (caption.y + caption.height);
93
+ if (belowGap >= -caption.height * 0.15) return { side: "below", gap: Math.max(0, belowGap) };
94
+ const aboveGap = caption.y - (media.y + media.height);
95
+ if (aboveGap >= -caption.height * 0.15) return { side: "above", gap: Math.max(0, aboveGap) };
96
+ return void 0;
97
+ }
98
+ function interveningScore(media, candidate, side, pageLines) {
99
+ const lower = side === "below" ? candidate.bounds.y + candidate.bounds.height : media.y + media.height;
100
+ const upper = side === "below" ? media.y : candidate.bounds.y;
101
+ const blockers = pageLines.filter(
102
+ (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
103
+ );
104
+ return blockers.length === 0 ? 1 : blockers.length === 1 ? 0.35 : 0;
105
+ }
106
+ function captionFontContrast(candidate, pageLines) {
107
+ const otherLines = pageLines.filter((line) => !candidate.block.lines.includes(line));
108
+ if (candidate.font !== dominantFontSignature(otherLines)) return 1;
109
+ const candidateSize = median(
110
+ candidate.block.lines.flatMap((line) => line.spans.map((span) => span.fontSize))
111
+ );
112
+ const bodySize = median(otherLines.flatMap((line) => line.spans.map((span) => span.fontSize)));
113
+ return Math.abs(candidateSize - bodySize) >= 0.75 ? 0.65 : 0.15;
114
+ }
115
+ function whitespaceScore(candidate, side, mediaGap, pageLines) {
116
+ const awayGaps = pageLines.filter((line) => !candidate.block.lines.includes(line)).filter(
117
+ (line) => overlap(line.bounds.x, line.bounds.width, candidate.bounds.x, candidate.bounds.width) > 0
118
+ ).flatMap((line) => {
119
+ if (side === "below" && line.bounds.y + line.bounds.height <= candidate.bounds.y)
120
+ return [candidate.bounds.y - line.bounds.y - line.bounds.height];
121
+ if (side === "above" && line.bounds.y >= candidate.bounds.y + candidate.bounds.height)
122
+ return [line.bounds.y - candidate.bounds.y - candidate.bounds.height];
123
+ return [];
124
+ });
125
+ const awayGap = Math.min(...awayGaps, Number.POSITIVE_INFINITY);
126
+ return Number.isFinite(awayGap) ? clamp01((awayGap + candidate.lineHeight * 0.25) / (mediaGap + candidate.lineHeight)) : 1;
127
+ }
128
+ function repeatedPatterns(edges) {
129
+ const counts = /* @__PURE__ */ new Map();
130
+ for (const edge of edges.filter((item) => item.evidence.score >= minimumCaptionScore - 0.08)) {
131
+ counts.set(patternKey(edge), (counts.get(patternKey(edge)) ?? 0) + 1);
132
+ }
133
+ return new Map([...counts].map(([key, count]) => [key, count >= 2 ? 1 : 0]));
134
+ }
135
+ function patternKey(edge) {
136
+ const widthRatio = edge.candidate.bounds.width / Math.max(1, edge.media.bounds.width);
137
+ return `${edge.candidate.font}|${edge.evidence.side}|${Math.round(widthRatio * 4) / 4}`;
138
+ }
139
+ function bestEdges(edges, key) {
140
+ const output = /* @__PURE__ */ new Map();
141
+ for (const edge of edges) {
142
+ const existing = output.get(key(edge));
143
+ if (!existing || edge.evidence.score > existing.evidence.score) output.set(key(edge), edge);
144
+ }
145
+ return output;
146
+ }
147
+ function insidePage(bounds2, pageWidth, pageHeight) {
148
+ return bounds2.x >= -2 && bounds2.y >= -2 && bounds2.x + bounds2.width <= pageWidth + 2 && bounds2.y + bounds2.height <= pageHeight + 2;
38
149
  }
39
150
  function unionLines(lines) {
40
151
  const x = Math.min(...lines.map((line) => line.bounds.x));
@@ -54,6 +165,15 @@ function dominantFontSignature(lines) {
54
165
  function fontSignature(span) {
55
166
  return `${(span.fontFamily ?? span.fontName ?? "").toLocaleLowerCase("en")}|${Math.round(span.fontSize * 2) / 2}|${span.color ?? ""}`;
56
167
  }
168
+ function center(bounds2) {
169
+ return bounds2.x + bounds2.width / 2;
170
+ }
171
+ function overlap(left, leftSize, right, rightSize) {
172
+ return Math.max(0, Math.min(left + leftSize, right + rightSize) - Math.max(left, right));
173
+ }
174
+ function clamp01(value) {
175
+ return Math.max(0, Math.min(1, value));
176
+ }
57
177
  function median(values) {
58
178
  const ordered = [...values].sort((left, right) => left - right);
59
179
  return ordered[Math.floor(ordered.length / 2)] ?? 1;
@@ -263,7 +383,7 @@ function base64(bytes) {
263
383
  function semanticMedia(page) {
264
384
  const output = (page.images ?? []).map((image) => rasterMedia(image));
265
385
  output.push(...vectorMedia(page));
266
- return output.sort((left, right) => right.bounds.y - left.bounds.y);
386
+ return mediaComponents(output, page).sort((left, right) => right.bounds.y - left.bounds.y);
267
387
  }
268
388
  function rasterMedia(image) {
269
389
  const bounds2 = transformedUnitBounds(image.transform);
@@ -272,6 +392,7 @@ function rasterMedia(image) {
272
392
  const opacity = unitInterval(image.opacity) ? `;opacity:${number2(image.opacity)}` : "";
273
393
  return {
274
394
  bounds: bounds2,
395
+ kind: "raster",
275
396
  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}">`
276
397
  };
277
398
  }
@@ -312,11 +433,70 @@ function vectorMedia(page) {
312
433
  const fontFaces = (page.fonts ?? []).filter((font) => fontIds.has(font.id)).map((font) => visualFontFace(font, aliases)).join("");
313
434
  return {
314
435
  bounds: bounds2,
436
+ kind: "vector",
315
437
  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>`,
316
438
  ...consumedSpans.length > 0 ? { consumedSpans } : {}
317
439
  };
318
440
  });
319
441
  }
442
+ function mediaComponents(media, page) {
443
+ const components = [];
444
+ for (const item of media) {
445
+ if (isPageBackdrop(item.bounds, page)) {
446
+ components.push([item]);
447
+ continue;
448
+ }
449
+ const matches = components.filter(
450
+ (component) => !component.some((member) => isPageBackdrop(member.bounds, page)) && component.some((member) => mediaPiecesTouch(member.bounds, item.bounds))
451
+ );
452
+ if (matches.length === 0) {
453
+ components.push([item]);
454
+ continue;
455
+ }
456
+ const target = matches[0];
457
+ target.push(item);
458
+ for (const component of matches.slice(1)) {
459
+ target.push(...component);
460
+ components.splice(components.indexOf(component), 1);
461
+ }
462
+ }
463
+ return components.map((component) => compositeMedia(component));
464
+ }
465
+ function compositeMedia(items) {
466
+ if (items.length === 1) return items[0];
467
+ const bounds2 = unionBounds(items.map((item) => item.bounds));
468
+ const layers = items.map((item) => {
469
+ const left = (item.bounds.x - bounds2.x) / bounds2.width * 100;
470
+ const top = (bounds2.y + bounds2.height - item.bounds.y - item.bounds.height) / bounds2.height * 100;
471
+ const width = item.bounds.width / bounds2.width * 100;
472
+ const height = item.bounds.height / bounds2.height * 100;
473
+ return `<div style="position:absolute;left:${number2(left)}%;top:${number2(top)}%;width:${number2(width)}%;height:${number2(height)}%;overflow:hidden">${item.html}</div>`;
474
+ }).join("");
475
+ return {
476
+ bounds: bounds2,
477
+ kind: "composite",
478
+ 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>`,
479
+ consumedSpans: items.flatMap((item) => item.consumedSpans ?? [])
480
+ };
481
+ }
482
+ function mediaPiecesTouch(left, right) {
483
+ const xOverlap = overlap2(left.x, left.width, right.x, right.width);
484
+ const yOverlap = overlap2(left.y, left.height, right.y, right.height);
485
+ if (xOverlap > 0 && yOverlap > 0) return true;
486
+ const horizontalGap = axisGap(left.x, left.width, right.x, right.width);
487
+ const verticalGap = axisGap(left.y, left.height, right.y, right.height);
488
+ if (horizontalGap <= 2 && yOverlap / Math.min(left.height, right.height) >= 0.65) return true;
489
+ return verticalGap <= 2 && xOverlap / Math.min(left.width, right.width) >= 0.65;
490
+ }
491
+ function isPageBackdrop(bounds2, page) {
492
+ return bounds2.width * bounds2.height >= page.width * page.height * 0.7;
493
+ }
494
+ function overlap2(left, leftSize, right, rightSize) {
495
+ return Math.max(0, Math.min(left + leftSize, right + rightSize) - Math.max(left, right));
496
+ }
497
+ function axisGap(left, leftSize, right, rightSize) {
498
+ return Math.max(0, right - left - leftSize, left - right - rightSize);
499
+ }
320
500
  function vectorComponents(primitives, padding) {
321
501
  const components = [];
322
502
  for (const primitive of primitives) {
@@ -487,12 +667,16 @@ async function writeSemanticDocument(pages, write, lookaheadPages) {
487
667
  page.structured.lines
488
668
  );
489
669
  const captionedMedia = new Set(captions.values());
670
+ const emittedMedia = /* @__PURE__ */ new Set();
490
671
  const futureFurniture = new Set(future.flatMap((candidate) => marginSignatures(candidate)));
491
672
  const repeatedFurniture = /* @__PURE__ */ new Set([...seenFurniture, ...futureFurniture]);
492
673
  for (const [blockIndex, block] of page.structured.blocks.entries()) {
493
674
  const nextBlock = page.structured.blocks[blockIndex + 1];
494
675
  const blockY = semanticBlockY(block);
495
676
  let emittedAsCaption = false;
677
+ while (page.media[mediaIndex] && emittedMedia.has(page.media[mediaIndex])) {
678
+ mediaIndex += 1;
679
+ }
496
680
  while ((page.media[mediaIndex]?.bounds.y ?? -Infinity) >= blockY) {
497
681
  await flushPendingParagraph();
498
682
  const item = page.media[mediaIndex];
@@ -500,6 +684,7 @@ async function writeSemanticDocument(pages, write, lookaheadPages) {
500
684
  const html2 = `<figure class="pdf-semantic-figure">${item.html}<figcaption>${semanticTextHtml(block.text, block.lines, defaultColor)}</figcaption></figure>`;
501
685
  if (activeTable) pendingMedia.push(html2);
502
686
  else await write(html2);
687
+ emittedMedia.add(item);
503
688
  mediaIndex += 1;
504
689
  emittedAsCaption = true;
505
690
  break;
@@ -510,6 +695,15 @@ async function writeSemanticDocument(pages, write, lookaheadPages) {
510
695
  else await write(html);
511
696
  mediaIndex += 1;
512
697
  }
698
+ const associatedMedia = captions.get(block);
699
+ if (!emittedAsCaption && associatedMedia && block.type === "paragraph") {
700
+ await flushPendingParagraph();
701
+ const html = `<figure class="pdf-semantic-figure">${associatedMedia.html}<figcaption>${semanticTextHtml(block.text, block.lines, defaultColor)}</figcaption></figure>`;
702
+ if (activeTable) pendingMedia.push(html);
703
+ else await write(html);
704
+ emittedMedia.add(associatedMedia);
705
+ emittedAsCaption = true;
706
+ }
513
707
  if (emittedAsCaption) continue;
514
708
  if (isRepeatedFurniture(block, page, repeatedFurniture)) {
515
709
  stats.suppressedFurniture += 1;
@@ -604,10 +798,13 @@ async function writeSemanticDocument(pages, write, lookaheadPages) {
604
798
  await write(semanticBlockHtml(block, defaultColor));
605
799
  }
606
800
  while (mediaIndex < page.media.length) {
607
- await flushPendingParagraph();
608
- const html = `<div class="pdf-semantic-visual">${page.media[mediaIndex]?.html}</div>`;
609
- if (activeTable) pendingMedia.push(html);
610
- else await write(html);
801
+ const item = page.media[mediaIndex];
802
+ if (item && !emittedMedia.has(item)) {
803
+ await flushPendingParagraph();
804
+ const html = `<div class="pdf-semantic-visual">${item.html}</div>`;
805
+ if (activeTable) pendingMedia.push(html);
806
+ else await write(html);
807
+ }
611
808
  mediaIndex += 1;
612
809
  }
613
810
  for (const signature of marginSignatures(page)) seenFurniture.add(signature);
@@ -1014,18 +1211,22 @@ async function writeFlowPage(page, write) {
1014
1211
  structured.lines
1015
1212
  );
1016
1213
  const captionedMedia = new Set(captions.values());
1214
+ const emittedMedia = /* @__PURE__ */ new Set();
1017
1215
  await write(
1018
1216
  `<section class="pdf-page pdf-page--semantic pdf-page--flow" data-page="${page.number}">`
1019
1217
  );
1020
1218
  for (const block of structured.blocks) {
1021
1219
  const blockY = semanticBlockY2(block);
1022
1220
  let emittedAsCaption = false;
1221
+ while (media[mediaIndex] && emittedMedia.has(media[mediaIndex]))
1222
+ mediaIndex += 1;
1023
1223
  while ((media[mediaIndex]?.bounds.y ?? -Infinity) >= blockY) {
1024
1224
  const item = media[mediaIndex];
1025
1225
  if (item && captions.get(block) === item && block.type === "paragraph") {
1026
1226
  await write(
1027
1227
  `<figure class="pdf-semantic-figure">${item.html}<figcaption>${semanticTextHtml(block.text, block.lines, defaultColor)}</figcaption></figure>`
1028
1228
  );
1229
+ emittedMedia.add(item);
1029
1230
  mediaIndex += 1;
1030
1231
  emittedAsCaption = true;
1031
1232
  break;
@@ -1034,6 +1235,14 @@ async function writeFlowPage(page, write) {
1034
1235
  await write(`<div class="pdf-semantic-visual">${item?.html}</div>`);
1035
1236
  mediaIndex += 1;
1036
1237
  }
1238
+ const associatedMedia = captions.get(block);
1239
+ if (!emittedAsCaption && associatedMedia && block.type === "paragraph") {
1240
+ await write(
1241
+ `<figure class="pdf-semantic-figure">${associatedMedia.html}<figcaption>${semanticTextHtml(block.text, block.lines, defaultColor)}</figcaption></figure>`
1242
+ );
1243
+ emittedMedia.add(associatedMedia);
1244
+ emittedAsCaption = true;
1245
+ }
1037
1246
  if (emittedAsCaption) continue;
1038
1247
  if (block.type === "table") await write(tableToHtml(block.table));
1039
1248
  else if (block.type === "heading") {
@@ -1088,7 +1297,10 @@ async function writeFlowPage(page, write) {
1088
1297
  }
1089
1298
  }
1090
1299
  while (mediaIndex < media.length) {
1091
- await write(`<div class="pdf-semantic-visual">${media[mediaIndex]?.html}</div>`);
1300
+ const item = media[mediaIndex];
1301
+ if (item && !emittedMedia.has(item)) {
1302
+ await write(`<div class="pdf-semantic-visual">${item.html}</div>`);
1303
+ }
1092
1304
  mediaIndex += 1;
1093
1305
  }
1094
1306
  await write("</section>");