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

Implement GIF loading/processing #18

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 @@ -29,7 +29,7 @@ Anything that's not listed below is not currently planned to be implemented.
- :white_check_mark: JPEG
- :white_check_mark: PNG
- :white_check_mark: HEIC/HEIF
- :x: GIF
- :construction: GIF
- :x: TIFF
- :x: WebP

Expand Down
132 changes: 132 additions & 0 deletions src/asset/GIF.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { BaseAsset } from './BaseAsset';
import { Asset } from './types';

class Parser {
private pos: number;

constructor(private readonly data: Uint8Array) {
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');
return this.data[this.pos++] + (this.data[this.pos++] << 8);
}

public skip(length: number) {
if (this.pos + length > this.data.length) throw new Error('Buffer underrun');
this.pos += length;
}
}

export class GIF extends BaseAsset implements Asset {
constructor(data: Uint8Array) {
super(data);
if (!GIF.canRead(data)) throw new Error('Not a GIF file');
this.readChunks();
}

public static canRead(buf: Uint8Array): boolean {
return (
buf.length > 6 &&
buf[0] === 0x47 && // G
buf[1] === 0x49 && // I
buf[2] === 0x46 && // F
buf[3] === 0x38 && // 8
(buf[4] === 0x39 || buf[4] === 0x37) && // 9 or 7
buf[5] === 0x61 // a
);
}

public dumpInfo() {
return ['GIF file'].join('\n');
}

private readChunks() {
const parser = new Parser(this.data);

// skip over the GIF87a or GIF89a signature header
parser.skip(6);

// read the "Logical Screen Descriptor"
const logicalScreenWidth = parser.readUInt16();

Check failure on line 57 in src/asset/GIF.ts

View workflow job for this annotation

GitHub Actions / Run eslint TypeScript Linter

'logicalScreenWidth' is assigned a value but never used
const logicalScreenHeight = parser.readUInt16();

Check failure on line 58 in src/asset/GIF.ts

View workflow job for this annotation

GitHub Actions / Run eslint TypeScript Linter

'logicalScreenHeight' is assigned a value but never used
const packedFields = parser.readUInt8();
const backgroundColorIndex = parser.readUInt8();
const pixelAspectRatio = parser.readUInt8();

Check failure on line 61 in src/asset/GIF.ts

View workflow job for this annotation

GitHub Actions / Run eslint TypeScript Linter

'pixelAspectRatio' is assigned a value but never used

// unpack the packed fields
const globalColorTableFlag = packedFields & 0x80;
const colorResolution = (packedFields & 0x70) >> 4;

Check failure on line 65 in src/asset/GIF.ts

View workflow job for this annotation

GitHub Actions / Run eslint TypeScript Linter

'colorResolution' is assigned a value but never used
const sortFlag = packedFields & 0x08;

Check failure on line 66 in src/asset/GIF.ts

View workflow job for this annotation

GitHub Actions / Run eslint TypeScript Linter

'sortFlag' is assigned a value but never used
const globalColorTableSize = 1 << ((packedFields & 0x07) + 1);

// skip over the "Global Color Table" if it is present
if (globalColorTableFlag) {
parser.skip(3 * globalColorTableSize);

if (backgroundColorIndex >= globalColorTableSize)
throw new Error('Malformed GIF (invalid background color index)');
}

// iterate over blocks:
// Every block starts with an exclamation mark ("!") or comma (",").
// The end of the image is marked with a semicolon (";").
for (;;) {
const blockType = parser.readUInt8();
switch (blockType) {
case 0x21: // Extension block ("!")
{
const extensionBlockType = parser.readUInt8();
if (extensionBlockType !== 0xf9)
throw new Error('Malformed GIF (invalid extension block type)');
const extensionBlockDataLength = parser.readUInt8();
parser.skip(extensionBlockDataLength);
if (parser.readUInt8() !== 0) throw new Error('Malformed GIF (invalid block terminator)');
}
break;
case 0x2c: // Image Descriptor (",")
{
// skipping size and position
parser.skip(8);

// decode packed fields
const packedImageDescriptorFields = parser.readUInt8();

const localColorTableFlag = packedImageDescriptorFields & 0x80;
const interlaceFlag = packedImageDescriptorFields & 0x40;

Check failure on line 102 in src/asset/GIF.ts

View workflow job for this annotation

GitHub Actions / Run eslint TypeScript Linter

'interlaceFlag' is assigned a value but never used
const sortFlag = packedImageDescriptorFields & 0x20;

Check failure on line 103 in src/asset/GIF.ts

View workflow job for this annotation

GitHub Actions / Run eslint TypeScript Linter

'sortFlag' is assigned a value but never used
const localColorTableSize = 1 << ((packedImageDescriptorFields & 0x07) + 1);

// skip over the "Local Color Table" if it is present
if (localColorTableFlag) {
parser.skip(3 * localColorTableSize);
}

const lzwCodeSize = parser.readUInt8();

Check failure on line 111 in src/asset/GIF.ts

View workflow job for this annotation

GitHub Actions / Run eslint TypeScript Linter

'lzwCodeSize' is assigned a value but never used

// decode the image data blocks
for (;;) {
const blockSize = parser.readUInt8();
if (blockSize === 0) break; // terminator
parser.skip(blockSize);
}
}
break;
case 0x3b: // Trailer (";")
return;
default:
throw new Error('Malformed GIF (invalid block type)');
}
}
}

public getManifestJUMBF(): Uint8Array | undefined {
return undefined;
}
}
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 './GIF';
export * from './JPEG';
export * from './PNG';
export * from './types';
Binary file added tests/c2pa.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
73 changes: 73 additions & 0 deletions tests/gif-processing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import assert from 'node:assert/strict';
import * as fs from 'node:fs/promises';
import { Asset, JUMBF, Manifest } from '../src';

// location of the GIF images
const baseDir = 'tests';

// test data sets with file names and expected outcomes
const testFiles = {
'c2pa.gif': {
jumbf: false,
valid: undefined,
},
};

describe('Functional GIF Reading Tests', function () {
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 GIF
assert.ok(Asset.GIF.canRead(buf));

// construct the asset
asset = new Asset.GIF(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);
});
}
});
}
});
Loading