表格支持

This commit is contained in:
iqudoo
2026-06-13 13:50:42 +08:00
parent 318569c757
commit 38b17c7589
10 changed files with 1933 additions and 56 deletions

View File

@@ -18,6 +18,7 @@ import { PropSidebar } from './components/PropSidebar';
import { ExportSidebar } from './components/ExportSidebar';
import { MobileExportView } from './components/MobileExportView';
import { MobileDesignView } from './components/MobileDesignView';
import type { TableCellCoord } from './tableUtils';
import { LayoutPreview } from './components/PreviewGrid';
import { CanvasLabelImage } from './components/CanvasLabelImage';
import { renderLabelToDataUrl } from './labelRenderer';
@@ -145,6 +146,7 @@ export default function App() {
// --- 5. Selection and Active item states ---
const [selectedElementIds, setSelectedElementIds] = useState<string[]>([]);
const [selectedTableCells, setSelectedTableCells] = useState<TableCellCoord[]>([]);
const [activeRowIndex, setActiveRowIndex] = useState<number>(0);
// Modal / Inline input values for template creations
const [showAddTmplForm, setShowAddTmplForm] = useState<boolean>(false);
@@ -215,6 +217,21 @@ export default function App() {
handleTemplateChange(next);
}, [activeTemplate, designHistory, templates]);
const handleSelectElements = useCallback(
(ids: string[]) => {
setSelectedElementIds(ids);
if (ids.length !== 1) {
setSelectedTableCells([]);
return;
}
const el = activeTemplate?.elements.find((e) => e.id === ids[0]);
if (!el || el.type !== 'table') {
setSelectedTableCells([]);
}
},
[activeTemplate]
);
useEffect(() => {
if (workflowStep !== 'template-design' || !activeTemplate?.id) return;
const needReset =
@@ -1131,7 +1148,9 @@ export default function App() {
template={activeTemplate}
paper={paper}
selectedElementIds={selectedElementIds}
onSelectElements={setSelectedElementIds}
onSelectElements={handleSelectElements}
selectedTableCells={selectedTableCells}
onSelectTableCells={setSelectedTableCells}
onChangeTemplate={handleDesignTemplateChange}
canUndo={designHistory.canUndo}
canRedo={designHistory.canRedo}
@@ -1198,13 +1217,17 @@ export default function App() {
template={activeTemplate}
onChange={handleDesignTemplateChange}
selectedElementIds={selectedElementIds}
onSelectElements={setSelectedElementIds}
onSelectElements={handleSelectElements}
selectedTableCells={selectedTableCells}
onSelectTableCells={setSelectedTableCells}
/>
<PropSidebar
template={activeTemplate}
onChangeTemplate={handleDesignTemplateChange}
selectedElementIds={selectedElementIds}
onSelectElements={setSelectedElementIds}
onSelectElements={handleSelectElements}
selectedTableCells={selectedTableCells}
onSelectTableCells={setSelectedTableCells}
activeTab="element"
onChangeTab={() => { }}
paper={paper}

View File

@@ -2,6 +2,17 @@ import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import { createPortal } from 'react-dom';
import { LabelTemplate, TemplateElement } from '../types';
import { mmToPx, MM_TO_PX, shouldRenderElement } from '../utils';
import {
createDefaultTableElement,
getTableColWidths,
getTableRowHeights,
iterTableCells,
getCellRectMm,
toggleTableCellSelection,
scaleTableTracks,
getCellAnchor,
type TableCellCoord,
} from '../tableUtils';
import { useIsNarrowScreen } from '../hooks/useIsMobile';
import { CanvasLabelImage } from './CanvasLabelImage';
import { ElementFloatPreview, renderElementFloatPreview } from './ElementFloatPreview';
@@ -16,6 +27,7 @@ import {
Image as ImageIcon,
Move,
Trash2,
Table2,
} from 'lucide-react';
interface LabelDesignerProps {
@@ -23,12 +35,14 @@ interface LabelDesignerProps {
onChange: (updated: LabelTemplate) => void;
selectedElementIds: string[];
onSelectElements: (ids: string[]) => void;
selectedTableCells?: TableCellCoord[];
onSelectTableCells?: (cells: TableCellCoord[] | ((prev: TableCellCoord[]) => TableCellCoord[])) => void;
variant?: 'desktop' | 'mobile';
/** 微调等场景下隐藏选区框与缩放手柄 */
suppressSelectionChrome?: boolean;
}
type ActiveTool = 'move' | 'text' | 'barcode' | 'qrcode';
type ActiveTool = 'move' | 'text' | 'barcode' | 'qrcode' | 'table';
type ResizeCorner = 'nw' | 'ne' | 'sw' | 'se';
const MIN_ELEM_W = 2;
@@ -194,6 +208,8 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
onChange,
selectedElementIds,
onSelectElements,
selectedTableCells = [],
onSelectTableCells,
variant = 'desktop',
suppressSelectionChrome = false,
}) => {
@@ -256,6 +272,8 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
startElemY: number;
startElemW: number;
startElemH: number;
startTableRowHeights?: number[];
startTableColWidths?: number[];
groupStartPositions?: Record<string, { x: number; y: number }>;
} | null>(null);
@@ -269,6 +287,15 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
const ignoreCanvasClickRef = useRef(false);
const dragMovedRef = useRef(false);
const tableCellPointerRef = useRef<{
row: number;
col: number;
elementId: string;
startX: number;
startY: number;
pointerId: number;
dragStarted: boolean;
} | null>(null);
const multiTouchActiveRef = useRef(false);
const touchPointerMapRef = useRef<Map<number, { x: number; y: number }>>(new Map());
const marqueeRef = useRef(marqueeState);
@@ -297,7 +324,8 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
barcodeFormat: 'CODE128',
showText: false,
fontFamily: 'sans',
backgroundColor: 'transparent',
backgroundColor: '#ffffff',
backgroundOpacity: 0,
imageFit: 'contain',
};
onChange({
@@ -311,12 +339,14 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
);
const addElement = useCallback(
(type: 'text' | 'barcode' | 'qrcode') => {
(type: 'text' | 'barcode' | 'qrcode' | 'table') => {
let newElement: TemplateElement;
const id = `${type}_${Date.now()}`;
const n = template.elements.length + 1;
if (type === 'text') {
if (type === 'table') {
newElement = createDefaultTableElement(id, n);
} else if (type === 'text') {
newElement = {
id,
type: 'text',
@@ -334,7 +364,8 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
barcodeFormat: 'CODE128',
showText: false,
fontFamily: 'sans',
backgroundColor: 'transparent',
backgroundColor: '#ffffff',
backgroundOpacity: 0,
};
} else if (type === 'barcode') {
newElement = {
@@ -352,7 +383,8 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
barcodeFormat: 'CODE128',
showText: true,
fontFamily: 'mono',
backgroundColor: 'transparent',
backgroundColor: '#ffffff',
backgroundOpacity: 0,
};
} else {
newElement = {
@@ -370,7 +402,8 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
barcodeFormat: 'CODE128',
showText: false,
fontFamily: 'sans',
backgroundColor: 'transparent',
backgroundColor: '#ffffff',
backgroundOpacity: 0,
};
}
@@ -883,11 +916,25 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
corner
);
const updated = template.elements.map((el) =>
el.id === dragState.elementId
? { ...el, x: rect.x, y: rect.y, width: rect.w, height: rect.h }
: el
const updated = template.elements.map((el) => {
if (el.id !== dragState.elementId) return el;
if (
el.type === 'table' &&
dragState.startTableRowHeights &&
dragState.startTableColWidths
) {
return scaleTableTracks(
{ ...el, x: rect.x, y: rect.y },
rect.w,
rect.h,
dragState.startElemW,
dragState.startElemH,
dragState.startTableRowHeights,
dragState.startTableColWidths
);
}
return { ...el, x: rect.x, y: rect.y, width: rect.w, height: rect.h };
});
pendingDragElementsRef.current = updated;
const paintInteractionFloat = () => {
@@ -1109,6 +1156,71 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
});
};
const handleTableCellPointerDown = (
e: React.PointerEvent,
element: TemplateElement,
row: number,
col: number
) => {
if (e.button !== 0 || shouldIgnoreTouchPointer(e) || element.locked) return;
e.stopPropagation();
e.currentTarget.setPointerCapture(e.pointerId);
tableCellPointerRef.current = {
row,
col,
elementId: element.id,
startX: e.clientX,
startY: e.clientY,
pointerId: e.pointerId,
dragStarted: false,
};
};
const handleTableCellPointerMove = (
e: React.PointerEvent,
element: TemplateElement
) => {
const pending = tableCellPointerRef.current;
if (!pending || pending.pointerId !== e.pointerId || pending.elementId !== element.id) {
return;
}
if (pending.dragStarted || element.locked) return;
const dx = e.clientX - pending.startX;
const dy = e.clientY - pending.startY;
if (
Math.abs(dx) > DRAG_START_THRESHOLD_PX ||
Math.abs(dy) > DRAG_START_THRESHOLD_PX
) {
pending.dragStarted = true;
dragMovedRef.current = true;
tableCellPointerRef.current = null;
try {
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
} catch {
/* ignore */
}
handleDragStart(e, element, 'drag');
}
};
const handleTableCellPointerUp = (e: React.PointerEvent) => {
const pending = tableCellPointerRef.current;
if (!pending || pending.pointerId !== e.pointerId) return;
if (!pending.dragStarted && onSelectTableCells) {
onSelectTableCells((prev) =>
toggleTableCellSelection(prev, { row: pending.row, col: pending.col })
);
}
tableCellPointerRef.current = null;
try {
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
} catch {
/* ignore */
}
};
const handleResizeStart = (
e: React.PointerEvent,
element: TemplateElement,
@@ -1135,6 +1247,10 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
startElemY: element.y,
startElemW: element.width,
startElemH: element.height,
startTableRowHeights:
element.type === 'table' ? getTableRowHeights(element) : undefined,
startTableColWidths:
element.type === 'table' ? getTableColWidths(element) : undefined,
});
};
@@ -1300,8 +1416,9 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
const overlayElements = filterVisibleElements(template.elements);
const tools: { id: ActiveTool; icon: React.ReactNode; label: string; shortcut?: string }[] = [
{ id: 'move', icon: <Move className="w-[18px] h-[18px]" />, label: '移动工具', shortcut: 'V' },
{ id: 'text', icon: <Type className="w-[18px] h-[18px]" />, label: '文本工具', shortcut: 'T' },
{ id: 'move', icon: <Move className="w-[18px] h-[18px]" />, label: '移动', shortcut: 'V' },
{ id: 'text', icon: <Type className="w-[18px] h-[18px]" />, label: '文本', shortcut: 'T' },
{ id: 'table', icon: <Table2 className="w-[18px] h-[18px]" />, label: '表格' },
{ id: 'barcode', icon: <Barcode className="w-[18px] h-[18px]" />, label: '条形码' },
{ id: 'qrcode', icon: <QrCode className="w-[18px] h-[18px]" />, label: '二维码' },
];
@@ -1734,6 +1851,52 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
{element.name}
</div>
)}
{isSelected &&
!hideSelectionChrome &&
!isLocked &&
selectedElementIds.length === 1 &&
overlayElement.type === 'table' &&
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 isCellSelected = selectedTableCells.some((c) => {
const anchor = getCellAnchor(overlayElement, c.row, c.col);
return anchor.row === row && anchor.col === col;
});
return (
<div
key={`cell-${row}-${col}`}
className={`absolute pointer-events-auto cursor-grab active:cursor-grabbing border touch-none ${
isCellSelected
? 'bg-[#31a8ff]/25 border-[#31a8ff]'
: 'border-transparent hover:bg-[#31a8ff]/10 hover:border-[#31a8ff]/40'
}`}
style={{
left: `${cellX}px`,
top: `${cellY}px`,
width: `${cellW}px`,
height: `${cellH}px`,
}}
onPointerDown={(e) =>
handleTableCellPointerDown(e, overlayElement, row, col)
}
onPointerMove={(e) =>
handleTableCellPointerMove(e, overlayElement)
}
onPointerUp={(e) => handleTableCellPointerUp(e)}
onPointerCancel={(e) => handleTableCellPointerUp(e)}
title={`单元格 ${row + 1},${col + 1}(拖动移动表格)`}
/>
);
})}
</div>
)}
</div>
);
})}

