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

740 lines
28 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)
// D2Exocet 'Q' and 'q' (Qoppa) have 1 outer boundary + 1 enclosed inner hole = 2 contours
expect(getTrueTypeGlyphContourCount(exocetBuf, 'Q'.charCodeAt(0))).toBe(2)
expect(getTrueTypeGlyphContourCount(exocetBuf, 'q'.charCodeAt(0))).toBe(2)
// D2Exocet '=' has 2 separate horizontal bar contours
expect(getTrueTypeGlyphContourCount(exocetBuf, '='.charCodeAt(0))).toBe(2)
// Solid strokes 'E', 'F', 'H', '5' have 1 outer contour and 0 highlight/shadow 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)
expect(getTrueTypeGlyphContourCount(exocetBuf, '5'.charCodeAt(0))).toBe(1)
const formalBuf = readFileSync(formalPath)
// D2Formal436 'O' (U+004F), 'e' (U+0065), and '&' (U+0026) 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)
expect(getTrueTypeGlyphContourCount(formalBuf, '&'.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', 'bnet.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)
// 5. Mixed ASCII + CJK string ("+45 防御") blits "+45" via DC6 drawImage (6 calls: 3 shadow + 3 fg) and draws "防御" via fillText
drawImageCalls.length = 0
fillTextCount = 0
renderer.drawText(blitCtx, '+45 防御', 200, 100, {
font: 'font16',
color: 'blue',
align: 'center',
shadow: true,
})
expect(drawImageCalls).toHaveLength(6)
expect(fillTextCount).toBe(4)
})
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 and Chinese Character Sheet labels inside stone recesses using DC6 font6/font8 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; x: number; y: number; align: string }> = []
const mockCtx = {
drawImage: () => {},
fillRect: () => {},
strokeRect: () => {},
save: () => {},
restore: () => {},
measureText: (t: string) => ({ width: t.length * 10 }),
fillText: () => {},
font: '',
textBaseline: 'alphabetic',
fillStyle: '',
strokeStyle: '',
} as unknown as CanvasRenderingContext2D
const spyFont = {
drawText: (
_ctx: CanvasRenderingContext2D,
text: string,
x: number,
y: number,
opts?: { font?: D2FontName; align?: string },
) => {
const f = opts?.font ?? 'font8'
drawnLabels.push({
text,
font: f,
widthPx: renderer.measureText(text, f),
x,
y,
align: opts?.align ?? 'left',
})
},
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 stone recesses (95px for resistances x=175..269)
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(97)
}
// Verify middle dot U+00B7 (0xB7), bullet (0x95), and en/em-dashes (0x96/0x97) in metrics.json across all 6 DC6 fonts
for (const fn of ['font6', 'font8', 'font16', 'font30', 'fontexocet10', 'fontformal12'] as const) {
expect(metricsJson[fn].glyphs[0xb7].width).toBe(metricsJson[fn].glyphs[0x2e].width)
expect(metricsJson[fn].glyphs[0xb7].frameW).toBeGreaterThan(0)
expect(metricsJson[fn].glyphs[0xb7].frameH).toBeGreaterThan(0)
expect(metricsJson[fn].glyphs[0x95].frameW).toBeGreaterThan(0)
expect(metricsJson[fn].glyphs[0x96].frameW).toBeGreaterThan(0)
expect(metricsJson[fn].glyphs[0x97].frameW).toBeGreaterThan(0)
}
// Verify horizontal and vertical centering inside invchar6.dc6 stone recesses (ox=80, oy=60)
expect(drawnLabels.find(d => d.text === 'Isendra')?.x).toBe(80 + 97)
expect(drawnLabels.find(d => d.text === 'Isendra')?.y).toBe(60 + 21)
expect(drawnLabels.find(d => d.text === 'Sorceress')?.x).toBe(80 + 252)
expect(drawnLabels.find(d => d.text === 'Sorceress')?.y).toBe(60 + 21)
expect(drawnLabels.find(d => d.text === 'Level')?.x).toBe(80 + 33)
expect(drawnLabels.find(d => d.text === 'Experience')?.x).toBe(80 + 124)
expect(drawnLabels.find(d => d.text === 'Next Level')?.x).toBe(80 + 252)
expect(drawnLabels.find(d => d.text === 'Fire Resistance')?.x).toBe(80 + 222)
expect(drawnLabels.find(d => d.text === 'Fire Resistance')?.align).toBe('center')
expect(drawnLabels.find(d => d.text === 'Strength')?.y).toBe(60 + 94)
expect(drawnLabels.find(d => d.text === 'Dexterity')?.y).toBe(60 + 156)
expect(drawnLabels.find(d => d.text === 'Vitality')?.y).toBe(60 + 242)
expect(drawnLabels.find(d => d.text === 'Energy')?.y).toBe(60 + 304)
// Verify Chinese resistance labels are also centered inside the x=175..269 (mid=222) stone recess
drawnLabels.length = 0
sheet.lang = 'CHI'
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,
)
for (const zhRes of ['火焰抗性', '冰冷抗性', '闪电抗性', '毒素抗性']) {
const match = drawnLabels.find(d => d.text === zhRes)
expect(match?.x).toBe(80 + 222)
expect(match?.align).toBe('center')
}
})
it('wraps mixed CJK + ASCII space paragraphs in Quest Log and NPC Dialog without overflowing maxW', () => {
const renderer = new D2FontRenderer()
const worldPanels = new WorldPanelsHud()
worldPanels.lang = 'CHI'
const drawnLines: Array<{ text: string; widthPx: number; font: D2FontName }> = []
const mockCtx = {
drawImage: () => {},
fillRect: () => {},
strokeRect: () => {},
beginPath: () => {},
arc: () => {},
fill: () => {},
stroke: () => {},
save: () => {},
restore: () => {},
measureText: (t: string) => ({ width: [...t].reduce((acc, c) => acc + (c.charCodeAt(0) > 0xff ? 12 : 7), 0) }),
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'
const widthPx = [...text].reduce((acc, c) => acc + (c.charCodeAt(0) > 0xff ? 12 : 7), 0)
drawnLines.push({ text, widthPx, font: f })
},
measureText: (text: string) =>
[...text].reduce((acc, c) => acc + (c.charCodeAt(0) > 0xff ? 12 : 7), 0),
} as unknown as D2FontRenderer
worldPanels.npcMenu = {
npcName: '阿卡拉',
x: 400,
y: 260,
talking: true,
dialogLines: [
'在荒野中有一处极其邪恶的洞穴,目盲之眼修女会的斥候报告那里聚集了大量堕落魔与僵尸。\n[奖励: +1 技能点 (Skill Point) 并解锁修女会营地信任]',
],
}
worldPanels.drawNpcMenu(mockCtx, spyFont, {
boxPiecesImg: { width: 256, height: 12, complete: true, naturalWidth: 256 } as unknown as HTMLImageElement,
focus16Img: null,
})
const bodyLines = drawnLines.filter(l => l.font === 'fontformal12')
expect(bodyLines.length).toBeGreaterThanOrEqual(3)
for (const line of bodyLines) {
// NPC dialog box maxW is boxW - 36 = 340 - 36 = 304px
expect(line.widthPx, `Line "${line.text}" should fit within 304px`).toBeLessThanOrEqual(304)
}
expect(renderer).toBeDefined()
})
})