@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.cjs CHANGED
@@ -27,6 +27,184 @@ __export(index_exports, {
27
27
  module.exports = __toCommonJS(index_exports);
28
28
  var import_structure2 = require("@boxpdf/reader/structure");
29
29
 
30
+ // src/semantic-caption.ts
31
+ var minimumCaptionScore = 0.72;
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);
57
+ const associations = /* @__PURE__ */ new Map();
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
+ }
62
+ }
63
+ return associations;
64
+ }
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;
175
+ }
176
+ function unionLines(lines) {
177
+ const x = Math.min(...lines.map((line) => line.bounds.x));
178
+ const y = Math.min(...lines.map((line) => line.bounds.y));
179
+ const right = Math.max(...lines.map((line) => line.bounds.x + line.bounds.width));
180
+ const top = Math.max(...lines.map((line) => line.bounds.y + line.bounds.height));
181
+ return { x, y, width: right - x, height: top - y };
182
+ }
183
+ function dominantFontSignature(lines) {
184
+ const counts = /* @__PURE__ */ new Map();
185
+ for (const span of lines.flatMap((line) => line.spans)) {
186
+ const signature = fontSignature(span);
187
+ counts.set(signature, (counts.get(signature) ?? 0) + Math.max(1, [...span.text].length));
188
+ }
189
+ return [...counts].sort((left, right) => right[1] - left[1])[0]?.[0] ?? "";
190
+ }
191
+ function fontSignature(span) {
192
+ return `${(span.fontFamily ?? span.fontName ?? "").toLocaleLowerCase("en")}|${Math.round(span.fontSize * 2) / 2}|${span.color ?? ""}`;
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
+ }
203
+ function median(values) {
204
+ const ordered = [...values].sort((left, right) => left - right);
205
+ return ordered[Math.floor(ordered.length / 2)] ?? 1;
206
+ }
207
+
30
208
  // src/semantic-document.ts
31
209
  var import_structure = require("@boxpdf/reader/structure");
32
210
 
@@ -99,6 +277,85 @@ function escapeHtml(value) {
99
277
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
100
278
  }
101
279
 
