259e5f3378
Two things kept a scanned `.tif` off the wall, and both had to be fixed for its tile to come out as a picture. CanvasKit was never up on the catalogue's route: the studio boots it when the workspace mounts, but a visitor who only ever opens the library never does, so the JPEG inside the file had no decoder and the tile was drawn as nothing. The `.tif` branch now brings the engine up itself. And the pixels were being read out of that JPEG through an image `readPixels` handed a buffer of ours, which CanvasKit answers by throwing rather than filling — the frame came back black to a check on the return value. It now takes the buffer CanvasKit hands back, the form the canvas calls in this repo have always used. Measured in the browser on a 5472x3648 JPEG-compressed scan: the tile went from 2378 bytes of solid black to 68670 bytes at mean 189.2 sd 74.6, against the 189.3/74.6 the same picture's uncompressed copy yields. `scripts/tiff-decode-check.mjs` gains the fixture that was broken — a JPEG-compressed strip — judged by how far a channel strays from the libvips reference rather than by equality, since that strip is lossy. Its bundle now re-exports the shim alongside the reader so the two are one module instance and the fixture's JPEG has a decoder behind it.
169 lines
9.1 KiB
JavaScript
169 lines
9.1 KiB
JavaScript
// The tile for a `.tif` is read out of the file by `src/engine/tiffDecode.ts`,
|
|
// because nothing else in the app draws a TIFF at all: `createImageBitmap`
|
|
// answers InvalidStateError to a baseline 5472x3648 scan, CanvasKit's
|
|
// `MakeImageFromEncoded` answers null, and LibRaw opens the file only to report a
|
|
// 0x0 frame. So the reader is the only thing standing between a scan and a blank
|
|
// cell in the wall, and it is checked here against a reference it had no hand in:
|
|
// one 8x8 crop of the sample frame, written by libvips to a PPM and to four
|
|
// TIFFs that differ only in how the strip is compressed — none, PackBits, LZW
|
|
// with horizontal differencing, and JPEG — the first three of which therefore
|
|
// all have to come back as the PPM's own pixels, exactly.
|
|
//
|
|
// cd docker/frontend && node scripts/tiff-decode-check.mjs
|
|
//
|
|
// The same crop through the real path (rawThumbnail -> a JPEG tile) is the
|
|
// browser's job: `scripts/ram-check.mjs` and the wall itself, where the JPEG
|
|
// decode needs CanvasKit.
|
|
import { build } from 'esbuild';
|
|
import { mkdir, rm } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
|
const FRONTEND = fileURLToPath(new URL('..', import.meta.url));
|
|
|
|
// `vips crop ../sample/P1010256.JPG out.v 1200 1600 8 8`, then
|
|
// `vips copy out.v out.{ppm,tif}`, `[compression=none|packbits|lzw]` — and once
|
|
// more with `[compression=jpeg,Q=100]`, which is the other TIFF a scan comes as
|
|
// and the only one whose strip is a JPEG: that one is lossy, so its pixels are
|
|
// near the PPM's rather than equal to them.
|
|
const FIXTURES = {
|
|
'none.tif':
|
|
'SUkqAMgAAABTR0lRQ0JuYViajoKsoJKvo5esoJShlIxMQEJURkZzZl6Ie3KWinyckIKZjYGShXxHPT5KPj5qXFl2aWGHenGUiHyUiHyOgXhHPEBQREZjV1dlWlhzaGKQhX+YjoWRh35IPUFRRkpbUVJeVFV0amiSiIaUioiMgoBJQEFNREVUS0xdVFV9c3KUiomPhoeKgYJPRkdQR0hTSktaUVJ9dXOUi4ySiYqMg4ZTTU9US05VTE9jWl2Hf32Ui4yIgoKFf4EPAAABAwABAAAACAAAAAEBAwABAAAACAAAAAIBAwADAAAAkgEAAAMBAwABAAAAAQAAAAYBAwABAAAAAgAAABEBBAABAAAACAAAABIBAwABAAAACAAAABUBAwABAAAAAwAAABYBAwABAAAAgAAAABcBBAABAAAAwAAAABoBBQABAAAAggEAABsBBQABAAAAigEAABwBAwABAAAAAQAAACgBAwABAAAAAgAAAFMBAwADAAAAmAEAAAAAAAC0AAAAAQAAALQAAAABAAAACAAIAAgAAQABAAEA',
|
|
'pb.tif':
|
|
'SUkqANAAAAAXU0dJUUNCbmFYmo6CrKCSr6OXrKCUoZSMF0xAQlRGRnNmXoh7cpaKfJyQgpmNgZKFfBdHPT5KPj5qXFl2aWGHenGUiHyUiHyOgXgXRzxAUERGY1dXZVpYc2hikIV/mI6FkYd+F0g9QVFGSltRUl5UVXRqaJKIhpSKiIyCgBdJQEFNREVUS0xdVFV9c3KUiomPhoeKgYIXT0ZHUEdIU0pLWlFSfXVzlIuMkomKjIOGF1NNT1RLTlVMT2NaXYd/fZSLjIiCgoV/gQ8AAAEDAAEAAAAIAAAAAQEDAAEAAAAIAAAAAgEDAAMAAACaAQAAAwEDAAEAAAAFgAAABgEDAAEAAAACAAAAEQEEAAEAAAAIAAAAEgEDAAEAAAAIAAAAFQEDAAEAAAADAAAAFgEDAAEAAACAAAAAFwEEAAEAAADIAAAAGgEFAAEAAACKAQAAGwEFAAEAAACSAQAAHAEDAAEAAAABAAAAKAEDAAEAAAACAAAAUwEDAAMAAACgAQAAAAAAALQAAAABAAAAtAAAAAEAAAAIAAgACAABAAEAAQA=',
|
|
'lzw.tif':
|
|
'SUkqAMQAAACAFMjkl/Px8h0PBYWC0VBIJBABgMCv2KPV6PgmEAhAgDAQPiAMBUKhQHA8FAaURR/vl8Psjj0fAMAgAQB4NgwGggIhEIA0HAsAUF9Pl+EceEAExwJhMIgKZA4HAoO1MEAkDPmsEgekEE10FAsEREBhYLBMPWcBWl8WskkAggS4Ae5V0EiAPh0L3l9vx/Pu/E8jEcA4OxXIDiMSCELhYMv7HPp9Pwpk0ngF/P/BgGoA4SCUQA0GA96Pd7SqAhAAAAEDAAEAAAAIAAAAAQEDAAEAAAAIAAAAAgEDAAMAAACaAQAAAwEDAAEAAAAFAAAABgEDAAEAAAACAAAAEQEEAAEAAAAIAAAAEgEDAAEAAAAIAAAAFQEDAAEAAAADAAAAFgEDAAEAAACAAAAAFwEEAAEAAAC8AAAAGgEFAAEAAACKAQAAGwEFAAEAAACSAQAAHAEDAAEAAAABAAAAKAEDAAEAAAACAAAAPQEDAAEAAAACAAAAUwEDAAMAAACgAQAAAAAAALQAAAABAAAAtAAAAAEAAAAIAAgACAABAAEAAQA=',
|
|
'crop.ppm':
|
|
'UDYKI3ZpcHMycHBtIC0gMjAyNi0xMC0wOVQxNTowMjozNi41MDg0MDIrMDcKOCA4CjI1NQpTR0lRQ0JuYViajoKsoJKvo5esoJShlIxMQEJURkZzZl6Ie3KWinyckIKZjYGShXxHPT5KPj5qXFl2aWGHenGUiHyUiHyOgXhHPEBQREZjV1dlWlhzaGKQhX+YjoWRh35IPUFRRkpbUVJeVFV0amiSiIaUioiMgoBJQEFNREVUS0xdVFV9c3KUiomPhoeKgYJPRkdQR0hTSktaUVJ9dXOUi4ySiYqMg4ZTTU9US05VTE9jWl2Hf32Ui4yIgoKFf4E=',
|
|
'jpeg.tif':
|
|
'SUkqALwAAAD/2P/AABEIAAgACANSEQBHEQBCEQD/2gAMA1IARwBCAAA/APLf2HvHvimy8KeI/DQvbqy0bS7E3d4Ec2r3GqajdSBWaZCtu37v5G8zdM6IgySeflj9h3xf4qs/CviXwyL67s9G0yye7vUic2rzapqN1LtJmULC37vKN5geZ0RBz3+Qf2MdR8SnQfEWgxajf6bpmn2b3l4FY2rXGqX90wUvKmyB/wB3iNvM3zOqRkYJYn//2QAQAAABAwABAAAACAAAAAEBAwABAAAACAAAAAIBAwADAAAAkgEAAAMBAwABAAAABwAAAAYBAwABAAAAAgAAABEBBAABAAAACAAAABIBAwABAAAACAAAABUBAwABAAAAAwAAABYBAwABAAAAgAAAABcBBAABAAAAswAAABoBBQABAAAAggEAABsBBQABAAAAigEAABwBAwABAAAAAQAAACgBAwABAAAAAgAAAFMBAwADAAAAmAEAAFsBBwAhAQAAngEAAAAAAAC0AAAAAQAAALQAAAABAAAACAAIAAgAAQABAAEA/9j/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/xAAfAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgv/xAC1EAACAQMDAgQDBQUEBAAAAX0BAgMABBEFEiExQQYTUWEHInEUMoGRoQgjQrHBFVLR8CQzYnKCCQoWFxgZGiUmJygpKjQ1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4eLj5OXm5+jp6vHy8/T19vf4+fr/2Q==',
|
|
};
|
|
|
|
function fixture(name) {
|
|
return new Uint8Array(Buffer.from(FIXTURES[name], 'base64'));
|
|
}
|
|
|
|
// P6, with libvips' own comment line, which is the reason this is not a
|
|
// `split('\n')`.
|
|
function readPpm(bytes) {
|
|
let at = 0;
|
|
const token = () => {
|
|
for (;;) {
|
|
if (at >= bytes.length) return '';
|
|
const c = bytes[at];
|
|
if (c === 0x23) {
|
|
while (at < bytes.length && bytes[at] !== 0x0a) at++;
|
|
} else if (c === 0x20 || c === 0x09 || c === 0x0a || c === 0x0d) {
|
|
at++;
|
|
} else break;
|
|
}
|
|
const from = at;
|
|
while (at < bytes.length && ![0x20, 0x09, 0x0a, 0x0d].includes(bytes[at])) at++;
|
|
return String.fromCharCode(...bytes.subarray(from, at));
|
|
};
|
|
if (token() !== 'P6') throw new Error('not a PPM');
|
|
const width = Number(token());
|
|
const height = Number(token());
|
|
if (Number(token()) !== 255) throw new Error('not 8-bit');
|
|
at++;
|
|
return { width, height, pixels: bytes.subarray(at, at + width * height * 3) };
|
|
}
|
|
|
|
const failures = [];
|
|
const check = (what, ok, detail = '') => {
|
|
if (!ok) failures.push(`${what}${detail ? `: ${detail}` : ''}`);
|
|
console.log(`${ok ? 'ok ' : 'FAIL'} ${what}${detail ? ` — ${detail}` : ''}`);
|
|
};
|
|
|
|
const cache = path.join(FRONTEND, 'node_modules', '.cache');
|
|
await mkdir(cache, { recursive: true });
|
|
const bundle = path.join(cache, 'tiff-decode-check.mjs');
|
|
// `jpeg.tif` is the one fixture whose strip is a JPEG, and the reader decodes
|
|
// that with the very CanvasKit the app boots — so the two have to come out of
|
|
// ONE module instance, or the shim's `Skia` stays null inside the reader. Hence
|
|
// the single stdin entry that re-exports both, rather than two bundles.
|
|
await build({
|
|
stdin: {
|
|
contents:
|
|
"export { decodeTiff } from './src/engine/tiffDecode';\n" +
|
|
"export { initSkia } from './src/engine/skiaShim';",
|
|
resolveDir: FRONTEND,
|
|
loader: 'ts',
|
|
},
|
|
bundle: true,
|
|
format: 'esm',
|
|
platform: 'node',
|
|
target: 'es2022',
|
|
external: ['canvaskit-wasm/full'],
|
|
outfile: bundle,
|
|
});
|
|
|
|
let decodeTiff, initSkia;
|
|
try {
|
|
({ decodeTiff, initSkia } = await import(pathToFileURL(bundle).href));
|
|
} finally {
|
|
await rm(bundle, { force: true });
|
|
}
|
|
|
|
await initSkia({
|
|
locateFile: () =>
|
|
fileURLToPath(new URL('../node_modules/canvaskit-wasm/bin/full/canvaskit.wasm', import.meta.url)),
|
|
});
|
|
|
|
const reference = readPpm(fixture('crop.ppm'));
|
|
|
|
for (const name of ['none.tif', 'pb.tif', 'lzw.tif']) {
|
|
const frame = decodeTiff(fixture(name), 640);
|
|
if (!frame) {
|
|
check(name, false, 'no frame');
|
|
continue;
|
|
}
|
|
if (frame.width !== reference.width || frame.height !== reference.height) {
|
|
check(name, false, `${frame.width}x${frame.height} against ${reference.width}x${reference.height}`);
|
|
continue;
|
|
}
|
|
let wrong = 0;
|
|
let opaque = true;
|
|
for (let i = 0; i < reference.width * reference.height; i++) {
|
|
if (
|
|
frame.rgba[i * 4] !== reference.pixels[i * 3] ||
|
|
frame.rgba[i * 4 + 1] !== reference.pixels[i * 3 + 1] ||
|
|
frame.rgba[i * 4 + 2] !== reference.pixels[i * 3 + 2]
|
|
) wrong++;
|
|
if (frame.rgba[i * 4 + 3] !== 255) opaque = false;
|
|
}
|
|
check(name, wrong === 0 && opaque, `${frame.width}x${frame.height}, ${wrong} wrong pixels`);
|
|
}
|
|
|
|
// The JPEG strip is lossy — Q=100 is close to the PPM, not equal to it — so this
|
|
// one is checked by how far a channel strays rather than by equality. It is also
|
|
// the fixture that fails loudly when CanvasKit is not up (its `Skia` is null) or
|
|
// when the pixels are read out of the decoded image the wrong way (all black).
|
|
{
|
|
const frame = decodeTiff(fixture('jpeg.tif'), 640);
|
|
if (!frame) {
|
|
check('jpeg.tif', false, 'no frame');
|
|
} else if (frame.width !== reference.width || frame.height !== reference.height) {
|
|
check('jpeg.tif', false, `${frame.width}x${frame.height} against ${reference.width}x${reference.height}`);
|
|
} else {
|
|
let worst = 0;
|
|
let opaque = true;
|
|
for (let i = 0; i < reference.width * reference.height; i++) {
|
|
for (let c = 0; c < 3; c++) {
|
|
worst = Math.max(worst, Math.abs(frame.rgba[i * 4 + c] - reference.pixels[i * 3 + c]));
|
|
}
|
|
if (frame.rgba[i * 4 + 3] !== 255) opaque = false;
|
|
}
|
|
check('jpeg.tif', worst <= 12 && opaque, `${frame.width}x${frame.height}, worst channel ${worst}`);
|
|
}
|
|
}
|
|
|
|
check('a file that is not a TIFF', decodeTiff(fixture('crop.ppm'), 640) === null);
|
|
check('a TIFF cut short', decodeTiff(fixture('none.tif').subarray(0, 40), 640) === null);
|
|
|
|
if (failures.length) {
|
|
console.error(`\n${failures.length} failed`);
|
|
process.exit(1);
|
|
}
|
|
console.log('\nall tiff decode checks passed');
|