From 40c0db420e14b8ebc7c06fc04f725061758bfb1f Mon Sep 17 00:00:00 2001 From: mhrsntrk Date: Thu, 5 Nov 2026 15:04:50 +0100 Subject: [PATCH] feat: EXIF carry-over in convertHeic, embedded thumbnail decoding convertHeic takes exif: 'none' | 'all' | 'no-location'. Copied EXIF gets Orientation reset to 1 and PixelX/YDimension set to the output size, since the pixels are already upright; leaving the iPhone's Orientation in place makes viewers rotate a second time. 'no-location' drops the GPS IFD pointer and zeroes every GPS byte, and fails closed on a block it cannot walk. Any Exif APP1 the encoder wrote (WebKit adds one) is replaced, not duplicated. decodeHeicThumbnail decodes the thmb item with WebCodecs and falls back to a downscaled primary; 4 of 9 recent iPhone photos in the local corpus have no thumbnail at all. readExif returns the raw TIFF block. New synthetic fixture exif-thumb.heic (fake GPS, Orientation 6, irot 270, 96 px thumbnail). exifr is a dev dependency so the tests check output with an independent parser rather than our own reader. --- .changeset/exif-and-thumbnails.md | 10 + README.md | 36 ++- docs/api.md | 29 +- docs/compatibility.md | 4 +- package.json | 1 + pnpm-lock.yaml | 8 + src/index.ts | 152 ++++++++++- src/parser/exif.ts | 291 +++++++++++++++++++++ src/plan.ts | 38 ++- src/types.ts | 35 +++ test/browser/metadata.spec.ts | 173 ++++++++++++ test/fixtures/README.md | 5 + test/fixtures/generated/exif-thumb.heic | Bin 0 -> 8637 bytes test/fixtures/generated/exif-thumb.ref.png | Bin 0 -> 11760 bytes test/fixtures/generated/manifest.json | 17 ++ test/unit/exif.test.ts | 181 +++++++++++++ tools/make-fixtures.sh | 14 +- 17 files changed, 975 insertions(+), 19 deletions(-) create mode 100644 .changeset/exif-and-thumbnails.md create mode 100644 src/parser/exif.ts create mode 100644 test/browser/metadata.spec.ts create mode 100644 test/fixtures/generated/exif-thumb.heic create mode 100644 test/fixtures/generated/exif-thumb.ref.png create mode 100644 test/unit/exif.test.ts diff --git a/.changeset/exif-and-thumbnails.md b/.changeset/exif-and-thumbnails.md new file mode 100644 index 0000000..ba80bea --- /dev/null +++ b/.changeset/exif-and-thumbnails.md @@ -0,0 +1,10 @@ +--- +"@su-engineering/heic": minor +--- + +Add EXIF carry-over and embedded-thumbnail decoding. + +- `convertHeic` takes `exif: 'none' | 'all' | 'no-location'` (default `'none'`). Copied EXIF has Orientation reset to 1 and the pixel dimensions set to the output size, so viewers do not rotate the upright pixels a second time. `'no-location'` removes the GPS block and zeroes its bytes. `ConvertedImage.exif` reports what was written. +- `decodeHeicThumbnail` decodes the embedded `thmb` thumbnail with WebCodecs, and falls back to a downscaled primary when the file has none or it cannot be decoded. +- `readExif` returns the raw EXIF TIFF block. +- `planItem` and `findThumbnailItemId` are exported for advanced use. diff --git a/README.md b/README.md index 419d993..60c9dca 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,35 @@ form.append('image', converted.blob, 'photo.jpg'); console.log(converted.width, converted.height, converted.strategy, converted.warnings); ``` -Install `libheif-js` for the fallback in this example. Omit `wasmLoader` if you only want browser decoding. Conversion runs in browsers and workers, releases its own bitmap, and does not copy source EXIF into the output. Browser encoders determine the final color and compression behavior. +Install `libheif-js` for the fallback in this example. Omit `wasmLoader` if you only want browser decoding. Conversion runs in browsers and workers and releases its own bitmap. Browser encoders determine the final color and compression behavior. + +### Keep EXIF in the JPEG + +Source EXIF is dropped unless you ask for it, so a photo's location never leaves the device by accident: + +```ts +const converted = await convertHeic(file, { exif: 'no-location' }); // or 'all' +console.log(converted.exif); // what was actually written: 'none', 'all' or 'no-location' +``` + +`'no-location'` removes the GPS block and overwrites its bytes; camera, lens and capture time stay. Either mode rewrites Orientation to 1 and the pixel dimensions to the output size, because the pixels are already upright; leaving the iPhone's Orientation in place makes viewers rotate the photo a second time. EXIF is written to JPEG only. When the source has no EXIF, the result says `exif: 'none'` with an `exif-missing` warning. + +To read the metadata without converting, `readExif(file)` returns the raw TIFF block for an EXIF parser such as [exifr](https://github.com/MikeKovarik/exifr). + +## Fast previews from the embedded thumbnail + +Many iPhone photos carry a ~320 px HEVC thumbnail next to the full 12 to 48 MP grid. Decoding it is far cheaper than decoding the photo, which suits gallery grids and upload previews: + +```ts +import { decodeHeicThumbnail } from '@su-engineering/heic'; + +const preview = await decodeHeicThumbnail(file); +canvas.getContext('2d')!.drawImage(preview.image, 0, 0); +preview.image.close(); +console.log(preview.embedded); // false when the full image was decoded instead +``` + +The embedded thumbnail is decoded with WebCodecs. Recent iPhones often omit it, and some browsers cannot decode it; in both cases the primary image is decoded through the normal cascade and downscaled to the thumbnail's size (320 px when there is none). Pass `fallback: 'none'` to get a `HeicUnsupportedError` instead. ## Choose by capability @@ -122,7 +150,9 @@ HEVC support depends on the browser, OS, installed codecs, hardware, and file pr | Export | Purpose | | --- | --- | | `decodeHeic(input, options?)` | Decode a `Blob`, `File`, `ArrayBuffer`, or `Uint8Array` to an `ImageBitmap` plus metadata. | -| `convertHeic(input, options?)` | Encode the primary image to a JPEG/PNG `Blob` plus decode metadata; releases its bitmap automatically. | +| `convertHeic(input, options?)` | Encode the primary image to a JPEG/PNG `Blob` plus decode metadata, optionally with EXIF; releases its bitmap automatically. | +| `decodeHeicThumbnail(input, options?)` | Decode the embedded thumbnail, or a downscaled primary when there is none. | +| `readExif(input)` | Return the raw EXIF TIFF block, or `null`. | | `isHeic(input)` | Inspect up to the first 64 KiB for HEIC identification and coding hints. | | `probeSupport()` | Probe native HEIC decoding and accepted WebCodecs HEVC configurations. | | `parseHeif(buffer)` | Inspect container items, properties, references, and locations without decoding pixels. | @@ -133,7 +163,7 @@ Common decode options are `strategy`, `maxDimension`, `colorSpace`, `signal`, an ## Scope and limits -This library returns pixels or a JPEG/PNG conversion of the primary HEVC image. It does not encode HEIC, supply an upload UI, preserve EXIF in the output, or decode AVIF, animation, or Live Photo video. Recognized alpha, depth, and HDR gain-map auxiliary items produce warnings; warning coverage is not exhaustive. +This library returns pixels or a JPEG/PNG conversion of the primary HEVC image. It does not encode HEIC, supply an upload UI, write EXIF to PNG, or decode AVIF, animation, or Live Photo video. Recognized alpha, depth, and HDR gain-map auxiliary items produce warnings; warning coverage is not exhaustive. `maxDimension` reduces the returned bitmap size. **It does not cap peak decode memory:** decoding and compositing may still allocate the full-resolution image. Apply file-size limits, bound concurrent decodes, and use workers for large or untrusted uploads. diff --git a/docs/api.md b/docs/api.md index cf15bc7..d09df82 100644 --- a/docs/api.md +++ b/docs/api.md @@ -42,11 +42,33 @@ The decoder returns pixels, not a new HEIC file. EXIF and other source metadata ## `convertHeic(input, options?)` -Returns `Promise` using the same inputs and decode options as `decodeHeic`. Adds `type: 'image/jpeg' | 'image/png'` (default JPEG) and `quality` (default `0.92`, a finite number from 0 to 1; ignored for PNG). Invalid type/quality values throw `TypeError`/`RangeError` before decoding. +Returns `Promise` using the same inputs and decode options as `decodeHeic`. Adds `type: 'image/jpeg' | 'image/png'` (default JPEG), `quality` (default `0.92`, a finite number from 0 to 1; ignored for PNG) and `exif` (below). Invalid type/quality/exif values throw `TypeError`/`RangeError` before decoding. The result contains `blob` and all `DecodedImage` metadata except `image`. Dimensions describe the encoded image. Transforms and `maxDimension` apply before encoding. Its bitmap and conversion canvas are released on success, cancellation, or encoder failure; callers do not need `close()`. Revoke any object URLs you create when finished. -Conversion uses `OffscreenCanvas.convertToBlob`, including in workers. Platform encoding errors can propagate. An empty Blob or unexpected MIME type throws `HeicDecodeError`. Cancellation is checked before and after encoding, but cannot interrupt an encoder already running. Quality and encoded bytes can differ across browsers. Source EXIF is not copied; full HDR and cross-browser color equivalence are not promised. The software fallback remains explicit through `wasmLoader` or adapter registration. +Conversion uses `OffscreenCanvas.convertToBlob`, including in workers. Platform encoding errors can propagate. An empty Blob or unexpected MIME type throws `HeicDecodeError`. Cancellation is checked before and after encoding, but cannot interrupt an encoder already running. Quality and encoded bytes can differ across browsers. Full HDR and cross-browser color equivalence are not promised. The software fallback remains explicit through `wasmLoader` or adapter registration. + +### EXIF + +| `exif` | Written to the JPEG | +| --- | --- | +| `'none'` (default) | Nothing from the source. Some encoders (WebKit) add their own color-space and dimension tags. | +| `'all'` | The source EXIF block, including GPS. | +| `'no-location'` | The source EXIF block with the GPS IFD pointer removed and every GPS byte, including out-of-line values, overwritten with zeros. | + +In both copying modes, Orientation is set to 1 and `PixelXDimension`/`PixelYDimension` to the encoded size, since the pixels are already transformed. All other tags, MakerNote included, are copied byte for byte; MakerNote contents are not inspected. Any Exif segment the browser encoder wrote is replaced. Requesting EXIF with `type: 'image/png'` throws `TypeError`. + +`ConvertedImage.exif` reports what was actually written. It is `'none'` with an `exif-missing` warning when the source has no EXIF, and `'none'` with an `exif-not-written` warning when the block is malformed or larger than one JPEG APP1 segment (about 64 KB). A malformed block is never written in `'no-location'` mode, because it cannot be proven free of location data. + +## `decodeHeicThumbnail(input, options?)` + +Returns `Promise`: a `DecodedImage` plus `embedded: boolean`. Takes the `decodeHeic` options plus `fallback: 'primary' | 'none'` (default `'primary'`). + +The thumbnail is the image item linked to the primary by a `thmb` reference (the largest, if there are several), decoded with WebCodecs and its own `irot`/`imir`/`clap` applied. `maxDimension` downsizes it further but never upscales. If the file has no thumbnail, `strategy` excludes WebCodecs, or the decode fails, `'primary'` decodes the primary image through the normal cascade with `maxDimension` defaulting to the thumbnail's longest side, or 320 when there is none; `'none'` throws `HeicUnsupportedError` listing the attempts. The caller owns and must close `image`, as with `decodeHeic`. + +## `readExif(input)` + +Returns `Promise`: a copy of the primary image's EXIF as a TIFF block starting at the `II`/`MM` byte-order mark, or `null`. The Exif item is found by its `cdsc` reference, or as the only Exif item in the file. Values are as stored: Orientation describes the coded pixels, which `decodeHeic` has already rotated. ## `isHeic(input)` @@ -112,7 +134,8 @@ These functions do not decode pixels and accept `ArrayBuffer`/`Uint8Array` where | `readItemData` | Extract an item's payload from its extents. | | `readGrid`, `parseGridPayload` | Read grid references and grid descriptors. | | `planDecode` | Resolve an `ImagePlan` with tiles, groups, display size, transforms, and warnings. | +| `planItem`, `findThumbnailItemId` | Plan any image item of a parsed file; find the primary's `thmb` thumbnail item. | | `parseHvcC`, `hvccToCodecString` | Parse HEVC configuration and produce a WebCodecs codec string. | | `hvccToAnnexBPrologue`, `lengthPrefixedToAnnexB` | Prepare HEVC NAL data for Annex B decoding. | -Exported types include `BinaryInput`, `IsHeicResult`, `DecodeOptions`, `DecodedImage`, `ConvertOptions`, `ConvertedImage`, `Strategy`, `OutputColorSpace`, `SourceColor`, `TransformsApplied`, `HeicWarning`, `SupportReport`, `HeicErrorContext`, `DecoderAdapter`, `AdapterRequest`, `AdapterResult`, `HeifFile`, `ItemInfo`, `ItemLocation`, `ItemProperty`, `ItemProperties`, `ItemReferences`, `GridDescriptor`, `HvcC`, `ImagePlan`, `PlannedTile`, `TileGroup`, and `TransformOp`. The WASM entry point additionally exports `WasmAdapterOptions`. +Exported types include `BinaryInput`, `IsHeicResult`, `DecodeOptions`, `DecodedImage`, `ConvertOptions`, `ConvertedImage`, `ExifMode`, `ThumbnailOptions`, `ThumbnailImage`, `Strategy`, `OutputColorSpace`, `SourceColor`, `TransformsApplied`, `HeicWarning`, `SupportReport`, `HeicErrorContext`, `DecoderAdapter`, `AdapterRequest`, `AdapterResult`, `HeifFile`, `ItemInfo`, `ItemLocation`, `ItemProperty`, `ItemProperties`, `ItemReferences`, `GridDescriptor`, `HvcC`, `ImagePlan`, `PlannedTile`, `TileGroup`, and `TransformOp`. The WASM entry point additionally exports `WasmAdapterOptions`. diff --git a/docs/compatibility.md b/docs/compatibility.md index 28fcd08..5110dd9 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -20,7 +20,7 @@ Do not infer support from a browser name or version alone. Platform codecs, hard Supported primary items are HEVC `hvc1`/`hev1` images and supported HEVC tiled grids. Container `irot`, `imir`, and valid `clap` transforms are planned; native/libheif paths are expected to apply those themselves. Unsupported essential properties cause an error. -The library decodes one primary image. Recognized alpha, depth/disparity, and HDR gain-map auxiliary items can generate warnings but are not composited. It does not decode AVIF/AV1, arbitrary derived-image types, animation, image sequences, Live Photo motion, or all HEIF extensions. Sequence and motion features are not exhaustively detected, so no warning does not establish their absence. +The library decodes one primary image, or its embedded `thmb` thumbnail through `decodeHeicThumbnail`. Recognized alpha, depth/disparity, and HDR gain-map auxiliary items can generate warnings but are not composited. It does not decode AVIF/AV1, arbitrary derived-image types, animation, image sequences, Live Photo motion, or all HEIF extensions. Sequence and motion features are not exhaustively detected, so no warning does not establish their absence. ## Color and HDR @@ -42,7 +42,7 @@ Cancellation is cooperative. An `AbortSignal` is checked around parts of the pip - Browser tests exercise the public built package, software decoding, transforms, dimensions, and workers across Playwright engines. - Native and WebCodecs tests skip when capability probes fail. Headless Linux generally cannot validate platform HEVC decoding; some WebKit builds accept limited profiles through software decoders. Tests query each fixture configuration. - The optional `chrome-hevc` project can exercise an installed browser with platform decoding. Released Safari still needs Apple-device testing. -- Committed fixtures include a tiny single-image file and generated asymmetric transform variants. They do not provide a broad real-world Apple grid/device corpus. Private local fixtures improve local coverage but are absent from CI. +- Committed fixtures include a tiny single-image file, generated asymmetric transform variants, and a generated file with EXIF (synthetic GPS, Orientation) and an embedded thumbnail, the way an iPhone writes them. They do not provide a broad real-world Apple grid/device corpus. Private local fixtures improve local coverage but are absent from CI. A green CI run establishes only the tests that executed. Review skips and the input corpus before making support claims. See [fixture documentation](https://github.com/su-engineering/heic-web/blob/master/test/fixtures/README.md). diff --git a/package.json b/package.json index fa3dde8..27105fc 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "@playwright/test": "^1.62.1", "@types/node": "^22.20.1", "esbuild": "^0.28.1", + "exifr": "7.1.3", "heic-to": "1.5.2", "libheif-js": "^1.23.2", "tsup": "^8.5.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e3c1107..167b7dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: esbuild: specifier: ^0.28.1 version: 0.28.2 + exifr: + specifier: 7.1.3 + version: 7.1.3 heic-to: specifier: 1.5.2 version: 1.5.2 @@ -596,6 +599,9 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + exifr@7.1.3: + resolution: {integrity: sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==} + expect-type@1.4.0: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} @@ -1642,6 +1648,8 @@ snapshots: dependencies: '@types/estree': 1.0.9 + exifr@7.1.3: {} + expect-type@1.4.0: {} extendable-error@0.1.7: {} diff --git a/src/index.ts b/src/index.ts index 696636b..0609729 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,9 @@ import { HeicAbortError, HeicDecodeError, HeicUnsupportedError } from './errors. import { decodeNative } from './decoders/native.ts'; import { decodeWithWebCodecs, isWebCodecsAvailable } from './decoders/webcodecs.ts'; import { DETECTION_PREFIX_BYTES, detectFromBuffer } from './parser/detect.ts'; -import { planDecode, type ImagePlan } from './plan.ts'; +import { extractExifTiff, insertJpegExif, prepareExifForOutput } from './parser/exif.ts'; +import { parseHeif } from './parser/meta.ts'; +import { findThumbnailItemId, planDecode, planItem, type ImagePlan } from './plan.ts'; import { createCanvas, throwIfAborted } from './render/canvas.ts'; import { applyTransforms, summarizeTransforms } from './render/transform.ts'; import type { @@ -12,7 +14,11 @@ import type { ConvertedImage, DecodedImage, DecoderAdapter, + ExifMode, + HeicWarning, Strategy, + ThumbnailImage, + ThumbnailOptions, TransformsApplied, } from './types.ts'; @@ -42,7 +48,7 @@ export { parseHvcC, } from './parser/hvcc.ts'; export type { HvcC } from './parser/hvcc.ts'; -export { planDecode } from './plan.ts'; +export { findThumbnailItemId, planDecode, planItem } from './plan.ts'; export type { ImagePlan, PlannedTile, TileGroup, TransformOp } from './plan.ts'; export { probeSupport } from './probe.ts'; export type { @@ -53,11 +59,14 @@ export type { ConvertedImage, DecodedImage, DecoderAdapter, + ExifMode, HeicWarning, OutputColorSpace, SourceColor, Strategy, SupportReport, + ThumbnailImage, + ThumbnailOptions, TransformsApplied, } from './types.ts'; @@ -208,23 +217,35 @@ export async function decodeHeic( }); } +const EXIF_MODES: readonly ExifMode[] = ['none', 'all', 'no-location']; + /** * Convert the primary HEIC image to JPEG or PNG using the browser's encoder. * Uses the same explicit decode cascade as decodeHeic and releases all pixels. - * Source EXIF is not copied into the output file. + * + * Source EXIF is dropped unless `exif` asks for it (JPEG only). When it is + * written, Orientation is reset to 1 and the pixel dimensions are updated, since + * the output pixels are already upright. */ export async function convertHeic( input: BinaryInput, options: ConvertOptions = {}, ): Promise { - const { type = 'image/jpeg', quality = 0.92, ...decodeOptions } = options; + const { type = 'image/jpeg', quality = 0.92, exif = 'none', ...decodeOptions } = options; if (type !== 'image/jpeg' && type !== 'image/png') { throw new TypeError('type must be image/jpeg or image/png'); } if (!Number.isFinite(quality) || quality < 0 || quality > 1) { throw new RangeError('quality must be a finite number between 0 and 1'); } - const decoded = await decodeHeic(input, decodeOptions); + if (!EXIF_MODES.includes(exif)) { + throw new TypeError("exif must be 'none', 'all' or 'no-location'"); + } + if (exif !== 'none' && type !== 'image/jpeg') { + throw new TypeError('exif can only be written to image/jpeg output'); + } + const bytes = await readAll(input); + const decoded = await decodeHeic(bytes, decodeOptions); let canvas: OffscreenCanvas | undefined; try { throwIfAborted(options.signal); @@ -232,13 +253,25 @@ export async function convertHeic( const ctx = canvas.getContext('2d', { colorSpace: options.colorSpace ?? 'srgb', alpha: false }); if (!ctx) throw new HeicDecodeError('Could not get a 2d context for conversion', {}); ctx.drawImage(decoded.image, 0, 0); - const blob = await canvas.convertToBlob({ type, quality }); + let blob = await canvas.convertToBlob({ type, quality }); throwIfAborted(options.signal); if (blob.type !== type || blob.size === 0) { throw new HeicDecodeError(`Browser could not encode ${type}`, {}); } + const { image: _image, ...metadata } = decoded; - return { ...metadata, blob }; + const warnings: HeicWarning[] = [...metadata.warnings]; + let written: ExifMode = 'none'; + if (exif !== 'none') { + const embedded = await embedExif(blob, bytes, exif, decoded.width, decoded.height); + if (embedded.blob) { + blob = embedded.blob; + written = exif; + } else { + warnings.push(embedded.warning); + } + } + return { ...metadata, warnings, exif: written, blob }; } finally { decoded.image.close(); if (canvas) { @@ -248,6 +281,111 @@ export async function convertHeic( } } +async function embedExif( + jpeg: Blob, + source: Uint8Array, + mode: Exclude, + width: number, + height: number, +): Promise<{ blob: Blob; warning?: never } | { blob?: never; warning: HeicWarning }> { + const tiff = extractExifTiff(parseHeif(source)); + if (!tiff) { + return { warning: { code: 'exif-missing', message: 'The source has no EXIF block to copy' } }; + } + const prepared = prepareExifForOutput(tiff, { width, height, stripLocation: mode === 'no-location' }); + const merged = prepared && insertJpegExif(new Uint8Array(await jpeg.arrayBuffer()), prepared); + if (!merged) { + return { + warning: { + code: 'exif-not-written', + message: prepared + ? 'The EXIF block does not fit in a JPEG APP1 segment; written without EXIF' + : 'The source EXIF block is malformed; written without EXIF', + }, + }; + } + return { blob: new Blob([merged as Uint8Array], { type: 'image/jpeg' }) }; +} + +/** + * The primary image's raw EXIF as a TIFF block ("II*\0" or "MM\0*" onwards), + * or `null` when the file has none. Hand it to an EXIF parser such as exifr. + * + * This is the metadata exactly as stored: Orientation there describes the coded + * pixels, which `decodeHeic` has already rotated. + */ +export async function readExif(input: BinaryInput): Promise { + return extractExifTiff(parseHeif(await readAll(input))) ?? null; +} + +/** Longest side used when there is no thumbnail to size the fallback after. */ +const DEFAULT_THUMBNAIL_SIZE = 320; + +/** + * Decodes a small preview: the embedded thumbnail when the file has one, which + * is a ~320 px HEVC frame and far cheaper than the full grid. + * + * The embedded thumbnail is decoded with WebCodecs. When there is none, or it + * cannot be decoded, the primary image is decoded through the normal cascade and + * downscaled (see `fallback`). `embedded` says which happened. + */ +export async function decodeHeicThumbnail( + input: BinaryInput, + options: ThumbnailOptions = {}, +): Promise { + const { fallback = 'primary', strategy = 'auto', colorSpace = 'srgb', maxDimension, signal } = + options; + throwIfAborted(signal); + const bytes = await readAll(input); + throwIfAborted(signal); + + const file = parseHeif(bytes); + const thumbnailId = findThumbnailItemId(file, file.primaryItemId); + const attempts: { strategy: string; reason: string }[] = []; + let thumbnailPlan: ImagePlan | undefined; + if (thumbnailId !== undefined) { + try { + thumbnailPlan = planItem(file, thumbnailId); + } catch (error) { + attempts.push({ strategy: 'webcodecs', reason: describeError(error) }); + } + } + + if (thumbnailPlan && (strategy === 'auto' || strategy === 'webcodecs')) { + try { + const plan = thumbnailPlan; + if (!isWebCodecsAvailable()) throw new Error('VideoDecoder is not available'); + const composited = await decodeWithWebCodecs(plan, colorSpace, signal); + const { canvas, applied } = applyTransforms(composited, plan.transforms, colorSpace); + const bitmap = await canvasToBitmap(canvas, maxDimension, signal); + return { ...describe(plan, bitmap, 'webcodecs', applied), embedded: true }; + } catch (error) { + if (error instanceof HeicAbortError) throw error; + attempts.push({ strategy: 'webcodecs', reason: describeError(error) }); + } + } + + if (fallback === 'none') { + throw new HeicUnsupportedError( + thumbnailId === undefined + ? 'This HEIC has no embedded thumbnail' + : 'The embedded thumbnail could not be decoded', + attempts, + { brand: file.majorBrand, itemId: thumbnailId ?? file.primaryItemId }, + ); + } + + const decoded = await decodeHeic(bytes, { + ...options, + maxDimension: + maxDimension ?? + (thumbnailPlan + ? Math.max(thumbnailPlan.displayWidth, thumbnailPlan.displayHeight) + : DEFAULT_THUMBNAIL_SIZE), + }); + return { ...decoded, embedded: false }; +} + // --------------------------------------------------------------------------- // Adapter registration // --------------------------------------------------------------------------- diff --git a/src/parser/exif.ts b/src/parser/exif.ts new file mode 100644 index 0000000..d74f085 --- /dev/null +++ b/src/parser/exif.ts @@ -0,0 +1,291 @@ +import { readItemData, type HeifFile } from './meta.ts'; + +/** + * EXIF handling: find the Exif item, hand out its TIFF bytes, and prepare a copy + * that is safe to embed in an image whose pixels have already been transformed. + * + * Offsets inside a TIFF block are relative to the block itself, so every edit + * here is done in place on a copy. Nothing is re-serialised, which keeps + * MakerNote and other opaque blocks byte-identical. + */ + +const TAG_ORIENTATION = 0x0112; +const TAG_EXIF_IFD = 0x8769; +const TAG_GPS_IFD = 0x8825; +const TAG_PIXEL_X = 0xa002; +const TAG_PIXEL_Y = 0xa003; + +const TYPE_SHORT = 3; +const TYPE_LONG = 4; + +/** Byte size of one value of each TIFF field type. Unknown types are treated as opaque. */ +const TYPE_SIZES: Record = { + 1: 1, 2: 1, 3: 2, 4: 4, 5: 8, 6: 1, 7: 1, 8: 2, 9: 4, 10: 8, 11: 4, 12: 8, +}; + +/** IFDs with more entries than this are malformed; real files have dozens. */ +const MAX_IFD_ENTRIES = 1024; + +/** + * Item ID of the Exif block that describes `itemId`. + * + * The spec links metadata to images with a `cdsc` reference. Some encoders omit + * the reference, so a lone Exif item is accepted as a fallback; two unreferenced + * Exif items are ambiguous and yield nothing. + */ +export function findExifItemId(file: HeifFile, itemId: number): number | undefined { + const cdsc = file.references.get('cdsc'); + if (cdsc) { + for (const [fromId, toIds] of cdsc) { + if (toIds.includes(itemId) && file.items.get(fromId)?.itemType === 'Exif') return fromId; + } + } + const exifItems = [...file.items.values()].filter((item) => item.itemType === 'Exif'); + return exifItems.length === 1 ? exifItems[0]!.itemId : undefined; +} + +/** + * The TIFF block ("II*\0" or "MM\0*" onwards) of the primary image's EXIF, as a + * fresh copy, or `undefined` when the file has none or it is malformed. + * + * The HEIF Exif item starts with a 4-byte offset to the TIFF header; Apple fills + * the gap with the JPEG-style "Exif\0\0" marker. + */ +export function extractExifTiff(file: HeifFile): Uint8Array | undefined { + const exifId = findExifItemId(file, file.primaryItemId); + if (exifId === undefined) return undefined; + const payload = readItemData(file, exifId); + if (payload.byteLength < 4) return undefined; + const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength); + const start = 4 + view.getUint32(0); + if (start + 8 > payload.byteLength) return undefined; + // new Uint8Array(view) always copies; .slice() on a Node Buffer does not. + const tiff = new Uint8Array(payload.subarray(start)); + return tiffByteOrder(tiff) === undefined ? undefined : tiff; +} + +function tiffByteOrder(tiff: Uint8Array): boolean | undefined { + if (tiff.byteLength < 8) return undefined; + const little = tiff[0] === 0x49 && tiff[1] === 0x49; + const big = tiff[0] === 0x4d && tiff[1] === 0x4d; + if (!little && !big) return undefined; + const view = new DataView(tiff.buffer, tiff.byteOffset, tiff.byteLength); + if (view.getUint16(2, little) !== 42) return undefined; + return little; +} + +interface IfdEntry { + /** Byte offset of the 12-byte entry within the TIFF block. */ + at: number; + tag: number; + type: number; + count: number; +} + +/** Thrown internally for anything structurally wrong; callers turn it into "no EXIF". */ +class MalformedExif extends Error {} + +class Tiff { + readonly view: DataView; + readonly little: boolean; + + constructor(readonly bytes: Uint8Array) { + const little = tiffByteOrder(bytes); + if (little === undefined) throw new MalformedExif('not a TIFF block'); + this.little = little; + this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + } + + u16(at: number): number { + this.check(at, 2); + return this.view.getUint16(at, this.little); + } + u32(at: number): number { + this.check(at, 4); + return this.view.getUint32(at, this.little); + } + setU16(at: number, value: number): void { + this.check(at, 2); + this.view.setUint16(at, value, this.little); + } + setU32(at: number, value: number): void { + this.check(at, 4); + this.view.setUint32(at, value, this.little); + } + check(at: number, length: number): void { + if (at < 0 || length < 0 || at + length > this.bytes.byteLength) { + throw new MalformedExif(`range [${at}, ${at + length}) outside ${this.bytes.byteLength} bytes`); + } + } + + get ifd0(): number { + return this.u32(4); + } + + entries(ifdOffset: number): IfdEntry[] { + const count = this.u16(ifdOffset); + if (count > MAX_IFD_ENTRIES) throw new MalformedExif(`IFD declares ${count} entries`); + this.check(ifdOffset + 2, count * 12 + 4); + const out: IfdEntry[] = []; + for (let i = 0; i < count; i++) { + const at = ifdOffset + 2 + i * 12; + out.push({ at, tag: this.u16(at), type: this.u16(at + 2), count: this.u32(at + 4) }); + } + return out; + } + + /** Out-of-line value range of an entry, or undefined when the value fits inline. */ + valueRange(entry: IfdEntry): { start: number; length: number } | undefined { + const size = TYPE_SIZES[entry.type]; + if (size === undefined) throw new MalformedExif(`unknown TIFF type ${entry.type}`); + const length = size * entry.count; + if (length <= 4) return undefined; + const start = this.u32(entry.at + 8); + this.check(start, length); + return { start, length }; + } + + /** Writes an inline SHORT or LONG value. Other types are left alone. */ + setInlineNumber(entry: IfdEntry, value: number): void { + if (entry.count !== 1) return; + if (entry.type === TYPE_SHORT) { + if (value > 0xffff) { + // Widen to LONG; the 4-byte value slot fits either. + this.setU16(entry.at + 2, TYPE_LONG); + this.setU32(entry.at + 8, value); + } else { + this.setU16(entry.at + 8, value); + this.setU16(entry.at + 10, 0); + } + } else if (entry.type === TYPE_LONG) { + this.setU32(entry.at + 8, value); + } + } +} + +export interface PrepareExifOptions { + /** Dimensions of the encoded output, written to PixelXDimension/PixelYDimension. */ + width: number; + height: number; + /** Remove the GPS IFD and overwrite its bytes with zeros. */ + stripLocation: boolean; +} + +/** + * Returns a copy of `tiff` that is correct for an output image whose pixels are + * already upright: Orientation becomes 1 (otherwise viewers rotate a second + * time) and the pixel dimensions match the output. + * + * With `stripLocation`, the GPS IFD pointer is removed from IFD0 and every byte + * of the GPS IFD, including out-of-line values, is zeroed so the coordinates are + * not recoverable from the file. + * + * Returns `undefined` when the block is malformed. Failing closed matters most + * for `stripLocation`: a block we cannot walk is a block we cannot prove clean. + */ +export function prepareExifForOutput( + tiff: Uint8Array, + options: PrepareExifOptions, +): Uint8Array | undefined { + const copy = new Uint8Array(tiff); // a real copy even when handed a Node Buffer + try { + const t = new Tiff(copy); + const ifd0 = t.ifd0; + const entries = t.entries(ifd0); + + for (const entry of entries) { + if (entry.tag === TAG_ORIENTATION) t.setInlineNumber(entry, 1); + } + + const exifPointer = entries.find((e) => e.tag === TAG_EXIF_IFD); + if (exifPointer) { + for (const entry of t.entries(t.u32(exifPointer.at + 8))) { + if (entry.tag === TAG_PIXEL_X) t.setInlineNumber(entry, options.width); + if (entry.tag === TAG_PIXEL_Y) t.setInlineNumber(entry, options.height); + } + } + + if (options.stripLocation) { + const gpsIndex = entries.findIndex((e) => e.tag === TAG_GPS_IFD); + if (gpsIndex !== -1) { + const gpsOffset = t.u32(entries[gpsIndex]!.at + 8); + const gpsEntries = t.entries(gpsOffset); + for (const entry of gpsEntries) { + const range = t.valueRange(entry); + if (range) copy.fill(0, range.start, range.start + range.length); + } + copy.fill(0, gpsOffset, gpsOffset + 2 + gpsEntries.length * 12 + 4); + removeEntry(t, ifd0, entries.length, gpsIndex); + } + } + return copy; + } catch (error) { + if (error instanceof MalformedExif) return undefined; + throw error; + } +} + +/** Deletes entry `index` from an IFD by shifting the rest down and moving the next-IFD link. */ +function removeEntry(t: Tiff, ifdOffset: number, count: number, index: number): void { + const entriesStart = ifdOffset + 2; + const nextLinkAt = entriesStart + count * 12; + const nextLink = t.u32(nextLinkAt); + t.bytes.copyWithin(entriesStart + index * 12, entriesStart + (index + 1) * 12, nextLinkAt); + t.setU16(ifdOffset, count - 1); + t.setU32(nextLinkAt - 12, nextLink); + t.bytes.fill(0, nextLinkAt - 8, nextLinkAt + 4); +} + +/** Largest TIFF block that fits one APP1 segment: 65535 minus length field and "Exif\0\0". */ +export const MAX_JPEG_EXIF_BYTES = 65_535 - 2 - 6; + +/** + * Inserts an APP1 Exif segment into a JPEG, after SOI and after a JFIF APP0 if + * one is present (the placement exiftool and libjpeg use). + * + * Any Exif APP1 the encoder already wrote is removed first: WebKit's encoder + * adds a small one of its own, and two Exif segments leave viewers to pick one. + * + * Returns `undefined` if `jpeg` is not a well-formed JPEG header or the block is + * too big for a single segment. + */ +export function insertJpegExif(jpeg: Uint8Array, tiff: Uint8Array): Uint8Array | undefined { + if (tiff.byteLength > MAX_JPEG_EXIF_BYTES) return undefined; + if (jpeg.byteLength < 4 || jpeg[0] !== 0xff || jpeg[1] !== 0xd8) return undefined; + + // Walk the header segments up to the first non-APPn marker. + const kept: Uint8Array[] = []; + let insertAt = 0; // index into `kept` after which the new segment goes + let pos = 2; + while (pos + 4 <= jpeg.byteLength && jpeg[pos] === 0xff) { + const marker = jpeg[pos + 1]!; + if (marker < 0xe0 || marker > 0xef) break; + const length = (jpeg[pos + 2]! << 8) | jpeg[pos + 3]!; + const end = pos + 2 + length; + if (length < 2 || end > jpeg.byteLength) return undefined; + const segment = jpeg.subarray(pos, end); + const isExif = + marker === 0xe1 && length >= 8 && + segment[4] === 0x45 && segment[5] === 0x78 && segment[6] === 0x69 && + segment[7] === 0x66 && segment[8] === 0x00 && segment[9] === 0x00; + if (!isExif) { + kept.push(segment); + if (marker === 0xe0 && kept.length === 1) insertAt = 1; + } + pos = end; + } + + const segmentLength = 2 + 6 + tiff.byteLength; + const app1 = new Uint8Array(2 + segmentLength); + app1.set([0xff, 0xe1, segmentLength >> 8, segmentLength & 0xff, 0x45, 0x78, 0x69, 0x66, 0, 0]); + app1.set(tiff, 10); + + const parts = [jpeg.subarray(0, 2), ...kept.slice(0, insertAt), app1, ...kept.slice(insertAt), jpeg.subarray(pos)]; + const out = new Uint8Array(parts.reduce((n, part) => n + part.byteLength, 0)); + let offset = 0; + for (const part of parts) { + out.set(part, offset); + offset += part.byteLength; + } + return out; +} diff --git a/src/plan.ts b/src/plan.ts index a16a092..d833a07 100644 --- a/src/plan.ts +++ b/src/plan.ts @@ -71,9 +71,19 @@ export interface ImagePlan { */ export function planDecode(input: ArrayBuffer | Uint8Array): ImagePlan { const file = parseHeif(input); + return planItem(file, file.primaryItemId); +} + +/** + * Plans the decode of any image item in an already-parsed file. `planDecode` + * is this applied to the primary item; the thumbnail path applies it to the + * `thmb` item. Feature warnings (alpha, depth, gain map) are only collected for + * the primary item, since that is what they describe. + */ +export function planItem(file: HeifFile, itemId: number): ImagePlan { const warnings: HeicWarning[] = []; - const primaryItemId = file.primaryItemId; + const primaryItemId = itemId; const info = file.items.get(primaryItemId); if (!info) { throw new HeicParseError(`Primary item ${primaryItemId} is not described by iinf`, { @@ -163,7 +173,7 @@ export function planDecode(input: ArrayBuffer | Uint8Array): ImagePlan { const bitDepth = pixi?.bitsPerChannel[0] ?? hvccBitDepth(tileGroups[0]!.hvcc); - collectFeatureWarnings(file, primaryItemId, warnings); + if (primaryItemId === file.primaryItemId) collectFeatureWarnings(file, primaryItemId, warnings); return { file, @@ -406,6 +416,30 @@ function collectFeatureWarnings( } } +/** + * Item ID of the embedded thumbnail for `itemId`, linked by a `thmb` reference. + * When several exist, the largest that is still an HEVC image wins. + */ +export function findThumbnailItemId(file: HeifFile, itemId: number): number | undefined { + const thmb = file.references.get('thmb'); + if (!thmb) return undefined; + let best: { id: number; pixels: number } | undefined; + for (const [fromId, toIds] of thmb) { + if (!toIds.includes(itemId)) continue; + const type = file.items.get(fromId)?.itemType; + if (type !== 'hvc1' && type !== 'hev1') continue; + let pixels = 0; + try { + const ispe = findProperty(propertiesForItem(file, fromId), 'ispe'); + pixels = ispe ? ispe.width * ispe.height : 0; + } catch { + continue; + } + if (!best || pixels > best.pixels) best = { id: fromId, pixels }; + } + return best?.id; +} + /** Item payload for a planned tile. */ export function tileData(plan: ImagePlan, tile: PlannedTile): Uint8Array { return readItemData(plan.file, tile.itemId); diff --git a/src/types.ts b/src/types.ts index 119f438..613200c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -39,6 +39,8 @@ export interface HeicWarning { | 'gain-map-ignored' | 'sequence-ignored' | 'thumbnail-ignored' + | 'exif-missing' + | 'exif-not-written' | 'grid-dimension-mismatch' | 'mixed-tile-configs' | 'unknown-property'; @@ -86,16 +88,49 @@ export interface DecodedImage { warnings: HeicWarning[]; } +/** + * What EXIF to carry into a converted JPEG. + * + * - `'none'`: no EXIF. The default, so location never leaves the device by accident. + * - `'all'`: the source EXIF, including GPS. + * - `'no-location'`: the source EXIF with the GPS block removed and its bytes zeroed. + * + * Either way Orientation is rewritten to 1 and the pixel dimensions to the + * output size, because the pixels are already upright. + */ +export type ExifMode = 'none' | 'all' | 'no-location'; + export interface ConvertOptions extends DecodeOptions { /** Output format. Default 'image/jpeg'. */ type?: 'image/jpeg' | 'image/png'; /** JPEG encoder quality, from 0 to 1. Default 0.92; ignored for PNG. */ quality?: number; + /** EXIF to embed. JPEG only. Default 'none'. */ + exif?: ExifMode; } /** Encoded pixels and decode metadata; no bitmap needs to be closed by the caller. */ export interface ConvertedImage extends Omit { blob: Blob; + /** The EXIF actually written: `'none'` when not requested, absent from the source, or unwritable. */ + exif: ExifMode; +} + +export interface ThumbnailOptions extends DecodeOptions { + /** + * What to do when the file has no decodable embedded thumbnail. Recent iPhones + * often omit it. + * + * - `'primary'` (default): decode the full image downscaled to `maxDimension`, + * or to the size the thumbnail would have had (320 px when there is none). + * - `'none'`: throw `HeicUnsupportedError`. + */ + fallback?: 'primary' | 'none'; +} + +export interface ThumbnailImage extends DecodedImage { + /** True when the pixels came from the embedded thumbnail rather than the primary image. */ + embedded: boolean; } export interface SupportReport { diff --git a/test/browser/metadata.spec.ts b/test/browser/metadata.spec.ts new file mode 100644 index 0000000..36135b4 --- /dev/null +++ b/test/browser/metadata.spec.ts @@ -0,0 +1,173 @@ +import { expect, test } from '@playwright/test'; +import exifr from 'exifr'; +import { hasWebCodecsHevc, loadFixtures, openHarness } from './support.ts'; + +const EXIF_FIXTURE = '/test/fixtures/generated/exif-thumb.heic'; +const EXIF_REFERENCE = '/test/fixtures/generated/exif-thumb.ref.png'; +const PLAIN_FIXTURE = '/test/fixtures/generated/asym-base.heic'; + +test.beforeEach(async ({ page }) => { await openHarness(page); }); + +/** Converts in the page and hands the encoded bytes back for parsing in Node. */ +async function convert( + page: import('@playwright/test').Page, + url: string, + options: Record, +) { + return page.evaluate(async ({ url, options }) => { + const source = await (await fetch(url)).blob(); + const converted = await window.heic.convertHeic(source, { + strategy: 'wasm', wasmLoader: async () => window.wasmAdapter(), ...options, + }); + // createImageBitmap honours EXIF Orientation by default, exactly like an + // . If Orientation were left at 6 the decoded bitmap would come out + // with width and height swapped. + const bitmap = await createImageBitmap(converted.blob); + const result = { + bytes: Array.from(new Uint8Array(await converted.blob.arrayBuffer())), + exif: converted.exif, + warnings: converted.warnings.map((w) => w.code), + width: converted.width, + height: converted.height, + shownWidth: bitmap.width, + shownHeight: bitmap.height, + }; + bitmap.close(); + return result; + }, { url, options }); +} + +test('EXIF is dropped by default', async ({ page }) => { + const result = await convert(page, EXIF_FIXTURE, {}); + expect(result.exif).toBe('none'); + // WebKit's encoder writes its own ColorSpace/dimension tags; nothing from the + // source file may appear. + const tags = (await exifr.parse(new Uint8Array(result.bytes))) ?? {}; + expect(tags.Make).toBeUndefined(); + expect(tags.DateTimeOriginal).toBeUndefined(); + expect(tags.latitude).toBeUndefined(); +}); + +test("exif: 'all' keeps the metadata and makes it match the upright pixels", async ({ page }) => { + const result = await convert(page, EXIF_FIXTURE, { exif: 'all' }); + expect(result.exif).toBe('all'); + expect(result.warnings).toEqual([]); + const tags = await exifr.parse(new Uint8Array(result.bytes), { translateValues: false }); + expect(tags.Make).toBe('heic-web'); + expect(tags.Orientation).toBe(1); + expect(tags.ExifImageWidth).toBe(result.width); + expect(tags.ExifImageHeight).toBe(result.height); + expect(tags.latitude).toBeCloseTo(41.3851, 3); + expect([result.width, result.height]).toEqual([320, 480]); + expect([result.shownWidth, result.shownHeight]).toEqual([320, 480]); +}); + +test("exif: 'no-location' removes GPS and keeps the rest", async ({ page }) => { + const result = await convert(page, EXIF_FIXTURE, { exif: 'no-location', maxDimension: 120 }); + expect(result.exif).toBe('no-location'); + const tags = await exifr.parse(new Uint8Array(result.bytes), { translateValues: false }); + expect(tags.latitude).toBeUndefined(); + expect(tags.GPSLatitude).toBeUndefined(); + expect(tags.Make).toBe('heic-web'); + expect(tags.Orientation).toBe(1); + expect(tags.ExifImageWidth).toBe(80); + expect(tags.ExifImageHeight).toBe(120); + expect(await exifr.gps(new Uint8Array(result.bytes))).toBeUndefined(); +}); + +test('asking for EXIF from a file without any reports it instead of failing', async ({ page }) => { + const result = await convert(page, PLAIN_FIXTURE, { exif: 'all' }); + expect(result.exif).toBe('none'); + expect(result.warnings).toContain('exif-missing'); +}); + +test('EXIF options are validated before decoding', async ({ page }) => { + const outcomes = await page.evaluate(async () => { + const out = []; + for (const options of [{ exif: 'all', type: 'image/png' }, { exif: 'gps' }]) { + try { + await window.heic.convertHeic(new Blob(), options as Parameters[1]); + } catch (error) { out.push((error as Error).name); } + } + return out; + }); + expect(outcomes).toEqual(['TypeError', 'TypeError']); +}); + +test('readExif returns the raw TIFF block, or null', async ({ page }) => { + const result = await page.evaluate(async ({ withExif, without }) => { + const read = async (url: string) => window.heic.readExif(await (await fetch(url)).blob()); + const tiff = await read(withExif); + return { head: tiff ? Array.from(tiff.subarray(0, 4)) : null, none: await read(without) }; + }, { withExif: EXIF_FIXTURE, without: PLAIN_FIXTURE }); + expect([[0x4d, 0x4d, 0x00, 0x2a], [0x49, 0x49, 0x2a, 0x00]]).toContainEqual(result.head); + expect(result.none).toBeNull(); +}); + +test.describe('decodeHeicThumbnail', () => { + test('decodes the embedded thumbnail upright with WebCodecs', async ({ page }) => { + test.skip(!(await hasWebCodecsHevc(page, EXIF_FIXTURE)), 'WebCodecs HEVC required'); + const result = await page.evaluate(async ({ url, reference }) => { + const source = await (await fetch(url)).blob(); + const thumb = await window.heic.decodeHeicThumbnail(source, { fallback: 'none' }); + const out = { + embedded: thumb.embedded, width: thumb.width, height: thumb.height, + strategy: thumb.strategy, + mad: await window.meanAbsoluteDifference(thumb.image, reference), + }; + thumb.image.close(); + return out; + }, { url: EXIF_FIXTURE, reference: EXIF_REFERENCE }); + expect(result).toMatchObject({ embedded: true, width: 64, height: 96, strategy: 'webcodecs' }); + // A 64 px frame upscaled against the full reference: loose, but a wrong + // rotation lands far above this. + expect(result.mad).toBeLessThan(25); + }); + + test('falls back to a downscaled primary sized like the thumbnail', async ({ page }) => { + const result = await page.evaluate(async ({ url, reference }) => { + const source = await (await fetch(url)).blob(); + const thumb = await window.heic.decodeHeicThumbnail(source, { + strategy: 'wasm', wasmLoader: async () => window.wasmAdapter(), + }); + const out = { + embedded: thumb.embedded, width: thumb.width, height: thumb.height, + mad: await window.meanAbsoluteDifference(thumb.image, reference), + }; + thumb.image.close(); + return out; + }, { url: EXIF_FIXTURE, reference: EXIF_REFERENCE }); + expect(result).toMatchObject({ embedded: false, width: 64, height: 96 }); + expect(result.mad).toBeLessThan(25); + }); + + test('without a thumbnail, falls back to 320 px or throws when told to', async ({ page }) => { + const result = await page.evaluate(async (url) => { + const source = await (await fetch(url)).blob(); + const thumb = await window.heic.decodeHeicThumbnail(source, { + strategy: 'wasm', wasmLoader: async () => window.wasmAdapter(), + }); + const out = { embedded: thumb.embedded, width: thumb.width, height: thumb.height, error: '' }; + thumb.image.close(); + try { await window.heic.decodeHeicThumbnail(source, { fallback: 'none' }); } + catch (error) { out.error = (error as Error).name; } + return out; + }, PLAIN_FIXTURE); + expect(result).toEqual({ embedded: false, width: 320, height: 213, error: 'HeicUnsupportedError' }); + }); + + for (const fixture of loadFixtures().filter((f) => f.name.toLowerCase().endsWith('.heic'))) { + test(`thumbnail of ${fixture.name} has the primary's orientation`, async ({ page }) => { + test.skip(!(await hasWebCodecsHevc(page, fixture.url)), 'WebCodecs HEVC required'); + const result = await page.evaluate(async (url) => { + const thumb = await window.heic.decodeHeicThumbnail(await (await fetch(url)).blob()); + const out = { width: thumb.width, height: thumb.height, embedded: thumb.embedded }; + thumb.image.close(); + return out; + }, fixture.url); + const portrait = fixture.expect.height > fixture.expect.width; + expect(result.height > result.width).toBe(portrait); + expect(Math.max(result.width, result.height)).toBeLessThanOrEqual(320); + }); + } +}); diff --git a/test/fixtures/README.md b/test/fixtures/README.md index 4a20b79..fcec80a 100644 --- a/test/fixtures/README.md +++ b/test/fixtures/README.md @@ -31,6 +31,11 @@ specifically to settle it by measurement. See the note in These are 10-bit, which also covers the 10-bit path. +`exif-thumb.heic` is the exception: 8-bit, encoded from a JPEG carrying EXIF +(synthetic GPS position, Orientation 6), with a 96 px embedded thumbnail. +`heif-enc` turns the Orientation into `irot` and leaves the EXIF tag in place, +which is what an iPhone does, so it covers EXIF carry-over and thumbnails. + ## `test/fixtures/local/` — gitignored A personal photo corpus. **Not committed**, because these are real photographs of diff --git a/test/fixtures/generated/exif-thumb.heic b/test/fixtures/generated/exif-thumb.heic new file mode 100644 index 0000000000000000000000000000000000000000..2d29d41d7974eeb7f8c9ff1d58c84f839586b4b8 GIT binary patch literal 8637 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 literal 0 HcmV?d00001 diff --git a/test/fixtures/generated/exif-thumb.ref.png b/test/fixtures/generated/exif-thumb.ref.png new file mode 100644 index 0000000000000000000000000000000000000000..31b2022d314fa98b08ce63c59efff68d8e572d65 GIT binary patch literal 11760 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�s$t4$ht@92P8>a_M2;9~doKmsn)Y3YkFO2e9t&@&Br|3XN89kzH4uGnILU6Bgc)b<4NvUFMfc?yc_=T95!lG4aVLvk@a}q5@hl{6rEaz zVYgx1(zG)WW`m%Jav0-R>+SP%Hnm54+*0gV+JQ@HBH{;x2MjDXy@aARrMK#aPAKH2 zy(uIy!pAD}kBgI9hFr2iL)Hfj1NUxxWTan?`%+eBiY>z%N%8ZagoOubZ0e{VTBtl( z`?VWf*rwJIEaMkJc~5NfQwiNyR~z|2*|A0U__3xZ_FUj96`z8$C)2!MWztU_|GUbb zuDS8GuESLw6AP_{oQDYOe7gnY$=0>-t=r**@W9?u+1ko6lH#$ZI4J@m=NFNP$q?&_s4ZK3z|%R1r;Z3=%S1Nm4M}9!@zOvuHmY zD^Lw(QF-COkaXRFN7K#G(Oz+(psn^>etYQI>EQ|LTeh}(gVb6bqd>8EMa}n*U*Gh9 zM!rmZ;uc(qOwYK>_Uvs23P1kL;kUtnjn8I#+ZmaxIJVsL%3Qj*c~8>%yro_t?bjo@ zd>}jeWMd}e;Ad9&I@10o=39|(ZuKrn!4SpPKjmNS41+Ce(D99}1L2ZI<@@g`d+XC# z$@98AT@q2!i`thXU?%ZmXfGO7h!m^Y$+Y@x;NjWpgPriF`KXPjTn!5NLe~cwE)phO z1~=_Qt{0Y-#fPaHiYlL8SejvtRIo?&-<~`1)%n5xeDb!51-Ee#wu)c5O?o`pHT|oS z+tm%m*T63~@7b@nvGU%hB@^wZ@2|8_mWqpubB8REZq2ppPeMi`BxbbwN^iCAO|>64 zSDj($l+I940Spd*Qmn_pkpd5j2yp!Y`t+tw)a>Rstw@h~`z_4)&2O-R6ZO;Z6JmE& z=sI!^b?{WE;F;f-hEY@Y9VTXodO#jl7YK~``BR}#HV#5IZy6$2GJmj-a#9^iG*k%n zoIaCC$Wjskl9mjKai(m4iLs?pcM(FtAqL~IYrDa$tgJD8^`6sVL4#R<5h3j;2QP39 z9v!5`$3YjlM))<^;^Qo{l>!`{=9?-*&QGlFRvsPWfz>{rdctxx~k?;j$-A;ribx;cErm_s(XXEqd=+z-He2E*RL)DD94#8LK?~4&?bx z38gjSy=`fKhP1RH6u9lle&@BbFovpTFI>=D*SVsl}Pb#A{}^Yl&!g_nF}1KX0-zGxF;(#cM;+ z$W|KF%(25ES_%B&)MB2C)wG+cDb9CyvmTU8FccwvCVpBEgx}$rx2QMLTwsg=B zqB63~va(`={QMfws72tIgBgW}aN?ZMq!8C}cw!NEk|<*+RYzxL1-)&y>0j^Xb0*=~ zbU%pVW^@u%AXUpTUGCzT5dWg7^XbUdTw{48u9Z%tpT94mh?yBn{EMk#5~Mh4y7xRs z$@2qa=Towt7+mpnf*$K|r)Tpe@z>c} zqK(SA!+JDVvg4wzm+v@R?*A>se^uXJSQGJMGWK3?{LVRn>{nF(&W~UB&Zty0US5DD TexC+D9R}%X89^&G9sc@Xhlf@P literal 0 HcmV?d00001 diff --git a/test/fixtures/generated/manifest.json b/test/fixtures/generated/manifest.json index 24bee63..5a44a14 100644 --- a/test/fixtures/generated/manifest.json +++ b/test/fixtures/generated/manifest.json @@ -100,5 +100,22 @@ }, "features": {}, "reference": "asym-irot-90.ref.png" + }, + "exif-thumb.heic": { + "device": "fixture", + "brand": "heic", + "expect": { + "width": 320, + "height": 480, + "isGrid": false, + "tileCount": 1, + "gridColumns": 1, + "gridRows": 1, + "bitDepth": 8, + "rotation": 270, + "mirrored": "none" + }, + "features": {}, + "reference": "exif-thumb.ref.png" } } diff --git a/test/unit/exif.test.ts b/test/unit/exif.test.ts new file mode 100644 index 0000000..7125fdd --- /dev/null +++ b/test/unit/exif.test.ts @@ -0,0 +1,181 @@ +import { readFileSync } from 'node:fs'; +import exifr from 'exifr'; +import { describe, expect, it } from 'vitest'; +import { + extractExifTiff, + findExifItemId, + insertJpegExif, + MAX_JPEG_EXIF_BYTES, + prepareExifForOutput, +} from '../../src/parser/exif.ts'; +import { parseHeif } from '../../src/parser/meta.ts'; +import { findThumbnailItemId, planItem } from '../../src/plan.ts'; + +const FIXTURE = 'test/fixtures/generated/exif-thumb.heic'; +const NO_EXIF = 'test/fixtures/generated/asym-base.heic'; + +function fixture(path: string) { + return parseHeif(readFileSync(path)); +} + +/** A minimal JPEG-shaped byte string: SOI, JFIF APP0, then an opaque tail. */ +function fakeJpeg(): Uint8Array { + const app0 = [0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 1, 1, 0, 0, 1, 0, 1, 0, 0]; + return new Uint8Array([0xff, 0xd8, ...app0, 0xff, 0xdb, 0x00, 0x02, 0xff, 0xd9]); +} + +async function parseTiff(tiff: Uint8Array) { + // exifr reads a JPEG; wrap the block so it is parsed the way a viewer would. + const jpeg = insertJpegExif(fakeJpeg(), tiff); + if (!jpeg) throw new Error('insert failed'); + return exifr.parse(jpeg, { tiff: true, exif: true, gps: true, translateValues: false }); +} + +describe('EXIF extraction', () => { + it('finds the Exif item through its cdsc reference', () => { + const file = fixture(FIXTURE); + const id = findExifItemId(file, file.primaryItemId); + expect(file.items.get(id!)?.itemType).toBe('Exif'); + }); + + it('returns the TIFF block starting at the byte-order mark', async () => { + const tiff = extractExifTiff(fixture(FIXTURE))!; + expect(String.fromCharCode(tiff[0]!, tiff[1]!)).toMatch(/^(II|MM)$/); + const tags = await parseTiff(tiff); + expect(tags.Make).toBe('heic-web'); + expect(tags.Orientation).toBe(6); + expect(tags.latitude).toBeCloseTo(41.3851, 3); + }); + + it('returns undefined for a file without EXIF', () => { + expect(extractExifTiff(fixture(NO_EXIF))).toBeUndefined(); + }); +}); + +describe('prepareExifForOutput', () => { + const source = () => extractExifTiff(fixture(FIXTURE))!; + + it('resets Orientation and writes the output dimensions', async () => { + const prepared = prepareExifForOutput(source(), { width: 320, height: 480, stripLocation: false })!; + const tags = await parseTiff(prepared); + expect(tags.Orientation).toBe(1); + expect(tags.ExifImageWidth).toBe(320); + expect(tags.ExifImageHeight).toBe(480); + expect(tags.latitude).toBeCloseTo(41.3851, 3); + expect(tags.DateTimeOriginal).toBeInstanceOf(Date); + }); + + it('does not modify its input, including a Node Buffer', () => { + for (const tiff of [source(), Buffer.from(source())]) { + const before = Array.from(tiff); + prepareExifForOutput(tiff, { width: 1, height: 1, stripLocation: true }); + expect(Array.from(tiff)).toEqual(before); + } + }); + + it('widens a SHORT dimension to LONG when the value needs it', async () => { + const prepared = prepareExifForOutput(source(), { + width: 70_000, + height: 480, + stripLocation: false, + })!; + const tags = await parseTiff(prepared); + expect(tags.ExifImageWidth).toBe(70_000); + }); + + it('removes GPS and leaves no trace of the coordinates', async () => { + const original = source(); + const prepared = prepareExifForOutput(original, { + width: 320, + height: 480, + stripLocation: true, + })!; + const tags = await parseTiff(prepared); + expect(tags.latitude).toBeUndefined(); + expect(tags.GPSLatitude).toBeUndefined(); + expect(tags.Make).toBe('heic-web'); + expect(tags.Orientation).toBe(1); + expect(tags.DateTimeOriginal).toBeInstanceOf(Date); + + // The latitude is stored as three RATIONALs; 41/1 must not survive anywhere. + expect((await parseTiff(original)).latitude).toBeCloseTo(41.3851, 3); + const little = prepared[0] === 0x49; + const needle = new Uint8Array(8); + const view = new DataView(needle.buffer); + view.setUint32(0, 41, little); + view.setUint32(4, 1, little); + expect(indexOf(prepared, needle)).toBe(-1); + expect(indexOf(original, needle)).not.toBe(-1); + }); + + it('fails closed on a malformed block', () => { + const tiff = source(); + // Point IFD0 past the end of the block. + const little = tiff[0] === 0x49; + new DataView(tiff.buffer, tiff.byteOffset).setUint32(4, tiff.byteLength + 10, little); + expect(prepareExifForOutput(tiff, { width: 1, height: 1, stripLocation: true })).toBeUndefined(); + expect(prepareExifForOutput(new Uint8Array([1, 2, 3]), { width: 1, height: 1, stripLocation: false })) + .toBeUndefined(); + }); +}); + +describe('insertJpegExif', () => { + it('places APP1 after the JFIF APP0 and keeps the rest intact', () => { + const jpeg = fakeJpeg(); + const tiff = new Uint8Array([0x4d, 0x4d, 0x00, 0x2a, 0, 0, 0, 8]); + const out = insertJpegExif(jpeg, tiff)!; + expect([...out.subarray(0, 2)]).toEqual([0xff, 0xd8]); + expect([...out.subarray(2, 4)]).toEqual([0xff, 0xe0]); + expect([...out.subarray(20, 22)]).toEqual([0xff, 0xe1]); + expect((out[22]! << 8) | out[23]!).toBe(2 + 6 + tiff.length); + expect(new TextDecoder().decode(out.subarray(24, 28))).toBe('Exif'); + expect([...out.subarray(out.length - 6)]).toEqual([...jpeg.subarray(jpeg.length - 6)]); + }); + + it('places APP1 straight after SOI when there is no APP0', () => { + const jpeg = new Uint8Array([0xff, 0xd8, 0xff, 0xdb, 0x00, 0x02, 0xff, 0xd9]); + const out = insertJpegExif(jpeg, new Uint8Array(8))!; + expect([...out.subarray(2, 4)]).toEqual([0xff, 0xe1]); + }); + + it('replaces an Exif segment the encoder already wrote', () => { + const encoderExif = [0xff, 0xe1, 0x00, 0x10, 0x45, 0x78, 0x69, 0x66, 0, 0, 0x4d, 0x4d, 0, 0x2a, 0, 0, 0, 8]; + const jpeg = new Uint8Array([0xff, 0xd8, ...encoderExif, 0xff, 0xdb, 0x00, 0x02, 0xff, 0xd9]); + const tiff = new Uint8Array([0x49, 0x49, 0x2a, 0x00, 8, 0, 0, 0]); + const out = insertJpegExif(jpeg, tiff)!; + expect(out.length).toBe(jpeg.length - encoderExif.length + 10 + tiff.length); + expect([...out.subarray(12, 16)]).toEqual([0x49, 0x49, 0x2a, 0x00]); + expect([...out.subarray(out.length - 6)]).toEqual([0xff, 0xdb, 0x00, 0x02, 0xff, 0xd9]); + }); + + it('refuses a block that does not fit one segment, and non-JPEG input', () => { + expect(insertJpegExif(fakeJpeg(), new Uint8Array(MAX_JPEG_EXIF_BYTES + 1))).toBeUndefined(); + expect(insertJpegExif(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), new Uint8Array(8))).toBeUndefined(); + }); +}); + +describe('thumbnails', () => { + it('finds the thmb item and plans it with its own transforms', () => { + const file = fixture(FIXTURE); + const id = findThumbnailItemId(file, file.primaryItemId)!; + expect(id).not.toBe(file.primaryItemId); + const plan = planItem(file, id); + expect(Math.max(plan.displayWidth, plan.displayHeight)).toBe(96); + // Portrait like the primary: the thumbnail carries the same irot. + expect(plan.displayHeight).toBeGreaterThan(plan.displayWidth); + expect(plan.warnings).toEqual([]); + }); + + it('returns undefined when there is no thumbnail', () => { + const file = fixture(NO_EXIF); + expect(findThumbnailItemId(file, file.primaryItemId)).toBeUndefined(); + }); +}); + +function indexOf(haystack: Uint8Array, needle: Uint8Array): number { + outer: for (let i = 0; i <= haystack.length - needle.length; i++) { + for (let j = 0; j < needle.length; j++) if (haystack[i + j] !== needle[j]) continue outer; + return i; + } + return -1; +} diff --git a/tools/make-fixtures.sh b/tools/make-fixtures.sh index b8abac6..904ee76 100755 --- a/tools/make-fixtures.sh +++ b/tools/make-fixtures.sh @@ -1,8 +1,8 @@ #!/bin/sh # Regenerates test/fixtures/generated/. # -# Requires libheif (heif-enc, heif-convert, heif-info) and ImageMagick. -# brew install libheif imagemagick +# Requires libheif (heif-enc, heif-convert, heif-info), ImageMagick and exiftool. +# brew install libheif imagemagick exiftool set -eu OUT=test/fixtures/generated @@ -31,6 +31,16 @@ for spec in "irot 90" "irot 180" "irot 270" "imir 0" "imir 1"; do "$OUT/asym-base.heic" "$OUT/asym-$kind-$value.heic" "$kind" "$value" done +# EXIF and an embedded thumbnail, the way an iPhone writes them: heif-enc turns +# the JPEG's Orientation into irot but leaves the EXIF tag in place, so the +# converted output must reset it. The GPS position is synthetic (Barcelona). +magick "$TMP/asym.png" "$TMP/asym.jpg" +exiftool -q -overwrite_original -Make=heic-web -Model=fixture -Orientation#=6 \ + -DateTimeOriginal="2026:01:02 03:04:05" \ + -GPSLatitude=41.3851 -GPSLatitudeRef=N -GPSLongitude=2.1734 -GPSLongitudeRef=E \ + -GPSAltitude=12 -ExifImageWidth=480 -ExifImageHeight=320 "$TMP/asym.jpg" +heif-enc -q 92 -t 96 -o "$OUT/exif-thumb.heic" "$TMP/asym.jpg" >/dev/null + # An AVIF, which shares the mif1 brand with HEIC: isHeic() must decline it while # still reporting that it is AV1-coded. heif-enc -A -q 80 -o "$OUT/sample.avif" "$TMP/asym.png" >/dev/null