support font extraction

This commit is contained in:
Skillbert
2025-12-04 00:34:31 +01:00
parent 8c18280f2d
commit 281fe65e93
10 changed files with 341 additions and 13 deletions

32
generated/fontmetrics.d.ts vendored Normal file
View File

@@ -0,0 +1,32 @@
// GENERATED DO NOT EDIT
// This source data is located at '..\src\opcodes\fontmetrics.jsonc'
// run `npm run filetypes` to rebuild
export type fontmetrics = {
type: number,
sprite: {
complexkerning: number,
sourceid: (number|-1),
chars: {
width: number,
height: number,
bearingy: number,
}[],
sheetwidth: number,
sheetheight: number,
positions: {
x: number,
y: number,
}[],
baseline: (0|number),
uppercaseascent: number,
median: number,
maxascent: number,
maxdescent: number,
scale: number,
} | null,
vector: {
sourceid: number,
size: number,
} | null,
};

View File

@@ -1,4 +1,5 @@
import { makeImageData } from "../imgutils";
import { crc32 } from "../libs/crc32util";
import { Stream } from "../utils";
export type SubImageData = {
@@ -231,4 +232,14 @@ export function parseTgaSprite(file: Buffer) {
img: makeImageData(imgdata, width, height)
};
return r;
}
export function spriteHash(img: ImageData) {
// copy since we need to modify it
const data = img.data.slice();
// for some reason 0 blue isn't possible in-game
for (let i = 0; i < data.length; i += 4) {
if (data[i + 2] == 0) { data[i + 2] = 1; }
}
return crc32(img.data);
}

View File

