postext-pdf 0.1.0

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.
Files changed (119) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +67 -0
  3. package/dist/__bench__/render.bench.test.d.ts +2 -0
  4. package/dist/__bench__/render.bench.test.d.ts.map +1 -0
  5. package/dist/__bench__/render.bench.test.js +86 -0
  6. package/dist/__bench__/render.bench.test.js.map +1 -0
  7. package/dist/__tests__/accessibility.test.d.ts +2 -0
  8. package/dist/__tests__/accessibility.test.d.ts.map +1 -0
  9. package/dist/__tests__/accessibility.test.js +364 -0
  10. package/dist/__tests__/accessibility.test.js.map +1 -0
  11. package/dist/__tests__/contentsLinks.test.d.ts +2 -0
  12. package/dist/__tests__/contentsLinks.test.d.ts.map +1 -0
  13. package/dist/__tests__/contentsLinks.test.js +131 -0
  14. package/dist/__tests__/contentsLinks.test.js.map +1 -0
  15. package/dist/__tests__/fontCache.test.d.ts +2 -0
  16. package/dist/__tests__/fontCache.test.d.ts.map +1 -0
  17. package/dist/__tests__/fontCache.test.js +42 -0
  18. package/dist/__tests__/fontCache.test.js.map +1 -0
  19. package/dist/__tests__/headerFooter.test.d.ts +2 -0
  20. package/dist/__tests__/headerFooter.test.d.ts.map +1 -0
  21. package/dist/__tests__/headerFooter.test.js +68 -0
  22. package/dist/__tests__/headerFooter.test.js.map +1 -0
  23. package/dist/__tests__/rotatedTable.test.d.ts +2 -0
  24. package/dist/__tests__/rotatedTable.test.d.ts.map +1 -0
  25. package/dist/__tests__/rotatedTable.test.js +112 -0
  26. package/dist/__tests__/rotatedTable.test.js.map +1 -0
  27. package/dist/__tests__/svgVector.test.d.ts +2 -0
  28. package/dist/__tests__/svgVector.test.d.ts.map +1 -0
  29. package/dist/__tests__/svgVector.test.js +322 -0
  30. package/dist/__tests__/svgVector.test.js.map +1 -0
  31. package/dist/__tests__/toUnicode.test.d.ts +2 -0
  32. package/dist/__tests__/toUnicode.test.d.ts.map +1 -0
  33. package/dist/__tests__/toUnicode.test.js +57 -0
  34. package/dist/__tests__/toUnicode.test.js.map +1 -0
  35. package/dist/colors.d.ts +23 -0
  36. package/dist/colors.d.ts.map +1 -0
  37. package/dist/colors.js +47 -0
  38. package/dist/colors.js.map +1 -0
  39. package/dist/fontCache.d.ts +41 -0
  40. package/dist/fontCache.d.ts.map +1 -0
  41. package/dist/fontCache.js +154 -0
  42. package/dist/fontCache.js.map +1 -0
  43. package/dist/fontString.d.ts +14 -0
  44. package/dist/fontString.d.ts.map +1 -0
  45. package/dist/fontString.js +43 -0
  46. package/dist/fontString.js.map +1 -0
  47. package/dist/index.d.ts +6 -0
  48. package/dist/index.d.ts.map +1 -0
  49. package/dist/index.js +5 -0
  50. package/dist/index.js.map +1 -0
  51. package/dist/pdf-backend/blockRender.d.ts +15 -0
  52. package/dist/pdf-backend/blockRender.d.ts.map +1 -0
  53. package/dist/pdf-backend/blockRender.js +244 -0
  54. package/dist/pdf-backend/blockRender.js.map +1 -0
  55. package/dist/pdf-backend/fontHelpers.d.ts +6 -0
  56. package/dist/pdf-backend/fontHelpers.d.ts.map +1 -0
  57. package/dist/pdf-backend/fontHelpers.js +81 -0
  58. package/dist/pdf-backend/fontHelpers.js.map +1 -0
  59. package/dist/pdf-backend/headerFooter.d.ts +15 -0
  60. package/dist/pdf-backend/headerFooter.d.ts.map +1 -0
  61. package/dist/pdf-backend/headerFooter.js +145 -0
  62. package/dist/pdf-backend/headerFooter.js.map +1 -0
  63. package/dist/pdf-backend/index.d.ts +47 -0
  64. package/dist/pdf-backend/index.d.ts.map +1 -0
  65. package/dist/pdf-backend/index.js +227 -0
  66. package/dist/pdf-backend/index.js.map +1 -0
  67. package/dist/pdf-backend/links.d.ts +44 -0
  68. package/dist/pdf-backend/links.d.ts.map +1 -0
  69. package/dist/pdf-backend/links.js +110 -0
  70. package/dist/pdf-backend/links.js.map +1 -0
  71. package/dist/pdf-backend/outlines.d.ts +8 -0
  72. package/dist/pdf-backend/outlines.d.ts.map +1 -0
  73. package/dist/pdf-backend/outlines.js +145 -0
  74. package/dist/pdf-backend/outlines.js.map +1 -0
  75. package/dist/pdf-backend/pageDecorations.d.ts +13 -0
  76. package/dist/pdf-backend/pageDecorations.d.ts.map +1 -0
  77. package/dist/pdf-backend/pageDecorations.js +88 -0
  78. package/dist/pdf-backend/pageDecorations.js.map +1 -0
  79. package/dist/pdf-backend/pageLabels.d.ts +8 -0
  80. package/dist/pdf-backend/pageLabels.d.ts.map +1 -0
  81. package/dist/pdf-backend/pageLabels.js +61 -0
  82. package/dist/pdf-backend/pageLabels.js.map +1 -0
  83. package/dist/pdf-backend/primitives.d.ts +46 -0
  84. package/dist/pdf-backend/primitives.d.ts.map +1 -0
  85. package/dist/pdf-backend/primitives.js +162 -0
  86. package/dist/pdf-backend/primitives.js.map +1 -0
  87. package/dist/pdf-backend/renderResourceBlock.d.ts +87 -0
  88. package/dist/pdf-backend/renderResourceBlock.d.ts.map +1 -0
  89. package/dist/pdf-backend/renderResourceBlock.js +753 -0
  90. package/dist/pdf-backend/renderResourceBlock.js.map +1 -0
  91. package/dist/pdf-backend/structureFlow.d.ts +55 -0
  92. package/dist/pdf-backend/structureFlow.d.ts.map +1 -0
  93. package/dist/pdf-backend/structureFlow.js +180 -0
  94. package/dist/pdf-backend/structureFlow.js.map +1 -0
  95. package/dist/pdf-backend/svgVector.d.ts +144 -0
  96. package/dist/pdf-backend/svgVector.d.ts.map +1 -0
  97. package/dist/pdf-backend/svgVector.js +1455 -0
  98. package/dist/pdf-backend/svgVector.js.map +1 -0
  99. package/dist/pdf-backend/tagging.d.ts +136 -0
  100. package/dist/pdf-backend/tagging.d.ts.map +1 -0
  101. package/dist/pdf-backend/tagging.js +296 -0
  102. package/dist/pdf-backend/tagging.js.map +1 -0
  103. package/dist/woff2.d.ts +11 -0
  104. package/dist/woff2.d.ts.map +1 -0
  105. package/dist/woff2.js +47 -0
  106. package/dist/woff2.js.map +1 -0
  107. package/dist/worker/client.d.ts +30 -0
  108. package/dist/worker/client.d.ts.map +1 -0
  109. package/dist/worker/client.js +112 -0
  110. package/dist/worker/client.js.map +1 -0
  111. package/dist/worker/pdf.worker.d.ts +2 -0
  112. package/dist/worker/pdf.worker.d.ts.map +1 -0
  113. package/dist/worker/pdf.worker.js +67 -0
  114. package/dist/worker/pdf.worker.js.map +1 -0
  115. package/dist/worker/protocol.d.ts +49 -0
  116. package/dist/worker/protocol.d.ts.map +1 -0
  117. package/dist/worker/protocol.js +2 -0
  118. package/dist/worker/protocol.js.map +1 -0
  119. package/package.json +67 -0