View File

@@ -1,6 +1,7 @@
import React, { useState } from 'react';
import { ChevronLeft, Printer, Undo2, Redo2 } from 'lucide-react';
import { LabelTemplate, PaperConfig } from '../types';
import type { TableCellCoord } from '../tableUtils';
import { LabelDesigner } from './LabelDesigner';
import { PropSidebar, MobilePropModule } from './PropSidebar';
@@ -9,6 +10,8 @@ interface MobileDesignViewProps {
paper: PaperConfig;
selectedElementIds: string[];
onSelectElements: (ids: string[]) => void;
selectedTableCells: TableCellCoord[];
onSelectTableCells: (cells: TableCellCoord[]) => void;
onChangeTemplate: (updated: LabelTemplate) => void;
canUndo: boolean;
canRedo: boolean;
@@ -23,6 +26,8 @@ export const MobileDesignView: React.FC<MobileDesignViewProps> = ({
paper,
selectedElementIds,
onSelectElements,
selectedTableCells,
onSelectTableCells,
onChangeTemplate,
canUndo,
canRedo,
@@ -82,6 +87,8 @@ export const MobileDesignView: React.FC<MobileDesignViewProps> = ({
onChange={onChangeTemplate}
selectedElementIds={selectedElementIds}
onSelectElements={onSelectElements}
selectedTableCells={selectedTableCells}
onSelectTableCells={onSelectTableCells}
suppressSelectionChrome={mobileModule === 'nudge'}
/>
</div>
@@ -93,6 +100,8 @@ export const MobileDesignView: React.FC<MobileDesignViewProps> = ({
onChangeTemplate={onChangeTemplate}
selectedElementIds={selectedElementIds}
onSelectElements={onSelectElements}
selectedTableCells={selectedTableCells}
onSelectTableCells={onSelectTableCells}
activeTab="element"
onChangeTab={() => {}}
paper={paper}

View File

@@ -24,6 +24,13 @@ import { FieldSelect } from './PsSelect';
import { CommitInput } from './CommitInput';
import { useAppDialog } from './AppDialog';
import { useIsNarrowScreen } from '../hooks/useIsMobile';
import {
TablePropertyFields,
TableCellContentFields,
scaleTableElementSize,
} from './TablePropertyFields';
import type { TableCellCoord } from '../tableUtils';
import { expandTableForPadding } from '../tableUtils';
import {
Sliders,
LayoutGrid,
@@ -63,6 +70,7 @@ import {
QrCode,
Image as ImageIcon,
Upload,
Table2,
} from 'lucide-react';
const PANEL_COLLAPSE_STORAGE_KEY = 'label_designer_panel_collapse_v2';
@@ -166,6 +174,8 @@ interface PropSidebarProps {
onChangeTab: (tab: 'element' | 'paper') => void;
paper: PaperConfig;
onSelectElements?: (ids: string[]) => void;
selectedTableCells?: TableCellCoord[];
onSelectTableCells?: (cells: TableCellCoord[]) => void;
variant?: 'desktop' | 'mobile';
mobileModule?: MobilePropModule;
onMobileModuleChange?: (module: MobilePropModule) => void;
@@ -186,6 +196,8 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
onChangeTab,
paper,
onSelectElements,
selectedTableCells = [],
onSelectTableCells,
variant = 'desktop',
mobileModule: mobileModuleProp,
onMobileModuleChange,
@@ -266,6 +278,14 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
});
};
const commitTableAwareChange = (updatedElem: TemplateElement) => {
if (updatedElem.type === 'table') {
handleElemChange(expandTableForPadding(updatedElem));
} else {
handleElemChange(updatedElem);
}
};
const nudgeableElementIds = useMemo(() => {
const idSet = new Set(selectedElementIds);
return template.elements
@@ -639,6 +659,8 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
</label>
</div>
)}
{selectedElem.type !== 'table' && (
<>
<PropTextarea
value={selectedElem.content}
onCommit={(val) => handleElemChange({ ...selectedElem, content: val })}
@@ -667,15 +689,34 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
</div>
</div>
)}
</>
)}
{selectedElem.type === 'table' && (
<p className="text-[10px] text-[#777] leading-normal">
</p>
)}
{(selectedElem.type === 'text' ||
selectedElem.type === 'barcode' ||
selectedElem.type === 'qrcode' ||
selectedElem.type === 'image') && (
selectedElem.type === 'image' ||
selectedElem.type === 'table') && (
<div className="pt-2 border-t border-[#3a3a3a]">
{selectedElem.type === 'table' ? (
<TableCellContentFields
elem={selectedElem}
selectedCells={selectedTableCells}
onChange={handleElemChange}
templateVariables={templateVariables}
/>
) : (
<>
<ValueExtractFields elem={selectedElem} onChange={handleElemChange} />
{selectedElem.type === 'text' && (
<TextDisplayAffixFields elem={selectedElem} onChange={handleElemChange} />
)}
</>
)}
</div>
)}
{renderVariableManagementBlock()}
@@ -954,6 +995,15 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
</div>
{/* Text Settings */}
{selectedElem.type === 'table' && selectedElementIds.length === 1 && (
<TablePropertyFields
elem={selectedElem}
selectedCells={selectedTableCells}
onChange={handleElemChange}
onSelectTableCells={onSelectTableCells}
templateVariables={templateVariables}
/>
)}
{selectedElem.type === 'text' && (
<div className="space-y-3 ps-section-divider">
<h5 className="ps-section-title">
@@ -1406,9 +1456,11 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
value={selectedElem.width}
onCommit={(val) => {
const wVal = Math.max(2, Number(val) || 2);
const updated = { ...selectedElem, width: wVal };
let updated = { ...selectedElem, width: wVal };
if (selectedElem.type === 'qrcode') {
updated.height = wVal;
} else if (selectedElem.type === 'table') {
updated = scaleTableElementSize(updated, wVal, updated.height);
}
handleElemChange(updated);
}}
@@ -1425,7 +1477,11 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
disabled={selectedElem.type === 'qrcode'}
onCommit={(val) => {
const hVal = Math.max(2, Number(val) || 2);
if (selectedElem.type === 'table') {
handleElemChange(scaleTableElementSize(selectedElem, selectedElem.width, hVal));
} else {
handleElemChange({ ...selectedElem, height: hVal });
}
}}
className={`ps-field font-mono ${selectedElem.type === 'qrcode' ? 'opacity-50 select-none' : ''
}`}
@@ -1508,7 +1564,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
if (v === 'transparent' || v === '') {
handleElemChange({
...selectedElem,
backgroundColor: 'transparent',
backgroundColor: '#ffffff',
backgroundOpacity: 0,
});
} else {
@@ -1573,7 +1629,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
step="0.5"
value={selectedElem.padding ?? 0}
onCommit={(val) =>
handleElemChange({
commitTableAwareChange({
...selectedElem,
padding: Math.max(0, Math.min(10, Number(val) || 0)),
})
@@ -1606,7 +1662,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
} else {
updated[key] = Math.max(0, Math.min(10, Number(val) || 0));
}
handleElemChange(updated);
commitTableAwareChange(updated);
}}
className="ps-field font-mono text-[10px] flex-1 min-w-0"
placeholder="—"
@@ -1863,6 +1919,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
{elem.type === 'barcode' && <Barcode className="w-3 h-3 text-[#666]" />}
{elem.type === 'qrcode' && <QrCode className="w-3 h-3 text-[#666]" />}
{elem.type === 'image' && <ImageIcon className="w-3 h-3 text-[#666]" />}
{elem.type === 'table' && <Table2 className="w-3 h-3 text-[#666]" />}
</span>
<span className="truncate" title={elem.name}>
{elem.name}

View File

@@ -0,0 +1,406 @@
import React from 'react';
import { TemplateElement } from '../types';
import { CommitInput } from './CommitInput';
import { FieldSelect } from './PsSelect';
import { ValueExtractFields } from './ValueExtractFields';
import { TextDisplayAffixFields } from './TextDisplayAffixFields';
import {
applyCellTextElementChange,
getCellSpan,
getEffectiveCellStyle,
} from '../tableUtils';
import {
Type,
Bold,
Italic,
Underline,
Strikethrough,
AlignLeft,
AlignCenter,
AlignRight,
AlignVerticalJustifyStart,
AlignVerticalJustifyCenter,
AlignVerticalJustifyEnd,
} from 'lucide-react';
const PropInput = CommitInput;
interface TableCellTextFieldsProps {
elem: TemplateElement;
row: number;
col: number;
onChange: (elem: TemplateElement) => void;
templateVariables?: string[];
}
export const TableCellTextFields: React.FC<TableCellTextFieldsProps> = ({
elem,
row,
col,
onChange,
templateVariables = [],
}) => {
const textElem = getEffectiveCellStyle(elem, row, col);
const span = getCellSpan(elem, row, col);
const patchText = (updated: TemplateElement) => {
onChange(applyCellTextElementChange(elem, row, col, updated));
};
return (
<div className="space-y-2 pt-2 border-t border-[#3a3a3a]">
<span className="text-[10px] font-semibold text-[#aaa] flex items-center gap-1">
<Type className="w-3 h-3" />
</span>
{(span.rowSpan > 1 || span.colSpan > 1) && (
<p className="text-[9px] text-[#888]"> {span.rowSpan}×{span.colSpan}</p>
)}
<div>
<label className="ps-field-label"></label>
<textarea
value={textElem.content}
rows={2}
className="ps-field resize-none"
placeholder="输入文本,使用 {变量名} 引用变量"
onChange={(e) => patchText({ ...textElem, content: e.target.value })}
/>
{templateVariables.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1.5">
{templateVariables.map((v) => (
<button
key={v}
type="button"
onClick={() => {
const token = `{${v}}`;
const content = textElem.content.includes(token)
? textElem.content
: textElem.content.trim()
? `${textElem.content}${textElem.content.endsWith(' ') ? '' : ' '}${token}`
: token;
patchText({ ...textElem, content });
}}
className="ps-btn text-[9px] font-mono"
>
{`{${v}}`}
</button>
))}
</div>
)}
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="ps-field-label"></label>
<FieldSelect
value={textElem.textFormat || 'text'}
onChange={(e) =>
patchText({
...textElem,
textFormat: e.target.value as 'text' | 'number' | 'percent' | 'currency',
})
}
className="ps-field"
>
<option value="text"></option>
<option value="number"></option>
<option value="percent"></option>
<option value="currency"> (¥)</option>
</FieldSelect>
</div>
{(textElem.textFormat === 'number' ||
textElem.textFormat === 'percent' ||
textElem.textFormat === 'currency') && (
<div>
<label className="ps-field-label"></label>
<PropInput
type="number"
min="0"
max="6"
value={textElem.decimalPlaces ?? 2}
onCommit={(val) =>
patchText({
...textElem,
decimalPlaces: Math.max(0, Math.min(6, Number(val) || 0)),
})
}
className="ps-field font-mono"
/>
</div>
)}
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="ps-field-label"> (pt)</label>
<PropInput
type="number"
min="6"
max="80"
value={textElem.fontSize}
onCommit={(val) => patchText({ ...textElem, fontSize: Number(val) || 12 })}
className="ps-field font-mono"
/>
</div>
<div>
<label className="ps-field-label"></label>
<FieldSelect
value={textElem.fontFamily}
onChange={(e) =>
patchText({
...textElem,
fontFamily: e.target.value as 'sans' | 'serif' | 'mono',
})
}
className="ps-field"
>
<option value="sans"> (Sans)</option>
<option value="serif"> (Serif)</option>
<option value="mono"> (Mono)</option>
</FieldSelect>
</div>
</div>
<div className="border-t border-[#3a3a3a] pt-2.5">
<label className="block text-[10px] font-semibold text-[#777] mb-1"></label>
<div className="flex gap-1 items-center">
<input
type="color"
value={textElem.textColor || '#111827'}
onChange={(e) => patchText({ ...textElem, textColor: e.target.value })}
className="color-input shrink-0"
/>
<input
type="text"
placeholder="#111827"
value={textElem.textColor || ''}
onChange={(e) => patchText({ ...textElem, textColor: e.target.value })}
className="ps-field font-mono text-[11px] flex-1 min-w-0"
/>
</div>
</div>
<div className="space-y-2">
<label className="ps-field-label mb-0"></label>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() =>
patchText({
...textElem,
fontWeight: textElem.fontWeight === 'bold' ? 'normal' : 'bold',
})
}
className={`ps-btn ${textElem.fontWeight === 'bold' ? 'active' : ''}`}
title="加粗"
>
<Bold className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() =>
patchText({
...textElem,
fontStyle: textElem.fontStyle === 'italic' ? 'normal' : 'italic',
})
}
className={`ps-btn ${textElem.fontStyle === 'italic' ? 'active' : ''}`}
title="斜体"
>
<Italic className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => patchText({ ...textElem, textUnderline: !textElem.textUnderline })}
className={`ps-btn ${textElem.textUnderline ? 'active' : ''}`}
title="下划线"
>
<Underline className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => patchText({ ...textElem, textLineThrough: !textElem.textLineThrough })}
className={`ps-btn ${textElem.textLineThrough ? 'active' : ''}`}
title="删除线"
>
<Strikethrough className="w-3.5 h-3.5" />
</button>
</div>
</div>
<div className="grid grid-cols-3 gap-2">
<div>
<label className="ps-field-label"></label>
<PropInput
type="number"
min="10"
max="500"
step="5"
value={Math.round((textElem.textScaleX ?? 1) * 100)}
onCommit={(val) =>
patchText({
...textElem,
textScaleX: Math.max(0.1, Math.min(5, (Number(val) || 100) / 100)),
})
}
className="ps-field font-mono"
/>
<p className="text-[9px] text-[#666] mt-0.5">%</p>
</div>
<div>
<label className="ps-field-label"></label>
<PropInput
type="number"
min="10"
max="500"
step="5"
value={Math.round((textElem.textScaleY ?? 1) * 100)}
onCommit={(val) =>
patchText({
...textElem,
textScaleY: Math.max(0.1, Math.min(5, (Number(val) || 100) / 100)),
})
}
className="ps-field font-mono"
/>
<p className="text-[9px] text-[#666] mt-0.5">%</p>
</div>
<div>
<label className="ps-field-label"></label>
<PropInput
type="number"
min="0"
max="10"
step="0.1"
value={textElem.letterSpacing ?? 0}
onCommit={(val) =>
patchText({
...textElem,
letterSpacing: Math.max(0, Math.min(10, Number(val) || 0)),
})
}
className="ps-field font-mono"
/>
<p className="text-[9px] text-[#666] mt-0.5">mm</p>
</div>
</div>
<div className="space-y-2">
<label className="ps-field-label mb-0"></label>
<div className="ps-toggle-group">
<button
type="button"
onClick={() => patchText({ ...textElem, textWritingMode: 'horizontal' })}
className={`ps-toggle-btn ${(textElem.textWritingMode ?? 'horizontal') === 'horizontal' ? 'active' : ''}`}
>
</button>
<button
type="button"
onClick={() => patchText({ ...textElem, textWritingMode: 'vertical' })}
className={`ps-toggle-btn ${textElem.textWritingMode === 'vertical' ? 'active' : ''}`}
>
</button>
</div>
</div>
<div className="space-y-2">
<label className="ps-field-label mb-0"></label>
<div className="flex flex-wrap items-center gap-2">
<div className="ps-toggle-group">
<button
type="button"
onClick={() => patchText({ ...textElem, textAlign: 'left' })}
className={`ps-toggle-btn ${textElem.textAlign === 'left' ? 'active' : ''}`}
title="左对齐"
>
<AlignLeft className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => patchText({ ...textElem, textAlign: 'center' })}
className={`ps-toggle-btn ${textElem.textAlign === 'center' ? 'active' : ''}`}
title="水平居中"
>
<AlignCenter className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => patchText({ ...textElem, textAlign: 'right' })}
className={`ps-toggle-btn ${textElem.textAlign === 'right' ? 'active' : ''}`}
title="右对齐"
>
<AlignRight className="w-3.5 h-3.5" />
</button>
</div>
<div className="ps-toggle-group">
<button
type="button"
onClick={() => patchText({ ...textElem, verticalAlign: 'top' })}
className={`ps-toggle-btn ${(textElem.verticalAlign ?? 'middle') === 'top' ? 'active' : ''}`}
title="顶对齐"
>
<AlignVerticalJustifyStart className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => patchText({ ...textElem, verticalAlign: 'middle' })}
className={`ps-toggle-btn ${(textElem.verticalAlign ?? 'middle') === 'middle' ? 'active' : ''}`}
title="垂直居中"
>
<AlignVerticalJustifyCenter className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => patchText({ ...textElem, verticalAlign: 'bottom' })}
className={`ps-toggle-btn ${(textElem.verticalAlign ?? 'middle') === 'bottom' ? 'active' : ''}`}
title="底对齐"
>
<AlignVerticalJustifyEnd className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={textElem.wordWrap !== false}
onChange={(e) => patchText({ ...textElem, wordWrap: e.target.checked })}
className="ps-checkbox"
/>
<span className="text-[12px] text-[#ccc]"></span>
</label>
<div className="grid grid-cols-2 gap-2 border-t border-[#3a3a3a] pt-2">
<div>
<label className="ps-field-label"></label>
<input
type="color"
value={
textElem.backgroundColor && textElem.backgroundColor !== 'transparent'
? textElem.backgroundColor
: '#ffffff'
}
onChange={(e) => patchText({ ...textElem, backgroundColor: e.target.value })}
className="color-input w-full"
/>
</div>
<div className="flex items-end">
<button
type="button"
onClick={() => patchText({ ...textElem, backgroundColor: 'transparent' })}
className="text-[9px] text-[#31a8ff] hover:underline cursor-pointer"
>
</button>
</div>
</div>
<ValueExtractFields elem={textElem} onChange={patchText} />
<TextDisplayAffixFields elem={textElem} onChange={patchText} />
</div>
);
};

