@quario/pdf 0.2.0 → 0.4.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/lib/canvas.js DELETED
@@ -1,376 +0,0 @@
1
- /**
2
- * The drawing surface. Every mark on a page goes through here, so nothing above
3
- * this module talks to pdf-lib page primitives.
4
- *
5
- * Two adapters satisfy one interface. `drawing` puts marks on a document;
6
- * `measuring` moves the cursor and marks nothing, which is how `probe` reserves
7
- * a band's height without emitting it. Measuring is therefore a choice of
8
- * adapter rather than a mode every primitive has to remember to check — and
9
- * since a measuring canvas is built from seven numbers and a font registry,
10
- * there is no parameter through which a document could reach it. Pagination is
11
- * the one thing measuring must never do: a page turn would reset the cursor
12
- * mid-measure and return a silently wrong height, so `newPage` throws there.
13
- */
14
- import { degrees, drawImage, drawText, rgb } from "pdf-lib";
15
- import { BLACK, dressed, shift } from "./style.js";
16
-
17
- // The operator builder wants explicit rotation and skew; report text has none.
18
- let NO_TURN = degrees(0);
19
-
20
- /** @typedef {import('./text.js').Line} Line */
21
- /** @typedef {import('./text.js').Metrics} Metrics */
22
- /** @typedef {import('pdf-lib').PDFPage} PDFPage */
23
- /** @typedef {import('pdf-lib').PDFFont} PDFFont */
24
- /** @typedef {import('pdf-lib').PDFName} PDFName */
25
-
26
- // The page box and the content box. Settled at `report-start` and fixed
27
- // thereafter: the geometry is the factory's and never moves, while `base` and
28
- // `style` take the report default one event into the render, before anything is
29
- // measured. The page bands are measured against the page box and take their
30
- // height off `top`/`bottom` through `adopt` below, while the first page is
31
- // still untouched.
32
- /**
33
- * `style` is the report default — the outermost style layer, resolved once per
34
- * render from `report-start` and carried here so a measuring canvas built off
35
- * this frame reads the same one the drawing canvas does. Null when the report
36
- * declares none.
37
- *
38
- * @typedef {{ width: number, height: number, margin: number, base: number,
39
- * content: number, top: number, bottom: number, style: any }} Frame
40
- */
41
-
42
- // A frame from the page box and the base size: how `content`/`top`/`bottom`
43
- // fall out of a page and a margin is derived here, once, so no caller and no
44
- // suite has to restate it and drift from what a real render uses.
45
- /** @type {(width: number, height: number, margin: number, base: number, style?: any) => Frame} */
46
- let frame = (width, height, margin, base, style = null) => ({
47
- width,
48
- height,
49
- margin,
50
- base,
51
- style,
52
- content: width - 2 * margin,
53
- top: height - margin,
54
- bottom: margin,
55
- });
56
-
57
- // `y` is the cursor on the open page and `fresh` says nothing has been drawn on
58
- // it yet, which is what makes a break legal. `count` is how many pages exist.
59
- /**
60
- * @typedef {Frame & Metrics & { y: number, fresh: boolean, count: number,
61
- * newPage: () => void,
62
- * rect: (color: any, x: number, y: number, w: number, h: number) => void,
63
- * rule: (x1: number, x2: number, y: number) => void,
64
- * picture: (bytes: Uint8Array, format: string, x: number, y: number,
65
- * w: number, h: number) => void,
66
- * drawLine: (line: Line, x: number, yTop: number, avail: number,
67
- * align: any) => void }} Canvas
68
- */
69
-
70
- // The page passes on top of the interface: re-visiting finished pages, handing
71
- // out their refs for the outline, and stamping them. Deliberately not on
72
- // `Canvas` — the layout may not re-target a page mid-walk, and a measurement
73
- // has no business doing any of it.
74
- /**
75
- * @typedef {Canvas & { select: (i: number) => void, refs: any[],
76
- * pictures: () => Promise<void>, watermark: (mark: any) => void }} Drawing
77
- */
78
-
79
- // What every canvas starts as, whichever adapter it is: its frame and faces,
80
- // and a cursor that has drawn nothing yet. Shared because the parity test
81
- // compares member names, not their values — two hand-written copies of this
82
- // could drift in what they start from and nothing would notice.
83
- /**
84
- * @type {(box: Frame, fonts: import('./fonts.js').Fonts) =>
85
- * Frame & Metrics & { y: number, fresh: boolean }}
86
- */
87
- let blank = (box, fonts) => ({ ...box, fonts, y: 0, fresh: true });
88
-
89
- let GREY = rgb(0.5, 0.5, 0.5);
90
-
91
- // Text decoration in the text colour. Thickness and offset come from the
92
- // line's ascender (the face metric already measured for baseline placement).
93
- // Empty lines (no width) draw nothing. Shared by the drawing adapter and the
94
- // layout recorder so both exercise the same path.
95
- /** @type {(line: Line) => any} */
96
- let ink = (line) => (line.pieces[0] && line.pieces[0].color) || BLACK;
97
-
98
- /** @type {(line: Line) => boolean} */
99
- let wantsDeco = (line) => !!line.w && dressed(line);
100
-
101
- /**
102
- * @type {(stroke: (x1: number, x2: number, y: number, thickness: number, color: any) => void,
103
- * line: Line, left: number, baseline: number) => void}
104
- */
105
- let decorateLine = (stroke, line, left, baseline) => {
106
- if (!wantsDeco(line)) return;
107
- let thickness = Math.max(line.asc / 12, 0.5);
108
- let right = left + line.w;
109
- let color = ink(line);
110
- if (line.underline) stroke(left, right, baseline - line.asc * 0.12, thickness, color);
111
- if (line.strikethrough) stroke(left, right, baseline + line.asc * 0.35, thickness, color);
112
- };
113
-
114
- /**
115
- * The adapter that draws: a canvas over a real document.
116
- *
117
- * @type {(doc: import('pdf-lib').PDFDocument, box: Frame,
118
- * fonts: import('./fonts.js').Fonts) => Drawing}
119
- */
120
- let drawing = (doc, box, fonts) => {
121
- /** @type {PDFPage} */
122
- let page;
123
- /** @type {PDFPage[]} */
124
- let pages = [];
125
- /**
126
- * The resource name a page refers to a face or an image by, minted once per
127
- * resource per page.
128
- *
129
- * pdf-lib's own page-level state cannot do this: `setFont` mints a fresh
130
- * random-suffixed name on every call, and `drawText({ font })` calls it twice
131
- * — once to select the face and once to restore the previous one. A report
132
- * that alternates bold and regular therefore grows its font dictionary with
133
- * its text rather than with its faces, and measured 4.8x larger for the same
134
- * content. `drawImage` on the page has the same shape, so one logo down a
135
- * thousand rows would put a thousand entries in one XObject dictionary,
136
- * every one of them pointing at the same stream.
137
- *
138
- * Keyed by page and kept for the canvas's whole life, never reset per page,
139
- * because the page-band pass returns to pages the body already drew on and
140
- * must reuse what they registered.
141
- *
142
- * @type {(mint: (on: PDFPage, resource: any) => PDFName) =>
143
- * (on: PDFPage, resource: any) => PDFName}
144
- */
145
- let perPage = (mint) => {
146
- /** @type {WeakMap<PDFPage, Map<any, PDFName>>} */
147
- let keys = new WeakMap();
148
- return (on, resource) => {
149
- let minted = keys.get(on);
150
- if (!minted) keys.set(on, (minted = new Map()));
151
- let key = minted.get(resource);
152
- if (!key) minted.set(resource, (key = mint(on, resource)));
153
- return key;
154
- };
155
- };
156
- let faceKey = perPage((on, font) => on.node.newFontDictionary(font.name, font.ref));
157
- let imageKey = perPage((on, image) => on.node.newXObject("Image", image.ref));
158
- /** @type {(font: PDFFont) => PDFName} */
159
- let fontKey = (font) => faceKey(page, font);
160
-
161
- // Open a fresh page and put the cursor at its top.
162
- let newPage = () => {
163
- page = doc.addPage([canvas.width, canvas.height]);
164
- pages.push(page);
165
- canvas.y = canvas.top;
166
- canvas.fresh = true;
167
- };
168
-
169
- /** @type {Canvas['rect']} */
170
- let rect = (color, x, y, w, h) => page.drawRectangle({ x, y, width: w, height: h, color });
171
-
172
- // Where every image goes, recorded as the walk passes it and drawn once the
173
- // walk is over. Embedding is asynchronous and a walk handler is not, so the
174
- // placement is what the flow makes and the bytes are turned into a document
175
- // resource afterwards -- which is also what lets one logo repeated on every
176
- // page be embedded once and referenced, keyed by the array the source
177
- // expression yielded.
178
- /** @type {{ page: PDFPage, bytes: Uint8Array, format: string, x: number,
179
- * y: number, w: number, h: number }[]} */
180
- let placed = [];
181
- // Drawn once, at the end of the render, so nothing clears this: the canvas
182
- // and the document it marks end together.
183
- /** @type {Canvas['picture']} */
184
- let picture = (bytes, format, x, y, w, h) => {
185
- placed.push({ page, bytes, format, x, y, w, h });
186
- };
187
- /** @type {Drawing['pictures']} */
188
- let pictures = async () => {
189
- // Keyed by the array the source expression yielded, so one logo reused
190
- // across pages is embedded once and every placement references it.
191
- /** @type {Map<Uint8Array, any>} */
192
- let embedded = new Map();
193
- for (let spot of placed) {
194
- let image = embedded.get(spot.bytes);
195
- if (!image) {
196
- image = await (spot.format === "png" ? doc.embedPng(spot.bytes) : doc.embedJpg(spot.bytes));
197
- embedded.set(spot.bytes, image);
198
- }
199
- spot.page.pushOperators(
200
- ...drawImage(imageKey(spot.page, image), {
201
- x: spot.x,
202
- y: spot.y,
203
- width: spot.w,
204
- height: spot.h,
205
- rotate: NO_TURN,
206
- xSkew: NO_TURN,
207
- ySkew: NO_TURN,
208
- }),
209
- );
210
- }
211
- };
212
-
213
- /** @type {Canvas['rule']} */
214
- let rule = (x1, x2, y) =>
215
- page.drawLine({ start: { x: x1, y }, end: { x: x2, y }, thickness: 0.5, color: BLACK });
216
-
217
- /** @type {(piece: Line['pieces'][number], x: number, y: number) => void} */
218
- let writePiece = (piece, x, y) => {
219
- if (!piece.text) return;
220
- page.pushOperators(
221
- ...drawText(piece.font.encodeText(piece.text), {
222
- font: fontKey(piece.font),
223
- size: piece.size,
224
- color: piece.color || BLACK,
225
- x,
226
- y,
227
- rotate: NO_TURN,
228
- xSkew: NO_TURN,
229
- ySkew: NO_TURN,
230
- }),
231
- );
232
- };
233
-
234
- /** @type {(left: number, right: number, y: number, thickness: number, color: any) => void} */
235
- let strokeAt = (left, right, y, thickness, color) =>
236
- page.drawLine({
237
- start: { x: left, y },
238
- end: { x: right, y },
239
- thickness,
240
- color,
241
- });
242
-
243
- // Draw one wrapped line with its baseline the line's ascender under `yTop`,
244
- // horizontally placed by `align` within `avail` starting at `x`.
245
- /** @type {Canvas['drawLine']} */
246
- let drawLine = (line, x, yTop, avail, align) => {
247
- let left = x + shift(align, avail - line.w);
248
- let baseline = yTop - line.asc;
249
- let cursor = left;
250
- for (let piece of line.pieces) {
251
- writePiece(piece, cursor, baseline);
252
- cursor += piece.w;
253
- }
254
- decorateLine(strokeAt, line, left, baseline);
255
- };
256
-
257
- // Draw one page's marking through the same operator path as body text, so the
258
- // face reuses the page's `fontKey` resource instead of growing the dictionary
259
- // per draw.
260
- /** @type {Drawing['watermark']} */
261
- let watermark = (mark) =>
262
- page.pushOperators(
263
- ...drawText(mark.line, {
264
- font: fontKey(mark.font),
265
- size: mark.size,
266
- color: GREY,
267
- x: mark.x,
268
- y: mark.y,
269
- rotate: mark.rotate,
270
- xSkew: NO_TURN,
271
- ySkew: NO_TURN,
272
- // Opacity needs a named ExtGState resource on the page, and pdf-lib
273
- // declares the only method that mints one private, with no public
274
- // equivalent. The cast is deliberate and stays as narrow as the call.
275
- graphicsState: /** @type {any} */ (page).maybeEmbedGraphicsState({ opacity: 0.15 }),
276
- }),
277
- );
278
-
279
- /** @type {Drawing} */
280
- let canvas = {
281
- ...blank(box, fonts),
282
- get count() {
283
- return pages.length;
284
- },
285
- newPage,
286
- rect,
287
- rule,
288
- picture,
289
- pictures,
290
- drawLine,
291
- // Re-open a finished page, for the passes that run over them all.
292
- select: (i) => {
293
- page = pages[i];
294
- },
295
- get refs() {
296
- return pages.map((each) => each.ref);
297
- },
298
- watermark,
299
- };
300
- return canvas;
301
- };
302
-
303
- // Nothing at all — what every primitive does when the canvas only measures.
304
- let MARKS_NOTHING = () => {};
305
-
306
- /**
307
- * The adapter that only measures: the same cursor arithmetic with every mark
308
- * discarded. It holds no document and no page, so a measurement cannot write.
309
- *
310
- * @type {(box: Frame, fonts: import('./fonts.js').Fonts) => Canvas}
311
- */
312
- let measuring = (box, fonts) => ({
313
- ...blank(box, fonts),
314
- count: 0,
315
- newPage: () => {
316
- throw new Error("probe reached newPage: the measuring path must not paginate");
317
- },
318
- rect: MARKS_NOTHING,
319
- rule: MARKS_NOTHING,
320
- picture: MARKS_NOTHING,
321
- drawLine: MARKS_NOTHING,
322
- });
323
-
324
- /**
325
- * Take on a content box someone else worked out — how much page furniture
326
- * needs is layout's policy (`reserve` there), while which of a frame's members
327
- * may move at all is this module's invariant. `top` and `bottom` are the two,
328
- * and the only two read here: the page box and the base size are fixed for the
329
- * whole document, so the rest of the frame that arrives is this canvas's own
330
- * and is left alone. Layout reads the bounds live, so every page the body then
331
- * flows through sees the narrowed box.
332
- *
333
- * Legal only while the render has not committed to a page — the band flow
334
- * adopts from its `report-start` handler, with its own first page open but
335
- * empty. `fresh` alone would not say that: a page turn makes it true again on
336
- * page five, where narrowing would silently mix two geometries in one document.
337
- *
338
- * @param {Canvas} canvas The canvas to narrow.
339
- * @param {Frame} box The frame it takes its content box from.
340
- */
341
- let adopt = (canvas, box) => {
342
- if (canvas.count > 1 || !canvas.fresh) throw Error("adopt: the content box is fixed");
343
- canvas.top = box.top;
344
- canvas.bottom = box.bottom;
345
- // The open page has drawn nothing, so its cursor moves with the box — the
346
- // same statement `newPage` makes, and nothing on the page can be lost by it.
347
- canvas.y = canvas.top;
348
- };
349
-
350
- // The unlicensed-output marking (LICENSE section 6): one translucent line
351
- // drawn corner-to-corner across the finished page — over the content, not
352
- // under it, so no filled table header or background rectangle can cover it.
353
- // The wording comes from the engine, on `report-start`; this target owns the
354
- // geometry, which depends only on the page size and never on key contents,
355
- // so document bytes stay deterministic in both licensed states.
356
-
357
- // The per-render half of the marking, computed once: page geometry, the face
358
- // and the wording are all fixed for a whole render, so the trig and the
359
- // glyph-width walk never repeat per page.
360
- /** @type {(canvas: Canvas, text: string) => any} */
361
- let stamp = (canvas, text) => {
362
- let font = canvas.fonts.families.sans[0];
363
- let angle = Math.atan2(canvas.height, canvas.width);
364
- // Scale the line to three quarters of the page diagonal, whatever the size.
365
- let span = Math.hypot(canvas.width, canvas.height) * 0.75;
366
- return {
367
- font,
368
- line: font.encodeText(text),
369
- size: (54 * span) / font.widthOfTextAtSize(text, 54),
370
- x: canvas.width / 2 - (span / 2) * Math.cos(angle),
371
- y: canvas.height / 2 - (span / 2) * Math.sin(angle),
372
- rotate: degrees((angle * 180) / Math.PI),
373
- };
374
- };
375
-
376
- export { adopt, decorateLine, drawing, frame, measuring, stamp };
package/lib/fonts.js DELETED
@@ -1,180 +0,0 @@
1
- /**
2
- * The faces a render can draw with.
3
- *
4
- * Every face is embedded on the document before the walk starts, so resolving
5
- * a style to a face during layout is synchronous — pdf-lib's `embedFont` is
6
- * async, and the walk is not. Embedding the twelve base-14 faces up front
7
- * costs under 200 bytes in the output, which is cheaper than threading a
8
- * two-pass measure/draw split through the layout to embed only what is used.
9
- */
10
- import { StandardFonts } from "pdf-lib";
11
-
12
- // The three families the vocabulary names, four variants each, indexed by
13
- // `bold + 2 * italic` — the same ordering the schema's `bold`/`italic` flags
14
- // produce. Unknown family names fall back to sans, as SCHEMA.md specifies.
15
- let BASE = {
16
- sans: [
17
- StandardFonts.Helvetica,
18
- StandardFonts.HelveticaBold,
19
- StandardFonts.HelveticaOblique,
20
- StandardFonts.HelveticaBoldOblique,
21
- ],
22
- serif: [
23
- StandardFonts.TimesRoman,
24
- StandardFonts.TimesRomanBold,
25
- StandardFonts.TimesRomanItalic,
26
- StandardFonts.TimesRomanBoldItalic,
27
- ],
28
- mono: [
29
- StandardFonts.Courier,
30
- StandardFonts.CourierBold,
31
- StandardFonts.CourierOblique,
32
- StandardFonts.CourierBoldOblique,
33
- ],
34
- };
35
-
36
- /** @typedef {{ families: Record<string, any[]> }} Fonts */
37
-
38
- // Host-supplied TrueType families need a font parser, which is an optional
39
- // peer: base-14 reports install nothing extra. Loaded only when `fonts` is
40
- // passed, and a missing package is reported as what it is.
41
- /** @type {(doc: any) => Promise<void>} */
42
- let useFontkit = async (doc) => {
43
- let fontkit;
44
- try {
45
- fontkit = (await import("@pdf-lib/fontkit")).default;
46
- } catch {
47
- throw Error("options.fonts: install @pdf-lib/fontkit to embed TrueType families");
48
- }
49
- doc.registerFontkit(fontkit);
50
- };
51
-
52
- /** @type {(doc: any) => Promise<Record<string, any[]>>} */
53
- let embedBase = async (doc) => {
54
- /** @type {Record<string, any[]>} */
55
- let families = {};
56
- for (let [name, variants] of Object.entries(BASE))
57
- families[name] = await Promise.all(variants.map((v) => doc.embedFont(v)));
58
- return families;
59
- };
60
-
61
- /** @type {(name: string, def: any) => string} */
62
- let asFamily = (name, def) => {
63
- let path = "options.fonts." + name;
64
- if (!def || typeof def !== "object")
65
- throw Error(path + ": expected { regular, bold?, italic?, boldItalic? }");
66
- if (def.regular == null) throw Error(path + ".regular: required");
67
- return path;
68
- };
69
-
70
- /** @type {(doc: any, src: any, path: string, variant: string) => Promise<any>} */
71
- let embedOne = async (doc, src, path, variant) => {
72
- try {
73
- return await doc.embedFont(src[variant], { subset: true });
74
- } catch (e) {
75
- throw Error(path + "." + variant + ": " + /** @type {Error} */ (e).message);
76
- }
77
- };
78
-
79
- // Missing variants fall back to the family's regular, so a face is
80
- // always resolvable once the family is named.
81
- /** @type {(doc: any, src: any, path: string, variant: string, faces: any[]) => Promise<any>} */
82
- let embedVariant = async (doc, src, path, variant, faces) => {
83
- if (src[variant] == null) return faces[0];
84
- return embedOne(doc, src, path, variant);
85
- };
86
-
87
- /** @type {(doc: any, name: string, def: any) => Promise<any[]>} */
88
- let familyOf = async (doc, name, def) => {
89
- let path = asFamily(name, def);
90
- /** @type {any[]} */
91
- let faces = [];
92
- for (let variant of ["regular", "bold", "italic", "boldItalic"])
93
- faces.push(await embedVariant(doc, def, path, variant, faces));
94
- return faces;
95
- };
96
-
97
- /** @type {(doc: any, custom: any, families: Record<string, any[]>) => Promise<void>} */
98
- let embedCustom = async (doc, custom, families) => {
99
- if (!(custom && Object.keys(custom).length)) return;
100
- await useFontkit(doc);
101
- for (let [name, def] of Object.entries(custom))
102
- families[name.toLowerCase()] = await familyOf(doc, name, def);
103
- };
104
-
105
- /**
106
- * Embed every face the render can reach and return the registry `face()` reads.
107
- *
108
- * @param {any} doc The pdf-lib document.
109
- * @param {any} custom `options.fonts`, or null.
110
- * @returns {Promise<Fonts>}
111
- */
112
- export async function embedFonts(doc, custom) {
113
- let families = await embedBase(doc);
114
- await embedCustom(doc, custom, families);
115
- return { families };
116
- }
117
-
118
- /** @type {(style: any) => string} */
119
- let familyName = (style) => (typeof style.family === "string" ? style.family.toLowerCase() : "");
120
-
121
- // Own-key lookup: the family name is render data, so `constructor` must
122
- // fall back to sans rather than resolve an inherited member.
123
- /** @type {(fonts: Fonts, name: string) => any[]} */
124
- let variantsOf = (fonts, name) =>
125
- Object.hasOwn(fonts.families, name) ? fonts.families[name] : fonts.families.sans;
126
-
127
- /** @type {(style: any) => number} */
128
- let faceIndex = (style) => (style.bold ? 1 : 0) | (style.italic ? 2 : 0);
129
-
130
- // Resolve a style block to one embedded face.
131
- /** @type {(fonts: Fonts, style: any) => any} */
132
- export function face(fonts, style) {
133
- return variantsOf(fonts, familyName(style))[faceIndex(style)];
134
- }
135
-
136
- // The distance from a line's top to its baseline: pdf-lib's ascender, which
137
- // matches the per-family fractions the hand-rolled writer used.
138
- /** @type {(font: any, size: number) => number} */
139
- export let ascOf = (font, size) => font.heightAtSize(size, { descender: false });
140
-
141
- // Text measurement. pdf-lib encodes on draw, so nothing here needs to know
142
- // about WinAnsi or glyph ids.
143
- /** @type {(font: any, text: string, size: number) => number} */
144
- export let width = (font, text, size) => font.widthOfTextAtSize(text, size);
145
-
146
- // Printable ASCII, which every face encodes and which almost all report text
147
- // is. Asking the face instead costs a full measurement per atom.
148
- let ASCII = /^[\x20-\x7E]*$/;
149
-
150
- /**
151
- * Replace characters a face cannot encode with `?`.
152
- *
153
- * The base-14 faces are WinAnsi and pdf-lib refuses anything outside it. Cell
154
- * text is untrusted data, so one stray character must not fail the render:
155
- * SCHEMA.md promises `?`, and full Unicode through `options.fonts`.
156
- *
157
- * There is no way to ask a pdf-lib face what it can encode without measuring
158
- * against it, so the ASCII guard carries the common case and only text with a
159
- * character outside it pays for the answer. WinAnsi is a superset of printable
160
- * ASCII, so the guard never lets an unencodable character through.
161
- *
162
- * @type {(font: any, text: string) => string}
163
- */
164
- export let printable = (font, text) => {
165
- if (!text || ASCII.test(text) || encodable(font, text)) return text;
166
- // WinAnsi encodability is a per-code-point question, so that is the unit to
167
- // ask it in — the grapheme clusters the rule protects are unencodable anyway.
168
- // oxlint-disable-next-line typescript/no-misused-spread
169
- return [...text].map((char) => (encodable(font, char) ? char : "?")).join("");
170
- };
171
-
172
- /** @type {(font: any, text: string) => boolean} */
173
- let encodable = (font, text) => {
174
- try {
175
- font.widthOfTextAtSize(text, 1);
176
- return true;
177
- } catch {
178
- return false;
179
- }
180
- };
package/lib/image.js DELETED
@@ -1,58 +0,0 @@
1
- /**
2
- * An image's own size, read from its header. Sizing is a fact about the bytes
3
- * rather than anything a page can answer, so it sits here and not on the
4
- * drawing surface: the band flow asks for it while measuring, where no
5
- * document is in reach at all, and again while drawing, where one is.
6
- *
7
- * Read, never decoded — a PNG's IHDR and a JPEG's frame header carry the two
8
- * numbers this target needs, and nothing else here looks at a pixel.
9
- *
10
- * The XLSX target reads the same two headers for its own placement, in pixels.
11
- * Whether that fact belongs on the engine's event instead — it already reads
12
- * the magic numbers to name the format — is bead `quario-b0h`.
13
- */
14
-
15
- // Points, at the conventional 96 dpi the schema names: 72/96 of a pixel.
16
- let PER_PX = 72 / 96;
17
-
18
- /** @type {(bytes: Uint8Array, at: number) => number} */
19
- let word = (bytes, at) => (bytes[at] << 8) | bytes[at + 1];
20
-
21
- /** @type {(code: number) => boolean} */
22
- let inSof = (code) => code >= 0xc0 && code <= 0xcf;
23
-
24
- /** @type {(code: number) => boolean} */
25
- let notTable = (code) => code !== 0xc4 && code !== 0xc8 && code !== 0xcc;
26
-
27
- // The frame header carries the dimensions, and it is the first SOFn marker:
28
- // every code in C0..CF except the three in that range that are not frames --
29
- // DHT, the JPG extension, and DAC.
30
- /** @type {(code: number) => boolean} */
31
- let isFrame = (code) => inSof(code) && notTable(code);
32
-
33
- /** @type {(bytes: Uint8Array) => { w: number, h: number }} */
34
- let jpegSize = (bytes) => {
35
- for (let at = 2; at + 9 < bytes.length; at += 2 + word(bytes, at + 2)) {
36
- if (bytes[at] !== 0xff) break;
37
- if (isFrame(bytes[at + 1])) return { w: word(bytes, at + 7), h: word(bytes, at + 5) };
38
- }
39
- return { w: 0, h: 0 };
40
- };
41
-
42
- /**
43
- * The image's intrinsic size in points. The format is the engine's sniff,
44
- * riding on the event, so nothing here decides it a second time.
45
- *
46
- * @param {Uint8Array} bytes The image file.
47
- * @param {string} format The engine's sniff: `"png"` or `"jpeg"`.
48
- * @returns {{ w: number, h: number }} The size, in points.
49
- */
50
- export let intrinsic = (bytes, format) => {
51
- // A PNG carries the two numbers in its IHDR at a fixed offset.
52
- let { w, h } = format === "png" ? { w: word(bytes, 18), h: word(bytes, 22) } : jpegSize(bytes);
53
- // The engine vouched for the magic numbers, not for the rest of the file:
54
- // a truncated header reaches here as a zero, and failing loudly beats
55
- // drawing an image with no size (SCHEMA.md, "Image item").
56
- if (!(w > 0 && h > 0)) throw Error("image: could not read the image's size from its bytes");
57
- return { w: w * PER_PX, h: h * PER_PX };
58
- };