diff --git a/package-lock.json b/package-lock.json index b9fba3a..915404d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1253,6 +1253,7 @@ "integrity": "sha512-tK3GPFWbirvNgsNKto+UmB/cRtn6TZfyw0D6IKrW55n6Vbs7KJoZtI//kpTKzE/DUmmnAFD8/Ca46s7Obs92/w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.4", "@typescript-eslint/types": "8.46.4", @@ -1546,6 +1547,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2267,6 +2269,7 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4874,6 +4877,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/src/pdf/font/embedder.ts b/src/pdf/font/embedder.ts index 59904da..5efeaaf 100644 --- a/src/pdf/font/embedder.ts +++ b/src/pdf/font/embedder.ts @@ -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"; @@ -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) }); diff --git a/src/pdf/font/font-parser.ts b/src/pdf/font/font-parser.ts new file mode 100644 index 0000000..958a2e7 --- /dev/null +++ b/src/pdf/font/font-parser.ts @@ -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); +} diff --git a/src/pdf/font/woff-parser.ts b/src/pdf/font/woff-parser.ts new file mode 100644 index 0000000..211bb76 --- /dev/null +++ b/src/pdf/font/woff-parser.ts @@ -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); +} diff --git a/src/pdf/font/woff2-parser.ts b/src/pdf/font/woff2-parser.ts new file mode 100644 index 0000000..5f865c2 --- /dev/null +++ b/src/pdf/font/woff2-parser.ts @@ -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); +} diff --git a/tests/assets/fonts/lato-bold.woff b/tests/assets/fonts/lato-bold.woff new file mode 100644 index 0000000..bdc24e4 Binary files /dev/null and b/tests/assets/fonts/lato-bold.woff differ diff --git a/tests/assets/fonts/lato-bold.woff2 b/tests/assets/fonts/lato-bold.woff2 new file mode 100644 index 0000000..11de83f Binary files /dev/null and b/tests/assets/fonts/lato-bold.woff2 differ diff --git a/tests/assets/fonts/lato-regular.woff b/tests/assets/fonts/lato-regular.woff new file mode 100644 index 0000000..a2033ca Binary files /dev/null and b/tests/assets/fonts/lato-regular.woff differ diff --git a/tests/assets/fonts/lato-regular.woff2 b/tests/assets/fonts/lato-regular.woff2 new file mode 100644 index 0000000..ff60934 Binary files /dev/null and b/tests/assets/fonts/lato-regular.woff2 differ diff --git a/tests/e2e/woff-fonts.spec.ts b/tests/e2e/woff-fonts.spec.ts new file mode 100644 index 0000000..1a5f199 --- /dev/null +++ b/tests/e2e/woff-fonts.spec.ts @@ -0,0 +1,72 @@ +import { readFileSync } from "fs"; +import { resolve } from "path"; +import { describe, it, expect } from "vitest"; +import { renderHtmlToPdf } from "../../src/html-to-pdf.js"; + +function toArrayBuffer(buffer: Buffer): ArrayBuffer { + const arrayBuffer = new ArrayBuffer(buffer.length); + const view = new Uint8Array(arrayBuffer); + for (let i = 0; i < buffer.length; ++i) { + view[i] = buffer[i]; + } + return arrayBuffer; +} + +describe("E2E: WOFF and WOFF2 Font Support", () => { + it("should render a PDF with WOFF and WOFF2 fonts without errors", async () => { + const latoRegularWoff = readFileSync(resolve("./tests/assets/fonts/lato-regular.woff")); + const latoBoldWoff2 = readFileSync(resolve("./tests/assets/fonts/lato-bold.woff2")); + + const html = ` + +
+ + + +This is regular text.
+This is bold text.
+ + + `; + + let pdfBuffer: Uint8Array | null = null; + try { + pdfBuffer = await renderHtmlToPdf({ + html, + fontConfig: { + fontFaceDefs: [ + { name: "Lato-Regular", family: "Lato", weight: "normal", data: toArrayBuffer(latoRegularWoff) }, + { name: "Lato-Bold", family: "Lato", weight: "bold", data: toArrayBuffer(latoBoldWoff2) }, + ], + }, + viewportWidth: 800, + viewportHeight: 600, + pageWidth: 595, + pageHeight: 842, + margins: { top: 50, bottom: 50, left: 50, right: 50 }, + css: '' + }); + } catch (e) { + // Fail the test explicitly if an error is thrown + expect(e).toBeNull(); + } + + expect(pdfBuffer).not.toBeNull(); + expect(pdfBuffer).toBeInstanceOf(Uint8Array); + expect(pdfBuffer!.length).toBeGreaterThan(0); + }); +});