fix(POSTV1-TEMPLATE-LAYOUT-19): 还原逐字纹理与叠层文字
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
This commit is contained in:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user