View File

@@ -0,0 +1,293 @@
import React from 'react';
import { TemplateElement } from '../types';
import { CommitInput } from './CommitInput';
import {
canMergeTableCells,
canUnmergeTableCell,
getCellAnchor,
getMergeAnchorFromSelection,
mergeTableCells,
normalizeTableCellSelection,
resizeTableGrid,
scaleTableTracks,
unmergeTableCell,
updateTableCells,
updateTableColWidth,
updateTableRowHeight,
getTableColWidths,
getTableRowHeights,
type TableCellCoord,
} from '../tableUtils';
import { Table2, Merge, SplitSquareHorizontal } from 'lucide-react';
import { TableCellTextFields } from './TableCellTextFields';
const PropInput = CommitInput;
interface TablePropertyFieldsProps {
elem: TemplateElement;
selectedCells: TableCellCoord[];
onChange: (elem: TemplateElement) => void;
onSelectTableCells?: (cells: TableCellCoord[]) => void;
templateVariables?: string[];
}
export const TablePropertyFields: React.FC<TablePropertyFieldsProps> = ({
elem,
selectedCells,
onChange,
onSelectTableCells,
templateVariables = [],
}) => {
const rowHeights = getTableRowHeights(elem);
const colWidths = getTableColWidths(elem);
const normalizedCells = normalizeTableCellSelection(elem, selectedCells);
const canMerge = canMergeTableCells(elem, selectedCells);
const canUnmerge =
normalizedCells.length === 1 &&
canUnmergeTableCell(elem, normalizedCells[0].row, normalizedCells[0].col);
const primaryCell =
normalizedCells.length === 1
? getCellAnchor(elem, normalizedCells[0].row, normalizedCells[0].col)
: null;
return (
<div className="space-y-3 ps-section-divider">
<h5 className="ps-section-title">
<Table2 className="w-3.5 h-3.5" />
</h5>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="ps-field-label"></label>
<PropInput
type="number"
min="1"
max="50"
value={elem.tableRows ?? 3}
onCommit={(val) => {
const rows = Math.max(1, Math.min(50, Number(val) || 1));
onChange(resizeTableGrid(elem, rows, elem.tableCols ?? 3));
}}
className="ps-field font-mono"
/>
</div>
<div>
<label className="ps-field-label"></label>
<PropInput
type="number"
min="1"
max="50"
value={elem.tableCols ?? 3}
onCommit={(val) => {
const cols = Math.max(1, Math.min(50, Number(val) || 1));
onChange(resizeTableGrid(elem, elem.tableRows ?? 3, cols));
}}
className="ps-field font-mono"
/>
</div>
</div>
<div className="space-y-1.5">
<span className="text-[10px] font-semibold text-[#aaa]"> (mm)</span>
<div className="grid grid-cols-3 gap-1.5">
{rowHeights.map((h, i) => (
<div key={`rh-${i}`}>
<label className="text-[9px] text-[#666]"> {i + 1} </label>
<PropInput
type="number"
step="0.5"
min="0.5"
value={h}
onCommit={(val) =>
onChange(updateTableRowHeight(elem, i, Number(val) || 0.5))
}
className="ps-field font-mono text-[10px]"
/>
</div>
))}
</div>
</div>
<div className="space-y-1.5">
<span className="text-[10px] font-semibold text-[#aaa]"> (mm)</span>
<div className="grid grid-cols-3 gap-1.5">
{colWidths.map((w, i) => (
<div key={`cw-${i}`}>
<label className="text-[9px] text-[#666]"> {i + 1} </label>
<PropInput
type="number"
step="0.5"
min="0.5"
value={w}
onCommit={(val) =>
onChange(updateTableColWidth(elem, i, Number(val) || 0.5))
}
className="ps-field font-mono text-[10px]"
/>
</div>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="ps-field-label">线 (mm)</label>
<PropInput
type="number"
step="0.1"
min="0"
value={elem.tableBorderWidth ?? 0.2}
onCommit={(val) =>
onChange({ ...elem, tableBorderWidth: Math.max(0, Number(val) || 0) })
}
className="ps-field font-mono"
/>
</div>
<div>
<label className="ps-field-label">线</label>
<input
type="color"
value={elem.tableBorderColor ?? '#374151'}
onChange={(e) => onChange({ ...elem, tableBorderColor: e.target.value })}
className="color-input w-full"
/>
</div>
</div>
{normalizedCells.length > 0 && (
<div className="bg-[#1e1e1e] border border-[#3a3a3a] p-3 space-y-3">
<span className="text-[10px] bg-[#31a8ff] text-white font-bold px-1.5 py-0.5 inline-block">
{normalizedCells.length}
</span>
<p className="text-[9px] text-[#666] leading-snug">
</p>
<div className="flex flex-wrap gap-1.5">
<button
type="button"
disabled={!canMerge}
onClick={() => {
const anchor = getMergeAnchorFromSelection(selectedCells);
onChange(mergeTableCells(elem, selectedCells));
onSelectTableCells?.([anchor]);
}}
className="ps-btn text-[10px] disabled:opacity-40 disabled:cursor-not-allowed"
title="合并所选单元格"
>
<Merge className="w-3 h-3" />
</button>
<button
type="button"
disabled={!canUnmerge}
onClick={() => {
const anchor = normalizedCells[0];
onChange(unmergeTableCell(elem, anchor.row, anchor.col));
onSelectTableCells?.([anchor]);
}}
className="ps-btn text-[10px] disabled:opacity-40 disabled:cursor-not-allowed"
title="取消合并"
>
<SplitSquareHorizontal className="w-3 h-3" />
</button>
</div>
{normalizedCells.length === 1 && primaryCell && (
<TableCellTextFields
elem={elem}
row={primaryCell.row}
col={primaryCell.col}
onChange={onChange}
templateVariables={templateVariables}
/>
)}
</div>
)}
</div>
);
};
interface TableCellContentFieldsProps {
elem: TemplateElement;
selectedCells: TableCellCoord[];
onChange: (elem: TemplateElement) => void;
templateVariables: string[];
}
export const TableCellContentFields: React.FC<TableCellContentFieldsProps> = ({
elem,
selectedCells,
onChange,
templateVariables,
}) => {
if (selectedCells.length === 0) {
return (
<p className="text-[10px] text-[#777] leading-normal">
</p>
);
}
if (selectedCells.length === 1) {
return (
<p className="text-[10px] text-[#777] leading-normal">
1
</p>
);
}
const content = getCellRawContent(elem, selectedCells[0].row, selectedCells[0].col);
return (
<div className="space-y-2 pt-2 border-t border-[#3a3a3a]">
<label className="ps-field-label">{selectedCells.length} </label>
<textarea
value={content}
rows={2}
className="ps-field resize-none"
placeholder="输入文本,使用 {变量名} 引用变量"
onChange={(e) => {
onChange(updateTableCells(elem, selectedCells, { content: e.target.value }));
}}
/>
{templateVariables.length > 0 && (
<div className="flex flex-wrap gap-1">
{templateVariables.map((v) => (
<button
key={v}
type="button"
onClick={() => {
const next = content + `{${v}}`;
onChange(updateTableCells(elem, selectedCells, { content: next }));
}}
className="ps-btn text-[9px] font-mono"
>
{`{${v}}`}
</button>
))}
</div>
)}
</div>
);
};
export function scaleTableElementSize(
elem: TemplateElement,
newWidth: number,
newHeight: number
): TemplateElement {
if (elem.type !== 'table') {
return { ...elem, width: newWidth, height: newHeight };
}
return scaleTableTracks(
elem,
newWidth,
newHeight,
elem.width,
elem.height,
getTableRowHeights(elem),
getTableColWidths(elem)
);
}

View File

@@ -17,6 +17,17 @@ import {
drawElementBorders,
drawElementChrome,
} from './elementBox';
import {
iterTableCells,
getCellRectMm,
getCellAnchor,
getCellData,
getEffectiveCellStyle,
getTableRows,
getTableCols,
getTableRowHeights,
getTableColWidths,
} from './tableUtils';
const GENERATOR_TRANSPARENT_BG = '#00000000';
@@ -215,6 +226,280 @@ function layoutVerticalTextColumns(
return columns;
}
function drawTextInRect(
ctx: CanvasRenderingContext2D,
elem: TemplateElement,
boxX: number,
boxY: number,
boxW: number,
boxH: number,
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 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 = boxX + boxW / 2;
const transformOriginY = boxY + boxH / 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 = boxX + pad + Math.max(0, (writableW - totalColumnsWidth) / 2) + columnWidth / 2;
} else if (elem.textAlign === 'right') {
startX = boxX + pad + Math.max(0, writableW - totalColumnsWidth) + columnWidth / 2;
} else {
startX = boxX + pad + 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 = boxY + pad + lineHeight / 2;
} else if (vAlign === 'bottom') {
colStartY = boxY + boxH - pad - colTextHeight + lineHeight / 2;
} else {
colStartY = boxY + pad + 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) {
ctx.fillText(column[i], colX, charY);
}
}
}
} else {
const lines = layoutHorizontalTextLines(ctx, String(value), writableW, wordWrap);
const totalTextHeight = lines.length * lineHeight;
let textX = boxX + pad;
let textAlign: CanvasTextAlign = 'left';
if (elem.textAlign === 'center') {
textX = boxX + boxW / 2;
textAlign = 'center';
} else if (elem.textAlign === 'right') {
textX = boxX + boxW - pad;
textAlign = 'right';
} else {
textX = boxX + pad + 2;
}
ctx.textAlign = textAlign;
let startY: number;
if (vAlign === 'top') {
startY = boxY + pad + lineHeight / 2;
} else if (vAlign === 'bottom') {
startY = boxY + boxH - pad - totalTextHeight + lineHeight / 2;
} else {
startY = boxY + pad + 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) {
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';
}
}
function alignStrokeCoord(v: number): number {
return Math.round(v) + 0.5;
}
/** 统一绘制表格网格线,避免相邻单元格各画一条边造成双线 */
function drawTableGridLines(
ctx: CanvasRenderingContext2D,
elem: TemplateElement,
ex: number,
ey: number,
scale: number
) {
const borderMm = elem.tableBorderWidth ?? 0.2;
if (borderMm <= 0) return;
const borderW = borderMm * scale;
if (borderW <= 0) return;
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 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;
ctx.strokeStyle = borderColor;
ctx.lineWidth = borderW;
ctx.beginPath();
const inset = borderW / 2;
ctx.rect(
alignStrokeCoord(left + inset),
alignStrokeCoord(top + inset),
Math.max(0, width - borderW),
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;
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);
}
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;
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);
}
yAcc2 += h;
}
}
ctx.stroke();
}
async function drawTableElement(
ctx: CanvasRenderingContext2D,
elem: TemplateElement,
ex: number,
ey: number,
ew: number,
eh: number,
scale: number,
rowData: RecordRow | null,
rowIndex: number,
variableDefaults: Record<string, string> | null | undefined,
mode: ContentResolveMode
) {
const radii = resolveCornerRadiiPx(elem, scale, ew, eh);
const sides = resolveBorderSides(elem);
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);
ctx.save();
ctx.beginPath();
ctx.rect(inner.x, inner.y, inner.w, inner.h);
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 cellData = getCellData(elem, row, col);
if (cellData?.backgroundColor && cellData.backgroundColor !== 'transparent') {
ctx.fillStyle = cellData.backgroundColor;
ctx.fillRect(cellX, cellY, cellW, cellH);
}
const textElem = getEffectiveCellStyle(elem, row, col);
if (!textElem.content.trim()) continue;
const cellValue = resolveElementValue(textElem, rowData, rowIndex, variableDefaults, mode);
const displayText = String(cellValue ?? '').trim();
if (!displayText) continue;
drawTextInRect(ctx, textElem, cellX, cellY, cellW, cellH, displayText, scale);
}
drawTableGridLines(ctx, elem, ex, ey, scale);
ctx.restore();
}
function drawImagePlaceholder(
ctx: CanvasRenderingContext2D,
x: number,
@@ -316,7 +601,7 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
const value = resolveElementValue(elem, rowData, rowIndex, variableDefaults, mode);
if (isGraphicContentEmpty(elem, value, mode)) {
if (elem.type !== 'table' && isGraphicContentEmpty(elem, value, mode)) {
continue;
}
@@ -545,6 +830,20 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
}
}
}
} else if (elem.type === 'table') {
await drawTableElement(
ctx,
elem,
ex,
ey,
ew,
eh,
scale,
rowData,
rowIndex,
variableDefaults,
mode
);
}
ctx.restore();

