fix(POSTV1-TEMPLATE-LAYOUT-19): 还原逐字纹理与叠层文字
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run

This commit is contained in:
suyx
2026-08-07 01:52:18 +08:00
parent 210554628d
commit 69f447b614
5 changed files with 834 additions and 10 deletions
+108 -4
View File
@@ -13,6 +13,7 @@ import {
textTemplateFontOptions,
textTemplateImageUrls,
type TextTemplateImageLayer,
type TextTemplateFillTextureLayout,
type TextTemplateNinePatch,
type TextTemplateParticleLayer,
type TextTemplateTextLayer,
@@ -328,12 +329,50 @@ 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);
@@ -345,6 +384,7 @@ function drawTextOnTemplatePath(
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;
@@ -355,12 +395,49 @@ function drawTextOnTemplatePath(
context.textAlign = "center";
context.textBaseline = "middle";
if (stroke) context.strokeText(glyph.character, 0, 0);
context.fillText(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(
context: CanvasRenderingContext2D,
element: CanvasState["elements"][number],
@@ -373,7 +450,7 @@ function drawTemplateTextLayer(
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 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.globalAlpha *= layer.alpha;
context.translate(layer.x, layer.y);
@@ -410,14 +487,41 @@ function drawTemplateTextLayer(
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
context.fillStyle = patternImage && !layer.fillTextureLayout ? 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 stroke = (editable ? styleValue(element, "stroke_enabled", layer.strokeWidth > 0) : layer.strokeWidth > 0) && context.lineWidth > 0;
if (layer.textPath) {
(context as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = "0px";
drawTextOnTemplatePath(context, lines.join(" "), layer.textPath, letterSpacing, stroke);
const fallbackFill = context.fillStyle;
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;
}
File diff suppressed because it is too large Load Diff
+14 -2
View File
@@ -52,9 +52,11 @@ export interface TextTemplateTextLayer {
align: TextAlign;
anchorX: number;
anchorY: number;
contentLinked: boolean;
editable: boolean;
fillColor: string;
fillPatternAssetId?: string;
fillTextureLayout?: TextTemplateFillTextureLayout;
fontId: string;
fontSize: number;
height: number;
@@ -78,6 +80,12 @@ export interface TextTemplateTextLayer {
y: number;
}
export interface TextTemplateFillTextureLayout {
columns: number;
idList: readonly number[];
rows: number;
}
export interface TextTemplateTextPath {
circle: boolean;
conicWeights: readonly number[];
@@ -170,7 +178,8 @@ function imageLayer(layer: {
}
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;
shadow_blur: number; shadow_color: string; shadow_offset_x: number; shadow_offset_y: number; stroke_color: string;
stroke_width: number; text: string; text_path?: {
@@ -192,8 +201,11 @@ function renderModel(item: (typeof complexAssetCatalog.text_templates)[number]):
const layer = value as unknown as RawTextLayer;
return {
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_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,
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,
+16 -1
View File
@@ -551,13 +551,27 @@ function prefabLayers(prefab, input) {
const outline = style.outlineInfo?.outlineSize > 0 ? style.outlineInfo : undefined;
const shadow = style.shadowInfos?.find((item) => Number(item?.offset?.x ?? 0) !== 0 || Number(item?.offset?.y ?? 0) !== 0);
const fontUuid = textMesh.m_font?.uuid?.uuid;
const fillPatternAssetId = firstMaterialTextureAssetId(textRenderer, input);
const sourceTextureLayout = textMesh.m_textureLayoutInfo;
const fillTextureLayout = fillPatternAssetId
&& Number(sourceTextureLayout?.rows) > 0
&& Number(sourceTextureLayout?.columns) > 0
&& Array.isArray(sourceTextureLayout?.idList)
&& sourceTextureLayout.idList.length > 0
? {
columns: Math.max(1, Number(sourceTextureLayout.columns)),
id_list: sourceTextureLayout.idList.map(Number),
rows: Math.max(1, Number(sourceTextureLayout.rows)),
}
: undefined;
layers.push({
align: Number(style.alignment ?? 0) === 2 ? "right" : Number(style.alignment ?? 0) === 1 ? "center" : "left",
...(transform.alpha === 1 ? {} : { alpha: transform.alpha }),
...(transform.anchorX === 0.5 ? {} : { anchor_x: transform.anchorX }),
...(transform.anchorY === 0.5 ? {} : { anchor_y: 1 - transform.anchorY }),
fill_color: colorHex(style.color),
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_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)),
@@ -725,6 +739,7 @@ function normalizeModel(layers, defaultValue, fontResources) {
const primary = textLayers.find((layer) => layer.text.trim().toLocaleLowerCase("zh-CN") === defaultValue.trim().toLocaleLowerCase("zh-CN")) ?? textLayers[0];
if (!primary) return undefined;
for (const layer of textLayers) {
layer.content_linked = layer.text === primary.text;
layer.editable = layer === primary;
layer.font_id = fontIdForFile(fontResources, layer.font_file, layer.font_md5);
delete layer.font_file;
+14 -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 { 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 { boundedTextTemplateWidth, ninePatchSlices, textTemplateBoxLayout } from "../../apps/web/src/editor-stage.js";
import { boundedTextTemplateWidth, ninePatchSlices, textTemplateBoxLayout, textTextureCell } from "../../apps/web/src/editor-stage.js";
import {
P0A_COLOR_CARD_IDS,
P0A_DYNAMIC_STICKER_IDS,
@@ -119,10 +119,12 @@ describe("POSTV1-ASSET-ALL-16 complete asset catalog", () => {
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[] };
@@ -134,6 +136,7 @@ describe("POSTV1-ASSET-ALL-16 complete asset catalog", () => {
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);
@@ -143,6 +146,7 @@ describe("POSTV1-ASSET-ALL-16 complete asset catalog", () => {
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]!;
@@ -151,6 +155,15 @@ describe("POSTV1-ASSET-ALL-16 complete asset catalog", () => {
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,