Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

TIFF processing support #19

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
173 changes: 173 additions & 0 deletions src/asset/TIFF.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
1 change: 1 addition & 0 deletions src/asset/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './BMFF';
export * from './JPEG';
export * from './PNG';
export * from './TIFF';
export * from './types';
Binary file added tests/c2pa.tiff
Binary file not shown.
Binary file added tests/c2pa_signed_ed25519.tiff
Binary file not shown.
79 changes: 79 additions & 0 deletions tests/tiff-processing.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
}
});
}
});