This commit is contained in:
iqudoo
2026-06-14 02:52:51 +08:00
parent 6cdae2f69c
commit fff6cb98ae
8 changed files with 325 additions and 256 deletions

View File

@@ -7,10 +7,10 @@ import {
getTableColWidths, getTableColWidths,
getTableRowHeights, getTableRowHeights,
iterTableCells, iterTableCells,
getCellRectMm,
toggleTableCellSelection, toggleTableCellSelection,
scaleTableTracks, scaleTableTracks,
getCellAnchor, getCellAnchor,
getCellRectPx,
type TableCellCoord, type TableCellCoord,
} from '../tableUtils'; } from '../tableUtils';
import { useIsNarrowScreen } from '../hooks/useIsMobile'; import { useIsNarrowScreen } from '../hooks/useIsMobile';
@@ -1860,11 +1860,8 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
onSelectTableCells && ( onSelectTableCells && (
<div className="absolute inset-0 z-10 pointer-events-none"> <div className="absolute inset-0 z-10 pointer-events-none">
{iterTableCells(overlayElement).map(({ row, col }) => { {iterTableCells(overlayElement).map(({ row, col }) => {
const rect = getCellRectMm(overlayElement, row, col); const { x: cellX, y: cellY, width: cellW, height: cellH } =
const cellX = mmToPx(rect.x, displayZoom); getCellRectPx(overlayElement, row, col, canvasRenderScale);
const cellY = mmToPx(rect.y, displayZoom);
const cellW = mmToPx(rect.width, displayZoom);
const cellH = mmToPx(rect.height, displayZoom);
const isCellSelected = selectedTableCells.some((c) => { const isCellSelected = selectedTableCells.some((c) => {
const anchor = getCellAnchor(overlayElement, c.row, c.col); const anchor = getCellAnchor(overlayElement, c.row, c.col);
return anchor.row === row && anchor.col === col; return anchor.row === row && anchor.col === col;
@@ -1872,16 +1869,19 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
return ( return (
<div <div
key={`cell-${row}-${col}`} key={`cell-${row}-${col}`}
className={`absolute pointer-events-auto cursor-grab active:cursor-grabbing border touch-none ${ className={`absolute pointer-events-auto cursor-grab active:cursor-grabbing touch-none ${
isCellSelected isCellSelected
? 'bg-[#31a8ff]/25 border-[#31a8ff]' ? 'bg-[#31a8ff]/25'
: 'border-transparent hover:bg-[#31a8ff]/10 hover:border-[#31a8ff]/40' : 'hover:bg-[#31a8ff]/10'
}`} }`}
style={{ style={{
left: `${cellX}px`, left: `${cellX}px`,
top: `${cellY}px`, top: `${cellY}px`,
width: `${cellW}px`, width: `${cellW}px`,
height: `${cellH}px`, height: `${cellH}px`,
boxShadow: isCellSelected
? 'inset 0 0 0 1px #31a8ff'
: undefined,
}} }}
onPointerDown={(e) => onPointerDown={(e) =>
handleTableCellPointerDown(e, overlayElement, row, col) handleTableCellPointerDown(e, overlayElement, row, col)

View File

@@ -482,7 +482,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
<div className="space-y-3"> <div className="space-y-3">
<div className="text-[10px] font-bold text-[#31a8ff]"></div> <div className="text-[10px] font-bold text-[#31a8ff]"></div>
<p className="text-[10px] text-[#666] leading-relaxed mb-3"> <p className="text-[10px] text-[#666] leading-relaxed mb-3">
</p> </p>
{templateVariables.map((v) => { {templateVariables.map((v) => {
const inUse = isTemplateVariableInUse(template, v); const inUse = isTemplateVariableInUse(template, v);
@@ -501,7 +501,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
type="text" type="text"
value={template.variableDefaults?.[v] ?? ''} value={template.variableDefaults?.[v] ?? ''}
onChange={(e) => setVariableDefault(v, e.target.value)} onChange={(e) => setVariableDefault(v, e.target.value)}
placeholder="预览默认值" placeholder="预览值"
className="ps-field font-mono text-[10px] w-full min-w-0" className="ps-field font-mono text-[10px] w-full min-w-0"
/> />
<div className="flex items-center justify-center w-[44px] shrink-0"> <div className="flex items-center justify-center w-[44px] shrink-0">
@@ -661,6 +661,17 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
)} )}
{selectedElem.type !== 'table' && ( {selectedElem.type !== 'table' && (
<> <>
<div className="flex items-center justify-between gap-2">
<label className="ps-field-label"></label>
<button
type="button"
disabled={!selectedElem.content}
onClick={() => handleElemChange({ ...selectedElem, content: '' })}
className="text-[9px] text-[#31a8ff] hover:underline cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed disabled:no-underline shrink-0"
>
</button>
</div>
<PropTextarea <PropTextarea
value={selectedElem.content} value={selectedElem.content}
onCommit={(val) => handleElemChange({ ...selectedElem, content: val })} onCommit={(val) => handleElemChange({ ...selectedElem, content: val })}
@@ -691,6 +702,31 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
)} )}
</> </>
)} )}
{(selectedElem.type === 'barcode' ||
selectedElem.type === 'qrcode' ||
selectedElem.type === 'image') && (
<label className="flex items-start gap-2 pt-1">
<input
type="checkbox"
checked={selectedElem.showWhenContentEmpty ?? false}
onChange={(e) =>
handleElemChange({
...selectedElem,
showWhenContentEmpty: e.target.checked ? true : undefined,
})
}
className="ps-checkbox mt-0.5"
/>
<span className="text-xs text-[#ccc] leading-normal">
{selectedElem.type === 'image' && (
<span className="block text-[9px] text-[#777] mt-0.5">
</span>
)}
</span>
</label>
)}
{selectedElem.type === 'table' && ( {selectedElem.type === 'table' && (
<p className="text-[10px] text-[#777] leading-normal"> <p className="text-[10px] text-[#777] leading-normal">
@@ -1407,6 +1443,45 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
<option value="fill"> (fill)</option> <option value="fill"> (fill)</option>
</FieldSelect> </FieldSelect>
</div> </div>
<div>
<label className="ps-field-label"></label>
<p className="text-[9px] text-[#777] leading-normal mb-1.5">
使
</p>
<label className="ps-btn text-[10px] cursor-pointer mb-1.5 inline-flex">
<Upload className="w-3.5 h-3.5" />
<input
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () =>
handleElemChange({
...selectedElem,
defaultImage: reader.result as string,
});
reader.readAsDataURL(file);
e.target.value = '';
}}
/>
</label>
<PropTextarea
value={selectedElem.defaultImage ?? ''}
onCommit={(val) =>
handleElemChange({
...selectedElem,
defaultImage: val.trim() || undefined,
})
}
rows={2}
className="ps-field resize-none"
placeholder="默认图片 URL 或 data URI"
/>
</div>
</div> </div>
)} )}

View File

@@ -58,7 +58,17 @@ export const TableCellTextFields: React.FC<TableCellTextFieldsProps> = ({
)} )}
<div> <div>
<label className="ps-field-label"></label> <div className="flex items-center justify-between gap-2">
<label className="ps-field-label"></label>
<button
type="button"
disabled={!textElem.content}
onClick={() => patchText({ ...textElem, content: '' })}
className="text-[9px] text-[#31a8ff] hover:underline cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed disabled:no-underline shrink-0"
>
</button>
</div>
<textarea <textarea
value={textElem.content} value={textElem.content}
rows={2} rows={2}

View File

@@ -242,7 +242,17 @@ export const TableCellContentFields: React.FC<TableCellContentFieldsProps> = ({
return ( return (
<div className="space-y-2 pt-2 border-t border-[#3a3a3a]"> <div className="space-y-2 pt-2 border-t border-[#3a3a3a]">
<label className="ps-field-label">{selectedCells.length} </label> <div className="flex items-center justify-between gap-2">
<label className="ps-field-label">{selectedCells.length} </label>
<button
type="button"
disabled={!content}
onClick={() => onChange(updateTableCells(elem, selectedCells, { content: '' }))}
className="text-[9px] text-[#31a8ff] hover:underline cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed disabled:no-underline shrink-0"
>
</button>
</div>
<textarea <textarea
value={content} value={content}
rows={2} rows={2}

View File

@@ -19,14 +19,13 @@ import {
} from './elementBox'; } from './elementBox';
import { import {
iterTableCells, iterTableCells,
getCellRectMm,
getCellAnchor, getCellAnchor,
getCellData, getCellData,
getEffectiveCellStyle, getEffectiveCellStyle,
getTableRows, getTableRows,
getTableCols, getTableCols,
getTableRowHeights, buildTableGridBoundariesPx,
getTableColWidths, getCellRectPx,
} from './tableUtils'; } from './tableUtils';
const GENERATOR_TRANSPARENT_BG = '#00000000'; const GENERATOR_TRANSPARENT_BG = '#00000000';
@@ -90,6 +89,34 @@ function loadImage(src: string): Promise<HTMLImageElement> {
}); });
} }
function loadImageOrNull(src: string): Promise<HTMLImageElement | null> {
const trimmed = src.trim();
if (!trimmed) return Promise.resolve(null);
return loadImage(trimmed).catch(() => null);
}
async function resolveElementImage(
elem: TemplateElement,
primarySrc: string,
mode: ContentResolveMode
): Promise<{ img: HTMLImageElement | null; showPlaceholder: boolean }> {
const primary = primarySrc.trim();
const fallback = (elem.defaultImage ?? '').trim();
let img = await loadImageOrNull(primary);
if (!img && fallback) {
img = await loadImageOrNull(fallback);
}
if (img) return { img, showPlaceholder: false };
if (mode === 'design' && !primary && !fallback) {
return { img: null, showPlaceholder: true };
}
return { img: null, showPlaceholder: false };
}
function drawImageWithFit( function drawImageWithFit(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
img: HTMLImageElement, img: HTMLImageElement,
@@ -226,7 +253,7 @@ function layoutVerticalTextColumns(
return columns; return columns;
} }
function drawTextInRect( function drawTextContentInBox(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
elem: TemplateElement, elem: TemplateElement,
boxX: number, boxX: number,
@@ -236,9 +263,15 @@ function drawTextInRect(
value: string, value: string,
scale: number scale: number
) { ) {
const pad = 2 * scale; const pad = resolvePaddingMm(elem);
const writableW = Math.max(1, boxW - pad * 2); const padPx = {
const writableH = Math.max(1, boxH - pad * 2); top: Math.round(pad.top * scale),
right: Math.round(pad.right * scale),
bottom: Math.round(pad.bottom * scale),
left: Math.round(pad.left * scale),
};
const writableW = Math.max(1, boxW - padPx.left - padPx.right);
const writableH = Math.max(1, boxH - padPx.top - padPx.bottom);
const fontStyle = elem.fontStyle === 'italic' ? 'italic ' : ''; const fontStyle = elem.fontStyle === 'italic' ? 'italic ' : '';
const fontWeight = elem.fontWeight === 'bold' ? 'bold ' : ''; const fontWeight = elem.fontWeight === 'bold' ? 'bold ' : '';
@@ -267,8 +300,8 @@ function drawTextInRect(
const textScaleX = Math.max(0.1, Math.min(5, elem.textScaleX ?? 1)); 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 textScaleY = Math.max(0.1, Math.min(5, elem.textScaleY ?? 1));
const transformOriginX = boxX + boxW / 2; const transformOriginX = boxX + padPx.left + writableW / 2;
const transformOriginY = boxY + boxH / 2; const transformOriginY = boxY + padPx.top + writableH / 2;
ctx.save(); ctx.save();
if (textScaleX !== 1 || textScaleY !== 1) { if (textScaleX !== 1 || textScaleY !== 1) {
@@ -285,11 +318,11 @@ function drawTextInRect(
let startX: number; let startX: number;
if (elem.textAlign === 'center') { if (elem.textAlign === 'center') {
startX = boxX + pad + Math.max(0, (writableW - totalColumnsWidth) / 2) + columnWidth / 2; startX = boxX + padPx.left + Math.max(0, (writableW - totalColumnsWidth) / 2) + columnWidth / 2;
} else if (elem.textAlign === 'right') { } else if (elem.textAlign === 'right') {
startX = boxX + pad + Math.max(0, writableW - totalColumnsWidth) + columnWidth / 2; startX = boxX + padPx.left + Math.max(0, writableW - totalColumnsWidth) + columnWidth / 2;
} else { } else {
startX = boxX + pad + columnWidth / 2; startX = boxX + padPx.left + columnWidth / 2;
} }
ctx.textAlign = 'center'; ctx.textAlign = 'center';
@@ -298,17 +331,18 @@ function drawTextInRect(
const colTextHeight = column.length * lineHeight; const colTextHeight = column.length * lineHeight;
let colStartY: number; let colStartY: number;
if (vAlign === 'top') { if (vAlign === 'top') {
colStartY = boxY + pad + lineHeight / 2; colStartY = boxY + padPx.top + lineHeight / 2;
} else if (vAlign === 'bottom') { } else if (vAlign === 'bottom') {
colStartY = boxY + boxH - pad - colTextHeight + lineHeight / 2; colStartY = boxY + boxH - padPx.bottom - colTextHeight + lineHeight / 2;
} else { } else {
colStartY = boxY + pad + Math.max(0, (writableH - colTextHeight) / 2) + lineHeight / 2; colStartY =
boxY + padPx.top + Math.max(0, (writableH - colTextHeight) / 2) + lineHeight / 2;
} }
const colX = startX + col * columnWidth; const colX = startX + col * columnWidth;
for (let i = 0; i < column.length; i++) { for (let i = 0; i < column.length; i++) {
const charY = colStartY + i * lineHeight; const charY = colStartY + i * lineHeight;
if (charY - lineHeight / 2 <= boxY + boxH - pad) { if (charY - lineHeight / 2 <= boxY + boxH - padPx.bottom) {
ctx.fillText(column[i], colX, charY); ctx.fillText(column[i], colX, charY);
} }
} }
@@ -317,31 +351,31 @@ function drawTextInRect(
const lines = layoutHorizontalTextLines(ctx, String(value), writableW, wordWrap); const lines = layoutHorizontalTextLines(ctx, String(value), writableW, wordWrap);
const totalTextHeight = lines.length * lineHeight; const totalTextHeight = lines.length * lineHeight;
let textX = boxX + pad; let textX = boxX + padPx.left;
let textAlign: CanvasTextAlign = 'left'; let textAlign: CanvasTextAlign = 'left';
if (elem.textAlign === 'center') { if (elem.textAlign === 'center') {
textX = boxX + boxW / 2; textX = boxX + padPx.left + writableW / 2;
textAlign = 'center'; textAlign = 'center';
} else if (elem.textAlign === 'right') { } else if (elem.textAlign === 'right') {
textX = boxX + boxW - pad; textX = boxX + padPx.left + (writableW - 2);
textAlign = 'right'; textAlign = 'right';
} else { } else {
textX = boxX + pad + 2; textX = boxX + padPx.left + 2;
} }
ctx.textAlign = textAlign; ctx.textAlign = textAlign;
let startY: number; let startY: number;
if (vAlign === 'top') { if (vAlign === 'top') {
startY = boxY + pad + lineHeight / 2; startY = boxY + padPx.top + lineHeight / 2;
} else if (vAlign === 'bottom') { } else if (vAlign === 'bottom') {
startY = boxY + boxH - pad - totalTextHeight + lineHeight / 2; startY = boxY + boxH - padPx.bottom - totalTextHeight + lineHeight / 2;
} else { } else {
startY = boxY + pad + Math.max(0, (writableH - totalTextHeight) / 2) + lineHeight / 2; startY = boxY + padPx.top + Math.max(0, (writableH - totalTextHeight) / 2) + lineHeight / 2;
} }
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
const lineY = startY + i * lineHeight; const lineY = startY + i * lineHeight;
if (lineY - lineHeight / 2 <= boxY + boxH - pad) { if (lineY - lineHeight / 2 <= boxY + boxH - padPx.bottom) {
const line = lines[i]; const line = lines[i];
ctx.fillText(line, textX, lineY); ctx.fillText(line, textX, lineY);
drawTextLineDecorations(ctx, elem, textX, lineY, line, fontSizePx, textAlign); drawTextLineDecorations(ctx, elem, textX, lineY, line, fontSizePx, textAlign);
@@ -376,19 +410,12 @@ function drawTableGridLines(
const borderColor = elem.tableBorderColor ?? '#374151'; const borderColor = elem.tableBorderColor ?? '#374151';
const rows = getTableRows(elem); const rows = getTableRows(elem);
const cols = getTableCols(elem); const cols = getTableCols(elem);
const rowHeights = getTableRowHeights(elem); const { rowBounds, colBounds } = buildTableGridBoundariesPx(elem, scale, ex, ey);
const colWidths = getTableColWidths(elem);
const pad = resolvePaddingMm(elem);
const x0 = pad.left; const left = colBounds[0];
const y0 = pad.top; const top = rowBounds[0];
const totalW = colWidths.reduce((a, b) => a + b, 0); const width = colBounds[colBounds.length - 1] - left;
const totalH = rowHeights.reduce((a, b) => a + b, 0); const height = rowBounds[rowBounds.length - 1] - top;
const left = ex + x0 * scale;
const top = ey + y0 * scale;
const width = totalW * scale;
const height = totalH * scale;
ctx.strokeStyle = borderColor; ctx.strokeStyle = borderColor;
ctx.lineWidth = borderW; ctx.lineWidth = borderW;
@@ -402,45 +429,31 @@ function drawTableGridLines(
Math.max(0, height - borderW) Math.max(0, height - borderW)
); );
let yAcc = y0;
for (let r = 1; r < rows; r++) { for (let r = 1; r < rows; r++) {
yAcc += rowHeights[r - 1]; const y = alignStrokeCoord(rowBounds[r]);
const y = alignStrokeCoord(ey + yAcc * scale);
let xAcc = x0;
for (let c = 0; c < cols; c++) { for (let c = 0; c < cols; c++) {
const w = colWidths[c];
const anchorAbove = getCellAnchor(elem, r - 1, c); const anchorAbove = getCellAnchor(elem, r - 1, c);
const anchorBelow = getCellAnchor(elem, r, c); const anchorBelow = getCellAnchor(elem, r, c);
const sameMerge = const sameMerge =
anchorAbove.row === anchorBelow.row && anchorAbove.col === anchorBelow.col; anchorAbove.row === anchorBelow.row && anchorAbove.col === anchorBelow.col;
if (!sameMerge) { if (!sameMerge) {
const x1 = alignStrokeCoord(ex + xAcc * scale); ctx.moveTo(alignStrokeCoord(colBounds[c]), y);
const x2 = alignStrokeCoord(ex + (xAcc + w) * scale); ctx.lineTo(alignStrokeCoord(colBounds[c + 1]), y);
ctx.moveTo(x1, y);
ctx.lineTo(x2, y);
} }
xAcc += w;
} }
} }
let xAcc = x0;
for (let c = 1; c < cols; c++) { for (let c = 1; c < cols; c++) {
xAcc += colWidths[c - 1]; const x = alignStrokeCoord(colBounds[c]);
const x = alignStrokeCoord(ex + xAcc * scale);
let yAcc2 = y0;
for (let r = 0; r < rows; r++) { for (let r = 0; r < rows; r++) {
const h = rowHeights[r];
const anchorLeft = getCellAnchor(elem, r, c - 1); const anchorLeft = getCellAnchor(elem, r, c - 1);
const anchorRight = getCellAnchor(elem, r, c); const anchorRight = getCellAnchor(elem, r, c);
const sameMerge = const sameMerge =
anchorLeft.row === anchorRight.row && anchorLeft.col === anchorRight.col; anchorLeft.row === anchorRight.row && anchorLeft.col === anchorRight.col;
if (!sameMerge) { if (!sameMerge) {
const y1 = alignStrokeCoord(ey + yAcc2 * scale); ctx.moveTo(x, alignStrokeCoord(rowBounds[r]));
const y2 = alignStrokeCoord(ey + (yAcc2 + h) * scale); ctx.lineTo(x, alignStrokeCoord(rowBounds[r + 1]));
ctx.moveTo(x, y1);
ctx.lineTo(x, y2);
} }
yAcc2 += h;
} }
} }
@@ -466,19 +479,26 @@ async function drawTableElement(
drawElementFill(ctx, ex, ey, ew, eh, elem, radii); drawElementFill(ctx, ex, ey, ew, eh, elem, radii);
drawElementBorders(ctx, ex, ey, ew, eh, radii, sides, elem, scale); drawElementBorders(ctx, ex, ey, ew, eh, radii, sides, elem, scale);
const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale); const { rowBounds, colBounds } = buildTableGridBoundariesPx(elem, scale, ex, ey);
const gridX = colBounds[0];
const gridY = rowBounds[0];
const gridW = colBounds[colBounds.length - 1] - gridX;
const gridH = rowBounds[rowBounds.length - 1] - gridY;
ctx.save(); ctx.save();
ctx.beginPath(); ctx.beginPath();
ctx.rect(inner.x, inner.y, inner.w, inner.h); ctx.rect(gridX, gridY, gridW, gridH);
ctx.clip(); ctx.clip();
const anchors = iterTableCells(elem); const anchors = iterTableCells(elem);
for (const { row, col } of anchors) { for (const { row, col } of anchors) {
const rect = getCellRectMm(elem, row, col); const { x: cellX, y: cellY, width: cellW, height: cellH } = getCellRectPx(
const cellX = ex + Math.round(rect.x * scale); elem,
const cellY = ey + Math.round(rect.y * scale); row,
const cellW = Math.round(rect.width * scale); col,
const cellH = Math.round(rect.height * scale); scale,
ex,
ey
);
const cellData = getCellData(elem, row, col); const cellData = getCellData(elem, row, col);
if (cellData?.backgroundColor && cellData.backgroundColor !== 'transparent') { if (cellData?.backgroundColor && cellData.backgroundColor !== 'transparent') {
@@ -493,7 +513,23 @@ async function drawTableElement(
const displayText = String(cellValue ?? '').trim(); const displayText = String(cellValue ?? '').trim();
if (!displayText) continue; if (!displayText) continue;
drawTextInRect(ctx, textElem, cellX, cellY, cellW, cellH, displayText, scale); drawTextContentInBox(
ctx,
{
...textElem,
padding: 0,
paddingTop: 0,
paddingRight: 0,
paddingBottom: 0,
paddingLeft: 0,
},
cellX,
cellY,
cellW,
cellH,
displayText,
scale
);
} }
drawTableGridLines(ctx, elem, ex, ey, scale); drawTableGridLines(ctx, elem, ex, ey, scale);
@@ -561,8 +597,8 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
const scale = scaleOption; const scale = scaleOption;
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.width = Math.max(50, Math.round(widthMm * scale)); canvas.width = Math.max(1, Math.round(widthMm * scale));
canvas.height = Math.max(50, Math.round(heightMm * scale)); canvas.height = Math.max(1, Math.round(heightMm * scale));
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
if (!ctx) return ''; if (!ctx) return '';
@@ -618,133 +654,8 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
drawElementFill(ctx, ex, ey, ew, eh, elem, radii); drawElementFill(ctx, ex, ey, ew, eh, elem, radii);
const padPx = (() => { drawTextContentInBox(ctx, elem, ex, ey, ew, eh, String(value), scale);
const pad = resolvePaddingMm(elem);
return {
top: Math.round(pad.top * scale),
right: Math.round(pad.right * scale),
bottom: Math.round(pad.bottom * scale),
left: Math.round(pad.left * scale),
};
})();
const writableW = Math.max(1, ew - padPx.left - padPx.right);
const writableH = Math.max(1, eh - padPx.top - padPx.bottom);
const fontStyle = elem.fontStyle === 'italic' ? 'italic ' : '';
const fontWeight = elem.fontWeight === 'bold' ? 'bold ' : '';
const fontSizePx = Math.max(6, Math.max(1, Math.round(elem.fontSize * 0.352777 * scale)));
const fontFamily =
elem.fontFamily === 'mono'
? 'JetBrains Mono, SFMono-Regular, Consolas, Courier New, monospace'
: elem.fontFamily === 'serif'
? 'Georgia, Times New Roman, serif'
: 'Inter, system-ui, -apple-system, Arial, sans-serif';
ctx.font = `${fontStyle}${fontWeight}${fontSizePx}px ${fontFamily}`.trim();
ctx.fillStyle = elem.textColor || '#111827';
const letterSpacingPx = Math.max(0, (elem.letterSpacing ?? 0) * scale);
if (letterSpacingPx > 0 && 'letterSpacing' in ctx) {
(ctx as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing =
`${letterSpacingPx}px`;
}
const wordWrap = elem.wordWrap !== false;
const lineHeight = fontSizePx * 1.25;
const columnWidth = lineHeight;
const isVertical = elem.textWritingMode === 'vertical';
ctx.textBaseline = 'middle';
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 + padPx.left + writableW / 2;
const transformOriginY = ey + padPx.top + writableH / 2;
ctx.save();
if (textScaleX !== 1 || textScaleY !== 1) {
ctx.translate(transformOriginX, transformOriginY);
ctx.scale(textScaleX, textScaleY);
ctx.translate(-transformOriginX, -transformOriginY);
}
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 + padPx.left + Math.max(0, (writableW - totalColumnsWidth) / 2) + columnWidth / 2;
} else if (elem.textAlign === 'right') {
startX = ex + padPx.left + Math.max(0, writableW - totalColumnsWidth) + columnWidth / 2;
} else {
startX = ex + padPx.left + 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 + padPx.top + lineHeight / 2;
} else if (vAlign === 'bottom') {
colStartY = ey + eh - padPx.bottom - colTextHeight + lineHeight / 2;
} else {
colStartY =
ey + padPx.top + 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 - padPx.bottom) {
ctx.fillText(column[i], colX, charY);
}
}
}
} else {
const lines = layoutHorizontalTextLines(ctx, String(value), writableW, wordWrap);
const totalTextHeight = lines.length * lineHeight;
let textX = ex + padPx.left;
let textAlign: CanvasTextAlign = 'left';
if (elem.textAlign === 'center') {
textX = ex + padPx.left + writableW / 2;
textAlign = 'center';
} else if (elem.textAlign === 'right') {
textX = ex + padPx.left + (writableW - 2);
textAlign = 'right';
} else {
textX = ex + padPx.left + 2;
}
ctx.textAlign = textAlign;
let startY: number;
if (vAlign === 'top') {
startY = ey + padPx.top + lineHeight / 2;
} else if (vAlign === 'bottom') {
startY = ey + eh - padPx.bottom - totalTextHeight + lineHeight / 2;
} else {
startY =
ey + padPx.top + 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 - padPx.bottom) {
const line = lines[i];
ctx.fillText(line, textX, lineY);
drawTextLineDecorations(ctx, elem, textX, lineY, line, fontSizePx, textAlign);
}
}
}
ctx.restore();
if (letterSpacingPx > 0 && 'letterSpacing' in ctx) {
(ctx as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = '0px';
}
ctx.restore(); ctx.restore();
drawElementBorders(ctx, ex, ey, ew, eh, radii, sides, elem, scale); drawElementBorders(ctx, ex, ey, ew, eh, radii, sides, elem, scale);
} else if (elem.type === 'barcode') { } else if (elem.type === 'barcode') {
@@ -813,22 +724,20 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
} }
} }
} else if (elem.type === 'image') { } else if (elem.type === 'image') {
const primarySrc = value ? String(value).trim() : '';
const { img, showPlaceholder } = await resolveElementImage(elem, primarySrc, mode);
if (!img && !showPlaceholder) {
ctx.restore();
continue;
}
drawElementChrome(ctx, ex, ey, ew, eh, elem, scale); drawElementChrome(ctx, ex, ey, ew, eh, elem, scale);
const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale); const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale);
const src = value ? String(value).trim() : ''; if (img) {
if (!src) { drawImageWithFit(ctx, img, inner.x, inner.y, inner.w, inner.h, elem.imageFit ?? 'contain');
if (mode === 'design') {
drawImagePlaceholder(ctx, inner.x, inner.y, inner.w, inner.h, '图片');
}
} else { } else {
try { drawImagePlaceholder(ctx, inner.x, inner.y, inner.w, inner.h, '图片');
const img = await loadImage(src);
drawImageWithFit(ctx, img, inner.x, inner.y, inner.w, inner.h, elem.imageFit ?? 'contain');
} catch {
if (mode === 'design') {
drawImagePlaceholder(ctx, inner.x, inner.y, inner.w, inner.h, '加载失败');
}
}
} }
} else if (elem.type === 'table') { } else if (elem.type === 'table') {
await drawTableElement( await drawTableElement(

View File

@@ -56,18 +56,49 @@ export function getTableColWidths(elem: TemplateElement): number[] {
return Array.from({ length: cols }, () => even); return Array.from({ length: cols }, () => even);
} }
/** 内边距变化后保持行高/列宽不变,自动增大元素尺寸 */ /** 将轨道尺寸总和调整为 targetTotal保持相对比例末项吸收取整误差 */
function scaleTracksToTotal(tracks: number[], targetTotal: number, min = 0.5): number[] {
if (tracks.length === 0) return [];
const safeTotal = Math.max(tracks.length * min, targetTotal);
const current = tracks.reduce((a, b) => a + b, 0);
if (current <= 0) {
return distributeTracks(safeTotal, tracks.length, min);
}
const scaled = tracks.map((t) => round1(Math.max(min, (t / current) * safeTotal)));
const sum = scaled.reduce((a, b) => a + b, 0);
const diff = round1(safeTotal - sum);
if (scaled.length > 0 && diff !== 0) {
scaled[scaled.length - 1] = round1(Math.max(min, scaled[scaled.length - 1] + diff));
}
return scaled;
}
/** 将 inner 区域均分给 count 条轨道(末项吸收取整误差) */
function distributeTracks(total: number, count: number, min = 0.5): number[] {
if (count <= 0) return [];
const safeTotal = Math.max(count * min, total);
const even = round1(safeTotal / count);
const tracks = Array.from({ length: count }, () => even);
const sum = tracks.reduce((a, b) => a + b, 0);
const diff = round1(safeTotal - sum);
if (tracks.length > 0 && diff !== 0) {
tracks[tracks.length - 1] = round1(Math.max(min, tracks[tracks.length - 1] + diff));
}
return tracks;
}
/** 内边距变化后保持元素外框尺寸不变,按比例重算行高/列宽以适配新的内容区 */
export function expandTableForPadding(elem: TemplateElement): TemplateElement { export function expandTableForPadding(elem: TemplateElement): TemplateElement {
if (elem.type !== 'table') return elem; if (elem.type !== 'table') return elem;
const pad = resolvePaddingMm(elem); const pad = resolvePaddingMm(elem);
const rowHeights = getTableRowHeights(elem); const innerW = Math.max(1, elem.width - pad.left - pad.right);
const colWidths = getTableColWidths(elem); const innerH = Math.max(1, elem.height - pad.top - pad.bottom);
return { return {
...elem, ...elem,
tableRowHeights: rowHeights, tableRowHeights: scaleTracksToTotal(getTableRowHeights(elem), innerH),
tableColWidths: colWidths, tableColWidths: scaleTracksToTotal(getTableColWidths(elem), innerW),
height: round1(rowHeights.reduce((a, b) => a + b, 0) + pad.top + pad.bottom), width: elem.width,
width: round1(colWidths.reduce((a, b) => a + b, 0) + pad.left + pad.right), height: elem.height,
}; };
} }
@@ -183,6 +214,53 @@ export function getCellRectMm(
return { x: round1(x), y: round1(y), width: round1(width), height: round1(height) }; return { x: round1(x), y: round1(y), width: round1(width), height: round1(height) };
} }
/** 表格行列像素边界(累积取整,避免单元格背景/选区出现缝隙) */
export function buildTableGridBoundariesPx(
elem: TemplateElement,
scale: number,
originX = 0,
originY = 0
): { rowBounds: number[]; colBounds: number[] } {
const pad = resolvePaddingMm(elem);
const rowHeights = getTableRowHeights(elem);
const colWidths = getTableColWidths(elem);
const rowBounds: number[] = [originY + Math.round(pad.top * scale)];
for (const h of rowHeights) {
rowBounds.push(rowBounds[rowBounds.length - 1] + Math.round(h * scale));
}
const colBounds: number[] = [originX + Math.round(pad.left * scale)];
for (const w of colWidths) {
colBounds.push(colBounds[colBounds.length - 1] + Math.round(w * scale));
}
return { rowBounds, colBounds };
}
/** 单元格像素矩形,与 canvas 绘制及设计器选区 overlay 对齐 */
export function getCellRectPx(
elem: TemplateElement,
row: number,
col: number,
scale: number,
originX = 0,
originY = 0
): { x: number; y: number; width: number; height: number } {
const { rowBounds, colBounds } = buildTableGridBoundariesPx(elem, scale, originX, originY);
const anchor = getCellAnchor(elem, row, col);
const { rowSpan, colSpan } = getCellSpan(elem, anchor.row, anchor.col);
const x = colBounds[anchor.col];
const y = rowBounds[anchor.row];
return {
x,
y,
width: colBounds[anchor.col + colSpan] - x,
height: rowBounds[anchor.row + rowSpan] - y,
};
}
export function createDefaultTableElement(id: string, index: number): TemplateElement { export function createDefaultTableElement(id: string, index: number): TemplateElement {
const rows = 3; const rows = 3;
const cols = 3; const cols = 3;
@@ -224,28 +302,6 @@ export function resizeTableGrid(
): TemplateElement { ): TemplateElement {
const rows = Math.max(1, Math.min(50, newRows)); const rows = Math.max(1, Math.min(50, newRows));
const cols = Math.max(1, Math.min(50, newCols)); const cols = Math.max(1, Math.min(50, newCols));
const oldRows = getTableRows(elem);
const oldCols = getTableCols(elem);
const { width, height } = getTableInnerSizeMm(elem);
let rowHeights = getTableRowHeights(elem);
let colWidths = getTableColWidths(elem);
if (rows > oldRows) {
const added = rows - oldRows;
const avg = rowHeights.reduce((a, b) => a + b, 0) / oldRows;
rowHeights = [...rowHeights, ...Array.from({ length: added }, () => round1(avg))];
} else if (rows < oldRows) {
rowHeights = rowHeights.slice(0, rows);
}
if (cols > oldCols) {
const added = cols - oldCols;
const avg = colWidths.reduce((a, b) => a + b, 0) / oldCols;
colWidths = [...colWidths, ...Array.from({ length: added }, () => round1(avg))];
} else if (cols < oldCols) {
colWidths = colWidths.slice(0, cols);
}
const newCells: Record<string, TableCellData> = {}; const newCells: Record<string, TableCellData> = {};
if (elem.tableCells) { if (elem.tableCells) {
@@ -259,15 +315,18 @@ export function resizeTableGrid(
} }
const pad = resolvePaddingMm(elem); const pad = resolvePaddingMm(elem);
const innerW = Math.max(1, elem.width - pad.left - pad.right);
const innerH = Math.max(1, elem.height - pad.top - pad.bottom);
const next: TemplateElement = { const next: TemplateElement = {
...elem, ...elem,
tableRows: rows, tableRows: rows,
tableCols: cols, tableCols: cols,
tableRowHeights: rowHeights, tableRowHeights: distributeTracks(innerH, rows),
tableColWidths: colWidths, tableColWidths: distributeTracks(innerW, cols),
tableCells: newCells, tableCells: newCells,
width: round1(colWidths.reduce((a, b) => a + b, 0) + pad.left + pad.right), width: elem.width,
height: round1(rowHeights.reduce((a, b) => a + b, 0) + pad.top + pad.bottom), height: elem.height,
}; };
return sanitizeTableMerges(next); return sanitizeTableMerges(next);
} }

View File

@@ -133,6 +133,10 @@ export interface TemplateElement {
locked?: boolean; locked?: boolean;
/** 图片缩放方式,仅 type=image 时有效 */ /** 图片缩放方式,仅 type=image 时有效 */
imageFit?: 'contain' | 'cover' | 'fill'; imageFit?: 'contain' | 'cover' | 'fill';
/** 默认图片 URL 或 data URI主图加载失败时使用仅 type=image 时有效 */
defaultImage?: string;
/** 输出模式下内容值为空时仍渲染;条码/二维码/图片有效,默认 false */
showWhenContentEmpty?: boolean;
/** 文本显示格式,仅 type=text 时有效 */ /** 文本显示格式,仅 type=text 时有效 */
textFormat?: TextFormat; textFormat?: TextFormat;
/** 数值/百分比/货币的小数位数,默认 2 */ /** 数值/百分比/货币的小数位数,默认 2 */

View File

@@ -524,7 +524,7 @@ export function extractTemplateVariables(template: {
vars.add(v); vars.add(v);
} }
for (const rule of resolveVisibilityRules(elem)) { for (const rule of resolveVisibilityRules(elem)) {
const name = rule.variable.trim(); const name = (rule.variable ?? '').trim();
if (name) vars.add(name); if (name) vars.add(name);
} }
} }
@@ -539,7 +539,7 @@ export function getUsedTemplateVariables(template: { elements: TemplateElement[]
vars.add(v); vars.add(v);
} }
for (const rule of resolveVisibilityRules(elem)) { for (const rule of resolveVisibilityRules(elem)) {
const name = rule.variable.trim(); const name = (rule.variable ?? '').trim();
if (name) vars.add(name); if (name) vars.add(name);
} }
} }
@@ -613,7 +613,7 @@ export function resolveVisibilityRules(elem: TemplateElement): VisibilityRule[]
return elem.visibilityRules return elem.visibilityRules
.map((r) => ({ .map((r) => ({
target: r.target ?? 'variable', target: r.target ?? 'variable',
variable: r.variable.trim(), variable: (r.variable ?? '').trim(),
operator: r.operator, operator: r.operator,
value: r.value, value: r.value,
})) }))
@@ -1021,7 +1021,7 @@ export function resolveElementValue(
return applyTextDisplayAffix(core, elem); return applyTextDisplayAffix(core, elem);
} }
/** 输出模式下条码/二维码/图片内容为空时不渲染 */ /** 输出模式下条码/二维码/图片内容为空时不渲染(除非 showWhenContentEmpty 为 true */
export function isGraphicContentEmpty( export function isGraphicContentEmpty(
elem: TemplateElement, elem: TemplateElement,
value: string, value: string,
@@ -1029,7 +1029,9 @@ export function isGraphicContentEmpty(
): boolean { ): boolean {
if (mode === 'design') return false; if (mode === 'design') return false;
if (elem.type !== 'barcode' && elem.type !== 'qrcode' && elem.type !== 'image') return false; if (elem.type !== 'barcode' && elem.type !== 'qrcode' && elem.type !== 'image') return false;
return !value || !String(value).trim(); const empty = !value || !String(value).trim();
if (!empty) return false;
return !elem.showWhenContentEmpty;
} }
// Generate columns and rows based on paper dimensions, label dimensions, gaps, and margins // Generate columns and rows based on paper dimensions, label dimensions, gaps, and margins