@documonster/mcp 0.10.0 → 0.11.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/README.md +170 -51
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +28 -0
- package/dist/capabilities.js.map +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +10 -2
- package/dist/config.js.map +1 -1
- package/dist/tools/diagram-inspect.d.ts +20 -0
- package/dist/tools/diagram-inspect.d.ts.map +1 -0
- package/dist/tools/diagram-inspect.js +91 -0
- package/dist/tools/diagram-inspect.js.map +1 -0
- package/dist/tools/diagram-markdown.d.ts +45 -0
- package/dist/tools/diagram-markdown.d.ts.map +1 -0
- package/dist/tools/diagram-markdown.js +127 -0
- package/dist/tools/diagram-markdown.js.map +1 -0
- package/dist/tools/diagram-render.d.ts +14 -0
- package/dist/tools/diagram-render.d.ts.map +1 -0
- package/dist/tools/diagram-render.js +117 -0
- package/dist/tools/diagram-render.js.map +1 -0
- package/dist/tools/diagram.d.ts +217 -0
- package/dist/tools/diagram.d.ts.map +1 -0
- package/dist/tools/diagram.js +760 -0
- package/dist/tools/diagram.js.map +1 -0
- package/dist/tools/doc-convert.d.ts.map +1 -1
- package/dist/tools/doc-convert.js +26 -4
- package/dist/tools/doc-convert.js.map +1 -1
- package/dist/tools/doc-read.d.ts.map +1 -1
- package/dist/tools/doc-read.js +32 -4
- package/dist/tools/doc-read.js.map +1 -1
- package/dist/tools/doc-write.d.ts.map +1 -1
- package/dist/tools/doc-write.js +21 -3
- package/dist/tools/doc-write.js.map +1 -1
- package/dist/tools/document.d.ts +1 -1
- package/dist/tools/document.d.ts.map +1 -1
- package/dist/tools/document.js +13 -1
- package/dist/tools/document.js.map +1 -1
- package/dist/tools/help.d.ts +263 -15
- package/dist/tools/help.d.ts.map +1 -1
- package/dist/tools/help.js +263 -15
- package/dist/tools/help.js.map +1 -1
- package/dist/tools/image.d.ts +147 -0
- package/dist/tools/image.d.ts.map +1 -0
- package/dist/tools/image.js +749 -0
- package/dist/tools/image.js.map +1 -0
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +4 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/inspect.d.ts.map +1 -1
- package/dist/tools/inspect.js +98 -6
- package/dist/tools/inspect.js.map +1 -1
- package/dist/tools/pdf-edit.d.ts +13 -6
- package/dist/tools/pdf-edit.d.ts.map +1 -1
- package/dist/tools/pdf-edit.js +93 -8
- package/dist/tools/pdf-edit.js.map +1 -1
- package/dist/tools/sheet-edit.d.ts.map +1 -1
- package/dist/tools/sheet-edit.js +37 -2
- package/dist/tools/sheet-edit.js.map +1 -1
- package/dist/tools/sheet-image.d.ts +58 -0
- package/dist/tools/sheet-image.d.ts.map +1 -0
- package/dist/tools/sheet-image.js +156 -0
- package/dist/tools/sheet-image.js.map +1 -0
- package/dist/tools/sheet-read.d.ts.map +1 -1
- package/dist/tools/sheet-read.js +18 -1
- package/dist/tools/sheet-read.js.map +1 -1
- package/dist/tools/sheet-write.d.ts.map +1 -1
- package/dist/tools/sheet-write.js +41 -4
- package/dist/tools/sheet-write.js.map +1 -1
- package/dist/tools/template.d.ts +19 -0
- package/dist/tools/template.d.ts.map +1 -1
- package/dist/tools/template.js +436 -11
- package/dist/tools/template.js.map +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,749 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image sources, shared by every tool that can put a picture somewhere.
|
|
3
|
+
*
|
|
4
|
+
* There is exactly one shape for "which image", and it routes on the file
|
|
5
|
+
* extension rather than on a discriminator the caller has to get right:
|
|
6
|
+
*
|
|
7
|
+
* - `from: "logo.png"` — a `.png` / `.jpg` / `.gif` file, embedded as it is.
|
|
8
|
+
* - `from: "design.md"` — a Markdown file's ```mermaid fence, drawn server-side.
|
|
9
|
+
* - `from: "flow.mmd"` — a diagram file, drawn server-side.
|
|
10
|
+
* - `source: "flowchart LR\n A --> B"` — Mermaid text, drawn server-side.
|
|
11
|
+
*
|
|
12
|
+
* That ordering is the point. A Mermaid diagram is *one source of an image*, not a
|
|
13
|
+
* separate feature: a tool that could only place diagrams would be a
|
|
14
|
+
* mermaid-shaped hole where generic image support belongs, and a caller with a
|
|
15
|
+
* PNG on disk would have no way in. The three raster formats are the three
|
|
16
|
+
* `Image.add` accepts, which is what makes the workbook path total.
|
|
17
|
+
*
|
|
18
|
+
* The natural size of a raster file has to be *read*, because two of the three
|
|
19
|
+
* destinations need it. A worksheet anchored to a single cell needs pixels; a Word
|
|
20
|
+
* template placeholder needs EMU and has no default at all. Guessing would place a
|
|
21
|
+
* portrait photograph as a square, and the model would never see it — so the
|
|
22
|
+
* dimension readers below fail loudly on a file they cannot parse rather than
|
|
23
|
+
* returning a 1×1 placeholder.
|
|
24
|
+
*/
|
|
25
|
+
import { readFile } from "node:fs/promises";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
import { crc32, unzlibSync } from "documonster/archive";
|
|
28
|
+
import { z } from "zod";
|
|
29
|
+
import { toolError } from "../errors.js";
|
|
30
|
+
import { resolveInRoot } from "../sandbox.js";
|
|
31
|
+
import { buildDrawList, parseDiagram, renderDiagram, resolveDiagramSource, toRenderOptions } from "./diagram.js";
|
|
32
|
+
import { assertReadableSize } from "./fs-helpers.js";
|
|
33
|
+
const RASTER_EXTENSIONS = {
|
|
34
|
+
".png": "png",
|
|
35
|
+
".jpg": "jpeg",
|
|
36
|
+
".jpeg": "jpeg",
|
|
37
|
+
".gif": "gif"
|
|
38
|
+
};
|
|
39
|
+
/** Extensions whose contents are a diagram to be drawn rather than an image to embed. */
|
|
40
|
+
const DIAGRAM_EXTENSIONS = [".mmd", ".mermaid", ".md", ".markdown"];
|
|
41
|
+
/** Pixels per inch a raster file's dimensions are read as — the CSS convention. */
|
|
42
|
+
const CSS_PIXELS_PER_INCH = 96;
|
|
43
|
+
/** Points per inch. A display list's unit. */
|
|
44
|
+
const POINTS_PER_INCH = 72;
|
|
45
|
+
/** EMU per inch, the unit Word measures a drawing in. */
|
|
46
|
+
const EMU_PER_INCH = 914400;
|
|
47
|
+
/** Metres per inch, for reading a PNG's `pHYs` pixels-per-metre. */
|
|
48
|
+
const METRES_PER_INCH = 0.0254;
|
|
49
|
+
/** Pixels per point a rendered diagram is rasterised at. 2 → 144 DPI. */
|
|
50
|
+
const DIAGRAM_RASTER_SCALE = 2;
|
|
51
|
+
/** Ceiling on a single embedded image's source bytes. */
|
|
52
|
+
const MAX_IMAGE_BYTES = 24 * 1024 * 1024;
|
|
53
|
+
/**
|
|
54
|
+
* Aggregate ceilings for one tool call.
|
|
55
|
+
*
|
|
56
|
+
* A per-file limit is not a budget: twenty files each just under it, or twenty
|
|
57
|
+
* diagrams each just under the rasteriser's own 40-megapixel cap, add up to
|
|
58
|
+
* gigabytes — every one of which is held as a `Uint8Array` until the workbook or
|
|
59
|
+
* document is serialised. The counts below are what a real report needs, and a
|
|
60
|
+
* caller who genuinely wants more can make a second call.
|
|
61
|
+
*/
|
|
62
|
+
const MAX_IMAGES_PER_CALL = 20;
|
|
63
|
+
const MAX_TOTAL_IMAGE_BYTES = 64 * 1024 * 1024;
|
|
64
|
+
const MAX_TOTAL_PIXELS = 80_000_000;
|
|
65
|
+
/**
|
|
66
|
+
* Largest intrinsic pixel dimension accepted from a raster header.
|
|
67
|
+
*
|
|
68
|
+
* A header is four bytes of attacker-controlled integer. `0xffffffff` parsed as a
|
|
69
|
+
* size yielded a 3.2-billion-point placement, which reaches Excel as a nonsense
|
|
70
|
+
* anchor and Word as an EMU value far outside the format's range. No real image is
|
|
71
|
+
* anywhere near this, so the cap costs nothing and closes the whole class.
|
|
72
|
+
*/
|
|
73
|
+
const MAX_INTRINSIC_PIXELS = 40_000;
|
|
74
|
+
/** A fresh budget for one tool call. */
|
|
75
|
+
export function newImageBudget() {
|
|
76
|
+
return { count: 0, bytes: 0, pixels: 0 };
|
|
77
|
+
}
|
|
78
|
+
function spend(budget, bytes, pixels, origin) {
|
|
79
|
+
budget.count += 1;
|
|
80
|
+
budget.bytes += bytes;
|
|
81
|
+
budget.pixels += pixels;
|
|
82
|
+
if (budget.count > MAX_IMAGES_PER_CALL) {
|
|
83
|
+
throw toolError.tooLarge(`this call places more than ${MAX_IMAGES_PER_CALL} images`, "Split it across several calls. Every picture is held in memory until the file is written.");
|
|
84
|
+
}
|
|
85
|
+
if (budget.bytes > MAX_TOTAL_IMAGE_BYTES) {
|
|
86
|
+
throw toolError.tooLarge(`the images in this call total more than ${Math.round(MAX_TOTAL_IMAGE_BYTES / (1024 * 1024))} MiB (reached at ${origin})`, "Split it across several calls, or shrink the sources.");
|
|
87
|
+
}
|
|
88
|
+
if (budget.pixels > MAX_TOTAL_PIXELS) {
|
|
89
|
+
throw toolError.tooLarge(`the images in this call total more than ${Math.round(MAX_TOTAL_PIXELS / 1e6)}M pixels (reached at ${origin})`, "Shrink the images, or lower the diagram sizes. Each one is decoded at full size before it is placed.");
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Where an image comes from and how big it should be.
|
|
94
|
+
*
|
|
95
|
+
* Spread into a tool's schema rather than nested, so the fields read as the
|
|
96
|
+
* tool's own and a caller does not have to discover an extra level of object.
|
|
97
|
+
*/
|
|
98
|
+
export const imageSourceShape = {
|
|
99
|
+
from: z
|
|
100
|
+
.string()
|
|
101
|
+
.optional()
|
|
102
|
+
.describe("Image path. A .png/.jpg/.gif is embedded as it is; a .mmd file or a ```mermaid fence in a .md file is drawn as a diagram first. Use this or `source`."),
|
|
103
|
+
source: z
|
|
104
|
+
.string()
|
|
105
|
+
.optional()
|
|
106
|
+
.describe("Mermaid diagram text, drawn server-side. Use this or `from`."),
|
|
107
|
+
index: z
|
|
108
|
+
.number()
|
|
109
|
+
.int()
|
|
110
|
+
.positive()
|
|
111
|
+
.optional()
|
|
112
|
+
.describe("Which ```mermaid fence, when `from` is a Markdown file with several. Defaults to 1."),
|
|
113
|
+
width: z
|
|
114
|
+
.number()
|
|
115
|
+
.positive()
|
|
116
|
+
.max(20_000)
|
|
117
|
+
.optional()
|
|
118
|
+
.describe("Display width in points (72 per inch). Omit for the image's natural size; the aspect ratio is kept whichever is given."),
|
|
119
|
+
height: z.number().positive().max(20_000).optional().describe("Display height in points."),
|
|
120
|
+
altText: z
|
|
121
|
+
.string()
|
|
122
|
+
.optional()
|
|
123
|
+
.describe("Alternative text, for accessibility. Defaults to the file name or the diagram type."),
|
|
124
|
+
theme: z
|
|
125
|
+
.enum(["default", "dark", "neutral"])
|
|
126
|
+
.optional()
|
|
127
|
+
.describe("Diagram sources only: colour set. Defaults to `default`."),
|
|
128
|
+
background: z
|
|
129
|
+
.string()
|
|
130
|
+
.optional()
|
|
131
|
+
.describe('Diagram sources only: page colour, or "transparent". Defaults to white.')
|
|
132
|
+
};
|
|
133
|
+
/**
|
|
134
|
+
* Turn an image source into bytes and a display size.
|
|
135
|
+
*
|
|
136
|
+
* @throws {McpToolError} `invalid_input` for a missing or ambiguous source, an
|
|
137
|
+
* unsupported extension, or a raster file whose header cannot be read.
|
|
138
|
+
*/
|
|
139
|
+
export async function resolveImageSource(config, args, options = {}) {
|
|
140
|
+
const hasFrom = typeof args.from === "string" && args.from.trim().length > 0;
|
|
141
|
+
const hasSource = typeof args.source === "string" && args.source.trim().length > 0;
|
|
142
|
+
if (hasFrom === hasSource) {
|
|
143
|
+
throw toolError.invalidInput(hasFrom
|
|
144
|
+
? "pass either `from` or `source` for the image, not both"
|
|
145
|
+
: "no image source: pass `from` with a path, or `source` with Mermaid diagram text", "`from` takes a .png/.jpg/.gif to embed, or a .mmd/.md to draw. `source` takes Mermaid text.");
|
|
146
|
+
}
|
|
147
|
+
const extension = hasFrom ? path.extname(args.from).toLowerCase() : "";
|
|
148
|
+
const raster = RASTER_EXTENSIONS[extension];
|
|
149
|
+
if (hasFrom && raster === undefined && !DIAGRAM_EXTENSIONS.includes(extension)) {
|
|
150
|
+
throw toolError.invalidInput(`cannot use ${JSON.stringify(args.from)} as an image`, `Supported: ${Object.keys(RASTER_EXTENSIONS).join(", ")} to embed, or ${DIAGRAM_EXTENSIONS.join(", ")} to draw a diagram.`);
|
|
151
|
+
}
|
|
152
|
+
const natural = raster === undefined
|
|
153
|
+
? await drawDiagram(config, args, options.maxWidthPoints)
|
|
154
|
+
: await readRaster(config, args.from, raster);
|
|
155
|
+
const scaled = fit(natural.width, natural.height, args, options.maxWidthPoints);
|
|
156
|
+
if (options.budget !== undefined) {
|
|
157
|
+
spend(options.budget, natural.bytes.length, natural.pixels, natural.origin);
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
bytes: natural.bytes,
|
|
161
|
+
mediaType: natural.mediaType,
|
|
162
|
+
width: scaled.width,
|
|
163
|
+
height: scaled.height,
|
|
164
|
+
fileName: natural.fileName,
|
|
165
|
+
altText: args.altText ?? natural.altText,
|
|
166
|
+
origin: natural.origin,
|
|
167
|
+
...(natural.diagram === undefined ? {} : { diagram: natural.diagram })
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
/** Read a raster file and its intrinsic size. */
|
|
171
|
+
async function readRaster(config, display, mediaType) {
|
|
172
|
+
const resolved = await resolveInRoot(config, display, { mustExist: true });
|
|
173
|
+
const size = await assertReadableSize(config, resolved, display);
|
|
174
|
+
if (size > MAX_IMAGE_BYTES) {
|
|
175
|
+
throw toolError.tooLarge(`${display} is ${size} bytes, over the ${MAX_IMAGE_BYTES} byte image limit`, "Embedding it would be held in memory twice. Shrink the image first.");
|
|
176
|
+
}
|
|
177
|
+
const bytes = new Uint8Array(await readFile(resolved));
|
|
178
|
+
const measured = readIntrinsicSize(bytes, mediaType, display);
|
|
179
|
+
// The file's own declared resolution when it has one, else the CSS convention of
|
|
180
|
+
// 96 per inch. This matters: Word and Excel honour a PNG's `pHYs` chunk and a
|
|
181
|
+
// JPEG's JFIF density, so a 300-dpi photograph they place at one inch would be
|
|
182
|
+
// placed by this tool at 3⅛ inches if the declaration were ignored.
|
|
183
|
+
const dpi = measured.dpi ?? CSS_PIXELS_PER_INCH;
|
|
184
|
+
return {
|
|
185
|
+
bytes,
|
|
186
|
+
mediaType,
|
|
187
|
+
width: (measured.width * POINTS_PER_INCH) / dpi,
|
|
188
|
+
height: (measured.height * POINTS_PER_INCH) / dpi,
|
|
189
|
+
pixels: measured.width * measured.height,
|
|
190
|
+
fileName: path.basename(display),
|
|
191
|
+
altText: path.basename(display, path.extname(display)),
|
|
192
|
+
origin: display
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Draw a Mermaid source to PNG.
|
|
197
|
+
*
|
|
198
|
+
* `maxWidthPoints` is applied to the **raster**, not only to the display size. A
|
|
199
|
+
* 1034-point flowchart headed for a 468-point text column was previously rasterised
|
|
200
|
+
* at its full natural size and then merely declared smaller, so more than half the
|
|
201
|
+
* pixels — and the CPU and memory that produced them — were thrown away by Word.
|
|
202
|
+
*/
|
|
203
|
+
async function drawDiagram(config, args, maxWidthPoints) {
|
|
204
|
+
const resolved = await resolveDiagramSource(config, args);
|
|
205
|
+
const diagram = parseDiagram(resolved.source);
|
|
206
|
+
const style = toRenderOptions({
|
|
207
|
+
...(args.theme === undefined ? {} : { theme: args.theme }),
|
|
208
|
+
...(args.background === undefined ? {} : { background: args.background })
|
|
209
|
+
});
|
|
210
|
+
const list = buildDrawList(resolved.source, style);
|
|
211
|
+
// Points the picture will actually occupy, so the pixels are sized to that.
|
|
212
|
+
const targetWidth = maxWidthPoints === undefined ? list.width : Math.min(list.width, maxWidthPoints);
|
|
213
|
+
const scale = (DIAGRAM_RASTER_SCALE * targetWidth) / list.width;
|
|
214
|
+
const rendered = await renderDiagram(list, "png", { scale }, style.background);
|
|
215
|
+
return {
|
|
216
|
+
bytes: rendered.bytes,
|
|
217
|
+
mediaType: "png",
|
|
218
|
+
// A display list's unit is a point, so its own size is already the size the
|
|
219
|
+
// picture should occupy — the raster scale only decides how sharp it is.
|
|
220
|
+
width: list.width,
|
|
221
|
+
height: list.height,
|
|
222
|
+
pixels: rendered.width * rendered.height,
|
|
223
|
+
fileName: `${diagram.kind}-diagram.png`,
|
|
224
|
+
altText: diagram.title ?? `${diagram.kind} diagram`,
|
|
225
|
+
origin: resolved.origin === "inline" ? "an inline Mermaid diagram" : resolved.origin,
|
|
226
|
+
diagram
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Apply the requested display size.
|
|
231
|
+
*
|
|
232
|
+
* One of `width`/`height` scales the other, because an image squashed to an
|
|
233
|
+
* aspect ratio nobody asked for is a defect the model cannot see. Both together
|
|
234
|
+
* are taken literally — a caller naming two numbers means them.
|
|
235
|
+
*/
|
|
236
|
+
function fit(naturalWidth, naturalHeight, args, maxWidthPoints) {
|
|
237
|
+
let width = naturalWidth;
|
|
238
|
+
let height = naturalHeight;
|
|
239
|
+
if (args.width !== undefined && args.height !== undefined) {
|
|
240
|
+
width = args.width;
|
|
241
|
+
height = args.height;
|
|
242
|
+
}
|
|
243
|
+
else if (args.width !== undefined) {
|
|
244
|
+
width = args.width;
|
|
245
|
+
height = (naturalHeight * args.width) / naturalWidth;
|
|
246
|
+
}
|
|
247
|
+
else if (args.height !== undefined) {
|
|
248
|
+
height = args.height;
|
|
249
|
+
width = (naturalWidth * args.height) / naturalHeight;
|
|
250
|
+
}
|
|
251
|
+
if (maxWidthPoints !== undefined && width > maxWidthPoints) {
|
|
252
|
+
height = (height * maxWidthPoints) / width;
|
|
253
|
+
width = maxWidthPoints;
|
|
254
|
+
}
|
|
255
|
+
return { width, height };
|
|
256
|
+
}
|
|
257
|
+
/** Display size in EMU, the unit Word measures a drawing in. */
|
|
258
|
+
export function toEmu(points) {
|
|
259
|
+
return Math.round((points * EMU_PER_INCH) / POINTS_PER_INCH);
|
|
260
|
+
}
|
|
261
|
+
/** Display size in CSS pixels, the unit a worksheet anchor's `ext` is in. */
|
|
262
|
+
export function toCssPixels(points) {
|
|
263
|
+
return Math.round((points * CSS_PIXELS_PER_INCH) / POINTS_PER_INCH);
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Read an image's intrinsic size and resolution from its header.
|
|
267
|
+
*
|
|
268
|
+
* Deliberately strict, in two ways the library's own reader is not.
|
|
269
|
+
*
|
|
270
|
+
* It **refuses** what it cannot parse. `parseImageDimensions` answers `1×1` for an
|
|
271
|
+
* unreadable file, which is right for a renderer that must draw something and wrong
|
|
272
|
+
* here: a 1×1 placement is invisible, and nothing downstream can look at the result
|
|
273
|
+
* to notice. So a bad header is an `invalid_input` naming the file.
|
|
274
|
+
*
|
|
275
|
+
* It also **bounds** what it accepts. A header is four bytes of caller-controlled
|
|
276
|
+
* integer; `0xffffffff` read as a width produced a placement 3.2 billion points
|
|
277
|
+
* wide. Real images are nowhere near {@link MAX_INTRINSIC_PIXELS}.
|
|
278
|
+
*
|
|
279
|
+
* What it is still not: a decoder. A structurally plausible header over truncated
|
|
280
|
+
* pixel data passes, and would reach Word or Excel as a media part they cannot
|
|
281
|
+
* display. Proving decodability means decoding, which is a different order of cost
|
|
282
|
+
* for a check whose purpose is to size a box — so the boundary is stated here rather
|
|
283
|
+
* than implied.
|
|
284
|
+
*/
|
|
285
|
+
function readIntrinsicSize(bytes, mediaType, display) {
|
|
286
|
+
const size = mediaType === "png" ? pngSize(bytes) : mediaType === "gif" ? gifSize(bytes) : jpegSize(bytes);
|
|
287
|
+
if (size === undefined || size.width <= 0 || size.height <= 0) {
|
|
288
|
+
throw toolError.invalidInput(`could not read the pixel size of ${display}`, `The bytes are not a readable ${mediaType.toUpperCase()} header. Run doc_inspect on it — the extension may not match the content.`);
|
|
289
|
+
}
|
|
290
|
+
if (size.width > MAX_INTRINSIC_PIXELS || size.height > MAX_INTRINSIC_PIXELS) {
|
|
291
|
+
throw toolError.tooLarge(`${display} declares ${size.width}×${size.height} pixels, over the ${MAX_INTRINSIC_PIXELS} pixel per-axis limit`, "Either the file is enormous or its header is corrupt. Check it with doc_inspect.");
|
|
292
|
+
}
|
|
293
|
+
// A readable header is not a usable file. Embedding a truncated or corrupt image
|
|
294
|
+
// produces a media part Word and Excel cannot display — and since nothing
|
|
295
|
+
// downstream can look at a picture, the report would claim success over a
|
|
296
|
+
// document that shows a broken-image box. So the container is checked through to
|
|
297
|
+
// its end marker before the bytes are allowed into a package.
|
|
298
|
+
const damage = describeDamage(bytes, mediaType, size);
|
|
299
|
+
if (damage !== undefined) {
|
|
300
|
+
throw toolError.invalidInput(`${display} is not a usable ${mediaType.toUpperCase()} file: ${damage}`, "Embedding it would produce a document with a broken image. Re-export or re-download the file.");
|
|
301
|
+
}
|
|
302
|
+
return size;
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* What is wrong with the container, or `undefined` when it is intact.
|
|
306
|
+
*
|
|
307
|
+
* This is an integrity check, not a decoder. It proves the file is *complete and
|
|
308
|
+
* uncorrupted* — every PNG chunk's CRC-32 verifies and its compressed image data
|
|
309
|
+
* inflates to exactly the expected byte count; a JPEG's segment chain reaches its
|
|
310
|
+
* end-of-image marker; a GIF's block chain reaches its trailer. What it deliberately
|
|
311
|
+
* does not do is reconstruct pixels: un-filtering a PNG scanline or entropy-decoding
|
|
312
|
+
* a JPEG would answer the same question at many times the cost, because a container
|
|
313
|
+
* that passes these tests and still fails to render is a decoder bug rather than a
|
|
314
|
+
* damaged file.
|
|
315
|
+
*
|
|
316
|
+
* The distinction that matters is truncation, which is overwhelmingly the way a real
|
|
317
|
+
* image arrives broken — an interrupted download, a partial copy, a `head -c` — and
|
|
318
|
+
* which a header check cannot see at all.
|
|
319
|
+
*/
|
|
320
|
+
function describeDamage(bytes, mediaType, size) {
|
|
321
|
+
if (mediaType === "png") {
|
|
322
|
+
return pngDamage(bytes, size);
|
|
323
|
+
}
|
|
324
|
+
if (mediaType === "gif") {
|
|
325
|
+
return gifDamage(bytes);
|
|
326
|
+
}
|
|
327
|
+
return jpegDamage(bytes);
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Walk a PNG's chunk chain, verifying each CRC and the image data itself.
|
|
331
|
+
*
|
|
332
|
+
* The CRC is the point: PNG stores one per chunk precisely so corruption is
|
|
333
|
+
* detectable, and checking it turns "these bytes look like a PNG" into "these bytes
|
|
334
|
+
* are the PNG that was written". Inflating the concatenated `IDAT` payload then
|
|
335
|
+
* confirms the compressed stream is complete and that it holds exactly one filter
|
|
336
|
+
* byte plus one row of samples for every scanline — the check that catches a file
|
|
337
|
+
* cut off half way.
|
|
338
|
+
*/
|
|
339
|
+
function pngDamage(bytes, size) {
|
|
340
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
341
|
+
const idat = [];
|
|
342
|
+
let offset = 8;
|
|
343
|
+
let sawIhdr = false;
|
|
344
|
+
let sawIend = false;
|
|
345
|
+
let interlaced = false;
|
|
346
|
+
while (offset + 12 <= bytes.length) {
|
|
347
|
+
const length = view.getUint32(offset);
|
|
348
|
+
const end = offset + 12 + length;
|
|
349
|
+
if (length > bytes.length || end > bytes.length) {
|
|
350
|
+
return "the chunk stream is truncated";
|
|
351
|
+
}
|
|
352
|
+
const type = new TextDecoder("latin1").decode(bytes.subarray(offset + 4, offset + 8));
|
|
353
|
+
// The CRC covers the type and the payload, but not the length.
|
|
354
|
+
const expected = view.getUint32(end - 4);
|
|
355
|
+
if (crc32(bytes.subarray(offset + 4, end - 4)) !== expected) {
|
|
356
|
+
return `the ${type} chunk fails its CRC, so the file is corrupt`;
|
|
357
|
+
}
|
|
358
|
+
if (type === "IHDR") {
|
|
359
|
+
sawIhdr = true;
|
|
360
|
+
interlaced = bytes[offset + 8 + 12] === 1;
|
|
361
|
+
}
|
|
362
|
+
else if (type === "IDAT") {
|
|
363
|
+
idat.push(bytes.subarray(offset + 8, end - 4));
|
|
364
|
+
}
|
|
365
|
+
else if (type === "IEND") {
|
|
366
|
+
sawIend = true;
|
|
367
|
+
break;
|
|
368
|
+
}
|
|
369
|
+
offset = end;
|
|
370
|
+
}
|
|
371
|
+
if (!sawIhdr) {
|
|
372
|
+
return "it has no IHDR chunk";
|
|
373
|
+
}
|
|
374
|
+
if (idat.length === 0) {
|
|
375
|
+
return "it contains no image data";
|
|
376
|
+
}
|
|
377
|
+
if (!sawIend) {
|
|
378
|
+
return "it has no IEND chunk, so it is truncated";
|
|
379
|
+
}
|
|
380
|
+
// Interlaced PNGs split the image into seven passes, so the inflated length is not
|
|
381
|
+
// a simple product. The stream is still inflated to prove it is complete; only the
|
|
382
|
+
// length assertion is skipped.
|
|
383
|
+
const expectedBytes = interlaced ? undefined : expectedRawSize(bytes, size);
|
|
384
|
+
try {
|
|
385
|
+
const inflated = unzlibSync(concat(idat));
|
|
386
|
+
if (expectedBytes !== undefined && inflated.length !== expectedBytes) {
|
|
387
|
+
return `its image data inflates to ${inflated.length} bytes where ${expectedBytes} are needed for ${size.width}×${size.height}`;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
return "its compressed image data will not inflate";
|
|
392
|
+
}
|
|
393
|
+
return undefined;
|
|
394
|
+
}
|
|
395
|
+
/** Bytes a non-interlaced PNG's scanlines occupy: a filter byte plus samples per row. */
|
|
396
|
+
function expectedRawSize(bytes, size) {
|
|
397
|
+
const bitDepth = bytes[24] ?? 0;
|
|
398
|
+
const colourType = bytes[25] ?? 0;
|
|
399
|
+
const channels = PNG_CHANNELS[colourType];
|
|
400
|
+
if (channels === undefined) {
|
|
401
|
+
return undefined;
|
|
402
|
+
}
|
|
403
|
+
const bitsPerRow = size.width * channels * bitDepth;
|
|
404
|
+
return size.height * (1 + Math.ceil(bitsPerRow / 8));
|
|
405
|
+
}
|
|
406
|
+
/** Samples per pixel for each PNG colour type. */
|
|
407
|
+
const PNG_CHANNELS = {
|
|
408
|
+
0: 1, // greyscale
|
|
409
|
+
2: 3, // truecolour
|
|
410
|
+
3: 1, // palette index
|
|
411
|
+
4: 2, // greyscale + alpha
|
|
412
|
+
6: 4 // truecolour + alpha
|
|
413
|
+
};
|
|
414
|
+
function concat(parts) {
|
|
415
|
+
const total = parts.reduce((sum, part) => sum + part.length, 0);
|
|
416
|
+
const out = new Uint8Array(total);
|
|
417
|
+
let at = 0;
|
|
418
|
+
for (const part of parts) {
|
|
419
|
+
out.set(part, at);
|
|
420
|
+
at += part.length;
|
|
421
|
+
}
|
|
422
|
+
return out;
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Walk a JPEG's segments to the end-of-image marker.
|
|
426
|
+
*
|
|
427
|
+
* Entropy-coded data cannot be walked as segments, so once SOS is reached the only
|
|
428
|
+
* thing left to establish is that the file ends where a JPEG ends. `FF D9` at the
|
|
429
|
+
* tail is exactly the evidence a truncated download lacks.
|
|
430
|
+
*/
|
|
431
|
+
function jpegDamage(bytes) {
|
|
432
|
+
let offset = 2;
|
|
433
|
+
let sawFrame = false;
|
|
434
|
+
while (offset + 1 < bytes.length) {
|
|
435
|
+
if (bytes[offset] !== 0xff) {
|
|
436
|
+
return "its segment chain is malformed";
|
|
437
|
+
}
|
|
438
|
+
const marker = bytes[offset + 1] ?? 0;
|
|
439
|
+
if (marker === 0xff) {
|
|
440
|
+
offset += 1;
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
|
|
444
|
+
offset += 2;
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
if (marker === 0xd9) {
|
|
448
|
+
return sawFrame ? undefined : "it ends before declaring an image";
|
|
449
|
+
}
|
|
450
|
+
if (offset + 3 >= bytes.length) {
|
|
451
|
+
return "it is truncated inside a segment header";
|
|
452
|
+
}
|
|
453
|
+
const length = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint16(offset + 2);
|
|
454
|
+
if (length < 2 || offset + 2 + length > bytes.length) {
|
|
455
|
+
return "a segment declares a length past the end of the file";
|
|
456
|
+
}
|
|
457
|
+
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
|
458
|
+
sawFrame = true;
|
|
459
|
+
}
|
|
460
|
+
if (marker === 0xda) {
|
|
461
|
+
// Scan data follows; a complete file ends with EOI.
|
|
462
|
+
const tail = bytes.subarray(Math.max(0, bytes.length - 2));
|
|
463
|
+
return tail[0] === 0xff && tail[1] === 0xd9
|
|
464
|
+
? undefined
|
|
465
|
+
: "it has no end-of-image marker, so it is truncated";
|
|
466
|
+
}
|
|
467
|
+
offset += 2 + length;
|
|
468
|
+
}
|
|
469
|
+
return "it contains no image data";
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Walk a GIF's block chain to the trailer.
|
|
473
|
+
*
|
|
474
|
+
* Every GIF block is length-prefixed, so the chain either arrives at `0x3B` or the
|
|
475
|
+
* file is short. Sub-block lists are followed rather than skipped, because that is
|
|
476
|
+
* where a truncated file runs out.
|
|
477
|
+
*/
|
|
478
|
+
function gifDamage(bytes) {
|
|
479
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
480
|
+
let offset = 13;
|
|
481
|
+
// A global colour table, when the packed field says so.
|
|
482
|
+
const packed = bytes[10] ?? 0;
|
|
483
|
+
if ((packed & 0x80) !== 0) {
|
|
484
|
+
offset += 3 * 2 ** ((packed & 0x07) + 1);
|
|
485
|
+
}
|
|
486
|
+
while (offset < bytes.length) {
|
|
487
|
+
const block = bytes[offset];
|
|
488
|
+
if (block === 0x3b) {
|
|
489
|
+
return undefined;
|
|
490
|
+
}
|
|
491
|
+
if (block === 0x21) {
|
|
492
|
+
// Extension: label, then sub-blocks.
|
|
493
|
+
offset += 2;
|
|
494
|
+
const after = skipSubBlocks(bytes, offset);
|
|
495
|
+
if (after === undefined) {
|
|
496
|
+
return "an extension block runs past the end of the file";
|
|
497
|
+
}
|
|
498
|
+
offset = after;
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
if (block === 0x2c) {
|
|
502
|
+
if (offset + 10 > bytes.length) {
|
|
503
|
+
return "an image descriptor is truncated";
|
|
504
|
+
}
|
|
505
|
+
const localPacked = bytes[offset + 9] ?? 0;
|
|
506
|
+
offset += 10;
|
|
507
|
+
if ((localPacked & 0x80) !== 0) {
|
|
508
|
+
offset += 3 * 2 ** ((localPacked & 0x07) + 1);
|
|
509
|
+
}
|
|
510
|
+
// The LZW minimum code size, then the image's sub-blocks.
|
|
511
|
+
offset += 1;
|
|
512
|
+
const after = skipSubBlocks(bytes, offset);
|
|
513
|
+
if (after === undefined) {
|
|
514
|
+
return "the image data runs past the end of the file";
|
|
515
|
+
}
|
|
516
|
+
offset = after;
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
return `it contains an unknown block type 0x${(block ?? 0).toString(16)}`;
|
|
520
|
+
}
|
|
521
|
+
void view;
|
|
522
|
+
return "it has no trailer, so it is truncated";
|
|
523
|
+
}
|
|
524
|
+
/** Walk a GIF sub-block list, returning the offset just past its terminator. */
|
|
525
|
+
function skipSubBlocks(bytes, start) {
|
|
526
|
+
let offset = start;
|
|
527
|
+
while (offset < bytes.length) {
|
|
528
|
+
const size = bytes[offset] ?? 0;
|
|
529
|
+
if (size === 0) {
|
|
530
|
+
return offset + 1;
|
|
531
|
+
}
|
|
532
|
+
offset += 1 + size;
|
|
533
|
+
}
|
|
534
|
+
return undefined;
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* PNG: IHDR is always the first chunk, and `pHYs` carries the resolution.
|
|
538
|
+
*
|
|
539
|
+
* The IHDR length is checked rather than assumed, because a file whose fourth word
|
|
540
|
+
* is not 13 is not a PNG and the bytes at 16–23 would then be something else
|
|
541
|
+
* entirely.
|
|
542
|
+
*/
|
|
543
|
+
function pngSize(bytes) {
|
|
544
|
+
const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
545
|
+
if (bytes.length < 33 || !signature.every((byte, index) => bytes[index] === byte)) {
|
|
546
|
+
return undefined;
|
|
547
|
+
}
|
|
548
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
549
|
+
if (view.getUint32(8) !== 13 || !hasChunkType(bytes, 12, "IHDR")) {
|
|
550
|
+
return undefined;
|
|
551
|
+
}
|
|
552
|
+
const bitDepth = bytes[24] ?? 0;
|
|
553
|
+
const colourType = bytes[25] ?? 0xff;
|
|
554
|
+
// The values PNG actually defines. A file outside them is not one.
|
|
555
|
+
if (![1, 2, 4, 8, 16].includes(bitDepth) || ![0, 2, 3, 4, 6].includes(colourType)) {
|
|
556
|
+
return undefined;
|
|
557
|
+
}
|
|
558
|
+
const dpi = pngResolution(bytes, view);
|
|
559
|
+
return {
|
|
560
|
+
width: view.getUint32(16),
|
|
561
|
+
height: view.getUint32(20),
|
|
562
|
+
...(dpi === undefined ? {} : { dpi })
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Walk PNG chunks for `pHYs`, which states pixels per metre.
|
|
567
|
+
*
|
|
568
|
+
* Stops at the first `IDAT`: `pHYs` must precede the image data, so continuing past
|
|
569
|
+
* it would only scan the payload for a byte pattern that happens to spell a chunk
|
|
570
|
+
* name.
|
|
571
|
+
*/
|
|
572
|
+
function pngResolution(bytes, view) {
|
|
573
|
+
let offset = 8;
|
|
574
|
+
while (offset + 8 <= bytes.length) {
|
|
575
|
+
const length = view.getUint32(offset);
|
|
576
|
+
if (hasChunkType(bytes, offset + 4, "IDAT")) {
|
|
577
|
+
return undefined;
|
|
578
|
+
}
|
|
579
|
+
if (hasChunkType(bytes, offset + 4, "pHYs") && offset + 8 + 9 <= bytes.length) {
|
|
580
|
+
const perUnitX = view.getUint32(offset + 8);
|
|
581
|
+
const unit = bytes[offset + 8 + 8];
|
|
582
|
+
// unit 1 is metres; unit 0 means "aspect ratio only" and states no resolution.
|
|
583
|
+
return unit === 1 && perUnitX > 0 ? perUnitX * METRES_PER_INCH : undefined;
|
|
584
|
+
}
|
|
585
|
+
// 4 length + 4 type + payload + 4 CRC.
|
|
586
|
+
const next = offset + 12 + length;
|
|
587
|
+
if (next <= offset) {
|
|
588
|
+
return undefined;
|
|
589
|
+
}
|
|
590
|
+
offset = next;
|
|
591
|
+
}
|
|
592
|
+
return undefined;
|
|
593
|
+
}
|
|
594
|
+
function hasChunkType(bytes, offset, type) {
|
|
595
|
+
for (let index = 0; index < 4; index += 1) {
|
|
596
|
+
if (bytes[offset + index] !== type.charCodeAt(index)) {
|
|
597
|
+
return false;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
return true;
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* GIF: the logical screen size sits at a fixed offset, little-endian.
|
|
604
|
+
*
|
|
605
|
+
* The full `GIF87a`/`GIF89a` signature is checked, not just `GIF` — three bytes is
|
|
606
|
+
* a prefix a great many files share by accident. GIF declares no resolution.
|
|
607
|
+
*/
|
|
608
|
+
function gifSize(bytes) {
|
|
609
|
+
if (bytes.length < 13) {
|
|
610
|
+
return undefined;
|
|
611
|
+
}
|
|
612
|
+
const signature = new TextDecoder("latin1").decode(bytes.subarray(0, 6));
|
|
613
|
+
if (signature !== "GIF87a" && signature !== "GIF89a") {
|
|
614
|
+
return undefined;
|
|
615
|
+
}
|
|
616
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
617
|
+
return { width: view.getUint16(6, true), height: view.getUint16(8, true) };
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* JPEG: walk the segment chain to the frame header, and read JFIF density on the way.
|
|
621
|
+
*
|
|
622
|
+
* There is no fixed offset — a JPEG is a chain of length-prefixed segments and the
|
|
623
|
+
* size lives in whichever SOF marker comes first. Three details are easy to get
|
|
624
|
+
* wrong and all three were:
|
|
625
|
+
*
|
|
626
|
+
* - The markers excluded below are the three in the `0xC0`–`0xCF` range that are
|
|
627
|
+
* *not* frame headers (DHT, JPG, DAC); reading one as a frame yields a plausible
|
|
628
|
+
* but wrong size.
|
|
629
|
+
* - A run of `0xFF` fill bytes is legal padding before a marker. Advancing by two
|
|
630
|
+
* over `FF FF C0` steps onto `FF` and then past `C0`, skipping the very frame
|
|
631
|
+
* header being looked for.
|
|
632
|
+
* - The scan is abandoned at SOS, because entropy-coded data follows and searching
|
|
633
|
+
* it for `FF C0` finds coincidences.
|
|
634
|
+
*/
|
|
635
|
+
function jpegSize(bytes) {
|
|
636
|
+
if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) {
|
|
637
|
+
return undefined;
|
|
638
|
+
}
|
|
639
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
640
|
+
let offset = 2;
|
|
641
|
+
let dpi;
|
|
642
|
+
while (offset + 1 < bytes.length) {
|
|
643
|
+
if (bytes[offset] !== 0xff) {
|
|
644
|
+
return undefined;
|
|
645
|
+
}
|
|
646
|
+
const marker = bytes[offset + 1] ?? 0;
|
|
647
|
+
// Fill byte: this is still the marker prefix, so advance one and look again.
|
|
648
|
+
if (marker === 0xff) {
|
|
649
|
+
offset += 1;
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
// Standalone markers carry no length field.
|
|
653
|
+
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd9)) {
|
|
654
|
+
offset += 2;
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
if (offset + 3 >= bytes.length) {
|
|
658
|
+
return undefined;
|
|
659
|
+
}
|
|
660
|
+
const length = view.getUint16(offset + 2);
|
|
661
|
+
if (length < 2 || offset + 2 + length > bytes.length) {
|
|
662
|
+
return undefined;
|
|
663
|
+
}
|
|
664
|
+
// SOS: entropy-coded data follows, so stop rather than scan it.
|
|
665
|
+
if (marker === 0xda) {
|
|
666
|
+
return undefined;
|
|
667
|
+
}
|
|
668
|
+
if (marker === 0xe0 && dpi === undefined) {
|
|
669
|
+
dpi = jfifResolution(bytes, view, offset + 4, length - 2);
|
|
670
|
+
}
|
|
671
|
+
const isFrame = marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
|
|
672
|
+
if (isFrame) {
|
|
673
|
+
if (offset + 9 >= bytes.length) {
|
|
674
|
+
return undefined;
|
|
675
|
+
}
|
|
676
|
+
return {
|
|
677
|
+
height: view.getUint16(offset + 5),
|
|
678
|
+
width: view.getUint16(offset + 7),
|
|
679
|
+
...(dpi === undefined ? {} : { dpi })
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
offset += 2 + length;
|
|
683
|
+
}
|
|
684
|
+
return undefined;
|
|
685
|
+
}
|
|
686
|
+
/** The X density from a JFIF APP0 segment, in dots per inch. */
|
|
687
|
+
function jfifResolution(bytes, view, payload, length) {
|
|
688
|
+
if (length < 12 || payload + 12 > bytes.length) {
|
|
689
|
+
return undefined;
|
|
690
|
+
}
|
|
691
|
+
if (new TextDecoder("latin1").decode(bytes.subarray(payload, payload + 5)) !== "JFIF\0") {
|
|
692
|
+
return undefined;
|
|
693
|
+
}
|
|
694
|
+
const units = bytes[payload + 7];
|
|
695
|
+
const density = view.getUint16(payload + 8);
|
|
696
|
+
if (density === 0) {
|
|
697
|
+
return undefined;
|
|
698
|
+
}
|
|
699
|
+
// 1 = dots per inch, 2 = dots per centimetre, 0 = aspect ratio only.
|
|
700
|
+
if (units === 1) {
|
|
701
|
+
return density;
|
|
702
|
+
}
|
|
703
|
+
return units === 2 ? density * 2.54 : undefined;
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* Read a raster header without throwing, for `doc_inspect`.
|
|
707
|
+
*
|
|
708
|
+
* Identification and placement want the same parse but different failure
|
|
709
|
+
* behaviour: placing must refuse a file it cannot size, whereas inspecting exists
|
|
710
|
+
* precisely to *report* that it cannot be sized. Sharing the parsers keeps the two
|
|
711
|
+
* answers consistent — a file `doc_inspect` calls unreadable is exactly one the
|
|
712
|
+
* placement tools reject.
|
|
713
|
+
*/
|
|
714
|
+
export function tryReadImageHeader(bytes) {
|
|
715
|
+
const candidates = ["png", "jpeg", "gif"];
|
|
716
|
+
for (const mediaType of candidates) {
|
|
717
|
+
const size = mediaType === "png" ? pngSize(bytes) : mediaType === "gif" ? gifSize(bytes) : jpegSize(bytes);
|
|
718
|
+
if (size === undefined || size.width <= 0 || size.height <= 0) {
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
721
|
+
const dpi = size.dpi ?? CSS_PIXELS_PER_INCH;
|
|
722
|
+
const damage = size.width > MAX_INTRINSIC_PIXELS || size.height > MAX_INTRINSIC_PIXELS
|
|
723
|
+
? `it declares ${size.width}×${size.height} pixels, past the ${MAX_INTRINSIC_PIXELS} per-axis limit`
|
|
724
|
+
: describeDamage(bytes, mediaType, size);
|
|
725
|
+
return {
|
|
726
|
+
mediaType,
|
|
727
|
+
width: size.width,
|
|
728
|
+
height: size.height,
|
|
729
|
+
...(size.dpi === undefined ? {} : { dpi: size.dpi }),
|
|
730
|
+
points: {
|
|
731
|
+
width: (size.width * POINTS_PER_INCH) / dpi,
|
|
732
|
+
height: (size.height * POINTS_PER_INCH) / dpi
|
|
733
|
+
},
|
|
734
|
+
...(damage === undefined ? {} : { damage })
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
return undefined;
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* Describe a placed image for a tool report.
|
|
741
|
+
*
|
|
742
|
+
* The diagram line is the load-bearing part: for a Mermaid source the model has
|
|
743
|
+
* no other way to check that the picture says what it meant.
|
|
744
|
+
*/
|
|
745
|
+
export function describePlacedImage(image, where) {
|
|
746
|
+
const size = `${Math.round(image.width)}×${Math.round(image.height)} pt`;
|
|
747
|
+
return `placed ${image.origin} at ${where} (${size}, ${image.mediaType})`;
|
|
748
|
+
}
|
|
749
|
+
//# sourceMappingURL=image.js.map
|