280
+ // src/vector-svg.ts
281
+ function vectorFillSvg(fill) {
282
+ if (!isCssHexColor(fill.color)) return "";
283
+ const points = fill.points.map(([x, y]) => `${number(x)},${number(y)}`).join(" ");
284
+ const opacity = isUnitInterval(fill.opacity) ? ` fill-opacity="${number(fill.opacity)}"` : "";
285
+ return `<polygon points="${points}" fill="${fill.color}"${opacity}/>`;
286
+ }
287
+ function vectorPathSvg(path, pageNumber, pathIndex) {
288
+ if (!isSvgPath(path.d)) return "";
289
+ const fill = isCssHexColor(path.fill) ? path.fill : "none";
290
+ const stroke = isCssHexColor(path.stroke) ? path.stroke : "none";
291
+ const width = finiteNonnegative(path.strokeWidth) ? ` stroke-width="${number(path.strokeWidth)}"` : "";
292
+ const fillOpacity = isUnitInterval(path.fillOpacity) ? ` fill-opacity="${number(path.fillOpacity)}"` : "";
293
+ const strokeOpacity = isUnitInterval(path.strokeOpacity) ? ` stroke-opacity="${number(path.strokeOpacity)}"` : "";
294
+ const dash = path.strokeDasharray?.every(finiteNonnegative) ? ` stroke-dasharray="${path.strokeDasharray.map(number).join(" ")}"` : "";
295
+ const dashoffset = Number.isFinite(path.strokeDashoffset) ? ` stroke-dashoffset="${number(path.strokeDashoffset ?? 0)}"` : "";
296
+ const linecap = path.strokeLinecap ? ` stroke-linecap="${path.strokeLinecap}"` : "";
297
+ const linejoin = path.strokeLinejoin ? ` stroke-linejoin="${path.strokeLinejoin}"` : "";
298
+ const rule = path.fillRule ? ` fill-rule="${path.fillRule}"` : "";
299
+ let output = `<path d="${path.d}" fill="${fill}" stroke="${stroke}"${width}${fillOpacity}${strokeOpacity}${dash}${dashoffset}${linecap}${linejoin}${rule}/>`;
300
+ for (let index = (path.clips?.length ?? 0) - 1; index >= 0; index -= 1) {
301
+ output = `<g clip-path="url(#${vectorPathClipId(pageNumber, pathIndex, index)})">${output}</g>`;
302
+ }
303
+ return output;
304
+ }
305
+ function vectorPathClipDefinitions(paths, pageNumber) {
306
+ return paths.flatMap(
307
+ ({ path, index: pathIndex }) => (path.clips ?? []).map((clip, clipIndex) => {
308
+ if (!isSvgPath(clip.d)) return "";
309
+ const rule = clip.fillRule ? ` clip-rule="${clip.fillRule}"` : "";
310
+ return `<clipPath id="${vectorPathClipId(pageNumber, pathIndex, clipIndex)}" clipPathUnits="userSpaceOnUse"><path d="${clip.d}"${rule}/></clipPath>`;
311
+ })
312
+ ).join("");
313
+ }
314
+ function vectorPathBounds(path) {
315
+ if (!isSvgPath(path.d)) return void 0;
316
+ const values = [...path.d.matchAll(/[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?/gi)].map(
317
+ (match) => Number(match[0])
318
+ );
319
+ if (values.length < 2) return void 0;
320
+ const xs = [];
321
+ const ys = [];
322
+ for (let index = 0; index + 1 < values.length; index += 2) {
323
+ xs.push(values[index] ?? 0);
324
+ ys.push(values[index + 1] ?? 0);
325
+ }
326
+ return bounds(xs, ys);
327
+ }
328
+ function vectorFillBounds(fill) {
329
+ if (fill.points.length === 0) return void 0;
330
+ return bounds(
331
+ fill.points.map(([x]) => x),
332
+ fill.points.map(([, y]) => y)
333
+ );
334
+ }
335
+ function isSvgPath(value) {
336
+ return value.length <= 1e6 && /^[\d\s.,+\-eEMmLlCcZz]+$/.test(value);
337
+ }
338
+ function bounds(xs, ys) {
339
+ const x = Math.min(...xs);
340
+ const y = Math.min(...ys);
341
+ return { x, y, width: Math.max(...xs) - x, height: Math.max(...ys) - y };
342
+ }
343
+ function vectorPathClipId(pageNumber, pathIndex, clipIndex) {
344
+ return `boxpdf-path-clip-${pageNumber}-${pathIndex}-${clipIndex}`;
345
+ }
346
+ function isCssHexColor(value) {
347
+ return typeof value === "string" && /^#[0-9a-f]{6}$/i.test(value);
348
+ }
349
+ function finiteNonnegative(value) {
350
+ return value !== void 0 && Number.isFinite(value) && value >= 0;
351
+ }
352
+ function isUnitInterval(value) {
353
+ return value !== void 0 && Number.isFinite(value) && value >= 0 && value <= 1;
354
+ }
355
+ function number(value) {
356
+ return Number(value.toFixed(4)).toString();
357
+ }
358
+
102
359
  // src/visual-font.ts
103
360
  function visualFontAliases(pageNumber, fonts) {
104
361
  return new Map(
@@ -148,47 +405,151 @@ function base64(bytes) {
148
405
  // src/semantic-media.ts
149
406
  function semanticMedia(page) {
150
407
  const output = (page.images ?? []).map((image) => rasterMedia(image));
151
- const vector = vectorMedia(page);
152
- if (vector) output.push(vector);
153
- return output.sort((left, right) => right.bounds.y - left.bounds.y);
408
+ output.push(...vectorMedia(page));
409
+ return mediaComponents(output, page).sort((left, right) => right.bounds.y - left.bounds.y);
154
410
  }
155
411
  function rasterMedia(image) {
156
- const bounds = transformedUnitBounds(image.transform);
412
+ const bounds2 = transformedUnitBounds(image.transform);
157
413
  const mime = image.format === "jpeg" ? "image/jpeg" : "image/bmp";
158
414
  const data = image.format === "jpeg" ? image.data : rgbBmp(image);
159
- const opacity = unitInterval(image.opacity) ? `;opacity:${number(image.opacity)}` : "";
415
+ const opacity = unitInterval(image.opacity) ? `;opacity:${number2(image.opacity)}` : "";
160
416
  return {
161
- bounds,
162
- 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}">`
417
+ bounds: bounds2,
418
+ kind: "raster",
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}">`
163
420
  };
164
421
  }
165
422
  function vectorMedia(page) {
166
- const paths = (page.paths ?? []).filter((path) => safePath(path.d));
167
- const fills = page.fills ?? [];
168
- const bounds = unionBounds([
169
- ...paths.map((path) => pathBounds(path.d)),
170
- ...fills.map(fillBounds)
171
- ]);
172
- if (!bounds || bounds.width <= 0 || bounds.height <= 0) return void 0;
423
+ const primitives = [
424
+ ...(page.paths ?? []).flatMap((path, index) => {
425
+ const bounds2 = vectorPathBounds(path);
426
+ return bounds2 ? [{ type: "path", value: path, index, bounds: bounds2 }] : [];
427
+ }),
428
+ ...(page.fills ?? []).flatMap((fill) => {
429
+ const bounds2 = vectorFillBounds(fill);
430
+ return bounds2 && !isPageBackground(fill, bounds2, page) ? [{ type: "fill", value: fill, bounds: bounds2 }] : [];
431
+ })
432
+ ];
433
+ const components = vectorComponents(primitives, Math.min(36, page.width * 0.06)).filter(
434
+ (component) => component.primitives.length >= 2 || component.bounds.width * component.bounds.height >= page.width * page.height * 2e-3
435
+ );
173
436
  const aliases = visualFontAliases(page.number, page.fonts ?? []);
174
437
  const visualCodeFonts = new Set(
175
438
  (page.fonts ?? []).filter((font) => font.format === "truetype" && font.visualCodeMapping).map((font) => font.id)
176
439
  );
177
- const visualSpans = page.visualSpans ?? page.spans;
178
- const overlay = visualSpans.filter(
179
- (span) => span.fontAssetId && visualCodeFonts.has(span.fontAssetId) && centerInside(span.bounds, bounds)
180
- );
181
- const consumedSpans = page.spans.filter(
182
- (span) => span.fontAssetId && visualCodeFonts.has(span.fontAssetId) && centerInside(span.bounds, bounds)
183
- );
184
- const fontIds = new Set(overlay.map((span) => span.fontAssetId));
185
- const fontFaces = (page.fonts ?? []).filter((font) => fontIds.has(font.id)).map((font) => visualFontFace(font, aliases)).join("");
440
+ return components.map((component) => {
441
+ const bounds2 = component.bounds;
442
+ const paths = component.primitives.flatMap(
443
+ (primitive) => primitive.type === "path" ? [{ path: primitive.value, index: primitive.index }] : []
444
+ );
445
+ const fills = component.primitives.flatMap(
446
+ (primitive) => primitive.type === "fill" ? [primitive.value] : []
447
+ );
448
+ const visualSpans = page.visualSpans ?? page.spans;
449
+ const overlay = visualSpans.filter(
450
+ (span) => span.fontAssetId && visualCodeFonts.has(span.fontAssetId) && centerInside(span.bounds, bounds2)
451
+ );
452
+ const consumedSpans = page.spans.filter(
453
+ (span) => span.fontAssetId && visualCodeFonts.has(span.fontAssetId) && centerInside(span.bounds, bounds2)
454
+ );
455
+ const fontIds = new Set(overlay.map((span) => span.fontAssetId));
456
+ const fontFaces = (page.fonts ?? []).filter((font) => fontIds.has(font.id)).map((font) => visualFontFace(font, aliases)).join("");
457
+ return {
458
+ bounds: bounds2,
459
+ kind: "vector",
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>`,
461
+ ...consumedSpans.length > 0 ? { consumedSpans } : {}
462
+ };
463
+ });
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("");
186
498
  return {
187
- bounds,
188
- 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>`,
189
- ...consumedSpans.length > 0 ? { consumedSpans } : {}
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 ?? [])
190
503
  };
191
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
+ }
523
+ function vectorComponents(primitives, padding) {
524
+ const components = [];
525
+ for (const primitive of primitives) {
526
+ const matches = components.filter(
527
+ (component) => nearby(component.bounds, primitive.bounds, padding)
528
+ );
529
+ if (matches.length === 0) {
530
+ components.push({ bounds: primitive.bounds, primitives: [primitive] });
531
+ continue;
532
+ }
533
+ const target = matches[0];
534
+ target.primitives.push(primitive);
535
+ target.bounds = unionBounds([target.bounds, primitive.bounds]);
536
+ for (const component of matches.slice(1)) {
537
+ target.primitives.push(...component.primitives);
538
+ target.bounds = unionBounds([target.bounds, component.bounds]);
539
+ components.splice(components.indexOf(component), 1);
540
+ }
541
+ }
542
+ return components;
543
+ }
544
+ function nearby(left, right, padding) {
545
+ 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);
546
+ }
547
+ function isPageBackground(fill, bounds2, page) {
548
+ if (!/^#f{6}$/i.test(fill.color)) return false;
549
+ const outside = bounds2.x < 0 || bounds2.y < 0 || bounds2.x + bounds2.width > page.width || bounds2.y + bounds2.height > page.height;
550
+ const large = bounds2.width * bounds2.height > page.width * page.height * 0.2;
551
+ return outside || large;
552
+ }
192
553
  function withoutSemanticMediaSpans(page, media) {
193
554
  const consumed = new Set(media.flatMap((item) => item.consumedSpans ?? []));
194
555
  return consumed.size > 0 ? { ...page, spans: page.spans.filter((span) => !consumed.has(span)) } : page;
@@ -197,7 +558,7 @@ function vectorText(span, pageHeight, aliases) {
197
558
  if (span.renderingMode === 3 || span.renderingMode === 7) return "";
198
559
  const styles2 = [
199
560
  cssColor(span.color) ? `fill:${span.color}` : "",
200
- unitInterval(span.fillOpacity) ? `fill-opacity:${number(span.fillOpacity)}` : "",
561
+ unitInterval(span.fillOpacity) ? `fill-opacity:${number2(span.fillOpacity)}` : "",
201
562
  ...visualFontStyles(
202
563
  span.fontFamily,
203
564
  span.fontAssetId ? aliases.get(span.fontAssetId) : void 0
@@ -205,33 +566,16 @@ function vectorText(span, pageHeight, aliases) {
205
566
  ].filter(Boolean).join(";");
206
567
  const anchorY = pageHeight - span.bounds.y;
207
568
  const transform = span.transform;
208
- 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)}"`;
569
+ 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)}"`;
209
570
  const extent = span.direction === "ttb" ? span.bounds.height : span.bounds.width;
210
- const length = extent > 0 ? ` textLength="${number(extent)}" lengthAdjust="spacingAndGlyphs"` : "";
211
- return `<text${position} font-size="${number(span.fontSize)}"${length}${styles2 ? ` style="${styles2}"` : ""}>${escapeHtml2(span.text)}</text>`;
571
+ const length = extent > 0 ? ` textLength="${number2(extent)}" lengthAdjust="spacingAndGlyphs"` : "";
572
+ return `<text${position} font-size="${number2(span.fontSize)}"${length}${styles2 ? ` style="${styles2}"` : ""}>${escapeHtml2(span.text)}</text>`;
212
573
  }
213
574
  function centerInside(inner, outer) {
214
575
  const x = inner.x + inner.width / 2;
215
576
  const y = inner.y + inner.height / 2;
216
577
  return x >= outer.x && x <= outer.x + outer.width && y >= outer.y && y <= outer.y + outer.height;
217
578
  }
218
- function vectorFill(fill) {
219
- const points = fill.points.map(([x, y]) => `${number(x)},${number(y)}`).join(" ");
220
- const opacity = unitInterval(fill.opacity) ? ` fill-opacity="${number(fill.opacity)}"` : "";
221
- return cssColor(fill.color) ? `<polygon points="${points}" fill="${fill.color}"${opacity}/>` : "";
222
- }
223
- function vectorPath(path) {
224
- const fill = cssColor(path.fill) ? path.fill : "none";
225
- const stroke = cssColor(path.stroke) ? path.stroke : "none";
226
- const width = finiteNonnegative(path.strokeWidth) ? ` stroke-width="${number(path.strokeWidth)}"` : "";
227
- const fillOpacity = unitInterval(path.fillOpacity) ? ` fill-opacity="${number(path.fillOpacity)}"` : "";
228
- const strokeOpacity = unitInterval(path.strokeOpacity) ? ` stroke-opacity="${number(path.strokeOpacity)}"` : "";
229
- const dash = path.strokeDasharray?.every(finiteNonnegative) ? ` stroke-dasharray="${path.strokeDasharray.map(number).join(" ")}"` : "";
230
- const linecap = path.strokeLinecap ? ` stroke-linecap="${path.strokeLinecap}"` : "";
231
- const linejoin = path.strokeLinejoin ? ` stroke-linejoin="${path.strokeLinejoin}"` : "";
232
- const rule = path.fillRule ? ` fill-rule="${path.fillRule}"` : "";
233
- return `<path d="${path.d}" fill="${fill}" stroke="${stroke}"${width}${fillOpacity}${strokeOpacity}${dash}${linecap}${linejoin}${rule}/>`;
234
- }
235
579
  function transformedUnitBounds([a, b, c, d, e, f]) {
236
580
  const points = [
237
581
  [e, f],
@@ -245,31 +589,8 @@ function transformedUnitBounds([a, b, c, d, e, f]) {
245
589
  const minY = Math.min(...ys);
246
590
  return { x: minX, y: minY, width: Math.max(...xs) - minX, height: Math.max(...ys) - minY };
247
591
  }
248
- function pathBounds(path) {
249
- const values = [...path.matchAll(/[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?/gi)].map(
250
- (match) => Number(match[0])
251
- );
252
- if (values.length < 2) return void 0;
253
- const xs = [];
254
- const ys = [];
255
- for (let index = 0; index + 1 < values.length; index += 2) {
256
- xs.push(values[index] ?? 0);
257
- ys.push(values[index + 1] ?? 0);
258
- }
259
- const minX = Math.min(...xs);
260
- const minY = Math.min(...ys);
261
- return { x: minX, y: minY, width: Math.max(...xs) - minX, height: Math.max(...ys) - minY };
262
- }
263
- function fillBounds(fill) {
264
- if (fill.points.length === 0) return void 0;
265
- const xs = fill.points.map(([x]) => x);
266
- const ys = fill.points.map(([, y]) => y);
267
- const minX = Math.min(...xs);
268
- const minY = Math.min(...ys);
269
- return { x: minX, y: minY, width: Math.max(...xs) - minX, height: Math.max(...ys) - minY };
270
- }
271
- function unionBounds(bounds) {
272
- const values = bounds.filter((value) => Boolean(value));
592
+ function unionBounds(bounds2) {
593
+ const values = bounds2.filter((value) => Boolean(value));
273
594
  if (values.length === 0) return void 0;
274
595
  const x = Math.min(...values.map((value) => value.x));
275
596
  const y = Math.min(...values.map((value) => value.y));
@@ -300,19 +621,16 @@ function rgbBmp(image) {
300
621
  }
301
622
  return output;
302
623
  }
303
- function safePath(value) {
304
- return value.length <= 1e6 && /^[\d\s.,+\-eEMmLlCcZz]+$/.test(value);
305
- }
306
624
  function cssColor(value) {
307
625
  return /^#[\da-f]{6}$/i.test(value ?? "");
308
626
  }
309
- function finiteNonnegative(value) {
627
+ function finiteNonnegative2(value) {
310
628
  return Number.isFinite(value) && (value ?? -1) >= 0;
311
629
  }
312
630
  function unitInterval(value) {
313
- return finiteNonnegative(value) && value <= 1;
631
+ return finiteNonnegative2(value) && value <= 1;
314
632
  }
315
- function number(value) {
633
+ function number2(value) {
316
634
  return Number.isFinite(value) ? String(Math.round(value * 1e3) / 1e3) : "0";
317
635
  }
318
636
  function escapeHtml2(value) {
@@ -364,18 +682,52 @@ async function writeSemanticDocument(pages, write, lookaheadPages) {
364
682
  const emitPage = async (page, future) => {
365
683
  const defaultColor = dominantTextColor(page.structured.lines);
366
684
  let mediaIndex = 0;
685
+ const captions = clearMediaCaptionAssociations(
686
+ page.media,
687
+ page.structured.blocks,
688
+ page.width,
689
+ page.height,
690
+ page.structured.lines
691
+ );
692
+ const captionedMedia = new Set(captions.values());
693
+ const emittedMedia = /* @__PURE__ */ new Set();
367
694
  const futureFurniture = new Set(future.flatMap((candidate) => marginSignatures(candidate)));
368
695
  const repeatedFurniture = /* @__PURE__ */ new Set([...seenFurniture, ...futureFurniture]);
369
696
  for (const [blockIndex, block] of page.structured.blocks.entries()) {
370
697
  const nextBlock = page.structured.blocks[blockIndex + 1];
371
698
  const blockY = semanticBlockY(block);
699
+ let emittedAsCaption = false;
700
+ while (page.media[mediaIndex] && emittedMedia.has(page.media[mediaIndex])) {
701
+ mediaIndex += 1;
702
+ }
372
703
  while ((page.media[mediaIndex]?.bounds.y ?? -Infinity) >= blockY) {
373
704
  await flushPendingParagraph();
374
- const html = `<div class="pdf-semantic-visual">${page.media[mediaIndex]?.html}</div>`;
705
+ const item = page.media[mediaIndex];
706
+ if (item && captions.get(block) === item && block.type === "paragraph") {
707
+ const html2 = `<figure class="pdf-semantic-figure">${item.html}<figcaption>${semanticTextHtml(block.text, block.lines, defaultColor)}</figcaption></figure>`;
708
+ if (activeTable) pendingMedia.push(html2);
709
+ else await write(html2);
710
+ emittedMedia.add(item);
711
+ mediaIndex += 1;
712
+ emittedAsCaption = true;
713
+ break;
714
+ }
715
+ if (item && captionedMedia.has(item)) break;
716
+ const html = `<div class="pdf-semantic-visual">${item?.html}</div>`;
375
717
  if (activeTable) pendingMedia.push(html);
376
718
  else await write(html);
377
719
  mediaIndex += 1;
378
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
+ }
730
+ if (emittedAsCaption) continue;
379
731
  if (isRepeatedFurniture(block, page, repeatedFurniture)) {
380
732
  stats.suppressedFurniture += 1;
381
733
  continue;
@@ -469,10 +821,13 @@ async function writeSemanticDocument(pages, write, lookaheadPages) {
469
821
  await write(semanticBlockHtml(block, defaultColor));
470
822
  }
471
823
  while (mediaIndex < page.media.length) {
472
- await flushPendingParagraph();
473
- const html = `<div class="pdf-semantic-visual">${page.media[mediaIndex]?.html}</div>`;
474
- if (activeTable) pendingMedia.push(html);
475
- 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
+ }
476
831
  mediaIndex += 1;
477
832
  }
478
833
  for (const signature of marginSignatures(page)) seenFurniture.add(signature);
@@ -606,6 +961,14 @@ function financialSummaryRow(entry, columns) {
606
961
  return `<tr><th scope="row"${colspan}>${escapeHtml3(entry.term)}</th><td>${escapeHtml3(entry.description)}</td></tr>`;
607
962
  }
608
963
  function semanticBlockHtml(block, defaultColor = "#000000") {
964
+ if (block.type === "insetGroup") {
965
+ return `<div class="pdf-semantic-inset" style="margin-inline-start:${block.indentEm}em">${block.blocks.map((item) => semanticBlockHtml(item, defaultColor)).join("")}</div>`;
966
+ }
967
+ if (block.type === "table") {
968
+ const rows = (0, import_structure.tableToRows)(block.table);
969
+ const header = tableHeader(rows);
970
+ return `<table>${rows.map((row, index) => tableRow(row, Boolean(header && index === 0))).join("")}</table>`;
971
+ }
609
972
  if (block.type === "heading")
610
973
  return `<h${block.level}>${semanticTextHtml(block.text, block.lines, defaultColor, false)}</h${block.level}>`;
611
974
  if (block.type === "paragraph")
@@ -721,7 +1084,7 @@ async function writePositionedPage(page, write, options) {
721
1084
  const displayWidth = quarterTurn ? page.height : page.width;
722
1085
  const displayHeight = quarterTurn ? page.width : page.height;
723
1086
  await write(
724
- `<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">`
1087
+ `<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">`
725
1088
  );
726
1089
  const fontAliases = visualFontAliases(page.number, page.fonts ?? []);
727
1090
  const type3Fonts = new Map(
@@ -733,44 +1096,26 @@ async function writePositionedPage(page, write, options) {
733
1096
  );
734
1097
  }
735
1098
  await write(
736
- `<div class="pdf-page-content pdf-page-content--${page.rotate}" style="width:${number2(page.width)}pt;height:${number2(page.height)}pt${rotationTransform(page)}">`
1099
+ `<div class="pdf-page-content pdf-page-content--${page.rotate}" style="width:${number3(page.width)}pt;height:${number3(page.height)}pt${rotationTransform(page)}">`
737
1100
  );
738
1101
  await write(
739
- `<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)}">`
1102
+ `<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)}">`
1103
+ );
1104
+ const clipDefinitions = imageClipDefinitions(page.images ?? [], page.number, page.height) + vectorPathClipDefinitions(
1105
+ (page.paths ?? []).map((path, index) => ({ path, index })),
1106
+ page.number
740
1107
  );
741
- const clipDefinitions = imageClipDefinitions(page.images ?? [], page.number, page.height) + pathClipDefinitions(page.paths ?? [], page.number);
742
1108
  if (clipDefinitions) await write(`<defs>${clipDefinitions}</defs>`);
743
1109
  if (reflectedOverlay) {
744
1110
  for (const [index, image] of (page.images ?? []).entries()) {
745
1111
  await write(visualImage(image, page.height, page.number, index));
746
1112
  }
747
1113
  }
748
- for (const fill of page.fills ?? []) {
749
- const points = fill.points.map(([x, y]) => `${number2(x)},${number2(page.height - y)}`).join(" ");
750
- if (isCssHexColor(fill.color)) {
751
- const opacity = isUnitInterval(fill.opacity) ? ` fill-opacity="${number2(fill.opacity)}"` : "";
752
- await write(`<polygon points="${points}" fill="${fill.color}"${opacity}/>`);
753
- }
754
- }
755
- if (page.paths?.length) {
756
- await write(`<g transform="translate(0 ${number2(page.height)}) scale(1 -1)">`);
757
- for (const [pathIndex, path] of page.paths.entries()) {
758
- if (!isSvgPath(path.d)) continue;
759
- const fill = isCssHexColor(path.fill) ? path.fill : "none";
760
- const stroke = isCssHexColor(path.stroke) ? path.stroke : "none";
761
- const strokeWidth = path.strokeWidth !== void 0 && Number.isFinite(path.strokeWidth) && path.strokeWidth >= 0 ? ` stroke-width="${number2(path.strokeWidth)}"` : "";
762
- const fillRule = path.fillRule ? ` fill-rule="${path.fillRule}"` : "";
763
- const fillOpacity = isUnitInterval(path.fillOpacity) ? ` fill-opacity="${number2(path.fillOpacity)}"` : "";
764
- const strokeOpacity = isUnitInterval(path.strokeOpacity) ? ` stroke-opacity="${number2(path.strokeOpacity)}"` : "";
765
- const dasharray = path.strokeDasharray?.every((value) => Number.isFinite(value) && value >= 0) ? ` stroke-dasharray="${path.strokeDasharray.map(number2).join(" ")}"` : "";
766
- const dashoffset = Number.isFinite(path.strokeDashoffset) ? ` stroke-dashoffset="${number2(path.strokeDashoffset ?? 0)}"` : "";
767
- const linecap = path.strokeLinecap ? ` stroke-linecap="${path.strokeLinecap}"` : "";
768
- const linejoin = path.strokeLinejoin ? ` stroke-linejoin="${path.strokeLinejoin}"` : "";
769
- let output = `<path d="${path.d}" fill="${fill}" stroke="${stroke}"${strokeWidth}${fillOpacity}${strokeOpacity}${dasharray}${dashoffset}${linecap}${linejoin}${fillRule}/>`;
770
- for (let index = (path.clips?.length ?? 0) - 1; index >= 0; index -= 1) {
771
- output = `<g clip-path="url(#${pathClipId(page.number, pathIndex, index)})">${output}</g>`;
772
- }
773
- await write(output);
1114
+ if (page.fills?.length || page.paths?.length) {
1115
+ await write(`<g transform="translate(0 ${number3(page.height)}) scale(1 -1)">`);
1116
+ for (const fill of page.fills ?? []) await write(vectorFillSvg(fill));
1117
+ for (const [pathIndex, path] of (page.paths ?? []).entries()) {
1118
+ await write(vectorPathSvg(path, page.number, pathIndex));
774
1119
  }
775
1120
  await write("</g>");
776
1121
  }
@@ -800,8 +1145,8 @@ function usesReflectedVisualOverlay(page, spans) {
800
1145
  }
801
1146
  function visualImage(image, pageHeight, pageNumber, imageIndex) {
802
1147
  const [a, b, c, d, e, f] = image.transform;
803
- const transform = [a, -b, -c, d, c + e, pageHeight - d - f].map(number2).join(" ");
804
- const opacity = isUnitInterval(image.opacity) ? ` opacity="${number2(image.opacity)}"` : "";
1148
+ const transform = [a, -b, -c, d, c + e, pageHeight - d - f].map(number3).join(" ");
1149
+ const opacity = isUnitInterval2(image.opacity) ? ` opacity="${number3(image.opacity)}"` : "";
805
1150
  const mime = image.format === "jpeg" ? "image/jpeg" : "image/bmp";
806
1151
  const data = image.format === "jpeg" ? image.data : rgbBmp2(image);
807
1152
  let output = `<image width="1" height="1" preserveAspectRatio="none" transform="matrix(${transform})" href="data:${mime};base64,${base64(data)}"${opacity}/>`;
@@ -815,25 +1160,13 @@ function imageClipDefinitions(images, pageNumber, pageHeight) {
815
1160
  (image, imageIndex) => (image.clips ?? []).map((clip, clipIndex) => {
816
1161
  if (!isSvgPath(clip.d)) return "";
817
1162
  const fillRule = clip.fillRule ? ` clip-rule="${clip.fillRule}"` : "";
818
- return `<clipPath id="${imageClipId(pageNumber, imageIndex, clipIndex)}" clipPathUnits="userSpaceOnUse"><path d="${clip.d}" transform="translate(0 ${number2(pageHeight)}) scale(1 -1)"${fillRule}/></clipPath>`;
1163
+ return `<clipPath id="${imageClipId(pageNumber, imageIndex, clipIndex)}" clipPathUnits="userSpaceOnUse"><path d="${clip.d}" transform="translate(0 ${number3(pageHeight)}) scale(1 -1)"${fillRule}/></clipPath>`;
819
1164
  })
820
1165
  ).join("");
821
1166
  }
822
1167
  function imageClipId(pageNumber, imageIndex, clipIndex) {
823
1168
  return `boxpdf-clip-${pageNumber}-${imageIndex}-${clipIndex}`;
824
1169
  }
825
- function pathClipDefinitions(paths, pageNumber) {
826
- return paths.flatMap(
827
- (path, pathIndex) => (path.clips ?? []).map((clip, clipIndex) => {
828
- if (!isSvgPath(clip.d)) return "";
829
- const fillRule = clip.fillRule ? ` clip-rule="${clip.fillRule}"` : "";
830
- return `<clipPath id="${pathClipId(pageNumber, pathIndex, clipIndex)}" clipPathUnits="userSpaceOnUse"><path d="${clip.d}"${fillRule}/></clipPath>`;
831
- })
832
- ).join("");
833
- }
834
- function pathClipId(pageNumber, pathIndex, clipIndex) {
835
- return `boxpdf-path-clip-${pageNumber}-${pathIndex}-${clipIndex}`;
836
- }
837
1170
  function rgbBmp2(image) {
838
1171
  const stride = Math.ceil(image.width * 3 / 4) * 4;
839
1172
  const output = new Uint8Array(54 + stride * image.height);
@@ -862,11 +1195,11 @@ function rgbBmp2(image) {
862
1195
  function rotationTransform(page) {
863
1196
  switch (page.rotate) {
864
1197
  case 90:
865
- return `;transform:translate(${number2(page.height)}pt,0) rotate(90deg)`;
1198
+ return `;transform:translate(${number3(page.height)}pt,0) rotate(90deg)`;
866
1199
  case 180:
867
- return `;transform:translate(${number2(page.width)}pt,${number2(page.height)}pt) rotate(180deg)`;
1200
+ return `;transform:translate(${number3(page.width)}pt,${number3(page.height)}pt) rotate(180deg)`;
868
1201
  case 270:
869
- return `;transform:translate(0,${number2(page.width)}pt) rotate(270deg)`;
1202
+ return `;transform:translate(0,${number3(page.width)}pt) rotate(270deg)`;
870
1203
  default:
871
1204
  return "";
872
1205
  }
@@ -874,13 +1207,13 @@ function rotationTransform(page) {
874
1207
  function positionedSpan(span, fontAliases) {
875
1208
  const direction = directionAttribute([span]);
876
1209
  const style = [
877
- `left:${number2(span.bounds.x)}pt`,
878
- `bottom:${number2(span.bounds.y)}pt`,
879
- `width:${number2(span.bounds.width)}pt`,
880
- `height:${number2(span.bounds.height)}pt`,
881
- `font-size:${number2(span.fontSize)}pt`,
882
- ...isCssHexColor(span.color) ? [`color:${span.color}`] : [],
883
- ...isUnitInterval(span.fillOpacity) ? [`opacity:${number2(span.fillOpacity)}`] : [],
1210
+ `left:${number3(span.bounds.x)}pt`,
1211
+ `bottom:${number3(span.bounds.y)}pt`,
1212
+ `width:${number3(span.bounds.width)}pt`,
1213
+ `height:${number3(span.bounds.height)}pt`,
1214
+ `font-size:${number3(span.fontSize)}pt`,
1215
+ ...isCssHexColor2(span.color) ? [`color:${span.color}`] : [],
1216
+ ...isUnitInterval2(span.fillOpacity) ? [`opacity:${number3(span.fillOpacity)}`] : [],
884
1217
  ...visualFontStyles(
885
1218
  span.fontFamily,
886
1219
  span.fontAssetId ? fontAliases.get(span.fontAssetId) : void 0
@@ -893,15 +1226,47 @@ async function writeFlowPage(page, write) {
893
1226
  const structured = (0, import_structure2.structurePage)(withoutSemanticMediaSpans(page, media));
894
1227
  const defaultColor = dominantTextColor(structured.lines);
895
1228
  let mediaIndex = 0;
1229
+ const captions = clearMediaCaptionAssociations(
1230
+ media,
1231
+ structured.blocks,
1232
+ page.width,
1233
+ page.height,
1234
+ structured.lines
1235
+ );
1236
+ const captionedMedia = new Set(captions.values());
1237
+ const emittedMedia = /* @__PURE__ */ new Set();
896
1238
  await write(
897
1239
  `<section class="pdf-page pdf-page--semantic pdf-page--flow" data-page="${page.number}">`
898
1240
  );
899
1241
  for (const block of structured.blocks) {
900
1242
  const blockY = semanticBlockY2(block);
1243
+ let emittedAsCaption = false;
1244
+ while (media[mediaIndex] && emittedMedia.has(media[mediaIndex]))
1245
+ mediaIndex += 1;
901
1246
  while ((media[mediaIndex]?.bounds.y ?? -Infinity) >= blockY) {
902
- await write(`<div class="pdf-semantic-visual">${media[mediaIndex]?.html}</div>`);
1247
+ const item = media[mediaIndex];
1248
+ if (item && captions.get(block) === item && block.type === "paragraph") {
1249
+ await write(
1250
+ `<figure class="pdf-semantic-figure">${item.html}<figcaption>${semanticTextHtml(block.text, block.lines, defaultColor)}</figcaption></figure>`
1251
+ );
1252
+ emittedMedia.add(item);
1253
+ mediaIndex += 1;
1254
+ emittedAsCaption = true;
1255
+ break;
1256
+ }
1257
+ if (item && captionedMedia.has(item)) break;
1258
+ await write(`<div class="pdf-semantic-visual">${item?.html}</div>`);
903
1259
  mediaIndex += 1;
904
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
+ }
1269
+ if (emittedAsCaption) continue;
905
1270
  if (block.type === "table") await write((0, import_structure2.tableToHtml)(block.table));
906
1271
  else if (block.type === "heading") {
907
1272
  await write(
@@ -941,6 +1306,10 @@ async function writeFlowPage(page, write) {
941
1306
  await write(
942
1307
  `<section><h3>${escapeHtml4(block.role)}</h3><p>${escapeHtml4(block.organization)}</p><p>${escapeHtml4(block.date)}</p></section>`
943
1308
  );
1309
+ } else if (block.type === "insetGroup") {
1310
+ await write(
1311
+ `<div class="pdf-semantic-inset" style="margin-inline-start:${number3(block.indentEm)}em">${block.blocks.map((item) => nestedSemanticBlockHtml(item, defaultColor)).join("")}</div>`
1312
+ );
944
1313
  } else {
945
1314
  const tag = block.ordered ? "ol" : "ul";
946
1315
  await write(`<${tag}>`);
@@ -951,11 +1320,41 @@ async function writeFlowPage(page, write) {
951
1320
  }
952
1321
  }
953
1322
  while (mediaIndex < media.length) {
954
- 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
+ }
955
1327
  mediaIndex += 1;
956
1328
  }
957
1329
  await write("</section>");
958
1330
  }
1331
+ function nestedSemanticBlockHtml(block, defaultColor) {
1332
+ if (block.type === "insetGroup") {
1333
+ return `<div class="pdf-semantic-inset" style="margin-inline-start:${number3(block.indentEm)}em">${block.blocks.map((item) => nestedSemanticBlockHtml(item, defaultColor)).join("")}</div>`;
1334
+ }
1335
+ if (block.type === "table") return (0, import_structure2.tableToHtml)(block.table);
1336
+ if (block.type === "heading") {
1337
+ return `<h${block.level}>${semanticTextHtml(block.text, block.lines, defaultColor, false)}</h${block.level}>`;
1338
+ }
1339
+ if (block.type === "paragraph") {
1340
+ return `<p>${semanticTextHtml(block.text, block.lines, defaultColor)}</p>`;
1341
+ }
1342
+ if (block.type === "preformatted") return `<pre>${escapeHtml4(block.text)}</pre>`;
1343
+ if (block.type === "definitionList") {
1344
+ return `<dl>${block.entries.map((entry) => `<div><dt>${escapeHtml4(entry.term)}</dt><dd>${escapeHtml4(entry.description)}</dd></div>`).join("")}</dl>`;
1345
+ }
1346
+ if (block.type === "cardList") {
1347
+ 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>`;
1348
+ }
1349
+ if (block.type === "sectionGroup") {
1350
+ 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>`;
1351
+ }
1352
+ if (block.type === "employment") {
1353
+ return `<section><h3>${escapeHtml4(block.role)}</h3><p>${escapeHtml4(block.organization)}</p><p>${escapeHtml4(block.date)}</p></section>`;
1354
+ }
1355
+ const tag = block.ordered ? "ol" : "ul";
1356
+ return `<${tag}>${block.items.map((item) => `<li>${semanticTextHtml(item.text, item.lines, defaultColor)}</li>`).join("")}</${tag}>`;
1357
+ }
959
1358
  function semanticBlockY2(block) {
960
1359
  const lines = block.type === "list" ? block.items.flatMap((item) => item.lines) : block.lines;
961
1360
  return Math.max(...lines.map((line) => line.bounds.y + line.bounds.height));
@@ -968,15 +1367,15 @@ function visualText(span, pageHeight, fontAliases, counterRotateReflectedText =
968
1367
  span.fontFamily,
969
1368
  span.fontAssetId ? fontAliases.get(span.fontAssetId) : void 0
970
1369
  ).join(";");
971
- const stroke = isCssHexColor(span.strokeColor) ? `stroke:${span.strokeColor}` : "";
972
- const strokeWidth = stroke && Number.isFinite(span.strokeWidth) && (span.strokeWidth ?? -1) >= 0 ? `stroke-width:${number2(span.strokeWidth ?? 0)}` : "";
1370
+ const stroke = isCssHexColor2(span.strokeColor) ? `stroke:${span.strokeColor}` : "";
1371
+ const strokeWidth = stroke && Number.isFinite(span.strokeWidth) && (span.strokeWidth ?? -1) >= 0 ? `stroke-width:${number3(span.strokeWidth ?? 0)}` : "";
973
1372
  const strokeOnly = span.renderingMode === 1 || span.renderingMode === 5;
974
- const fillOpacity = isUnitInterval(span.fillOpacity) ? `fill-opacity:${number2(span.fillOpacity)}` : "";
975
- const strokeOpacity = isUnitInterval(span.strokeOpacity) ? `stroke-opacity:${number2(span.strokeOpacity)}` : "";
1373
+ const fillOpacity = isUnitInterval2(span.fillOpacity) ? `fill-opacity:${number3(span.fillOpacity)}` : "";
1374
+ const strokeOpacity = isUnitInterval2(span.strokeOpacity) ? `stroke-opacity:${number3(span.strokeOpacity)}` : "";
976
1375
  const style = [
977
1376
  isHebrewPaintOrder(span) ? "unicode-bidi:bidi-override;direction:ltr" : "",
978
1377
  span.direction === "ttb" ? "writing-mode:vertical-rl" : "",
979
- strokeOnly ? "fill:none" : isCssHexColor(span.color) ? `fill:${span.color}` : "",
1378
+ strokeOnly ? "fill:none" : isCssHexColor2(span.color) ? `fill:${span.color}` : "",
980
1379
  stroke,
981
1380
  strokeWidth,
982
1381
  fillOpacity,
@@ -984,7 +1383,7 @@ function visualText(span, pageHeight, fontAliases, counterRotateReflectedText =
984
1383
  font
985
1384
  ].filter(Boolean).join(";");
986
1385
  const textExtent = span.direction === "ttb" ? span.bounds.height : span.bounds.width;
987
- const textLength = textExtent > 0 && !isHebrewPaintOrder(span) ? ` textLength="${number2(textExtent)}" lengthAdjust="${span.direction === "ttb" || usesSpacingAdjustment(span) ? "spacing" : "spacingAndGlyphs"}"` : "";
1386
+ const textLength = textExtent > 0 && !isHebrewPaintOrder(span) ? ` textLength="${number3(textExtent)}" lengthAdjust="${span.direction === "ttb" || usesSpacingAdjustment(span) ? "spacing" : "spacingAndGlyphs"}"` : "";
988
1387
  const transform = counterRotateReflectedText && span.transform ? [
989
1388
  span.transform[0],
990
1389
  span.transform[1],
@@ -997,8 +1396,8 @@ function visualText(span, pageHeight, fontAliases, counterRotateReflectedText =
997
1396
  const basisY = transform?.[1] ?? 0;
998
1397
  const anchorX = span.bounds.x + basisX * rtlOffset;
999
1398
  const anchorY = pageHeight - span.bounds.y + basisY * rtlOffset;
1000
- const position = transformed ? ` x="0" y="0" transform="matrix(${transform?.map(number2).join(" ")} ${number2(anchorX)} ${number2(anchorY)})"` : ` x="${number2(anchorX)}" y="${number2(anchorY)}"`;
1001
- return `<text${direction}${position} font-size="${number2(span.fontSize)}"${textLength}${style ? ` style="${style}"` : ""}>${escapeHtml4(span.text)}</text>`;
1399
+ const position = transformed ? ` x="0" y="0" transform="matrix(${transform?.map(number3).join(" ")} ${number3(anchorX)} ${number3(anchorY)})"` : ` x="${number3(anchorX)}" y="${number3(anchorY)}"`;
1400
+ return `<text${direction}${position} font-size="${number3(span.fontSize)}"${textLength}${style ? ` style="${style}"` : ""}>${escapeHtml4(span.text)}</text>`;
1002
1401
  }
1003
1402
  function isAdobeCjkFont(fontFamily) {
1004
1403
  return /^Adobe(?:Heiti|Song|Kaiti|Ming|Gothic|Mincho)Std-/i.test(fontFamily ?? "");
@@ -1010,16 +1409,16 @@ function visualType3Text(span, font, pageHeight) {
1010
1409
  const totalAdvance = sequence.reduce((total, glyph) => total + (glyph?.advance ?? 0), 0);
1011
1410
  if (totalAdvance <= 0 || span.bounds.width <= 0 || span.fontSize <= 0) return "";
1012
1411
  const transform = span.transform ?? [1, 0, 0, 1];
1013
- const outer = `matrix(${transform.map(number2).join(" ")} ${number2(span.bounds.x)} ${number2(pageHeight - span.bounds.y)})`;
1412
+ const outer = `matrix(${transform.map(number3).join(" ")} ${number3(span.bounds.x)} ${number3(pageHeight - span.bounds.y)})`;
1014
1413
  const xScale = span.bounds.width / totalAdvance;
1015
1414
  let offset = 0;
1016
1415
  let content = "";
1017
1416
  for (const glyph of sequence) {
1018
1417
  if (!glyph) continue;
1019
- content += `<g transform="translate(${number2(offset)} 0)">${type3Glyph(glyph, span.color)}</g>`;
1418
+ content += `<g transform="translate(${number3(offset)} 0)">${type3Glyph(glyph, span.color)}</g>`;
1020
1419
  offset += glyph.advance;
1021
1420
  }
1022
- return `<g transform="${outer}"><g transform="scale(${number2(xScale)} ${number2(-span.fontSize)})">${content}</g></g>`;
1421
+ return `<g transform="${outer}"><g transform="scale(${number3(xScale)} ${number3(-span.fontSize)})">${content}</g></g>`;
1023
1422
  }
1024
1423
  function isHebrewPaintOrder(span) {
1025
1424
  return span.direction === "ltr" && /[\u0590-\u05ff]/u.test(span.text);
@@ -1030,30 +1429,27 @@ function usesSpacingAdjustment(span) {
1030
1429
  function type3Glyph(glyph, textColor) {
1031
1430
  let output = "";
1032
1431
  for (const fill of glyph.fills ?? []) {
1033
- const color = glyph.usesTextColor && isCssHexColor(textColor) ? textColor : fill.color;
1034
- if (!isCssHexColor(color)) continue;
1035
- const points = fill.points.map(([x, y]) => `${number2(x)},${number2(y)}`).join(" ");
1036
- const opacity = isUnitInterval(fill.opacity) ? ` fill-opacity="${number2(fill.opacity)}"` : "";
1432
+ const color = glyph.usesTextColor && isCssHexColor2(textColor) ? textColor : fill.color;
1433
+ if (!isCssHexColor2(color)) continue;
1434
+ const points = fill.points.map(([x, y]) => `${number3(x)},${number3(y)}`).join(" ");
1435
+ const opacity = isUnitInterval2(fill.opacity) ? ` fill-opacity="${number3(fill.opacity)}"` : "";
1037
1436
  output += `<polygon points="${points}" fill="${color}"${opacity}/>`;
1038
1437
  }
1039
1438
  for (const path of glyph.paths ?? []) {
1040
1439
  if (!isSvgPath(path.d)) continue;
1041
- const fill = glyph.usesTextColor && isCssHexColor(textColor) ? textColor : isCssHexColor(path.fill) ? path.fill : "none";
1042
- const stroke = glyph.usesTextColor && isCssHexColor(textColor) && path.stroke ? textColor : isCssHexColor(path.stroke) ? path.stroke : "none";
1043
- const width = path.strokeWidth !== void 0 && Number.isFinite(path.strokeWidth) && path.strokeWidth >= 0 ? ` stroke-width="${number2(path.strokeWidth)}"` : "";
1440
+ const fill = glyph.usesTextColor && isCssHexColor2(textColor) ? textColor : isCssHexColor2(path.fill) ? path.fill : "none";
1441
+ const stroke = glyph.usesTextColor && isCssHexColor2(textColor) && path.stroke ? textColor : isCssHexColor2(path.stroke) ? path.stroke : "none";
1442
+ const width = path.strokeWidth !== void 0 && Number.isFinite(path.strokeWidth) && path.strokeWidth >= 0 ? ` stroke-width="${number3(path.strokeWidth)}"` : "";
1044
1443
  output += `<path d="${path.d}" fill="${fill}" stroke="${stroke}"${width}/>`;
1045
1444
  }
1046
1445
  return (glyph.fills?.length ?? 0) > 64 && glyph.advance > 2 ? `<g shape-rendering="crispEdges">${output}</g>` : output;
1047
1446
  }
1048
- function isCssHexColor(value) {
1447
+ function isCssHexColor2(value) {
1049
1448
  return /^#[\da-f]{6}$/i.test(value ?? "");
1050
1449
  }
1051
- function isUnitInterval(value) {
1450
+ function isUnitInterval2(value) {
1052
1451
  return Number.isFinite(value) && (value ?? -1) >= 0 && (value ?? 2) <= 1;
1053
1452
  }
1054
- function isSvgPath(value) {
1055
- return value.length <= 1e6 && /^[\d\s.,+\-eEMmLlCcZz]+$/.test(value);
1056
- }
1057
1453
  function isMonospace(fontFamily) {
1058
1454
  return /courier|mono/i.test(fontFamily ?? "");
1059
1455
  }
@@ -1071,7 +1467,7 @@ function directionAttribute(spans) {
1071
1467
  if (vertical > rtl && vertical * 2 >= spans.length) return ' data-direction="ttb"';
1072
1468
  return rtl * 2 >= spans.length && spans.length > 0 ? ' dir="rtl"' : "";
1073
1469
  }
1074
- function number2(value) {
1470
+ function number3(value) {
1075
1471
  return Number.isFinite(value) ? String(Math.round(value * 1e3) / 1e3) : "0";
1076
1472
  }
1077
1473
  function escapeAttribute(value) {