优化:支持文字排版

This commit is contained in:
iqudoo
2026-06-12 19:49:24 +08:00
parent 89b59ecc1b
commit 7ad1f59ae1
7 changed files with 247 additions and 72 deletions

View File

@@ -121,6 +121,98 @@ function drawImageWithFit(
ctx.drawImage(img, sx, sy, sw, sh, x, y, w, h);
}
function applyElementRotation(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number,
rotationDeg: number | undefined
) {
const rotation = rotationDeg ?? 0;
if (rotation === 0) return;
const cx = x + w / 2;
const cy = y + h / 2;
ctx.translate(cx, cy);
ctx.rotate((rotation * Math.PI) / 180);
ctx.translate(-cx, -cy);
}
function layoutHorizontalTextLines(
ctx: CanvasRenderingContext2D,
value: string,
writableW: number,
wordWrap: boolean
): string[] {
const lines: string[] = [];
const paragraphs = String(value).split(/\r?\n/);
for (const paragraph of paragraphs) {
if (!wordWrap) {
lines.push(paragraph);
continue;
}
let currentLine = '';
for (let i = 0; i < paragraph.length; i++) {
const char = paragraph[i];
const testLine = currentLine + char;
const metrics = ctx.measureText(testLine);
if (metrics.width > writableW - 4 && i > 0 && currentLine.length > 0) {
lines.push(currentLine);
currentLine = char;
} else {
currentLine = testLine;
}
}
if (currentLine || lines.length === 0) {
lines.push(currentLine);
}
}
return lines;
}
/** 竖排:按列组织字符,列高受限时换列 */
function layoutVerticalTextColumns(
value: string,
writableH: number,
lineHeight: number,
wordWrap: boolean
): string[][] {
const columns: string[][] = [];
const paragraphs = String(value).split(/\r?\n/);
for (let p = 0; p < paragraphs.length; p++) {
const paragraph = paragraphs[p];
if (!wordWrap) {
columns.push([...paragraph]);
} else {
let currentColumn: string[] = [];
for (const char of paragraph) {
const colHeight = (currentColumn.length + 1) * lineHeight;
if (colHeight > writableH - 4 && currentColumn.length > 0) {
columns.push(currentColumn);
currentColumn = [char];
} else {
currentColumn.push(char);
}
}
if (currentColumn.length > 0) {
columns.push(currentColumn);
}
}
if (p < paragraphs.length - 1) {
columns.push([]);
}
}
if (columns.length === 0) {
columns.push([]);
}
return columns;
}
function getPaddedRect(
x: number,
y: number,
@@ -238,6 +330,9 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
continue;
}
ctx.save();
applyElementRotation(ctx, ex, ey, ew, eh, elem.rotation);
if (elem.type === 'text') {
const radii = resolveCornerRadiiPx(elem, scale, ew, eh);
const sides = resolveBorderSides(elem);
@@ -273,58 +368,11 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
}
const wordWrap = elem.wordWrap !== false;
const lines: string[] = [];
const paragraphs = String(value).split(/\r?\n/);
for (const paragraph of paragraphs) {
if (!wordWrap) {
lines.push(paragraph);
continue;
}
let currentLine = '';
for (let i = 0; i < paragraph.length; i++) {
const char = paragraph[i];
const testLine = currentLine + char;
const metrics = ctx.measureText(testLine);
if (metrics.width > writableW - 4 && i > 0 && currentLine.length > 0) {
lines.push(currentLine);
currentLine = char;
} else {
currentLine = testLine;
}
}
if (currentLine || lines.length === 0) {
lines.push(currentLine);
}
}
const lineHeight = fontSizePx * 1.25;
const totalTextHeight = lines.length * lineHeight;
const columnWidth = lineHeight;
const isVertical = elem.textWritingMode === 'vertical';
ctx.textBaseline = 'middle';
let textX = ex + paddingPx;
let textAlign: CanvasTextAlign = 'left';
if (elem.textAlign === 'center') {
textX = ex + paddingPx + writableW / 2;
textAlign = 'center';
} else if (elem.textAlign === 'right') {
textX = ex + paddingPx + (writableW - 2);
textAlign = 'right';
} else {
textX = ex + paddingPx + 2;
}
ctx.textAlign = textAlign;
const vAlign = elem.verticalAlign ?? 'middle';
let startY: number;
if (vAlign === 'top') {
startY = ey + paddingPx + lineHeight / 2;
} else if (vAlign === 'bottom') {
startY = ey + eh - paddingPx - totalTextHeight + lineHeight / 2;
} else {
startY = ey + paddingPx + Math.max(0, (writableH - totalTextHeight) / 2) + lineHeight / 2;
}
const textScaleX = Math.max(0.1, Math.min(5, elem.textScaleX ?? 1));
const textScaleY = Math.max(0.1, Math.min(5, elem.textScaleY ?? 1));
const transformOriginX = ex + paddingPx + writableW / 2;
@@ -337,12 +385,77 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
ctx.translate(-transformOriginX, -transformOriginY);
}
for (let i = 0; i < lines.length; i++) {
const lineY = startY + i * lineHeight;
if (lineY - lineHeight / 2 <= ey + eh - paddingPx) {
const line = lines[i];
ctx.fillText(line, textX, lineY);
drawTextLineDecorations(ctx, elem, textX, lineY, line, fontSizePx, textAlign);
const vAlign = elem.verticalAlign ?? 'middle';
if (isVertical) {
const columns = layoutVerticalTextColumns(String(value), writableH, lineHeight, wordWrap);
const totalColumnsWidth = columns.length * columnWidth;
let startX: number;
if (elem.textAlign === 'center') {
startX = ex + paddingPx + Math.max(0, (writableW - totalColumnsWidth) / 2) + columnWidth / 2;
} else if (elem.textAlign === 'right') {
startX = ex + paddingPx + Math.max(0, writableW - totalColumnsWidth) + columnWidth / 2;
} else {
startX = ex + paddingPx + columnWidth / 2;
}
ctx.textAlign = 'center';
for (let col = 0; col < columns.length; col++) {
const column = columns[col];
const colTextHeight = column.length * lineHeight;
let colStartY: number;
if (vAlign === 'top') {
colStartY = ey + paddingPx + lineHeight / 2;
} else if (vAlign === 'bottom') {
colStartY = ey + eh - paddingPx - colTextHeight + lineHeight / 2;
} else {
colStartY =
ey + paddingPx + Math.max(0, (writableH - colTextHeight) / 2) + lineHeight / 2;
}
const colX = startX + col * columnWidth;
for (let i = 0; i < column.length; i++) {
const charY = colStartY + i * lineHeight;
if (charY - lineHeight / 2 <= ey + eh - paddingPx) {
ctx.fillText(column[i], colX, charY);
}
}
}
} else {
const lines = layoutHorizontalTextLines(ctx, String(value), writableW, wordWrap);
const totalTextHeight = lines.length * lineHeight;
let textX = ex + paddingPx;
let textAlign: CanvasTextAlign = 'left';
if (elem.textAlign === 'center') {
textX = ex + paddingPx + writableW / 2;
textAlign = 'center';
} else if (elem.textAlign === 'right') {
textX = ex + paddingPx + (writableW - 2);
textAlign = 'right';
} else {
textX = ex + paddingPx + 2;
}
ctx.textAlign = textAlign;
let startY: number;
if (vAlign === 'top') {
startY = ey + paddingPx + lineHeight / 2;
} else if (vAlign === 'bottom') {
startY = ey + eh - paddingPx - totalTextHeight + lineHeight / 2;
} else {
startY =
ey + paddingPx + Math.max(0, (writableH - totalTextHeight) / 2) + lineHeight / 2;
}
for (let i = 0; i < lines.length; i++) {
const lineY = startY + i * lineHeight;
if (lineY - lineHeight / 2 <= ey + eh - paddingPx) {
const line = lines[i];
ctx.fillText(line, textX, lineY);
drawTextLineDecorations(ctx, elem, textX, lineY, line, fontSizePx, textAlign);
}
}
}
@@ -359,8 +472,7 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
const tempCanvas = document.createElement('canvas');
const barcodeValue = value ? String(value).trim() : mode === 'design' ? 'SAMPLE' : '';
if (!barcodeValue) continue;
if (barcodeValue) {
JsBarcode(tempCanvas, barcodeValue, {
format: elem.barcodeFormat || 'CODE128',
width: 2,
@@ -372,6 +484,7 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
});
ctx.drawImage(tempCanvas, inner.x, inner.y, inner.w, inner.h);
}
} catch (error) {
console.warn('Barcode drawing failed', error);
if (mode === 'design') {
@@ -393,8 +506,7 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
const inner = getPaddedRect(ex, ey, ew, eh, elem.padding || 0, scale);
const qrValue = value ? String(value).trim() : mode === 'design' ? 'SAMPLE QR' : '';
if (!qrValue) continue;
if (qrValue) {
const tempCanvas = document.createElement('canvas');
await QRCode.toCanvas(tempCanvas, qrValue, {
width: Math.max(64, inner.w),
@@ -406,6 +518,7 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
});
ctx.drawImage(tempCanvas, inner.x, inner.y, inner.w, inner.h);
}
} catch (error) {
console.warn('QR code drawing failed', error);
if (mode === 'design') {
@@ -436,6 +549,8 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
}
}
}
ctx.restore();
}
ctx.restore();