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.
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/dist/__bench__/render.bench.test.d.ts +2 -0
- package/dist/__bench__/render.bench.test.d.ts.map +1 -0
- package/dist/__bench__/render.bench.test.js +86 -0
- package/dist/__bench__/render.bench.test.js.map +1 -0
- package/dist/__tests__/accessibility.test.d.ts +2 -0
- package/dist/__tests__/accessibility.test.d.ts.map +1 -0
- package/dist/__tests__/accessibility.test.js +364 -0
- package/dist/__tests__/accessibility.test.js.map +1 -0
- package/dist/__tests__/contentsLinks.test.d.ts +2 -0
- package/dist/__tests__/contentsLinks.test.d.ts.map +1 -0
- package/dist/__tests__/contentsLinks.test.js +131 -0
- package/dist/__tests__/contentsLinks.test.js.map +1 -0
- package/dist/__tests__/fontCache.test.d.ts +2 -0
- package/dist/__tests__/fontCache.test.d.ts.map +1 -0
- package/dist/__tests__/fontCache.test.js +42 -0
- package/dist/__tests__/fontCache.test.js.map +1 -0
- package/dist/__tests__/headerFooter.test.d.ts +2 -0
- package/dist/__tests__/headerFooter.test.d.ts.map +1 -0
- package/dist/__tests__/headerFooter.test.js +68 -0
- package/dist/__tests__/headerFooter.test.js.map +1 -0
- package/dist/__tests__/rotatedTable.test.d.ts +2 -0
- package/dist/__tests__/rotatedTable.test.d.ts.map +1 -0
- package/dist/__tests__/rotatedTable.test.js +112 -0
- package/dist/__tests__/rotatedTable.test.js.map +1 -0
- package/dist/__tests__/svgVector.test.d.ts +2 -0
- package/dist/__tests__/svgVector.test.d.ts.map +1 -0
- package/dist/__tests__/svgVector.test.js +322 -0
- package/dist/__tests__/svgVector.test.js.map +1 -0
- package/dist/__tests__/toUnicode.test.d.ts +2 -0
- package/dist/__tests__/toUnicode.test.d.ts.map +1 -0
- package/dist/__tests__/toUnicode.test.js +57 -0
- package/dist/__tests__/toUnicode.test.js.map +1 -0
- package/dist/colors.d.ts +23 -0
- package/dist/colors.d.ts.map +1 -0
- package/dist/colors.js +47 -0
- package/dist/colors.js.map +1 -0
- package/dist/fontCache.d.ts +41 -0
- package/dist/fontCache.d.ts.map +1 -0
- package/dist/fontCache.js +154 -0
- package/dist/fontCache.js.map +1 -0
- package/dist/fontString.d.ts +14 -0
- package/dist/fontString.d.ts.map +1 -0
- package/dist/fontString.js +43 -0
- package/dist/fontString.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/pdf-backend/blockRender.d.ts +15 -0
- package/dist/pdf-backend/blockRender.d.ts.map +1 -0
- package/dist/pdf-backend/blockRender.js +244 -0
- package/dist/pdf-backend/blockRender.js.map +1 -0
- package/dist/pdf-backend/fontHelpers.d.ts +6 -0
- package/dist/pdf-backend/fontHelpers.d.ts.map +1 -0
- package/dist/pdf-backend/fontHelpers.js +81 -0
- package/dist/pdf-backend/fontHelpers.js.map +1 -0
- package/dist/pdf-backend/headerFooter.d.ts +15 -0
- package/dist/pdf-backend/headerFooter.d.ts.map +1 -0
- package/dist/pdf-backend/headerFooter.js +145 -0
- package/dist/pdf-backend/headerFooter.js.map +1 -0
- package/dist/pdf-backend/index.d.ts +47 -0
- package/dist/pdf-backend/index.d.ts.map +1 -0
- package/dist/pdf-backend/index.js +227 -0
- package/dist/pdf-backend/index.js.map +1 -0
- package/dist/pdf-backend/links.d.ts +44 -0
- package/dist/pdf-backend/links.d.ts.map +1 -0
- package/dist/pdf-backend/links.js +110 -0
- package/dist/pdf-backend/links.js.map +1 -0
- package/dist/pdf-backend/outlines.d.ts +8 -0
- package/dist/pdf-backend/outlines.d.ts.map +1 -0
- package/dist/pdf-backend/outlines.js +145 -0
- package/dist/pdf-backend/outlines.js.map +1 -0
- package/dist/pdf-backend/pageDecorations.d.ts +13 -0
- package/dist/pdf-backend/pageDecorations.d.ts.map +1 -0
- package/dist/pdf-backend/pageDecorations.js +88 -0
- package/dist/pdf-backend/pageDecorations.js.map +1 -0
- package/dist/pdf-backend/pageLabels.d.ts +8 -0
- package/dist/pdf-backend/pageLabels.d.ts.map +1 -0
- package/dist/pdf-backend/pageLabels.js +61 -0
- package/dist/pdf-backend/pageLabels.js.map +1 -0
- package/dist/pdf-backend/primitives.d.ts +46 -0
- package/dist/pdf-backend/primitives.d.ts.map +1 -0
- package/dist/pdf-backend/primitives.js +162 -0
- package/dist/pdf-backend/primitives.js.map +1 -0
- package/dist/pdf-backend/renderResourceBlock.d.ts +87 -0
- package/dist/pdf-backend/renderResourceBlock.d.ts.map +1 -0
- package/dist/pdf-backend/renderResourceBlock.js +753 -0
- package/dist/pdf-backend/renderResourceBlock.js.map +1 -0
- package/dist/pdf-backend/structureFlow.d.ts +55 -0
- package/dist/pdf-backend/structureFlow.d.ts.map +1 -0
- package/dist/pdf-backend/structureFlow.js +180 -0
- package/dist/pdf-backend/structureFlow.js.map +1 -0
- package/dist/pdf-backend/svgVector.d.ts +144 -0
- package/dist/pdf-backend/svgVector.d.ts.map +1 -0
- package/dist/pdf-backend/svgVector.js +1455 -0
- package/dist/pdf-backend/svgVector.js.map +1 -0
- package/dist/pdf-backend/tagging.d.ts +136 -0
- package/dist/pdf-backend/tagging.d.ts.map +1 -0
- package/dist/pdf-backend/tagging.js +296 -0
- package/dist/pdf-backend/tagging.js.map +1 -0
- package/dist/woff2.d.ts +11 -0
- package/dist/woff2.d.ts.map +1 -0
- package/dist/woff2.js +47 -0
- package/dist/woff2.js.map +1 -0
- package/dist/worker/client.d.ts +30 -0
- package/dist/worker/client.d.ts.map +1 -0
- package/dist/worker/client.js +112 -0
- package/dist/worker/client.js.map +1 -0
- package/dist/worker/pdf.worker.d.ts +2 -0
- package/dist/worker/pdf.worker.d.ts.map +1 -0
- package/dist/worker/pdf.worker.js +67 -0
- package/dist/worker/pdf.worker.js.map +1 -0
- package/dist/worker/protocol.d.ts +49 -0
- package/dist/worker/protocol.d.ts.map +1 -0
- package/dist/worker/protocol.js +2 -0
- package/dist/worker/protocol.js.map +1 -0
- 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
|