Skip to content
Draft
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
4 changes: 4 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions src/pdf/font/embedder.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { PdfObjectRef, PdfDocument } from "../primitives/pdf-document.js";
import { parseTtfBuffer } from "./ttf-lite.js";
import { parseFont } from "./font-parser.js";
import type { FontFaceDef, FontConfig, TtfFontMetrics } from "../../types/fonts.js";
import { log } from "../../debug/log.js";
import { normalizeFontWeight } from "../../css/font-weight.js";
Expand Down Expand Up @@ -130,7 +130,7 @@ export class FontEmbedder {
continue;
}
try {
const metrics = parseTtfBuffer(face.data);
const metrics = parseFont(face.data);
this.faceMetrics.set(face.name, metrics);
} catch (error) {
log("FONT", "ERROR", `Failed to load font ${face.name}`, { error: error instanceof Error ? error.message : String(error) });
Expand Down
30 changes: 30 additions & 0 deletions src/pdf/font/font-parser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { parseTtfBuffer } from './ttf-lite.js';
import { parseWoff } from './woff-parser.js';
import { parseWoff2 } from './woff2-parser.js';
import { TtfFontMetrics } from '../../types/fonts.js';

function getFontSignature(buffer: ArrayBuffer): number {
const view = new DataView(buffer);
return view.getUint32(0);
}

export function parseFont(buffer: ArrayBuffer): TtfFontMetrics {
const signature = getFontSignature(buffer);

let sfntBuffer: ArrayBuffer;

switch (signature) {
case 0x774f4646: // 'wOFF'
sfntBuffer = parseWoff(buffer);
break;
case 0x774f4632: // 'wOF2'
sfntBuffer = parseWoff2(buffer);
break;
default:
// Assuming TTF/OTF if signature is not WOFF or WOFF2
sfntBuffer = buffer;
break;
}

return parseTtfBuffer(sfntBuffer);
}
137 changes: 137 additions & 0 deletions src/pdf/font/woff-parser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { WOFFDeflate, decompress } from '../../compression/deflate.js';

class DataParser {
private view: DataView;
private offset: number = 0;

constructor(buffer: ArrayBuffer) {
this.view = new DataView(buffer);
}

getUint8(): number {
const val = this.view.getUint8(this.offset);
this.offset += 1;
return val;
}

getUint16(): number {
const val = this.view.getUint16(this.offset);
this.offset += 2;
return val;
}

getUint32(): number {
const val = this.view.getUint32(this.offset);
this.offset += 4;
return val;
}

getUint8Array(length: number): Uint8Array {
const arr = new Uint8Array(this.view.buffer, this.view.byteOffset + this.offset, length);
this.offset += length;
return arr;
}

seek(offset: number) {
this.offset = offset;
}

skip(bytes: number) {
this.offset += bytes;
}
}

interface WoffTableHeader {
tag: number;
offset: number;
compLength: number;
origLength: number;
origChecksum: number;
}

function parseWoffHeader(parser: DataParser) {
const signature = parser.getUint32();
if (signature !== 0x774f4646) { // 'wOFF'
throw new Error('Invalid WOFF signature');
}
const flavor = parser.getUint32();
const length = parser.getUint32();
const numTables = parser.getUint16();
parser.skip(2); // reserved
const totalSfntSize = parser.getUint32();
// Skip over versions, meta, and private data offsets/lengths.
// total header size is 44 bytes. we have read 20 bytes so far. 44 - 20 = 24.
parser.skip(24);
return { flavor, length, numTables, totalSfntSize };
}

function parseWoffDirectory(parser: DataParser, numTables: number): WoffTableHeader[] {
const tables: WoffTableHeader[] = [];
for (let i = 0; i < numTables; i++) {
tables.push({
tag: parser.getUint32(),
offset: parser.getUint32(),
compLength: parser.getUint32(),
origLength: parser.getUint32(),
origChecksum: parser.getUint32(),
});
}
return tables;
}

function reconstructSfnt(parser: DataParser, directory: WoffTableHeader[], totalSfntSize: number, flavor: number) {
const sfntBuffer = new ArrayBuffer(totalSfntSize);
const sfntView = new DataView(sfntBuffer);
let sfntOffset = 0;

// SFNT Header
sfntView.setUint32(sfntOffset, flavor);
sfntView.setUint16(sfntOffset + 4, directory.length);
const entrySelector = Math.floor(Math.log2(directory.length));
sfntView.setUint16(sfntOffset + 6, (1 << entrySelector) * 16);
sfntView.setUint16(sfntOffset + 8, entrySelector);
sfntView.setUint16(sfntOffset + 10, directory.length * 16 - (1 << entrySelector) * 16);
sfntOffset += 12;

// Table Directory
const tableDataArray: { header: WoffTableHeader, data: Uint8Array }[] = [];
for (const tableHeader of directory) {
parser.seek(tableHeader.offset);
const compressedData = parser.getUint8Array(tableHeader.compLength);
const tableData = tableHeader.compLength < tableHeader.origLength
? decompress(compressedData, new WOFFDeflate())
: compressedData;

if (tableData.length !== tableHeader.origLength) {
throw new Error(`Table ${tableHeader.tag} decompressed to wrong size`);
}

tableDataArray.push({ header: tableHeader, data: tableData });
}

// Sort tables by tag as required by SFNT format
tableDataArray.sort((a, b) => a.header.tag - b.header.tag);

let tableOffset = sfntOffset + directory.length * 16;
for (const { header, data } of tableDataArray) {
sfntView.setUint32(sfntOffset, header.tag);
sfntView.setUint32(sfntOffset + 4, 0); // checksum placeholder for now
sfntView.setUint32(sfntOffset + 8, tableOffset);
sfntView.setUint32(sfntOffset + 12, header.origLength);
sfntOffset += 16;

// Write table data
const dest = new Uint8Array(sfntBuffer, tableOffset);
dest.set(data);
tableOffset += (header.origLength + 3) & ~3; // align to 4-byte boundary
}

return sfntBuffer;
}

export function parseWoff(buffer: ArrayBuffer): ArrayBuffer {
const parser = new DataParser(buffer);
const { flavor, numTables, totalSfntSize } = parseWoffHeader(parser);
const directory = parseWoffDirectory(parser, numTables);
return reconstructSfnt(parser, directory, totalSfntSize, flavor);
}
161 changes: 161 additions & 0 deletions src/pdf/font/woff2-parser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { WOFF2Brotli, decompressWithTransform } from '../../compression/brotli/index.js';

class DataParser {
private view: DataView;
private offset: number = 0;

constructor(buffer: ArrayBuffer) {
this.view = new DataView(buffer);
}

getUint8(): number {
const val = this.view.getUint8(this.offset);
this.offset += 1;
return val;
}

getUint16(): number {
const val = this.view.getUint16(this.offset);
this.offset += 2;
return val;
}

getInt16(): number {
const val = this.view.getInt16(this.offset);
this.offset += 2;
return val;
}

getUint32(): number {
const val = this.view.getUint32(this.offset);
this.offset += 4;
return val;
}

getUint8Array(length: number): Uint8Array {
const arr = new Uint8Array(this.view.buffer, this.view.byteOffset + this.offset, length);
this.offset += length;
return arr;
}

readBytes(n: number): number[] {
const bytes: number[] = [];
for (let i = 0; i < n; i++) {
bytes.push(this.getUint8());
}
return bytes;
}

seek(offset: number) {
this.offset = offset;
}

skip(bytes: number) {
this.offset += bytes;
}

tell(): number {
return this.offset;
}
}

interface Woff2TableHeader {
flags: number;
tag: number;
origLength: number;
transformLength?: number;
}

function parseWoff2Header(parser: DataParser) {
const signature = parser.getUint32();
if (signature !== 0x774f4632) { // 'wOF2'
throw new Error('Invalid WOFF2 signature');
}
const flavor = parser.getUint32();
const length = parser.getUint32();
const numTables = parser.getUint16();
parser.skip(2); // reserved
const totalSfntSize = parser.getUint32();
const totalCompressedSize = parser.getUint32();
// WOFF2 header is 48 bytes. We have read 24 bytes so far. 48 - 24 = 24.
parser.skip(24);
return { flavor, length, numTables, totalSfntSize, totalCompressedSize };
}

function read255UInt16(parser: DataParser): number {
let value = 0;
let count = 0;
while (count < 5) {
const byte = parser.getUint8();
value = (value << 7) | (byte & 0x7F);
if ((byte & 0x80) === 0) {
return value;
}
count++;
}
throw new Error('Invalid 255UInt16 encoding');
}

function parseWoff2Directory(parser: DataParser, numTables: number): Woff2TableHeader[] {
const tables: Woff2TableHeader[] = [];
let lastTag = 0;

for (let i = 0; i < numTables; i++) {
const flags = parser.getUint8();
const tag = (flags & 0x3F) === 0x3F ? parser.getUint32() : (lastTag + 1);

const origLength = read255UInt16(parser);
const transformLength = ((flags >> 6) & 0x3) !== 0 ? read255UInt16(parser) : undefined;

tables.push({ flags, tag, origLength, transformLength });
lastTag = tag;
}
return tables;
}

function reconstructSfnt(parser: DataParser, directory: Woff2TableHeader[], totalSfntSize: number, flavor: number, totalCompressedSize: number, compressedDataOffset: number) {
const sfntBuffer = new ArrayBuffer(totalSfntSize);
const sfntView = new DataView(sfntBuffer);

parser.seek(compressedDataOffset);
const compressedData = parser.getUint8Array(totalCompressedSize);
const decompressedData = decompressWithTransform(compressedData, new WOFF2Brotli(), directory);

let sfntOffset = 0;
// SFNT Header
sfntView.setUint32(sfntOffset, flavor);
sfntView.setUint16(sfntOffset + 4, directory.length);
const entrySelector = Math.floor(Math.log2(directory.length));
sfntView.setUint16(sfntOffset + 6, (1 << entrySelector) * 16);
sfntView.setUint16(sfntOffset + 8, entrySelector);
sfntView.setUint16(sfntOffset + 10, directory.length * 16 - (1 << entrySelector) * 16);
sfntOffset += 12;

// Table Directory
let tableOffset = sfntOffset + directory.length * 16;
let decompressedOffset = 0;

for (const table of directory) {
sfntView.setUint32(sfntOffset, table.tag);
sfntView.setUint32(sfntOffset + 4, 0); // checksum placeholder
sfntView.setUint32(sfntOffset + 8, tableOffset);
sfntView.setUint32(sfntOffset + 12, table.origLength);
sfntOffset += 16;

const tableData = decompressedData.subarray(decompressedOffset, decompressedOffset + table.origLength);
new Uint8Array(sfntBuffer, tableOffset).set(tableData);

tableOffset += (table.origLength + 3) & ~3;
decompressedOffset += table.origLength;
}

return sfntBuffer;
}

export function parseWoff2(buffer: ArrayBuffer): ArrayBuffer {
const parser = new DataParser(buffer);
const { flavor, numTables, totalSfntSize, totalCompressedSize } = parseWoff2Header(parser);
const directory = parseWoff2Directory(parser, numTables);
const compressedDataOffset = parser.tell();
return reconstructSfnt(parser, directory, totalSfntSize, flavor, totalCompressedSize, compressedDataOffset);
}
Binary file added tests/assets/fonts/lato-bold.woff
Binary file not shown.
Binary file added tests/assets/fonts/lato-bold.woff2
Binary file not shown.
Binary file added tests/assets/fonts/lato-regular.woff
Binary file not shown.
Binary file added tests/assets/fonts/lato-regular.woff2
Binary file not shown.
Loading