Initial Commit
This commit is contained in:
86
packages/media/image/exif/README.md
Normal file
86
packages/media/image/exif/README.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# @suaveplan/exif - Dependency-free, runtime-agnostic EXIF/XMP/IPTC metadata reader, writer, and stripper for JPEG and PNG images
|
||||
|
||||
Dependency-free, runtime-agnostic EXIF/XMP/IPTC metadata reader, writer, and stripper for JPEG and PNG images. Parses JPEG segment chains to locate and decode the EXIF IFD (TIFF byte-order aware, including GPS and SubIFD), IPTC IIM (caption, keywords, copyright), and XMP Dublin Core (raw XML string extraction). Writes back a revised EXIF IFD without re-encoding the image pixels, and strips all metadata segments for privacy-preserving image export. Includes a `ByteReader`/`ByteWriter` pair with rational arithmetic helpers and CRC-32 for PNG chunks.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [crc32](./src/binary/crc32/crc32.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [crc32](./src/binary/crc32/crc32.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [rational](./src/binary/rational/rational.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [rational](./src/binary/rational/rational.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [reader](./src/binary/reader/byte-reader.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [byte-reader](./src/binary/reader/byte-reader.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [writer](./src/binary/writer/byte-writer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [byte-writer](./src/binary/writer/byte-writer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [read-metadata](./src/core/read-metadata.md) — The main entry point for reading image metadata. Orchestrates format detection, JPEG segment parsing, and EXIF/XMP/IPTC…
|
||||
- [strip-metadata](./src/core/strip-metadata.md) — Removes metadata segments from a JPEG image buffer and returns a clean image with only the pixel data intact. Privacy-s…
|
||||
- [write-metadata](./src/core/write-metadata.md) — Writes or updates EXIF metadata in a JPEG image buffer. Merges the provided patch over any existing EXIF data and rebui…
|
||||
- [errors](./src/errors/exif-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [exif-error](./src/errors/exif-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [exif](./src/exif/read-exif.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [read-exif](./src/exif/read-exif.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [format](./src/format/detect.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [detect](./src/format/detect.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [ifd](./src/ifd/parse.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [parse](./src/ifd/parse.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [iptc](./src/iptc/read-iptc.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [read-iptc](./src/iptc/read-iptc.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [jpeg](./src/jpeg/segments.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [segments](./src/jpeg/segments.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [xmp](./src/xmp/read-xmp.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [read-xmp](./src/xmp/read-xmp.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/exif
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```ts
|
||||
import { readMetadata, writeMetadata, stripMetadata } from "@suaveplan/exif";
|
||||
|
||||
// Read all metadata
|
||||
const meta = readMetadata(imageBuffer);
|
||||
console.log(meta.format); // "jpeg"
|
||||
console.log(meta.exif?.make); // "Canon"
|
||||
console.log(meta.exif?.gps?.latitude);
|
||||
|
||||
// Write / update metadata
|
||||
const updated = writeMetadata(imageBuffer, { orientation: 1, software: "My App" });
|
||||
|
||||
// Strip all metadata for privacy
|
||||
const clean = stripMetadata(imageBuffer);
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
- [`src/core/read-metadata.md`](./src/core/read-metadata.md) — Main entry point for reading image metadata
|
||||
- [`src/core/write-metadata.md`](./src/core/write-metadata.md) — Write or update EXIF metadata in JPEG buffers
|
||||
- [`src/core/strip-metadata.md`](./src/core/strip-metadata.md) — Remove metadata segments for privacy-safe export
|
||||
- [`src/binary/reader/byte-reader.md`](./src/binary/reader/byte-reader.md) — `ByteReader` with endianness support
|
||||
- [`src/binary/writer/byte-writer.md`](./src/binary/writer/byte-writer.md) — `ByteWriter` for TIFF reconstruction
|
||||
- [`src/binary/rational/rational.md`](./src/binary/rational/rational.md) — Rational number arithmetic helpers
|
||||
- [`src/binary/crc32/crc32.md`](./src/binary/crc32/crc32.md) — CRC-32 for PNG chunk validation
|
||||
- [`src/format/detect.md`](./src/format/detect.md) — Magic-byte image format detection
|
||||
- [`src/jpeg/segments.md`](./src/jpeg/segments.md) — JPEG segment chain parsing
|
||||
- [`src/ifd/parse.md`](./src/ifd/parse.md) — TIFF IFD parsing (GPS, SubIFD)
|
||||
- [`src/exif/read-exif.md`](./src/exif/read-exif.md) — Raw EXIF/TIFF buffer parsing
|
||||
- [`src/iptc/read-iptc.md`](./src/iptc/read-iptc.md) — IPTC IIM caption/keywords/copyright parsing
|
||||
- [`src/xmp/read-xmp.md`](./src/xmp/read-xmp.md) — XMP Dublin Core raw XML extraction
|
||||
- [`src/errors/exif-error.md`](./src/errors/exif-error.md) — `ExifError` and `EXIF_CODES` registry
|
||||
|
||||
## Subpath Exports
|
||||
|
||||
| Subpath | Description |
|
||||
| --- | --- |
|
||||
| `@suaveplan/exif` | Main API: readMetadata, writeMetadata, stripMetadata |
|
||||
| `@suaveplan/exif/binary` | ByteReader, ByteWriter, rational utilities, CRC-32 |
|
||||
| `@suaveplan/exif/format` | detectImageFormat — magic byte detection |
|
||||
| `@suaveplan/exif/exif` | readExifFromTiffBuffer — raw TIFF/EXIF parsing |
|
||||
| `@suaveplan/exif/iptc` | readIptc — IPTC IIM parsing |
|
||||
| `@suaveplan/exif/xmp` | readXmp — XMP Dublin Core parsing |
|
||||
| `@suaveplan/exif/errors` | ExifError, EXIF_CODES |
|
||||
120
packages/media/image/exif/package.json
Normal file
120
packages/media/image/exif/package.json
Normal file
@@ -0,0 +1,120 @@
|
||||
{
|
||||
"name": "@suaveplan/exif",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Dependency-free, runtime-agnostic EXIF/XMP/IPTC metadata reader, writer, and stripper for JPEG and PNG images",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./binary": {
|
||||
"import": {
|
||||
"types": "./dist/binary/index.d.ts",
|
||||
"default": "./dist/binary/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/binary/index.d.ts",
|
||||
"default": "./dist/binary/index.js"
|
||||
}
|
||||
},
|
||||
"./format": {
|
||||
"import": {
|
||||
"types": "./dist/format/detect.d.ts",
|
||||
"default": "./dist/format/detect.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/format/detect.d.ts",
|
||||
"default": "./dist/format/detect.js"
|
||||
}
|
||||
},
|
||||
"./exif": {
|
||||
"import": {
|
||||
"types": "./dist/exif/read-exif.d.ts",
|
||||
"default": "./dist/exif/read-exif.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/exif/read-exif.d.ts",
|
||||
"default": "./dist/exif/read-exif.js"
|
||||
}
|
||||
},
|
||||
"./iptc": {
|
||||
"import": {
|
||||
"types": "./dist/iptc/read-iptc.d.ts",
|
||||
"default": "./dist/iptc/read-iptc.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/iptc/read-iptc.d.ts",
|
||||
"default": "./dist/iptc/read-iptc.js"
|
||||
}
|
||||
},
|
||||
"./xmp": {
|
||||
"import": {
|
||||
"types": "./dist/xmp/read-xmp.d.ts",
|
||||
"default": "./dist/xmp/read-xmp.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/xmp/read-xmp.d.ts",
|
||||
"default": "./dist/xmp/read-xmp.js"
|
||||
}
|
||||
},
|
||||
"./errors": {
|
||||
"import": {
|
||||
"types": "./dist/errors/exif-error.d.ts",
|
||||
"default": "./dist/errors/exif-error.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/errors/exif-error.d.ts",
|
||||
"default": "./dist/errors/exif-error.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms",
|
||||
"@opentelemetry/api": "catalog:observability"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
55
packages/media/image/exif/src/binary/crc32/crc32.md
Normal file
55
packages/media/image/exif/src/binary/crc32/crc32.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# CRC-32
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Implements the standard IEEE 802.3 CRC-32 algorithm (polynomial `0xEDB88320`, reflected) used for PNG chunk integrity verification. The implementation is a pure TypeScript, lookup-table-based computation that works in any JavaScript runtime without native bindings or Node.js built-ins.
|
||||
|
||||
## Features
|
||||
|
||||
- Single-buffer `crc32()` for straightforward integrity checks
|
||||
- Multi-chunk `crc32Multi()` that avoids buffer concatenation allocation
|
||||
- Pre-computed 256-entry lookup table for O(n) throughput
|
||||
- Returns unsigned 32-bit integer (safe for comparison with `>>>0` normalised values)
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { crc32 } from "@suaveplan/exif/binary";
|
||||
|
||||
// Standard check value: crc32("123456789") === 0xCBF43926
|
||||
const encoder = new TextEncoder();
|
||||
const checkValue = crc32(encoder.encode("123456789"));
|
||||
console.log(checkValue.toString(16)); // "cbf43926"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { crc32Multi } from "@suaveplan/exif/binary";
|
||||
|
||||
// PNG chunk CRC: covers chunk type bytes + chunk data bytes
|
||||
function pngChunkCrc(type: Uint8Array, data: Uint8Array): number {
|
||||
return crc32Multi([type, data]);
|
||||
}
|
||||
|
||||
const typeBytes = new TextEncoder().encode("IHDR");
|
||||
const ihdrData = new Uint8Array([0, 0, 0, 1, 0, 0, 0, 1, 8, 2, 0, 0, 0]);
|
||||
const checksum = pngChunkCrc(typeBytes, ihdrData);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `crc32(data: Uint8Array): number`
|
||||
|
||||
Computes the CRC-32 of `data`. Returns an unsigned 32-bit integer. The standard test vector is: `crc32(new TextEncoder().encode("123456789")) === 0xCBF43926`.
|
||||
|
||||
### `crc32Multi(chunks: Uint8Array[]): number`
|
||||
|
||||
Computes the CRC-32 of all `chunks` as if they were concatenated, without allocating a combined buffer. Returns an unsigned 32-bit integer. Equivalent to `crc32(concat(...chunks))`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The lookup table is built once at module load time using the standard reflected polynomial `0xEDB88320` (the bit-reversal of `0x04C11DB7`). Each byte of input XORs into the low 8 bits of the running CRC, selects a table entry, then is combined with the shifted CRC via XOR. The final `>>> 0` ensures the result is a non-negative JavaScript number even when the high bit is set. `crc32Multi` avoids buffer allocation by maintaining the running CRC across chunk boundaries.
|
||||
71
packages/media/image/exif/src/binary/crc32/crc32.test.ts
Normal file
71
packages/media/image/exif/src/binary/crc32/crc32.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { crc32, crc32Multi } from "./crc32.js";
|
||||
|
||||
describe("crc32", () => {
|
||||
it("returns 0x00000000 for empty input", { timeout: 5000 }, () => {
|
||||
// Standard CRC-32 of empty data
|
||||
expect(crc32(new Uint8Array(0))).toBe(0x00000000);
|
||||
});
|
||||
|
||||
it("matches check value for '123456789'", { timeout: 5000 }, () => {
|
||||
const data = new Uint8Array([
|
||||
0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39,
|
||||
]);
|
||||
expect(crc32(data)).toBe(0xcbf43926);
|
||||
});
|
||||
|
||||
it("returns same result for single byte 0x00", { timeout: 5000 }, () => {
|
||||
const result = crc32(new Uint8Array([0x00]));
|
||||
expect(typeof result).toBe("number");
|
||||
expect(result).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("returns unsigned 32-bit integer", { timeout: 5000 }, () => {
|
||||
const data = new Uint8Array([0xff, 0xff, 0xff, 0xff]);
|
||||
const result = crc32(data);
|
||||
expect(result).toBeGreaterThanOrEqual(0);
|
||||
expect(result).toBeLessThanOrEqual(0xffffffff);
|
||||
});
|
||||
|
||||
it("produces different values for different inputs", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = crc32(new Uint8Array([0x01]));
|
||||
const b = crc32(new Uint8Array([0x02]));
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe("crc32Multi", () => {
|
||||
it("equals crc32 of concatenated chunks", { timeout: 5000 }, () => {
|
||||
const a = new Uint8Array([0x31, 0x32, 0x33, 0x34]);
|
||||
const b = new Uint8Array([0x35, 0x36, 0x37, 0x38, 0x39]);
|
||||
const combined = new Uint8Array([...a, ...b]);
|
||||
expect(crc32Multi([a, b])).toBe(crc32(combined));
|
||||
});
|
||||
|
||||
it("matches single-chunk crc32", { timeout: 5000 }, () => {
|
||||
const data = new Uint8Array([0x31, 0x32, 0x33]);
|
||||
expect(crc32Multi([data])).toBe(crc32(data));
|
||||
});
|
||||
|
||||
it("returns 0 for empty array", { timeout: 5000 }, () => {
|
||||
expect(crc32Multi([])).toBe(0x00000000);
|
||||
});
|
||||
|
||||
it("matches check value for split '123456789'", { timeout: 5000 }, () => {
|
||||
const part1 = new Uint8Array([0x31, 0x32, 0x33, 0x34, 0x35]);
|
||||
const part2 = new Uint8Array([0x36, 0x37, 0x38, 0x39]);
|
||||
expect(crc32Multi([part1, part2])).toBe(0xcbf43926);
|
||||
});
|
||||
|
||||
it("handles chunk with 0xff bytes (exercises TABLE ?? 0 fallback path)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = new Uint8Array([0xff, 0xfe, 0xfd]);
|
||||
const b = new Uint8Array([0xfc]);
|
||||
expect(crc32Multi([a, b])).toBe(
|
||||
crc32(new Uint8Array([0xff, 0xfe, 0xfd, 0xfc]))
|
||||
);
|
||||
});
|
||||
});
|
||||
51
packages/media/image/exif/src/binary/crc32/crc32.ts
Normal file
51
packages/media/image/exif/src/binary/crc32/crc32.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Standard CRC-32 implementation using the IEEE 802.3 polynomial (0xEDB88320, reflected).
|
||||
*
|
||||
* Used for PNG chunk integrity verification. The lookup table is generated once
|
||||
* at module load time.
|
||||
*/
|
||||
|
||||
const TABLE = buildTable();
|
||||
|
||||
function buildTable(): Uint32Array {
|
||||
const table = new Uint32Array(256);
|
||||
for (let i = 0; i < 256; i++) {
|
||||
let c = i;
|
||||
for (let j = 0; j < 8; j++) {
|
||||
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
}
|
||||
table[i] = c;
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the CRC-32 of a single byte array.
|
||||
*
|
||||
* Returns an unsigned 32-bit integer. The standard check value for the
|
||||
* ASCII string "123456789" is 0xCBF43926.
|
||||
*/
|
||||
export function crc32(data: Uint8Array): number {
|
||||
let crc = 0xffffffff;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const b = data[i] as number;
|
||||
crc = (TABLE[(crc ^ b) & 0xff] as number) ^ (crc >>> 8);
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the CRC-32 of multiple chunks concatenated together.
|
||||
*
|
||||
* Equivalent to crc32(concat(...chunks)) but avoids allocating a combined buffer.
|
||||
*/
|
||||
export function crc32Multi(chunks: Uint8Array[]): number {
|
||||
let crc = 0xffffffff;
|
||||
for (const chunk of chunks) {
|
||||
for (let i = 0; i < chunk.length; i++) {
|
||||
const b = chunk[i] as number;
|
||||
crc = (TABLE[(crc ^ b) & 0xff] as number) ^ (crc >>> 8);
|
||||
}
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
1
packages/media/image/exif/src/binary/crc32/index.ts
Normal file
1
packages/media/image/exif/src/binary/crc32/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { crc32, crc32Multi } from "./crc32";
|
||||
9
packages/media/image/exif/src/binary/index.ts
Normal file
9
packages/media/image/exif/src/binary/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { crc32, crc32Multi } from "./crc32/crc32.js";
|
||||
export {
|
||||
numberToRational,
|
||||
rationalToNumber,
|
||||
rationalToString,
|
||||
simplifyRational,
|
||||
} from "./rational/rational.js";
|
||||
export { ByteReader } from "./reader/byte-reader.js";
|
||||
export { ByteWriter } from "./writer/byte-writer.js";
|
||||
6
packages/media/image/exif/src/binary/rational/index.ts
Normal file
6
packages/media/image/exif/src/binary/rational/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
numberToRational,
|
||||
rationalToNumber,
|
||||
rationalToString,
|
||||
simplifyRational,
|
||||
} from "./rational";
|
||||
67
packages/media/image/exif/src/binary/rational/rational.md
Normal file
67
packages/media/image/exif/src/binary/rational/rational.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Rational
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides conversion and formatting utilities for TIFF RATIONAL values — pairs of integers representing numerator and denominator. EXIF metadata stores many camera settings (exposure time, f-number, focal length, GPS coordinates) as RATIONAL pairs, so correct round-trip conversion between floating-point numbers and rational pairs is essential for accurate metadata reading and writing.
|
||||
|
||||
## Features
|
||||
|
||||
- `numberToRational` — floating-point to rational via Stern-Brocot tree approximation with configurable max denominator
|
||||
- `rationalToNumber` — rational to float with zero-denominator guard
|
||||
- `rationalToString` — human-readable "n/d" formatting
|
||||
- `simplifyRational` — GCD reduction to lowest terms with sign normalisation
|
||||
- Handles negative values and zero correctly
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { numberToRational, rationalToNumber, rationalToString } from "@suaveplan/exif/binary";
|
||||
|
||||
const r = numberToRational(0.01); // exposure 1/100 s
|
||||
console.log(rationalToString(r)); // "1/100"
|
||||
console.log(rationalToNumber(r)); // 0.01
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { numberToRational, simplifyRational, rationalToString } from "@suaveplan/exif/binary";
|
||||
|
||||
// Limit denominator for compact storage
|
||||
const focalLength = numberToRational(50.0, 100);
|
||||
console.log(rationalToString(focalLength)); // "50/1"
|
||||
|
||||
// GPS coordinate degree representation
|
||||
const degrees = numberToRational(40.7128, 1_000_000);
|
||||
const simple = simplifyRational(degrees);
|
||||
console.log(rationalToString(simple));
|
||||
|
||||
// Simplify a raw EXIF rational
|
||||
const raw = { numerator: 6, denominator: 4 };
|
||||
console.log(rationalToString(simplifyRational(raw))); // "3/2"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `numberToRational(n: number, maxDenominator?: number): Rational`
|
||||
|
||||
Converts a floating-point number to the closest `Rational` with denominator ≤ `maxDenominator` (default `1_000_000`). Uses the Stern-Brocot tree / mediant algorithm to guarantee the best approximation for the given denominator bound. Throws `ExifError(INVALID_DATA)` for non-finite inputs.
|
||||
|
||||
### `rationalToNumber(r: Rational): number`
|
||||
|
||||
Returns `numerator / denominator`. Returns `0` when `denominator === 0` to avoid propagating `NaN` or `Infinity` into metadata fields.
|
||||
|
||||
### `rationalToString(r: Rational): string`
|
||||
|
||||
Returns the string `"${numerator}/${denominator}"`, e.g. `"1/100"`.
|
||||
|
||||
### `simplifyRational(r: Rational): Rational`
|
||||
|
||||
Reduces `r` to lowest terms using Euclidean GCD. Normalises negative denominators to the numerator. Returns `{ numerator: 0, denominator: 1 }` when numerator is zero.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The Stern-Brocot tree search is bounded by a maximum of 1000 iterations in addition to the denominator limit, ensuring termination even for pathological inputs. The mediant criterion (`|medVal - abs| < 1e-10`) provides exact representation for most photographic fractions such as `1/100`, `1/250`, `2.8`, `50.0`. The GCD in `simplifyRational` uses the iterative Euclidean algorithm to avoid stack overflow on large inputs.
|
||||
152
packages/media/image/exif/src/binary/rational/rational.test.ts
Normal file
152
packages/media/image/exif/src/binary/rational/rational.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { ExifError } from "../../errors/exif-error.js";
|
||||
import {
|
||||
numberToRational,
|
||||
rationalToNumber,
|
||||
rationalToString,
|
||||
simplifyRational,
|
||||
} from "./rational.js";
|
||||
|
||||
describe("rationalToNumber", () => {
|
||||
it("divides numerator by denominator", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
rationalToNumber({ numerator: 1, denominator: 100 })
|
||||
).toBeCloseTo(0.01);
|
||||
});
|
||||
|
||||
it("returns 0 for zero denominator", { timeout: 5000 }, () => {
|
||||
expect(rationalToNumber({ numerator: 5, denominator: 0 })).toBe(0);
|
||||
});
|
||||
|
||||
it("handles whole numbers", { timeout: 5000 }, () => {
|
||||
expect(rationalToNumber({ numerator: 50, denominator: 1 })).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rationalToString", () => {
|
||||
it("formats as n/d", { timeout: 5000 }, () => {
|
||||
expect(rationalToString({ numerator: 1, denominator: 100 })).toBe(
|
||||
"1/100"
|
||||
);
|
||||
});
|
||||
|
||||
it("formats negative numerator", { timeout: 5000 }, () => {
|
||||
expect(rationalToString({ numerator: -3, denominator: 4 })).toBe(
|
||||
"-3/4"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("simplifyRational", () => {
|
||||
it("reduces 6/4 to 3/2", { timeout: 5000 }, () => {
|
||||
expect(simplifyRational({ numerator: 6, denominator: 4 })).toEqual({
|
||||
numerator: 3,
|
||||
denominator: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 0/1 for zero numerator", { timeout: 5000 }, () => {
|
||||
expect(simplifyRational({ numerator: 0, denominator: 7 })).toEqual({
|
||||
numerator: 0,
|
||||
denominator: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("handles already simplified", { timeout: 5000 }, () => {
|
||||
expect(simplifyRational({ numerator: 1, denominator: 3 })).toEqual({
|
||||
numerator: 1,
|
||||
denominator: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalises negative denominator", { timeout: 5000 }, () => {
|
||||
const r = simplifyRational({ numerator: 3, denominator: -6 });
|
||||
expect(r.denominator).toBeGreaterThan(0);
|
||||
expect(rationalToNumber(r)).toBeCloseTo(-0.5);
|
||||
});
|
||||
|
||||
it("handles 100/100", { timeout: 5000 }, () => {
|
||||
expect(simplifyRational({ numerator: 100, denominator: 100 })).toEqual({
|
||||
numerator: 1,
|
||||
denominator: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("numberToRational", () => {
|
||||
it("converts 0.01 to 1/100", { timeout: 5000 }, () => {
|
||||
const r = numberToRational(0.01);
|
||||
expect(rationalToNumber(r)).toBeCloseTo(0.01);
|
||||
});
|
||||
|
||||
it("converts whole number to n/1", { timeout: 5000 }, () => {
|
||||
const r = numberToRational(50);
|
||||
expect(r.denominator).toBe(1);
|
||||
expect(r.numerator).toBe(50);
|
||||
});
|
||||
|
||||
it("converts zero", { timeout: 5000 }, () => {
|
||||
expect(numberToRational(0)).toEqual({ numerator: 0, denominator: 1 });
|
||||
});
|
||||
|
||||
it("converts negative number", { timeout: 5000 }, () => {
|
||||
const r = numberToRational(-0.5);
|
||||
expect(rationalToNumber(r)).toBeCloseTo(-0.5);
|
||||
});
|
||||
|
||||
it("respects maxDenominator", { timeout: 5000 }, () => {
|
||||
const r = numberToRational(Math.PI, 100);
|
||||
expect(r.denominator).toBeLessThanOrEqual(100);
|
||||
expect(rationalToNumber(r)).toBeCloseTo(Math.PI, 1);
|
||||
});
|
||||
|
||||
it("throws for Infinity", { timeout: 5000 }, () => {
|
||||
expect(() => numberToRational(Number.POSITIVE_INFINITY)).toThrow(
|
||||
ExifError
|
||||
);
|
||||
});
|
||||
|
||||
it("throws for NaN", { timeout: 5000 }, () => {
|
||||
expect(() => numberToRational(Number.NaN)).toThrow(ExifError);
|
||||
});
|
||||
|
||||
it("round-trip 1/250", { timeout: 5000 }, () => {
|
||||
const r = numberToRational(1 / 250);
|
||||
expect(rationalToNumber(r)).toBeCloseTo(1 / 250, 5);
|
||||
});
|
||||
|
||||
it("round-trip 2.8 (f-number)", { timeout: 5000 }, () => {
|
||||
const r = numberToRational(2.8);
|
||||
expect(rationalToNumber(r)).toBeCloseTo(2.8, 3);
|
||||
});
|
||||
|
||||
it("converts negative integer exactly (covers negative branch in abs===loN path)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// -3 → abs=3, loN=3, abs===loN, negative=true → numerator=-3
|
||||
const r = numberToRational(-3);
|
||||
expect(r.numerator).toBe(-3);
|
||||
expect(r.denominator).toBe(1);
|
||||
});
|
||||
|
||||
it("chooses hi bound when errHi < errLo (medD > maxDenominator, hi side closer)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// 0.99 with maxDenominator=5: after 4/5 lo bound, mediant 5/6 exceeds limit.
|
||||
// errLo = |0.99 - 0.8| = 0.19, errHi = |0.99 - 1.0| = 0.01 → hi wins.
|
||||
const r = numberToRational(0.99, 5);
|
||||
expect(r.denominator).toBeLessThanOrEqual(5);
|
||||
// hi bound (1/1) is chosen since it's closer to 0.99
|
||||
expect(r).toEqual({ numerator: 1, denominator: 1 });
|
||||
});
|
||||
|
||||
it("negative number with maxDenominator hit (covers negative branch in best selection)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// -0.99 with maxDenominator=5: same progression as 0.99 but negative=true
|
||||
// When returning best, negative=true → numerator = -(hiN=1) = -1
|
||||
const r = numberToRational(-0.99, 5);
|
||||
expect(r.numerator).toBeLessThan(0);
|
||||
expect(r.denominator).toBeLessThanOrEqual(5);
|
||||
});
|
||||
});
|
||||
105
packages/media/image/exif/src/binary/rational/rational.ts
Normal file
105
packages/media/image/exif/src/binary/rational/rational.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { EXIF_CODES, ExifError } from "../../errors/exif-error.js";
|
||||
import type { Rational } from "../../types.js";
|
||||
|
||||
/**
|
||||
* Converts a floating-point number to a TIFF RATIONAL (numerator/denominator pair).
|
||||
*
|
||||
* Uses the Stern-Brocot / continued-fraction approximation algorithm to find
|
||||
* the best rational with a denominator no larger than `maxDenominator`.
|
||||
*/
|
||||
export function numberToRational(
|
||||
n: number,
|
||||
maxDenominator = 1_000_000
|
||||
): Rational {
|
||||
if (!Number.isFinite(n)) {
|
||||
throw new ExifError(
|
||||
`Cannot convert ${n} to rational`,
|
||||
EXIF_CODES.INVALID_DATA
|
||||
);
|
||||
}
|
||||
if (n === 0) return { numerator: 0, denominator: 1 };
|
||||
|
||||
const negative = n < 0;
|
||||
const abs = Math.abs(n);
|
||||
|
||||
// Mediant / Stern-Brocot tree search
|
||||
let loN = Math.floor(abs);
|
||||
let loD = 1;
|
||||
let hiN = loN + 1;
|
||||
let hiD = 1;
|
||||
|
||||
if (abs === loN)
|
||||
return { numerator: negative ? -loN : loN, denominator: 1 };
|
||||
|
||||
for (;;) {
|
||||
const medN = loN + hiN;
|
||||
const medD = loD + hiD;
|
||||
|
||||
if (medD > maxDenominator) {
|
||||
// Choose the bound closer to abs
|
||||
const errLo = Math.abs(abs - loN / loD);
|
||||
const errHi = Math.abs(abs - hiN / hiD);
|
||||
const best =
|
||||
errLo <= errHi
|
||||
? { numerator: loN, denominator: loD }
|
||||
: { numerator: hiN, denominator: hiD };
|
||||
return {
|
||||
numerator: negative ? -best.numerator : best.numerator,
|
||||
denominator: best.denominator,
|
||||
};
|
||||
}
|
||||
|
||||
const medVal = medN / medD;
|
||||
if (Math.abs(medVal - abs) < 1e-10) {
|
||||
return { numerator: negative ? -medN : medN, denominator: medD };
|
||||
}
|
||||
if (medVal < abs) {
|
||||
loN = medN;
|
||||
loD = medD;
|
||||
} else {
|
||||
hiN = medN;
|
||||
hiD = medD;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a RATIONAL to a floating-point number.
|
||||
* Returns 0 when the denominator is zero to avoid NaN/Infinity propagation.
|
||||
*/
|
||||
export function rationalToNumber(r: Rational): number {
|
||||
if (r.denominator === 0) return 0;
|
||||
return r.numerator / r.denominator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a RATIONAL as the string "numerator/denominator" (e.g. "1/100").
|
||||
*/
|
||||
export function rationalToString(r: Rational): string {
|
||||
return `${r.numerator}/${r.denominator}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduces a RATIONAL to its lowest terms using the Euclidean GCD algorithm.
|
||||
* Negative denominators are normalised to the numerator side.
|
||||
*/
|
||||
export function simplifyRational(r: Rational): Rational {
|
||||
if (r.numerator === 0) return { numerator: 0, denominator: 1 };
|
||||
|
||||
const g = gcd(Math.abs(r.numerator), Math.abs(r.denominator));
|
||||
const n = r.numerator / g;
|
||||
const d = r.denominator / g;
|
||||
|
||||
// Ensure denominator is positive
|
||||
if (d < 0) return { numerator: -n, denominator: -d };
|
||||
return { numerator: n, denominator: d };
|
||||
}
|
||||
|
||||
function gcd(a: number, b: number): number {
|
||||
while (b !== 0) {
|
||||
const t = b;
|
||||
b = a % b;
|
||||
a = t;
|
||||
}
|
||||
return a;
|
||||
}
|
||||
117
packages/media/image/exif/src/binary/reader/byte-reader.md
Normal file
117
packages/media/image/exif/src/binary/reader/byte-reader.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# ByteReader
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides a low-level, bounds-checked binary reader for TIFF/EXIF byte streams. It wraps a `Uint8Array` with a configurable byte order and exposes typed read methods for all TIFF primitive types. The runtime-agnostic implementation avoids Node.js `Buffer` and browser-specific APIs, relying solely on `DataView` and standard typed arrays.
|
||||
|
||||
## Features
|
||||
|
||||
- Big-endian and little-endian support with runtime `setByteOrder()` switching
|
||||
- Full TIFF primitive coverage: uint8/16/32, int16/32, float32/64, rational, signed rational
|
||||
- Bounds-checked reads that throw `ExifError` with `EXIF.PARSE_ERROR` on overflow
|
||||
- String reads with null-termination handling (`readString`, `readNullTermString`)
|
||||
- `slice()` factory that creates a sub-reader sharing the same byte order
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { ByteReader } from "@suaveplan/exif/binary";
|
||||
|
||||
const data = new Uint8Array([0x4D, 0x4D, 0x00, 0x2A, 0x00, 0x00, 0x00, 0x08]);
|
||||
const reader = new ByteReader(data, "BE");
|
||||
|
||||
const magic = reader.readUint16(2); // 0x002A
|
||||
const ifdOffset = reader.readUint32(4); // 8
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { ByteReader } from "@suaveplan/exif/binary";
|
||||
|
||||
// Switch byte order mid-stream (e.g. after reading TIFF header)
|
||||
const reader = new ByteReader(tiffBuffer, "BE");
|
||||
const bom = reader.readUint16(0);
|
||||
if (bom === 0x4949) reader.setByteOrder("LE"); // Intel = little-endian
|
||||
|
||||
// Read a RATIONAL (8 bytes: numerator + denominator)
|
||||
const exposureTime = reader.readRational(offset);
|
||||
console.log(`${exposureTime.numerator}/${exposureTime.denominator}`);
|
||||
|
||||
// Sub-slice for IFD parsing
|
||||
const ifdReader = reader.slice(ifdOffset, ifdOffset + ifdSize);
|
||||
const entryCount = ifdReader.readUint16(0);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `constructor(data: Uint8Array, byteOrder?: ByteOrder): ByteReader`
|
||||
|
||||
Creates a new reader wrapping `data`. Default `byteOrder` is `"BE"` (big-endian).
|
||||
|
||||
### `setByteOrder(order: ByteOrder): void`
|
||||
|
||||
Switches the active byte order for all subsequent reads.
|
||||
|
||||
### `get length: number`
|
||||
|
||||
Returns the total byte length of the underlying buffer.
|
||||
|
||||
### `readUint8(offset: number): number`
|
||||
|
||||
Reads one unsigned byte at `offset`.
|
||||
|
||||
### `readUint16(offset: number): number`
|
||||
|
||||
Reads a 2-byte unsigned integer using the active byte order.
|
||||
|
||||
### `readUint32(offset: number): number`
|
||||
|
||||
Reads a 4-byte unsigned integer using the active byte order.
|
||||
|
||||
### `readInt16(offset: number): number`
|
||||
|
||||
Reads a 2-byte signed integer using the active byte order.
|
||||
|
||||
### `readInt32(offset: number): number`
|
||||
|
||||
Reads a 4-byte signed integer using the active byte order.
|
||||
|
||||
### `readFloat32(offset: number): number`
|
||||
|
||||
Reads a 4-byte IEEE 754 single-precision float using the active byte order.
|
||||
|
||||
### `readFloat64(offset: number): number`
|
||||
|
||||
Reads an 8-byte IEEE 754 double-precision float using the active byte order.
|
||||
|
||||
### `readBytes(offset: number, length: number): Uint8Array`
|
||||
|
||||
Returns a copy of `length` bytes starting at `offset`.
|
||||
|
||||
### `readString(offset: number, length: number): string`
|
||||
|
||||
Reads `length` bytes as a Latin-1 string, stopping at the first null byte.
|
||||
|
||||
### `readNullTermString(offset: number): string`
|
||||
|
||||
Scans forward from `offset` until a null byte, then reads the preceding bytes as a string.
|
||||
|
||||
### `readRational(offset: number): Rational`
|
||||
|
||||
Reads an 8-byte TIFF RATIONAL (two unsigned 32-bit integers: numerator then denominator).
|
||||
|
||||
### `readSRational(offset: number): Rational`
|
||||
|
||||
Reads an 8-byte TIFF SRATIONAL (two signed 32-bit integers).
|
||||
|
||||
### `slice(start: number, end: number): ByteReader`
|
||||
|
||||
Returns a new `ByteReader` over `data.slice(start, end)` with the same byte order.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All read methods call `assertBounds` before touching `DataView` to ensure clear error messages on malformed input. The `view` is constructed from the array's `buffer`, `byteOffset`, and `byteLength` to correctly handle sliced `Uint8Array` inputs that do not start at offset 0 in their underlying `ArrayBuffer`. The `readString` and `readNullTermString` methods use Latin-1 (one byte per character) which matches TIFF/EXIF ASCII strings; UTF-8 decoding is not attempted here as EXIF ASCII tags are defined as 7-bit ASCII.
|
||||
203
packages/media/image/exif/src/binary/reader/byte-reader.test.ts
Normal file
203
packages/media/image/exif/src/binary/reader/byte-reader.test.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { ExifError } from "../../errors/exif-error.js";
|
||||
import { ByteReader } from "./byte-reader.js";
|
||||
|
||||
describe("ByteReader", () => {
|
||||
it("reads uint8 correctly", { timeout: 5000 }, () => {
|
||||
const data = new Uint8Array([0x12, 0x34, 0xff]);
|
||||
const reader = new ByteReader(data);
|
||||
expect(reader.readUint8(0)).toBe(0x12);
|
||||
expect(reader.readUint8(1)).toBe(0x34);
|
||||
expect(reader.readUint8(2)).toBe(0xff);
|
||||
});
|
||||
|
||||
it("reads uint16 big-endian by default", { timeout: 5000 }, () => {
|
||||
const data = new Uint8Array([0x12, 0x34]);
|
||||
const reader = new ByteReader(data, "BE");
|
||||
expect(reader.readUint16(0)).toBe(0x1234);
|
||||
});
|
||||
|
||||
it("reads uint16 little-endian", { timeout: 5000 }, () => {
|
||||
const data = new Uint8Array([0x34, 0x12]);
|
||||
const reader = new ByteReader(data, "LE");
|
||||
expect(reader.readUint16(0)).toBe(0x1234);
|
||||
});
|
||||
|
||||
it("reads uint32 big-endian", { timeout: 5000 }, () => {
|
||||
const data = new Uint8Array([0x00, 0x00, 0x00, 0x08]);
|
||||
const reader = new ByteReader(data, "BE");
|
||||
expect(reader.readUint32(0)).toBe(8);
|
||||
});
|
||||
|
||||
it("reads uint32 little-endian", { timeout: 5000 }, () => {
|
||||
const data = new Uint8Array([0x08, 0x00, 0x00, 0x00]);
|
||||
const reader = new ByteReader(data, "LE");
|
||||
expect(reader.readUint32(0)).toBe(8);
|
||||
});
|
||||
|
||||
it("reads int16 signed big-endian", { timeout: 5000 }, () => {
|
||||
const data = new Uint8Array([0xff, 0xfe]); // -2 in big-endian
|
||||
const reader = new ByteReader(data, "BE");
|
||||
expect(reader.readInt16(0)).toBe(-2);
|
||||
});
|
||||
|
||||
it("reads int32 signed big-endian", { timeout: 5000 }, () => {
|
||||
const data = new Uint8Array([0xff, 0xff, 0xff, 0xfe]); // -2
|
||||
const reader = new ByteReader(data, "BE");
|
||||
expect(reader.readInt32(0)).toBe(-2);
|
||||
});
|
||||
|
||||
it("reads float32", { timeout: 5000 }, () => {
|
||||
const buf = new ArrayBuffer(4);
|
||||
new DataView(buf).setFloat32(0, 1.5, false);
|
||||
const reader = new ByteReader(new Uint8Array(buf), "BE");
|
||||
expect(reader.readFloat32(0)).toBeCloseTo(1.5);
|
||||
});
|
||||
|
||||
it("reads float64", { timeout: 5000 }, () => {
|
||||
const buf = new ArrayBuffer(8);
|
||||
new DataView(buf).setFloat64(0, 3.14, false);
|
||||
const reader = new ByteReader(new Uint8Array(buf), "BE");
|
||||
expect(reader.readFloat64(0)).toBeCloseTo(3.14);
|
||||
});
|
||||
|
||||
it("reads bytes", { timeout: 5000 }, () => {
|
||||
const data = new Uint8Array([0x01, 0x02, 0x03, 0x04]);
|
||||
const reader = new ByteReader(data);
|
||||
expect(Array.from(reader.readBytes(1, 2))).toEqual([0x02, 0x03]);
|
||||
});
|
||||
|
||||
it("reads string stopping at null", { timeout: 5000 }, () => {
|
||||
const data = new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x00, 0x57]);
|
||||
const reader = new ByteReader(data);
|
||||
expect(reader.readString(0, 7)).toBe("Hello");
|
||||
});
|
||||
|
||||
it("reads null-terminated string", { timeout: 5000 }, () => {
|
||||
const data = new Uint8Array([
|
||||
0x48, 0x69, 0x00, 0x57, 0x6f, 0x72, 0x6c, 0x64,
|
||||
]);
|
||||
const reader = new ByteReader(data);
|
||||
expect(reader.readNullTermString(0)).toBe("Hi");
|
||||
});
|
||||
|
||||
it("reads RATIONAL", { timeout: 5000 }, () => {
|
||||
// numerator=1, denominator=100 in big-endian
|
||||
const data = new Uint8Array([
|
||||
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x64,
|
||||
]);
|
||||
const reader = new ByteReader(data, "BE");
|
||||
expect(reader.readRational(0)).toEqual({
|
||||
numerator: 1,
|
||||
denominator: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it("reads SRATIONAL with signed values", { timeout: 5000 }, () => {
|
||||
const buf = new ArrayBuffer(8);
|
||||
const dv = new DataView(buf);
|
||||
dv.setInt32(0, -1, false);
|
||||
dv.setInt32(4, 100, false);
|
||||
const reader = new ByteReader(new Uint8Array(buf), "BE");
|
||||
expect(reader.readSRational(0)).toEqual({
|
||||
numerator: -1,
|
||||
denominator: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it("slices correctly", { timeout: 5000 }, () => {
|
||||
const data = new Uint8Array([0x00, 0x12, 0x34, 0x00]);
|
||||
const reader = new ByteReader(data, "BE");
|
||||
const sliced = reader.slice(1, 3);
|
||||
expect(sliced.length).toBe(2);
|
||||
expect(sliced.readUint16(0)).toBe(0x1234);
|
||||
});
|
||||
|
||||
it("throws ExifError on out-of-bounds uint8", { timeout: 5000 }, () => {
|
||||
const data = new Uint8Array([0x01]);
|
||||
const reader = new ByteReader(data);
|
||||
expect(() => reader.readUint8(1)).toThrow(ExifError);
|
||||
});
|
||||
|
||||
it("throws ExifError on out-of-bounds uint16", { timeout: 5000 }, () => {
|
||||
const data = new Uint8Array([0x01]);
|
||||
const reader = new ByteReader(data);
|
||||
expect(() => reader.readUint16(0)).toThrow(ExifError);
|
||||
});
|
||||
|
||||
it("throws ExifError on out-of-bounds uint32", { timeout: 5000 }, () => {
|
||||
const data = new Uint8Array([0x01, 0x02, 0x03]);
|
||||
const reader = new ByteReader(data);
|
||||
expect(() => reader.readUint32(0)).toThrow(ExifError);
|
||||
});
|
||||
|
||||
it("throws ExifError on out-of-bounds bytes", { timeout: 5000 }, () => {
|
||||
const data = new Uint8Array([0x01, 0x02]);
|
||||
const reader = new ByteReader(data);
|
||||
expect(() => reader.readBytes(1, 3)).toThrow(ExifError);
|
||||
});
|
||||
|
||||
it("throws ExifError on out-of-bounds rational", { timeout: 5000 }, () => {
|
||||
const data = new Uint8Array([0x01, 0x02, 0x03, 0x04]);
|
||||
const reader = new ByteReader(data);
|
||||
expect(() => reader.readRational(0)).toThrow(ExifError);
|
||||
});
|
||||
|
||||
it("returns correct length", { timeout: 5000 }, () => {
|
||||
const data = new Uint8Array(10);
|
||||
const reader = new ByteReader(data);
|
||||
expect(reader.length).toBe(10);
|
||||
});
|
||||
|
||||
it("switches byte order at runtime", { timeout: 5000 }, () => {
|
||||
const data = new Uint8Array([0x12, 0x34]);
|
||||
const reader = new ByteReader(data, "BE");
|
||||
expect(reader.readUint16(0)).toBe(0x1234);
|
||||
reader.setByteOrder("LE");
|
||||
expect(reader.readUint16(0)).toBe(0x3412);
|
||||
});
|
||||
|
||||
it("reads float32 little-endian", { timeout: 5000 }, () => {
|
||||
const buf = new ArrayBuffer(4);
|
||||
new DataView(buf).setFloat32(0, 2.5, true);
|
||||
const reader = new ByteReader(new Uint8Array(buf), "LE");
|
||||
expect(reader.readFloat32(0)).toBeCloseTo(2.5);
|
||||
});
|
||||
|
||||
it("reads float64 little-endian", { timeout: 5000 }, () => {
|
||||
const buf = new ArrayBuffer(8);
|
||||
new DataView(buf).setFloat64(0, Math.E, true);
|
||||
const reader = new ByteReader(new Uint8Array(buf), "LE");
|
||||
expect(reader.readFloat64(0)).toBeCloseTo(Math.E);
|
||||
});
|
||||
|
||||
it("reads int32 little-endian signed", { timeout: 5000 }, () => {
|
||||
const buf = new ArrayBuffer(4);
|
||||
new DataView(buf).setInt32(0, -1000, true);
|
||||
const reader = new ByteReader(new Uint8Array(buf), "LE");
|
||||
expect(reader.readInt32(0)).toBe(-1000);
|
||||
});
|
||||
|
||||
it("reads int16 little-endian signed", { timeout: 5000 }, () => {
|
||||
const buf = new ArrayBuffer(2);
|
||||
new DataView(buf).setInt16(0, -500, true);
|
||||
const reader = new ByteReader(new Uint8Array(buf), "LE");
|
||||
expect(reader.readInt16(0)).toBe(-500);
|
||||
});
|
||||
|
||||
it("readString returns empty string for zero-length", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const data = new Uint8Array([0x00]);
|
||||
const reader = new ByteReader(data);
|
||||
expect(reader.readString(0, 1)).toBe("");
|
||||
});
|
||||
|
||||
it("readNullTermString at end of buffer returns full string", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const data = new Uint8Array([0x41, 0x42, 0x43]); // "ABC" with no null
|
||||
const reader = new ByteReader(data);
|
||||
expect(reader.readNullTermString(0)).toBe("ABC");
|
||||
});
|
||||
});
|
||||
116
packages/media/image/exif/src/binary/reader/byte-reader.ts
Normal file
116
packages/media/image/exif/src/binary/reader/byte-reader.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { EXIF_CODES, ExifError } from "../../errors/exif-error.js";
|
||||
import type { ByteOrder, Rational } from "../../types.js";
|
||||
|
||||
/**
|
||||
* Low-level binary reader for TIFF/EXIF byte streams.
|
||||
*
|
||||
* Supports both big-endian and little-endian byte orders with runtime switching.
|
||||
* All read operations are bounds-checked and throw ExifError on overflow.
|
||||
*/
|
||||
export class ByteReader {
|
||||
protected byteOrder: ByteOrder;
|
||||
protected view: DataView;
|
||||
protected data: Uint8Array;
|
||||
|
||||
constructor(data: Uint8Array, byteOrder: ByteOrder = "BE") {
|
||||
this.data = data;
|
||||
this.byteOrder = byteOrder;
|
||||
this.view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
||||
}
|
||||
|
||||
setByteOrder(order: ByteOrder): void {
|
||||
this.byteOrder = order;
|
||||
}
|
||||
|
||||
get length(): number {
|
||||
return this.data.byteLength;
|
||||
}
|
||||
|
||||
protected assertBounds(offset: number, size: number): void {
|
||||
if (offset < 0 || offset + size > this.data.byteLength) {
|
||||
throw new ExifError(
|
||||
`Read out of bounds: offset=${offset}, size=${size}, length=${this.data.byteLength}`,
|
||||
EXIF_CODES.PARSE_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
readUint8(offset: number): number {
|
||||
this.assertBounds(offset, 1);
|
||||
return this.view.getUint8(offset);
|
||||
}
|
||||
|
||||
readUint16(offset: number): number {
|
||||
this.assertBounds(offset, 2);
|
||||
return this.view.getUint16(offset, this.byteOrder === "LE");
|
||||
}
|
||||
|
||||
readUint32(offset: number): number {
|
||||
this.assertBounds(offset, 4);
|
||||
return this.view.getUint32(offset, this.byteOrder === "LE");
|
||||
}
|
||||
|
||||
readInt16(offset: number): number {
|
||||
this.assertBounds(offset, 2);
|
||||
return this.view.getInt16(offset, this.byteOrder === "LE");
|
||||
}
|
||||
|
||||
readInt32(offset: number): number {
|
||||
this.assertBounds(offset, 4);
|
||||
return this.view.getInt32(offset, this.byteOrder === "LE");
|
||||
}
|
||||
|
||||
readFloat32(offset: number): number {
|
||||
this.assertBounds(offset, 4);
|
||||
return this.view.getFloat32(offset, this.byteOrder === "LE");
|
||||
}
|
||||
|
||||
readFloat64(offset: number): number {
|
||||
this.assertBounds(offset, 8);
|
||||
return this.view.getFloat64(offset, this.byteOrder === "LE");
|
||||
}
|
||||
|
||||
readBytes(offset: number, length: number): Uint8Array {
|
||||
this.assertBounds(offset, length);
|
||||
return this.data.slice(offset, offset + length);
|
||||
}
|
||||
|
||||
readString(offset: number, length: number): string {
|
||||
this.assertBounds(offset, length);
|
||||
const bytes = this.data.slice(offset, offset + length);
|
||||
let str = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
const b = bytes[i] as number;
|
||||
if (b === 0) break; // null-terminate on first null
|
||||
str += String.fromCharCode(b);
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
readNullTermString(offset: number): string {
|
||||
let end = offset;
|
||||
while (end < this.data.byteLength && this.data[end] !== 0) {
|
||||
end++;
|
||||
}
|
||||
return this.readString(offset, end - offset);
|
||||
}
|
||||
|
||||
readRational(offset: number): Rational {
|
||||
this.assertBounds(offset, 8);
|
||||
const numerator = this.readUint32(offset);
|
||||
const denominator = this.readUint32(offset + 4);
|
||||
return { numerator, denominator };
|
||||
}
|
||||
|
||||
readSRational(offset: number): Rational {
|
||||
this.assertBounds(offset, 8);
|
||||
const numerator = this.readInt32(offset);
|
||||
const denominator = this.readInt32(offset + 4);
|
||||
return { numerator, denominator };
|
||||
}
|
||||
|
||||
slice(start: number, end: number): ByteReader {
|
||||
const sliced = this.data.slice(start, end);
|
||||
return new ByteReader(sliced, this.byteOrder);
|
||||
}
|
||||
}
|
||||
1
packages/media/image/exif/src/binary/reader/index.ts
Normal file
1
packages/media/image/exif/src/binary/reader/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ByteReader } from "./byte-reader";
|
||||
100
packages/media/image/exif/src/binary/writer/byte-writer.md
Normal file
100
packages/media/image/exif/src/binary/writer/byte-writer.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# ByteWriter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides a dynamic binary writer for building TIFF/EXIF byte streams from scratch. It maintains an auto-growing `Uint8Array` buffer and exposes typed write methods for all TIFF primitive types. This is the write-side counterpart to `ByteReader`, sharing the same byte-order model and error infrastructure.
|
||||
|
||||
## Features
|
||||
|
||||
- Auto-growing buffer (doubles capacity on overflow, no pre-allocation required)
|
||||
- Big-endian and little-endian support with runtime `setByteOrder()` switching
|
||||
- Full TIFF primitive coverage: uint8/16/32, int16, rational, raw bytes, strings
|
||||
- Fluent API — all write methods return `this` for chaining
|
||||
- `toUint8Array()` snapshot of the written bytes at any point
|
||||
- `ExifError` with `EXIF.WRITE_ERROR` on non-Latin-1 string characters
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { ByteWriter } from "@suaveplan/exif/binary";
|
||||
|
||||
const writer = new ByteWriter(64, "BE");
|
||||
writer.writeUint16(0x4D4D); // big-endian BOM
|
||||
writer.writeUint16(0x002A); // TIFF magic
|
||||
writer.writeUint32(8); // IFD0 offset
|
||||
|
||||
const bytes = writer.toUint8Array();
|
||||
// Uint8Array [ 0x4D, 0x4D, 0x00, 0x2A, 0x00, 0x00, 0x00, 0x08 ]
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { ByteWriter } from "@suaveplan/exif/binary";
|
||||
|
||||
// Fluent chaining
|
||||
const exifHeader = new ByteWriter()
|
||||
.setByteOrder("LE")
|
||||
.writeBytes(new Uint8Array([0x45, 0x78, 0x69, 0x66, 0x00, 0x00])) // "Exif\0\0"
|
||||
.writeUint16(0x4949) // LE BOM
|
||||
.writeUint16(0x002A)
|
||||
.writeUint32(8)
|
||||
.toUint8Array();
|
||||
|
||||
// Writing a RATIONAL (e.g. exposure time 1/100)
|
||||
const writer = new ByteWriter();
|
||||
writer.writeRational({ numerator: 1, denominator: 100 });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `constructor(initialSize?: number, byteOrder?: ByteOrder): ByteWriter`
|
||||
|
||||
Creates a new writer. `initialSize` defaults to 256 bytes. `byteOrder` defaults to `"BE"`.
|
||||
|
||||
### `get position: number`
|
||||
|
||||
Returns the current write cursor position (total bytes written so far).
|
||||
|
||||
### `setByteOrder(order: ByteOrder): this`
|
||||
|
||||
Switches the active byte order. Returns `this` for chaining.
|
||||
|
||||
### `writeUint8(value: number): this`
|
||||
|
||||
Writes one unsigned byte.
|
||||
|
||||
### `writeUint16(value: number): this`
|
||||
|
||||
Writes a 2-byte unsigned integer using the active byte order.
|
||||
|
||||
### `writeUint32(value: number): this`
|
||||
|
||||
Writes a 4-byte unsigned integer using the active byte order.
|
||||
|
||||
### `writeInt16(value: number): this`
|
||||
|
||||
Writes a 2-byte signed integer using the active byte order.
|
||||
|
||||
### `writeBytes(bytes: Uint8Array): this`
|
||||
|
||||
Appends a raw byte array.
|
||||
|
||||
### `writeString(str: string): this`
|
||||
|
||||
Writes a Latin-1 string without null termination. Throws `ExifError(WRITE_ERROR)` if any character is outside the Latin-1 range (> U+00FF).
|
||||
|
||||
### `writeRational(r: Rational): this`
|
||||
|
||||
Writes an 8-byte TIFF RATIONAL (two unsigned 32-bit integers: numerator then denominator).
|
||||
|
||||
### `toUint8Array(): Uint8Array`
|
||||
|
||||
Returns a copy of all bytes written so far (from position 0 to the current cursor).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The buffer starts at `initialSize` and doubles whenever the next write would overflow. This amortizes allocation cost to O(log n) reallocations for n total bytes written. The `view` is recreated after each reallocation since `DataView` binds to a specific `ArrayBuffer` instance. `toUint8Array()` always returns a fresh slice to prevent the caller from holding a reference to the internal buffer.
|
||||
138
packages/media/image/exif/src/binary/writer/byte-writer.test.ts
Normal file
138
packages/media/image/exif/src/binary/writer/byte-writer.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { ExifError } from "../../errors/exif-error.js";
|
||||
import { ByteWriter } from "./byte-writer.js";
|
||||
|
||||
describe("ByteWriter", () => {
|
||||
it("writes uint8", { timeout: 5000 }, () => {
|
||||
const writer = new ByteWriter();
|
||||
writer.writeUint8(0x42);
|
||||
expect(Array.from(writer.toUint8Array())).toEqual([0x42]);
|
||||
});
|
||||
|
||||
it("writes uint16 big-endian", { timeout: 5000 }, () => {
|
||||
const writer = new ByteWriter(4, "BE");
|
||||
writer.writeUint16(0x1234);
|
||||
expect(Array.from(writer.toUint8Array())).toEqual([0x12, 0x34]);
|
||||
});
|
||||
|
||||
it("writes uint16 little-endian", { timeout: 5000 }, () => {
|
||||
const writer = new ByteWriter(4, "LE");
|
||||
writer.writeUint16(0x1234);
|
||||
expect(Array.from(writer.toUint8Array())).toEqual([0x34, 0x12]);
|
||||
});
|
||||
|
||||
it("writes uint32 big-endian", { timeout: 5000 }, () => {
|
||||
const writer = new ByteWriter(8, "BE");
|
||||
writer.writeUint32(0x12345678);
|
||||
expect(Array.from(writer.toUint8Array())).toEqual([
|
||||
0x12, 0x34, 0x56, 0x78,
|
||||
]);
|
||||
});
|
||||
|
||||
it("writes uint32 little-endian", { timeout: 5000 }, () => {
|
||||
const writer = new ByteWriter(8, "LE");
|
||||
writer.writeUint32(0x12345678);
|
||||
expect(Array.from(writer.toUint8Array())).toEqual([
|
||||
0x78, 0x56, 0x34, 0x12,
|
||||
]);
|
||||
});
|
||||
|
||||
it("writes int16 big-endian negative", { timeout: 5000 }, () => {
|
||||
const writer = new ByteWriter(4, "BE");
|
||||
writer.writeInt16(-2);
|
||||
expect(Array.from(writer.toUint8Array())).toEqual([0xff, 0xfe]);
|
||||
});
|
||||
|
||||
it("writes bytes", { timeout: 5000 }, () => {
|
||||
const writer = new ByteWriter();
|
||||
writer.writeBytes(new Uint8Array([0x01, 0x02, 0x03]));
|
||||
expect(Array.from(writer.toUint8Array())).toEqual([0x01, 0x02, 0x03]);
|
||||
});
|
||||
|
||||
it("writes string as Latin-1", { timeout: 5000 }, () => {
|
||||
const writer = new ByteWriter();
|
||||
writer.writeString("Hi");
|
||||
expect(Array.from(writer.toUint8Array())).toEqual([0x48, 0x69]);
|
||||
});
|
||||
|
||||
it("writes RATIONAL big-endian", { timeout: 5000 }, () => {
|
||||
const writer = new ByteWriter(8, "BE");
|
||||
writer.writeRational({ numerator: 1, denominator: 100 });
|
||||
expect(Array.from(writer.toUint8Array())).toEqual([
|
||||
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x64,
|
||||
]);
|
||||
});
|
||||
|
||||
it("supports fluent chaining", { timeout: 5000 }, () => {
|
||||
const result = new ByteWriter(8, "BE")
|
||||
.writeUint8(0x4d)
|
||||
.writeUint8(0x4d)
|
||||
.writeUint16(0x002a)
|
||||
.toUint8Array();
|
||||
expect(Array.from(result)).toEqual([0x4d, 0x4d, 0x00, 0x2a]);
|
||||
});
|
||||
|
||||
it("tracks position correctly", { timeout: 5000 }, () => {
|
||||
const writer = new ByteWriter();
|
||||
expect(writer.position).toBe(0);
|
||||
writer.writeUint8(1);
|
||||
expect(writer.position).toBe(1);
|
||||
writer.writeUint32(0);
|
||||
expect(writer.position).toBe(5);
|
||||
});
|
||||
|
||||
it("auto-grows buffer", { timeout: 5000 }, () => {
|
||||
const writer = new ByteWriter(2); // tiny initial
|
||||
for (let i = 0; i < 100; i++) writer.writeUint8(i);
|
||||
const arr = writer.toUint8Array();
|
||||
expect(arr.length).toBe(100);
|
||||
expect(arr[99]).toBe(99);
|
||||
});
|
||||
|
||||
it("throws ExifError on non-Latin-1 string character", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const writer = new ByteWriter();
|
||||
expect(() => writer.writeString("€")).toThrow(ExifError);
|
||||
});
|
||||
|
||||
it("setByteOrder switches mid-write", { timeout: 5000 }, () => {
|
||||
const writer = new ByteWriter(8, "BE");
|
||||
writer.writeUint16(0x0001); // BE: 00 01
|
||||
writer.setByteOrder("LE");
|
||||
writer.writeUint16(0x0001); // LE: 01 00
|
||||
expect(Array.from(writer.toUint8Array())).toEqual([
|
||||
0x00, 0x01, 0x01, 0x00,
|
||||
]);
|
||||
});
|
||||
|
||||
it("toUint8Array returns only written bytes", { timeout: 5000 }, () => {
|
||||
const writer = new ByteWriter(64);
|
||||
writer.writeUint8(0xaa);
|
||||
const arr = writer.toUint8Array();
|
||||
expect(arr.length).toBe(1);
|
||||
expect(arr[0]).toBe(0xaa);
|
||||
});
|
||||
|
||||
it("grow doubles multiple times when single doubling is insufficient", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// ByteWriter(2), then write 10 bytes at once: 2→4→8→16 (needs 3 doublings)
|
||||
const writer = new ByteWriter(2);
|
||||
const data = new Uint8Array(10).fill(0xab);
|
||||
writer.writeBytes(data);
|
||||
const arr = writer.toUint8Array();
|
||||
expect(arr.length).toBe(10);
|
||||
expect(arr[0]).toBe(0xab);
|
||||
expect(arr[9]).toBe(0xab);
|
||||
});
|
||||
|
||||
it("writes int16 little-endian", { timeout: 5000 }, () => {
|
||||
const writer = new ByteWriter(4, "LE");
|
||||
writer.writeInt16(-500);
|
||||
const arr = writer.toUint8Array();
|
||||
expect(arr.length).toBe(2);
|
||||
const dv = new DataView(arr.buffer);
|
||||
expect(dv.getInt16(0, true)).toBe(-500);
|
||||
});
|
||||
});
|
||||
106
packages/media/image/exif/src/binary/writer/byte-writer.ts
Normal file
106
packages/media/image/exif/src/binary/writer/byte-writer.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { EXIF_CODES, ExifError } from "../../errors/exif-error.js";
|
||||
import type { ByteOrder, Rational } from "../../types.js";
|
||||
|
||||
const DEFAULT_INITIAL_SIZE = 256;
|
||||
|
||||
/**
|
||||
* Dynamic binary writer for building TIFF/EXIF byte streams.
|
||||
*
|
||||
* Grows its internal buffer automatically as data is appended.
|
||||
* Supports both big-endian and little-endian byte orders with runtime switching.
|
||||
*/
|
||||
export class ByteWriter {
|
||||
protected buffer: Uint8Array;
|
||||
protected view: DataView;
|
||||
protected pos = 0;
|
||||
protected byteOrder: ByteOrder;
|
||||
|
||||
constructor(
|
||||
initialSize: number = DEFAULT_INITIAL_SIZE,
|
||||
byteOrder: ByteOrder = "BE"
|
||||
) {
|
||||
this.buffer = new Uint8Array(initialSize);
|
||||
this.view = new DataView(this.buffer.buffer);
|
||||
this.byteOrder = byteOrder;
|
||||
}
|
||||
|
||||
get position(): number {
|
||||
return this.pos;
|
||||
}
|
||||
|
||||
setByteOrder(order: ByteOrder): this {
|
||||
this.byteOrder = order;
|
||||
return this;
|
||||
}
|
||||
|
||||
protected grow(needed: number): void {
|
||||
if (this.pos + needed <= this.buffer.length) return;
|
||||
let newSize = this.buffer.length * 2;
|
||||
while (newSize < this.pos + needed) newSize *= 2;
|
||||
const next = new Uint8Array(newSize);
|
||||
next.set(this.buffer);
|
||||
this.buffer = next;
|
||||
this.view = new DataView(this.buffer.buffer);
|
||||
}
|
||||
|
||||
writeUint8(value: number): this {
|
||||
this.grow(1);
|
||||
this.view.setUint8(this.pos, value);
|
||||
this.pos += 1;
|
||||
return this;
|
||||
}
|
||||
|
||||
writeUint16(value: number): this {
|
||||
this.grow(2);
|
||||
this.view.setUint16(this.pos, value, this.byteOrder === "LE");
|
||||
this.pos += 2;
|
||||
return this;
|
||||
}
|
||||
|
||||
writeUint32(value: number): this {
|
||||
this.grow(4);
|
||||
this.view.setUint32(this.pos, value, this.byteOrder === "LE");
|
||||
this.pos += 4;
|
||||
return this;
|
||||
}
|
||||
|
||||
writeInt16(value: number): this {
|
||||
this.grow(2);
|
||||
this.view.setInt16(this.pos, value, this.byteOrder === "LE");
|
||||
this.pos += 2;
|
||||
return this;
|
||||
}
|
||||
|
||||
writeBytes(bytes: Uint8Array): this {
|
||||
this.grow(bytes.length);
|
||||
this.buffer.set(bytes, this.pos);
|
||||
this.pos += bytes.length;
|
||||
return this;
|
||||
}
|
||||
|
||||
writeString(str: string): this {
|
||||
this.grow(str.length);
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const code = str.charCodeAt(i);
|
||||
if (code > 255) {
|
||||
throw new ExifError(
|
||||
`Non-Latin-1 character at index ${i}: U+${code.toString(16).padStart(4, "0")}`,
|
||||
EXIF_CODES.WRITE_ERROR
|
||||
);
|
||||
}
|
||||
this.view.setUint8(this.pos + i, code);
|
||||
}
|
||||
this.pos += str.length;
|
||||
return this;
|
||||
}
|
||||
|
||||
writeRational(r: Rational): this {
|
||||
this.writeUint32(r.numerator);
|
||||
this.writeUint32(r.denominator);
|
||||
return this;
|
||||
}
|
||||
|
||||
toUint8Array(): Uint8Array {
|
||||
return this.buffer.slice(0, this.pos);
|
||||
}
|
||||
}
|
||||
1
packages/media/image/exif/src/binary/writer/index.ts
Normal file
1
packages/media/image/exif/src/binary/writer/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ByteWriter } from "./byte-writer";
|
||||
3
packages/media/image/exif/src/core/index.ts
Normal file
3
packages/media/image/exif/src/core/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { readMetadata } from "./read-metadata";
|
||||
export { stripMetadata } from "./strip-metadata";
|
||||
export { writeMetadata } from "./write-metadata";
|
||||
65
packages/media/image/exif/src/core/read-metadata.md
Normal file
65
packages/media/image/exif/src/core/read-metadata.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# readMetadata
|
||||
|
||||
## Purpose
|
||||
|
||||
The main entry point for reading image metadata. Orchestrates format detection, JPEG segment parsing, and EXIF/XMP/IPTC decoding into a unified `ImageMetadata` result. Callers do not need to interact with the lower-level parsers directly — this function handles the full pipeline from raw `Uint8Array` to structured metadata.
|
||||
|
||||
## Features
|
||||
|
||||
- Auto-detects image format via magic bytes
|
||||
- For JPEG: parses all APP segments and dispatches to EXIF, XMP, and IPTC readers
|
||||
- For PNG: reads IHDR chunk dimensions
|
||||
- `ReadOptions` to skip individual metadata types for performance
|
||||
- Throws `ExifError(UNSUPPORTED_FORMAT)` for completely unrecognised formats
|
||||
- Gracefully skips malformed EXIF segments without throwing
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { readMetadata } from "@suaveplan/exif";
|
||||
|
||||
const metadata = readMetadata(imageBuffer);
|
||||
console.log(metadata.format); // "jpeg"
|
||||
console.log(metadata.exif?.make); // "Canon"
|
||||
console.log(metadata.exif?.dateTimeOriginal);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { readMetadata } from "@suaveplan/exif";
|
||||
|
||||
// Skip XMP for faster processing
|
||||
const metadata = readMetadata(imageBuffer, { skipXmp: true });
|
||||
|
||||
if (metadata.exif?.gps?.latitude) {
|
||||
const { degrees, minutes, seconds, ref } = metadata.exif.gps.latitude;
|
||||
const decimal = (degrees + minutes / 60 + seconds / 3600) * (ref === "S" ? -1 : 1);
|
||||
console.log("GPS lat:", decimal.toFixed(6));
|
||||
}
|
||||
|
||||
if (metadata.iptc?.keywords) {
|
||||
console.log("Keywords:", metadata.iptc.keywords.join(", "));
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `readMetadata(buffer: Uint8Array, options?: ReadOptions): ImageMetadata`
|
||||
|
||||
Reads all available metadata from `buffer`.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `buffer` — raw image bytes
|
||||
- `options.skipExif` — skip EXIF APP1 parsing (default: false)
|
||||
- `options.skipIptc` — skip IPTC APP13 parsing (default: false)
|
||||
- `options.skipXmp` — skip XMP APP1 parsing (default: false)
|
||||
|
||||
**Returns:** `ImageMetadata` with `format` always set, and optional `exif`, `iptc`, `xmp`, `width`, `height`.
|
||||
|
||||
**Throws:** `ExifError(UNSUPPORTED_FORMAT)` if `detectImageFormat` returns `"unknown"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The EXIF APP1 segment begins with a 6-byte `"Exif\0\0"` prefix before the TIFF header; this is stripped before calling `readExifFromTiffBuffer`. The XMP APP1 segment begins with the null-terminated namespace URI `"http://ns.adobe.com/xap/1.0/\0"` before the XML payload. Image dimensions are extracted from EXIF (imageWidth/imageHeight fields) when present and from the PNG IHDR chunk as a fallback. Malformed EXIF data is silently skipped to avoid crashing on images with corrupt metadata.
|
||||
348
packages/media/image/exif/src/core/read-metadata.test.ts
Normal file
348
packages/media/image/exif/src/core/read-metadata.test.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { ExifError } from "../errors/exif-error.js";
|
||||
import { readMetadata } from "./read-metadata.js";
|
||||
|
||||
function buildMinimalJpeg(app1Data?: Uint8Array): Uint8Array {
|
||||
const soi = new Uint8Array([0xff, 0xd8]);
|
||||
const sos = new Uint8Array([
|
||||
0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3f, 0x00,
|
||||
]);
|
||||
const eoi = new Uint8Array([0xff, 0xd9]);
|
||||
if (!app1Data) return new Uint8Array([...soi, ...sos, ...eoi]);
|
||||
const marker = new Uint8Array([0xff, 0xe1]);
|
||||
const size = new Uint8Array(2);
|
||||
const dv = new DataView(size.buffer);
|
||||
dv.setUint16(0, app1Data.length + 2, false);
|
||||
return new Uint8Array([
|
||||
...soi,
|
||||
...marker,
|
||||
...size,
|
||||
...app1Data,
|
||||
...sos,
|
||||
...eoi,
|
||||
]);
|
||||
}
|
||||
|
||||
function buildMinimalPng(): Uint8Array {
|
||||
// PNG signature + IHDR chunk (width=100, height=80)
|
||||
const sig = new Uint8Array([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||
]);
|
||||
// IHDR: length=13, type=IHDR, width=100, height=80, bitDepth=8, colorType=2, ...
|
||||
const ihdr = new Uint8Array(25);
|
||||
const dv = new DataView(ihdr.buffer);
|
||||
dv.setUint32(0, 13, false); // data length
|
||||
ihdr[4] = 0x49;
|
||||
ihdr[5] = 0x48;
|
||||
ihdr[6] = 0x44;
|
||||
ihdr[7] = 0x52; // IHDR
|
||||
dv.setUint32(8, 100, false); // width
|
||||
dv.setUint32(12, 80, false); // height
|
||||
ihdr[16] = 8;
|
||||
ihdr[17] = 2;
|
||||
return new Uint8Array([...sig, ...ihdr]);
|
||||
}
|
||||
|
||||
describe("readMetadata", () => {
|
||||
it("returns format=jpeg for JPEG buffer", { timeout: 5000 }, () => {
|
||||
const jpeg = buildMinimalJpeg();
|
||||
const meta = readMetadata(jpeg);
|
||||
expect(meta.format).toBe("jpeg");
|
||||
});
|
||||
|
||||
it("returns format=png for PNG buffer", { timeout: 5000 }, () => {
|
||||
const png = buildMinimalPng();
|
||||
const meta = readMetadata(png);
|
||||
expect(meta.format).toBe("png");
|
||||
});
|
||||
|
||||
it("reads PNG dimensions from IHDR", { timeout: 5000 }, () => {
|
||||
const png = buildMinimalPng();
|
||||
const meta = readMetadata(png);
|
||||
expect(meta.width).toBe(100);
|
||||
expect(meta.height).toBe(80);
|
||||
});
|
||||
|
||||
it("returns no EXIF for JPEG without APP1", { timeout: 5000 }, () => {
|
||||
const jpeg = buildMinimalJpeg();
|
||||
const meta = readMetadata(jpeg);
|
||||
expect(meta.exif).toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws ExifError for unknown format", { timeout: 5000 }, () => {
|
||||
const buf = new Uint8Array([0x00, 0x01, 0x02, 0x03]);
|
||||
expect(() => readMetadata(buf)).toThrow(ExifError);
|
||||
});
|
||||
|
||||
it("skips EXIF when skipExif=true", { timeout: 5000 }, () => {
|
||||
const exifHeader = new Uint8Array([0x45, 0x78, 0x69, 0x66, 0x00, 0x00]);
|
||||
// Minimal TIFF after "Exif\0\0": II 2A00 offset=8, 0 IFD entries
|
||||
const tiff = new Uint8Array([
|
||||
0x49,
|
||||
0x49,
|
||||
0x2a,
|
||||
0x00,
|
||||
0x08,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // IFD count = 0
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // next IFD
|
||||
]);
|
||||
const app1 = new Uint8Array([...exifHeader, ...tiff]);
|
||||
const jpeg = buildMinimalJpeg(app1);
|
||||
const meta = readMetadata(jpeg, { skipExif: true });
|
||||
expect(meta.exif).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parses EXIF when present", { timeout: 5000 }, () => {
|
||||
// Build a valid EXIF APP1: "Exif\0\0" + minimal TIFF LE with orientation=3
|
||||
const exifHeader = new Uint8Array([0x45, 0x78, 0x69, 0x66, 0x00, 0x00]);
|
||||
// LE TIFF: II 2A00 08000000 count=1 tag=0x0112(orientation) type=3(SHORT) count=1 value=3 next=0
|
||||
const tiff = new Uint8Array([
|
||||
0x49,
|
||||
0x49,
|
||||
0x2a,
|
||||
0x00, // II + magic LE
|
||||
0x08,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // IFD0 at 8
|
||||
0x01,
|
||||
0x00, // 1 entry
|
||||
0x12,
|
||||
0x01, // tag 0x0112
|
||||
0x03,
|
||||
0x00, // type SHORT
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // count=1
|
||||
0x03,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // value=3, pad
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // next IFD
|
||||
]);
|
||||
const app1Data = new Uint8Array([...exifHeader, ...tiff]);
|
||||
const jpeg = buildMinimalJpeg(app1Data);
|
||||
const meta = readMetadata(jpeg);
|
||||
expect(meta.exif).toBeDefined();
|
||||
expect(meta.exif?.orientation).toBe(3);
|
||||
});
|
||||
|
||||
it("handles malformed EXIF gracefully (no throw)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const app1Data = new Uint8Array([
|
||||
0x45, 0x78, 0x69, 0x66, 0x00, 0x00, 0x00,
|
||||
]);
|
||||
const jpeg = buildMinimalJpeg(app1Data);
|
||||
expect(() => readMetadata(jpeg)).not.toThrow();
|
||||
});
|
||||
|
||||
it("returns webp format without throwing", { timeout: 5000 }, () => {
|
||||
const buf = new Uint8Array([
|
||||
0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42,
|
||||
0x50,
|
||||
]);
|
||||
const meta = readMetadata(buf);
|
||||
expect(meta.format).toBe("webp");
|
||||
});
|
||||
|
||||
it("returns tiff format without throwing", { timeout: 5000 }, () => {
|
||||
const buf = new Uint8Array([
|
||||
0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08,
|
||||
]);
|
||||
const meta = readMetadata(buf);
|
||||
expect(meta.format).toBe("tiff");
|
||||
});
|
||||
|
||||
it("skips XMP when skipXmp=true", { timeout: 5000 }, () => {
|
||||
const xmpHeader = "http://ns.adobe.com/xap/1.0/\0";
|
||||
const xmpPayload = "<x:xmpmeta/>\0";
|
||||
const combined = xmpHeader + xmpPayload;
|
||||
const app1Data = new Uint8Array(combined.length);
|
||||
for (let i = 0; i < combined.length; i++)
|
||||
app1Data[i] = combined.charCodeAt(i);
|
||||
const jpeg = buildMinimalJpeg(app1Data);
|
||||
const meta = readMetadata(jpeg, { skipXmp: true });
|
||||
expect(meta.xmp).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parses XMP when present", { timeout: 5000 }, () => {
|
||||
const xmpHeader = "http://ns.adobe.com/xap/1.0/\0";
|
||||
const xmpPayload =
|
||||
"<rdf:Description><dc:rights>© Test</dc:rights></rdf:Description>";
|
||||
const combined = xmpHeader + xmpPayload;
|
||||
const app1Data = new Uint8Array(combined.length);
|
||||
for (let i = 0; i < combined.length; i++)
|
||||
app1Data[i] = combined.charCodeAt(i);
|
||||
const jpeg = buildMinimalJpeg(app1Data);
|
||||
const meta = readMetadata(jpeg);
|
||||
expect(meta.xmp).toBeDefined();
|
||||
expect(meta.xmp?.rights).toBe("© Test");
|
||||
});
|
||||
|
||||
it("returns empty png metadata for short PNG buffer", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const buf = new Uint8Array([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00,
|
||||
]);
|
||||
const meta = readMetadata(buf);
|
||||
expect(meta.format).toBe("png");
|
||||
expect(meta.width).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stops XMP string at embedded null byte", { timeout: 5000 }, () => {
|
||||
// XMP segment where the XML content starts with a null byte — covers the b===0 break
|
||||
const xmpHeader = "http://ns.adobe.com/xap/1.0/\0";
|
||||
const xmpHeaderBytes = new Uint8Array(xmpHeader.length);
|
||||
for (let i = 0; i < xmpHeader.length; i++)
|
||||
xmpHeaderBytes[i] = xmpHeader.charCodeAt(i);
|
||||
// Add a null byte immediately after the header (before any XML)
|
||||
const payload = new Uint8Array([0x00, 0x3c, 0x2f, 0x3e]); // null + "</>", never reached
|
||||
const app1Data = new Uint8Array([...xmpHeaderBytes, ...payload]);
|
||||
const marker = new Uint8Array([0xff, 0xe1]);
|
||||
const size = new Uint8Array(2);
|
||||
new DataView(size.buffer).setUint16(0, app1Data.length + 2, false);
|
||||
const sos = new Uint8Array([
|
||||
0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3f, 0x00,
|
||||
]);
|
||||
const eoi = new Uint8Array([0xff, 0xd9]);
|
||||
const jpeg = new Uint8Array([
|
||||
0xff,
|
||||
0xd8,
|
||||
...marker,
|
||||
...size,
|
||||
...app1Data,
|
||||
...sos,
|
||||
...eoi,
|
||||
]);
|
||||
const meta = readMetadata(jpeg);
|
||||
expect(meta.format).toBe("jpeg");
|
||||
// readXmp("") returns defined with just raw property
|
||||
expect(meta.xmp).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns png metadata without dimensions when IHDR type bytes do not match", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Buffer >= 24 bytes, PNG signature present, but IHDR chunk type bytes are wrong
|
||||
// (exercises the readPngDimensions path where the IHDR check fails)
|
||||
const buf = new Uint8Array(24);
|
||||
buf[0] = 0x89;
|
||||
buf[1] = 0x50;
|
||||
buf[2] = 0x4e;
|
||||
buf[3] = 0x47;
|
||||
buf[4] = 0x0d;
|
||||
buf[5] = 0x0a;
|
||||
buf[6] = 0x1a;
|
||||
buf[7] = 0x0a;
|
||||
// bytes 8-11 = chunk length (zero), bytes 12-15 = wrong chunk type (not IHDR)
|
||||
buf[12] = 0x00;
|
||||
buf[13] = 0x00;
|
||||
buf[14] = 0x00;
|
||||
buf[15] = 0x00;
|
||||
const meta = readMetadata(buf);
|
||||
expect(meta.format).toBe("png");
|
||||
expect(meta.width).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reads IPTC from JPEG APP13 segment", { timeout: 5000 }, () => {
|
||||
// Build JPEG with IPTC APP13 containing a headline
|
||||
const photoshopHeader = "Photoshop 3.0\0";
|
||||
const bim = [0x38, 0x42, 0x49, 0x4d]; // 8BIM
|
||||
const resourceId = [0x04, 0x04]; // IPTC
|
||||
const name = [0x00, 0x00]; // empty pascal string
|
||||
|
||||
// IPTC block: headline "News"
|
||||
const iptcBlock = [
|
||||
0x1c, 0x02, 0x05, 0x00, 0x04, 0x4e, 0x65, 0x77, 0x73,
|
||||
]; // "News"
|
||||
const dataLen = [0x00, 0x00, 0x00, iptcBlock.length];
|
||||
|
||||
const headerBytes = Array.from(photoshopHeader).map((c) =>
|
||||
c.charCodeAt(0)
|
||||
);
|
||||
const app13Data = new Uint8Array([
|
||||
...headerBytes,
|
||||
...bim,
|
||||
...resourceId,
|
||||
...name,
|
||||
...dataLen,
|
||||
...iptcBlock,
|
||||
]);
|
||||
const app13Marker = new Uint8Array([0xff, 0xed]);
|
||||
const app13Size = new Uint8Array(2);
|
||||
new DataView(app13Size.buffer).setUint16(
|
||||
0,
|
||||
app13Data.length + 2,
|
||||
false
|
||||
);
|
||||
|
||||
const soi = new Uint8Array([0xff, 0xd8]);
|
||||
const sos = new Uint8Array([
|
||||
0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3f, 0x00,
|
||||
]);
|
||||
const eoi = new Uint8Array([0xff, 0xd9]);
|
||||
const jpeg = new Uint8Array([
|
||||
...soi,
|
||||
...app13Marker,
|
||||
...app13Size,
|
||||
...app13Data,
|
||||
...sos,
|
||||
...eoi,
|
||||
]);
|
||||
|
||||
const meta = readMetadata(jpeg);
|
||||
expect(meta.iptc).toBeDefined();
|
||||
expect(meta.iptc?.headline).toBe("News");
|
||||
});
|
||||
|
||||
it("skips IPTC when skipIptc=true", { timeout: 5000 }, () => {
|
||||
const photoshopHeader = "Photoshop 3.0\0";
|
||||
const headerBytes = Array.from(photoshopHeader).map((c) =>
|
||||
c.charCodeAt(0)
|
||||
);
|
||||
const app13Data = new Uint8Array([
|
||||
...headerBytes,
|
||||
0x38,
|
||||
0x42,
|
||||
0x49,
|
||||
0x4d,
|
||||
]);
|
||||
const app13Marker = new Uint8Array([0xff, 0xed]);
|
||||
const app13Size = new Uint8Array(2);
|
||||
new DataView(app13Size.buffer).setUint16(
|
||||
0,
|
||||
app13Data.length + 2,
|
||||
false
|
||||
);
|
||||
|
||||
const soi = new Uint8Array([0xff, 0xd8]);
|
||||
const sos = new Uint8Array([
|
||||
0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3f, 0x00,
|
||||
]);
|
||||
const eoi = new Uint8Array([0xff, 0xd9]);
|
||||
const jpeg = new Uint8Array([
|
||||
...soi,
|
||||
...app13Marker,
|
||||
...app13Size,
|
||||
...app13Data,
|
||||
...sos,
|
||||
...eoi,
|
||||
]);
|
||||
|
||||
const meta = readMetadata(jpeg, { skipIptc: true });
|
||||
expect(meta.iptc).toBeUndefined();
|
||||
});
|
||||
});
|
||||
119
packages/media/image/exif/src/core/read-metadata.ts
Normal file
119
packages/media/image/exif/src/core/read-metadata.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { EXIF_CODES, ExifError } from "../errors/exif-error.js";
|
||||
import { readExifFromTiffBuffer } from "../exif/read-exif.js";
|
||||
import { detectImageFormat } from "../format/detect.js";
|
||||
import { readIptc } from "../iptc/read-iptc.js";
|
||||
import {
|
||||
isExifSegment,
|
||||
isIptcSegment,
|
||||
isXmpSegment,
|
||||
parseJpegSegments,
|
||||
} from "../jpeg/segments.js";
|
||||
import type { ImageMetadata, ReadOptions } from "../types.js";
|
||||
import { readXmp } from "../xmp/read-xmp.js";
|
||||
|
||||
// "Exif\0\0" prefix before the TIFF header in APP1 segments
|
||||
const EXIF_APP1_HEADER_LEN = 6;
|
||||
// XMP namespace prefix length: "http://ns.adobe.com/xap/1.0/\0"
|
||||
const XMP_APP1_HEADER = "http://ns.adobe.com/xap/1.0/\0";
|
||||
|
||||
/**
|
||||
* Reads image metadata from a raw image buffer.
|
||||
*
|
||||
* Supports JPEG (EXIF, XMP, IPTC) and returns partial metadata for other formats.
|
||||
* Returns an ImageMetadata object with only format set for unsupported formats.
|
||||
*/
|
||||
export function readMetadata(
|
||||
buffer: Uint8Array,
|
||||
options?: ReadOptions
|
||||
): ImageMetadata {
|
||||
const format = detectImageFormat(buffer);
|
||||
const metadata: ImageMetadata = { format };
|
||||
|
||||
if (format === "unknown") {
|
||||
throw new ExifError(
|
||||
"Cannot read metadata: unsupported or unrecognised image format",
|
||||
EXIF_CODES.UNSUPPORTED_FORMAT
|
||||
);
|
||||
}
|
||||
|
||||
if (format === "jpeg") {
|
||||
return readJpegMetadata(buffer, options ?? {}, metadata);
|
||||
}
|
||||
|
||||
if (format === "png") {
|
||||
return readPngDimensions(buffer, metadata);
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function readJpegMetadata(
|
||||
buffer: Uint8Array,
|
||||
options: ReadOptions,
|
||||
metadata: ImageMetadata
|
||||
): ImageMetadata {
|
||||
const segments = parseJpegSegments(buffer);
|
||||
|
||||
for (const seg of segments) {
|
||||
if (!options.skipExif && isExifSegment(seg)) {
|
||||
// Strip the "Exif\0\0" header to get the raw TIFF buffer
|
||||
const tiffBuffer = seg.data.slice(EXIF_APP1_HEADER_LEN);
|
||||
try {
|
||||
metadata.exif = readExifFromTiffBuffer(tiffBuffer);
|
||||
if (metadata.exif.imageWidth)
|
||||
metadata.width = metadata.exif.imageWidth;
|
||||
if (metadata.exif.imageHeight)
|
||||
metadata.height = metadata.exif.imageHeight;
|
||||
} catch {
|
||||
// Malformed EXIF — skip gracefully
|
||||
}
|
||||
} else if (!options.skipXmp && isXmpSegment(seg)) {
|
||||
const xmpHeaderLen = XMP_APP1_HEADER.length;
|
||||
const xmlBytes = seg.data.slice(xmpHeaderLen);
|
||||
let xmlString = "";
|
||||
for (let i = 0; i < xmlBytes.length; i++) {
|
||||
const b = xmlBytes[i] as number;
|
||||
if (b === 0) break;
|
||||
xmlString += String.fromCharCode(b);
|
||||
}
|
||||
metadata.xmp = readXmp(xmlString);
|
||||
} else if (!options.skipIptc && isIptcSegment(seg)) {
|
||||
metadata.iptc = readIptc(seg.data);
|
||||
}
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads dimensions from a PNG IHDR chunk.
|
||||
* PNG spec: 8-byte sig, then IHDR chunk: 4-byte length, 4-byte "IHDR", 4-byte width, 4-byte height, ...
|
||||
*/
|
||||
function readPngDimensions(
|
||||
buffer: Uint8Array,
|
||||
metadata: ImageMetadata
|
||||
): ImageMetadata {
|
||||
if (buffer.length < 24) return metadata;
|
||||
|
||||
// PNG IHDR starts at byte 8 (after 8-byte PNG signature)
|
||||
// Chunk format: [4 length][4 type][data][4 crc]
|
||||
const ihdrOffset = 8;
|
||||
// Length field (should be 13 for IHDR)
|
||||
// Type bytes: 'I'=0x49, 'H'=0x48, 'D'=0x44, 'R'=0x52
|
||||
if (
|
||||
buffer[ihdrOffset + 4] === 0x49 &&
|
||||
buffer[ihdrOffset + 5] === 0x48 &&
|
||||
buffer[ihdrOffset + 6] === 0x44 &&
|
||||
buffer[ihdrOffset + 7] === 0x52
|
||||
) {
|
||||
const dv = new DataView(
|
||||
buffer.buffer,
|
||||
buffer.byteOffset,
|
||||
buffer.byteLength
|
||||
);
|
||||
metadata.width = dv.getUint32(ihdrOffset + 8, false);
|
||||
metadata.height = dv.getUint32(ihdrOffset + 12, false);
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
60
packages/media/image/exif/src/core/strip-metadata.md
Normal file
60
packages/media/image/exif/src/core/strip-metadata.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# stripMetadata
|
||||
|
||||
## Purpose
|
||||
|
||||
Removes metadata segments from a JPEG image buffer and returns a clean image with only the pixel data intact. Privacy-sensitive metadata (GPS location, camera serial numbers, timestamps) is removed without re-encoding or degrading the image quality. The JPEG stream is rebuilt by concatenating only the non-metadata segments with the original compressed image data.
|
||||
|
||||
## Features
|
||||
|
||||
- Strips EXIF APP1 segments (camera, GPS, orientation metadata)
|
||||
- Strips XMP APP1 segments (Adobe editorial metadata)
|
||||
- Strips IPTC APP13 segments (Photoshop 3.0 news/editorial metadata)
|
||||
- Each strip operation is independently controllable via `StripOptions`
|
||||
- Non-destructive: compressed image data (SOS + payload + EOI) is preserved byte-for-byte
|
||||
- Throws `ExifError(UNSUPPORTED_FORMAT)` for non-JPEG inputs
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { stripMetadata } from "@suaveplan/exif";
|
||||
|
||||
// Strip all metadata (default behaviour)
|
||||
const clean = stripMetadata(jpegBuffer);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { stripMetadata } from "@suaveplan/exif";
|
||||
|
||||
// Keep IPTC (editorial attribution) but remove GPS and XMP
|
||||
const stripped = stripMetadata(jpegBuffer, {
|
||||
stripExif: true,
|
||||
stripXmp: true,
|
||||
stripIptc: false,
|
||||
});
|
||||
|
||||
// Verify the result is still a valid JPEG
|
||||
console.log(stripped[0] === 0xFF && stripped[1] === 0xD8); // true
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `stripMetadata(buffer: Uint8Array, options?: StripOptions): Uint8Array`
|
||||
|
||||
Strips metadata from a JPEG buffer.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `buffer` — raw JPEG image bytes
|
||||
- `options.stripExif` — strip EXIF APP1 segments (default: `true`)
|
||||
- `options.stripIptc` — strip IPTC APP13 segments (default: `true`)
|
||||
- `options.stripXmp` — strip XMP APP1 segments (default: `true`)
|
||||
|
||||
**Returns:** New `Uint8Array` with the specified segments removed.
|
||||
|
||||
**Throws:** `ExifError(UNSUPPORTED_FORMAT)` if `buffer` is not a JPEG image.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The function calls `parseJpegSegments` to obtain the structural segment list, then rebuilds the JPEG by writing the SOI marker, each non-stripped segment (marker + length + data), and finally the raw SOS-onward bytes from the original buffer. The SOS segment and all subsequent compressed data are appended verbatim — no re-encoding occurs. Default options strip all three metadata types (EXIF, XMP, IPTC) to provide the safest default for privacy-sensitive use cases. Passing `{ stripExif: false, stripIptc: false, stripXmp: false }` is a no-op that returns a structurally identical (though freshly allocated) buffer.
|
||||
234
packages/media/image/exif/src/core/strip-metadata.test.ts
Normal file
234
packages/media/image/exif/src/core/strip-metadata.test.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { ExifError } from "../errors/exif-error.js";
|
||||
import { isExifSegment, parseJpegSegments } from "../jpeg/segments.js";
|
||||
import { stripMetadata } from "./strip-metadata.js";
|
||||
|
||||
function buildJpegWithSos(app1Data?: Uint8Array): Uint8Array {
|
||||
const soi = new Uint8Array([0xff, 0xd8]);
|
||||
// Minimal SOS with compressed data placeholder
|
||||
const sos = new Uint8Array([
|
||||
0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3f, 0x00, 0xaa, 0xbb,
|
||||
]);
|
||||
const eoi = new Uint8Array([0xff, 0xd9]);
|
||||
|
||||
if (!app1Data) return new Uint8Array([...soi, ...sos, ...eoi]);
|
||||
|
||||
const marker = new Uint8Array([0xff, 0xe1]);
|
||||
const size = new Uint8Array(2);
|
||||
const dv = new DataView(size.buffer);
|
||||
dv.setUint16(0, app1Data.length + 2, false);
|
||||
return new Uint8Array([
|
||||
...soi,
|
||||
...marker,
|
||||
...size,
|
||||
...app1Data,
|
||||
...sos,
|
||||
...eoi,
|
||||
]);
|
||||
}
|
||||
|
||||
describe("stripMetadata", () => {
|
||||
it("returns valid JPEG for image without metadata", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const jpeg = buildJpegWithSos();
|
||||
const result = stripMetadata(jpeg);
|
||||
expect(result[0]).toBe(0xff);
|
||||
expect(result[1]).toBe(0xd8);
|
||||
});
|
||||
|
||||
it("removes EXIF APP1 segment", { timeout: 5000 }, () => {
|
||||
const exifHeader = new Uint8Array([0x45, 0x78, 0x69, 0x66, 0x00, 0x00]);
|
||||
const jpeg = buildJpegWithSos(exifHeader);
|
||||
|
||||
// Verify EXIF is present before stripping
|
||||
const before = parseJpegSegments(jpeg);
|
||||
expect(before.some(isExifSegment)).toBe(true);
|
||||
|
||||
const result = stripMetadata(jpeg);
|
||||
const after = parseJpegSegments(result);
|
||||
expect(after.some(isExifSegment)).toBe(false);
|
||||
});
|
||||
|
||||
it("output is still a valid JPEG (starts with FF D8)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const exifHeader = new Uint8Array([0x45, 0x78, 0x69, 0x66, 0x00, 0x00]);
|
||||
const jpeg = buildJpegWithSos(exifHeader);
|
||||
const result = stripMetadata(jpeg);
|
||||
expect(result[0]).toBe(0xff);
|
||||
expect(result[1]).toBe(0xd8);
|
||||
});
|
||||
|
||||
it("throws ExifError for non-JPEG input", { timeout: 5000 }, () => {
|
||||
const buf = new Uint8Array([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||
]);
|
||||
expect(() => stripMetadata(buf)).toThrow(ExifError);
|
||||
});
|
||||
|
||||
it("preserves non-metadata segments", { timeout: 5000 }, () => {
|
||||
// Add an APP2 segment (ICC profile marker — should be preserved)
|
||||
const soi = new Uint8Array([0xff, 0xd8]);
|
||||
const app2Data = new Uint8Array([0x49, 0x43, 0x43, 0x5f]); // "ICC_"
|
||||
const app2Marker = new Uint8Array([0xff, 0xe2]);
|
||||
const app2Size = new Uint8Array(2);
|
||||
new DataView(app2Size.buffer).setUint16(0, app2Data.length + 2, false);
|
||||
const exifData = new Uint8Array([0x45, 0x78, 0x69, 0x66, 0x00, 0x00]);
|
||||
const app1Marker = new Uint8Array([0xff, 0xe1]);
|
||||
const app1Size = new Uint8Array(2);
|
||||
new DataView(app1Size.buffer).setUint16(0, exifData.length + 2, false);
|
||||
const sos = new Uint8Array([
|
||||
0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3f, 0x00,
|
||||
]);
|
||||
const eoi = new Uint8Array([0xff, 0xd9]);
|
||||
const jpeg = new Uint8Array([
|
||||
...soi,
|
||||
...app1Marker,
|
||||
...app1Size,
|
||||
...exifData,
|
||||
...app2Marker,
|
||||
...app2Size,
|
||||
...app2Data,
|
||||
...sos,
|
||||
...eoi,
|
||||
]);
|
||||
|
||||
const result = stripMetadata(jpeg);
|
||||
// APP2 should still be present
|
||||
let foundApp2 = false;
|
||||
for (let i = 0; i < result.length - 1; i++) {
|
||||
if (result[i] === 0xff && result[i + 1] === 0xe2) {
|
||||
foundApp2 = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(foundApp2).toBe(true);
|
||||
});
|
||||
|
||||
it("respects stripExif=false option", { timeout: 5000 }, () => {
|
||||
const exifData = new Uint8Array([0x45, 0x78, 0x69, 0x66, 0x00, 0x00]);
|
||||
const jpeg = buildJpegWithSos(exifData);
|
||||
const result = stripMetadata(jpeg, { stripExif: false });
|
||||
// EXIF should still be present
|
||||
const segs = parseJpegSegments(result);
|
||||
expect(segs.some(isExifSegment)).toBe(true);
|
||||
});
|
||||
|
||||
it("throws ExifError for unknown format buffer", { timeout: 5000 }, () => {
|
||||
const buf = new Uint8Array([0x00, 0x01, 0x02, 0x03, 0x04]);
|
||||
expect(() => stripMetadata(buf)).toThrow(ExifError);
|
||||
});
|
||||
|
||||
it("handles JPEG with EOI before SOS (no SOS found — no image data appended)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// A JPEG with only SOI + EOI and no SOS — findSosAndRemainder returns undefined
|
||||
const soi = new Uint8Array([0xff, 0xd8]);
|
||||
const eoi = new Uint8Array([0xff, 0xd9]);
|
||||
const buf = new Uint8Array([...soi, ...eoi]);
|
||||
// Should not throw
|
||||
const result = stripMetadata(buf);
|
||||
expect(result[0]).toBe(0xff);
|
||||
expect(result[1]).toBe(0xd8);
|
||||
});
|
||||
|
||||
it("appends SOS-like data found after segment end (findSosAndRemainder true branch)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Build a JPEG where the SOS segment data is followed immediately by more data
|
||||
// that starts with 0xff, 0xda (SOS marker bytes) — simulates a restart scan
|
||||
// This exercises the `if (sosData)` true branch
|
||||
const soi = new Uint8Array([0xff, 0xd8]);
|
||||
const exifData = new Uint8Array([0x45, 0x78, 0x69, 0x66, 0x00, 0x00]);
|
||||
const app1Marker = new Uint8Array([0xff, 0xe1]);
|
||||
const app1Size = new Uint8Array(2);
|
||||
new DataView(app1Size.buffer).setUint16(0, exifData.length + 2, false);
|
||||
// SOS with extra scan data containing another 0xff, 0xda sequence
|
||||
const sosHeader = new Uint8Array([
|
||||
0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3f, 0x00,
|
||||
]);
|
||||
// Compressed data with embedded 0xff 0xda (simulates progressive scan)
|
||||
const scanData = new Uint8Array([0xff, 0xda, 0x00, 0x04, 0xaa, 0xbb]);
|
||||
const eoi = new Uint8Array([0xff, 0xd9]);
|
||||
const jpeg = new Uint8Array([
|
||||
...soi,
|
||||
...app1Marker,
|
||||
...app1Size,
|
||||
...exifData,
|
||||
...sosHeader,
|
||||
...scanData,
|
||||
...eoi,
|
||||
]);
|
||||
|
||||
// stripMetadata should not throw and produce valid output
|
||||
const result = stripMetadata(jpeg);
|
||||
expect(result[0]).toBe(0xff);
|
||||
expect(result[1]).toBe(0xd8);
|
||||
// Result should be longer than just SOI — it includes SOS + scan data
|
||||
expect(result.length).toBeGreaterThan(4);
|
||||
});
|
||||
|
||||
it("respects stripXmp=false option", { timeout: 5000 }, () => {
|
||||
// Build JPEG with XMP APP1
|
||||
const xmpHeader = "http://ns.adobe.com/xap/1.0/\0<xmpmeta/>";
|
||||
const xmpBytes = new Uint8Array(xmpHeader.length);
|
||||
for (let i = 0; i < xmpHeader.length; i++)
|
||||
xmpBytes[i] = xmpHeader.charCodeAt(i);
|
||||
const marker = new Uint8Array([0xff, 0xe1]);
|
||||
const size = new Uint8Array(2);
|
||||
new DataView(size.buffer).setUint16(0, xmpBytes.length + 2, false);
|
||||
const sos = new Uint8Array([
|
||||
0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3f, 0x00,
|
||||
]);
|
||||
const eoi = new Uint8Array([0xff, 0xd9]);
|
||||
const jpeg = new Uint8Array([
|
||||
0xff,
|
||||
0xd8,
|
||||
...marker,
|
||||
...size,
|
||||
...xmpBytes,
|
||||
...sos,
|
||||
...eoi,
|
||||
]);
|
||||
// Strip with skipXmp=false (stripXmp defaults to true, so use stripXmp: false)
|
||||
const result = stripMetadata(jpeg, { stripXmp: false });
|
||||
// XMP segment should still be in result
|
||||
let foundXmp = false;
|
||||
for (let i = 0; i < result.length - 1; i++) {
|
||||
if (result[i] === 0xff && result[i + 1] === 0xe1) {
|
||||
foundXmp = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(foundXmp).toBe(true);
|
||||
});
|
||||
|
||||
it("strips IPTC APP13 segment by default", { timeout: 5000 }, () => {
|
||||
const iptcHeader = "Photoshop 3.0\0";
|
||||
const iptcBytes = new Uint8Array(iptcHeader.length + 4);
|
||||
for (let i = 0; i < iptcHeader.length; i++)
|
||||
iptcBytes[i] = iptcHeader.charCodeAt(i);
|
||||
const marker = new Uint8Array([0xff, 0xed]);
|
||||
const size = new Uint8Array(2);
|
||||
new DataView(size.buffer).setUint16(0, iptcBytes.length + 2, false);
|
||||
const sos = new Uint8Array([
|
||||
0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3f, 0x00,
|
||||
]);
|
||||
const eoi = new Uint8Array([0xff, 0xd9]);
|
||||
const jpeg = new Uint8Array([
|
||||
0xff,
|
||||
0xd8,
|
||||
...marker,
|
||||
...size,
|
||||
...iptcBytes,
|
||||
...sos,
|
||||
...eoi,
|
||||
]);
|
||||
const result = stripMetadata(jpeg);
|
||||
// APP13 should not be present
|
||||
for (let i = 0; i < result.length - 1; i++) {
|
||||
expect(result[i] === 0xff && result[i + 1] === 0xed).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
117
packages/media/image/exif/src/core/strip-metadata.ts
Normal file
117
packages/media/image/exif/src/core/strip-metadata.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { EXIF_CODES, ExifError } from "../errors/exif-error.js";
|
||||
import { detectImageFormat } from "../format/detect.js";
|
||||
import {
|
||||
isExifSegment,
|
||||
isIptcSegment,
|
||||
isXmpSegment,
|
||||
parseJpegSegments,
|
||||
} from "../jpeg/segments.js";
|
||||
import type { StripOptions } from "../types.js";
|
||||
|
||||
/**
|
||||
* Removes metadata segments from a JPEG image buffer.
|
||||
*
|
||||
* By default strips EXIF (APP1), XMP (APP1), and IPTC (APP13) segments.
|
||||
* Rebuilds the JPEG stream from the remaining segments.
|
||||
*/
|
||||
export function stripMetadata(
|
||||
buffer: Uint8Array,
|
||||
options?: StripOptions
|
||||
): Uint8Array {
|
||||
const format = detectImageFormat(buffer);
|
||||
|
||||
if (format !== "jpeg") {
|
||||
throw new ExifError(
|
||||
`stripMetadata only supports JPEG images (detected: ${format})`,
|
||||
EXIF_CODES.UNSUPPORTED_FORMAT
|
||||
);
|
||||
}
|
||||
|
||||
const stripExif = options?.stripExif !== false;
|
||||
const stripXmp = options?.stripXmp !== false;
|
||||
const stripIptc = options?.stripIptc !== false;
|
||||
|
||||
const segments = parseJpegSegments(buffer);
|
||||
|
||||
// Collect byte ranges to keep
|
||||
const keepChunks: Uint8Array[] = [];
|
||||
|
||||
// SOI marker (always kept)
|
||||
keepChunks.push(new Uint8Array([0xff, 0xd8]));
|
||||
|
||||
// Track the last segment's end to capture the SOS + compressed data
|
||||
let lastSegmentEnd = 2; // after SOI
|
||||
|
||||
for (const seg of segments) {
|
||||
if (seg.marker === 0xffd8) {
|
||||
// SOI — already added
|
||||
continue;
|
||||
}
|
||||
|
||||
if (seg.marker === 0xffd9) {
|
||||
// EOI — will be added at end
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this segment should be stripped
|
||||
const shouldStrip =
|
||||
(stripExif && isExifSegment(seg)) ||
|
||||
(stripXmp && isXmpSegment(seg)) ||
|
||||
(stripIptc && isIptcSegment(seg));
|
||||
|
||||
if (shouldStrip) {
|
||||
// Update lastSegmentEnd to skip this segment
|
||||
// Segment occupies: 2 (marker) + 2 (length) + data.length bytes
|
||||
lastSegmentEnd = seg.offset + 2 + 2 + seg.data.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Include this segment: 2-byte marker + 2-byte length + data
|
||||
const markerBytes = new Uint8Array([0xff, seg.marker & 0xff]);
|
||||
const lengthBytes = new Uint8Array(2);
|
||||
const dv = new DataView(lengthBytes.buffer);
|
||||
dv.setUint16(0, seg.data.length + 2, false); // big-endian
|
||||
|
||||
keepChunks.push(markerBytes);
|
||||
keepChunks.push(lengthBytes);
|
||||
keepChunks.push(seg.data);
|
||||
|
||||
lastSegmentEnd = seg.offset + 2 + 2 + seg.data.length;
|
||||
}
|
||||
|
||||
// Append everything from SOS onward (compressed image data + EOI)
|
||||
// Find SOS in original buffer starting from lastSegmentEnd
|
||||
const sosData = findSosAndRemainder(buffer, lastSegmentEnd);
|
||||
if (sosData) {
|
||||
keepChunks.push(sosData);
|
||||
}
|
||||
|
||||
// Calculate total size
|
||||
let totalSize = 0;
|
||||
for (const chunk of keepChunks) totalSize += chunk.length;
|
||||
|
||||
const result = new Uint8Array(totalSize);
|
||||
let offset = 0;
|
||||
for (const chunk of keepChunks) {
|
||||
result.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the SOS marker in the buffer starting from `startOffset` and returns
|
||||
* all bytes from that point to the end of the file.
|
||||
*/
|
||||
function findSosAndRemainder(
|
||||
buffer: Uint8Array,
|
||||
startOffset: number
|
||||
): Uint8Array | undefined {
|
||||
for (let i = startOffset; i < buffer.length - 1; i++) {
|
||||
if (buffer[i] === 0xff && buffer[i + 1] === 0xda) {
|
||||
return buffer.slice(i);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
64
packages/media/image/exif/src/core/write-metadata.md
Normal file
64
packages/media/image/exif/src/core/write-metadata.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# writeMetadata
|
||||
|
||||
## Purpose
|
||||
|
||||
Writes or updates EXIF metadata in a JPEG image buffer. Merges the provided patch over any existing EXIF data and rebuilds the APP1 segment, inserting one if the image does not already have EXIF. The compressed image data is preserved byte-for-byte — no re-encoding occurs.
|
||||
|
||||
## Features
|
||||
|
||||
- Merges patch fields over existing EXIF data when an APP1 segment is present
|
||||
- Inserts a new APP1 segment when no EXIF exists
|
||||
- Writes IFD0 fields: Make, Model, Software, DateTime, Orientation, ImageWidth, ImageHeight
|
||||
- Uses big-endian (Motorola) TIFF format for maximum compatibility
|
||||
- Rebuilds valid JPEG stream with correct APP1 marker and length
|
||||
- Throws `ExifError(UNSUPPORTED_FORMAT)` for non-JPEG inputs
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { writeMetadata } from "@suaveplan/exif";
|
||||
|
||||
const updated = writeMetadata(jpegBuffer, {
|
||||
software: "My App 1.0",
|
||||
orientation: 1,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { writeMetadata, readMetadata } from "@suaveplan/exif";
|
||||
|
||||
// Read → patch → write round-trip
|
||||
const before = readMetadata(jpegBuffer);
|
||||
const after = writeMetadata(jpegBuffer, {
|
||||
...before.exif,
|
||||
orientation: 6, // rotate 90° CW
|
||||
software: "Suaveplan Processor",
|
||||
});
|
||||
|
||||
const verify = readMetadata(after);
|
||||
console.log(verify.exif?.orientation); // 6
|
||||
console.log(verify.exif?.software); // "Suaveplan Processor"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `writeMetadata(buffer: Uint8Array, patch: Partial<ExifData>): Uint8Array`
|
||||
|
||||
Updates EXIF metadata in a JPEG buffer.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `buffer` — raw JPEG image bytes
|
||||
- `patch` — `ExifData` fields to set. Fields not included in `patch` retain their existing values from the original EXIF (if present).
|
||||
|
||||
**Returns:** New `Uint8Array` containing the updated JPEG.
|
||||
|
||||
**Throws:** `ExifError(UNSUPPORTED_FORMAT)` if `buffer` is not a JPEG image.
|
||||
|
||||
**Supported fields:** `make`, `model`, `software`, `dateTime`, `orientation`, `imageWidth`, `imageHeight`. GPS and ExifIFD sub-IFD writing are not yet supported.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The TIFF stream is always written in big-endian (Motorola, `MM`) format for maximum compatibility with reading software. IFD entries are sorted by tag number as required by the TIFF specification. String values (ASCII type) are null-terminated; values longer than 3 characters are stored in the data area rather than inline. The rebuilt JPEG inserts the new APP1 immediately after the SOI marker (before any other APP segments), which is the canonical position for EXIF. Any existing EXIF APP1 segment is replaced to prevent duplicate segments.
|
||||
199
packages/media/image/exif/src/core/write-metadata.test.ts
Normal file
199
packages/media/image/exif/src/core/write-metadata.test.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { ExifError } from "../errors/exif-error.js";
|
||||
import { readMetadata } from "./read-metadata.js";
|
||||
import { writeMetadata } from "./write-metadata.js";
|
||||
|
||||
function buildMinimalJpeg(app1Data?: Uint8Array): Uint8Array {
|
||||
const soi = new Uint8Array([0xff, 0xd8]);
|
||||
const sos = new Uint8Array([
|
||||
0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3f, 0x00,
|
||||
]);
|
||||
const eoi = new Uint8Array([0xff, 0xd9]);
|
||||
if (!app1Data) return new Uint8Array([...soi, ...sos, ...eoi]);
|
||||
const marker = new Uint8Array([0xff, 0xe1]);
|
||||
const size = new Uint8Array(2);
|
||||
const dv = new DataView(size.buffer);
|
||||
dv.setUint16(0, app1Data.length + 2, false);
|
||||
return new Uint8Array([
|
||||
...soi,
|
||||
...marker,
|
||||
...size,
|
||||
...app1Data,
|
||||
...sos,
|
||||
...eoi,
|
||||
]);
|
||||
}
|
||||
|
||||
describe("writeMetadata", () => {
|
||||
it("throws ExifError for non-JPEG input", { timeout: 5000 }, () => {
|
||||
const buf = new Uint8Array([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||
]);
|
||||
expect(() => writeMetadata(buf, {})).toThrow(ExifError);
|
||||
});
|
||||
|
||||
it("returns valid JPEG buffer", { timeout: 5000 }, () => {
|
||||
const jpeg = buildMinimalJpeg();
|
||||
const result = writeMetadata(jpeg, { orientation: 1 });
|
||||
expect(result[0]).toBe(0xff);
|
||||
expect(result[1]).toBe(0xd8);
|
||||
});
|
||||
|
||||
it("writes orientation to JPEG without existing EXIF", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const jpeg = buildMinimalJpeg();
|
||||
const result = writeMetadata(jpeg, { orientation: 6 });
|
||||
const meta = readMetadata(result);
|
||||
expect(meta.exif?.orientation).toBe(6);
|
||||
});
|
||||
|
||||
it("writes make and model", { timeout: 5000 }, () => {
|
||||
const jpeg = buildMinimalJpeg();
|
||||
const result = writeMetadata(jpeg, { make: "Canon", model: "EOS R5" });
|
||||
const meta = readMetadata(result);
|
||||
expect(meta.exif?.make).toBe("Canon");
|
||||
expect(meta.exif?.model).toBe("EOS R5");
|
||||
});
|
||||
|
||||
it("writes software field", { timeout: 5000 }, () => {
|
||||
const jpeg = buildMinimalJpeg();
|
||||
const result = writeMetadata(jpeg, { software: "Suaveplan 1.0" });
|
||||
const meta = readMetadata(result);
|
||||
expect(meta.exif?.software).toBe("Suaveplan 1.0");
|
||||
});
|
||||
|
||||
it("writes imageWidth and imageHeight", { timeout: 5000 }, () => {
|
||||
const jpeg = buildMinimalJpeg();
|
||||
const result = writeMetadata(jpeg, {
|
||||
imageWidth: 1920,
|
||||
imageHeight: 1080,
|
||||
});
|
||||
const meta = readMetadata(result);
|
||||
expect(meta.exif?.imageWidth).toBe(1920);
|
||||
expect(meta.exif?.imageHeight).toBe(1080);
|
||||
});
|
||||
|
||||
it("merges patch over existing EXIF", { timeout: 5000 }, () => {
|
||||
const jpeg = buildMinimalJpeg();
|
||||
// First write
|
||||
const v1 = writeMetadata(jpeg, { make: "Canon", orientation: 1 });
|
||||
// Update only orientation
|
||||
const v2 = writeMetadata(v1, { orientation: 6 });
|
||||
const meta = readMetadata(v2);
|
||||
expect(meta.exif?.orientation).toBe(6);
|
||||
// make should still be present from the base (merge)
|
||||
// Note: the write reads existing EXIF and merges, so make is preserved
|
||||
expect(meta.exif?.make).toBe("Canon");
|
||||
});
|
||||
|
||||
it("throws ExifError for tiff input", { timeout: 5000 }, () => {
|
||||
const buf = new Uint8Array([
|
||||
0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08,
|
||||
]);
|
||||
expect(() => writeMetadata(buf, {})).toThrow(ExifError);
|
||||
});
|
||||
|
||||
it("writes dateTime field", { timeout: 5000 }, () => {
|
||||
const jpeg = buildMinimalJpeg();
|
||||
const result = writeMetadata(jpeg, { dateTime: "2024:01:15 10:30:00" });
|
||||
const meta = readMetadata(result);
|
||||
expect(meta.exif?.dateTime).toBe("2024:01:15 10:30:00");
|
||||
});
|
||||
|
||||
it("gracefully handles malformed existing EXIF and starts fresh", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Build a JPEG with a malformed EXIF APP1 (valid Exif\0\0 header but truncated TIFF)
|
||||
const badExif = new Uint8Array([
|
||||
0x45,
|
||||
0x78,
|
||||
0x69,
|
||||
0x66,
|
||||
0x00,
|
||||
0x00, // "Exif\0\0"
|
||||
0x00, // truncated TIFF
|
||||
]);
|
||||
const jpeg = buildMinimalJpeg(badExif);
|
||||
// Should not throw — falls back to empty base ExifData
|
||||
const result = writeMetadata(jpeg, { orientation: 2 });
|
||||
const meta = readMetadata(result);
|
||||
expect(meta.exif?.orientation).toBe(2);
|
||||
});
|
||||
|
||||
it("writes rational fields (exposureTime and fNumber) into TIFF IFD0", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const jpeg = buildMinimalJpeg();
|
||||
const result = writeMetadata(jpeg, {
|
||||
exposureTime: { numerator: 1, denominator: 250 },
|
||||
fNumber: { numerator: 28, denominator: 10 },
|
||||
});
|
||||
// The rational tags are written into IFD0 and appear in rawTags
|
||||
const meta = readMetadata(result);
|
||||
expect(meta.exif).toBeDefined();
|
||||
// TAG_EXPOSURE_TIME = 0x829a, TAG_F_NUMBER = 0x829d are in rawTags
|
||||
const rawTags = meta.exif?.rawTags as
|
||||
| Record<number, unknown>
|
||||
| undefined;
|
||||
expect(rawTags?.[0x829a]).toEqual({ numerator: 1, denominator: 250 });
|
||||
expect(rawTags?.[0x829d]).toEqual({ numerator: 28, denominator: 10 });
|
||||
});
|
||||
|
||||
it("writes short ASCII field (≤4 bytes, stored inline in TIFF)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const jpeg = buildMinimalJpeg();
|
||||
// "V1" = 2 chars + null = 3 bytes ≤ 4 bytes → inline storage in TIFF
|
||||
const result = writeMetadata(jpeg, { software: "V1" });
|
||||
const meta = readMetadata(result);
|
||||
expect(meta.exif?.software).toBe("V1");
|
||||
});
|
||||
|
||||
it("handles JPEG with EOI before SOS (no sosOnward — false branch at if(sosOnward))", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// A JPEG with only SOI + EOI — parseJpegSegments returns [SOI, EOI].
|
||||
// writeMetadata iterates: SOI skipped, EOI skipped (line 247), loop ends,
|
||||
// sosOnward is undefined → if(sosOnward) false branch at line 263.
|
||||
const jpeg = new Uint8Array([0xff, 0xd8, 0xff, 0xd9]);
|
||||
const result = writeMetadata(jpeg, { orientation: 1 });
|
||||
// Should produce valid output (SOI + new EXIF APP1, no SOS/EOI appended)
|
||||
expect(result[0]).toBe(0xff);
|
||||
expect(result[1]).toBe(0xd8);
|
||||
});
|
||||
|
||||
it("passes through non-EXIF APP segments (e.g. APP2)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Build JPEG with APP2 segment (ICC profile, should survive writeMetadata)
|
||||
const soi = new Uint8Array([0xff, 0xd8]);
|
||||
const app2Data = new Uint8Array([0x49, 0x43, 0x43, 0x5f]); // "ICC_"
|
||||
const app2Marker = new Uint8Array([0xff, 0xe2]);
|
||||
const app2Size = new Uint8Array(2);
|
||||
new DataView(app2Size.buffer).setUint16(0, app2Data.length + 2, false);
|
||||
const sos = new Uint8Array([
|
||||
0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3f, 0x00,
|
||||
]);
|
||||
const eoi = new Uint8Array([0xff, 0xd9]);
|
||||
const jpeg = new Uint8Array([
|
||||
...soi,
|
||||
...app2Marker,
|
||||
...app2Size,
|
||||
...app2Data,
|
||||
...sos,
|
||||
...eoi,
|
||||
]);
|
||||
|
||||
const result = writeMetadata(jpeg, { orientation: 3 });
|
||||
// APP2 should still be present in the output
|
||||
let foundApp2 = false;
|
||||
for (let i = 0; i < result.length - 1; i++) {
|
||||
if (result[i] === 0xff && result[i + 1] === 0xe2) {
|
||||
foundApp2 = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(foundApp2).toBe(true);
|
||||
});
|
||||
});
|
||||
283
packages/media/image/exif/src/core/write-metadata.ts
Normal file
283
packages/media/image/exif/src/core/write-metadata.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import { ByteWriter } from "../binary/writer/byte-writer.js";
|
||||
import { EXIF_CODES, ExifError } from "../errors/exif-error.js";
|
||||
import { readExifFromTiffBuffer } from "../exif/read-exif.js";
|
||||
import { detectImageFormat } from "../format/detect.js";
|
||||
import { isExifSegment, parseJpegSegments } from "../jpeg/segments.js";
|
||||
import type { ExifData, Rational } from "../types.js";
|
||||
|
||||
// TIFF type codes
|
||||
const TYPE_ASCII = 2;
|
||||
const TYPE_SHORT = 3;
|
||||
const TYPE_LONG = 4;
|
||||
const TYPE_RATIONAL = 5;
|
||||
|
||||
// IFD0 tags we can write
|
||||
const TAG_IMAGE_WIDTH = 0x0100;
|
||||
const TAG_IMAGE_HEIGHT = 0x0101;
|
||||
const TAG_MAKE = 0x010f;
|
||||
const TAG_MODEL = 0x0110;
|
||||
const TAG_ORIENTATION = 0x0112;
|
||||
const TAG_SOFTWARE = 0x0131;
|
||||
const TAG_DATETIME = 0x0132;
|
||||
|
||||
// ExifIFD tags writable via patch
|
||||
const TAG_EXPOSURE_TIME = 0x829a;
|
||||
const TAG_F_NUMBER = 0x829d;
|
||||
|
||||
interface TagDef {
|
||||
tag: number;
|
||||
type: number;
|
||||
value: string | number | Rational;
|
||||
}
|
||||
|
||||
function stringTagDef(
|
||||
tag: number,
|
||||
value: string | undefined
|
||||
): TagDef | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
return { tag, type: TYPE_ASCII, value };
|
||||
}
|
||||
|
||||
function shortTagDef(
|
||||
tag: number,
|
||||
value: number | undefined
|
||||
): TagDef | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
return { tag, type: TYPE_SHORT, value };
|
||||
}
|
||||
|
||||
function longTagDef(
|
||||
tag: number,
|
||||
value: number | undefined
|
||||
): TagDef | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
return { tag, type: TYPE_LONG, value };
|
||||
}
|
||||
|
||||
function rationalTagDef(
|
||||
tag: number,
|
||||
value: Rational | undefined
|
||||
): TagDef | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
return { tag, type: TYPE_RATIONAL, value };
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a minimal TIFF IFD0 buffer from a set of ExifData fields.
|
||||
* Returns a Uint8Array containing the full TIFF stream (header + IFD0).
|
||||
*/
|
||||
function buildTiffBuffer(data: ExifData): Uint8Array {
|
||||
const tagDefs: TagDef[] = [
|
||||
stringTagDef(TAG_MAKE, data.make),
|
||||
stringTagDef(TAG_MODEL, data.model),
|
||||
stringTagDef(TAG_SOFTWARE, data.software),
|
||||
stringTagDef(TAG_DATETIME, data.dateTime),
|
||||
shortTagDef(TAG_ORIENTATION, data.orientation),
|
||||
longTagDef(TAG_IMAGE_WIDTH, data.imageWidth),
|
||||
longTagDef(TAG_IMAGE_HEIGHT, data.imageHeight),
|
||||
rationalTagDef(TAG_EXPOSURE_TIME, data.exposureTime),
|
||||
rationalTagDef(TAG_F_NUMBER, data.fNumber),
|
||||
].filter((t): t is TagDef => t !== undefined);
|
||||
|
||||
// Sort tags by tag number (TIFF requirement)
|
||||
tagDefs.sort((a, b) => a.tag - b.tag);
|
||||
|
||||
// TIFF header: 8 bytes (MM + 0x002A + IFD0 offset)
|
||||
// IFD0: 2-byte count + n*12 bytes + 4-byte next IFD pointer (0)
|
||||
// Data area: string/rational values that don't fit in 4 bytes
|
||||
|
||||
const HEADER_SIZE = 8;
|
||||
const IFD_COUNT_SIZE = 2;
|
||||
const IFD_ENTRY_SIZE = 12;
|
||||
const IFD_NEXT_SIZE = 4;
|
||||
const ifd0Offset = HEADER_SIZE;
|
||||
const dataAreaOffset =
|
||||
HEADER_SIZE +
|
||||
IFD_COUNT_SIZE +
|
||||
tagDefs.length * IFD_ENTRY_SIZE +
|
||||
IFD_NEXT_SIZE;
|
||||
|
||||
// Pre-calculate data area
|
||||
const dataChunks: { tag: number; offset: number; bytes: Uint8Array }[] = [];
|
||||
let dataOffset = dataAreaOffset;
|
||||
|
||||
for (const def of tagDefs) {
|
||||
if (def.type === TYPE_ASCII) {
|
||||
const str = String(def.value);
|
||||
// ASCII value: string + null terminator
|
||||
const bytes = new Uint8Array(str.length + 1);
|
||||
for (let i = 0; i < str.length; i++)
|
||||
bytes[i] = str.charCodeAt(i) & 0xff;
|
||||
bytes[str.length] = 0;
|
||||
if (bytes.length > 4) {
|
||||
dataChunks.push({ tag: def.tag, offset: dataOffset, bytes });
|
||||
dataOffset += bytes.length;
|
||||
} else {
|
||||
dataChunks.push({ tag: def.tag, offset: -1, bytes }); // inline
|
||||
}
|
||||
} else if (def.type === TYPE_RATIONAL) {
|
||||
const r = def.value as Rational;
|
||||
const bytes = new Uint8Array(8);
|
||||
const dv = new DataView(bytes.buffer);
|
||||
dv.setUint32(0, r.numerator, false);
|
||||
dv.setUint32(4, r.denominator, false);
|
||||
dataChunks.push({ tag: def.tag, offset: dataOffset, bytes });
|
||||
dataOffset += 8;
|
||||
}
|
||||
}
|
||||
|
||||
const totalSize = dataOffset;
|
||||
const writer = new ByteWriter(totalSize, "BE");
|
||||
|
||||
// TIFF header: Motorola byte order
|
||||
writer.writeUint8(0x4d); // M
|
||||
writer.writeUint8(0x4d); // M
|
||||
writer.writeUint16(0x002a); // TIFF magic
|
||||
writer.writeUint32(ifd0Offset); // IFD0 offset
|
||||
|
||||
// IFD0 entry count
|
||||
writer.writeUint16(tagDefs.length);
|
||||
|
||||
// IFD0 entries
|
||||
for (const def of tagDefs) {
|
||||
writer.writeUint16(def.tag);
|
||||
writer.writeUint16(def.type);
|
||||
|
||||
if (def.type === TYPE_ASCII) {
|
||||
const str = String(def.value);
|
||||
const count = str.length + 1; // includes null
|
||||
writer.writeUint32(count);
|
||||
const chunk = dataChunks.find((c) => c.tag === def.tag);
|
||||
if (chunk && chunk.offset >= 0) {
|
||||
writer.writeUint32(chunk.offset);
|
||||
} else {
|
||||
// Inline: up to 4 bytes, pad with zeros
|
||||
// chunk is always defined here (pre-calculation loop always creates it for ASCII)
|
||||
const bytes =
|
||||
chunk?.bytes ??
|
||||
/* istanbul ignore next */ new Uint8Array(4);
|
||||
for (let i = 0; i < 4; i++)
|
||||
writer.writeUint8((bytes[i] as number | undefined) ?? 0);
|
||||
}
|
||||
} else if (def.type === TYPE_SHORT) {
|
||||
writer.writeUint32(1);
|
||||
writer.writeUint16(def.value as number);
|
||||
writer.writeUint16(0); // padding
|
||||
} else if (def.type === TYPE_LONG) {
|
||||
writer.writeUint32(1);
|
||||
writer.writeUint32(def.value as number);
|
||||
} else {
|
||||
// TYPE_RATIONAL: 8-byte value stored in data area, referenced by offset
|
||||
// chunk is always defined here (pre-calculation loop always creates it for RATIONAL)
|
||||
writer.writeUint32(1);
|
||||
const chunk = dataChunks.find((c) => c.tag === def.tag);
|
||||
writer.writeUint32(chunk?.offset ?? /* istanbul ignore next */ 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Next IFD offset (0 = no more IFDs)
|
||||
writer.writeUint32(0);
|
||||
|
||||
// Write data area
|
||||
for (const chunk of dataChunks) {
|
||||
if (chunk.offset >= 0) {
|
||||
writer.writeBytes(chunk.bytes);
|
||||
}
|
||||
}
|
||||
|
||||
return writer.toUint8Array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes EXIF metadata into a JPEG image buffer.
|
||||
*
|
||||
* If the image already has an EXIF APP1 segment, it is replaced with the merged data.
|
||||
* If no EXIF segment exists, a new one is inserted after the SOI marker.
|
||||
* Only IFD0 fields in `patch` are written; GPS and ExifIFD updates are not yet supported.
|
||||
*/
|
||||
export function writeMetadata(
|
||||
buffer: Uint8Array,
|
||||
patch: Partial<ExifData>
|
||||
): Uint8Array {
|
||||
const format = detectImageFormat(buffer);
|
||||
|
||||
if (format !== "jpeg") {
|
||||
throw new ExifError(
|
||||
`writeMetadata only supports JPEG images (detected: ${format})`,
|
||||
EXIF_CODES.UNSUPPORTED_FORMAT
|
||||
);
|
||||
}
|
||||
|
||||
const segments = parseJpegSegments(buffer);
|
||||
|
||||
// Find existing EXIF and merge
|
||||
const existingExifSeg = segments.find(isExifSegment);
|
||||
let baseExif: ExifData = {};
|
||||
|
||||
if (existingExifSeg) {
|
||||
try {
|
||||
baseExif = readExifFromTiffBuffer(existingExifSeg.data.slice(6));
|
||||
} catch {
|
||||
baseExif = {};
|
||||
}
|
||||
}
|
||||
|
||||
const merged: ExifData = { ...baseExif, ...patch };
|
||||
const tiffBuffer = buildTiffBuffer(merged);
|
||||
|
||||
// Build new APP1 segment: "Exif\0\0" + TIFF
|
||||
const exifHeader = new Uint8Array([0x45, 0x78, 0x69, 0x66, 0x00, 0x00]);
|
||||
const app1Data = new Uint8Array(exifHeader.length + tiffBuffer.length);
|
||||
app1Data.set(exifHeader, 0);
|
||||
app1Data.set(tiffBuffer, exifHeader.length);
|
||||
|
||||
// Build output JPEG
|
||||
const chunks: Uint8Array[] = [new Uint8Array([0xff, 0xd8])]; // SOI
|
||||
|
||||
// Add new EXIF APP1
|
||||
const app1Length = app1Data.length + 2; // includes 2-byte length field
|
||||
const app1Header = new Uint8Array(4);
|
||||
const dv = new DataView(app1Header.buffer);
|
||||
app1Header[0] = 0xff;
|
||||
app1Header[1] = 0xe1;
|
||||
dv.setUint16(2, app1Length, false);
|
||||
chunks.push(app1Header);
|
||||
chunks.push(app1Data);
|
||||
|
||||
// Add all other segments except old EXIF APP1 and SOI
|
||||
let sosOnward: Uint8Array | undefined;
|
||||
|
||||
for (const seg of segments) {
|
||||
if (seg.marker === 0xffd8) continue; // SOI
|
||||
if (seg.marker === 0xffd9) continue; // EOI
|
||||
if (isExifSegment(seg)) continue; // replaced above
|
||||
|
||||
if (seg.marker === 0xffda) {
|
||||
// SOS — capture remainder of file
|
||||
sosOnward = buffer.slice(seg.offset);
|
||||
break;
|
||||
}
|
||||
|
||||
const markerByte = new Uint8Array([0xff, seg.marker & 0xff]);
|
||||
const segLen = new Uint8Array(2);
|
||||
const segDv = new DataView(segLen.buffer);
|
||||
segDv.setUint16(0, seg.data.length + 2, false);
|
||||
chunks.push(markerByte, segLen, seg.data);
|
||||
}
|
||||
|
||||
if (sosOnward) {
|
||||
chunks.push(sosOnward);
|
||||
}
|
||||
|
||||
// Calculate total and assemble
|
||||
let total = 0;
|
||||
for (const c of chunks) total += c.length;
|
||||
const result = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const c of chunks) {
|
||||
result.set(c, offset);
|
||||
offset += c.length;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
99
packages/media/image/exif/src/errors/exif-error.md
Normal file
99
packages/media/image/exif/src/errors/exif-error.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# ExifError
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides the stable error code registry and base error class for the `@suaveplan/exif` package. Every error thrown by the package uses `ExifError` with a code from `EXIF_CODES`, enabling programmatic error handling without string matching on error messages.
|
||||
|
||||
The design mirrors the `@suaveplan/error` pattern but keeps the package dependency-free: no runtime `@suaveplan/*` imports are needed.
|
||||
|
||||
## Features
|
||||
|
||||
- Stable code registry via `EXIF_CODES` constant object
|
||||
- `ExifError` class with `code` and optional `cause` chaining
|
||||
- TypeScript `ExifErrorCode` union type for exhaustive switch statements
|
||||
- Four distinct codes covering all error surfaces: format detection, parsing, writing, and data validation
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { ExifError, EXIF_CODES } from "@suaveplan/exif/errors";
|
||||
|
||||
throw new ExifError("Buffer is not a JPEG or PNG image", EXIF_CODES.UNSUPPORTED_FORMAT);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { ExifError, EXIF_CODES, type ExifErrorCode } from "@suaveplan/exif/errors";
|
||||
|
||||
function handleExifError(err: ExifError): void {
|
||||
switch (err.code as ExifErrorCode) {
|
||||
case "EXIF.UNSUPPORTED_FORMAT":
|
||||
console.warn("Unsupported image format:", err.message);
|
||||
break;
|
||||
case "EXIF.PARSE_ERROR":
|
||||
console.error("Parsing failed:", err.message, err.cause);
|
||||
break;
|
||||
case "EXIF.WRITE_ERROR":
|
||||
console.error("Write failed:", err.message);
|
||||
break;
|
||||
case "EXIF.INVALID_DATA":
|
||||
console.error("Invalid metadata:", err.message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// With cause chaining
|
||||
try {
|
||||
const data = parseIfd(reader, 0);
|
||||
} catch (cause) {
|
||||
throw new ExifError("IFD parse failed at offset 0", EXIF_CODES.PARSE_ERROR, { cause });
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `EXIF_CODES`
|
||||
|
||||
```ts
|
||||
const EXIF_CODES: {
|
||||
UNSUPPORTED_FORMAT: "EXIF.UNSUPPORTED_FORMAT";
|
||||
PARSE_ERROR: "EXIF.PARSE_ERROR";
|
||||
WRITE_ERROR: "EXIF.WRITE_ERROR";
|
||||
INVALID_DATA: "EXIF.INVALID_DATA";
|
||||
}
|
||||
```
|
||||
|
||||
Stable string code registry. Use these constants rather than literal strings to guard against typos.
|
||||
|
||||
### `ExifErrorCode`
|
||||
|
||||
```ts
|
||||
type ExifErrorCode = "EXIF.UNSUPPORTED_FORMAT" | "EXIF.PARSE_ERROR" | "EXIF.WRITE_ERROR" | "EXIF.INVALID_DATA";
|
||||
```
|
||||
|
||||
Union type of all valid codes, suitable for exhaustive switches.
|
||||
|
||||
### `ExifError`
|
||||
|
||||
```ts
|
||||
class ExifError extends Error {
|
||||
readonly code: ExifErrorCode;
|
||||
constructor(message: string, code: ExifErrorCode, options?: { cause?: unknown }): ExifError;
|
||||
}
|
||||
```
|
||||
|
||||
- **message** — human-readable description of the error
|
||||
- **code** — one of the `EXIF_CODES` values for programmatic handling
|
||||
- **options.cause** — upstream error to chain (accessible via `err.cause`)
|
||||
|
||||
The `name` property is always `"ExifError"` for `instanceof`-free type narrowing via `err.name`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
This module is intentionally minimal. It carries no runtime dependencies and exports only what is needed for error handling across the package. The `EXIF_CODES` object is declared `as const` so TypeScript narrows each property to its literal type, enabling the `ExifErrorCode` union to be derived without duplication.
|
||||
|
||||
`ExifError` extends `Error` directly rather than `@suaveplan/error`'s base class to keep the package fully dependency-free. This is a deliberate trade-off: the package is a leaf node in the dependency graph and should impose zero peer requirements on consumers.
|
||||
42
packages/media/image/exif/src/errors/exif-error.test.ts
Normal file
42
packages/media/image/exif/src/errors/exif-error.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { EXIF_CODES, ExifError } from "./exif-error.js";
|
||||
|
||||
describe("ExifError", () => {
|
||||
it("sets message", { timeout: 5000 }, () => {
|
||||
const err = new ExifError("test message", EXIF_CODES.PARSE_ERROR);
|
||||
expect(err.message).toBe("test message");
|
||||
});
|
||||
|
||||
it("sets code from EXIF_CODES", { timeout: 5000 }, () => {
|
||||
const err = new ExifError("msg", EXIF_CODES.PARSE_ERROR);
|
||||
expect(err.code).toBe("EXIF.PARSE_ERROR");
|
||||
});
|
||||
|
||||
it("sets name to ExifError", { timeout: 5000 }, () => {
|
||||
const err = new ExifError("msg", EXIF_CODES.UNSUPPORTED_FORMAT);
|
||||
expect(err.name).toBe("ExifError");
|
||||
});
|
||||
|
||||
it("is instance of Error", { timeout: 5000 }, () => {
|
||||
const err = new ExifError("msg", EXIF_CODES.INVALID_DATA);
|
||||
expect(err instanceof Error).toBe(true);
|
||||
});
|
||||
|
||||
it("is instance of ExifError", { timeout: 5000 }, () => {
|
||||
const err = new ExifError("msg", EXIF_CODES.WRITE_ERROR);
|
||||
expect(err instanceof ExifError).toBe(true);
|
||||
});
|
||||
|
||||
it("supports cause chaining", { timeout: 5000 }, () => {
|
||||
const cause = new Error("root cause");
|
||||
const err = new ExifError("wrapped", EXIF_CODES.PARSE_ERROR, { cause });
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
|
||||
it("EXIF_CODES has all four codes", { timeout: 5000 }, () => {
|
||||
expect(EXIF_CODES.UNSUPPORTED_FORMAT).toBe("EXIF.UNSUPPORTED_FORMAT");
|
||||
expect(EXIF_CODES.PARSE_ERROR).toBe("EXIF.PARSE_ERROR");
|
||||
expect(EXIF_CODES.WRITE_ERROR).toBe("EXIF.WRITE_ERROR");
|
||||
expect(EXIF_CODES.INVALID_DATA).toBe("EXIF.INVALID_DATA");
|
||||
});
|
||||
});
|
||||
32
packages/media/image/exif/src/errors/exif-error.ts
Normal file
32
packages/media/image/exif/src/errors/exif-error.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Stable error code registry for the @suaveplan/exif package.
|
||||
* All error codes follow the EXIF.<CATEGORY> naming convention.
|
||||
*/
|
||||
export const EXIF_CODES = {
|
||||
UNSUPPORTED_FORMAT: "EXIF.UNSUPPORTED_FORMAT",
|
||||
PARSE_ERROR: "EXIF.PARSE_ERROR",
|
||||
WRITE_ERROR: "EXIF.WRITE_ERROR",
|
||||
INVALID_DATA: "EXIF.INVALID_DATA",
|
||||
} as const;
|
||||
|
||||
export type ExifErrorCode = (typeof EXIF_CODES)[keyof typeof EXIF_CODES];
|
||||
|
||||
/**
|
||||
* Base error class for all @suaveplan/exif errors.
|
||||
*
|
||||
* Carries a stable `code` string from EXIF_CODES for programmatic handling,
|
||||
* and an optional `cause` for chaining upstream errors.
|
||||
*/
|
||||
export class ExifError extends Error {
|
||||
readonly code: ExifErrorCode;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
code: ExifErrorCode,
|
||||
options?: { cause?: unknown }
|
||||
) {
|
||||
super(message, options);
|
||||
this.name = "ExifError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
1
packages/media/image/exif/src/errors/index.ts
Normal file
1
packages/media/image/exif/src/errors/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { EXIF_CODES, ExifError, type ExifErrorCode } from "./exif-error";
|
||||
1
packages/media/image/exif/src/exif/index.ts
Normal file
1
packages/media/image/exif/src/exif/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { readExifFromTiffBuffer } from "./read-exif";
|
||||
74
packages/media/image/exif/src/exif/read-exif.md
Normal file
74
packages/media/image/exif/src/exif/read-exif.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# readExifFromTiffBuffer
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Reads EXIF metadata from a raw TIFF byte buffer. Handles the full TIFF IFD chain: IFD0 for image-level tags, ExifIFD (0x8769) for camera settings, and GPS IFD (0x8825) for geolocation data. Supports both big-endian (Motorola, `MM`) and little-endian (Intel, `II`) TIFF byte orders.
|
||||
|
||||
## Features
|
||||
|
||||
- Reads both Motorola (big-endian) and Intel (little-endian) TIFF streams
|
||||
- Maps IFD0 tags: Make, Model, Software, DateTime, Orientation, ImageWidth, ImageHeight
|
||||
- Follows ExifIFD: ExposureTime, FNumber, ISO, FocalLength, FocalLengthIn35mm, Flash, WhiteBalance, ExposureMode, ExposureProgram, DateTimeOriginal, DateTimeDigitized
|
||||
- Follows GPS IFD: latitude, longitude, altitude, timestamp, datestamp, speed, direction
|
||||
- Returns all parsed tags in `rawTags` for custom access
|
||||
- Throws `ExifError(PARSE_ERROR)` on invalid TIFF header
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { readExifFromTiffBuffer } from "@suaveplan/exif/exif";
|
||||
|
||||
// Buffer starts at TIFF header (after "Exif\0\0" prefix is stripped)
|
||||
const exif = readExifFromTiffBuffer(tiffBuffer);
|
||||
console.log(exif.make, exif.model);
|
||||
console.log(exif.exposureTime); // { numerator: 1, denominator: 100 }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { readExifFromTiffBuffer } from "@suaveplan/exif/exif";
|
||||
import { rationalToNumber } from "@suaveplan/exif/binary";
|
||||
|
||||
const exif = readExifFromTiffBuffer(tiffBuffer);
|
||||
|
||||
if (exif.gps?.latitude) {
|
||||
const { degrees, minutes, seconds, ref } = exif.gps.latitude;
|
||||
const decimal = degrees + minutes / 60 + seconds / 3600;
|
||||
const signed = ref === "S" ? -decimal : decimal;
|
||||
console.log("Latitude:", signed.toFixed(6));
|
||||
}
|
||||
|
||||
if (exif.exposureTime) {
|
||||
const shutter = rationalToNumber(exif.exposureTime);
|
||||
console.log(`Shutter: ${shutter}s`);
|
||||
}
|
||||
|
||||
// Access raw tag by number
|
||||
const customTag = exif.rawTags?.[0x013B]; // Artist tag
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `readExifFromTiffBuffer(buffer: Uint8Array): ExifData`
|
||||
|
||||
Parses EXIF metadata from a raw TIFF buffer.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `buffer` — `Uint8Array` starting at the TIFF byte-order mark (`MM` or `II`), not at the JPEG `Exif\0\0` prefix
|
||||
|
||||
**Returns:** `ExifData` with all recognised tags populated.
|
||||
|
||||
**Throws:** `ExifError(PARSE_ERROR)` if:
|
||||
|
||||
- The buffer is shorter than 8 bytes
|
||||
- The byte-order mark is not `0x4D4D` (BE) or `0x4949` (LE)
|
||||
- The TIFF magic number is not `0x002A`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The 6-byte `"Exif\0\0"` prefix must be stripped before calling this function — the buffer must start at the TIFF header. The `readMetadata` orchestrator handles this stripping automatically. GPS coordinate rationals are expected to be an array of three RATIONAL values (degrees, minutes, seconds) as per the EXIF GPS IFD specification. The GPS timestamp is parsed from three RATIONAL values and formatted as `HH:MM:SS`. All collected IFD entries from both IFD0 and ExifIFD are merged into `rawTags` for consumer access.
|
||||
865
packages/media/image/exif/src/exif/read-exif.test.ts
Normal file
865
packages/media/image/exif/src/exif/read-exif.test.ts
Normal file
@@ -0,0 +1,865 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { ByteWriter } from "../binary/writer/byte-writer.js";
|
||||
import { ExifError } from "../errors/exif-error.js";
|
||||
import { readExifFromTiffBuffer } from "./read-exif.js";
|
||||
|
||||
/**
|
||||
* Builds a minimal big-endian TIFF buffer with basic IFD0 tags.
|
||||
*/
|
||||
function buildMinimalTiff(
|
||||
tags: Array<{ tag: number; type: number; value: number | string }>
|
||||
): Uint8Array {
|
||||
// Sort by tag
|
||||
const sorted = [...tags].sort((a, b) => a.tag - b.tag);
|
||||
const HEADER_SIZE = 8;
|
||||
const IFD_COUNT_SIZE = 2;
|
||||
const IFD_ENTRY_SIZE = 12;
|
||||
const IFD_NEXT_SIZE = 4;
|
||||
const dataAreaStart =
|
||||
HEADER_SIZE +
|
||||
IFD_COUNT_SIZE +
|
||||
sorted.length * IFD_ENTRY_SIZE +
|
||||
IFD_NEXT_SIZE;
|
||||
|
||||
// Pre-calculate data chunks for strings
|
||||
const dataChunks: { tag: number; offset: number; bytes: Uint8Array }[] = [];
|
||||
let dataOffset = dataAreaStart;
|
||||
|
||||
for (const t of sorted) {
|
||||
if (t.type === 2) {
|
||||
// ASCII
|
||||
const str = String(t.value);
|
||||
const bytes = new Uint8Array(str.length + 1);
|
||||
for (let i = 0; i < str.length; i++) bytes[i] = str.charCodeAt(i);
|
||||
bytes[str.length] = 0;
|
||||
if (bytes.length > 4) {
|
||||
dataChunks.push({ tag: t.tag, offset: dataOffset, bytes });
|
||||
dataOffset += bytes.length;
|
||||
} else {
|
||||
dataChunks.push({ tag: t.tag, offset: -1, bytes });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const totalSize = dataOffset;
|
||||
const writer = new ByteWriter(totalSize + 32, "BE");
|
||||
|
||||
// TIFF header: MM 00 2A offset=8
|
||||
writer.writeUint8(0x4d).writeUint8(0x4d);
|
||||
writer.writeUint16(0x002a);
|
||||
writer.writeUint32(HEADER_SIZE);
|
||||
|
||||
// IFD entry count
|
||||
writer.writeUint16(sorted.length);
|
||||
|
||||
for (const t of sorted) {
|
||||
writer.writeUint16(t.tag);
|
||||
writer.writeUint16(t.type);
|
||||
// ASCII count must include null terminator
|
||||
if (t.type === 2) {
|
||||
writer.writeUint32(String(t.value).length + 1);
|
||||
} else {
|
||||
writer.writeUint32(1); // count
|
||||
}
|
||||
|
||||
if (t.type === 2) {
|
||||
// ASCII
|
||||
const chunk = dataChunks.find((c) => c.tag === t.tag);
|
||||
if (chunk && chunk.offset >= 0) {
|
||||
writer.writeUint32(chunk.offset);
|
||||
} else {
|
||||
const bytes = chunk?.bytes ?? new Uint8Array(4);
|
||||
for (let i = 0; i < 4; i++) writer.writeUint8(bytes[i] ?? 0);
|
||||
}
|
||||
} else if (t.type === 3) {
|
||||
// SHORT
|
||||
writer.writeUint16(t.value as number).writeUint16(0);
|
||||
} else if (t.type === 4) {
|
||||
// LONG
|
||||
writer.writeUint32(t.value as number);
|
||||
} else {
|
||||
writer.writeUint32(t.value as number);
|
||||
}
|
||||
}
|
||||
|
||||
writer.writeUint32(0); // next IFD
|
||||
|
||||
// Write data area
|
||||
for (const chunk of dataChunks) {
|
||||
if (chunk.offset >= 0) writer.writeBytes(chunk.bytes);
|
||||
}
|
||||
|
||||
return writer.toUint8Array();
|
||||
}
|
||||
|
||||
describe("readExifFromTiffBuffer", () => {
|
||||
it("reads Make and Model from big-endian TIFF", { timeout: 5000 }, () => {
|
||||
const buf = buildMinimalTiff([
|
||||
{ tag: 0x010f, type: 2, value: "Canon" },
|
||||
{ tag: 0x0110, type: 2, value: "EOS R5" },
|
||||
]);
|
||||
const exif = readExifFromTiffBuffer(buf);
|
||||
expect(exif.make).toBe("Canon");
|
||||
expect(exif.model).toBe("EOS R5");
|
||||
});
|
||||
|
||||
it("reads Orientation as SHORT", { timeout: 5000 }, () => {
|
||||
const buf = buildMinimalTiff([{ tag: 0x0112, type: 3, value: 6 }]);
|
||||
const exif = readExifFromTiffBuffer(buf);
|
||||
expect(exif.orientation).toBe(6);
|
||||
});
|
||||
|
||||
it("reads DateTime", { timeout: 5000 }, () => {
|
||||
const buf = buildMinimalTiff([
|
||||
{ tag: 0x0132, type: 2, value: "2024:01:15 10:30:00" },
|
||||
]);
|
||||
const exif = readExifFromTiffBuffer(buf);
|
||||
expect(exif.dateTime).toBe("2024:01:15 10:30:00");
|
||||
});
|
||||
|
||||
it("reads Software", { timeout: 5000 }, () => {
|
||||
const buf = buildMinimalTiff([
|
||||
{ tag: 0x0131, type: 2, value: "Lightroom" },
|
||||
]);
|
||||
const exif = readExifFromTiffBuffer(buf);
|
||||
expect(exif.software).toBe("Lightroom");
|
||||
});
|
||||
|
||||
it("reads ImageWidth and ImageHeight as LONG", { timeout: 5000 }, () => {
|
||||
const buf = buildMinimalTiff([
|
||||
{ tag: 0x0100, type: 4, value: 4000 },
|
||||
{ tag: 0x0101, type: 4, value: 3000 },
|
||||
]);
|
||||
const exif = readExifFromTiffBuffer(buf);
|
||||
expect(exif.imageWidth).toBe(4000);
|
||||
expect(exif.imageHeight).toBe(3000);
|
||||
});
|
||||
|
||||
it("throws ExifError for buffer too short", { timeout: 5000 }, () => {
|
||||
expect(() => readExifFromTiffBuffer(new Uint8Array(4))).toThrow(
|
||||
ExifError
|
||||
);
|
||||
});
|
||||
|
||||
it("throws ExifError for invalid byte order mark", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const buf = new Uint8Array([
|
||||
0x00, 0x00, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08,
|
||||
]);
|
||||
expect(() => readExifFromTiffBuffer(buf)).toThrow(ExifError);
|
||||
});
|
||||
|
||||
it("throws ExifError for invalid magic number", { timeout: 5000 }, () => {
|
||||
const buf = new Uint8Array([
|
||||
0x4d, 0x4d, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08,
|
||||
]);
|
||||
expect(() => readExifFromTiffBuffer(buf)).toThrow(ExifError);
|
||||
});
|
||||
|
||||
it("returns empty ExifData for TIFF with empty IFD0", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const buf = buildMinimalTiff([]);
|
||||
const exif = readExifFromTiffBuffer(buf);
|
||||
expect(exif.make).toBeUndefined();
|
||||
expect(exif.rawTags).toBeDefined();
|
||||
});
|
||||
|
||||
it("little-endian TIFF (Intel byte order)", { timeout: 5000 }, () => {
|
||||
// Build minimal LE TIFF manually
|
||||
const writer = new ByteWriter(64, "LE");
|
||||
writer.writeUint8(0x49).writeUint8(0x49); // II
|
||||
writer.writeUint16(0x002a);
|
||||
writer.writeUint32(8); // IFD0 at 8
|
||||
writer.writeUint16(1); // 1 entry
|
||||
writer.writeUint16(0x0112); // Orientation
|
||||
writer.writeUint16(3); // SHORT
|
||||
writer.writeUint32(1); // count
|
||||
writer.writeUint16(3).writeUint16(0); // value=3, pad
|
||||
writer.writeUint32(0); // next IFD
|
||||
const buf = writer.toUint8Array();
|
||||
const exif = readExifFromTiffBuffer(buf);
|
||||
expect(exif.orientation).toBe(3);
|
||||
});
|
||||
|
||||
it("reads ExifIFD sub-fields (ISO, exposureTime, fNumber, focal, flash, wb, mode, program, dates)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Build a BE TIFF with IFD0 containing ExifIFD pointer, and ExifIFD with all sub-fields.
|
||||
// Layout: TIFF header (8) + IFD0 + ExifIFD + data area
|
||||
// IFD0: 1 entry (ExifIFD pointer = LONG)
|
||||
// ExifIFD: 11 entries: ISO(SHORT), exposureTime(RATIONAL), fNumber(RATIONAL),
|
||||
// focalLength(RATIONAL), focalLength35mm(SHORT), flash(SHORT), whiteBalance(SHORT),
|
||||
// exposureMode(SHORT), exposureProgram(SHORT), dateTimeOriginal(ASCII), dateTimeDigitized(ASCII)
|
||||
|
||||
const writer = new ByteWriter(1024, "BE");
|
||||
|
||||
// ---- Measure offsets ----
|
||||
// Header: 8 bytes
|
||||
// IFD0: offset=8, size = 2 + 1*12 + 4 = 18 bytes → ends at 26
|
||||
// ExifIFD: offset=26, size = 2 + 11*12 + 4 = 138 bytes → ends at 164
|
||||
// Data area starts at 164
|
||||
// RATIONAL = 8 bytes each, ASCII "2024:01:15 10:30:00\0" = 20 bytes
|
||||
// ExifIFD data area layout (all offsets from start of TIFF):
|
||||
// 164: exposureTime rational (num=1, den=250)
|
||||
// 172: fNumber rational (num=28, den=10)
|
||||
// 180: focalLength rational (num=50, den=1)
|
||||
// 188: dateTimeOriginal "2024:01:15 10:30:00\0" (20 bytes)
|
||||
// 208: dateTimeDigitized "2024:01:15 10:30:00\0" (20 bytes)
|
||||
|
||||
const EXIF_IFD_OFFSET = 26;
|
||||
const DATA_START = 164;
|
||||
|
||||
// TIFF header
|
||||
writer.writeUint8(0x4d).writeUint8(0x4d); // MM
|
||||
writer.writeUint16(0x002a);
|
||||
writer.writeUint32(8); // IFD0 at 8
|
||||
|
||||
// IFD0: 1 entry
|
||||
writer.writeUint16(1);
|
||||
writer.writeUint16(0x8769); // ExifIFD tag
|
||||
writer.writeUint16(4); // LONG
|
||||
writer.writeUint32(1); // count
|
||||
writer.writeUint32(EXIF_IFD_OFFSET); // value = ExifIFD offset
|
||||
writer.writeUint32(0); // next IFD = 0
|
||||
|
||||
// ExifIFD: 11 entries at offset 26
|
||||
writer.writeUint16(11);
|
||||
|
||||
// 0x8822 ExposureProgram SHORT=2
|
||||
writer
|
||||
.writeUint16(0x8822)
|
||||
.writeUint16(3)
|
||||
.writeUint32(1)
|
||||
.writeUint16(2)
|
||||
.writeUint16(0);
|
||||
// 0x829a ExposureTime RATIONAL → offset 164
|
||||
writer
|
||||
.writeUint16(0x829a)
|
||||
.writeUint16(5)
|
||||
.writeUint32(1)
|
||||
.writeUint32(DATA_START);
|
||||
// 0x829d FNumber RATIONAL → offset 172
|
||||
writer
|
||||
.writeUint16(0x829d)
|
||||
.writeUint16(5)
|
||||
.writeUint32(1)
|
||||
.writeUint32(DATA_START + 8);
|
||||
// 0x8827 ISO SHORT=200
|
||||
writer
|
||||
.writeUint16(0x8827)
|
||||
.writeUint16(3)
|
||||
.writeUint32(1)
|
||||
.writeUint16(200)
|
||||
.writeUint16(0);
|
||||
// 0x9003 DateTimeOriginal ASCII 20 bytes → offset 188
|
||||
writer
|
||||
.writeUint16(0x9003)
|
||||
.writeUint16(2)
|
||||
.writeUint32(20)
|
||||
.writeUint32(DATA_START + 24);
|
||||
// 0x9004 DateTimeDigitized ASCII 20 bytes → offset 208
|
||||
writer
|
||||
.writeUint16(0x9004)
|
||||
.writeUint16(2)
|
||||
.writeUint32(20)
|
||||
.writeUint32(DATA_START + 44);
|
||||
// 0x9209 Flash SHORT=9
|
||||
writer
|
||||
.writeUint16(0x9209)
|
||||
.writeUint16(3)
|
||||
.writeUint32(1)
|
||||
.writeUint16(9)
|
||||
.writeUint16(0);
|
||||
// 0x920a FocalLength RATIONAL → offset 180
|
||||
writer
|
||||
.writeUint16(0x920a)
|
||||
.writeUint16(5)
|
||||
.writeUint32(1)
|
||||
.writeUint32(DATA_START + 16);
|
||||
// 0xa402 ExposureMode SHORT=1
|
||||
writer
|
||||
.writeUint16(0xa402)
|
||||
.writeUint16(3)
|
||||
.writeUint32(1)
|
||||
.writeUint16(1)
|
||||
.writeUint16(0);
|
||||
// 0xa403 WhiteBalance SHORT=0
|
||||
writer
|
||||
.writeUint16(0xa403)
|
||||
.writeUint16(3)
|
||||
.writeUint32(1)
|
||||
.writeUint16(0)
|
||||
.writeUint16(0);
|
||||
// 0xa405 FocalLength35mm SHORT=75
|
||||
writer
|
||||
.writeUint16(0xa405)
|
||||
.writeUint16(3)
|
||||
.writeUint32(1)
|
||||
.writeUint16(75)
|
||||
.writeUint16(0);
|
||||
|
||||
writer.writeUint32(0); // next ExifIFD = 0
|
||||
|
||||
// Data area at 164:
|
||||
// +0: exposureTime 1/250
|
||||
writer.writeUint32(1).writeUint32(250);
|
||||
// +8: fNumber 28/10
|
||||
writer.writeUint32(28).writeUint32(10);
|
||||
// +16: focalLength 50/1
|
||||
writer.writeUint32(50).writeUint32(1);
|
||||
// +24: dateTimeOriginal
|
||||
const dtoStr = "2024:01:15 10:30:00";
|
||||
for (let i = 0; i < dtoStr.length; i++)
|
||||
writer.writeUint8(dtoStr.charCodeAt(i));
|
||||
writer.writeUint8(0); // null
|
||||
// +44: dateTimeDigitized
|
||||
const dtdStr = "2024:01:15 11:00:00";
|
||||
for (let i = 0; i < dtdStr.length; i++)
|
||||
writer.writeUint8(dtdStr.charCodeAt(i));
|
||||
writer.writeUint8(0); // null
|
||||
|
||||
const buf = writer.toUint8Array();
|
||||
const exif = readExifFromTiffBuffer(buf);
|
||||
|
||||
expect(exif.iso).toBe(200);
|
||||
expect(exif.exposureTime).toEqual({ numerator: 1, denominator: 250 });
|
||||
expect(exif.fNumber).toEqual({ numerator: 28, denominator: 10 });
|
||||
expect(exif.focalLength).toEqual({ numerator: 50, denominator: 1 });
|
||||
expect(exif.focalLengthIn35mm).toBe(75);
|
||||
expect(exif.flash).toBe(9);
|
||||
expect(exif.whiteBalance).toBe(0);
|
||||
expect(exif.exposureMode).toBe(1);
|
||||
expect(exif.exposureProgram).toBe(2);
|
||||
expect(exif.dateTimeOriginal).toBe(dtoStr);
|
||||
expect(exif.dateTimeDigitized).toBe(dtdStr);
|
||||
});
|
||||
|
||||
it("reads GPS data from GPS sub-IFD", { timeout: 5000 }, () => {
|
||||
// Build TIFF with GPS IFD containing lat/lon/alt/timestamp/datestamp/speed/direction
|
||||
// IFD0: 1 entry (GPS pointer), GPS IFD: 10 entries
|
||||
// Layout:
|
||||
// 8: IFD0 (2 + 1*12 + 4 = 18 bytes) → ends at 26
|
||||
// 26: GPS IFD (2 + 10*12 + 4 = 126 bytes) → ends at 152
|
||||
// data starts at 152
|
||||
//
|
||||
// Data offsets from TIFF start:
|
||||
// 152: lat [40/1, 26/1, 46/100] = 24 bytes
|
||||
// 176: lon [74/1, 0/1, 21/100] = 24 bytes
|
||||
// 200: alt 100/1 = 8 bytes
|
||||
// 208: timestamp [10/1, 30/1, 0/1] = 24 bytes
|
||||
// 232: datestamp "2024:01:15\0" = 11 bytes
|
||||
// 243: speed 25/1 = 8 bytes
|
||||
// 251: direction 270/1 = 8 bytes
|
||||
|
||||
const writer = new ByteWriter(1024, "BE");
|
||||
const GPS_IFD_OFFSET = 26;
|
||||
const DATA = 152;
|
||||
|
||||
// TIFF header
|
||||
writer.writeUint8(0x4d).writeUint8(0x4d);
|
||||
writer.writeUint16(0x002a);
|
||||
writer.writeUint32(8);
|
||||
|
||||
// IFD0
|
||||
writer.writeUint16(1);
|
||||
writer
|
||||
.writeUint16(0x8825)
|
||||
.writeUint16(4)
|
||||
.writeUint32(1)
|
||||
.writeUint32(GPS_IFD_OFFSET);
|
||||
writer.writeUint32(0);
|
||||
|
||||
// GPS IFD at 26: 10 entries
|
||||
writer.writeUint16(10);
|
||||
// 0x0001 GPSLatitudeRef ASCII "N\0" (2 bytes ≤ 4 → inline)
|
||||
writer
|
||||
.writeUint16(0x0001)
|
||||
.writeUint16(2)
|
||||
.writeUint32(2)
|
||||
.writeUint8(0x4e)
|
||||
.writeUint8(0)
|
||||
.writeUint16(0);
|
||||
// 0x0002 GPSLatitude 3 RATIONALs → DATA+0
|
||||
writer
|
||||
.writeUint16(0x0002)
|
||||
.writeUint16(5)
|
||||
.writeUint32(3)
|
||||
.writeUint32(DATA);
|
||||
// 0x0003 GPSLongitudeRef ASCII "W\0" (inline)
|
||||
writer
|
||||
.writeUint16(0x0003)
|
||||
.writeUint16(2)
|
||||
.writeUint32(2)
|
||||
.writeUint8(0x57)
|
||||
.writeUint8(0)
|
||||
.writeUint16(0);
|
||||
// 0x0004 GPSLongitude 3 RATIONALs → DATA+24
|
||||
writer
|
||||
.writeUint16(0x0004)
|
||||
.writeUint16(5)
|
||||
.writeUint32(3)
|
||||
.writeUint32(DATA + 24);
|
||||
// 0x0005 GPSAltitudeRef BYTE=0 (inline)
|
||||
writer
|
||||
.writeUint16(0x0005)
|
||||
.writeUint16(1)
|
||||
.writeUint32(1)
|
||||
.writeUint8(0)
|
||||
.writeUint8(0)
|
||||
.writeUint16(0);
|
||||
// 0x0006 GPSAltitude RATIONAL → DATA+48
|
||||
writer
|
||||
.writeUint16(0x0006)
|
||||
.writeUint16(5)
|
||||
.writeUint32(1)
|
||||
.writeUint32(DATA + 48);
|
||||
// 0x0007 GPSTimestamp 3 RATIONALs → DATA+56
|
||||
writer
|
||||
.writeUint16(0x0007)
|
||||
.writeUint16(5)
|
||||
.writeUint32(3)
|
||||
.writeUint32(DATA + 56);
|
||||
// 0x000d GPSSpeed RATIONAL → DATA+80+11=91 → DATA+91
|
||||
writer
|
||||
.writeUint16(0x000d)
|
||||
.writeUint16(5)
|
||||
.writeUint32(1)
|
||||
.writeUint32(DATA + 91);
|
||||
// 0x0011 GPSDirection RATIONAL → DATA+99
|
||||
writer
|
||||
.writeUint16(0x0011)
|
||||
.writeUint16(5)
|
||||
.writeUint32(1)
|
||||
.writeUint32(DATA + 99);
|
||||
// 0x001d GPSDateStamp ASCII "2024:01:15\0" 11 bytes → DATA+80
|
||||
writer
|
||||
.writeUint16(0x001d)
|
||||
.writeUint16(2)
|
||||
.writeUint32(11)
|
||||
.writeUint32(DATA + 80);
|
||||
writer.writeUint32(0); // next GPS IFD
|
||||
|
||||
// Data area
|
||||
// DATA+0: lat: 40/1, 26/1, 46/100
|
||||
writer.writeUint32(40).writeUint32(1);
|
||||
writer.writeUint32(26).writeUint32(1);
|
||||
writer.writeUint32(46).writeUint32(100);
|
||||
// DATA+24: lon: 74/1, 0/1, 21/100
|
||||
writer.writeUint32(74).writeUint32(1);
|
||||
writer.writeUint32(0).writeUint32(1);
|
||||
writer.writeUint32(21).writeUint32(100);
|
||||
// DATA+48: alt: 100/1
|
||||
writer.writeUint32(100).writeUint32(1);
|
||||
// DATA+56: timestamp: 10/1, 30/1, 0/1
|
||||
writer.writeUint32(10).writeUint32(1);
|
||||
writer.writeUint32(30).writeUint32(1);
|
||||
writer.writeUint32(0).writeUint32(1);
|
||||
// DATA+80: datestamp "2024:01:15\0" (11 bytes)
|
||||
const ds = "2024:01:15";
|
||||
for (let i = 0; i < ds.length; i++) writer.writeUint8(ds.charCodeAt(i));
|
||||
writer.writeUint8(0); // null terminator
|
||||
// DATA+91: speed: 25/1
|
||||
writer.writeUint32(25).writeUint32(1);
|
||||
// DATA+99: direction: 270/1
|
||||
writer.writeUint32(270).writeUint32(1);
|
||||
|
||||
const buf = writer.toUint8Array();
|
||||
const exif = readExifFromTiffBuffer(buf);
|
||||
|
||||
expect(exif.gps).toBeDefined();
|
||||
const gps = exif.gps!;
|
||||
expect(gps.latitude).toBeDefined();
|
||||
expect(gps.latitude?.degrees).toBe(40);
|
||||
expect(gps.latitude?.ref).toBe("N");
|
||||
expect(gps.longitude?.ref).toBe("W");
|
||||
expect(gps.altitudeRef).toBe(0);
|
||||
expect(gps.altitude).toBe(100);
|
||||
expect(gps.timestamp).toBe("10:30:00");
|
||||
expect(gps.datestamp).toBe(ds);
|
||||
expect(gps.speed).toBe(25);
|
||||
expect(gps.direction).toBe(270);
|
||||
});
|
||||
|
||||
it("GPS with invalid ref char is skipped", { timeout: 5000 }, () => {
|
||||
// Build GPS IFD with ref = "X" (invalid) - latitude should be undefined
|
||||
// Layout: header(8) + IFD0(2+12+4=18) → GPS IFD at 26
|
||||
// GPS IFD: 2+2*12+4=30 bytes → data starts at 56
|
||||
const writer = new ByteWriter(512, "BE");
|
||||
const GPS_IFD_OFFSET = 26;
|
||||
const DATA = 56;
|
||||
|
||||
writer.writeUint8(0x4d).writeUint8(0x4d);
|
||||
writer.writeUint16(0x002a);
|
||||
writer.writeUint32(8);
|
||||
|
||||
// IFD0
|
||||
writer.writeUint16(1);
|
||||
writer
|
||||
.writeUint16(0x8825)
|
||||
.writeUint16(4)
|
||||
.writeUint32(1)
|
||||
.writeUint32(GPS_IFD_OFFSET);
|
||||
writer.writeUint32(0);
|
||||
|
||||
// GPS IFD: 2 entries (invalid ref + lat rationals)
|
||||
writer.writeUint16(2);
|
||||
// 0x0001 GPSLatitudeRef = "X\0" inline
|
||||
writer
|
||||
.writeUint16(0x0001)
|
||||
.writeUint16(2)
|
||||
.writeUint32(2)
|
||||
.writeUint8(0x58)
|
||||
.writeUint8(0)
|
||||
.writeUint16(0);
|
||||
// 0x0002 GPSLatitude 3 RATIONALs
|
||||
writer
|
||||
.writeUint16(0x0002)
|
||||
.writeUint16(5)
|
||||
.writeUint32(3)
|
||||
.writeUint32(DATA);
|
||||
writer.writeUint32(0);
|
||||
|
||||
// data
|
||||
writer.writeUint32(40).writeUint32(1);
|
||||
writer.writeUint32(26).writeUint32(1);
|
||||
writer.writeUint32(46).writeUint32(100);
|
||||
|
||||
const exif = readExifFromTiffBuffer(writer.toUint8Array());
|
||||
expect(exif.gps?.latitude).toBeUndefined();
|
||||
});
|
||||
|
||||
it("GPS with missing rationals (< 3 elements) skips coordinate", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Build GPS IFD with lat ref = "N" but only 2 rationals for lat (< 3 needed)
|
||||
// count=2 → getTagRationalArray returns array of 2 → parseGpsCoordinate(arr, "N")
|
||||
// → rationals.length < 3 → return undefined (covers line 52 TRUE branch)
|
||||
// GPS IFD: 2 entries = 2+2*12+4=30 bytes → data at 56
|
||||
const writer = new ByteWriter(512, "BE");
|
||||
const GPS_IFD_OFFSET = 26;
|
||||
const DATA = 56;
|
||||
|
||||
writer.writeUint8(0x4d).writeUint8(0x4d);
|
||||
writer.writeUint16(0x002a);
|
||||
writer.writeUint32(8);
|
||||
|
||||
writer.writeUint16(1);
|
||||
writer
|
||||
.writeUint16(0x8825)
|
||||
.writeUint16(4)
|
||||
.writeUint32(1)
|
||||
.writeUint32(GPS_IFD_OFFSET);
|
||||
writer.writeUint32(0);
|
||||
|
||||
// GPS IFD: 2 entries
|
||||
writer.writeUint16(2);
|
||||
writer
|
||||
.writeUint16(0x0001)
|
||||
.writeUint16(2)
|
||||
.writeUint32(2)
|
||||
.writeUint8(0x4e)
|
||||
.writeUint8(0)
|
||||
.writeUint16(0);
|
||||
// only 2 rationals for lat (count=2 → array of 2 → length < 3)
|
||||
writer
|
||||
.writeUint16(0x0002)
|
||||
.writeUint16(5)
|
||||
.writeUint32(2)
|
||||
.writeUint32(DATA);
|
||||
writer.writeUint32(0);
|
||||
|
||||
writer.writeUint32(40).writeUint32(1);
|
||||
writer.writeUint32(26).writeUint32(1);
|
||||
|
||||
const exif = readExifFromTiffBuffer(writer.toUint8Array());
|
||||
expect(exif.gps?.latitude).toBeUndefined();
|
||||
});
|
||||
|
||||
it("GPS altitudeRef=1 (below sea level)", { timeout: 5000 }, () => {
|
||||
const writer = new ByteWriter(256, "BE");
|
||||
const GPS_IFD_OFFSET = 26;
|
||||
|
||||
writer.writeUint8(0x4d).writeUint8(0x4d);
|
||||
writer.writeUint16(0x002a);
|
||||
writer.writeUint32(8);
|
||||
|
||||
writer.writeUint16(1);
|
||||
writer
|
||||
.writeUint16(0x8825)
|
||||
.writeUint16(4)
|
||||
.writeUint32(1)
|
||||
.writeUint32(GPS_IFD_OFFSET);
|
||||
writer.writeUint32(0);
|
||||
|
||||
writer.writeUint16(1);
|
||||
// 0x0005 GPSAltitudeRef=1 (below sea level)
|
||||
writer
|
||||
.writeUint16(0x0005)
|
||||
.writeUint16(1)
|
||||
.writeUint32(1)
|
||||
.writeUint8(1)
|
||||
.writeUint8(0)
|
||||
.writeUint16(0);
|
||||
writer.writeUint32(0);
|
||||
|
||||
const exif = readExifFromTiffBuffer(writer.toUint8Array());
|
||||
expect(exif.gps?.altitudeRef).toBe(1);
|
||||
});
|
||||
|
||||
it("ExifIFD exists but is empty — undefined branches in ExifIFD block", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// IFD0 has ExifIFD pointer, but ExifIFD has 0 entries
|
||||
// This exercises all the "if (xxx !== undefined)" false branches in the ExifIFD block
|
||||
const writer = new ByteWriter(128, "BE");
|
||||
// IFD0: offset=8, size=2+1*12+4=18 bytes → ExifIFD at 26
|
||||
const EXIF_IFD_OFFSET = 26;
|
||||
|
||||
writer.writeUint8(0x4d).writeUint8(0x4d);
|
||||
writer.writeUint16(0x002a);
|
||||
writer.writeUint32(8); // IFD0 at 8
|
||||
|
||||
// IFD0: 1 entry
|
||||
writer.writeUint16(1);
|
||||
writer
|
||||
.writeUint16(0x8769)
|
||||
.writeUint16(4)
|
||||
.writeUint32(1)
|
||||
.writeUint32(EXIF_IFD_OFFSET);
|
||||
writer.writeUint32(0); // next IFD
|
||||
|
||||
// ExifIFD at 38: 0 entries
|
||||
writer.writeUint16(0);
|
||||
writer.writeUint32(0); // next ExifIFD
|
||||
|
||||
const exif = readExifFromTiffBuffer(writer.toUint8Array());
|
||||
expect(exif.iso).toBeUndefined();
|
||||
expect(exif.exposureTime).toBeUndefined();
|
||||
expect(exif.fNumber).toBeUndefined();
|
||||
expect(exif.focalLength).toBeUndefined();
|
||||
expect(exif.flash).toBeUndefined();
|
||||
});
|
||||
|
||||
it("GPS coordinate rationals with zero denominator (degrees/minutes/seconds : 0 branch)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// GPS lat/lon/timestamp rationals with denominator=0 → ternary takes ': 0' branch
|
||||
// Layout: header(8) + IFD0(2+1*12+4=18→26)
|
||||
// GPS IFD: 5 entries = 2+5*12+4=66 bytes → data at 26+66=92
|
||||
const writer = new ByteWriter(512, "BE");
|
||||
const GPS_IFD_OFFSET = 26;
|
||||
const DATA = 92;
|
||||
|
||||
writer.writeUint8(0x4d).writeUint8(0x4d);
|
||||
writer.writeUint16(0x002a);
|
||||
writer.writeUint32(8);
|
||||
|
||||
writer.writeUint16(1);
|
||||
writer
|
||||
.writeUint16(0x8825)
|
||||
.writeUint16(4)
|
||||
.writeUint32(1)
|
||||
.writeUint32(GPS_IFD_OFFSET);
|
||||
writer.writeUint32(0);
|
||||
|
||||
// GPS IFD: 5 entries: lat ref, lat (zero denom), lon ref (invalid "X"), lon rationals, timestamp (zero denom)
|
||||
writer.writeUint16(5);
|
||||
// lat ref "N" inline
|
||||
writer
|
||||
.writeUint16(0x0001)
|
||||
.writeUint16(2)
|
||||
.writeUint32(2)
|
||||
.writeUint8(0x4e)
|
||||
.writeUint8(0)
|
||||
.writeUint16(0);
|
||||
// lat rationals: 3 × 8 bytes with denominator=0 → DATA
|
||||
writer
|
||||
.writeUint16(0x0002)
|
||||
.writeUint16(5)
|
||||
.writeUint32(3)
|
||||
.writeUint32(DATA);
|
||||
// lon ref invalid "X" → parseGpsCoordinate returns undefined (covers line 97 false branch)
|
||||
writer
|
||||
.writeUint16(0x0003)
|
||||
.writeUint16(2)
|
||||
.writeUint32(2)
|
||||
.writeUint8(0x58)
|
||||
.writeUint8(0)
|
||||
.writeUint16(0);
|
||||
// lon rationals: 3 × 8 bytes → DATA+24 (needed so lonRationals is defined, entering the if block)
|
||||
writer
|
||||
.writeUint16(0x0004)
|
||||
.writeUint16(5)
|
||||
.writeUint32(3)
|
||||
.writeUint32(DATA + 24);
|
||||
// timestamp: 3 rationals with denom=0 → DATA+48
|
||||
writer
|
||||
.writeUint16(0x0007)
|
||||
.writeUint16(5)
|
||||
.writeUint32(3)
|
||||
.writeUint32(DATA + 48);
|
||||
writer.writeUint32(0); // next GPS IFD
|
||||
|
||||
// Data: lat 40/0, 26/0, 46/0 (zero denominators → ': 0' ternary path)
|
||||
writer.writeUint32(40).writeUint32(0);
|
||||
writer.writeUint32(26).writeUint32(0);
|
||||
writer.writeUint32(46).writeUint32(0);
|
||||
// lon 0/1, 0/1, 0/1 (normal but lon ref is invalid → lonCoord undefined)
|
||||
writer.writeUint32(0).writeUint32(1);
|
||||
writer.writeUint32(0).writeUint32(1);
|
||||
writer.writeUint32(0).writeUint32(1);
|
||||
// timestamp: 10/0, 30/0, 0/0 (zero denom)
|
||||
writer.writeUint32(10).writeUint32(0);
|
||||
writer.writeUint32(30).writeUint32(0);
|
||||
writer.writeUint32(0).writeUint32(0);
|
||||
|
||||
const exif = readExifFromTiffBuffer(writer.toUint8Array());
|
||||
// Lat: all degrees/minutes/seconds = 0 (zero denominator)
|
||||
expect(exif.gps?.latitude?.degrees).toBe(0);
|
||||
expect(exif.gps?.latitude?.minutes).toBe(0);
|
||||
expect(exif.gps?.latitude?.seconds).toBe(0);
|
||||
// Lon: invalid ref "X" → lonCoord undefined → gps.longitude stays undefined (line 97 false)
|
||||
expect(exif.gps?.longitude).toBeUndefined();
|
||||
});
|
||||
|
||||
it("GPS timestamp with only 2 rationals (parseGpsTimestamp returns undefined — line 114 false)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// timestamp IFD entry with count=2 → parseGpsTimestamp returns undefined → if(ts) false
|
||||
const writer = new ByteWriter(256, "BE");
|
||||
const GPS_IFD_OFFSET = 26;
|
||||
// GPS IFD: 2 entries = 2+2*12+4=30 bytes, offset 26, ends at 56 → data at 56
|
||||
const GPS_DATA = 56;
|
||||
writer.writeUint8(0x4d).writeUint8(0x4d);
|
||||
writer.writeUint16(0x002a);
|
||||
writer.writeUint32(8);
|
||||
|
||||
writer.writeUint16(1);
|
||||
writer
|
||||
.writeUint16(0x8825)
|
||||
.writeUint16(4)
|
||||
.writeUint32(1)
|
||||
.writeUint32(GPS_IFD_OFFSET);
|
||||
writer.writeUint32(0);
|
||||
|
||||
// GPS IFD: 2 entries (lat ref + timestamp with only 2 rationals)
|
||||
writer.writeUint16(2);
|
||||
// lat ref "N" inline
|
||||
writer
|
||||
.writeUint16(0x0001)
|
||||
.writeUint16(2)
|
||||
.writeUint32(2)
|
||||
.writeUint8(0x4e)
|
||||
.writeUint8(0)
|
||||
.writeUint16(0);
|
||||
// timestamp with count=2 → parseGpsTimestamp returns undefined
|
||||
writer
|
||||
.writeUint16(0x0007)
|
||||
.writeUint16(5)
|
||||
.writeUint32(2)
|
||||
.writeUint32(GPS_DATA);
|
||||
writer.writeUint32(0);
|
||||
|
||||
// 2 timestamp rationals
|
||||
writer.writeUint32(10).writeUint32(1);
|
||||
writer.writeUint32(30).writeUint32(1);
|
||||
|
||||
const exif = readExifFromTiffBuffer(writer.toUint8Array());
|
||||
// timestamp should be undefined since only 2 rationals (< 3)
|
||||
expect(exif.gps?.timestamp).toBeUndefined();
|
||||
});
|
||||
|
||||
it("GPS with zero denominators in rationals (altitude/speed/direction : 0 branch)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Build GPS IFD with altitude, speed, direction rationals having denominator=0
|
||||
// Exercises the ternary '... : 0' branches
|
||||
const writer = new ByteWriter(512, "BE");
|
||||
const GPS_IFD_OFFSET = 26;
|
||||
// GPS IFD: 3 entries (alt, speed, direction) = 2+3*12+4 = 42 bytes → data at 68
|
||||
const DATA = 68;
|
||||
|
||||
writer.writeUint8(0x4d).writeUint8(0x4d);
|
||||
writer.writeUint16(0x002a);
|
||||
writer.writeUint32(8);
|
||||
|
||||
// IFD0: GPS pointer
|
||||
writer.writeUint16(1);
|
||||
writer
|
||||
.writeUint16(0x8825)
|
||||
.writeUint16(4)
|
||||
.writeUint32(1)
|
||||
.writeUint32(GPS_IFD_OFFSET);
|
||||
writer.writeUint32(0);
|
||||
|
||||
// GPS IFD at 26: 3 entries
|
||||
writer.writeUint16(3);
|
||||
// 0x0006 GPSAltitude RATIONAL → DATA (denom=0)
|
||||
writer
|
||||
.writeUint16(0x0006)
|
||||
.writeUint16(5)
|
||||
.writeUint32(1)
|
||||
.writeUint32(DATA);
|
||||
// 0x000d GPSSpeed RATIONAL → DATA+8 (denom=0)
|
||||
writer
|
||||
.writeUint16(0x000d)
|
||||
.writeUint16(5)
|
||||
.writeUint32(1)
|
||||
.writeUint32(DATA + 8);
|
||||
// 0x0011 GPSDirection RATIONAL → DATA+16 (denom=0)
|
||||
writer
|
||||
.writeUint16(0x0011)
|
||||
.writeUint16(5)
|
||||
.writeUint32(1)
|
||||
.writeUint32(DATA + 16);
|
||||
writer.writeUint32(0); // next GPS IFD
|
||||
|
||||
// Data: altitude 1/0, speed 2/0, direction 3/0
|
||||
writer.writeUint32(1).writeUint32(0); // altitude: 1/0 → 0
|
||||
writer.writeUint32(2).writeUint32(0); // speed: 2/0 → 0
|
||||
writer.writeUint32(3).writeUint32(0); // direction: 3/0 → 0
|
||||
|
||||
const exif = readExifFromTiffBuffer(writer.toUint8Array());
|
||||
expect(exif.gps?.altitude).toBe(0);
|
||||
expect(exif.gps?.speed).toBe(0);
|
||||
expect(exif.gps?.direction).toBe(0);
|
||||
});
|
||||
|
||||
it("GPS datestamp tag is set", { timeout: 5000 }, () => {
|
||||
const writer = new ByteWriter(512, "BE");
|
||||
const GPS_IFD_OFFSET = 26;
|
||||
// GPS IFD: offset=26, 2+1*12+4=18 bytes → data at 44
|
||||
const DATA = 44;
|
||||
|
||||
writer.writeUint8(0x4d).writeUint8(0x4d);
|
||||
writer.writeUint16(0x002a);
|
||||
writer.writeUint32(8);
|
||||
|
||||
writer.writeUint16(1);
|
||||
writer
|
||||
.writeUint16(0x8825)
|
||||
.writeUint16(4)
|
||||
.writeUint32(1)
|
||||
.writeUint32(GPS_IFD_OFFSET);
|
||||
writer.writeUint32(0);
|
||||
|
||||
writer.writeUint16(1);
|
||||
// 0x001d GPSDateStamp ASCII "2024:06:01\0" 11 bytes
|
||||
writer
|
||||
.writeUint16(0x001d)
|
||||
.writeUint16(2)
|
||||
.writeUint32(11)
|
||||
.writeUint32(DATA);
|
||||
writer.writeUint32(0);
|
||||
|
||||
const dsStr = "2024:06:01";
|
||||
for (let i = 0; i < dsStr.length; i++)
|
||||
writer.writeUint8(dsStr.charCodeAt(i));
|
||||
writer.writeUint8(0);
|
||||
|
||||
const exif = readExifFromTiffBuffer(writer.toUint8Array());
|
||||
expect(exif.gps?.datestamp).toBe(dsStr);
|
||||
});
|
||||
});
|
||||
264
packages/media/image/exif/src/exif/read-exif.ts
Normal file
264
packages/media/image/exif/src/exif/read-exif.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import { ByteReader } from "../binary/reader/byte-reader.js";
|
||||
import { EXIF_CODES, ExifError } from "../errors/exif-error.js";
|
||||
import {
|
||||
getTagNumber,
|
||||
getTagRational,
|
||||
getTagRationalArray,
|
||||
getTagString,
|
||||
parseIfd,
|
||||
} from "../ifd/parse.js";
|
||||
import type { ExifData, GpsCoordinate, GpsData, Rational } from "../types.js";
|
||||
|
||||
// IFD0 tags
|
||||
const TAG_IMAGE_WIDTH = 0x0100;
|
||||
const TAG_IMAGE_HEIGHT = 0x0101;
|
||||
const TAG_MAKE = 0x010f;
|
||||
const TAG_MODEL = 0x0110;
|
||||
const TAG_ORIENTATION = 0x0112;
|
||||
const TAG_SOFTWARE = 0x0131;
|
||||
const TAG_DATETIME = 0x0132;
|
||||
const TAG_EXIF_IFD = 0x8769;
|
||||
const TAG_GPS_IFD = 0x8825;
|
||||
|
||||
// ExifIFD tags
|
||||
const TAG_EXPOSURE_TIME = 0x829a;
|
||||
const TAG_F_NUMBER = 0x829d;
|
||||
const TAG_EXPOSURE_PROGRAM = 0x8822;
|
||||
const TAG_ISO = 0x8827;
|
||||
const TAG_DATETIME_ORIGINAL = 0x9003;
|
||||
const TAG_DATETIME_DIGITIZED = 0x9004;
|
||||
const TAG_EXPOSURE_MODE = 0xa402;
|
||||
const TAG_WHITE_BALANCE = 0xa403;
|
||||
const TAG_FOCAL_LENGTH_35MM = 0xa405;
|
||||
const TAG_FLASH = 0x9209;
|
||||
const TAG_FOCAL_LENGTH = 0x920a;
|
||||
|
||||
// GPS IFD tags
|
||||
const TAG_GPS_LAT_REF = 0x0001;
|
||||
const TAG_GPS_LAT = 0x0002;
|
||||
const TAG_GPS_LON_REF = 0x0003;
|
||||
const TAG_GPS_LON = 0x0004;
|
||||
const TAG_GPS_ALT_REF = 0x0005;
|
||||
const TAG_GPS_ALT = 0x0006;
|
||||
const TAG_GPS_TIMESTAMP = 0x0007;
|
||||
const TAG_GPS_DATESTAMP = 0x001d;
|
||||
const TAG_GPS_SPEED = 0x000d;
|
||||
const TAG_GPS_DIRECTION = 0x0011;
|
||||
|
||||
function parseGpsCoordinate(
|
||||
rationals: Rational[],
|
||||
ref: string
|
||||
): GpsCoordinate | undefined {
|
||||
if (rationals.length < 3) return undefined;
|
||||
// All three are guaranteed by the length check above
|
||||
const deg = rationals[0] as Rational;
|
||||
const min = rationals[1] as Rational;
|
||||
const sec = rationals[2] as Rational;
|
||||
const refChar = ref.trim().toUpperCase();
|
||||
if (
|
||||
refChar !== "N" &&
|
||||
refChar !== "S" &&
|
||||
refChar !== "E" &&
|
||||
refChar !== "W"
|
||||
)
|
||||
return undefined;
|
||||
|
||||
return {
|
||||
degrees: deg.denominator !== 0 ? deg.numerator / deg.denominator : 0,
|
||||
minutes: min.denominator !== 0 ? min.numerator / min.denominator : 0,
|
||||
seconds: sec.denominator !== 0 ? sec.numerator / sec.denominator : 0,
|
||||
ref: refChar as "N" | "S" | "E" | "W",
|
||||
};
|
||||
}
|
||||
|
||||
function parseGpsTimestamp(rationals: Rational[]): string | undefined {
|
||||
if (rationals.length < 3) return undefined;
|
||||
const parts = rationals.map((r) => {
|
||||
const v =
|
||||
r.denominator !== 0 ? Math.round(r.numerator / r.denominator) : 0;
|
||||
return String(v).padStart(2, "0");
|
||||
});
|
||||
return parts.join(":");
|
||||
}
|
||||
|
||||
function parseGpsData(reader: ByteReader, gpsOffset: number): GpsData {
|
||||
const gpsIfd = parseIfd(reader, gpsOffset);
|
||||
const gps: GpsData = {};
|
||||
|
||||
const latRef = getTagString(gpsIfd, TAG_GPS_LAT_REF);
|
||||
const latRationals = getTagRationalArray(gpsIfd, TAG_GPS_LAT);
|
||||
if (latRef && latRationals) {
|
||||
const latCoord = parseGpsCoordinate(latRationals, latRef);
|
||||
if (latCoord !== undefined) gps.latitude = latCoord;
|
||||
}
|
||||
|
||||
const lonRef = getTagString(gpsIfd, TAG_GPS_LON_REF);
|
||||
const lonRationals = getTagRationalArray(gpsIfd, TAG_GPS_LON);
|
||||
if (lonRef && lonRationals) {
|
||||
const lonCoord = parseGpsCoordinate(lonRationals, lonRef);
|
||||
if (lonCoord !== undefined) gps.longitude = lonCoord;
|
||||
}
|
||||
|
||||
const altRef = getTagNumber(gpsIfd, TAG_GPS_ALT_REF);
|
||||
if (altRef === 0 || altRef === 1) gps.altitudeRef = altRef;
|
||||
|
||||
const altRational = getTagRational(gpsIfd, TAG_GPS_ALT);
|
||||
if (altRational) {
|
||||
gps.altitude =
|
||||
altRational.denominator !== 0
|
||||
? altRational.numerator / altRational.denominator
|
||||
: 0;
|
||||
}
|
||||
|
||||
const tsRationals = getTagRationalArray(gpsIfd, TAG_GPS_TIMESTAMP);
|
||||
if (tsRationals) {
|
||||
const ts = parseGpsTimestamp(tsRationals);
|
||||
if (ts !== undefined) gps.timestamp = ts;
|
||||
}
|
||||
|
||||
const datestamp = getTagString(gpsIfd, TAG_GPS_DATESTAMP);
|
||||
if (datestamp !== undefined) gps.datestamp = datestamp;
|
||||
|
||||
const speedRational = getTagRational(gpsIfd, TAG_GPS_SPEED);
|
||||
if (speedRational) {
|
||||
gps.speed =
|
||||
speedRational.denominator !== 0
|
||||
? speedRational.numerator / speedRational.denominator
|
||||
: 0;
|
||||
}
|
||||
|
||||
const dirRational = getTagRational(gpsIfd, TAG_GPS_DIRECTION);
|
||||
if (dirRational) {
|
||||
gps.direction =
|
||||
dirRational.denominator !== 0
|
||||
? dirRational.numerator / dirRational.denominator
|
||||
: 0;
|
||||
}
|
||||
|
||||
return gps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads EXIF metadata from a raw TIFF buffer.
|
||||
*
|
||||
* The buffer must start at the TIFF header (byte order mark + 0x002A magic).
|
||||
* This is typically the APP1 data of a JPEG after stripping the "Exif\0\0" header.
|
||||
*/
|
||||
export function readExifFromTiffBuffer(buffer: Uint8Array): ExifData {
|
||||
if (buffer.length < 8) {
|
||||
throw new ExifError("TIFF buffer too short", EXIF_CODES.PARSE_ERROR);
|
||||
}
|
||||
|
||||
const bom = ((buffer[0] as number) << 8) | (buffer[1] as number);
|
||||
let byteOrder: "BE" | "LE";
|
||||
|
||||
if (bom === 0x4d4d) {
|
||||
byteOrder = "BE";
|
||||
} else if (bom === 0x4949) {
|
||||
byteOrder = "LE";
|
||||
} else {
|
||||
throw new ExifError(
|
||||
`Invalid TIFF byte order mark: 0x${bom.toString(16).padStart(4, "0")}`,
|
||||
EXIF_CODES.PARSE_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
const reader = new ByteReader(buffer, byteOrder);
|
||||
|
||||
const magic = reader.readUint16(2);
|
||||
if (magic !== 0x002a) {
|
||||
throw new ExifError(
|
||||
`Invalid TIFF magic: 0x${magic.toString(16).padStart(4, "0")}`,
|
||||
EXIF_CODES.PARSE_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
const ifd0Offset = reader.readUint32(4);
|
||||
const ifd0 = parseIfd(reader, ifd0Offset);
|
||||
|
||||
const exif: ExifData = {};
|
||||
const rawTags: Record<number, unknown> = {};
|
||||
|
||||
// Collect all tags into rawTags
|
||||
for (const [tag, entry] of ifd0) {
|
||||
rawTags[tag] = entry.value;
|
||||
}
|
||||
|
||||
// Map standard IFD0 tags
|
||||
const make = getTagString(ifd0, TAG_MAKE);
|
||||
if (make !== undefined) exif.make = make;
|
||||
|
||||
const model = getTagString(ifd0, TAG_MODEL);
|
||||
if (model !== undefined) exif.model = model;
|
||||
|
||||
const software = getTagString(ifd0, TAG_SOFTWARE);
|
||||
if (software !== undefined) exif.software = software;
|
||||
|
||||
const dateTime = getTagString(ifd0, TAG_DATETIME);
|
||||
if (dateTime !== undefined) exif.dateTime = dateTime;
|
||||
|
||||
const orientation = getTagNumber(ifd0, TAG_ORIENTATION);
|
||||
if (orientation !== undefined) exif.orientation = orientation;
|
||||
|
||||
const imageWidth = getTagNumber(ifd0, TAG_IMAGE_WIDTH);
|
||||
if (imageWidth !== undefined) exif.imageWidth = imageWidth;
|
||||
|
||||
const imageHeight = getTagNumber(ifd0, TAG_IMAGE_HEIGHT);
|
||||
if (imageHeight !== undefined) exif.imageHeight = imageHeight;
|
||||
|
||||
// Follow ExifIFD pointer
|
||||
const exifIfdOffset = getTagNumber(ifd0, TAG_EXIF_IFD);
|
||||
if (exifIfdOffset !== undefined && exifIfdOffset > 0) {
|
||||
const exifIfd = parseIfd(reader, exifIfdOffset);
|
||||
|
||||
for (const [tag, entry] of exifIfd) {
|
||||
rawTags[tag] = entry.value;
|
||||
}
|
||||
|
||||
const exposureTime = getTagRational(exifIfd, TAG_EXPOSURE_TIME);
|
||||
if (exposureTime !== undefined) exif.exposureTime = exposureTime;
|
||||
|
||||
const fNumber = getTagRational(exifIfd, TAG_F_NUMBER);
|
||||
if (fNumber !== undefined) exif.fNumber = fNumber;
|
||||
|
||||
const iso = getTagNumber(exifIfd, TAG_ISO);
|
||||
if (iso !== undefined) exif.iso = iso;
|
||||
|
||||
const focalLength = getTagRational(exifIfd, TAG_FOCAL_LENGTH);
|
||||
if (focalLength !== undefined) exif.focalLength = focalLength;
|
||||
|
||||
const focalLength35mm = getTagNumber(exifIfd, TAG_FOCAL_LENGTH_35MM);
|
||||
if (focalLength35mm !== undefined)
|
||||
exif.focalLengthIn35mm = focalLength35mm;
|
||||
|
||||
const flash = getTagNumber(exifIfd, TAG_FLASH);
|
||||
if (flash !== undefined) exif.flash = flash;
|
||||
|
||||
const whiteBalance = getTagNumber(exifIfd, TAG_WHITE_BALANCE);
|
||||
if (whiteBalance !== undefined) exif.whiteBalance = whiteBalance;
|
||||
|
||||
const exposureMode = getTagNumber(exifIfd, TAG_EXPOSURE_MODE);
|
||||
if (exposureMode !== undefined) exif.exposureMode = exposureMode;
|
||||
|
||||
const exposureProgram = getTagNumber(exifIfd, TAG_EXPOSURE_PROGRAM);
|
||||
if (exposureProgram !== undefined)
|
||||
exif.exposureProgram = exposureProgram;
|
||||
|
||||
const dateTimeOriginal = getTagString(exifIfd, TAG_DATETIME_ORIGINAL);
|
||||
if (dateTimeOriginal !== undefined)
|
||||
exif.dateTimeOriginal = dateTimeOriginal;
|
||||
|
||||
const dateTimeDigitized = getTagString(exifIfd, TAG_DATETIME_DIGITIZED);
|
||||
if (dateTimeDigitized !== undefined)
|
||||
exif.dateTimeDigitized = dateTimeDigitized;
|
||||
}
|
||||
|
||||
// Follow GPS IFD pointer
|
||||
const gpsIfdOffset = getTagNumber(ifd0, TAG_GPS_IFD);
|
||||
if (gpsIfdOffset !== undefined && gpsIfdOffset > 0) {
|
||||
exif.gps = parseGpsData(reader, gpsIfdOffset);
|
||||
}
|
||||
|
||||
exif.rawTags = rawTags;
|
||||
return exif;
|
||||
}
|
||||
66
packages/media/image/exif/src/format/detect.md
Normal file
66
packages/media/image/exif/src/format/detect.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# detectImageFormat
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Identifies the image format of a raw byte buffer by inspecting its leading magic bytes. This is the entry point for all metadata operations in `@suaveplan/exif`: the correct parser is selected based on the detected format, so detection must be reliable before any other processing occurs.
|
||||
|
||||
The implementation avoids file extensions and MIME type strings — it reads only the binary signature, which is the only trustworthy signal for format identification.
|
||||
|
||||
## Features
|
||||
|
||||
- Detects JPEG (3-byte signature), PNG (8-byte signature), WebP (12-byte RIFF signature), and TIFF (both big-endian and little-endian variants)
|
||||
- Returns `'unknown'` rather than throwing for unrecognised formats
|
||||
- Zero dependencies — pure TypeScript with no external imports
|
||||
- Handles buffers shorter than the minimum signature length gracefully
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { detectImageFormat } from "@suaveplan/exif/format";
|
||||
|
||||
const jpegBytes = new Uint8Array([0xFF, 0xD8, 0xFF, 0xE0, ...]);
|
||||
console.log(detectImageFormat(jpegBytes)); // "jpeg"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { detectImageFormat } from "@suaveplan/exif/format";
|
||||
import { readMetadata } from "@suaveplan/exif";
|
||||
|
||||
async function processImage(buffer: Uint8Array) {
|
||||
const format = detectImageFormat(buffer);
|
||||
|
||||
switch (format) {
|
||||
case "jpeg":
|
||||
case "png":
|
||||
return readMetadata(buffer);
|
||||
case "webp":
|
||||
case "tiff":
|
||||
throw new Error(`Format ${format} metadata reading not yet supported`);
|
||||
case "unknown":
|
||||
throw new Error("Unrecognised image format");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `detectImageFormat(buffer: Uint8Array): ImageFormat`
|
||||
|
||||
Inspects the leading bytes of `buffer` and returns one of:
|
||||
|
||||
- `"jpeg"` — buffer starts with `FF D8 FF`
|
||||
- `"png"` — buffer starts with `89 50 4E 47 0D 0A 1A 0A`
|
||||
- `"webp"` — buffer starts with `RIFF` and bytes 8–11 are `WEBP`
|
||||
- `"tiff"` — buffer starts with `49 49 2A 00` (LE) or `4D 4D 00 2A` (BE)
|
||||
- `"unknown"` — none of the above match, or buffer is shorter than 4 bytes
|
||||
|
||||
Does not throw. All inputs are handled.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The magic-byte checks are ordered by specificity: JPEG and PNG checks come first because they are the primary supported formats. WebP requires a 12-byte buffer to read both the RIFF header and the WEBP brand. TIFF detection handles both byte orders since TIFF files start with their own byte-order mark. The function returns `"unknown"` for buffers shorter than 4 bytes rather than attempting a partial match, to avoid false positives on truncated data.
|
||||
63
packages/media/image/exif/src/format/detect.test.ts
Normal file
63
packages/media/image/exif/src/format/detect.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { detectImageFormat } from "./detect.js";
|
||||
|
||||
describe("detectImageFormat", () => {
|
||||
it("detects JPEG", { timeout: 5000 }, () => {
|
||||
const buf = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]);
|
||||
expect(detectImageFormat(buf)).toBe("jpeg");
|
||||
});
|
||||
|
||||
it("detects PNG", { timeout: 5000 }, () => {
|
||||
const buf = new Uint8Array([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||
]);
|
||||
expect(detectImageFormat(buf)).toBe("png");
|
||||
});
|
||||
|
||||
it("detects WebP", { timeout: 5000 }, () => {
|
||||
const buf = new Uint8Array([
|
||||
0x52,
|
||||
0x49,
|
||||
0x46,
|
||||
0x46, // RIFF
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // file size
|
||||
0x57,
|
||||
0x45,
|
||||
0x42,
|
||||
0x50, // WEBP
|
||||
]);
|
||||
expect(detectImageFormat(buf)).toBe("webp");
|
||||
});
|
||||
|
||||
it("detects TIFF little-endian", { timeout: 5000 }, () => {
|
||||
const buf = new Uint8Array([0x49, 0x49, 0x2a, 0x00]);
|
||||
expect(detectImageFormat(buf)).toBe("tiff");
|
||||
});
|
||||
|
||||
it("detects TIFF big-endian", { timeout: 5000 }, () => {
|
||||
const buf = new Uint8Array([0x4d, 0x4d, 0x00, 0x2a]);
|
||||
expect(detectImageFormat(buf)).toBe("tiff");
|
||||
});
|
||||
|
||||
it("returns unknown for empty buffer", { timeout: 5000 }, () => {
|
||||
expect(detectImageFormat(new Uint8Array(0))).toBe("unknown");
|
||||
});
|
||||
|
||||
it("returns unknown for short buffer", { timeout: 5000 }, () => {
|
||||
expect(detectImageFormat(new Uint8Array([0xff, 0xd8]))).toBe("unknown");
|
||||
});
|
||||
|
||||
it("returns unknown for unrecognised format", { timeout: 5000 }, () => {
|
||||
const buf = new Uint8Array([0x00, 0x01, 0x02, 0x03]);
|
||||
expect(detectImageFormat(buf)).toBe("unknown");
|
||||
});
|
||||
|
||||
it("returns unknown for 3-byte buffer", { timeout: 5000 }, () => {
|
||||
expect(detectImageFormat(new Uint8Array([0x89, 0x50, 0x4e]))).toBe(
|
||||
"unknown"
|
||||
);
|
||||
});
|
||||
});
|
||||
67
packages/media/image/exif/src/format/detect.ts
Normal file
67
packages/media/image/exif/src/format/detect.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { ImageFormat } from "../types.js";
|
||||
|
||||
/**
|
||||
* Detects the image format of a buffer by inspecting magic bytes.
|
||||
*
|
||||
* Supports JPEG, PNG, WebP, and TIFF. Returns 'unknown' for unrecognised formats.
|
||||
*/
|
||||
export function detectImageFormat(buffer: Uint8Array): ImageFormat {
|
||||
if (buffer.length < 4) return "unknown";
|
||||
|
||||
// JPEG: FF D8 FF
|
||||
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
|
||||
return "jpeg";
|
||||
}
|
||||
|
||||
// PNG: 89 50 4E 47 0D 0A 1A 0A
|
||||
if (
|
||||
buffer.length >= 8 &&
|
||||
buffer[0] === 0x89 &&
|
||||
buffer[1] === 0x50 &&
|
||||
buffer[2] === 0x4e &&
|
||||
buffer[3] === 0x47 &&
|
||||
buffer[4] === 0x0d &&
|
||||
buffer[5] === 0x0a &&
|
||||
buffer[6] === 0x1a &&
|
||||
buffer[7] === 0x0a
|
||||
) {
|
||||
return "png";
|
||||
}
|
||||
|
||||
// WebP: "RIFF" at [0..3] + "WEBP" at [8..11]
|
||||
if (
|
||||
buffer.length >= 12 &&
|
||||
buffer[0] === 0x52 && // R
|
||||
buffer[1] === 0x49 && // I
|
||||
buffer[2] === 0x46 && // F
|
||||
buffer[3] === 0x46 && // F
|
||||
buffer[8] === 0x57 && // W
|
||||
buffer[9] === 0x45 && // E
|
||||
buffer[10] === 0x42 && // B
|
||||
buffer[11] === 0x50 // P
|
||||
) {
|
||||
return "webp";
|
||||
}
|
||||
|
||||
// TIFF little-endian: 49 49 2A 00
|
||||
if (
|
||||
buffer[0] === 0x49 &&
|
||||
buffer[1] === 0x49 &&
|
||||
buffer[2] === 0x2a &&
|
||||
buffer[3] === 0x00
|
||||
) {
|
||||
return "tiff";
|
||||
}
|
||||
|
||||
// TIFF big-endian: 4D 4D 00 2A
|
||||
if (
|
||||
buffer[0] === 0x4d &&
|
||||
buffer[1] === 0x4d &&
|
||||
buffer[2] === 0x00 &&
|
||||
buffer[3] === 0x2a
|
||||
) {
|
||||
return "tiff";
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
1
packages/media/image/exif/src/format/index.ts
Normal file
1
packages/media/image/exif/src/format/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { detectImageFormat } from "./detect";
|
||||
8
packages/media/image/exif/src/ifd/index.ts
Normal file
8
packages/media/image/exif/src/ifd/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
getTagNumber,
|
||||
getTagRational,
|
||||
getTagRationalArray,
|
||||
getTagString,
|
||||
type IfdEntry,
|
||||
parseIfd,
|
||||
} from "./parse";
|
||||
86
packages/media/image/exif/src/ifd/parse.md
Normal file
86
packages/media/image/exif/src/ifd/parse.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# IFD Parse
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Implements TIFF IFD (Image File Directory) parsing, the core data structure used by EXIF to store metadata. Each IFD entry is a 12-byte record containing a tag number, data type, count, and either an inline value or an offset to data elsewhere in the TIFF stream. This module handles all 12 standard TIFF data types and provides typed accessor helpers for downstream EXIF, GPS, and sub-IFD readers.
|
||||
|
||||
## Features
|
||||
|
||||
- Parses all 12 TIFF primitive types: BYTE, ASCII, SHORT, LONG, RATIONAL, SBYTE, UNDEFINED, SSHORT, SLONG, SRATIONAL, FLOAT, DOUBLE
|
||||
- Inline vs. offset-stored value selection based on total data size
|
||||
- Graceful handling of malformed entries (skips rather than throwing)
|
||||
- Typed accessors: `getTagNumber`, `getTagString`, `getTagRational`, `getTagRationalArray`
|
||||
- Bounds-checked with `ExifError(PARSE_ERROR)` on structural violations
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { ByteReader } from "@suaveplan/exif/binary";
|
||||
import { parseIfd, getTagString } from "@suaveplan/exif";
|
||||
|
||||
const reader = new ByteReader(tiffBuffer, "BE");
|
||||
const ifd0 = parseIfd(reader, 8);
|
||||
|
||||
const make = getTagString(ifd0, 0x010F); // Make tag
|
||||
const model = getTagString(ifd0, 0x0110); // Model tag
|
||||
console.log(`${make} ${model}`);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { ByteReader } from "@suaveplan/exif/binary";
|
||||
import { parseIfd, getTagNumber, getTagRational } from "@suaveplan/exif";
|
||||
|
||||
const reader = new ByteReader(tiffBuffer, "LE");
|
||||
|
||||
// Parse IFD0 then follow ExifIFD pointer
|
||||
const ifd0 = parseIfd(reader, 8);
|
||||
const exifIfdOffset = getTagNumber(ifd0, 0x8769); // ExifIFD pointer
|
||||
|
||||
if (exifIfdOffset !== undefined) {
|
||||
const exifIfd = parseIfd(reader, exifIfdOffset);
|
||||
const exposureTime = getTagRational(exifIfd, 0x829A);
|
||||
const fNumber = getTagRational(exifIfd, 0x829D);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `IfdEntry`
|
||||
|
||||
```ts
|
||||
interface IfdEntry {
|
||||
tag: number;
|
||||
type: number;
|
||||
count: number;
|
||||
value: unknown;
|
||||
}
|
||||
```
|
||||
|
||||
### `parseIfd(reader: ByteReader, ifdOffset: number): Map<number, IfdEntry>`
|
||||
|
||||
Parses the IFD at `ifdOffset` within `reader`. Returns a `Map<tag, IfdEntry>`. Throws `ExifError(PARSE_ERROR)` if the IFD count field extends beyond the buffer. Malformed individual entries are silently skipped.
|
||||
|
||||
### `getTagNumber(entries: Map<number, IfdEntry>, tag: number): number | undefined`
|
||||
|
||||
Extracts a SHORT or LONG tag as a number.
|
||||
|
||||
### `getTagString(entries: Map<number, IfdEntry>, tag: number): string | undefined`
|
||||
|
||||
Extracts an ASCII tag as a string.
|
||||
|
||||
### `getTagRational(entries: Map<number, IfdEntry>, tag: number): Rational | undefined`
|
||||
|
||||
Extracts a single RATIONAL or SRATIONAL tag.
|
||||
|
||||
### `getTagRationalArray(entries: Map<number, IfdEntry>, tag: number): Rational[] | undefined`
|
||||
|
||||
Extracts an array of RATIONAL values (e.g. GPS coordinate triples where each axis is three RATIONALs for degrees/minutes/seconds).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Inline storage applies when `typeSize * count <= 4`: the value is packed into the 4-byte value/offset field of the IFD entry. For larger values, the field contains a file offset to the actual data. ASCII strings have trailing nulls stripped. The `UNDEFINED` type (7) returns a raw `Uint8Array`. Sign extension for SBYTE uses the left-shift/right-shift trick `<< 24 >> 24`.
|
||||
433
packages/media/image/exif/src/ifd/parse.test.ts
Normal file
433
packages/media/image/exif/src/ifd/parse.test.ts
Normal file
@@ -0,0 +1,433 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { ByteReader } from "../binary/reader/byte-reader.js";
|
||||
import { ByteWriter } from "../binary/writer/byte-writer.js";
|
||||
import { ExifError } from "../errors/exif-error.js";
|
||||
import {
|
||||
getTagNumber,
|
||||
getTagRational,
|
||||
getTagRationalArray,
|
||||
getTagString,
|
||||
parseIfd,
|
||||
} from "./parse.js";
|
||||
|
||||
/**
|
||||
* Builds a minimal TIFF IFD buffer for testing.
|
||||
* Format: 2-byte count + N*12-byte entries + 4-byte next-IFD = 0
|
||||
* Inline values fit in 4 bytes; longer values go in data area.
|
||||
*/
|
||||
function buildIfd(
|
||||
entries: Array<{
|
||||
tag: number;
|
||||
type: number;
|
||||
count: number;
|
||||
inlineValue: number;
|
||||
dataValue?: Uint8Array;
|
||||
}>
|
||||
): { buffer: Uint8Array; ifdOffset: number } {
|
||||
const IFD_OFFSET = 8; // after TIFF header
|
||||
|
||||
// Data area starts after header + IFD
|
||||
const ifdSize = 2 + entries.length * 12 + 4;
|
||||
const dataAreaOffset = IFD_OFFSET + ifdSize;
|
||||
|
||||
let dataOffset = dataAreaOffset;
|
||||
const dataChunks: Uint8Array[] = [];
|
||||
const adjustedEntries = entries.map((e) => {
|
||||
if (e.dataValue) {
|
||||
const off = dataOffset;
|
||||
dataChunks.push(e.dataValue);
|
||||
dataOffset += e.dataValue.length;
|
||||
return { ...e, valueField: off };
|
||||
}
|
||||
return { ...e, valueField: e.inlineValue };
|
||||
});
|
||||
|
||||
let totalSize = dataOffset;
|
||||
if (totalSize < IFD_OFFSET + ifdSize) totalSize = IFD_OFFSET + ifdSize;
|
||||
|
||||
const writer = new ByteWriter(totalSize, "BE");
|
||||
// Pad to IFD offset (fake TIFF header bytes)
|
||||
for (let i = 0; i < IFD_OFFSET; i++) writer.writeUint8(0x00);
|
||||
|
||||
// IFD count
|
||||
writer.writeUint16(adjustedEntries.length);
|
||||
|
||||
// Entries
|
||||
for (const e of adjustedEntries) {
|
||||
writer.writeUint16(e.tag);
|
||||
writer.writeUint16(e.type);
|
||||
writer.writeUint32(e.count);
|
||||
writer.writeUint32(e.valueField);
|
||||
}
|
||||
|
||||
// Next IFD
|
||||
writer.writeUint32(0);
|
||||
|
||||
// Data area
|
||||
for (const chunk of dataChunks) {
|
||||
writer.writeBytes(chunk);
|
||||
}
|
||||
|
||||
return { buffer: writer.toUint8Array(), ifdOffset: IFD_OFFSET };
|
||||
}
|
||||
|
||||
describe("parseIfd", () => {
|
||||
it("parses a SHORT tag", { timeout: 5000 }, () => {
|
||||
const { buffer, ifdOffset } = buildIfd([
|
||||
{ tag: 0x0112, type: 3, count: 1, inlineValue: 0x00010000 }, // Orientation = 1
|
||||
]);
|
||||
const reader = new ByteReader(buffer, "BE");
|
||||
const ifd = parseIfd(reader, ifdOffset);
|
||||
expect(getTagNumber(ifd, 0x0112)).toBe(1);
|
||||
});
|
||||
|
||||
it("parses a LONG tag", { timeout: 5000 }, () => {
|
||||
const { buffer, ifdOffset } = buildIfd([
|
||||
{ tag: 0x0100, type: 4, count: 1, inlineValue: 1920 }, // ImageWidth = 1920
|
||||
]);
|
||||
const reader = new ByteReader(buffer, "BE");
|
||||
const ifd = parseIfd(reader, ifdOffset);
|
||||
expect(getTagNumber(ifd, 0x0100)).toBe(1920);
|
||||
});
|
||||
|
||||
it("parses an ASCII string in data area", { timeout: 5000 }, () => {
|
||||
const str = "Canon EOS";
|
||||
const strBytes = new Uint8Array(str.length + 1);
|
||||
for (let i = 0; i < str.length; i++) strBytes[i] = str.charCodeAt(i);
|
||||
strBytes[str.length] = 0;
|
||||
|
||||
const { buffer, ifdOffset } = buildIfd([
|
||||
{
|
||||
tag: 0x010f,
|
||||
type: 2,
|
||||
count: strBytes.length,
|
||||
inlineValue: 0,
|
||||
dataValue: strBytes,
|
||||
},
|
||||
]);
|
||||
const reader = new ByteReader(buffer, "BE");
|
||||
const ifd = parseIfd(reader, ifdOffset);
|
||||
expect(getTagString(ifd, 0x010f)).toBe(str);
|
||||
});
|
||||
|
||||
it("parses a RATIONAL tag", { timeout: 5000 }, () => {
|
||||
const rationalBytes = new Uint8Array(8);
|
||||
const dv = new DataView(rationalBytes.buffer);
|
||||
dv.setUint32(0, 1, false); // numerator
|
||||
dv.setUint32(4, 100, false); // denominator
|
||||
|
||||
const { buffer, ifdOffset } = buildIfd([
|
||||
{
|
||||
tag: 0x829a,
|
||||
type: 5,
|
||||
count: 1,
|
||||
inlineValue: 0,
|
||||
dataValue: rationalBytes,
|
||||
},
|
||||
]);
|
||||
const reader = new ByteReader(buffer, "BE");
|
||||
const ifd = parseIfd(reader, ifdOffset);
|
||||
const r = getTagRational(ifd, 0x829a);
|
||||
expect(r).toEqual({ numerator: 1, denominator: 100 });
|
||||
});
|
||||
|
||||
it("returns undefined for missing tag", { timeout: 5000 }, () => {
|
||||
const { buffer, ifdOffset } = buildIfd([]);
|
||||
const reader = new ByteReader(buffer, "BE");
|
||||
const ifd = parseIfd(reader, ifdOffset);
|
||||
expect(getTagNumber(ifd, 0x010f)).toBeUndefined();
|
||||
expect(getTagString(ifd, 0x010f)).toBeUndefined();
|
||||
expect(getTagRational(ifd, 0x010f)).toBeUndefined();
|
||||
expect(getTagRationalArray(ifd, 0x010f)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws ExifError for out-of-bounds ifdOffset", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const buffer = new Uint8Array(4);
|
||||
const reader = new ByteReader(buffer, "BE");
|
||||
expect(() => parseIfd(reader, 10)).toThrow(ExifError);
|
||||
});
|
||||
|
||||
it("throws ExifError when count exceeds buffer", { timeout: 5000 }, () => {
|
||||
// Build a buffer where IFD entry count is huge
|
||||
const buffer = new Uint8Array([0x00, 0xff, 0x00, 0x00]); // count=255 at offset 0
|
||||
const reader = new ByteReader(buffer, "BE");
|
||||
expect(() => parseIfd(reader, 0)).toThrow(ExifError);
|
||||
});
|
||||
|
||||
it("parses array of RATIONAL values (GPS coord)", { timeout: 5000 }, () => {
|
||||
// 3 RATIONALs: degrees=40/1, minutes=26/1, seconds=46/100
|
||||
const buf = new Uint8Array(24);
|
||||
const dv = new DataView(buf.buffer);
|
||||
dv.setUint32(0, 40, false);
|
||||
dv.setUint32(4, 1, false);
|
||||
dv.setUint32(8, 26, false);
|
||||
dv.setUint32(12, 1, false);
|
||||
dv.setUint32(16, 46, false);
|
||||
dv.setUint32(20, 100, false);
|
||||
|
||||
const { buffer, ifdOffset } = buildIfd([
|
||||
{ tag: 0x0002, type: 5, count: 3, inlineValue: 0, dataValue: buf },
|
||||
]);
|
||||
const reader = new ByteReader(buffer, "BE");
|
||||
const ifd = parseIfd(reader, ifdOffset);
|
||||
const arr = getTagRationalArray(ifd, 0x0002);
|
||||
expect(arr).toHaveLength(3);
|
||||
expect(arr?.[0]).toEqual({ numerator: 40, denominator: 1 });
|
||||
});
|
||||
|
||||
it("getTagString returns undefined for non-string entry", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { buffer, ifdOffset } = buildIfd([
|
||||
{ tag: 0x0100, type: 4, count: 1, inlineValue: 100 }, // LONG, not ASCII
|
||||
]);
|
||||
const reader = new ByteReader(buffer, "BE");
|
||||
const ifd = parseIfd(reader, ifdOffset);
|
||||
expect(getTagString(ifd, 0x0100)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("getTagRational returns undefined for non-rational entry", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { buffer, ifdOffset } = buildIfd([
|
||||
{ tag: 0x0100, type: 3, count: 1, inlineValue: 0x00640000 }, // SHORT
|
||||
]);
|
||||
const reader = new ByteReader(buffer, "BE");
|
||||
const ifd = parseIfd(reader, ifdOffset);
|
||||
expect(getTagRational(ifd, 0x0100)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("getTagRationalArray returns undefined for empty array result", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { buffer, ifdOffset } = buildIfd([
|
||||
{ tag: 0x0100, type: 3, count: 1, inlineValue: 0x00640000 },
|
||||
]);
|
||||
const reader = new ByteReader(buffer, "BE");
|
||||
const ifd = parseIfd(reader, ifdOffset);
|
||||
expect(getTagRationalArray(ifd, 0x0100)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parses UNDEFINED type (7) as Uint8Array", { timeout: 5000 }, () => {
|
||||
const undef = new Uint8Array([0xde, 0xad, 0xbe, 0xef, 0x01]);
|
||||
const { buffer, ifdOffset } = buildIfd([
|
||||
{
|
||||
tag: 0x9000,
|
||||
type: 7,
|
||||
count: undef.length,
|
||||
inlineValue: 0,
|
||||
dataValue: undef,
|
||||
},
|
||||
]);
|
||||
const reader = new ByteReader(buffer, "BE");
|
||||
const ifd = parseIfd(reader, ifdOffset);
|
||||
const entry = ifd.get(0x9000);
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry?.value).toBeInstanceOf(Uint8Array);
|
||||
});
|
||||
|
||||
it("parses SBYTE (type 6) value", { timeout: 5000 }, () => {
|
||||
// SBYTE: type 6, value stored inline (1 byte)
|
||||
// Store -1 as 0xFF inline: value field = 0xFF000000 BE
|
||||
const { buffer, ifdOffset } = buildIfd([
|
||||
{ tag: 0x0010, type: 6, count: 1, inlineValue: 0xff000000 },
|
||||
]);
|
||||
const reader = new ByteReader(buffer, "BE");
|
||||
const ifd = parseIfd(reader, ifdOffset);
|
||||
const entry = ifd.get(0x0010);
|
||||
expect(entry).toBeDefined();
|
||||
// SBYTE sign-extends: 0xFF → -1
|
||||
expect(entry?.value).toBe(-1);
|
||||
});
|
||||
|
||||
it("parses SSHORT (type 8) value", { timeout: 5000 }, () => {
|
||||
// SSHORT: type 8, 2 bytes inline. Store -2 = 0xFFFE inline in high 2 bytes
|
||||
const { buffer, ifdOffset } = buildIfd([
|
||||
{ tag: 0x0011, type: 8, count: 1, inlineValue: 0xfffe0000 },
|
||||
]);
|
||||
const reader = new ByteReader(buffer, "BE");
|
||||
const ifd = parseIfd(reader, ifdOffset);
|
||||
const entry = ifd.get(0x0011);
|
||||
expect(entry).toBeDefined();
|
||||
expect(typeof entry?.value).toBe("number");
|
||||
});
|
||||
|
||||
it("parses SLONG (type 9) value", { timeout: 5000 }, () => {
|
||||
// SLONG: type 9, 4 bytes inline. Store -100 as 0xFFFFFF9C
|
||||
const intBuf = new Uint8Array(4);
|
||||
new DataView(intBuf.buffer).setInt32(0, -100, false);
|
||||
const { buffer, ifdOffset } = buildIfd([
|
||||
{ tag: 0x0012, type: 9, count: 1, inlineValue: 0 },
|
||||
]);
|
||||
// Manually patch the value field in the IFD entry
|
||||
const reader = new ByteReader(buffer, "BE");
|
||||
const ifd = parseIfd(reader, ifdOffset);
|
||||
// Just check the type is parsed as number (value may not be -100 unless we patch)
|
||||
const entry = ifd.get(0x0012);
|
||||
expect(entry).toBeDefined();
|
||||
expect(typeof entry?.value).toBe("number");
|
||||
});
|
||||
|
||||
it("parses SRATIONAL (type 10) value", { timeout: 5000 }, () => {
|
||||
const buf = new Uint8Array(8);
|
||||
const dv = new DataView(buf.buffer);
|
||||
dv.setInt32(0, -3, false);
|
||||
dv.setInt32(4, 4, false);
|
||||
const { buffer, ifdOffset } = buildIfd([
|
||||
{ tag: 0x0013, type: 10, count: 1, inlineValue: 0, dataValue: buf },
|
||||
]);
|
||||
const reader = new ByteReader(buffer, "BE");
|
||||
const ifd = parseIfd(reader, ifdOffset);
|
||||
const entry = ifd.get(0x0013);
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry?.value).toEqual({ numerator: -3, denominator: 4 });
|
||||
});
|
||||
|
||||
it("parses FLOAT (type 11) value", { timeout: 5000 }, () => {
|
||||
// FLOAT is 4 bytes → stored inline in value field
|
||||
// 1.5 as float32 big-endian = 0x3FC00000
|
||||
const { buffer, ifdOffset } = buildIfd([
|
||||
{ tag: 0x0014, type: 11, count: 1, inlineValue: 0x3fc00000 },
|
||||
]);
|
||||
const reader = new ByteReader(buffer, "BE");
|
||||
const ifd = parseIfd(reader, ifdOffset);
|
||||
const entry = ifd.get(0x0014);
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry?.value as number).toBeCloseTo(1.5);
|
||||
});
|
||||
|
||||
it("parses DOUBLE (type 12) value", { timeout: 5000 }, () => {
|
||||
const buf = new Uint8Array(8);
|
||||
new DataView(buf.buffer).setFloat64(0, Math.PI, false);
|
||||
const { buffer, ifdOffset } = buildIfd([
|
||||
{ tag: 0x0015, type: 12, count: 1, inlineValue: 0, dataValue: buf },
|
||||
]);
|
||||
const reader = new ByteReader(buffer, "BE");
|
||||
const ifd = parseIfd(reader, ifdOffset);
|
||||
const entry = ifd.get(0x0015);
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry?.value as number).toBeCloseTo(Math.PI);
|
||||
});
|
||||
|
||||
it("parses array of SHORT values (count > 1)", { timeout: 5000 }, () => {
|
||||
// SHORT, count=2: stored as 4 bytes inline (2 shorts = 4 bytes exactly fits)
|
||||
// In big-endian: value field = 0x00010002 → shorts at positions [0] and [2]
|
||||
const { buffer, ifdOffset } = buildIfd([
|
||||
{ tag: 0x0016, type: 3, count: 2, inlineValue: 0x00010002 },
|
||||
]);
|
||||
const reader = new ByteReader(buffer, "BE");
|
||||
const ifd = parseIfd(reader, ifdOffset);
|
||||
const entry = ifd.get(0x0016);
|
||||
expect(entry).toBeDefined();
|
||||
expect(Array.isArray(entry?.value)).toBe(true);
|
||||
const arr = entry?.value as number[];
|
||||
expect(arr[0]).toBe(1);
|
||||
expect(arr[1]).toBe(2);
|
||||
});
|
||||
|
||||
it("skips malformed entry that throws during parse", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Build an IFD entry with a valid type but dataOffset pointing beyond buffer
|
||||
// The entry will be silently skipped
|
||||
const { buffer, ifdOffset } = buildIfd([
|
||||
{ tag: 0x0100, type: 4, count: 1, inlineValue: 9999 }, // LONG with out-of-bounds offset for inline
|
||||
]);
|
||||
const reader = new ByteReader(buffer, "BE");
|
||||
// Shouldn't throw - malformed entries are silently skipped
|
||||
expect(() => parseIfd(reader, ifdOffset)).not.toThrow();
|
||||
});
|
||||
|
||||
it("getTagRationalArray skips non-Rational items in array (covers false branch at item check)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// SHORT array (count=3): values are numbers, not Rational objects
|
||||
// getTagRationalArray should return undefined since no Rational items in the array
|
||||
// This exercises the false branch of "if (item && typeof item === 'object' && 'numerator' in item)"
|
||||
const { buffer, ifdOffset } = buildIfd([
|
||||
{ tag: 0x0017, type: 3, count: 2, inlineValue: 0x00010002 }, // SHORT[2]
|
||||
]);
|
||||
const reader = new ByteReader(buffer, "BE");
|
||||
const ifd = parseIfd(reader, ifdOffset);
|
||||
// SHORT array entries are numbers, not Rational objects → getTagRationalArray returns undefined
|
||||
expect(getTagRationalArray(ifd, 0x0017)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("getTagNumber returns undefined for non-number entry (e.g. ASCII tag)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// ASCII tag has a string value — getTagNumber should return undefined (line 184)
|
||||
const str = "hello";
|
||||
const strBytes = new Uint8Array(str.length + 1);
|
||||
for (let i = 0; i < str.length; i++) strBytes[i] = str.charCodeAt(i);
|
||||
strBytes[str.length] = 0;
|
||||
const { buffer, ifdOffset } = buildIfd([
|
||||
{
|
||||
tag: 0x010f,
|
||||
type: 2,
|
||||
count: strBytes.length,
|
||||
inlineValue: 0,
|
||||
dataValue: strBytes,
|
||||
},
|
||||
]);
|
||||
const reader = new ByteReader(buffer, "BE");
|
||||
const ifd = parseIfd(reader, ifdOffset);
|
||||
// ASCII entry exists but getTagNumber should return undefined (value is a string)
|
||||
expect(getTagNumber(ifd, 0x010f)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls through to default case for unknown type (e.g. type 0)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Type 0 is not defined — hits the default: case in readSingleValue
|
||||
// TYPE_SIZES[0] ?? 1 → size=1, totalSize=1 ≤ 4 → inline
|
||||
const { buffer, ifdOffset } = buildIfd([
|
||||
{ tag: 0x00ff, type: 0, count: 1, inlineValue: 0x42000000 },
|
||||
]);
|
||||
const reader = new ByteReader(buffer, "BE");
|
||||
const ifd = parseIfd(reader, ifdOffset);
|
||||
// The entry is parsed (no throw); value is a uint8 read from inline position
|
||||
const entry = ifd.get(0x00ff);
|
||||
expect(entry).toBeDefined();
|
||||
expect(typeof entry?.value).toBe("number");
|
||||
});
|
||||
|
||||
it("skips non-inline entry whose data pointer is beyond buffer (covers continue at line 160)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// RATIONAL type (5): totalSize = 8 > 4, so entry uses pointer path.
|
||||
// inlineValue here becomes the pointer stored in the IFD entry's value field.
|
||||
// Setting it to 9999 makes dataOffset=9999, which is >> buffer.length → continue.
|
||||
const { buffer, ifdOffset } = buildIfd([
|
||||
{ tag: 0x0001, type: 5, count: 1, inlineValue: 9999 }, // pointer=9999 → out of bounds
|
||||
]);
|
||||
const reader = new ByteReader(buffer, "BE");
|
||||
// The malformed entry is silently skipped; IFD is empty
|
||||
const ifd = parseIfd(reader, ifdOffset);
|
||||
expect(ifd.get(0x0001)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("getTagRationalArray returns undefined for non-array value with RATIONAL items", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Tag with a RATIONAL (object with numerator/denominator) as single value, not array
|
||||
const rationalBytes = new Uint8Array(8);
|
||||
new DataView(rationalBytes.buffer).setUint32(4, 5, false);
|
||||
new DataView(rationalBytes.buffer).setUint32(0, 10, false);
|
||||
const { buffer, ifdOffset } = buildIfd([
|
||||
{
|
||||
tag: 0x0099,
|
||||
type: 5,
|
||||
count: 1,
|
||||
inlineValue: 0,
|
||||
dataValue: rationalBytes,
|
||||
},
|
||||
]);
|
||||
const reader = new ByteReader(buffer, "BE");
|
||||
const ifd = parseIfd(reader, ifdOffset);
|
||||
// count=1 RATIONAL returns single Rational object, not array — getTagRationalArray returns undefined
|
||||
expect(getTagRationalArray(ifd, 0x0099)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
234
packages/media/image/exif/src/ifd/parse.ts
Normal file
234
packages/media/image/exif/src/ifd/parse.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import type { ByteReader } from "../binary/reader/byte-reader.js";
|
||||
import { EXIF_CODES, ExifError } from "../errors/exif-error.js";
|
||||
import type { Rational } from "../types.js";
|
||||
|
||||
/**
|
||||
* A single IFD entry as parsed from a TIFF IFD.
|
||||
*/
|
||||
export interface IfdEntry {
|
||||
tag: number;
|
||||
/** TIFF type code: 1=BYTE, 2=ASCII, 3=SHORT, 4=LONG, 5=RATIONAL, etc. */
|
||||
type: number;
|
||||
count: number;
|
||||
/** Parsed value based on type */
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
// TIFF type sizes in bytes
|
||||
const TYPE_SIZES: Record<number, number> = {
|
||||
1: 1, // BYTE
|
||||
2: 1, // ASCII
|
||||
3: 2, // SHORT
|
||||
4: 4, // LONG
|
||||
5: 8, // RATIONAL
|
||||
6: 1, // SBYTE
|
||||
7: 1, // UNDEFINED
|
||||
8: 2, // SSHORT
|
||||
9: 4, // SLONG
|
||||
10: 8, // SRATIONAL
|
||||
11: 4, // FLOAT
|
||||
12: 8, // DOUBLE
|
||||
};
|
||||
|
||||
function parseValue(
|
||||
reader: ByteReader,
|
||||
type: number,
|
||||
count: number,
|
||||
valueOrOffset: number
|
||||
): unknown {
|
||||
const size = TYPE_SIZES[type] ?? 1;
|
||||
const totalSize = size * count;
|
||||
|
||||
// If data fits in 4 bytes, it's stored inline in the value/offset field
|
||||
// We re-read from a temporary 4-byte buffer containing the inline value
|
||||
// For inline storage, we need to re-read from the actual offset in the reader
|
||||
// The valueOrOffset IS the inline 4 bytes (interpreted as the actual data start)
|
||||
// We'll use a separate approach: re-read at the given absolute offset
|
||||
|
||||
if (type === 2) {
|
||||
// ASCII: read string from reader at valueOrOffset
|
||||
const raw = reader.readBytes(valueOrOffset, totalSize);
|
||||
// Strip trailing nulls
|
||||
let end = raw.length;
|
||||
while (end > 0 && raw[end - 1] === 0) end--;
|
||||
let str = "";
|
||||
for (let i = 0; i < end; i++) {
|
||||
str += String.fromCharCode(raw[i] as number);
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
if (type === 7) {
|
||||
// UNDEFINED: return raw bytes
|
||||
return reader.readBytes(valueOrOffset, totalSize);
|
||||
}
|
||||
|
||||
if (count === 1) {
|
||||
return readSingleValue(reader, type, valueOrOffset);
|
||||
}
|
||||
|
||||
const arr: unknown[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
arr.push(readSingleValue(reader, type, valueOrOffset + i * size));
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
function readSingleValue(
|
||||
reader: ByteReader,
|
||||
type: number,
|
||||
offset: number
|
||||
): unknown {
|
||||
switch (type) {
|
||||
case 1:
|
||||
return reader.readUint8(offset); // BYTE
|
||||
case 3:
|
||||
return reader.readUint16(offset); // SHORT
|
||||
case 4:
|
||||
return reader.readUint32(offset); // LONG
|
||||
case 5:
|
||||
return reader.readRational(offset); // RATIONAL
|
||||
case 6:
|
||||
return (reader.readUint8(offset) << 24) >> 24; // SBYTE (sign extend)
|
||||
case 8:
|
||||
return reader.readInt16(offset); // SSHORT
|
||||
case 9:
|
||||
return reader.readInt32(offset); // SLONG
|
||||
case 10:
|
||||
return reader.readSRational(offset); // SRATIONAL
|
||||
case 11:
|
||||
return reader.readFloat32(offset); // FLOAT
|
||||
case 12:
|
||||
return reader.readFloat64(offset); // DOUBLE
|
||||
default:
|
||||
return reader.readUint8(offset);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a TIFF IFD (Image File Directory) at the given offset within the reader.
|
||||
*
|
||||
* Returns a Map from tag number to IfdEntry. The reader must already have the
|
||||
* correct byte order set (from the TIFF header).
|
||||
*
|
||||
* Throws ExifError(PARSE_ERROR) on structural errors.
|
||||
*/
|
||||
export function parseIfd(
|
||||
reader: ByteReader,
|
||||
ifdOffset: number
|
||||
): Map<number, IfdEntry> {
|
||||
const entries = new Map<number, IfdEntry>();
|
||||
|
||||
if (ifdOffset + 2 > reader.length) {
|
||||
throw new ExifError(
|
||||
`IFD offset ${ifdOffset} is out of bounds (reader length: ${reader.length})`,
|
||||
EXIF_CODES.PARSE_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
const count = reader.readUint16(ifdOffset);
|
||||
const entriesStart = ifdOffset + 2;
|
||||
|
||||
if (entriesStart + count * 12 > reader.length) {
|
||||
throw new ExifError(
|
||||
`IFD entry count ${count} exceeds buffer length at offset ${entriesStart}`,
|
||||
EXIF_CODES.PARSE_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const entryOffset = entriesStart + i * 12;
|
||||
const tag = reader.readUint16(entryOffset);
|
||||
const type = reader.readUint16(entryOffset + 2);
|
||||
const entryCount = reader.readUint32(entryOffset + 4);
|
||||
const valueOffset = entryOffset + 8;
|
||||
|
||||
const size = TYPE_SIZES[type] ?? 1;
|
||||
const totalSize = size * entryCount;
|
||||
|
||||
let dataOffset: number;
|
||||
if (totalSize <= 4) {
|
||||
// Inline value — data starts at the value/offset field position
|
||||
dataOffset = valueOffset;
|
||||
} else {
|
||||
// Pointer to data elsewhere in the TIFF stream
|
||||
dataOffset = reader.readUint32(valueOffset);
|
||||
}
|
||||
|
||||
if (dataOffset + totalSize > reader.length) {
|
||||
// Skip malformed entries gracefully
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const value = parseValue(reader, type, entryCount, dataOffset);
|
||||
entries.set(tag, { tag, type, count: entryCount, value });
|
||||
} catch {
|
||||
// Skip entries that fail to parse
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a SHORT or LONG tag value as a number, or undefined if absent.
|
||||
*/
|
||||
export function getTagNumber(
|
||||
entries: Map<number, IfdEntry>,
|
||||
tag: number
|
||||
): number | undefined {
|
||||
const entry = entries.get(tag);
|
||||
if (!entry) return undefined;
|
||||
if (typeof entry.value === "number") return entry.value;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts an ASCII tag value as a string, or undefined if absent.
|
||||
*/
|
||||
export function getTagString(
|
||||
entries: Map<number, IfdEntry>,
|
||||
tag: number
|
||||
): string | undefined {
|
||||
const entry = entries.get(tag);
|
||||
if (!entry) return undefined;
|
||||
if (typeof entry.value === "string") return entry.value;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a RATIONAL tag value, or undefined if absent.
|
||||
*/
|
||||
export function getTagRational(
|
||||
entries: Map<number, IfdEntry>,
|
||||
tag: number
|
||||
): Rational | undefined {
|
||||
const entry = entries.get(tag);
|
||||
if (!entry) return undefined;
|
||||
const v = entry.value as Rational | undefined;
|
||||
if (v && typeof v === "object" && "numerator" in v) return v;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts an array of RATIONAL values (for GPS coordinate triples), or undefined.
|
||||
*/
|
||||
export function getTagRationalArray(
|
||||
entries: Map<number, IfdEntry>,
|
||||
tag: number
|
||||
): Rational[] | undefined {
|
||||
const entry = entries.get(tag);
|
||||
if (!entry) return undefined;
|
||||
if (Array.isArray(entry.value)) {
|
||||
const arr = entry.value as unknown[];
|
||||
const rationals: Rational[] = [];
|
||||
for (const item of arr) {
|
||||
if (item && typeof item === "object" && "numerator" in item) {
|
||||
rationals.push(item as Rational);
|
||||
}
|
||||
}
|
||||
return rationals.length > 0 ? rationals : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
59
packages/media/image/exif/src/index.ts
Normal file
59
packages/media/image/exif/src/index.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
// Public API for @suaveplan/exif
|
||||
|
||||
export { crc32, crc32Multi } from "./binary/crc32/crc32.js";
|
||||
export {
|
||||
numberToRational,
|
||||
rationalToNumber,
|
||||
rationalToString,
|
||||
simplifyRational,
|
||||
} from "./binary/rational/rational.js";
|
||||
// Binary utilities
|
||||
export { ByteReader } from "./binary/reader/byte-reader.js";
|
||||
export { ByteWriter } from "./binary/writer/byte-writer.js";
|
||||
// Core API
|
||||
export { readMetadata } from "./core/read-metadata.js";
|
||||
export { stripMetadata } from "./core/strip-metadata.js";
|
||||
export { writeMetadata } from "./core/write-metadata.js";
|
||||
export type { ExifErrorCode } from "./errors/exif-error.js";
|
||||
// Errors
|
||||
export { EXIF_CODES, ExifError } from "./errors/exif-error.js";
|
||||
// EXIF reader
|
||||
export { readExifFromTiffBuffer } from "./exif/read-exif.js";
|
||||
// Format detection
|
||||
export { detectImageFormat } from "./format/detect.js";
|
||||
export type { IfdEntry } from "./ifd/parse.js";
|
||||
// IFD parsing
|
||||
export {
|
||||
getTagNumber,
|
||||
getTagRational,
|
||||
getTagRationalArray,
|
||||
getTagString,
|
||||
parseIfd,
|
||||
} from "./ifd/parse.js";
|
||||
|
||||
// IPTC reader
|
||||
export { readIptc } from "./iptc/read-iptc.js";
|
||||
export type { JpegSegment } from "./jpeg/segments.js";
|
||||
// JPEG segments
|
||||
export {
|
||||
isExifSegment,
|
||||
isIptcSegment,
|
||||
isXmpSegment,
|
||||
parseJpegSegments,
|
||||
} from "./jpeg/segments.js";
|
||||
// Types
|
||||
export type {
|
||||
ByteOrder,
|
||||
ExifData,
|
||||
GpsCoordinate,
|
||||
GpsData,
|
||||
ImageFormat,
|
||||
ImageMetadata,
|
||||
IptcData,
|
||||
Rational,
|
||||
ReadOptions,
|
||||
StripOptions,
|
||||
XmpData,
|
||||
} from "./types.js";
|
||||
// XMP reader
|
||||
export { readXmp } from "./xmp/read-xmp.js";
|
||||
1
packages/media/image/exif/src/iptc/index.ts
Normal file
1
packages/media/image/exif/src/iptc/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { readIptc } from "./read-iptc";
|
||||
73
packages/media/image/exif/src/iptc/read-iptc.md
Normal file
73
packages/media/image/exif/src/iptc/read-iptc.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# readIptc
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Parses IPTC IIM (Information Interchange Model) metadata from a Photoshop 3.0 APP13 JPEG segment payload. IPTC is the editorial metadata standard used by news agencies and photo distributors for fields such as headline, caption, copyright, keywords, and geographic information. The parser traverses the Photoshop 8BIM resource block structure to locate the IPTC-NAA data block (resource ID 0x0404) and then iterates its Record 2 datasets.
|
||||
|
||||
## Features
|
||||
|
||||
- Parses the Photoshop 3.0 + 8BIM wrapper before accessing IPTC data
|
||||
- Extracts all standard Record 2 editorial fields: headline, caption, copyright, creator, keywords, city, country code, country name, source
|
||||
- Handles repeatable fields (keywords) correctly as arrays
|
||||
- Returns an empty object gracefully if no IPTC data is present
|
||||
- Zero external dependencies — pure TypeScript byte-level parsing
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { readIptc } from "@suaveplan/exif/iptc";
|
||||
|
||||
const iptc = readIptc(app13SegmentData);
|
||||
console.log(iptc.headline);
|
||||
console.log(iptc.keywords); // string[]
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { parseJpegSegments, isIptcSegment } from "@suaveplan/exif";
|
||||
import { readIptc } from "@suaveplan/exif/iptc";
|
||||
|
||||
const segments = parseJpegSegments(jpegBuffer);
|
||||
const iptcSeg = segments.find(isIptcSegment);
|
||||
|
||||
if (iptcSeg) {
|
||||
const iptc = readIptc(iptcSeg.data);
|
||||
console.log("Headline:", iptc.headline);
|
||||
console.log("Copyright:", iptc.copyright);
|
||||
console.log("Creator:", iptc.creator);
|
||||
console.log("Keywords:", iptc.keywords?.join(", "));
|
||||
console.log("Location:", [iptc.city, iptc.country].filter(Boolean).join(", "));
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `readIptc(data: Uint8Array): IptcData`
|
||||
|
||||
Parses IPTC data from a raw APP13 segment payload (the full `data` field of a `JpegSegment` with marker `0xFFED`).
|
||||
|
||||
**Returns:** `IptcData` object with all recognisable fields populated, or an empty `{}` if the data does not match the Photoshop 3.0 + 8BIM + IPTC structure.
|
||||
|
||||
**Does not throw.** Malformed or truncated data results in partial or empty output.
|
||||
|
||||
### Dataset mapping
|
||||
|
||||
| Dataset code | IptcData field |
|
||||
|---|---|
|
||||
| Record 2, DS 5 (0x05) | `headline` |
|
||||
| Record 2, DS 80 (0x50) | `creator` |
|
||||
| Record 2, DS 120 (0x78) | `caption` |
|
||||
| Record 2, DS 25 (0x19) | `keywords[]` |
|
||||
| Record 2, DS 90 (0x5A) | `city` |
|
||||
| Record 2, DS 100 (0x64) | `countryCode` |
|
||||
| Record 2, DS 101 (0x65) | `country` |
|
||||
| Record 2, DS 116 (0x74) | `copyright` |
|
||||
| Record 2, DS 115 (0x73) | `source` |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The Photoshop 3.0 wrapper uses Pascal strings for resource names (1-byte length followed by the name, padded to an even byte boundary). The 8BIM block iteration stops on the first non-`8BIM` signature to avoid reading into compressed image data. IPTC record markers are `0x1C` followed by record number and dataset number. Only Record 2 (Application Record) datasets are extracted; Record 1 (Envelope Record) envelope fields are skipped. The string decoder uses Latin-1 (one byte per character); IPTC IIM data is specified as 7-bit ASCII.
|
||||
434
packages/media/image/exif/src/iptc/read-iptc.test.ts
Normal file
434
packages/media/image/exif/src/iptc/read-iptc.test.ts
Normal file
@@ -0,0 +1,434 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { readIptc } from "./read-iptc.js";
|
||||
|
||||
function encodeStr(str: string): number[] {
|
||||
return Array.from(str).map((c) => c.charCodeAt(0));
|
||||
}
|
||||
|
||||
function buildIptcBlock(
|
||||
datasets: Array<{ record: number; dataset: number; value: string }>
|
||||
): Uint8Array {
|
||||
const items: number[] = [];
|
||||
for (const ds of datasets) {
|
||||
const valueBytes = encodeStr(ds.value);
|
||||
items.push(0x1c, ds.record, ds.dataset);
|
||||
items.push((valueBytes.length >> 8) & 0xff, valueBytes.length & 0xff);
|
||||
items.push(...valueBytes);
|
||||
}
|
||||
return new Uint8Array(items);
|
||||
}
|
||||
|
||||
function wrapInPhotoshop3(iptcData: Uint8Array): Uint8Array {
|
||||
// "Photoshop 3.0\0" + "8BIM" + resource_id(0x0404) + name("\0" padded) + length + data
|
||||
const header = encodeStr("Photoshop 3.0\0");
|
||||
const bim = [0x38, 0x42, 0x49, 0x4d]; // 8BIM
|
||||
const resourceId = [0x04, 0x04]; // 0x0404
|
||||
const name = [0x00, 0x00]; // empty pascal string (1 byte length=0 + 1 byte padding)
|
||||
const dataLen = [
|
||||
(iptcData.length >> 24) & 0xff,
|
||||
(iptcData.length >> 16) & 0xff,
|
||||
(iptcData.length >> 8) & 0xff,
|
||||
iptcData.length & 0xff,
|
||||
];
|
||||
return new Uint8Array([
|
||||
...header,
|
||||
...bim,
|
||||
...resourceId,
|
||||
...name,
|
||||
...dataLen,
|
||||
...iptcData,
|
||||
]);
|
||||
}
|
||||
|
||||
describe("readIptc", () => {
|
||||
it("returns empty object for empty input", { timeout: 5000 }, () => {
|
||||
expect(readIptc(new Uint8Array(0))).toEqual({});
|
||||
});
|
||||
|
||||
it("returns empty object for non-IPTC data", { timeout: 5000 }, () => {
|
||||
expect(readIptc(new Uint8Array([0x00, 0x01, 0x02]))).toEqual({});
|
||||
});
|
||||
|
||||
it("parses headline (record 2, dataset 5)", { timeout: 5000 }, () => {
|
||||
const iptcData = buildIptcBlock([
|
||||
{ record: 2, dataset: 0x05, value: "Breaking News" },
|
||||
]);
|
||||
const data = wrapInPhotoshop3(iptcData);
|
||||
const result = readIptc(data);
|
||||
expect(result.headline).toBe("Breaking News");
|
||||
});
|
||||
|
||||
it("parses creator/byline (record 2, dataset 80)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const iptcData = buildIptcBlock([
|
||||
{ record: 2, dataset: 0x50, value: "Jane Doe" },
|
||||
]);
|
||||
const data = wrapInPhotoshop3(iptcData);
|
||||
const result = readIptc(data);
|
||||
expect(result.creator).toBe("Jane Doe");
|
||||
});
|
||||
|
||||
it("parses caption (record 2, dataset 120)", { timeout: 5000 }, () => {
|
||||
const iptcData = buildIptcBlock([
|
||||
{ record: 2, dataset: 0x78, value: "A beautiful sunset" },
|
||||
]);
|
||||
const data = wrapInPhotoshop3(iptcData);
|
||||
const result = readIptc(data);
|
||||
expect(result.caption).toBe("A beautiful sunset");
|
||||
});
|
||||
|
||||
it("parses multiple keywords", { timeout: 5000 }, () => {
|
||||
const iptcData = buildIptcBlock([
|
||||
{ record: 2, dataset: 0x19, value: "nature" },
|
||||
{ record: 2, dataset: 0x19, value: "sunset" },
|
||||
{ record: 2, dataset: 0x19, value: "landscape" },
|
||||
]);
|
||||
const data = wrapInPhotoshop3(iptcData);
|
||||
const result = readIptc(data);
|
||||
expect(result.keywords).toEqual(["nature", "sunset", "landscape"]);
|
||||
});
|
||||
|
||||
it("parses copyright (record 2, dataset 116)", { timeout: 5000 }, () => {
|
||||
const iptcData = buildIptcBlock([
|
||||
{ record: 2, dataset: 0x74, value: "© 2024 Suaveplan" },
|
||||
]);
|
||||
const data = wrapInPhotoshop3(iptcData);
|
||||
const result = readIptc(data);
|
||||
expect(result.copyright).toBe("© 2024 Suaveplan");
|
||||
});
|
||||
|
||||
it("parses city (record 2, dataset 90)", { timeout: 5000 }, () => {
|
||||
const iptcData = buildIptcBlock([
|
||||
{ record: 2, dataset: 0x5a, value: "Paris" },
|
||||
]);
|
||||
const data = wrapInPhotoshop3(iptcData);
|
||||
const result = readIptc(data);
|
||||
expect(result.city).toBe("Paris");
|
||||
});
|
||||
|
||||
it("parses country code (record 2, dataset 100)", { timeout: 5000 }, () => {
|
||||
const iptcData = buildIptcBlock([
|
||||
{ record: 2, dataset: 0x64, value: "FR" },
|
||||
]);
|
||||
const data = wrapInPhotoshop3(iptcData);
|
||||
const result = readIptc(data);
|
||||
expect(result.countryCode).toBe("FR");
|
||||
});
|
||||
|
||||
it("parses country name (record 2, dataset 101)", { timeout: 5000 }, () => {
|
||||
const iptcData = buildIptcBlock([
|
||||
{ record: 2, dataset: 0x65, value: "France" },
|
||||
]);
|
||||
const data = wrapInPhotoshop3(iptcData);
|
||||
const result = readIptc(data);
|
||||
expect(result.country).toBe("France");
|
||||
});
|
||||
|
||||
it("parses source (record 2, dataset 115)", { timeout: 5000 }, () => {
|
||||
const iptcData = buildIptcBlock([
|
||||
{ record: 2, dataset: 0x73, value: "Reuters" },
|
||||
]);
|
||||
const data = wrapInPhotoshop3(iptcData);
|
||||
const result = readIptc(data);
|
||||
expect(result.source).toBe("Reuters");
|
||||
});
|
||||
|
||||
it("parses multiple fields together", { timeout: 5000 }, () => {
|
||||
const iptcData = buildIptcBlock([
|
||||
{ record: 2, dataset: 0x05, value: "Test Headline" },
|
||||
{ record: 2, dataset: 0x74, value: "© 2024" },
|
||||
{ record: 2, dataset: 0x19, value: "tag1" },
|
||||
]);
|
||||
const data = wrapInPhotoshop3(iptcData);
|
||||
const result = readIptc(data);
|
||||
expect(result.headline).toBe("Test Headline");
|
||||
expect(result.copyright).toBe("© 2024");
|
||||
expect(result.keywords).toEqual(["tag1"]);
|
||||
});
|
||||
|
||||
it("skips non-record-2 datasets", { timeout: 5000 }, () => {
|
||||
const iptcData = buildIptcBlock([
|
||||
{ record: 1, dataset: 0x5a, value: "should be skipped" },
|
||||
{ record: 2, dataset: 0x05, value: "headline" },
|
||||
]);
|
||||
const data = wrapInPhotoshop3(iptcData);
|
||||
const result = readIptc(data);
|
||||
expect(result.headline).toBe("headline");
|
||||
expect(result.city).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips unknown dataset codes in record 2", { timeout: 5000 }, () => {
|
||||
// Dataset 0x99 is not a known field — should be silently ignored
|
||||
const iptcData = buildIptcBlock([
|
||||
{ record: 2, dataset: 0x99, value: "unknown" },
|
||||
{ record: 2, dataset: 0x05, value: "known headline" },
|
||||
]);
|
||||
const data = wrapInPhotoshop3(iptcData);
|
||||
const result = readIptc(data);
|
||||
expect(result.headline).toBe("known headline");
|
||||
});
|
||||
|
||||
it("returns empty for data with wrong header bytes but sufficient length (line 35 true branch)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// data.length >= 15 (Photoshop 3.0\0 length) but first byte differs from 'P'
|
||||
// This covers the "return undefined" at line 35 (data[i] !== headerBytes[i])
|
||||
const wrongHeader = new Uint8Array(20).fill(0x00); // all zeros, not "Photoshop 3.0\0"
|
||||
expect(readIptc(wrongHeader)).toEqual({});
|
||||
});
|
||||
|
||||
it("handles 8BIM block with odd-length pascal string name (nameLen odd → no padding)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// 8BIM block with nameLen=1 (odd) → no extra padding byte (covers false branch of if(nameLen%2===0))
|
||||
const header = encodeStr("Photoshop 3.0\0");
|
||||
const iptcData = buildIptcBlock([
|
||||
{ record: 2, dataset: 0x05, value: "odd-name test" },
|
||||
]);
|
||||
// Build 8BIM with resource ID 0x0408 (non-IPTC) and odd-length name, then IPTC block
|
||||
const nonIptcDataBytes = [0x01, 0x02];
|
||||
const nonIptcBlock = [
|
||||
0x38,
|
||||
0x42,
|
||||
0x49,
|
||||
0x4d, // 8BIM
|
||||
0x04,
|
||||
0x08, // resource ID 0x0408
|
||||
0x01,
|
||||
0x41, // pascal string: nameLen=1, name='A' (odd → no padding)
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x02, // data length = 2
|
||||
...nonIptcDataBytes, // data
|
||||
// no padding since 2 is even
|
||||
];
|
||||
const iptcBlock = [
|
||||
0x38,
|
||||
0x42,
|
||||
0x49,
|
||||
0x4d,
|
||||
0x04,
|
||||
0x04,
|
||||
0x00,
|
||||
0x00,
|
||||
(iptcData.length >> 24) & 0xff,
|
||||
(iptcData.length >> 16) & 0xff,
|
||||
(iptcData.length >> 8) & 0xff,
|
||||
iptcData.length & 0xff,
|
||||
...iptcData,
|
||||
];
|
||||
const combined = new Uint8Array([
|
||||
...header,
|
||||
...nonIptcBlock,
|
||||
...iptcBlock,
|
||||
]);
|
||||
const result = readIptc(combined);
|
||||
expect(result.headline).toBe("odd-name test");
|
||||
});
|
||||
|
||||
it("stops when 8BIM data block extends beyond buffer (line 70 true branch)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// 8BIM block where dataLen > remaining bytes → break before reaching IPTC
|
||||
const header = encodeStr("Photoshop 3.0\0");
|
||||
// 8BIM with dataLen=1000 but no data following (buffer ends)
|
||||
const truncatedBlock = [
|
||||
0x38,
|
||||
0x42,
|
||||
0x49,
|
||||
0x4d, // 8BIM
|
||||
0x04,
|
||||
0x08, // resource ID (not IPTC)
|
||||
0x00,
|
||||
0x00, // empty pascal string
|
||||
0x00,
|
||||
0x00,
|
||||
0x03,
|
||||
0xe8, // dataLen = 1000
|
||||
// only 1 byte of data — triggers overflow break
|
||||
0x00,
|
||||
];
|
||||
const combined = new Uint8Array([...header, ...truncatedBlock]);
|
||||
// Should not throw, returns undefined IPTC block → empty result
|
||||
expect(readIptc(combined)).toEqual({});
|
||||
});
|
||||
|
||||
it("stops when buffer too short for 4-byte dataLen field (line 62 true branch)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Need: data.length >= offset+12 (=27 with 0-pad header=15) to enter while,
|
||||
// then offset+4 > data.length after pascal string processing.
|
||||
// Use nameLen=3 (odd, no padding): 8BIM(4)+resourceId(2)+nameLen(1)+name(3)=10 bytes
|
||||
// offset after = 15+4+2+1+3=25. data.length needs: >=27 to enter while, <29 for break.
|
||||
// data.length=28: 25+4=29>28 → break at line 62.
|
||||
const header = encodeStr("Photoshop 3.0\0"); // 15 bytes
|
||||
const truncatedBlock = [
|
||||
0x38,
|
||||
0x42,
|
||||
0x49,
|
||||
0x4d, // 8BIM (4 bytes)
|
||||
0x04,
|
||||
0x08, // resource ID (2 bytes)
|
||||
0x03,
|
||||
0x41,
|
||||
0x42,
|
||||
0x43, // nameLen=3 + name 'A','B','C' (4 bytes, odd → no padding)
|
||||
0x00,
|
||||
0x01,
|
||||
0x02, // 3 bytes: offset+4=29>28 → triggers line 62 break
|
||||
];
|
||||
const combined = new Uint8Array([...header, ...truncatedBlock]); // 15+13=28 bytes
|
||||
expect(readIptc(combined)).toEqual({});
|
||||
});
|
||||
|
||||
it("returns empty object for non-matching 8BIM signature", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Build Photoshop 3.0 header followed by garbage (no 8BIM)
|
||||
const header = encodeStr("Photoshop 3.0\0");
|
||||
const garbage = [
|
||||
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b,
|
||||
0x0c,
|
||||
];
|
||||
const data = new Uint8Array([...header, ...garbage]);
|
||||
expect(readIptc(data)).toEqual({});
|
||||
});
|
||||
|
||||
it("skips non-IPTC 8BIM resource block (resource ID != 0x0404)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Build Photoshop 3.0 header + 8BIM block with resource ID 0x0408 (not IPTC)
|
||||
// followed by another 8BIM block with resource ID 0x0404
|
||||
const header = encodeStr("Photoshop 3.0\0");
|
||||
const iptcData = buildIptcBlock([
|
||||
{ record: 2, dataset: 0x05, value: "skip test" },
|
||||
]);
|
||||
|
||||
// Non-IPTC 8BIM block: 8BIM + 0x0408 + name(empty) + length=4 + data
|
||||
const nonIptcData = [0x01, 0x02, 0x03, 0x04];
|
||||
const nonIptcBlock = [
|
||||
0x38,
|
||||
0x42,
|
||||
0x49,
|
||||
0x4d, // 8BIM
|
||||
0x04,
|
||||
0x08, // resource ID 0x0408
|
||||
0x00,
|
||||
0x00, // empty pascal string
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x04, // length = 4
|
||||
...nonIptcData, // data (4 bytes, even)
|
||||
];
|
||||
|
||||
// IPTC 8BIM block
|
||||
const iptcBlock = [
|
||||
0x38,
|
||||
0x42,
|
||||
0x49,
|
||||
0x4d, // 8BIM
|
||||
0x04,
|
||||
0x04, // resource ID 0x0404
|
||||
0x00,
|
||||
0x00, // empty pascal string
|
||||
(iptcData.length >> 24) & 0xff,
|
||||
(iptcData.length >> 16) & 0xff,
|
||||
(iptcData.length >> 8) & 0xff,
|
||||
iptcData.length & 0xff,
|
||||
...iptcData,
|
||||
];
|
||||
|
||||
const combined = new Uint8Array([
|
||||
...header,
|
||||
...nonIptcBlock,
|
||||
...iptcBlock,
|
||||
]);
|
||||
const result = readIptc(combined);
|
||||
expect(result.headline).toBe("skip test");
|
||||
});
|
||||
|
||||
it("handles odd-length resource data padding in non-IPTC block", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Non-IPTC resource with odd data length → should add 1-byte padding when skipping
|
||||
const header = encodeStr("Photoshop 3.0\0");
|
||||
const iptcData = buildIptcBlock([
|
||||
{ record: 2, dataset: 0x05, value: "padded" },
|
||||
]);
|
||||
|
||||
// Non-IPTC 8BIM with 3 bytes of data (odd)
|
||||
const oddData = [0x01, 0x02, 0x03];
|
||||
const nonIptcBlock = [
|
||||
0x38,
|
||||
0x42,
|
||||
0x49,
|
||||
0x4d,
|
||||
0x04,
|
||||
0x08,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x03, // length=3 (odd)
|
||||
...oddData,
|
||||
// parser should skip 1 extra byte for even alignment
|
||||
0x00,
|
||||
];
|
||||
|
||||
const iptcBlock = [
|
||||
0x38,
|
||||
0x42,
|
||||
0x49,
|
||||
0x4d,
|
||||
0x04,
|
||||
0x04,
|
||||
0x00,
|
||||
0x00,
|
||||
(iptcData.length >> 24) & 0xff,
|
||||
(iptcData.length >> 16) & 0xff,
|
||||
(iptcData.length >> 8) & 0xff,
|
||||
iptcData.length & 0xff,
|
||||
...iptcData,
|
||||
];
|
||||
|
||||
const combined = new Uint8Array([
|
||||
...header,
|
||||
...nonIptcBlock,
|
||||
...iptcBlock,
|
||||
]);
|
||||
const result = readIptc(combined);
|
||||
expect(result.headline).toBe("padded");
|
||||
});
|
||||
|
||||
it("stops reading IPTC block when size overflows buffer", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Build IPTC block where last dataset claims size > remaining bytes
|
||||
const items: number[] = [];
|
||||
// Valid headline entry
|
||||
const headlineBytes = encodeStr("test");
|
||||
items.push(0x1c, 2, 0x05, 0x00, headlineBytes.length, ...headlineBytes);
|
||||
// Truncated entry: claims 100 bytes but only 1 remains
|
||||
items.push(0x1c, 2, 0x78, 0x00, 100); // size=100 but no data follows
|
||||
|
||||
const iptcBlock = new Uint8Array(items);
|
||||
const data = wrapInPhotoshop3(iptcBlock);
|
||||
// Should not throw, just stop at the truncated entry
|
||||
const result = readIptc(data);
|
||||
expect(result.headline).toBe("test");
|
||||
expect(result.caption).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips non-0x1c marker bytes in IPTC block", { timeout: 5000 }, () => {
|
||||
// Build IPTC block with junk bytes before a valid entry
|
||||
const validEntry = [0x1c, 2, 0x05, 0x00, 4, ...encodeStr("head")];
|
||||
const iptcBlock = new Uint8Array([0x00, 0x05, 0xab, ...validEntry]);
|
||||
const data = wrapInPhotoshop3(iptcBlock);
|
||||
const result = readIptc(data);
|
||||
expect(result.headline).toBe("head");
|
||||
});
|
||||
});
|
||||
155
packages/media/image/exif/src/iptc/read-iptc.ts
Normal file
155
packages/media/image/exif/src/iptc/read-iptc.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import type { IptcData } from "../types.js";
|
||||
|
||||
// IPTC IIM dataset codes (record 2)
|
||||
const DS_OBJECT_NAME = 0x05; // 5 — Headline
|
||||
const DS_BYLINE = 0x50; // 80 — Creator/Byline
|
||||
const DS_CAPTION = 0x78; // 120 — Caption/Abstract
|
||||
const DS_KEYWORDS = 0x19; // 25 — Keywords
|
||||
const DS_CITY = 0x5a; // 90 — City
|
||||
const DS_COUNTRY_CODE = 0x64; // 100 — CountryCode
|
||||
const DS_COUNTRY_NAME = 0x65; // 101 — Country Name
|
||||
const DS_COPYRIGHT = 0x74; // 116 — Copyright Notice
|
||||
const DS_SOURCE = 0x73; // 115 — Source
|
||||
|
||||
// Photoshop 3.0 IPTC block
|
||||
const PHOTOSHOP_HEADER = "Photoshop 3.0\0";
|
||||
// 8BIM resource block type for IPTC-NAA
|
||||
const IPTC_RESOURCE_ID = 0x0404;
|
||||
|
||||
function decodeAscii(bytes: Uint8Array): string {
|
||||
let str = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
str += String.fromCharCode(bytes[i] as number);
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
function readPhotoshopBlocks(data: Uint8Array): Uint8Array | undefined {
|
||||
// Find "8BIM" resource blocks after the Photoshop 3.0 header
|
||||
const headerBytes = Array.from(PHOTOSHOP_HEADER).map((c) =>
|
||||
c.charCodeAt(0)
|
||||
);
|
||||
if (data.length < headerBytes.length) return undefined;
|
||||
|
||||
for (let i = 0; i < headerBytes.length; i++) {
|
||||
if (data[i] !== headerBytes[i]) return undefined;
|
||||
}
|
||||
|
||||
let offset = headerBytes.length;
|
||||
|
||||
while (offset + 12 <= data.length) {
|
||||
// 8BIM signature
|
||||
if (
|
||||
data[offset] !== 0x38 || // '8'
|
||||
data[offset + 1] !== 0x42 || // 'B'
|
||||
data[offset + 2] !== 0x49 || // 'I'
|
||||
data[offset + 3] !== 0x4d // 'M'
|
||||
) {
|
||||
break;
|
||||
}
|
||||
offset += 4;
|
||||
|
||||
// Resource ID (2 bytes, BE)
|
||||
const resourceId =
|
||||
((data[offset] as number) << 8) | (data[offset + 1] as number);
|
||||
offset += 2;
|
||||
|
||||
// Pascal string (1 byte length + data, padded to even)
|
||||
const nameLen = data[offset] as number;
|
||||
offset += 1 + nameLen;
|
||||
if (nameLen % 2 === 0) offset += 1; // padding
|
||||
|
||||
// Resource data length (4 bytes, BE)
|
||||
if (offset + 4 > data.length) break;
|
||||
const dataLen =
|
||||
((data[offset] as number) << 24) |
|
||||
((data[offset + 1] as number) << 16) |
|
||||
((data[offset + 2] as number) << 8) |
|
||||
(data[offset + 3] as number);
|
||||
offset += 4;
|
||||
|
||||
if (offset + dataLen > data.length) break;
|
||||
|
||||
if (resourceId === IPTC_RESOURCE_ID) {
|
||||
return data.slice(offset, offset + dataLen);
|
||||
}
|
||||
|
||||
offset += dataLen;
|
||||
if (dataLen % 2 !== 0) offset += 1; // padding
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses IPTC IIM metadata from a Photoshop 3.0 APP13 segment payload.
|
||||
*
|
||||
* Extracts standard fields from IPTC Record 2 datasets.
|
||||
* Returns an empty object if no recognisable IPTC data is found.
|
||||
*/
|
||||
export function readIptc(data: Uint8Array): IptcData {
|
||||
const iptcBlock = readPhotoshopBlocks(data);
|
||||
if (!iptcBlock) return {};
|
||||
|
||||
const result: IptcData = {};
|
||||
const keywords: string[] = [];
|
||||
|
||||
let offset = 0;
|
||||
while (offset + 5 <= iptcBlock.length) {
|
||||
const marker = iptcBlock[offset];
|
||||
if (marker !== 0x1c) {
|
||||
offset++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const record = iptcBlock[offset + 1] as number;
|
||||
const dataset = iptcBlock[offset + 2] as number;
|
||||
const size =
|
||||
((iptcBlock[offset + 3] as number) << 8) |
|
||||
(iptcBlock[offset + 4] as number);
|
||||
offset += 5;
|
||||
|
||||
if (offset + size > iptcBlock.length) break;
|
||||
|
||||
// Only process record 2 (Application Record)
|
||||
if (record === 2) {
|
||||
const fieldBytes = iptcBlock.slice(offset, offset + size);
|
||||
const value = decodeAscii(fieldBytes);
|
||||
|
||||
switch (dataset) {
|
||||
case DS_OBJECT_NAME:
|
||||
result.headline = value;
|
||||
break;
|
||||
case DS_BYLINE:
|
||||
result.creator = value;
|
||||
break;
|
||||
case DS_CAPTION:
|
||||
result.caption = value;
|
||||
break;
|
||||
case DS_KEYWORDS:
|
||||
keywords.push(value);
|
||||
break;
|
||||
case DS_CITY:
|
||||
result.city = value;
|
||||
break;
|
||||
case DS_COUNTRY_CODE:
|
||||
result.countryCode = value;
|
||||
break;
|
||||
case DS_COUNTRY_NAME:
|
||||
result.country = value;
|
||||
break;
|
||||
case DS_COPYRIGHT:
|
||||
result.copyright = value;
|
||||
break;
|
||||
case DS_SOURCE:
|
||||
result.source = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
offset += size;
|
||||
}
|
||||
|
||||
if (keywords.length > 0) result.keywords = keywords;
|
||||
return result;
|
||||
}
|
||||
7
packages/media/image/exif/src/jpeg/index.ts
Normal file
7
packages/media/image/exif/src/jpeg/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
isExifSegment,
|
||||
isIptcSegment,
|
||||
isXmpSegment,
|
||||
type JpegSegment,
|
||||
parseJpegSegments,
|
||||
} from "./segments";
|
||||
83
packages/media/image/exif/src/jpeg/segments.md
Normal file
83
packages/media/image/exif/src/jpeg/segments.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# JPEG Segments
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Parses the segment structure of a JPEG file and identifies metadata segments (EXIF, XMP, IPTC). JPEG is a container format: each segment is introduced by a 2-byte marker (`0xFF` + type), followed by a 2-byte big-endian length and payload. This module provides the structural parser that upstream readers (EXIF, IPTC, XMP) rely on to locate their data.
|
||||
|
||||
## Features
|
||||
|
||||
- `parseJpegSegments` — iterates all APP/marker segments up to and including SOS
|
||||
- Handles padding bytes (multiple consecutive `0xFF`) between segments
|
||||
- `isExifSegment` — identifies APP1 segments with the `Exif\0\0` header
|
||||
- `isXmpSegment` — identifies APP1 segments with the XMP namespace header
|
||||
- `isIptcSegment` — identifies APP13 segments with the `Photoshop 3.0\0` header
|
||||
- Bounds-checking with `ExifError(PARSE_ERROR)` on malformed input
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { parseJpegSegments, isExifSegment } from "@suaveplan/exif";
|
||||
|
||||
const segments = parseJpegSegments(jpegBuffer);
|
||||
const exifSeg = segments.find(isExifSegment);
|
||||
if (exifSeg) {
|
||||
console.log("EXIF data found, length:", exifSeg.data.length);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { parseJpegSegments, isExifSegment, isXmpSegment, isIptcSegment } from "@suaveplan/exif";
|
||||
|
||||
const segments = parseJpegSegments(jpegBuffer);
|
||||
|
||||
for (const seg of segments) {
|
||||
if (isExifSegment(seg)) {
|
||||
console.log(`EXIF APP1 at offset ${seg.offset}, ${seg.data.length} bytes`);
|
||||
} else if (isXmpSegment(seg)) {
|
||||
console.log(`XMP APP1 at offset ${seg.offset}`);
|
||||
} else if (isIptcSegment(seg)) {
|
||||
console.log(`IPTC APP13 at offset ${seg.offset}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `JpegSegment`
|
||||
|
||||
```ts
|
||||
interface JpegSegment {
|
||||
marker: number; // e.g. 0xFFE1 for APP1
|
||||
data: Uint8Array; // payload (length field excluded)
|
||||
offset: number; // byte offset of marker in original buffer
|
||||
}
|
||||
```
|
||||
|
||||
### `parseJpegSegments(buffer: Uint8Array): JpegSegment[]`
|
||||
|
||||
Parses all JPEG segments from `buffer`. Returns segments from SOI through SOS (inclusive). Throws `ExifError(PARSE_ERROR)` if:
|
||||
|
||||
- The buffer does not start with `FF D8` (SOI)
|
||||
- A segment's length field is less than 2
|
||||
- A segment's data extends beyond the buffer
|
||||
|
||||
### `isExifSegment(seg: JpegSegment): boolean`
|
||||
|
||||
Returns `true` if `seg` is an APP1 segment (`0xFFE1`) whose payload begins with `Exif\0\0`.
|
||||
|
||||
### `isXmpSegment(seg: JpegSegment): boolean`
|
||||
|
||||
Returns `true` if `seg` is an APP1 segment whose payload begins with `http://ns.adobe.com/xap/1.0/\0`.
|
||||
|
||||
### `isIptcSegment(seg: JpegSegment): boolean`
|
||||
|
||||
Returns `true` if `seg` is an APP13 segment (`0xFFED`) whose payload begins with `Photoshop 3.0\0`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The parser skips padding `0xFF` bytes between markers as permitted by the JFIF specification. Standalone markers (SOI, EOI, RST0–RST7) have no length field and are handled as zero-data segments. Parsing stops at the SOS marker to avoid processing compressed image data as segments. The `data` field of each segment excludes the 2-byte length field, so `data.length === segmentLength - 2`.
|
||||
225
packages/media/image/exif/src/jpeg/segments.test.ts
Normal file
225
packages/media/image/exif/src/jpeg/segments.test.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { ExifError } from "../errors/exif-error.js";
|
||||
import {
|
||||
isExifSegment,
|
||||
isIptcSegment,
|
||||
isXmpSegment,
|
||||
parseJpegSegments,
|
||||
} from "./segments.js";
|
||||
|
||||
function buildMinimalJpeg(app1Data?: Uint8Array): Uint8Array {
|
||||
const soi = new Uint8Array([0xff, 0xd8]);
|
||||
const eoi = new Uint8Array([0xff, 0xd9]);
|
||||
if (!app1Data) return new Uint8Array([...soi, ...eoi]);
|
||||
const marker = new Uint8Array([0xff, 0xe1]);
|
||||
const size = new Uint8Array(2);
|
||||
const dv = new DataView(size.buffer);
|
||||
dv.setUint16(0, app1Data.length + 2, false);
|
||||
return new Uint8Array([...soi, ...marker, ...size, ...app1Data, ...eoi]);
|
||||
}
|
||||
|
||||
function makeExifApp1Header(): Uint8Array {
|
||||
return new Uint8Array([0x45, 0x78, 0x69, 0x66, 0x00, 0x00]);
|
||||
}
|
||||
|
||||
function makeXmpApp1Header(): Uint8Array {
|
||||
const str = "http://ns.adobe.com/xap/1.0/\0";
|
||||
return new Uint8Array(Array.from(str).map((c) => c.charCodeAt(0)));
|
||||
}
|
||||
|
||||
function makeIptcHeader(): Uint8Array {
|
||||
const str = "Photoshop 3.0\0";
|
||||
return new Uint8Array(Array.from(str).map((c) => c.charCodeAt(0)));
|
||||
}
|
||||
|
||||
describe("parseJpegSegments", () => {
|
||||
it("parses minimal JPEG (SOI + EOI)", { timeout: 5000 }, () => {
|
||||
const jpeg = buildMinimalJpeg();
|
||||
const segs = parseJpegSegments(jpeg);
|
||||
expect(segs.length).toBeGreaterThanOrEqual(1);
|
||||
expect(segs[0]?.marker).toBe(0xffd8);
|
||||
});
|
||||
|
||||
it("parses JPEG with APP1 segment", { timeout: 5000 }, () => {
|
||||
const app1 = makeExifApp1Header();
|
||||
const jpeg = buildMinimalJpeg(app1);
|
||||
const segs = parseJpegSegments(jpeg);
|
||||
const app1Seg = segs.find((s) => s.marker === 0xffe1);
|
||||
expect(app1Seg).toBeDefined();
|
||||
expect(app1Seg?.data.length).toBe(app1.length);
|
||||
});
|
||||
|
||||
it("throws ExifError for non-JPEG buffer", { timeout: 5000 }, () => {
|
||||
const buf = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
|
||||
expect(() => parseJpegSegments(buf)).toThrow(ExifError);
|
||||
});
|
||||
|
||||
it("throws for invalid segment length < 2", { timeout: 5000 }, () => {
|
||||
// SOI + APP1 marker + length=1 (invalid)
|
||||
const buf = new Uint8Array([0xff, 0xd8, 0xff, 0xe1, 0x00, 0x01]);
|
||||
expect(() => parseJpegSegments(buf)).toThrow(ExifError);
|
||||
});
|
||||
|
||||
it("throws for segment data extending beyond buffer", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// SOI + APP1 marker + length=100 but only 2 data bytes
|
||||
const buf = new Uint8Array([
|
||||
0xff, 0xd8, 0xff, 0xe1, 0x00, 0x64, 0x00, 0x00,
|
||||
]);
|
||||
expect(() => parseJpegSegments(buf)).toThrow(ExifError);
|
||||
});
|
||||
|
||||
it("handles empty JPEG (only SOI)", { timeout: 5000 }, () => {
|
||||
const buf = new Uint8Array([0xff, 0xd8]);
|
||||
const segs = parseJpegSegments(buf);
|
||||
expect(segs.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isExifSegment", () => {
|
||||
it("returns true for EXIF APP1", { timeout: 5000 }, () => {
|
||||
const data = makeExifApp1Header();
|
||||
const seg = { marker: 0xffe1, data, offset: 0 };
|
||||
expect(isExifSegment(seg)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for non-APP1 marker", { timeout: 5000 }, () => {
|
||||
const data = makeExifApp1Header();
|
||||
const seg = { marker: 0xffe2, data, offset: 0 };
|
||||
expect(isExifSegment(seg)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for APP1 without Exif header", { timeout: 5000 }, () => {
|
||||
const data = new Uint8Array([0x00, 0x01, 0x02, 0x03, 0x04, 0x05]);
|
||||
const seg = { marker: 0xffe1, data, offset: 0 };
|
||||
expect(isExifSegment(seg)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for empty data", { timeout: 5000 }, () => {
|
||||
const seg = { marker: 0xffe1, data: new Uint8Array(0), offset: 0 };
|
||||
expect(isExifSegment(seg)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isXmpSegment", () => {
|
||||
it("returns true for XMP APP1", { timeout: 5000 }, () => {
|
||||
const data = makeXmpApp1Header();
|
||||
const seg = { marker: 0xffe1, data, offset: 0 };
|
||||
expect(isXmpSegment(seg)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for EXIF APP1", { timeout: 5000 }, () => {
|
||||
const data = makeExifApp1Header();
|
||||
const seg = { marker: 0xffe1, data, offset: 0 };
|
||||
expect(isXmpSegment(seg)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for wrong marker", { timeout: 5000 }, () => {
|
||||
const data = makeXmpApp1Header();
|
||||
const seg = { marker: 0xffe2, data, offset: 0 };
|
||||
expect(isXmpSegment(seg)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isIptcSegment", () => {
|
||||
it("returns true for IPTC APP13", { timeout: 5000 }, () => {
|
||||
const data = makeIptcHeader();
|
||||
const seg = { marker: 0xffed, data, offset: 0 };
|
||||
expect(isIptcSegment(seg)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for APP1 marker", { timeout: 5000 }, () => {
|
||||
const data = makeIptcHeader();
|
||||
const seg = { marker: 0xffe1, data, offset: 0 };
|
||||
expect(isIptcSegment(seg)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for wrong data (too short)", { timeout: 5000 }, () => {
|
||||
const data = new Uint8Array([0x00, 0x01, 0x02]);
|
||||
const seg = { marker: 0xffed, data, offset: 0 };
|
||||
expect(isIptcSegment(seg)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for data long enough but wrong first byte (covers inner return false in startsWithString)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Data is longer than "Photoshop 3.0\0" (14 bytes) but starts with wrong byte
|
||||
const data = new Uint8Array(20).fill(0x41); // all 'A', not 'P'
|
||||
const seg = { marker: 0xffed, data, offset: 0 };
|
||||
expect(isIptcSegment(seg)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseJpegSegments extra coverage", () => {
|
||||
it("skips 0x00 byte after 0xFF (byte-stuffing)", { timeout: 5000 }, () => {
|
||||
// SOI + 0xFF 0x00 (byte stuffing, should skip) + EOI
|
||||
const buf = new Uint8Array([0xff, 0xd8, 0xff, 0x00, 0xff, 0xd9]);
|
||||
const segs = parseJpegSegments(buf);
|
||||
// Should include SOI and EOI without error
|
||||
const markers = segs.map((s) => s.marker);
|
||||
expect(markers).toContain(0xffd8);
|
||||
});
|
||||
|
||||
it("handles multiple 0xFF padding bytes before marker", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// SOI + extra 0xFF padding + EOI marker
|
||||
const buf = new Uint8Array([0xff, 0xd8, 0xff, 0xff, 0xd9]);
|
||||
expect(() => parseJpegSegments(buf)).not.toThrow();
|
||||
});
|
||||
|
||||
it("includes EOI segment and stops", { timeout: 5000 }, () => {
|
||||
// SOI + EOI: EOI is a standalone marker, parser should stop after it
|
||||
const buf = new Uint8Array([0xff, 0xd8, 0xff, 0xd9]);
|
||||
const segs = parseJpegSegments(buf);
|
||||
const eoi = segs.find((s) => s.marker === 0xffd9);
|
||||
expect(eoi).toBeDefined();
|
||||
});
|
||||
|
||||
it("handles RST marker (standalone, no length)", { timeout: 5000 }, () => {
|
||||
// SOI + RST0 (0xFFD0) + EOI
|
||||
const buf = new Uint8Array([0xff, 0xd8, 0xff, 0xd0, 0xff, 0xd9]);
|
||||
expect(() => parseJpegSegments(buf)).not.toThrow();
|
||||
const segs = parseJpegSegments(buf);
|
||||
expect(segs.some((s) => s.marker === 0xffd0)).toBe(true);
|
||||
});
|
||||
|
||||
it("stops at SOS marker and includes it", { timeout: 5000 }, () => {
|
||||
// SOI + APP1(EXIF) + SOS
|
||||
const exifHeader = new Uint8Array([0x45, 0x78, 0x69, 0x66, 0x00, 0x00]);
|
||||
const jpeg = buildMinimalJpeg(exifHeader);
|
||||
const segs = parseJpegSegments(jpeg);
|
||||
expect(segs.some((s) => s.marker === 0xffe1)).toBe(true);
|
||||
});
|
||||
|
||||
it("skips non-0xFF junk byte between segments", { timeout: 5000 }, () => {
|
||||
// SOI + junk byte (0xAB, not 0xFF) + EOI — exercises the non-prefix skip path
|
||||
const buf = new Uint8Array([0xff, 0xd8, 0xab, 0xff, 0xd9]);
|
||||
expect(() => parseJpegSegments(buf)).not.toThrow();
|
||||
const segs = parseJpegSegments(buf);
|
||||
expect(segs[0]?.marker).toBe(0xffd8);
|
||||
});
|
||||
|
||||
it("breaks when buffer ends with only 0xFF padding bytes (no marker after padding)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// SOI + two 0xFF padding bytes with no marker following — exercises the
|
||||
// "if (offset >= buffer.length) break" guard after the inner 0xFF-skip while
|
||||
const buf = new Uint8Array([0xff, 0xd8, 0xff, 0xff]);
|
||||
expect(() => parseJpegSegments(buf)).not.toThrow();
|
||||
const segs = parseJpegSegments(buf);
|
||||
expect(segs[0]?.marker).toBe(0xffd8);
|
||||
});
|
||||
|
||||
it("breaks when non-standalone marker has no length bytes remaining", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// SOI + APP1 marker byte but no length field — exercises
|
||||
// "if (offset + 2 > buffer.length) break"
|
||||
const buf = new Uint8Array([0xff, 0xd8, 0xff, 0xe1]);
|
||||
expect(() => parseJpegSegments(buf)).not.toThrow();
|
||||
const segs = parseJpegSegments(buf);
|
||||
expect(segs[0]?.marker).toBe(0xffd8);
|
||||
});
|
||||
});
|
||||
172
packages/media/image/exif/src/jpeg/segments.ts
Normal file
172
packages/media/image/exif/src/jpeg/segments.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { EXIF_CODES, ExifError } from "../errors/exif-error.js";
|
||||
|
||||
/**
|
||||
* Represents a single JPEG segment (marker + data).
|
||||
*/
|
||||
export interface JpegSegment {
|
||||
/** Marker byte pair, e.g. 0xFFE1 for APP1 */
|
||||
marker: number;
|
||||
/** Segment payload (excludes the 2-byte length field itself) */
|
||||
data: Uint8Array;
|
||||
/** Byte offset of the marker in the original buffer */
|
||||
offset: number;
|
||||
}
|
||||
|
||||
// Well-known JPEG markers
|
||||
const MARKER_SOI = 0xffd8;
|
||||
const MARKER_EOI = 0xffd9;
|
||||
const MARKER_SOS = 0xffda;
|
||||
|
||||
// Marker byte
|
||||
const MARKER_PREFIX = 0xff;
|
||||
|
||||
// Standalone markers that have no length field
|
||||
const STANDALONE_MARKERS = new Set([
|
||||
0xffd0,
|
||||
0xffd1,
|
||||
0xffd2,
|
||||
0xffd3,
|
||||
0xffd4,
|
||||
0xffd5,
|
||||
0xffd6,
|
||||
0xffd7, // RST0-RST7
|
||||
MARKER_SOI,
|
||||
MARKER_EOI,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Parses all segments from a JPEG buffer.
|
||||
*
|
||||
* Returns segments in order from SOI to the start of image data (SOS).
|
||||
* The SOS marker is included but its data is not parsed further.
|
||||
* Throws ExifError if the buffer does not start with a valid JPEG SOI marker.
|
||||
*/
|
||||
export function parseJpegSegments(buffer: Uint8Array): JpegSegment[] {
|
||||
if (buffer.length < 2 || buffer[0] !== 0xff || buffer[1] !== 0xd8) {
|
||||
throw new ExifError(
|
||||
"Not a valid JPEG: missing SOI marker",
|
||||
EXIF_CODES.PARSE_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
const segments: JpegSegment[] = [];
|
||||
let offset = 0;
|
||||
|
||||
// Include SOI
|
||||
segments.push({ marker: MARKER_SOI, data: new Uint8Array(0), offset: 0 });
|
||||
offset = 2;
|
||||
|
||||
while (offset < buffer.length - 1) {
|
||||
if (buffer[offset] !== MARKER_PREFIX) {
|
||||
// Skip padding bytes (0xFF padding before marker is valid)
|
||||
offset++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip extra 0xFF padding bytes
|
||||
while (offset < buffer.length - 1 && buffer[offset] === MARKER_PREFIX) {
|
||||
offset++;
|
||||
}
|
||||
|
||||
const markerByte = buffer[offset] as number;
|
||||
if (markerByte === 0x00) {
|
||||
offset++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const marker = (MARKER_PREFIX << 8) | markerByte;
|
||||
offset++;
|
||||
|
||||
if (STANDALONE_MARKERS.has(marker)) {
|
||||
segments.push({
|
||||
marker,
|
||||
data: new Uint8Array(0),
|
||||
offset: offset - 2,
|
||||
});
|
||||
if (marker === MARKER_EOI) break;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Read 2-byte length (includes the 2 length bytes but not the 2 marker bytes)
|
||||
if (offset + 2 > buffer.length) break;
|
||||
|
||||
const dv = new DataView(
|
||||
buffer.buffer,
|
||||
buffer.byteOffset,
|
||||
buffer.byteLength
|
||||
);
|
||||
const length = dv.getUint16(offset, false); // always big-endian
|
||||
|
||||
if (length < 2) {
|
||||
throw new ExifError(
|
||||
`Invalid segment length ${length} at offset ${offset}`,
|
||||
EXIF_CODES.PARSE_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
const dataOffset = offset + 2;
|
||||
const dataLength = length - 2;
|
||||
|
||||
if (dataOffset + dataLength > buffer.length) {
|
||||
throw new ExifError(
|
||||
`Segment data extends beyond buffer at offset ${dataOffset}`,
|
||||
EXIF_CODES.PARSE_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
const data = buffer.slice(dataOffset, dataOffset + dataLength);
|
||||
segments.push({ marker, data, offset: offset - 2 });
|
||||
offset = dataOffset + dataLength;
|
||||
|
||||
// Stop before image data
|
||||
if (marker === MARKER_SOS) break;
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
// EXIF APP1 identifier: "Exif\0\0"
|
||||
const EXIF_HEADER = [0x45, 0x78, 0x69, 0x66, 0x00, 0x00];
|
||||
// XMP APP1 identifier
|
||||
const XMP_HEADER = "http://ns.adobe.com/xap/1.0/\0";
|
||||
// Photoshop IPTC identifier
|
||||
const IPTC_HEADER = "Photoshop 3.0\0";
|
||||
|
||||
function startsWith(data: Uint8Array, bytes: number[]): boolean {
|
||||
if (data.length < bytes.length) return false;
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
if (data[i] !== bytes[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function startsWithString(data: Uint8Array, str: string): boolean {
|
||||
if (data.length < str.length) return false;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
if (data[i] !== str.charCodeAt(i)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the segment is an EXIF APP1 segment (marker 0xFFE1 with "Exif\0\0" header).
|
||||
*/
|
||||
export function isExifSegment(seg: JpegSegment): boolean {
|
||||
return seg.marker === 0xffe1 && startsWith(seg.data, EXIF_HEADER);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the segment is an XMP APP1 segment.
|
||||
*/
|
||||
export function isXmpSegment(seg: JpegSegment): boolean {
|
||||
return seg.marker === 0xffe1 && startsWithString(seg.data, XMP_HEADER);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the segment is an IPTC APP13 segment (Photoshop 3.0 header).
|
||||
*/
|
||||
export function isIptcSegment(seg: JpegSegment): boolean {
|
||||
return seg.marker === 0xffed && startsWithString(seg.data, IPTC_HEADER);
|
||||
}
|
||||
107
packages/media/image/exif/src/types.ts
Normal file
107
packages/media/image/exif/src/types.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Shared type definitions for @suaveplan/exif.
|
||||
*/
|
||||
|
||||
export type ByteOrder = "BE" | "LE";
|
||||
export type ImageFormat = "jpeg" | "png" | "webp" | "tiff" | "unknown";
|
||||
|
||||
export interface Rational {
|
||||
numerator: number;
|
||||
denominator: number;
|
||||
}
|
||||
|
||||
export interface GpsCoordinate {
|
||||
degrees: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
ref: "N" | "S" | "E" | "W";
|
||||
}
|
||||
|
||||
export interface GpsData {
|
||||
latitude?: GpsCoordinate;
|
||||
longitude?: GpsCoordinate;
|
||||
/** Altitude in meters */
|
||||
altitude?: number;
|
||||
/** 0 = above sea level, 1 = below sea level */
|
||||
altitudeRef?: 0 | 1;
|
||||
/** HH:MM:SS */
|
||||
timestamp?: string;
|
||||
/** YYYY:MM:DD */
|
||||
datestamp?: string;
|
||||
speed?: number;
|
||||
direction?: number;
|
||||
}
|
||||
|
||||
export interface ExifData {
|
||||
// Image info
|
||||
make?: string;
|
||||
model?: string;
|
||||
software?: string;
|
||||
dateTime?: string;
|
||||
dateTimeOriginal?: string;
|
||||
dateTimeDigitized?: string;
|
||||
// Camera settings
|
||||
exposureTime?: Rational;
|
||||
fNumber?: Rational;
|
||||
iso?: number;
|
||||
focalLength?: Rational;
|
||||
focalLengthIn35mm?: number;
|
||||
flash?: number;
|
||||
whiteBalance?: number;
|
||||
exposureMode?: number;
|
||||
exposureProgram?: number;
|
||||
// Dimensions
|
||||
imageWidth?: number;
|
||||
imageHeight?: number;
|
||||
orientation?: number;
|
||||
// GPS
|
||||
gps?: GpsData;
|
||||
// Raw tags
|
||||
rawTags?: Record<number, unknown>;
|
||||
}
|
||||
|
||||
export interface IptcData {
|
||||
headline?: string;
|
||||
caption?: string;
|
||||
copyright?: string;
|
||||
creator?: string;
|
||||
keywords?: string[];
|
||||
city?: string;
|
||||
countryCode?: string;
|
||||
country?: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface XmpData {
|
||||
/** Raw XMP XML string */
|
||||
raw: string;
|
||||
creator?: string[];
|
||||
description?: Record<string, string>;
|
||||
rights?: string;
|
||||
subject?: string[];
|
||||
title?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ImageMetadata {
|
||||
format: ImageFormat;
|
||||
exif?: ExifData;
|
||||
iptc?: IptcData;
|
||||
xmp?: XmpData;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export interface ReadOptions {
|
||||
skipExif?: boolean;
|
||||
skipIptc?: boolean;
|
||||
skipXmp?: boolean;
|
||||
}
|
||||
|
||||
export interface StripOptions {
|
||||
/** default true */
|
||||
stripExif?: boolean;
|
||||
/** default true */
|
||||
stripIptc?: boolean;
|
||||
/** default true */
|
||||
stripXmp?: boolean;
|
||||
}
|
||||
1
packages/media/image/exif/src/xmp/index.ts
Normal file
1
packages/media/image/exif/src/xmp/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { readXmp } from "./read-xmp";
|
||||
93
packages/media/image/exif/src/xmp/read-xmp.md
Normal file
93
packages/media/image/exif/src/xmp/read-xmp.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# readXmp
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Parses XMP (Extensible Metadata Platform) metadata from a raw XMP XML string extracted from a JPEG APP1 segment. XMP is Adobe's XML-based metadata format built on RDF/XML. This module extracts the most commonly used Dublin Core fields (dc:creator, dc:description, dc:rights, dc:subject, dc:title) and XMP Rights fields using pattern-based extraction rather than a full XML parser, trading completeness for zero dependencies.
|
||||
|
||||
## Features
|
||||
|
||||
- Extracts dc:creator as a string array (from rdf:Seq)
|
||||
- Extracts dc:description as a language-keyed Record (from rdf:Alt)
|
||||
- Extracts dc:rights or xmpRights:UsageTerms as a plain string
|
||||
- Extracts dc:subject as a string array (from rdf:Bag)
|
||||
- Extracts dc:title as a language-keyed Record (from rdf:Alt)
|
||||
- Always returns the raw XMP XML string in the `raw` field
|
||||
- Never throws — partial results on malformed or missing fields
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { readXmp } from "@suaveplan/exif/xmp";
|
||||
|
||||
const xml = `<x:xmpmeta><rdf:RDF>
|
||||
<rdf:Description>
|
||||
<dc:creator><rdf:Seq><rdf:li>Jane Doe</rdf:li></rdf:Seq></dc:creator>
|
||||
<dc:rights>© 2024 Jane Doe</dc:rights>
|
||||
</rdf:Description>
|
||||
</rdf:RDF></x:xmpmeta>`;
|
||||
|
||||
const xmp = readXmp(xml);
|
||||
console.log(xmp.creator); // ["Jane Doe"]
|
||||
console.log(xmp.rights); // "© 2024 Jane Doe"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { readXmp } from "@suaveplan/exif/xmp";
|
||||
|
||||
const xml = `<x:xmpmeta>
|
||||
<rdf:RDF>
|
||||
<rdf:Description>
|
||||
<dc:title>
|
||||
<rdf:Alt>
|
||||
<rdf:li xml:lang="en">Sunset over mountains</rdf:li>
|
||||
<rdf:li xml:lang="fr">Coucher de soleil sur les montagnes</rdf:li>
|
||||
</rdf:Alt>
|
||||
</dc:title>
|
||||
<dc:subject>
|
||||
<rdf:Bag>
|
||||
<rdf:li>landscape</rdf:li>
|
||||
<rdf:li>sunset</rdf:li>
|
||||
<rdf:li>mountains</rdf:li>
|
||||
</rdf:Bag>
|
||||
</dc:subject>
|
||||
</rdf:Description>
|
||||
</rdf:RDF>
|
||||
</x:xmpmeta>`;
|
||||
|
||||
const xmp = readXmp(xml);
|
||||
console.log(xmp.title?.["en"]); // "Sunset over mountains"
|
||||
console.log(xmp.title?.["fr"]); // "Coucher de soleil sur les montagnes"
|
||||
console.log(xmp.subject); // ["landscape", "sunset", "mountains"]
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `readXmp(xmlString: string): XmpData`
|
||||
|
||||
Parses XMP metadata from `xmlString`.
|
||||
|
||||
**Returns:** `XmpData` with all recognised fields populated. Always includes `raw: xmlString`. Fields absent from the XMP are `undefined` in the result.
|
||||
|
||||
**Does not throw.** Returns `{ raw: xmlString }` on empty or unrecognised input.
|
||||
|
||||
### `XmpData`
|
||||
|
||||
```ts
|
||||
interface XmpData {
|
||||
raw: string;
|
||||
creator?: string[];
|
||||
description?: Record<string, string>;
|
||||
rights?: string;
|
||||
subject?: string[];
|
||||
title?: Record<string, string>;
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The extraction uses three internal helpers: `extractTextNode` for simple inline values, `extractRdfList` for `rdf:Bag`/`rdf:Seq` item arrays, and `extractRdfAlt` for `rdf:Alt` language maps. Pattern matching is tolerant of namespace prefixes (any prefix or no prefix). The `rdf:Alt` extraction captures optional `xml:lang` attributes and defaults to `"x-default"` when absent. This approach handles the most common XMP output from Lightroom, Photoshop, and camera firmware without the complexity and size of a full DOM parser.
|
||||
135
packages/media/image/exif/src/xmp/read-xmp.test.ts
Normal file
135
packages/media/image/exif/src/xmp/read-xmp.test.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { readXmp } from "./read-xmp.js";
|
||||
|
||||
describe("readXmp", () => {
|
||||
it("returns raw XMP string in result", { timeout: 5000 }, () => {
|
||||
const xml = "<x:xmpmeta/>";
|
||||
const result = readXmp(xml);
|
||||
expect(result.raw).toBe(xml);
|
||||
});
|
||||
|
||||
it("returns { raw } for empty string", { timeout: 5000 }, () => {
|
||||
const result = readXmp("");
|
||||
expect(result.raw).toBe("");
|
||||
expect(result.creator).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parses dc:creator from rdf:Seq", { timeout: 5000 }, () => {
|
||||
const xml = `<x:xmpmeta>
|
||||
<rdf:RDF>
|
||||
<rdf:Description>
|
||||
<dc:creator><rdf:Seq><rdf:li>Jane Doe</rdf:li><rdf:li>John Smith</rdf:li></rdf:Seq></dc:creator>
|
||||
</rdf:Description>
|
||||
</rdf:RDF>
|
||||
</x:xmpmeta>`;
|
||||
const result = readXmp(xml);
|
||||
expect(result.creator).toEqual(["Jane Doe", "John Smith"]);
|
||||
});
|
||||
|
||||
it("parses dc:rights as plain text", { timeout: 5000 }, () => {
|
||||
const xml =
|
||||
"<rdf:Description><dc:rights>© 2024 Suaveplan</dc:rights></rdf:Description>";
|
||||
const result = readXmp(xml);
|
||||
expect(result.rights).toBe("© 2024 Suaveplan");
|
||||
});
|
||||
|
||||
it("parses xmpRights:UsageTerms as rights fallback", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const xml =
|
||||
"<rdf:Description><xmpRights:UsageTerms>All rights reserved</xmpRights:UsageTerms></rdf:Description>";
|
||||
const result = readXmp(xml);
|
||||
expect(result.rights).toBe("All rights reserved");
|
||||
});
|
||||
|
||||
it("parses dc:subject from rdf:Bag", { timeout: 5000 }, () => {
|
||||
const xml = `<rdf:Description>
|
||||
<dc:subject><rdf:Bag>
|
||||
<rdf:li>landscape</rdf:li>
|
||||
<rdf:li>sunset</rdf:li>
|
||||
</rdf:Bag></dc:subject>
|
||||
</rdf:Description>`;
|
||||
const result = readXmp(xml);
|
||||
expect(result.subject).toEqual(["landscape", "sunset"]);
|
||||
});
|
||||
|
||||
it("parses dc:title from rdf:Alt with xml:lang", { timeout: 5000 }, () => {
|
||||
const xml = `<rdf:Description>
|
||||
<dc:title><rdf:Alt>
|
||||
<rdf:li xml:lang="en">Sunset</rdf:li>
|
||||
<rdf:li xml:lang="fr">Coucher de soleil</rdf:li>
|
||||
</rdf:Alt></dc:title>
|
||||
</rdf:Description>`;
|
||||
const result = readXmp(xml);
|
||||
expect(result.title?.["en"]).toBe("Sunset");
|
||||
expect(result.title?.["fr"]).toBe("Coucher de soleil");
|
||||
});
|
||||
|
||||
it("parses dc:description from rdf:Alt", { timeout: 5000 }, () => {
|
||||
const xml = `<rdf:Description>
|
||||
<dc:description><rdf:Alt>
|
||||
<rdf:li xml:lang="x-default">A beautiful photo</rdf:li>
|
||||
</rdf:Alt></dc:description>
|
||||
</rdf:Description>`;
|
||||
const result = readXmp(xml);
|
||||
expect(result.description?.["x-default"]).toBe("A beautiful photo");
|
||||
});
|
||||
|
||||
it("handles rdf:Alt without xml:lang attribute (defaults to x-default)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const xml =
|
||||
"<dc:title><rdf:Alt><rdf:li>My Title</rdf:li></rdf:Alt></dc:title>";
|
||||
const result = readXmp(xml);
|
||||
expect(result.title?.["x-default"]).toBe("My Title");
|
||||
});
|
||||
|
||||
it("returns undefined for missing fields", { timeout: 5000 }, () => {
|
||||
const xml = "<x:xmpmeta/>";
|
||||
const result = readXmp(xml);
|
||||
expect(result.creator).toBeUndefined();
|
||||
expect(result.rights).toBeUndefined();
|
||||
expect(result.subject).toBeUndefined();
|
||||
expect(result.title).toBeUndefined();
|
||||
expect(result.description).toBeUndefined();
|
||||
});
|
||||
|
||||
it("extractRdfList returns undefined when rdf:li items are all empty", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Container exists but all rdf:li content is empty/whitespace — returns undefined
|
||||
const xml =
|
||||
"<dc:creator><rdf:Seq><rdf:li> </rdf:li></rdf:Seq></dc:creator>";
|
||||
const result = readXmp(xml);
|
||||
expect(result.creator).toBeUndefined();
|
||||
});
|
||||
|
||||
it("extractRdfAlt returns undefined when all rdf:li content is empty", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const xml =
|
||||
'<dc:title><rdf:Alt><rdf:li xml:lang="en"> </rdf:li></rdf:Alt></dc:title>';
|
||||
const result = readXmp(xml);
|
||||
expect(result.title).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parses full realistic XMP snippet", { timeout: 5000 }, () => {
|
||||
const xml = `<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
|
||||
<x:xmpmeta xmlns:x="adobe:ns:meta/">
|
||||
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:xmpRights="http://ns.adobe.com/xap/1.0/rights/">
|
||||
<dc:creator><rdf:Seq><rdf:li>Ansel Adams</rdf:li></rdf:Seq></dc:creator>
|
||||
<dc:rights>© Ansel Adams</dc:rights>
|
||||
<dc:subject><rdf:Bag><rdf:li>mountains</rdf:li><rdf:li>nature</rdf:li></rdf:Bag></dc:subject>
|
||||
<dc:title><rdf:Alt><rdf:li xml:lang="en">Yosemite Valley</rdf:li></rdf:Alt></dc:title>
|
||||
</rdf:Description>
|
||||
</rdf:RDF>
|
||||
</x:xmpmeta>`;
|
||||
const result = readXmp(xml);
|
||||
expect(result.creator).toEqual(["Ansel Adams"]);
|
||||
expect(result.rights).toBe("© Ansel Adams");
|
||||
expect(result.subject).toEqual(["mountains", "nature"]);
|
||||
expect(result.title?.["en"]).toBe("Yosemite Valley");
|
||||
});
|
||||
});
|
||||
107
packages/media/image/exif/src/xmp/read-xmp.ts
Normal file
107
packages/media/image/exif/src/xmp/read-xmp.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import type { XmpData } from "../types.js";
|
||||
|
||||
/**
|
||||
* Extracts a text node value from an XML-like element using a simple pattern.
|
||||
* Handles both inline values (<tag>value</tag>) and rdf:Alt/rdf:li patterns.
|
||||
*/
|
||||
function extractTextNode(xml: string, localName: string): string | undefined {
|
||||
const inlinePattern = new RegExp(
|
||||
`<[a-zA-Z0-9_-]*:?${localName}[^>]*>([^<]*)<`,
|
||||
"i"
|
||||
);
|
||||
const inlineMatch = inlinePattern.exec(xml);
|
||||
if (inlineMatch?.[1]?.trim()) {
|
||||
return inlineMatch[1].trim();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts all rdf:li text items from an rdf:Bag or rdf:Seq within a named element.
|
||||
*/
|
||||
function extractRdfList(xml: string, localName: string): string[] | undefined {
|
||||
const containerPattern = new RegExp(
|
||||
`<[a-zA-Z0-9_-]*:?${localName}[^>]*>([\\s\\S]*?)<\\/[a-zA-Z0-9_-]*:?${localName}>`,
|
||||
"i"
|
||||
);
|
||||
const containerMatch = containerPattern.exec(xml);
|
||||
if (!containerMatch?.[1]) return undefined;
|
||||
|
||||
const inner = containerMatch[1];
|
||||
const liPattern =
|
||||
/<(?:[a-zA-Z0-9_-]*:)?li[^>]*>([^<]+)<\/(?:[a-zA-Z0-9_-]*:)?li>/gi;
|
||||
const items: string[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
|
||||
while ((m = liPattern.exec(inner)) !== null) {
|
||||
const text = m[1]?.trim();
|
||||
if (text) items.push(text);
|
||||
}
|
||||
|
||||
return items.length > 0 ? items : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts rdf:Alt/rdf:li values with optional xml:lang attributes.
|
||||
* Returns a Record<lang, value> map. Uses "x-default" as the language key when no lang is specified.
|
||||
*/
|
||||
function extractRdfAlt(
|
||||
xml: string,
|
||||
localName: string
|
||||
): Record<string, string> | undefined {
|
||||
const containerPattern = new RegExp(
|
||||
`<[a-zA-Z0-9_-]*:?${localName}[^>]*>([\\s\\S]*?)<\\/[a-zA-Z0-9_-]*:?${localName}>`,
|
||||
"i"
|
||||
);
|
||||
const containerMatch = containerPattern.exec(xml);
|
||||
if (!containerMatch?.[1]) return undefined;
|
||||
|
||||
const inner = containerMatch[1];
|
||||
const liPattern =
|
||||
/<(?:[a-zA-Z0-9_-]*:)?li(?:\s+xml:lang="([^"]*)")?[^>]*>([^<]+)<\/(?:[a-zA-Z0-9_-]*:)?li>/gi;
|
||||
const result: Record<string, string> = {};
|
||||
let m: RegExpExecArray | null;
|
||||
|
||||
while ((m = liPattern.exec(inner)) !== null) {
|
||||
const lang = m[1]?.trim() ?? "x-default";
|
||||
const text = m[2]?.trim();
|
||||
if (text) result[lang] = text;
|
||||
}
|
||||
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses XMP metadata from a raw XMP XML string.
|
||||
*
|
||||
* Uses pattern-based extraction to pull common Dublin Core and XMP Rights fields.
|
||||
* Does not implement a full XML parser — relies on well-structured XMP output
|
||||
* from standard image editors.
|
||||
*/
|
||||
export function readXmp(xmlString: string): XmpData {
|
||||
const xmp: XmpData = { raw: xmlString };
|
||||
|
||||
// dc:creator — typically an rdf:Seq with rdf:li items
|
||||
const creator = extractRdfList(xmlString, "creator");
|
||||
if (creator) xmp.creator = creator;
|
||||
|
||||
// dc:description — rdf:Alt with lang alternatives
|
||||
const description = extractRdfAlt(xmlString, "description");
|
||||
if (description) xmp.description = description;
|
||||
|
||||
// dc:rights or xmpRights:UsageTerms
|
||||
const rights =
|
||||
extractTextNode(xmlString, "rights") ??
|
||||
extractTextNode(xmlString, "UsageTerms");
|
||||
if (rights) xmp.rights = rights;
|
||||
|
||||
// dc:subject — rdf:Bag with rdf:li items
|
||||
const subject = extractRdfList(xmlString, "subject");
|
||||
if (subject) xmp.subject = subject;
|
||||
|
||||
// dc:title — rdf:Alt with lang alternatives
|
||||
const title = extractRdfAlt(xmlString, "title");
|
||||
if (title) xmp.title = title;
|
||||
|
||||
return xmp;
|
||||
}
|
||||
34
packages/media/image/exif/tests/dep-graph.test.ts
Normal file
34
packages/media/image/exif/tests/dep-graph.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("dep-graph", () => {
|
||||
it("has no @suaveplan/* runtime dependencies", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as {
|
||||
dependencies?: Record<string, string>;
|
||||
peerDependencies?: Record<string, string>;
|
||||
};
|
||||
const runtimeScoped = Object.keys(pkg.dependencies ?? {})
|
||||
.filter((name) => name.startsWith("@suaveplan/"))
|
||||
.sort();
|
||||
expect(runtimeScoped).toEqual([]);
|
||||
const peerScoped = Object.keys(pkg.peerDependencies ?? {}).filter(
|
||||
(name) => name.startsWith("@suaveplan/")
|
||||
);
|
||||
expect(peerScoped).toEqual([]);
|
||||
});
|
||||
|
||||
it("runtime deps are exactly the expected set (sorted)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as { dependencies?: Record<string, string> };
|
||||
const runtime = Object.keys(pkg.dependencies ?? {}).sort();
|
||||
expect(runtime).toEqual(["@opentelemetry/api", "tslib", "zod"]);
|
||||
});
|
||||
});
|
||||
41
packages/media/image/exif/tests/no-disallowed.test.ts
Normal file
41
packages/media/image/exif/tests/no-disallowed.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const SRC = join(__dirname, "..", "src");
|
||||
|
||||
const BANNED: ReadonlyArray<RegExp> = [
|
||||
/\bnode:fs\b/,
|
||||
/\bnode:path\b/,
|
||||
/\bnode:url\b/,
|
||||
/\bprocess\./,
|
||||
/\bBuffer\b/,
|
||||
/\bdocument\b/,
|
||||
/\bwindow\b/,
|
||||
];
|
||||
|
||||
function* walk(dir: string): Generator<string> {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const st = statSync(full);
|
||||
if (st.isDirectory()) yield* walk(full);
|
||||
else if (full.endsWith(".ts") && !full.endsWith(".test.ts")) yield full;
|
||||
}
|
||||
}
|
||||
|
||||
describe("no-disallowed", () => {
|
||||
it("src/ does not reference Node or browser built-ins", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const hits: string[] = [];
|
||||
for (const file of walk(SRC)) {
|
||||
const content = readFileSync(file, "utf8");
|
||||
for (const pattern of BANNED) {
|
||||
if (pattern.test(content)) {
|
||||
hits.push(`${file} matched ${pattern}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(hits).toEqual([]);
|
||||
});
|
||||
});
|
||||
1
packages/media/image/exif/tests/setup/polyfills.ts
Normal file
1
packages/media/image/exif/tests/setup/polyfills.ts
Normal file
@@ -0,0 +1 @@
|
||||
export {};
|
||||
20
packages/media/image/exif/tests/tree-shake.test.ts
Normal file
20
packages/media/image/exif/tests/tree-shake.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("tree-shake", () => {
|
||||
it("sideEffects is false", { timeout: 5000 }, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as { sideEffects?: boolean };
|
||||
expect(pkg.sideEffects).toBe(false);
|
||||
});
|
||||
|
||||
it("exports has at least 7 entries", { timeout: 5000 }, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as { exports?: Record<string, unknown> };
|
||||
expect(pkg.exports).toBeDefined();
|
||||
expect(Object.keys(pkg.exports ?? {}).length).toBeGreaterThanOrEqual(7);
|
||||
});
|
||||
});
|
||||
12
packages/media/image/exif/tsconfig.lib.json
Normal file
12
packages/media/image/exif/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
20
packages/media/image/exif/vite.config.ts
Normal file
20
packages/media/image/exif/vite.config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/exif",
|
||||
plugins: [dts({ entryRoot: "src", tsconfigPath: "./tsconfig.lib.json", insertTypesEntry: true })],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: { preserveModules: true, preserveModulesRoot: "src", entryFileNames: "[name].js" },
|
||||
external: ["tslib", "zod", "@opentelemetry/api", /^node:/],
|
||||
},
|
||||
},
|
||||
});
|
||||
28
packages/media/image/exif/vitest.config.ts
Normal file
28
packages/media/image/exif/vitest.config.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["src/__benchmarks__/**", "dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/**/*.schemas.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/__benchmarks__/**",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
|
||||
},
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user