feat(POSTV1-ASSET-ALL-16): 完整还原归档文字模板素材
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run

This commit is contained in:
suyx
2026-08-06 16:20:24 +08:00
parent ef6950c5df
commit a70b9fc241
18 changed files with 28556 additions and 1147 deletions
+4 -2
View File
@@ -2,6 +2,7 @@ import { isCanvasState, type CanvasState } from "@dada/shared-contracts";
import { DYNAMIC_RENDER_MODELS } from "./dynamic-render-models.js";
import type { DynamicTemplateId } from "./dynamic-provider.js";
import { textTemplateById } from "./text-assets.js";
type CanvasElement = CanvasState["elements"][number];
@@ -76,9 +77,10 @@ export function elementHalfExtents(state: CanvasState, element: CanvasElement):
const longestCharacterCount = Math.max(1, ...lines.map((line) => Array.from(line).length));
const widthPixels = longestLine * fontSize + (longestCharacterCount - 1) * letterSpacing + 32 + strokeWidth * 2;
const heightPixels = Math.max(1, lines.length) * fontSize * lineHeight + 32 + strokeWidth * 2;
const template = textTemplateById(element.template_or_asset_id);
return {
x: Math.max(hitHalfExtent, widthPixels / state.pixel_width / 2) * element.scale.x,
y: Math.max(hitHalfExtent, heightPixels / state.pixel_height / 2) * element.scale.y,
x: Math.max(hitHalfExtent, widthPixels / state.pixel_width / 2, (template?.renderModel.halfSize.width ?? 0) / state.pixel_width) * element.scale.x,
y: Math.max(hitHalfExtent, heightPixels / state.pixel_height / 2, (template?.renderModel.halfSize.height ?? 0) / state.pixel_height) * element.scale.y,
};
}
+26 -12
View File
@@ -32,6 +32,7 @@ import {
P0A_TEXT_TEMPLATES,
TextEditSession,
createTextTemplateElement,
textTemplateFontOptions,
type TextStylePatch,
type TextTemplateCategory,
type TextTemplateDefinition,
@@ -251,12 +252,14 @@ export function EditorPage({ projectId }: { projectId: string }) {
useEffect(() => {
if (!canvasState) return;
for (const element of canvasState.elements) {
const options = element.type === "text_template"
? [fontIdForTextElement(element)].map((fontId) => fontId ? fontOption(fontId) : undefined).filter((option) => option !== undefined)
: element.type === "dynamic_sticker" ? dynamicFontOptionsFor(element.template_or_asset_id) : [];
for (const option of options) void ensureFont(option.fontId, option.url);
}
const options = canvasState.elements.flatMap((element) => element.type === "text_template"
? [...textTemplateFontOptions(element.template_or_asset_id), ...[fontIdForTextElement(element)]
.map((fontId) => fontId ? fontOption(fontId) : undefined).filter((option) => option !== undefined)]
: element.type === "dynamic_sticker" ? dynamicFontOptionsFor(element.template_or_asset_id) : []);
const unique = [...new Map(options.map((option) => [option.fontId, option])).values()];
void (async () => {
for (const option of unique) await ensureFont(option.fontId, option.url);
})();
}, [canvasState?.elements.map((element) => `${element.element_id}:${element.font_override ?? ""}:${element.template_or_asset_id}`).join("|")]);
useEffect(() => {
@@ -300,8 +303,21 @@ export function EditorPage({ projectId }: { projectId: string }) {
}
async function retryTextFonts() {
const available = P0A_TEXT_TEMPLATES.filter((template) => template.available && template.fontUrl);
await Promise.all(available.map((template) => ensureFont(template.defaultFontId, template.fontUrl!, true)));
const failed = [...new Set(P0A_TEXT_TEMPLATES
.filter((template) => fontStatuses[template.defaultFontId] === "unavailable")
.map((template) => template.defaultFontId))];
for (const fontId of failed) {
const option = fontOption(fontId);
if (option) await ensureFont(option.fontId, option.url, true);
}
}
async function ensureTemplateFonts(template: TextTemplateDefinition) {
for (const option of textTemplateFontOptions(template.templateId)) {
const status = await ensureFont(option.fontId, option.url, fontStatuses[option.fontId] === "unavailable");
if (status !== "ready") return false;
}
return true;
}
function finalizeTextHistory() {
@@ -516,8 +532,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
async function addTextTemplate(template: TextTemplateDefinition) {
if (!template.fontUrl || !canvasState) return;
const status = await ensureFont(template.defaultFontId, template.fontUrl);
if (status !== "ready") {
if (!await ensureTemplateFonts(template)) {
showNotice("素材暂不可用,未使用系统字体替代。");
return;
}
@@ -581,8 +596,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
async function changeTextTemplate(templateId: string) {
const template = P0A_TEXT_TEMPLATES.find((candidate) => candidate.templateId === templateId);
if (!template?.fontUrl) return;
const status = await ensureFont(template.defaultFontId, template.fontUrl);
if (status !== "ready") {
if (!await ensureTemplateFonts(template)) {
showNotice("素材暂不可用,未使用系统字体替代。");
return;
}
+138 -32
View File
@@ -7,7 +7,15 @@ import type { DynamicTemplateId } from "./dynamic-provider.js";
import { DYNAMIC_RENDER_MODELS, dynamicFontOptionsFor, dynamicImageUrl, dynamicTextValue } from "./dynamic-render-models.js";
import type { CanvasPoint, CanvasRect } from "./editor-elements.js";
import { fontFamilyName, type ArchivedFontStatus } from "./text-font-loader.js";
import { fontIdForTextElement } from "./text-assets.js";
import {
fontIdForTextElement,
textTemplateById,
textTemplateFontOptions,
textTemplateImageUrls,
type TextTemplateImageLayer,
type TextTemplateParticleLayer,
type TextTemplateTextLayer,
} from "./text-assets.js";
import { COLOR_CARD_HALF_SIZES, drawColorCard } from "./palette-provider.js";
interface Gesture {
@@ -81,47 +89,135 @@ function measureTextElement(context: CanvasRenderingContext2D, element: CanvasSt
};
}
function drawTextElement(context: CanvasRenderingContext2D, element: CanvasState["elements"][number], fontStatuses: Readonly<Record<string, ArchivedFontStatus>>) {
const fontId = fontIdForTextElement(element);
if (!fontId || fontStatuses[fontId] !== "ready") {
context.fillStyle = "#e5e7eb";
context.fillRect(-110, -34, 220, 68);
context.fillStyle = "#9f1d1d";
context.font = "600 22px Microsoft YaHei UI, sans-serif";
context.textAlign = "center";
context.fillText("字体不可用", 0, 8);
return;
function drawTemplateImageLayer(
context: CanvasRenderingContext2D,
layer: TextTemplateImageLayer,
resourceImages: Readonly<Record<string, HTMLImageElement>>,
) {
const image = resourceImages[layer.assetId];
if (!image) return;
context.save();
context.translate(layer.x, layer.y);
context.rotate(layer.rotation * Math.PI / 180);
context.scale(layer.scaleX, layer.scaleY);
context.drawImage(image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
context.restore();
}
function deterministicUnit(index: number, salt: number) {
const value = Math.sin(index * 12.9898 + salt * 78.233) * 43_758.5453;
return value - Math.floor(value);
}
function drawTemplateParticles(
context: CanvasRenderingContext2D,
layer: TextTemplateParticleLayer,
resourceImages: Readonly<Record<string, HTMLImageElement>>,
) {
const image = resourceImages[layer.assetId];
if (!image) return;
const columns = Math.max(1, Math.floor(layer.atlasColumns));
const rows = Math.max(1, Math.floor(layer.atlasRows));
const sourceWidth = image.naturalWidth / columns;
const sourceHeight = image.naturalHeight / rows;
const count = Math.max(6, Math.min(80, Math.round((layer.width + layer.height) / 18 * layer.density)));
context.save();
context.globalAlpha *= layer.alpha;
for (let index = 0; index < count; index += 1) {
const progress = index / count;
const angle = progress * Math.PI * 2;
const jitter = (deterministicUnit(index, 1) - 0.5) * layer.randomizePosition * 18;
const x = layer.x + Math.cos(angle) * (layer.width / 2 + jitter);
const y = layer.y + Math.sin(angle) * (layer.height / 2 + jitter);
const cell = index % (columns * rows);
const sourceX = (cell % columns) * sourceWidth;
const sourceY = Math.floor(cell / columns) * sourceHeight;
context.save();
context.translate(x, y);
context.rotate((layer.rotation + (deterministicUnit(index, 2) - 0.5) * layer.randomizeAngle * 360) * Math.PI / 180);
context.drawImage(
image, sourceX, sourceY, sourceWidth, sourceHeight,
-layer.particleWidth / 2, -layer.particleHeight / 2, layer.particleWidth, layer.particleHeight,
);
context.restore();
}
const fontSize = element.font_size ?? 48;
const lineHeight = styleValue(element, "line_height", 1.2);
const letterSpacing = styleValue(element, "letter_spacing", 1);
const align = styleValue(element, "text_align", "center") as CanvasTextAlign;
const lines = (element.content ?? "").split("\n");
prepareTextContext(context, element, fontId);
context.restore();
}
function drawTemplateTextLayer(
context: CanvasRenderingContext2D,
element: CanvasState["elements"][number],
layer: TextTemplateTextLayer,
resourceImages: Readonly<Record<string, HTMLImageElement>>,
) {
const editable = layer.editable;
const fontId = editable ? fontIdForTextElement(element) ?? layer.fontId : layer.fontId;
const fontSize = editable ? element.font_size ?? layer.fontSize : layer.fontSize;
const lineHeight = editable ? styleValue(element, "line_height", layer.lineHeight) : layer.lineHeight;
const letterSpacing = editable ? styleValue(element, "letter_spacing", layer.letterSpacing) : layer.letterSpacing;
const align = (editable ? styleValue(element, "text_align", layer.align) : layer.align) as CanvasTextAlign;
const lines = (editable ? element.content ?? layer.text : layer.text).split("\n");
context.save();
context.translate(layer.x, layer.y);
context.rotate(layer.rotation * Math.PI / 180);
context.scale(layer.scaleX, layer.scaleY);
context.font = `${fontSize}px "${fontFamilyName(fontId)}"`;
(context as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = `${letterSpacing}px`;
context.textAlign = align;
context.textBaseline = "middle";
const widths = lines.map((line) => context.measureText(line).width + Math.max(0, line.length - 1) * letterSpacing);
const widths = lines.map((line) => context.measureText(line).width + Math.max(0, Array.from(line).length - 1) * letterSpacing);
const textWidth = Math.max(1, ...widths);
const textHeight = Math.max(fontSize * lineHeight, lines.length * fontSize * lineHeight);
const padding = 16;
const backgroundEnabled = styleValue(element, "background_enabled", false);
if (backgroundEnabled) {
const elementOpacity = context.globalAlpha;
context.globalAlpha = elementOpacity * styleValue(element, "background_opacity", 1);
if (editable && styleValue(element, "background_enabled", false)) {
const alpha = context.globalAlpha;
context.globalAlpha = alpha * styleValue(element, "background_opacity", 1);
context.fillStyle = styleValue(element, "background_color", "#FFE62C");
context.fillRect(-textWidth / 2 - padding, -textHeight / 2 - padding, textWidth + padding * 2, textHeight + padding * 2);
context.globalAlpha = elementOpacity;
context.fillRect(-textWidth / 2 - 16, -textHeight / 2 - 16, textWidth + 32, textHeight + 32);
context.globalAlpha = alpha;
}
context.shadowColor = layer.shadowColor;
context.shadowBlur = layer.shadowBlur;
context.shadowOffsetX = layer.shadowOffsetX;
context.shadowOffsetY = layer.shadowOffsetY;
const fillOverridden = editable && styleValue(element, "template_fill_overridden", false);
const patternImage = !fillOverridden && layer.fillPatternAssetId ? resourceImages[layer.fillPatternAssetId] : undefined;
context.fillStyle = patternImage ? context.createPattern(patternImage, "repeat") ?? layer.fillColor
: editable ? styleValue(element, "fill_color", layer.fillColor) : layer.fillColor;
context.strokeStyle = editable ? styleValue(element, "stroke_color", layer.strokeColor) : layer.strokeColor;
context.lineWidth = editable ? styleValue(element, "stroke_width", layer.strokeWidth) : layer.strokeWidth;
const firstY = -((lines.length - 1) * fontSize * lineHeight) / 2;
const anchorX = align === "left" ? -textWidth / 2 : align === "right" ? textWidth / 2 : 0;
context.fillStyle = styleValue(element, "fill_color", "#111111");
context.strokeStyle = styleValue(element, "stroke_color", "#000000");
context.lineWidth = styleValue(element, "stroke_width", 0);
lines.forEach((line, index) => {
const y = firstY + index * fontSize * lineHeight;
if (styleValue(element, "stroke_enabled", false) && context.lineWidth > 0) context.strokeText(line, anchorX, y);
if ((editable ? styleValue(element, "stroke_enabled", layer.strokeWidth > 0) : layer.strokeWidth > 0) && context.lineWidth > 0) {
context.strokeText(line, anchorX, y);
}
context.fillText(line, anchorX, y);
});
context.restore();
}
function drawTextTemplate(
context: CanvasRenderingContext2D,
element: CanvasState["elements"][number],
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>,
resourceImages: Readonly<Record<string, HTMLImageElement>>,
) {
const template = textTemplateById(element.template_or_asset_id);
if (!template || textTemplateFontOptions(template.templateId).some((font) => fontStatuses[font.fontId] !== "ready")) {
drawDynamicUnavailable(context, "原版字体不可用");
return;
}
const layers = [
...template.renderModel.imageLayers.map((layer) => ({ kind: "image" as const, layer })),
...template.renderModel.particleLayers.map((layer) => ({ kind: "particles" as const, layer })),
...template.renderModel.textLayers.map((layer) => ({ kind: "text" as const, layer })),
].toSorted((left, right) => left.layer.order - right.layer.order);
for (const item of layers) {
if (item.kind === "image") drawTemplateImageLayer(context, item.layer, resourceImages);
else if (item.kind === "particles") drawTemplateParticles(context, item.layer, resourceImages);
else drawTemplateTextLayer(context, element, item.layer, resourceImages);
}
}
function drawDynamicUnavailable(context: CanvasRenderingContext2D, message: string) {
@@ -194,12 +290,16 @@ function elementSelectionHalfSize(
return { height: (model?.halfSize.height ?? 62) * element.scale.y, width: (model?.halfSize.width ?? 170) * element.scale.x };
}
if (element.type !== "text_template") return { height: 78 * element.scale.y, width: 78 * element.scale.x };
const template = textTemplateById(element.template_or_asset_id);
const fontId = fontIdForTextElement(element);
if (!fontId || fontStatuses[fontId] !== "ready") return { height: 34 * element.scale.y, width: 110 * element.scale.x };
context.save();
const geometry = measureTextElement(context, element, fontId);
context.restore();
return { height: geometry.height * element.scale.y / 2, width: geometry.width * element.scale.x / 2 };
return {
height: Math.max(geometry.height / 2, template?.renderModel.halfSize.height ?? 0) * element.scale.y,
width: Math.max(geometry.width / 2, template?.renderModel.halfSize.width ?? 0) * element.scale.x,
};
}
function drawElement(
@@ -228,7 +328,7 @@ function drawElement(
const height = image.naturalHeight * scale;
context.drawImage(image, -width / 2, -height / 2, width, height);
}
} else if (element.type === "text_template") drawTextElement(context, element, fontStatuses);
} else if (element.type === "text_template") drawTextTemplate(context, element, fontStatuses, resourceImages);
else if (element.type === "dynamic_sticker") drawDynamicSticker(context, element, fontStatuses, resourceImages);
context.restore();
}
@@ -271,6 +371,9 @@ function resourceUrlsForCanvas(canvasState: CanvasState) {
const model = DYNAMIC_RENDER_MODELS[element.template_or_asset_id as DynamicTemplateId];
for (const layer of model?.imageLayers ?? []) imageReferences.set(layer.assetId, dynamicImageUrl(element.resource_version, layer.assetId));
}
if (element.type === "text_template") {
for (const [assetId, url] of textTemplateImageUrls(element.template_or_asset_id, element.resource_version)) imageReferences.set(assetId, url);
}
}
return imageReferences;
}
@@ -347,7 +450,10 @@ function renderEditorScene(
function requiredFontIds(canvasState: CanvasState) {
return canvasState.elements.flatMap((element) => {
if (element.type === "text_template") return [fontIdForTextElement(element)].filter((fontId): fontId is string => Boolean(fontId));
if (element.type === "text_template") return [...new Set([
...textTemplateFontOptions(element.template_or_asset_id).map((font) => font.fontId),
...[fontIdForTextElement(element)].filter((fontId): fontId is string => Boolean(fontId)),
])];
if (element.type === "dynamic_sticker") return dynamicFontOptionsFor(element.template_or_asset_id).map((font) => font.fontId);
return [];
});
File diff suppressed because it is too large Load Diff
+157 -6
View File
@@ -9,6 +9,63 @@ type CanvasElement = CanvasState["elements"][number];
export type TextTemplateCategory = "flower" | "simple" | "tag" | "title";
export type TextAlign = "center" | "left" | "right";
export interface TextTemplateImageLayer {
assetId: string;
height: number;
order: number;
rotation: number;
scaleX: number;
scaleY: number;
width: number;
x: number;
y: number;
}
export interface TextTemplateParticleLayer extends TextTemplateImageLayer {
alpha: number;
atlasColumns: number;
atlasRows: number;
color: string;
density: number;
particleHeight: number;
particleWidth: number;
randomizeAngle: number;
randomizePosition: number;
}
export interface TextTemplateTextLayer {
align: TextAlign;
editable: boolean;
fillColor: string;
fillPatternAssetId?: string;
fontId: string;
fontSize: number;
height: number;
letterSpacing: number;
lineHeight: number;
order: number;
rotation: number;
scaleX: number;
scaleY: number;
shadowBlur: number;
shadowColor: string;
shadowOffsetX: number;
shadowOffsetY: number;
strokeColor: string;
strokeWidth: number;
text: string;
width: number;
x: number;
y: number;
}
export interface TextTemplateRenderModel {
halfSize: { height: number; width: number };
imageLayers: readonly TextTemplateImageLayer[];
particleLayers: readonly TextTemplateParticleLayer[];
textLayers: readonly TextTemplateTextLayer[];
}
export interface TextTemplateDefinition {
available: boolean;
catalogOrder: number;
@@ -19,6 +76,7 @@ export interface TextTemplateDefinition {
displayName: string;
fontUrl?: string;
previewUrl?: string;
renderModel: TextTemplateRenderModel;
resourceClass: "parameter_only" | "zip_template";
resourceVersion: string;
templateId: string;
@@ -45,6 +103,9 @@ export interface TextStylePatch {
}
const resourceVersion = P0A_COMPLEX_RELEASE_VERSION;
const textResourceRevision = complexAssetCatalog.text_resource_revision;
const publicTextAssetUrl = (assetId: string, version = resourceVersion) =>
`/api/v1/assets/public/${version}/${assetId}?revision=${textResourceRevision}`;
const defaults = {
background_color: "#FFE62C",
background_enabled: false,
@@ -60,6 +121,47 @@ const defaults = {
const textCatalogById = new Map(complexAssetCatalog.text_templates.map((item) => [item.template_id, item]));
function imageLayer(layer: {
asset_id: string; height: number; order: number; rotation: number; scale_x: number; scale_y: number; width: number; x: number; y: number;
}): TextTemplateImageLayer {
return {
assetId: layer.asset_id, height: layer.height, order: layer.order, rotation: layer.rotation,
scaleX: layer.scale_x, scaleY: layer.scale_y, width: layer.width, x: layer.x, y: layer.y,
};
}
interface RawTextLayer {
align: string; editable: boolean; fill_color: string; fill_pattern_asset_id?: string; font_id: string; font_size: number;
height: number; letter_spacing: number; line_height: number; order: number; rotation: number; scale_x: number; scale_y: number;
shadow_blur: number; shadow_color: string; shadow_offset_x: number; shadow_offset_y: number; stroke_color: string;
stroke_width: number; text: string; width: number; x: number; y: number;
}
function renderModel(item: (typeof complexAssetCatalog.text_templates)[number]): TextTemplateRenderModel {
return {
halfSize: { height: item.render_model.half_size.height, width: item.render_model.half_size.width },
imageLayers: item.render_model.image_layers.map(imageLayer),
particleLayers: item.render_model.particle_layers.map((layer) => ({
...imageLayer(layer), alpha: layer.alpha, atlasColumns: layer.atlas_columns, atlasRows: layer.atlas_rows,
color: layer.color, density: layer.density, particleHeight: layer.particle_height,
particleWidth: layer.particle_width, randomizeAngle: layer.randomize_angle, randomizePosition: layer.randomize_position,
})),
textLayers: item.render_model.text_layers.map((value) => {
const layer = value as unknown as RawTextLayer;
return {
align: layer.align as TextAlign, editable: layer.editable, fillColor: layer.fill_color,
...(layer.fill_pattern_asset_id ? { fillPatternAssetId: layer.fill_pattern_asset_id } : {}),
fontId: layer.font_id, fontSize: layer.font_size, height: layer.height, letterSpacing: layer.letter_spacing,
lineHeight: layer.line_height, order: layer.order, rotation: layer.rotation, scaleX: layer.scale_x,
scaleY: layer.scale_y, shadowBlur: layer.shadow_blur, shadowColor: layer.shadow_color,
shadowOffsetX: layer.shadow_offset_x, shadowOffsetY: layer.shadow_offset_y,
strokeColor: layer.stroke_color, strokeWidth: layer.stroke_width, text: layer.text,
width: layer.width, x: layer.x, y: layer.y,
};
}),
};
}
export const P0A_TEXT_TEMPLATES: readonly TextTemplateDefinition[] = P0A_TEXT_TEMPLATE_IDS.map((templateId, catalogOrder) => {
const item = textCatalogById.get(templateId);
if (!item) throw new Error(`missing text template definition ${templateId}`);
@@ -71,8 +173,9 @@ export const P0A_TEXT_TEMPLATES: readonly TextTemplateDefinition[] = P0A_TEXT_TE
defaultFontSize: item.default_font_size,
defaultText: item.default_text,
displayName: item.display_name,
fontUrl: `/api/v1/assets/public/${resourceVersion}/${item.default_font_id}`,
...(item.preview_asset_id ? { previewUrl: `/api/v1/assets/public/${resourceVersion}/${item.preview_asset_id}` } : {}),
fontUrl: publicTextAssetUrl(item.default_font_id),
...(item.preview_asset_id ? { previewUrl: publicTextAssetUrl(item.preview_asset_id) } : {}),
renderModel: renderModel(item),
resourceClass: item.resource_class as "parameter_only" | "zip_template",
resourceVersion,
templateId,
@@ -87,12 +190,45 @@ export const P0A_FONT_OPTIONS: readonly FontOption[] = P0A_REQUIRED_FONT_PANEL_I
return {
displayName: item.display_name,
fontId,
url: `/api/v1/assets/public/${resourceVersion}/${fontId}`,
url: publicTextAssetUrl(fontId),
};
});
const templateFontOptions: readonly FontOption[] = P0A_TEXT_TEMPLATES.flatMap((template) => {
const fontIds = [...new Set(template.renderModel.textLayers.map((layer) => layer.fontId))];
return fontIds.map((fontId, index) => ({
displayName: `${template.displayName}原版字体${fontIds.length > 1 ? ` ${index + 1}` : ""}`,
fontId,
url: publicTextAssetUrl(fontId, template.resourceVersion),
}));
});
const allFontOptions = new Map([...P0A_FONT_OPTIONS, ...templateFontOptions].map((option) => [option.fontId, option]));
export function fontOption(fontId: string) {
return P0A_FONT_OPTIONS.find((option) => option.fontId === fontId);
return allFontOptions.get(fontId);
}
export function textTemplateById(templateId: string) {
return P0A_TEXT_TEMPLATES.find((template) => template.templateId === templateId);
}
export function textTemplateFontOptions(templateId: string) {
const template = textTemplateById(templateId);
if (!template) return [];
return [...new Set(template.renderModel.textLayers.map((layer) => layer.fontId))]
.map((fontId) => fontOption(fontId)).filter((option): option is FontOption => option !== undefined);
}
export function textTemplateImageUrls(templateId: string, version = resourceVersion) {
const template = textTemplateById(templateId);
if (!template) return new Map<string, string>();
const assetIds = new Set([
...template.renderModel.imageLayers.map((layer) => layer.assetId),
...template.renderModel.particleLayers.map((layer) => layer.assetId),
...template.renderModel.textLayers.flatMap((layer) => layer.fillPatternAssetId ? [layer.fillPatternAssetId] : []),
]);
return new Map([...assetIds].map((assetId) => [assetId, publicTextAssetUrl(assetId, version)]));
}
export function fontIdForTextElement(element: CanvasElement) {
@@ -120,7 +256,19 @@ function isStep(value: number, minimum: number, step: number) {
}
function templateStyle(template: TextTemplateDefinition): Record<string, string | number | boolean | null> {
return { ...defaults, default_font_id: template.defaultFontId };
const primary = template.renderModel.textLayers.find((layer) => layer.editable) ?? template.renderModel.textLayers[0];
return {
...defaults,
default_font_id: template.defaultFontId,
fill_color: primary?.fillColor ?? defaults.fill_color,
letter_spacing: primary?.letterSpacing ?? defaults.letter_spacing,
line_height: primary?.lineHeight ?? defaults.line_height,
stroke_color: primary?.strokeColor ?? defaults.stroke_color,
stroke_enabled: (primary?.strokeWidth ?? 0) > 0,
stroke_width: primary?.strokeWidth ?? defaults.stroke_width,
template_fill_overridden: false,
text_align: primary?.align ?? defaults.text_align,
};
}
export function searchTextTemplates(
@@ -196,7 +344,10 @@ export class TextEditSession {
setStyle(patch: TextStylePatch) {
const style = { ...(this.draft.style_parameters ?? {}) };
if (patch.fillColor !== undefined) style.fill_color = checkedColor(patch.fillColor);
if (patch.fillColor !== undefined) {
style.fill_color = checkedColor(patch.fillColor);
style.template_fill_overridden = true;
}
if (patch.strokeColor !== undefined) style.stroke_color = checkedColor(patch.strokeColor);
if (patch.backgroundColor !== undefined) style.background_color = checkedColor(patch.backgroundColor);
if (patch.strokeEnabled !== undefined) style.stroke_enabled = patch.strokeEnabled;
+4 -3
View File
@@ -31,14 +31,15 @@ export function TextTemplatePanel(props: {
<div className="editor-template-grid">
{visible.map((template) => {
const status = props.fontStatuses[template.defaultFontId] ?? "idle";
const unavailable = !template.available || status === "unavailable";
return <button aria-label={`${template.templateId} ${template.displayName}${unavailable ? " 素材暂不可用" : ""}`} disabled={!props.canAdd || unavailable || status === "loading"} key={template.templateId} onClick={() => props.onAdd(template)} type="button">
const unavailable = !template.available;
const retryable = template.available && status === "unavailable";
return <button aria-label={`${template.templateId} ${template.displayName}${unavailable ? " 素材暂不可用" : retryable ? " 字体待重试" : ""}`} disabled={!props.canAdd || unavailable || status === "loading"} key={template.templateId} onClick={() => props.onAdd(template)} type="button">
{template.previewUrl
? <img alt="" className="editor-template-preview" decoding="async" loading="lazy" src={template.previewUrl} />
: <span className={`editor-template-mark ${template.category}`}>{template.displayName.slice(0, 2)}</span>}
<strong>{template.templateId}</strong>
<span>{template.displayName}</span>
{unavailable ? <small></small> : status === "loading" ? <small></small> : null}
{unavailable ? <small></small> : retryable ? <small></small> : status === "loading" ? <small></small> : null}
</button>;
})}
</div>
File diff suppressed because it is too large Load Diff
+1
View File
@@ -24,6 +24,7 @@
"package:portable": "node scripts/build-portable.mjs",
"assets:manifest": "pnpm build:workspace-packages && node scripts/generate-runtime-asset-manifest.mjs",
"assets:deploy": "pnpm build:workspace-packages && node scripts/deploy-runtime-assets.mjs",
"assets:validate-browser-fonts": "node scripts/validate-browser-font-assets.mjs",
"assets:browser-catalog": "pnpm build:workspace-packages && node scripts/generate-complex-browser-catalog.mjs",
"generate:openapi": "node scripts/generate-openapi.mjs",
"check:openapi": "node scripts/check-openapi.mjs",
+1 -1
View File
@@ -33,5 +33,5 @@ const plan = await buildP0aRuntimeAssetPlan({
if (serializeRuntimeAssetManifest(plan.manifest) !== serializeRuntimeAssetManifest(trustedManifest)) {
throw new Error("runtime_asset_source_does_not_match_trusted_manifest");
}
const result = deployRuntimeAssetPlan({ assetRoot, manifest: trustedManifest, resources: plan.resources });
const result = deployRuntimeAssetPlan({ allowManagedUpdate: true, assetRoot, manifest: trustedManifest, resources: plan.resources });
process.stdout.write(`${JSON.stringify({ linked_files: result.linked_files, status: result.status })}\n`);
+20 -18
View File
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { homedir } from "node:os";
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
@@ -8,6 +8,7 @@ import {
P0A_REQUIRED_FONT_PANEL_IDS,
P0A_TEXT_TEMPLATE_IDS,
} from "../packages/template-registry/dist/index.js";
import { compileTextTemplateAssets } from "./lib/text-template-assets.mjs";
function option(name) {
const index = process.argv.indexOf(name);
@@ -25,10 +26,6 @@ function oneDirectoryWithPrefix(root, prefix) {
return join(root, matches[0].name);
}
function sha256(path) {
return createHash("sha256").update(readFileSync(path)).digest("hex").toUpperCase();
}
function textTemplateDirectory(root, templateId) {
const family = templateId.startsWith("FLOWER") ? "花字"
: templateId.startsWith("SIMPLE") ? "简约"
@@ -57,11 +54,9 @@ const fontPackagesRoot = join(replicationRoot, "sticker_text", "字体", "面板
const textRoot = join(replicationRoot, "sticker_text", "模板", "单模板归档");
const dynamicRoot = join(replicationRoot, "sticker_interactive", "单模板归档", "templates");
const fontHashes = new Map();
const fontPanelItems = P0A_REQUIRED_FONT_PANEL_IDS.map((fontId, displayOrder) => {
const metadata = readJson(join(oneDirectoryWithPrefix(fontPackagesRoot, fontId), "metadata.json"));
if (typeof metadata.local_sha256 !== "string") throw new Error(`complex_catalog_font_hash_missing:${fontId}`);
fontHashes.set(metadata.local_sha256.toUpperCase(), fontId);
return {
display_name: String(metadata.display_name ?? fontId),
display_order: displayOrder,
@@ -69,24 +64,24 @@ const fontPanelItems = P0A_REQUIRED_FONT_PANEL_IDS.map((fontId, displayOrder) =>
};
});
const textResourceRevision = createHash("sha256");
const textTemplates = P0A_TEXT_TEMPLATE_IDS.map((templateId, catalogOrder) => {
const directory = textTemplateDirectory(textRoot, templateId);
const metadata = readJson(join(directory, "metadata.json"));
const fontReferences = Array.isArray(metadata.files?.fonts) ? metadata.files.fonts : [];
const matchedFontIds = [...new Set(fontReferences
.map((reference) => fontHashes.get(sha256(join(directory, ...reference.split("/")))))
.filter(Boolean))];
const compiled = compileTextTemplateAssets({ templateDirectory: directory, templateId });
if (compiled.diagnostics.unresolved_images !== 0) throw new Error(`text_template_image_reference_unresolved:${templateId}`);
for (const resource of compiled.resources) {
const bytes = resource.sourceBytes ?? readFileSync(resource.sourcePath);
textResourceRevision.update(resource.assetId).update(createHash("sha256").update(bytes).digest());
}
const previewReference = typeof metadata.files?.preview === "string" ? metadata.files.preview : undefined;
const hasPreview = previewReference ? existsSync(join(directory, ...previewReference.split("/"))) : false;
return {
available: true,
catalog_order: catalogOrder,
category: normalizedTextCategory(metadata.category),
default_font_id: matchedFontIds[0] ?? "FONT081",
default_font_size: 48,
default_text: String(metadata.default_text ?? metadata.display_name ?? templateId),
display_name: String(metadata.display_name || metadata.default_text || templateId),
font_match_status: matchedFontIds.length > 0 ? "exact_panel_hash" : "catalog_fallback",
...compiled.catalog,
display_name: String(metadata.display_name || metadata.default_text || compiled.catalog.default_text || templateId),
...(hasPreview ? { preview_asset_id: `TEXT-PREVIEW-${templateId}` } : {}),
resource_class: metadata.resource_class === "parameter_only" ? "parameter_only" : "zip_template",
template_id: templateId,
@@ -114,12 +109,19 @@ const dynamicStickers = P0A_DYNAMIC_STICKER_IDS.map((templateId, catalogOrder) =
const catalog = {
dynamic_stickers: dynamicStickers,
font_panel_items: fontPanelItems,
schema_version: "DadaComplexBrowserCatalog/v1",
schema_version: "DadaComplexBrowserCatalog/v2",
text_resource_revision: textResourceRevision.digest("hex").slice(0, 16),
text_templates: textTemplates,
};
const serialized = `${JSON.stringify(catalog, null, 2)}\n`;
if (/[A-Z]:[\\/]/i.test(serialized)) throw new Error("complex_catalog_absolute_path_detected");
function containsAbsolutePath(value) {
if (typeof value === "string") return /^[A-Z]:[\\/]/i.test(value);
if (Array.isArray(value)) return value.some(containsAbsolutePath);
return value && typeof value === "object" ? Object.values(value).some(containsAbsolutePath) : false;
}
const unsafeTemplateIds = textTemplates.filter(containsAbsolutePath).map((item) => item.template_id);
if (unsafeTemplateIds.length > 0) throw new Error(`complex_catalog_absolute_path_detected:${unsafeTemplateIds.join(",")}`);
mkdirSync(dirname(outputPath), { recursive: true });
writeFileSync(outputPath, serialized);
process.stdout.write(`${JSON.stringify({
+76 -14
View File
@@ -8,6 +8,7 @@ import {
readFileSync,
readdirSync,
realpathSync,
renameSync,
rmSync,
statSync,
writeFileSync,
@@ -15,6 +16,8 @@ import {
import { tmpdir } from "node:os";
import { basename, dirname, extname, isAbsolute, join, resolve, sep } from "node:path";
import { compileTextTemplateAssets } from "./text-template-assets.mjs";
export const P0A_RUNTIME_ASSET_ROOT_REF = "p0a_runtime_assets";
export const RUNTIME_ASSET_MANIFEST_SCHEMA = "DadaRuntimeAssets/v1";
@@ -64,6 +67,8 @@ function derivedCounts(entries) {
dynamic_images: entries.filter((entry) => entry.resourceVersion === "p0a-complex-v1" && /^DYN\d{3}-/.test(entry.assetId)).length,
font_panel_items: entries.filter((entry) => entry.resourceVersion === "p0a-complex-v1" && /^FONT\d{3}$/.test(entry.assetId)).length,
static_stickers: entries.filter((entry) => entry.resourceVersion === "p0a-static-v1" && /^STK\d{3,4}$/.test(entry.assetId)).length,
text_fonts: entries.filter((entry) => entry.resourceVersion === "p0a-complex-v1" && /^TEXT-FONT-/.test(entry.assetId)).length,
text_images: entries.filter((entry) => entry.resourceVersion === "p0a-complex-v1" && /^TEXT-IMAGE-/.test(entry.assetId)).length,
text_previews: entries.filter((entry) => entry.resourceVersion === "p0a-complex-v1" && /^TEXT-PREVIEW-/.test(entry.assetId)).length,
};
}
@@ -131,7 +136,42 @@ function sameFile(left, right) {
return leftStat.dev === rightStat.dev && leftStat.ino === rightStat.ino;
}
export function deployRuntimeAssetPlan({ assetRoot, manifest, resources }) {
function installRuntimeAsset(resource, targetPath, expectedSha) {
const generatedBytes = Buffer.isBuffer(resource.sourceBytes) ? resource.sourceBytes : undefined;
if (generatedBytes) writeFileSync(targetPath, generatedBytes, { flag: "wx" });
else {
try {
linkSync(resource.sourcePath, targetPath);
} catch (error) {
if (error && typeof error === "object" && "code" in error && error.code === "EXDEV") {
throw new Error("asset_hardlink_volume_mismatch");
}
throw error;
}
}
if (fileSha256(targetPath) !== expectedSha) throw new Error(generatedBytes ? "asset_generated_write_invalid" : "asset_hardlink_verification_failed");
if (!generatedBytes && !sameFile(resource.sourcePath, targetPath)) throw new Error("asset_hardlink_verification_failed");
}
function replaceManagedRuntimeAsset(resource, targetPath, expectedSha) {
const nextPath = `${targetPath}.dada-next`;
const previousPath = `${targetPath}.dada-previous`;
if (existsSync(nextPath) || existsSync(previousPath)) throw new Error("asset_update_staging_conflict");
installRuntimeAsset(resource, nextPath, expectedSha);
renameSync(targetPath, previousPath);
try {
renameSync(nextPath, targetPath);
if (fileSha256(targetPath) !== expectedSha) throw new Error("asset_update_verification_failed");
rmSync(previousPath, { force: true });
} catch (error) {
if (existsSync(targetPath)) renameSync(targetPath, nextPath);
renameSync(previousPath, targetPath);
rmSync(nextPath, { force: true });
throw error;
}
}
export function deployRuntimeAssetPlan({ allowManagedUpdate = false, assetRoot, manifest, resources }) {
if (!isAbsolute(assetRoot)) throw new Error("asset_root_must_be_absolute");
const normalizedManifest = createRuntimeAssetManifest({
counts: manifest.counts,
@@ -141,32 +181,35 @@ export function deployRuntimeAssetPlan({ assetRoot, manifest, resources }) {
const entries = new Map(normalizedManifest.entries.map((entry) => [`${entry.resourceVersion}\u0000${entry.assetId}`, entry]));
if (resources.length !== entries.size) throw new Error("asset_resource_plan_incomplete");
mkdirSync(assetRoot, { recursive: true });
const previousManifestPath = join(assetRoot, "manifest.json");
const previousEntries = allowManagedUpdate && existsSync(previousManifestPath)
? new Map(readRuntimeAssetManifest(previousManifestPath).entries.map((entry) => [entry.relativePath, entry]))
: new Map();
for (const resource of resources) {
const key = `${resource.entry.resourceVersion}\u0000${resource.entry.assetId}`;
const entry = entries.get(key);
if (!entry || JSON.stringify(entry) !== JSON.stringify({ ...resource.entry, sha256: resource.entry.sha256.toLowerCase() })) {
throw new Error("asset_resource_plan_mismatch");
}
if (!existsSync(resource.sourcePath) || !statSync(resource.sourcePath).isFile() || lstatSync(resource.sourcePath).isSymbolicLink()) {
const generatedBytes = Buffer.isBuffer(resource.sourceBytes) ? resource.sourceBytes : undefined;
if (!generatedBytes && (!existsSync(resource.sourcePath) || !statSync(resource.sourcePath).isFile() || lstatSync(resource.sourcePath).isSymbolicLink())) {
throw new Error("asset_source_invalid");
}
if (fileSha256(resource.sourcePath) !== entry.sha256) throw new Error("asset_source_hash_invalid");
if ((generatedBytes ? sha256(generatedBytes) : fileSha256(resource.sourcePath)) !== entry.sha256) throw new Error("asset_source_hash_invalid");
const targetPath = targetWithinRoot(assetRoot, entry.relativePath);
mkdirSync(dirname(targetPath), { recursive: true });
if (existsSync(targetPath)) {
if (fileSha256(targetPath) !== entry.sha256) throw new Error("asset_target_conflict");
if (!sameFile(resource.sourcePath, targetPath)) throw new Error("asset_target_not_hardlink");
const targetSha = fileSha256(targetPath);
if (targetSha !== entry.sha256) {
const previous = previousEntries.get(entry.relativePath);
if (!previous || targetSha !== previous.sha256) throw new Error("asset_target_conflict");
replaceManagedRuntimeAsset(resource, targetPath, entry.sha256);
continue;
}
if (!generatedBytes && !sameFile(resource.sourcePath, targetPath)) throw new Error("asset_target_not_hardlink");
continue;
}
try {
linkSync(resource.sourcePath, targetPath);
} catch (error) {
if (error && typeof error === "object" && "code" in error && error.code === "EXDEV") {
throw new Error("asset_hardlink_volume_mismatch");
}
throw error;
}
if (!sameFile(resource.sourcePath, targetPath)) throw new Error("asset_hardlink_verification_failed");
installRuntimeAsset(resource, targetPath, entry.sha256);
}
writeRuntimeAssetManifest(join(assetRoot, "manifest.json"), normalizedManifest);
return { linked_files: resources.length, manifest: normalizedManifest, status: "ready" };
@@ -197,6 +240,17 @@ function entryFor(sourcePath, assetId, resourceVersion, relativePath, mimeType)
};
}
function entryForBytes(sourceBytes, assetId, resourceVersion, relativePath, mimeType) {
return {
assetId,
mimeType,
relativePath,
resourceVersion,
rootRef: P0A_RUNTIME_ASSET_ROOT_REF,
sha256: sha256(sourceBytes),
};
}
function safeRuntimeComponent(value) {
const normalized = value.normalize("NFKD").replaceAll(/[^A-Za-z0-9_-]+/g, "-").replaceAll(/^-+|-+$/g, "");
if (normalized === value) return normalized;
@@ -325,6 +379,14 @@ export async function buildP0aRuntimeAssetPlan({ replicationRoot }) {
for (const templateId of registry.P0A_TEXT_TEMPLATE_IDS) {
const templateDirectory = textTemplateDirectory(textRoot, templateId);
const metadata = JSON.parse(readFileSync(join(templateDirectory, "metadata.json"), "utf8"));
const compiled = compileTextTemplateAssets({ templateDirectory, templateId });
for (const resource of compiled.resources) {
const relativePath = `${registry.P0A_COMPLEX_RELEASE_VERSION}/${resource.assetId}${resource.extension}`;
const entry = resource.sourceBytes
? entryForBytes(resource.sourceBytes, resource.assetId, registry.P0A_COMPLEX_RELEASE_VERSION, relativePath, resource.mimeType)
: entryFor(resource.sourcePath, resource.assetId, registry.P0A_COMPLEX_RELEASE_VERSION, relativePath, resource.mimeType);
addResource({ entry, ...(resource.sourceBytes ? { sourceBytes: resource.sourceBytes } : { sourcePath: resource.sourcePath }) });
}
const previewReference = metadata.files?.preview;
if (typeof previewReference !== "string" || previewReference.length === 0) continue;
const sourcePath = join(templateDirectory, ...previewReference.split("/"));
+714
View File
@@ -0,0 +1,714 @@
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),
};
}
+91
View File
@@ -0,0 +1,91 @@
import { readFileSync } from "node:fs";
import { createServer } from "node:http";
import { isAbsolute, join, resolve } from "node:path";
import { chromium } from "@playwright/test";
function option(name) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
function defaultConfigPath() {
if (!process.env.LOCALAPPDATA || !isAbsolute(process.env.LOCALAPPDATA)) {
throw new Error("local_app_data_unavailable");
}
return join(process.env.LOCALAPPDATA, "Dada", "P0A", "config", "instance.json");
}
const manifestPath = resolve(option("--manifest") ?? "config/runtime-assets-manifest.json");
const configPath = resolve(option("--config") ?? process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultConfigPath());
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
const configuration = JSON.parse(readFileSync(configPath, "utf8"));
if (typeof configuration.asset_root !== "string" || !isAbsolute(configuration.asset_root)) {
throw new Error("asset_root_configuration_invalid");
}
const fontEntries = manifest.entries
.filter((entry) => entry.assetId.startsWith("TEXT-FONT-") && entry.mimeType.startsWith("font/"))
.toSorted((left, right) => left.assetId.localeCompare(right.assetId));
const uniqueEntries = [...new Map(fontEntries.map((entry) => [entry.sha256, entry])).values()];
const fontPaths = new Map(
uniqueEntries.map((entry) => [
`/${encodeURIComponent(entry.assetId)}`,
{ mimeType: entry.mimeType, path: join(configuration.asset_root, entry.relativePath) },
]),
);
const server = createServer((request, response) => {
const font = fontPaths.get(request.url ?? "");
if (!font) {
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
response.end("<!doctype html><title>Dada browser font validation</title>");
return;
}
const bytes = readFileSync(font.path);
response.writeHead(200, { "Content-Length": bytes.length, "Content-Type": font.mimeType });
response.end(bytes);
});
await new Promise((resolveReady) => server.listen(0, "127.0.0.1", resolveReady));
const address = server.address();
if (!address || typeof address === "string") throw new Error("browser_font_probe_server_unavailable");
const origin = `http://127.0.0.1:${address.port}`;
const browser = await chromium.launch({ channel: option("--channel") ?? "msedge", headless: true });
const failures = [];
try {
let page = await browser.newPage();
await page.goto(origin);
for (let index = 0; index < uniqueEntries.length; index += 1) {
if (index > 0 && index % 25 === 0) {
await page.close();
page = await browser.newPage();
await page.goto(origin);
}
const entry = uniqueEntries[index];
const result = await page.evaluate(async ({ family, url }) => {
try {
const response = await fetch(url);
if (!response.ok) return `http_${response.status}`;
await new FontFace(family, await response.arrayBuffer()).load();
return "loaded";
} catch (error) {
return error instanceof Error ? `${error.name}: ${error.message}` : String(error);
}
}, { family: `DadaFontProbe${index}`, url: `${origin}/${encodeURIComponent(entry.assetId)}` });
if (result !== "loaded") failures.push({ asset_id: entry.assetId, error: result });
}
} finally {
await browser.close();
await new Promise((resolveClosed) => server.close(resolveClosed));
}
const result = {
checked_entries: fontEntries.length,
failed_fonts: failures,
status: failures.length === 0 ? "passed" : "failed",
unique_fonts: uniqueEntries.length,
};
process.stdout.write(`${JSON.stringify(result)}\n`);
if (failures.length > 0) process.exitCode = 1;
+79 -7
View File
@@ -43,6 +43,11 @@ interface Backend {
version: number;
}
interface EditorRouteOptions {
assetRequests?: string[];
failFontOnce?: string;
}
function writeEvidence(caseId: string, name: string, value: unknown) {
const root = process.env.DADA_EVIDENCE_DIR_TEXT_EDITOR;
if (!root) return;
@@ -51,7 +56,7 @@ function writeEvidence(caseId: string, name: string, value: unknown) {
writeFileSync(resolve(directory, name), `${JSON.stringify(value, null, 2)}\n`);
}
async function routeEditor(page: Page, projectId: string, backend: Backend) {
async function routeEditor(page: Page, projectId: string, backend: Backend, options: EditorRouteOptions = {}) {
const windowsFont = join(process.env.WINDIR ?? "C:\\Windows", "Fonts", "arial.ttf");
if (!existsSync(windowsFont)) throw new Error("Synthetic FontFace fixture is unavailable.");
const fontBytes = readFileSync(windowsFont);
@@ -76,10 +81,29 @@ async function routeEditor(page: Page, projectId: string, backend: Backend) {
backend.recent = [item, ...backend.recent.filter((entry) => entry.asset_id !== item.asset_id)];
await route.fulfill({ body: JSON.stringify({ status: "recorded" }), contentType: "application/json", status: 200 });
});
const previewPng = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", "base64");
const imageFixture = '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="#ff00ff"/></svg>';
let failedFontRequests = 0;
await page.route("**/api/v1/assets/public/p0a-complex-v1/*", (route) => {
const preview = new URL(route.request().url()).pathname.includes("TEXT-PREVIEW-");
return route.fulfill(preview ? { body: previewPng, contentType: "image/png", status: 200 } : { body: fontBytes, contentType: "font/ttf", status: 200 });
const assetId = new URL(route.request().url()).pathname.split("/").at(-1) ?? "";
options.assetRequests?.push(assetId);
if (options.failFontOnce === assetId && failedFontRequests++ === 0) return route.fulfill({ status: 503 });
const image = assetId.startsWith("TEXT-PREVIEW-") || assetId.startsWith("TEXT-IMAGE-");
return route.fulfill(image
? { body: imageFixture, contentType: "image/svg+xml", status: 200 }
: { body: fontBytes, contentType: "font/ttf", status: 200 });
});
}
async function magentaPixels(page: Page) {
return page.getByLabel("编辑画布").evaluate((canvas: HTMLCanvasElement) => {
const context = canvas.getContext("2d");
if (!context) throw new Error("Canvas context unavailable.");
const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data;
let count = 0;
for (let index = 0; index < pixels.length; index += 4) {
if ((pixels[index] ?? 0) > 240 && (pixels[index + 1] ?? 255) < 20 && (pixels[index + 2] ?? 0) > 240) count += 1;
}
return count;
});
}
@@ -119,6 +143,47 @@ test("TDD-WP4-TXT-003 exposes the complete catalog, display-name search and acco
writeEvidence("TDD-WP4-TXT-003-catalog-search-recent", "db-diff.json", { account_user_id: userId, recent: backend.recent, search_did_not_write: true });
});
test("POSTV1-ASSET-ALL-16 retries a transient archived font failure without permanently disabling the template", async ({ page }) => {
const projectId = uuid(735);
const backend: Backend = { canvas: emptyCanvas(), recent: [], saves: 0, version: 2 };
const failedFont = "TEXT-FONT-FLOWER001-01";
const assetRequests: string[] = [];
await routeEditor(page, projectId, backend, { assetRequests, failFontOnce: failedFont });
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
await page.getByRole("button", { name: "文字模板", exact: true }).click();
await page.getByRole("button", { name: /FLOWER001 春日计划/ }).click();
await expect(page.getByText("素材暂不可用,未使用系统字体替代。", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: /FLOWER001 春日计划 字体待重试/ })).toBeEnabled();
expect(backend.canvas.elements).toHaveLength(0);
await page.getByRole("button", { name: /FLOWER001 春日计划 字体待重试/ }).click();
await expect.poll(() => backend.canvas.elements.length).toBe(1);
expect(assetRequests.filter((assetId) => assetId === failedFont)).toHaveLength(2);
});
test("POSTV1-ASSET-ALL-16 renders captured image, material, particle and underline decorations", async ({ page }) => {
const projectId = uuid(736);
const backend: Backend = { canvas: emptyCanvas(), recent: [], saves: 0, version: 2 };
const assetRequests: string[] = [];
await routeEditor(page, projectId, backend, { assetRequests });
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
await page.getByRole("button", { name: "文字模板", exact: true }).click();
for (const template of [
{ id: "FLOWER001", name: "春日计划", image: "TEXT-IMAGE-FLOWER001-001" },
{ id: "FLOWER048", name: "糖", image: "TEXT-IMAGE-FLOWER048-001" },
{ id: "H013", name: "周末俱乐部", image: "TEXT-IMAGE-H013-001" },
{ id: "FLOWER121", name: "厨房和生活", image: "TEXT-IMAGE-FLOWER121-001" },
]) {
await page.getByRole("button", { name: new RegExp(`${template.id} ${template.name}`) }).click();
await expect.poll(() => assetRequests.includes(template.image)).toBe(true);
await expect.poll(() => magentaPixels(page)).toBeGreaterThan(20);
await page.getByRole("button", { name: "撤销" }).click();
await expect.poll(() => backend.canvas.elements.length).toBe(0);
}
});
test("TDD-WP4-TXT-001 preserves multiline content and transforms across a template switch", async ({ page }) => {
const projectId = uuid(740);
const backend: Backend = { canvas: emptyCanvas(), recent: [], saves: 0, version: 3 };
@@ -205,15 +270,22 @@ test("TDD-WP4-TXT-002 waits for the archived font and commits exact style ranges
const element = backend.canvas.elements[0]!;
expect(element.opacity).toBe(1);
expect(element.font_override).toBe("FONT081");
expect(element.scale).toEqual({ x: 2, y: 2 });
expect(element.scale).toEqual({ x: 96 / 50, y: 96 / 50 });
expect(element.style_parameters).toMatchObject({ background_opacity: 0.35, letter_spacing: 20, line_height: 1.9, stroke_width: 12, text_align: "right" });
await expect.poll(() => page.evaluate(() => document.fonts.check('16px "Dada_FONT081"'))).toBe(true);
await page.getByRole("button", { name: "撤销" }).click();
await expect(page.getByRole("spinbutton", { name: "有效字号", exact: true })).toHaveValue("48");
await expect(page.getByRole("spinbutton", { name: "有效字号", exact: true })).toHaveValue("50");
await page.getByRole("button", { name: "重做" }).click();
await expect(page.getByRole("spinbutton", { name: "有效字号", exact: true })).toHaveValue("96");
await page.reload();
await page.getByLabel("编辑画布").click({ position: { x: 270, y: 360 } });
const reopenedStage = page.getByLabel("编辑画布");
const reopenedBounds = await reopenedStage.boundingBox();
const reopenedText = backend.canvas.elements[0];
if (!reopenedBounds || !reopenedText) throw new Error("Reopened text geometry is unavailable.");
await reopenedStage.click({ position: {
x: reopenedBounds.width * reopenedText.position.x,
y: reopenedBounds.height * reopenedText.position.y,
} });
await expect(page.getByLabel("字体覆盖")).toHaveValue("FONT081");
writeEvidence("TDD-WP4-TXT-002-font-metrics-ranges", "font-load.json", { fallback: null, font_id: "FONT081", ready: true, source: "public_release_fixture" });
writeEvidence("TDD-WP4-TXT-002-font-metrics-ranges", "canvas-state.json", backend.canvas);
+56 -10
View File
@@ -5,6 +5,8 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import complexAssetCatalog from "../../apps/web/src/generated/complex-assets.json" with { type: "json" };
import {
createRuntimeAssetManifest,
deployRuntimeAssetPlan,
@@ -14,14 +16,29 @@ import {
test("committed P0-A runtime manifest covers the frozen first-version binary assets", () => {
const manifest = readRuntimeAssetManifest("config/runtime-assets-manifest.json");
assert.deepEqual(manifest.counts, {
dynamic_fonts: 18,
dynamic_images: 43,
font_panel_items: 86,
static_stickers: 1407,
text_previews: 261,
});
assert.equal(manifest.entries.length, 1815);
assert.equal(manifest.counts.dynamic_fonts, 18);
assert.equal(manifest.counts.dynamic_images, 43);
assert.equal(manifest.counts.font_panel_items, 86);
assert.equal(manifest.counts.static_stickers, 1407);
assert.equal(manifest.counts.text_fonts >= 332, true);
assert.equal(manifest.counts.text_images >= 300, true);
assert.equal(manifest.counts.text_previews, 261);
assert.equal(manifest.entries.length, Object.values(manifest.counts).reduce((sum, count) => sum + count, 0));
const publicAssetKeys = new Set(manifest.entries.map((entry) => `${entry.resourceVersion}\u0000${entry.assetId}`));
for (const template of complexAssetCatalog.text_templates) {
for (const fontId of template.render_model.text_layers.map((layer) => layer.font_id)) {
assert.equal(publicAssetKeys.has(`p0a-complex-v1\u0000${fontId}`), true, `${template.template_id}:${fontId}`);
}
for (const imageId of template.render_model.image_layers.map((layer) => layer.asset_id)) {
assert.equal(publicAssetKeys.has(`p0a-complex-v1\u0000${imageId}`), true, `${template.template_id}:${imageId}`);
}
for (const imageId of template.render_model.particle_layers.map((layer) => layer.asset_id)) {
assert.equal(publicAssetKeys.has(`p0a-complex-v1\u0000${imageId}`), true, `${template.template_id}:${imageId}`);
}
for (const imageId of template.render_model.text_layers.flatMap((layer) => layer.fill_pattern_asset_id ? [layer.fill_pattern_asset_id] : [])) {
assert.equal(publicAssetKeys.has(`p0a-complex-v1\u0000${imageId}`), true, `${template.template_id}:${imageId}`);
}
}
assert.doesNotMatch(serializeRuntimeAssetManifest(manifest), /[A-Za-z]:[\\/]/);
});
@@ -43,7 +60,7 @@ test("runtime asset deployment creates verified hardlinks and a path-free manife
sha256: createHash("sha256").update(bytes).digest("hex"),
};
const manifest = createRuntimeAssetManifest({
counts: { dynamic_fonts: 0, dynamic_images: 0, font_panel_items: 0, static_stickers: 1, text_previews: 0 },
counts: { dynamic_fonts: 0, dynamic_images: 0, font_panel_items: 0, static_stickers: 1, text_fonts: 0, text_images: 0, text_previews: 0 },
entries: [entry],
});
@@ -76,7 +93,7 @@ test("runtime asset deployment refuses a mismatched existing target", async (t)
sha256: createHash("sha256").update("expected").digest("hex"),
};
const manifest = createRuntimeAssetManifest({
counts: { dynamic_fonts: 0, dynamic_images: 0, font_panel_items: 0, static_stickers: 1, text_previews: 0 },
counts: { dynamic_fonts: 0, dynamic_images: 0, font_panel_items: 0, static_stickers: 1, text_fonts: 0, text_images: 0, text_previews: 0 },
entries: [entry],
});
@@ -85,3 +102,32 @@ test("runtime asset deployment refuses a mismatched existing target", async (t)
/asset_target_conflict/,
);
});
test("runtime asset deployment upgrades only an unchanged file covered by its previous manifest", async (t) => {
const root = await mkdtemp(join(tmpdir(), "dada-runtime-assets-upgrade-"));
t.after(() => rm(root, { force: true, recursive: true }));
const assetRoot = join(root, "assets");
const firstSourcePath = join(root, "source-v1.ttf");
const secondSourcePath = join(root, "source-v2.ttf");
const entry = (bytes) => ({
assetId: "TEXT-FONT-FIXTURE-01",
mimeType: "font/ttf",
relativePath: "p0a-complex-v1/TEXT-FONT-FIXTURE-01.ttf",
resourceVersion: "p0a-complex-v1",
rootRef: "p0a_runtime_assets",
sha256: createHash("sha256").update(bytes).digest("hex"),
});
const counts = { dynamic_fonts: 0, dynamic_images: 0, font_panel_items: 0, static_stickers: 0, text_fonts: 1, text_images: 0, text_previews: 0 };
await writeFile(firstSourcePath, "version one");
const firstEntry = entry("version one");
const firstManifest = createRuntimeAssetManifest({ counts, entries: [firstEntry] });
deployRuntimeAssetPlan({ assetRoot, manifest: firstManifest, resources: [{ entry: firstEntry, sourcePath: firstSourcePath }] });
await writeFile(secondSourcePath, "version two");
const secondEntry = entry("version two");
const secondManifest = createRuntimeAssetManifest({ counts, entries: [secondEntry] });
deployRuntimeAssetPlan({ allowManagedUpdate: true, assetRoot, manifest: secondManifest, resources: [{ entry: secondEntry, sourcePath: secondSourcePath }] });
assert.equal(await readFile(join(assetRoot, secondEntry.relativePath), "utf8"), "version two");
assert.deepEqual(readRuntimeAssetManifest(join(assetRoot, "manifest.json")), secondManifest);
});
+45 -2
View File
@@ -12,6 +12,12 @@ import {
P0A_TEXT_TEMPLATE_IDS,
} from "../../packages/template-registry/src/index.js";
function containsAbsolutePath(value: unknown): boolean {
if (typeof value === "string") return /^[A-Z]:[\\/]/i.test(value);
if (Array.isArray(value)) return value.some(containsAbsolutePath);
return value !== null && typeof value === "object" && Object.values(value).some(containsAbsolutePath);
}
describe("POSTV1-ASSET-ALL-16 complete asset catalog", () => {
it("publishes every normalized complex asset instead of the original alpha subset", () => {
expect(P0A_TEXT_TEMPLATE_IDS).toHaveLength(332);
@@ -32,13 +38,50 @@ describe("POSTV1-ASSET-ALL-16 complete asset catalog", () => {
});
it("generates a sanitized browser catalog for every text, font, and dynamic definition", () => {
expect(complexAssetCatalog.schema_version).toBe("DadaComplexBrowserCatalog/v1");
expect(complexAssetCatalog.schema_version).toBe("DadaComplexBrowserCatalog/v2");
expect(complexAssetCatalog.text_resource_revision).toMatch(/^[a-f0-9]{16}$/);
expect(complexAssetCatalog.text_templates).toHaveLength(332);
expect(complexAssetCatalog.font_panel_items).toHaveLength(86);
expect(complexAssetCatalog.dynamic_stickers).toHaveLength(35);
expect(complexAssetCatalog.text_templates.filter((item) => item.preview_asset_id)).toHaveLength(261);
expect(complexAssetCatalog.text_templates.every((item) => item.available)).toBe(true);
expect(JSON.stringify(complexAssetCatalog)).not.toMatch(/[A-Z]:[\\/]/i);
expect(complexAssetCatalog.text_templates.every((item) => item.render_model.text_layers.length > 0)).toBe(true);
expect(complexAssetCatalog.text_templates.filter((item) => item.resource_class === "zip_template")).toHaveLength(330);
expect(complexAssetCatalog.text_templates.reduce((count, item) => count + item.render_model.image_layers.length, 0)).toBe(316);
expect(complexAssetCatalog.text_templates.reduce((count, item) => count + item.render_model.particle_layers.length, 0)).toBe(9);
expect(complexAssetCatalog.text_templates.reduce((count, item) => count + item.render_model.text_layers.length, 0)).toBe(490);
expect(complexAssetCatalog.text_templates.filter((item) => item.render_model.text_layers.some((layer) => "fill_pattern_asset_id" in layer))).toHaveLength(14);
expect(containsAbsolutePath(complexAssetCatalog)).toBe(false);
});
it("keeps the captured template font, default text, style, and decoration layers", () => {
const byId = new Map(complexAssetCatalog.text_templates.map((item) => [item.template_id, item]));
const flower001 = byId.get("FLOWER001")!;
expect(flower001.default_font_id).toMatch(/^TEXT-FONT-FLOWER001-/);
expect(flower001.font_match_status).toBe("template_package");
expect(flower001.render_model.image_layers).toHaveLength(1);
expect(flower001.render_model.text_layers[0]).toMatchObject({
fill_color: "#FFC5D4",
stroke_color: "#FF69A6",
stroke_width: 5,
});
const flower008 = byId.get("FLOWER008")!;
expect(flower008.default_text).toBe("Vlog.");
expect(flower008.default_font_id).toMatch(/^TEXT-FONT-FLOWER008-/);
const heading001 = byId.get("H001")!;
expect(heading001.render_model.image_layers).toHaveLength(5);
expect(heading001.default_font_id).toMatch(/^TEXT-FONT-H001-/);
const materialText = byId.get("FLOWER048")!;
expect(materialText.render_model.text_layers[0]).toHaveProperty("fill_pattern_asset_id");
const underlinedText = byId.get("FLOWER121")!;
expect(underlinedText.render_model.image_layers).toHaveLength(1);
const particleText = byId.get("H013")!;
expect(particleText.render_model.particle_layers).toHaveLength(3);
});
it("exposes every generated definition to the editor", () => {
@@ -0,0 +1,123 @@
import { describe, expect, it } from "vitest";
import { normalizeBrowserFontBytes } from "../../scripts/lib/text-template-assets.mjs";
function checksum(bytes: Buffer) {
let value = 0;
for (let index = 0; index < bytes.length; index += 4) {
let word = 0;
for (let byte = 0; byte < 4; byte += 1) word = (word << 8) | (bytes[index + byte] ?? 0);
value = (value + (word >>> 0)) >>> 0;
}
return value;
}
function fontFixture(verticalVersion: number) {
const bytes = Buffer.alloc(80);
bytes.writeUInt32BE(0x00010000, 0);
bytes.writeUInt16BE(3, 4);
bytes.write("head", 12, "ascii");
bytes.writeUInt32BE(60, 20);
bytes.writeUInt32BE(12, 24);
bytes.write("post", 28, "ascii");
bytes.writeUInt32BE(72, 36);
bytes.writeUInt32BE(4, 40);
bytes.write("vhea", 44, "ascii");
bytes.writeUInt32BE(76, 52);
bytes.writeUInt32BE(4, 56);
bytes.writeUInt32BE(0x00030000, 72);
bytes.writeUInt32BE(verticalVersion, 76);
return bytes;
}
function validFontFixture(verticalVersion: number) {
const bytes = fontFixture(verticalVersion);
bytes.writeUInt32BE((0xB1B0AFBA - checksum(bytes)) >>> 0, 68);
return bytes;
}
function tableRecord(bytes: Buffer, wantedTag: string) {
const tableCount = bytes.readUInt16BE(4);
for (let index = 0; index < tableCount; index += 1) {
const recordOffset = 12 + index * 16;
const tag = bytes.toString("ascii", recordOffset, recordOffset + 4);
if (tag === wantedTag) {
return {
length: bytes.readUInt32BE(recordOffset + 12),
offset: bytes.readUInt32BE(recordOffset + 8),
};
}
}
return undefined;
}
function invalidGlyphBoundsFixture() {
const bytes = Buffer.alloc(156);
bytes.writeUInt32BE(0x00010000, 0);
bytes.writeUInt16BE(4, 4);
const records = [
{ length: 10, offset: 76, tag: "glyf" },
{ length: 54, offset: 88, tag: "head" },
{ length: 4, offset: 144, tag: "loca" },
{ length: 6, offset: 148, tag: "maxp" },
];
records.forEach((record, index) => {
const directoryOffset = 12 + index * 16;
bytes.write(record.tag, directoryOffset, 4, "ascii");
bytes.writeUInt32BE(record.offset, directoryOffset + 8);
bytes.writeUInt32BE(record.length, directoryOffset + 12);
});
bytes.writeInt16BE(1, 76);
bytes.writeInt16BE(43, 78);
bytes.writeInt16BE(757, 80);
bytes.writeInt16BE(246, 82);
bytes.writeInt16BE(17, 84);
bytes.writeInt16BE(0, 138);
bytes.writeUInt16BE(0, 144);
bytes.writeUInt16BE(5, 146);
bytes.writeUInt32BE(0x00010000, 148);
bytes.writeUInt16BE(1, 152);
bytes.writeUInt32BE((0xB1B0AFBA - checksum(bytes)) >>> 0, 96);
return bytes;
}
describe("POSTV1-ASSET-ALL-16 browser font normalization", () => {
it("removes the captured malformed vertical metrics and recalculates SFNT checksums", () => {
const original = fontFixture(0x00010001);
const normalized = normalizeBrowserFontBytes(original);
expect(normalized).toBeDefined();
expect(original.readUInt32BE(76)).toBe(0x00010001);
expect(tableRecord(normalized!, "vhea")).toBeUndefined();
expect(tableRecord(normalized!, "vmtx")).toBeUndefined();
expect(tableRecord(normalized!, "head")).toEqual({ length: 12, offset: 44 });
expect(tableRecord(normalized!, "post")).toEqual({ length: 32, offset: 56 });
expect(normalized!.readUInt32BE(56)).toBe(0x00030000);
expect(checksum(normalized!)).toBe(0xB1B0AFBA);
});
it("does not rewrite fonts whose vertical header is already valid", () => {
expect(normalizeBrowserFontBytes(validFontFixture(0x00010000))).toBeUndefined();
expect(normalizeBrowserFontBytes(validFontFixture(0x00011000))).toBeUndefined();
});
it("repairs a stale whole-font checksum without changing a valid vertical header", () => {
const normalized = normalizeBrowserFontBytes(fontFixture(0x00011000));
expect(normalized).toBeDefined();
const verticalHeader = tableRecord(normalized!, "vhea");
expect(verticalHeader).toBeDefined();
expect(normalized!.readUInt32BE(verticalHeader!.offset)).toBe(0x00011000);
expect(checksum(normalized!)).toBe(0xB1B0AFBA);
});
it("repairs reversed glyph bounds without changing the glyph outline payload", () => {
const normalized = normalizeBrowserFontBytes(invalidGlyphBoundsFixture());
expect(normalized).toBeDefined();
const glyphs = tableRecord(normalized!, "glyf");
expect(glyphs).toBeDefined();
expect(normalized!.readInt16BE(glyphs!.offset + 2)).toBe(43);
expect(normalized!.readInt16BE(glyphs!.offset + 4)).toBe(17);
expect(normalized!.readInt16BE(glyphs!.offset + 6)).toBe(246);
expect(normalized!.readInt16BE(glyphs!.offset + 8)).toBe(757);
expect(checksum(normalized!)).toBe(0xB1B0AFBA);
});
});
+9 -7
View File
@@ -7,6 +7,7 @@ import {
TextEditSession,
createTextTemplateElement,
effectiveFontSize,
fontOption,
searchTextTemplates,
} from "../../apps/web/src/text-assets.js";
import { elementHalfExtents } from "../../apps/web/src/editor-elements.js";
@@ -51,7 +52,7 @@ describe("TASK-WP4-03 text templates and properties", () => {
template_or_asset_id: "H003",
});
expect(switched.style_parameters).toMatchObject({
fill_color: "#111111", letter_spacing: 1, line_height: 1.2, stroke_enabled: false,
fill_color: "#FFFFFF", letter_spacing: 0, line_height: 1, stroke_enabled: false,
});
});
@@ -73,12 +74,12 @@ describe("TASK-WP4-03 text templates and properties", () => {
it("synchronizes numeric font size with the canvas scale", () => {
const element = createTextTemplateElement(P0A_TEXT_TEMPLATES[0]!, identity, 0, { scale: { x: 1.5, y: 1.5 } });
expect(effectiveFontSize(element)).toBe(72);
expect(effectiveFontSize(element)).toBe(P0A_TEXT_TEMPLATES[0]!.defaultFontSize * 1.5);
const edit = new TextEditSession(element, P0A_TEXT_TEMPLATES);
edit.setEffectiveFontSize(96);
const resized = edit.complete();
expect(resized.font_size).toBe(48);
expect(resized.scale).toEqual({ x: 2, y: 2 });
expect(resized.font_size).toBe(P0A_TEXT_TEMPLATES[0]!.defaultFontSize);
expect(resized.scale).toEqual({ x: 96 / P0A_TEXT_TEMPLATES[0]!.defaultFontSize, y: 96 / P0A_TEXT_TEMPLATES[0]!.defaultFontSize });
expect(effectiveFontSize(resized)).toBe(96);
});
@@ -97,10 +98,11 @@ describe("TASK-WP4-03 text templates and properties", () => {
expect(bounds.y).toBeGreaterThan(0.16);
});
it("adds every template with a registered catalog font", () => {
it("adds every template with its captured default font", () => {
expect(P0A_TEXT_TEMPLATES.every((template) => template.available && template.fontUrl)).toBe(true);
const registeredFonts = new Set(P0A_FONT_OPTIONS.map((font) => font.fontId));
expect(P0A_TEXT_TEMPLATES.every((template) => registeredFonts.has(template.defaultFontId))).toBe(true);
expect(P0A_TEXT_TEMPLATES.every((template) => fontOption(template.defaultFontId)?.url === template.fontUrl)).toBe(true);
expect(P0A_TEXT_TEMPLATES.filter((template) => template.defaultFontId.startsWith("TEXT-FONT-"))).toHaveLength(332);
expect(P0A_FONT_OPTIONS).toHaveLength(86);
const available = P0A_TEXT_TEMPLATES[0]!;
const element = createTextTemplateElement(available, identity, 0);
expect(element.font_override).toBeUndefined();