From 281fe65e930922504e5a50dc276e1bb7facbe371 Mon Sep 17 00:00:00 2001 From: Skillbert Date: Thu, 4 Dec 2025 00:34:31 +0100 Subject: [PATCH] support font extraction --- generated/fontmetrics.d.ts | 32 ++++++++ src/3d/sprite.ts | 11 +++ src/constants.ts | 2 + src/opcodes/fontmetrics.jsonc | 31 ++++++++ src/opdecoder.ts | 1 + src/scripts/filetypes.ts | 46 +++++++++-- src/scripts/fontmetrics.ts | 146 ++++++++++++++++++++++++++++++++++ src/viewer/commoncontrols.tsx | 25 ++++-- src/viewer/fontviewer.tsx | 57 +++++++++++++ src/viewer/maincomponents.tsx | 3 + 10 files changed, 341 insertions(+), 13 deletions(-) create mode 100644 generated/fontmetrics.d.ts create mode 100644 src/opcodes/fontmetrics.jsonc create mode 100644 src/scripts/fontmetrics.ts create mode 100644 src/viewer/fontviewer.tsx diff --git a/generated/fontmetrics.d.ts b/generated/fontmetrics.d.ts new file mode 100644 index 0000000..c2df8ed --- /dev/null +++ b/generated/fontmetrics.d.ts @@ -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, +}; diff --git a/src/3d/sprite.ts b/src/3d/sprite.ts index 62c39aa..5209c9f 100644 --- a/src/3d/sprite.ts +++ b/src/3d/sprite.ts @@ -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); } \ No newline at end of file diff --git a/src/constants.ts b/src/constants.ts index 9fbf033..91c68f7 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -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 diff --git a/src/opcodes/fontmetrics.jsonc b/src/opcodes/fontmetrics.jsonc new file mode 100644 index 0000000..37b08e6 --- /dev/null +++ b/src/opcodes/fontmetrics.jsonc @@ -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"] + ]]] +] \ No newline at end of file diff --git a/src/opdecoder.ts b/src/opdecoder.ts index 60541d3..5cdead6 100644 --- a/src/opdecoder.ts +++ b/src/opdecoder.ts @@ -114,6 +114,7 @@ function allParsers() { mapsquareEnvironment: FileParser.fromJson(require("./opcodes/mapsquare_envs.jsonc")), mapZones: FileParser.fromJson(require("./opcodes/mapzones.json")), enums: FileParser.fromJson(require("./opcodes/enums.json")), + fontmetrics: FileParser.fromJson(require("./opcodes/fontmetrics.jsonc")), mapscenes: FileParser.fromJson(require("./opcodes/mapscenes.json")), sequences: FileParser.fromJson(require("./opcodes/sequences.json")), framemaps: FileParser.fromJson(require("./opcodes/framemaps.jsonc")), diff --git a/src/scripts/filetypes.ts b/src/scripts/filetypes.ts index 1aaf438..d96aea0 100644 --- a/src/scripts/filetypes.ts +++ b/src/scripts/filetypes.ts @@ -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()({ 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()({ cutscenehtml: decodeCutscene, interfacehtml: decodeInterface, interfaceviewer: decodeInterface2, + fontviewer: fontViewer, clientscript: decodeClientScript, clientscriptviewer: decodeClientScriptViewer, }) const cacheFileDecodersOther = constrainedMap()({ bin: decodeBinary, spritehash: decodeSpriteHash, + fonthash: decodeFontHash, modelhash: decodeMeshHash, npcmodels: npcmodels, }); diff --git a/src/scripts/fontmetrics.ts b/src/scripts/fontmetrics.ts new file mode 100644 index 0000000..b586588 --- /dev/null +++ b/src/scripts/fontmetrics.ts @@ -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; +} diff --git a/src/viewer/commoncontrols.tsx b/src/viewer/commoncontrols.tsx index e578572..227f4c4 100644 --- a/src/viewer/commoncontrols.tsx +++ b/src/viewer/commoncontrols.tsx @@ -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(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, HTMLInputElement>>{ +export class InputCommitted extends React.Component, HTMLInputElement>> { el: HTMLInputElement | null = null; stale = false; diff --git a/src/viewer/fontviewer.tsx b/src/viewer/fontviewer.tsx new file mode 100644 index 0000000..a5a0b59 --- /dev/null +++ b/src/viewer/fontviewer.tsx @@ -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(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 ( +
+
+