Files
label-designer/src/utils.ts
2026-06-15 14:46:30 +08:00

1415 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
LabelTemplate,
PaperConfig,
RecordRow,
ImportData,
DataSourceMeta,
TemplateElement,
TextFormat,
TextExtractMode,
NumberPartDisplay,
ExportRangeConfig,
CutLineConfig,
VisibilityRule,
VisibilityOperator,
VISIBILITY_OPERATOR_LABELS,
} from './types';
const IMPORT_DATA_STORAGE_PREFIX = 'label_import_data_v2_';
const GLOBAL_IMPORT_DATA_KEY = 'label_import_data_global_v2';
const VARIABLE_MAPPING_KEY = 'label_var_column_mapping_v2';
/** 变量映射:使用模板中配置的默认值(非数据列) */
export const VARIABLE_MAPPING_USE_DEFAULT = '__use_default__';
/** 变量映射使用当前数据行序号1-based与 {#INDEX} 一致) */
export const VARIABLE_MAPPING_USE_ROW_INDEX = '__use_row_index__';
export const VARIABLE_MAPPING_ROW_INDEX_LABEL = '数据序号';
/** 粘贴副本时相对原位置的偏移 (mm) */
export const PASTE_ELEMENT_OFFSET_MM = 2;
/** 在标签画布上居中放置元素mm保留 1 位小数) */
export function centerElementOnLabel(
labelWidth: number,
labelHeight: number,
elementWidth: number,
elementHeight: number
): { x: number; y: number } {
const x = Math.round(((labelWidth - elementWidth) / 2) * 10) / 10;
const y = Math.round(((labelHeight - elementHeight) / 2) * 10) / 10;
return {
x: Math.max(0, x),
y: Math.max(0, y),
};
}
const AUTO_ELEMENT_NAME_RE = /^(文本|条形码|二维码|图片|表格) \d+$/;
const MAX_LAYER_NAME_FROM_CONTENT = 40;
/** 是否为新建时的默认图层名(含粘贴副本后缀) */
export function isAutoGeneratedElementName(name: string): boolean {
const trimmed = name.trim();
if (!trimmed || trimmed === '未命名元素') return true;
if (AUTO_ELEMENT_NAME_RE.test(trimmed)) return true;
if (trimmed.endsWith(' 副本')) {
const base = trimmed.slice(0, -3).trim();
if (AUTO_ELEMENT_NAME_RE.test(base) || base === '未命名元素') return true;
}
return false;
}
/** 是否应根据内容自动同步图层名称 */
export function shouldSyncElementNameFromContent(elem: TemplateElement): boolean {
if (elem.nameCustomized) return false;
return isAutoGeneratedElementName(elem.name);
}
/** 从元素内容推导图层显示名称 */
export function deriveElementNameFromContent(
content: string,
type: TemplateElement['type']
): string {
const trimmed = content.trim();
if (!trimmed) return '未命名元素';
if (type === 'image') {
if (trimmed.startsWith('data:')) return '图片';
const withoutVars = trimmed.replace(/\{[^}]+\}/g, '').trim();
if (withoutVars && /^https?:\/\//i.test(withoutVars)) {
try {
const path = withoutVars.split(/[?#]/)[0];
const segment = path.split('/').filter(Boolean).pop();
if (segment) {
return decodeURIComponent(segment).slice(0, MAX_LAYER_NAME_FROM_CONTENT);
}
} catch {
/* ignore */
}
}
}
const singleLine = trimmed.split(/\r?\n/)[0].trim();
if (!singleLine) return '未命名元素';
if (singleLine.startsWith('data:')) return type === 'image' ? '图片' : '未命名元素';
return singleLine.slice(0, MAX_LAYER_NAME_FROM_CONTENT);
}
/** 更新内容并在需要时同步图层名称 */
export function withContentAndSyncedName(
elem: TemplateElement,
content: string
): TemplateElement {
const updated = { ...elem, content };
if (!shouldSyncElementNameFromContent(elem)) return updated;
return {
...updated,
name: deriveElementNameFromContent(content, elem.type),
};
}
/** 深拷贝元素并生成新 id用于复制/粘贴 */
export function cloneTemplateElementsForPaste(
elements: TemplateElement[],
pasteRepeat: number
): TemplateElement[] {
const offset = PASTE_ELEMENT_OFFSET_MM * Math.max(1, pasteRepeat);
const stamp = Date.now();
return elements.map((element, index) => {
const cloned = JSON.parse(JSON.stringify(element)) as TemplateElement;
cloned.id = `${element.type}_${stamp}_${index}`;
cloned.name = `${element.name} 副本`;
cloned.x = Math.round((element.x + offset) * 10) / 10;
cloned.y = Math.round((element.y + offset) * 10) / 10;
return cloned;
});
}
export function toDataSourceMeta(data: ImportData): DataSourceMeta {
return {
fileName: data.fileName,
sheets: data.sheets,
currentSheet: data.currentSheet,
columns: data.columns,
rowCount: data.rows.length,
};
}
export function saveImportDataForTemplate(templateId: string, data: ImportData): void {
try {
localStorage.setItem(IMPORT_DATA_STORAGE_PREFIX + templateId, JSON.stringify(data));
} catch (e) {
console.warn('Failed to save import data', e);
}
}
export function loadImportDataForTemplate(templateId: string): ImportData | null {
try {
const raw = localStorage.getItem(IMPORT_DATA_STORAGE_PREFIX + templateId);
if (raw) return JSON.parse(raw) as ImportData;
} catch (e) {}
return null;
}
export function clearImportDataForTemplate(templateId: string): void {
try {
localStorage.removeItem(IMPORT_DATA_STORAGE_PREFIX + templateId);
} catch (e) {}
}
export function saveGlobalImportData(data: ImportData): void {
try {
localStorage.setItem(GLOBAL_IMPORT_DATA_KEY, JSON.stringify(data));
} catch (e) {
console.warn('Failed to save global import data', e);
}
}
export function loadGlobalImportData(): ImportData | null {
try {
const raw = localStorage.getItem(GLOBAL_IMPORT_DATA_KEY);
if (raw) return JSON.parse(raw) as ImportData;
} catch (e) {}
return null;
}
export function clearGlobalImportData(): void {
try {
localStorage.removeItem(GLOBAL_IMPORT_DATA_KEY);
} catch (e) {}
}
export function saveVariableColumnMapping(mapping: Record<string, string>): void {
try {
localStorage.setItem(VARIABLE_MAPPING_KEY, JSON.stringify(mapping));
} catch (e) {}
}
export function loadVariableColumnMapping(): Record<string, string> {
try {
const raw = localStorage.getItem(VARIABLE_MAPPING_KEY);
if (raw) return JSON.parse(raw) as Record<string, string>;
} catch (e) {}
return {};
}
/** 变量名与数据列同名时自动建立映射 */
export function buildAutoVariableMapping(
variables: string[],
columns: string[]
): Record<string, string> {
const mapping: Record<string, string> = {};
for (const v of variables) {
if (columns.includes(v)) mapping[v] = v;
}
return mapping;
}
/** 变量是否已完成映射配置(含「使用默认值」) */
export function isVariableMappingConfigured(
mapping: Record<string, string>,
varName: string,
columns: string[]
): boolean {
const mapped = mapping[varName];
if (!mapped) return false;
if (mapped === VARIABLE_MAPPING_USE_DEFAULT) return true;
if (mapped === VARIABLE_MAPPING_USE_ROW_INDEX) return true;
return columns.includes(mapped);
}
/** 按映射将数据行中的列值填入模板变量键;「使用默认值」/「数据序号」时写入对应值 */
export function applyRowVariableMapping(
row: RecordRow,
mapping: Record<string, string>,
variables: string[],
variableDefaults?: Record<string, string> | null,
rowIndex: number = 0
): RecordRow {
const result = { ...row };
for (const varName of variables) {
const mapped = mapping[varName];
if (mapped === VARIABLE_MAPPING_USE_ROW_INDEX) {
result[varName] = String(rowIndex + 1);
continue;
}
if (mapped === VARIABLE_MAPPING_USE_DEFAULT) {
const def = variableDefaults?.[varName];
if (def !== undefined && String(def).trim() !== '') {
result[varName] = String(def).trim();
} else {
delete result[varName];
}
continue;
}
const col = mapped ?? varName;
if (row[col] !== undefined) {
result[varName] = row[col];
}
}
return result;
}
export const DEFAULT_EXPORT_RANGE: ExportRangeConfig = { mode: 'all' };
export const exportRangeEquals = (a: ExportRangeConfig, b: ExportRangeConfig) =>
a.mode === b.mode && (a.custom ?? '') === (b.custom ?? '');
/** 模板数据过滤规则(仅 variable 目标) */
export function resolveDataFilterRules(
template: Pick<LabelTemplate, 'dataFilterRules'>
): VisibilityRule[] {
return resolveVariableFilterRules(template.dataFilterRules);
}
/** 按模板数据过滤条件筛选 0-based 行索引 */
export function getDataFilteredIndices(
rows: RecordRow[],
template: Pick<LabelTemplate, 'dataFilterRules'>,
variableDefaults?: Record<string, string> | null
): number[] {
const total = rows.length;
if (total <= 0) return [];
const rules = getActiveVisibilityRules(resolveDataFilterRules(template));
if (rules.length === 0) return Array.from({ length: total }, (_, i) => i);
return Array.from({ length: total }, (_, i) => i).filter((i) =>
rowMatchesVariableFilterRules(rows[i], i, rules, variableDefaults)
);
}
/** 在候选行上应用排版过滤(奇偶/自定义序号,基于原始 1-based 行号) */
export function getLayoutRangeIndices(
candidateIndices: number[],
totalRows: number,
range: ExportRangeConfig
): number[] {
if (candidateIndices.length === 0) return [];
switch (range.mode) {
case 'odd':
return candidateIndices.filter((i) => (i + 1) % 2 === 1);
case 'even':
return candidateIndices.filter((i) => (i + 1) % 2 === 0);
case 'custom': {
const parsed = parseCustomExportRange(range.custom ?? '', totalRows);
const candidateSet = new Set(candidateIndices);
return parsed.filter((i) => candidateSet.has(i));
}
case 'all':
default:
return candidateIndices;
}
}
/** 按数据过滤 + 排版过滤得到最终 0-based 行索引(保持升序) */
export function getExportRangeIndices(
rows: RecordRow[],
range: ExportRangeConfig,
template: Pick<LabelTemplate, 'dataFilterRules'>,
variableDefaults?: Record<string, string> | null
): number[] {
const dataFiltered = getDataFilteredIndices(rows, template, variableDefaults);
return getLayoutRangeIndices(dataFiltered, rows.length, range);
}
export const DEFAULT_CUT_LINE_CONFIG: CutLineConfig = {
enabled: true,
style: 'dashed',
width: 0.2,
color: '#333333',
};
/** 裁切线有效线宽 (mm),预览与 PDF 共用 */
export function getCutLineStrokeWidthMm(cutLine: Pick<CutLineConfig, 'width'>): number {
return Math.max(0.02, cutLine.width);
}
/** 虚线单段长度 (mm),预览与 PDF 共用 */
export function getCutLineDashSegmentMm(cutLine: Pick<CutLineConfig, 'style' | 'width'>): number {
if (cutLine.style !== 'dashed') return 0;
return Math.max(0.5, cutLine.width * 4);
}
/** 预览 SVG strokeDasharrayscale 为 mm→px 比例 */
export function formatCutLineSvgDashArray(
cutLine: Pick<CutLineConfig, 'style' | 'width'>,
scale: number
): string | undefined {
if (cutLine.style !== 'dashed') return undefined;
const seg = getCutLineDashSegmentMm(cutLine) * scale;
return `${seg} ${seg}`;
}
export interface GridCutLinePositions {
xs: number[];
ys: number[];
gridW: number;
gridH: number;
}
/** 收集网格裁切线坐标(去重),每条线只绘制一次,避免转角/外缘叠线变粗 */
export function collectGridCutLinePositions(
originX: number,
originY: number,
labelW: number,
labelH: number,
gridCols: number,
gridRows: number,
columnGap: number,
rowGap: number
): GridCutLinePositions {
const gridW = gridCols * labelW + Math.max(0, gridCols - 1) * columnGap;
const gridH = gridRows * labelH + Math.max(0, gridRows - 1) * rowGap;
const xs = new Set<number>();
const ys = new Set<number>();
for (let c = 0; c < gridCols; c++) {
xs.add(originX + c * (labelW + columnGap));
xs.add(originX + c * (labelW + columnGap) + labelW);
}
for (let r = 0; r < gridRows; r++) {
ys.add(originY + r * (labelH + rowGap));
ys.add(originY + r * (labelH + rowGap) + labelH);
}
return {
xs: [...xs].sort((a, b) => a - b),
ys: [...ys].sort((a, b) => a - b),
gridW,
gridH,
};
}
/** 间距为 0 且启用裁切线时,为裁切线预留半线宽通道,避免与标签图重叠导致内外粗细不一 */
export function getCutLineChannel(
columnGap: number,
rowGap: number,
cutLine: { enabled: boolean; width: number }
): { x: number; y: number } {
if (!cutLine.enabled) return { x: 0, y: 0 };
const half = cutLine.width / 2;
return {
x: columnGap === 0 ? half : 0,
y: rowGap === 0 ? half : 0,
};
}
export interface PrintableLabel {
row: RecordRow;
/** 原始数据行索引0-based用于变量解析 */
sourceIndex: number;
}
/** 解析自定义范围字符串1-based 序号) */
export function parseCustomExportRange(input: string, total: number): number[] {
const trimmed = input.trim();
if (!trimmed || total <= 0) return [];
const result = new Set<number>();
const parts = trimmed.split(/[,,、\s]+/).filter(Boolean);
for (const part of parts) {
const rangeMatch = part.match(/^(\d+)\s*-\s*(\d+)$/);
if (rangeMatch) {
const start = Math.max(1, parseInt(rangeMatch[1], 10));
const end = Math.min(total, parseInt(rangeMatch[2], 10));
if (!Number.isNaN(start) && !Number.isNaN(end)) {
for (let n = start; n <= end; n++) result.add(n - 1);
}
} else {
const n = parseInt(part, 10);
if (!Number.isNaN(n) && n >= 1 && n <= total) result.add(n - 1);
}
}
return Array.from(result).sort((a, b) => a - b);
}
export function buildPrintablePages(
rows: RecordRow[],
paper: PaperConfig,
template: Pick<LabelTemplate, 'width' | 'height' | 'dataFilterRules'>,
exportRange: ExportRangeConfig,
variableDefaults?: Record<string, string> | null
) {
const gridInfo = calculateGrid(paper, template);
const labelsPerPage = gridInfo.cols * gridInfo.rows;
const indices = getExportRangeIndices(rows, exportRange, template, variableDefaults);
const selected: PrintableLabel[] = indices.map((i) => ({ row: rows[i], sourceIndex: i }));
const totalPages = Math.ceil(selected.length / labelsPerPage) || 1;
const pages: (PrintableLabel | null)[][] = [];
for (let p = 0; p < totalPages; p++) {
const pageCells: (PrintableLabel | null)[] = [];
const startIdx = p * labelsPerPage;
for (let cell = 0; cell < labelsPerPage; cell++) {
pageCells.push(selected[startIdx + cell] ?? null);
}
if (paper.flow === 'top-bottom-left-right') {
const reordered: (PrintableLabel | null)[] = Array(labelsPerPage).fill(null);
let ptr = 0;
for (let c = 0; c < gridInfo.cols; c++) {
for (let r = 0; r < gridInfo.rows; r++) {
const cellIndexInGrid = r * gridInfo.cols + c;
if (ptr < pageCells.length) {
reordered[cellIndexInGrid] = pageCells[ptr++];
}
}
}
pages.push(reordered);
} else {
pages.push(pageCells);
}
}
return {
pages,
gridCols: gridInfo.cols,
gridRows: gridInfo.rows,
labelsPerPage,
totalPages,
selectedCount: selected.length,
};
}
/** 自动排版时允许的最小页边距 (mm) */
export const AUTO_LAYOUT_MIN_PAGE_MARGIN_MM = 10;
const AUTO_LAYOUT_MIN_MARGIN_STORAGE_KEY = 'label_designer_auto_layout_min_margin_mm';
/** 读取上次自动排版使用的最小页边距 (mm) */
export function loadAutoLayoutMinPageMarginMm(): number {
try {
const raw = localStorage.getItem(AUTO_LAYOUT_MIN_MARGIN_STORAGE_KEY);
if (raw === null) return AUTO_LAYOUT_MIN_PAGE_MARGIN_MM;
const n = Number(raw);
if (Number.isFinite(n) && n >= 0) return n;
} catch {
/* ignore */
}
return AUTO_LAYOUT_MIN_PAGE_MARGIN_MM;
}
/** 保存自动排版使用的最小页边距 (mm) */
export function saveAutoLayoutMinPageMarginMm(value: number): void {
try {
localStorage.setItem(AUTO_LAYOUT_MIN_MARGIN_STORAGE_KEY, String(value));
} catch {
/* ignore */
}
}
/** 按最小页边距居中计算四边页边距 */
export function computeAutoLayoutPaper(
paper: PaperConfig,
template: Pick<LabelTemplate, 'width' | 'height'>,
minMargin: number
): PaperConfig {
const paperW = paper.orientation === 'portrait' ? paper.width : paper.height;
const paperH = paper.orientation === 'portrait' ? paper.height : paper.width;
const lw = template.width;
const lh = template.height;
const usableWInit = paperW - minMargin * 2;
const usableHInit = paperH - minMargin * 2;
const maxCols = Math.floor((usableWInit + paper.columnGap) / (lw + paper.columnGap)) || 1;
const maxRows = Math.floor((usableHInit + paper.rowGap) / (lh + paper.rowGap)) || 1;
const gridWidth = maxCols * lw + (maxCols - 1) * paper.columnGap;
const gridHeight = maxRows * lh + (maxRows - 1) * paper.rowGap;
const balancedLR = Math.max(minMargin, Math.round(((paperW - gridWidth) / 2) * 10) / 10);
const balancedTB = Math.max(minMargin, Math.round(((paperH - gridHeight) / 2) * 10) / 10);
return {
...paper,
marginLeft: balancedLR,
marginRight: balancedLR,
marginTop: balancedTB,
marginBottom: balancedTB,
};
}
export const DEFAULT_PAPER_CONFIG: PaperConfig = {
type: 'A4',
width: 210,
height: 297,
orientation: 'portrait',
marginTop: AUTO_LAYOUT_MIN_PAGE_MARGIN_MM,
marginBottom: AUTO_LAYOUT_MIN_PAGE_MARGIN_MM,
marginLeft: AUTO_LAYOUT_MIN_PAGE_MARGIN_MM,
marginRight: AUTO_LAYOUT_MIN_PAGE_MARGIN_MM,
columnGap: 0,
rowGap: 0,
flow: 'left-right-top-bottom',
};
/** 补全模板默认排版字段,剥离已废弃的数据源字段 */
export function normalizeTemplate(tmpl: LabelTemplate): LabelTemplate {
const { ...rest } = tmpl;
const cutLine: CutLineConfig = {
...DEFAULT_CUT_LINE_CONFIG,
...rest.cutLine,
enabled: rest.cutLine?.enabled ?? true,
width:
typeof rest.cutLine?.width === 'number' && rest.cutLine.width > 0
? rest.cutLine.width
: DEFAULT_CUT_LINE_CONFIG.width,
};
return {
...rest,
paper: rest.paper ?? DEFAULT_PAPER_CONFIG,
cutLine,
};
}
/** 从模板对象中剥离数据相关字段,仅保留设计信息 */
export function stripTemplateForStorage(tmpl: LabelTemplate): LabelTemplate {
return normalizeTemplate(tmpl);
}
export const MM_TO_PX = 3.78;
export function mmToPx(mm: number, zoom: number = 1): number {
return imgRound(mm * MM_TO_PX * zoom);
}
function imgRound(val: number): number {
return Math.round(val * 100) / 100;
}
/** 从文本中提取 {变量名} 占位符(排除 #INDEX */
export function extractVariablesFromContent(content: string): string[] {
const vars = new Set<string>();
const matches = content.match(/\{([^}]+)\}/g);
if (matches) {
for (const m of matches) {
const name = m.slice(1, -1).trim();
if (name && name !== '#INDEX') vars.add(name);
}
}
return Array.from(vars);
}
/** 将元素背景色与不透明度解析为 canvas 可用的 rgba 字符串;无需绘制时返回 null */
export function resolveElementBackgroundFill(
backgroundColor?: string,
backgroundOpacity?: number
): string | null {
if (!backgroundColor || backgroundColor === 'transparent') return null;
const opacity = backgroundOpacity !== undefined ? backgroundOpacity : 100;
if (opacity <= 0) return null;
const alpha = Math.min(1, Math.max(0, opacity / 100));
const color = backgroundColor.trim();
if (color.startsWith('rgba(')) {
const m = color.match(/rgba\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*\)/);
if (m) {
const a = Math.min(1, Math.max(0, parseFloat(m[4]) * alpha));
return `rgba(${m[1]},${m[2]},${m[3]},${a})`;
}
return color;
}
if (color.startsWith('rgb(')) {
const m = color.match(/rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/);
if (m) return `rgba(${m[1]},${m[2]},${m[3]},${alpha})`;
}
if (color.startsWith('#')) {
let hex = color.slice(1);
if (hex.length === 3) {
hex = hex.split('').map((c) => c + c).join('');
}
if (hex.length === 8) {
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
const a = (parseInt(hex.slice(6, 8), 16) / 255) * alpha;
return `rgba(${r},${g},${b},${a})`;
}
if (hex.length === 6) {
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
return `rgba(${r},${g},${b},${alpha})`;
}
}
return color;
}
/** 从显示/过滤条件中收集引用的变量名 */
function collectVariablesFromVisibilityRule(rule: VisibilityRule, vars: Set<string>) {
const name = (rule.variable ?? '').trim();
if (name) vars.add(name);
if (rule.value) {
for (const v of extractVariablesFromContent(rule.value)) {
vars.add(v);
}
}
}
function collectVariablesFromVisibilityRules(rules: VisibilityRule[] | undefined, vars: Set<string>) {
if (!rules) return;
for (const rule of rules) {
collectVariablesFromVisibilityRule(rule, vars);
}
}
/** 从元素内容(含表格单元格)提取变量名 */
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[];
elements: TemplateElement[];
dataFilterRules?: VisibilityRule[];
exportImageFilterRules?: VisibilityRule[];
}): string[] {
const vars = new Set<string>(template.variableList ?? []);
for (const elem of template.elements) {
for (const v of extractVariablesFromElement(elem)) {
vars.add(v);
}
collectVariablesFromVisibilityRules(resolveVisibilityRules(elem), vars);
}
collectVariablesFromVisibilityRules(template.dataFilterRules, vars);
collectVariablesFromVisibilityRules(template.exportImageFilterRules, vars);
return Array.from(vars).sort((a, b) => a.localeCompare(b, 'zh-CN'));
}
/** 被元素内容或显示条件引用的变量 */
export function getUsedTemplateVariables(template: {
elements: TemplateElement[];
dataFilterRules?: VisibilityRule[];
exportImageFilterRules?: VisibilityRule[];
}): string[] {
const vars = new Set<string>();
for (const elem of template.elements) {
for (const v of extractVariablesFromElement(elem)) {
vars.add(v);
}
collectVariablesFromVisibilityRules(resolveVisibilityRules(elem), vars);
}
collectVariablesFromVisibilityRules(template.dataFilterRules, vars);
collectVariablesFromVisibilityRules(template.exportImageFilterRules, vars);
return Array.from(vars);
}
export function isTemplateVariableInUse(
template: { elements: TemplateElement[] },
varName: string
): boolean {
return getUsedTemplateVariables(template).includes(varName);
}
/** 从模板声明列表移除未使用的变量(内容及显示条件未引用) */
export function removeUnusedTemplateVariable(
template: LabelTemplate,
varName: string
): LabelTemplate {
if (isTemplateVariableInUse(template, varName)) return template;
const variableList = (template.variableList ?? []).filter((v) => v !== varName);
const defaults = { ...(template.variableDefaults || {}) };
delete defaults[varName];
return {
...template,
variableList: variableList.length > 0 ? variableList : undefined,
variableDefaults: Object.keys(defaults).length > 0 ? defaults : undefined,
};
}
export function isConditionalVisibility(elem: TemplateElement): boolean {
if (elem.visibilityMode === 'conditional') return true;
if (elem.visibilityMode === 'always') return false;
return !!elem.hideWhenNoData;
}
export function visibilityOperatorNeedsValue(operator: VisibilityOperator): boolean {
return (
operator === 'gt' ||
operator === 'lt' ||
operator === 'eq' ||
operator === 'neq' ||
operator === 'contains' ||
operator === 'not_contains'
);
}
export function isVisibilityRuleEnabled(rule: VisibilityRule): boolean {
return rule.enabled !== false;
}
export function getActiveVisibilityRules(rules: VisibilityRule[]): VisibilityRule[] {
return rules.filter(isVisibilityRuleEnabled);
}
export function toggleVisibilityRuleEnabled(rule: VisibilityRule): VisibilityRule {
return { ...rule, enabled: isVisibilityRuleEnabled(rule) ? false : undefined };
}
/** 将显示条件格式化为可读说明 */
export function formatVisibilityRuleDescription(rule: VisibilityRule): string {
const subjectLabel =
rule.target === 'content' ? '内容值' : `{${rule.variable}}`;
switch (rule.operator) {
case 'empty':
return `${subjectLabel} 为空`;
case 'not_empty':
return `${subjectLabel} 不为空`;
case 'gt':
return `${subjectLabel} 大于 ${rule.value ?? '—'}`;
case 'lt':
return `${subjectLabel} 小于 ${rule.value ?? '—'}`;
case 'eq':
return `${subjectLabel} 等于 ${rule.value ?? '—'}`;
case 'neq':
return `${subjectLabel} 不等于 ${rule.value ?? '—'}`;
case 'contains':
return `${subjectLabel} 包含 ${rule.value ?? '—'}`;
case 'not_contains':
return `${subjectLabel} 不包含 ${rule.value ?? '—'}`;
default:
return `${subjectLabel} ${VISIBILITY_OPERATOR_LABELS[rule.operator]}`;
}
}
export function isVisibilityContentTarget(rule: VisibilityRule): boolean {
return rule.target === 'content';
}
/** 解析显示控制规则(兼容旧 visibilityVariables 字段) */
export function resolveVisibilityRules(elem: TemplateElement): VisibilityRule[] {
if (elem.visibilityRules !== undefined) {
return elem.visibilityRules
.map((r) => ({
target: r.target ?? 'variable',
variable: (r.variable ?? '').trim(),
operator: r.operator,
value: r.value,
enabled: r.enabled,
}))
.filter((r) => r.target === 'content' || r.variable);
}
const legacyVars = (elem.visibilityVariables ?? []).map((v) => v.trim()).filter(Boolean);
if (legacyVars.length > 0) {
return legacyVars.map((variable) => ({ variable, operator: 'not_empty' as const }));
}
const legacy = elem.visibilityVariable?.trim();
if (legacy) return [{ variable: legacy, operator: 'not_empty' }];
if (elem.hideWhenNoData) {
return extractVariablesFromContent(elem.content).map((variable) => ({
variable,
operator: 'not_empty' as const,
}));
}
return [];
}
function parseComparableNumber(value: string): number | null {
const num = Number(value.replace(/,/g, '').trim());
return Number.isNaN(num) ? null : num;
}
function compareVariableToTarget(
raw: string,
target: string,
operator: 'gt' | 'lt' | 'eq' | 'neq'
): boolean {
const numRaw = parseComparableNumber(raw);
const numTarget = parseComparableNumber(target);
if (numRaw !== null && numTarget !== null) {
if (operator === 'gt') return numRaw > numTarget;
if (operator === 'lt') return numRaw < numTarget;
if (operator === 'eq') return numRaw === numTarget;
return numRaw !== numTarget;
}
if (operator === 'eq') return raw === target;
if (operator === 'neq') return raw !== target;
return false;
}
/** 读取显示条件所判断的原始值 */
function getVisibilityRuleRawValue(
rule: VisibilityRule,
elem: TemplateElement | undefined,
row: RecordRow | null,
rowIndex: number,
defaults?: Record<string, string> | null,
mode: ContentResolveMode = 'output'
): string | undefined {
if (isVisibilityContentTarget(rule)) {
if (!elem) return undefined;
const effectiveRow =
row ?? (mode === 'design' && defaults ? (defaults as RecordRow) : null);
const resolved = resolveElementCoreValue(elem, effectiveRow, rowIndex, defaults, 'output');
const trimmed = resolved.trim();
return trimmed || undefined;
}
return getVariableRawValue(rule.variable, row, defaults, mode);
}
/** 解析条件目标值(支持 {变量名} */
export function resolveVisibilityRuleCompareTarget(
targetTemplate: string | undefined,
row: RecordRow | null,
defaults: Record<string, string> | null | undefined,
mode: ContentResolveMode,
rowIndex: number = 0
): string {
const trimmed = (targetTemplate ?? '').trim();
if (!trimmed) return '';
if (!trimmed.includes('{')) return trimmed;
return resolveContent(trimmed, row, rowIndex, defaults, { mode }).trim();
}
/** 单条显示条件是否满足 */
export function evaluateVisibilityRule(
rule: VisibilityRule,
row: RecordRow | null,
defaults?: Record<string, string> | null,
mode: ContentResolveMode = 'output',
elem?: TemplateElement,
rowIndex: number = 0
): boolean {
const raw = getVisibilityRuleRawValue(rule, elem, row, rowIndex, defaults, mode);
const isEmpty = raw === undefined;
const compareTarget = resolveVisibilityRuleCompareTarget(
rule.value,
row,
defaults,
mode,
rowIndex
);
switch (rule.operator) {
case 'empty':
return isEmpty;
case 'not_empty':
return !isEmpty;
case 'gt':
case 'lt':
case 'eq': {
if (isEmpty) return false;
return compareVariableToTarget(raw!, compareTarget, rule.operator);
}
case 'neq': {
if (isEmpty) return compareTarget !== '';
return compareVariableToTarget(raw!, compareTarget, 'neq');
}
case 'contains': {
if (!compareTarget || isEmpty) return false;
return raw!.includes(compareTarget);
}
case 'not_contains': {
if (!compareTarget) return true;
if (isEmpty) return true;
return !raw!.includes(compareTarget);
}
default:
return true;
}
}
export type ContentResolveMode = 'design' | 'output';
export interface ResolveContentOptions {
mode?: ContentResolveMode;
/** 对单个变量替换值做格式化(如数值/百分比) */
formatSubst?: (raw: string) => string;
}
/** 将 {变量} 替换为行数据或设计默认值 */
export function resolveContent(
content: string,
row: RecordRow | null,
fallbackIndex: number = 0,
defaults?: Record<string, string> | null,
options?: ResolveContentOptions
): string {
if (!content) return '';
const mode = options?.mode ?? 'design';
return content.replace(/\{([^}]+)\}/g, (_, key) => {
if (key === '#INDEX') return String(fallbackIndex + 1);
let val: string | undefined;
if (row && row[key] !== undefined && String(row[key]).trim() !== '') {
val = String(row[key]);
} else if (defaults && defaults[key] !== undefined && String(defaults[key]).trim() !== '') {
val = String(defaults[key]);
}
if (val === undefined) {
return mode === 'design' ? `[${key}]` : '';
}
return options?.formatSubst ? options.formatSubst(val) : val;
});
}
/** 清理为安全的文件名(不含扩展名) */
export function sanitizeFileName(name: string, fallback = 'label'): string {
const cleaned = name
.replace(/[\\/:*?"<>|]/g, '_')
.replace(/[\u0000-\u001f]/g, '')
.replace(/\s+/g, ' ')
.trim()
.replace(/[. ]+$/g, '')
.slice(0, 120);
return cleaned || fallback;
}
/**
* 解析批量导出图片的文件名模板。
* - {#SEQ}导出顺序1-based{#SEQ:4} 可补零
* - {#INDEX}数据行序号1-based{#INDEX:4} 可补零
* - {变量名}:当前行数据或 variableDefaults
*/
export function resolveExportImageFileName(
pattern: string | undefined,
label: PrintableLabel,
seq: number,
padWidth: number,
variableDefaults?: Record<string, string> | null
): string {
if (!pattern?.trim()) {
return String(seq).padStart(padWidth, '0');
}
const normalizedPattern = pattern.replace(/\r?\n/g, '').trim();
if (!normalizedPattern) {
return String(seq).padStart(padWidth, '0');
}
const resolved = normalizedPattern.replace(/\{([^}]+)\}/g, (_, key: string) => {
const special = key.match(/^(#SEQ|#INDEX)(?::(\d+))?$/);
if (special) {
const width = special[2] ? parseInt(special[2], 10) : 0;
const raw = special[1] === '#SEQ' ? String(seq) : String(label.sourceIndex + 1);
return width > 0 ? raw.padStart(width, '0') : raw;
}
if (key === '#SEQ') return String(seq);
if (key === '#INDEX') return String(label.sourceIndex + 1);
let val: string | undefined;
if (label.row && label.row[key] !== undefined && String(label.row[key]).trim() !== '') {
val = String(label.row[key]);
} else if (
variableDefaults &&
variableDefaults[key] !== undefined &&
String(variableDefaults[key]).trim() !== ''
) {
val = String(variableDefaults[key]);
}
return val ?? '';
});
return sanitizeFileName(resolved);
}
/** 解析变量过滤规则(仅 variable 目标) */
export function resolveVariableFilterRules(rules?: VisibilityRule[]): VisibilityRule[] {
return (rules ?? [])
.map((rule) => ({
target: rule.target ?? 'variable',
variable: (rule.variable ?? '').trim(),
operator: rule.operator,
value: rule.value,
enabled: rule.enabled,
}))
.filter((rule) => rule.target === 'variable' && rule.variable);
}
/** 数据行是否满足全部变量过滤条件 */
export function rowMatchesVariableFilterRules(
row: RecordRow,
rowIndex: number,
rules: VisibilityRule[],
variableDefaults?: Record<string, string> | null
): boolean {
const activeRules = getActiveVisibilityRules(rules);
if (activeRules.length === 0) return true;
return activeRules.every((rule) =>
evaluateVisibilityRule(rule, row, variableDefaults, 'output', undefined, rowIndex)
);
}
export function isExportImageFilterConditional(
template: Pick<LabelTemplate, 'exportImageFilterMode'>
): boolean {
return template.exportImageFilterMode === 'conditional';
}
export function resolveExportImageFilterRules(
template: Pick<LabelTemplate, 'exportImageFilterRules'>
): VisibilityRule[] {
return resolveVariableFilterRules(template.exportImageFilterRules);
}
/** 批量导出图片时,当前数据行是否应导出 */
export function shouldExportLabelImage(
template: Pick<LabelTemplate, 'exportImageFilterMode' | 'exportImageFilterRules'>,
label: PrintableLabel,
variableDefaults?: Record<string, string> | null
): boolean {
if (!isExportImageFilterConditional(template)) return true;
const rules = resolveExportImageFilterRules(template);
return rowMatchesVariableFilterRules(label.row, label.sourceIndex, rules, variableDefaults);
}
export function filterLabelsForImageExport(
printablePages: (PrintableLabel | null)[][],
template: Pick<LabelTemplate, 'exportImageFilterMode' | 'exportImageFilterRules'>,
variableDefaults?: Record<string, string> | null
): PrintableLabel[] {
return flattenPrintableLabelsForExport(printablePages).filter((label) =>
shouldExportLabelImage(template, label, variableDefaults)
);
}
function flattenPrintableLabelsForExport(
printablePages: (PrintableLabel | null)[][]
): PrintableLabel[] {
const labels: PrintableLabel[] = [];
for (const page of printablePages) {
for (const item of page) {
if (item) labels.push(item);
}
}
return labels;
}
/** 格式化单个数值用于文本显示 */
export function formatDisplayValue(
value: string,
format: Exclude<TextFormat, 'text'>,
decimalPlaces?: number
): string {
const trimmed = value.trim();
if (!trimmed) return '';
const num = Number(trimmed.replace(/,/g, ''));
if (Number.isNaN(num)) return value;
const places = decimalPlaces ?? 2;
const formatted = num.toLocaleString('zh-CN', {
minimumFractionDigits: places,
maximumFractionDigits: places,
});
switch (format) {
case 'number':
return formatted;
case 'percent':
return `${formatted}%`;
case 'currency':
return `¥${formatted}`;
default:
return value;
}
}
/** 按整数/小数部分拆分数值显示(如价格 19.9 →「19」与「.9」分元素展示) */
export function applyNumberPartDisplay(
value: string,
format: Exclude<TextFormat, 'text'>,
decimalPlaces: number | undefined,
part: NumberPartDisplay
): string {
if (part === 'full') {
return formatDisplayValue(value, format, decimalPlaces);
}
const trimmed = value.trim().replace(/,/g, '');
if (!trimmed) return '';
const num = Number(trimmed);
if (Number.isNaN(num)) {
return formatDisplayValue(value, format, decimalPlaces);
}
const places = decimalPlaces ?? 2;
const sign = num < 0 ? '-' : '';
const abs = Math.abs(num);
const intVal = Math.trunc(abs);
const intStr = intVal.toLocaleString('zh-CN');
if (part === 'integer') {
switch (format) {
case 'number':
return `${sign}${intStr}`;
case 'percent':
return `${sign}${intStr}%`;
case 'currency':
return `${sign}¥${intStr}`;
default:
return `${sign}${intStr}`;
}
}
const fracNum = abs - intVal;
let fracStr = fracNum.toFixed(places).slice(1);
if (!fracStr || fracStr === '.' || fracStr === '.' + '0'.repeat(places)) {
fracStr = places > 0 ? '.' + '0'.repeat(places) : '';
}
return fracStr;
}
/** 按分隔符取值中的第 N 段1-based段数不足时返回空字符串 */
export function applyTextSplitSegment(
value: string,
delimiter: string,
index: number
): string {
if (!delimiter || index < 1) return value;
const parts = value.split(delimiter);
if (index > parts.length) return '';
return parts[index - 1];
}
/** 解析元素有效的文本提取方式(兼容旧版仅配置分隔符) */
export function getTextExtractMode(elem: Pick<
TemplateElement,
'textExtractMode' | 'textSplitDelimiter' | 'textSplitIndex'
>): TextExtractMode {
if (elem.textExtractMode !== undefined) return elem.textExtractMode;
const delimiter = elem.textSplitDelimiter?.trim();
const splitIndex = elem.textSplitIndex ?? 0;
if (delimiter && splitIndex >= 1) return 'split';
return 'none';
}
/** 按配置提取变量值片段 */
export function applyTextExtract(
value: string,
elem: Pick<
TemplateElement,
'textExtractMode' | 'textSplitDelimiter' | 'textSplitIndex' | 'textExtractLength'
>
): string {
const trimmed = value.trim();
const mode = getTextExtractMode(elem);
switch (mode) {
case 'split': {
const delimiter = elem.textSplitDelimiter?.trim();
const index = elem.textSplitIndex ?? 0;
if (!delimiter || index < 1) return trimmed;
return applyTextSplitSegment(trimmed, delimiter, index);
}
case 'prefix': {
const n = elem.textExtractLength ?? 0;
if (n < 1) return trimmed;
return trimmed.slice(0, n);
}
case 'suffix': {
const n = elem.textExtractLength ?? 0;
if (n < 1) return trimmed;
return trimmed.slice(-n);
}
default:
return trimmed;
}
}
/** 元素是否启用了变量值提取(文本/条码/二维码/图片内容均有效) */
export function elementHasValueExtract(
elem: Pick<
TemplateElement,
'textExtractMode' | 'textSplitDelimiter' | 'textSplitIndex' | 'textExtractLength'
>
): boolean {
const mode = getTextExtractMode(elem);
if (mode === 'split') {
const delimiter = elem.textSplitDelimiter?.trim();
const index = elem.textSplitIndex ?? 0;
return !!(delimiter && index >= 1);
}
if (mode === 'prefix' || mode === 'suffix') {
return (elem.textExtractLength ?? 0) >= 1;
}
return false;
}
/** @deprecated 使用 elementHasValueExtract */
export const textElementHasExtract = elementHasValueExtract;
/** 提取后为文本追加显示用前后缀(不参与内容值条件) */
export function applyTextDisplayAffix(
value: string,
elem: Pick<TemplateElement, 'textDisplayPrefix' | 'textDisplaySuffix'>
): string {
const prefix = elem.textDisplayPrefix ?? '';
const suffix = elem.textDisplaySuffix ?? '';
if (!prefix && !suffix) return value;
return `${prefix}${value}${suffix}`;
}
/** 文本元素单个变量值的数值格式化(不含内容提取) */
export function formatTextNumberSubstValue(elem: TemplateElement, raw: string): string {
if (elem.type !== 'text' || !elem.textFormat || elem.textFormat === 'text') {
return raw;
}
return applyNumberPartDisplay(
raw,
elem.textFormat as Exclude<TextFormat, 'text'>,
elem.decimalPlaces,
elem.numberPartDisplay ?? 'full'
);
}
/** @deprecated 提取已改为对完整内容生效,请使用 formatTextNumberSubstValue */
export function formatElementSubstValue(elem: TemplateElement, raw: string): string {
return formatTextNumberSubstValue(elem, raw);
}
/** @deprecated 使用 formatTextNumberSubstValue */
export const formatTextSubstValue = formatElementSubstValue;
/** 文本元素是否需要对变量替换值做数值格式化 */
export function textElementNeedsNumberFormatSubst(elem: TemplateElement): boolean {
return elem.type === 'text' && !!(elem.textFormat && elem.textFormat !== 'text');
}
/** @deprecated 使用 textElementNeedsNumberFormatSubst 与 elementHasValueExtract */
export function elementNeedsFormatSubst(elem: TemplateElement): boolean {
return elementHasValueExtract(elem) || textElementNeedsNumberFormatSubst(elem);
}
/** @deprecated 使用 textElementNeedsNumberFormatSubst */
export const textElementNeedsFormatSubst = elementNeedsFormatSubst;
/** 读取变量原始值(仅行数据;设计模式可选用默认值) */
export function getVariableRawValue(
varName: string,
row: RecordRow | null,
defaults?: Record<string, string> | null,
mode: ContentResolveMode = 'output'
): string | undefined {
if (row && row[varName] !== undefined && String(row[varName]).trim() !== '') {
return String(row[varName]).trim();
}
if (mode === 'design' && defaults && defaults[varName] !== undefined && String(defaults[varName]).trim() !== '') {
return String(defaults[varName]).trim();
}
return undefined;
}
/** 是否应渲染元素(显示控制为 conditional 时按关联变量判断;设计模式使用变量默认值) */
export function shouldRenderElement(
elem: TemplateElement,
row: RecordRow | null,
rowIndex: number,
defaults?: Record<string, string> | null,
mode: ContentResolveMode = 'output'
): boolean {
if (!isConditionalVisibility(elem)) return true;
const rules = resolveVisibilityRules(elem);
if (rules.length > 0) {
const activeRules = getActiveVisibilityRules(rules);
if (activeRules.length === 0) return true;
return activeRules.every((rule) =>
evaluateVisibilityRule(rule, row, defaults, mode, elem, rowIndex)
);
}
if (elem.visibilityMode === 'conditional' || elem.visibilityRules !== undefined) {
return true;
}
const value = resolveElementCoreValue(elem, row, rowIndex, defaults, mode);
return !!value && String(value).trim() !== '';
}
/** 解析元素内容值:变量替换 + 数值格式 + 提取(不含显示前后缀) */
export function resolveElementCoreValue(
elem: TemplateElement,
row: RecordRow | null,
rowIndex: number,
defaults?: Record<string, string> | null,
mode: ContentResolveMode = 'design'
): string {
const formatSubst = textElementNeedsNumberFormatSubst(elem)
? (raw: string) => formatTextNumberSubstValue(elem, raw)
: undefined;
let resolved = resolveContent(elem.content, row, rowIndex, defaults, { mode, formatSubst });
if (elementHasValueExtract(elem)) {
resolved = applyTextExtract(resolved, elem);
}
return resolved;
}
/** 解析元素最终显示内容(含显示前后缀) */
export function resolveElementValue(
elem: TemplateElement,
row: RecordRow | null,
rowIndex: number,
defaults?: Record<string, string> | null,
mode: ContentResolveMode = 'design'
): string {
const core = resolveElementCoreValue(elem, row, rowIndex, defaults, mode);
if (elem.type !== 'text') return core;
return applyTextDisplayAffix(core, elem);
}
/** 输出模式下条码/二维码/图片内容为空时不渲染(除非 showWhenContentEmpty 为 true */
export function isGraphicContentEmpty(
elem: TemplateElement,
value: string,
mode: ContentResolveMode
): boolean {
if (mode === 'design') return false;
if (elem.type !== 'barcode' && elem.type !== 'qrcode' && elem.type !== 'image') return false;
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
export function calculateGrid(paper: PaperConfig, label: { width: number; height: number }) {
const paperW = paper.orientation === 'portrait' ? paper.width : paper.height;
const paperH = paper.orientation === 'portrait' ? paper.height : paper.width;
const usableW = paperW - paper.marginLeft - paper.marginRight;
const usableH = paperH - paper.marginTop - paper.marginBottom;
// Let's solve: cols * label.width + (cols - 1) * columnGap <= usableW
// cols * (label.width + columnGap) - columnGap <= usableW
// cols <= (usableW + columnGap) / (label.width + columnGap)
const cols = Math.max(1, Math.floor((usableW + paper.columnGap) / (label.width + paper.columnGap)));
const rows = Math.max(1, Math.floor((usableH + paper.rowGap) / (label.height + paper.rowGap)));
return { cols, rows };
}
export const SAMPLE_COLUMNS = ['货位编号', '物料名称', '规格型号', '批次号', '条形码', '二维码'];
export const SAMPLE_ROWS: RecordRow[] = [
{ '货位编号': 'A-01-05', '物料名称': '轴承支撑座', '规格型号': 'BF12-M8', '批次号': '2026061101', '条形码': 'LOC-A0105', '二维码': 'https://ais-dev.run.apps/cargo/A0105' },
{ '货位编号': 'A-01-06', '物料名称': '高精滚珠丝杠', '规格型号': '1605-300mm', '批次号': '2026061102', '条形码': 'LOC-A0106', '二维码': 'https://ais-dev.run.apps/cargo/A0106' },
{ '货位编号': 'B-02-11', '物料名称': '同步带轮', '规格型号': 'XL20-6.35B', '批次号': '2026061103', '条形码': 'LOC-B0211', '二维码': 'https://ais-dev.run.apps/cargo/B0211' },
{ '货位编号': 'B-02-12', '物料名称': '联轴器弹性体', '规格型号': 'D20L25-8x8', '批次号': '2026061104', '条形码': 'LOC-B0212', '二维码': 'https://ais-dev.run.apps/cargo/B0212' },
{ '货位编号': 'C-03-01', '物料名称': '防尘阻水风扇', '规格型号': '12038-220V', '批次号': '2026061105', '条形码': 'LOC-C0301', '二维码': 'https://ais-dev.run.apps/cargo/C0301' },
{ '货位编号': 'C-03-02', '物料名称': '直流开关电源', '规格型号': 'LRS-350-24', '批次号': '2026061106', '条形码': 'LOC-C0302', '二维码': 'https://ais-dev.run.apps/cargo/C0302' },
{ '货位编号': 'D-04-15', '物料名称': '工业继电器组', '规格型号': 'MY2NJ-DC24V', '批次号': '2026061107', '条形码': 'LOC-D0415', '二维码': 'https://ais-dev.run.apps/cargo/D0415' },
{ '货位编号': 'D-04-16', '物料名称': '可编程控制器', '规格型号': 'FX3U-14MT', '批次号': '2026061108', '条形码': 'LOC-D0416', '二维码': 'https://ais-dev.run.apps/cargo/D0416' }
];