Skip to content

Commit fddf3ed

Browse files
Copilotbschlenk
andauthored
Implement WOFF2 hmtx transform 1 decoder (#23)
* Initial plan * Implement WOFF2 hmtx transform 1 decoder Co-authored-by: bschlenk <1390303+bschlenk@users.noreply.github.com> * Fix linting issues in hmtx transform implementation Co-authored-by: bschlenk <1390303+bschlenk@users.noreply.github.com> * Improve comments in hmtx transform decoder Co-authored-by: bschlenk <1390303+bschlenk@users.noreply.github.com> * Use asDataView helper instead of manual DataView construction Co-authored-by: bschlenk <1390303+bschlenk@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: bschlenk <1390303+bschlenk@users.noreply.github.com>
1 parent 76d04ca commit fddf3ed

2 files changed

Lines changed: 142 additions & 3 deletions

File tree

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { Reader } from '../buffer/reader.js'
2+
import { Writer } from '../buffer/writer.js'
3+
import { error } from '../utils/utils.js'
4+
5+
/**
6+
* Decode WOFF2 hmtx transform 1.
7+
*
8+
* The hmtx transform stores horizontal metrics in a more compact format:
9+
* - A flags byte indicating which data is present
10+
* - An array of advance widths for the first numHMetrics glyphs
11+
* - An array of left side bearings (either explicit or derived from x_mins)
12+
*
13+
* @see https://www.w3.org/TR/WOFF2/#hmtx_table_format
14+
*/
15+
export function decodeHmtxTransform1(
16+
buff: Uint8Array,
17+
numGlyphs: number,
18+
numHMetrics: number,
19+
xMins: number[],
20+
): Uint8Array {
21+
const view = new Reader(buff)
22+
23+
// Read flags byte
24+
const flags = view.u8()
25+
26+
// Bit 0: if 0, proportional LSBs are present in the data (read them)
27+
// if 1, proportional LSBs are omitted (use x_mins instead)
28+
// Bit 1: if 0, monospace LSBs are present in the data (read them)
29+
// if 1, monospace LSBs are omitted (use x_mins instead)
30+
// Bits 2-7: reserved, must be 0
31+
const hasProportionalLsbs = (flags & 0x01) === 0
32+
const hasMonospaceLsbs = (flags & 0x02) === 0
33+
34+
// Validate reserved bits
35+
if ((flags & 0xfc) !== 0) {
36+
error('Invalid hmtx flags: bits 2-7 must be 0')
37+
}
38+
39+
// At least one LSB array must be omitted (replaced by x_mins)
40+
if (hasProportionalLsbs && hasMonospaceLsbs) {
41+
error('Invalid hmtx transform: both LSB arrays present')
42+
}
43+
44+
if (xMins.length !== numGlyphs) {
45+
error(
46+
`x_mins array size mismatch: expected ${numGlyphs}, got ${xMins.length}`,
47+
)
48+
}
49+
50+
if (numHMetrics < 1) {
51+
error('numHMetrics must be at least 1')
52+
}
53+
54+
if (numHMetrics > numGlyphs) {
55+
error('numHMetrics cannot exceed numGlyphs')
56+
}
57+
58+
// Read advance widths for the first numHMetrics glyphs
59+
const advanceWidths: number[] = []
60+
for (let i = 0; i < numHMetrics; i++) {
61+
advanceWidths.push(view.u16())
62+
}
63+
64+
// Read or derive left side bearings for proportional glyphs
65+
const lsbs: number[] = []
66+
for (let i = 0; i < numHMetrics; i++) {
67+
if (hasProportionalLsbs) {
68+
lsbs.push(view.i16())
69+
} else {
70+
lsbs.push(xMins[i])
71+
}
72+
}
73+
74+
// Read or derive left side bearings for monospace glyphs
75+
for (let i = numHMetrics; i < numGlyphs; i++) {
76+
if (hasMonospaceLsbs) {
77+
lsbs.push(view.i16())
78+
} else {
79+
lsbs.push(xMins[i])
80+
}
81+
}
82+
83+
// Write the reconstructed hmtx table
84+
return writeHmtxTable(numGlyphs, numHMetrics, advanceWidths, lsbs)
85+
}
86+
87+
/**
88+
* Write hmtx table in standard OpenType format.
89+
*
90+
* Format:
91+
* - For first numHMetrics glyphs: uint16 advanceWidth, int16 lsb
92+
* - For remaining glyphs: int16 lsb only (advance width is repeated from last)
93+
*/
94+
function writeHmtxTable(
95+
numGlyphs: number,
96+
numHMetrics: number,
97+
advanceWidths: number[],
98+
lsbs: number[],
99+
): Uint8Array {
100+
const writer = new Writer()
101+
102+
// Write longHorMetric records (advance width + lsb)
103+
for (let i = 0; i < numHMetrics; i++) {
104+
writer.u16(advanceWidths[i])
105+
writer.i16(lsbs[i])
106+
}
107+
108+
// Write remaining lsb values only
109+
for (let i = numHMetrics; i < numGlyphs; i++) {
110+
writer.i16(lsbs[i])
111+
}
112+
113+
return writer.toBuffer()
114+
}

‎packages/otfjs/src/woff2/woff2.ts‎

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import decompress from 'brotli/decompress.js'
22

33
import { Reader } from '../buffer/reader.js'
4-
import { asUint8Array } from '../buffer/utils.js'
4+
import { asDataView, asUint8Array } from '../buffer/utils.js'
55
import { asSfntVersion } from '../enum-utils.js'
66
import { buildFont } from '../font-builder.js'
77
import { assert, error, sum } from '../utils/utils.js'
88
import { writeGlyfTable } from '../writers/glyf.js'
99
import { writeLocaTable } from '../writers/loca.js'
1010
import { decodeGlyfTransform0 } from './glyf-transform.js'
11+
import { decodeHmtxTransform1 } from './hmtx-transform.js'
1112
import { KNOWN_TAGS } from './tags.js'
1213
import { WOFF2_SIGNATURE } from './utils.js'
1314

@@ -71,6 +72,11 @@ export function decodeWoff2(buffer: Uint8Array): Uint8Array {
7172
let locaEncountered = false
7273
let offset = 0
7374
const tables: Record<string, Uint8Array> = {}
75+
76+
// Store metadata needed for hmtx reconstruction
77+
let xMins: number[] = []
78+
let numGlyphs = 0
79+
let hmtxTransformBuff: Uint8Array | null = null
7480

7581
for (const table of tableInfo) {
7682
const buff = asUint8Array(data, offset, table.length)
@@ -94,6 +100,10 @@ export function decodeWoff2(buffer: Uint8Array): Uint8Array {
94100
const { glyphs, indexFormat } = decodeGlyfTransform0(buff)
95101
tables[table.tag] = writeGlyfTable(glyphs, loca)
96102
tables.loca = writeLocaTable(loca, indexFormat)
103+
104+
// Extract x_mins for hmtx reconstruction
105+
numGlyphs = glyphs.length
106+
xMins = glyphs.map((g) => g.xMin)
97107

98108
break
99109
}
@@ -112,8 +122,9 @@ export function decodeWoff2(buffer: Uint8Array): Uint8Array {
112122
error(`Unrecognized hmtx transform ${table.transform}`)
113123
}
114124

115-
error('TODO: decode hmtx transform 1')
116-
// decodeHmtxTransform0(``)
125+
// We need to defer hmtx decoding until after glyf is decoded
126+
// Store the buffer for later processing
127+
hmtxTransformBuff = buff
117128
break
118129
}
119130

@@ -125,6 +136,20 @@ export function decodeWoff2(buffer: Uint8Array): Uint8Array {
125136
if (loca.length && !locaEncountered) {
126137
error('Expected entry for loca table after glyf table')
127138
}
139+
140+
// Now process hmtx if it was encountered
141+
if (hmtxTransformBuff !== null) {
142+
// We need numHMetrics from hhea table
143+
if (!tables.hhea) {
144+
error('hhea table required for hmtx reconstruction')
145+
}
146+
147+
// Read numHMetrics from hhea table (at offset 34)
148+
const hheaView = asDataView(tables.hhea)
149+
const numHMetrics = hheaView.getUint16(34)
150+
151+
tables.hmtx = decodeHmtxTransform1(hmtxTransformBuff, numGlyphs, numHMetrics, xMins)
152+
}
128153

129154
return buildFont({ sfntVersion: flavor, tables })
130155
}

0 commit comments

Comments
 (0)