@afixt/screenshot-utils 0.9.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/CHANGELOG.md +39 -0
- package/LICENSE +13 -0
- package/README.md +132 -0
- package/bin/afixt-screenshot +14 -0
- package/dist/index.js +2797 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +2795 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +164 -0
- package/src/adapters/cdp.js +116 -0
- package/src/adapters/index.js +17 -0
- package/src/adapters/playwright.js +150 -0
- package/src/adapters/puppeteer.js +128 -0
- package/src/capture/element.js +257 -0
- package/src/capture/index.js +17 -0
- package/src/capture/long-page.js +267 -0
- package/src/capture/page.js +207 -0
- package/src/capture/responsive.js +94 -0
- package/src/capture/settle.js +182 -0
- package/src/capture/viewport.js +21 -0
- package/src/cli/index.js +266 -0
- package/src/compose/annotate.js +283 -0
- package/src/compose/diff.js +173 -0
- package/src/compose/heatmap.js +159 -0
- package/src/compose/index.js +8 -0
- package/src/compose/theme.js +45 -0
- package/src/errors/adapter.js +65 -0
- package/src/errors/base.js +48 -0
- package/src/errors/capture.js +175 -0
- package/src/errors/compose.js +78 -0
- package/src/errors/index.js +75 -0
- package/src/errors/optional.js +37 -0
- package/src/errors/redaction.js +42 -0
- package/src/errors/tile.js +61 -0
- package/src/errors/transform.js +78 -0
- package/src/index.js +69 -0
- package/src/redact/index.js +6 -0
- package/src/redact/policy.js +67 -0
- package/src/redact/redact.js +268 -0
- package/src/tile/dzi.js +268 -0
- package/src/tile/index.js +5 -0
- package/src/transform/convert.js +54 -0
- package/src/transform/crop.js +251 -0
- package/src/transform/index.js +8 -0
- package/src/transform/normalize.js +83 -0
- package/src/transform/resize.js +99 -0
- package/src/types/index.d.ts +432 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { BufferCorruptError, RedactionError, InvalidRectError } = require('../errors/index.js');
|
|
4
|
+
|
|
5
|
+
const VALID_MODES = new Set(['blur', 'mask', 'pixelate']);
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Apply one of three redaction modes to specified regions of an image.
|
|
9
|
+
*
|
|
10
|
+
* Implements `spec.md` § 8.5.1.
|
|
11
|
+
*
|
|
12
|
+
* - `mode: 'blur'` — Gaussian blur with the given `radius`.
|
|
13
|
+
* - `mode: 'mask'` — solid-color fill (`color`, default `#000`).
|
|
14
|
+
* - `mode: 'pixelate'` — downscale-then-upscale with the given `cellSize`.
|
|
15
|
+
*
|
|
16
|
+
* @public
|
|
17
|
+
* @param {Buffer} input
|
|
18
|
+
* @param {Array<import('../types/index.d.ts').RedactRegion>} regions
|
|
19
|
+
* @param {import('../types/index.d.ts').RedactOptions} [options]
|
|
20
|
+
* @returns {Promise<Buffer>}
|
|
21
|
+
*/
|
|
22
|
+
async function redact(input, regions, options = {}) {
|
|
23
|
+
if (process.env.AFIXT_SCREENSHOT_DISABLE_REDACTION === '1') {
|
|
24
|
+
// Spec § 11.3 escape hatch for test fixtures only.
|
|
25
|
+
return input;
|
|
26
|
+
}
|
|
27
|
+
assertBuffer(input);
|
|
28
|
+
if (!Array.isArray(regions)) {
|
|
29
|
+
throw new RedactionError('regions must be an array', {
|
|
30
|
+
context: { received: typeof regions },
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
const { default: defaultMode = 'blur', defaultRadius = 8 } = options;
|
|
34
|
+
if (!VALID_MODES.has(defaultMode)) {
|
|
35
|
+
throw new RedactionError(
|
|
36
|
+
`default mode must be one of ${[...VALID_MODES].join(', ')}; received ${defaultMode}`,
|
|
37
|
+
{ context: { received: defaultMode } }
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const sharp = require('sharp');
|
|
42
|
+
const meta = await readMeta(sharp, input);
|
|
43
|
+
|
|
44
|
+
if (regions.length === 0) {
|
|
45
|
+
return input;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Build the composite layer list. Each region produces ONE overlay
|
|
49
|
+
// that sharp composites at the right offset. Tiny rects (< 1px) are
|
|
50
|
+
// silently dropped — they cannot produce useful output.
|
|
51
|
+
const overlays = [];
|
|
52
|
+
for (const [i, region] of regions.entries()) {
|
|
53
|
+
const overlay = await buildOverlay(sharp, input, region, {
|
|
54
|
+
index: i,
|
|
55
|
+
defaultMode,
|
|
56
|
+
defaultRadius,
|
|
57
|
+
meta,
|
|
58
|
+
});
|
|
59
|
+
if (overlay) {
|
|
60
|
+
overlays.push(overlay);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return sharp(input).composite(overlays).toBuffer();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Produce a single composite descriptor for one region. Returns null
|
|
69
|
+
* when the region clamps to a zero-area rect after image-bound clamping.
|
|
70
|
+
*
|
|
71
|
+
* @param {ReturnType<typeof require>} sharp
|
|
72
|
+
* @param {Buffer} input
|
|
73
|
+
* @param {import('../types/index.d.ts').RedactRegion} region
|
|
74
|
+
* @param {{index: number, defaultMode: string, defaultRadius: number, meta: {width: number, height: number}}} ctx
|
|
75
|
+
* @returns {Promise<{input: Buffer, top: number, left: number} | null>}
|
|
76
|
+
*/
|
|
77
|
+
async function buildOverlay(sharp, input, region, ctx) {
|
|
78
|
+
assertRegion(region, ctx.index);
|
|
79
|
+
const mode = region.mode ?? ctx.defaultMode;
|
|
80
|
+
if (!VALID_MODES.has(mode)) {
|
|
81
|
+
throw new RedactionError(
|
|
82
|
+
`regions[${ctx.index}].mode must be 'blur', 'mask', or 'pixelate'; received ${mode}`,
|
|
83
|
+
{ context: { index: ctx.index, mode } }
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
const clamped = clampRect(region.rect, ctx.meta);
|
|
87
|
+
if (clamped.width <= 0 || clamped.height <= 0) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
switch (mode) {
|
|
92
|
+
case 'blur':
|
|
93
|
+
return blurOverlay(sharp, input, clamped, region.radius ?? ctx.defaultRadius);
|
|
94
|
+
case 'mask':
|
|
95
|
+
return maskOverlay(sharp, clamped, region.color ?? '#000');
|
|
96
|
+
case 'pixelate':
|
|
97
|
+
return pixelateOverlay(sharp, input, clamped, region.cellSize ?? 16);
|
|
98
|
+
default:
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Build a blurred crop overlay.
|
|
105
|
+
*
|
|
106
|
+
* @param {ReturnType<typeof require>} sharp
|
|
107
|
+
* @param {Buffer} input
|
|
108
|
+
* @param {{left: number, top: number, width: number, height: number}} rect
|
|
109
|
+
* @param {number} radius
|
|
110
|
+
* @returns {Promise<{input: Buffer, top: number, left: number}>}
|
|
111
|
+
*/
|
|
112
|
+
async function blurOverlay(sharp, input, rect, radius) {
|
|
113
|
+
const validRadius = Math.max(0.3, Math.min(1000, Number(radius) || 8));
|
|
114
|
+
const buffer = await sharp(input)
|
|
115
|
+
.extract({ left: rect.left, top: rect.top, width: rect.width, height: rect.height })
|
|
116
|
+
.blur(validRadius)
|
|
117
|
+
.toBuffer();
|
|
118
|
+
return { input: buffer, top: rect.top, left: rect.left };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Build a solid-color mask overlay.
|
|
123
|
+
*
|
|
124
|
+
* @param {ReturnType<typeof require>} sharp
|
|
125
|
+
* @param {{left: number, top: number, width: number, height: number}} rect
|
|
126
|
+
* @param {string} color CSS color string (#000 by default).
|
|
127
|
+
* @returns {Promise<{input: Buffer, top: number, left: number}>}
|
|
128
|
+
*/
|
|
129
|
+
async function maskOverlay(sharp, rect, color) {
|
|
130
|
+
const { r, g, b, alpha } = parseColor(color);
|
|
131
|
+
const buffer = await sharp({
|
|
132
|
+
create: {
|
|
133
|
+
width: rect.width,
|
|
134
|
+
height: rect.height,
|
|
135
|
+
channels: 4,
|
|
136
|
+
background: { r, g, b, alpha },
|
|
137
|
+
},
|
|
138
|
+
})
|
|
139
|
+
.png()
|
|
140
|
+
.toBuffer();
|
|
141
|
+
return { input: buffer, top: rect.top, left: rect.left };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Build a pixelated crop overlay (downsize then upsize, nearest-neighbour).
|
|
146
|
+
*
|
|
147
|
+
* @param {ReturnType<typeof require>} sharp
|
|
148
|
+
* @param {Buffer} input
|
|
149
|
+
* @param {{left: number, top: number, width: number, height: number}} rect
|
|
150
|
+
* @param {number} cellSize
|
|
151
|
+
* @returns {Promise<{input: Buffer, top: number, left: number}>}
|
|
152
|
+
*/
|
|
153
|
+
async function pixelateOverlay(sharp, input, rect, cellSize) {
|
|
154
|
+
const cs = Math.max(2, Math.round(cellSize) || 16);
|
|
155
|
+
const downW = Math.max(1, Math.floor(rect.width / cs));
|
|
156
|
+
const downH = Math.max(1, Math.floor(rect.height / cs));
|
|
157
|
+
const buffer = await sharp(input)
|
|
158
|
+
.extract({ left: rect.left, top: rect.top, width: rect.width, height: rect.height })
|
|
159
|
+
.resize({ width: downW, height: downH, kernel: 'nearest' })
|
|
160
|
+
.resize({ width: rect.width, height: rect.height, kernel: 'nearest' })
|
|
161
|
+
.toBuffer();
|
|
162
|
+
return { input: buffer, top: rect.top, left: rect.left };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Clamp a rect to image bounds and round to integers (sharp.extract needs ints).
|
|
167
|
+
*
|
|
168
|
+
* @param {import('../types/index.d.ts').Rect} rect
|
|
169
|
+
* @param {{width: number, height: number}} meta
|
|
170
|
+
* @returns {{left: number, top: number, width: number, height: number}}
|
|
171
|
+
*/
|
|
172
|
+
function clampRect(rect, meta) {
|
|
173
|
+
const left = Math.max(0, Math.min(meta.width, Math.round(rect.x)));
|
|
174
|
+
const top = Math.max(0, Math.min(meta.height, Math.round(rect.y)));
|
|
175
|
+
const right = Math.max(0, Math.min(meta.width, Math.round(rect.x + rect.width)));
|
|
176
|
+
const bottom = Math.max(0, Math.min(meta.height, Math.round(rect.y + rect.height)));
|
|
177
|
+
return {
|
|
178
|
+
left,
|
|
179
|
+
top,
|
|
180
|
+
width: Math.max(0, right - left),
|
|
181
|
+
height: Math.max(0, bottom - top),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function assertRegion(region, index) {
|
|
186
|
+
if (!region || typeof region !== 'object') {
|
|
187
|
+
throw new RedactionError(`regions[${index}] must be an object`, {
|
|
188
|
+
context: { index, received: typeof region },
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
const rect = region.rect;
|
|
192
|
+
if (
|
|
193
|
+
!rect ||
|
|
194
|
+
!Number.isFinite(rect.x) ||
|
|
195
|
+
!Number.isFinite(rect.y) ||
|
|
196
|
+
!Number.isFinite(rect.width) ||
|
|
197
|
+
!Number.isFinite(rect.height)
|
|
198
|
+
) {
|
|
199
|
+
throw new InvalidRectError(`regions[${index}].rect must be a finite Rect`, {
|
|
200
|
+
context: { index, rect },
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Parse a CSS color string (`#rgb`, `#rrggbb`, `#rrggbbaa`, or named black).
|
|
207
|
+
*
|
|
208
|
+
* @param {string} color
|
|
209
|
+
* @returns {{r: number, g: number, b: number, alpha: number}}
|
|
210
|
+
*/
|
|
211
|
+
function parseColor(color) {
|
|
212
|
+
if (typeof color !== 'string') {
|
|
213
|
+
return { r: 0, g: 0, b: 0, alpha: 1 };
|
|
214
|
+
}
|
|
215
|
+
const hex = color.trim().replace(/^#/, '');
|
|
216
|
+
if (hex.length === 3) {
|
|
217
|
+
return {
|
|
218
|
+
r: Number.parseInt(hex[0] + hex[0], 16),
|
|
219
|
+
g: Number.parseInt(hex[1] + hex[1], 16),
|
|
220
|
+
b: Number.parseInt(hex[2] + hex[2], 16),
|
|
221
|
+
alpha: 1,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
if (hex.length === 6) {
|
|
225
|
+
return {
|
|
226
|
+
r: Number.parseInt(hex.slice(0, 2), 16),
|
|
227
|
+
g: Number.parseInt(hex.slice(2, 4), 16),
|
|
228
|
+
b: Number.parseInt(hex.slice(4, 6), 16),
|
|
229
|
+
alpha: 1,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
if (hex.length === 8) {
|
|
233
|
+
return {
|
|
234
|
+
r: Number.parseInt(hex.slice(0, 2), 16),
|
|
235
|
+
g: Number.parseInt(hex.slice(2, 4), 16),
|
|
236
|
+
b: Number.parseInt(hex.slice(4, 6), 16),
|
|
237
|
+
alpha: Number.parseInt(hex.slice(6, 8), 16) / 255,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
return { r: 0, g: 0, b: 0, alpha: 1 };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function assertBuffer(input) {
|
|
244
|
+
if (!Buffer.isBuffer(input)) {
|
|
245
|
+
throw new BufferCorruptError('input must be a Buffer', {
|
|
246
|
+
context: { received: typeof input },
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function readMeta(sharp, input) {
|
|
252
|
+
try {
|
|
253
|
+
const meta = await sharp(input).metadata();
|
|
254
|
+
if (!meta.width || !meta.height) {
|
|
255
|
+
throw new BufferCorruptError('Image has no detectable dimensions');
|
|
256
|
+
}
|
|
257
|
+
return { width: meta.width, height: meta.height };
|
|
258
|
+
} catch (error) {
|
|
259
|
+
if (error instanceof BufferCorruptError) {
|
|
260
|
+
throw error;
|
|
261
|
+
}
|
|
262
|
+
throw new BufferCorruptError('sharp could not read redact input buffer', {
|
|
263
|
+
cause: error,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
module.exports = { redact, parseColor };
|
package/src/tile/dzi.js
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs/promises');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
const { randomUUID } = require('node:crypto');
|
|
7
|
+
|
|
8
|
+
const { TileSizeError, TileWriteError, BufferCorruptError } = require('../errors/index.js');
|
|
9
|
+
|
|
10
|
+
const MAX_TILES = 10_000;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Generate a Deep Zoom Image pyramid from a source buffer using
|
|
14
|
+
* `sharp().tile({ layout: 'dz' })`.
|
|
15
|
+
*
|
|
16
|
+
* Implements `spec.md` § 8.4.1. Three output modes:
|
|
17
|
+
* - `'buffers'` (default): returns tile bytes in memory, cleans up the temp dir.
|
|
18
|
+
* - `'directory'`: returns the path to the temp dir; the caller is responsible
|
|
19
|
+
* for moving / uploading / deleting it.
|
|
20
|
+
* - `'stream'`: NOT IMPLEMENTED in v1.0 (per spec § 22 — deferred to v1.1).
|
|
21
|
+
*
|
|
22
|
+
* @public
|
|
23
|
+
* @param {Buffer} input
|
|
24
|
+
* @param {import('../types/index.d.ts').TileDziOptions} [options]
|
|
25
|
+
* @returns {Promise<import('../types/index.d.ts').DziResult>}
|
|
26
|
+
*/
|
|
27
|
+
async function tileDzi(input, options = {}) {
|
|
28
|
+
assertBuffer(input);
|
|
29
|
+
const {
|
|
30
|
+
tileSize = 256,
|
|
31
|
+
overlap = 1,
|
|
32
|
+
tileFormat = 'webp',
|
|
33
|
+
quality = 80,
|
|
34
|
+
output = 'buffers',
|
|
35
|
+
name,
|
|
36
|
+
} = options;
|
|
37
|
+
|
|
38
|
+
if (tileSize !== 256 && tileSize !== 512) {
|
|
39
|
+
throw new TileSizeError(`tileSize must be 256 or 512; received ${tileSize}`, {
|
|
40
|
+
context: { tileSize },
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
if (output === 'stream') {
|
|
44
|
+
throw new TileSizeError(
|
|
45
|
+
"output: 'stream' is deferred to v1.1; use 'buffers' or 'directory'",
|
|
46
|
+
{ context: { output } }
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
if (!['buffers', 'directory'].includes(output)) {
|
|
50
|
+
throw new TileSizeError(`output must be 'buffers' or 'directory'; received ${output}`, {
|
|
51
|
+
context: { output },
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const sharp = require('sharp');
|
|
56
|
+
|
|
57
|
+
const meta = await readMeta(sharp, input);
|
|
58
|
+
const totalEstimate = estimateTileCount(meta.width, meta.height, tileSize);
|
|
59
|
+
if (totalEstimate > MAX_TILES) {
|
|
60
|
+
throw new TileSizeError(
|
|
61
|
+
`Estimated tile count ${totalEstimate} exceeds the ${MAX_TILES} ceiling for a single tileDzi() call`,
|
|
62
|
+
{
|
|
63
|
+
context: {
|
|
64
|
+
width: meta.width,
|
|
65
|
+
height: meta.height,
|
|
66
|
+
tileSize,
|
|
67
|
+
estimate: totalEstimate,
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const dirName = name ?? `dzi-${randomUUID()}`;
|
|
74
|
+
let tempDir;
|
|
75
|
+
try {
|
|
76
|
+
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'screenshot-utils-dzi-'));
|
|
77
|
+
} catch (error) {
|
|
78
|
+
throw new TileWriteError('Failed to create temp directory for DZI output', {
|
|
79
|
+
cause: error,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const outputBase = path.join(tempDir, dirName);
|
|
84
|
+
try {
|
|
85
|
+
await applyTileFormat(sharp(input), tileFormat, quality)
|
|
86
|
+
.tile({ size: tileSize, overlap, layout: 'dz' })
|
|
87
|
+
.toFile(outputBase);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
|
90
|
+
throw new TileWriteError('sharp().tile().toFile() failed', { cause: error });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const dziXml = await readDziXml(outputBase);
|
|
94
|
+
const tiles = await collectTiles({
|
|
95
|
+
outputBase,
|
|
96
|
+
dirName,
|
|
97
|
+
output,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
if (output === 'buffers') {
|
|
101
|
+
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
descriptor: {
|
|
106
|
+
xml: dziXml,
|
|
107
|
+
format: tileFormat,
|
|
108
|
+
width: meta.width,
|
|
109
|
+
height: meta.height,
|
|
110
|
+
tileSize,
|
|
111
|
+
overlap,
|
|
112
|
+
},
|
|
113
|
+
tiles,
|
|
114
|
+
outputDir: output === 'directory' ? tempDir : undefined,
|
|
115
|
+
totalBytes: tiles.reduce((sum, t) => sum + (t.bytes ? t.bytes.length : 0), 0),
|
|
116
|
+
totalTiles: tiles.length,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Estimate the total tile count across all pyramid levels for sanity-limit
|
|
122
|
+
* enforcement before invoking sharp. Each level halves the dimensions; the
|
|
123
|
+
* lowest level is always 1×1.
|
|
124
|
+
*
|
|
125
|
+
* @param {number} width
|
|
126
|
+
* @param {number} height
|
|
127
|
+
* @param {number} tileSize
|
|
128
|
+
* @returns {number}
|
|
129
|
+
*/
|
|
130
|
+
function estimateTileCount(width, height, tileSize) {
|
|
131
|
+
let total = 0;
|
|
132
|
+
let w = width;
|
|
133
|
+
let h = height;
|
|
134
|
+
while (w >= 1 && h >= 1) {
|
|
135
|
+
const cols = Math.ceil(w / tileSize);
|
|
136
|
+
const rows = Math.ceil(h / tileSize);
|
|
137
|
+
total += cols * rows;
|
|
138
|
+
if (w === 1 && h === 1) {
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
w = Math.max(1, Math.ceil(w / 2));
|
|
142
|
+
h = Math.max(1, Math.ceil(h / 2));
|
|
143
|
+
}
|
|
144
|
+
return total;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Apply the requested tile format on a sharp pipeline before .tile().
|
|
149
|
+
* Sharp uses the pipeline's selected format for emitted tile files.
|
|
150
|
+
*
|
|
151
|
+
* @template T
|
|
152
|
+
* @param {T} pipeline
|
|
153
|
+
* @param {string} format
|
|
154
|
+
* @param {number} quality
|
|
155
|
+
* @returns {T}
|
|
156
|
+
*/
|
|
157
|
+
function applyTileFormat(pipeline, format, quality) {
|
|
158
|
+
switch (format) {
|
|
159
|
+
case 'png':
|
|
160
|
+
return pipeline.png();
|
|
161
|
+
case 'jpeg':
|
|
162
|
+
return pipeline.jpeg({ quality });
|
|
163
|
+
case 'webp':
|
|
164
|
+
return pipeline.webp({ quality });
|
|
165
|
+
default:
|
|
166
|
+
throw new TileSizeError(`tileFormat must be png, jpeg, or webp; received ${format}`, {
|
|
167
|
+
context: { tileFormat: format },
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Read the .dzi XML descriptor written by sharp.
|
|
174
|
+
*
|
|
175
|
+
* @param {string} outputBase Path without extension.
|
|
176
|
+
* @returns {Promise<string>}
|
|
177
|
+
*/
|
|
178
|
+
async function readDziXml(outputBase) {
|
|
179
|
+
try {
|
|
180
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
181
|
+
return await fs.readFile(`${outputBase}.dzi`, 'utf8');
|
|
182
|
+
} catch (error) {
|
|
183
|
+
throw new TileWriteError('Sharp did not produce the .dzi descriptor file', {
|
|
184
|
+
cause: error,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Walk the `<base>_files/` tree and assemble the tile list. Each tile path
|
|
191
|
+
* is recorded relative to the DZI root (matching the DZI spec).
|
|
192
|
+
*
|
|
193
|
+
* @param {{outputBase: string, dirName: string, output: 'buffers'|'directory'}} args
|
|
194
|
+
* @returns {Promise<Array<import('../types/index.d.ts').DziTile>>}
|
|
195
|
+
*/
|
|
196
|
+
async function collectTiles({ outputBase, dirName, output }) {
|
|
197
|
+
const filesDir = `${outputBase}_files`;
|
|
198
|
+
const tiles = [];
|
|
199
|
+
/** @type {string[]} */
|
|
200
|
+
let levels;
|
|
201
|
+
try {
|
|
202
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
203
|
+
levels = await fs.readdir(filesDir);
|
|
204
|
+
} catch (error) {
|
|
205
|
+
throw new TileWriteError('Sharp did not produce the _files directory', {
|
|
206
|
+
cause: error,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
const levelNums = levels
|
|
210
|
+
.map(Number)
|
|
211
|
+
.filter(n => Number.isFinite(n))
|
|
212
|
+
.toSorted((a, b) => a - b);
|
|
213
|
+
|
|
214
|
+
for (const level of levelNums) {
|
|
215
|
+
const levelDir = path.join(filesDir, String(level));
|
|
216
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
217
|
+
const dirEntries = await fs.readdir(levelDir);
|
|
218
|
+
const fileNames = dirEntries.toSorted();
|
|
219
|
+
for (const fileName of fileNames) {
|
|
220
|
+
const match = /^(\d+)_(\d+)\.\w+$/.exec(fileName);
|
|
221
|
+
if (!match) {
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
const col = Number(match[1]);
|
|
225
|
+
const row = Number(match[2]);
|
|
226
|
+
const relPath = `${dirName}_files/${level}/${fileName}`;
|
|
227
|
+
const entry = {
|
|
228
|
+
level,
|
|
229
|
+
col,
|
|
230
|
+
row,
|
|
231
|
+
path: relPath,
|
|
232
|
+
};
|
|
233
|
+
if (output === 'buffers') {
|
|
234
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
235
|
+
entry.bytes = await fs.readFile(path.join(levelDir, fileName));
|
|
236
|
+
}
|
|
237
|
+
tiles.push(entry);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return tiles;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function readMeta(sharp, input) {
|
|
244
|
+
try {
|
|
245
|
+
const meta = await sharp(input).metadata();
|
|
246
|
+
if (!meta.width || !meta.height) {
|
|
247
|
+
throw new BufferCorruptError('Input image has no detectable dimensions');
|
|
248
|
+
}
|
|
249
|
+
return { width: meta.width, height: meta.height };
|
|
250
|
+
} catch (error) {
|
|
251
|
+
if (error instanceof BufferCorruptError) {
|
|
252
|
+
throw error;
|
|
253
|
+
}
|
|
254
|
+
throw new BufferCorruptError('sharp could not read tile input buffer', {
|
|
255
|
+
cause: error,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function assertBuffer(input) {
|
|
261
|
+
if (!Buffer.isBuffer(input)) {
|
|
262
|
+
throw new BufferCorruptError('input must be a Buffer', {
|
|
263
|
+
context: { received: typeof input },
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
module.exports = { tileDzi, MAX_TILES };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { BufferCorruptError, UnsupportedFormatError } = require('../errors/index.js');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Re-encode a buffer to a different image format.
|
|
7
|
+
*
|
|
8
|
+
* Implements `spec.md` § 8.2.4. Thin wrapper over `sharp(buf).toFormat()`
|
|
9
|
+
* with safer defaults and the package's error contract.
|
|
10
|
+
*
|
|
11
|
+
* @public
|
|
12
|
+
* @param {Buffer} input
|
|
13
|
+
* @param {import('../types/index.d.ts').ConvertOptions} options
|
|
14
|
+
* @returns {Promise<Buffer>}
|
|
15
|
+
*/
|
|
16
|
+
async function convert(input, options) {
|
|
17
|
+
assertBuffer(input);
|
|
18
|
+
if (!options || typeof options !== 'object') {
|
|
19
|
+
throw new UnsupportedFormatError('convert requires an options object with a format', {
|
|
20
|
+
context: { received: typeof options },
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
const { format, quality = 90, lossless = false, effort = 4 } = options;
|
|
24
|
+
if (!['png', 'jpeg', 'webp'].includes(format)) {
|
|
25
|
+
throw new UnsupportedFormatError(
|
|
26
|
+
`format must be 'png', 'jpeg', or 'webp'; received ${JSON.stringify(format)}`,
|
|
27
|
+
{ context: { received: format } }
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
const sharp = require('sharp');
|
|
31
|
+
let pipeline = sharp(input);
|
|
32
|
+
switch (format) {
|
|
33
|
+
case 'png':
|
|
34
|
+
pipeline = pipeline.png();
|
|
35
|
+
break;
|
|
36
|
+
case 'jpeg':
|
|
37
|
+
pipeline = pipeline.jpeg({ quality });
|
|
38
|
+
break;
|
|
39
|
+
case 'webp':
|
|
40
|
+
pipeline = pipeline.webp({ quality, lossless, effort });
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
return pipeline.toBuffer();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function assertBuffer(input) {
|
|
47
|
+
if (!Buffer.isBuffer(input)) {
|
|
48
|
+
throw new BufferCorruptError('input must be a Buffer', {
|
|
49
|
+
context: { received: typeof input },
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = { convert };
|