560
src/tableUtils.ts Normal file
View File

@@ -0,0 +1,560 @@
import { TemplateElement, TableCellData } from './types';
import { resolvePaddingMm } from './elementBox';
export interface TableCellCoord {
row: number;
col: number;
}
export function tableCellKey(row: number, col: number): string {
return `${row},${col}`;
}
export function parseTableCellKey(key: string): TableCellCoord | null {
const [r, c] = key.split(',').map(Number);
if (!Number.isFinite(r) || !Number.isFinite(c)) return null;
return { row: r, col: c };
}
const round1 = (n: number) => Math.round(n * 10) / 10;
export function getTableRows(elem: TemplateElement): number {
return Math.max(1, elem.tableRows ?? 3);
}
export function getTableCols(elem: TemplateElement): number {
return Math.max(1, elem.tableCols ?? 3);
}
export function getTableInnerSizeMm(elem: TemplateElement): { width: number; height: number } {
const pad = resolvePaddingMm(elem);
return {
width: Math.max(1, elem.width - pad.left - pad.right),
height: Math.max(1, elem.height - pad.top - pad.bottom),
};
}
export function getTableRowHeights(elem: TemplateElement): number[] {
const rows = getTableRows(elem);
const stored = elem.tableRowHeights;
if (stored && stored.length === rows) {
return stored.map((h) => Math.max(0.5, h));
}
const { height } = getTableInnerSizeMm(elem);
const even = round1(height / rows);
return Array.from({ length: rows }, () => even);
}
export function getTableColWidths(elem: TemplateElement): number[] {
const cols = getTableCols(elem);
const stored = elem.tableColWidths;
if (stored && stored.length === cols) {
return stored.map((w) => Math.max(0.5, w));
}
const { width } = getTableInnerSizeMm(elem);
const even = round1(width / cols);
return Array.from({ length: cols }, () => even);
}
/** 内边距变化后保持行高/列宽不变,自动增大元素尺寸 */
export function expandTableForPadding(elem: TemplateElement): TemplateElement {
if (elem.type !== 'table') return elem;
const pad = resolvePaddingMm(elem);
const rowHeights = getTableRowHeights(elem);
const colWidths = getTableColWidths(elem);
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),
};
}
export function syncTableDimensionsFromTracks(elem: TemplateElement): TemplateElement {
const pad = resolvePaddingMm(elem);
const rowHeights = getTableRowHeights(elem);
const colWidths = getTableColWidths(elem);
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),
};
}
export function scaleTableTracks(
elem: TemplateElement,
newWidth: number,
newHeight: number,
startWidth: number,
startHeight: number,
startRowHeights: number[],
startColWidths: number[]
): TemplateElement {
const pad = resolvePaddingMm(elem);
const startInnerW = Math.max(0.1, startWidth - pad.left - pad.right);
const startInnerH = Math.max(0.1, startHeight - pad.top - pad.bottom);
const newInnerW = Math.max(1, newWidth - pad.left - pad.right);
const newInnerH = Math.max(1, newHeight - pad.top - pad.bottom);
const scaleW = newInnerW / startInnerW;
const scaleH = newInnerH / startInnerH;
const tableRowHeights = startRowHeights.map((h) => round1(Math.max(0.5, h * scaleH)));
const tableColWidths = startColWidths.map((w) => round1(Math.max(0.5, w * scaleW)));
return {
...elem,
x: elem.x,
y: elem.y,
width: round1(newWidth),
height: round1(newHeight),
tableRowHeights,
tableColWidths,
};
}
export function getCellData(elem: TemplateElement, row: number, col: number): TableCellData | undefined {
return elem.tableCells?.[tableCellKey(row, col)];
}
export function getCellAnchor(elem: TemplateElement, row: number, col: number): TableCellCoord {
const cell = getCellData(elem, row, col);
if (cell?.mergeAnchor) {
return { row: cell.mergeAnchor.row, col: cell.mergeAnchor.col };
}
return { row, col };
}
export function getCellSpan(
elem: TemplateElement,
row: number,
col: number
): { rowSpan: number; colSpan: number } {
const anchor = getCellAnchor(elem, row, col);
const anchorCell = getCellData(elem, anchor.row, anchor.col);
return {
rowSpan: Math.max(1, anchorCell?.rowSpan ?? 1),
colSpan: Math.max(1, anchorCell?.colSpan ?? 1),
};
}
export function isCellAnchor(elem: TemplateElement, row: number, col: number): boolean {
const anchor = getCellAnchor(elem, row, col);
return anchor.row === row && anchor.col === col;
}
export function iterTableCells(elem: TemplateElement): TableCellCoord[] {
const rows = getTableRows(elem);
const cols = getTableCols(elem);
const result: TableCellCoord[] = [];
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
if (isCellAnchor(elem, r, c)) {
result.push({ row: r, col: c });
}
}
}
return result;
}
export function getCellRectMm(
elem: TemplateElement,
row: number,
col: number
): { x: number; y: number; width: number; height: number } {
const pad = resolvePaddingMm(elem);
const rowHeights = getTableRowHeights(elem);
const colWidths = getTableColWidths(elem);
const anchor = getCellAnchor(elem, row, col);
const { rowSpan, colSpan } = getCellSpan(elem, anchor.row, anchor.col);
let y = pad.top;
for (let r = 0; r < anchor.row; r++) y += rowHeights[r] ?? 0;
let x = pad.left;
for (let c = 0; c < anchor.col; c++) x += colWidths[c] ?? 0;
let width = 0;
for (let c = anchor.col; c < anchor.col + colSpan; c++) width += colWidths[c] ?? 0;
let height = 0;
for (let r = anchor.row; r < anchor.row + rowSpan; r++) height += rowHeights[r] ?? 0;
return { x: round1(x), y: round1(y), width: round1(width), height: round1(height) };
}
export function createDefaultTableElement(id: string, index: number): TemplateElement {
const rows = 3;
const cols = 3;
const rowH = 5;
const colW = 10;
return {
id,
type: 'table',
name: `表格 ${index}`,
x: 5,
y: 5,
width: cols * colW,
height: rows * rowH,
content: '',
fontSize: 10,
textAlign: 'center',
verticalAlign: 'middle',
wordWrap: true,
fontWeight: 'normal',
barcodeFormat: 'CODE128',
showText: false,
fontFamily: 'sans',
backgroundColor: '#ffffff',
backgroundOpacity: 0,
tableRows: rows,
tableCols: cols,
tableRowHeights: Array.from({ length: rows }, () => rowH),
tableColWidths: Array.from({ length: cols }, () => colW),
tableBorderWidth: 0.2,
tableBorderColor: '#374151',
tableCells: {},
};
}
export function resizeTableGrid(
elem: TemplateElement,
newRows: number,
newCols: number
): 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) {
for (const [key, data] of Object.entries(elem.tableCells)) {
const coord = parseTableCellKey(key);
if (!coord || coord.row >= rows || coord.col >= cols) continue;
const anchor = data.mergeAnchor ?? { row: coord.row, col: coord.col };
if (anchor.row >= rows || anchor.col >= cols) continue;
newCells[key] = data;
}
}
const pad = resolvePaddingMm(elem);
const next: TemplateElement = {
...elem,
tableRows: rows,
tableCols: cols,
tableRowHeights: rowHeights,
tableColWidths: colWidths,
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),
};
return sanitizeTableMerges(next);
}
function selectionBounds(selection: TableCellCoord[]) {
const rows = selection.map((s) => s.row);
const cols = selection.map((s) => s.col);
return {
minR: Math.min(...rows),
maxR: Math.max(...rows),
minC: Math.min(...cols),
maxC: Math.max(...cols),
};
}
/** 合并选区的左上角锚点坐标 */
export function getMergeAnchorFromSelection(selection: TableCellCoord[]): TableCellCoord {
const { minR, minC } = selectionBounds(selection);
return { row: minR, col: minC };
}
/** 将选区归一化为各合并区域的主单元格,去重 */
export function normalizeTableCellSelection(
elem: TemplateElement,
selection: TableCellCoord[]
): TableCellCoord[] {
const seen = new Set<string>();
const result: TableCellCoord[] = [];
for (const coord of selection) {
const anchor = getCellAnchor(elem, coord.row, coord.col);
const key = tableCellKey(anchor.row, anchor.col);
if (!seen.has(key)) {
seen.add(key);
result.push({ row: anchor.row, col: anchor.col });
}
}
return result;
}
export function canMergeTableCells(elem: TemplateElement, selection: TableCellCoord[]): boolean {
if (selection.length < 2) return false;
const { minR, maxR, minC, maxC } = selectionBounds(selection);
const expected = (maxR - minR + 1) * (maxC - minC + 1);
if (selection.length !== expected) return false;
const selSet = new Set(selection.map((s) => tableCellKey(s.row, s.col)));
for (let r = minR; r <= maxR; r++) {
for (let c = minC; c <= maxC; c++) {
if (!selSet.has(tableCellKey(r, c))) return false;
const anchor = getCellAnchor(elem, r, c);
const span = getCellSpan(elem, anchor.row, anchor.col);
for (let ar = anchor.row; ar < anchor.row + span.rowSpan; ar++) {
for (let ac = anchor.col; ac < anchor.col + span.colSpan; ac++) {
if (!selSet.has(tableCellKey(ar, ac))) return false;
}
}
}
}
return true;
}
export function mergeTableCells(elem: TemplateElement, selection: TableCellCoord[]): TemplateElement {
if (!canMergeTableCells(elem, selection)) return elem;
const { minR, maxR, minC, maxC } = selectionBounds(selection);
const rowSpan = maxR - minR + 1;
const colSpan = maxC - minC + 1;
const cells = { ...(elem.tableCells ?? {}) };
for (let r = minR; r <= maxR; r++) {
for (let c = minC; c <= maxC; c++) {
const key = tableCellKey(r, c);
const prev = cells[key] ?? {};
if (r === minR && c === minC) {
cells[key] = { ...prev, rowSpan, colSpan, mergeAnchor: undefined };
} else {
cells[key] = { ...prev, mergeAnchor: { row: minR, col: minC }, rowSpan: undefined, colSpan: undefined };
}
}
}
return { ...elem, tableCells: cells };
}
export function canUnmergeTableCell(elem: TemplateElement, row: number, col: number): boolean {
const anchor = getCellAnchor(elem, row, col);
const span = getCellSpan(elem, anchor.row, anchor.col);
return span.rowSpan > 1 || span.colSpan > 1;
}
export function unmergeTableCell(elem: TemplateElement, row: number, col: number): TemplateElement {
const anchor = getCellAnchor(elem, row, col);
const span = getCellSpan(elem, anchor.row, anchor.col);
if (span.rowSpan === 1 && span.colSpan === 1) return elem;
const cells = { ...(elem.tableCells ?? {}) };
for (let r = anchor.row; r < anchor.row + span.rowSpan; r++) {
for (let c = anchor.col; c < anchor.col + span.colSpan; c++) {
const key = tableCellKey(r, c);
const prev = { ...(cells[key] ?? {}) };
delete prev.mergeAnchor;
delete prev.rowSpan;
delete prev.colSpan;
if (Object.keys(prev).length === 0) {
delete cells[key];
} else {
cells[key] = prev;
}
}
}
return { ...elem, tableCells: cells };
}
function sanitizeTableMerges(elem: TemplateElement): TemplateElement {
const rows = getTableRows(elem);
const cols = getTableCols(elem);
const cells = { ...(elem.tableCells ?? {}) };
for (const key of Object.keys(cells)) {
const coord = parseTableCellKey(key);
if (!coord || coord.row >= rows || coord.col >= cols) {
delete cells[key];
}
}
return { ...elem, tableCells: cells };
}
export function updateTableRowHeight(
elem: TemplateElement,
rowIndex: number,
heightMm: number
): TemplateElement {
const rowHeights = [...getTableRowHeights(elem)];
if (rowIndex < 0 || rowIndex >= rowHeights.length) return elem;
rowHeights[rowIndex] = Math.max(0.5, round1(heightMm));
const pad = resolvePaddingMm(elem);
return {
...elem,
tableRowHeights: rowHeights,
height: round1(rowHeights.reduce((a, b) => a + b, 0) + pad.top + pad.bottom),
};
}
export function updateTableColWidth(
elem: TemplateElement,
colIndex: number,
widthMm: number
): TemplateElement {
const colWidths = [...getTableColWidths(elem)];
if (colIndex < 0 || colIndex >= colWidths.length) return elem;
colWidths[colIndex] = Math.max(0.5, round1(widthMm));
const pad = resolvePaddingMm(elem);
return {
...elem,
tableColWidths: colWidths,
width: round1(colWidths.reduce((a, b) => a + b, 0) + pad.left + pad.right),
};
}
/** 单元格原始内容,未设置时为空字符串 */
export function getCellRawContent(elem: TemplateElement, row: number, col: number): string {
const anchor = getCellAnchor(elem, row, col);
const cell = getCellData(elem, anchor.row, anchor.col);
return cell?.content ?? '';
}
export function resolveTableCellContent(
elem: TemplateElement,
row: number,
col: number
): string {
return getCellRawContent(elem, row, col);
}
export function getEffectiveCellStyle(
elem: TemplateElement,
row: number,
col: number
): TemplateElement {
const anchor = getCellAnchor(elem, row, col);
const cell = getCellData(elem, anchor.row, anchor.col);
const pick = <K extends keyof TableCellData & keyof TemplateElement>(
key: K,
fallback: TemplateElement[K]
): TemplateElement[K] => {
const cellVal = cell?.[key as keyof TableCellData];
if (cellVal !== undefined) return cellVal as TemplateElement[K];
const elemVal = elem[key];
return (elemVal !== undefined ? elemVal : fallback) as TemplateElement[K];
};
return {
...elem,
type: 'text',
content: getCellRawContent(elem, anchor.row, anchor.col),
fontSize: pick('fontSize', elem.fontSize),
textAlign: pick('textAlign', elem.textAlign),
verticalAlign: pick('verticalAlign', elem.verticalAlign ?? 'middle'),
fontWeight: pick('fontWeight', elem.fontWeight),
fontStyle: pick('fontStyle', elem.fontStyle ?? 'normal'),
textUnderline: pick('textUnderline', elem.textUnderline ?? false),
textLineThrough: pick('textLineThrough', elem.textLineThrough ?? false),
textScaleX: pick('textScaleX', elem.textScaleX ?? 1),
textScaleY: pick('textScaleY', elem.textScaleY ?? 1),
letterSpacing: pick('letterSpacing', elem.letterSpacing ?? 0),
textWritingMode: pick('textWritingMode', elem.textWritingMode ?? 'horizontal'),
textColor: pick('textColor', elem.textColor ?? '#111827'),
wordWrap: cell?.wordWrap ?? elem.wordWrap !== false,
fontFamily: pick('fontFamily', elem.fontFamily),
textFormat: pick('textFormat', elem.textFormat ?? 'text'),
decimalPlaces: pick('decimalPlaces', elem.decimalPlaces ?? 2),
numberPartDisplay: pick('numberPartDisplay', elem.numberPartDisplay ?? 'full'),
textExtractMode: pick('textExtractMode', elem.textExtractMode ?? 'none'),
textSplitDelimiter: cell?.textSplitDelimiter ?? elem.textSplitDelimiter,
textSplitIndex: cell?.textSplitIndex ?? elem.textSplitIndex,
textExtractLength: cell?.textExtractLength ?? elem.textExtractLength,
textDisplayPrefix: cell?.textDisplayPrefix ?? elem.textDisplayPrefix,
textDisplaySuffix: cell?.textDisplaySuffix ?? elem.textDisplaySuffix,
backgroundColor: cell?.backgroundColor,
};
}
const CELL_TEXT_PATCH_KEYS: (keyof TableCellData)[] = [
'content',
'fontSize',
'textAlign',
'verticalAlign',
'fontWeight',
'fontStyle',
'textUnderline',
'textLineThrough',
'textScaleX',
'textScaleY',
'letterSpacing',
'textWritingMode',
'textColor',
'backgroundColor',
'wordWrap',
'fontFamily',
'textFormat',
'decimalPlaces',
'numberPartDisplay',
'textExtractMode',
'textSplitDelimiter',
'textSplitIndex',
'textExtractLength',
'textDisplayPrefix',
'textDisplaySuffix',
];
export function applyCellTextElementChange(
elem: TemplateElement,
row: number,
col: number,
textElem: TemplateElement
): TemplateElement {
const patch: Partial<TableCellData> = {};
for (const key of CELL_TEXT_PATCH_KEYS) {
const val = textElem[key as keyof TemplateElement];
if (val !== undefined) {
(patch as Record<string, unknown>)[key] = val;
}
}
return updateTableCells(elem, [{ row, col }], patch);
}
export function updateTableCells(
elem: TemplateElement,
coords: TableCellCoord[],
patch: Partial<TableCellData>
): TemplateElement {
const cells = { ...(elem.tableCells ?? {}) };
for (const { row, col } of coords) {
const anchor = getCellAnchor(elem, row, col);
const key = tableCellKey(anchor.row, anchor.col);
cells[key] = { ...(cells[key] ?? {}), ...patch };
}
return { ...elem, tableCells: cells };
}
export function cellCoordKey(coord: TableCellCoord): string {
return tableCellKey(coord.row, coord.col);
}
/** 点击单元格:已选则取消,未选则加入选区(无需 Shift/Ctrl */
export function toggleTableCellSelection(
current: TableCellCoord[],
coord: TableCellCoord
): TableCellCoord[] {
const key = cellCoordKey(coord);
const exists = current.some((c) => cellCoordKey(c) === key);
if (exists) return current.filter((c) => cellCoordKey(c) !== key);
return [...current, coord];
}

