433 lines
16 KiB
TypeScript
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)
|
|
})
|
|
})
|