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

View File

@@ -482,7 +482,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
<div className="space-y-3">
<div className="text-[10px] font-bold text-[#31a8ff]"></div>
<p className="text-[10px] text-[#666] leading-relaxed mb-3">
</p>
{templateVariables.map((v) => {
const inUse = isTemplateVariableInUse(template, v);
@@ -501,7 +501,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
type="text"
value={template.variableDefaults?.[v] ?? ''}
onChange={(e) => setVariableDefault(v, e.target.value)}
placeholder="预览默认值"
placeholder="预览值"
className="ps-field font-mono text-[10px] w-full min-w-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' && (
<>
<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
value={selectedElem.content}
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' && (
<p className="text-[10px] text-[#777] leading-normal">
@@ -1407,6 +1443,45 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
<option value="fill"> (fill)</option>
</FieldSelect>
</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>
)}

View File

@@ -58,7 +58,17 @@ export const TableCellTextFields: React.FC<TableCellTextFieldsProps> = ({
)}
<div>
<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
value={textElem.content}
rows={2}

View File

@@ -242,7 +242,17 @@ export const TableCellContentFields: React.FC<TableCellContentFieldsProps> = ({
return (
<div className="space-y-2 pt-2 border-t border-[#3a3a3a]">
<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
value={content}
rows={2}

View File

@@ -19,14 +19,13 @@ import {
} from './elementBox';
import {
iterTableCells,
getCellRectMm,
getCellAnchor,
getCellData,
getEffectiveCellStyle,
getTableRows,
getTableCols,
getTableRowHeights,
getTableColWidths,
buildTableGridBoundariesPx,
getCellRectPx,
} from './tableUtils';
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(
ctx: CanvasRenderingContext2D,
img: HTMLImageElement,
@@ -226,7 +253,7 @@ function layoutVerticalTextColumns(
return columns;
}
function drawTextInRect(
function drawTextContentInBox(
ctx: CanvasRenderingContext2D,
elem: TemplateElement,
boxX: number,
@@ -236,9 +263,15 @@ function drawTextInRect(
value: string,
scale: number
) {
const pad = 2 * scale;
const writableW = Math.max(1, boxW - pad * 2);
const writableH = Math.max(1, boxH - pad * 2);
const pad = resolvePaddingMm(elem);
const padPx = {
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 fontWeight = elem.fontWeight === 'bold' ? 'bold ' : '';
@@ -267,8 +300,8 @@ function drawTextInRect(
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 = boxX + boxW / 2;
const transformOriginY = boxY + boxH / 2;
const transformOriginX = boxX + padPx.left + writableW / 2;
const transformOriginY = boxY + padPx.top + writableH / 2;
ctx.save();
if (textScaleX !== 1 || textScaleY !== 1) {
@@ -285,11 +318,11 @@ function drawTextInRect(
let startX: number;
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') {
startX = boxX + pad + Math.max(0, writableW - totalColumnsWidth) + columnWidth / 2;
startX = boxX + padPx.left + Math.max(0, writableW - totalColumnsWidth) + columnWidth / 2;
} else {
startX = boxX + pad + columnWidth / 2;
startX = boxX + padPx.left + columnWidth / 2;
}
ctx.textAlign = 'center';
@@ -298,17 +331,18 @@ function drawTextInRect(
const colTextHeight = column.length * lineHeight;
let colStartY: number;
if (vAlign === 'top') {
colStartY = boxY + pad + lineHeight / 2;
colStartY = boxY + padPx.top + lineHeight / 2;
} else if (vAlign === 'bottom') {
colStartY = boxY + boxH - pad - colTextHeight + lineHeight / 2;
colStartY = boxY + boxH - padPx.bottom - colTextHeight + lineHeight / 2;
} 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;
for (let i = 0; i < column.length; i++) {
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);
}
}
@@ -317,31 +351,31 @@ function drawTextInRect(
const lines = layoutHorizontalTextLines(ctx, String(value), writableW, wordWrap);
const totalTextHeight = lines.length * lineHeight;
let textX = boxX + pad;
let textX = boxX + padPx.left;
let textAlign: CanvasTextAlign = 'left';
if (elem.textAlign === 'center') {
textX = boxX + boxW / 2;
textX = boxX + padPx.left + writableW / 2;
textAlign = 'center';
} else if (elem.textAlign === 'right') {
textX = boxX + boxW - pad;
textX = boxX + padPx.left + (writableW - 2);
textAlign = 'right';
} else {
textX = boxX + pad + 2;
textX = boxX + padPx.left + 2;
}
ctx.textAlign = textAlign;
let startY: number;
if (vAlign === 'top') {
startY = boxY + pad + lineHeight / 2;
startY = boxY + padPx.top + lineHeight / 2;
} else if (vAlign === 'bottom') {
startY = boxY + boxH - pad - totalTextHeight + lineHeight / 2;
startY = boxY + boxH - padPx.bottom - totalTextHeight + lineHeight / 2;
} 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++) {
const lineY = startY + i * lineHeight;
if (lineY - lineHeight / 2 <= boxY + boxH - pad) {
if (lineY - lineHeight / 2 <= boxY + boxH - padPx.bottom) {
const line = lines[i];
ctx.fillText(line, textX, lineY);
drawTextLineDecorations(ctx, elem, textX, lineY, line, fontSizePx, textAlign);
@@ -376,19 +410,12 @@ function drawTableGridLines(
const borderColor = elem.tableBorderColor ?? '#374151';
const rows = getTableRows(elem);
const cols = getTableCols(elem);
const rowHeights = getTableRowHeights(elem);
const colWidths = getTableColWidths(elem);
const pad = resolvePaddingMm(elem);
const { rowBounds, colBounds } = buildTableGridBoundariesPx(elem, scale, ex, ey);
const x0 = pad.left;
const y0 = pad.top;
const totalW = colWidths.reduce((a, b) => a + b, 0);
const totalH = rowHeights.reduce((a, b) => a + b, 0);
const left = ex + x0 * scale;
const top = ey + y0 * scale;
const width = totalW * scale;
const height = totalH * scale;
const left = colBounds[0];
const top = rowBounds[0];
const width = colBounds[colBounds.length - 1] - left;
const height = rowBounds[rowBounds.length - 1] - top;
ctx.strokeStyle = borderColor;
ctx.lineWidth = borderW;
@@ -402,45 +429,31 @@ function drawTableGridLines(
Math.max(0, height - borderW)
);
let yAcc = y0;
for (let r = 1; r < rows; r++) {
yAcc += rowHeights[r - 1];
const y = alignStrokeCoord(ey + yAcc * scale);
let xAcc = x0;
const y = alignStrokeCoord(rowBounds[r]);
for (let c = 0; c < cols; c++) {
const w = colWidths[c];
const anchorAbove = getCellAnchor(elem, r - 1, c);
const anchorBelow = getCellAnchor(elem, r, c);
const sameMerge =
anchorAbove.row === anchorBelow.row && anchorAbove.col === anchorBelow.col;
if (!sameMerge) {
const x1 = alignStrokeCoord(ex + xAcc * scale);
const x2 = alignStrokeCoord(ex + (xAcc + w) * scale);
ctx.moveTo(x1, y);
ctx.lineTo(x2, y);
ctx.moveTo(alignStrokeCoord(colBounds[c]), y);
ctx.lineTo(alignStrokeCoord(colBounds[c + 1]), y);
}
xAcc += w;
}
}
let xAcc = x0;
for (let c = 1; c < cols; c++) {
xAcc += colWidths[c - 1];
const x = alignStrokeCoord(ex + xAcc * scale);
let yAcc2 = y0;
const x = alignStrokeCoord(colBounds[c]);
for (let r = 0; r < rows; r++) {
const h = rowHeights[r];
const anchorLeft = getCellAnchor(elem, r, c - 1);
const anchorRight = getCellAnchor(elem, r, c);
const sameMerge =
anchorLeft.row === anchorRight.row && anchorLeft.col === anchorRight.col;
if (!sameMerge) {
const y1 = alignStrokeCoord(ey + yAcc2 * scale);
const y2 = alignStrokeCoord(ey + (yAcc2 + h) * scale);
ctx.moveTo(x, y1);
ctx.lineTo(x, y2);
ctx.moveTo(x, alignStrokeCoord(rowBounds[r]));
ctx.lineTo(x, alignStrokeCoord(rowBounds[r + 1]));
}
yAcc2 += h;
}
}
@@ -466,19 +479,26 @@ async function drawTableElement(
drawElementFill(ctx, ex, ey, ew, eh, elem, radii);
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.beginPath();
ctx.rect(inner.x, inner.y, inner.w, inner.h);
ctx.rect(gridX, gridY, gridW, gridH);
ctx.clip();
const anchors = iterTableCells(elem);
for (const { row, col } of anchors) {
const rect = getCellRectMm(elem, row, col);
const cellX = ex + Math.round(rect.x * scale);
const cellY = ey + Math.round(rect.y * scale);
const cellW = Math.round(rect.width * scale);
const cellH = Math.round(rect.height * scale);
const { x: cellX, y: cellY, width: cellW, height: cellH } = getCellRectPx(
elem,
row,
col,
scale,
ex,
ey
);
const cellData = getCellData(elem, row, col);
if (cellData?.backgroundColor && cellData.backgroundColor !== 'transparent') {
@@ -493,7 +513,23 @@ async function drawTableElement(
const displayText = String(cellValue ?? '').trim();
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);
@@ -561,8 +597,8 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
const scale = scaleOption;
const canvas = document.createElement('canvas');
canvas.width = Math.max(50, Math.round(widthMm * scale));
canvas.height = Math.max(50, Math.round(heightMm * scale));
canvas.width = Math.max(1, Math.round(widthMm * scale));
canvas.height = Math.max(1, Math.round(heightMm * scale));
const ctx = canvas.getContext('2d');
if (!ctx) return '';
@@ -618,133 +654,8 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
drawElementFill(ctx, ex, ey, ew, eh, elem, radii);
const padPx = (() => {
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);
drawTextContentInBox(ctx, elem, ex, ey, ew, eh, String(value), scale);
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();
drawElementBorders(ctx, ex, ey, ew, eh, radii, sides, elem, scale);
} else if (elem.type === 'barcode') {
@@ -813,22 +724,20 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
}
}
} 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);
const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale);
const src = value ? String(value).trim() : '';
if (!src) {
if (mode === 'design') {
drawImagePlaceholder(ctx, inner.x, inner.y, inner.w, inner.h, '图片');
}
} else {
try {
const img = await loadImage(src);
if (img) {
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 {
drawImagePlaceholder(ctx, inner.x, inner.y, inner.w, inner.h, '图片');
}
} else if (elem.type === 'table') {
await drawTableElement(

View File

@@ -56,18 +56,49 @@ export function getTableColWidths(elem: TemplateElement): number[] {
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 {
if (elem.type !== 'table') return elem;
const pad = resolvePaddingMm(elem);
const rowHeights = getTableRowHeights(elem);
const colWidths = getTableColWidths(elem);
const innerW = Math.max(1, elem.width - pad.left - pad.right);
const innerH = Math.max(1, elem.height - pad.top - pad.bottom);
return {
...elem,
tableRowHeights: rowHeights,
tableColWidths: colWidths,
height: round1(rowHeights.reduce((a, b) => a + b, 0) + pad.top + pad.bottom),
width: round1(colWidths.reduce((a, b) => a + b, 0) + pad.left + pad.right),
tableRowHeights: scaleTracksToTotal(getTableRowHeights(elem), innerH),
tableColWidths: scaleTracksToTotal(getTableColWidths(elem), innerW),
width: elem.width,
height: elem.height,
};
}
@@ -183,6 +214,53 @@ export function getCellRectMm(
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 {
const rows = 3;
const cols = 3;
@@ -224,28 +302,6 @@ export function resizeTableGrid(
): TemplateElement {
const rows = Math.max(1, Math.min(50, newRows));
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> = {};
if (elem.tableCells) {
@@ -259,15 +315,18 @@ export function resizeTableGrid(
}
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 = {
...elem,
tableRows: rows,
tableCols: cols,
tableRowHeights: rowHeights,
tableColWidths: colWidths,
tableRowHeights: distributeTracks(innerH, rows),
tableColWidths: distributeTracks(innerW, cols),
tableCells: newCells,
width: round1(colWidths.reduce((a, b) => a + b, 0) + pad.left + pad.right),
height: round1(rowHeights.reduce((a, b) => a + b, 0) + pad.top + pad.bottom),
width: elem.width,
height: elem.height,
};
return sanitizeTableMerges(next);
}

View File

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

View File

@@ -524,7 +524,7 @@ export function extractTemplateVariables(template: {
vars.add(v);
}
for (const rule of resolveVisibilityRules(elem)) {
const name = rule.variable.trim();
const name = (rule.variable ?? '').trim();
if (name) vars.add(name);
}
}
@@ -539,7 +539,7 @@ export function getUsedTemplateVariables(template: { elements: TemplateElement[]
vars.add(v);
}
for (const rule of resolveVisibilityRules(elem)) {
const name = rule.variable.trim();
const name = (rule.variable ?? '').trim();
if (name) vars.add(name);
}
}
@@ -613,7 +613,7 @@ export function resolveVisibilityRules(elem: TemplateElement): VisibilityRule[]
return elem.visibilityRules
.map((r) => ({
target: r.target ?? 'variable',
variable: r.variable.trim(),
variable: (r.variable ?? '').trim(),
operator: r.operator,
value: r.value,
}))
@@ -1021,7 +1021,7 @@ export function resolveElementValue(
return applyTextDisplayAffix(core, elem);
}
/** 输出模式下条码/二维码/图片内容为空时不渲染 */
/** 输出模式下条码/二维码/图片内容为空时不渲染(除非 showWhenContentEmpty 为 true */
export function isGraphicContentEmpty(
elem: TemplateElement,
value: string,
@@ -1029,7 +1029,9 @@ export function isGraphicContentEmpty(
): boolean {
if (mode === 'design') 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