fix(library): read a .tif's own pixels for its tile
A `.tif` in an album drew nothing: the tile took the file's embedded JPEG — which a TIFF has not got, so the search found the frame's own pixel data and kept three hundred kilobytes of it as the frame's tile — and nothing else in the app draws a TIFF either, `createImageBitmap` answering InvalidStateError and CanvasKit's `MakeImageFromEncoded` answering null to one. `src/engine/tiffDecode.ts` reads the file itself, strips or tiles, and hands back a shrunk frame, which is what the tile is made of now. The tiles that were kept are dropped once (v4) so a frame already looked at is drawn right.
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
// 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 three
|
||||
// TIFFs that differ only in how the strip is compressed — none, PackBits, and
|
||||
// LZW with horizontal differencing — 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]`.
|
||||
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=',
|
||||
};
|
||||
|
||||
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');
|
||||
await build({
|
||||
entryPoints: [path.join(FRONTEND, 'src', 'engine', 'tiffDecode.ts')],
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
platform: 'node',
|
||||
target: 'es2022',
|
||||
// The one thing in the reader that is CanvasKit is the JPEG-in-TIFF strip, and
|
||||
// these three fixtures have no JPEG in them; leaving it out keeps the check
|
||||
// about the strip reader and not about a wasm download.
|
||||
external: ['canvaskit-wasm/full'],
|
||||
outfile: bundle,
|
||||
});
|
||||
|
||||
let decodeTiff;
|
||||
try {
|
||||
({ decodeTiff } = await import(pathToFileURL(bundle).href));
|
||||
} finally {
|
||||
await rm(bundle, { force: true });
|
||||
}
|
||||
|
||||
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`);
|
||||
}
|
||||
|
||||
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');
|
||||
Reference in New Issue
Block a user