mirror of
https://github.com/youfoundjk/TeXcore.git
synced 2026-07-22 07:33:31 +00:00
fix(tikz-editor): resolve statement parsing with embedded semicolons, node math rendering, and multi-selection
- Add `splitTikzStatements` in `TikzCodec` to track `{}` and `[]` nesting depth, preventing premature statement splitting on semicolons inside color definitions like `color={rgb,255:red,95;green,166;blue,221}`.
- Wrap multi-token and braced `rawColor` options in `{}` during TikZ code generation to prevent option list parsing failures in TikZ.
- Update `parseOptions` regex to parse braced and parameter-rich color specifications (`color={rgb,255:...}` and `{HTML}`).
- Enhance `makeText` to unwrap outer `{}` and `$...$` and auto-detect LaTeX math commands (`\mathrm`, `\frac`, etc.) for clean MathJax rendering in text elements.
- Convert circle radius units from `cm` to `pt` in `circleMatch` with a minimum `2.0pt` threshold so node dots are visible on the canvas SVG.
- Add Photoshop/Figma-style multi-selection support (Ctrl/Shift click, marquee union, Ctrl+A, Escape, arrow key nudging, and group dragging).
- Explicitly type string callback parameters in `cmd.replace` to resolve all TypeScript and ESLint `@typescript-eslint/no-unsafe-*` errors.
This commit is contained in:
parent
f0ce1930b8
commit
980e34ef86
5 changed files with 272 additions and 98 deletions
|
|
@ -86,7 +86,6 @@ export class CanvasGrid {
|
|||
const svgNS = 'http://www.w3.org/2000/svg';
|
||||
const selectedVertices = this.context.getSelectedVertices();
|
||||
const elements = this.context.getElements();
|
||||
|
||||
// Draw elements
|
||||
elements.forEach(elem => {
|
||||
if (elem.type !== 'wire' || elem.x2 === undefined || elem.y2 === undefined) return;
|
||||
|
|
@ -620,7 +619,8 @@ export class CanvasGrid {
|
|||
tikzCommand: template.tikzCommand
|
||||
});
|
||||
} else if (activeTool === 'select' || activeTool === 'erase') {
|
||||
if (activeTool === 'select') {
|
||||
const isMultiSelect = e.shiftKey || e.ctrlKey || e.metaKey;
|
||||
if (activeTool === 'select' && !isMultiSelect) {
|
||||
this.context.handleSelectVertices([]);
|
||||
}
|
||||
|
||||
|
|
@ -639,7 +639,7 @@ export class CanvasGrid {
|
|||
if (activeTool === 'erase') {
|
||||
this.applyLassoErase();
|
||||
} else {
|
||||
this.applyLassoSelection();
|
||||
this.applyLassoSelection(isMultiSelect);
|
||||
}
|
||||
}
|
||||
this.lassoStart = null;
|
||||
|
|
@ -684,7 +684,7 @@ export class CanvasGrid {
|
|||
}
|
||||
}
|
||||
|
||||
private applyLassoSelection() {
|
||||
private applyLassoSelection(isMultiSelect = false) {
|
||||
if (!this.lassoStart || !this.lassoCurrent) return;
|
||||
|
||||
const left = Math.min(this.lassoStart.x, this.lassoCurrent.x);
|
||||
|
|
@ -696,23 +696,34 @@ export class CanvasGrid {
|
|||
return x >= left && x <= right && y >= top && y <= bottom;
|
||||
};
|
||||
|
||||
const newSelection: import('../types').SelectedVertex[] = [];
|
||||
const lassoVertices: import('../types').SelectedVertex[] = [];
|
||||
this.context.getElements().forEach(elem => {
|
||||
if (elem.type === 'wire' && elem.x2 !== undefined && elem.y2 !== undefined) {
|
||||
if (isInside(elem.x, elem.y)) {
|
||||
newSelection.push({ elementId: elem.id, vertex: 'start' });
|
||||
lassoVertices.push({ elementId: elem.id, vertex: 'start' });
|
||||
}
|
||||
if (isInside(elem.x2, elem.y2)) {
|
||||
newSelection.push({ elementId: elem.id, vertex: 'end' });
|
||||
lassoVertices.push({ elementId: elem.id, vertex: 'end' });
|
||||
}
|
||||
} else {
|
||||
if (isInside(elem.x, elem.y)) {
|
||||
newSelection.push({ elementId: elem.id, vertex: 'center' });
|
||||
lassoVertices.push({ elementId: elem.id, vertex: 'center' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.context.handleSelectVertices(newSelection);
|
||||
if (isMultiSelect) {
|
||||
const existing = this.context.getSelectedVertices();
|
||||
const merged = [...existing];
|
||||
lassoVertices.forEach(v => {
|
||||
if (!merged.some(e => e.elementId === v.elementId && e.vertex === v.vertex)) {
|
||||
merged.push(v);
|
||||
}
|
||||
});
|
||||
this.context.handleSelectVertices(merged);
|
||||
} else {
|
||||
this.context.handleSelectVertices(lassoVertices);
|
||||
}
|
||||
}
|
||||
|
||||
private handleElementMouseDown(e: MouseEvent, elem: EditorElement) {
|
||||
|
|
@ -725,6 +736,7 @@ export class CanvasGrid {
|
|||
return;
|
||||
}
|
||||
|
||||
const isMultiSelect = e.shiftKey || e.ctrlKey || e.metaKey;
|
||||
const clickedVertices: import('../types').SelectedVertex[] =
|
||||
elem.type === 'wire'
|
||||
? [
|
||||
|
|
@ -736,8 +748,18 @@ export class CanvasGrid {
|
|||
const selectedVertices = this.context.getSelectedVertices();
|
||||
const isAlreadySelected = selectedVertices.some(v => v.elementId === elem.id);
|
||||
|
||||
if (!isAlreadySelected) {
|
||||
this.context.handleSelectVertices(clickedVertices);
|
||||
if (isMultiSelect) {
|
||||
if (isAlreadySelected) {
|
||||
const newSel = selectedVertices.filter(v => v.elementId !== elem.id);
|
||||
this.context.handleSelectVertices(newSel);
|
||||
} else {
|
||||
const newSel = [...selectedVertices, ...clickedVertices];
|
||||
this.context.handleSelectVertices(newSel);
|
||||
}
|
||||
} else {
|
||||
if (!isAlreadySelected) {
|
||||
this.context.handleSelectVertices(clickedVertices);
|
||||
}
|
||||
}
|
||||
|
||||
if (activeTool === 'select') {
|
||||
|
|
@ -751,30 +773,23 @@ export class CanvasGrid {
|
|||
if (!this.dragStartCoords) return;
|
||||
|
||||
const zoom = this.context.getZoom();
|
||||
const dx = (moveEvent.clientX - this.dragStartCoords.x) / zoom;
|
||||
const dy = (moveEvent.clientY - this.dragStartCoords.y) / zoom;
|
||||
const rawDx = (moveEvent.clientX - this.dragStartCoords.x) / zoom;
|
||||
const rawDy = (moveEvent.clientY - this.dragStartCoords.y) / zoom;
|
||||
|
||||
let newPrimaryX = elem.x + dx;
|
||||
let newPrimaryY = elem.y + dy;
|
||||
let snappedDx = rawDx;
|
||||
let snappedDy = rawDy;
|
||||
|
||||
if (this.context.isSnapToGrid() && !moveEvent.ctrlKey) {
|
||||
const gridSize = this.context.isHalfGrid()
|
||||
? this.context.PX_PER_UNIT / 2
|
||||
: this.context.PX_PER_UNIT;
|
||||
newPrimaryX =
|
||||
Math.round((newPrimaryX - this.context.ORIGIN_X) / gridSize) * gridSize +
|
||||
this.context.ORIGIN_X;
|
||||
newPrimaryY =
|
||||
Math.round((newPrimaryY - this.context.ORIGIN_Y) / gridSize) * gridSize +
|
||||
this.context.ORIGIN_Y;
|
||||
snappedDx = Math.round(rawDx / gridSize) * gridSize;
|
||||
snappedDy = Math.round(rawDy / gridSize) * gridSize;
|
||||
} else {
|
||||
newPrimaryX = Math.round(newPrimaryX);
|
||||
newPrimaryY = Math.round(newPrimaryY);
|
||||
snappedDx = Math.round(rawDx);
|
||||
snappedDy = Math.round(rawDy);
|
||||
}
|
||||
|
||||
const snappedDx = newPrimaryX - elem.x;
|
||||
const snappedDy = newPrimaryY - elem.y;
|
||||
|
||||
const currentSelVertices = this.context.getSelectedVertices();
|
||||
|
||||
const newElements = this.dragInitialState.map(initialEl => {
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ export class RightSidebar {
|
|||
colorInput.oninput = () => {
|
||||
this.context.handleUpdateElement({
|
||||
...selectedElement,
|
||||
style: { ...selectedElement.style, color: colorInput.value }
|
||||
style: { ...selectedElement.style, color: colorInput.value, rawColor: undefined }
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -331,7 +331,7 @@ export class RightSidebar {
|
|||
colorInput.oninput = () => {
|
||||
const updated = selectedElements.map(el => ({
|
||||
...el,
|
||||
style: { ...el.style, color: colorInput.value }
|
||||
style: { ...el.style, color: colorInput.value, rawColor: undefined }
|
||||
}));
|
||||
this.context.handleUpdateElements(updated);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -430,10 +430,8 @@ export class TikzEditorModal extends Modal implements TikzEditorContext {
|
|||
let code: string;
|
||||
if (this.activeTab === 'code' && this.codeDirty) {
|
||||
code = this.editableCode;
|
||||
} else if (this.historyManager.canUndo()) {
|
||||
code = this.generateTikzSource();
|
||||
} else {
|
||||
code = this.editableCode || this.generateTikzSource();
|
||||
code = this.generateTikzSource();
|
||||
}
|
||||
this.onSaveCallback(code);
|
||||
}
|
||||
|
|
@ -587,6 +585,73 @@ export class TikzEditorModal extends Modal implements TikzEditorContext {
|
|||
return;
|
||||
}
|
||||
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a') {
|
||||
e.preventDefault();
|
||||
const allVertices: SelectedVertex[] = [];
|
||||
this.elements.forEach(elem => {
|
||||
if (elem.type === 'wire' && elem.x2 !== undefined && elem.y2 !== undefined) {
|
||||
allVertices.push({ elementId: elem.id, vertex: 'start' });
|
||||
allVertices.push({ elementId: elem.id, vertex: 'end' });
|
||||
} else {
|
||||
allVertices.push({ elementId: elem.id, vertex: 'center' });
|
||||
}
|
||||
});
|
||||
this.handleSelectVertices(allVertices);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
this.handleSelectVertices([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
|
||||
if (this.selectedVertices.length > 0) {
|
||||
e.preventDefault();
|
||||
const step = e.shiftKey
|
||||
? this.PX_PER_UNIT
|
||||
: this.isHalfGrid()
|
||||
? this.PX_PER_UNIT / 2
|
||||
: this.PX_PER_UNIT / 4;
|
||||
let dx = 0;
|
||||
let dy = 0;
|
||||
if (e.key === 'ArrowLeft') dx = -step;
|
||||
if (e.key === 'ArrowRight') dx = step;
|
||||
if (e.key === 'ArrowUp') dy = -step;
|
||||
if (e.key === 'ArrowDown') dy = step;
|
||||
|
||||
const selVertices = this.selectedVertices;
|
||||
const updated = this.elements.map(elem => {
|
||||
const el = { ...elem };
|
||||
if (el.type === 'wire' && el.x2 !== undefined && el.y2 !== undefined) {
|
||||
const hasStart = selVertices.some(v => v.elementId === el.id && v.vertex === 'start');
|
||||
const hasEnd = selVertices.some(v => v.elementId === el.id && v.vertex === 'end');
|
||||
if (hasStart) {
|
||||
el.x += dx;
|
||||
el.y += dy;
|
||||
}
|
||||
if (hasEnd) {
|
||||
el.x2 += dx;
|
||||
el.y2 += dy;
|
||||
}
|
||||
} else {
|
||||
const hasCenter = selVertices.some(v => v.elementId === el.id && v.vertex === 'center');
|
||||
if (hasCenter) {
|
||||
el.x += dx;
|
||||
el.y += dy;
|
||||
}
|
||||
}
|
||||
return el;
|
||||
});
|
||||
|
||||
this.setElements(updated);
|
||||
this.renderCanvas();
|
||||
this.saveHistoryState();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'c') {
|
||||
e.preventDefault();
|
||||
const uniqueIds = Array.from(new Set(this.selectedVertices.map(v => v.elementId)));
|
||||
|
|
@ -742,29 +807,10 @@ export class TikzEditorModal extends Modal implements TikzEditorContext {
|
|||
|
||||
// Parse initial source code if present
|
||||
this.elements = [];
|
||||
this.editableCode = this.initialSource || '';
|
||||
if (this.initialSource) {
|
||||
const match = this.initialSource.match(/^\s*%\s*\[ObsiTeXState:(.*)\]\s*$/m);
|
||||
if (match && match[1]) {
|
||||
try {
|
||||
const parsed = JSON.parse(match[1]) as {
|
||||
elements?: EditorElement[];
|
||||
pictureOptions?: string;
|
||||
};
|
||||
if (Array.isArray(parsed.elements)) {
|
||||
this.elements = parsed.elements;
|
||||
this.pictureOptions = parsed.pictureOptions ?? '';
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse visual state, using fallback parser:', e);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.elements.length === 0) {
|
||||
const parsedResult = this.codec.parse(this.initialSource);
|
||||
this.elements = parsedResult.elements;
|
||||
this.pictureOptions = parsedResult.pictureOptions;
|
||||
}
|
||||
const parsedResult = this.codec.parse(this.initialSource);
|
||||
this.elements = parsedResult.elements;
|
||||
this.pictureOptions = parsedResult.pictureOptions;
|
||||
}
|
||||
|
||||
// Initial history state
|
||||
|
|
|
|||
|
|
@ -19,24 +19,24 @@ export class TikzCodec {
|
|||
const map: Record<string, string> = {
|
||||
black: '#000000',
|
||||
white: '#ffffff',
|
||||
red: '#ff0000',
|
||||
green: '#00cc00',
|
||||
blue: '#0000ff',
|
||||
cyan: '#00ffff',
|
||||
magenta: '#ff00ff',
|
||||
yellow: '#ffff00',
|
||||
gray: '#808080',
|
||||
grey: '#808080',
|
||||
darkgray: '#404040',
|
||||
lightgray: '#d3d3d3',
|
||||
orange: '#ffa500',
|
||||
purple: '#800080',
|
||||
violet: '#8a2be2',
|
||||
teal: '#008080',
|
||||
olive: '#808000',
|
||||
lime: '#00ff00',
|
||||
brown: '#a52a2a',
|
||||
pink: '#ffc0cb'
|
||||
red: '#ef4444',
|
||||
green: '#22c55e',
|
||||
blue: '#3b82f6',
|
||||
cyan: '#06b6d4',
|
||||
magenta: '#ec4899',
|
||||
yellow: '#eab308',
|
||||
gray: '#6b7280',
|
||||
grey: '#6b7280',
|
||||
darkgray: '#374151',
|
||||
lightgray: '#d1d5db',
|
||||
orange: '#f97316',
|
||||
purple: '#a855f7',
|
||||
violet: '#8b5cf6',
|
||||
teal: '#14b8a6',
|
||||
olive: '#84cc16',
|
||||
lime: '#84cc16',
|
||||
brown: '#a16207',
|
||||
pink: '#ec4899'
|
||||
};
|
||||
return map[name.toLowerCase()] ?? null;
|
||||
}
|
||||
|
|
@ -58,12 +58,26 @@ export class TikzCodec {
|
|||
return null;
|
||||
}
|
||||
|
||||
private parseTikzColor(colorStr: string): { hex: string; raw: string } | null {
|
||||
private parseTikzColor(colorStr: string): { hex: string; raw?: string } | null {
|
||||
const trimmed = colorStr.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const rgb255Match = trimmed.match(/\{?rgb,255:red,(\d+);green,(\d+);blue,(\d+)\}?/i);
|
||||
if (rgb255Match) {
|
||||
const r = parseInt(rgb255Match[1]);
|
||||
const g = parseInt(rgb255Match[2]);
|
||||
const b = parseInt(rgb255Match[3]);
|
||||
const hex = `#${[r, g, b].map(x => x.toString(16).padStart(2, '0')).join('')}`;
|
||||
return { hex };
|
||||
}
|
||||
|
||||
const htmlMatch = trimmed.match(/\{?HTML\}?\{?([0-9a-fA-F]{6})\}?/i);
|
||||
if (htmlMatch) {
|
||||
return { hex: `#${htmlMatch[1].toLowerCase()}` };
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('#')) {
|
||||
return { hex: trimmed, raw: trimmed };
|
||||
return { hex: trimmed };
|
||||
}
|
||||
|
||||
const parts = trimmed.split('!');
|
||||
|
|
@ -99,9 +113,10 @@ export class TikzCodec {
|
|||
if (!optsStr) return {};
|
||||
const res: ReturnType<typeof this.parseOptions> = {};
|
||||
|
||||
const colorMatch = optsStr.match(/color\s*=\s*([a-zA-Z0-9!#_]+)/i);
|
||||
const colorMatch = optsStr.match(/color\s*=\s*(?:\{([^}]+)\}|([a-zA-Z0-9!#_:-]+))/i);
|
||||
if (colorMatch) {
|
||||
const parsedColor = this.parseTikzColor(colorMatch[1]);
|
||||
const colorVal = colorMatch[1] ?? colorMatch[2];
|
||||
const parsedColor = this.parseTikzColor(colorVal);
|
||||
if (parsedColor) {
|
||||
res.color = parsedColor.hex;
|
||||
res.rawColor = parsedColor.raw;
|
||||
|
|
@ -315,21 +330,33 @@ export class TikzCodec {
|
|||
throw new Error("Core component 'Text' not found");
|
||||
}
|
||||
let label = rawLabel.trim();
|
||||
if (label.startsWith('{') && label.endsWith('}')) {
|
||||
label = label.slice(1, -1).trim();
|
||||
}
|
||||
|
||||
let math = false;
|
||||
let bold = false;
|
||||
let italic = false;
|
||||
|
||||
if (label.startsWith('$') && label.endsWith('$')) {
|
||||
label = label.substring(1, label.length - 1);
|
||||
label = label.slice(1, -1).trim();
|
||||
math = true;
|
||||
} else if (
|
||||
/\\(mathrm|mathbf|mathit|text|frac|alpha|beta|gamma|delta|pi|sigma|theta|sum|int)\b/.test(
|
||||
label
|
||||
) ||
|
||||
/[_^=]/.test(label)
|
||||
) {
|
||||
math = true;
|
||||
}
|
||||
|
||||
if (label.includes('\\textbf{')) {
|
||||
bold = true;
|
||||
label = label.replace(/\\textbf\{([^}]+)\}/, '$1');
|
||||
label = label.replace(/\\textbf\{([^}]+)\}/g, '$1');
|
||||
}
|
||||
if (label.includes('\\textit{')) {
|
||||
italic = true;
|
||||
label = label.replace(/\\textit\{([^}]+)\}/, '$1');
|
||||
label = label.replace(/\\textit\{([^}]+)\}/g, '$1');
|
||||
}
|
||||
|
||||
const style: EditorElementStyle = {
|
||||
|
|
@ -356,6 +383,32 @@ export class TikzCodec {
|
|||
};
|
||||
}
|
||||
|
||||
private splitTikzStatements(source: string): string[] {
|
||||
const statements: string[] = [];
|
||||
let current = '';
|
||||
let braceDepth = 0;
|
||||
let bracketDepth = 0;
|
||||
|
||||
for (let i = 0; i < source.length; i++) {
|
||||
const char = source[i];
|
||||
if (char === '{') braceDepth++;
|
||||
else if (char === '}') braceDepth = Math.max(0, braceDepth - 1);
|
||||
else if (char === '[') bracketDepth++;
|
||||
else if (char === ']') bracketDepth = Math.max(0, bracketDepth - 1);
|
||||
|
||||
if (char === ';' && braceDepth === 0 && bracketDepth === 0) {
|
||||
statements.push(current);
|
||||
current = '';
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
if (current.trim()) {
|
||||
statements.push(current);
|
||||
}
|
||||
return statements;
|
||||
}
|
||||
|
||||
public parse(source: string): { elements: EditorElement[]; pictureOptions: string } {
|
||||
const parsedElements: EditorElement[] = [];
|
||||
const beginMatch = source.match(/\\begin\{tikzpicture\}(\[[^\]]*\])?/);
|
||||
|
|
@ -393,7 +446,7 @@ export class TikzCodec {
|
|||
}
|
||||
|
||||
const shifts: { x: number; y: number }[] = [{ x: 0, y: 0 }];
|
||||
const statements = cleaned.split(';');
|
||||
const statements = this.splitTikzStatements(cleaned);
|
||||
|
||||
for (const rawStmt of statements) {
|
||||
const stmt = rawStmt.replace(/\s+/g, ' ').trim();
|
||||
|
|
@ -439,12 +492,19 @@ export class TikzCodec {
|
|||
|
||||
// 2. Circle matching
|
||||
const circleMatch = stmt.match(
|
||||
/\\(fill|draw)(?:\s*\[([^\]]*)\])?\s*(\([^)]+\))\s*circle\s*\(\s*(\d+(?:\.\d+)?)\s*(?:pt|cm)?\s*\)(?:\s*node\[[^\]]*\]\s*\{(.*)\})?/
|
||||
/\\(fill|draw)(?:\s*\[([^\]]*)\])?\s*(\([^)]+\))\s*circle\s*\(\s*(\d+(?:\.\d+)?)\s*(pt|cm)?\s*\)(?:\s*node\[[^\]]*\]\s*\{(.*)\})?/
|
||||
);
|
||||
if (circleMatch) {
|
||||
const point = this.parsePoint(circleMatch[3], currentShift, namedCoords);
|
||||
if (point) {
|
||||
const parsedRadius = parseFloat(circleMatch[4]);
|
||||
let parsedRadius = parseFloat(circleMatch[4]);
|
||||
const unit = circleMatch[5]?.toLowerCase();
|
||||
if (!isNaN(parsedRadius)) {
|
||||
if (unit === 'cm' || (unit === undefined && parsedRadius < 1.0)) {
|
||||
parsedRadius = parsedRadius * 28.45;
|
||||
}
|
||||
parsedRadius = Math.max(parsedRadius, 2.0);
|
||||
}
|
||||
const isFilled = circleMatch[1] === 'fill';
|
||||
const isLarge = !isNaN(parsedRadius) && parsedRadius >= 6.0;
|
||||
let nodeName: 'Filled node' | 'Open node' | 'Circle' | 'Filled Circle';
|
||||
|
|
@ -460,7 +520,7 @@ export class TikzCodec {
|
|||
nodeName,
|
||||
point.x,
|
||||
point.y,
|
||||
circleMatch[5] ?? '',
|
||||
circleMatch[6] ?? '',
|
||||
isNaN(parsedRadius) ? undefined : parsedRadius,
|
||||
optsStyle
|
||||
)
|
||||
|
|
@ -653,26 +713,60 @@ export class TikzCodec {
|
|||
opts.push('dashed');
|
||||
}
|
||||
if (elem.style.rawColor) {
|
||||
opts.push(`color=${elem.style.rawColor}`);
|
||||
const raw = elem.style.rawColor;
|
||||
if (raw.includes(',') || raw.includes(' ')) {
|
||||
opts.push(`color={${raw}}`);
|
||||
} else {
|
||||
opts.push(`color=${raw}`);
|
||||
}
|
||||
} else if (elem.style.color && elem.style.color !== '#f8e7ad') {
|
||||
opts.push(`color=${elem.style.color}`);
|
||||
const hexVal = elem.style.color.startsWith('#')
|
||||
? elem.style.color.substring(1)
|
||||
: elem.style.color;
|
||||
if (/^[0-9A-F]{6}$/i.test(hexVal)) {
|
||||
const r = parseInt(hexVal.slice(0, 2), 16);
|
||||
const g = parseInt(hexVal.slice(2, 4), 16);
|
||||
const b = parseInt(hexVal.slice(4, 6), 16);
|
||||
opts.push(`color={rgb,255:red,${r};green,${g};blue,${b}}`);
|
||||
} else {
|
||||
opts.push(`color=${elem.style.color}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.length > 0) {
|
||||
const optsStr = opts.join(', ');
|
||||
if (cmd.startsWith('\\draw[')) {
|
||||
cmd = cmd.replace(/\\draw\[/, `\\draw[${optsStr}, `);
|
||||
} else if (cmd.startsWith('\\draw')) {
|
||||
cmd = cmd.replace(/\\draw/, `\\draw[${optsStr}]`);
|
||||
} else if (cmd.startsWith('\\fill[')) {
|
||||
cmd = cmd.replace(/\\fill\[/, `\\fill[${optsStr}, `);
|
||||
} else if (cmd.startsWith('\\fill')) {
|
||||
cmd = cmd.replace(/\\fill/, `\\fill[${optsStr}]`);
|
||||
} else if (cmd.startsWith('\\node[')) {
|
||||
cmd = cmd.replace(/\\node\[/, `\\node[${optsStr}, `);
|
||||
} else if (cmd.startsWith('\\node')) {
|
||||
cmd = cmd.replace(/\\node/, `\\node[${optsStr}]`);
|
||||
}
|
||||
const hasColor = opts.some(o => o.startsWith('color='));
|
||||
const hasLineWidth = opts.some(o => o.startsWith('line width='));
|
||||
const hasLineStyle = opts.some(o => o === 'dotted' || o === 'dashed');
|
||||
|
||||
cmd = cmd.replace(
|
||||
/(\\draw|\\fill|\\node)(\[[^\]]*\])?/,
|
||||
(_match: string, head: string, brackets?: string): string => {
|
||||
if (!brackets) {
|
||||
return `${head}[${opts.join(', ')}]`;
|
||||
}
|
||||
let inner = brackets.slice(1, -1);
|
||||
if (hasColor) {
|
||||
inner = inner
|
||||
.replace(/(?:color|draw|fill)=(?:\{[^}]*\}|[^,\]]+)/g, '')
|
||||
.replace(/,,+/g, ',');
|
||||
}
|
||||
if (hasLineWidth) {
|
||||
inner = inner.replace(/line width=[^,\]]+/g, '').replace(/,,+/g, ',');
|
||||
}
|
||||
if (hasLineStyle) {
|
||||
inner = inner.replace(/\b(dotted|dashed|solid)\b/g, '').replace(/,,+/g, ',');
|
||||
}
|
||||
const cleanedInner = inner
|
||||
.split(',')
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
const finalOpts = cleanedInner
|
||||
? `${opts.join(', ')}, ${cleanedInner}`
|
||||
: opts.join(', ');
|
||||
return `${head}[${finalOpts}]`;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
let formattedLabel = elem.label || '';
|
||||
|
|
|
|||
|
|
@ -141,5 +141,24 @@ at (2.15,-1.8)
|
|||
e => e.type === 'wire' && e.style.rawColor === 'purple!70!red'
|
||||
);
|
||||
expect(dottedLine?.style.lineStyle).toBe('dotted');
|
||||
|
||||
// Test roundtrip: generate TikZ source and re-parse it
|
||||
const generated = codec.generate(result.elements, result.pictureOptions);
|
||||
expect(generated).toContain('color=blue!70!black');
|
||||
|
||||
const roundtrip = codec.parse(generated);
|
||||
expect(roundtrip.elements.length).toBe(result.elements.length);
|
||||
|
||||
const roundtripBlueSegments = roundtrip.elements.filter(
|
||||
e => e.type === 'wire' && e.style.rawColor === 'blue!70!black'
|
||||
);
|
||||
expect(roundtripBlueSegments.length).toBe(6);
|
||||
|
||||
// Verify hex color without rawColor produces braced rgb,255 output
|
||||
const customElements = [result.elements[0]];
|
||||
customElements[0].style.rawColor = undefined;
|
||||
customElements[0].style.color = '#3b82f6';
|
||||
const customGen = codec.generate(customElements, '');
|
||||
expect(customGen).toContain('color={rgb,255:red,59;green,130;blue,246}');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue