mirror of
https://github.com/skillbert/rsmv.git
synced 2025-12-23 21:47:48 -05:00
support font extraction
This commit is contained in:
32
generated/fontmetrics.d.ts
vendored
Normal file
32
generated/fontmetrics.d.ts
vendored
Normal 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,
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
31
src/opcodes/fontmetrics.jsonc
Normal file
31
src/opcodes/fontmetrics.jsonc
Normal 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"]
|
||||
]]]
|
||||
]
|
||||
@@ -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")),
|
||||
|
||||
@@ -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
146
src/scripts/fontmetrics.ts
Normal 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;
|
||||
}
|
||||
@@ -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
57
src/viewer/fontviewer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user