@@ -0,0 +1,753 @@
1
+ /**
2
+ * PDF rendering of resource blocks (issue #49 §7).
3
+ *
4
+ * Draws an embedded `Resource` (bitmap / svg / table) plus its caption into a
5
+ * pdf-lib page. Image payloads live out-of-band (IndexedDB in the sandbox); the
6
+ * host supplies their raw bytes via {@link RenderToPdfOptions.resourceBytes},
7
+ * which {@link preloadResourceImages} embeds into the document up front (PDF
8
+ * embedding is async; page rendering is sync).
9
+ *
10
+ * The bytes are sniffed, so the host never has to say what it hands over:
11
+ * - `%PDF` → the first page is embedded as a form XObject and drawn
12
+ * verbatim (vector print masters, `Resource.svg.pdfFileId`);
13
+ * - SVG → single-ink recolouring when `diagramStyle.singleInk` is on,
14
+ * then vector emission through {@link svgToVectorDrawing}, with
15
+ * `text` set in fonts loaded through the document's
16
+ * {@link FontCache} (same provider as the body text); SVGs
17
+ * outside the supported subset are rasterised (browser only) at
18
+ * {@link RASTER_DPI} for their largest placement;
19
+ * - PNG / JPEG / WebP / GIF → `embedPng` / `embedJpg` (WebP is decoded to
20
+ * PNG via a canvas first; browser only).
21
+ * A missing or undecodable payload leaves the map entry absent and the
22
+ * renderer draws a neutral placeholder.
23
+ *
24
+ * Inline `:ref` segments render in the link colour and, via {@link LinkRegistry},
25
+ * become clickable link annotations targeting the resource embed's destination.
26
+ */
27
+ import { PDFHexString } from 'pdf-lib';
28
+ import { applySingleInkToSvg, resolveColorValue } from 'postext';
29
+ import { parseFontString } from '../fontString';
30
+ import { drawTextPx, drawLinePx, drawSwatchPx, fillRectPx, colorFromHex, mapRectThrough, pushTransform, popTransform, } from './primitives';
31
+ import { tagArtifact, tagContent } from './tagging';
32
+ import { svgToVectorDrawing, drawVectorDrawing, } from './svgVector';
33
+ /** Resolution of the raster fallback for SVGs the vector path cannot emit,
34
+ * relative to the figure's largest placement on the page. Diagrams are line
35
+ * art with small type, so this sits above the 300 dpi photographs need. */
36
+ export const RASTER_DPI = 600;
37
+ /** Identify a payload from its leading bytes. */
38
+ export function sniffBytes(bytes) {
39
+ const at = (i) => bytes[i] ?? -1;
40
+ if (at(0) === 0x25 && at(1) === 0x50 && at(2) === 0x44 && at(3) === 0x46)
41
+ return 'pdf'; // %PDF
42
+ if (at(0) === 0x89 && at(1) === 0x50 && at(2) === 0x4e && at(3) === 0x47)
43
+ return 'png';
44
+ if (at(0) === 0xff && at(1) === 0xd8)
45
+ return 'jpeg';
46
+ if (at(0) === 0x47 && at(1) === 0x49 && at(2) === 0x46 && at(3) === 0x38)
47
+ return 'gif';
48
+ if (at(0) === 0x52 && at(1) === 0x49 && at(2) === 0x46 && at(3) === 0x46
49
+ && at(8) === 0x57 && at(9) === 0x45 && at(10) === 0x42 && at(11) === 0x50)
50
+ return 'webp';
51
+ // SVG: optional BOM / whitespace, then `<` (XML declaration, comment,
52
+ // doctype or the root element itself).
53
+ let i = 0;
54
+ if (at(0) === 0xef && at(1) === 0xbb && at(2) === 0xbf)
55
+ i = 3;
56
+ while (i < bytes.length && (at(i) === 0x20 || at(i) === 0x09 || at(i) === 0x0a || at(i) === 0x0d))
57
+ i++;
58
+ if (at(i) === 0x3c) {
59
+ const head = new TextDecoder().decode(bytes.subarray(i, Math.min(bytes.length, i + 4096)));
60
+ if (/<svg[\s>]/i.test(head) || /^<\?xml/i.test(head) || head.startsWith('<!'))
61
+ return 'svg';
62
+ }
63
+ return 'unknown';
64
+ }
65
+ /** A PNG of `bitmap`, drawn on whatever canvas the environment has (a
66
+ * worker has `OffscreenCanvas`, a document has both). */
67
+ async function bitmapToPng(bitmap) {
68
+ if (typeof OffscreenCanvas !== 'undefined') {
69
+ const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
70
+ const c2d = canvas.getContext('2d');
71
+ if (!c2d)
72
+ return null;
73
+ c2d.drawImage(bitmap, 0, 0);
74
+ const pngBlob = await canvas.convertToBlob({ type: 'image/png' });
75
+ return new Uint8Array(await pngBlob.arrayBuffer());
76
+ }
77
+ if (typeof document === 'undefined')
78
+ return null;
79
+ const canvas = document.createElement('canvas');
80
+ canvas.width = bitmap.width;
81
+ canvas.height = bitmap.height;
82
+ const c2d = canvas.getContext('2d');
83
+ if (!c2d)
84
+ return null;
85
+ c2d.drawImage(bitmap, 0, 0);
86
+ const pngBlob = await new Promise((res) => canvas.toBlob(res, 'image/png'));
87
+ if (!pngBlob)
88
+ return null;
89
+ return new Uint8Array(await pngBlob.arrayBuffer());
90
+ }
91
+ /** Decode WebP bytes to PNG bytes via a canvas. Browser (or worker) only. */
92
+ async function webpToPng(bytes) {
93
+ if (typeof createImageBitmap === 'undefined')
94
+ return null;
95
+ try {
96
+ const blob = new Blob([bytes], { type: 'image/webp' });
97
+ return await bitmapToPng(await createImageBitmap(blob));
98
+ }
99
+ catch {
100
+ return null;
101
+ }
102
+ }
103
+ /** Rasterise SVG markup to PNG bytes at the given pixel size, through an
104
+ * `Image` and a canvas of the document. Browser main thread only (a
105
+ * worker cannot decode SVG); returns null elsewhere or when the SVG does
106
+ * not decode. The default {@link SvgRasterizer}. */
107
+ export async function rasterizeSvgWithDom(svgText, widthPx, heightPx) {
108
+ if (typeof document === 'undefined' || typeof URL === 'undefined' || typeof Image === 'undefined')
109
+ return null;
110
+ const blob = new Blob([svgText], { type: 'image/svg+xml' });
111
+ const url = URL.createObjectURL(blob);
112
+ try {
113
+ const img = new Image();
114
+ await new Promise((resolve, reject) => {
115
+ img.onload = () => resolve();
116
+ img.onerror = () => reject(new Error('SVG decode failed'));
117
+ img.src = url;
118
+ });
119
+ const canvas = document.createElement('canvas');
120
+ canvas.width = Math.max(1, Math.round(widthPx));
121
+ canvas.height = Math.max(1, Math.round(heightPx));
122
+ const c2d = canvas.getContext('2d');
123
+ if (!c2d)
124
+ return null;
125
+ c2d.drawImage(img, 0, 0, canvas.width, canvas.height);
126
+ const pngBlob = await new Promise((res) => canvas.toBlob(res, 'image/png'));
127
+ if (!pngBlob)
128
+ return null;
129
+ return new Uint8Array(await pngBlob.arrayBuffer());
130
+ }
131
+ catch {
132
+ return null;
133
+ }
134
+ finally {
135
+ URL.revokeObjectURL(url);
136
+ }
137
+ }
138
+ /** Generic CSS families that never name a loadable font. */
139
+ const GENERIC_FAMILIES = new Set([
140
+ 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui', 'ui-serif', 'ui-sans-serif',
141
+ 'ui-monospace', 'ui-rounded', 'emoji', 'math', 'fangsong', 'inherit', 'initial',
142
+ ]);
143
+ /** A canvas font string for one family / weight / style (size is irrelevant
144
+ * to the cache key). */
145
+ function svgFontString(family, weight, italic) {
146
+ return `${italic ? 'italic ' : ''}${weight} 16px ${family}`;
147
+ }
148
+ /** Fonts an SVG's text would need, as font strings the {@link FontCache}
149
+ * can preload: every named (non-generic) family of each run, so the
150
+ * resolver can fall through the `font-family` list in order. */
151
+ function collectSvgFontStrings(svgText) {
152
+ const wanted = new Set();
153
+ const probe = (families, weight, italic) => {
154
+ for (const f of families)
155
+ if (!GENERIC_FAMILIES.has(f.toLowerCase()))
156
+ wanted.add(svgFontString(f, weight, italic));
157
+ return { widthOf: () => 0, pdfFont: null };
158
+ };
159
+ svgToVectorDrawing(svgText, { fonts: probe, probe: true });
160
+ return [...wanted];
161
+ }
162
+ /** Resolve SVG font requests against the preloaded cache: the first named
163
+ * family of the list that loaded wins; generic families never match. */
164
+ function fontResolverFor(fontCache) {
165
+ const fonts = new Map();
166
+ return (families, weight, italic) => {
167
+ for (const f of families) {
168
+ if (GENERIC_FAMILIES.has(f.toLowerCase()))
169
+ continue;
170
+ const pdfFont = fontCache.get(svgFontString(f, weight, italic));
171
+ if (!pdfFont)
172
+ continue;
173
+ let vf = fonts.get(pdfFont);
174
+ if (!vf) {
175
+ vf = { pdfFont, widthOf: (text, size) => pdfFont.widthOfTextAtSize(text, size) };
176
+ fonts.set(pdfFont, vf);
177
+ }
178
+ return vf;
179
+ }
180
+ return null;
181
+ };
182
+ }
183
+ const FONT_MIME = { ttf: 'font/ttf', otf: 'font/otf', woff2: 'font/woff2', woff: 'font/woff' };
184
+ function sniffFontFormat(bytes) {
185
+ const tag = String.fromCharCode(...bytes.subarray(0, 4));
186
+ if (tag === 'OTTO')
187
+ return 'otf';
188
+ if (tag === 'wOF2')
189
+ return 'woff2';
190
+ if (tag === 'wOFF')
191
+ return 'woff';
192
+ return 'ttf';
193
+ }
194
+ function toBase64(bytes) {
195
+ if (typeof Buffer !== 'undefined')
196
+ return Buffer.from(bytes).toString('base64');
197
+ let binary = '';
198
+ for (let i = 0; i < bytes.length; i += 0x8000) {
199
+ binary += String.fromCharCode.apply(null, Array.from(bytes.subarray(i, i + 0x8000)));
200
+ }
201
+ return btoa(binary);
202
+ }
203
+ /** Before an SVG with text is rasterised through an `<img>` — which cannot
204
+ * see any page font — embed the faces its runs ask for as `@font-face` data
205
+ * URIs, fetched from the same provider the body text uses. Families the
206
+ * provider cannot supply are skipped (the image falls back to a system
207
+ * face, as the screen preview would). */
208
+ export async function inlineSvgFontsForRaster(svgText, fontStrings, provider) {
209
+ let css = '';
210
+ for (const fs of fontStrings) {
211
+ const parsed = parseFontString(fs);
212
+ if (!parsed)
213
+ continue;
214
+ const bytes = await provider(parsed.family, parsed.weight, parsed.style).catch(() => null);
215
+ if (!bytes || bytes.length === 0)
216
+ continue;
217
+ const format = sniffFontFormat(bytes);
218
+ css += `@font-face{font-family:"${parsed.family.replace(/["\\]/g, '')}";font-weight:${parsed.weight};font-style:${parsed.style};src:url(data:${FONT_MIME[format]};base64,${toBase64(bytes)})}`;
219
+ }
220
+ if (!css)
221
+ return svgText;
222
+ const m = /<svg\b[^>]*?>/i.exec(svgText);
223
+ if (!m || m[0].endsWith('/>'))
224
+ return svgText;
225
+ const at = m.index + m[0].length;
226
+ return `${svgText.slice(0, at)}<style type="text/css"><![CDATA[${css}]]></style>${svgText.slice(at)}`;
227
+ }
228
+ /** Largest placement of each `fileId` in points, to size raster fallbacks. */
229
+ function largestPlacements(doc, blocks) {
230
+ const scale = 72 / doc.config.page.dpi;
231
+ const out = new Map();
232
+ const note = (fileId, wPx, hPx) => {
233
+ const w = wPx * scale;
234
+ const h = hPx * scale;
235
+ const prev = out.get(fileId);
236
+ if (!prev || w * h > prev.w * prev.h)
237
+ out.set(fileId, { w, h });
238
+ };
239
+ for (const block of blocks) {
240
+ const rb = block.resourceBlock;
241
+ if (rb?.fileId)
242
+ note(rb.fileId, rb.bodyRect.width, rb.bodyRect.height);
243
+ for (const cell of rb?.table?.cells ?? []) {
244
+ if (cell.image)
245
+ note(cell.image.fileId, cell.image.rect.width, cell.image.rect.height);
246
+ }
247
+ const overlay = block.designOverlay;
248
+ if (overlay) {
249
+ for (const b of overlay.blocks)
250
+ if (b.kind === 'image')
251
+ note(b.fileId, b.bbox.width, b.bbox.height);
252
+ }
253
+ }
254
+ // Image elements of the page design slots (a logo on a title page).
255
+ for (const page of doc.pages) {
256
+ for (const slot of [page.header, page.footer, page.openerBand]) {
257
+ if (!slot)
258
+ continue;
259
+ for (const b of slot.blocks)
260
+ if (b.kind === 'image')
261
+ note(b.fileId, b.bbox.width, b.bbox.height);
262
+ }
263
+ }
264
+ return out;
265
+ }
266
+ /**
267
+ * Embed every bitmap / svg / print-master resource referenced in the
268
+ * document. Returns a map of `fileId → EmbeddedResource` consumed by
269
+ * {@link renderResourceBlock} and the design-overlay image blocks. A missing or
270
+ * undecodable payload is simply absent from the map and the renderer draws a
271
+ * placeholder.
272
+ */
273
+ export async function preloadResourceImages(pdfDoc, doc, bytesProvider, fontCache, fontProvider,
274
+ /** Map to add to (a book renders several documents into one PDF; a
275
+ * resource is embedded once for all of them). */
276
+ into,
277
+ /** How to rasterise an SVG outside the vector subset; the document's
278
+ * own `Image` by default (a worker hands the job to its host). */
279
+ rasterizeSvg = rasterizeSvgWithDom) {
280
+ const out = into ?? new Map();
281
+ if (!bytesProvider)
282
+ return out;
283
+ // Inline resources live in doc.blocks; floated resources only exist on
284
+ // their page's float band (page.floats), so both must be walked.
285
+ const blocks = [...doc.blocks];
286
+ for (const page of doc.pages) {
287
+ if (page.floats)
288
+ blocks.push(...page.floats);
289
+ }
290
+ const placements = largestPlacements(doc, blocks);
291
+ const ds = doc.config.diagramStyle;
292
+ const inkHex = ds?.singleInk
293
+ ? resolveColorValue(ds.inkColor, doc.config.colorPalette, ds.inkColor).hex
294
+ : null;
295
+ /** Embed one payload by `fileId` (format hint from the resource when known). */
296
+ const embed = async (fileId, format) => {
297
+ if (out.has(fileId))
298
+ return;
299
+ const bytes = bytesProvider(fileId);
300
+ if (!bytes)
301
+ return;
302
+ try {
303
+ const sniffed = sniffBytes(bytes);
304
+ const fmt = sniffed !== 'unknown' ? sniffed : (format ?? '').toLowerCase().replace('jpg', 'jpeg');
305
+ if (fmt === 'pdf') {
306
+ const [page] = await pdfDoc.embedPdf(bytes, [0]);
307
+ if (page)
308
+ out.set(fileId, { kind: 'page', page });
309
+ return;
310
+ }
311
+ if (fmt === 'svg') {
312
+ let svgText = new TextDecoder().decode(bytes);
313
+ if (inkHex)
314
+ svgText = applySingleInkToSvg(svgText, inkHex);
315
+ let fonts;
316
+ let wanted = [];
317
+ if (fontCache) {
318
+ // Text runs need their fonts embedded before the sync conversion.
319
+ wanted = collectSvgFontStrings(svgText);
320
+ if (wanted.length > 0)
321
+ await fontCache.preloadFontStrings(wanted);
322
+ fonts = fontResolverFor(fontCache);
323
+ }
324
+ const drawing = svgToVectorDrawing(svgText, { fonts });
325
+ if (drawing) {
326
+ // Pictures inside the drawing become image XObjects.
327
+ for (const shape of drawing.shapes) {
328
+ if (shape.kind !== 'image')
329
+ continue;
330
+ try {
331
+ shape.pdfImage = shape.format === 'png' ? await pdfDoc.embedPng(shape.data) : await pdfDoc.embedJpg(shape.data);
332
+ }
333
+ catch {
334
+ shape.pdfImage = undefined;
335
+ }
336
+ }
337
+ out.set(fileId, { kind: 'vector', drawing });
338
+ return;
339
+ }
340
+ const size = placements.get(fileId) ?? { w: 360, h: 270 };
341
+ const rasterSvg = wanted.length > 0 && fontProvider
342
+ ? await inlineSvgFontsForRaster(svgText, wanted, fontProvider)
343
+ : svgText;
344
+ const png = await rasterizeSvg(rasterSvg, (size.w / 72) * RASTER_DPI, (size.h / 72) * RASTER_DPI);
345
+ if (png)
346
+ out.set(fileId, { kind: 'image', image: await pdfDoc.embedPng(png) });
347
+ return;
348
+ }
349
+ let image = null;
350
+ if (fmt === 'jpeg') {
351
+ image = await pdfDoc.embedJpg(bytes);
352
+ }
353
+ else if (fmt === 'webp') {
354
+ const png = await webpToPng(bytes);
355
+ if (png)
356
+ image = await pdfDoc.embedPng(png);
357
+ }
358
+ else {
359
+ // png / gif-first-frame / unknown all go through embedPng.
360
+ image = await pdfDoc.embedPng(bytes);
361
+ }
362
+ if (image)
363
+ out.set(fileId, { kind: 'image', image });
364
+ }
365
+ catch (err) {
366
+ // Undecodable — leave absent; renderer falls back to a placeholder.
367
+ if (typeof process !== 'undefined' && process.env?.POSTEXT_SVG_DEBUG)
368
+ console.warn('embed failed:', fileId, err.message);
369
+ }
370
+ };
371
+ for (const block of blocks) {
372
+ const rb = block.resourceBlock;
373
+ if (rb && rb.fileId && (rb.kind === 'bitmap' || rb.kind === 'svg')) {
374
+ await embed(rb.fileId, rb.format);
375
+ }
376
+ // Images embedded in table cells draw through the same map.
377
+ for (const cell of rb?.table?.cells ?? []) {
378
+ if (cell.image)
379
+ await embed(cell.image.fileId, cell.image.format);
380
+ }
381
+ // Callout icons and markers (`kind: 'resource'`) draw through the same map.
382
+ const iconFileId = block.callout?.iconFileId;
383
+ if (iconFileId)
384
+ await embed(iconFileId, block.callout?.iconFormat);
385
+ const markerFileId = block.callout?.markerFileId;
386
+ if (markerFileId)
387
+ await embed(markerFileId, block.callout?.markerFormat);
388
+ // Pictures of a box's decoration (a label icon) draw through the map too.
389
+ for (const b of block.designOverlay?.blocks ?? []) {
390
+ if (b.kind === 'image')
391
+ await embed(b.fileId, undefined);
392
+ }
393
+ }
394
+ // Image elements of the page design slots (a logo on a title page, the
395
+ // chapter motif in a running head, a part page's pictures).
396
+ for (const page of doc.pages) {
397
+ for (const slot of [page.header, page.footer, page.openerBand]) {
398
+ if (!slot)
399
+ continue;
400
+ for (const b of slot.blocks)
401
+ if (b.kind === 'image')
402
+ await embed(b.fileId, undefined);
403
+ }
404
+ }
405
+ return out;
406
+ }
407
+ /** Draw an embedded resource fitted to a box in document pixels (top-down). */
408
+ export function drawEmbeddedResource(ctx, res, xPx, yPx, wPx, hPx) {
409
+ const { scale, pageHeightPt } = ctx;
410
+ const box = {
411
+ x: xPx * scale,
412
+ y: pageHeightPt - (yPx + hPx) * scale,
413
+ width: wPx * scale,
414
+ height: hPx * scale,
415
+ };
416
+ if (res.kind === 'image')
417
+ ctx.page.drawImage(res.image, box);
418
+ else if (res.kind === 'page')
419
+ ctx.page.drawPage(res.page, box);
420
+ else
421
+ drawVectorDrawing(ctx, res.drawing, xPx, yPx, wPx, hPx);
422
+ }
423
+ function pickFont(bold, italic, fonts) {
424
+ if (bold && italic)
425
+ return fonts.boldItalic;
426
+ if (bold)
427
+ return fonts.bold;
428
+ if (italic)
429
+ return fonts.italic;
430
+ return fonts.normal;
431
+ }
432
+ /** Paint one absolutely-positioned rich-text line; records ref link rects.
433
+ * In a tagged render the text joins `elem` (refs get a `Link` child) and
434
+ * word spaces are painted as real glyphs; without `elem` the line joins
435
+ * whatever sequence is open (an artifact for repeated table headers). */
436
+ function paintLine(ctx, line, fonts, fontCache, color, linkColor, linkRegistry, resolveRefId, labelColor = color, elem) {
437
+ const baseFont = fontCache.get(fonts.normal);
438
+ if (!baseFont)
439
+ return;
440
+ const baseSize = parseFontString(fonts.normal)?.sizePx ?? 0;
441
+ if (line.segments && line.segments.length > 0) {
442
+ let x = line.bbox.x;
443
+ for (const seg of line.segments) {
444
+ if (seg.kind === 'space') {
445
+ if (ctx.tags && seg.text) {
446
+ tagContent(ctx, elem);
447
+ drawTextPx(ctx, seg.text, x, line.baseline, baseFont, baseSize, color);
448
+ }
449
+ x += seg.width;
450
+ continue;
451
+ }
452
+ if (seg.kind === 'swatch') {
453
+ tagContent(ctx, elem);
454
+ drawSwatchPx(ctx, x, line.baseline, seg.width, seg.swatch?.color, color);
455
+ x += seg.width;
456
+ continue;
457
+ }
458
+ const fontStr = seg.fontString ?? pickFont(!!seg.bold, !!seg.italic, fonts);
459
+ const font = fontCache.get(fontStr) ?? baseFont;
460
+ const size = parseFontString(fontStr)?.sizePx ?? baseSize;
461
+ const refId = resolveRefId(seg);
462
+ const segColor = refId !== undefined ? linkColor : seg.captionLabel ? labelColor : color;
463
+ const link = refId !== undefined && elem ? elem.child('Link') : undefined;
464
+ tagContent(ctx, link ?? elem);
465
+ drawTextPx(ctx, seg.text, x, line.baseline + (seg.baselineShift ?? 0), font, size, segColor);
466
+ if (refId !== undefined && linkRegistry) {
467
+ const { scale, pageHeightPt } = ctx;
468
+ const x1 = x * scale;
469
+ const y2 = pageHeightPt - (line.bbox.y) * scale;
470
+ const y1 = pageHeightPt - (line.bbox.y + line.bbox.height) * scale;
471
+ const x2 = (x + seg.width) * scale;
472
+ const rect = [x1, y1, x2, y2];
473
+ linkRegistry.addLink(ctx.page, ctx.mapRectPt ? ctx.mapRectPt(rect) : rect, refId, link ? { elem: link, contents: seg.text } : undefined);
474
+ }
475
+ x += seg.width;
476
+ }
477
+ return;
478
+ }
479
+ tagContent(ctx, elem);
480
+ drawTextPx(ctx, line.text, line.bbox.x, line.baseline, baseFont, baseSize, color);
481
+ }
482
+ /** Layout attributes of a figure: its bounding box on the page (PDF user
483
+ * space, bottom-up) and block placement. */
484
+ function figureLayout(ctx, xPx, yPx, wPx, hPx) {
485
+ const { scale, pageHeightPt } = ctx;
486
+ const rect = [
487
+ xPx * scale,
488
+ pageHeightPt - (yPx + hPx) * scale,
489
+ (xPx + wPx) * scale,
490
+ pageHeightPt - yPx * scale,
491
+ ];
492
+ const bbox = ctx.page.doc.context.obj(ctx.mapRectPt ? ctx.mapRectPt(rect) : rect);
493
+ return [{ owner: 'Layout', entries: { BBox: bbox, Placement: 'Block' } }];
494
+ }
495
+ /** Plain text of a resource caption, for figure alt text fallbacks. */
496
+ function captionText(rb) {
497
+ return rb.captionLines.map((l) => l.text).join(' ').replace(/\s+/g, ' ').trim();
498
+ }
499
+ /** Alt text of a figure: the resource's `altText`, else its caption, else
500
+ * its label (`Figure 1.2`) — PDF/UA-1 §7.3 requires one on every figure. */
501
+ function figureAlt(rb) {
502
+ return rb.resource.altText?.trim() || captionText(rb) || `${rb.captionPrefix} ${rb.number}`.trim();
503
+ }
504
+ /** Rows whose every cell is a header cell: the column-header rows, which
505
+ * a continuation slice repeats and an accessible render then flags as
506
+ * artifacts (the reader already met them on the first slice). */
507
+ function headerRows(cells) {
508
+ const rows = new Map();
509
+ for (const cell of cells)
510
+ rows.set(cell.row, (rows.get(cell.row) ?? true) && cell.isHeader);
511
+ const out = new Set();
512
+ for (const [row, allHeader] of rows)
513
+ if (allHeader)
514
+ out.add(row);
515
+ return out;
516
+ }
517
+ /** Build the `TR` › `TH` / `TD` elements of a table slice: one element per
518
+ * cell in row / column order, with span and scope attributes. Cells of
519
+ * repeated header rows on a continuation slice get no element. */
520
+ function tableCellElems(table, cells, continued) {
521
+ const out = new Map();
522
+ const heads = headerRows(cells);
523
+ const ordered = [...cells].sort((a, b) => a.row - b.row || a.col - b.col);
524
+ let row;
525
+ let rowIndex = -1;
526
+ for (const cell of ordered) {
527
+ if (continued && heads.has(cell.row))
528
+ continue;
529
+ if (cell.row !== rowIndex) {
530
+ rowIndex = cell.row;
531
+ row = table.child('TR');
532
+ }
533
+ const entries = {};
534
+ if (cell.colSpan > 1)
535
+ entries.ColSpan = cell.colSpan;
536
+ if (cell.rowSpan > 1)
537
+ entries.RowSpan = cell.rowSpan;
538
+ if (cell.isHeader)
539
+ entries.Scope = heads.has(cell.row) ? 'Column' : 'Row';
540
+ const attrs = Object.keys(entries).length > 0
541
+ ? { attributes: [{ owner: 'Table', entries }] }
542
+ : {};
543
+ out.set(cell, row.child(cell.isHeader ? 'TH' : 'TD', attrs));
544
+ }
545
+ return out;
546
+ }
547
+ function renderTable(ctx, rb, bx, by, fontCache, images, linkColor, linkRegistry, tableElem) {
548
+ const t = rb.table;
549
+ if (!t)
550
+ return;
551
+ // Cell grid, fills and rules are layout; the reader gets the cell text.
552
+ tagArtifact(ctx, { type: 'Layout' });
553
+ const cellElems = tableElem ? tableCellElems(tableElem, t.cells, !!rb.slice?.continued) : undefined;
554
+ const borderColor = colorFromHex(t.borderColor, ctx.colorSpace);
555
+ const headerBg = t.headerBackground ? colorFromHex(t.headerBackground, ctx.colorSpace) : undefined;
556
+ const bodyBg = t.bodyBackground ? colorFromHex(t.bodyBackground, ctx.colorSpace) : undefined;
557
+ const bodyColor = colorFromHex(t.color, ctx.colorSpace);
558
+ const headerColor = colorFromHex(t.headerColor, ctx.colorSpace);
559
+ const bodyFonts = {
560
+ normal: t.fontString,
561
+ bold: t.boldFontString,
562
+ italic: t.italicFontString,
563
+ boldItalic: t.boldItalicFontString,
564
+ };
565
+ const headerFonts = {
566
+ normal: t.headerFontString,
567
+ bold: t.headerBoldFontString,
568
+ italic: t.headerItalicFontString,
569
+ boldItalic: t.headerBoldItalicFontString,
570
+ };
571
+ // Cell backgrounds (the cell's own fill, else the header tint / body fill).
572
+ for (const cell of t.cells) {
573
+ const fill = cell.background ? colorFromHex(cell.background, ctx.colorSpace) : cell.isHeader ? headerBg : bodyBg;
574
+ if (fill)
575
+ fillRectPx(ctx, cell.rect.x, cell.rect.y, cell.rect.width, cell.rect.height, fill);
576
+ }
577
+ // Borders: the full cell grid, horizontal rules only, or the outer frame.
578
+ // `drawLinePx` strokes exactly `borderWidthPx` (scaled to pt), so fractional
579
+ // hairlines such as 0.5pt survive.
580
+ if (t.borderWidthPx > 0) {
581
+ const rules = t.rules ?? 'grid';
582
+ const bw = t.borderWidthPx;
583
+ if (rules === 'outer') {
584
+ const tableHeight = t.rowEdges[t.rowEdges.length - 1] ?? rb.bodyRect.height;
585
+ const x = bx, y = by, width = rb.bodyRect.width, height = tableHeight;
586
+ drawLinePx(ctx, x, y, x + width, y, borderColor, bw);
587
+ drawLinePx(ctx, x, y + height, x + width, y + height, borderColor, bw);
588
+ drawLinePx(ctx, x, y, x, y + height, borderColor, bw);
589
+ drawLinePx(ctx, x + width, y, x + width, y + height, borderColor, bw);
590
+ }
591
+ else {
592
+ for (const cell of t.cells) {
593
+ const { x, y, width, height } = cell.rect;
594
+ drawLinePx(ctx, x, y, x + width, y, borderColor, bw);
595
+ drawLinePx(ctx, x, y + height, x + width, y + height, borderColor, bw);
596
+ if (rules === 'grid') {
597
+ drawLinePx(ctx, x, y, x, y + height, borderColor, bw);
598
+ drawLinePx(ctx, x + width, y, x + width, y + height, borderColor, bw);
599
+ }
600
+ }
601
+ }
602
+ }
603
+ // Cell images (bitmap / SVG resources embedded in cells), then text.
604
+ for (const cell of t.cells) {
605
+ const img = cell.image;
606
+ if (!img)
607
+ continue;
608
+ const { x, y, width, height } = img.rect;
609
+ const cellElem = cellElems?.get(cell);
610
+ if (cellElems) {
611
+ if (cellElem) {
612
+ tagContent(ctx, cellElem.child('Figure', {
613
+ alt: img.altText?.trim() || img.resourceId,
614
+ attributes: figureLayout(ctx, x, y, width, height),
615
+ }));
616
+ }
617
+ else {
618
+ tagArtifact(ctx, { type: 'Layout' });
619
+ }
620
+ }
621
+ const embedded = images.get(img.fileId);
622
+ if (embedded)
623
+ drawEmbeddedResource(ctx, embedded, x, y, width, height);
624
+ else
625
+ fillRectPx(ctx, x, y, width, height, colorFromHex('#eeeeee', ctx.colorSpace));
626
+ }
627
+ // Cell content.
628
+ for (const cell of t.cells) {
629
+ const fonts = cell.isHeader ? headerFonts : bodyFonts;
630
+ const color = cell.isHeader ? headerColor : bodyColor;
631
+ const cellElem = cellElems?.get(cell);
632
+ if (cellElems && !cellElem)
633
+ tagArtifact(ctx, { type: 'Layout' });
634
+ for (const line of cell.lines) {
635
+ paintLine(ctx, line, fonts, fontCache, color, linkColor, linkRegistry, (seg) => seg.refResourceId, color, cellElem);
636
+ }
637
+ }
638
+ }
639
+ function drawPlaceholder(ctx, rb, x, y) {
640
+ const fill = colorFromHex('#eeeeee', ctx.colorSpace);
641
+ fillRectPx(ctx, x, y, rb.bodyRect.width, rb.bodyRect.height, fill);
642
+ }
643
+ export function renderResourceBlock(ctx, block, fontCache, images, linkRegistry, structure) {
644
+ const rb = block.resourceBlock;
645
+ if (!rb)
646
+ return;
647
+ const { scale, pageHeightPt } = ctx;
648
+ // A rotated block: its geometry is in the upright frame. A quarter-turn
649
+ // matrix lands that frame on the page — set up so the `*Px` helpers, which
650
+ // flip y against the page height, keep working unchanged inside it — and
651
+ // the rects that live outside the content stream (link annotations,
652
+ // structure bounding boxes) go through the same matrix.
653
+ const rot = rb.rotation;
654
+ let matrix;
655
+ if (rot) {
656
+ matrix = rot.direction === 'ccw'
657
+ ? [0, 1, -1, 0, rot.originX * scale + pageHeightPt, pageHeightPt - rot.originY * scale]
658
+ : [0, -1, 1, 0, rot.originX * scale - pageHeightPt, pageHeightPt - rot.originY * scale];
659
+ pushTransform(ctx, matrix);
660
+ const m = matrix;
661
+ ctx.mapRectPt = (r) => mapRectThrough(m, r);
662
+ }
663
+ const bx = (rot ? 0 : block.bbox.x) + rb.bodyRect.x;
664
+ const by = (rot ? 0 : block.bbox.y) + rb.bodyRect.y;
665
+ const bw = rb.bodyRect.width;
666
+ const bh = rb.bodyRect.height;
667
+ const linkColor = colorFromHex(rb.linkColor, ctx.colorSpace);
668
+ const continued = !!rb.slice?.continued;
669
+ // Structure: a `Figure` (alt text required) or a `Table` (its alt text as
670
+ // the summary), shared by the slices of a split table.
671
+ let owner;
672
+ if (structure) {
673
+ if (rb.kind === 'table') {
674
+ const alt = rb.resource.altText?.trim();
675
+ owner = structure.resourceElem(block, 'Table', alt
676
+ ? { attributes: [{ owner: 'Table', entries: { Summary: PDFHexString.fromText(alt) } }] }
677
+ : {});
678
+ }
679
+ else {
680
+ owner = structure.resourceElem(block, 'Figure', {
681
+ alt: figureAlt(rb),
682
+ attributes: figureLayout(ctx, bx, by, bw, bh),
683
+ });
684
+ }
685
+ }
686
+ if (rb.kind === 'bitmap' || rb.kind === 'svg') {
687
+ tagContent(ctx, owner);
688
+ const embedded = rb.fileId ? images.get(rb.fileId) : undefined;
689
+ if (embedded) {
690
+ drawEmbeddedResource(ctx, embedded, bx, by, bw, bh);
691
+ }
692
+ else {
693
+ drawPlaceholder(ctx, rb, bx, by);
694
+ }
695
+ }
696
+ else if (rb.kind === 'table') {
697
+ renderTable(ctx, rb, bx, by, fontCache, images, linkColor, linkRegistry, owner);
698
+ }
699
+ // Named destination for inline refs: top-left of the placed block (the
700
+ // first slice of a split table; continuations are not targets).
701
+ if (linkRegistry && rb.resource.id && !rb.slice?.continued) {
702
+ const destTop = pageHeightPt - block.bbox.y * scale;
703
+ linkRegistry.addDestination(rb.resource.id, ctx.page, block.bbox.x * scale, destTop);
704
+ }
705
+ // Caption bar (behind the caption lines).
706
+ if (rb.captionBar) {
707
+ tagArtifact(ctx, { type: 'Layout' });
708
+ const { rect, background } = rb.captionBar;
709
+ fillRectPx(ctx, rect.x, rect.y, rect.width, rect.height, colorFromHex(background, ctx.colorSpace));
710
+ }
711
+ // Caption — the first slice's is the `Caption` of the figure / table; the
712
+ // repeated (continued) caption of a later slice is an artifact.
713
+ const captionColor = colorFromHex(rb.captionColor, ctx.colorSpace);
714
+ const captionLabelColor = colorFromHex(rb.captionLabelColor, ctx.colorSpace);
715
+ const captionFonts = {
716
+ normal: rb.captionFontString,
717
+ bold: rb.captionBoldFontString,
718
+ italic: rb.captionItalicFontString,
719
+ boldItalic: rb.captionBoldItalicFontString,
720
+ };
721
+ if (structure && rb.captionLines.length > 0 && continued)
722
+ tagArtifact(ctx, { type: 'Layout' });
723
+ const captionElem = structure && owner && rb.captionLines.length > 0 && !continued
724
+ ? structure.captionElem(owner)
725
+ : undefined;
726
+ for (const line of rb.captionLines) {
727
+ paintLine(ctx, line, captionFonts, fontCache, captionColor, linkColor, linkRegistry, (seg) => seg.refResourceId, captionLabelColor, captionElem);
728
+ }
729
+ // Note.
730
+ const noteColor = colorFromHex(rb.noteColor, ctx.colorSpace);
731
+ const noteFonts = {
732
+ normal: rb.noteFontString,
733
+ bold: rb.noteBoldFontString,
734
+ italic: rb.noteItalicFontString,
735
+ boldItalic: rb.noteBoldItalicFontString,
736
+ };
737
+ const noteElem = structure && rb.noteLines.length > 0 ? structure.noteElem(block) : undefined;
738
+ for (const line of rb.noteLines) {
739
+ paintLine(ctx, line, noteFonts, fontCache, noteColor, linkColor, linkRegistry, (seg) => seg.refResourceId, noteColor, noteElem);
740
+ }
741
+ // The "continued" marker of a table slice that goes on is a layout cue.
742
+ if (rb.continuesLines && rb.continuesLines.length > 0) {
743
+ tagArtifact(ctx, { type: 'Layout' });
744
+ for (const line of rb.continuesLines) {
745
+ paintLine(ctx, line, noteFonts, fontCache, noteColor, linkColor, linkRegistry, (seg) => seg.refResourceId);
746
+ }
747
+ }
748
+ if (matrix) {
749
+ popTransform(ctx);
750
+ delete ctx.mapRectPt;
751
+ }
752
+ }
753
+ //# sourceMappingURL=renderResourceBlock.js.map