View File

@@ -40,9 +40,44 @@ export const VISIBILITY_OPERATOR_LABELS: Record<VisibilityOperator, string> = {
export type ElementSide = 'top' | 'right' | 'bottom' | 'left';
export type ElementCorner = 'tl' | 'tr' | 'br' | 'bl';
/** 表格单元格数据;键为 `${row},${col}` */
export interface TableCellData {
/** 被合并单元格指向主单元格坐标 */
mergeAnchor?: { row: number; col: number };
/** 主单元格合并行数,默认 1 */
rowSpan?: number;
/** 主单元格合并列数,默认 1 */
colSpan?: number;
content?: string;
fontSize?: number;
textAlign?: 'left' | 'center' | 'right';
verticalAlign?: 'top' | 'middle' | 'bottom';
fontWeight?: 'normal' | 'bold';
fontStyle?: 'normal' | 'italic';
textUnderline?: boolean;
textLineThrough?: boolean;
textScaleX?: number;
textScaleY?: number;
letterSpacing?: number;
textWritingMode?: 'horizontal' | 'vertical';
textColor?: string;
backgroundColor?: string;
wordWrap?: boolean;
fontFamily?: 'sans' | 'serif' | 'mono';
textFormat?: TextFormat;
decimalPlaces?: number;
numberPartDisplay?: NumberPartDisplay;
textExtractMode?: TextExtractMode;
textSplitDelimiter?: string;
textSplitIndex?: number;
textExtractLength?: number;
textDisplayPrefix?: string;
textDisplaySuffix?: string;
}
export interface TemplateElement {
id: string;
type: 'text' | 'barcode' | 'qrcode' | 'image';
type: 'text' | 'barcode' | 'qrcode' | 'image' | 'table';
name: string;
x: number; // in mm
y: number; // in mm
@@ -137,6 +172,20 @@ export interface TemplateElement {
borderRadiusTR?: number;
borderRadiusBR?: number;
borderRadiusBL?: number;
/** 表格行数,仅 type=table */
tableRows?: number;
/** 表格列数,仅 type=table */
tableCols?: number;
/** 各行高度 (mm),仅 type=table */
tableRowHeights?: number[];
/** 各列宽度 (mm),仅 type=table */
tableColWidths?: number[];
/** 单元格数据,键 `${row},${col}`,仅 type=table */
tableCells?: Record<string, TableCellData>;
/** 单元格网格线宽 (mm),仅 type=table默认 0.2 */
tableBorderWidth?: number;
/** 单元格网格线颜色,仅 type=table */
tableBorderColor?: string;
}
export interface DataSourceMeta {

View File

@@ -400,11 +400,11 @@ export const DEFAULT_PAPER_CONFIG: PaperConfig = {
/** 补全模板默认排版字段,剥离已废弃的数据源字段 */
export function normalizeTemplate(tmpl: LabelTemplate): LabelTemplate {
const { dataSource, dataSourceMeta, showPdfCutLines, exportRange: _exportRange, ...rest } = tmpl;
const { ...rest } = tmpl;
const cutLine: CutLineConfig = {
...DEFAULT_CUT_LINE_CONFIG,
...rest.cutLine,
enabled: rest.cutLine?.enabled ?? showPdfCutLines !== false,
enabled: rest.cutLine?.enabled ?? true,
width:
typeof rest.cutLine?.width === 'number' && rest.cutLine.width > 0
? rest.cutLine.width
@@ -495,6 +495,24 @@ export function resolveElementBackgroundFill(
return color;
}
/** 从元素内容(含表格单元格)提取变量名 */
export function extractVariablesFromElement(elem: TemplateElement): string[] {
const vars = new Set<string>();
for (const v of extractVariablesFromContent(elem.content)) {
vars.add(v);
}
if (elem.type === 'table' && elem.tableCells) {
for (const cell of Object.values(elem.tableCells)) {
if (cell.content) {
for (const v of extractVariablesFromContent(cell.content)) {
vars.add(v);
}
}
}
}
return Array.from(vars);
}
/** 从模板汇总全部变量(声明列表 + 内容 + 显示控制) */
export function extractTemplateVariables(template: {
variableList?: string[];
@@ -502,7 +520,7 @@ export function extractTemplateVariables(template: {
}): string[] {
const vars = new Set<string>(template.variableList ?? []);
for (const elem of template.elements) {
for (const v of extractVariablesFromContent(elem.content)) {
for (const v of extractVariablesFromElement(elem)) {
vars.add(v);
}
for (const rule of resolveVisibilityRules(elem)) {
@@ -517,7 +535,7 @@ export function extractTemplateVariables(template: {
export function getUsedTemplateVariables(template: { elements: TemplateElement[] }): string[] {
const vars = new Set<string>();
for (const elem of template.elements) {
for (const v of extractVariablesFromContent(elem.content)) {
for (const v of extractVariablesFromElement(elem)) {
vars.add(v);
}
for (const rule of resolveVisibilityRules(elem)) {