@boxpdf/html-writer 0.1.17 → 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
@@ -1,6 +1,184 @@
1
1
  // src/index.ts
2
2
  import { structurePage as structurePage2, tableToHtml } from "@boxpdf/reader/structure";
3
3
 
4
+ // src/semantic-caption.ts
5
+ var minimumCaptionScore = 0.72;
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);
31
+ const associations = /* @__PURE__ */ new Map();
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
+ }
36
+ }
37
+ return associations;
38
+ }
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;
149
+ }
150
+ function unionLines(lines) {
151
+ const x = Math.min(...lines.map((line) => line.bounds.x));
152
+ const y = Math.min(...lines.map((line) => line.bounds.y));
153
+ const right = Math.max(...lines.map((line) => line.bounds.x + line.bounds.width));
154
+ const top = Math.max(...lines.map((line) => line.bounds.y + line.bounds.height));
155
+ return { x, y, width: right - x, height: top - y };
156
+ }
157
+ function dominantFontSignature(lines) {
158
+ const counts = /* @__PURE__ */ new Map();
159
+ for (const span of lines.flatMap((line) => line.spans)) {
160
+ const signature = fontSignature(span);
161
+ counts.set(signature, (counts.get(signature) ?? 0) + Math.max(1, [...span.text].length));
162
+ }
163
+ return [...counts].sort((left, right) => right[1] - left[1])[0]?.[0] ?? "";
164
+ }
165
+ function fontSignature(span) {
166
+ return `${(span.fontFamily ?? span.fontName ?? "").toLocaleLowerCase("en")}|${Math.round(span.fontSize * 2) / 2}|${span.color ?? ""}`;
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
+ }
177
+ function median(values) {
178
+ const ordered = [...values].sort((left, right) => left - right);
179
+ return ordered[Math.floor(ordered.length / 2)] ?? 1;
180
+ }
181
+
4
182
  // src/semantic-document.ts
