Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a510200cb2 | ||
|
|
d07a41752d | ||
|
|
071c622f35 | ||
|
|
69f447b614 | ||
|
|
210554628d | ||
|
|
3a018dfa37 | ||
|
|
c6e893515c | ||
|
|
a173222d74 | ||
|
|
f244920a61 | ||
|
|
5e8a491f2a | ||
|
|
ff115f70ca |
@@ -400,6 +400,7 @@
|
|||||||
.editor-template-grid strong { overflow: hidden; font-family: Consolas, monospace; font-size: 10px; text-overflow: ellipsis; }
|
.editor-template-grid strong { overflow: hidden; font-family: Consolas, monospace; font-size: 10px; text-overflow: ellipsis; }
|
||||||
.editor-template-grid small { color: #8f1d14; font-size: 9px; }
|
.editor-template-grid small { color: #8f1d14; font-size: 9px; }
|
||||||
.editor-template-preview { width: 100%; height: 44px; object-fit: contain; border: 1px solid #111111; background: #30343b; }
|
.editor-template-preview { width: 100%; height: 44px; object-fit: contain; border: 1px solid #111111; background: #30343b; }
|
||||||
|
.editor-template-live-preview { display: grid; width: 100%; height: 44px; place-items: center; overflow: hidden; border: 1px solid #111111; background: #30343b; white-space: nowrap; }
|
||||||
.editor-template-mark { display: grid; width: 100%; height: 44px; place-items: center; border: 1px solid #111111; background: #f2f400; font-size: 18px; font-weight: 800; }
|
.editor-template-mark { display: grid; width: 100%; height: 44px; place-items: center; border: 1px solid #111111; background: #f2f400; font-size: 18px; font-weight: 800; }
|
||||||
.editor-template-mark.title { background: #111111; color: #ffffff; }
|
.editor-template-mark.title { background: #111111; color: #ffffff; }
|
||||||
.editor-template-mark.tag { background: #dbeafe; }
|
.editor-template-mark.tag { background: #dbeafe; }
|
||||||
|
|||||||
@@ -946,6 +946,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
fontStatuses={fontStatuses}
|
fontStatuses={fontStatuses}
|
||||||
onAdd={(template) => { void addTextTemplate(template); }}
|
onAdd={(template) => { void addTextTemplate(template); }}
|
||||||
onCategory={setTemplateCategory}
|
onCategory={setTemplateCategory}
|
||||||
|
onEnsure={(template) => { void ensureTemplateFonts(template); }}
|
||||||
onQuery={setTemplateQuery}
|
onQuery={setTemplateQuery}
|
||||||
onRetry={() => { void retryTextFonts(); }}
|
onRetry={() => { void retryTextFonts(); }}
|
||||||
query={templateQuery}
|
query={templateQuery}
|
||||||
|
|||||||
+372
-15
@@ -13,8 +13,12 @@ import {
|
|||||||
textTemplateFontOptions,
|
textTemplateFontOptions,
|
||||||
textTemplateImageUrls,
|
textTemplateImageUrls,
|
||||||
type TextTemplateImageLayer,
|
type TextTemplateImageLayer,
|
||||||
|
type TextTemplateFillTextureLayout,
|
||||||
|
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";
|
||||||
|
|
||||||
@@ -68,6 +72,47 @@ interface TextPixelGeometry {
|
|||||||
width: number;
|
width: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface NinePatchRectangle { height: number; width: number; x: number; y: number }
|
||||||
|
export interface NinePatchSlice { destination: NinePatchRectangle; source: NinePatchRectangle }
|
||||||
|
|
||||||
|
function destinationEdges(size: number, first: number, last: number) {
|
||||||
|
const fixed = first + last;
|
||||||
|
if (fixed <= size || fixed === 0) return [first, last] as const;
|
||||||
|
const ratio = size / fixed;
|
||||||
|
return [first * ratio, last * ratio] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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[] = [];
|
||||||
|
for (let row = 0; row < 3; row += 1) {
|
||||||
|
for (let column = 0; column < 3; column += 1) {
|
||||||
|
const source = {
|
||||||
|
height: sourceRows[row + 1]! - sourceRows[row]!, width: sourceColumns[column + 1]! - sourceColumns[column]!,
|
||||||
|
x: sourceColumns[column]!, y: sourceRows[row]!,
|
||||||
|
};
|
||||||
|
const destination = {
|
||||||
|
height: destinationRows[row + 1]! - destinationRows[row]!, width: destinationColumns[column + 1]! - destinationColumns[column]!,
|
||||||
|
x: destinationColumns[column]!, y: destinationRows[row]!,
|
||||||
|
};
|
||||||
|
if (source.width > 0 && source.height > 0 && destination.width > 0 && destination.height > 0) slices.push({ destination, source });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return slices;
|
||||||
|
}
|
||||||
|
|
||||||
function prepareTextContext(context: CanvasRenderingContext2D, element: CanvasState["elements"][number], fontId: string) {
|
function prepareTextContext(context: CanvasRenderingContext2D, element: CanvasState["elements"][number], fontId: string) {
|
||||||
const fontSize = element.font_size ?? 48;
|
const fontSize = element.font_size ?? 48;
|
||||||
const letterSpacing = styleValue(element, "letter_spacing", 1);
|
const letterSpacing = styleValue(element, "letter_spacing", 1);
|
||||||
@@ -97,10 +142,25 @@ function drawTemplateImageLayer(
|
|||||||
const image = resourceImages[layer.assetId];
|
const image = resourceImages[layer.assetId];
|
||||||
if (!image) return;
|
if (!image) return;
|
||||||
context.save();
|
context.save();
|
||||||
|
context.globalAlpha *= layer.alpha;
|
||||||
context.translate(layer.x, layer.y);
|
context.translate(layer.x, layer.y);
|
||||||
context.rotate(layer.rotation * Math.PI / 180);
|
context.rotate(layer.rotation * Math.PI / 180);
|
||||||
context.scale(layer.scaleX, layer.scaleY);
|
context.scale(layer.scaleX, layer.scaleY);
|
||||||
context.drawImage(image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
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,
|
||||||
|
{ 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else context.drawImage(image, left, top, layer.width, layer.height);
|
||||||
context.restore();
|
context.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,6 +204,247 @@ 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 drawStyledTemplateLine(
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
line: string,
|
||||||
|
align: TextTemplateTextLayer["align"],
|
||||||
|
letterSpacing: number,
|
||||||
|
stroke: boolean,
|
||||||
|
patternImage: HTMLImageElement | undefined,
|
||||||
|
textureLayout: TextTemplateFillTextureLayout | undefined,
|
||||||
|
glyphColors: readonly string[] | undefined,
|
||||||
|
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;
|
||||||
|
const glyphColor = glyphColors?.[textureGlyphIndex];
|
||||||
|
if (glyphColor) {
|
||||||
|
context.fillStyle = glyphColor;
|
||||||
|
} else if (patternImage && textureLayout) {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
textureGlyphIndex += 1;
|
||||||
|
}
|
||||||
|
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],
|
||||||
@@ -155,24 +456,37 @@ 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.translate(layer.x, layer.y);
|
context.translate(layer.x, layer.y);
|
||||||
context.rotate(layer.rotation * Math.PI / 180);
|
context.rotate(layer.rotation * Math.PI / 180);
|
||||||
context.scale(layer.scaleX, layer.scaleY);
|
context.scale(layer.scaleX, layer.scaleY);
|
||||||
|
context.transform(1, Math.tan(-layer.skewX * Math.PI / 180), Math.tan(-layer.skewY * Math.PI / 180), 1, 0, 0);
|
||||||
context.font = `${fontSize}px "${fontFamilyName(fontId)}"`;
|
context.font = `${fontSize}px "${fontFamilyName(fontId)}"`;
|
||||||
(context as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = `${letterSpacing}px`;
|
(context as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = `${letterSpacing}px`;
|
||||||
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;
|
||||||
@@ -181,18 +495,61 @@ 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
|
const gradient = !fillOverridden && layer.fillGradient
|
||||||
: editable ? styleValue(element, "fill_color", layer.fillColor) : layer.fillColor;
|
? context.createLinearGradient(0, -layer.anchorY * layer.height, 0, (1 - layer.anchorY) * layer.height)
|
||||||
|
: undefined;
|
||||||
|
if (gradient && layer.fillGradient) {
|
||||||
|
gradient.addColorStop(0, layer.fillGradient.top);
|
||||||
|
gradient.addColorStop(1, layer.fillGradient.bottom);
|
||||||
|
}
|
||||||
|
const baseFill = editable ? styleValue(element, "fill_color", layer.fillColor) : layer.fillColor;
|
||||||
|
context.fillStyle = patternImage && !layer.fillTextureLayout ? context.createPattern(patternImage, "repeat") ?? baseFill
|
||||||
|
: gradient ?? baseFill;
|
||||||
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 firstY = -((lines.length - 1) * fontSize * lineHeight) / 2;
|
const stroke = (editable ? styleValue(element, "stroke_enabled", layer.strokeWidth > 0) : layer.strokeWidth > 0) && context.lineWidth > 0;
|
||||||
const anchorX = align === "left" ? -textWidth / 2 : align === "right" ? textWidth / 2 : 0;
|
if (layer.textPath) {
|
||||||
|
(context as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = "0px";
|
||||||
|
const fallbackFill = context.fillStyle;
|
||||||
|
drawTextOnTemplatePath(context, lines.join(" "), layer.textPath, letterSpacing, stroke,
|
||||||
|
(patternImage && layer.fillTextureLayout) || (!fillOverridden && layer.glyphColors)
|
||||||
|
? (character, glyphIndex, width) => {
|
||||||
|
context.fillStyle = fallbackFill;
|
||||||
|
const glyphColor = layer.glyphColors?.[glyphIndex];
|
||||||
|
if (glyphColor) {
|
||||||
|
context.fillStyle = glyphColor;
|
||||||
|
} else if (patternImage && layer.fillTextureLayout) {
|
||||||
|
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) || (!fillOverridden && layer.glyphColors)) {
|
||||||
|
(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 = drawStyledTemplateLine(context, line, align, letterSpacing, stroke,
|
||||||
|
patternImage, layer.fillTextureLayout, fillOverridden ? undefined : layer.glyphColors, 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();
|
||||||
}
|
}
|
||||||
|
|||||||
+5912
-1939
File diff suppressed because it is too large
Load Diff
@@ -8,8 +8,12 @@ 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;
|
||||||
|
anchorX: number;
|
||||||
|
anchorY: number;
|
||||||
assetId: string;
|
assetId: string;
|
||||||
height: number;
|
height: number;
|
||||||
order: number;
|
order: number;
|
||||||
@@ -19,6 +23,16 @@ export interface TextTemplateImageLayer {
|
|||||||
width: number;
|
width: number;
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
|
ninePatch?: TextTemplateNinePatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextTemplateNinePatch {
|
||||||
|
bottom: number;
|
||||||
|
left: number;
|
||||||
|
right: number;
|
||||||
|
sourceHeight: number;
|
||||||
|
sourceWidth: number;
|
||||||
|
top: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TextTemplateParticleLayer extends TextTemplateImageLayer {
|
export interface TextTemplateParticleLayer extends TextTemplateImageLayer {
|
||||||
@@ -34,13 +48,20 @@ export interface TextTemplateParticleLayer extends TextTemplateImageLayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface TextTemplateTextLayer {
|
export interface TextTemplateTextLayer {
|
||||||
|
alpha: number;
|
||||||
align: TextAlign;
|
align: TextAlign;
|
||||||
|
anchorX: number;
|
||||||
|
anchorY: number;
|
||||||
|
contentLinked: boolean;
|
||||||
editable: boolean;
|
editable: boolean;
|
||||||
fillColor: string;
|
fillColor: string;
|
||||||
|
fillGradient?: { bottom: string; top: string };
|
||||||
fillPatternAssetId?: string;
|
fillPatternAssetId?: string;
|
||||||
|
fillTextureLayout?: TextTemplateFillTextureLayout;
|
||||||
fontId: string;
|
fontId: string;
|
||||||
fontSize: number;
|
fontSize: number;
|
||||||
height: number;
|
height: number;
|
||||||
|
glyphColors?: readonly string[];
|
||||||
letterSpacing: number;
|
letterSpacing: number;
|
||||||
lineHeight: number;
|
lineHeight: number;
|
||||||
order: number;
|
order: number;
|
||||||
@@ -51,14 +72,37 @@ export interface TextTemplateTextLayer {
|
|||||||
shadowColor: string;
|
shadowColor: string;
|
||||||
shadowOffsetX: number;
|
shadowOffsetX: number;
|
||||||
shadowOffsetY: number;
|
shadowOffsetY: number;
|
||||||
|
skewX: number;
|
||||||
|
skewY: number;
|
||||||
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[];
|
||||||
@@ -122,19 +166,31 @@ const defaults = {
|
|||||||
const textCatalogById = new Map(complexAssetCatalog.text_templates.map((item) => [item.template_id, item]));
|
const textCatalogById = new Map(complexAssetCatalog.text_templates.map((item) => [item.template_id, item]));
|
||||||
|
|
||||||
function imageLayer(layer: {
|
function imageLayer(layer: {
|
||||||
asset_id: string; height: number; order: number; rotation: number; scale_x: number; scale_y: number; width: number; x: number; y: number;
|
alpha?: number; anchor_x?: number; anchor_y?: number; asset_id: string; height: number; nine_patch?: {
|
||||||
|
bottom: number; left: number; right: number; source_height: number; source_width: number; top: number;
|
||||||
|
}; order: number; rotation: number; scale_x: number; scale_y: number; width: number; x: number; y: number;
|
||||||
}): TextTemplateImageLayer {
|
}): TextTemplateImageLayer {
|
||||||
return {
|
return {
|
||||||
|
alpha: layer.alpha ?? 1, anchorX: layer.anchor_x ?? 0.5, anchorY: layer.anchor_y ?? 0.5,
|
||||||
assetId: layer.asset_id, height: layer.height, order: layer.order, rotation: layer.rotation,
|
assetId: layer.asset_id, height: layer.height, order: layer.order, rotation: layer.rotation,
|
||||||
scaleX: layer.scale_x, scaleY: layer.scale_y, width: layer.width, x: layer.x, y: layer.y,
|
scaleX: layer.scale_x, scaleY: layer.scale_y, width: layer.width, x: layer.x, y: layer.y,
|
||||||
|
...(layer.nine_patch ? { ninePatch: {
|
||||||
|
bottom: layer.nine_patch.bottom, left: layer.nine_patch.left, right: layer.nine_patch.right,
|
||||||
|
sourceHeight: layer.nine_patch.source_height, sourceWidth: layer.nine_patch.source_width, top: layer.nine_patch.top,
|
||||||
|
} } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RawTextLayer {
|
interface RawTextLayer {
|
||||||
align: string; 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;
|
||||||
height: number; letter_spacing: number; line_height: number; order: number; rotation: number; scale_x: number; scale_y: number;
|
fill_gradient?: { bottom: string; top: string }; fill_pattern_asset_id?: string;
|
||||||
|
fill_texture_layout?: { columns: number; id_list: number[]; rows: number }; font_id: string; font_size: number;
|
||||||
|
glyph_colors?: string[]; 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;
|
skew_x?: number; skew_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 {
|
||||||
@@ -149,14 +205,28 @@ function renderModel(item: (typeof complexAssetCatalog.text_templates)[number]):
|
|||||||
textLayers: item.render_model.text_layers.map((value) => {
|
textLayers: item.render_model.text_layers.map((value) => {
|
||||||
const layer = value as unknown as RawTextLayer;
|
const layer = value as unknown as RawTextLayer;
|
||||||
return {
|
return {
|
||||||
align: layer.align as TextAlign, editable: layer.editable, fillColor: layer.fill_color,
|
alpha: layer.alpha ?? 1, align: layer.align as TextAlign, anchorX: layer.anchor_x ?? 0.5, anchorY: layer.anchor_y ?? 0.5,
|
||||||
|
contentLinked: layer.content_linked ?? layer.editable, editable: layer.editable, fillColor: layer.fill_color,
|
||||||
|
...(layer.fill_gradient ? { fillGradient: layer.fill_gradient } : {}),
|
||||||
...(layer.fill_pattern_asset_id ? { fillPatternAssetId: layer.fill_pattern_asset_id } : {}),
|
...(layer.fill_pattern_asset_id ? { fillPatternAssetId: layer.fill_pattern_asset_id } : {}),
|
||||||
fontId: layer.font_id, fontSize: layer.font_size, height: layer.height, letterSpacing: layer.letter_spacing,
|
...(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, ...(layer.glyph_colors ? { glyphColors: layer.glyph_colors } : {}),
|
||||||
|
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,
|
||||||
|
skewX: layer.skew_x ?? 0, skewY: layer.skew_y ?? 0,
|
||||||
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,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,44 @@
|
|||||||
import type { ArchivedFontStatus } from "./text-font-loader.js";
|
import { useEffect, useRef, type CSSProperties } from "react";
|
||||||
|
import { fontFamilyName, type ArchivedFontStatus } from "./text-font-loader.js";
|
||||||
import { searchTextTemplates, type TextTemplateCategory, type TextTemplateDefinition } from "./text-assets.js";
|
import { searchTextTemplates, type TextTemplateCategory, type TextTemplateDefinition } from "./text-assets.js";
|
||||||
|
|
||||||
const categories: Array<{ id?: TextTemplateCategory; label: string }> = [
|
const categories: Array<{ id?: TextTemplateCategory; label: string }> = [
|
||||||
{ label: "全部" }, { id: "flower", label: "花字" }, { id: "title", label: "标题" }, { id: "tag", label: "标签" }, { id: "simple", label: "简约" },
|
{ label: "全部" }, { id: "flower", label: "花字" }, { id: "title", label: "标题" }, { id: "tag", label: "标签" }, { id: "simple", label: "简约" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function LiveTemplatePreview(props: { onEnsure: () => void; template: TextTemplateDefinition }) {
|
||||||
|
const previewRef = useRef<HTMLSpanElement>(null);
|
||||||
|
const layer = props.template.renderModel.textLayers.find((item) => item.editable) ?? props.template.renderModel.textLayers[0]!;
|
||||||
|
useEffect(() => {
|
||||||
|
const node = previewRef.current;
|
||||||
|
if (!node || typeof IntersectionObserver === "undefined") {
|
||||||
|
props.onEnsure();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const observer = new IntersectionObserver((entries) => {
|
||||||
|
if (!entries.some((entry) => entry.isIntersecting)) return;
|
||||||
|
observer.disconnect();
|
||||||
|
props.onEnsure();
|
||||||
|
});
|
||||||
|
observer.observe(node);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [props.onEnsure, props.template.templateId]);
|
||||||
|
const scale = Math.min(1, 38 / Math.max(1, layer.height), 112 / Math.max(1, layer.width));
|
||||||
|
const style: CSSProperties = {
|
||||||
|
color: layer.fillColor,
|
||||||
|
fontFamily: `"${fontFamilyName(layer.fontId)}"`,
|
||||||
|
fontSize: `${layer.fontSize * scale}px`,
|
||||||
|
letterSpacing: `${layer.letterSpacing * scale}px`,
|
||||||
|
lineHeight: layer.lineHeight,
|
||||||
|
textShadow: layer.shadowBlur > 0 || layer.shadowOffsetX !== 0 || layer.shadowOffsetY !== 0
|
||||||
|
? `${layer.shadowOffsetX * scale}px ${layer.shadowOffsetY * scale}px ${layer.shadowBlur * scale}px ${layer.shadowColor}`
|
||||||
|
: undefined,
|
||||||
|
transform: `rotate(${layer.rotation}deg) scale(${layer.scaleX}, ${layer.scaleY})`,
|
||||||
|
WebkitTextStroke: layer.strokeWidth > 0 ? `${layer.strokeWidth * scale}px ${layer.strokeColor}` : undefined,
|
||||||
|
};
|
||||||
|
return <span className="editor-template-live-preview" ref={previewRef} style={style}>{layer.text || props.template.defaultText}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
export function TextTemplatePanel(props: {
|
export function TextTemplatePanel(props: {
|
||||||
canAdd: boolean;
|
canAdd: boolean;
|
||||||
category?: TextTemplateCategory;
|
category?: TextTemplateCategory;
|
||||||
@@ -12,6 +46,7 @@ export function TextTemplatePanel(props: {
|
|||||||
onAdd: (template: TextTemplateDefinition) => void;
|
onAdd: (template: TextTemplateDefinition) => void;
|
||||||
onCategory: (category?: TextTemplateCategory) => void;
|
onCategory: (category?: TextTemplateCategory) => void;
|
||||||
onQuery: (query: string) => void;
|
onQuery: (query: string) => void;
|
||||||
|
onEnsure: (template: TextTemplateDefinition) => void;
|
||||||
onRetry: () => void;
|
onRetry: () => void;
|
||||||
query: string;
|
query: string;
|
||||||
recentIds: readonly string[];
|
recentIds: readonly string[];
|
||||||
@@ -36,7 +71,7 @@ export function TextTemplatePanel(props: {
|
|||||||
return <button aria-label={`${template.templateId} ${template.displayName}${unavailable ? " 素材暂不可用" : retryable ? " 字体待重试" : ""}`} disabled={!props.canAdd || unavailable || status === "loading"} key={template.templateId} onClick={() => props.onAdd(template)} type="button">
|
return <button aria-label={`${template.templateId} ${template.displayName}${unavailable ? " 素材暂不可用" : retryable ? " 字体待重试" : ""}`} disabled={!props.canAdd || unavailable || status === "loading"} key={template.templateId} onClick={() => props.onAdd(template)} type="button">
|
||||||
{template.previewUrl
|
{template.previewUrl
|
||||||
? <img alt="" className="editor-template-preview" decoding="async" loading="lazy" src={template.previewUrl} />
|
? <img alt="" className="editor-template-preview" decoding="async" loading="lazy" src={template.previewUrl} />
|
||||||
: <span className={`editor-template-mark ${template.category}`}>{template.displayName.slice(0, 2)}</span>}
|
: <LiveTemplatePreview onEnsure={() => props.onEnsure(template)} template={template} />}
|
||||||
<strong>{template.templateId}</strong>
|
<strong>{template.templateId}</strong>
|
||||||
<span>{template.displayName}</span>
|
<span>{template.displayName}</span>
|
||||||
{unavailable ? <small>素材暂不可用</small> : retryable ? <small>点击重试原版字体</small> : status === "loading" ? <small>正在加载字体</small> : null}
|
{unavailable ? <small>素材暂不可用</small> : retryable ? <small>点击重试原版字体</small> : status === "loading" ? <small>正在加载字体</small> : null}
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ function copyApplication(source, destination, runtimeDependencies) {
|
|||||||
function buildArtifacts(stagingRoot) {
|
function buildArtifacts(stagingRoot) {
|
||||||
debug("build workspace artifacts");
|
debug("build workspace artifacts");
|
||||||
run("pnpm", ["build:workspace-packages"]);
|
run("pnpm", ["build:workspace-packages"]);
|
||||||
|
removeTree(join(repositoryRoot, "apps", "web", "dist"));
|
||||||
run("pnpm", ["--filter", "@dada/web", "build"]);
|
run("pnpm", ["--filter", "@dada/web", "build"]);
|
||||||
run("pnpm", ["--filter", "@dada/api", "build"]);
|
run("pnpm", ["--filter", "@dada/api", "build"]);
|
||||||
run("pnpm", ["--filter", "@dada/worker", "build"]);
|
run("pnpm", ["--filter", "@dada/worker", "build"]);
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
@@ -280,6 +281,7 @@ function manifestFileMap(packageFiles) {
|
|||||||
filesByAsciiIdentity.set(asciiIdentity, asciiValues);
|
filesByAsciiIdentity.set(asciiIdentity, asciiValues);
|
||||||
}
|
}
|
||||||
const mappings = new Map();
|
const mappings = new Map();
|
||||||
|
const spriteDefinitions = new Map();
|
||||||
const manifestEntries = [];
|
const manifestEntries = [];
|
||||||
for (const path of packageFiles.filter((candidate) => extname(candidate).toLowerCase() === ".manifest")) {
|
for (const path of packageFiles.filter((candidate) => extname(candidate).toLowerCase() === ".manifest")) {
|
||||||
let manifest;
|
let manifest;
|
||||||
@@ -299,7 +301,10 @@ function manifestFileMap(packageFiles) {
|
|||||||
const resolved = existsSync(candidate) ? candidate
|
const resolved = existsSync(candidate) ? candidate
|
||||||
: filesByName.get(normalizedName)?.[0]
|
: filesByName.get(normalizedName)?.[0]
|
||||||
?? (asciiCandidates.length === 1 ? asciiCandidates[0] : undefined);
|
?? (asciiCandidates.length === 1 ? asciiCandidates[0] : undefined);
|
||||||
if (resolved) mappings.set(uuid, resolved);
|
if (resolved) {
|
||||||
|
mappings.set(uuid, resolved);
|
||||||
|
if (assetFileType(resolved) === "sprite") spriteDefinitions.set(uuid, resolved);
|
||||||
|
}
|
||||||
else mappings.set(uuid, fileName);
|
else mappings.set(uuid, fileName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -333,9 +338,13 @@ function manifestFileMap(packageFiles) {
|
|||||||
const imagePath = imageEntry ? actualImagesByUuid.get(imageEntry.uuid) ?? mappings.get(imageEntry.uuid)
|
const imagePath = imageEntry ? actualImagesByUuid.get(imageEntry.uuid) ?? mappings.get(imageEntry.uuid)
|
||||||
: inferredImageUuid ? actualImagesByUuid.get(inferredImageUuid) ?? mappings.get(inferredImageUuid)
|
: inferredImageUuid ? actualImagesByUuid.get(inferredImageUuid) ?? mappings.get(inferredImageUuid)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const spriteDefinition = matchingActualSprites.length === 1 ? matchingActualSprites[0].path : mappings.get(spriteEntry.uuid);
|
||||||
|
if (typeof spriteDefinition === "string" && existsSync(spriteDefinition) && assetFileType(spriteDefinition) === "sprite") {
|
||||||
|
spriteDefinitions.set(spriteEntry.uuid, spriteDefinition);
|
||||||
|
}
|
||||||
if (typeof imagePath === "string" && existsSync(imagePath)) mappings.set(spriteEntry.uuid, imagePath);
|
if (typeof imagePath === "string" && existsSync(imagePath)) mappings.set(spriteEntry.uuid, imagePath);
|
||||||
}
|
}
|
||||||
return mappings;
|
return { mappings, spriteDefinitions };
|
||||||
}
|
}
|
||||||
|
|
||||||
function colorHex(value, fallback = "#111111") {
|
function colorHex(value, fallback = "#111111") {
|
||||||
@@ -344,12 +353,104 @@ function colorHex(value, fallback = "#111111") {
|
|||||||
return `#${channel("r")}${channel("g")}${channel("b")}`.toUpperCase();
|
return `#${channel("r")}${channel("g")}${channel("b")}`.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function textFillGradient(style) {
|
||||||
|
const colors = style?.vertexColor;
|
||||||
|
const topLeft = colors?.vertexColor0;
|
||||||
|
const topRight = colors?.vertexColor1;
|
||||||
|
const bottomLeft = colors?.vertexColor2;
|
||||||
|
const bottomRight = colors?.vertexColor3;
|
||||||
|
if (![topLeft, topRight, bottomLeft, bottomRight].every((color) => color && typeof color === "object")) return undefined;
|
||||||
|
const top = colorHex(topLeft);
|
||||||
|
const bottom = colorHex(bottomLeft);
|
||||||
|
return top === colorHex(topRight) && bottom === colorHex(bottomRight) && top !== bottom ? { bottom, top } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function textGlyphColors(textMesh) {
|
||||||
|
const changes = textMesh?.m_textFormatController?.m_textFormatChange;
|
||||||
|
if (!Array.isArray(changes)) return undefined;
|
||||||
|
const colors = changes.filter((change) => (Number(change?.flags ?? 0) & 4) !== 0 && Number(change?.format?.color?.a ?? 0) > 0)
|
||||||
|
.map((change) => ({ color: colorHex(change.format.color), position: Number(change.startPosition) }))
|
||||||
|
.toSorted((left, right) => left.position - right.position);
|
||||||
|
if (colors.length === 0 || colors.some((entry, index) => entry.position !== index)) return undefined;
|
||||||
|
return colors.map((entry) => entry.color);
|
||||||
|
}
|
||||||
|
|
||||||
function quaternionDegrees(rotation) {
|
function quaternionDegrees(rotation) {
|
||||||
const z = Number(rotation?.z ?? 0);
|
const z = Number(rotation?.z ?? 0);
|
||||||
const w = Number(rotation?.w ?? 1);
|
const w = Number(rotation?.w ?? 1);
|
||||||
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) {
|
||||||
|
const object = sprite?.object;
|
||||||
|
if (Number(object?.m_type) !== 3) return undefined;
|
||||||
|
const sourceWidth = Number(object.m_width);
|
||||||
|
const sourceHeight = Number(object.m_height);
|
||||||
|
const left = Number(object.m_startW);
|
||||||
|
const rightEdge = Number(object.m_endW);
|
||||||
|
const top = Number(object.m_startH);
|
||||||
|
const bottomEdge = Number(object.m_endH);
|
||||||
|
if (![sourceWidth, sourceHeight, left, rightEdge, top, bottomEdge].every(Number.isFinite)
|
||||||
|
|| sourceWidth <= 0 || sourceHeight <= 0
|
||||||
|
|| left < 0 || rightEdge < left || rightEdge > sourceWidth
|
||||||
|
|| top < 0 || bottomEdge < top || bottomEdge > sourceHeight) return undefined;
|
||||||
|
return {
|
||||||
|
bottom: sourceHeight - bottomEdge,
|
||||||
|
left,
|
||||||
|
right: sourceWidth - rightEdge,
|
||||||
|
source_height: sourceHeight,
|
||||||
|
source_width: sourceWidth,
|
||||||
|
top,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function pngDimensions(path) {
|
function pngDimensions(path) {
|
||||||
const bytes = readFileSync(path);
|
const bytes = readFileSync(path);
|
||||||
if (bytes.length < 24 || bytes.readUInt32BE(12) !== 0x49484452) return undefined;
|
if (bytes.length < 24 || bytes.readUInt32BE(12) !== 0x49484452) return undefined;
|
||||||
@@ -428,21 +529,30 @@ 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 localY = ignorePosition ? 0 : Number(localPosition.y ?? 0) * parent.scaleY;
|
||||||
|
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))),
|
||||||
|
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))),
|
||||||
rotation: parent.rotation + quaternionDegrees(local.m_Rotation),
|
rotation: parent.rotation + quaternionDegrees(local.m_Rotation),
|
||||||
scaleX,
|
scaleX,
|
||||||
scaleY,
|
scaleY,
|
||||||
x: parent.x + (ignorePosition ? 0 : Number(localPosition.x ?? 0) * parent.scaleX),
|
x: parent.x + localX * Math.cos(parentRadians) - localY * Math.sin(parentRadians),
|
||||||
y: parent.y + (ignorePosition ? 0 : Number(localPosition.y ?? 0) * parent.scaleY),
|
y: parent.y + localX * Math.sin(parentRadians) + localY * Math.cos(parentRadians),
|
||||||
};
|
};
|
||||||
const nodeComponents = components(object, resolve);
|
const nodeComponents = components(object, resolve);
|
||||||
const localUnderlines = [
|
const localUnderlines = [
|
||||||
@@ -454,34 +564,67 @@ 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 outline = style.outlineInfo?.outlineSize > 0 ? style.outlineInfo
|
const inferredSize = inferredTextContentSize(textMesh.m_text, style);
|
||||||
: style.shadowInfos?.find((item) => item?.outlineInfo?.outlineSize > 0)?.outlineInfo;
|
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 fillGradient = textFillGradient(style);
|
||||||
|
const glyphColors = textGlyphColors(textMesh);
|
||||||
|
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.anchorX === 0.5 ? {} : { anchor_x: transform.anchorX }),
|
||||||
|
...(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 } : {}),
|
||||||
|
...(fillGradient ? { fill_gradient: fillGradient } : {}),
|
||||||
|
...(glyphColors ? { glyph_colors: glyphColors } : {}),
|
||||||
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++,
|
||||||
rotation: transform.rotation,
|
rotation: -transform.rotation,
|
||||||
scale_x: Math.sign(scaleX) || 1,
|
scale_x: Math.sign(scaleX) || 1,
|
||||||
scale_y: Math.sign(scaleY) || 1,
|
scale_y: Math.sign(scaleY) || 1,
|
||||||
shadow_blur: Math.max(0, Number(shadow?.blur ?? shadow?.SDFFontBorder ?? 0)),
|
shadow_blur: Math.max(0, Number(shadow?.blur ?? shadow?.SDFFontBorder ?? 0)),
|
||||||
shadow_color: colorHex(shadow?.color, "#000000"),
|
shadow_color: colorHex(shadow?.color, "#000000"),
|
||||||
shadow_offset_x: Number(shadow?.offset?.x ?? 0),
|
shadow_offset_x: Number(shadow?.offset?.x ?? 0),
|
||||||
shadow_offset_y: -Number(shadow?.offset?.y ?? 0),
|
shadow_offset_y: -Number(shadow?.offset?.y ?? 0),
|
||||||
|
skew_x: Number(textMesh.m_skewValue?.x ?? 0),
|
||||||
|
skew_y: Number(textMesh.m_skewValue?.y ?? 0),
|
||||||
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,
|
||||||
});
|
});
|
||||||
for (const underline of localUnderlines.filter((item) => item?.p1?.enable !== false && item?.p0 === object.m_Name)) {
|
for (const underline of localUnderlines.filter((item) => item?.p1?.enable !== false && item?.p0 === object.m_Name)) {
|
||||||
const config = underline.p1?.exportParams ?? {};
|
const config = underline.p1?.exportParams ?? {};
|
||||||
@@ -496,16 +639,17 @@ function prefabLayers(prefab, input) {
|
|||||||
const naturalRatio = dimensions ? dimensions.height / Math.max(1, dimensions.width) : 0.15;
|
const naturalRatio = dimensions ? dimensions.height / Math.max(1, dimensions.width) : 0.15;
|
||||||
const targetHeight = Math.max(2, Math.min(Number(object.m_contentSize?.height ?? 48) * 0.65, targetWidth * naturalRatio));
|
const targetHeight = Math.max(2, Math.min(Number(object.m_contentSize?.height ?? 48) * 0.65, targetWidth * naturalRatio));
|
||||||
layers.push({
|
layers.push({
|
||||||
|
...(transform.alpha === 1 ? {} : { alpha: transform.alpha }),
|
||||||
asset_id: assetId,
|
asset_id: assetId,
|
||||||
height: targetHeight,
|
height: targetHeight,
|
||||||
order: order++,
|
order: order++,
|
||||||
rotation: transform.rotation,
|
rotation: -transform.rotation,
|
||||||
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: "image",
|
type: "image",
|
||||||
width: targetWidth,
|
width: targetWidth,
|
||||||
x: transform.x,
|
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,
|
+ Number(config.relativeDistance ?? 0) + targetHeight / 2,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -516,25 +660,27 @@ function prefabLayers(prefab, input) {
|
|||||||
const assetId = typeof texturePath === "string" ? input.imageIds.get(texturePath) : undefined;
|
const assetId = typeof texturePath === "string" ? input.imageIds.get(texturePath) : undefined;
|
||||||
if (!assetId) continue;
|
if (!assetId) continue;
|
||||||
layers.push({
|
layers.push({
|
||||||
alpha: Math.max(0, Math.min(1, Number(particleComponent.m_AlphaIdensity ?? 1))),
|
alpha: transform.alpha * Math.max(0, Math.min(1, Number(particleComponent.m_AlphaIdensity ?? 1))),
|
||||||
|
anchor_x: transform.anchorX,
|
||||||
|
anchor_y: 1 - transform.anchorY,
|
||||||
asset_id: assetId,
|
asset_id: assetId,
|
||||||
atlas_columns: Math.max(1, Number(particleComponent.m_altasUcount ?? 1)),
|
atlas_columns: Math.max(1, Number(particleComponent.m_altasUcount ?? 1)),
|
||||||
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)),
|
||||||
randomize_angle: Number(particleComponent.m_particlesRandomizeAngle ?? 0),
|
randomize_angle: Number(particleComponent.m_particlesRandomizeAngle ?? 0),
|
||||||
randomize_position: Number(particleComponent.m_particlesRandomizePosition ?? 0),
|
randomize_position: Number(particleComponent.m_particlesRandomizePosition ?? 0),
|
||||||
rotation: transform.rotation,
|
rotation: -transform.rotation,
|
||||||
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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -542,37 +688,69 @@ function prefabLayers(prefab, input) {
|
|||||||
if (spriteRenderer && spriteRenderer.m_isEnabled !== false) {
|
if (spriteRenderer && spriteRenderer.m_isEnabled !== false) {
|
||||||
const spriteUuid = spriteRenderer?.m_sprite?.uuid?.uuid;
|
const spriteUuid = spriteRenderer?.m_sprite?.uuid?.uuid;
|
||||||
const spritePath = typeof spriteUuid === "string" ? input.manifestMappings.get(spriteUuid) : undefined;
|
const spritePath = typeof spriteUuid === "string" ? input.manifestMappings.get(spriteUuid) : undefined;
|
||||||
|
const spriteDefinitionPath = typeof spriteUuid === "string" ? input.spriteDefinitions.get(spriteUuid) : undefined;
|
||||||
let imagePath;
|
let imagePath;
|
||||||
if (typeof spritePath === "string" && assetFileType(spritePath) === "sprite" && existsSync(spritePath)) {
|
let ninePatch;
|
||||||
|
if (typeof spriteDefinitionPath === "string" && existsSync(spriteDefinitionPath)) {
|
||||||
|
const sprite = readJson(spriteDefinitionPath);
|
||||||
|
const imageUuid = sprite?.object?.m_UUIDList?.[0]?.uuid;
|
||||||
|
imagePath = typeof spritePath === "string" && assetFileType(spritePath) === "png"
|
||||||
|
? spritePath
|
||||||
|
: typeof imageUuid === "string" ? input.manifestMappings.get(imageUuid) : undefined;
|
||||||
|
ninePatch = spriteNinePatch(sprite);
|
||||||
|
} else if (typeof spritePath === "string" && assetFileType(spritePath) === "sprite" && existsSync(spritePath)) {
|
||||||
const sprite = readJson(spritePath);
|
const sprite = readJson(spritePath);
|
||||||
const imageUuid = sprite?.object?.m_UUIDList?.[0]?.uuid;
|
const imageUuid = sprite?.object?.m_UUIDList?.[0]?.uuid;
|
||||||
if (typeof imageUuid === "string") imagePath = input.manifestMappings.get(imageUuid);
|
if (typeof imageUuid === "string") imagePath = input.manifestMappings.get(imageUuid);
|
||||||
|
ninePatch = spriteNinePatch(sprite);
|
||||||
} else if (typeof spritePath === "string" && assetFileType(spritePath) === "png") imagePath = spritePath;
|
} else if (typeof spritePath === "string" && assetFileType(spritePath) === "png") imagePath = spritePath;
|
||||||
const assetId = typeof imagePath === "string" ? input.imageIds.get(imagePath) : undefined;
|
const assetId = typeof imagePath === "string" ? input.imageIds.get(imagePath) : undefined;
|
||||||
if (assetId) {
|
if (assetId) {
|
||||||
layers.push({
|
layers.push({
|
||||||
|
...(transform.alpha === 1 ? {} : { alpha: transform.alpha }),
|
||||||
|
...(transform.anchorX === 0.5 ? {} : { anchor_x: transform.anchorX }),
|
||||||
|
...(transform.anchorY === 0.5 ? {} : { anchor_y: 1 - transform.anchorY }),
|
||||||
asset_id: assetId,
|
asset_id: assetId,
|
||||||
height: Math.max(1, Number(object.m_contentSize?.height ?? 1) * Math.abs(scaleY)),
|
height: Math.max(1, Number(object.m_contentSize?.height ?? 1) * Math.abs(scaleY)),
|
||||||
order: order++,
|
order: order++,
|
||||||
rotation: transform.rotation,
|
rotation: -transform.rotation,
|
||||||
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: "image",
|
type: "image",
|
||||||
width: Math.max(1, Number(object.m_contentSize?.width ?? 1) * Math.abs(scaleX)),
|
width: Math.max(1, Number(object.m_contentSize?.width ?? 1) * Math.abs(scaleX)),
|
||||||
x: transform.x,
|
x: transform.x,
|
||||||
y: transform.y,
|
y: -transform.y,
|
||||||
|
...(ninePatch ? { nine_patch: ninePatch } : {}),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
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, { 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;
|
||||||
@@ -589,16 +767,34 @@ 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) => {
|
||||||
bottom: layer.y + layer.height / 2,
|
const anchorX = Number(layer.anchor_x ?? 0.5);
|
||||||
left: layer.x - layer.width / 2,
|
const anchorY = Number(layer.anchor_y ?? 0.5);
|
||||||
right: layer.x + layer.width / 2,
|
const radians = Number(layer.rotation ?? 0) * Math.PI / 180;
|
||||||
top: layer.y - layer.height / 2,
|
const cosine = Math.cos(radians);
|
||||||
}));
|
const sine = Math.sin(radians);
|
||||||
|
const corners = [
|
||||||
|
[-anchorX * layer.width, -anchorY * layer.height],
|
||||||
|
[(1 - anchorX) * layer.width, -anchorY * layer.height],
|
||||||
|
[-anchorX * layer.width, (1 - anchorY) * layer.height],
|
||||||
|
[(1 - anchorX) * layer.width, (1 - anchorY) * layer.height],
|
||||||
|
].map(([x, y]) => ({
|
||||||
|
x: layer.x + x * Number(layer.scale_x ?? 1) * cosine - y * Number(layer.scale_y ?? 1) * sine,
|
||||||
|
y: layer.y + x * Number(layer.scale_x ?? 1) * sine + y * Number(layer.scale_y ?? 1) * cosine,
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
bottom: Math.max(...corners.map((item) => item.y)),
|
||||||
|
left: Math.min(...corners.map((item) => item.x)),
|
||||||
|
right: Math.max(...corners.map((item) => item.x)),
|
||||||
|
top: Math.min(...corners.map((item) => item.y)),
|
||||||
|
};
|
||||||
|
});
|
||||||
const left = Math.min(...bounds.map((item) => item.left));
|
const left = Math.min(...bounds.map((item) => item.left));
|
||||||
const right = Math.max(...bounds.map((item) => item.right));
|
const right = Math.max(...bounds.map((item) => item.right));
|
||||||
const top = Math.min(...bounds.map((item) => item.top));
|
const top = Math.min(...bounds.map((item) => item.top));
|
||||||
@@ -613,10 +809,22 @@ 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 === "image" && layer.nine_patch) {
|
||||||
|
layer.nine_patch.left = Number((layer.nine_patch.left * normalization).toFixed(3));
|
||||||
|
layer.nine_patch.right = Number((layer.nine_patch.right * normalization).toFixed(3));
|
||||||
|
layer.nine_patch.top = Number((layer.nine_patch.top * normalization).toFixed(3));
|
||||||
|
layer.nine_patch.bottom = Number((layer.nine_patch.bottom * 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));
|
||||||
@@ -638,7 +846,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");
|
||||||
@@ -651,7 +864,7 @@ export function compileTextTemplateAssets({ templateDirectory, templateId }) {
|
|||||||
sourcePath,
|
sourcePath,
|
||||||
}));
|
}));
|
||||||
const imageIds = new Map(imagePaths.map((path, index) => [path, imageResources[index].assetId]));
|
const imageIds = new Map(imagePaths.map((path, index) => [path, imageResources[index].assetId]));
|
||||||
const manifestMappings = manifestFileMap(packageFiles);
|
const { mappings: manifestMappings, spriteDefinitions } = manifestFileMap(packageFiles);
|
||||||
const unresolvedImages = [];
|
const unresolvedImages = [];
|
||||||
const prefabCandidates = packageFiles.filter((path) => extname(path).toLowerCase() === ".prefab").flatMap((path) => {
|
const prefabCandidates = packageFiles.filter((path) => extname(path).toLowerCase() === ".prefab").flatMap((path) => {
|
||||||
let prefab;
|
let prefab;
|
||||||
@@ -660,7 +873,7 @@ export function compileTextTemplateAssets({ templateDirectory, templateId }) {
|
|||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const layers = prefabLayers(prefab, { imageIds, manifestMappings, unresolvedImages });
|
const layers = prefabLayers(prefab, { imageIds, manifestMappings, spriteDefinitions, unresolvedImages });
|
||||||
const texts = layers.filter((layer) => layer.type === "text").map((layer) => layer.text.trim().toLocaleLowerCase("zh-CN"));
|
const texts = layers.filter((layer) => layer.type === "text").map((layer) => layer.text.trim().toLocaleLowerCase("zh-CN"));
|
||||||
const expected = [metadata.default_text, metadata.runtime?.layer?.default_text, ...(metadata.runtime?.human_strings ?? [])]
|
const expected = [metadata.default_text, metadata.runtime?.layer?.default_text, ...(metadata.runtime?.human_strings ?? [])]
|
||||||
.filter((value) => typeof value === "string" && value.trim()).map((value) => value.trim().toLocaleLowerCase("zh-CN"));
|
.filter((value) => typeof value === "string" && value.trim()).map((value) => value.trim().toLocaleLowerCase("zh-CN"));
|
||||||
@@ -690,6 +903,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,
|
||||||
@@ -709,6 +923,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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,6 +162,47 @@ test("POSTV1-ASSET-ALL-16 retries a transient archived font failure without perm
|
|||||||
expect(assetRequests.filter((assetId) => assetId === failedFont)).toHaveLength(2);
|
expect(assetRequests.filter((assetId) => assetId === failedFont)).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("POSTV1-TEMPLATE-FIDELITY-17 renders archived styles instead of two-character preview placeholders", async ({ page }) => {
|
||||||
|
const projectId = uuid(737);
|
||||||
|
const backend: Backend = { canvas: emptyCanvas(), recent: [], saves: 0, version: 2 };
|
||||||
|
await routeEditor(page, projectId, backend);
|
||||||
|
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
||||||
|
await page.getByRole("button", { name: "文字模板", exact: true }).click();
|
||||||
|
|
||||||
|
const template = page.getByRole("button", { name: /FLOWER003 人生照片/ });
|
||||||
|
const preview = template.locator(".editor-template-live-preview");
|
||||||
|
await expect(preview).toBeVisible();
|
||||||
|
await expect(template.locator(".editor-template-mark")).toHaveCount(0);
|
||||||
|
await expect(preview).toContainText("#人生照片");
|
||||||
|
await expect(preview).toHaveCSS("font-family", /Dada_TEXT_FONT_FLOWER003_01/);
|
||||||
|
await expect(preview).toHaveCSS("color", "rgb(255, 255, 248)");
|
||||||
|
await expect.poll(() => page.evaluate(() => document.fonts.check('16px "Dada_TEXT_FONT_FLOWER003_01"'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POSTV1-TEMPLATE-FIDELITY-17 isolates manual stroke state between templates", async ({ page }) => {
|
||||||
|
const projectId = uuid(738);
|
||||||
|
const backend: Backend = { canvas: emptyCanvas(), recent: [], saves: 0, version: 2 };
|
||||||
|
await routeEditor(page, projectId, backend);
|
||||||
|
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
||||||
|
await page.getByRole("button", { name: "文字模板", exact: true }).click();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: /H003 生活分享家/ }).click();
|
||||||
|
await expect(page.getByLabel("启用描边")).not.toBeChecked();
|
||||||
|
await page.getByLabel("启用描边").check();
|
||||||
|
await expect.poll(() => backend.canvas.elements[0]?.style_parameters?.stroke_enabled).toBe(true);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: /FLOWER002 笑不活了/ }).click();
|
||||||
|
await expect.poll(() => backend.canvas.elements).toHaveLength(2);
|
||||||
|
await expect(page.getByLabel("启用描边")).not.toBeChecked();
|
||||||
|
expect(backend.canvas.elements[0]?.style_parameters?.stroke_enabled).toBe(true);
|
||||||
|
expect(backend.canvas.elements[1]?.style_parameters?.stroke_enabled).toBe(false);
|
||||||
|
|
||||||
|
await page.getByLabel("文字模板切换").selectOption("H003");
|
||||||
|
await expect(page.getByLabel("启用描边")).not.toBeChecked();
|
||||||
|
await expect.poll(() => backend.canvas.elements[1]?.template_or_asset_id).toBe("H003");
|
||||||
|
expect(backend.canvas.elements[1]?.style_parameters?.stroke_enabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
test("POSTV1-ASSET-ALL-16 renders captured image, material, particle and underline decorations", async ({ page }) => {
|
test("POSTV1-ASSET-ALL-16 renders captured image, material, particle and underline decorations", async ({ page }) => {
|
||||||
const projectId = uuid(736);
|
const projectId = uuid(736);
|
||||||
const backend: Backend = { canvas: emptyCanvas(), recent: [], saves: 0, version: 2 };
|
const backend: Backend = { canvas: emptyCanvas(), recent: [], saves: 0, version: 2 };
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import { resolve } from "node:path";
|
import { join, resolve } from "node:path";
|
||||||
|
|
||||||
import { buildAndValidatePortablePackage } from "../../scripts/lib/portable-package.mjs";
|
import { buildAndValidatePortablePackage } from "../../scripts/lib/portable-package.mjs";
|
||||||
|
|
||||||
@@ -22,4 +23,12 @@ test("builds an isolated candidate portable package", async () => {
|
|||||||
assert.equal(result.processTree.native.status, "passed");
|
assert.equal(result.processTree.native.status, "passed");
|
||||||
assert.equal(result.processTree.supervisor.credential_store_access, false);
|
assert.equal(result.processTree.supervisor.credential_store_access, false);
|
||||||
assert.equal(result.processTree.supervisor.status, "passed");
|
assert.equal(result.processTree.supervisor.status, "passed");
|
||||||
|
|
||||||
|
const packageDirectory = join(outputRoot, result.packageManifest.package_name);
|
||||||
|
const webIndex = readFileSync(join(packageDirectory, "web", "index.html"), "utf8");
|
||||||
|
const startupBundles = result.packageManifest.files
|
||||||
|
.map((entry) => entry.path)
|
||||||
|
.filter((path) => /^web\/assets\/index-[^/]+\.js$/u.test(path));
|
||||||
|
assert.equal(startupBundles.length, 1);
|
||||||
|
assert.match(webIndex, new RegExp(`/${startupBundles[0].slice("web/".length).replace(".", "\\.")}`));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +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 { 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,
|
||||||
@@ -77,11 +78,161 @@ describe("POSTV1-ASSET-ALL-16 complete asset catalog", () => {
|
|||||||
const materialText = byId.get("FLOWER048")!;
|
const materialText = byId.get("FLOWER048")!;
|
||||||
expect(materialText.render_model.text_layers[0]).toHaveProperty("fill_pattern_asset_id");
|
expect(materialText.render_model.text_layers[0]).toHaveProperty("fill_pattern_asset_id");
|
||||||
|
|
||||||
|
expect(byId.get("FLOWER037")!.render_model.text_layers[0]).toMatchObject({
|
||||||
|
fill_gradient: { bottom: "#FC686F", top: "#FFEB7B" },
|
||||||
|
});
|
||||||
|
expect(byId.get("FLOWER077")!.render_model.text_layers[0]).toMatchObject({ skew_x: 0, skew_y: 11 });
|
||||||
|
expect(byId.get("FLOWER125")!.render_model.text_layers[0]).toMatchObject({
|
||||||
|
fill_texture_layout: { columns: 1, id_list: [0, 0, 0, 0, 0, 0], rows: 1 },
|
||||||
|
glyph_colors: ["#FC7878", "#F0F054", "#F28FDE", "#47BAFF", "#FFB80F", "#4ADB91"],
|
||||||
|
});
|
||||||
|
|
||||||
const underlinedText = byId.get("FLOWER121")!;
|
const underlinedText = byId.get("FLOWER121")!;
|
||||||
expect(underlinedText.render_model.image_layers).toHaveLength(1);
|
expect(underlinedText.render_model.image_layers).toHaveLength(1);
|
||||||
|
|
||||||
const particleText = byId.get("H013")!;
|
const particleText = byId.get("H013")!;
|
||||||
expect(particleText.render_model.particle_layers).toHaveLength(3);
|
expect(particleText.render_model.particle_layers).toHaveLength(3);
|
||||||
|
|
||||||
|
const ninePatchLayers = complexAssetCatalog.text_templates.flatMap((item) => item.render_model.image_layers)
|
||||||
|
.filter((layer) => "nine_patch" in layer);
|
||||||
|
expect(ninePatchLayers.length).toBeGreaterThanOrEqual(100);
|
||||||
|
expect(byId.get("FLOWER023")!.render_model.image_layers[0]).toMatchObject({
|
||||||
|
nine_patch: { bottom: 16, left: 53, right: 25, source_height: 63, source_width: 159, top: 12 },
|
||||||
|
});
|
||||||
|
const motherBackground = byId.get("FLOWER077")!.render_model.image_layers[0]!;
|
||||||
|
expect(motherBackground.nine_patch).toMatchObject({
|
||||||
|
bottom: 0.407, left: 19.108, right: 19.514, source_height: 224, source_width: 445, top: 8.944,
|
||||||
|
});
|
||||||
|
expect(motherBackground.nine_patch!.left + motherBackground.nine_patch!.right).toBeLessThan(motherBackground.width);
|
||||||
|
expect(motherBackground.nine_patch!.top + motherBackground.nine_patch!.bottom).toBeLessThan(motherBackground.height);
|
||||||
|
expect(byId.get("H001")!.render_model.image_layers).toEqual(expect.arrayContaining([
|
||||||
|
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("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 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("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", () => {
|
||||||
|
const slices = ninePatchSlices(231, 63, {
|
||||||
|
bottom: 16, left: 53, right: 25, sourceHeight: 63, sourceWidth: 159, top: 12,
|
||||||
|
});
|
||||||
|
expect(slices).toHaveLength(9);
|
||||||
|
expect(slices[0]).toEqual({ destination: { height: 12, width: 53, x: 0, y: 0 }, source: { height: 12, width: 53, x: 0, y: 0 } });
|
||||||
|
expect(slices[4]).toEqual({ destination: { height: 35, width: 153, x: 53, y: 12 }, source: { height: 35, width: 81, x: 53, y: 12 } });
|
||||||
|
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", () => {
|
it("exposes every generated definition to the editor", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user