Files
tyx_AI_xhs/scripts/lib/text-template-assets.mjs
T
suyx f244920a61
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
fix(POSTV1-TEMPLATE-LAYOUT-19): 按原始文本框修复模板偏移
2026-08-06 19:01:33 +08:00

791 lines
38 KiB
JavaScript

import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
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 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) => {
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 ?? {};
const scaleX = parent.scaleX * Number(localScale.x ?? 1);
const scaleY = parent.scaleY * 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(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 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;
layers.push({
align: Number(style.alignment ?? 0) === 2 ? "right" : Number(style.alignment ?? 0) === 1 ? "left" : "center",
...(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),
fill_pattern_asset_id: firstMaterialTextureAssetId(textRenderer, input),
font_file: typeof fontUuid === "string" ? input.manifestMappings.get(fontUuid) : undefined,
font_size: Math.max(1, Number(style.fontSize ?? 48) * Math.abs(scaleY)),
height: Math.max(1, Number(object.m_contentSize?.height ?? style.fontSize ?? 48) * 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 ?? ""),
type: "text",
vertical_align: Number(style.vAlignment ?? 1) === 0 ? "top"
: Number(style.vAlignment ?? 1) === 2 ? "bottom"
: "middle",
width: Math.max(1, Number(object.m_contentSize?.width ?? 1) * 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, Number(object.m_contentSize?.height ?? textMesh.m_fontStyleInfo?.fontSize ?? 48) * 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, Number(object.m_contentSize?.width ?? 1) * 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 });
}
}
for (const child of object.m_Children ?? []) visit(child, transform);
};
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) {
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.editable = layer === primary;
layer.font_id = fontIdForFile(fontResources, layer.font_file);
delete layer.font_file;
}
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.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));
} 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));
return normalized ? { ...resource, sourceBytes: normalized, sourcePath: undefined } : resource;
});
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(({ names: _names, ...resource }) => resource),
};
}