diablo2-web/tests/issue-504-exocet-fonts.test.ts

607 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Unit & Parity Tests for Issue #504:
* Authentic Diablo II v1.13c Exocet & Formal 436 Typography across Canvas HUD & DOM/CSS.
*/
import { describe, expect, it } from 'vitest'
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import {
D2FontRenderer,
D2_COLOR_HEX,
D2_EXOCET_FONT_STACK,
D2_FORMAL_FONT_STACK,
FONT_BASELINE_OFFSET,
FONT_PX_SIZE,
type D2ColorCode,
type D2FontName,
} from '../src/ui/font.ts'
import { CharacterSheetPanel } from '../src/ui/character-sheet.ts'
import { WorldPanelsHud } from '../src/ui/world-panels.ts'
import { GroundLabelOverlay } from '../src/ui/ground-labels.ts'
import { parseColoredText } from '../src/ui/font.ts'
import { traceBitmapContours, buildTrueTypeFont } from '../scripts/build-d2-ttf.ts'
function signedContourArea(pts: Array<{ x: number; y: number }>): number {
let area = 0
for (let i = 0; i < pts.length; i++) {
const p1 = pts[i]!
const p2 = pts[(i + 1) % pts.length]!
area += p1.x * p2.y - p2.x * p1.y
}
return area / 2
}
function getTrueTypeGlyphContourCount(ttfBuf: Buffer, charCode: number): number {
const view = new DataView(ttfBuf.buffer, ttfBuf.byteOffset, ttfBuf.byteLength)
const numTables = view.getUint16(4, false)
const tables: Record<string, { off: number; len: number }> = {}
for (let i = 0; i < numTables; i++) {
const rec = 12 + i * 16
const tag = String.fromCharCode(
view.getUint8(rec),
view.getUint8(rec + 1),
view.getUint8(rec + 2),
view.getUint8(rec + 3),
)
tables[tag] = {
off: view.getUint32(rec + 8, false),
len: view.getUint32(rec + 12, false),
}
}
const cmapOff = tables.cmap!.off
const subOff = cmapOff + view.getUint32(cmapOff + 8, false)
const segCount = view.getUint16(subOff + 6, false) / 2
const endCodeOff = subOff + 14
const startCodeOff = endCodeOff + segCount * 2 + 2
const idDeltaOff = startCodeOff + segCount * 2
let gIdx = 0
for (let i = 0; i < segCount; i++) {
const end = view.getUint16(endCodeOff + i * 2, false)
const start = view.getUint16(startCodeOff + i * 2, false)
const delta = view.getInt16(idDeltaOff + i * 2, false)
if (charCode >= start && charCode <= end) {
gIdx = (charCode + delta) & 0xffff
break
}
}
const locaOff = tables.loca!.off
const glyfOff = tables.glyf!.off
const gStart = view.getUint32(locaOff + gIdx * 4, false)
const gEnd = view.getUint32(locaOff + (gIdx + 1) * 4, false)
if (gStart === gEnd) return 0
return view.getInt16(glyfOff + gStart, false)
}
describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => {
it('bundles valid TrueType D2Exocet.ttf and D2Formal436.ttf in public/ui/fonts/ with required OpenType tables and exact glyph contour topology', () => {
const fontsDir = join(process.cwd(), 'public', 'ui', 'fonts')
const exocetPath = join(fontsDir, 'D2Exocet.ttf')
const formalPath = join(fontsDir, 'D2Formal436.ttf')
expect(existsSync(exocetPath)).toBe(true)
expect(existsSync(formalPath)).toBe(true)
for (const ttfPath of [exocetPath, formalPath]) {
const buf = readFileSync(ttfPath)
expect(buf.byteLength).toBeGreaterThan(20_000)
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength)
// TrueType sfnt version 0x00010000
expect(view.getUint32(0, false)).toBe(0x00010000)
const numTables = view.getUint16(4, false)
expect(numTables).toBe(10)
const tags: string[] = []
for (let i = 0; i < numTables; i++) {
const recOff = 12 + i * 16
const tag = String.fromCharCode(
view.getUint8(recOff),
view.getUint8(recOff + 1),
view.getUint8(recOff + 2),
view.getUint8(recOff + 3),
)
const offset = view.getUint32(recOff + 8, false)
const length = view.getUint32(recOff + 12, false)
expect(offset + length).toBeLessThanOrEqual(buf.byteLength)
tags.push(tag)
}
expect(tags).toEqual([
'OS/2',
'cmap',
'glyf',
'head',
'hhea',
'hmtx',
'loca',
'maxp',
'name',
'post',
])
}
const exocetBuf = readFileSync(exocetPath)
// D2Exocet 'O' (U+004F) has 1 outer boundary + 4 inner holes around the central cross (⊕) = 5 contours
expect(getTrueTypeGlyphContourCount(exocetBuf, 'O'.charCodeAt(0))).toBe(5)
// Solid strokes 'E', 'F', 'H' have 1 outer contour and 0 highlight pinholes
expect(getTrueTypeGlyphContourCount(exocetBuf, 'E'.charCodeAt(0))).toBe(1)
expect(getTrueTypeGlyphContourCount(exocetBuf, 'F'.charCodeAt(0))).toBe(1)
expect(getTrueTypeGlyphContourCount(exocetBuf, 'H'.charCodeAt(0))).toBe(1)
const formalBuf = readFileSync(formalPath)
// D2Formal436 'O' (U+004F) and 'e' (U+0065) have 1 outer boundary + 1 open counter hole = 2 contours
expect(getTrueTypeGlyphContourCount(formalBuf, 'O'.charCodeAt(0))).toBe(2)
expect(getTrueTypeGlyphContourCount(formalBuf, 'e'.charCodeAt(0))).toBe(2)
// '8' and 'B' have 1 outer boundary + 2 counter holes = 3 contours
expect(getTrueTypeGlyphContourCount(formalBuf, '8'.charCodeAt(0))).toBe(3)
expect(getTrueTypeGlyphContourCount(formalBuf, 'B'.charCodeAt(0))).toBe(3)
// '!', '?', 'i' have separate dot + stem = 2 contours
expect(getTrueTypeGlyphContourCount(formalBuf, '!'.charCodeAt(0))).toBe(2)
expect(getTrueTypeGlyphContourCount(formalBuf, '?'.charCodeAt(0))).toBe(2)
expect(getTrueTypeGlyphContourCount(formalBuf, 'i'.charCodeAt(0))).toBe(2)
})
it('traces clockwise outer contours and counter-clockwise inner holes for Exocet cross-inside-O (⊕) and Formal hollow O', () => {
// 1. Construct a 9x9 Exocet cross-inside-O (⊕): outer ring + central cross (vertical x=4, horizontal y=4)
const w = 9
const h = 9
const exocetO = new Uint8Array(w * h)
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const isBorder = x === 0 || x === w - 1 || y === 0 || y === h - 1
const isCross = x === 4 || y === 4
if (isBorder || isCross) {
exocetO[y * w + x] = 1
}
}
}
const exocetContours = traceBitmapContours(exocetO, w, h, h, 10)
// 1 outer contour + 4 quadrant holes around the central cross = 5 contours
expect(exocetContours).toHaveLength(5)
const exocetAreas = exocetContours.map(signedContourArea)
// In Y-up font coordinates, clockwise outer contour has negative signed area,
// and counter-clockwise inner holes have positive signed area.
const outerContours = exocetAreas.filter(a => a < 0)
const innerHoles = exocetAreas.filter(a => a > 0)
expect(outerContours).toHaveLength(1)
expect(innerHoles).toHaveLength(4)
// 2. Construct a 9x9 Formal hollow O: outer ring only
const formalO = new Uint8Array(w * h)
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const isBorder = x === 0 || x === w - 1 || y === 0 || y === h - 1
if (isBorder) {
formalO[y * w + x] = 1
}
}
}
const formalContours = traceBitmapContours(formalO, w, h, h, 10)
expect(formalContours).toHaveLength(2)
expect(formalContours.map(signedContourArea).filter(a => a < 0)).toHaveLength(1)
expect(formalContours.map(signedContourArea).filter(a => a > 0)).toHaveLength(1)
// 3. Verify buildTrueTypeFont produces a valid sfnt binary from DC6 frames + tblBytes
const frames = Array.from({ length: 256 }, (_, code) => ({
width: code === 79 ? w : 0,
height: code === 79 ? h : 0,
offsetX: 0,
offsetY: 0,
indices: code === 79 ? exocetO : new Uint8Array(0),
}))
const tblBytes = new Uint8Array(12 + 256 * 14)
tblBytes[12 + 79 * 14 + 3] = 10
tblBytes[12 + 79 * 14 + 8] = 79
const ttf = buildTrueTypeFont({
familyName: 'D2ExocetTest',
postScriptName: 'D2ExocetTest-Regular',
frames,
tblBytes,
baselineY: 8,
scale: 50,
isForeground: idx => idx > 0,
})
expect(ttf.byteLength).toBeGreaterThan(500)
const view = new DataView(ttf.buffer, ttf.byteOffset, ttf.byteLength)
expect(view.getUint32(0, false)).toBe(0x00010000)
})
it('registers local @font-face D2Exocet and D2Formal436 across HTML pages and GroundLabelOverlay with zero external CDN links', () => {
const htmlFiles = ['index.html', 'acts-en.html', 'acts.html', 'acts-zh.html']
for (const file of htmlFiles) {
const content = readFileSync(join(process.cwd(), file), 'utf8')
expect(content, `${file} must declare D2Exocet @font-face`).toMatch(/font-family:\s*['"]D2Exocet['"]/)
expect(content, `${file} must reference local /ui/fonts/D2Exocet.ttf`).toMatch(/url\(['"]\/ui\/fonts\/D2Exocet\.ttf['"]\)/)
expect(content, `${file} must declare D2Formal436 @font-face`).toMatch(/font-family:\s*['"]D2Formal436['"]/)
expect(content, `${file} must reference local /ui/fonts/D2Formal436.ttf`).toMatch(/url\(['"]\/ui\/fonts\/D2Formal436\.ttf['"]\)/)
expect(content, `${file} must define --font-display with D2Exocet`).toContain('--font-display: "D2Exocet"')
expect(content, `${file} must define --font-body with D2Formal436`).toContain('--font-body: "D2Formal436"')
// Zero external CDN links
expect(content).not.toContain('fonts.googleapis.com')
expect(content).not.toContain('fonts.gstatic.com')
expect(content).not.toContain('https://')
expect(content).not.toContain('http://')
}
for (const actHtml of ['acts-en.html', 'acts.html', 'acts-zh.html']) {
const content = readFileSync(join(process.cwd(), actHtml), 'utf8')
expect(content).toMatch(/#hud\s*\{[^}]*font-family:\s*var\(--font-display\)/s)
expect(content).toMatch(/#controls\s*\{[^}]*font-family:\s*var\(--font-display\)/s)
expect(content).toMatch(/#dialog\s*\{[^}]*font-family:\s*var\(--font-body\)/s)
}
const groundLabelsSrc = readFileSync(join(process.cwd(), 'src', 'ui', 'ground-labels.ts'), 'utf8')
expect(groundLabelsSrc).toContain('"D2Exocet"')
expect(groundLabelsSrc).toContain('"Times New Roman"')
expect(groundLabelsSrc).not.toContain('Georgia')
})
it('configures D2FontRenderer with D2Exocet and D2Formal436 stacks (never Georgia) and blits genuine 1.13c DC6 glyphs when loaded', () => {
expect(D2_EXOCET_FONT_STACK).toContain('"D2Exocet"')
expect(D2_EXOCET_FONT_STACK).toContain('"Times New Roman"')
expect(D2_EXOCET_FONT_STACK).not.toContain('Georgia')
expect(D2_FORMAL_FONT_STACK).toContain('"D2Formal436"')
expect(D2_FORMAL_FONT_STACK).toContain('"Times New Roman"')
expect(D2_FORMAL_FONT_STACK).not.toContain('Georgia')
const renderer = new D2FontRenderer()
// 1. Before load() or for CJK fallback, ctx.font uses D2_EXOCET_FONT_STACK for Exocet sizes and D2_FORMAL_FONT_STACK for fontformal12
const capturedFonts: Record<string, string> = {}
const fillTextCalls: Array<{ text: string; x: number; y: number; fillStyle: string }> = []
const fallbackCtx = {
save: () => {},
restore: () => {},
measureText: (t: string) => ({ width: t.length * 7 }),
fillText: function (this: { fillStyle: string }, text: string, x: number, y: number) {
fillTextCalls.push({ text, x, y, fillStyle: this.fillStyle })
},
font: '',
textBaseline: 'alphabetic',
fillStyle: '',
} as unknown as CanvasRenderingContext2D
const allSizes: D2FontName[] = ['font6', 'font8', 'fontexocet10', 'font16', 'font30', 'fontformal12']
for (const size of allSizes) {
renderer.drawText(fallbackCtx, 'Rogue O', 100, 100, { font: size, color: 'gold' })
capturedFonts[size] = fallbackCtx.font
}
for (const nonBoldExocetSize of ['font6', 'font8'] as D2FontName[]) {
expect(capturedFonts[nonBoldExocetSize]).toBe(`${FONT_PX_SIZE[nonBoldExocetSize]}px ${D2_EXOCET_FONT_STACK}`)
}
for (const boldExocetSize of ['fontexocet10', 'font16', 'font30'] as D2FontName[]) {
expect(capturedFonts[boldExocetSize]).toBe(`bold ${FONT_PX_SIZE[boldExocetSize]}px ${D2_EXOCET_FONT_STACK}`)
}
expect(capturedFonts.fontformal12).toBe(`${FONT_PX_SIZE.fontformal12}px ${D2_FORMAL_FONT_STACK}`)
// 2. When loaded with a font atlas + metrics.json, ASCII/Latin text blits genuine DC6 bitmap glyphs via ctx.drawImage
const metricsJson = JSON.parse(
readFileSync(join(process.cwd(), 'public', 'ui', 'fonts', 'metrics.json'), 'utf8'),
)
const mockAtlas = { width: 512, height: 512, tag: 'font16-atlas' } as unknown as HTMLImageElement
renderer.setFontAtlas('font16', metricsJson.font16, mockAtlas)
const drawImageCalls: Array<{
img: unknown
sx: number
sy: number
sw: number
sh: number
dx: number
dy: number
dw: number
dh: number
}> = []
let fillTextCount = 0
const blitCtx = {
save: () => {},
restore: () => {},
measureText: (t: string) => ({ width: t.length * 8 }),
fillText: () => {
fillTextCount++
},
drawImage: (
img: unknown,
sx: number,
sy: number,
sw: number,
sh: number,
dx: number,
dy: number,
dw: number,
dh: number,
) => {
drawImageCalls.push({ img, sx, sy, sw, sh, dx, dy, dw, dh })
},
font: '',
textBaseline: 'alphabetic',
fillStyle: '',
} as unknown as CanvasRenderingContext2D
// Render "OT" centered at (200, 100) with shadow=true
renderer.drawText(blitCtx, 'OT', 200, 100, {
font: 'font16',
color: 'gold',
align: 'center',
shadow: true,
})
// Must use DC6 bitmap blitting (0 fillText calls, 4 drawImage calls: 2 shadow + 2 foreground for 'O' and 'T')
expect(fillTextCount).toBe(0)
expect(drawImageCalls).toHaveLength(4)
const meta16 = renderer.getFontMeta('font16')!
const glyphO = meta16.glyphs['O'.charCodeAt(0)]!
const glyphT = meta16.glyphs['T'.charCodeAt(0)]!
const advO = Math.max(4, glyphO.width)
const advT = Math.max(4, glyphT.width)
const expectedWidth = advO + advT
const expectedStartX = Math.round(200 - expectedWidth / 2)
const expectedDstY = Math.round(100 - FONT_BASELINE_OFFSET.font16)
// First glyph ('O'): shadow at (expectedStartX + 1, expectedDstY + 1), foreground at (expectedStartX, expectedDstY)
expect(drawImageCalls[0]).toMatchObject({
sx: glyphO.x,
sy: glyphO.y,
sw: glyphO.frameW,
sh: glyphO.frameH,
dx: expectedStartX + 1,
dy: expectedDstY + 1,
dw: glyphO.frameW,
dh: glyphO.frameH,
})
expect(drawImageCalls[1]).toMatchObject({
sx: glyphO.x,
sy: glyphO.y,
sw: glyphO.frameW,
sh: glyphO.frameH,
dx: expectedStartX,
dy: expectedDstY,
dw: glyphO.frameW,
dh: glyphO.frameH,
})
// Second glyph ('T'): shadow at (expectedStartX + advO + 1, expectedDstY + 1), foreground at (expectedStartX + advO, expectedDstY)
expect(drawImageCalls[2]).toMatchObject({
sx: glyphT.x,
sy: glyphT.y,
sw: glyphT.frameW,
sh: glyphT.frameH,
dx: expectedStartX + advO + 1,
dy: expectedDstY + 1,
dw: glyphT.frameW,
dh: glyphT.frameH,
})
expect(drawImageCalls[3]).toMatchObject({
sx: glyphT.x,
sy: glyphT.y,
sw: glyphT.frameW,
sh: glyphT.frameH,
dx: expectedStartX + advO,
dy: expectedDstY,
dw: glyphT.frameW,
dh: glyphT.frameH,
})
// 3. Common Unicode punctuation (en-dash, smart apostrophe, bullet) normalizes to DC6 glyphs and blits via drawImage
drawImageCalls.length = 0
fillTextCount = 0
renderer.drawText(blitCtx, 'Akara’s • 3–6', 200, 100, {
font: 'font16',
color: 'white',
shadow: false,
})
expect(fillTextCount).toBe(0)
expect(drawImageCalls.length).toBeGreaterThan(0)
// 4. CJK string ("罗格营地") automatically falls back to ctx.fillText even when DC6 atlas is loaded
drawImageCalls.length = 0
fillTextCount = 0
renderer.drawText(blitCtx, '罗格营地', 200, 100, {
font: 'font16',
color: 'gold',
align: 'center',
shadow: true,
})
expect(drawImageCalls).toHaveLength(0)
expect(fillTextCount).toBeGreaterThan(0)
})
it('produces 12 distinct colors and tinted atlases for all 12 Diablo II color codes (ÿc0..ÿc;)', () => {
const codeSeq = 'ÿc0Wÿc1Rÿc2Gÿc3Bÿc4Dÿc5Yÿc6Kÿc7Tÿc8Oÿc9Lÿc:Eÿc;P'
const spans = parseColoredText(codeSeq)
expect(spans).toHaveLength(12)
const expectedColors: D2ColorCode[] = [
'white',
'red',
'green',
'blue',
'gold',
'gray',
'black',
'tan',
'orange',
'yellow',
'darkgreen',
'purple',
]
expect(spans.map(s => s.color)).toEqual(expectedColors)
const hexSet = new Set(expectedColors.map(c => D2_COLOR_HEX[c]))
expect(hexSet.size).toBe(12)
})
it('renders Quest Log description body and NPC speech dialog body in fontformal12 (Formal 436)', () => {
const worldPanels = new WorldPanelsHud()
worldPanels.lang = 'ENG'
worldPanels.selectedActTab = 1
worldPanels.selectedQuestIdx = 0
const drawnCalls: Array<{ text: string; font: D2FontName }> = []
const mockCtx = {
drawImage: () => {},
fillRect: () => {},
strokeRect: () => {},
beginPath: () => {},
moveTo: () => {},
lineTo: () => {},
stroke: () => {},
save: () => {},
restore: () => {},
fillStyle: '',
strokeStyle: '',
lineWidth: 1,
} as unknown as CanvasRenderingContext2D
const spyFont = {
drawText: (
_ctx: CanvasRenderingContext2D,
text: string,
_x: number,
_y: number,
opts?: { font?: D2FontName },
) => {
drawnCalls.push({ text, font: opts?.font ?? 'font16' })
},
measureText: (text: string) => text.length * 7,
} as unknown as D2FontRenderer
worldPanels.drawLeftDockPanel(
mockCtx,
'quest',
{
borderLeftImg: null,
questBgImg: null,
waypointBgImg: null,
stashBgImg: null,
cubeBgImg: null,
vendorBgImg: null,
buySellBtnImg: null,
},
spyFont,
)
// Quest title uses fontexocet10, while wrapped Quest body prose uses fontformal12
expect(drawnCalls.some(c => c.font === 'fontexocet10' && c.text.includes('Den of Evil'))).toBe(true)
const questBodyCalls = drawnCalls.filter(c => c.font === 'fontformal12')
expect(questBodyCalls.length).toBeGreaterThan(0)
expect(questBodyCalls.map(c => c.text).join(' ')).toContain('Evil')
// NPC talking dialog body also uses fontformal12 while header uses font16
drawnCalls.length = 0
worldPanels.npcMenu = {
npcName: 'Akara',
x: 400,
y: 260,
talking: true,
dialogLines: ['There is a place of great evil in the wilderness.'],
}
worldPanels.drawNpcMenu(mockCtx, spyFont, {
boxPiecesImg: { width: 256, height: 12, complete: true, naturalWidth: 256 } as unknown as HTMLImageElement,
focus16Img: null,
})
expect(drawnCalls.some(c => c.font === 'font16' && c.text === 'Akara')).toBe(true)
const npcBodyCalls = drawnCalls.filter(c => c.font === 'fontformal12')
expect(npcBodyCalls.length).toBeGreaterThan(0)
expect(npcBodyCalls.map(c => c.text).join(' ')).toContain('great evil')
})
it('fits English Character Sheet labels inside stone recesses using DC6 font6 metrics', () => {
const metricsJson = JSON.parse(
readFileSync(join(process.cwd(), 'public', 'ui', 'fonts', 'metrics.json'), 'utf8'),
)
const renderer = new D2FontRenderer()
renderer.setFontAtlas('font6', metricsJson.font6, { width: 256, height: 256 } as unknown as HTMLImageElement)
renderer.setFontAtlas('font8', metricsJson.font8, { width: 256, height: 256 } as unknown as HTMLImageElement)
const sheet = new CharacterSheetPanel()
sheet.lang = 'ENG'
sheet.visible = true
const drawnLabels: Array<{ text: string; font: D2FontName; widthPx: number; y: number }> = []
const mockCtx = {
drawImage: () => {},
fillRect: () => {},
strokeRect: () => {},
save: () => {},
restore: () => {},
measureText: (t: string) => ({ width: t.length * 6 }),
fillText: () => {},
font: '',
textBaseline: 'alphabetic',
fillStyle: '',
strokeStyle: '',
} as unknown as CanvasRenderingContext2D
const spyFont = {
drawText: (
_ctx: CanvasRenderingContext2D,
text: string,
_x: number,
y: number,
opts?: { font?: D2FontName },
) => {
const f = opts?.font ?? 'font8'
drawnLabels.push({
text,
font: f,
widthPx: renderer.measureText(text, f),
y,
})
},
measureText: (text: string, font?: D2FontName) => renderer.measureText(text, font),
} as unknown as D2FontRenderer
sheet.draw(
mockCtx,
{
hp: 340,
maxHp: 340,
mana: 420,
maxMana: 420,
stamina: 180,
maxStamina: 180,
leftSkillDmg: '3-6',
rightSkillDmg: '50-100',
},
{
borderLeftImg: null,
charSheetImg: null,
buySellBtnImg: null,
},
spyFont,
)
// Check that multi-word English labels use font6 and fit strictly within their 104px stone recesses (ox+162..ox+266)
const compactLabels = [
'Left Attack',
'Right Attack',
'Damage',
'Stat Points',
'Remaining',
'Fire Resistance',
'Cold Resistance',
'Lightning',
'Resistance',
'Poison Resistance',
]
for (const labelText of compactLabels) {
const match = drawnLabels.find(d => d.text === labelText)
expect(match, `Expected CharacterSheetPanel to draw "${labelText}" in English mode`).toBeDefined()
expect(match!.font).toBe('font6')
expect(match!.widthPx).toBeLessThan(renderer.measureText(labelText, 'font8'))
expect(match!.widthPx).toBeLessThanOrEqual(104)
}
// Verify middle dot U+00B7 (0xB7) in metrics.json uses period width (<= 10px) rather than '?' placeholder width
expect(metricsJson.fontformal12.glyphs[0xb7].width).toBe(metricsJson.fontformal12.glyphs[0x2e].width)
expect(metricsJson.font30.glyphs[0xb7].width).toBe(metricsJson.font30.glyphs[0x2e].width)
})
})