diff --git a/README.md b/README.md index 1a28fee7..4bf373c8 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Anything that's not listed below is not currently planned to be implemented. - :white_check_mark: PNG - :white_check_mark: HEIC/HEIF - :x: GIF -- :x: TIFF +- :construction: TIFF (Basic support exists, but it is mostly unproven) - :x: WebP ### Supported assertions diff --git a/src/asset/TIFF.ts b/src/asset/TIFF.ts new file mode 100644 index 00000000..288fbd31 --- /dev/null +++ b/src/asset/TIFF.ts @@ -0,0 +1,173 @@ +import { BaseAsset } from './BaseAsset'; +import { Asset } from './types'; + +// helper class to move around the file and read values +class Parser { + private pos: number; + + constructor( + private readonly data: Uint8Array, + private readonly little_endian: boolean, + ) { + this.pos = 0; + } + + public readUInt8(): number { + if (this.pos + 1 > this.data.length) throw new Error('Buffer underrun'); + return this.data[this.pos++]; + } + + public readUInt16(): number { + if (this.pos + 2 > this.data.length) throw new Error('Buffer underrun'); + if (this.little_endian) { + return this.data[this.pos++] + (this.data[this.pos++] << 8); + } else { + return (this.data[this.pos++] << 8) + this.data[this.pos++]; + } + } + + public readUInt32(): number { + if (this.pos + 4 > this.data.length) throw new Error('Buffer underrun'); + if (this.little_endian) { + return ( + this.data[this.pos++] + + (this.data[this.pos++] << 8) + + (this.data[this.pos++] << 16) + + (this.data[this.pos++] << 24) + ); + } else { + return ( + (this.data[this.pos++] << 24) + + (this.data[this.pos++] << 16) + + (this.data[this.pos++] << 8) + + this.data[this.pos++] + ); + } + } + + public seekTo(offset: number) { + if (offset > this.data.length) throw new Error('invalid offset'); + this.pos = offset; + } + + public skip(length: number) { + if (this.pos + length > this.data.length) throw new Error('Buffer underrun'); + this.pos += length; + } +} + +export class TIFF extends BaseAsset implements Asset { + private jumbf: Uint8Array | undefined; + + constructor(data: Uint8Array) { + super(data); + if (!TIFF.canRead(data)) throw new Error('Not a TIFF file'); + this.readChunks(); + } + + public static canRead(buf: Uint8Array): boolean { + if (buf.length < 4) return false; + + // first two bytes contain either "II" or "MM" and serve as + // BOM (byte order mark) for endianness of the TIFF file + const bom = buf[0] + (buf[1] << 8); + + // third and fourth bytes contain the value 42, in little or big + // endian representation, depending on the BOM. + let signature: number; + switch (bom) { + case 0x4949: // little endian + signature = buf[2] + (buf[3] << 8); + break; + case 0x4d4d: // big endian + signature = (buf[2] << 8) + buf[3]; + break; + default: + return false; + } + if (signature !== 0x002a) return false; + + return true; + } + + public dumpInfo() { + return ['TIFF file'].join('\n'); + } + + private readChunks() { + // The first two bytes contain either "II" or "MM" and serve as + // BOM (byte order mark) for the endianness of the TIFF file. + const bom = this.data[0] + (this.data[1] << 8); + if (bom !== 0x4949 && bom !== 0x4d4d) throw new Error('Invalid TIFF file'); + + const parser = new Parser(this.data, bom == 0x4949); + + // skip BOM + parser.skip(2); + + // verify magic number (42) + const magic = parser.readUInt16(); + if (magic != 0x002a) throw new Error('Invalid TIFF file'); + + // locate first IFD ("Image File Directory") + const ifdPosition = parser.readUInt32(); + parser.seekTo(ifdPosition); + + const ifdCount = parser.readUInt16(); + if (ifdCount < 1) throw new Error('Invalid TIFF file'); + for (let i = 0; i < ifdCount; i++) { + const tag = parser.readUInt16(); + const type = parser.readUInt16(); + const count = parser.readUInt32(); + const value_offset = parser.readUInt32(); + + let size: number; + switch (type) { + case 1: // BYTE + case 2: // ASCII + case 6: // SIGNED BYTE + case 17: // SIGNED SHORT + size = 1; + break; + case 3: // SHORT + case 16: // UNSIGNED SHORT + size = 2; + break; + case 4: // LONG + case 5: // UNSIGNED LONG + case 11: // FLOAT + case 12: // DOUBLE + size = 4; + break; + case 7: // UNDEFINED + case 10: // DOUBLE + size = 8; + break; + default: + throw new Error(`Unknown TIFF type ${type}`); + } + + // The C2PA Manifest Store is embedded into the TIFF as a tag + // with ID 52545 (0xcd41) and type UNDEFINED (7). + const manifestStoreTag = 0xcd41; + const manifestStoreType = 7; + if (type === manifestStoreType && tag === manifestStoreTag) { + const jumbf = this.data.slice(value_offset, value_offset + count * size); + + // Extract and validate the length stored in the JUMBF + // (JPEG Universal Media Fragment) itself. Note that it + // always uses big endian notation, regardless of the + // TIFF's endianess. + const jumbfParser = new Parser(jumbf, false); + if (jumbfParser.readUInt32() != count) + throw new Error('Mismatch between TIFF IDF length and JUMBF length'); + + this.jumbf = jumbf; + } + } + } + + public getManifestJUMBF(): Uint8Array | undefined { + return this.jumbf; + } +} diff --git a/src/asset/index.ts b/src/asset/index.ts index c0c93601..1643f1cc 100644 --- a/src/asset/index.ts +++ b/src/asset/index.ts @@ -1,4 +1,5 @@ export * from './BMFF'; export * from './JPEG'; export * from './PNG'; +export * from './TIFF'; export * from './types'; diff --git a/tests/c2pa.tiff b/tests/c2pa.tiff new file mode 100644 index 00000000..bcd82ecf Binary files /dev/null and b/tests/c2pa.tiff differ diff --git a/tests/c2pa_signed_ed25519.tiff b/tests/c2pa_signed_ed25519.tiff new file mode 100644 index 00000000..f5328c62 Binary files /dev/null and b/tests/c2pa_signed_ed25519.tiff differ diff --git a/tests/tiff-processing.test.ts b/tests/tiff-processing.test.ts new file mode 100644 index 00000000..f486bead --- /dev/null +++ b/tests/tiff-processing.test.ts @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import * as fs from 'node:fs/promises'; +import { Asset, JUMBF, Manifest } from '../src'; + +// location of the TIFF images +const baseDir = 'tests'; + +// test data sets with file names and expected outcomes +const testFiles = { + 'c2pa.tiff': { + jumbf: false, + valid: undefined, + }, + 'c2pa_signed_ed25519.tiff': { + jumbf: true, + valid: true, + }, +}; + +describe('Functional TIFF Reading Tests', function () { + this.timeout(0); + + for (const [filename, data] of Object.entries(testFiles)) { + describe(`test file ${filename}`, () => { + let buf: Buffer | undefined = undefined; + it(`loading test file`, async () => { + // load the file into a buffer + buf = await fs.readFile(`${baseDir}/${filename}`); + assert.ok(buf); + }); + + let asset: Asset.Asset | undefined = undefined; + it(`constructing the asset`, async function () { + if (!buf) { + this.skip(); + } + + // ensure it's a TIFF + assert.ok(Asset.TIFF.canRead(buf)); + + // construct the asset + asset = new Asset.TIFF(buf); + }); + + let jumbf: Uint8Array | undefined = undefined; + it(`extract the manifest JUMBF`, async function () { + if (!asset) { + this.skip(); + } + + // extract the C2PA manifest store in binary JUMBF format + jumbf = asset.getManifestJUMBF(); + if (data.jumbf) { + assert.ok(jumbf, 'no JUMBF found'); + } else { + assert.ok(jumbf === undefined, 'unexpected JUMBF found'); + } + }); + + if (data.jumbf) { + it(`validate manifest`, async function () { + if (!jumbf || !asset) { + this.skip(); + } + + // deserialize the JUMBF box structure + const superBox = JUMBF.SuperBox.fromBuffer(jumbf); + + // Read the manifest store from the JUMBF container + const manifests = Manifest.ManifestStore.read(superBox); + + // Validate the asset with the manifest + const validationResult = await manifests.validate(asset); + assert.equal(validationResult.isValid, data.valid); + }); + } + }); + } +});