@@ -7,6 +7,7 @@ export const cacheMajors = {
oldmodels: 7,
sprites: 8,
clientscript: 12,
fontmetricsOld: 13,
sounds: 14,
objects: 16,
enums: 17,
@@ -41,6 +42,7 @@ export const cacheMajors = {
skeletalAnims: 56,
achievements: 57,
fontmetrics: 58,
cutscenes: 66,
index: 255

View File

@@ -0,0 +1,31 @@
["struct",
["type","ubyte"],
["sprite",["opt","type!=2",["struct",
["complexkerning","ubyte"],
["sourceid",["match",["ref","type"],{"1":"int","other":-1}]],
["chars",["chunkedarray",256,[
["width","ubyte"]
],[
["height","ubyte"]
],[
["bearingy","ubyte"]
]]],
["sheetwidth","ushort"],
["sheetheight","ushort"],
["positions",["chunkedarray",256,[
["x","ushort"]
],[
["y","ushort"]
]]],
["baseline",["match",["ref","complexkerning"],{"1":0,"other":"byte"}]],
["uppercaseascent","ubyte"],
["median","ubyte"],
["maxascent","ubyte"],
["maxdescent","ubyte"],
["scale","ubyte"]
]]],
["vector",["opt","type==2",["struct",
["sourceid","uint"],
["size","ubyte"]
]]]
]

View File

@@ -114,6 +114,7 @@ function allParsers() {
mapsquareEnvironment: FileParser.fromJson<import("../generated/mapsquare_envs").mapsquare_envs>(require("./opcodes/mapsquare_envs.jsonc")),
mapZones: FileParser.fromJson<import("../generated/mapzones").mapzones>(require("./opcodes/mapzones.json")),
enums: FileParser.fromJson<import("../generated/enums").enums>(require("./opcodes/enums.json")),
fontmetrics: FileParser.fromJson<import("../generated/fontmetrics").fontmetrics>(require("./opcodes/fontmetrics.jsonc")),
mapscenes: FileParser.fromJson<import("../generated/mapscenes").mapscenes>(require("./opcodes/mapscenes.json")),
sequences: FileParser.fromJson<import("../generated/sequences").sequences>(require("./opcodes/sequences.json")),
framemaps: FileParser.fromJson<import("../generated/framemaps").framemaps>(require("./opcodes/framemaps.jsonc")),

View File

@@ -6,9 +6,8 @@ import { cacheFilenameHash, constrainedMap } from "../utils";
import prettyJson from "json-stringify-pretty-compact";
import { ScriptFS, ScriptOutput } from "../scriptrunner";
import { JSONSchema6Definition } from "json-schema";
import { parseLegacySprite, parseSprite, parseTgaSprite } from "../3d/sprite";
import { parseLegacySprite, parseSprite, parseTgaSprite, spriteHash } from "../3d/sprite";
import { pixelsToImageFile } from "../imgutils";
import { crc32, CrcBuilder } from "../libs/crc32util";
import { getModelHashes, EngineCache } from "../3d/modeltothree";
import { ParsedTexture } from "../3d/textures";
import { parseMusic } from "./musictrack";
@@ -17,6 +16,7 @@ import { classicGroups } from "../cache/classicloader";
import { renderCutscene } from "./rendercutscene";
import { UiRenderContext, renderRsInterfaceHTML } from "./renderrsinterface";
import { compileClientScript, prepareClientScript, renderClientScript, writeClientVarFile, writeOpcodeFile } from "../clientscript";
import { loadFontMetrics } from "./fontmetrics";
type CacheFileId = {
@@ -462,6 +462,27 @@ const decodeInterface2: DecodeModeFactory = () => {
}
}
const fontViewer: DecodeModeFactory = () => {
return {
ext: "font.json",
major: cacheMajors.fontmetrics,
minor: undefined,
logicalDimensions: 1,
usesArchieves: false,
fileToLogical(source, major, minor, subfile) { if (subfile != 0) { throw new Error("subfile 0 expected") } return [minor]; },
logicalToFile(source, id) { return { major: cacheMajors.fontmetrics, minor: id[0], subid: 0 }; },
async logicalRangeToFiles(source, start, end) {
let indexfile = await source.getCacheIndex(cacheMajors.fontmetrics);
return indexfile.filter(q => q && q.minor >= start[0] && q.minor <= end[0]).map(q => ({ index: q, subindex: 0 }));
},
...throwOnNonSimple,
async read(buf, fileid, source) {
return JSON.stringify(await loadFontMetrics(source, buf));
},
description: "Opens the built-in font viewer. Does not support newer vector fonts"
}
}
const decodeClientScript: DecodeModeFactory = (ops) => {
return {
ext: "ts",
@@ -577,10 +598,7 @@ const decodeSpriteHash: DecodeModeFactory = () => {
let images = parseSprite(b);
let str = "";
for (let [sub, img] of images.entries()) {
const data = img.img.data;
// for some reason 0 blue isn't possible in-game
for (let i = 0; i < data.length; i += 4) { if (data[i + 2] == 0) { data[i + 2] = 1; } }
let hash = crc32(img.img.data);
let hash = spriteHash(img.img);
str += (str == "" ? "" : ",") + `{"id":${id[0]},"sub":${sub},"hash":${hash}}`;
}
return str;
@@ -590,6 +608,19 @@ const decodeSpriteHash: DecodeModeFactory = () => {
}
}
const decodeFontHash: DecodeModeFactory = () => {
return {
ext: "json",
...noArchiveIndex(cacheMajors.sprites),
...throwOnNonSimple,
async read(buf, id, source) {
let font = await loadFontMetrics(source, buf);
return JSON.stringify(font.characters.filter(q => q));
},
description: "Used to efficiently compare fonts."
}
}
const decodeMeshHash: DecodeModeFactory = () => {
return {
ext: "json",
@@ -660,6 +691,7 @@ export const cacheFileJsonModes = constrainedMap<JsonBasedFile>()({
proctextures: { parser: parse.proctexture, lookup: noArchiveIndex(cacheMajors.texturesOldPng) },
oldproctextures: { parser: parse.oldproctexture, lookup: singleMinorIndex(cacheMajors.texturesOldPng, 0) },
interfaces: { parser: parse.interfaces, lookup: standardIndex(cacheMajors.interfaces) },
fontmetrics: { parser: parse.fontmetrics, lookup: standardIndex(cacheMajors.fontmetrics) },
classicmodels: { parser: parse.classicmodels, lookup: singleMinorIndex(0, classicGroups.models) },
@@ -725,12 +757,14 @@ const cacheFileDecodersInteractive = constrainedMap<DecodeModeFactory>()({
cutscenehtml: decodeCutscene,
interfacehtml: decodeInterface,
interfaceviewer: decodeInterface2,
fontviewer: fontViewer,
clientscript: decodeClientScript,
clientscriptviewer: decodeClientScriptViewer,
})
const cacheFileDecodersOther = constrainedMap<DecodeModeFactory>()({
bin: decodeBinary,
spritehash: decodeSpriteHash,
fonthash: decodeFontHash,
modelhash: decodeMeshHash,
npcmodels: npcmodels,
});

146
src/scripts/fontmetrics.ts Normal file
View File

@@ -0,0 +1,146 @@
import { parseSprite, spriteHash } from "../3d/sprite";
import { CacheFileSource } from "../cache";
import { cacheMajors } from "../constants";
import { pixelsToDataUrl, sliceImage } from "../imgutils";
import { parse } from "../opdecoder";
export type ParsedFontJson = {
characters: (FontCharacter | null)[],
median: number,
baseline: number,
maxascent: number,
maxdescent: number,
scale: number,
sheethash: number,
sheetwidth: number,
sheetheight: number,
sheet: string
}
export type FontCharacter = {
name: string,
x: number,
y: number,
width: number,
height: number,
bearingy: number,
hash: number
}
export async function loadFontMetrics(cache: CacheFileSource, buf: Buffer) {
let fontdata = parse.fontmetrics.read(buf, cache);
if (!fontdata.sprite) {
throw new Error("fontmetrics missing sprite data");
}
let sprite = await cache.getFileById(cacheMajors.sprites, fontdata.sprite.sourceid);
let imgs = parseSprite(sprite);
if (imgs.length != 1) {
throw new Error("fontmetrics sprite did not contain exactly 1 image");
}
let img = imgs[0];
if (img.fullwidth != fontdata.sprite.sheetwidth || img.fullheight != fontdata.sprite.sheetheight) {
throw new Error("fontmetrics sprite image dimensions do not match metadata");
}
let font: ParsedFontJson = {
characters: [],
median: fontdata.sprite.median,
baseline: fontdata.sprite.baseline,
maxascent: fontdata.sprite.maxascent,
maxdescent: fontdata.sprite.maxdescent,
scale: fontdata.sprite.scale,
sheethash: spriteHash(img.img),
sheetwidth: fontdata.sprite.sheetwidth,
sheetheight: fontdata.sprite.sheetheight,
sheet: await pixelsToDataUrl(img.img)
};
for (let i = 0; i < fontdata.sprite.positions.length; i++) {
let pos = fontdata.sprite.positions[i];
let size = fontdata.sprite.chars[i];
if (size.width === 0 || size.height === 0) {
font.characters.push(null);
continue;
}
let subimg = sliceImage(img.img, { x: pos.x, y: pos.y, width: size.width, height: size.height });
font.characters.push({
name: String.fromCharCode(i),
x: pos.x,
y: pos.y,
width: size.width,
height: size.height,
bearingy: size.bearingy,
hash: spriteHash(subimg)
});
}
return font;
}
export function measureFontText(font: ParsedFontJson, text: string) {
let width = 0;
let height = font.baseline + font.maxdescent;
let x = 0;
for (let i = 0; i < text.length; i++) {
if (text[i] == "\n") {
height += font.baseline;
x = 0;
continue;
}
let fontchar = font.characters[text.charCodeAt(i)];
if (fontchar) {
x += fontchar.width;
width = Math.max(width, x);
}
}
return { width, height };
}
export function fontTextCanvas(font: ParsedFontJson, sheet: HTMLImageElement, text: string, scale: number) {
let { width, height } = measureFontText(font, text);
let canvas = document.createElement("canvas");
canvas.width = Math.max(1, width * scale);
canvas.height = Math.max(1, height * scale);
let ctx = canvas.getContext("2d")!;
ctx.scale(scale, scale);
let x = 0;
let y = 0;
for (let i = 0; i < text.length; i++) {
if (text[i] == "\n") {
y += font.baseline;
x = 0;
continue;
}
let fontchar = font.characters[text.charCodeAt(i)];
if (fontchar) {
let dy = fontchar.bearingy;
ctx.drawImage(sheet, fontchar.x, fontchar.y, fontchar.width, fontchar.height, x, y + dy, fontchar.width, fontchar.height);
x += fontchar.width;
}
}
return canvas;
}
export function composeTexts(cnv: HTMLCanvasElement, color: string, shadow: boolean) {
let tmp = document.createElement("canvas");
tmp.width = cnv.width + (shadow ? 1 : 0);
tmp.height = cnv.height + (shadow ? 1 : 0);
// gotto do some sorcery to colorize the font while preserving alpha because canvas "multiply" messes with alpha
let ctx = tmp.getContext("2d")!;
ctx.fillStyle = color;
ctx.fillRect(0, 0, tmp.width, tmp.height);
ctx.globalCompositeOperation = "multiply";
ctx.drawImage(cnv, 0, 0);
ctx.globalCompositeOperation = "destination-in";
ctx.drawImage(cnv, 0, 0);
if (shadow) {
ctx.filter = "drop-shadow(1px 1px 0px black)";
ctx.globalCompositeOperation = "copy";
ctx.drawImage(tmp, 0, 0);
}
return tmp;
}

View File

@@ -7,11 +7,15 @@ import { cacheFileJsonModes } from "scripts/filetypes";
import { JsonSearch, JsonSearchFilter, useJsonCacheSearch } from "./jsonsearch";
export function CanvasView(p: { canvas: HTMLCanvasElement, fillHeight?: boolean }) {
export function CanvasView(p: { canvas: HTMLCanvasElement | null, fillHeight?: boolean }) {
let ref = React.useCallback((el: HTMLDivElement | null) => {
p.canvas.classList.add("mv-image-preview-canvas");
if (el) { el.appendChild(p.canvas); }
else { p.canvas.remove(); }
if (el && p.canvas) {
p.canvas.classList.add("mv-image-preview-canvas");
el.appendChild(p.canvas);
}
else {
p.canvas?.remove();
}
}, [p.canvas]);
return (
@@ -164,11 +168,18 @@ export function LabeledInput(p: { label: string, children: React.ReactNode }) {
);
}
export function CopyButton(p: ({ text: string } | { getText: () => string }) & { onCopy?: () => void }) {
export function CopyButton(p: { text?: string, canvas?: HTMLCanvasElement, getText?: () => string, onCopy?: () => void }) {
let [didcopy, setdidcopy] = React.useState(false);
let copy = async () => {
await navigator.clipboard.writeText("text" in p ? p.text : p.getText());
if (p.text != undefined) {
await navigator.clipboard.writeText(p.text);
} else if (p.getText) {
await navigator.clipboard.writeText(p.getText());
} else if (p.canvas) {
let item = new ClipboardItem({ 'image/png': new Promise<Blob>(d => p.canvas!.toBlob(d as any)) })
await navigator.clipboard.write([item]);
}
setdidcopy(true);
setTimeout(() => setdidcopy(false), 2000);
}
@@ -193,7 +204,7 @@ export function PasteButton(p: { onPaste: (str: string) => void }) {
);
}
export class InputCommitted extends React.Component<React.DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>>{
export class InputCommitted extends React.Component<React.DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>> {
el: HTMLInputElement | null = null;
stale = false;

57
src/viewer/fontviewer.tsx Normal file
View File

@@ -0,0 +1,57 @@
import * as React from "react";
import { composeTexts, fontTextCanvas, ParsedFontJson } from "../scripts/fontmetrics";
import { CanvasView, CopyButton } from "./commoncontrols";
export function RsFontViewer(p: { data: ParsedFontJson }) {
let [text, settext] = React.useState("The quick brown fox jumps over the lazy dog.");
let [color, setcolor] = React.useState("#ffffff");
let [shadow, setshadow] = React.useState(true);
let [loaded, setloaded] = React.useState(false);
//cache the sheet image
let sheetimg = React.useMemo(() => {
let img = new Image();
img.src = p.data.sheet;
setloaded(img.complete);
img.decode().then(() => setloaded(true));
return img;
}, [p.data]);
let [canvas, setcanvas] = React.useState<HTMLCanvasElement | null>(null);
React.useEffect(() => {
if (!loaded) { return; }
let textcnv = fontTextCanvas(p.data, sheetimg, text, 1 / p.data.scale)
// let textcnv = fontTextCanvas(p.data, sheetimg, text, 1)
let composed = composeTexts(textcnv, color, shadow);
setcanvas(composed);
}, [p.data, text, color, shadow, loaded]);
let ref = (el: HTMLDivElement) => {
if (el) {
el.replaceChildren(sheetimg);
}
}
return (
<div>
<div style={{ marginBottom: "8px" }}>
<textarea style={{ width: "100%", height: "80px", resize: "vertical" }} value={text} onChange={e => settext(e.currentTarget.value)} />
</div>
<div>
Text Color
<input type="color" value={color} onChange={e => setcolor(e.currentTarget.value)} style={{ width: "100px" }} />
</div>
<div>
<label>
<input type="checkbox" checked={shadow} onChange={e => setshadow(e.currentTarget.checked)} />
Drop Shadow
</label>
</div>
<CopyButton canvas={canvas ?? undefined} />
<div style={{ maxWidth: "100%", overflow: "auto", display: "block" }}>
<CanvasView canvas={canvas} fillHeight={true} />
</div>
<div ref={ref} />
</div>
)
}

View File

@@ -22,6 +22,7 @@ import { CLIScriptFS, ScriptFS } from "../scriptrunner";
import { drawTexture } from "../imgutils";
import { RsUIViewer } from "./rsuiviewer";
import { ClientScriptViewer } from "./cs2viewer";
import { RsFontViewer } from "./fontviewer";
//see if we have access to a valid electron import
let electron: typeof import("electron/renderer") | null = (() => {
@@ -680,6 +681,8 @@ export function FileDisplay(p: { file: UIOpenedFile }) {
el = <FileDecodeErrorViewer file={fileText()} />;
} else if (ext == "ui.json") {
el = <RsUIViewer data={fileText()} />
} else if (ext == "font.json") {
el = <RsFontViewer data={JSON.parse(fileText())} />
} else if (ext == "cs2.json") {
el = <ClientScriptViewer data={fileText()} />
} else if (ext == "html") {