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
116 changes: 114 additions & 2 deletions packages/otfjs/src/font.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { asUint8Array } from './buffer/utils.js'
import { Cache, createCache } from './cache.js'
import { NameId, PlatformId } from './enums.js'
import { compositeGlyphComponentMatrix } from './glyph-utils.js'
import { CffTable, readCffTable } from './tables/cff.js'
import { CffTable, parseCharString, readCffTable } from './tables/cff.js'
import { CmapTable, readCmapTable } from './tables/cmap.js'
import { ColrTable, readColrTable } from './tables/colr.js'
import { readTableAsI16Array, readTableAsU8Array } from './tables/common.js'
Expand All @@ -22,7 +22,7 @@ import { MaxpTable, readMaxpTable } from './tables/maxp.js'
import { NameTable, readNameTable } from './tables/name.js'
import { OS2Table, readOS2Table } from './tables/os-2.js'
import { PostTable, readPostTable } from './tables/post.js'
import type { GlyphSimple } from './types.js'
import type { GlyphSimple, Point } from './types.js'
import { toObject } from './utils/utils.js'
import { validateHeader, validateTable } from './validation.js'

Expand Down Expand Up @@ -131,6 +131,12 @@ export class Font {
}

public getGlyph(id: number): GlyphEnriched {
// Check if this is a CFF font
if (this.hasTable('CFF ')) {
return this.getCffGlyph(id)
}

// TrueType font (glyf table)
const loca = this.getTable('loca')
const hmtx = this.getTable('hmtx')

Expand Down Expand Up @@ -194,6 +200,62 @@ export class Font {
return fullGlyph
}

private getCffGlyph(id: number): GlyphEnriched {
const cff = this.getTable('CFF ')
const hmtx = this.getTable('hmtx')

const { advanceWidth } =
hmtx.longHorMetrics[id] ??
hmtx.longHorMetrics[hmtx.longHorMetrics.length - 1]

// Handle missing or out-of-bounds glyphs
if (!cff.charStrings || id >= cff.charStrings.length) {
// Return empty glyph
return {
type: 'simple',
id,
xMin: 0,
yMin: 0,
xMax: 0,
yMax: 0,
endPtsOfContours: [],
instructions: new Uint8Array(0),
points: [],
contoursOverlap: false,
advanceWidth,
}
}

const charString = cff.charStrings[id]
const defaultWidthX = cff.privateDict?.defaultWidthX?.[0] ?? 0
const nominalWidthX = cff.privateDict?.nominalWidthX?.[0] ?? 0

const path = parseCharString(
charString,
cff.globalSubrIndex,
cff.localSubrIndex,
defaultWidthX,
nominalWidthX,
)

// Convert CFF path commands to Point[] format used by TrueType
const { points, endPtsOfContours } = convertCffPathToPoints(path.commands)

return {
type: 'simple',
id,
xMin: path.xMin,
yMin: path.yMin,
xMax: path.xMax,
yMax: path.yMax,
endPtsOfContours,
instructions: new Uint8Array(0),
points,
contoursOverlap: false,
advanceWidth: path.width || advanceWidth,
}
}

public *glyphs() {
const numGlyphs = this.numGlyphs
for (let i = 0; i < numGlyphs; ++i) {
Expand Down Expand Up @@ -266,3 +328,53 @@ export class Font {
}
}
}

// Helper function to convert CFF path commands to Point[] format
function convertCffPathToPoints(
commands: (
| { type: 'moveTo'; x: number; y: number }
| { type: 'lineTo'; x: number; y: number }
| {
type: 'curveTo'
x1: number
y1: number
x2: number
y2: number
x: number
y: number
}
)[],
): { points: Point[]; endPtsOfContours: number[] } {
const points: Point[] = []
const endPtsOfContours: number[] = []
let contourStart = 0

for (const cmd of commands) {

if (cmd.type === 'moveTo') {
// Start a new contour
if (points.length > contourStart) {
endPtsOfContours.push(points.length - 1)
contourStart = points.length
}
// Don't add moveTo as a point, it just sets position
continue
} else if (cmd.type === 'lineTo') {
points.push({ x: cmd.x, y: cmd.y, onCurve: true })
} else if (cmd.type === 'curveTo') {
// Add cubic bezier curve as two quadratic curves
// For CFF, we have cubic curves (x1, y1, x2, y2, x, y)
// We'll approximate with control points
points.push({ x: cmd.x1, y: cmd.y1, onCurve: false })
points.push({ x: cmd.x2, y: cmd.y2, onCurve: false })
points.push({ x: cmd.x, y: cmd.y, onCurve: true })
}
}

// Close the last contour
if (points.length > contourStart) {
endPtsOfContours.push(points.length - 1)
}

return { points, endPtsOfContours }
}
126 changes: 126 additions & 0 deletions packages/otfjs/src/tables/__tests__/cff.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { describe, expect, it } from 'vitest'

import { Reader } from '../../buffer/reader.js'
import { parseCharString } from '../cff.js'

describe('parseCharString', () => {
it('should parse a simple horizontal line', () => {
// CharString: 100 hmoveto 50 hlineto endchar
// hmoveto (22) moves horizontally, hlineto (6) draws horizontal line, endchar (14)
const charString = new Uint8Array([
// 100 (using format: 32-246 means b0 - 139)
239, // 239 - 139 = 100
22, // hmoveto
// 50
189, // 189 - 139 = 50
6, // hlineto
14, // endchar
])

const path = parseCharString(charString, null, null, 0, 0)

expect(path.commands).toHaveLength(2)
expect(path.commands[0]).toEqual({ type: 'moveTo', x: 100, y: 0 })
expect(path.commands[1]).toEqual({ type: 'lineTo', x: 150, y: 0 })
})

it('should parse a simple vertical line', () => {
// CharString: 100 vmoveto 50 vlineto endchar
const charString = new Uint8Array([
239, // 100
4, // vmoveto
189, // 50
7, // vlineto
14, // endchar
])

const path = parseCharString(charString, null, null, 0, 0)

expect(path.commands).toHaveLength(2)
expect(path.commands[0]).toEqual({ type: 'moveTo', x: 0, y: 100 })
expect(path.commands[1]).toEqual({ type: 'lineTo', x: 0, y: 150 })
})

it('should parse a curve', () => {
// CharString: 0 0 rmoveto 10 20 30 40 50 60 rrcurveto endchar
const charString = new Uint8Array([
139, // 0
139, // 0
21, // rmoveto
149, // 10
159, // 20
169, // 30
179, // 40
189, // 50
199, // 60
8, // rrcurveto
14, // endchar
])

const path = parseCharString(charString, null, null, 0, 0)

expect(path.commands).toHaveLength(2)
expect(path.commands[0]).toEqual({ type: 'moveTo', x: 0, y: 0 })
expect(path.commands[1]).toEqual({
type: 'curveTo',
x1: 10,
y1: 20,
x2: 40,
y2: 60,
x: 90,
y: 120,
})
})

it('should handle width in charstring', () => {
// CharString with width: 500 100 hmoveto endchar
const charString = new Uint8Array([
// 500 (needs 16-bit encoding)
28, // indicates 16-bit number follows
1,
244, // 500 in big-endian
239, // 100
22, // hmoveto
14, // endchar
])

const path = parseCharString(charString, null, null, 0, 100)

// Width should be nominalWidthX (100) + first operand (500) = 600
expect(path.width).toBe(600)
expect(path.commands).toHaveLength(1)
expect(path.commands[0]).toEqual({ type: 'moveTo', x: 100, y: 0 })
})

it('should calculate bounds correctly', () => {
// CharString: 10 20 rmoveto 30 40 rlineto endchar
const charString = new Uint8Array([
149, // 10
159, // 20
21, // rmoveto
169, // 30
179, // 40
5, // rlineto
14, // endchar
])

const path = parseCharString(charString, null, null, 0, 0)

expect(path.xMin).toBe(10)
expect(path.yMin).toBe(20)
expect(path.xMax).toBe(40) // 10 + 30
expect(path.yMax).toBe(60) // 20 + 40
})
})

describe('CFF INDEX reading', () => {
it('should handle empty INDEX', () => {
// Create a Reader with an empty INDEX (count = 0)
const data = new Uint8Array([0, 0]) // count = 0
const reader = new Reader(data)

// This tests that readIndex handles count === 0 correctly
// The actual readIndex function is not exported, so we test indirectly
expect(reader.u16()).toBe(0)
})
})
Loading