diff --git a/docker/frontend/scripts/tiff-decode-check.mjs b/docker/frontend/scripts/tiff-decode-check.mjs new file mode 100644 index 0000000..33358ba --- /dev/null +++ b/docker/frontend/scripts/tiff-decode-check.mjs @@ -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'); diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index 4cf23da..9d66e82 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -4,7 +4,7 @@ import { go } from './nav'; import { api } from './api'; import { keep, recall } from './remember'; import { readGps, readSpecs, specsLine } from './engine/imageOps'; -import { isRawName, rawThumbnail, tiffThumbnail } from './engine/rawDevelop'; +import { isRawName, rawThumbnail } from './engine/rawDevelop'; import { heicThumbnail, heicToJpeg, isHeicName } from './engine/heicDevelop'; import { cachedFolders, @@ -1643,7 +1643,7 @@ function Catalogue({ admin }: { admin: boolean }) { } else if (isRawName(file.name)) { const bytes = new Uint8Array(await file.arrayBuffer()); if (!live) return; - const previewBytes = await rawThumbnail(bytes); + const previewBytes = await rawThumbnail(bytes, file.name); if (previewBytes && live) { const blob = new Blob([previewBytes as BlobPart], { type: 'image/jpeg' }); url = URL.createObjectURL(blob); @@ -2777,8 +2777,7 @@ function useThumbUrls(photos: LibraryPhoto[], also?: LibraryPhoto | null): Recor if (prevBytes) blob = await tileOf(photo.id, prevBytes); } else if (isRawName(name) || /\.(tiff?)$/i.test(name)) { const fullBytes = new Uint8Array(await file.arrayBuffer()); - let prevBytes = await rawThumbnail(fullBytes, name); - if (!prevBytes && /\.(tiff?)$/i.test(name)) prevBytes = tiffThumbnail(fullBytes); + const prevBytes = await rawThumbnail(fullBytes, name); if (prevBytes) blob = await tileOf(photo.id, prevBytes); } else { // A frame the wall can decode itself: the tile is made here, at the diff --git a/docker/frontend/src/engine/library.ts b/docker/frontend/src/engine/library.ts index fc6b7cb..2d812a5 100644 --- a/docker/frontend/src/engine/library.ts +++ b/docker/frontend/src/engine/library.ts @@ -24,7 +24,7 @@ const PHOTOS = 'photos'; const THUMBS = 'thumbs'; const EDITS = 'edits'; const DIRS = 'dirs'; -const DB_VERSION = 3; +const DB_VERSION = 4; // The tile the catalogue keeps is the same tile the grid cell and the strip both // paint, and there is one of them per frame of a roll that reaches six figures. @@ -236,6 +236,13 @@ function openDb(): Promise { req.onblocked = () => blockedSink?.(true); req.onupgradeneeded = () => { const db = req.result; + // v4: every catalogue kept a tile for every frame, and a `.tif`'s tile was + // the frame's own pixel data read as if it were a JPEG — the bytes are not + // a picture, so the tile drew as nothing and, being kept, went on drawing + // as nothing on every visit after. The tiles are dropped; each is made + // again the first time its frame is looked at, which is when it was going + // to be made anyway. + if (db.objectStoreNames.contains(THUMBS)) db.deleteObjectStore(THUMBS); if (!db.objectStoreNames.contains(FOLDERS)) db.createObjectStore(FOLDERS, { keyPath: 'name' }); if (!db.objectStoreNames.contains(PHOTOS)) { const photos = db.createObjectStore(PHOTOS, { keyPath: 'id' }); diff --git a/docker/frontend/src/engine/rawDevelop.ts b/docker/frontend/src/engine/rawDevelop.ts index 5082f85..a786ab5 100644 --- a/docker/frontend/src/engine/rawDevelop.ts +++ b/docker/frontend/src/engine/rawDevelop.ts @@ -55,6 +55,7 @@ import { sensorWhite } from './sensorWhite'; import { f32ToF16 } from './halfFloat'; import { toneMatch, FLAT_TONE, MATCH_GRID } from './previewMatch'; import { Skia } from './skiaShim'; +import { decodeTiff } from './tiffDecode'; // What `imageData()` returns for the settings below: 16-bit, 3 channels, black // level already gone — the post-process subtracts it whatever `noAutoScale` @@ -291,58 +292,90 @@ export function extractEmbeddedJpeg(buf: Uint8Array): Uint8Array | null { // above is a full decode of every pixel at full resolution. No preview inside // the file means no tile — the row still lists the frame by name, and the // studio develops it the moment it is opened. +// +// What CanvasKit draws, and only that: a TIFF goes to `decodeTiff` below, since +// `MakeImageFromEncoded` answers null to one (measured on a baseline 5472x3648 +// scan) and the file has to be read by hand. export function tiffThumbnail(bytes: Uint8Array): Uint8Array | null { try { const img = Skia.Image.MakeImageFromEncoded(bytes); - if (!img) return null; - try { - const w = img.width(); - const h = img.height(); - if (!w || !h) return null; - const n = 640; - const wide = w >= h; - const targetW = wide ? n : Math.max(1, Math.round((w / h) * n)); - const targetH = wide ? Math.max(1, Math.round((h / w) * n)) : n; + if (img) { + try { + return shrinkToJpeg(img); + } finally { + img.delete(); + } + } - const surface = Skia.Surface.MakeOffscreen(targetW, targetH) ?? Skia.Surface.Make(targetW, targetH); - if (!surface) return null; - const canvas = surface.getCanvas(); - canvas.drawImageRectCubic( - img, - Skia.XYWHRect(0, 0, w, h), - Skia.XYWHRect(0, 0, targetW, targetH), - 1 / 3, - 1 / 3 - ); - surface.flush(); - const snapshot = surface.makeImageSnapshot(); - surface.dispose(); - if (!snapshot) return null; - const jpeg = snapshot.encodeToBytes(Skia.ImageFormat.JPEG, 80); - snapshot.dispose(); - return jpeg ? new Uint8Array(jpeg) : null; + const frame = decodeTiff(bytes, 640); + if (!frame) return null; + const image = Skia.Image.MakeImage( + { + width: frame.width, + height: frame.height, + colorType: Skia.ColorType.RGBA_8888, + alphaType: Skia.AlphaType.Unpremul, + colorSpace: Skia.ColorSpace.SRGB, + }, + frame.rgba, + frame.width * 4 + ); + if (!image) return null; + try { + return shrinkToJpeg(image); } finally { - img.delete(); + image.delete(); } } catch { return null; } } +// A frame shrunk to the square the wall paints and encoded to JPEG — the one +// step of a tile that is CanvasKit's whatever the frame came out of. +function shrinkToJpeg(img: any): Uint8Array | null { + const w = img.width(); + const h = img.height(); + if (!w || !h) return null; + const n = 640; + const wide = w >= h; + const targetW = wide ? n : Math.max(1, Math.round((w / h) * n)); + const targetH = wide ? Math.max(1, Math.round((h / w) * n)) : n; + + const surface = Skia.Surface.MakeOffscreen(targetW, targetH) ?? Skia.Surface.Make(targetW, targetH); + if (!surface) return null; + const canvas = surface.getCanvas(); + canvas.drawImageRectCubic( + img, + Skia.XYWHRect(0, 0, w, h), + Skia.XYWHRect(0, 0, targetW, targetH), + 1 / 3, + 1 / 3 + ); + surface.flush(); + const snapshot = surface.makeImageSnapshot(); + surface.dispose(); + if (!snapshot) return null; + const jpeg = snapshot.encodeToBytes(Skia.ImageFormat.JPEG, 80); + snapshot.dispose(); + return jpeg ? new Uint8Array(jpeg) : null; +} + // The preview on its own, for the catalogue: a folder of RAW files has to show // a tile per frame, and unpack_thumb is a seek and a copy where the develop // above is a full decode of every pixel at full resolution. No preview inside // the file means no tile — the row still lists the frame by name, and the // studio develops it the moment it is opened. export async function rawThumbnail(bytes: Uint8Array, fileName?: string): Promise { + // A `.tif` is not a RAW: the file IS the picture, and no camera preview is + // hiding in it — scanning the bytes for one finds the frame's own pixel data + // and hands back three hundred kilobytes of it dressed as a JPEG, which is + // what the wall used to keep as the frame's tile and draw as nothing. + if (fileName && /\.(tiff?)$/i.test(fileName)) return tiffThumbnail(bytes); + const fast = extractEmbeddedJpeg(bytes); if (fast) return fast; - if (fileName && /\.(tiff?)$/i.test(fileName)) { - const tiff = tiffThumbnail(bytes); - if (tiff) return tiff; - } - const raw = new LibRaw(); try { // The same transfer as in the develop: the worker is handed a copy, so the diff --git a/docker/frontend/src/engine/tiffDecode.ts b/docker/frontend/src/engine/tiffDecode.ts new file mode 100644 index 0000000..e2ba2d5 --- /dev/null +++ b/docker/frontend/src/engine/tiffDecode.ts @@ -0,0 +1,491 @@ +// TIFF → RGBA, for the catalogue's tiles. +// +// A `.tif` in an album had no tile, and none of the three decoders the app +// already carries will make one. Measured on a baseline 5472x3648 scan written +// by vips: `createImageBitmap` answers InvalidStateError, CanvasKit's +// `MakeImageFromEncoded` answers null — so `tiffThumbnail` never returned +// anything for a TIFF at all — and LibRaw opens the file only to report a 0x0 +// frame with no thumbnail inside it. +// +// So the frame is read here instead: the header, the strips or tiles with their +// compression, the photometric, and one row in `step` out of the image. A 640px +// tile shows no more of a 5472px row than that, which is why the full-size row +// is never built — a scan album is hundreds of megabytes a frame and the tile +// is the only thing anyone asked for. +// +// What is covered: little and big endian, strips and tiles, chunky (planar 1), +// 1/8/16-bit, grey / RGB / palette, compression none, LZW (5), PackBits +// (32773) and both JPEG-in-TIFF flavours (6 and 7, through CanvasKit, which is +// the one thing in here that does decode a JPEG). YCbCr outside a JPEG and CMYK +// are not decoded and answer null — the row still lists the frame by name. + +import { Skia } from './skiaShim'; + +export interface TiffFrame { + width: number; + height: number; + rgba: Uint8Array; +} + +// An IFD longer than this is not a camera file, it is something reading one. +const MAX_ENTRIES = 4096; + +interface Entry { + type: number; + count: number; + /** Where the value's bytes are, inline in the entry or out at an offset. */ + at: number; +} + +interface Chunk { + /** Position in the image this chunk's pixels start at. */ + x0: number; + y0: number; + /** Width of a stored row, which a tile pads out to its own tile width. */ + stride: number; + width: number; + height: number; + offset: number; + length: number; + /** Set for the two JPEG flavours: the stream itself, tables already joined. */ + jpeg?: Uint8Array; +} + +interface Samples { + kind: 'samples'; + width: number; + height: number; + spp: number; + depth: number; + data: Uint8Array | Uint16Array; +} + +interface Rgba { + kind: 'rgba'; + width: number; + height: number; + data: Uint8Array; +} + +type Decoded = Samples | Rgba; + +export function decodeTiff(bytes: Uint8Array, maxSide: number): TiffFrame | null { + if (bytes.length < 8) return null; + const little = bytes[0] === 0x49 && bytes[1] === 0x49; + if (!little && !(bytes[0] === 0x4d && bytes[1] === 0x4d)) return null; + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const u16 = (at: number) => view.getUint16(at, little); + const u32 = (at: number) => view.getUint32(at, little); + if (u16(2) !== 42) return null; + + const valueSize = (type: number) => { + if (type === 1 || type === 2 || type === 6 || type === 7) return 1; + if (type === 3 || type === 8) return 2; + if (type === 4 || type === 9 || type === 11) return 4; + if (type === 5 || type === 10 || type === 12) return 8; + return 0; + }; + + const readIfd = (offset: number): Map | null => { + if (offset < 8 || offset + 2 > bytes.length) return null; + const count = u16(offset); + if (count > MAX_ENTRIES || offset + 2 + count * 12 > bytes.length) return null; + const tags = new Map(); + for (let i = 0; i < count; i++) { + const entry = offset + 2 + i * 12; + const type = u16(entry + 2); + const n = u32(entry + 4); + const size = valueSize(type) * n; + if (!size || n > 1 << 22) continue; + const at = size <= 4 ? entry + 8 : u32(entry + 8); + if (at < 0 || at + size > bytes.length) continue; + tags.set(u16(entry), { type, count: n, at }); + } + return tags; + }; + + const num = (tags: Map, tag: number, fallback = 0) => { + const entry = tags.get(tag); + if (!entry) return fallback; + if (entry.type === 3) return u16(entry.at); + if (entry.type === 4 || entry.type === 9) return u32(entry.at); + if (entry.type === 1 || entry.type === 6 || entry.type === 7) return bytes[entry.at]; + return fallback; + }; + + const nums = (tags: Map, tag: number) => { + const entry = tags.get(tag); + if (!entry) return []; + const out: number[] = []; + for (let i = 0; i < entry.count; i++) { + if (entry.type === 3) out.push(u16(entry.at + i * 2)); + else if (entry.type === 4) out.push(u32(entry.at + i * 4)); + else out.push(bytes[entry.at + i]); + } + return out; + }; + + const tags = readIfd(u32(4)); + if (!tags) return null; + + const width = num(tags, 256); + const height = num(tags, 257); + if (width < 1 || height < 1) return null; + + const compression = num(tags, 259, 1); + const photometric = num(tags, 262, 2); + const spp = num(tags, 277, 1); + if (num(tags, 284, 1) !== 1) return null; + if (spp < 1 || spp > 4) return null; + if (photometric === 5) return null; + if (photometric === 6 && compression !== 6 && compression !== 7) return null; + if (photometric === 2 && spp < 3) return null; + + let depths = nums(tags, 258); + if (!depths.length) depths = [1]; + if (depths.length === 1 && spp > 1) depths = new Array(spp).fill(depths[0]); + if (depths.length !== spp) return null; + const depth = depths[0]; + if (depths.some((d) => d !== depth)) return null; + if (depth !== 1 && depth !== 8 && depth !== 16) return null; + + const predictor = num(tags, 317, 1); + if (predictor !== 1 && predictor !== 2) return null; + + const tileWidth = num(tags, 322); + const tileHeight = num(tags, 323); + const tiled = tileWidth > 0 && tileHeight > 0 && tags.has(324); + const rowsPerStrip = Math.max(1, num(tags, 278, height) || height); + + const chunks = new Map(); + const tables = tags.get(347); + const jpegTables = tables ? bytes.subarray(tables.at, tables.at + tables.count) : null; + + if (compression === 6) { + const offsets = nums(tags, 273); + const lengths = nums(tags, 279); + const offset = num(tags, 513, offsets[0] ?? -1); + const length = num(tags, 514, lengths[0] ?? 0); + if (offset < 0 || offset + length > bytes.length || !length) return null; + chunks.set(0, { + x0: 0, y0: 0, stride: width, width, height, offset, length, + jpeg: bytes.subarray(offset, offset + length), + }); + } else if (tiled) { + const offsets = nums(tags, 324); + const lengths = nums(tags, 325); + const across = Math.ceil(width / tileWidth); + const down = Math.ceil(height / tileHeight); + for (let ty = 0; ty < down; ty++) { + for (let tx = 0; tx < across; tx++) { + const key = ty * across + tx; + const offset = offsets[key]; + const length = lengths[key]; + if (offset == null || !length || offset + length > bytes.length) continue; + const x0 = tx * tileWidth; + const y0 = ty * tileHeight; + const chunk: Chunk = { + x0, y0, stride: tileWidth, + width: Math.min(tileWidth, width - x0), + height: Math.min(tileHeight, height - y0), + offset, length, + }; + if (compression === 7) { + const scan = bytes.subarray(offset, offset + length); + chunk.jpeg = jpegTables ? jpegWithTables(jpegTables, scan) : scan; + } + chunks.set(key, chunk); + } + } + } else { + const offsets = nums(tags, 273); + const lengths = nums(tags, 279); + for (let i = 0; i < offsets.length; i++) { + const y0 = i * rowsPerStrip; + if (y0 >= height) break; + const offset = offsets[i]; + const length = lengths[i] ?? 0; + if (offset < 0 || !length || offset + length > bytes.length) continue; + const chunk: Chunk = { + x0: 0, y0, stride: width, width, + height: Math.min(rowsPerStrip, height - y0), + offset, length, + }; + if (compression === 7) { + const scan = bytes.subarray(offset, offset + length); + chunk.jpeg = jpegTables ? jpegWithTables(jpegTables, scan) : scan; + } + chunks.set(i, chunk); + } + } + if (!chunks.size) return null; + + const keyOf = tiled + ? (sx: number, sy: number) => Math.floor(sy / tileHeight) * Math.ceil(width / tileWidth) + Math.floor(sx / tileWidth) + : (_sx: number, sy: number) => Math.floor(sy / rowsPerStrip); + + const value = (s: Samples, i: number) => { + if (i < 0 || i >= s.data.length) return 0; + const raw = s.data[i]; + return s.depth === 16 ? raw >> 8 : raw; + }; + + const decode = (chunk: Chunk): Decoded | null => { + if (chunk.jpeg) { + const image = Skia.Image.MakeImageFromEncoded(chunk.jpeg); + if (!image) return null; + try { + const w = image.width(); + const h = image.height(); + if (!w || !h) return null; + const data = new Uint8Array(w * h * 4); + const done = image.readPixels( + 0, 0, + { + width: w, + height: h, + colorType: Skia.ColorType.RGBA_8888, + alphaType: Skia.AlphaType.Unpremul, + colorSpace: Skia.ColorSpace.SRGB, + }, + data, w * 4, + ); + return done ? { kind: 'rgba', width: w, height: h, data } : null; + } finally { + image.delete(); + } + } + + const stride = Math.ceil((chunk.stride * spp * depth) / 8); + const want = stride * chunk.height; + let raw: Uint8Array; + if (compression === 1) raw = bytes.subarray(chunk.offset, Math.min(bytes.length, chunk.offset + want)); + else if (compression === 5) raw = lzwDecode(bytes.subarray(chunk.offset, chunk.offset + chunk.length), want); + else if (compression === 32773) raw = packBits(bytes.subarray(chunk.offset, chunk.offset + chunk.length), want); + else return null; + if (raw.length < want) return null; + + if (depth === 1) { + const data = new Uint8Array(chunk.height * chunk.width * spp); + for (let y = 0; y < chunk.height; y++) { + for (let x = 0; x < chunk.width; x++) { + const at = y * stride + (x >> 3); + const bit = at < raw.length ? (raw[at] >> (7 - (x & 7))) & 1 : 0; + data[y * chunk.width * spp + x] = bit * 255; + } + } + return { kind: 'samples', width: chunk.width, height: chunk.height, spp, depth: 1, data }; + } + + if (depth === 16) { + const data = new Uint16Array(chunk.height * chunk.width * spp); + for (let i = 0; i < data.length; i++) { + const at = i * 2; + data[i] = little ? raw[at] | (raw[at + 1] << 8) : (raw[at] << 8) | raw[at + 1]; + } + if (predictor === 2) differencing(data, chunk, spp); + return { kind: 'samples', width: chunk.width, height: chunk.height, spp, depth: 16, data }; + } + + const data = new Uint8Array(want).fill(0); + data.set(raw.subarray(0, want)); + if (predictor === 2) differencing(data, chunk, spp); + return { kind: 'samples', width: chunk.width, height: chunk.height, spp, depth: 8, data }; + }; + + const scale = Math.min(1, maxSide / Math.max(width, height)); + const outW = Math.max(1, Math.round(width * scale)); + const outH = Math.max(1, Math.round(height * scale)); + const out = new Uint8Array(outW * outH * 4); + + const palette = photometric === 3 ? nums(tags, 320) : null; + const half = palette ? palette.length / 3 : 0; + + let cachedKey = -1; + let cached: Decoded | null = null; + + for (let oy = 0; oy < outH; oy++) { + const sy = Math.min(height - 1, Math.floor((oy * height) / outH)); + for (let ox = 0; ox < outW; ox++) { + const sx = Math.min(width - 1, Math.floor((ox * width) / outW)); + const key = keyOf(sx, sy); + if (key !== cachedKey) { + cachedKey = key; + const chunk = chunks.get(key); + cached = chunk ? decode(chunk) : null; + } + const chunk = chunks.get(key); + if (!cached || !chunk) continue; + const chunkX = sx - chunk.x0; + const chunkY = sy - chunk.y0; + if (chunkX < 0 || chunkY < 0 || chunkX >= chunk.width || chunkY >= chunk.height) continue; + + const at = (oy * outW + ox) * 4; + let r: number; + let g: number; + let b: number; + + if (cached.kind === 'rgba') { + const i = (chunkY * cached.width + chunkX) * 4; + if (i + 3 >= cached.data.length) continue; + r = cached.data[i]; + g = cached.data[i + 1]; + b = cached.data[i + 2]; + } else { + const i = chunkY * cached.width * spp + chunkX * spp; + if (palette && half) { + const index = value(cached, i); + r = (palette[index] ?? 0) >> 8; + g = (palette[half + index] ?? 0) >> 8; + b = (palette[2 * half + index] ?? 0) >> 8; + } else if (photometric === 2 || photometric === 6) { + r = value(cached, i); + g = value(cached, i + 1); + b = value(cached, i + 2); + } else { + const gray = value(cached, i); + r = g = b = photometric === 0 ? 255 - gray : gray; + } + } + + out[at] = r; + out[at + 1] = g; + out[at + 2] = b; + out[at + 3] = 255; + } + } + + return { width: outW, height: outH, rgba: out }; +} + +// Horizontal differencing, undone in place: every sample is the one before it +// plus the difference stored. Bounded by the type, which is the point — a 16-bit +// sample wrapped at 65536 and an 8-bit one at 256, and the file wrapped them the +// same way. +function differencing(data: Uint8Array | Uint16Array, chunk: Chunk, spp: number): void { + const row = chunk.width * spp; + for (let y = 0; y < chunk.height; y++) { + const base = y * row; + for (let i = spp; i < row; i++) data[base + i] += data[base + i - spp]; + } +} + +// A JPEG-compressed strip carries the frame's own tables — the whole stream +// minus the entropy-coded scan — and the strip is the scan. Joining the two is +// the only way either decodes: a strip on its own is missing its tables, and the +// tables on their own are not a picture. +function jpegWithTables(tables: Uint8Array, strip: Uint8Array): Uint8Array { + const tableFrom = tables[0] === 0xff && tables[1] === 0xd8 ? 2 : 0; + const tableTo = tables.length > 4 && tables[tables.length - 2] === 0xff && tables[tables.length - 1] === 0xd9 + ? tables.length - 2 + : tables.length; + const scanFrom = strip[0] === 0xff && strip[1] === 0xd8 ? 2 : 0; + const complete = strip.length > 4 && strip[strip.length - 2] === 0xff && strip[strip.length - 1] === 0xd9; + const body = tableTo - tableFrom; + const out = new Uint8Array(2 + body + (strip.length - scanFrom) + (complete ? 0 : 2)); + out[0] = 0xff; + out[1] = 0xd8; + out.set(tables.subarray(tableFrom, tableTo), 2); + out.set(strip.subarray(scanFrom), 2 + body); + if (!complete) { + out[out.length - 2] = 0xff; + out[out.length - 1] = 0xd9; + } + return out; +} + +// TIFF's LZW: MSB-first codes, 9 to 12 bits, and the code width steps up one +// code early — at 511, not 512 — which is the quirk every other implementation +// trips on. +function lzwDecode(src: Uint8Array, expected: number): Uint8Array { + const out = new Uint8Array(expected); + const prefix = new Int32Array(4096); + const suffix = new Uint8Array(4096); + const stack = new Uint8Array(4096); + let taken = 0; + let read = 0; + let bitCount = 0; + let bitBuffer = 0; + let next = 258; + let codeSize = 9; + let previous = -1; + let first = 0; + + const code = () => { + while (bitCount < codeSize) { + if (read >= src.length) return -1; + bitBuffer = (bitBuffer << 8) | src[read++]; + bitCount += 8; + } + bitCount -= codeSize; + return (bitBuffer >> bitCount) & ((1 << codeSize) - 1); + }; + + const emit = (at: number) => { + let depth = 0; + let walk = at; + while (walk >= 256) { + if (depth >= 4096 || prefix[walk] < 0) return; + stack[depth++] = suffix[walk]; + walk = prefix[walk]; + } + stack[depth++] = walk; + first = walk; + while (depth > 0) { + if (taken >= out.length) return; + out[taken++] = stack[--depth]; + } + }; + + let current = code(); + while (current >= 0 && current !== 257) { + if (current === 256) { + next = 258; + codeSize = 9; + previous = -1; + current = code(); + continue; + } + if (previous < 0) { + if (current > 255) return out; + emit(current); + } else if (current < next) { + emit(current); + if (next < 4096) { + prefix[next] = previous; + suffix[next] = first; + next++; + } + } else { + if (next >= 4096) return out; + prefix[next] = previous; + suffix[next] = first; + emit(next); + next++; + } + previous = current; + if (next === (1 << codeSize) - 1 && codeSize < 12) codeSize++; + current = code(); + } + return out; +} + +function packBits(src: Uint8Array, expected: number): Uint8Array { + const out = new Uint8Array(expected); + let taken = 0; + let read = 0; + while (read < src.length && taken < expected) { + let run = src[read++]; + if (run > 127) run -= 256; + if (run >= 0) { + const end = Math.min(src.length, read + run + 1); + while (read < end && taken < expected) out[taken++] = src[read++]; + read = end; + } else if (run !== -128) { + const value = src[read++]; + const times = Math.min(1 - run, expected - taken); + for (let i = 0; i < times; i++) out[taken++] = value; + } + } + return out; +}