Compare commits

..
Author SHA1 Message Date
suyx 69f447b614 fix(POSTV1-TEMPLATE-LAYOUT-19): 还原逐字纹理与叠层文字
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-07 01:52:18 +08:00
suyx 210554628d fix(POSTV1-TEMPLATE-LAYOUT-19): 还原路径文字与多字体布局
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-07 01:13:47 +08:00
suyx 3a018dfa37 fix(POSTV1-TEMPLATE-LAYOUT-19): 修复联动文字背景锚点
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-07 00:13:30 +08:00
suyx c6e893515c fix(POSTV1-TEMPLATE-LAYOUT-19): 修正文字模板对齐枚举
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-06 23:32:47 +08:00
suyx a173222d74 fix(POSTV1-TEMPLATE-LAYOUT-19): 同步缩放字距并限制文本边界
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-06 19:37:08 +08:00
suyx f244920a61 fix(POSTV1-TEMPLATE-LAYOUT-19): 按原始文本框修复模板偏移
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-06 19:01:33 +08:00
5 changed files with 3168 additions and 757 deletions
+291 -16
View File
@@ -13,9 +13,12 @@ import {
textTemplateFontOptions, textTemplateFontOptions,
textTemplateImageUrls, textTemplateImageUrls,
type TextTemplateImageLayer, type TextTemplateImageLayer,
type TextTemplateFillTextureLayout,
type TextTemplateNinePatch, type TextTemplateNinePatch,
type TextTemplateParticleLayer, type TextTemplateParticleLayer,
type TextTemplateTextLayer, type TextTemplateTextLayer,
type TextTemplateTextPath,
type TextVerticalAlign,
} from "./text-assets.js"; } from "./text-assets.js";
import { COLOR_CARD_HALF_SIZES, drawColorCard } from "./palette-provider.js"; import { COLOR_CARD_HALF_SIZES, drawColorCard } from "./palette-provider.js";
@@ -201,6 +204,240 @@ function drawTemplateParticles(
context.restore(); context.restore();
} }
interface TextTemplateBoxLayoutInput {
align: "center" | "left" | "right";
anchorX: number;
anchorY: number;
boxHeight: number;
boxWidth: number;
fontSize: number;
lineCount: number;
lineHeight: number;
verticalAlign: TextVerticalAlign;
}
export function textTemplateBoxLayout(input: TextTemplateBoxLayoutInput) {
const boxLeft = -input.anchorX * input.boxWidth;
const boxTop = -input.anchorY * input.boxHeight;
const lineAdvance = input.fontSize * input.lineHeight;
const blockHeight = input.fontSize + Math.max(0, input.lineCount - 1) * lineAdvance;
const x = input.align === "left" ? boxLeft
: input.align === "right" ? boxLeft + input.boxWidth
: boxLeft + input.boxWidth / 2;
const firstY = input.verticalAlign === "top" ? boxTop + input.fontSize / 2
: input.verticalAlign === "bottom" ? boxTop + input.boxHeight - blockHeight + input.fontSize / 2
: boxTop + (input.boxHeight - blockHeight) / 2 + input.fontSize / 2;
return { blockHeight, firstY, lineAdvance, x };
}
export function boundedTextTemplateWidth(measuredWidth: number, boxWidth: number) {
return Math.max(1, Math.min(measuredWidth, boxWidth));
}
interface TemplatePathPoint { x: number; y: number }
function mixPathPoint(from: TemplatePathPoint, to: TemplatePathPoint, amount: number): TemplatePathPoint {
return { x: from.x + (to.x - from.x) * amount, y: from.y + (to.y - from.y) * amount };
}
function quadraticPathPoint(from: TemplatePathPoint, control: TemplatePathPoint, to: TemplatePathPoint, amount: number) {
const inverse = 1 - amount;
return {
x: inverse * inverse * from.x + 2 * inverse * amount * control.x + amount * amount * to.x,
y: inverse * inverse * from.y + 2 * inverse * amount * control.y + amount * amount * to.y,
};
}
function conicPathPoint(from: TemplatePathPoint, control: TemplatePathPoint, to: TemplatePathPoint, weight: number, amount: number) {
const inverse = 1 - amount;
const denominator = inverse * inverse + 2 * weight * inverse * amount + amount * amount;
return {
x: (inverse * inverse * from.x + 2 * weight * inverse * amount * control.x + amount * amount * to.x) / denominator,
y: (inverse * inverse * from.y + 2 * weight * inverse * amount * control.y + amount * amount * to.y) / denominator,
};
}
function cubicPathPoint(from: TemplatePathPoint, first: TemplatePathPoint, second: TemplatePathPoint, to: TemplatePathPoint, amount: number) {
const inverse = 1 - amount;
return {
x: inverse ** 3 * from.x + 3 * inverse * inverse * amount * first.x + 3 * inverse * amount * amount * second.x + amount ** 3 * to.x,
y: inverse ** 3 * from.y + 3 * inverse * inverse * amount * first.y + 3 * inverse * amount * amount * second.y + amount ** 3 * to.y,
};
}
export function sampleTemplateTextPath(path: TextTemplateTextPath, steps = 28): TemplatePathPoint[] {
const first = path.points[0];
if (!first) return [];
const sampled: TemplatePathPoint[] = [first];
let current = first;
let pointIndex = 1;
let conicIndex = 0;
for (const verb of path.verbs.slice(1)) {
const appendCurve = (factory: (amount: number) => TemplatePathPoint) => {
for (let step = 1; step <= steps; step += 1) sampled.push(factory(step / steps));
current = sampled.at(-1)!;
};
if (verb === 1) {
const to = path.points[pointIndex++];
if (to) appendCurve((amount) => mixPathPoint(current, to, amount));
} else if (verb === 2) {
const control = path.points[pointIndex++];
const to = path.points[pointIndex++];
if (control && to) appendCurve((amount) => quadraticPathPoint(current, control, to, amount));
} else if (verb === 3) {
const control = path.points[pointIndex++];
const to = path.points[pointIndex++];
const weight = path.conicWeights[conicIndex++] ?? 1;
if (control && to) appendCurve((amount) => conicPathPoint(current, control, to, weight, amount));
} else if (verb === 4) {
const controlA = path.points[pointIndex++];
const controlB = path.points[pointIndex++];
const to = path.points[pointIndex++];
if (controlA && controlB && to) appendCurve((amount) => cubicPathPoint(current, controlA, controlB, to, amount));
} else if (verb === 5) appendCurve((amount) => mixPathPoint(current, first, amount));
}
return path.reversed ? sampled.reverse() : sampled;
}
function pathLengths(points: readonly TemplatePathPoint[]) {
const lengths = [0];
for (let index = 1; index < points.length; index += 1) {
const previous = points[index - 1]!;
const current = points[index]!;
lengths.push(lengths[index - 1]! + Math.hypot(current.x - previous.x, current.y - previous.y));
}
return lengths;
}
function pointOnSampledPath(points: readonly TemplatePathPoint[], lengths: readonly number[], distance: number) {
const total = lengths.at(-1) ?? 0;
const target = Math.max(0, Math.min(total, distance));
let index = 1;
while (index < lengths.length && lengths[index]! < target) index += 1;
const current = points[Math.min(index, points.length - 1)]!;
const previous = points[Math.max(0, index - 1)]!;
const start = lengths[Math.max(0, index - 1)] ?? 0;
const end = lengths[Math.min(index, lengths.length - 1)] ?? start;
const amount = end > start ? (target - start) / (end - start) : 0;
return {
angle: Math.atan2(current.y - previous.y, current.x - previous.x),
...mixPathPoint(previous, current, amount),
};
}
function pathMargin(value: number, length: number) {
return Math.abs(value) <= 1 ? value * length : value;
}
export function textTextureCell(layout: TextTemplateFillTextureLayout, glyphIndex: number) {
const columns = Math.max(1, Math.trunc(layout.columns));
const rows = Math.max(1, Math.trunc(layout.rows));
const ids = layout.idList.length > 0 ? layout.idList : [0];
const rawId = Math.trunc(ids[Math.max(0, glyphIndex) % ids.length] ?? 0);
const cellId = ((rawId % (columns * rows)) + columns * rows) % (columns * rows);
return { column: cellId % columns, row: Math.floor(cellId / columns) };
}
function setGlyphTextureFill(
context: CanvasRenderingContext2D,
image: HTMLImageElement,
layout: TextTemplateFillTextureLayout,
glyphIndex: number,
bounds: { height: number; left: number; top: number; width: number },
) {
const sourceWidth = image.naturalWidth || image.width;
const sourceHeight = image.naturalHeight || image.height;
const pattern = sourceWidth > 0 && sourceHeight > 0 ? context.createPattern(image, "no-repeat") : null;
if (!pattern) return false;
const cell = textTextureCell(layout, glyphIndex);
const cellWidth = sourceWidth / Math.max(1, layout.columns);
const cellHeight = sourceHeight / Math.max(1, layout.rows);
const scaleX = Math.max(1, bounds.width) / cellWidth;
const scaleY = Math.max(1, bounds.height) / cellHeight;
pattern.setTransform({
a: scaleX,
b: 0,
c: 0,
d: scaleY,
e: bounds.left - cell.column * cellWidth * scaleX,
f: bounds.top - cell.row * cellHeight * scaleY,
});
context.fillStyle = pattern;
return true;
}
function drawTextOnTemplatePath(
context: CanvasRenderingContext2D,
text: string,
path: TextTemplateTextPath,
letterSpacing: number,
stroke: boolean,
fillGlyph?: (character: string, glyphIndex: number, width: number) => void,
) {
const sampled = sampleTemplateTextPath(path);
const lengths = pathLengths(sampled);
const total = lengths.at(-1) ?? 0;
if (sampled.length < 2 || total <= 0) return;
const glyphs = Array.from(text.replaceAll("\n", " ")).map((character) => ({ character, width: context.measureText(character).width }));
const textLength = glyphs.reduce((sum, glyph, index) => sum + glyph.width + (index === 0 ? 0 : letterSpacing), 0);
const firstMargin = Math.max(0, pathMargin(path.firstMargin, total));
const lastMargin = Math.max(0, pathMargin(path.lastMargin, total));
const available = Math.max(0, total - firstMargin - lastMargin);
let cursor = firstMargin + (path.forceAlignment || path.circle ? Math.max(0, available - textLength) / 2 : 0);
let textureGlyphIndex = 0;
for (const glyph of glyphs) {
const center = cursor + glyph.width / 2;
if (center > total - lastMargin) break;
const point = pointOnSampledPath(sampled, lengths, center);
context.save();
context.translate(point.x, point.y);
if (path.perpendicular) context.rotate(point.angle);
context.textAlign = "center";
context.textBaseline = "middle";
if (stroke) context.strokeText(glyph.character, 0, 0);
if (fillGlyph && !/\s/u.test(glyph.character)) fillGlyph(glyph.character, textureGlyphIndex++, glyph.width);
else context.fillText(glyph.character, 0, 0);
context.restore();
cursor += glyph.width + letterSpacing;
}
}
function drawTexturedTemplateLine(
context: CanvasRenderingContext2D,
line: string,
align: TextTemplateTextLayer["align"],
letterSpacing: number,
stroke: boolean,
patternImage: HTMLImageElement,
textureLayout: TextTemplateFillTextureLayout,
firstGlyphIndex: number,
) {
const glyphs = Array.from(line).map((character) => ({ character, width: context.measureText(character).width }));
const lineWidth = glyphs.reduce((sum, glyph, index) => sum + glyph.width + (index === 0 ? 0 : letterSpacing), 0);
let cursor = align === "left" ? 0 : align === "right" ? -lineWidth : -lineWidth / 2;
let textureGlyphIndex = firstGlyphIndex;
const fallbackFill = context.fillStyle;
context.textAlign = "center";
context.textBaseline = "middle";
for (const glyph of glyphs) {
const center = cursor + glyph.width / 2;
if (stroke) context.strokeText(glyph.character, center, 0);
if (!/\s/u.test(glyph.character)) {
context.fillStyle = fallbackFill;
setGlyphTextureFill(context, patternImage, textureLayout, textureGlyphIndex++, {
height: Math.max(1, Number.parseFloat(context.font) || 1),
left: cursor,
top: -(Number.parseFloat(context.font) || 1) / 2,
width: glyph.width,
});
}
context.fillText(glyph.character, center, 0);
cursor += glyph.width + letterSpacing;
}
context.fillStyle = fallbackFill;
return textureGlyphIndex;
}
function drawTemplateTextLayer( function drawTemplateTextLayer(
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
element: CanvasState["elements"][number], element: CanvasState["elements"][number],
@@ -212,8 +449,8 @@ function drawTemplateTextLayer(
const fontSize = editable ? element.font_size ?? layer.fontSize : layer.fontSize; const fontSize = editable ? element.font_size ?? layer.fontSize : layer.fontSize;
const lineHeight = editable ? styleValue(element, "line_height", layer.lineHeight) : layer.lineHeight; const lineHeight = editable ? styleValue(element, "line_height", layer.lineHeight) : layer.lineHeight;
const letterSpacing = editable ? styleValue(element, "letter_spacing", layer.letterSpacing) : layer.letterSpacing; 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 align = (editable ? styleValue(element, "text_align", layer.align) : layer.align) as TextTemplateTextLayer["align"];
const lines = (editable ? element.content ?? layer.text : layer.text).split("\n"); const lines = (layer.contentLinked ? element.content ?? layer.text : layer.text).split("\n");
context.save(); context.save();
context.globalAlpha *= layer.alpha; context.globalAlpha *= layer.alpha;
context.translate(layer.x, layer.y); context.translate(layer.x, layer.y);
@@ -224,13 +461,24 @@ function drawTemplateTextLayer(
context.textAlign = align; context.textAlign = align;
context.textBaseline = "middle"; context.textBaseline = "middle";
const widths = lines.map((line) => context.measureText(line).width + Math.max(0, Array.from(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 textWidth = boundedTextTemplateWidth(Math.max(1, ...widths), layer.width);
const textHeight = Math.max(fontSize * lineHeight, lines.length * fontSize * lineHeight); const layout = textTemplateBoxLayout({
align,
anchorX: layer.anchorX,
anchorY: layer.anchorY,
boxHeight: layer.height,
boxWidth: layer.width,
fontSize,
lineCount: lines.length,
lineHeight,
verticalAlign: layer.verticalAlign,
});
if (editable && styleValue(element, "background_enabled", false)) { if (editable && styleValue(element, "background_enabled", false)) {
const alpha = context.globalAlpha; const alpha = context.globalAlpha;
context.globalAlpha = alpha * styleValue(element, "background_opacity", 1); context.globalAlpha = alpha * styleValue(element, "background_opacity", 1);
context.fillStyle = styleValue(element, "background_color", "#FFE62C"); context.fillStyle = styleValue(element, "background_color", "#FFE62C");
context.fillRect(-textWidth / 2 - 16, -textHeight / 2 - 16, textWidth + 32, textHeight + 32); const backgroundLeft = align === "left" ? layout.x : align === "right" ? layout.x - textWidth : layout.x - textWidth / 2;
context.fillRect(backgroundLeft - 16, layout.firstY - fontSize / 2 - 16, textWidth + 32, layout.blockHeight + 32);
context.globalAlpha = alpha; context.globalAlpha = alpha;
} }
context.shadowColor = layer.shadowColor; context.shadowColor = layer.shadowColor;
@@ -239,21 +487,48 @@ function drawTemplateTextLayer(
context.shadowOffsetY = layer.shadowOffsetY; context.shadowOffsetY = layer.shadowOffsetY;
const fillOverridden = editable && styleValue(element, "template_fill_overridden", false); const fillOverridden = editable && styleValue(element, "template_fill_overridden", false);
const patternImage = !fillOverridden && layer.fillPatternAssetId ? resourceImages[layer.fillPatternAssetId] : undefined; const patternImage = !fillOverridden && layer.fillPatternAssetId ? resourceImages[layer.fillPatternAssetId] : undefined;
context.fillStyle = patternImage ? context.createPattern(patternImage, "repeat") ?? layer.fillColor context.fillStyle = patternImage && !layer.fillTextureLayout ? context.createPattern(patternImage, "repeat") ?? layer.fillColor
: editable ? styleValue(element, "fill_color", layer.fillColor) : layer.fillColor; : editable ? styleValue(element, "fill_color", layer.fillColor) : layer.fillColor;
context.strokeStyle = editable ? styleValue(element, "stroke_color", layer.strokeColor) : layer.strokeColor; context.strokeStyle = editable ? styleValue(element, "stroke_color", layer.strokeColor) : layer.strokeColor;
context.lineWidth = editable ? styleValue(element, "stroke_width", layer.strokeWidth) : layer.strokeWidth; context.lineWidth = editable ? styleValue(element, "stroke_width", layer.strokeWidth) : layer.strokeWidth;
const centerY = (0.5 - layer.anchorY) * textHeight; const stroke = (editable ? styleValue(element, "stroke_enabled", layer.strokeWidth > 0) : layer.strokeWidth > 0) && context.lineWidth > 0;
const firstY = centerY - ((lines.length - 1) * fontSize * lineHeight) / 2; if (layer.textPath) {
const anchorX = align === "left" ? -layer.anchorX * textWidth (context as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = "0px";
: align === "right" ? (1 - layer.anchorX) * textWidth const fallbackFill = context.fillStyle;
: (0.5 - layer.anchorX) * textWidth; drawTextOnTemplatePath(context, lines.join(" "), layer.textPath, letterSpacing, stroke,
patternImage && layer.fillTextureLayout
? (character, glyphIndex, width) => {
context.fillStyle = fallbackFill;
setGlyphTextureFill(context, patternImage, layer.fillTextureLayout!, glyphIndex, {
height: fontSize, left: -width / 2, top: -fontSize / 2, width,
});
context.fillText(character, 0, 0);
}
: undefined);
context.restore();
return;
}
if (patternImage && layer.fillTextureLayout) {
(context as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = "0px";
let glyphIndex = 0;
lines.forEach((line, index) => {
const y = layout.firstY + index * layout.lineAdvance;
const naturalWidth = context.measureText(line).width + Math.max(0, Array.from(line).length - 1) * letterSpacing;
const horizontalScale = Math.min(1, layer.width / Math.max(1, naturalWidth));
context.save();
context.translate(layout.x, y);
context.scale(horizontalScale, 1);
glyphIndex = drawTexturedTemplateLine(context, line, align, letterSpacing, stroke,
patternImage, layer.fillTextureLayout!, glyphIndex);
context.restore();
});
context.restore();
return;
}
lines.forEach((line, index) => { lines.forEach((line, index) => {
const y = firstY + index * fontSize * lineHeight; const y = layout.firstY + index * layout.lineAdvance;
if ((editable ? styleValue(element, "stroke_enabled", layer.strokeWidth > 0) : layer.strokeWidth > 0) && context.lineWidth > 0) { if (stroke) context.strokeText(line, layout.x, y, layer.width);
context.strokeText(line, anchorX, y); context.fillText(line, layout.x, y, layer.width);
}
context.fillText(line, anchorX, y);
}); });
context.restore(); context.restore();
} }
File diff suppressed because it is too large Load Diff
+42 -4
View File
@@ -8,6 +8,7 @@ type CanvasElement = CanvasState["elements"][number];
export type TextTemplateCategory = "flower" | "simple" | "tag" | "title"; export type TextTemplateCategory = "flower" | "simple" | "tag" | "title";
export type TextAlign = "center" | "left" | "right"; export type TextAlign = "center" | "left" | "right";
export type TextVerticalAlign = "bottom" | "middle" | "top";
export interface TextTemplateImageLayer { export interface TextTemplateImageLayer {
alpha: number; alpha: number;
@@ -51,9 +52,11 @@ export interface TextTemplateTextLayer {
align: TextAlign; align: TextAlign;
anchorX: number; anchorX: number;
anchorY: number; anchorY: number;
contentLinked: boolean;
editable: boolean; editable: boolean;
fillColor: string; fillColor: string;
fillPatternAssetId?: string; fillPatternAssetId?: string;
fillTextureLayout?: TextTemplateFillTextureLayout;
fontId: string; fontId: string;
fontSize: number; fontSize: number;
height: number; height: number;
@@ -70,11 +73,32 @@ export interface TextTemplateTextLayer {
strokeColor: string; strokeColor: string;
strokeWidth: number; strokeWidth: number;
text: string; text: string;
textPath?: TextTemplateTextPath;
verticalAlign: TextVerticalAlign;
width: number; width: number;
x: number; x: number;
y: number; y: number;
} }
export interface TextTemplateFillTextureLayout {
columns: number;
idList: readonly number[];
rows: number;
}
export interface TextTemplateTextPath {
circle: boolean;
conicWeights: readonly number[];
firstMargin: number;
forceAlignment: boolean;
isAbsoluteMode: boolean;
lastMargin: number;
perpendicular: boolean;
points: readonly { x: number; y: number }[];
reversed: boolean;
verbs: readonly number[];
}
export interface TextTemplateRenderModel { export interface TextTemplateRenderModel {
halfSize: { height: number; width: number }; halfSize: { height: number; width: number };
imageLayers: readonly TextTemplateImageLayer[]; imageLayers: readonly TextTemplateImageLayer[];
@@ -154,10 +178,14 @@ function imageLayer(layer: {
} }
interface RawTextLayer { interface RawTextLayer {
alpha?: number; align: string; anchor_x?: number; anchor_y?: number; editable: boolean; fill_color: string; fill_pattern_asset_id?: string; font_id: string; font_size: number; alpha?: number; align: string; anchor_x?: number; anchor_y?: number; content_linked?: boolean; editable: boolean; fill_color: string; fill_pattern_asset_id?: string;
fill_texture_layout?: { columns: number; id_list: number[]; rows: number }; font_id: string; font_size: number;
height: number; letter_spacing: number; line_height: number; order: number; rotation: number; scale_x: number; scale_y: 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; 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; stroke_width: number; text: string; text_path?: {
circle: boolean; conic_weights: number[]; first_margin: number; force_alignment: boolean; is_absolute_mode: boolean;
last_margin: number; perpendicular: boolean; points: Array<{ x: number; y: number }>; reversed: boolean; verbs: number[];
}; vertical_align: TextVerticalAlign; width: number; x: number; y: number;
} }
function renderModel(item: (typeof complexAssetCatalog.text_templates)[number]): TextTemplateRenderModel { function renderModel(item: (typeof complexAssetCatalog.text_templates)[number]): TextTemplateRenderModel {
@@ -173,14 +201,24 @@ function renderModel(item: (typeof complexAssetCatalog.text_templates)[number]):
const layer = value as unknown as RawTextLayer; const layer = value as unknown as RawTextLayer;
return { return {
alpha: layer.alpha ?? 1, align: layer.align as TextAlign, anchorX: layer.anchor_x ?? 0.5, anchorY: layer.anchor_y ?? 0.5, alpha: layer.alpha ?? 1, align: layer.align as TextAlign, anchorX: layer.anchor_x ?? 0.5, anchorY: layer.anchor_y ?? 0.5,
editable: layer.editable, fillColor: layer.fill_color, contentLinked: layer.content_linked ?? layer.editable, editable: layer.editable, fillColor: layer.fill_color,
...(layer.fill_pattern_asset_id ? { fillPatternAssetId: layer.fill_pattern_asset_id } : {}), ...(layer.fill_pattern_asset_id ? { fillPatternAssetId: layer.fill_pattern_asset_id } : {}),
...(layer.fill_texture_layout ? { fillTextureLayout: {
columns: layer.fill_texture_layout.columns, idList: layer.fill_texture_layout.id_list, rows: layer.fill_texture_layout.rows,
} } : {}),
fontId: layer.font_id, fontSize: layer.font_size, height: layer.height, letterSpacing: layer.letter_spacing, 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, 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, scaleY: layer.scale_y, shadowBlur: layer.shadow_blur, shadowColor: layer.shadow_color,
shadowOffsetX: layer.shadow_offset_x, shadowOffsetY: layer.shadow_offset_y, shadowOffsetX: layer.shadow_offset_x, shadowOffsetY: layer.shadow_offset_y,
strokeColor: layer.stroke_color, strokeWidth: layer.stroke_width, text: layer.text, strokeColor: layer.stroke_color, strokeWidth: layer.stroke_width, text: layer.text,
width: layer.width, x: layer.x, y: layer.y, ...(layer.text_path ? { textPath: {
circle: layer.text_path.circle, conicWeights: layer.text_path.conic_weights,
firstMargin: layer.text_path.first_margin, forceAlignment: layer.text_path.force_alignment,
isAbsoluteMode: layer.text_path.is_absolute_mode, lastMargin: layer.text_path.last_margin,
perpendicular: layer.text_path.perpendicular, points: layer.text_path.points,
reversed: layer.text_path.reversed, verbs: layer.text_path.verbs,
} } : {}),
verticalAlign: layer.vertical_align, width: layer.width, x: layer.x, y: layer.y,
}; };
}), }),
}; };
+124 -15
View File
@@ -1,4 +1,5 @@
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import { createHash } from "node:crypto";
import { basename, dirname, extname, join, relative } from "node:path"; import { basename, dirname, extname, join, relative } from "node:path";
import { inflateRawSync } from "node:zlib"; import { inflateRawSync } from "node:zlib";
@@ -358,6 +359,53 @@ function quaternionDegrees(rotation) {
return Math.atan2(2 * w * z, 1 - 2 * z * z) * 180 / Math.PI; return Math.atan2(2 * w * z, 1 - 2 * z * z) * 180 / Math.PI;
} }
function estimatedGlyphWidth(character, fontSize) {
if (/\s/u.test(character)) return fontSize * 0.34;
if (/^[A-Z]$/u.test(character)) return fontSize * 0.68;
if (/^[a-z0-9]$/u.test(character)) return fontSize * 0.56;
if (/^[.,:;!?@©()()\-_']$/u.test(character)) return fontSize * 0.42;
return fontSize;
}
function inferredTextContentSize(text, style) {
const fontSize = Math.max(1, Number(style.fontSize ?? 48));
const characterSpacing = Number(style.characterSpacing ?? 0);
const lineSpacing = Math.max(0.1, Number(style.lineSpacing ?? 1));
const lines = String(text ?? "").split("\n");
const width = Math.max(1, ...lines.map((line) => Array.from(line).reduce(
(total, character, index) => total + estimatedGlyphWidth(character, fontSize) + (index === 0 ? 0 : characterSpacing),
0,
)));
const height = fontSize + Math.max(0, lines.length - 1) * fontSize * lineSpacing;
return { height, width };
}
function textPathModel(object, transform, contentSize) {
const option = object?.m_textPathOption;
const info = option?.pk?.mPathInfo;
if (!Array.isArray(info?.mPathVerbs) || info.mPathVerbs.length === 0 || !Array.isArray(info?.mPoints)) return undefined;
const sourceAnchorX = Number(object.m_anchorPoint?.x ?? 0.5);
const sourceAnchorY = Number(object.m_anchorPoint?.y ?? 0.5);
const circle = info.mIsCircle === true;
const scaleX = Math.abs(transform.scaleX);
const scaleY = Math.abs(transform.scaleY);
return {
circle,
conic_weights: Array.isArray(info.mConicWeights) ? info.mConicWeights.map(Number) : [],
first_margin: Number(option.firstMargin ?? 0),
force_alignment: option.forceAlignment === true,
is_absolute_mode: option.isAbsoluteMode === true,
last_margin: Number(option.lastMargin ?? 0),
perpendicular: option.perpendicularToPath !== false,
points: info.mPoints.map((point) => ({
x: (Number(point?.x ?? 0) + (circle ? (0.5 - sourceAnchorX) * contentSize.width : -sourceAnchorX * contentSize.width)) * scaleX,
y: (-Number(point?.y ?? 0) + (circle ? (sourceAnchorY - 0.5) * contentSize.height : sourceAnchorY * contentSize.height)) * scaleY,
})),
reversed: option.reversedPath === true,
verbs: info.mPathVerbs.map(Number),
};
}
function spriteNinePatch(sprite) { function spriteNinePatch(sprite) {
const object = sprite?.object; const object = sprite?.object;
if (Number(object?.m_type) !== 3) return undefined; if (Number(object?.m_type) !== 3) return undefined;
@@ -459,21 +507,24 @@ function prefabLayers(prefab, input) {
let order = 0; let order = 0;
const resolve = prefabResolver(prefab); const resolve = prefabResolver(prefab);
const root = resolve(prefab?.object?.m_RootSo)?.object; const root = resolve(prefab?.object?.m_RootSo)?.object;
const visit = (wrapped, parent, ignorePosition = false) => { const visit = (wrapped, parent, ignorePosition = false, anchorXOverride) => {
const typed = resolve(wrapped); const typed = resolve(wrapped);
const object = typed?.object; const object = typed?.object;
if (!object) return; if (!object) return;
const local = object.m_LocalTfrm ?? {}; const local = object.m_LocalTfrm ?? {};
const localPosition = local.m_Position ?? {}; const localPosition = local.m_Position ?? {};
const localScale = local.m_Scale ?? {}; const localScale = local.m_Scale ?? {};
const scaleX = parent.scaleX * Number(localScale.x ?? 1); // The first UI group may use a negative scale to bridge the source editor's
const scaleY = parent.scaleY * Number(localScale.y ?? 1); // coordinate system. Browser-space Y is already inverted below, so keeping
// that sign would mirror every glyph and decoration a second time.
const scaleX = parent.scaleX * (ignorePosition ? Math.abs(Number(localScale.x ?? 1)) : Number(localScale.x ?? 1));
const scaleY = parent.scaleY * (ignorePosition ? Math.abs(Number(localScale.y ?? 1)) : Number(localScale.y ?? 1));
const localX = ignorePosition ? 0 : Number(localPosition.x ?? 0) * parent.scaleX; const localX = ignorePosition ? 0 : Number(localPosition.x ?? 0) * parent.scaleX;
const localY = ignorePosition ? 0 : Number(localPosition.y ?? 0) * parent.scaleY; const localY = ignorePosition ? 0 : Number(localPosition.y ?? 0) * parent.scaleY;
const parentRadians = parent.rotation * Math.PI / 180; const parentRadians = parent.rotation * Math.PI / 180;
const transform = { const transform = {
alpha: parent.alpha * Math.max(0, Math.min(1, Number(object.m_CustomAlpha ?? 1))), alpha: parent.alpha * Math.max(0, Math.min(1, Number(object.m_CustomAlpha ?? 1))),
anchorX: Math.max(0, Math.min(1, Number(object.m_anchorPoint?.x ?? 0.5))), anchorX: Math.max(0, Math.min(1, Number(anchorXOverride ?? object.m_anchorPoint?.x ?? 0.5))),
anchorY: Math.max(0, Math.min(1, Number(object.m_anchorPoint?.y ?? 0.5))), anchorY: Math.max(0, Math.min(1, Number(object.m_anchorPoint?.y ?? 0.5))),
rotation: parent.rotation + quaternionDegrees(local.m_Rotation), rotation: parent.rotation + quaternionDegrees(local.m_Rotation),
scaleX, scaleX,
@@ -491,19 +542,40 @@ function prefabLayers(prefab, input) {
if (textMesh) { if (textMesh) {
const textRenderer = nodeComponents.find((item) => item.typeId === "TextRenderer")?.object; const textRenderer = nodeComponents.find((item) => item.typeId === "TextRenderer")?.object;
const style = textMesh.m_fontStyleInfo ?? {}; const style = textMesh.m_fontStyleInfo ?? {};
const inferredSize = inferredTextContentSize(textMesh.m_text, style);
const contentSize = {
height: Number(object.m_contentSize?.height ?? 0) > 0 ? Number(object.m_contentSize.height) : inferredSize.height,
width: Number(object.m_contentSize?.width ?? 0) > 0 ? Number(object.m_contentSize.width) : inferredSize.width,
};
const textPath = textPathModel(object, transform, contentSize);
const outline = style.outlineInfo?.outlineSize > 0 ? style.outlineInfo : undefined; const outline = style.outlineInfo?.outlineSize > 0 ? style.outlineInfo : undefined;
const shadow = style.shadowInfos?.find((item) => Number(item?.offset?.x ?? 0) !== 0 || Number(item?.offset?.y ?? 0) !== 0); const shadow = style.shadowInfos?.find((item) => Number(item?.offset?.x ?? 0) !== 0 || Number(item?.offset?.y ?? 0) !== 0);
const fontUuid = textMesh.m_font?.uuid?.uuid; const fontUuid = textMesh.m_font?.uuid?.uuid;
const fillPatternAssetId = firstMaterialTextureAssetId(textRenderer, input);
const sourceTextureLayout = textMesh.m_textureLayoutInfo;
const fillTextureLayout = fillPatternAssetId
&& Number(sourceTextureLayout?.rows) > 0
&& Number(sourceTextureLayout?.columns) > 0
&& Array.isArray(sourceTextureLayout?.idList)
&& sourceTextureLayout.idList.length > 0
? {
columns: Math.max(1, Number(sourceTextureLayout.columns)),
id_list: sourceTextureLayout.idList.map(Number),
rows: Math.max(1, Number(sourceTextureLayout.rows)),
}
: undefined;
layers.push({ layers.push({
align: Number(style.alignment ?? 0) === 2 ? "right" : Number(style.alignment ?? 0) === 1 ? "left" : "center", align: Number(style.alignment ?? 0) === 2 ? "right" : Number(style.alignment ?? 0) === 1 ? "center" : "left",
...(transform.alpha === 1 ? {} : { alpha: transform.alpha }), ...(transform.alpha === 1 ? {} : { alpha: transform.alpha }),
...(transform.anchorX === 0.5 ? {} : { anchor_x: transform.anchorX }), ...(transform.anchorX === 0.5 ? {} : { anchor_x: transform.anchorX }),
...(transform.anchorY === 0.5 ? {} : { anchor_y: 1 - transform.anchorY }), ...(transform.anchorY === 0.5 ? {} : { anchor_y: 1 - transform.anchorY }),
fill_color: colorHex(style.color), fill_color: colorHex(style.color),
fill_pattern_asset_id: firstMaterialTextureAssetId(textRenderer, input), ...(fillPatternAssetId ? { fill_pattern_asset_id: fillPatternAssetId } : {}),
...(fillTextureLayout ? { fill_texture_layout: fillTextureLayout } : {}),
font_file: typeof fontUuid === "string" ? input.manifestMappings.get(fontUuid) : undefined, font_file: typeof fontUuid === "string" ? input.manifestMappings.get(fontUuid) : undefined,
font_md5: typeof object.m_fontMd5Value === "string" ? object.m_fontMd5Value.toLocaleLowerCase("en-US") : undefined,
font_size: Math.max(1, Number(style.fontSize ?? 48) * Math.abs(scaleY)), 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)), height: Math.max(1, contentSize.height * Math.abs(scaleY)),
letter_spacing: Number(style.characterSpacing ?? 1), letter_spacing: Number(style.characterSpacing ?? 1),
line_height: Number(style.lineSpacing ?? 1), line_height: Number(style.lineSpacing ?? 1),
order: order++, order: order++,
@@ -517,8 +589,12 @@ function prefabLayers(prefab, input) {
stroke_color: colorHex(outline?.outlineColor, "#000000"), stroke_color: colorHex(outline?.outlineColor, "#000000"),
stroke_width: Math.max(0, Number(outline?.outlineSize ?? 0)), stroke_width: Math.max(0, Number(outline?.outlineSize ?? 0)),
text: String(textMesh.m_text ?? ""), text: String(textMesh.m_text ?? ""),
...(textPath ? { text_path: textPath } : {}),
type: "text", type: "text",
width: Math.max(1, Number(object.m_contentSize?.width ?? 1) * Math.abs(scaleX)), vertical_align: Number(style.vAlignment ?? 1) === 0 ? "top"
: Number(style.vAlignment ?? 1) === 2 ? "bottom"
: "middle",
width: Math.max(1, contentSize.width * Math.abs(scaleX)),
x: transform.x, x: transform.x,
y: -transform.y, y: -transform.y,
}); });
@@ -564,7 +640,7 @@ function prefabLayers(prefab, input) {
atlas_rows: Math.max(1, Number(particleComponent.m_altasVcount ?? 1)), atlas_rows: Math.max(1, Number(particleComponent.m_altasVcount ?? 1)),
color: colorHex(particleComponent.m_ParticleColor, "#FFFFFF"), color: colorHex(particleComponent.m_ParticleColor, "#FFFFFF"),
density: Math.max(1, Number(particleComponent.m_particlesDensity ?? 1)), 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)), height: Math.max(1, contentSize.height * Math.abs(scaleY)),
order: order++, order: order++,
particle_height: Math.max(1, Number(particleComponent.m_particlesRenderSize?.y ?? 8)), particle_height: Math.max(1, Number(particleComponent.m_particlesRenderSize?.y ?? 8)),
particle_width: Math.max(1, Number(particleComponent.m_particlesRenderSize?.x ?? 8)), particle_width: Math.max(1, Number(particleComponent.m_particlesRenderSize?.x ?? 8)),
@@ -574,7 +650,7 @@ function prefabLayers(prefab, input) {
scale_x: Math.sign(scaleX) || 1, scale_x: Math.sign(scaleX) || 1,
scale_y: Math.sign(scaleY) || 1, scale_y: Math.sign(scaleY) || 1,
type: "particles", type: "particles",
width: Math.max(1, Number(object.m_contentSize?.width ?? 1) * Math.abs(scaleX)), width: Math.max(1, contentSize.width * Math.abs(scaleX)),
x: transform.x, x: transform.x,
y: -transform.y, y: -transform.y,
}); });
@@ -622,13 +698,31 @@ function prefabLayers(prefab, input) {
input.unresolvedImages.push({ spriteUuid, spritePath }); input.unresolvedImages.push({ spriteUuid, spritePath });
} }
} }
for (const child of object.m_Children ?? []) visit(child, transform); const children = (object.m_Children ?? []).map((child) => ({ child, typed: resolve(child) }));
// Linked text backgrounds can store their image position at the text box's left edge.
const linkedTextLeftEdges = children
.filter(({ typed }) => typed?.typeId === "TextView" && Number(typed.object?.m_UseLink ?? 0) === 1)
.map(({ typed }) => {
const textObject = typed.object;
return Number(textObject.m_LocalTfrm?.m_Position?.x ?? 0)
- Number(textObject.m_anchorPoint?.x ?? 0.5) * Number(textObject.m_contentSize?.width ?? 0);
});
for (const { child, typed } of children) {
const childX = Number(typed?.object?.m_LocalTfrm?.m_Position?.x ?? 0);
const usesLinkedTextLeftEdge = typed?.typeId === "ImageView"
&& linkedTextLeftEdges.some((leftEdge) => Math.abs(childX - leftEdge) < 0.01);
visit(child, transform, false, usesLinkedTextLeftEdge ? 0 : undefined);
}
}; };
for (const child of root?.m_Children ?? []) visit(child, { alpha: 1, rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, true); for (const child of root?.m_Children ?? []) visit(child, { alpha: 1, rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, true);
return layers; return layers;
} }
function fontIdForFile(fontResources, file) { function fontIdForFile(fontResources, file, contentMd5) {
if (typeof contentMd5 === "string") {
const hashMatch = fontResources.find((resource) => resource.contentMd5 === contentMd5.toLocaleLowerCase("en-US"));
if (hashMatch) return hashMatch.assetId;
}
if (typeof file !== "string") return fontResources[0]?.assetId; if (typeof file !== "string") return fontResources[0]?.assetId;
const name = basename(file).toLocaleLowerCase("en-US"); const name = basename(file).toLocaleLowerCase("en-US");
return fontResources.find((resource) => resource.names.includes(name))?.assetId ?? fontResources[0]?.assetId; return fontResources.find((resource) => resource.names.includes(name))?.assetId ?? fontResources[0]?.assetId;
@@ -645,9 +739,11 @@ function normalizeModel(layers, defaultValue, fontResources) {
const primary = textLayers.find((layer) => layer.text.trim().toLocaleLowerCase("zh-CN") === defaultValue.trim().toLocaleLowerCase("zh-CN")) ?? textLayers[0]; const primary = textLayers.find((layer) => layer.text.trim().toLocaleLowerCase("zh-CN") === defaultValue.trim().toLocaleLowerCase("zh-CN")) ?? textLayers[0];
if (!primary) return undefined; if (!primary) return undefined;
for (const layer of textLayers) { for (const layer of textLayers) {
layer.content_linked = layer.text === primary.text;
layer.editable = layer === primary; layer.editable = layer === primary;
layer.font_id = fontIdForFile(fontResources, layer.font_file); layer.font_id = fontIdForFile(fontResources, layer.font_file, layer.font_md5);
delete layer.font_file; delete layer.font_file;
delete layer.font_md5;
} }
const bounds = layers.map((layer) => { const bounds = layers.map((layer) => {
const anchorX = Number(layer.anchor_x ?? 0.5); const anchorX = Number(layer.anchor_x ?? 0.5);
@@ -685,10 +781,17 @@ function normalizeModel(layers, defaultValue, fontResources) {
layer.height = Number((layer.height * normalization).toFixed(3)); layer.height = Number((layer.height * normalization).toFixed(3));
if (layer.type === "text") { if (layer.type === "text") {
layer.font_size = Number((layer.font_size * normalization).toFixed(3)); layer.font_size = Number((layer.font_size * normalization).toFixed(3));
layer.letter_spacing = Number((layer.letter_spacing * normalization).toFixed(3));
layer.stroke_width = Number((layer.stroke_width * normalization).toFixed(3)); layer.stroke_width = Number((layer.stroke_width * normalization).toFixed(3));
layer.shadow_blur = Number((layer.shadow_blur * 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_x = Number((layer.shadow_offset_x * normalization).toFixed(3));
layer.shadow_offset_y = Number((layer.shadow_offset_y * normalization).toFixed(3)); layer.shadow_offset_y = Number((layer.shadow_offset_y * normalization).toFixed(3));
if (layer.text_path) {
layer.text_path.points = layer.text_path.points.map((point) => ({
x: Number((point.x * normalization).toFixed(3)),
y: Number((point.y * normalization).toFixed(3)),
}));
}
} else if (layer.type === "particles") { } else if (layer.type === "particles") {
layer.particle_height = Number((layer.particle_height * normalization).toFixed(3)); layer.particle_height = Number((layer.particle_height * normalization).toFixed(3));
layer.particle_width = Number((layer.particle_width * normalization).toFixed(3)); layer.particle_width = Number((layer.particle_width * normalization).toFixed(3));
@@ -710,7 +813,12 @@ export function compileTextTemplateAssets({ templateDirectory, templateId }) {
const fontResources = (metadata.files?.fonts ?? []).map((reference, index) => { const fontResources = (metadata.files?.fonts ?? []).map((reference, index) => {
const resource = browserFontResource(templateDirectory, templateId, reference, index); const resource = browserFontResource(templateDirectory, templateId, reference, index);
const normalized = normalizeBrowserFontBytes(resource.sourceBytes ?? readFileSync(resource.sourcePath)); const normalized = normalizeBrowserFontBytes(resource.sourceBytes ?? readFileSync(resource.sourcePath));
return normalized ? { ...resource, sourceBytes: normalized, sourcePath: undefined } : resource; const browserBytes = normalized ?? resource.sourceBytes ?? readFileSync(resource.sourcePath);
return {
...resource,
contentMd5: createHash("md5").update(browserBytes).digest("hex"),
...(normalized ? { sourceBytes: normalized, sourcePath: undefined } : {}),
};
}); });
if (fontResources.length === 0) throw new Error(`text_template_font_missing:${templateId}`); if (fontResources.length === 0) throw new Error(`text_template_font_missing:${templateId}`);
const packageRoot = join(templateDirectory, "package"); const packageRoot = join(templateDirectory, "package");
@@ -762,6 +870,7 @@ export function compileTextTemplateAssets({ templateDirectory, templateId }) {
stroke_width: 0, stroke_width: 0,
text: value, text: value,
type: "text", type: "text",
vertical_align: "middle",
width: Math.max(96, Array.from(value).length * 52), width: Math.max(96, Array.from(value).length * 52),
x: 0, x: 0,
y: 0, y: 0,
@@ -781,6 +890,6 @@ export function compileTextTemplateAssets({ templateDirectory, templateId }) {
prefab_candidates: prefabCandidates.length, prefab_candidates: prefabCandidates.length,
unresolved_images: unresolvedImages.length, unresolved_images: unresolvedImages.length,
}, },
resources: [...fontResources, ...imageResources].map(({ names: _names, ...resource }) => resource), resources: [...fontResources, ...imageResources].map(({ contentMd5: _contentMd5, names: _names, ...resource }) => resource),
}; };
} }
+88 -1
View File
@@ -5,7 +5,7 @@ import { P0A_DYNAMIC_STICKERS } from "../../apps/web/src/dynamic-provider.js";
import { DYNAMIC_RENDER_MODELS } from "../../apps/web/src/dynamic-render-models.js"; import { DYNAMIC_RENDER_MODELS } from "../../apps/web/src/dynamic-render-models.js";
import { P0A_COLOR_CARDS } from "../../apps/web/src/palette-provider.js"; import { P0A_COLOR_CARDS } from "../../apps/web/src/palette-provider.js";
import { P0A_FONT_OPTIONS, P0A_TEXT_TEMPLATES } from "../../apps/web/src/text-assets.js"; import { P0A_FONT_OPTIONS, P0A_TEXT_TEMPLATES } from "../../apps/web/src/text-assets.js";
import { ninePatchSlices } from "../../apps/web/src/editor-stage.js"; import { boundedTextTemplateWidth, ninePatchSlices, textTemplateBoxLayout, textTextureCell } from "../../apps/web/src/editor-stage.js";
import { import {
P0A_COLOR_CARD_IDS, P0A_COLOR_CARD_IDS,
P0A_DYNAMIC_STICKER_IDS, P0A_DYNAMIC_STICKER_IDS,
@@ -96,15 +96,102 @@ describe("POSTV1-ASSET-ALL-16 complete asset catalog", () => {
])); ]));
expect(byId.get("H079")!.render_model.text_layers.slice(0, 3).every((layer) => Math.abs(layer.rotation + 10) < 0.001)).toBe(true); expect(byId.get("H079")!.render_model.text_layers.slice(0, 3).every((layer) => Math.abs(layer.rotation + 10) < 0.001)).toBe(true);
expect(byId.get("H005")!.render_model.text_layers[1]).toMatchObject({ anchor_y: 0 }); expect(byId.get("H005")!.render_model.text_layers[1]).toMatchObject({ anchor_y: 0 });
expect(byId.get("H005")!.render_model.text_layers[0]).toMatchObject({ vertical_align: "middle" });
expect(byId.get("H005")!.render_model.text_layers[1]).toMatchObject({ vertical_align: "top" });
expect(byId.get("H004")!.render_model.text_layers[0]).toMatchObject({ align: "center" });
expect(byId.get("H009")!.render_model.image_layers.find((layer) => layer.asset_id === "TEXT-IMAGE-H009-003"))
.toMatchObject({ anchor_x: 0 });
const heading016Text = byId.get("H016")!.render_model.text_layers;
expect(heading016Text).toHaveLength(3);
expect(heading016Text.every((layer) => layer.vertical_align === "bottom")).toBe(true);
const heading041 = byId.get("H041")!; const heading041 = byId.get("H041")!;
const heading041Text = heading041.render_model.text_layers[0]!; const heading041Text = heading041.render_model.text_layers[0]!;
const heading041Underline = heading041.render_model.image_layers.find((layer) => layer.asset_id === "TEXT-IMAGE-H041-004")!; const heading041Underline = heading041.render_model.image_layers.find((layer) => layer.asset_id === "TEXT-IMAGE-H041-004")!;
expect(heading041Text.stroke_width).toBe(0); expect(heading041Text.stroke_width).toBe(0);
expect(heading041Text.letter_spacing).toBeCloseTo(2.714, 3);
expect(heading041Underline.y).toBeGreaterThan(heading041Text.y); expect(heading041Underline.y).toBeGreaterThan(heading041Text.y);
expect(byId.get("FLOWER024")!.render_model.image_layers[0]).toMatchObject({ alpha: 0.4000000059604645 }); expect(byId.get("FLOWER024")!.render_model.image_layers[0]).toMatchObject({ alpha: 0.4000000059604645 });
}); });
it("preserves source path, coordinate, and auto-layout semantics for fidelity regressions", () => {
const byId = new Map(complexAssetCatalog.text_templates.map((item) => [item.template_id, item]));
const summer = byId.get("H015")!.render_model.text_layers[0] as unknown as {
fill_texture_layout?: { columns: number; id_list: number[]; rows: number };
text_path?: { circle: boolean; points: Array<{ x: number; y: number }>; verbs: number[] };
};
expect(summer.text_path).toMatchObject({ circle: false, verbs: [0, 4, 4] });
expect(summer.text_path?.points).toHaveLength(7);
expect(summer.fill_texture_layout).toEqual({ columns: 2, id_list: [0, 1, 2, 3], rows: 2 });
const doubleSeventh = byId.get("H017")!.render_model.text_layers[0] as unknown as {
text_path?: { circle: boolean; points: Array<{ x: number; y: number }>; verbs: number[] };
};
expect(doubleSeventh.text_path).toMatchObject({ circle: true, verbs: [0, 3, 3, 3, 3, 5] });
expect(doubleSeventh.text_path?.points).toHaveLength(9);
const relax = byId.get("H022")!.render_model;
expect([...relax.text_layers, ...relax.particle_layers].every((layer) => layer.scale_y === 1)).toBe(true);
expect(relax.text_layers[0]!.y).toBeLessThan(relax.text_layers.at(-1)!.y);
expect(relax.text_layers.at(-1)!.font_id).toBe("TEXT-FONT-H022-03");
expect(relax.text_layers.slice(0, 3).map((layer) => layer.content_linked)).toEqual([true, true, true]);
const sightseeing = byId.get("H023")!.render_model;
expect(sightseeing.half_size.width).toBeGreaterThan(80);
expect(sightseeing.half_size.height).toBeGreaterThan(50);
expect(sightseeing.text_layers.every((layer) => layer.width > 1 && layer.height > 1)).toBe(true);
expect(sightseeing.particle_layers.every((layer) => layer.width > 1 && layer.height > 1)).toBe(true);
expect(sightseeing.text_layers.map((layer) => layer.font_id)).toEqual([
"TEXT-FONT-H023-03", "TEXT-FONT-H023-03", "TEXT-FONT-H023-03", "TEXT-FONT-H023-01", "TEXT-FONT-H023-02",
]);
expect(sightseeing.text_layers.slice(0, 3).map((layer) => layer.content_linked)).toEqual([true, true, true]);
const mother = byId.get("FLOWER077")!.render_model;
const motherText = mother.text_layers[0]!;
const motherBackground = mother.image_layers[0]!;
expect(motherText.width).toBeLessThan(motherBackground.width);
expect(Math.abs(motherText.x - motherBackground.x)).toBeLessThan(motherBackground.width * 0.05);
});
it("maps each glyph to the source texture-atlas cell", () => {
const layout = { columns: 2, idList: [0, 1, 2, 3], rows: 2 };
expect(textTextureCell(layout, 0)).toEqual({ column: 0, row: 0 });
expect(textTextureCell(layout, 1)).toEqual({ column: 1, row: 0 });
expect(textTextureCell(layout, 2)).toEqual({ column: 0, row: 1 });
expect(textTextureCell(layout, 3)).toEqual({ column: 1, row: 1 });
expect(textTextureCell(layout, 7)).toEqual({ column: 1, row: 1 });
});
it("positions template text inside the captured fixed box instead of its measured glyph width", () => {
const shortText = textTemplateBoxLayout({
align: "center", anchorX: 0, anchorY: 1, boxHeight: 70, boxWidth: 517,
fontSize: 70, lineCount: 1, lineHeight: 1, verticalAlign: "bottom",
});
const longText = textTemplateBoxLayout({
align: "center", anchorX: 0, anchorY: 1, boxHeight: 70, boxWidth: 517,
fontSize: 70, lineCount: 1, lineHeight: 1, verticalAlign: "bottom",
});
expect(shortText.x).toBe(258.5);
expect(longText.x).toBe(shortText.x);
expect(shortText.firstY).toBe(-35);
expect(boundedTextTemplateWidth(640, 180)).toBe(180);
expect(boundedTextTemplateWidth(120, 180)).toBe(120);
});
it("honors source top, middle, and bottom vertical alignment inside fixed text boxes", () => {
const layout = (verticalAlign: "top" | "middle" | "bottom") => textTemplateBoxLayout({
align: "left", anchorX: 0, anchorY: 0, boxHeight: 120, boxWidth: 200,
fontSize: 40, lineCount: 1, lineHeight: 1, verticalAlign,
});
expect(layout("top").firstY).toBe(20);
expect(layout("middle").firstY).toBe(60);
expect(layout("bottom").firstY).toBe(100);
});
it("preserves nine-patch edges while stretching only the decoration center", () => { it("preserves nine-patch edges while stretching only the decoration center", () => {
const slices = ninePatchSlices(231, 63, { const slices = ninePatchSlices(231, 63, {
bottom: 16, left: 53, right: 25, sourceHeight: 63, sourceWidth: 159, top: 12, bottom: 16, left: 53, right: 25, sourceHeight: 63, sourceWidth: 159, top: 12,