Compare commits

...
Author SHA1 Message Date
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
suyx 5e8a491f2a fix(POSTV1-TEMPLATE-PLACEMENT-18): 修复文字模板坐标与描边解析
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-06 18:25:41 +08:00
5 changed files with 1439 additions and 834 deletions
+65 -16
View File
@@ -16,6 +16,7 @@ import {
type TextTemplateNinePatch,
type TextTemplateParticleLayer,
type TextTemplateTextLayer,
type TextVerticalAlign,
} from "./text-assets.js";
import { COLOR_CARD_HALF_SIZES, drawColorCard } from "./palette-provider.js";
@@ -79,11 +80,18 @@ function destinationEdges(size: number, first: number, last: number) {
return [first * ratio, last * ratio] as const;
}
export function ninePatchSlices(width: number, height: number, patch: TextTemplateNinePatch): NinePatchSlice[] {
export function ninePatchSlices(
width: number,
height: number,
patch: TextTemplateNinePatch,
bitmapSize: { height: number; width: number } = { height: patch.sourceHeight, width: patch.sourceWidth },
): NinePatchSlice[] {
const [left, right] = destinationEdges(width, patch.left, patch.right);
const [top, bottom] = destinationEdges(height, patch.top, patch.bottom);
const sourceColumns = [0, patch.left, patch.sourceWidth - patch.right, patch.sourceWidth];
const sourceRows = [0, patch.top, patch.sourceHeight - patch.bottom, patch.sourceHeight];
const sourceScaleX = bitmapSize.width / patch.sourceWidth;
const sourceScaleY = bitmapSize.height / patch.sourceHeight;
const sourceColumns = [0, patch.left * sourceScaleX, bitmapSize.width - patch.right * sourceScaleX, bitmapSize.width];
const sourceRows = [0, patch.top * sourceScaleY, bitmapSize.height - patch.bottom * sourceScaleY, bitmapSize.height];
const destinationColumns = [0, left, width - right, width];
const destinationRows = [0, top, height - bottom, height];
const slices: NinePatchSlice[] = [];
@@ -139,7 +147,12 @@ function drawTemplateImageLayer(
const left = -layer.anchorX * layer.width;
const top = -layer.anchorY * layer.height;
if (layer.ninePatch) {
for (const slice of ninePatchSlices(layer.width, layer.height, layer.ninePatch)) {
for (const slice of ninePatchSlices(
layer.width,
layer.height,
layer.ninePatch,
{ height: image.naturalHeight, width: image.naturalWidth },
)) {
context.drawImage(
image, slice.source.x, slice.source.y, slice.source.width, slice.source.height,
left + slice.destination.x, top + slice.destination.y, slice.destination.width, slice.destination.height,
@@ -189,6 +202,36 @@ function drawTemplateParticles(
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));
}
function drawTemplateTextLayer(
context: CanvasRenderingContext2D,
element: CanvasState["elements"][number],
@@ -200,7 +243,7 @@ function drawTemplateTextLayer(
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 align = (editable ? styleValue(element, "text_align", layer.align) : layer.align) as TextTemplateTextLayer["align"];
const lines = (editable ? element.content ?? layer.text : layer.text).split("\n");
context.save();
context.globalAlpha *= layer.alpha;
@@ -212,13 +255,24 @@ function drawTemplateTextLayer(
context.textAlign = align;
context.textBaseline = "middle";
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 textWidth = boundedTextTemplateWidth(Math.max(1, ...widths), layer.width);
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)) {
const alpha = context.globalAlpha;
context.globalAlpha = alpha * styleValue(element, "background_opacity", 1);
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.shadowColor = layer.shadowColor;
@@ -231,17 +285,12 @@ function drawTemplateTextLayer(
: 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 centerY = (0.5 - layer.anchorY) * textHeight;
const firstY = centerY - ((lines.length - 1) * fontSize * lineHeight) / 2;
const anchorX = align === "left" ? -layer.anchorX * textWidth
: align === "right" ? (1 - layer.anchorX) * textWidth
: (0.5 - layer.anchorX) * textWidth;
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) {
context.strokeText(line, anchorX, y);
context.strokeText(line, layout.x, y, layer.width);
}
context.fillText(line, anchorX, y);
context.fillText(line, layout.x, y, layer.width);
});
context.restore();
}
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -8,6 +8,7 @@ type CanvasElement = CanvasState["elements"][number];
export type TextTemplateCategory = "flower" | "simple" | "tag" | "title";
export type TextAlign = "center" | "left" | "right";
export type TextVerticalAlign = "bottom" | "middle" | "top";
export interface TextTemplateImageLayer {
alpha: number;
@@ -70,6 +71,7 @@ export interface TextTemplateTextLayer {
strokeColor: string;
strokeWidth: number;
text: string;
verticalAlign: TextVerticalAlign;
width: number;
x: number;
y: number;
@@ -157,7 +159,7 @@ 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;
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;
stroke_width: number; text: string; vertical_align: TextVerticalAlign; width: number; x: number; y: number;
}
function renderModel(item: (typeof complexAssetCatalog.text_templates)[number]): TextTemplateRenderModel {
@@ -180,7 +182,7 @@ function renderModel(item: (typeof complexAssetCatalog.text_templates)[number]):
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,
verticalAlign: layer.vertical_align, width: layer.width, x: layer.x, y: layer.y,
};
}),
};
+17 -13
View File
@@ -491,15 +491,14 @@ function prefabLayers(prefab, input) {
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 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;
layers.push({
align: Number(style.alignment ?? 0) === 2 ? "right" : Number(style.alignment ?? 0) === 1 ? "left" : "center",
...(transform.alpha === 1 ? {} : { alpha: transform.alpha }),
...(transform.anchorX === 0.5 ? {} : { anchor_x: transform.anchorX }),
...(transform.anchorY === 0.5 ? {} : { anchor_y: transform.anchorY }),
...(transform.anchorY === 0.5 ? {} : { anchor_y: 1 - transform.anchorY }),
fill_color: colorHex(style.color),
fill_pattern_asset_id: firstMaterialTextureAssetId(textRenderer, input),
font_file: typeof fontUuid === "string" ? input.manifestMappings.get(fontUuid) : undefined,
@@ -508,7 +507,7 @@ function prefabLayers(prefab, input) {
letter_spacing: Number(style.characterSpacing ?? 1),
line_height: Number(style.lineSpacing ?? 1),
order: order++,
rotation: transform.rotation,
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)),
@@ -519,9 +518,12 @@ function prefabLayers(prefab, input) {
stroke_width: Math.max(0, Number(outline?.outlineSize ?? 0)),
text: String(textMesh.m_text ?? ""),
type: "text",
vertical_align: Number(style.vAlignment ?? 1) === 0 ? "top"
: Number(style.vAlignment ?? 1) === 2 ? "bottom"
: "middle",
width: Math.max(1, Number(object.m_contentSize?.width ?? 1) * Math.abs(scaleX)),
x: transform.x,
y: transform.y,
y: -transform.y,
});
for (const underline of localUnderlines.filter((item) => item?.p1?.enable !== false && item?.p0 === object.m_Name)) {
const config = underline.p1?.exportParams ?? {};
@@ -540,13 +542,13 @@ function prefabLayers(prefab, input) {
asset_id: assetId,
height: targetHeight,
order: order++,
rotation: transform.rotation,
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
y: -transform.y + Number(object.m_contentSize?.height ?? 48) * Math.abs(scaleY) / 2
+ Number(config.relativeDistance ?? 0) + targetHeight / 2,
});
}
@@ -559,7 +561,7 @@ function prefabLayers(prefab, input) {
layers.push({
alpha: transform.alpha * Math.max(0, Math.min(1, Number(particleComponent.m_AlphaIdensity ?? 1))),
anchor_x: transform.anchorX,
anchor_y: transform.anchorY,
anchor_y: 1 - transform.anchorY,
asset_id: assetId,
atlas_columns: Math.max(1, Number(particleComponent.m_altasUcount ?? 1)),
atlas_rows: Math.max(1, Number(particleComponent.m_altasVcount ?? 1)),
@@ -571,13 +573,13 @@ function prefabLayers(prefab, input) {
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,
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,
y: -transform.y,
});
}
}
@@ -606,17 +608,17 @@ function prefabLayers(prefab, input) {
layers.push({
...(transform.alpha === 1 ? {} : { alpha: transform.alpha }),
...(transform.anchorX === 0.5 ? {} : { anchor_x: transform.anchorX }),
...(transform.anchorY === 0.5 ? {} : { anchor_y: transform.anchorY }),
...(transform.anchorY === 0.5 ? {} : { anchor_y: 1 - transform.anchorY }),
asset_id: assetId,
height: Math.max(1, Number(object.m_contentSize?.height ?? 1) * Math.abs(scaleY)),
order: order++,
rotation: transform.rotation,
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,
y: -transform.y,
...(ninePatch ? { nine_patch: ninePatch } : {}),
});
} else {
@@ -686,6 +688,7 @@ function normalizeModel(layers, defaultValue, fontResources) {
layer.height = Number((layer.height * normalization).toFixed(3));
if (layer.type === "text") {
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.shadow_blur = Number((layer.shadow_blur * normalization).toFixed(3));
layer.shadow_offset_x = Number((layer.shadow_offset_x * normalization).toFixed(3));
@@ -763,6 +766,7 @@ export function compileTextTemplateAssets({ templateDirectory, templateId }) {
stroke_width: 0,
text: value,
type: "text",
vertical_align: "middle",
width: Math.max(96, Array.from(value).length * 52),
x: 0,
y: 0,
+62 -2
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 { ninePatchSlices } from "../../apps/web/src/editor-stage.js";
import { boundedTextTemplateWidth, ninePatchSlices, textTemplateBoxLayout } from "../../apps/web/src/editor-stage.js";
import {
P0A_COLOR_CARD_IDS,
P0A_DYNAMIC_STICKER_IDS,
@@ -94,10 +94,52 @@ describe("POSTV1-ASSET-ALL-16 complete asset catalog", () => {
expect.objectContaining({ anchor_x: 0 }),
expect.objectContaining({ anchor_x: 1 }),
]));
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[0]).toMatchObject({ vertical_align: "middle" });
expect(byId.get("H005")!.render_model.text_layers[1]).toMatchObject({ vertical_align: "top" });
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 heading041Text = heading041.render_model.text_layers[0]!;
const heading041Underline = heading041.render_model.image_layers.find((layer) => layer.asset_id === "TEXT-IMAGE-H041-004")!;
expect(heading041Text.stroke_width).toBe(0);
expect(heading041Text.letter_spacing).toBeCloseTo(2.714, 3);
expect(heading041Underline.y).toBeGreaterThan(heading041Text.y);
expect(byId.get("FLOWER024")!.render_model.image_layers[0]).toMatchObject({ alpha: 0.4000000059604645 });
});
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", () => {
const slices = ninePatchSlices(231, 63, {
bottom: 16, left: 53, right: 25, sourceHeight: 63, sourceWidth: 159, top: 12,
@@ -108,6 +150,24 @@ describe("POSTV1-ASSET-ALL-16 complete asset catalog", () => {
expect(slices[8]).toEqual({ destination: { height: 16, width: 25, x: 206, y: 47 }, source: { height: 16, width: 25, x: 134, y: 47 } });
});
it("scales nine-patch source slices to the deployed bitmap without changing layout edges", () => {
const slices = ninePatchSlices(231, 63, {
bottom: 16, left: 53, right: 25, sourceHeight: 63, sourceWidth: 159, top: 12,
}, { height: 199, width: 509 });
expect(slices[0]!.destination).toEqual({ height: 12, width: 53, x: 0, y: 0 });
expect(slices[4]!.destination).toEqual({ height: 35, width: 153, x: 53, y: 12 });
expect(slices[8]!.destination).toEqual({ height: 16, width: 25, x: 206, y: 47 });
expect(slices[4]!.source.x).toBeCloseTo(169.667, 3);
expect(slices[4]!.source.y).toBeCloseTo(37.905, 3);
expect(slices[4]!.source.width).toBeCloseTo(259.302, 3);
expect(slices[4]!.source.height).toBeCloseTo(110.556, 3);
expect(slices[8]!.source.x).toBeCloseTo(428.969, 3);
expect(slices[8]!.source.y).toBeCloseTo(148.46, 2);
expect(slices[8]!.source.width).toBeCloseTo(80.031, 3);
expect(slices[8]!.source.height).toBeCloseTo(50.54, 2);
});
it("exposes every generated definition to the editor", () => {
expect(P0A_TEXT_TEMPLATES).toHaveLength(332);
expect(P0A_TEXT_TEMPLATES.every((item) => item.available && item.fontUrl)).toBe(true);