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

433 lines
16 KiB
TypeScript

/**
* 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 { GroundLabelOverlay } from '../src/ui/ground-labels.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
}
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', () => {
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',
])
}
})
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. 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('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 }> = []
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),
})
},
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)
})
})