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:
2026-10-09 15:03:53 +07:00
parent 29759721cb
commit c3d46b0d15
5 changed files with 695 additions and 37 deletions
@@ -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');