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 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); 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; if (typeof imagePath === "string" && existsSync(imagePath)) mappings.set(spriteEntry.uuid, imagePath); } return mappings; } 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 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 transform = { rotation: parent.rotation + quaternionDegrees(local.m_Rotation), scaleX, scaleY, x: parent.x + (ignorePosition ? 0 : Number(localPosition.x ?? 0) * parent.scaleX), y: parent.y + (ignorePosition ? 0 : Number(localPosition.y ?? 0) * parent.scaleY), }; 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 : style.shadowInfos?.find((item) => item?.outlineInfo?.outlineSize > 0)?.outlineInfo; 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", 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", 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({ 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: Math.max(0, Math.min(1, Number(particleComponent.m_AlphaIdensity ?? 1))), 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; let imagePath; 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); } else if (typeof spritePath === "string" && assetFileType(spritePath) === "png") imagePath = spritePath; const assetId = typeof imagePath === "string" ? input.imageIds.get(imagePath) : undefined; if (assetId) { layers.push({ 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, }); } 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, { 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) => ({ bottom: layer.y + layer.height / 2, left: layer.x - layer.width / 2, right: layer.x + layer.width / 2, top: layer.y - layer.height / 2, })); 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 manifestMappings = 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, 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", 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), }; }