feat(ui): integrate authentic 1.13c Exocet & Formal 436 DC6 bitmap & TrueType fonts (#504)
44
acts-en.html
|
|
@ -5,34 +5,53 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>d2web · Diablo II v1.13c</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
@font-face {
|
||||
font-family: "D2Exocet";
|
||||
src: url("/ui/fonts/D2Exocet.ttf") format("truetype");
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "D2Formal436";
|
||||
src: url("/ui/fonts/D2Formal436.ttf") format("truetype");
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--font-display: "D2Exocet", "Cinzel", "Times New Roman", "Liberation Serif", "Noto Serif", "Songti SC", "Noto Serif SC", serif;
|
||||
--font-body: "D2Formal436", "Times New Roman", "Liberation Serif", "Noto Serif", "Songti SC", "Noto Serif SC", serif;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; height: 100%; background: #0b0906; color: #e8dcc8;
|
||||
font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow: hidden; }
|
||||
font: 13px/1.5 var(--font-body); overflow: hidden; }
|
||||
#view { position: fixed; inset: 0; width: 100%; height: 100%; display: block; background: #000; cursor: none; }
|
||||
.overlay { position: fixed; z-index: 2; background: rgba(11, 9, 6, .82); border: 1px solid #33291f;
|
||||
border-radius: 6px; backdrop-filter: blur(3px); }
|
||||
header { top: 0; left: 0; right: 0; padding: 6px 12px; border-radius: 0; border-width: 0 0 1px 0;
|
||||
display: flex; gap: 14px; align-items: baseline; flex-wrap: wrap; }
|
||||
h1 { font-size: 13px; margin: 0; color: #c8a15a; letter-spacing: .06em; }
|
||||
h1 { font-family: var(--font-display); font-size: 13px; margin: 0; color: #c8a15a; letter-spacing: .06em; }
|
||||
a { color: #c8a15a; }
|
||||
#hud { color: #9c8d78; }
|
||||
#hud { font-family: var(--font-display); color: #9c8d78; }
|
||||
#controls { top: 4px; right: 12px; padding: 3px 8px; display: flex; gap: 8px;
|
||||
align-items: center; flex-wrap: wrap; max-width: 74vw; font-size: 12px; z-index: 4; }
|
||||
#controls label { color: #9c8d78; }
|
||||
align-items: center; flex-wrap: wrap; max-width: 74vw; font-family: var(--font-display); font-size: 12px; z-index: 4; }
|
||||
#controls label { font-family: var(--font-display); color: #9c8d78; }
|
||||
select { background: #1c1813; color: #e8dcc8; border: 1px solid #33291f;
|
||||
border-radius: 4px; padding: 2px 5px; font: inherit; max-width: 22vw; }
|
||||
border-radius: 4px; padding: 2px 5px; font: inherit; font-family: var(--font-body); max-width: 22vw; }
|
||||
select.non-default, .non-default {
|
||||
border: 1px solid #e74c3c !important;
|
||||
color: #ff6b6b !important;
|
||||
}
|
||||
optgroup { background: #1c1813; color: #c8a15a; }
|
||||
option { background: #1c1813; }
|
||||
#status { left: 12px; top: 34px; padding: 3px 8px; max-width: 55vw; color: #9c8d78; font-size: 11px; opacity: 0.85; pointer-events: none; z-index: 3; }
|
||||
optgroup { background: #1c1813; color: #c8a15a; font-family: var(--font-body); }
|
||||
option { background: #1c1813; font-family: var(--font-body); }
|
||||
#status { left: 12px; top: 34px; padding: 3px 8px; max-width: 55vw; color: #9c8d78; font-family: var(--font-body); font-size: 11px; opacity: 0.85; pointer-events: none; z-index: 3; }
|
||||
#status:empty { display: none; }
|
||||
#dialog { right: 12px; top: 42px; padding: 8px 12px; min-width: 240px; color: #e8dcc8; white-space: pre-wrap; font-size: 14px; background: rgba(30, 26, 21, .9); border-color: #c8a15a; z-index: 5; }
|
||||
#dialog { right: 12px; top: 42px; padding: 8px 12px; min-width: 240px; color: #e8dcc8; white-space: pre-wrap; font-family: var(--font-body); font-size: 14px; background: rgba(30, 26, 21, .9); border-color: #c8a15a; z-index: 5; }
|
||||
#labels, .ground-label { font-family: var(--font-display); }
|
||||
#d2-hud-canvas { position: fixed; inset: 0; width: 100%; height: 100%; display: block; z-index: 6; pointer-events: none; image-rendering: pixelated; cursor: none; }
|
||||
.hint { color: #6d5a34; }
|
||||
.hint { font-family: var(--font-body); color: #6d5a34; }
|
||||
.lang-btn {
|
||||
display: inline-block;
|
||||
padding: 2px 7px;
|
||||
|
|
@ -41,6 +60,7 @@
|
|||
border: 1px solid #5c4628;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
font-family: var(--font-display);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
|
|
|||
44
acts-zh.html
|
|
@ -5,34 +5,53 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>d2web · 暗黑破坏神 2 v1.13c</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
@font-face {
|
||||
font-family: "D2Exocet";
|
||||
src: url("/ui/fonts/D2Exocet.ttf") format("truetype");
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "D2Formal436";
|
||||
src: url("/ui/fonts/D2Formal436.ttf") format("truetype");
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--font-display: "D2Exocet", "Cinzel", "Times New Roman", "Liberation Serif", "Noto Serif", "Songti SC", "Noto Serif SC", serif;
|
||||
--font-body: "D2Formal436", "Times New Roman", "Liberation Serif", "Noto Serif", "Songti SC", "Noto Serif SC", serif;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; height: 100%; background: #0b0906; color: #e8dcc8;
|
||||
font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow: hidden; }
|
||||
font: 13px/1.5 var(--font-body); overflow: hidden; }
|
||||
#view { position: fixed; inset: 0; width: 100%; height: 100%; display: block; background: #000; cursor: none; }
|
||||
.overlay { position: fixed; z-index: 2; background: rgba(11, 9, 6, .82); border: 1px solid #33291f;
|
||||
border-radius: 6px; backdrop-filter: blur(3px); }
|
||||
header { top: 0; left: 0; right: 0; padding: 6px 12px; border-radius: 0; border-width: 0 0 1px 0;
|
||||
display: flex; gap: 14px; align-items: baseline; flex-wrap: wrap; }
|
||||
h1 { font-size: 13px; margin: 0; color: #c8a15a; letter-spacing: .06em; }
|
||||
h1 { font-family: var(--font-display); font-size: 13px; margin: 0; color: #c8a15a; letter-spacing: .06em; }
|
||||
a { color: #c8a15a; }
|
||||
#hud { color: #9c8d78; }
|
||||
#hud { font-family: var(--font-display); color: #9c8d78; }
|
||||
#controls { top: 4px; right: 12px; padding: 3px 8px; display: flex; gap: 8px;
|
||||
align-items: center; flex-wrap: wrap; max-width: 74vw; font-size: 12px; z-index: 4; }
|
||||
#controls label { color: #9c8d78; }
|
||||
align-items: center; flex-wrap: wrap; max-width: 74vw; font-family: var(--font-display); font-size: 12px; z-index: 4; }
|
||||
#controls label { font-family: var(--font-display); color: #9c8d78; }
|
||||
select { background: #1c1813; color: #e8dcc8; border: 1px solid #33291f;
|
||||
border-radius: 4px; padding: 2px 5px; font: inherit; max-width: 22vw; }
|
||||
border-radius: 4px; padding: 2px 5px; font: inherit; font-family: var(--font-body); max-width: 22vw; }
|
||||
select.non-default, .non-default {
|
||||
border: 1px solid #e74c3c !important;
|
||||
color: #ff6b6b !important;
|
||||
}
|
||||
optgroup { background: #1c1813; color: #c8a15a; }
|
||||
option { background: #1c1813; }
|
||||
#status { left: 12px; top: 34px; padding: 3px 8px; max-width: 55vw; color: #9c8d78; font-size: 11px; opacity: 0.85; pointer-events: none; z-index: 3; }
|
||||
optgroup { background: #1c1813; color: #c8a15a; font-family: var(--font-body); }
|
||||
option { background: #1c1813; font-family: var(--font-body); }
|
||||
#status { left: 12px; top: 34px; padding: 3px 8px; max-width: 55vw; color: #9c8d78; font-family: var(--font-body); font-size: 11px; opacity: 0.85; pointer-events: none; z-index: 3; }
|
||||
#status:empty { display: none; }
|
||||
#dialog { right: 12px; top: 42px; padding: 8px 12px; min-width: 240px; color: #e8dcc8; white-space: pre-wrap; font-size: 14px; background: rgba(30, 26, 21, .9); border-color: #c8a15a; z-index: 5; }
|
||||
#dialog { right: 12px; top: 42px; padding: 8px 12px; min-width: 240px; color: #e8dcc8; white-space: pre-wrap; font-family: var(--font-body); font-size: 14px; background: rgba(30, 26, 21, .9); border-color: #c8a15a; z-index: 5; }
|
||||
#labels, .ground-label { font-family: var(--font-display); }
|
||||
#d2-hud-canvas { position: fixed; inset: 0; width: 100%; height: 100%; display: block; z-index: 6; pointer-events: none; image-rendering: pixelated; cursor: none; }
|
||||
.hint { color: #6d5a34; }
|
||||
.hint { font-family: var(--font-body); color: #6d5a34; }
|
||||
.lang-btn {
|
||||
display: inline-block;
|
||||
padding: 2px 7px;
|
||||
|
|
@ -41,6 +60,7 @@
|
|||
border: 1px solid #5c4628;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
font-family: var(--font-display);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
|
|
|||
44
acts.html
|
|
@ -5,34 +5,53 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>d2web · 暗黑破坏神 2 v1.13c</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
@font-face {
|
||||
font-family: "D2Exocet";
|
||||
src: url("/ui/fonts/D2Exocet.ttf") format("truetype");
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "D2Formal436";
|
||||
src: url("/ui/fonts/D2Formal436.ttf") format("truetype");
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--font-display: "D2Exocet", "Cinzel", "Times New Roman", "Liberation Serif", "Noto Serif", "Songti SC", "Noto Serif SC", serif;
|
||||
--font-body: "D2Formal436", "Times New Roman", "Liberation Serif", "Noto Serif", "Songti SC", "Noto Serif SC", serif;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; height: 100%; background: #0b0906; color: #e8dcc8;
|
||||
font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow: hidden; }
|
||||
font: 13px/1.5 var(--font-body); overflow: hidden; }
|
||||
#view { position: fixed; inset: 0; width: 100%; height: 100%; display: block; background: #000; cursor: none; }
|
||||
.overlay { position: fixed; z-index: 2; background: rgba(11, 9, 6, .82); border: 1px solid #33291f;
|
||||
border-radius: 6px; backdrop-filter: blur(3px); }
|
||||
header { top: 0; left: 0; right: 0; padding: 6px 12px; border-radius: 0; border-width: 0 0 1px 0;
|
||||
display: flex; gap: 14px; align-items: baseline; flex-wrap: wrap; }
|
||||
h1 { font-size: 13px; margin: 0; color: #c8a15a; letter-spacing: .06em; }
|
||||
h1 { font-family: var(--font-display); font-size: 13px; margin: 0; color: #c8a15a; letter-spacing: .06em; }
|
||||
a { color: #c8a15a; }
|
||||
#hud { color: #9c8d78; }
|
||||
#hud { font-family: var(--font-display); color: #9c8d78; }
|
||||
#controls { top: 4px; right: 12px; padding: 3px 8px; display: flex; gap: 8px;
|
||||
align-items: center; flex-wrap: wrap; max-width: 74vw; font-size: 12px; z-index: 4; }
|
||||
#controls label { color: #9c8d78; }
|
||||
align-items: center; flex-wrap: wrap; max-width: 74vw; font-family: var(--font-display); font-size: 12px; z-index: 4; }
|
||||
#controls label { font-family: var(--font-display); color: #9c8d78; }
|
||||
select { background: #1c1813; color: #e8dcc8; border: 1px solid #33291f;
|
||||
border-radius: 4px; padding: 2px 5px; font: inherit; max-width: 22vw; }
|
||||
border-radius: 4px; padding: 2px 5px; font: inherit; font-family: var(--font-body); max-width: 22vw; }
|
||||
select.non-default, .non-default {
|
||||
border: 1px solid #e74c3c !important;
|
||||
color: #ff6b6b !important;
|
||||
}
|
||||
optgroup { background: #1c1813; color: #c8a15a; }
|
||||
option { background: #1c1813; }
|
||||
#status { left: 12px; top: 34px; padding: 3px 8px; max-width: 55vw; color: #9c8d78; font-size: 11px; opacity: 0.85; pointer-events: none; z-index: 3; }
|
||||
optgroup { background: #1c1813; color: #c8a15a; font-family: var(--font-body); }
|
||||
option { background: #1c1813; font-family: var(--font-body); }
|
||||
#status { left: 12px; top: 34px; padding: 3px 8px; max-width: 55vw; color: #9c8d78; font-family: var(--font-body); font-size: 11px; opacity: 0.85; pointer-events: none; z-index: 3; }
|
||||
#status:empty { display: none; }
|
||||
#dialog { right: 12px; top: 42px; padding: 8px 12px; min-width: 240px; color: #e8dcc8; white-space: pre-wrap; font-size: 14px; background: rgba(30, 26, 21, .9); border-color: #c8a15a; z-index: 5; }
|
||||
#dialog { right: 12px; top: 42px; padding: 8px 12px; min-width: 240px; color: #e8dcc8; white-space: pre-wrap; font-family: var(--font-body); font-size: 14px; background: rgba(30, 26, 21, .9); border-color: #c8a15a; z-index: 5; }
|
||||
#labels, .ground-label { font-family: var(--font-display); }
|
||||
#d2-hud-canvas { position: fixed; inset: 0; width: 100%; height: 100%; display: block; z-index: 6; pointer-events: none; image-rendering: pixelated; cursor: none; }
|
||||
.hint { color: #6d5a34; }
|
||||
.hint { font-family: var(--font-body); color: #6d5a34; }
|
||||
.lang-btn {
|
||||
display: inline-block;
|
||||
padding: 2px 7px;
|
||||
|
|
@ -41,6 +60,7 @@
|
|||
border: 1px solid #5c4628;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
font-family: var(--font-display);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
|
|
|||
26
index.html
|
|
@ -5,8 +5,24 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Diablo II Web · 暗黑破坏神 II 网页版 (v1.13c)</title>
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: "D2Exocet";
|
||||
src: url("/ui/fonts/D2Exocet.ttf") format("truetype");
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "D2Formal436";
|
||||
src: url("/ui/fonts/D2Formal436.ttf") format("truetype");
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--font-display: "D2Exocet", "Cinzel", "Times New Roman", "Liberation Serif", "Noto Serif", "Songti SC", "Noto Serif SC", serif;
|
||||
--font-body: "D2Formal436", "Times New Roman", "Liberation Serif", "Noto Serif", "Songti SC", "Noto Serif SC", serif;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
|
|
@ -18,7 +34,7 @@
|
|||
background:
|
||||
radial-gradient(circle at 50% 24%, rgba(168, 92, 24, 0.22) 0%, rgba(18, 12, 8, 0.92) 56%, #070503 100%);
|
||||
color: #e8dcc8;
|
||||
font-family: 'Exocet', 'Cinzel', 'Georgia', 'Songti SC', 'SimSun', serif;
|
||||
font-family: var(--font-display);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
|
@ -49,6 +65,7 @@
|
|||
border: 1px solid #6e5430;
|
||||
background: rgba(34, 24, 14, 0.85);
|
||||
color: #c8a15a;
|
||||
font-family: var(--font-display);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
|
|
@ -56,6 +73,7 @@
|
|||
}
|
||||
h1 {
|
||||
margin: 0 0 8px;
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(26px, 4vw, 38px);
|
||||
color: #d8b46a;
|
||||
letter-spacing: 0.08em;
|
||||
|
|
@ -64,6 +82,7 @@
|
|||
.subtitle {
|
||||
margin: 0 0 28px;
|
||||
color: #a7967d;
|
||||
font-family: var(--font-body);
|
||||
font-size: 15px;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
|
@ -98,6 +117,7 @@
|
|||
outline: none;
|
||||
}
|
||||
.lang-badge {
|
||||
font-family: var(--font-display);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.16em;
|
||||
color: #9c8458;
|
||||
|
|
@ -105,6 +125,7 @@
|
|||
margin-bottom: 8px;
|
||||
}
|
||||
.lang-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #f0d596;
|
||||
|
|
@ -112,6 +133,7 @@
|
|||
letter-spacing: 0.04em;
|
||||
}
|
||||
.lang-desc {
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
color: #9c8d78;
|
||||
line-height: 1.5;
|
||||
|
|
@ -122,10 +144,12 @@
|
|||
background: rgba(200, 161, 90, 0.14);
|
||||
border: 1px solid #8c6e3e;
|
||||
color: #e8c478;
|
||||
font-family: var(--font-display);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.footer-meta {
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
color: #6d5e4b;
|
||||
letter-spacing: 0.04em;
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
|
@ -0,0 +1,661 @@
|
|||
/**
|
||||
* Authentic Diablo II v1.13c TrueType (`.ttf`) WebFont Builder.
|
||||
*
|
||||
* Vectorizes the 256-glyph DC6 bitmap font tables from `d2data.mpq`
|
||||
* (`data\local\font\latin\font42.dc6` + `font42.tbl` for `D2Exocet`, and
|
||||
* `data\local\font\latin\fontformal12.dc6` + `fontformal12.tbl` for `D2Formal436`)
|
||||
* into standards-compliant TrueType (`sfnt` 0x00010000) font files with zero
|
||||
* external network dependencies.
|
||||
*/
|
||||
|
||||
import type { SpriteFrame } from '../src/formats/sprite.ts'
|
||||
|
||||
interface Point {
|
||||
readonly x: number
|
||||
readonly y: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace closed polygon contours (clockwise outer boundaries, counter-clockwise holes
|
||||
* in +X right, +Y up coordinates) from a binary foreground mask `mask[y * width + x]`.
|
||||
*/
|
||||
export function traceBitmapContours(
|
||||
mask: Uint8Array,
|
||||
width: number,
|
||||
height: number,
|
||||
baselineY: number,
|
||||
scale: number,
|
||||
): Point[][] {
|
||||
interface Edge {
|
||||
x0: number
|
||||
y0: number
|
||||
x1: number
|
||||
y1: number
|
||||
dir: number // 0=up, 1=right, 2=down, 3=left
|
||||
used: boolean
|
||||
}
|
||||
|
||||
const edgesByStart = new Map<string, Edge[]>()
|
||||
const allEdges: Edge[] = []
|
||||
const addEdge = (x0: number, y0: number, x1: number, y1: number, dir: number): void => {
|
||||
const e: Edge = { x0, y0, x1, y1, dir, used: false }
|
||||
allEdges.push(e)
|
||||
const k = `${x0},${y0}`
|
||||
let list = edgesByStart.get(k)
|
||||
if (!list) {
|
||||
list = []
|
||||
edgesByStart.set(k, list)
|
||||
}
|
||||
list.push(e)
|
||||
}
|
||||
|
||||
const get = (x: number, y: number): number => {
|
||||
if (x < 0 || x >= width || y < 0 || y >= height) return 0
|
||||
return mask[y * width + x] ?? 0
|
||||
}
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
const yUp = baselineY - y
|
||||
for (let x = 0; x < width; x++) {
|
||||
if (!get(x, y)) continue
|
||||
if (!get(x - 1, y)) addEdge(x, yUp - 1, x, yUp, 0)
|
||||
if (!get(x, y - 1)) addEdge(x, yUp, x + 1, yUp, 1)
|
||||
if (!get(x + 1, y)) addEdge(x + 1, yUp, x + 1, yUp - 1, 2)
|
||||
if (!get(x, y + 1)) addEdge(x + 1, yUp - 1, x, yUp - 1, 3)
|
||||
}
|
||||
}
|
||||
|
||||
const contours: Point[][] = []
|
||||
for (const startEdge of allEdges) {
|
||||
if (startEdge.used) continue
|
||||
const rawPts: { x: number; y: number; dir: number }[] = []
|
||||
let cur: Edge | undefined = startEdge
|
||||
while (cur && !cur.used) {
|
||||
cur.used = true
|
||||
rawPts.push({ x: cur.x0, y: cur.y0, dir: cur.dir })
|
||||
const candidates = edgesByStart.get(`${cur.x1},${cur.y1}`)
|
||||
if (!candidates) break
|
||||
let next: Edge | undefined
|
||||
const prefDirs = [(cur.dir + 1) % 4, cur.dir, (cur.dir + 3) % 4]
|
||||
for (const pd of prefDirs) {
|
||||
next = candidates.find(c => !c.used && c.dir === pd)
|
||||
if (next) break
|
||||
}
|
||||
if (!next) {
|
||||
next = candidates.find(c => !c.used)
|
||||
}
|
||||
cur = next
|
||||
}
|
||||
if (rawPts.length < 4) continue
|
||||
const simplified: Point[] = []
|
||||
for (let i = 0; i < rawPts.length; i++) {
|
||||
const prev = rawPts[(i + rawPts.length - 1) % rawPts.length]!
|
||||
const curr = rawPts[i]!
|
||||
if (prev.dir !== curr.dir) {
|
||||
simplified.push({
|
||||
x: Math.round(curr.x * scale),
|
||||
y: Math.round(curr.y * scale),
|
||||
})
|
||||
}
|
||||
}
|
||||
if (simplified.length >= 3) {
|
||||
contours.push(simplified)
|
||||
}
|
||||
}
|
||||
|
||||
return contours
|
||||
}
|
||||
|
||||
export interface Dc6FrameLike {
|
||||
readonly width: number
|
||||
readonly height: number
|
||||
readonly indices: Uint8Array
|
||||
}
|
||||
|
||||
export interface BuildTrueTypeFontOptions {
|
||||
readonly familyName: string
|
||||
readonly postScriptName: string
|
||||
readonly frames: readonly Dc6FrameLike[]
|
||||
readonly tblBytes: Uint8Array
|
||||
readonly baselineY: number
|
||||
readonly scale: number
|
||||
readonly isForeground: (paletteIdx: number) => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a complete TrueType (`.ttf`) binary buffer from a 256-glyph DC6 sheet and `.tbl` metrics.
|
||||
*/
|
||||
export function buildTrueTypeFont(options: BuildTrueTypeFontOptions): Uint8Array {
|
||||
const { familyName, postScriptName, frames, tblBytes, baselineY, scale, isForeground } = options
|
||||
|
||||
interface BuiltGlyph {
|
||||
contours: Point[][]
|
||||
advanceWidth: number
|
||||
xMin: number
|
||||
yMin: number
|
||||
xMax: number
|
||||
yMax: number
|
||||
}
|
||||
|
||||
const glyphs: BuiltGlyph[] = []
|
||||
const unicodeToGlyph = new Map<number, number>()
|
||||
|
||||
// Glyph 0: .notdef
|
||||
glyphs.push({
|
||||
contours: [
|
||||
[
|
||||
{ x: 40, y: 0 },
|
||||
{ x: 40, y: 600 },
|
||||
{ x: 440, y: 600 },
|
||||
{ x: 440, y: 0 },
|
||||
],
|
||||
[
|
||||
{ x: 100, y: 60 },
|
||||
{ x: 380, y: 60 },
|
||||
{ x: 380, y: 540 },
|
||||
{ x: 100, y: 540 },
|
||||
],
|
||||
],
|
||||
advanceWidth: 500,
|
||||
xMin: 40,
|
||||
yMin: 0,
|
||||
xMax: 440,
|
||||
yMax: 600,
|
||||
})
|
||||
|
||||
const win1252Map: Record<number, number> = {
|
||||
0x80: 0x20ac,
|
||||
0x82: 0x201a,
|
||||
0x83: 0x0192,
|
||||
0x84: 0x201e,
|
||||
0x85: 0x2026,
|
||||
0x86: 0x2020,
|
||||
0x87: 0x2021,
|
||||
0x88: 0x02c6,
|
||||
0x89: 0x2030,
|
||||
0x8a: 0x0160,
|
||||
0x8b: 0x2039,
|
||||
0x8c: 0x0152,
|
||||
0x8e: 0x017d,
|
||||
0x91: 0x2018,
|
||||
0x92: 0x2019,
|
||||
0x93: 0x201c,
|
||||
0x94: 0x201d,
|
||||
0x95: 0x2022,
|
||||
0x96: 0x2013,
|
||||
0x97: 0x2014,
|
||||
0x98: 0x02dc,
|
||||
0x99: 0x2122,
|
||||
0x9a: 0x0161,
|
||||
0x9b: 0x203a,
|
||||
0x9c: 0x0153,
|
||||
0x9e: 0x017e,
|
||||
0x9f: 0x0178,
|
||||
}
|
||||
|
||||
const qRecOff = 12 + 0x3f * 14
|
||||
const qFrameIdx = qRecOff + 8 < tblBytes.length ? (tblBytes[qRecOff + 8] ?? 0x3f) : 0x3f
|
||||
const placeholderCandidates = [
|
||||
frames[qFrameIdx] ?? frames[0x3f],
|
||||
frames[0x7f],
|
||||
frames[0x81],
|
||||
frames[0x8d],
|
||||
frames[0x8f],
|
||||
frames[0x90],
|
||||
frames[0x9d],
|
||||
].filter((f): f is Dc6FrameLike => Boolean(f && f.width > 0 && f.height > 0))
|
||||
|
||||
const isSameFrame = (a: Dc6FrameLike | undefined, b: Dc6FrameLike | undefined): boolean => {
|
||||
if (!a || !b || a.width === 0 || b.width === 0) return false
|
||||
if (a.width !== b.width || a.height !== b.height) return false
|
||||
for (let i = 0; i < a.indices.length; i++) {
|
||||
if (isForeground(a.indices[i] ?? 0) !== isForeground(b.indices[i] ?? 0)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const isPlaceholderQuestionMark = (f: Dc6FrameLike | undefined): boolean =>
|
||||
placeholderCandidates.some(cand => isSameFrame(f, cand))
|
||||
|
||||
const dotRecOff = 12 + 0x2e * 14
|
||||
const dotMetricW = dotRecOff + 3 < tblBytes.length ? (tblBytes[dotRecOff + 3] || 6) : 6
|
||||
const dotFrameIdx = dotRecOff + 8 < tblBytes.length ? (tblBytes[dotRecOff + 8] ?? 0x2e) : 0x2e
|
||||
const dotFrame = frames[dotFrameIdx] ?? frames[0x2e]
|
||||
|
||||
const dashRecOff = 12 + 0x2d * 14
|
||||
const dashMetricW = dashRecOff + 3 < tblBytes.length ? (tblBytes[dashRecOff + 3] || 8) : 8
|
||||
const dashFrameIdx = dashRecOff + 8 < tblBytes.length ? (tblBytes[dashRecOff + 8] ?? 0x2d) : 0x2d
|
||||
const dashFrame = frames[dashFrameIdx] ?? frames[0x2d]
|
||||
|
||||
for (let code = 0x20; code <= 0xff; code++) {
|
||||
const recOff = 12 + code * 14
|
||||
let metricWidth = recOff + 3 < tblBytes.length ? (tblBytes[recOff + 3] || 8) : 8
|
||||
const frameIdx = recOff + 8 < tblBytes.length ? (tblBytes[recOff + 8] ?? code) : code
|
||||
let frame = frames[frameIdx] ?? frames[code]
|
||||
let glyphBaselineY = baselineY
|
||||
|
||||
let yShiftUnits = 0
|
||||
if ((code === 0xb7 || code === 0x95) && dotFrame && dotFrame.width > 0 && dotFrame.height > 0) {
|
||||
frame = dotFrame
|
||||
metricWidth = dotMetricW
|
||||
yShiftUnits = Math.round(baselineY * 0.35 * scale)
|
||||
} else if (code !== 0x3f && code !== 0x20 && code !== 0xa0 && isPlaceholderQuestionMark(frame)) {
|
||||
if ((code === 0x96 || code === 0x97) && dashFrame && dashFrame.width > 0 && dashFrame.height > 0) {
|
||||
frame = dashFrame
|
||||
metricWidth = dashMetricW
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
let contours: Point[][] = []
|
||||
if (code !== 0x20 && code !== 0xa0 && frame && frame.width > 0 && frame.height > 0) {
|
||||
const mask = new Uint8Array(frame.width * frame.height)
|
||||
for (let i = 0; i < mask.length; i++) {
|
||||
mask[i] = isForeground(frame.indices[i] ?? 0) ? 1 : 0
|
||||
}
|
||||
contours = traceBitmapContours(mask, frame.width, frame.height, glyphBaselineY, scale)
|
||||
if (yShiftUnits !== 0) {
|
||||
contours = contours.map(c => c.map(p => ({ x: p.x, y: p.y + yShiftUnits })))
|
||||
}
|
||||
}
|
||||
|
||||
let xMin = 0
|
||||
let yMin = 0
|
||||
let xMax = 0
|
||||
let yMax = 0
|
||||
if (contours.length > 0) {
|
||||
xMin = Infinity
|
||||
yMin = Infinity
|
||||
xMax = -Infinity
|
||||
yMax = -Infinity
|
||||
for (const c of contours) {
|
||||
for (const p of c) {
|
||||
if (p.x < xMin) xMin = p.x
|
||||
if (p.y < yMin) yMin = p.y
|
||||
if (p.x > xMax) xMax = p.x
|
||||
if (p.y > yMax) yMax = p.y
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const advanceWidth = Math.max(
|
||||
Math.round(metricWidth * scale),
|
||||
contours.length > 0 ? xMax + Math.round(scale) : Math.round(6 * scale),
|
||||
)
|
||||
|
||||
const gIdx = glyphs.length
|
||||
glyphs.push({
|
||||
contours,
|
||||
advanceWidth,
|
||||
xMin,
|
||||
yMin,
|
||||
xMax,
|
||||
yMax,
|
||||
})
|
||||
|
||||
if (code < 0x7f || code >= 0xa0) {
|
||||
unicodeToGlyph.set(code, gIdx)
|
||||
} else if (win1252Map[code]) {
|
||||
unicodeToGlyph.set(win1252Map[code]!, gIdx)
|
||||
}
|
||||
}
|
||||
|
||||
const glyfChunks: Uint8Array[] = []
|
||||
const locaOffsets: number[] = []
|
||||
let currentGlyfOffset = 0
|
||||
|
||||
let fontXMin = Infinity
|
||||
let fontYMin = Infinity
|
||||
let fontXMax = -Infinity
|
||||
let fontYMax = -Infinity
|
||||
let maxPoints = 0
|
||||
let maxContours = 0
|
||||
let advanceWidthMax = 0
|
||||
let minLsb = Infinity
|
||||
let minRsb = Infinity
|
||||
let xMaxExtent = -Infinity
|
||||
|
||||
for (const g of glyphs) {
|
||||
locaOffsets.push(currentGlyfOffset)
|
||||
if (g.advanceWidth > advanceWidthMax) advanceWidthMax = g.advanceWidth
|
||||
if (g.contours.length === 0) {
|
||||
minLsb = Math.min(minLsb, 0)
|
||||
minRsb = Math.min(minRsb, g.advanceWidth)
|
||||
xMaxExtent = Math.max(xMaxExtent, 0)
|
||||
continue
|
||||
}
|
||||
|
||||
if (g.xMin < fontXMin) fontXMin = g.xMin
|
||||
if (g.yMin < fontYMin) fontYMin = g.yMin
|
||||
if (g.xMax > fontXMax) fontXMax = g.xMax
|
||||
if (g.yMax > fontYMax) fontYMax = g.yMax
|
||||
if (g.xMin < minLsb) minLsb = g.xMin
|
||||
const rsb = g.advanceWidth - g.xMax
|
||||
if (rsb < minRsb) minRsb = rsb
|
||||
if (g.xMax > xMaxExtent) xMaxExtent = g.xMax
|
||||
|
||||
const numContours = g.contours.length
|
||||
if (numContours > maxContours) maxContours = numContours
|
||||
const allPts: Point[] = []
|
||||
const endPts: number[] = []
|
||||
for (const c of g.contours) {
|
||||
for (const p of c) allPts.push(p)
|
||||
endPts.push(allPts.length - 1)
|
||||
}
|
||||
if (allPts.length > maxPoints) maxPoints = allPts.length
|
||||
|
||||
const rawLen = 10 + numContours * 2 + 2 + allPts.length * 5
|
||||
const paddedLen = (rawLen + 3) & ~3
|
||||
const buf = new Uint8Array(paddedLen)
|
||||
const dv = new DataView(buf.buffer)
|
||||
dv.setInt16(0, numContours, false)
|
||||
dv.setInt16(2, g.xMin, false)
|
||||
dv.setInt16(4, g.yMin, false)
|
||||
dv.setInt16(6, g.xMax, false)
|
||||
dv.setInt16(8, g.yMax, false)
|
||||
let off = 10
|
||||
for (const ep of endPts) {
|
||||
dv.setUint16(off, ep, false)
|
||||
off += 2
|
||||
}
|
||||
dv.setUint16(off, 0, false) // instructionLength = 0
|
||||
off += 2
|
||||
for (let i = 0; i < allPts.length; i++) {
|
||||
buf[off++] = 0x01
|
||||
}
|
||||
let prevX = 0
|
||||
for (const pt of allPts) {
|
||||
dv.setInt16(off, pt.x - prevX, false)
|
||||
prevX = pt.x
|
||||
off += 2
|
||||
}
|
||||
let prevY = 0
|
||||
for (const pt of allPts) {
|
||||
dv.setInt16(off, pt.y - prevY, false)
|
||||
prevY = pt.y
|
||||
off += 2
|
||||
}
|
||||
|
||||
glyfChunks.push(buf)
|
||||
currentGlyfOffset += paddedLen
|
||||
}
|
||||
locaOffsets.push(currentGlyfOffset)
|
||||
|
||||
const glyfTable = new Uint8Array(currentGlyfOffset)
|
||||
let gPos = 0
|
||||
for (const chunk of glyfChunks) {
|
||||
glyfTable.set(chunk, gPos)
|
||||
gPos += chunk.length
|
||||
}
|
||||
|
||||
const locaTable = new Uint8Array(locaOffsets.length * 4)
|
||||
const locaDv = new DataView(locaTable.buffer)
|
||||
locaOffsets.forEach((o, idx) => locaDv.setUint32(idx * 4, o, false))
|
||||
|
||||
const headTable = new Uint8Array(54)
|
||||
const headDv = new DataView(headTable.buffer)
|
||||
headDv.setUint32(0, 0x00010000, false)
|
||||
headDv.setUint32(4, 0x00010000, false)
|
||||
headDv.setUint32(8, 0, false)
|
||||
headDv.setUint32(12, 0x5f0f3cf5, false)
|
||||
headDv.setUint16(16, 0x000b, false)
|
||||
headDv.setUint16(18, 1024, false)
|
||||
headDv.setInt16(36, fontXMin, false)
|
||||
headDv.setInt16(38, fontYMin, false)
|
||||
headDv.setInt16(40, fontXMax, false)
|
||||
headDv.setInt16(42, fontYMax, false)
|
||||
headDv.setUint16(44, 0, false)
|
||||
headDv.setUint16(46, 8, false)
|
||||
headDv.setInt16(48, 2, false)
|
||||
headDv.setInt16(50, 1, false)
|
||||
headDv.setInt16(52, 0, false)
|
||||
|
||||
const ascender = Math.max(832, fontYMax)
|
||||
const descender = Math.min(-192, fontYMin)
|
||||
|
||||
const hheaTable = new Uint8Array(36)
|
||||
const hheaDv = new DataView(hheaTable.buffer)
|
||||
hheaDv.setUint32(0, 0x00010000, false)
|
||||
hheaDv.setInt16(4, ascender, false)
|
||||
hheaDv.setInt16(6, descender, false)
|
||||
hheaDv.setInt16(8, 0, false)
|
||||
hheaDv.setUint16(10, advanceWidthMax, false)
|
||||
hheaDv.setInt16(12, minLsb, false)
|
||||
hheaDv.setInt16(14, minRsb, false)
|
||||
hheaDv.setInt16(16, xMaxExtent, false)
|
||||
hheaDv.setInt16(18, 1, false)
|
||||
hheaDv.setInt16(20, 0, false)
|
||||
hheaDv.setInt16(22, 0, false)
|
||||
hheaDv.setInt16(32, 0, false)
|
||||
hheaDv.setUint16(34, glyphs.length, false)
|
||||
|
||||
const maxpTable = new Uint8Array(32)
|
||||
const maxpDv = new DataView(maxpTable.buffer)
|
||||
maxpDv.setUint32(0, 0x00010000, false)
|
||||
maxpDv.setUint16(4, glyphs.length, false)
|
||||
maxpDv.setUint16(6, maxPoints, false)
|
||||
maxpDv.setUint16(8, maxContours, false)
|
||||
maxpDv.setUint16(10, 0, false)
|
||||
maxpDv.setUint16(12, 0, false)
|
||||
maxpDv.setUint16(14, 2, false)
|
||||
|
||||
const hmtxTable = new Uint8Array(glyphs.length * 4)
|
||||
const hmtxDv = new DataView(hmtxTable.buffer)
|
||||
glyphs.forEach((g, idx) => {
|
||||
hmtxDv.setUint16(idx * 4, g.advanceWidth, false)
|
||||
hmtxDv.setInt16(idx * 4 + 2, g.contours.length > 0 ? g.xMin : 0, false)
|
||||
})
|
||||
|
||||
const os2Table = new Uint8Array(96)
|
||||
const os2Dv = new DataView(os2Table.buffer)
|
||||
const avgWidth = Math.round(
|
||||
glyphs.reduce((acc, g) => acc + g.advanceWidth, 0) / Math.max(1, glyphs.length),
|
||||
)
|
||||
os2Dv.setUint16(0, 4, false)
|
||||
os2Dv.setInt16(2, avgWidth, false)
|
||||
os2Dv.setUint16(4, 400, false)
|
||||
os2Dv.setUint16(6, 5, false)
|
||||
os2Dv.setUint16(8, 0, false)
|
||||
os2Dv.setInt16(10, 650, false)
|
||||
os2Dv.setInt16(12, 600, false)
|
||||
os2Dv.setInt16(14, 0, false)
|
||||
os2Dv.setInt16(16, 75, false)
|
||||
os2Dv.setInt16(18, 650, false)
|
||||
os2Dv.setInt16(20, 600, false)
|
||||
os2Dv.setInt16(22, 0, false)
|
||||
os2Dv.setInt16(24, 350, false)
|
||||
os2Dv.setInt16(26, 50, false)
|
||||
os2Dv.setInt16(28, 300, false)
|
||||
os2Dv.setInt16(30, 0, false)
|
||||
os2Table.set([2, 0, 5, 3, 0, 0, 0, 0, 0, 0], 32)
|
||||
os2Dv.setUint32(42, 0x00000003, false)
|
||||
os2Table.set([0x42, 0x4c, 0x49, 0x5a], 58)
|
||||
os2Dv.setUint16(62, 0x0040, false)
|
||||
const sortedCodes = [...unicodeToGlyph.keys()].sort((a, b) => a - b)
|
||||
os2Dv.setUint16(64, sortedCodes[0] ?? 0x0020, false)
|
||||
os2Dv.setUint16(66, sortedCodes[sortedCodes.length - 1] ?? 0x00ff, false)
|
||||
os2Dv.setInt16(68, ascender, false)
|
||||
os2Dv.setInt16(70, descender, false)
|
||||
os2Dv.setInt16(72, 0, false)
|
||||
os2Dv.setUint16(74, ascender, false)
|
||||
os2Dv.setUint16(76, Math.abs(descender), false)
|
||||
os2Dv.setUint32(78, 0x00000001, false)
|
||||
os2Dv.setInt16(86, 512, false)
|
||||
os2Dv.setInt16(88, 704, false)
|
||||
os2Dv.setUint16(90, 0, false)
|
||||
os2Dv.setUint16(92, 0x0020, false)
|
||||
os2Dv.setUint16(94, 1, false)
|
||||
|
||||
interface Seg {
|
||||
start: number
|
||||
end: number
|
||||
delta: number
|
||||
}
|
||||
const segs: Seg[] = []
|
||||
for (const code of sortedCodes) {
|
||||
const gIdx = unicodeToGlyph.get(code)!
|
||||
const delta = (gIdx - code) & 0xffff
|
||||
const prev = segs[segs.length - 1]
|
||||
if (prev && prev.end + 1 === code && prev.delta === delta) {
|
||||
prev.end = code
|
||||
} else {
|
||||
segs.push({ start: code, end: code, delta })
|
||||
}
|
||||
}
|
||||
segs.push({ start: 0xffff, end: 0xffff, delta: 1 })
|
||||
const segCount = segs.length
|
||||
const segCountX2 = segCount * 2
|
||||
const maxPow2 = 1 << Math.floor(Math.log2(segCount))
|
||||
const searchRangeCmap = maxPow2 * 2
|
||||
const entrySelectorCmap = Math.floor(Math.log2(segCount))
|
||||
const rangeShiftCmap = segCountX2 - searchRangeCmap
|
||||
|
||||
const subtableLen = 16 + segCount * 8
|
||||
const cmapTable = new Uint8Array(4 + 8 * 2 + subtableLen)
|
||||
const cmapDv = new DataView(cmapTable.buffer)
|
||||
cmapDv.setUint16(0, 0, false)
|
||||
cmapDv.setUint16(2, 2, false)
|
||||
cmapDv.setUint16(4, 0, false)
|
||||
cmapDv.setUint16(6, 3, false)
|
||||
cmapDv.setUint32(8, 20, false)
|
||||
cmapDv.setUint16(12, 3, false)
|
||||
cmapDv.setUint16(14, 1, false)
|
||||
cmapDv.setUint32(16, 20, false)
|
||||
|
||||
let cOff = 20
|
||||
cmapDv.setUint16(cOff, 4, false)
|
||||
cmapDv.setUint16(cOff + 2, subtableLen, false)
|
||||
cmapDv.setUint16(cOff + 4, 0, false)
|
||||
cmapDv.setUint16(cOff + 6, segCountX2, false)
|
||||
cmapDv.setUint16(cOff + 8, searchRangeCmap, false)
|
||||
cmapDv.setUint16(cOff + 10, entrySelectorCmap, false)
|
||||
cmapDv.setUint16(cOff + 12, rangeShiftCmap, false)
|
||||
cOff += 14
|
||||
for (const s of segs) {
|
||||
cmapDv.setUint16(cOff, s.end, false)
|
||||
cOff += 2
|
||||
}
|
||||
cmapDv.setUint16(cOff, 0, false)
|
||||
cOff += 2
|
||||
for (const s of segs) {
|
||||
cmapDv.setUint16(cOff, s.start, false)
|
||||
cOff += 2
|
||||
}
|
||||
for (const s of segs) {
|
||||
cmapDv.setUint16(cOff, s.delta, false)
|
||||
cOff += 2
|
||||
}
|
||||
for (let i = 0; i < segs.length; i++) {
|
||||
cmapDv.setUint16(cOff, 0, false)
|
||||
cOff += 2
|
||||
}
|
||||
|
||||
const nameRecords: { nameId: number; str: string }[] = [
|
||||
{ nameId: 1, str: familyName },
|
||||
{ nameId: 2, str: 'Regular' },
|
||||
{ nameId: 3, str: `${familyName}-1.13c` },
|
||||
{ nameId: 4, str: familyName },
|
||||
{ nameId: 5, str: 'Version 1.13c' },
|
||||
{ nameId: 6, str: postScriptName },
|
||||
]
|
||||
const encodedStrings: Uint8Array[] = nameRecords.map(r => {
|
||||
const b = new Uint8Array(r.str.length * 2)
|
||||
const dv = new DataView(b.buffer)
|
||||
for (let i = 0; i < r.str.length; i++) {
|
||||
dv.setUint16(i * 2, r.str.charCodeAt(i), false)
|
||||
}
|
||||
return b
|
||||
})
|
||||
const storageOffset = 6 + nameRecords.length * 12
|
||||
const totalStrBytes = encodedStrings.reduce((a, b) => a + b.length, 0)
|
||||
const nameTable = new Uint8Array(storageOffset + totalStrBytes)
|
||||
const nameDv = new DataView(nameTable.buffer)
|
||||
nameDv.setUint16(0, 0, false)
|
||||
nameDv.setUint16(2, nameRecords.length, false)
|
||||
nameDv.setUint16(4, storageOffset, false)
|
||||
let strCur = 0
|
||||
nameRecords.forEach((rec, idx) => {
|
||||
const strBytes = encodedStrings[idx]!
|
||||
const rOff = 6 + idx * 12
|
||||
nameDv.setUint16(rOff, 3, false)
|
||||
nameDv.setUint16(rOff + 2, 1, false)
|
||||
nameDv.setUint16(rOff + 4, 0x0409, false)
|
||||
nameDv.setUint16(rOff + 6, rec.nameId, false)
|
||||
nameDv.setUint16(rOff + 8, strBytes.length, false)
|
||||
nameDv.setUint16(rOff + 10, strCur, false)
|
||||
nameTable.set(strBytes, storageOffset + strCur)
|
||||
strCur += strBytes.length
|
||||
})
|
||||
|
||||
const postTable = new Uint8Array(32)
|
||||
const postDv = new DataView(postTable.buffer)
|
||||
postDv.setUint32(0, 0x00030000, false)
|
||||
postDv.setInt32(4, 0, false)
|
||||
postDv.setInt16(8, -100, false)
|
||||
postDv.setInt16(10, 50, false)
|
||||
postDv.setUint32(12, 0, false)
|
||||
|
||||
const tables: { tag: string; data: Uint8Array }[] = [
|
||||
{ tag: 'OS/2', data: os2Table },
|
||||
{ tag: 'cmap', data: cmapTable },
|
||||
{ tag: 'glyf', data: glyfTable },
|
||||
{ tag: 'head', data: headTable },
|
||||
{ tag: 'hhea', data: hheaTable },
|
||||
{ tag: 'hmtx', data: hmtxTable },
|
||||
{ tag: 'loca', data: locaTable },
|
||||
{ tag: 'maxp', data: maxpTable },
|
||||
{ tag: 'name', data: nameTable },
|
||||
{ tag: 'post', data: postTable },
|
||||
].sort((a, b) => (a.tag < b.tag ? -1 : a.tag > b.tag ? 1 : 0))
|
||||
|
||||
const calcChecksum = (bytes: Uint8Array): number => {
|
||||
const paddedLen = (bytes.length + 3) & ~3
|
||||
const padded = new Uint8Array(paddedLen)
|
||||
padded.set(bytes)
|
||||
const dv = new DataView(padded.buffer)
|
||||
let sum = 0
|
||||
for (let i = 0; i < paddedLen; i += 4) {
|
||||
sum = (sum + dv.getUint32(i, false)) >>> 0
|
||||
}
|
||||
return sum
|
||||
}
|
||||
|
||||
const numTables = tables.length
|
||||
const dirSize = 12 + numTables * 16
|
||||
let totalSize = dirSize
|
||||
const offsets: number[] = []
|
||||
for (const t of tables) {
|
||||
offsets.push(totalSize)
|
||||
totalSize += (t.data.length + 3) & ~3
|
||||
}
|
||||
|
||||
const out = new Uint8Array(totalSize)
|
||||
const outDv = new DataView(out.buffer)
|
||||
outDv.setUint32(0, 0x00010000, false)
|
||||
outDv.setUint16(4, numTables, false)
|
||||
outDv.setUint16(6, 128, false)
|
||||
outDv.setUint16(8, 3, false)
|
||||
outDv.setUint16(10, 32, false)
|
||||
|
||||
let headOffsetInFile = 0
|
||||
tables.forEach((t, idx) => {
|
||||
const entryOff = 12 + idx * 16
|
||||
for (let c = 0; c < 4; c++) {
|
||||
out[entryOff + c] = t.tag.charCodeAt(c)
|
||||
}
|
||||
const chksum = calcChecksum(t.data)
|
||||
const fileOff = offsets[idx]!
|
||||
if (t.tag === 'head') headOffsetInFile = fileOff
|
||||
outDv.setUint32(entryOff + 4, chksum, false)
|
||||
outDv.setUint32(entryOff + 8, fileOff, false)
|
||||
outDv.setUint32(entryOff + 12, t.data.length, false)
|
||||
out.set(t.data, fileOff)
|
||||
})
|
||||
|
||||
const fileSum = calcChecksum(out)
|
||||
const checkSumAdjustment = (0xb1b0afba - fileSum) >>> 0
|
||||
outDv.setUint32(headOffsetInFile + 8, checkSumAdjustment, false)
|
||||
|
||||
return out
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@
|
|||
* bitmap fonts, and inventory item graphics into `public/ui/`.
|
||||
*/
|
||||
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { MpqArchive } from '../src/mpq/archive.ts'
|
||||
import { fileSource } from '../src/mpq/file-source.ts'
|
||||
|
|
@ -16,6 +16,7 @@ import type { SpriteFrame, SpriteSheet } from '../src/formats/sprite.ts'
|
|||
import { decodePl2 } from '../src/formats/pl2.ts'
|
||||
import { parseTable } from '../src/game/tables.ts'
|
||||
import { encodeIndexedPng } from './png.ts'
|
||||
import { buildTrueTypeFont } from './build-d2-ttf.ts'
|
||||
|
||||
interface BlitPlacement {
|
||||
readonly frame: SpriteFrame
|
||||
|
|
@ -133,12 +134,57 @@ function buildFontAtlas(name: string, dc6: SpriteSheet, tblBytes: Uint8Array): {
|
|||
const placements: BlitPlacement[] = []
|
||||
const glyphs: FontGlyphMetric[] = []
|
||||
|
||||
const qRecOff = 12 + 0x3f * 14
|
||||
const qFrameIdx = qRecOff + 8 < tblBytes.length ? (tblBytes[qRecOff + 8] ?? 0x3f) : 0x3f
|
||||
const placeholderCandidates = [
|
||||
frames[qFrameIdx] ?? frames[0x3f],
|
||||
frames[0x7f],
|
||||
frames[0x81],
|
||||
frames[0x8d],
|
||||
frames[0x8f],
|
||||
frames[0x90],
|
||||
frames[0x9d],
|
||||
].filter((f): f is SpriteFrame => Boolean(f && f.width > 0 && f.height > 0))
|
||||
|
||||
const isSameFrame = (a: SpriteFrame | undefined, b: SpriteFrame | undefined): boolean => {
|
||||
if (!a || !b || a.width === 0 || b.width === 0) return false
|
||||
if (a.width !== b.width || a.height !== b.height) return false
|
||||
for (let k = 0; k < a.indices.length; k++) {
|
||||
if (a.indices[k] !== b.indices[k]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const isPlaceholderQuestionMark = (f: SpriteFrame | undefined): boolean =>
|
||||
placeholderCandidates.some(cand => isSameFrame(f, cand))
|
||||
|
||||
const dotRecOff = 12 + 0x2e * 14
|
||||
const dotMetricW = dotRecOff + 3 < tblBytes.length ? (tblBytes[dotRecOff + 3] || 6) : 6
|
||||
const dotFrameIdx = dotRecOff + 8 < tblBytes.length ? (tblBytes[dotRecOff + 8] ?? 0x2e) : 0x2e
|
||||
const dotFrame = frames[dotFrameIdx] ?? frames[0x2e]
|
||||
|
||||
for (let i = 0; i < 256; i++) {
|
||||
const recOff = 12 + i * 14
|
||||
const metricWidth = recOff + 3 < tblBytes.length ? (tblBytes[recOff + 3] || 6) : 6
|
||||
let metricWidth = recOff + 3 < tblBytes.length ? (tblBytes[recOff + 3] || 6) : 6
|
||||
const metricHeight = recOff + 4 < tblBytes.length ? (tblBytes[recOff + 4] || lineHeight) : lineHeight
|
||||
const frameIdx = recOff + 8 < tblBytes.length ? (tblBytes[recOff + 8] ?? i) : i
|
||||
const frame = frames[frameIdx] ?? frames[i]
|
||||
let frame: SpriteFrame | undefined = frames[frameIdx] ?? frames[i]
|
||||
if (i === 0xb7 && dotFrame) {
|
||||
const shiftY = Math.max(1, Math.round(dotFrame.height * 0.35))
|
||||
const shiftedIndices = new Uint8Array(dotFrame.width * dotFrame.height)
|
||||
for (let y = 0; y < dotFrame.height; y++) {
|
||||
const srcY = y + shiftY
|
||||
if (srcY < dotFrame.height) {
|
||||
for (let x = 0; x < dotFrame.width; x++) {
|
||||
shiftedIndices[y * dotFrame.width + x] = dotFrame.indices[srcY * dotFrame.width + x]!
|
||||
}
|
||||
}
|
||||
}
|
||||
frame = { ...dotFrame, indices: shiftedIndices }
|
||||
metricWidth = dotMetricW
|
||||
} else if (i !== 0x3f && i >= 0x7f && isPlaceholderQuestionMark(frame)) {
|
||||
frame = undefined
|
||||
}
|
||||
const gx = (i % 16) * cellW
|
||||
const gy = Math.floor(i / 16) * cellH
|
||||
if (frame) {
|
||||
|
|
@ -155,6 +201,16 @@ function buildFontAtlas(name: string, dc6: SpriteSheet, tblBytes: Uint8Array): {
|
|||
})
|
||||
}
|
||||
|
||||
const pixels = stitchFrames(atlasWidth, atlasHeight, placements)
|
||||
if (name === 'fontexocet10') {
|
||||
for (let p = 0; p < pixels.length; p++) {
|
||||
const idx = pixels[p]!
|
||||
if (idx >= 15 && idx <= 32) {
|
||||
pixels[p] = Math.max(15, Math.min(31, 31 - (idx - 15)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
meta: {
|
||||
name,
|
||||
|
|
@ -164,7 +220,7 @@ function buildFontAtlas(name: string, dc6: SpriteSheet, tblBytes: Uint8Array): {
|
|||
atlasHeight,
|
||||
glyphs,
|
||||
},
|
||||
pixels: stitchFrames(atlasWidth, atlasHeight, placements),
|
||||
pixels,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -401,6 +457,34 @@ async function main(): Promise<void> {
|
|||
}
|
||||
writeFileSync(join(fontsDir, 'metrics.json'), JSON.stringify(fontMetas, null, 2))
|
||||
|
||||
// 7b. Generate authentic local TrueType WebFonts (`D2Exocet.ttf` & `D2Formal436.ttf`) from 1.13c DC6 font sheets
|
||||
const f42Dc6 = await decodeUiDc6('data\\local\\font\\latin\\font42.dc6')
|
||||
const f42Tbl = await archives.read('data\\local\\font\\latin\\font42.tbl')
|
||||
const exocetFg = new Set([4, 6, 7, 12, 13, 14, 19, 20, 23, 24, 26, 28, 29, 30])
|
||||
const exocetTtf = buildTrueTypeFont({
|
||||
familyName: 'D2Exocet',
|
||||
postScriptName: 'D2Exocet-Regular',
|
||||
frames: f42Dc6.groups[0]!.frames,
|
||||
tblBytes: f42Tbl,
|
||||
baselineY: 32,
|
||||
scale: 28,
|
||||
isForeground: idx => exocetFg.has(idx),
|
||||
})
|
||||
writeFileSync(join(fontsDir, 'D2Exocet.ttf'), exocetTtf)
|
||||
|
||||
const ff12Dc6 = await decodeUiDc6('data\\local\\font\\latin\\fontformal12.dc6')
|
||||
const ff12Tbl = await archives.read('data\\local\\font\\latin\\fontformal12.tbl')
|
||||
const formalTtf = buildTrueTypeFont({
|
||||
familyName: 'D2Formal436',
|
||||
postScriptName: 'D2Formal436-Regular',
|
||||
frames: ff12Dc6.groups[0]!.frames,
|
||||
tblBytes: ff12Tbl,
|
||||
baselineY: 26,
|
||||
scale: 56,
|
||||
isForeground: idx => idx >= 21,
|
||||
})
|
||||
writeFileSync(join(fontsDir, 'D2Formal436.ttf'), formalTtf)
|
||||
|
||||
// 8. Extract Item Inventory Sprites (`data\global\items\inv*.dc6`)
|
||||
const codeToInvFile: Record<string, string> = {}
|
||||
const uniqueInvFiles = new Set<string>()
|
||||
|
|
@ -451,7 +535,17 @@ async function main(): Promise<void> {
|
|||
const itemAtlasPixels = stitchFrames(ATLAS_W, atlasH, itemPlacements)
|
||||
savePng('items-atlas.png', ATLAS_W, atlasH, itemAtlasPixels)
|
||||
|
||||
const manifestPath = join(outDir, 'manifest.json')
|
||||
let existingManifest: Record<string, unknown> = {}
|
||||
if (existsSync(manifestPath)) {
|
||||
try {
|
||||
existingManifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
|
||||
} catch {
|
||||
existingManifest = {}
|
||||
}
|
||||
}
|
||||
const manifest = {
|
||||
...existingManifest,
|
||||
version: '1.13c',
|
||||
decodedDc6Count: decodedCount,
|
||||
dc6DecodeFailures: 0,
|
||||
|
|
@ -460,7 +554,7 @@ async function main(): Promise<void> {
|
|||
codeToInvFile,
|
||||
itemRects,
|
||||
}
|
||||
writeFileSync(join(outDir, 'manifest.json'), JSON.stringify(manifest, null, 2))
|
||||
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2))
|
||||
console.log(`Successfully decoded ${decodedCount} DC6 sheets (0 failures) and generated public/ui/ assets!`)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,261 @@
|
|||
/**
|
||||
* Automated Headless Browser Verification & Visual Audit for Issue #504:
|
||||
* Authentic Diablo II v1.13c Exocet (`D2Exocet`) & Formal 436 (`D2Formal436`)
|
||||
* Typography across Canvas HUD (`D2FontRenderer` DC6 bitmap blitting) and DOM/CSS
|
||||
* (`index.html`, `acts-en.html`, `acts.html`, `acts-zh.html`).
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { createServer } from 'vite'
|
||||
|
||||
const ARTIFACT_DIR =
|
||||
process.argv[2] ||
|
||||
'/usr/local/google/home/taodao/diablo2-web/.agents/teamwork/implementer_504_r1'
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
|
||||
|
||||
interface CdpSession {
|
||||
evalJs: <T = any>(expression: string) => Promise<T>
|
||||
saveScreenshot: (filename: string) => Promise<void>
|
||||
waitForGameReady: () => Promise<void>
|
||||
}
|
||||
|
||||
async function withChromePage<T>(
|
||||
url: string,
|
||||
matchSubstr: string,
|
||||
debugPort: number,
|
||||
fn: (session: CdpSession) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const chromeProc = spawn('/usr/bin/google-chrome', [
|
||||
'--headless=new',
|
||||
`--remote-debugging-port=${debugPort}`,
|
||||
'--no-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--enable-webgl',
|
||||
'--ignore-gpu-blocklist',
|
||||
'--use-gl=angle',
|
||||
'--use-angle=swiftshader',
|
||||
'--window-size=1280,840',
|
||||
url,
|
||||
])
|
||||
|
||||
try {
|
||||
let wsUrl: string | null = null
|
||||
for (let i = 0; i < 60; i++) {
|
||||
await sleep(300)
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${debugPort}/json/list`)
|
||||
const pages = (await res.json()) as { type: string; url: string; webSocketDebuggerUrl?: string }[]
|
||||
const page = pages.find(p => p.type === 'page' && p.url.includes(matchSubstr))
|
||||
if (page?.webSocketDebuggerUrl) {
|
||||
wsUrl = page.webSocketDebuggerUrl
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
// Wait for Chrome CDP
|
||||
}
|
||||
}
|
||||
|
||||
if (!wsUrl) {
|
||||
throw new Error(`Failed to connect to Chrome CDP for ${url}`)
|
||||
}
|
||||
const ws = new WebSocket(wsUrl)
|
||||
await new Promise<void>(resolve => {
|
||||
ws.onopen = () => resolve()
|
||||
})
|
||||
|
||||
let idCounter = 1
|
||||
const sendCommand = <R = any>(method: string, params: Record<string, unknown> = {}): Promise<R> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const id = idCounter++
|
||||
const handler = (event: MessageEvent) => {
|
||||
const msg = JSON.parse(String(event.data))
|
||||
if (msg.id === id) {
|
||||
ws.removeEventListener('message', handler)
|
||||
if (msg.error) reject(new Error(JSON.stringify(msg.error)))
|
||||
else resolve(msg.result as R)
|
||||
}
|
||||
}
|
||||
ws.addEventListener('message', handler)
|
||||
ws.send(JSON.stringify({ id, method, params }))
|
||||
})
|
||||
|
||||
const evalJs = async <R = any>(expression: string): Promise<R> => {
|
||||
const res = await sendCommand<{ result: { value: R }; exceptionDetails?: any }>('Runtime.evaluate', {
|
||||
expression,
|
||||
awaitPromise: true,
|
||||
returnByValue: true,
|
||||
})
|
||||
if (res.exceptionDetails) {
|
||||
throw new Error(`JS exception: ${JSON.stringify(res.exceptionDetails)}`)
|
||||
}
|
||||
return res.result.value
|
||||
}
|
||||
|
||||
const saveScreenshot = async (filename: string): Promise<void> => {
|
||||
const shot = await sendCommand<{ data: string }>('Page.captureScreenshot', { format: 'png' })
|
||||
writeFileSync(join(ARTIFACT_DIR, filename), Buffer.from(shot.data, 'base64'))
|
||||
console.log(` Saved screenshot: ${filename}`)
|
||||
}
|
||||
|
||||
const waitForGameReady = async (): Promise<void> => {
|
||||
for (let i = 0; i < 120; i++) {
|
||||
const ready = await evalJs<boolean>(
|
||||
`Boolean(window.__d2webAct?.ready && window.__d2webHud?.ready && window.__d2webHudInstance && window.__d2webHud?.assetsLoaded?.ctrlPnl)`,
|
||||
)
|
||||
if (ready) {
|
||||
await sleep(500)
|
||||
return
|
||||
}
|
||||
await sleep(300)
|
||||
}
|
||||
throw new Error(`Timed out waiting for game ready at ${url}`)
|
||||
}
|
||||
|
||||
await sendCommand('Runtime.enable')
|
||||
await sendCommand('Page.enable')
|
||||
|
||||
const result = await fn({ evalJs, saveScreenshot, waitForGameReady })
|
||||
ws.close()
|
||||
return result
|
||||
} finally {
|
||||
chromeProc.kill('SIGKILL')
|
||||
await sleep(200)
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
mkdirSync(ARTIFACT_DIR, { recursive: true })
|
||||
const server = await createServer({
|
||||
root: process.cwd(),
|
||||
server: { port: 0, host: '127.0.0.1' },
|
||||
})
|
||||
await server.listen()
|
||||
const address = server.httpServer?.address()
|
||||
const port = typeof address === 'object' && address ? address.port : 5173
|
||||
const baseUrl = `http://127.0.0.1:${port}`
|
||||
console.log(`1. Vite dev server listening at ${baseUrl}`)
|
||||
|
||||
try {
|
||||
// ── Phase A: Verify index.html WebFont loading & computed typography ──
|
||||
await withChromePage(`${baseUrl}/index.html`, 'index.html', 9251, async ({ evalJs, saveScreenshot }) => {
|
||||
await sleep(500)
|
||||
const indexFonts = await evalJs<{
|
||||
exocetLoaded: boolean
|
||||
formalLoaded: boolean
|
||||
h1FontFamily: string
|
||||
descFontFamily: string
|
||||
}>(`(async () => {
|
||||
await document.fonts.ready;
|
||||
await Promise.all([
|
||||
document.fonts.load('16px D2Exocet'),
|
||||
document.fonts.load('16px D2Formal436'),
|
||||
]);
|
||||
return {
|
||||
exocetLoaded: document.fonts.check('16px D2Exocet'),
|
||||
formalLoaded: document.fonts.check('16px D2Formal436'),
|
||||
h1FontFamily: getComputedStyle(document.querySelector('h1')).fontFamily,
|
||||
descFontFamily: getComputedStyle(document.querySelector('.lang-desc')).fontFamily,
|
||||
};
|
||||
})()`)
|
||||
console.log('2. Verified index.html WebFonts:', JSON.stringify(indexFonts))
|
||||
if (!indexFonts.exocetLoaded || !indexFonts.formalLoaded) {
|
||||
throw new Error(`index.html failed to load D2Exocet or D2Formal436: ${JSON.stringify(indexFonts)}`)
|
||||
}
|
||||
if (!indexFonts.h1FontFamily.includes('D2Exocet') || !indexFonts.descFontFamily.includes('D2Formal436')) {
|
||||
throw new Error(`index.html unexpected font-family: ${JSON.stringify(indexFonts)}`)
|
||||
}
|
||||
await saveScreenshot('issue-504-index.png')
|
||||
})
|
||||
|
||||
// ── Phase B: Verify acts-en.html Canvas DC6 bitmap font blitting & DOM/CSS WebFonts ──
|
||||
await withChromePage(`${baseUrl}/acts-en.html?act=1`, 'acts-en.html', 9252, async ({ evalJs, saveScreenshot, waitForGameReady }) => {
|
||||
await waitForGameReady()
|
||||
const enCheck = await evalJs<{
|
||||
exocetLoaded: boolean
|
||||
formalLoaded: boolean
|
||||
hudFontFamily: string
|
||||
controlsFontFamily: string
|
||||
dialogFontFamily: string
|
||||
dialogTitleFontFamily: string
|
||||
dc6FontLoaded: boolean
|
||||
dc6BlitCount: number
|
||||
fillTextCount: number
|
||||
}>(`(async () => {
|
||||
await document.fonts.ready;
|
||||
await Promise.all([
|
||||
document.fonts.load('16px D2Exocet'),
|
||||
document.fonts.load('16px D2Formal436'),
|
||||
]);
|
||||
const inst = window.__d2webHudInstance;
|
||||
let blitCount = 0;
|
||||
let fillCount = 0;
|
||||
const origDrawImage = CanvasRenderingContext2D.prototype.drawImage;
|
||||
const origFillText = CanvasRenderingContext2D.prototype.fillText;
|
||||
CanvasRenderingContext2D.prototype.drawImage = function(...args) {
|
||||
if (args.length === 9) blitCount++;
|
||||
return origDrawImage.apply(this, args);
|
||||
};
|
||||
CanvasRenderingContext2D.prototype.fillText = function(...args) {
|
||||
fillCount++;
|
||||
return origFillText.apply(this, args);
|
||||
};
|
||||
try {
|
||||
if (inst.leftPanel !== 'char') inst.toggleLeftPanel('char');
|
||||
if (inst.rightPanel !== 'inv') inst.toggleRightPanel('inv');
|
||||
inst.inventory.handleMouseMove(561, 93);
|
||||
inst.render(performance.now(), false);
|
||||
} finally {
|
||||
CanvasRenderingContext2D.prototype.drawImage = origDrawImage;
|
||||
CanvasRenderingContext2D.prototype.fillText = origFillText;
|
||||
}
|
||||
return {
|
||||
exocetLoaded: document.fonts.check('16px D2Exocet'),
|
||||
formalLoaded: document.fonts.check('16px D2Formal436'),
|
||||
hudFontFamily: getComputedStyle(document.getElementById('hud')).fontFamily,
|
||||
controlsFontFamily: getComputedStyle(document.getElementById('controls')).fontFamily,
|
||||
dialogFontFamily: getComputedStyle(document.getElementById('dialog')).fontFamily,
|
||||
dialogTitleFontFamily: getComputedStyle(document.getElementById('status')).fontFamily,
|
||||
dc6FontLoaded: Boolean(inst.font?.loaded),
|
||||
dc6BlitCount: blitCount,
|
||||
fillTextCount: fillCount,
|
||||
};
|
||||
})()`)
|
||||
console.log('3. Verified acts-en.html Canvas DC6 blitting & WebFonts:', JSON.stringify(enCheck))
|
||||
if (!enCheck.exocetLoaded || !enCheck.formalLoaded || !enCheck.dc6FontLoaded) {
|
||||
throw new Error(`acts-en.html font loading failed: ${JSON.stringify(enCheck)}`)
|
||||
}
|
||||
if (enCheck.dc6BlitCount < 100 || enCheck.fillTextCount !== 0) {
|
||||
throw new Error(`Expected acts-en.html HUD to use DC6 bitmap blitting (>100 blits, 0 fillText), got: ${JSON.stringify(enCheck)}`)
|
||||
}
|
||||
await saveScreenshot('issue-504-acts-en-panels.png')
|
||||
|
||||
// Open Quest Log + Skill Tree + NPC Dialog to verify Formal 436 & Exocet in all panels
|
||||
await evalJs(`(() => {
|
||||
const inst = window.__d2webHudInstance;
|
||||
inst.toggleLeftPanel('quest');
|
||||
inst.toggleRightPanel('skill');
|
||||
const dialog = document.getElementById('dialog');
|
||||
if (dialog) {
|
||||
dialog.innerHTML = '<div class="dialog-title">Akara · High Priestess of the Sisterhood</div><div class="dialog-body">I am Akara, High Priestess of the Sisterhood of the Sightless Eye. I welcome you, traveler, to our camp, but I am afraid I can offer you but poor shelter within these rickety walls.</div>';
|
||||
dialog.hidden = false;
|
||||
dialog.style.display = 'block';
|
||||
}
|
||||
inst.render(performance.now(), false);
|
||||
})()`)
|
||||
await sleep(300)
|
||||
await saveScreenshot('issue-504-acts-en-quests-skills-dialog.png')
|
||||
})
|
||||
|
||||
console.log('✅ All Issue #504 Headless Browser E2E Checks PASSED!')
|
||||
} finally {
|
||||
await server.close()
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('❌ Issue #504 browser verification failed:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
|
|
@ -4085,7 +4085,7 @@ export function showNotification(message: string, durationMs = 2000, status?: HT
|
|||
banner.style.left = '50%'
|
||||
banner.style.transform = 'translateX(-50%)'
|
||||
banner.style.color = '#e03030'
|
||||
banner.style.fontFamily = 'monospace, sans-serif'
|
||||
banner.style.fontFamily = 'var(--font-display, "D2Exocet", "Cinzel", "Times New Roman", "Liberation Serif", "Noto Serif", "Songti SC", "Noto Serif SC", serif)'
|
||||
banner.style.fontSize = '20px'
|
||||
banner.style.fontWeight = 'bold'
|
||||
banner.style.textShadow = '0 0 8px rgba(0,0,0,0.9), 1px 1px 2px #000'
|
||||
|
|
|
|||
|
|
@ -174,14 +174,14 @@ export class CharacterSheetPanel {
|
|||
})
|
||||
|
||||
// 4. Level, Experience, Next Level
|
||||
font.drawText(ctx, isEn ? 'Level' : '等级', ox + 42, oy + 48, { font: 'font8', color: 'tan', align: 'center' })
|
||||
font.drawText(ctx, String(this.attrs.level), ox + 42, oy + 64, { font: 'fontexocet10', color: 'white', align: 'center' })
|
||||
font.drawText(ctx, isEn ? 'Level' : '等级', ox + 42, oy + 46, { font: isEn ? 'font6' : 'font8', color: 'tan', align: 'center' })
|
||||
font.drawText(ctx, String(this.attrs.level), ox + 42, oy + 63, { font: 'fontexocet10', color: 'white', align: 'center' })
|
||||
|
||||
font.drawText(ctx, isEn ? 'Experience' : '当前经验值', ox + 135, oy + 48, { font: 'font8', color: 'tan', align: 'center' })
|
||||
font.drawText(ctx, this.attrs.xp.toLocaleString(), ox + 135, oy + 64, { font: 'font8', color: 'white', align: 'center' })
|
||||
font.drawText(ctx, isEn ? 'Experience' : '当前经验值', ox + 135, oy + 46, { font: isEn ? 'font6' : 'font8', color: 'tan', align: 'center' })
|
||||
font.drawText(ctx, this.attrs.xp.toLocaleString(), ox + 135, oy + 61, { font: 'font6', color: 'white', align: 'center' })
|
||||
|
||||
font.drawText(ctx, isEn ? 'Next Level' : '升级所需经验', ox + 245, oy + 48, { font: 'font8', color: 'tan', align: 'center' })
|
||||
font.drawText(ctx, this.attrs.nextLevelXp.toLocaleString(), ox + 245, oy + 64, { font: 'font8', color: 'white', align: 'center' })
|
||||
font.drawText(ctx, isEn ? 'Next Level' : '升级所需经验', ox + 245, oy + 46, { font: isEn ? 'font6' : 'font8', color: 'tan', align: 'center' })
|
||||
font.drawText(ctx, this.attrs.nextLevelXp.toLocaleString(), ox + 245, oy + 61, { font: 'font6', color: 'white', align: 'center' })
|
||||
|
||||
// 5. Four Primary Attributes (`Str`, `Dex`, `Vit`, `Ene`)
|
||||
const statRows: readonly { key: BaseStatKey; label: string; y: number; val: number }[] = [
|
||||
|
|
@ -211,7 +211,24 @@ export class CharacterSheetPanel {
|
|||
|
||||
// Remaining Stat Points
|
||||
if (this.attrs.statPoints > 0) {
|
||||
font.drawText(ctx, isEn ? 'Stat Points Remaining' : '剩余属性点', ox + 56, oy + 366, { font: 'font8', color: 'red', align: 'center' })
|
||||
if (isEn) {
|
||||
font.drawText(ctx, 'Stat Points', ox + 56, oy + 361, {
|
||||
font: 'font6',
|
||||
color: 'red',
|
||||
align: 'center',
|
||||
})
|
||||
font.drawText(ctx, 'Remaining', ox + 56, oy + 371, {
|
||||
font: 'font6',
|
||||
color: 'red',
|
||||
align: 'center',
|
||||
})
|
||||
} else {
|
||||
font.drawText(ctx, '剩余属性点', ox + 56, oy + 366, {
|
||||
font: 'font8',
|
||||
color: 'red',
|
||||
align: 'center',
|
||||
})
|
||||
}
|
||||
font.drawText(ctx, String(this.attrs.statPoints), ox + 122, oy + 366, {
|
||||
font: 'fontexocet10',
|
||||
color: 'red',
|
||||
|
|
@ -220,16 +237,48 @@ export class CharacterSheetPanel {
|
|||
}
|
||||
|
||||
// 6. Combat Ratings, Stamina, Life, Mana
|
||||
font.drawText(ctx, isEn ? 'Left Attack Damage' : '左键技能伤害', ox + 166, oy + 91, { font: 'font8', color: 'tan' })
|
||||
font.drawText(ctx, liveRuntime.leftSkillDmg, ox + 285, oy + 91, {
|
||||
font: 'font8',
|
||||
if (isEn) {
|
||||
font.drawText(ctx, 'Left Attack', ox + 214, oy + 87, {
|
||||
font: 'font6',
|
||||
color: 'tan',
|
||||
align: 'center',
|
||||
})
|
||||
font.drawText(ctx, 'Damage', ox + 214, oy + 95, {
|
||||
font: 'font6',
|
||||
color: 'tan',
|
||||
align: 'center',
|
||||
})
|
||||
} else {
|
||||
font.drawText(ctx, '左键技能伤害', ox + 164, oy + 91, {
|
||||
font: 'font8',
|
||||
color: 'tan',
|
||||
})
|
||||
}
|
||||
font.drawText(ctx, liveRuntime.leftSkillDmg, ox + 288, oy + 91, {
|
||||
font: 'font6',
|
||||
color: 'blue',
|
||||
align: 'center',
|
||||
})
|
||||
|
||||
font.drawText(ctx, isEn ? 'Right Attack Damage' : '右键技能伤害', ox + 166, oy + 115, { font: 'font8', color: 'tan' })
|
||||
font.drawText(ctx, liveRuntime.rightSkillDmg, ox + 285, oy + 115, {
|
||||
font: 'font8',
|
||||
if (isEn) {
|
||||
font.drawText(ctx, 'Right Attack', ox + 214, oy + 111, {
|
||||
font: 'font6',
|
||||
color: 'tan',
|
||||
align: 'center',
|
||||
})
|
||||
font.drawText(ctx, 'Damage', ox + 214, oy + 119, {
|
||||
font: 'font6',
|
||||
color: 'tan',
|
||||
align: 'center',
|
||||
})
|
||||
} else {
|
||||
font.drawText(ctx, '右键技能伤害', ox + 164, oy + 115, {
|
||||
font: 'font8',
|
||||
color: 'tan',
|
||||
})
|
||||
}
|
||||
font.drawText(ctx, liveRuntime.rightSkillDmg, ox + 288, oy + 115, {
|
||||
font: 'font6',
|
||||
color: 'blue',
|
||||
align: 'center',
|
||||
})
|
||||
|
|
@ -286,13 +335,37 @@ export class CharacterSheetPanel {
|
|||
|
||||
// 7. Four Elemental Resistances
|
||||
const resRows = [
|
||||
{ label: isEn ? 'Fire Resistance' : '火焰抗性', y: 341, val: this.attrs.fireRes },
|
||||
{ label: isEn ? 'Cold Resistance' : '冰冷抗性', y: 365, val: this.attrs.coldRes },
|
||||
{ label: isEn ? 'Lightning Resistance' : '闪电抗性', y: 389, val: this.attrs.lightRes },
|
||||
{ label: isEn ? 'Poison Resistance' : '毒素抗性', y: 413, val: this.attrs.poisRes },
|
||||
{ labelEn: ['Fire Resistance'], labelZh: '火焰抗性', y: 341, val: this.attrs.fireRes },
|
||||
{ labelEn: ['Cold Resistance'], labelZh: '冰冷抗性', y: 365, val: this.attrs.coldRes },
|
||||
{ labelEn: ['Lightning', 'Resistance'], labelZh: '闪电抗性', y: 389, val: this.attrs.lightRes },
|
||||
{ labelEn: ['Poison Resistance'], labelZh: '毒素抗性', y: 413, val: this.attrs.poisRes },
|
||||
] as const
|
||||
for (const res of resRows) {
|
||||
font.drawText(ctx, res.label, ox + 166, oy + res.y, { font: 'font8', color: 'tan' })
|
||||
if (isEn) {
|
||||
if (res.labelEn.length === 2) {
|
||||
font.drawText(ctx, res.labelEn[0], ox + 214, oy + res.y - 4, {
|
||||
font: 'font6',
|
||||
color: 'tan',
|
||||
align: 'center',
|
||||
})
|
||||
font.drawText(ctx, res.labelEn[1], ox + 214, oy + res.y + 5, {
|
||||
font: 'font6',
|
||||
color: 'tan',
|
||||
align: 'center',
|
||||
})
|
||||
} else {
|
||||
font.drawText(ctx, res.labelEn[0], ox + 214, oy + res.y, {
|
||||
font: 'font6',
|
||||
color: 'tan',
|
||||
align: 'center',
|
||||
})
|
||||
}
|
||||
} else {
|
||||
font.drawText(ctx, res.labelZh, ox + 166, oy + res.y, {
|
||||
font: 'font8',
|
||||
color: 'tan',
|
||||
})
|
||||
}
|
||||
font.drawText(ctx, `${res.val}%`, ox + 291, oy + res.y, {
|
||||
font: 'fontexocet10',
|
||||
color: res.val >= 75 ? 'gold' : res.val < 0 ? 'red' : 'white',
|
||||
|
|
|
|||
161
src/ui/font.ts
|
|
@ -139,7 +139,7 @@ export function parseColoredText(input: string, defaultColor: D2ColorCode = 'whi
|
|||
return spans
|
||||
}
|
||||
|
||||
const FONT_PX_SIZE: Record<D2FontName, number> = {
|
||||
export const FONT_PX_SIZE: Record<D2FontName, number> = {
|
||||
font6: 10,
|
||||
font8: 11,
|
||||
fontexocet10: 13,
|
||||
|
|
@ -148,12 +148,39 @@ const FONT_PX_SIZE: Record<D2FontName, number> = {
|
|||
font30: 26,
|
||||
}
|
||||
|
||||
/**
|
||||
* Frame-relative alphabetic baseline row (`y_frame`) for each 1.13c DC6 font sheet
|
||||
* (`data\local\font\latin\font*.dc6`), aligning uppercase letters (`'A'`, `'O'`, `'T'`)
|
||||
* and digits (`'0'..'9'`) with `baseY = Math.round(y)`.
|
||||
*/
|
||||
export const FONT_BASELINE_OFFSET: Record<D2FontName, number> = {
|
||||
font6: 8,
|
||||
font8: 11,
|
||||
fontexocet10: 13,
|
||||
font16: 13,
|
||||
font30: 23,
|
||||
fontformal12: 25,
|
||||
}
|
||||
|
||||
export const D2_EXOCET_FONT_STACK =
|
||||
'"D2Exocet", "Cinzel", "Times New Roman", "Liberation Serif", "Noto Serif", "Songti SC", "Noto Serif SC", serif'
|
||||
|
||||
export const D2_FORMAL_FONT_STACK =
|
||||
'"D2Formal436", "Times New Roman", "Liberation Serif", "Noto Serif", "Songti SC", "Noto Serif SC", serif'
|
||||
|
||||
function parseHexRgb(hex: string): readonly [number, number, number] {
|
||||
const clean = hex.startsWith('#') ? hex.slice(1) : hex
|
||||
const num = parseInt(clean, 16)
|
||||
if (Number.isNaN(num)) return [232, 228, 220]
|
||||
return [(num >> 16) & 0xff, (num >> 8) & 0xff, num & 0xff]
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 images = new Map<string, HTMLImageElement | HTMLCanvasElement>()
|
||||
private tintedCache = new Map<string, HTMLCanvasElement>()
|
||||
loaded = false
|
||||
|
||||
|
|
@ -183,10 +210,38 @@ export class D2FontRenderer {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register or override a loaded font atlas and metric table (used by tests and offline verifiers).
|
||||
*/
|
||||
setFontAtlas(
|
||||
font: D2FontName,
|
||||
imageOrMeta: HTMLImageElement | HTMLCanvasElement | FontAtlasMeta,
|
||||
maybeImage?: HTMLImageElement | HTMLCanvasElement | FontAtlasMeta,
|
||||
): void {
|
||||
if ('glyphs' in imageOrMeta && 'atlasWidth' in imageOrMeta) {
|
||||
this.metas[font] = imageOrMeta
|
||||
if (maybeImage && !('glyphs' in maybeImage)) {
|
||||
this.images.set(font, maybeImage)
|
||||
}
|
||||
} else {
|
||||
this.images.set(font, imageOrMeta)
|
||||
if (maybeImage && 'glyphs' in maybeImage) {
|
||||
this.metas[font] = maybeImage
|
||||
}
|
||||
}
|
||||
for (const k of [...this.tintedCache.keys()]) {
|
||||
if (k.startsWith(`${font}:`)) this.tintedCache.delete(k)
|
||||
}
|
||||
this.loaded = this.images.size > 0
|
||||
}
|
||||
|
||||
getFontMeta(font: D2FontName): FontAtlasMeta | undefined {
|
||||
return this.metas[font]
|
||||
}
|
||||
|
||||
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
|
||||
|
|
@ -197,8 +252,43 @@ export class D2FontRenderer {
|
|||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return baseImg
|
||||
ctx.drawImage(baseImg, 0, 0)
|
||||
const hex = D2_COLOR_HEX[color]
|
||||
if (typeof ctx.getImageData === 'function' && typeof ctx.putImageData === 'function') {
|
||||
try {
|
||||
const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height)
|
||||
const d = imgData.data
|
||||
const [tr, tg, tb] = parseHexRgb(hex)
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
const a = d[i + 3]!
|
||||
if (a === 0) continue
|
||||
const maxC = Math.max(d[i]!, d[i + 1]!, d[i + 2]!)
|
||||
if (color === 'black') {
|
||||
d[i] = 8
|
||||
d[i + 1] = 8
|
||||
d[i + 2] = 8
|
||||
d[i + 3] = maxC >= 135 ? 210 : 0
|
||||
} else if (maxC < 110) {
|
||||
d[i] = Math.round(tr * 0.15)
|
||||
d[i + 1] = Math.round(tg * 0.15)
|
||||
d[i + 2] = Math.round(tb * 0.15)
|
||||
d[i + 3] = Math.min(200, Math.round(maxC * 1.8))
|
||||
} else {
|
||||
const lum = Math.min(1, (maxC - 60) / 144)
|
||||
d[i] = Math.min(255, Math.round(tr * lum))
|
||||
d[i + 1] = Math.min(255, Math.round(tg * lum))
|
||||
d[i + 2] = Math.min(255, Math.round(tb * lum))
|
||||
d[i + 3] = 255
|
||||
}
|
||||
}
|
||||
ctx.putImageData(imgData, 0, 0)
|
||||
this.tintedCache.set(key, canvas)
|
||||
return canvas
|
||||
} catch {
|
||||
// Fall through to composite operation when canvas is tainted or mock lacks pixel data
|
||||
}
|
||||
}
|
||||
ctx.globalCompositeOperation = 'source-atop'
|
||||
ctx.fillStyle = D2_COLOR_HEX[color]
|
||||
ctx.fillStyle = hex
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
ctx.globalCompositeOperation = 'source-over'
|
||||
this.tintedCache.set(key, canvas)
|
||||
|
|
@ -216,7 +306,7 @@ export class D2FontRenderer {
|
|||
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]) {
|
||||
if (code < 256 && meta?.glyphs[code]) {
|
||||
total += Math.max(4, meta.glyphs[code]!.width)
|
||||
} else {
|
||||
total += code > 255 ? pxSize : Math.round(pxSize * 0.58)
|
||||
|
|
@ -226,6 +316,19 @@ export class D2FontRenderer {
|
|||
return total
|
||||
}
|
||||
|
||||
private canBlitAllSpans(font: D2FontName, spans: readonly TextSpan[]): boolean {
|
||||
if (!this.loaded || !this.images.has(font)) return false
|
||||
const meta = this.metas[font]
|
||||
if (!meta) return false
|
||||
for (const span of spans) {
|
||||
for (let i = 0; i < span.text.length; i++) {
|
||||
const code = span.text.charCodeAt(i)
|
||||
if (code >= 256 || !meta.glyphs[code]) return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw text onto a CanvasRenderingContext2D using genuine DC6 glyphs for ASCII/Latin
|
||||
* and sharp outlined gothic/serif typography for CJK characters.
|
||||
|
|
@ -248,7 +351,7 @@ export class D2FontRenderer {
|
|||
const shadow = options.shadow ?? true
|
||||
|
||||
const totalWidth = this.measureText(text, font)
|
||||
let curX = x
|
||||
let curX = Math.round(x)
|
||||
if (align === 'center') curX = Math.round(x - totalWidth / 2)
|
||||
else if (align === 'right') curX = Math.round(x - totalWidth)
|
||||
const baseY = Math.round(y)
|
||||
|
|
@ -258,9 +361,53 @@ export class D2FontRenderer {
|
|||
|
||||
ctx.save()
|
||||
const isTitleFont = font === 'font16' || font === 'font30' || font === 'fontexocet10'
|
||||
ctx.font = `${isTitleFont ? 'bold ' : ''}${pxSize}px "Cinzel", "Times New Roman", "Liberation Serif", "Noto Serif", "Songti SC", "Noto Serif SC", serif`
|
||||
const fontStack = font === 'fontformal12' ? D2_FORMAL_FONT_STACK : D2_EXOCET_FONT_STACK
|
||||
ctx.font = `${isTitleFont ? 'bold ' : ''}${pxSize}px ${fontStack}`
|
||||
ctx.textBaseline = 'alphabetic'
|
||||
|
||||
if (typeof ctx.drawImage === 'function' && this.canBlitAllSpans(font, spans)) {
|
||||
const meta = this.metas[font]!
|
||||
const dstY = baseY - FONT_BASELINE_OFFSET[font]
|
||||
const shadowAtlas = shadow ? this.getTintedAtlas(font, 'black') : null
|
||||
for (const span of spans) {
|
||||
const tinted = this.getTintedAtlas(font, span.color)
|
||||
for (let i = 0; i < span.text.length; i++) {
|
||||
const code = span.text.charCodeAt(i)
|
||||
const g = meta.glyphs[code]!
|
||||
const adv = Math.max(4, g.width)
|
||||
if (code !== 0x20 && code !== 0xa0 && g.frameW > 0 && g.frameH > 0 && tinted) {
|
||||
if (shadow && span.color !== 'black' && shadowAtlas) {
|
||||
ctx.drawImage(
|
||||
shadowAtlas,
|
||||
g.x,
|
||||
g.y,
|
||||
g.frameW,
|
||||
g.frameH,
|
||||
curX + 1,
|
||||
dstY + 1,
|
||||
g.frameW,
|
||||
g.frameH,
|
||||
)
|
||||
}
|
||||
ctx.drawImage(
|
||||
tinted,
|
||||
g.x,
|
||||
g.y,
|
||||
g.frameW,
|
||||
g.frameH,
|
||||
curX,
|
||||
dstY,
|
||||
g.frameW,
|
||||
g.frameH,
|
||||
)
|
||||
}
|
||||
curX += adv
|
||||
}
|
||||
}
|
||||
ctx.restore()
|
||||
return
|
||||
}
|
||||
|
||||
let measuredTotal = 0
|
||||
for (const s of spans) measuredTotal += ctx.measureText(s.text).width
|
||||
if (align === 'center') curX = Math.round(x - measuredTotal / 2)
|
||||
|
|
|
|||
|
|
@ -419,12 +419,12 @@ export class GroundLabelOverlay {
|
|||
let el = this.labelPool[count]
|
||||
if (el === undefined) {
|
||||
el = document.createElement('div')
|
||||
el.className = 'd2-ground-label'
|
||||
el.className = 'd2-ground-label ground-label'
|
||||
el.style.position = 'absolute'
|
||||
el.style.transform = 'translate(-50%, -50%)'
|
||||
el.style.background = 'rgba(0, 0, 0, 0.88)'
|
||||
el.style.padding = '2px 6px'
|
||||
el.style.fontFamily = "'Exocet', 'Formal', 'SimSun', sans-serif"
|
||||
el.style.fontFamily = '"D2Exocet", "Cinzel", "Times New Roman", "Liberation Serif", "Noto Serif", "Songti SC", "Noto Serif SC", serif'
|
||||
el.style.fontSize = '13px'
|
||||
el.style.lineHeight = '14px'
|
||||
el.style.whiteSpace = 'nowrap'
|
||||
|
|
|
|||
|
|
@ -2848,6 +2848,33 @@ export class WorldPanelsHud {
|
|||
|
||||
for (const p of paragraphs) {
|
||||
if (linesDrawn >= maxLines) break
|
||||
if (p.includes(' ')) {
|
||||
const words = p.split(/\s+/).filter(Boolean)
|
||||
let line = ''
|
||||
for (const word of words) {
|
||||
const candidate = line ? `${line} ${word}` : word
|
||||
if (line && font.measureText(candidate, 'font8') > maxW) {
|
||||
if (linesDrawn + 1 >= maxLines) {
|
||||
const truncated = line.slice(0, Math.max(0, line.length - 2)) + '...'
|
||||
font.drawText(ctx, truncated, x, curY, { font: 'font8', color: 'white' })
|
||||
return
|
||||
}
|
||||
font.drawText(ctx, line, x, curY, { font: 'font8', color: 'white' })
|
||||
linesDrawn++
|
||||
line = word
|
||||
curY += lineH
|
||||
} else {
|
||||
line = candidate
|
||||
}
|
||||
}
|
||||
if (line && linesDrawn < maxLines) {
|
||||
font.drawText(ctx, line, x, curY, { font: 'font8', color: 'white' })
|
||||
linesDrawn++
|
||||
curY += lineH
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
let line = ''
|
||||
for (let i = 0; i < p.length; i++) {
|
||||
const ch = p[i]!
|
||||
|
|
|
|||
|
|
@ -0,0 +1,432 @@
|
|||
/**
|
||||
* 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)
|
||||
})
|
||||
})
|
||||