卫戍协议:盟约 web remake — initial import
Online co-op browser remake of Arknights' Stronghold Protocol: Alliance (client-simulated combat, official waves/bonds/items/strategies, 3D board, facing wheel, operator loadout, official emotes, deployment scripts).
This commit is contained in:
commit
01a44cd288
408 files changed
+308986
No files matched your search
@@ -0,0 +1,168 @@
|
||||
// Minimal WOFF2 encoder/decoder (W3C WOFF 2.0) with *null* table transforms,
|
||||
// built on Node's zlib Brotli. Used to convert the self-hosted OTF/TTF fonts
|
||||
// (Bender, Novecento) to .woff2 without adding a dependency.
|
||||
//
|
||||
// Encoder: every table is stored untransformed (glyf/loca with transform
|
||||
// version 3 = null, all other tables version 0 = null), concatenated in
|
||||
// directory order (tags ascending, loca directly after glyf) and compressed as
|
||||
// one Brotli stream (font mode, quality 11). The file is padded to 4 bytes.
|
||||
// Decoder: only what our own encoder produces (null transforms, no metadata),
|
||||
// used by tests to verify lossless round trips.
|
||||
|
||||
import zlib from 'node:zlib';
|
||||
|
||||
const KNOWN_TAGS = [
|
||||
'cmap', 'head', 'hhea', 'hmtx', 'maxp', 'name', 'OS/2', 'post', 'cvt ', 'fpgm', 'glyf', 'loca', 'prep', 'CFF ',
|
||||
'VORG', 'EBDT', 'EBLC', 'gasp', 'hdmx', 'kern', 'LTSH', 'PCLT', 'VDMX', 'vhea', 'vmtx', 'BASE', 'GDEF', 'GPOS',
|
||||
'GSUB', 'EBSC', 'JSTF', 'MATH', 'CBDT', 'CBLC', 'COLR', 'CPAL', 'SVG ', 'sbix', 'acnt', 'avar', 'bdat', 'bloc',
|
||||
'bsln', 'cvar', 'fdsc', 'feat', 'fmtx', 'fvar', 'gvar', 'hsty', 'just', 'lcar', 'mort', 'morx', 'opbd', 'prop',
|
||||
'trak', 'Zapf', 'Silf', 'Glat', 'Gloc', 'Feat', 'Sill',
|
||||
];
|
||||
|
||||
const round4 = (n) => (n + 3) & ~3;
|
||||
|
||||
/**
|
||||
* Encode an unsigned integer as WOFF2 UIntBase128.
|
||||
* @param {number} n 0 ≤ n < 2^32
|
||||
* @returns {number[]}
|
||||
*/
|
||||
export function uintBase128(n) {
|
||||
if (!Number.isInteger(n) || n < 0 || n > 0xffffffff) throw new RangeError(`UIntBase128 out of range: ${n}`);
|
||||
const bytes = [];
|
||||
let v = n;
|
||||
do { bytes.unshift(v % 128); v = Math.floor(v / 128); } while (v > 0);
|
||||
for (let i = 0; i < bytes.length - 1; i++) bytes[i] |= 0x80;
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function readBase128(buf, pos) {
|
||||
let v = 0;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const b = buf[pos.i++];
|
||||
if (b === undefined) throw new Error('truncated UIntBase128');
|
||||
if (i === 0 && b === 0x80) throw new Error('UIntBase128 leading zero');
|
||||
v = v * 128 + (b & 0x7f);
|
||||
if (v > 0xffffffff) throw new Error('UIntBase128 overflow');
|
||||
if (!(b & 0x80)) return v;
|
||||
}
|
||||
throw new Error('UIntBase128 too long');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the table directory of an sfnt (OTF/TTF) font.
|
||||
* @param {Buffer} sfnt
|
||||
* @returns {{ flavor: number, tables: {tag:string, data:Buffer}[] }}
|
||||
*/
|
||||
export function readSfnt(sfnt) {
|
||||
if (!Buffer.isBuffer(sfnt) || sfnt.length < 12) throw new Error('not an sfnt font');
|
||||
const flavor = sfnt.readUInt32BE(0);
|
||||
if (![0x00010000, 0x4f54544f, 0x74727565].includes(flavor)) throw new Error('unsupported sfnt flavor (TTC/WOFF?)');
|
||||
const numTables = sfnt.readUInt16BE(4);
|
||||
if (12 + numTables * 16 > sfnt.length) throw new Error('truncated table directory');
|
||||
const tables = [];
|
||||
for (let i = 0; i < numTables; i++) {
|
||||
const rec = 12 + i * 16;
|
||||
const tag = sfnt.toString('latin1', rec, rec + 4);
|
||||
const offset = sfnt.readUInt32BE(rec + 8);
|
||||
const length = sfnt.readUInt32BE(rec + 12);
|
||||
if (offset + length > sfnt.length) throw new Error(`table ${tag} out of bounds`);
|
||||
tables.push({ tag, data: sfnt.subarray(offset, offset + length) });
|
||||
}
|
||||
return { flavor, tables };
|
||||
}
|
||||
|
||||
function orderTables(tables) {
|
||||
const sorted = [...tables].sort((a, b) => (a.tag < b.tag ? -1 : a.tag > b.tag ? 1 : 0));
|
||||
const li = sorted.findIndex((t) => t.tag === 'loca');
|
||||
const gi = sorted.findIndex((t) => t.tag === 'glyf');
|
||||
if (li >= 0 && gi >= 0 && li !== gi + 1) {
|
||||
const [loca] = sorted.splice(li, 1);
|
||||
sorted.splice(sorted.findIndex((t) => t.tag === 'glyf') + 1, 0, loca);
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an OTF/TTF font to WOFF2 (null transforms).
|
||||
* @param {Buffer} sfnt
|
||||
* @returns {Buffer}
|
||||
*/
|
||||
export function encodeWoff2(sfnt) {
|
||||
const { flavor, tables } = readSfnt(sfnt);
|
||||
const ordered = orderTables(tables);
|
||||
const hasGlyf = ordered.some((t) => t.tag === 'glyf');
|
||||
const hasLoca = ordered.some((t) => t.tag === 'loca');
|
||||
if (hasGlyf !== hasLoca) throw new Error('glyf and loca must both be present');
|
||||
const dir = [];
|
||||
for (const t of ordered) {
|
||||
const idx = KNOWN_TAGS.indexOf(t.tag);
|
||||
const transform = t.tag === 'glyf' || t.tag === 'loca' ? 3 : 0; // null transform
|
||||
dir.push((transform << 6) | (idx >= 0 ? idx : 63));
|
||||
if (idx < 0) for (let i = 0; i < 4; i++) dir.push(t.tag.charCodeAt(i) & 0xff);
|
||||
dir.push(...uintBase128(t.data.length));
|
||||
}
|
||||
const stream = Buffer.concat(ordered.map((t) => t.data));
|
||||
const compressed = zlib.brotliCompressSync(stream, {
|
||||
params: {
|
||||
[zlib.constants.BROTLI_PARAM_MODE]: zlib.constants.BROTLI_MODE_FONT,
|
||||
[zlib.constants.BROTLI_PARAM_QUALITY]: 11,
|
||||
[zlib.constants.BROTLI_PARAM_SIZE_HINT]: stream.length,
|
||||
},
|
||||
});
|
||||
const totalSfntSize = 12 + 16 * ordered.length + ordered.reduce((s, t) => s + round4(t.data.length), 0);
|
||||
const unpadded = 48 + dir.length + compressed.length;
|
||||
const length = round4(unpadded);
|
||||
const out = Buffer.alloc(length);
|
||||
out.writeUInt32BE(0x774f4632, 0); // 'wOF2'
|
||||
out.writeUInt32BE(flavor, 4);
|
||||
out.writeUInt32BE(length, 8);
|
||||
out.writeUInt16BE(ordered.length, 12);
|
||||
out.writeUInt16BE(0, 14);
|
||||
out.writeUInt32BE(totalSfntSize, 16);
|
||||
out.writeUInt32BE(compressed.length, 20);
|
||||
out.writeUInt16BE(1, 24); // majorVersion
|
||||
out.writeUInt16BE(0, 26); // minorVersion
|
||||
// meta/priv offsets and lengths stay 0 (bytes 28..47).
|
||||
Buffer.from(dir).copy(out, 48);
|
||||
compressed.copy(out, 48 + dir.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a WOFF2 produced by encodeWoff2 back to its tables (verification helper).
|
||||
* @param {Buffer} woff2
|
||||
* @returns {{ flavor: number, tables: {tag:string, data:Buffer}[] }}
|
||||
* @throws when the file uses transforms/metadata or is malformed
|
||||
*/
|
||||
export function decodeWoff2Tables(woff2) {
|
||||
if (!Buffer.isBuffer(woff2) || woff2.length < 48 || woff2.readUInt32BE(0) !== 0x774f4632) throw new Error('not a WOFF2 file');
|
||||
const flavor = woff2.readUInt32BE(4);
|
||||
const length = woff2.readUInt32BE(8);
|
||||
if (length !== woff2.length || length % 4 !== 0) throw new Error('bad WOFF2 length');
|
||||
const numTables = woff2.readUInt16BE(12);
|
||||
const compressedLength = woff2.readUInt32BE(20);
|
||||
const pos = { i: 48 };
|
||||
const entries = [];
|
||||
for (let n = 0; n < numTables; n++) {
|
||||
const flags = woff2[pos.i++];
|
||||
const idx = flags & 0x3f;
|
||||
const transform = flags >> 6;
|
||||
let tag;
|
||||
if (idx === 63) { tag = woff2.toString('latin1', pos.i, pos.i + 4); pos.i += 4; } else tag = KNOWN_TAGS[idx];
|
||||
const origLength = readBase128(woff2, pos);
|
||||
const isGlyfLoca = tag === 'glyf' || tag === 'loca';
|
||||
if ((isGlyfLoca && transform !== 3) || (!isGlyfLoca && transform !== 0)) throw new Error(`transformed table ${tag} not supported`);
|
||||
entries.push({ tag, origLength });
|
||||
}
|
||||
const end = pos.i + compressedLength;
|
||||
if (end > woff2.length || round4(end) !== length) throw new Error('bad compressed block size');
|
||||
const stream = zlib.brotliDecompressSync(woff2.subarray(pos.i, end));
|
||||
let off = 0;
|
||||
const tables = entries.map((e) => {
|
||||
const data = stream.subarray(off, off + e.origLength);
|
||||
off += e.origLength;
|
||||
return { tag: e.tag, data };
|
||||
});
|
||||
if (off !== stream.length) throw new Error('stream length mismatch');
|
||||
return { flavor, tables };
|
||||
}
|
||||
Reference in new issue
Block a user