896 lines
43 KiB
JavaScript
896 lines
43 KiB
JavaScript
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
||
import { createHash } from "node:crypto";
|
||
import { basename, dirname, extname, join, relative } from "node:path";
|
||
import { inflateRawSync } from "node:zlib";
|
||
|
||
const fontMimeTypes = new Map([
|
||
[".otf", "font/otf"],
|
||
[".ttf", "font/ttf"],
|
||
[".woff", "font/woff"],
|
||
[".woff2", "font/woff2"],
|
||
]);
|
||
|
||
function sfntChecksum(bytes, offset = 0, length = bytes.length) {
|
||
let checksum = 0;
|
||
for (let index = 0; index < length; index += 4) {
|
||
let value = 0;
|
||
for (let byte = 0; byte < 4; byte += 1) value = (value << 8) | (bytes[offset + index + byte] ?? 0);
|
||
checksum = (checksum + (value >>> 0)) >>> 0;
|
||
}
|
||
return checksum;
|
||
}
|
||
|
||
function rebuildSfnt(source, tables) {
|
||
const ordered = tables.toSorted((left, right) => (left.tag < right.tag ? -1 : left.tag > right.tag ? 1 : 0));
|
||
const tableCount = ordered.length;
|
||
const largestPower = 2 ** Math.floor(Math.log2(tableCount));
|
||
let outputLength = 12 + tableCount * 16;
|
||
const records = ordered.map((table) => {
|
||
const bytes = table.bytes
|
||
? Buffer.from(table.bytes)
|
||
: Buffer.from(source.subarray(table.offset, table.offset + table.length));
|
||
if (table.tag === "head") bytes.writeUInt32BE(0, 8);
|
||
const record = { ...table, bytes, offset: outputLength };
|
||
outputLength += Math.ceil(bytes.length / 4) * 4;
|
||
return record;
|
||
});
|
||
const output = Buffer.alloc(outputLength);
|
||
output.writeUInt32BE(source.readUInt32BE(0), 0);
|
||
output.writeUInt16BE(tableCount, 4);
|
||
output.writeUInt16BE(largestPower * 16, 6);
|
||
output.writeUInt16BE(Math.log2(largestPower), 8);
|
||
output.writeUInt16BE(tableCount * 16 - largestPower * 16, 10);
|
||
records.forEach((record, index) => {
|
||
const directoryOffset = 12 + index * 16;
|
||
output.write(record.tag, directoryOffset, 4, "ascii");
|
||
output.writeUInt32BE(sfntChecksum(record.bytes), directoryOffset + 4);
|
||
output.writeUInt32BE(record.offset, directoryOffset + 8);
|
||
output.writeUInt32BE(record.bytes.length, directoryOffset + 12);
|
||
record.bytes.copy(output, record.offset);
|
||
});
|
||
const head = records.find((record) => record.tag === "head");
|
||
if (!head || head.bytes.length < 12) throw new Error("text_font_sfnt_head_invalid");
|
||
output.writeUInt32BE((0xB1B0AFBA - sfntChecksum(output)) >>> 0, head.offset + 8);
|
||
return output;
|
||
}
|
||
|
||
function normalizeGlyphBounds(source, tables) {
|
||
const glyphTable = tables.get("glyf");
|
||
const headerTable = tables.get("head");
|
||
const locationTable = tables.get("loca");
|
||
const maximumProfileTable = tables.get("maxp");
|
||
if (
|
||
!glyphTable
|
||
|| !headerTable
|
||
|| headerTable.length < 54
|
||
|| !locationTable
|
||
|| !maximumProfileTable
|
||
|| maximumProfileTable.length < 6
|
||
) return undefined;
|
||
const glyphCount = source.readUInt16BE(maximumProfileTable.offset + 4);
|
||
const locationFormat = source.readInt16BE(headerTable.offset + 50);
|
||
const locationEntrySize = locationFormat === 0 ? 2 : locationFormat === 1 ? 4 : 0;
|
||
if (locationEntrySize === 0 || locationTable.length < (glyphCount + 1) * locationEntrySize) {
|
||
throw new Error("text_font_glyph_location_invalid");
|
||
}
|
||
const glyphBytes = Buffer.from(source.subarray(glyphTable.offset, glyphTable.offset + glyphTable.length));
|
||
const glyphOffset = (index) => {
|
||
const offset = locationTable.offset + index * locationEntrySize;
|
||
return locationFormat === 0 ? source.readUInt16BE(offset) * 2 : source.readUInt32BE(offset);
|
||
};
|
||
let changed = false;
|
||
let previousEnd = 0;
|
||
for (let index = 0; index < glyphCount; index += 1) {
|
||
const start = glyphOffset(index);
|
||
const end = glyphOffset(index + 1);
|
||
if (start < previousEnd || end < start || end > glyphBytes.length) throw new Error("text_font_glyph_location_invalid");
|
||
previousEnd = end;
|
||
if (end - start < 10) continue;
|
||
const xMin = glyphBytes.readInt16BE(start + 2);
|
||
const yMin = glyphBytes.readInt16BE(start + 4);
|
||
const xMax = glyphBytes.readInt16BE(start + 6);
|
||
const yMax = glyphBytes.readInt16BE(start + 8);
|
||
if (xMin > xMax) {
|
||
glyphBytes.writeInt16BE(xMax, start + 2);
|
||
glyphBytes.writeInt16BE(xMin, start + 6);
|
||
changed = true;
|
||
}
|
||
if (yMin > yMax) {
|
||
glyphBytes.writeInt16BE(yMax, start + 4);
|
||
glyphBytes.writeInt16BE(yMin, start + 8);
|
||
changed = true;
|
||
}
|
||
}
|
||
return changed ? glyphBytes : undefined;
|
||
}
|
||
|
||
export function normalizeBrowserFontBytes(source) {
|
||
const bytes = Buffer.from(source);
|
||
if (bytes.length < 12 || bytes.readUInt32BE(0) !== 0x00010000) return undefined;
|
||
const tableCount = bytes.readUInt16BE(4);
|
||
if (12 + tableCount * 16 > bytes.length) throw new Error("text_font_sfnt_directory_invalid");
|
||
const tables = new Map();
|
||
for (let index = 0; index < tableCount; index += 1) {
|
||
const recordOffset = 12 + index * 16;
|
||
const tag = bytes.toString("ascii", recordOffset, recordOffset + 4);
|
||
const offset = bytes.readUInt32BE(recordOffset + 8);
|
||
const length = bytes.readUInt32BE(recordOffset + 12);
|
||
if (offset + length > bytes.length) throw new Error("text_font_sfnt_table_invalid");
|
||
tables.set(tag, { length, offset, recordOffset, tag });
|
||
}
|
||
const head = tables.get("head");
|
||
const verticalHeader = tables.get("vhea");
|
||
if (!head || head.length < 12) return undefined;
|
||
const invalidVerticalVersion = verticalHeader?.length >= 4 && bytes.readUInt32BE(verticalHeader.offset) === 0x00010001;
|
||
const invalidWholeFontChecksum = sfntChecksum(bytes) !== 0xB1B0AFBA;
|
||
const normalizedGlyphs = normalizeGlyphBounds(bytes, tables);
|
||
if (!invalidVerticalVersion && !invalidWholeFontChecksum && !normalizedGlyphs) return undefined;
|
||
const keptTables = [...tables.values()]
|
||
.filter((table) => !invalidVerticalVersion || !["vhea", "vmtx"].includes(table.tag))
|
||
.map((table) => {
|
||
if (table.tag === "glyf" && normalizedGlyphs) return { ...table, bytes: normalizedGlyphs };
|
||
if (!invalidVerticalVersion || table.tag !== "post") return table;
|
||
const post = Buffer.alloc(32);
|
||
bytes.copy(post, 0, table.offset, table.offset + Math.min(table.length, post.length));
|
||
post.writeUInt32BE(0x00030000, 0);
|
||
return { ...table, bytes: post, length: post.length };
|
||
});
|
||
if (invalidVerticalVersion && !tables.has("post")) {
|
||
const post = Buffer.alloc(32);
|
||
post.writeUInt32BE(0x00030000, 0);
|
||
keptTables.push({ bytes: post, length: post.length, offset: 0, recordOffset: 0, tag: "post" });
|
||
}
|
||
return rebuildSfnt(bytes, keptTables);
|
||
}
|
||
|
||
function filesBelow(root) {
|
||
const files = [];
|
||
const visit = (directory) => {
|
||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||
if (entry.name === "__MACOSX" || entry.name === ".DS_Store" || entry.name.startsWith("._")) continue;
|
||
const path = join(directory, entry.name);
|
||
if (entry.isDirectory()) visit(path);
|
||
else if (entry.isFile()) files.push(path);
|
||
}
|
||
};
|
||
if (existsSync(root)) visit(root);
|
||
return files.toSorted((left, right) => left.localeCompare(right));
|
||
}
|
||
|
||
function assetFileType(path) {
|
||
const extension = extname(path).toLowerCase();
|
||
if ([".manifest", ".mat", ".png", ".prefab", ".sprite"].includes(extension)) return extension.slice(1);
|
||
const bytes = readFileSync(path);
|
||
if (bytes.length >= 24 && bytes.readUInt32BE(12) === 0x49484452) return "png";
|
||
if (bytes[0] === 0x7b) {
|
||
try {
|
||
const parsed = JSON.parse(bytes.toString("utf8"));
|
||
if (["Sprite", "Prefab", "Material"].includes(parsed?.typeId)) return String(parsed.typeId).toLocaleLowerCase("en-US");
|
||
} catch {
|
||
return undefined;
|
||
}
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
function readJson(path) {
|
||
return JSON.parse(readFileSync(path, "utf8"));
|
||
}
|
||
|
||
function stemKey(path) {
|
||
return basename(path, extname(path)).normalize("NFKC").toLocaleLowerCase("en-US").replaceAll(/[^\p{L}\p{N}]+/gu, "");
|
||
}
|
||
|
||
function resourceStemKey(path, type) {
|
||
const name = basename(path);
|
||
const extension = extname(name).toLowerCase();
|
||
const stem = extension === `.${type}` ? basename(name, extension) : name.replace(new RegExp(`_${type}$`, "i"), "");
|
||
return stem.normalize("NFKC").toLocaleLowerCase("en-US").replaceAll(/[^\p{L}\p{N}]+/gu, "");
|
||
}
|
||
|
||
function zipEntries(path) {
|
||
const archive = readFileSync(path);
|
||
let eocd = -1;
|
||
for (let index = archive.length - 22; index >= Math.max(0, archive.length - 65_557); index -= 1) {
|
||
if (archive.readUInt32LE(index) === 0x06054b50) {
|
||
eocd = index;
|
||
break;
|
||
}
|
||
}
|
||
if (eocd < 0) throw new Error(`text_font_zip_invalid:${basename(path)}`);
|
||
const totalEntries = archive.readUInt16LE(eocd + 10);
|
||
let cursor = archive.readUInt32LE(eocd + 16);
|
||
const entries = [];
|
||
for (let index = 0; index < totalEntries; index += 1) {
|
||
if (archive.readUInt32LE(cursor) !== 0x02014b50) throw new Error(`text_font_zip_directory_invalid:${basename(path)}`);
|
||
const compression = archive.readUInt16LE(cursor + 10);
|
||
const compressedSize = archive.readUInt32LE(cursor + 20);
|
||
const uncompressedSize = archive.readUInt32LE(cursor + 24);
|
||
const nameLength = archive.readUInt16LE(cursor + 28);
|
||
const extraLength = archive.readUInt16LE(cursor + 30);
|
||
const commentLength = archive.readUInt16LE(cursor + 32);
|
||
const localOffset = archive.readUInt32LE(cursor + 42);
|
||
const name = archive.subarray(cursor + 46, cursor + 46 + nameLength).toString("utf8").replaceAll("\\", "/");
|
||
if (name.startsWith("/") || name.split("/").includes("..")) throw new Error(`text_font_zip_path_invalid:${basename(path)}`);
|
||
if (!name.endsWith("/")) {
|
||
if (archive.readUInt32LE(localOffset) !== 0x04034b50) throw new Error(`text_font_zip_entry_invalid:${basename(path)}`);
|
||
const localNameLength = archive.readUInt16LE(localOffset + 26);
|
||
const localExtraLength = archive.readUInt16LE(localOffset + 28);
|
||
const dataOffset = localOffset + 30 + localNameLength + localExtraLength;
|
||
const compressed = archive.subarray(dataOffset, dataOffset + compressedSize);
|
||
const bytes = compression === 0 ? Buffer.from(compressed)
|
||
: compression === 8 ? inflateRawSync(compressed)
|
||
: undefined;
|
||
if (!bytes || bytes.length !== uncompressedSize) throw new Error(`text_font_zip_compression_invalid:${basename(path)}`);
|
||
entries.push({ bytes, name });
|
||
}
|
||
cursor += 46 + nameLength + extraLength + commentLength;
|
||
}
|
||
return entries;
|
||
}
|
||
|
||
function browserFontResource(templateDirectory, templateId, sourceReference, index) {
|
||
const sourcePath = join(templateDirectory, ...sourceReference.split("/"));
|
||
if (!existsSync(sourcePath) || !statSync(sourcePath).isFile()) throw new Error(`text_font_source_missing:${templateId}:${index}`);
|
||
const assetId = `TEXT-FONT-${templateId}-${String(index + 1).padStart(2, "0")}`;
|
||
const directExtension = extname(sourcePath).toLowerCase();
|
||
if (fontMimeTypes.has(directExtension)) {
|
||
return {
|
||
assetId,
|
||
extension: directExtension,
|
||
mimeType: fontMimeTypes.get(directExtension),
|
||
names: [basename(sourcePath).toLocaleLowerCase("en-US")],
|
||
sourcePath,
|
||
};
|
||
}
|
||
const extracted = filesBelow(dirname(sourcePath)).filter((path) => fontMimeTypes.has(extname(path).toLowerCase()));
|
||
if (extracted.length === 1) {
|
||
const extension = extname(extracted[0]).toLowerCase();
|
||
return {
|
||
assetId,
|
||
extension,
|
||
mimeType: fontMimeTypes.get(extension),
|
||
names: [basename(extracted[0]).toLocaleLowerCase("en-US"), basename(sourcePath, directExtension).toLocaleLowerCase("en-US")],
|
||
sourcePath: extracted[0],
|
||
};
|
||
}
|
||
const archivedFonts = zipEntries(sourcePath).filter((entry) => fontMimeTypes.has(extname(entry.name).toLowerCase()));
|
||
if (archivedFonts.length !== 1) throw new Error(`text_font_archive_ambiguous:${templateId}:${index}`);
|
||
const archived = archivedFonts[0];
|
||
const extension = extname(archived.name).toLowerCase();
|
||
return {
|
||
assetId,
|
||
extension,
|
||
mimeType: fontMimeTypes.get(extension),
|
||
names: [basename(archived.name).toLocaleLowerCase("en-US"), basename(sourcePath, directExtension).toLocaleLowerCase("en-US")],
|
||
sourceBytes: archived.bytes,
|
||
};
|
||
}
|
||
|
||
function manifestFileMap(packageFiles) {
|
||
const filesByName = new Map();
|
||
const filesByAsciiIdentity = new Map();
|
||
for (const path of packageFiles) {
|
||
const key = basename(path).toLocaleLowerCase("en-US");
|
||
const values = filesByName.get(key) ?? [];
|
||
values.push(path);
|
||
filesByName.set(key, values);
|
||
const asciiIdentity = key.replaceAll(/[^a-z0-9]+/g, "");
|
||
const asciiValues = filesByAsciiIdentity.get(asciiIdentity) ?? [];
|
||
asciiValues.push(path);
|
||
filesByAsciiIdentity.set(asciiIdentity, asciiValues);
|
||
}
|
||
const mappings = new Map();
|
||
const spriteDefinitions = new Map();
|
||
const manifestEntries = [];
|
||
for (const path of packageFiles.filter((candidate) => extname(candidate).toLowerCase() === ".manifest")) {
|
||
let manifest;
|
||
try {
|
||
manifest = readJson(path);
|
||
} catch {
|
||
continue;
|
||
}
|
||
for (const item of manifest.UUIDToFilePath ?? []) {
|
||
const uuid = item?.key?.value;
|
||
const fileName = item?.value?.fileName;
|
||
if (typeof uuid !== "string" || typeof fileName !== "string") continue;
|
||
manifestEntries.push({ directories: item.value.directories ?? [], fileName, uuid });
|
||
const candidate = join(dirname(path), ...(item.value.directories ?? []), fileName);
|
||
const normalizedName = basename(fileName).toLocaleLowerCase("en-US");
|
||
const asciiCandidates = filesByAsciiIdentity.get(normalizedName.replaceAll(/[^a-z0-9]+/g, "")) ?? [];
|
||
const resolved = existsSync(candidate) ? candidate
|
||
: filesByName.get(normalizedName)?.[0]
|
||
?? (asciiCandidates.length === 1 ? asciiCandidates[0] : undefined);
|
||
if (resolved) {
|
||
mappings.set(uuid, resolved);
|
||
if (assetFileType(resolved) === "sprite") spriteDefinitions.set(uuid, resolved);
|
||
}
|
||
else mappings.set(uuid, fileName);
|
||
}
|
||
}
|
||
const actualImagesByUuid = new Map();
|
||
const actualSprites = [];
|
||
for (const spritePath of packageFiles.filter((candidate) => assetFileType(candidate) === "sprite")) {
|
||
let sprite;
|
||
try {
|
||
sprite = readJson(spritePath);
|
||
} catch {
|
||
continue;
|
||
}
|
||
const imageUuid = sprite?.object?.m_UUIDList?.[0]?.uuid;
|
||
const siblingImages = packageFiles.filter((candidate) => dirname(candidate) === dirname(spritePath) && assetFileType(candidate) === "png");
|
||
const imagePath = siblingImages.find((candidate) => resourceStemKey(candidate, "png") === resourceStemKey(spritePath, "sprite"))
|
||
?? (siblingImages.length === 1 ? siblingImages[0] : undefined);
|
||
if (typeof imageUuid === "string") actualSprites.push({ imageUuid, path: spritePath });
|
||
if (typeof imageUuid === "string" && imagePath) {
|
||
actualImagesByUuid.set(imageUuid, imagePath);
|
||
mappings.set(imageUuid, imagePath);
|
||
}
|
||
}
|
||
for (const spriteEntry of manifestEntries.filter((entry) => extname(entry.fileName).toLowerCase() === ".sprite")) {
|
||
const imageEntry = manifestEntries.find((entry) => extname(entry.fileName).toLowerCase() === ".png"
|
||
&& stemKey(entry.fileName) === stemKey(spriteEntry.fileName)
|
||
&& JSON.stringify(entry.directories) === JSON.stringify(spriteEntry.directories));
|
||
const asciiIdentity = basename(spriteEntry.fileName).toLocaleLowerCase("en-US").replaceAll(/[^a-z0-9]+/g, "");
|
||
const matchingActualSprites = actualSprites.filter((entry) => basename(entry.path).toLocaleLowerCase("en-US").replaceAll(/[^a-z0-9]+/g, "") === asciiIdentity);
|
||
const matchingImageUuids = [...new Set(matchingActualSprites.map((entry) => entry.imageUuid))];
|
||
const inferredImageUuid = matchingImageUuids.length === 1 ? matchingImageUuids[0] : undefined;
|
||
const imagePath = imageEntry ? actualImagesByUuid.get(imageEntry.uuid) ?? mappings.get(imageEntry.uuid)
|
||
: inferredImageUuid ? actualImagesByUuid.get(inferredImageUuid) ?? mappings.get(inferredImageUuid)
|
||
: undefined;
|
||
const spriteDefinition = matchingActualSprites.length === 1 ? matchingActualSprites[0].path : mappings.get(spriteEntry.uuid);
|
||
if (typeof spriteDefinition === "string" && existsSync(spriteDefinition) && assetFileType(spriteDefinition) === "sprite") {
|
||
spriteDefinitions.set(spriteEntry.uuid, spriteDefinition);
|
||
}
|
||
if (typeof imagePath === "string" && existsSync(imagePath)) mappings.set(spriteEntry.uuid, imagePath);
|
||
}
|
||
return { mappings, spriteDefinitions };
|
||
}
|
||
|
||
function colorHex(value, fallback = "#111111") {
|
||
if (!value || typeof value !== "object") return fallback;
|
||
const channel = (name) => Math.max(0, Math.min(255, Math.round(Number(value[name] ?? 0) * 255))).toString(16).padStart(2, "0");
|
||
return `#${channel("r")}${channel("g")}${channel("b")}`.toUpperCase();
|
||
}
|
||
|
||
function quaternionDegrees(rotation) {
|
||
const z = Number(rotation?.z ?? 0);
|
||
const w = Number(rotation?.w ?? 1);
|
||
return Math.atan2(2 * w * z, 1 - 2 * z * z) * 180 / Math.PI;
|
||
}
|
||
|
||
function estimatedGlyphWidth(character, fontSize) {
|
||
if (/\s/u.test(character)) return fontSize * 0.34;
|
||
if (/^[A-Z]$/u.test(character)) return fontSize * 0.68;
|
||
if (/^[a-z0-9]$/u.test(character)) return fontSize * 0.56;
|
||
if (/^[.,:;!?@©()()\-_'’]$/u.test(character)) return fontSize * 0.42;
|
||
return fontSize;
|
||
}
|
||
|
||
function inferredTextContentSize(text, style) {
|
||
const fontSize = Math.max(1, Number(style.fontSize ?? 48));
|
||
const characterSpacing = Number(style.characterSpacing ?? 0);
|
||
const lineSpacing = Math.max(0.1, Number(style.lineSpacing ?? 1));
|
||
const lines = String(text ?? "").split("\n");
|
||
const width = Math.max(1, ...lines.map((line) => Array.from(line).reduce(
|
||
(total, character, index) => total + estimatedGlyphWidth(character, fontSize) + (index === 0 ? 0 : characterSpacing),
|
||
0,
|
||
)));
|
||
const height = fontSize + Math.max(0, lines.length - 1) * fontSize * lineSpacing;
|
||
return { height, width };
|
||
}
|
||
|
||
function textPathModel(object, transform, contentSize) {
|
||
const option = object?.m_textPathOption;
|
||
const info = option?.pk?.mPathInfo;
|
||
if (!Array.isArray(info?.mPathVerbs) || info.mPathVerbs.length === 0 || !Array.isArray(info?.mPoints)) return undefined;
|
||
const sourceAnchorX = Number(object.m_anchorPoint?.x ?? 0.5);
|
||
const sourceAnchorY = Number(object.m_anchorPoint?.y ?? 0.5);
|
||
const circle = info.mIsCircle === true;
|
||
const scaleX = Math.abs(transform.scaleX);
|
||
const scaleY = Math.abs(transform.scaleY);
|
||
return {
|
||
circle,
|
||
conic_weights: Array.isArray(info.mConicWeights) ? info.mConicWeights.map(Number) : [],
|
||
first_margin: Number(option.firstMargin ?? 0),
|
||
force_alignment: option.forceAlignment === true,
|
||
is_absolute_mode: option.isAbsoluteMode === true,
|
||
last_margin: Number(option.lastMargin ?? 0),
|
||
perpendicular: option.perpendicularToPath !== false,
|
||
points: info.mPoints.map((point) => ({
|
||
x: (Number(point?.x ?? 0) + (circle ? (0.5 - sourceAnchorX) * contentSize.width : -sourceAnchorX * contentSize.width)) * scaleX,
|
||
y: (-Number(point?.y ?? 0) + (circle ? (sourceAnchorY - 0.5) * contentSize.height : sourceAnchorY * contentSize.height)) * scaleY,
|
||
})),
|
||
reversed: option.reversedPath === true,
|
||
verbs: info.mPathVerbs.map(Number),
|
||
};
|
||
}
|
||
|
||
function spriteNinePatch(sprite) {
|
||
const object = sprite?.object;
|
||
if (Number(object?.m_type) !== 3) return undefined;
|
||
const sourceWidth = Number(object.m_width);
|
||
const sourceHeight = Number(object.m_height);
|
||
const left = Number(object.m_startW);
|
||
const rightEdge = Number(object.m_endW);
|
||
const top = Number(object.m_startH);
|
||
const bottomEdge = Number(object.m_endH);
|
||
if (![sourceWidth, sourceHeight, left, rightEdge, top, bottomEdge].every(Number.isFinite)
|
||
|| sourceWidth <= 0 || sourceHeight <= 0
|
||
|| left < 0 || rightEdge < left || rightEdge > sourceWidth
|
||
|| top < 0 || bottomEdge < top || bottomEdge > sourceHeight) return undefined;
|
||
return {
|
||
bottom: sourceHeight - bottomEdge,
|
||
left,
|
||
right: sourceWidth - rightEdge,
|
||
source_height: sourceHeight,
|
||
source_width: sourceWidth,
|
||
top,
|
||
};
|
||
}
|
||
|
||
function pngDimensions(path) {
|
||
const bytes = readFileSync(path);
|
||
if (bytes.length < 24 || bytes.readUInt32BE(12) !== 0x49484452) return undefined;
|
||
return { height: bytes.readUInt32BE(20), width: bytes.readUInt32BE(16) };
|
||
}
|
||
|
||
function firstMaterialTextureAssetId(renderer, input) {
|
||
const materialUuids = (renderer?.m_Materials ?? []).map((item) => item?.uuid?.uuid).filter((uuid) => typeof uuid === "string");
|
||
for (const materialUuid of materialUuids) {
|
||
const materialPath = input.manifestMappings.get(materialUuid);
|
||
if (typeof materialPath !== "string" || extname(materialPath).toLowerCase() !== ".mat" || !existsSync(materialPath)) continue;
|
||
let material;
|
||
try {
|
||
material = readJson(materialPath);
|
||
} catch {
|
||
continue;
|
||
}
|
||
const pending = [material];
|
||
while (pending.length > 0) {
|
||
const value = pending.pop();
|
||
if (Array.isArray(value)) {
|
||
pending.push(...value);
|
||
continue;
|
||
}
|
||
if (!value || typeof value !== "object") continue;
|
||
const textureUuid = value?.uuid?.uuid;
|
||
if (typeof textureUuid === "string") {
|
||
const texturePath = input.manifestMappings.get(textureUuid);
|
||
const assetId = typeof texturePath === "string" ? input.imageIds.get(texturePath) : undefined;
|
||
if (assetId) return assetId;
|
||
}
|
||
pending.push(...Object.values(value));
|
||
}
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
function prefabResolver(prefab) {
|
||
const instances = new Map();
|
||
for (const item of prefab?.instance_map ?? []) {
|
||
if (Number.isInteger(item?.instance_type) && Number.isInteger(item?.instance_id)) {
|
||
instances.set(`${item.instance_type}:${item.instance_id}`, item);
|
||
}
|
||
}
|
||
const resolve = (value) => {
|
||
let current = value?.internalObject ?? value;
|
||
const visited = new Set();
|
||
while (current && typeof current === "object" && !current.object) {
|
||
if (current.internalObject) {
|
||
current = current.internalObject;
|
||
continue;
|
||
}
|
||
if (current.inner_ptr) {
|
||
current = current.inner_ptr;
|
||
continue;
|
||
}
|
||
const key = Number.isInteger(current.instance_type) && Number.isInteger(current.instance_id)
|
||
? `${current.instance_type}:${current.instance_id}`
|
||
: undefined;
|
||
if (!key || visited.has(key) || !instances.has(key)) break;
|
||
visited.add(key);
|
||
current = instances.get(key);
|
||
}
|
||
if (current?.inner_ptr && !current.object) return resolve(current.inner_ptr);
|
||
return current;
|
||
};
|
||
return resolve;
|
||
}
|
||
|
||
function components(object, resolve) {
|
||
return (object?.m_Components ?? []).map(resolve).filter((item) => item?.object);
|
||
}
|
||
|
||
function prefabLayers(prefab, input) {
|
||
const layers = [];
|
||
let order = 0;
|
||
const resolve = prefabResolver(prefab);
|
||
const root = resolve(prefab?.object?.m_RootSo)?.object;
|
||
const visit = (wrapped, parent, ignorePosition = false, anchorXOverride) => {
|
||
const typed = resolve(wrapped);
|
||
const object = typed?.object;
|
||
if (!object) return;
|
||
const local = object.m_LocalTfrm ?? {};
|
||
const localPosition = local.m_Position ?? {};
|
||
const localScale = local.m_Scale ?? {};
|
||
// The first UI group may use a negative scale to bridge the source editor's
|
||
// coordinate system. Browser-space Y is already inverted below, so keeping
|
||
// that sign would mirror every glyph and decoration a second time.
|
||
const scaleX = parent.scaleX * (ignorePosition ? Math.abs(Number(localScale.x ?? 1)) : Number(localScale.x ?? 1));
|
||
const scaleY = parent.scaleY * (ignorePosition ? Math.abs(Number(localScale.y ?? 1)) : Number(localScale.y ?? 1));
|
||
const localX = ignorePosition ? 0 : Number(localPosition.x ?? 0) * parent.scaleX;
|
||
const localY = ignorePosition ? 0 : Number(localPosition.y ?? 0) * parent.scaleY;
|
||
const parentRadians = parent.rotation * Math.PI / 180;
|
||
const transform = {
|
||
alpha: parent.alpha * Math.max(0, Math.min(1, Number(object.m_CustomAlpha ?? 1))),
|
||
anchorX: Math.max(0, Math.min(1, Number(anchorXOverride ?? object.m_anchorPoint?.x ?? 0.5))),
|
||
anchorY: Math.max(0, Math.min(1, Number(object.m_anchorPoint?.y ?? 0.5))),
|
||
rotation: parent.rotation + quaternionDegrees(local.m_Rotation),
|
||
scaleX,
|
||
scaleY,
|
||
x: parent.x + localX * Math.cos(parentRadians) - localY * Math.sin(parentRadians),
|
||
y: parent.y + localX * Math.sin(parentRadians) + localY * Math.cos(parentRadians),
|
||
};
|
||
const nodeComponents = components(object, resolve);
|
||
const localUnderlines = [
|
||
...(parent.underlines ?? []),
|
||
...nodeComponents.filter((item) => item.typeId === "UnderLineBehavior").flatMap((item) => item.object?.m_UnderLineConfig ?? []),
|
||
];
|
||
transform.underlines = localUnderlines;
|
||
const textMesh = nodeComponents.find((item) => item.typeId === "TextMesh")?.object;
|
||
if (textMesh) {
|
||
const textRenderer = nodeComponents.find((item) => item.typeId === "TextRenderer")?.object;
|
||
const style = textMesh.m_fontStyleInfo ?? {};
|
||
const inferredSize = inferredTextContentSize(textMesh.m_text, style);
|
||
const contentSize = {
|
||
height: Number(object.m_contentSize?.height ?? 0) > 0 ? Number(object.m_contentSize.height) : inferredSize.height,
|
||
width: Number(object.m_contentSize?.width ?? 0) > 0 ? Number(object.m_contentSize.width) : inferredSize.width,
|
||
};
|
||
const textPath = textPathModel(object, transform, contentSize);
|
||
const outline = style.outlineInfo?.outlineSize > 0 ? style.outlineInfo : undefined;
|
||
const shadow = style.shadowInfos?.find((item) => Number(item?.offset?.x ?? 0) !== 0 || Number(item?.offset?.y ?? 0) !== 0);
|
||
const fontUuid = textMesh.m_font?.uuid?.uuid;
|
||
const fillPatternAssetId = firstMaterialTextureAssetId(textRenderer, input);
|
||
const sourceTextureLayout = textMesh.m_textureLayoutInfo;
|
||
const fillTextureLayout = fillPatternAssetId
|
||
&& Number(sourceTextureLayout?.rows) > 0
|
||
&& Number(sourceTextureLayout?.columns) > 0
|
||
&& Array.isArray(sourceTextureLayout?.idList)
|
||
&& sourceTextureLayout.idList.length > 0
|
||
? {
|
||
columns: Math.max(1, Number(sourceTextureLayout.columns)),
|
||
id_list: sourceTextureLayout.idList.map(Number),
|
||
rows: Math.max(1, Number(sourceTextureLayout.rows)),
|
||
}
|
||
: undefined;
|
||
layers.push({
|
||
align: Number(style.alignment ?? 0) === 2 ? "right" : Number(style.alignment ?? 0) === 1 ? "center" : "left",
|
||
...(transform.alpha === 1 ? {} : { alpha: transform.alpha }),
|
||
...(transform.anchorX === 0.5 ? {} : { anchor_x: transform.anchorX }),
|
||
...(transform.anchorY === 0.5 ? {} : { anchor_y: 1 - transform.anchorY }),
|
||
fill_color: colorHex(style.color),
|
||
...(fillPatternAssetId ? { fill_pattern_asset_id: fillPatternAssetId } : {}),
|
||
...(fillTextureLayout ? { fill_texture_layout: fillTextureLayout } : {}),
|
||
font_file: typeof fontUuid === "string" ? input.manifestMappings.get(fontUuid) : undefined,
|
||
font_md5: typeof object.m_fontMd5Value === "string" ? object.m_fontMd5Value.toLocaleLowerCase("en-US") : undefined,
|
||
font_size: Math.max(1, Number(style.fontSize ?? 48) * Math.abs(scaleY)),
|
||
height: Math.max(1, contentSize.height * Math.abs(scaleY)),
|
||
letter_spacing: Number(style.characterSpacing ?? 1),
|
||
line_height: Number(style.lineSpacing ?? 1),
|
||
order: order++,
|
||
rotation: -transform.rotation,
|
||
scale_x: Math.sign(scaleX) || 1,
|
||
scale_y: Math.sign(scaleY) || 1,
|
||
shadow_blur: Math.max(0, Number(shadow?.blur ?? shadow?.SDFFontBorder ?? 0)),
|
||
shadow_color: colorHex(shadow?.color, "#000000"),
|
||
shadow_offset_x: Number(shadow?.offset?.x ?? 0),
|
||
shadow_offset_y: -Number(shadow?.offset?.y ?? 0),
|
||
stroke_color: colorHex(outline?.outlineColor, "#000000"),
|
||
stroke_width: Math.max(0, Number(outline?.outlineSize ?? 0)),
|
||
text: String(textMesh.m_text ?? ""),
|
||
...(textPath ? { text_path: textPath } : {}),
|
||
type: "text",
|
||
vertical_align: Number(style.vAlignment ?? 1) === 0 ? "top"
|
||
: Number(style.vAlignment ?? 1) === 2 ? "bottom"
|
||
: "middle",
|
||
width: Math.max(1, contentSize.width * Math.abs(scaleX)),
|
||
x: transform.x,
|
||
y: -transform.y,
|
||
});
|
||
for (const underline of localUnderlines.filter((item) => item?.p1?.enable !== false && item?.p0 === object.m_Name)) {
|
||
const config = underline.p1?.exportParams ?? {};
|
||
const ninePatch = config.ninePatchInfos?.find((item) => item?.enable !== false && typeof item?.texture?.uuid?.uuid === "string");
|
||
const textureUuid = ninePatch?.texture?.uuid?.uuid ?? config.texture?.uuid?.uuid;
|
||
const texturePath = typeof textureUuid === "string" ? input.manifestMappings.get(textureUuid) : undefined;
|
||
const assetId = typeof texturePath === "string" ? input.imageIds.get(texturePath) : undefined;
|
||
if (!assetId || typeof texturePath !== "string") continue;
|
||
const dimensions = pngDimensions(texturePath);
|
||
const targetWidth = Math.max(1, Number(object.m_contentSize?.width ?? 1) * Math.abs(scaleX)
|
||
* (config.enableUnderLineSizeWithText === false ? 1 : Number(config.underLineSize ?? 100) / 100));
|
||
const naturalRatio = dimensions ? dimensions.height / Math.max(1, dimensions.width) : 0.15;
|
||
const targetHeight = Math.max(2, Math.min(Number(object.m_contentSize?.height ?? 48) * 0.65, targetWidth * naturalRatio));
|
||
layers.push({
|
||
...(transform.alpha === 1 ? {} : { alpha: transform.alpha }),
|
||
asset_id: assetId,
|
||
height: targetHeight,
|
||
order: order++,
|
||
rotation: -transform.rotation,
|
||
scale_x: Math.sign(scaleX) || 1,
|
||
scale_y: Math.sign(scaleY) || 1,
|
||
type: "image",
|
||
width: targetWidth,
|
||
x: transform.x,
|
||
y: -transform.y + Number(object.m_contentSize?.height ?? 48) * Math.abs(scaleY) / 2
|
||
+ Number(config.relativeDistance ?? 0) + targetHeight / 2,
|
||
});
|
||
}
|
||
for (const particleComponent of nodeComponents.filter((item) => item.typeId === "ParticlesText2D").map((item) => item.object)) {
|
||
if (particleComponent?.m_isEnabled === false) continue;
|
||
const textureUuid = particleComponent?.m_altasTexUUID?.uuid;
|
||
const texturePath = typeof textureUuid === "string" ? input.manifestMappings.get(textureUuid) : undefined;
|
||
const assetId = typeof texturePath === "string" ? input.imageIds.get(texturePath) : undefined;
|
||
if (!assetId) continue;
|
||
layers.push({
|
||
alpha: transform.alpha * Math.max(0, Math.min(1, Number(particleComponent.m_AlphaIdensity ?? 1))),
|
||
anchor_x: transform.anchorX,
|
||
anchor_y: 1 - transform.anchorY,
|
||
asset_id: assetId,
|
||
atlas_columns: Math.max(1, Number(particleComponent.m_altasUcount ?? 1)),
|
||
atlas_rows: Math.max(1, Number(particleComponent.m_altasVcount ?? 1)),
|
||
color: colorHex(particleComponent.m_ParticleColor, "#FFFFFF"),
|
||
density: Math.max(1, Number(particleComponent.m_particlesDensity ?? 1)),
|
||
height: Math.max(1, contentSize.height * Math.abs(scaleY)),
|
||
order: order++,
|
||
particle_height: Math.max(1, Number(particleComponent.m_particlesRenderSize?.y ?? 8)),
|
||
particle_width: Math.max(1, Number(particleComponent.m_particlesRenderSize?.x ?? 8)),
|
||
randomize_angle: Number(particleComponent.m_particlesRandomizeAngle ?? 0),
|
||
randomize_position: Number(particleComponent.m_particlesRandomizePosition ?? 0),
|
||
rotation: -transform.rotation,
|
||
scale_x: Math.sign(scaleX) || 1,
|
||
scale_y: Math.sign(scaleY) || 1,
|
||
type: "particles",
|
||
width: Math.max(1, contentSize.width * Math.abs(scaleX)),
|
||
x: transform.x,
|
||
y: -transform.y,
|
||
});
|
||
}
|
||
}
|
||
const spriteRenderer = nodeComponents.find((item) => item.typeId === "SpriteRenderer")?.object;
|
||
if (spriteRenderer && spriteRenderer.m_isEnabled !== false) {
|
||
const spriteUuid = spriteRenderer?.m_sprite?.uuid?.uuid;
|
||
const spritePath = typeof spriteUuid === "string" ? input.manifestMappings.get(spriteUuid) : undefined;
|
||
const spriteDefinitionPath = typeof spriteUuid === "string" ? input.spriteDefinitions.get(spriteUuid) : undefined;
|
||
let imagePath;
|
||
let ninePatch;
|
||
if (typeof spriteDefinitionPath === "string" && existsSync(spriteDefinitionPath)) {
|
||
const sprite = readJson(spriteDefinitionPath);
|
||
const imageUuid = sprite?.object?.m_UUIDList?.[0]?.uuid;
|
||
imagePath = typeof spritePath === "string" && assetFileType(spritePath) === "png"
|
||
? spritePath
|
||
: typeof imageUuid === "string" ? input.manifestMappings.get(imageUuid) : undefined;
|
||
ninePatch = spriteNinePatch(sprite);
|
||
} else if (typeof spritePath === "string" && assetFileType(spritePath) === "sprite" && existsSync(spritePath)) {
|
||
const sprite = readJson(spritePath);
|
||
const imageUuid = sprite?.object?.m_UUIDList?.[0]?.uuid;
|
||
if (typeof imageUuid === "string") imagePath = input.manifestMappings.get(imageUuid);
|
||
ninePatch = spriteNinePatch(sprite);
|
||
} else if (typeof spritePath === "string" && assetFileType(spritePath) === "png") imagePath = spritePath;
|
||
const assetId = typeof imagePath === "string" ? input.imageIds.get(imagePath) : undefined;
|
||
if (assetId) {
|
||
layers.push({
|
||
...(transform.alpha === 1 ? {} : { alpha: transform.alpha }),
|
||
...(transform.anchorX === 0.5 ? {} : { anchor_x: transform.anchorX }),
|
||
...(transform.anchorY === 0.5 ? {} : { anchor_y: 1 - transform.anchorY }),
|
||
asset_id: assetId,
|
||
height: Math.max(1, Number(object.m_contentSize?.height ?? 1) * Math.abs(scaleY)),
|
||
order: order++,
|
||
rotation: -transform.rotation,
|
||
scale_x: Math.sign(scaleX) || 1,
|
||
scale_y: Math.sign(scaleY) || 1,
|
||
type: "image",
|
||
width: Math.max(1, Number(object.m_contentSize?.width ?? 1) * Math.abs(scaleX)),
|
||
x: transform.x,
|
||
y: -transform.y,
|
||
...(ninePatch ? { nine_patch: ninePatch } : {}),
|
||
});
|
||
} else {
|
||
input.unresolvedImages.push({ spriteUuid, spritePath });
|
||
}
|
||
}
|
||
const children = (object.m_Children ?? []).map((child) => ({ child, typed: resolve(child) }));
|
||
// Linked text backgrounds can store their image position at the text box's left edge.
|
||
const linkedTextLeftEdges = children
|
||
.filter(({ typed }) => typed?.typeId === "TextView" && Number(typed.object?.m_UseLink ?? 0) === 1)
|
||
.map(({ typed }) => {
|
||
const textObject = typed.object;
|
||
return Number(textObject.m_LocalTfrm?.m_Position?.x ?? 0)
|
||
- Number(textObject.m_anchorPoint?.x ?? 0.5) * Number(textObject.m_contentSize?.width ?? 0);
|
||
});
|
||
for (const { child, typed } of children) {
|
||
const childX = Number(typed?.object?.m_LocalTfrm?.m_Position?.x ?? 0);
|
||
const usesLinkedTextLeftEdge = typed?.typeId === "ImageView"
|
||
&& linkedTextLeftEdges.some((leftEdge) => Math.abs(childX - leftEdge) < 0.01);
|
||
visit(child, transform, false, usesLinkedTextLeftEdge ? 0 : undefined);
|
||
}
|
||
};
|
||
for (const child of root?.m_Children ?? []) visit(child, { alpha: 1, rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, true);
|
||
return layers;
|
||
}
|
||
|
||
function fontIdForFile(fontResources, file, contentMd5) {
|
||
if (typeof contentMd5 === "string") {
|
||
const hashMatch = fontResources.find((resource) => resource.contentMd5 === contentMd5.toLocaleLowerCase("en-US"));
|
||
if (hashMatch) return hashMatch.assetId;
|
||
}
|
||
if (typeof file !== "string") return fontResources[0]?.assetId;
|
||
const name = basename(file).toLocaleLowerCase("en-US");
|
||
return fontResources.find((resource) => resource.names.includes(name))?.assetId ?? fontResources[0]?.assetId;
|
||
}
|
||
|
||
function defaultText(metadata, layers) {
|
||
const candidates = [metadata.default_text, metadata.runtime?.layer?.default_text, ...(metadata.runtime?.human_strings ?? [])]
|
||
.filter((value) => typeof value === "string" && value.trim());
|
||
return String(candidates[0] ?? layers.find((layer) => layer.type === "text" && layer.text.trim())?.text ?? metadata.display_name ?? metadata.canonical_id);
|
||
}
|
||
|
||
function normalizeModel(layers, defaultValue, fontResources) {
|
||
const textLayers = layers.filter((layer) => layer.type === "text");
|
||
const primary = textLayers.find((layer) => layer.text.trim().toLocaleLowerCase("zh-CN") === defaultValue.trim().toLocaleLowerCase("zh-CN")) ?? textLayers[0];
|
||
if (!primary) return undefined;
|
||
for (const layer of textLayers) {
|
||
layer.content_linked = layer.text === primary.text;
|
||
layer.editable = layer === primary;
|
||
layer.font_id = fontIdForFile(fontResources, layer.font_file, layer.font_md5);
|
||
delete layer.font_file;
|
||
delete layer.font_md5;
|
||
}
|
||
const bounds = layers.map((layer) => {
|
||
const anchorX = Number(layer.anchor_x ?? 0.5);
|
||
const anchorY = Number(layer.anchor_y ?? 0.5);
|
||
const radians = Number(layer.rotation ?? 0) * Math.PI / 180;
|
||
const cosine = Math.cos(radians);
|
||
const sine = Math.sin(radians);
|
||
const corners = [
|
||
[-anchorX * layer.width, -anchorY * layer.height],
|
||
[(1 - anchorX) * layer.width, -anchorY * layer.height],
|
||
[-anchorX * layer.width, (1 - anchorY) * layer.height],
|
||
[(1 - anchorX) * layer.width, (1 - anchorY) * layer.height],
|
||
].map(([x, y]) => ({
|
||
x: layer.x + x * Number(layer.scale_x ?? 1) * cosine - y * Number(layer.scale_y ?? 1) * sine,
|
||
y: layer.y + x * Number(layer.scale_x ?? 1) * sine + y * Number(layer.scale_y ?? 1) * cosine,
|
||
}));
|
||
return {
|
||
bottom: Math.max(...corners.map((item) => item.y)),
|
||
left: Math.min(...corners.map((item) => item.x)),
|
||
right: Math.max(...corners.map((item) => item.x)),
|
||
top: Math.min(...corners.map((item) => item.y)),
|
||
};
|
||
});
|
||
const left = Math.min(...bounds.map((item) => item.left));
|
||
const right = Math.max(...bounds.map((item) => item.right));
|
||
const top = Math.min(...bounds.map((item) => item.top));
|
||
const bottom = Math.max(...bounds.map((item) => item.bottom));
|
||
const centerX = (left + right) / 2;
|
||
const centerY = (top + bottom) / 2;
|
||
const normalization = Math.min(1, 360 / Math.max(1, right - left), 260 / Math.max(1, bottom - top));
|
||
for (const layer of layers) {
|
||
layer.x = Number(((layer.x - centerX) * normalization).toFixed(3));
|
||
layer.y = Number(((layer.y - centerY) * normalization).toFixed(3));
|
||
layer.width = Number((layer.width * normalization).toFixed(3));
|
||
layer.height = Number((layer.height * normalization).toFixed(3));
|
||
if (layer.type === "text") {
|
||
layer.font_size = Number((layer.font_size * normalization).toFixed(3));
|
||
layer.letter_spacing = Number((layer.letter_spacing * normalization).toFixed(3));
|
||
layer.stroke_width = Number((layer.stroke_width * normalization).toFixed(3));
|
||
layer.shadow_blur = Number((layer.shadow_blur * normalization).toFixed(3));
|
||
layer.shadow_offset_x = Number((layer.shadow_offset_x * normalization).toFixed(3));
|
||
layer.shadow_offset_y = Number((layer.shadow_offset_y * normalization).toFixed(3));
|
||
if (layer.text_path) {
|
||
layer.text_path.points = layer.text_path.points.map((point) => ({
|
||
x: Number((point.x * normalization).toFixed(3)),
|
||
y: Number((point.y * normalization).toFixed(3)),
|
||
}));
|
||
}
|
||
} else if (layer.type === "particles") {
|
||
layer.particle_height = Number((layer.particle_height * normalization).toFixed(3));
|
||
layer.particle_width = Number((layer.particle_width * normalization).toFixed(3));
|
||
}
|
||
}
|
||
return {
|
||
half_size: {
|
||
height: Number(((bottom - top) * normalization / 2).toFixed(3)),
|
||
width: Number(((right - left) * normalization / 2).toFixed(3)),
|
||
},
|
||
image_layers: layers.filter((layer) => layer.type === "image"),
|
||
particle_layers: layers.filter((layer) => layer.type === "particles"),
|
||
text_layers: textLayers,
|
||
};
|
||
}
|
||
|
||
export function compileTextTemplateAssets({ templateDirectory, templateId }) {
|
||
const metadata = readJson(join(templateDirectory, "metadata.json"));
|
||
const fontResources = (metadata.files?.fonts ?? []).map((reference, index) => {
|
||
const resource = browserFontResource(templateDirectory, templateId, reference, index);
|
||
const normalized = normalizeBrowserFontBytes(resource.sourceBytes ?? readFileSync(resource.sourcePath));
|
||
const browserBytes = normalized ?? resource.sourceBytes ?? readFileSync(resource.sourcePath);
|
||
return {
|
||
...resource,
|
||
contentMd5: createHash("md5").update(browserBytes).digest("hex"),
|
||
...(normalized ? { sourceBytes: normalized, sourcePath: undefined } : {}),
|
||
};
|
||
});
|
||
if (fontResources.length === 0) throw new Error(`text_template_font_missing:${templateId}`);
|
||
const packageRoot = join(templateDirectory, "package");
|
||
const packageFiles = filesBelow(packageRoot);
|
||
const imagePaths = packageFiles.filter((path) => assetFileType(path) === "png");
|
||
const imageResources = imagePaths.map((sourcePath, index) => ({
|
||
assetId: `TEXT-IMAGE-${templateId}-${String(index + 1).padStart(3, "0")}`,
|
||
extension: ".png",
|
||
mimeType: "image/png",
|
||
sourcePath,
|
||
}));
|
||
const imageIds = new Map(imagePaths.map((path, index) => [path, imageResources[index].assetId]));
|
||
const { mappings: manifestMappings, spriteDefinitions } = manifestFileMap(packageFiles);
|
||
const unresolvedImages = [];
|
||
const prefabCandidates = packageFiles.filter((path) => extname(path).toLowerCase() === ".prefab").flatMap((path) => {
|
||
let prefab;
|
||
try {
|
||
prefab = readJson(path);
|
||
} catch {
|
||
return [];
|
||
}
|
||
const layers = prefabLayers(prefab, { imageIds, manifestMappings, spriteDefinitions, unresolvedImages });
|
||
const texts = layers.filter((layer) => layer.type === "text").map((layer) => layer.text.trim().toLocaleLowerCase("zh-CN"));
|
||
const expected = [metadata.default_text, metadata.runtime?.layer?.default_text, ...(metadata.runtime?.human_strings ?? [])]
|
||
.filter((value) => typeof value === "string" && value.trim()).map((value) => value.trim().toLocaleLowerCase("zh-CN"));
|
||
const match = expected.some((value) => texts.includes(value));
|
||
return [{ layers, path, score: (match ? 10_000 : 0) + texts.length * 100 + layers.length }];
|
||
}).filter((candidate) => candidate.layers.some((layer) => layer.type === "text"));
|
||
const chosen = prefabCandidates.toSorted((left, right) => right.score - left.score || left.path.localeCompare(right.path))[0];
|
||
const value = defaultText(metadata, chosen?.layers ?? []);
|
||
const fallbackLayers = [{
|
||
align: "center",
|
||
editable: true,
|
||
fill_color: "#111111",
|
||
font_id: fontResources[0].assetId,
|
||
font_size: 48,
|
||
height: 58,
|
||
letter_spacing: 1,
|
||
line_height: 1.2,
|
||
order: 0,
|
||
rotation: 0,
|
||
scale_x: 1,
|
||
scale_y: 1,
|
||
shadow_blur: 0,
|
||
shadow_color: "#000000",
|
||
shadow_offset_x: 0,
|
||
shadow_offset_y: 0,
|
||
stroke_color: "#000000",
|
||
stroke_width: 0,
|
||
text: value,
|
||
type: "text",
|
||
vertical_align: "middle",
|
||
width: Math.max(96, Array.from(value).length * 52),
|
||
x: 0,
|
||
y: 0,
|
||
}];
|
||
const renderModel = normalizeModel(chosen?.layers ?? fallbackLayers, value, fontResources);
|
||
if (!renderModel || renderModel.text_layers.some((layer) => !layer.font_id)) throw new Error(`text_template_render_model_invalid:${templateId}`);
|
||
return {
|
||
catalog: {
|
||
default_font_id: renderModel.text_layers.find((layer) => layer.editable)?.font_id ?? fontResources[0].assetId,
|
||
default_font_size: renderModel.text_layers.find((layer) => layer.editable)?.font_size ?? 48,
|
||
default_text: value,
|
||
font_match_status: "template_package",
|
||
render_model: renderModel,
|
||
},
|
||
diagnostics: {
|
||
package_images: imageResources.length,
|
||
prefab_candidates: prefabCandidates.length,
|
||
unresolved_images: unresolvedImages.length,
|
||
},
|
||
resources: [...fontResources, ...imageResources].map(({ contentMd5: _contentMd5, names: _names, ...resource }) => resource),
|
||
};
|
||
}
|