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