5
183
  import {
6
184
  structurePage,
@@ -76,6 +254,85 @@ function escapeHtml(value) {
76
254
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
77
255
  }
78
256
 
257
+ // src/vector-svg.ts
258
+ function vectorFillSvg(fill) {
259
+ if (!isCssHexColor(fill.color)) return "";
260
+ const points = fill.points.map(([x, y]) => `${number(x)},${number(y)}`).join(" ");
261
+ const opacity = isUnitInterval(fill.opacity) ? ` fill-opacity="${number(fill.opacity)}"` : "";
262
+ return `<polygon points="${points}" fill="${fill.color}"${opacity}/>`;
263
+ }
264
+ function vectorPathSvg(path, pageNumber, pathIndex) {
265
+ if (!isSvgPath(path.d)) return "";
266
+ const fill = isCssHexColor(path.fill) ? path.fill : "none";
267
+ const stroke = isCssHexColor(path.stroke) ? path.stroke : "none";
268
+ const width = finiteNonnegative(path.strokeWidth) ? ` stroke-width="${number(path.strokeWidth)}"` : "";
269
+ const fillOpacity = isUnitInterval(path.fillOpacity) ? ` fill-opacity="${number(path.fillOpacity)}"` : "";
270
+ const strokeOpacity = isUnitInterval(path.strokeOpacity) ? ` stroke-opacity="${number(path.strokeOpacity)}"` : "";
271
+ const dash = path.strokeDasharray?.every(finiteNonnegative) ? ` stroke-dasharray="${path.strokeDasharray.map(number).join(" ")}"` : "";
272
+ const dashoffset = Number.isFinite(path.strokeDashoffset) ? ` stroke-dashoffset="${number(path.strokeDashoffset ?? 0)}"` : "";
273
+ const linecap = path.strokeLinecap ? ` stroke-linecap="${path.strokeLinecap}"` : "";
274
+ const linejoin = path.strokeLinejoin ? ` stroke-linejoin="${path.strokeLinejoin}"` : "";
275
+ const rule = path.fillRule ? ` fill-rule="${path.fillRule}"` : "";
276
+ let output = `<path d="${path.d}" fill="${fill}" stroke="${stroke}"${width}${fillOpacity}${strokeOpacity}${dash}${dashoffset}${linecap}${linejoin}${rule}/>`;
277
+ for (let index = (path.clips?.length ?? 0) - 1; index >= 0; index -= 1) {
278
+ output = `<g clip-path="url(#${vectorPathClipId(pageNumber, pathIndex, index)})">${output}</g>`;
279
+ }
280
+ return output;
281
+ }
282
+ function vectorPathClipDefinitions(paths, pageNumber) {
283
+ return paths.flatMap(
284
+ ({ path, index: pathIndex }) => (path.clips ?? []).map((clip, clipIndex) => {
285
+ if (!isSvgPath(clip.d)) return "";
286
+ const rule = clip.fillRule ? ` clip-rule="${clip.fillRule}"` : "";
287
+ return `<clipPath id="${vectorPathClipId(pageNumber, pathIndex, clipIndex)}" clipPathUnits="userSpaceOnUse"><path d="${clip.d}"${rule}/></clipPath>`;
288
+ })
289
+ ).join("");
290
+ }
291
+ function vectorPathBounds(path) {
292
+ if (!isSvgPath(path.d)) return void 0;
293
+ const values = [...path.d.matchAll(/[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?/gi)].map(
294
+ (match) => Number(match[0])
295
+ );
296
+ if (values.length < 2) return void 0;
297
+ const xs = [];
298
+ const ys = [];
299
+ for (let index = 0; index + 1 < values.length; index += 2) {
300
+ xs.push(values[index] ?? 0);
301
+ ys.push(values[index + 1] ?? 0);
302
+ }
303
+ return bounds(xs, ys);
304
+ }
305
+ function vectorFillBounds(fill) {
306
+ if (fill.points.length === 0) return void 0;
307
+ return bounds(
308
+ fill.points.map(([x]) => x),
309
+ fill.points.map(([, y]) => y)
310
+ );
311
+ }
312
+ function isSvgPath(value) {
313
+ return value.length <= 1e6 && /^[\d\s.,+\-eEMmLlCcZz]+$/.test(value);
314
+ }
315
+ function bounds(xs, ys) {
316
+ const x = Math.min(...xs);
317
+ const y = Math.min(...ys);
318
+ return { x, y, width: Math.max(...xs) - x, height: Math.max(...ys) - y };
319
+ }
320
+ function vectorPathClipId(pageNumber, pathIndex, clipIndex) {
321
+ return `boxpdf-path-clip-${pageNumber}-${pathIndex}-${clipIndex}`;
322
+ }
323
+ function isCssHexColor(value) {
324
+ return typeof value === "string" && /^#[0-9a-f]{6}$/i.test(value);
325
+ }
326
+ function finiteNonnegative(value) {
327
+ return value !== void 0 && Number.isFinite(value) && value >= 0;
328
+ }
329
+ function isUnitInterval(value) {
330
+ return value !== void 0 && Number.isFinite(value) && value >= 0 && value <= 1;
331
+ }
332
+ function number(value) {
333
+ return Number(value.toFixed(4)).toString();
334
+ }
335
+
79
336
  // src/visual-font.ts
80
337
  function visualFontAliases(pageNumber, fonts) {
81
338
  return new Map(
@@ -125,47 +382,151 @@ function base64(bytes) {
125
382
  // src/semantic-media.ts
126
383
  function semanticMedia(page) {
127
384
  const output = (page.images ?? []).map((image) => rasterMedia(image));
128
- const vector = vectorMedia(page);
129
- if (vector) output.push(vector);
130
- return output.sort((left, right) => right.bounds.y - left.bounds.y);
385
+ output.push(...vectorMedia(page));
386
+ return mediaComponents(output, page).sort((left, right) => right.bounds.y - left.bounds.y);
131
387
  }
132
388
  function rasterMedia(image) {
133
- const bounds = transformedUnitBounds(image.transform);
389
+ const bounds2 = transformedUnitBounds(image.transform);
134
390
  const mime = image.format === "jpeg" ? "image/jpeg" : "image/bmp";
135
391
  const data = image.format === "jpeg" ? image.data : rgbBmp(image);
136
- const opacity = unitInterval(image.opacity) ? `;opacity:${number(image.opacity)}` : "";
392
+ const opacity = unitInterval(image.opacity) ? `;opacity:${number2(image.opacity)}` : "";
137
393
  return {
138
- bounds,
139
- html: `<img class="pdf-semantic-media" src="data:${mime};base64,${base64(data)}" width="${number(bounds.width)}" height="${number(bounds.height)}" alt="" style="max-width:100%;height:auto${opacity}">`
394
+ bounds: bounds2,
395
+ kind: "raster",
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}">`
140
397
  };
141
398
  }
142
399
  function vectorMedia(page) {
143
- const paths = (page.paths ?? []).filter((path) => safePath(path.d));
144
- const fills = page.fills ?? [];
145
- const bounds = unionBounds([
146
- ...paths.map((path) => pathBounds(path.d)),
147
- ...fills.map(fillBounds)
148
- ]);
149
- if (!bounds || bounds.width <= 0 || bounds.height <= 0) return void 0;
400
+ const primitives = [
401
+ ...(page.paths ?? []).flatMap((path, index) => {
402
+ const bounds2 = vectorPathBounds(path);
403
+ return bounds2 ? [{ type: "path", value: path, index, bounds: bounds2 }] : [];
404
+ }),
405
+ ...(page.fills ?? []).flatMap((fill) => {
406
+ const bounds2 = vectorFillBounds(fill);
407
+ return bounds2 && !isPageBackground(fill, bounds2, page) ? [{ type: "fill", value: fill, bounds: bounds2 }] : [];
408
+ })
409
+ ];
410
+ const components = vectorComponents(primitives, Math.min(36, page.width * 0.06)).filter(
411
+ (component) => component.primitives.length >= 2 || component.bounds.width * component.bounds.height >= page.width * page.height * 2e-3
412
+ );
150
413
  const aliases = visualFontAliases(page.number, page.fonts ?? []);
151
414
  const visualCodeFonts = new Set(
152
415
  (page.fonts ?? []).filter((font) => font.format === "truetype" && font.visualCodeMapping).map((font) => font.id)
153
416
  );
154
- const visualSpans = page.visualSpans ?? page.spans;
155
- const overlay = visualSpans.filter(
156
- (span) => span.fontAssetId && visualCodeFonts.has(span.fontAssetId) && centerInside(span.bounds, bounds)
157
- );
158
- const consumedSpans = page.spans.filter(
159
- (span) => span.fontAssetId && visualCodeFonts.has(span.fontAssetId) && centerInside(span.bounds, bounds)
160
- );
161
- const fontIds = new Set(overlay.map((span) => span.fontAssetId));
162
- const fontFaces = (page.fonts ?? []).filter((font) => fontIds.has(font.id)).map((font) => visualFontFace(font, aliases)).join("");
417
+ return components.map((component) => {
418
+ const bounds2 = component.bounds;
419
+ const paths = component.primitives.flatMap(
420
+ (primitive) => primitive.type === "path" ? [{ path: primitive.value, index: primitive.index }] : []
421
+ );
422
+ const fills = component.primitives.flatMap(
423
+ (primitive) => primitive.type === "fill" ? [primitive.value] : []
424
+ );
425
+ const visualSpans = page.visualSpans ?? page.spans;
426
+ const overlay = visualSpans.filter(
427
+ (span) => span.fontAssetId && visualCodeFonts.has(span.fontAssetId) && centerInside(span.bounds, bounds2)
428
+ );
429
+ const consumedSpans = page.spans.filter(
430
+ (span) => span.fontAssetId && visualCodeFonts.has(span.fontAssetId) && centerInside(span.bounds, bounds2)
431
+ );
432
+ const fontIds = new Set(overlay.map((span) => span.fontAssetId));
433
+ const fontFaces = (page.fonts ?? []).filter((font) => fontIds.has(font.id)).map((font) => visualFontFace(font, aliases)).join("");
434
+ return {
435
+ bounds: bounds2,
436
+ kind: "vector",
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>`,
438
+ ...consumedSpans.length > 0 ? { consumedSpans } : {}
439
+ };
440
+ });
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("");
163
475
  return {
164
- bounds,
165
- html: `<svg class="pdf-semantic-media" xmlns="http://www.w3.org/2000/svg" viewBox="${number(bounds.x)} ${number(page.height - bounds.y - bounds.height)} ${number(bounds.width)} ${number(bounds.height)}" style="display:block;max-width:100%;height:auto" aria-hidden="true">${fontFaces ? `<style>${fontFaces}</style>` : ""}<g transform="translate(0 ${number(page.height)}) scale(1 -1)">${fills.map(vectorFill).join("") + paths.map((path) => vectorPath(path)).join("")}</g>${overlay.map((span) => vectorText(span, page.height, aliases)).join("")}</svg>`,
166
- ...consumedSpans.length > 0 ? { consumedSpans } : {}
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 ?? [])
167
480
  };
168
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
+ }
500
+ function vectorComponents(primitives, padding) {
501
+ const components = [];
502
+ for (const primitive of primitives) {
503
+ const matches = components.filter(
504
+ (component) => nearby(component.bounds, primitive.bounds, padding)
505
+ );
506
+ if (matches.length === 0) {
507
+ components.push({ bounds: primitive.bounds, primitives: [primitive] });
508
+ continue;
509
+ }
510
+ const target = matches[0];
511
+ target.primitives.push(primitive);
512
+ target.bounds = unionBounds([target.bounds, primitive.bounds]);
513
+ for (const component of matches.slice(1)) {
514
+ target.primitives.push(...component.primitives);
515
+ target.bounds = unionBounds([target.bounds, component.bounds]);
516
+ components.splice(components.indexOf(component), 1);
517
+ }
518
+ }
519
+ return components;
520
+ }
521
+ function nearby(left, right, padding) {
522
+ return !(left.x + left.width + padding < right.x || right.x + right.width + padding < left.x || left.y + left.height + padding < right.y || right.y + right.height + padding < left.y);
523
+ }
524
+ function isPageBackground(fill, bounds2, page) {
525
+ if (!/^#f{6}$/i.test(fill.color)) return false;
526
+ const outside = bounds2.x < 0 || bounds2.y < 0 || bounds2.x + bounds2.width > page.width || bounds2.y + bounds2.height > page.height;
527
+ const large = bounds2.width * bounds2.height > page.width * page.height * 0.2;
528
+ return outside || large;
529
+ }
169
530
  function withoutSemanticMediaSpans(page, media) {
170
531
  const consumed = new Set(media.flatMap((item) => item.consumedSpans ?? []));
171
532
  return consumed.size > 0 ? { ...page, spans: page.spans.filter((span) => !consumed.has(span)) } : page;
@@ -174,7 +535,7 @@ function vectorText(span, pageHeight, aliases) {
174
535
  if (span.renderingMode === 3 || span.renderingMode === 7) return "";
175
536
  const styles2 = [
176
537
  cssColor(span.color) ? `fill:${span.color}` : "",
177
- unitInterval(span.fillOpacity) ? `fill-opacity:${number(span.fillOpacity)}` : "",
538
+ unitInterval(span.fillOpacity) ? `fill-opacity:${number2(span.fillOpacity)}` : "",
178
539
  ...visualFontStyles(
179
540
  span.fontFamily,
180
541
  span.fontAssetId ? aliases.get(span.fontAssetId) : void 0
@@ -182,33 +543,16 @@ function vectorText(span, pageHeight, aliases) {
182
543
  ].filter(Boolean).join(";");
183
544
  const anchorY = pageHeight - span.bounds.y;
184
545
  const transform = span.transform;
185
- const position = transform ? ` x="0" y="0" transform="matrix(${transform.map(number).join(" ")} ${number(span.bounds.x)} ${number(anchorY)})"` : ` x="${number(span.bounds.x)}" y="${number(anchorY)}"`;
546
+ const position = transform ? ` x="0" y="0" transform="matrix(${transform.map(number2).join(" ")} ${number2(span.bounds.x)} ${number2(anchorY)})"` : ` x="${number2(span.bounds.x)}" y="${number2(anchorY)}"`;
186
547
  const extent = span.direction === "ttb" ? span.bounds.height : span.bounds.width;
187
- const length = extent > 0 ? ` textLength="${number(extent)}" lengthAdjust="spacingAndGlyphs"` : "";
188
- return `<text${position} font-size="${number(span.fontSize)}"${length}${styles2 ? ` style="${styles2}"` : ""}>${escapeHtml2(span.text)}</text>`;
548
+ const length = extent > 0 ? ` textLength="${number2(extent)}" lengthAdjust="spacingAndGlyphs"` : "";
549
+ return `<text${position} font-size="${number2(span.fontSize)}"${length}${styles2 ? ` style="${styles2}"` : ""}>${escapeHtml2(span.text)}</text>`;
189
550
  }
190
551
  function centerInside(inner, outer) {
191
552
  const x = inner.x + inner.width / 2;
192
553
  const y = inner.y + inner.height / 2;
193
554
  return x >= outer.x && x <= outer.x + outer.width && y >= outer.y && y <= outer.y + outer.height;
194
555
  }
195
- function vectorFill(fill) {
196
- const points = fill.points.map(([x, y]) => `${number(x)},${number(y)}`).join(" ");
197
- const opacity = unitInterval(fill.opacity) ? ` fill-opacity="${number(fill.opacity)}"` : "";
198
- return cssColor(fill.color) ? `<polygon points="${points}" fill="${fill.color}"${opacity}/>` : "";
199
- }
200
- function vectorPath(path) {
201
- const fill = cssColor(path.fill) ? path.fill : "none";
202
- const stroke = cssColor(path.stroke) ? path.stroke : "none";
203
- const width = finiteNonnegative(path.strokeWidth) ? ` stroke-width="${number(path.strokeWidth)}"` : "";
204
- const fillOpacity = unitInterval(path.fillOpacity) ? ` fill-opacity="${number(path.fillOpacity)}"` : "";
205
- const strokeOpacity = unitInterval(path.strokeOpacity) ? ` stroke-opacity="${number(path.strokeOpacity)}"` : "";
206
- const dash = path.strokeDasharray?.every(finiteNonnegative) ? ` stroke-dasharray="${path.strokeDasharray.map(number).join(" ")}"` : "";
207
- const linecap = path.strokeLinecap ? ` stroke-linecap="${path.strokeLinecap}"` : "";
208
- const linejoin = path.strokeLinejoin ? ` stroke-linejoin="${path.strokeLinejoin}"` : "";
209
- const rule = path.fillRule ? ` fill-rule="${path.fillRule}"` : "";
210
- return `<path d="${path.d}" fill="${fill}" stroke="${stroke}"${width}${fillOpacity}${strokeOpacity}${dash}${linecap}${linejoin}${rule}/>`;
211
- }
212
556
  function transformedUnitBounds([a, b, c, d, e, f]) {
213
557
  const points = [
214
558
  [e, f],
@@ -222,31 +566,8 @@ function transformedUnitBounds([a, b, c, d, e, f]) {
222
566
  const minY = Math.min(...ys);
223
567
  return { x: minX, y: minY, width: Math.max(...xs) - minX, height: Math.max(...ys) - minY };
224
568
  }
225
- function pathBounds(path) {
226
- const values = [...path.matchAll(/[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?/gi)].map(
227
- (match) => Number(match[0])
228
- );
229
- if (values.length < 2) return void 0;
230
- const xs = [];
231
- const ys = [];
232
- for (let index = 0; index + 1 < values.length; index += 2) {
233
- xs.push(values[index] ?? 0);
234
- ys.push(values[index + 1] ?? 0);
235
- }
236
- const minX = Math.min(...xs);
237
- const minY = Math.min(...ys);
238
- return { x: minX, y: minY, width: Math.max(...xs) - minX, height: Math.max(...ys) - minY };
239
- }
240
- function fillBounds(fill) {
241
- if (fill.points.length === 0) return void 0;
242
- const xs = fill.points.map(([x]) => x);
243
- const ys = fill.points.map(([, y]) => y);
244
- const minX = Math.min(...xs);
245
- const minY = Math.min(...ys);
246
- return { x: minX, y: minY, width: Math.max(...xs) - minX, height: Math.max(...ys) - minY };
247
- }
248
- function unionBounds(bounds) {
249
- const values = bounds.filter((value) => Boolean(value));
569
+ function unionBounds(bounds2) {
570
+ const values = bounds2.filter((value) => Boolean(value));
250
571
  if (values.length === 0) return void 0;
251
572
  const x = Math.min(...values.map((value) => value.x));
252
573
  const y = Math.min(...values.map((value) => value.y));
@@ -277,19 +598,16 @@ function rgbBmp(image) {
277
598
  }
278
599
  return output;
279
600
  }
280
- function safePath(value) {
281
- return value.length <= 1e6 && /^[\d\s.,+\-eEMmLlCcZz]+$/.test(value);
282
- }
283
601
  function cssColor(value) {
284
602
  return /^#[\da-f]{6}$/i.test(value ?? "");
285
603
  }
286
- function finiteNonnegative(value) {
604
+ function finiteNonnegative2(value) {
287
605
  return Number.isFinite(value) && (value ?? -1) >= 0;
288
606
  }
289
607
  function unitInterval(value) {
290
- return finiteNonnegative(value) && value <= 1;
608
+ return finiteNonnegative2(value) && value <= 1;
291
609
  }
292
- function number(value) {
610
+ function number2(value) {
293
611
  return Number.isFinite(value) ? String(Math.round(value * 1e3) / 1e3) : "0";
294
612
  }
295
613
  function escapeHtml2(value) {
@@ -341,18 +659,52 @@ async function writeSemanticDocument(pages, write, lookaheadPages) {
341
659
  const emitPage = async (page, future) => {
342
660
  const defaultColor = dominantTextColor(page.structured.lines);
343
661
  let mediaIndex = 0;
662
+ const captions = clearMediaCaptionAssociations(
663
+ page.media,
664
+ page.structured.blocks,
665
+ page.width,
666
+ page.height,
667
+ page.structured.lines
668
+ );
669
+ const captionedMedia = new Set(captions.values());
670
+ const emittedMedia = /* @__PURE__ */ new Set();
344
671
  const futureFurniture = new Set(future.flatMap((candidate) => marginSignatures(candidate)));
345
672
  const repeatedFurniture = /* @__PURE__ */ new Set([...seenFurniture, ...futureFurniture]);
346
673
  for (const [blockIndex, block] of page.structured.blocks.entries()) {
347
674
  const nextBlock = page.structured.blocks[blockIndex + 1];
348
675
  const blockY = semanticBlockY(block);
676
+ let emittedAsCaption = false;
677
+ while (page.media[mediaIndex] && emittedMedia.has(page.media[mediaIndex])) {
678
+ mediaIndex += 1;
679
+ }
349
680
  while ((page.media[mediaIndex]?.bounds.y ?? -Infinity) >= blockY) {
350
681
  await flushPendingParagraph();
351
- const html = `<div class="pdf-semantic-visual">${page.media[mediaIndex]?.html}</div>`;
682
+ const item = page.media[mediaIndex];
683
+ if (item && captions.get(block) === item && block.type === "paragraph") {
684
+ const html2 = `<figure class="pdf-semantic-figure">${item.html}<figcaption>${semanticTextHtml(block.text, block.lines, defaultColor)}</figcaption></figure>`;
685
+ if (activeTable) pendingMedia.push(html2);
686
+ else await write(html2);
687
+ emittedMedia.add(item);
688
+ mediaIndex += 1;
689
+ emittedAsCaption = true;
690
+ break;
691
+ }
692
+ if (item && captionedMedia.has(item)) break;
693
+ const html = `<div class="pdf-semantic-visual">${item?.html}</div>`;
352
694
  if (activeTable) pendingMedia.push(html);
353
695
  else await write(html);
354
696
  mediaIndex += 1;
355
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
+ }
707
+ if (emittedAsCaption) continue;
356
708
  if (isRepeatedFurniture(block, page, repeatedFurniture)) {
357
709
  stats.suppressedFurniture += 1;
358
710
  continue;
@@ -446,10 +798,13 @@ async function writeSemanticDocument(pages, write, lookaheadPages) {
446
798
  await write(semanticBlockHtml(block, defaultColor));
447
799
  }
448
800
  while (mediaIndex < page.media.length) {
449
- await flushPendingParagraph();
450
- const html = `<div class="pdf-semantic-visual">${page.media[mediaIndex]?.html}</div>`;
451
- if (activeTable) pendingMedia.push(html);
452
- 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
+ }
453
808
  mediaIndex += 1;
454
809
  }
455
810
  for (const signature of marginSignatures(page)) seenFurniture.add(signature);
@@ -583,6 +938,14 @@ function financialSummaryRow(entry, columns) {
583
938
  return `<tr><th scope="row"${colspan}>${escapeHtml3(entry.term)}</th><td>${escapeHtml3(entry.description)}</td></tr>`;
584
939
  }
585
940
  function semanticBlockHtml(block, defaultColor = "#000000") {
941
+ if (block.type === "insetGroup") {
942
+ return `<div class="pdf-semantic-inset" style="margin-inline-start:${block.indentEm}em">${block.blocks.map((item) => semanticBlockHtml(item, defaultColor)).join("")}</div>`;
943
+ }
944
+ if (block.type === "table") {
945
+ const rows = tableToRows(block.table);
946
+ const header = tableHeader(rows);
947
+ return `<table>${rows.map((row, index) => tableRow(row, Boolean(header && index === 0))).join("")}</table>`;
948
+ }
586
949
  if (block.type === "heading")
587
950
  return `<h${block.level}>${semanticTextHtml(block.text, block.lines, defaultColor, false)}</h${block.level}>`;
588
951
  if (block.type === "paragraph")
@@ -698,7 +1061,7 @@ async function writePositionedPage(page, write, options) {
698
1061
  const displayWidth = quarterTurn ? page.height : page.width;
699
1062
  const displayHeight = quarterTurn ? page.width : page.height;
700
1063
  await write(
701
- `<section class="pdf-page pdf-page--visual pdf-page--positioned" data-page="${page.number}" data-rotate="${page.rotate}" style="width:${number2(displayWidth)}pt;height:${number2(displayHeight)}pt">`
1064
+ `<section class="pdf-page pdf-page--visual pdf-page--positioned" data-page="${page.number}" data-rotate="${page.rotate}" style="width:${number3(displayWidth)}pt;height:${number3(displayHeight)}pt">`
702
1065
  );
703
1066
  const fontAliases = visualFontAliases(page.number, page.fonts ?? []);
704
1067
  const type3Fonts = new Map(
@@ -710,44 +1073,26 @@ async function writePositionedPage(page, write, options) {
710
1073
  );
711
1074
  }
712
1075
  await write(
713
- `<div class="pdf-page-content pdf-page-content--${page.rotate}" style="width:${number2(page.width)}pt;height:${number2(page.height)}pt${rotationTransform(page)}">`
1076
+ `<div class="pdf-page-content pdf-page-content--${page.rotate}" style="width:${number3(page.width)}pt;height:${number3(page.height)}pt${rotationTransform(page)}">`
714
1077
  );
715
1078
  await write(
716
- `<svg class="pdf-visual-text" xmlns="http://www.w3.org/2000/svg" width="${number2(page.width)}pt" height="${number2(page.height)}pt" viewBox="0 0 ${number2(page.width)} ${number2(page.height)}">`
1079
+ `<svg class="pdf-visual-text" xmlns="http://www.w3.org/2000/svg" width="${number3(page.width)}pt" height="${number3(page.height)}pt" viewBox="0 0 ${number3(page.width)} ${number3(page.height)}">`
1080
+ );
1081
+ const clipDefinitions = imageClipDefinitions(page.images ?? [], page.number, page.height) + vectorPathClipDefinitions(
1082
+ (page.paths ?? []).map((path, index) => ({ path, index })),
1083
+ page.number
717
1084
  );
718
- const clipDefinitions = imageClipDefinitions(page.images ?? [], page.number, page.height) + pathClipDefinitions(page.paths ?? [], page.number);
719
1085
  if (clipDefinitions) await write(`<defs>${clipDefinitions}</defs>`);
720
1086
  if (reflectedOverlay) {
721
1087
  for (const [index, image] of (page.images ?? []).entries()) {
722
1088
  await write(visualImage(image, page.height, page.number, index));
723
1089
  }
724
1090
  }
725
- for (const fill of page.fills ?? []) {
726
- const points = fill.points.map(([x, y]) => `${number2(x)},${number2(page.height - y)}`).join(" ");
727
- if (isCssHexColor(fill.color)) {
728
- const opacity = isUnitInterval(fill.opacity) ? ` fill-opacity="${number2(fill.opacity)}"` : "";
729
- await write(`<polygon points="${points}" fill="${fill.color}"${opacity}/>`);
730
- }
731
- }
732
- if (page.paths?.length) {
733
- await write(`<g transform="translate(0 ${number2(page.height)}) scale(1 -1)">`);
734
- for (const [pathIndex, path] of page.paths.entries()) {
735
- if (!isSvgPath(path.d)) continue;
736
- const fill = isCssHexColor(path.fill) ? path.fill : "none";
737
- const stroke = isCssHexColor(path.stroke) ? path.stroke : "none";
738
- const strokeWidth = path.strokeWidth !== void 0 && Number.isFinite(path.strokeWidth) && path.strokeWidth >= 0 ? ` stroke-width="${number2(path.strokeWidth)}"` : "";
739
- const fillRule = path.fillRule ? ` fill-rule="${path.fillRule}"` : "";
740
- const fillOpacity = isUnitInterval(path.fillOpacity) ? ` fill-opacity="${number2(path.fillOpacity)}"` : "";
741
- const strokeOpacity = isUnitInterval(path.strokeOpacity) ? ` stroke-opacity="${number2(path.strokeOpacity)}"` : "";
742
- const dasharray = path.strokeDasharray?.every((value) => Number.isFinite(value) && value >= 0) ? ` stroke-dasharray="${path.strokeDasharray.map(number2).join(" ")}"` : "";
743
- const dashoffset = Number.isFinite(path.strokeDashoffset) ? ` stroke-dashoffset="${number2(path.strokeDashoffset ?? 0)}"` : "";
744
- const linecap = path.strokeLinecap ? ` stroke-linecap="${path.strokeLinecap}"` : "";
745
- const linejoin = path.strokeLinejoin ? ` stroke-linejoin="${path.strokeLinejoin}"` : "";
746
- let output = `<path d="${path.d}" fill="${fill}" stroke="${stroke}"${strokeWidth}${fillOpacity}${strokeOpacity}${dasharray}${dashoffset}${linecap}${linejoin}${fillRule}/>`;
747
- for (let index = (path.clips?.length ?? 0) - 1; index >= 0; index -= 1) {
748
- output = `<g clip-path="url(#${pathClipId(page.number, pathIndex, index)})">${output}</g>`;
749
- }
750
- await write(output);
1091
+ if (page.fills?.length || page.paths?.length) {
1092
+ await write(`<g transform="translate(0 ${number3(page.height)}) scale(1 -1)">`);
1093
+ for (const fill of page.fills ?? []) await write(vectorFillSvg(fill));
1094
+ for (const [pathIndex, path] of (page.paths ?? []).entries()) {
1095
+ await write(vectorPathSvg(path, page.number, pathIndex));
751
1096
  }
752
1097
  await write("</g>");
753
1098
  }
@@ -777,8 +1122,8 @@ function usesReflectedVisualOverlay(page, spans) {
777
1122
  }
778
1123
  function visualImage(image, pageHeight, pageNumber, imageIndex) {
779
1124
  const [a, b, c, d, e, f] = image.transform;
780
- const transform = [a, -b, -c, d, c + e, pageHeight - d - f].map(number2).join(" ");
781
- const opacity = isUnitInterval(image.opacity) ? ` opacity="${number2(image.opacity)}"` : "";
1125
+ const transform = [a, -b, -c, d, c + e, pageHeight - d - f].map(number3).join(" ");
1126
+ const opacity = isUnitInterval2(image.opacity) ? ` opacity="${number3(image.opacity)}"` : "";
782
1127
  const mime = image.format === "jpeg" ? "image/jpeg" : "image/bmp";
783
1128
  const data = image.format === "jpeg" ? image.data : rgbBmp2(image);
784
1129
  let output = `<image width="1" height="1" preserveAspectRatio="none" transform="matrix(${transform})" href="data:${mime};base64,${base64(data)}"${opacity}/>`;
@@ -792,25 +1137,13 @@ function imageClipDefinitions(images, pageNumber, pageHeight) {
792
1137
  (image, imageIndex) => (image.clips ?? []).map((clip, clipIndex) => {
793
1138
  if (!isSvgPath(clip.d)) return "";
794
1139
  const fillRule = clip.fillRule ? ` clip-rule="${clip.fillRule}"` : "";
795
- return `<clipPath id="${imageClipId(pageNumber, imageIndex, clipIndex)}" clipPathUnits="userSpaceOnUse"><path d="${clip.d}" transform="translate(0 ${number2(pageHeight)}) scale(1 -1)"${fillRule}/></clipPath>`;
1140
+ return `<clipPath id="${imageClipId(pageNumber, imageIndex, clipIndex)}" clipPathUnits="userSpaceOnUse"><path d="${clip.d}" transform="translate(0 ${number3(pageHeight)}) scale(1 -1)"${fillRule}/></clipPath>`;
796
1141
  })
797
1142
  ).join("");
798
1143
  }
799
1144
  function imageClipId(pageNumber, imageIndex, clipIndex) {
800
1145
  return `boxpdf-clip-${pageNumber}-${imageIndex}-${clipIndex}`;
801
1146
  }
802
- function pathClipDefinitions(paths, pageNumber) {
803
- return paths.flatMap(
804
- (path, pathIndex) => (path.clips ?? []).map((clip, clipIndex) => {
805
- if (!isSvgPath(clip.d)) return "";
806
- const fillRule = clip.fillRule ? ` clip-rule="${clip.fillRule}"` : "";
807
- return `<clipPath id="${pathClipId(pageNumber, pathIndex, clipIndex)}" clipPathUnits="userSpaceOnUse"><path d="${clip.d}"${fillRule}/></clipPath>`;
808
- })
809
- ).join("");
810
- }
811
- function pathClipId(pageNumber, pathIndex, clipIndex) {
812
- return `boxpdf-path-clip-${pageNumber}-${pathIndex}-${clipIndex}`;
813
- }
814
1147
  function rgbBmp2(image) {
815
1148
  const stride = Math.ceil(image.width * 3 / 4) * 4;
816
1149
  const output = new Uint8Array(54 + stride * image.height);
@@ -839,11 +1172,11 @@ function rgbBmp2(image) {
839
1172
  function rotationTransform(page) {
840
1173
  switch (page.rotate) {
841
1174
  case 90:
842
- return `;transform:translate(${number2(page.height)}pt,0) rotate(90deg)`;
1175
+ return `;transform:translate(${number3(page.height)}pt,0) rotate(90deg)`;
843
1176
  case 180:
844
- return `;transform:translate(${number2(page.width)}pt,${number2(page.height)}pt) rotate(180deg)`;
1177
+ return `;transform:translate(${number3(page.width)}pt,${number3(page.height)}pt) rotate(180deg)`;
845
1178
  case 270:
846
- return `;transform:translate(0,${number2(page.width)}pt) rotate(270deg)`;
1179
+ return `;transform:translate(0,${number3(page.width)}pt) rotate(270deg)`;
847
1180
  default:
848
1181
  return "";
849
1182
  }
@@ -851,13 +1184,13 @@ function rotationTransform(page) {
851
1184
  function positionedSpan(span, fontAliases) {
852
1185
  const direction = directionAttribute([span]);
853
1186
  const style = [
854
- `left:${number2(span.bounds.x)}pt`,
855
- `bottom:${number2(span.bounds.y)}pt`,
856
- `width:${number2(span.bounds.width)}pt`,
857
- `height:${number2(span.bounds.height)}pt`,
858
- `font-size:${number2(span.fontSize)}pt`,
859
- ...isCssHexColor(span.color) ? [`color:${span.color}`] : [],
860
- ...isUnitInterval(span.fillOpacity) ? [`opacity:${number2(span.fillOpacity)}`] : [],
1187
+ `left:${number3(span.bounds.x)}pt`,
1188
+ `bottom:${number3(span.bounds.y)}pt`,
1189
+ `width:${number3(span.bounds.width)}pt`,
1190
+ `height:${number3(span.bounds.height)}pt`,
1191
+ `font-size:${number3(span.fontSize)}pt`,
1192
+ ...isCssHexColor2(span.color) ? [`color:${span.color}`] : [],
1193
+ ...isUnitInterval2(span.fillOpacity) ? [`opacity:${number3(span.fillOpacity)}`] : [],
861
1194
  ...visualFontStyles(
862
1195
  span.fontFamily,
863
1196
  span.fontAssetId ? fontAliases.get(span.fontAssetId) : void 0
@@ -870,15 +1203,47 @@ async function writeFlowPage(page, write) {
870
1203
  const structured = structurePage2(withoutSemanticMediaSpans(page, media));
871
1204
  const defaultColor = dominantTextColor(structured.lines);
872
1205
  let mediaIndex = 0;
1206
+ const captions = clearMediaCaptionAssociations(
1207
+ media,
1208
+ structured.blocks,
1209
+ page.width,
1210
+ page.height,
1211
+ structured.lines
1212
+ );
1213
+ const captionedMedia = new Set(captions.values());
1214
+ const emittedMedia = /* @__PURE__ */ new Set();
873
1215
  await write(
874
1216
  `<section class="pdf-page pdf-page--semantic pdf-page--flow" data-page="${page.number}">`
875
1217
  );
876
1218
  for (const block of structured.blocks) {
877
1219
  const blockY = semanticBlockY2(block);
1220
+ let emittedAsCaption = false;
1221
+ while (media[mediaIndex] && emittedMedia.has(media[mediaIndex]))
1222
+ mediaIndex += 1;
878
1223
  while ((media[mediaIndex]?.bounds.y ?? -Infinity) >= blockY) {
879
- await write(`<div class="pdf-semantic-visual">${media[mediaIndex]?.html}</div>`);
1224
+ const item = media[mediaIndex];
1225
+ if (item && captions.get(block) === item && block.type === "paragraph") {
1226
+ await write(
1227
+ `<figure class="pdf-semantic-figure">${item.html}<figcaption>${semanticTextHtml(block.text, block.lines, defaultColor)}</figcaption></figure>`
1228
+ );
1229
+ emittedMedia.add(item);
1230
+ mediaIndex += 1;
1231
+ emittedAsCaption = true;
1232
+ break;
1233
+ }
1234
+ if (item && captionedMedia.has(item)) break;
1235
+ await write(`<div class="pdf-semantic-visual">${item?.html}</div>`);
880
1236
  mediaIndex += 1;
881
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
+ }
1246
+ if (emittedAsCaption) continue;
882
1247
  if (block.type === "table") await write(tableToHtml(block.table));
883
1248
  else if (block.type === "heading") {
884
1249
  await write(
@@ -918,6 +1283,10 @@ async function writeFlowPage(page, write) {
918
1283
  await write(
919
1284
  `<section><h3>${escapeHtml4(block.role)}</h3><p>${escapeHtml4(block.organization)}</p><p>${escapeHtml4(block.date)}</p></section>`
920
1285
  );
1286
+ } else if (block.type === "insetGroup") {
1287
+ await write(
1288
+ `<div class="pdf-semantic-inset" style="margin-inline-start:${number3(block.indentEm)}em">${block.blocks.map((item) => nestedSemanticBlockHtml(item, defaultColor)).join("")}</div>`
1289
+ );
921
1290
  } else {
922
1291
  const tag = block.ordered ? "ol" : "ul";
923
1292
  await write(`<${tag}>`);
@@ -928,11 +1297,41 @@ async function writeFlowPage(page, write) {
928
1297
  }
929
1298
  }
930
1299
  while (mediaIndex < media.length) {
931
- 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
+ }
932
1304
  mediaIndex += 1;
933
1305
  }
934
1306
  await write("</section>");
935
1307
  }
1308
+ function nestedSemanticBlockHtml(block, defaultColor) {
1309
+ if (block.type === "insetGroup") {
1310
+ return `<div class="pdf-semantic-inset" style="margin-inline-start:${number3(block.indentEm)}em">${block.blocks.map((item) => nestedSemanticBlockHtml(item, defaultColor)).join("")}</div>`;
1311
+ }
1312
+ if (block.type === "table") return tableToHtml(block.table);
1313
+ if (block.type === "heading") {
1314
+ return `<h${block.level}>${semanticTextHtml(block.text, block.lines, defaultColor, false)}</h${block.level}>`;
1315
+ }
1316
+ if (block.type === "paragraph") {
1317
+ return `<p>${semanticTextHtml(block.text, block.lines, defaultColor)}</p>`;
1318
+ }
1319
+ if (block.type === "preformatted") return `<pre>${escapeHtml4(block.text)}</pre>`;
1320
+ if (block.type === "definitionList") {
1321
+ return `<dl>${block.entries.map((entry) => `<div><dt>${escapeHtml4(entry.term)}</dt><dd>${escapeHtml4(entry.description)}</dd></div>`).join("")}</dl>`;
1322
+ }
1323
+ if (block.type === "cardList") {
1324
+ return `<div class="pdf-semantic-cards">${block.items.map((item) => `<article><h3>${escapeHtml4(item.title)}</h3>${item.details.map((detail) => `<p>${escapeHtml4(detail)}</p>`).join("")}</article>`).join("")}</div>`;
1325
+ }
1326
+ if (block.type === "sectionGroup") {
1327
+ return `<div class="pdf-semantic-sections">${block.items.map((item) => `<section><h3>${escapeHtml4(item.label)}</h3>${item.content.map((content) => `<p>${escapeHtml4(content)}</p>`).join("")}</section>`).join("")}</div>`;
1328
+ }
1329
+ if (block.type === "employment") {
1330
+ return `<section><h3>${escapeHtml4(block.role)}</h3><p>${escapeHtml4(block.organization)}</p><p>${escapeHtml4(block.date)}</p></section>`;
1331
+ }
1332
+ const tag = block.ordered ? "ol" : "ul";
1333
+ return `<${tag}>${block.items.map((item) => `<li>${semanticTextHtml(item.text, item.lines, defaultColor)}</li>`).join("")}</${tag}>`;
1334
+ }
936
1335
  function semanticBlockY2(block) {
937
1336
  const lines = block.type === "list" ? block.items.flatMap((item) => item.lines) : block.lines;
938
1337
  return Math.max(...lines.map((line) => line.bounds.y + line.bounds.height));
@@ -945,15 +1344,15 @@ function visualText(span, pageHeight, fontAliases, counterRotateReflectedText =
945
1344
  span.fontFamily,
946
1345
  span.fontAssetId ? fontAliases.get(span.fontAssetId) : void 0
947
1346
  ).join(";");
948
- const stroke = isCssHexColor(span.strokeColor) ? `stroke:${span.strokeColor}` : "";
949
- const strokeWidth = stroke && Number.isFinite(span.strokeWidth) && (span.strokeWidth ?? -1) >= 0 ? `stroke-width:${number2(span.strokeWidth ?? 0)}` : "";
1347
+ const stroke = isCssHexColor2(span.strokeColor) ? `stroke:${span.strokeColor}` : "";
1348
+ const strokeWidth = stroke && Number.isFinite(span.strokeWidth) && (span.strokeWidth ?? -1) >= 0 ? `stroke-width:${number3(span.strokeWidth ?? 0)}` : "";
950
1349
  const strokeOnly = span.renderingMode === 1 || span.renderingMode === 5;
951
- const fillOpacity = isUnitInterval(span.fillOpacity) ? `fill-opacity:${number2(span.fillOpacity)}` : "";
952
- const strokeOpacity = isUnitInterval(span.strokeOpacity) ? `stroke-opacity:${number2(span.strokeOpacity)}` : "";
1350
+ const fillOpacity = isUnitInterval2(span.fillOpacity) ? `fill-opacity:${number3(span.fillOpacity)}` : "";
1351
+ const strokeOpacity = isUnitInterval2(span.strokeOpacity) ? `stroke-opacity:${number3(span.strokeOpacity)}` : "";
953
1352
  const style = [
954
1353
  isHebrewPaintOrder(span) ? "unicode-bidi:bidi-override;direction:ltr" : "",
955
1354
  span.direction === "ttb" ? "writing-mode:vertical-rl" : "",
956
- strokeOnly ? "fill:none" : isCssHexColor(span.color) ? `fill:${span.color}` : "",
1355
+ strokeOnly ? "fill:none" : isCssHexColor2(span.color) ? `fill:${span.color}` : "",
957
1356
  stroke,
958
1357
  strokeWidth,
959
1358
  fillOpacity,
@@ -961,7 +1360,7 @@ function visualText(span, pageHeight, fontAliases, counterRotateReflectedText =
961
1360
  font
962
1361
  ].filter(Boolean).join(";");
963
1362
  const textExtent = span.direction === "ttb" ? span.bounds.height : span.bounds.width;
964
- const textLength = textExtent > 0 && !isHebrewPaintOrder(span) ? ` textLength="${number2(textExtent)}" lengthAdjust="${span.direction === "ttb" || usesSpacingAdjustment(span) ? "spacing" : "spacingAndGlyphs"}"` : "";
1363
+ const textLength = textExtent > 0 && !isHebrewPaintOrder(span) ? ` textLength="${number3(textExtent)}" lengthAdjust="${span.direction === "ttb" || usesSpacingAdjustment(span) ? "spacing" : "spacingAndGlyphs"}"` : "";
965
1364
  const transform = counterRotateReflectedText && span.transform ? [
966
1365
  span.transform[0],
967
1366
  span.transform[1],
@@ -974,8 +1373,8 @@ function visualText(span, pageHeight, fontAliases, counterRotateReflectedText =
974
1373
  const basisY = transform?.[1] ?? 0;
975
1374
  const anchorX = span.bounds.x + basisX * rtlOffset;
976
1375
  const anchorY = pageHeight - span.bounds.y + basisY * rtlOffset;
977
- const position = transformed ? ` x="0" y="0" transform="matrix(${transform?.map(number2).join(" ")} ${number2(anchorX)} ${number2(anchorY)})"` : ` x="${number2(anchorX)}" y="${number2(anchorY)}"`;
978
- return `<text${direction}${position} font-size="${number2(span.fontSize)}"${textLength}${style ? ` style="${style}"` : ""}>${escapeHtml4(span.text)}</text>`;
1376
+ const position = transformed ? ` x="0" y="0" transform="matrix(${transform?.map(number3).join(" ")} ${number3(anchorX)} ${number3(anchorY)})"` : ` x="${number3(anchorX)}" y="${number3(anchorY)}"`;
1377
+ return `<text${direction}${position} font-size="${number3(span.fontSize)}"${textLength}${style ? ` style="${style}"` : ""}>${escapeHtml4(span.text)}</text>`;
979
1378
  }
980
1379
  function isAdobeCjkFont(fontFamily) {
981
1380
  return /^Adobe(?:Heiti|Song|Kaiti|Ming|Gothic|Mincho)Std-/i.test(fontFamily ?? "");
@@ -987,16 +1386,16 @@ function visualType3Text(span, font, pageHeight) {
987
1386
  const totalAdvance = sequence.reduce((total, glyph) => total + (glyph?.advance ?? 0), 0);
988
1387
  if (totalAdvance <= 0 || span.bounds.width <= 0 || span.fontSize <= 0) return "";
989
1388
  const transform = span.transform ?? [1, 0, 0, 1];
990
- const outer = `matrix(${transform.map(number2).join(" ")} ${number2(span.bounds.x)} ${number2(pageHeight - span.bounds.y)})`;
1389
+ const outer = `matrix(${transform.map(number3).join(" ")} ${number3(span.bounds.x)} ${number3(pageHeight - span.bounds.y)})`;
991
1390
  const xScale = span.bounds.width / totalAdvance;
992
1391
  let offset = 0;
993
1392
  let content = "";
994
1393
  for (const glyph of sequence) {
995
1394
  if (!glyph) continue;
996
- content += `<g transform="translate(${number2(offset)} 0)">${type3Glyph(glyph, span.color)}</g>`;
1395
+ content += `<g transform="translate(${number3(offset)} 0)">${type3Glyph(glyph, span.color)}</g>`;
997
1396
  offset += glyph.advance;
998
1397
  }
999
- return `<g transform="${outer}"><g transform="scale(${number2(xScale)} ${number2(-span.fontSize)})">${content}</g></g>`;
1398
+ return `<g transform="${outer}"><g transform="scale(${number3(xScale)} ${number3(-span.fontSize)})">${content}</g></g>`;
1000
1399
  }
1001
1400
  function isHebrewPaintOrder(span) {
1002
1401
  return span.direction === "ltr" && /[\u0590-\u05ff]/u.test(span.text);
@@ -1007,30 +1406,27 @@ function usesSpacingAdjustment(span) {
1007
1406
  function type3Glyph(glyph, textColor) {
1008
1407
  let output = "";
1009
1408
  for (const fill of glyph.fills ?? []) {
1010
- const color = glyph.usesTextColor && isCssHexColor(textColor) ? textColor : fill.color;
1011
- if (!isCssHexColor(color)) continue;
1012
- const points = fill.points.map(([x, y]) => `${number2(x)},${number2(y)}`).join(" ");
1013
- const opacity = isUnitInterval(fill.opacity) ? ` fill-opacity="${number2(fill.opacity)}"` : "";
1409
+ const color = glyph.usesTextColor && isCssHexColor2(textColor) ? textColor : fill.color;
1410
+ if (!isCssHexColor2(color)) continue;
1411
+ const points = fill.points.map(([x, y]) => `${number3(x)},${number3(y)}`).join(" ");
1412
+ const opacity = isUnitInterval2(fill.opacity) ? ` fill-opacity="${number3(fill.opacity)}"` : "";
1014
1413
  output += `<polygon points="${points}" fill="${color}"${opacity}/>`;
1015
1414
  }
1016
1415
  for (const path of glyph.paths ?? []) {
1017
1416
  if (!isSvgPath(path.d)) continue;
1018
- const fill = glyph.usesTextColor && isCssHexColor(textColor) ? textColor : isCssHexColor(path.fill) ? path.fill : "none";
1019
- const stroke = glyph.usesTextColor && isCssHexColor(textColor) && path.stroke ? textColor : isCssHexColor(path.stroke) ? path.stroke : "none";
1020
- const width = path.strokeWidth !== void 0 && Number.isFinite(path.strokeWidth) && path.strokeWidth >= 0 ? ` stroke-width="${number2(path.strokeWidth)}"` : "";
1417
+ const fill = glyph.usesTextColor && isCssHexColor2(textColor) ? textColor : isCssHexColor2(path.fill) ? path.fill : "none";
1418
+ const stroke = glyph.usesTextColor && isCssHexColor2(textColor) && path.stroke ? textColor : isCssHexColor2(path.stroke) ? path.stroke : "none";
1419
+ const width = path.strokeWidth !== void 0 && Number.isFinite(path.strokeWidth) && path.strokeWidth >= 0 ? ` stroke-width="${number3(path.strokeWidth)}"` : "";
1021
1420
  output += `<path d="${path.d}" fill="${fill}" stroke="${stroke}"${width}/>`;
1022
1421
  }
1023
1422
  return (glyph.fills?.length ?? 0) > 64 && glyph.advance > 2 ? `<g shape-rendering="crispEdges">${output}</g>` : output;
1024
1423
  }
1025
- function isCssHexColor(value) {
1424
+ function isCssHexColor2(value) {
1026
1425
  return /^#[\da-f]{6}$/i.test(value ?? "");
1027
1426
  }
1028
- function isUnitInterval(value) {
1427
+ function isUnitInterval2(value) {
1029
1428
  return Number.isFinite(value) && (value ?? -1) >= 0 && (value ?? 2) <= 1;
1030
1429
  }
1031
- function isSvgPath(value) {
1032
- return value.length <= 1e6 && /^[\d\s.,+\-eEMmLlCcZz]+$/.test(value);
1033
- }
1034
1430
  function isMonospace(fontFamily) {
1035
1431
  return /courier|mono/i.test(fontFamily ?? "");
1036
1432
  }
@@ -1048,7 +1444,7 @@ function directionAttribute(spans) {
1048
1444
  if (vertical > rtl && vertical * 2 >= spans.length) return ' data-direction="ttb"';
1049
1445
  return rtl * 2 >= spans.length && spans.length > 0 ? ' dir="rtl"' : "";
1050
1446
  }
1051
- function number2(value) {
1447
+ function number3(value) {
1052
1448
  return Number.isFinite(value) ? String(Math.round(value * 1e3) / 1e3) : "0";
1053
1449
  }
1054
1450
  function escapeAttribute(value) {