// 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');