diablo2-web/src/ui/font.ts

283 lines
8.1 KiB
TypeScript

/**
* Diablo II v1.13c Bitmap Font & Color Code Engine (`D2WinFont.cpp`).
*
* Supports:
* - Authentic `Woo!\x01` `.tbl` metric parsing (`data\local\font\latin\font*.tbl`)
* - 12 canonical Diablo II color control sequences (`ÿc0`..`ÿc;` and `\xffc0`..`\xffc;`)
* - Pixel-accurate glyph blitting from DC6 font atlases (`font6`, `font8`, `font16`, `font30`, `fontexocet10`, `fontformal12`)
* - Seamless CJK (Chinese) fallback with 1px black drop-outline matching Exocet/Formal cap heights
*/
export type D2FontName = 'font6' | 'font8' | 'font16' | 'font30' | 'fontexocet10' | 'fontformal12'
export type D2ColorCode =
| 'white'
| 'red'
| 'green'
| 'blue'
| 'gold'
| 'gray'
| 'black'
| 'tan'
| 'orange'
| 'yellow'
| 'darkgreen'
| 'purple'
export const D2_COLOR_HEX: Record<D2ColorCode, string> = {
white: '#e8e4dc',
red: '#ff4d4d',
green: '#00fc00',
blue: '#6868ff',
gold: '#c8a15a',
gray: '#696969',
black: '#080808',
tan: '#d8c898',
orange: '#ff9c18',
yellow: '#ffff64',
darkgreen: '#008000',
purple: '#b040ff',
}
const CODE_CHAR_TO_COLOR: Record<string, D2ColorCode> = {
'0': 'white',
'1': 'red',
'2': 'green',
'3': 'blue',
'4': 'gold',
'5': 'gray',
'6': 'black',
'7': 'tan',
'8': 'orange',
'9': 'yellow',
':': 'darkgreen',
';': 'purple',
}
export interface FontGlyphMetric {
readonly code: number
readonly width: number
readonly height: number
readonly x: number
readonly y: number
readonly frameW: number
readonly frameH: number
}
export interface FontAtlasMeta {
readonly name: string
readonly lineHeight: number
readonly capHeight: number
readonly atlasWidth: number
readonly atlasHeight: number
readonly glyphs: readonly FontGlyphMetric[]
}
export interface TextSpan {
readonly text: string
readonly color: D2ColorCode
}
/**
* Parse a `Woo!\x01` `.tbl` font metrics binary (`D2WinFont.cpp`).
*/
export function parseFontTblMetrics(tblBytes: Uint8Array): {
lineHeight: number
capHeight: number
widths: number[]
heights: number[]
frameIndices: number[]
} {
if (tblBytes.length < 12) {
throw new Error(`Font tbl too short: ${tblBytes.length} bytes`)
}
const magic = new TextDecoder().decode(tblBytes.slice(0, 4))
if (magic !== 'Woo!') {
throw new Error(`Invalid font tbl signature: ${magic}`)
}
const lineHeight = tblBytes[10] || 14
const capHeight = tblBytes[11] || 16
const widths: number[] = []
const heights: number[] = []
const frameIndices: number[] = []
for (let i = 0; i < 256; i++) {
const off = 12 + i * 14
widths.push(off + 3 < tblBytes.length ? (tblBytes[off + 3] || 6) : 6)
heights.push(off + 4 < tblBytes.length ? (tblBytes[off + 4] || lineHeight) : lineHeight)
frameIndices.push(off + 8 < tblBytes.length ? (tblBytes[off + 8] ?? i) : i)
}
return { lineHeight, capHeight, widths, heights, frameIndices }
}
/**
* Split a string containing Diablo II `ÿcX` color codes into colored spans.
*/
export function parseColoredText(input: string, defaultColor: D2ColorCode = 'white'): TextSpan[] {
const spans: TextSpan[] = []
let currentColor: D2ColorCode = defaultColor
let buf = ''
for (let i = 0; i < input.length; i++) {
const ch = input[i]!
if ((ch === 'ÿ' || ch === '\xff') && input[i + 1] === 'c' && i + 2 < input.length) {
const codeChar = input[i + 2]!
const nextColor = CODE_CHAR_TO_COLOR[codeChar]
if (nextColor !== undefined) {
if (buf.length > 0) {
spans.push({ text: buf, color: currentColor })
buf = ''
}
currentColor = nextColor
i += 2
continue
}
}
buf += ch
}
if (buf.length > 0 || spans.length === 0) {
spans.push({ text: buf, color: currentColor })
}
return spans
}
const FONT_PX_SIZE: Record<D2FontName, number> = {
font6: 10,
font8: 11,
fontexocet10: 13,
fontformal12: 14,
font16: 16,
font30: 26,
}
/**
* Runtime Diablo II Font Manager supporting both bitmap DC6 glyphs and CJK fallback.
*/
export class D2FontRenderer {
private metas: Record<string, FontAtlasMeta> = {}
private images = new Map<string, HTMLImageElement>()
private tintedCache = new Map<string, HTMLCanvasElement>()
loaded = false
async load(baseUrl = '/ui/fonts'): Promise<void> {
try {
const res = await fetch(`${baseUrl}/metrics.json`)
if (!res.ok) return
this.metas = (await res.json()) as Record<string, FontAtlasMeta>
const names = Object.keys(this.metas)
await Promise.all(
names.map(
name =>
new Promise<void>((resolve) => {
const img = new Image()
img.onload = () => {
this.images.set(name, img)
resolve()
}
img.onerror = () => resolve()
img.src = `${baseUrl}/${name}.png`
}),
),
)
this.loaded = this.images.size > 0
} catch {
this.loaded = false
}
}
private getTintedAtlas(font: D2FontName, color: D2ColorCode): HTMLCanvasElement | HTMLImageElement | null {
const baseImg = this.images.get(font)
if (!baseImg) return null
if (color === 'white') return baseImg
const key = `${font}:${color}`
const cached = this.tintedCache.get(key)
if (cached) return cached
if (typeof document === 'undefined') return baseImg
const canvas = document.createElement('canvas')
canvas.width = baseImg.width
canvas.height = baseImg.height
const ctx = canvas.getContext('2d')
if (!ctx) return baseImg
ctx.drawImage(baseImg, 0, 0)
ctx.globalCompositeOperation = 'source-atop'
ctx.fillStyle = D2_COLOR_HEX[color]
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.globalCompositeOperation = 'source-over'
this.tintedCache.set(key, canvas)
return canvas
}
/**
* Measure pixel width of text (stripping `ÿcX` codes).
*/
measureText(text: string, font: D2FontName = 'font16'): number {
const spans = parseColoredText(text)
const meta = this.metas[font]
const pxSize = FONT_PX_SIZE[font]
let total = 0
for (const span of spans) {
for (let i = 0; i < span.text.length; i++) {
const code = span.text.charCodeAt(i)
if (code < 128 && meta?.glyphs[code]) {
total += Math.max(4, meta.glyphs[code]!.width)
} else {
total += code > 255 ? pxSize : Math.round(pxSize * 0.58)
}
}
}
return total
}
/**
* Draw text onto a CanvasRenderingContext2D using genuine DC6 glyphs for ASCII/Latin
* and sharp outlined gothic/serif typography for CJK characters.
*/
drawText(
ctx: CanvasRenderingContext2D,
text: string,
x: number,
y: number,
options: {
font?: D2FontName
color?: D2ColorCode
align?: 'left' | 'center' | 'right'
shadow?: boolean
} = {},
): void {
const font = options.font ?? 'font16'
const defaultColor = options.color ?? 'white'
const align = options.align ?? 'left'
const shadow = options.shadow ?? true
const totalWidth = this.measureText(text, font)
let curX = x
if (align === 'center') curX = Math.round(x - totalWidth / 2)
else if (align === 'right') curX = Math.round(x - totalWidth)
const baseY = Math.round(y)
const spans = parseColoredText(text, defaultColor)
const pxSize = FONT_PX_SIZE[font]
ctx.save()
const isTitleFont = font === 'font16' || font === 'font30' || font === 'fontexocet10'
ctx.font = `${isTitleFont ? 'bold ' : ''}${pxSize}px "Cinzel", "Georgia", "Songti SC", "Noto Serif SC", serif`
ctx.textBaseline = 'alphabetic'
let measuredTotal = 0
for (const s of spans) measuredTotal += ctx.measureText(s.text).width
if (align === 'center') curX = Math.round(x - measuredTotal / 2)
else if (align === 'right') curX = Math.round(x - measuredTotal)
for (const span of spans) {
if (shadow) {
ctx.fillStyle = '#000000'
ctx.fillText(span.text, curX + 1, baseY + 1)
ctx.fillText(span.text, curX - 1, baseY + 1)
ctx.fillText(span.text, curX + 1, baseY - 1)
}
ctx.fillStyle = D2_COLOR_HEX[span.color]
ctx.fillText(span.text, curX, baseY)
curX += ctx.measureText(span.text).width
}
ctx.restore()
}
}