import type { ConditionExpression } from '@cloudpss/expression/definitions'; import { TEXT_X_HEIGHT, TEXT_Y_OFFSET, type Model, type ModelCellAttrs, type ModelGraphic, type Parameter, } from '../../model/index.js'; const xmlParser = new DOMParser(); /** * 解析数字 */ function parseNumber(value: unknown): number | undefined { const v = Number(value); if (Number.isNaN(v)) return undefined; return v; } /** * 解析数字,扩大 factor */ function parseInt(value: unknown, factor: number): number { const v = parseNumber(value); if (v == null) return 0; return Number((v * factor).toFixed(0)); } /** * 限制数字大小 */ function clampTo0And1(value?: number): number { if (value == null || Number.isNaN(value)) return 0; if (value <= 0) return 0; if (value >= 0.999_999_9) return 0.999_999_9; return Math.round(value * 1_000_000) / 1_000_000; } /** * 解析 `` */ function parsePath(element: Element, factor: number): string { const n = (v: unknown): number => parseInt(v, factor); let path = ''; for (const instrument of element.children) { switch (instrument.tagName) { case 'move': { const x = n(instrument.getAttribute('x')); const y = n(instrument.getAttribute('y')); path += `M${x} ${y}`; break; } case 'line': { const x = n(instrument.getAttribute('x')); const y = n(instrument.getAttribute('y')); path += `L${x} ${y}`; break; } case 'quad': { const x1 = n(instrument.getAttribute('x1')); const y1 = n(instrument.getAttribute('y1')); const x2 = n(instrument.getAttribute('x2')); const y2 = n(instrument.getAttribute('y2')); path += `Q${x1} ${y1} ${x2} ${y2}`; break; } case 'curve': { const x1 = n(instrument.getAttribute('x1')); const y1 = n(instrument.getAttribute('y1')); const x2 = n(instrument.getAttribute('x2')); const y2 = n(instrument.getAttribute('y2')); const x3 = n(instrument.getAttribute('x3')); const y3 = n(instrument.getAttribute('y3')); path += `C${x1} ${y1} ${x2} ${y2} ${x3} ${y3}`; break; } case 'arc': { const rx = n(instrument.getAttribute('rx')); const ry = n(instrument.getAttribute('ry')); const xa = parseNumber(instrument.getAttribute('x-axis-rotation')) ?? 0; const lf = parseNumber(instrument.getAttribute('large-arc-flag')) ?? 0; const sf = parseNumber(instrument.getAttribute('sweep-flag')) ?? 0; const x = n(instrument.getAttribute('x')); const y = n(instrument.getAttribute('y')); path += `A${rx} ${ry} ${xa} ${lf} ${sf} ${x} ${y}`; break; } case 'close': { path += `Z `; break; } default: break; } } return path; } /** * 将旧 graphic 转换为新的 shape */ class GraphicMigrator { constructor( readonly project: Model, readonly graphic = project.revision.graphic, ) {} /** 引脚 */ readonly pins: ModelGraphic['pins'] = {}; /** 图形 */ readonly markup: ModelGraphic['markup'] = []; /** 属性 */ readonly attrs: ModelCellAttrs = { pathStroke: { stroke: 'var(--stroke)', strokeWidth: 'var(--stroke-width)', strokeOpacity: 'var(--stroke-opacity)', fill: 'transparent', }, pathHelp: { stroke: 'transparent', strokeWidth: 15, fill: 'transparent', }, }; /** 文本图形,放在最顶层 */ readonly textMarkup: ModelGraphic['markup'] = []; /** 转换结果 */ nodeData!: ModelGraphic; /** 转换引脚 `` */ private convertPin(constraint: Element): void { const key = constraint.getAttribute('name') ?? undefined; if (!key) return; this.pins[key] = { position: { x: clampTo0And1(parseNumber(constraint.getAttribute('x'))), y: clampTo0And1(parseNumber(constraint.getAttribute('y'))), }, }; } /** 转换 `` */ private convertText(text: Element, condition: ConditionExpression | undefined): void { const x = parseNumber(text.getAttribute('x')) ?? 0; const y = parseNumber(text.getAttribute('y')) ?? 0; const str = text.getAttribute('str'); if (!str) return; const textSelector = `t${this.textMarkup.length}`; const textContent = str.replaceAll(/%([a-z_]\w*)/gi, (match: string, argName: string): string => { let param: Parameter | undefined; for (const parameters of this.project.revision.parameters) { if (param) break; for (const parameter of parameters.items) { if (parameter.key === argName) { param = parameter; break; } } } if (!param) return match; if ((param.type !== 'real' && param.type !== 'integer') || !param.unit) return `$${argName}`; return `\${${argName}}${param.unit}`; }); const align = text.getAttribute('align')?.toLowerCase(); const vAlign = text.getAttribute('valign')?.toLowerCase(); const textAnchor = align === 'left' ? 'start' : align === 'right' ? 'end' : 'middle'; this.textMarkup.push({ tagName: 'text', condition, selector: textSelector, textContent, attrs: { y: vAlign === 'top' ? `${TEXT_Y_OFFSET + TEXT_X_HEIGHT}em` : vAlign === 'bottom' ? `${TEXT_Y_OFFSET - TEXT_X_HEIGHT}em` : `${TEXT_Y_OFFSET}em`, }, }); this.attrs[textSelector] = { refX: clampTo0And1(x / this.nodeData.width), refY: clampTo0And1(y / this.nodeData.height), textAnchor, }; } /** 转换 `` */ private convertShape(shape: Element): void { const width = parseNumber(shape.getAttribute('w')) ?? this.nodeData.width; const height = parseNumber(shape.getAttribute('h')) ?? this.nodeData.height; let condition: ConditionExpression | null | undefined = shape .getAttribute('cond') ?.trim() as ConditionExpression; if (!condition) condition = undefined; if (condition === 'false') return; const background = shape.querySelector('background')!; let path = ''; for (const element of background.children) { switch (element.tagName) { case 'text': { this.convertText(element, condition); break; } case 'rect': case 'ellipse': { const x = parseInt(element.getAttribute('x'), 1000); const y = parseInt(element.getAttribute('y'), 1000); const w = parseInt(element.getAttribute('w'), 1000); const h = parseInt(element.getAttribute('h'), 1000); if (element.tagName === 'rect') { path += `M${x} ${y}h${w}v${h}h${-w}z`; } else { path += `M${x} ${y + h / 2}a${w / 2} ${h / 2} 0 1 1 ${w} 0a${w / 2} ${h / 2} 0 1 1 ${-w} 0z`; } break; } case 'path': { path += parsePath(element, 1000); break; } // case 'stroke': // case 'fillstroke': default: break; } } if (!path) return; const pathSelector = `p${this.markup.length}`; this.markup.push( { tagName: 'path', groupSelector: [pathSelector, 'pathStroke'], condition, }, { tagName: 'path', groupSelector: [pathSelector, 'pathHelp'], condition, }, ); this.attrs[pathSelector] = { refPath: { d: path, w: Math.round(width * 1000), h: Math.round(height * 1000) }, }; // Foreground 仅用于 pin 名字标签 } /** * 将旧 graphic 转换为新的 shape */ migrate(): void { try { if (typeof this.graphic != 'string' || !this.graphic) throw new Error('No legacy graphic data.'); const document = xmlParser.parseFromString(this.graphic, 'text/xml'); const shapes = document.querySelector('shapes')!; const name = shapes.getAttribute('name'); this.nodeData = { pins: this.pins, markup: this.markup, attrs: this.attrs, width: Math.round(parseNumber(shapes.getAttribute('w')) ?? 50), height: Math.round(parseNumber(shapes.getAttribute('h')) ?? 50), }; Reflect.set(this.nodeData, 'name', name); for (const constraint of shapes.querySelectorAll('connections > constraint')) { this.convertPin(constraint); } for (const shape of document.querySelectorAll('shape')) { this.convertShape(shape); } // 添加文本元素 this.markup.push(...this.textMarkup); // 替换旧的数据 this.project.revision.graphic = this.nodeData; } catch (ex) { // 置空使用自动生成的图形 this.project.revision.graphic = undefined; } } } /** * 将旧 graphic 转换为新的 shape */ export function migrateGraphic(project: Model): void { const migrator = new GraphicMigrator(project); migrator.migrate(); }