mirror of
https://github.com/youfoundjk/TeXcore.git
synced 2026-07-22 07:33:31 +00:00
fix(canvas): resolve text node translation, library resolution, and canvas panning
- Fix text label positioning offset in TikZJax rendering by calculating relative offsets `(pos.h - originH)` and `(pos.v - originV)` for PGF `{?x}` and `{?y}` placeholders instead of absolute DVI page coordinates in `CustomHTMLMachine.putSVG()`.
- Expand TikZ library loader in `loader.ts` to check `pgflibrary${lib}.code.tex.gz` as well as `tikzlibrary${lib}.code.tex.gz`, enabling `arrows.meta` and arrowhead rendering.
- Strip `\begin{tikzpicture}` and `\end{tikzpicture}` environment lines prior to statement splitting in `TikzCodec.parse()` so leading `\draw` paths (e.g. x-axis) are not dropped.
- Upgrade TikZ graphical editor modal to a 4000px × 4000px workspace with transform-based 360° free panning (`translate(panX, panY)`), cursor-centered zooming, and a "Center view" action.
- Address all Obsidian Community Guidelines linting issues across TikZ editor and renderer modules, removing debug console statements, fixing unsafe types, and correcting regex escapes.
- Add unit test suite in `test_helpers/tikz-codec.test.ts` for TikZ statement parsing, loop expansion, and color mixing.
This commit is contained in:
parent
ec2e6f7dda
commit
f0ce1930b8
8 changed files with 774 additions and 235 deletions
|
|
@ -58,7 +58,7 @@ export class CanvasGrid {
|
|||
axisOverlay.appendChild(lineY);
|
||||
|
||||
// X labels
|
||||
for (let x = 0; x <= 12; x++) {
|
||||
for (let x = -20; x <= 20; x++) {
|
||||
const text = activeDocument.createElementNS(svgNS, 'text');
|
||||
text.setAttribute('x', (this.context.ORIGIN_X + x * this.context.PX_PER_UNIT).toString());
|
||||
text.setAttribute('y', (this.context.ORIGIN_Y + 18).toString());
|
||||
|
|
@ -67,7 +67,7 @@ export class CanvasGrid {
|
|||
}
|
||||
|
||||
// Y labels
|
||||
for (let y = -4; y <= 4; y++) {
|
||||
for (let y = -20; y <= 20; y++) {
|
||||
if (y !== 0) {
|
||||
const text = activeDocument.createElementNS(svgNS, 'text');
|
||||
text.setAttribute('x', (this.context.ORIGIN_X - 12).toString());
|
||||
|
|
@ -108,20 +108,47 @@ export class CanvasGrid {
|
|||
lineClick.setCssStyles({ cursor: 'pointer' });
|
||||
group.appendChild(lineClick);
|
||||
|
||||
if (elem.name === 'Wire') {
|
||||
const isBasicWire =
|
||||
elem.name === 'Wire' || elem.name === 'Dashed Wire' || elem.name === 'Arrow';
|
||||
if (isBasicWire) {
|
||||
const line = activeDocument.createElementNS(svgNS, 'line');
|
||||
line.setAttribute('x1', elem.x.toString());
|
||||
line.setAttribute('y1', elem.y.toString());
|
||||
line.setAttribute('x2', elem.x2.toString());
|
||||
line.setAttribute('y2', elem.y2.toString());
|
||||
line.setAttribute(
|
||||
'stroke',
|
||||
selectedVertices.some(v => v.elementId === elem.id)
|
||||
? 'var(--text-accent)'
|
||||
: elem.style.color || 'var(--text-normal)'
|
||||
);
|
||||
const strokeColor = selectedVertices.some(v => v.elementId === elem.id)
|
||||
? 'var(--text-accent)'
|
||||
: elem.style.color && elem.style.color !== '#f8e7ad'
|
||||
? elem.style.color
|
||||
: '#e2e8f0';
|
||||
line.setAttribute('stroke', strokeColor);
|
||||
line.setAttribute('stroke-width', (elem.style.thickness ?? 1.0).toString());
|
||||
|
||||
if (elem.name === 'Dashed Wire' || elem.style.lineStyle === 'dashed') {
|
||||
line.setAttribute('stroke-dasharray', '6,4');
|
||||
} else if (elem.style.lineStyle === 'dotted') {
|
||||
line.setAttribute('stroke-dasharray', '2,4');
|
||||
}
|
||||
|
||||
group.appendChild(line);
|
||||
|
||||
if (elem.name === 'Arrow' || elem.style.arrowStyle) {
|
||||
const dx = elem.x2 - elem.x;
|
||||
const dy = elem.y2 - elem.y;
|
||||
const len = Math.hypot(dx, dy);
|
||||
if (len > 0) {
|
||||
const angle = Math.atan2(dy, dx);
|
||||
const headLen = Math.min(10, len);
|
||||
const p1x = elem.x2 - headLen * Math.cos(angle - Math.PI / 6);
|
||||
const p1y = elem.y2 - headLen * Math.sin(angle - Math.PI / 6);
|
||||
const p2x = elem.x2 - headLen * Math.cos(angle + Math.PI / 6);
|
||||
const p2y = elem.y2 - headLen * Math.sin(angle + Math.PI / 6);
|
||||
const head = activeDocument.createElementNS(svgNS, 'polygon');
|
||||
head.setAttribute('points', `${elem.x2},${elem.y2} ${p1x},${p1y} ${p2x},${p2y}`);
|
||||
head.setAttribute('fill', strokeColor);
|
||||
group.appendChild(head);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const dx = elem.x2 - elem.x;
|
||||
const dy = elem.y2 - elem.y;
|
||||
|
|
|
|||
|
|
@ -18,10 +18,10 @@ import { TikzCodec } from './utils/tikz-codec';
|
|||
export class TikzEditorModal extends Modal implements TikzEditorContext {
|
||||
// Constants
|
||||
public readonly PX_PER_UNIT = 80;
|
||||
public readonly ORIGIN_X = 120;
|
||||
public readonly ORIGIN_Y = 360;
|
||||
public readonly CANVAS_WIDTH = 1120;
|
||||
public readonly CANVAS_HEIGHT = 720;
|
||||
public readonly ORIGIN_X = 2000;
|
||||
public readonly ORIGIN_Y = 2000;
|
||||
public readonly CANVAS_WIDTH = 4000;
|
||||
public readonly CANVAS_HEIGHT = 4000;
|
||||
public readonly DEFAULT_STYLE = {
|
||||
bold: false,
|
||||
italic: false,
|
||||
|
|
@ -40,13 +40,15 @@ export class TikzEditorModal extends Modal implements TikzEditorContext {
|
|||
private halfGrid = true;
|
||||
private pictureOptions = '';
|
||||
private zoom = 1.0;
|
||||
private panX = 0;
|
||||
private panY = 0;
|
||||
private copiedElements: EditorElement[] = [];
|
||||
|
||||
// Panning state
|
||||
private isSpacePressed = false;
|
||||
private isPanning = false;
|
||||
private panStartMouse = { x: 0, y: 0 };
|
||||
private panStartScroll = { left: 0, top: 0 };
|
||||
private panStartPan = { x: 0, y: 0 };
|
||||
|
||||
// Sidebar / library state
|
||||
private searchQuery = '';
|
||||
|
|
@ -126,6 +128,32 @@ export class TikzEditorModal extends Modal implements TikzEditorContext {
|
|||
}
|
||||
setZoom(zoom: number) {
|
||||
this.zoom = zoom;
|
||||
this.updateWorkspaceTransform();
|
||||
}
|
||||
getPan() {
|
||||
return { x: this.panX, y: this.panY };
|
||||
}
|
||||
setPan(x: number, y: number) {
|
||||
this.panX = x;
|
||||
this.panY = y;
|
||||
this.updateWorkspaceTransform();
|
||||
}
|
||||
updateWorkspaceTransform() {
|
||||
if (this.canvasWorkspaceEl) {
|
||||
this.canvasWorkspaceEl.style.transform = `translate(${this.panX}px, ${this.panY}px) scale(${this.zoom})`;
|
||||
}
|
||||
}
|
||||
resetView() {
|
||||
const w = this.canvasContainerEl?.clientWidth || 800;
|
||||
const h = this.canvasContainerEl?.clientHeight || 600;
|
||||
this.zoom = 1.0;
|
||||
this.panX = Math.round(w / 2 - (this.ORIGIN_X + 2.5 * this.PX_PER_UNIT) * this.zoom);
|
||||
this.panY = Math.round(h / 2 - (this.ORIGIN_Y - 0.5 * this.PX_PER_UNIT) * this.zoom);
|
||||
const zoomLabel = this.contentEl?.querySelector('.zoom-label');
|
||||
if (zoomLabel) {
|
||||
zoomLabel.textContent = '100%';
|
||||
}
|
||||
this.updateWorkspaceTransform();
|
||||
}
|
||||
getSearchQuery() {
|
||||
return this.searchQuery;
|
||||
|
|
@ -399,8 +427,14 @@ export class TikzEditorModal extends Modal implements TikzEditorContext {
|
|||
|
||||
handleInsertCode() {
|
||||
if (this.onSaveCallback) {
|
||||
const code =
|
||||
this.activeTab === 'code' && this.codeDirty ? this.editableCode : this.generateTikzSource();
|
||||
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();
|
||||
}
|
||||
this.onSaveCallback(code);
|
||||
}
|
||||
this.close();
|
||||
|
|
@ -486,11 +520,13 @@ export class TikzEditorModal extends Modal implements TikzEditorContext {
|
|||
switchTab(tab: 'edit' | 'code') {
|
||||
this.activeTab = tab;
|
||||
if (tab === 'code' && !this.codeDirty) {
|
||||
try {
|
||||
this.editableCode = this.generateTikzSource();
|
||||
} catch (err) {
|
||||
console.error('[TikzEditorModal] Error generating TikZ code:', err);
|
||||
this.editableCode = `% Error generating TikZ code: ${err instanceof Error ? err.message : String(err)}`;
|
||||
if (!this.editableCode || this.historyManager.canUndo()) {
|
||||
try {
|
||||
this.editableCode = this.generateTikzSource();
|
||||
} catch (err) {
|
||||
console.error('[TikzEditorModal] Error generating TikZ code:', err);
|
||||
this.editableCode = `% Error generating TikZ code: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
this.renderRightSidebar();
|
||||
|
|
@ -706,6 +742,7 @@ 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]) {
|
||||
|
|
@ -770,7 +807,6 @@ export class TikzEditorModal extends Modal implements TikzEditorContext {
|
|||
const controls = canvasAreaEl.createDiv({ cls: 'tikz-canvas-controls' });
|
||||
|
||||
const zoomOutBtn = controls.createEl('button', { title: 'Zoom out [ctrl + -]' });
|
||||
|
||||
const zoomOutParser = new DOMParser();
|
||||
const zoomOutDoc = zoomOutParser.parseFromString(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="8" y1="11" x2="14" y2="11"/></svg>`,
|
||||
|
|
@ -778,15 +814,23 @@ export class TikzEditorModal extends Modal implements TikzEditorContext {
|
|||
);
|
||||
zoomOutBtn.appendChild(activeDocument.importNode(zoomOutDoc.documentElement, true));
|
||||
zoomOutBtn.onclick = () => {
|
||||
this.zoom = Math.max(this.zoom - 0.1, 0.5);
|
||||
const oldZoom = this.zoom;
|
||||
this.zoom = Math.max(this.zoom - 0.1, 0.3);
|
||||
const w = this.canvasContainerEl.clientWidth || 800;
|
||||
const h = this.canvasContainerEl.clientHeight || 600;
|
||||
const centerX = w / 2;
|
||||
const centerY = h / 2;
|
||||
const canvasX = (centerX - this.panX) / oldZoom;
|
||||
const canvasY = (centerY - this.panY) / oldZoom;
|
||||
this.panX = centerX - canvasX * this.zoom;
|
||||
this.panY = centerY - canvasY * this.zoom;
|
||||
zoomLabel.textContent = `${Math.round(this.zoom * 100)}%`;
|
||||
this.canvasWorkspaceEl.style.transform = `scale(${this.zoom})`;
|
||||
this.updateWorkspaceTransform();
|
||||
};
|
||||
|
||||
const zoomLabel = controls.createSpan({ cls: 'zoom-label', text: '100%' });
|
||||
|
||||
const zoomInBtn = controls.createEl('button', { title: 'Zoom in [ctrl + +]' });
|
||||
|
||||
const zoomInParser = new DOMParser();
|
||||
const zoomInDoc = zoomInParser.parseFromString(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>`,
|
||||
|
|
@ -794,16 +838,33 @@ export class TikzEditorModal extends Modal implements TikzEditorContext {
|
|||
);
|
||||
zoomInBtn.appendChild(activeDocument.importNode(zoomInDoc.documentElement, true));
|
||||
zoomInBtn.onclick = () => {
|
||||
this.zoom = Math.min(this.zoom + 0.1, 2.0);
|
||||
const oldZoom = this.zoom;
|
||||
this.zoom = Math.min(this.zoom + 0.1, 3.0);
|
||||
const w = this.canvasContainerEl.clientWidth || 800;
|
||||
const h = this.canvasContainerEl.clientHeight || 600;
|
||||
const centerX = w / 2;
|
||||
const centerY = h / 2;
|
||||
const canvasX = (centerX - this.panX) / oldZoom;
|
||||
const canvasY = (centerY - this.panY) / oldZoom;
|
||||
this.panX = centerX - canvasX * this.zoom;
|
||||
this.panY = centerY - canvasY * this.zoom;
|
||||
zoomLabel.textContent = `${Math.round(this.zoom * 100)}%`;
|
||||
this.canvasWorkspaceEl.style.transform = `scale(${this.zoom})`;
|
||||
this.updateWorkspaceTransform();
|
||||
};
|
||||
|
||||
const resetBtn = controls.createEl('button', { title: 'Center view' });
|
||||
const resetParser = new DOMParser();
|
||||
const resetDoc = resetParser.parseFromString(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>`,
|
||||
'image/svg+xml'
|
||||
);
|
||||
resetBtn.appendChild(activeDocument.importNode(resetDoc.documentElement, true));
|
||||
resetBtn.onclick = () => this.resetView();
|
||||
|
||||
controls.createDiv({ cls: 'divider' });
|
||||
|
||||
const undoBtn = controls.createEl('button', { title: 'Undo [Ctrl + Z]' });
|
||||
undoBtn.disabled = true;
|
||||
|
||||
const undoParser = new DOMParser();
|
||||
const undoDoc = undoParser.parseFromString(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"/></svg>`,
|
||||
|
|
@ -814,7 +875,6 @@ export class TikzEditorModal extends Modal implements TikzEditorContext {
|
|||
|
||||
const redoBtn = controls.createEl('button', { title: 'Redo [Ctrl + Y]' });
|
||||
redoBtn.disabled = true;
|
||||
|
||||
const redoParser = new DOMParser();
|
||||
const redoDoc = redoParser.parseFromString(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 7v6h-6"/><path d="M3 17a9 9 0 0 1 9-9 9 9 0 0 1 6 2.3l3 2.7"/></svg>`,
|
||||
|
|
@ -826,7 +886,6 @@ export class TikzEditorModal extends Modal implements TikzEditorContext {
|
|||
// Workspace
|
||||
this.canvasWorkspaceEl = this.canvasContainerEl.createDiv({ cls: 'canvas-workspace' });
|
||||
this.canvasWorkspaceEl.setCssStyles({ transformOrigin: '0 0' });
|
||||
this.canvasWorkspaceEl.style.transform = `scale(${this.zoom})`;
|
||||
|
||||
// Grid lines
|
||||
this.canvasWorkspaceEl.createDiv({ cls: 'grid-background' });
|
||||
|
|
@ -853,28 +912,26 @@ export class TikzEditorModal extends Modal implements TikzEditorContext {
|
|||
this.wiresOverlayEl
|
||||
);
|
||||
|
||||
// Panning handler via spacebar holding
|
||||
// Panning handler via Spacebar + Left Click or Middle Click
|
||||
this.canvasContainerEl.addEventListener(
|
||||
'mousedown',
|
||||
e => {
|
||||
if (this.isSpacePressed) {
|
||||
if (e.button === 1 || (e.button === 0 && this.isSpacePressed)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.isPanning = true;
|
||||
this.canvasContainerEl.removeClass('is-grab');
|
||||
this.canvasContainerEl.addClass('is-grabbing');
|
||||
this.panStartMouse = { x: e.clientX, y: e.clientY };
|
||||
this.panStartScroll = {
|
||||
left: this.canvasContainerEl.scrollLeft,
|
||||
top: this.canvasContainerEl.scrollTop
|
||||
};
|
||||
this.panStartPan = { x: this.panX, y: this.panY };
|
||||
|
||||
const onMouseMove = (moveEvent: MouseEvent) => {
|
||||
if (!this.isPanning) return;
|
||||
const dx = moveEvent.clientX - this.panStartMouse.x;
|
||||
const dy = moveEvent.clientY - this.panStartMouse.y;
|
||||
this.canvasContainerEl.scrollLeft = this.panStartScroll.left - dx;
|
||||
this.canvasContainerEl.scrollTop = this.panStartScroll.top - dy;
|
||||
this.panX = this.panStartPan.x + dx;
|
||||
this.panY = this.panStartPan.y + dy;
|
||||
this.updateWorkspaceTransform();
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
|
|
@ -894,13 +951,9 @@ export class TikzEditorModal extends Modal implements TikzEditorContext {
|
|||
}
|
||||
},
|
||||
true
|
||||
); // Use capture phase to intercept before canvas events
|
||||
);
|
||||
|
||||
// Scroll container behavior initial state
|
||||
this.canvasContainerEl.scrollLeft = 0;
|
||||
this.canvasContainerEl.scrollTop = 0;
|
||||
|
||||
// Mouse wheel zoom listener (centered on cursor)
|
||||
// Mouse wheel / Trackpad pan & zoom listener
|
||||
this.canvasContainerEl.addEventListener(
|
||||
'wheel',
|
||||
e => {
|
||||
|
|
@ -915,32 +968,44 @@ export class TikzEditorModal extends Modal implements TikzEditorContext {
|
|||
}
|
||||
e.preventDefault();
|
||||
|
||||
const oldZoom = this.zoom;
|
||||
const zoomFactor = e.ctrlKey ? 0.05 : 0.03;
|
||||
if (e.deltaY < 0) {
|
||||
this.zoom = Math.min(this.zoom + zoomFactor, 2.0);
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
const oldZoom = this.zoom;
|
||||
const zoomFactor = 0.05;
|
||||
const newZoom =
|
||||
e.deltaY < 0
|
||||
? Math.min(this.zoom + zoomFactor, 3.0)
|
||||
: Math.max(this.zoom - zoomFactor, 0.3);
|
||||
|
||||
if (newZoom !== oldZoom) {
|
||||
const rect = this.canvasContainerEl.getBoundingClientRect();
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const mouseY = e.clientY - rect.top;
|
||||
|
||||
const canvasX = (mouseX - this.panX) / oldZoom;
|
||||
const canvasY = (mouseY - this.panY) / oldZoom;
|
||||
|
||||
this.zoom = newZoom;
|
||||
this.panX = mouseX - canvasX * newZoom;
|
||||
this.panY = mouseY - canvasY * newZoom;
|
||||
|
||||
const zoomLabel = this.contentEl.querySelector('.zoom-label');
|
||||
if (zoomLabel) {
|
||||
zoomLabel.textContent = `${Math.round(this.zoom * 100)}%`;
|
||||
}
|
||||
this.updateWorkspaceTransform();
|
||||
}
|
||||
} else {
|
||||
this.zoom = Math.max(this.zoom - zoomFactor, 0.5);
|
||||
this.panX -= e.deltaX;
|
||||
this.panY -= e.deltaY;
|
||||
this.updateWorkspaceTransform();
|
||||
}
|
||||
|
||||
const zoomLabel = this.contentEl.querySelector('.zoom-label');
|
||||
if (zoomLabel) {
|
||||
zoomLabel.textContent = `${Math.round(this.zoom * 100)}%`;
|
||||
}
|
||||
this.canvasWorkspaceEl.style.transform = `scale(${this.zoom})`;
|
||||
|
||||
// Adjust scroll to zoom towards mouse pointer
|
||||
const rect = this.canvasContainerEl.getBoundingClientRect();
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const mouseY = e.clientY - rect.top;
|
||||
|
||||
const canvasX = (mouseX + this.canvasContainerEl.scrollLeft) / oldZoom;
|
||||
const canvasY = (mouseY + this.canvasContainerEl.scrollTop) / oldZoom;
|
||||
|
||||
this.canvasContainerEl.scrollLeft = canvasX * this.zoom - mouseX;
|
||||
this.canvasContainerEl.scrollTop = canvasY * this.zoom - mouseY;
|
||||
},
|
||||
{ passive: false }
|
||||
);
|
||||
|
||||
// Initial view positioning
|
||||
window.setTimeout(() => {
|
||||
this.resetView();
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,11 @@ export interface EditorElementStyle {
|
|||
italic: boolean;
|
||||
math: boolean;
|
||||
color: string;
|
||||
rawColor?: string;
|
||||
fontSize: number; // in pt
|
||||
thickness?: number; // line width in pt
|
||||
lineStyle?: 'solid' | 'dashed' | 'dotted';
|
||||
arrowStyle?: boolean;
|
||||
}
|
||||
|
||||
export interface EditorElement {
|
||||
|
|
@ -56,6 +59,9 @@ export interface TikzEditorContext {
|
|||
getPictureOptions(): string;
|
||||
getZoom(): number;
|
||||
setZoom(zoom: number): void;
|
||||
getPan(): { x: number; y: number };
|
||||
setPan(x: number, y: number): void;
|
||||
resetView(): void;
|
||||
getSearchQuery(): string;
|
||||
setSearchQuery(query: string): void;
|
||||
isShowPackageManager(): boolean;
|
||||
|
|
|
|||
|
|
@ -15,31 +15,255 @@ export class TikzCodec {
|
|||
return Number.parseFloat(value.trim());
|
||||
}
|
||||
|
||||
private parsePoint(value: string, shift: { x: number; y: number }) {
|
||||
const match = value.match(/\(\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)/);
|
||||
if (!match) return null;
|
||||
return {
|
||||
x: this.parseNumber(match[1]) + shift.x,
|
||||
y: this.parseNumber(match[2]) + shift.y
|
||||
private colorNameToHex(name: string): string | null {
|
||||
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'
|
||||
};
|
||||
return map[name.toLowerCase()] ?? null;
|
||||
}
|
||||
|
||||
private makeWire(x1: number, y1: number, x2: number, y2: number): EditorElement {
|
||||
const wire = AssetsManager.getCoreComponents().find(t => t.name === 'Wire');
|
||||
if (!wire) {
|
||||
throw new Error("Core component 'Wire' not found");
|
||||
private parseHex(hex: string): { r: number; g: number; b: number } | null {
|
||||
if (hex.startsWith('#')) hex = hex.slice(1);
|
||||
if (hex.length === 3) {
|
||||
hex = hex
|
||||
.split('')
|
||||
.map(c => c + c)
|
||||
.join('');
|
||||
}
|
||||
if (hex.length === 6) {
|
||||
const num = parseInt(hex, 16);
|
||||
if (!isNaN(num)) {
|
||||
return { r: (num >> 16) & 255, g: (num >> 8) & 255, b: num & 255 };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private parseTikzColor(colorStr: string): { hex: string; raw: string } | null {
|
||||
const trimmed = colorStr.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
if (trimmed.startsWith('#')) {
|
||||
return { hex: trimmed, raw: trimmed };
|
||||
}
|
||||
|
||||
const parts = trimmed.split('!');
|
||||
if (parts.length === 1) {
|
||||
const hex = this.colorNameToHex(parts[0]);
|
||||
return hex ? { hex, raw: trimmed } : null;
|
||||
}
|
||||
|
||||
const c1Hex = this.colorNameToHex(parts[0]) || '#000000';
|
||||
const pct = parseFloat(parts[1]);
|
||||
const weight = isNaN(pct) ? 1.0 : pct / 100;
|
||||
const c2Hex = parts[2] ? this.colorNameToHex(parts[2]) || '#ffffff' : '#ffffff';
|
||||
|
||||
const rgb1 = this.parseHex(c1Hex) || { r: 0, g: 0, b: 0 };
|
||||
const rgb2 = this.parseHex(c2Hex) || { r: 255, g: 255, b: 255 };
|
||||
|
||||
const r = Math.round(rgb1.r * weight + rgb2.r * (1 - weight));
|
||||
const g = Math.round(rgb1.g * weight + rgb2.g * (1 - weight));
|
||||
const b = Math.round(rgb1.b * weight + rgb2.b * (1 - weight));
|
||||
|
||||
const hex = `#${[r, g, b].map(x => x.toString(16).padStart(2, '0')).join('')}`;
|
||||
return { hex, raw: trimmed };
|
||||
}
|
||||
|
||||
private parseOptions(optsStr: string): {
|
||||
color?: string;
|
||||
rawColor?: string;
|
||||
thickness?: number;
|
||||
lineStyle?: 'solid' | 'dashed' | 'dotted';
|
||||
arrowStyle?: boolean;
|
||||
fontSize?: number;
|
||||
} {
|
||||
if (!optsStr) return {};
|
||||
const res: ReturnType<typeof this.parseOptions> = {};
|
||||
|
||||
const colorMatch = optsStr.match(/color\s*=\s*([a-zA-Z0-9!#_]+)/i);
|
||||
if (colorMatch) {
|
||||
const parsedColor = this.parseTikzColor(colorMatch[1]);
|
||||
if (parsedColor) {
|
||||
res.color = parsedColor.hex;
|
||||
res.rawColor = parsedColor.raw;
|
||||
}
|
||||
} else {
|
||||
const tokens = optsStr.split(/[\s,]+/);
|
||||
for (const token of tokens) {
|
||||
const parsedColor = this.parseTikzColor(token);
|
||||
if (parsedColor) {
|
||||
res.color = parsedColor.hex;
|
||||
res.rawColor = parsedColor.raw;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const widthMatch = optsStr.match(/line\s*width\s*=\s*(\d+(?:\.\d+)?)\s*(pt|cm|mm)?/i);
|
||||
if (widthMatch) {
|
||||
let val = parseFloat(widthMatch[1]);
|
||||
const unit = widthMatch[2]?.toLowerCase();
|
||||
if (unit === 'cm') val *= 28.45;
|
||||
else if (unit === 'mm') val *= 2.845;
|
||||
res.thickness = val;
|
||||
} else if (optsStr.includes('ultra thick')) res.thickness = 3.0;
|
||||
else if (optsStr.includes('very thick')) res.thickness = 2.0;
|
||||
else if (optsStr.includes('thick')) res.thickness = 1.5;
|
||||
else if (optsStr.includes('semithick')) res.thickness = 1.2;
|
||||
else if (optsStr.includes('very thin')) res.thickness = 0.4;
|
||||
else if (optsStr.includes('ultra thin')) res.thickness = 0.2;
|
||||
else if (optsStr.includes('thin')) res.thickness = 0.8;
|
||||
|
||||
if (optsStr.includes('dotted')) res.lineStyle = 'dotted';
|
||||
else if (optsStr.includes('dashed')) res.lineStyle = 'dashed';
|
||||
|
||||
if (
|
||||
optsStr.includes('->') ||
|
||||
optsStr.includes('-stealth') ||
|
||||
optsStr.includes('Triangle') ||
|
||||
optsStr.includes('-latex') ||
|
||||
optsStr.includes('->>')
|
||||
) {
|
||||
res.arrowStyle = true;
|
||||
}
|
||||
|
||||
const scaleMatch = optsStr.match(/scale\s*=\s*(\d+(?:\.\d+)?)/i);
|
||||
if (scaleMatch) {
|
||||
res.fontSize = Math.round(12 * parseFloat(scaleMatch[1]));
|
||||
} else if (optsStr.includes('\\Huge')) res.fontSize = 24;
|
||||
else if (optsStr.includes('\\Large')) res.fontSize = 18;
|
||||
else if (optsStr.includes('\\large')) res.fontSize = 14;
|
||||
else if (optsStr.includes('\\small')) res.fontSize = 10;
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
private parsePoint(
|
||||
value: string,
|
||||
shift: { x: number; y: number },
|
||||
namedCoords?: Map<string, { x: number; y: number }>
|
||||
): { x: number; y: number } | null {
|
||||
const literalMatch = value.match(/\(\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)/);
|
||||
if (literalMatch) {
|
||||
return {
|
||||
x: this.parseNumber(literalMatch[1]) + shift.x,
|
||||
y: this.parseNumber(literalMatch[2]) + shift.y
|
||||
};
|
||||
}
|
||||
const namedMatch = value.match(/\(\s*([A-Za-z0-9_]+)\s*\)/);
|
||||
if (namedMatch && namedCoords) {
|
||||
const coord = namedCoords.get(namedMatch[1]);
|
||||
if (coord) {
|
||||
return {
|
||||
x: coord.x + shift.x,
|
||||
y: coord.y + shift.y
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private expandForeach(source: string): string {
|
||||
let prev = '';
|
||||
let curr = source;
|
||||
for (let iter = 0; iter < 10 && curr !== prev; iter++) {
|
||||
prev = curr;
|
||||
curr = curr.replace(
|
||||
/\\foreach\s*\\([a-zA-Z0-9_]+)\s*in\s*\{([^}]+)\}\s*([\s\S]*?;)/g,
|
||||
(_fullMatch: string, varName: string, valuesList: string, body: string): string => {
|
||||
const items: string[] = [];
|
||||
const rawItems = valuesList.split(',');
|
||||
for (const item of rawItems) {
|
||||
const trimmed = item.trim();
|
||||
const rangeMatch = trimmed.match(/^(-?\d+)\s*\.\.\.\s*(-?\d+)$/);
|
||||
if (rangeMatch) {
|
||||
const start = parseInt(rangeMatch[1]);
|
||||
const end = parseInt(rangeMatch[2]);
|
||||
const step = start <= end ? 1 : -1;
|
||||
for (let i = start; step > 0 ? i <= end : i >= end; i += step) {
|
||||
items.push(i.toString());
|
||||
}
|
||||
} else if (trimmed) {
|
||||
items.push(trimmed);
|
||||
}
|
||||
}
|
||||
const regex = new RegExp(`\\\\${varName}\\b`, 'g');
|
||||
return items.map(val => body.replace(regex, val)).join('\n');
|
||||
}
|
||||
);
|
||||
}
|
||||
return curr;
|
||||
}
|
||||
|
||||
private stripComments(source: string): string {
|
||||
return source
|
||||
.split('\n')
|
||||
.map(line => {
|
||||
const idx = line.indexOf('%');
|
||||
if (idx !== -1) return line.substring(0, idx);
|
||||
return line;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
private makeWire(
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
optsStyle?: ReturnType<typeof this.parseOptions>
|
||||
): EditorElement {
|
||||
let name = 'Wire';
|
||||
if (optsStyle?.lineStyle === 'dashed' || optsStyle?.lineStyle === 'dotted') {
|
||||
name = 'Dashed Wire';
|
||||
} else if (optsStyle?.arrowStyle) {
|
||||
name = 'Arrow';
|
||||
}
|
||||
|
||||
const wire = AssetsManager.getCoreComponents().find(t => t.name === name);
|
||||
if (!wire) {
|
||||
throw new Error(`Core component '${name}' not found`);
|
||||
}
|
||||
|
||||
const style: EditorElementStyle = {
|
||||
...this.defaultStyle,
|
||||
color: optsStyle?.color ?? this.defaultStyle.color,
|
||||
rawColor: optsStyle?.rawColor,
|
||||
thickness: optsStyle?.thickness ?? this.defaultStyle.thickness,
|
||||
lineStyle: optsStyle?.lineStyle,
|
||||
arrowStyle: optsStyle?.arrowStyle
|
||||
};
|
||||
|
||||
return {
|
||||
id: this.createId(),
|
||||
type: 'wire',
|
||||
name: 'Wire',
|
||||
name,
|
||||
x: this.toCanvasX(x1),
|
||||
y: this.toCanvasY(y1),
|
||||
x2: this.toCanvasX(x2),
|
||||
y2: this.toCanvasY(y2),
|
||||
label: '',
|
||||
rotation: 0,
|
||||
style: { ...this.defaultStyle },
|
||||
style,
|
||||
svgMarkup: wire.svgMarkup,
|
||||
tikzCommand: wire.tikzCommand
|
||||
};
|
||||
|
|
@ -50,12 +274,21 @@ export class TikzCodec {
|
|||
x: number,
|
||||
y: number,
|
||||
label = '',
|
||||
radius?: number
|
||||
radius?: number,
|
||||
optsStyle?: ReturnType<typeof this.parseOptions>
|
||||
): EditorElement {
|
||||
const template = AssetsManager.getCoreComponents().find(t => t.name === name);
|
||||
if (!template) {
|
||||
throw new Error(`Core component '${name}' not found`);
|
||||
}
|
||||
|
||||
const style: EditorElementStyle = {
|
||||
...this.defaultStyle,
|
||||
color: optsStyle?.color ?? this.defaultStyle.color,
|
||||
rawColor: optsStyle?.rawColor,
|
||||
thickness: optsStyle?.thickness ?? this.defaultStyle.thickness
|
||||
};
|
||||
|
||||
return {
|
||||
id: this.createId(),
|
||||
type: 'component',
|
||||
|
|
@ -65,23 +298,49 @@ export class TikzCodec {
|
|||
label,
|
||||
rotation: 0,
|
||||
radius: radius ?? (name.toLowerCase().includes('circle') ? 12.0 : 2.0),
|
||||
style: { ...this.defaultStyle },
|
||||
style,
|
||||
svgMarkup: template.svgMarkup,
|
||||
tikzCommand: template.tikzCommand
|
||||
};
|
||||
}
|
||||
|
||||
private makeText(x: number, y: number, rawLabel: string): EditorElement {
|
||||
private makeText(
|
||||
x: number,
|
||||
y: number,
|
||||
rawLabel: string,
|
||||
optsStyle?: ReturnType<typeof this.parseOptions>
|
||||
): EditorElement {
|
||||
const template = AssetsManager.getCoreComponents().find(t => t.name === 'Text');
|
||||
if (!template) {
|
||||
throw new Error("Core component 'Text' not found");
|
||||
}
|
||||
let label = rawLabel.trim();
|
||||
let math = false;
|
||||
let bold = false;
|
||||
let italic = false;
|
||||
|
||||
if (label.startsWith('$') && label.endsWith('$')) {
|
||||
label = label.substring(1, label.length - 1);
|
||||
math = true;
|
||||
}
|
||||
if (label.includes('\\textbf{')) {
|
||||
bold = true;
|
||||
label = label.replace(/\\textbf\{([^}]+)\}/, '$1');
|
||||
}
|
||||
if (label.includes('\\textit{')) {
|
||||
italic = true;
|
||||
label = label.replace(/\\textit\{([^}]+)\}/, '$1');
|
||||
}
|
||||
|
||||
const style: EditorElementStyle = {
|
||||
...this.defaultStyle,
|
||||
math,
|
||||
bold,
|
||||
italic,
|
||||
color: optsStyle?.color ?? this.defaultStyle.color,
|
||||
rawColor: optsStyle?.rawColor,
|
||||
fontSize: optsStyle?.fontSize ?? this.defaultStyle.fontSize
|
||||
};
|
||||
|
||||
return {
|
||||
id: this.createId(),
|
||||
|
|
@ -91,7 +350,7 @@ export class TikzCodec {
|
|||
y: this.toCanvasY(y),
|
||||
label,
|
||||
rotation: 0,
|
||||
style: { ...this.defaultStyle, math },
|
||||
style,
|
||||
svgMarkup: template.svgMarkup,
|
||||
tikzCommand: template.tikzCommand
|
||||
};
|
||||
|
|
@ -102,24 +361,55 @@ export class TikzCodec {
|
|||
const beginMatch = source.match(/\\begin\{tikzpicture\}(\[[^\]]*\])?/);
|
||||
const pictureOptions = beginMatch?.[1] ?? '';
|
||||
|
||||
const shifts: { x: number; y: number }[] = [{ x: 0, y: 0 }];
|
||||
const lines = source.split('\n');
|
||||
const rawCleaned = this.expandForeach(this.stripComments(source));
|
||||
const cleaned = rawCleaned
|
||||
.replace(/\\begin\{tikzpicture\}(\[[^\]]*\])?/g, '')
|
||||
.replace(/\\end\{tikzpicture\}/g, '')
|
||||
.replace(/\\usepackage(?:\s*\[[^\]]*\])?\s*\{[^}]+\}/g, '')
|
||||
.replace(/\\usetikzlibrary\s*\{[^}]+\}/g, '');
|
||||
const namedCoords = new Map<string, { x: number; y: number }>();
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
// Pre-pass for named coordinates: \coordinate (A) at (x,y);
|
||||
const coordMatches = cleaned.matchAll(
|
||||
/\\coordinate(?:\s*\[[^\]]*\])?\s*\(\s*([A-Za-z0-9_]+)\s*\)\s*at\s*(\([^)]+\))\s*;/g
|
||||
);
|
||||
for (const match of coordMatches) {
|
||||
const pt = this.parsePoint(match[2], { x: 0, y: 0 });
|
||||
if (pt) {
|
||||
namedCoords.set(match[1], pt);
|
||||
}
|
||||
}
|
||||
|
||||
// Node pre-pass for named nodes: \node (A) at (x,y) or \node at (x,y) (A)
|
||||
const nodeCoordMatches = cleaned.matchAll(
|
||||
/\\node(?:\s*\[[^\]]*\])?\s*(?:\(\s*([A-Za-z0-9_]+)\s*\)\s*)?at\s*(\([^)]+\))(?:\s*\(\s*([A-Za-z0-9_]+)\s*\))?/g
|
||||
);
|
||||
for (const match of nodeCoordMatches) {
|
||||
const name = match[1] || match[3];
|
||||
if (name) {
|
||||
const pt = this.parsePoint(match[2], { x: 0, y: 0 });
|
||||
if (pt) namedCoords.set(name, pt);
|
||||
}
|
||||
}
|
||||
|
||||
const shifts: { x: number; y: number }[] = [{ x: 0, y: 0 }];
|
||||
const statements = cleaned.split(';');
|
||||
|
||||
for (const rawStmt of statements) {
|
||||
const stmt = rawStmt.replace(/\s+/g, ' ').trim();
|
||||
if (
|
||||
!trimmed ||
|
||||
trimmed.startsWith('%') ||
|
||||
trimmed.startsWith('\\usepackage') ||
|
||||
trimmed.startsWith('\\usetikzlibrary') ||
|
||||
trimmed.startsWith('\\begin{tikzpicture}') ||
|
||||
trimmed.startsWith('\\end{tikzpicture}')
|
||||
!stmt ||
|
||||
stmt.startsWith('\\usepackage') ||
|
||||
stmt.startsWith('\\usetikzlibrary') ||
|
||||
stmt.startsWith('\\begin{tikzpicture}') ||
|
||||
stmt.startsWith('\\end{tikzpicture}')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentShift = shifts[shifts.length - 1];
|
||||
const scopeMatch = trimmed.match(
|
||||
|
||||
const scopeMatch = stmt.match(
|
||||
/\\begin\{scope\}\s*\[shift=\{\(\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)\}\]/
|
||||
);
|
||||
if (scopeMatch) {
|
||||
|
|
@ -129,27 +419,32 @@ export class TikzCodec {
|
|||
});
|
||||
continue;
|
||||
}
|
||||
if (trimmed.startsWith('\\end{scope}')) {
|
||||
if (stmt.startsWith('\\end{scope}')) {
|
||||
if (shifts.length > 1) shifts.pop();
|
||||
continue;
|
||||
}
|
||||
|
||||
const nodeMatch = trimmed.match(
|
||||
/\\node(?:\s*\[[^\]]*\])?\s*at\s*(\(\s*-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?\s*\))\s*(?:\(\w+\)\s*)?\{(.*)\}\s*;/
|
||||
// 1. Node matching
|
||||
const nodeMatch = stmt.match(
|
||||
/\\node(?:\s*\[([^\]]*)\])?(?:\s*\(([A-Za-z0-9_]+)\))?\s*at\s*(\([^)]+\))\s*(?:(?:\(([A-Za-z0-9_]+)\))?\s*)?\{((?:[^{}]|\{[^{}]*\})*)\}/
|
||||
);
|
||||
if (nodeMatch) {
|
||||
const point = this.parsePoint(nodeMatch[1], currentShift);
|
||||
if (point) parsedElements.push(this.makeText(point.x, point.y, nodeMatch[2]));
|
||||
const point = this.parsePoint(nodeMatch[3], currentShift, namedCoords);
|
||||
if (point) {
|
||||
const optsStyle = this.parseOptions(nodeMatch[1] ?? '');
|
||||
parsedElements.push(this.makeText(point.x, point.y, nodeMatch[5], optsStyle));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const circleMatch = trimmed.match(
|
||||
/\\(fill|draw)\s*(\(\s*-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?\s*\))\s*circle\s*\(\s*(\d+(?:\.\d+)?)\s*(?:pt|cm)?\s*\)(?:\s*node\[[^\]]*\]\s*\{(.*)\})?\s*;/
|
||||
// 2. Circle matching
|
||||
const circleMatch = stmt.match(
|
||||
/\\(fill|draw)(?:\s*\[([^\]]*)\])?\s*(\([^)]+\))\s*circle\s*\(\s*(\d+(?:\.\d+)?)\s*(?:pt|cm)?\s*\)(?:\s*node\[[^\]]*\]\s*\{(.*)\})?/
|
||||
);
|
||||
if (circleMatch) {
|
||||
const point = this.parsePoint(circleMatch[2], currentShift);
|
||||
const point = this.parsePoint(circleMatch[3], currentShift, namedCoords);
|
||||
if (point) {
|
||||
const parsedRadius = parseFloat(circleMatch[3]);
|
||||
const parsedRadius = parseFloat(circleMatch[4]);
|
||||
const isFilled = circleMatch[1] === 'fill';
|
||||
const isLarge = !isNaN(parsedRadius) && parsedRadius >= 6.0;
|
||||
let nodeName: 'Filled node' | 'Open node' | 'Circle' | 'Filled Circle';
|
||||
|
|
@ -159,25 +454,28 @@ export class TikzCodec {
|
|||
nodeName = isLarge ? 'Circle' : 'Open node';
|
||||
}
|
||||
|
||||
const optsStyle = this.parseOptions(circleMatch[2] ?? '');
|
||||
parsedElements.push(
|
||||
this.makeNode(
|
||||
nodeName,
|
||||
point.x,
|
||||
point.y,
|
||||
circleMatch[4] ?? '',
|
||||
isNaN(parsedRadius) ? undefined : parsedRadius
|
||||
circleMatch[5] ?? '',
|
||||
isNaN(parsedRadius) ? undefined : parsedRadius,
|
||||
optsStyle
|
||||
)
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const circuitMatch = trimmed.match(
|
||||
/\\draw\s*(\(\s*-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?\s*\))\s*to\s*\[\s*([A-Za-z]+).*?(?:l=\{(.*?)\})?.*?\]\s*(\(\s*-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?\s*\))\s*;/
|
||||
// 3. Circuit component matching
|
||||
const circuitMatch = stmt.match(
|
||||
/\\draw(?:\s*\[([^\]]*)\])?\s*(\([^)]+\))\s*to\s*\[\s*([A-Za-z]+).*?(?:l=\{(.*?)\})?.*?\]\s*(\([^)]+\))/
|
||||
);
|
||||
if (circuitMatch) {
|
||||
const start = this.parsePoint(circuitMatch[1], currentShift);
|
||||
const end = this.parsePoint(circuitMatch[4], currentShift);
|
||||
const start = this.parsePoint(circuitMatch[2], currentShift, namedCoords);
|
||||
const end = this.parsePoint(circuitMatch[5], currentShift, namedCoords);
|
||||
if (start && end) {
|
||||
const templates = [
|
||||
...AssetsManager.getCoreComponents(),
|
||||
|
|
@ -188,7 +486,8 @@ export class TikzCodec {
|
|||
throw new Error("Core component 'Wire' not found");
|
||||
}
|
||||
const template =
|
||||
templates.find(t => t.tikzCommand.includes(`to[${circuitMatch[2]}`)) ?? wireTemplate;
|
||||
templates.find(t => t.tikzCommand.includes(`to[${circuitMatch[3]}`)) ?? wireTemplate;
|
||||
const optsStyle = this.parseOptions(circuitMatch[1] ?? '');
|
||||
parsedElements.push({
|
||||
id: this.createId(),
|
||||
type: 'wire',
|
||||
|
|
@ -197,9 +496,14 @@ export class TikzCodec {
|
|||
y: this.toCanvasY(start.y),
|
||||
x2: this.toCanvasX(end.x),
|
||||
y2: this.toCanvasY(end.y),
|
||||
label: circuitMatch[3] ?? template.name,
|
||||
label: circuitMatch[4] ?? template.name,
|
||||
rotation: 0,
|
||||
style: { ...this.defaultStyle },
|
||||
style: {
|
||||
...this.defaultStyle,
|
||||
color: optsStyle.color ?? this.defaultStyle.color,
|
||||
rawColor: optsStyle.rawColor,
|
||||
thickness: optsStyle.thickness ?? this.defaultStyle.thickness
|
||||
},
|
||||
svgMarkup: template.svgMarkup,
|
||||
tikzCommand: template.tikzCommand
|
||||
});
|
||||
|
|
@ -207,16 +511,39 @@ export class TikzCodec {
|
|||
continue;
|
||||
}
|
||||
|
||||
const rectMatch = trimmed.match(
|
||||
/\\(draw|fill)\s*(\(\s*-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?\s*\))\s*\+\(\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)\s*rectangle\s*\+\(\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)\s*;/
|
||||
// 4. Plot coordinates matching
|
||||
const plotMatch = stmt.match(
|
||||
/\\draw(?:\s*\[([^\]]*)\])?\s*plot(?:\s*\[[^\]]*\])?\s*coordinates\s*\{([^}]+)\}/
|
||||
);
|
||||
if (plotMatch) {
|
||||
const optsStyle = this.parseOptions(plotMatch[1] ?? '');
|
||||
const rawCoordsStr = plotMatch[2];
|
||||
const coordMatchesList = Array.from(rawCoordsStr.matchAll(/\([^)]+\)/g));
|
||||
const pts: { x: number; y: number }[] = [];
|
||||
for (const m of coordMatchesList) {
|
||||
const p = this.parsePoint(m[0], currentShift, namedCoords);
|
||||
if (p) pts.push(p);
|
||||
}
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
parsedElements.push(
|
||||
this.makeWire(pts[i].x, pts[i].y, pts[i + 1].x, pts[i + 1].y, optsStyle)
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 5. Rectangle matching
|
||||
const rectMatch = stmt.match(
|
||||
/\\(draw|fill)(?:\s*\[([^\]]*)\])?\s*(\([^)]+\))\s*\+\(\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)\s*rectangle\s*\+\(\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)/
|
||||
);
|
||||
if (rectMatch) {
|
||||
const point = this.parsePoint(rectMatch[2], currentShift);
|
||||
const point = this.parsePoint(rectMatch[3], currentShift, namedCoords);
|
||||
if (point) {
|
||||
const template = AssetsManager.getCoreComponents().find(t => t.name === 'Rectangle');
|
||||
if (!template) {
|
||||
throw new Error("Core component 'Rectangle' not found");
|
||||
}
|
||||
const optsStyle = this.parseOptions(rectMatch[2] ?? '');
|
||||
parsedElements.push({
|
||||
id: this.createId(),
|
||||
type: 'component',
|
||||
|
|
@ -225,7 +552,11 @@ export class TikzCodec {
|
|||
y: this.toCanvasY(point.y),
|
||||
label: '',
|
||||
rotation: 0,
|
||||
style: { ...this.defaultStyle },
|
||||
style: {
|
||||
...this.defaultStyle,
|
||||
color: optsStyle.color ?? this.defaultStyle.color,
|
||||
rawColor: optsStyle.rawColor
|
||||
},
|
||||
svgMarkup: template.svgMarkup,
|
||||
tikzCommand: template.tikzCommand
|
||||
});
|
||||
|
|
@ -233,71 +564,22 @@ export class TikzCodec {
|
|||
continue;
|
||||
}
|
||||
|
||||
const triMatch = trimmed.match(
|
||||
/\\draw\s*(\(\s*-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?\s*\))\s*\+\(0\s*,\s*0\.4\)\s*--\s*\+\(-0\.4\s*,\s*-0\.3\)\s*--\s*\+\(0\.4\s*,\s*-0\.3\)\s*--\s*cycle\s*;/
|
||||
);
|
||||
if (triMatch) {
|
||||
const point = this.parsePoint(triMatch[1], currentShift);
|
||||
if (point) {
|
||||
const template = AssetsManager.getCoreComponents().find(t => t.name === 'Triangle');
|
||||
if (!template) {
|
||||
throw new Error("Core component 'Triangle' not found");
|
||||
}
|
||||
parsedElements.push({
|
||||
id: this.createId(),
|
||||
type: 'component',
|
||||
name: 'Triangle',
|
||||
x: this.toCanvasX(point.x),
|
||||
y: this.toCanvasY(point.y),
|
||||
label: '',
|
||||
rotation: 0,
|
||||
style: { ...this.defaultStyle },
|
||||
svgMarkup: template.svgMarkup,
|
||||
tikzCommand: template.tikzCommand
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// 6. Generic \draw paths matching (multi-point lines / arrows)
|
||||
if (stmt.startsWith('\\draw')) {
|
||||
const drawOptsMatch = stmt.match(/^\\draw\s*\[([^\]]*)\]/);
|
||||
const optsStyle = this.parseOptions(drawOptsMatch?.[1] ?? '');
|
||||
|
||||
if (trimmed.startsWith('\\draw')) {
|
||||
const drawOptsMatch = trimmed.match(/^\\draw\s*\[([^\]]*)\]/);
|
||||
const opts = drawOptsMatch?.[1] ?? '';
|
||||
let wireName: 'Wire' | 'Dashed Wire' | 'Arrow' = 'Wire';
|
||||
if (opts.includes('dashed')) {
|
||||
wireName = 'Dashed Wire';
|
||||
} else if (opts.includes('-stealth') || opts.includes('->') || opts.includes('-latex')) {
|
||||
wireName = 'Arrow';
|
||||
const pointMatchesList = Array.from(stmt.matchAll(/\([^)]+\)/g));
|
||||
const pts: { x: number; y: number }[] = [];
|
||||
for (const m of pointMatchesList) {
|
||||
const p = this.parsePoint(m[0], currentShift, namedCoords);
|
||||
if (p) pts.push(p);
|
||||
}
|
||||
|
||||
const pointMatches = Array.from(
|
||||
trimmed.matchAll(/\(\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)/g)
|
||||
);
|
||||
for (let index = 0; index < pointMatches.length - 1; index++) {
|
||||
const first = pointMatches[index];
|
||||
const second = pointMatches[index + 1];
|
||||
const startX = this.parseNumber(first[1]) + currentShift.x;
|
||||
const startY = this.parseNumber(first[2]) + currentShift.y;
|
||||
const endX = this.parseNumber(second[1]) + currentShift.x;
|
||||
const endY = this.parseNumber(second[2]) + currentShift.y;
|
||||
const template = AssetsManager.getCoreComponents().find(t => t.name === wireName);
|
||||
if (!template) {
|
||||
throw new Error(`Core component '${wireName}' not found`);
|
||||
}
|
||||
|
||||
parsedElements.push({
|
||||
id: this.createId(),
|
||||
type: 'wire',
|
||||
name: wireName,
|
||||
x: this.toCanvasX(startX),
|
||||
y: this.toCanvasY(startY),
|
||||
x2: this.toCanvasX(endX),
|
||||
y2: this.toCanvasY(endY),
|
||||
label: '',
|
||||
rotation: 0,
|
||||
style: { ...this.defaultStyle },
|
||||
svgMarkup: template.svgMarkup,
|
||||
tikzCommand: template.tikzCommand
|
||||
});
|
||||
for (let index = 0; index < pts.length - 1; index++) {
|
||||
const startPt = pts[index];
|
||||
const endPt = pts[index + 1];
|
||||
parsedElements.push(this.makeWire(startPt.x, startPt.y, endPt.x, endPt.y, optsStyle));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -310,6 +592,14 @@ export class TikzCodec {
|
|||
const neededLibraries = new Set<string>();
|
||||
|
||||
elements.forEach(elem => {
|
||||
if (
|
||||
elem.name === 'Arrow' ||
|
||||
elem.style.arrowStyle ||
|
||||
elem.tikzCommand?.includes('stealth') ||
|
||||
elem.tikzCommand?.includes('Triangle')
|
||||
) {
|
||||
neededLibraries.add('arrows.meta');
|
||||
}
|
||||
const regPkg = AssetsManager.getRegistry().find(p =>
|
||||
p.components.some(comp => comp.name === elem.name)
|
||||
);
|
||||
|
|
@ -351,13 +641,37 @@ export class TikzCodec {
|
|||
cmd = cmd.replace(/circle\s*\([^)]*\)/g, `circle (${elem.radius}pt)`);
|
||||
}
|
||||
|
||||
// Handle style options insertion into TikZ command
|
||||
const opts: string[] = [];
|
||||
|
||||
if (elem.style.thickness && elem.style.thickness !== 1.0) {
|
||||
if (cmd.startsWith('\\draw') && cmd.includes('line width=')) {
|
||||
cmd = cmd.replace(/line width=[^,\]]+/, `line width=${elem.style.thickness}pt`);
|
||||
} else if (cmd.startsWith('\\draw[')) {
|
||||
cmd = cmd.replace(/\\draw\[/, `\\draw[line width=${elem.style.thickness}pt, `);
|
||||
opts.push(`line width=${elem.style.thickness}pt`);
|
||||
}
|
||||
if (elem.style.lineStyle === 'dotted') {
|
||||
opts.push('dotted');
|
||||
} else if (elem.style.lineStyle === 'dashed') {
|
||||
opts.push('dashed');
|
||||
}
|
||||
if (elem.style.rawColor) {
|
||||
opts.push(`color=${elem.style.rawColor}`);
|
||||
} else if (elem.style.color && elem.style.color !== '#f8e7ad') {
|
||||
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[line width=${elem.style.thickness}pt]`);
|
||||
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}]`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -48,15 +48,7 @@ export class TikzRenderer {
|
|||
|
||||
public async render(source: string): Promise<SVGElement> {
|
||||
const code = this.tidyTikzSource(source);
|
||||
|
||||
// Check cache first
|
||||
const cachedHtml = this.renderCache.get(code);
|
||||
if (cachedHtml !== undefined) {
|
||||
const domParser = new DOMParser();
|
||||
const doc = domParser.parseFromString(cachedHtml, 'text/html');
|
||||
const svg = doc.querySelector('svg') as unknown as SVGElement;
|
||||
return svg;
|
||||
}
|
||||
this.renderCache.delete(code);
|
||||
|
||||
// Lazy-load tikzjax.css if not loaded yet
|
||||
if (!this.tikzjaxCss) {
|
||||
|
|
@ -202,7 +194,19 @@ export class TikzRenderer {
|
|||
// Trim whitespace and remove empty lines
|
||||
lines = lines.map(line => line.trim()).filter(line => line);
|
||||
|
||||
return lines.join('\n');
|
||||
let cleaned = lines.join('\n');
|
||||
const needsArrowsMeta =
|
||||
(cleaned.includes('Triangle') ||
|
||||
cleaned.includes('stealth') ||
|
||||
cleaned.includes('-{') ||
|
||||
cleaned.includes('}->')) &&
|
||||
!cleaned.includes('arrows.meta');
|
||||
|
||||
if (needsArrowsMeta) {
|
||||
cleaned = `\\usetikzlibrary{arrows.meta}\n${cleaned}`;
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
private registerCodeBlockProcessor() {
|
||||
|
|
|
|||
|
|
@ -42,9 +42,6 @@ class CustomHTMLMachine extends HTMLMachine {
|
|||
this.textBlockOriginV = pos.v;
|
||||
}
|
||||
|
||||
const left = pos.h * this.pointsPerDviUnit;
|
||||
const top = pos.v * this.pointsPerDviUnit;
|
||||
|
||||
this.svgDepth = this.svgDepth || 0;
|
||||
this.svgDepth += (svgStr.match(/<svg/g) || []).length;
|
||||
this.svgDepth -= (svgStr.match(/<\/svg>/g) || []).length;
|
||||
|
|
@ -58,8 +55,11 @@ class CustomHTMLMachine extends HTMLMachine {
|
|||
`<svg width="10pt" height="10pt" viewBox="0 0 10 10" style="overflow: visible; position: relative;">`
|
||||
);
|
||||
|
||||
replacedSvg = replacedSvg.replace(/{\?x}/g, left.toString());
|
||||
replacedSvg = replacedSvg.replace(/{\?y}/g, top.toString());
|
||||
const relLeft = (pos.h - this.originH) * this.pointsPerDviUnit;
|
||||
const relTop = (pos.v - this.originV) * this.pointsPerDviUnit;
|
||||
|
||||
replacedSvg = replacedSvg.replace(/{\?x}/g, relLeft.toString());
|
||||
replacedSvg = replacedSvg.replace(/{\?y}/g, relTop.toString());
|
||||
this.output.write(replacedSvg);
|
||||
}
|
||||
|
||||
|
|
@ -459,9 +459,14 @@ export class TikzJaxLoader {
|
|||
|
||||
// Resolve TikZ libraries
|
||||
for (const lib of libraries) {
|
||||
const cand = `tex_files/tikzlibrary${lib}.code.tex.gz`;
|
||||
if (availableAssets.includes(cand)) {
|
||||
assetsToLoad.add(cand);
|
||||
const candidates = [
|
||||
`tex_files/tikzlibrary${lib}.code.tex.gz`,
|
||||
`tex_files/pgflibrary${lib}.code.tex.gz`
|
||||
];
|
||||
for (const cand of candidates) {
|
||||
if (availableAssets.includes(cand)) {
|
||||
assetsToLoad.add(cand);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -665,10 +670,10 @@ interface Matrix2D {
|
|||
function getSvgBoundingBox(
|
||||
svg: SVGElement
|
||||
): { minX: number; minY: number; maxX: number; maxY: number } | null {
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
let minX = 0;
|
||||
let minY = 0;
|
||||
let maxX = 0;
|
||||
let maxY = 0;
|
||||
|
||||
function updateBounds(x: number, y: number) {
|
||||
if (x < minX) minX = x;
|
||||
|
|
@ -836,33 +841,6 @@ function getSvgBoundingBox(
|
|||
updateBounds(p2.x, p2.y);
|
||||
});
|
||||
|
||||
// 6. Scan texts
|
||||
const texts = svg.querySelectorAll('text');
|
||||
texts.forEach(text => {
|
||||
if (text.closest('defs')) return;
|
||||
const x = parseFloat(text.getAttribute('x') || '0');
|
||||
const y = parseFloat(text.getAttribute('y') || '0');
|
||||
const matrix = getAbsoluteMatrix(text);
|
||||
const pt = applyTransform(matrix, x, y);
|
||||
|
||||
// Extract font size from style
|
||||
const style = text.getAttribute('style') || '';
|
||||
const fontSizeMatch = style.match(/font-size:\s*([\d.]+)/);
|
||||
const fontSize = fontSizeMatch ? parseFloat(fontSizeMatch[1]) : 10;
|
||||
|
||||
// Extract text content and estimate width
|
||||
const content = text.textContent || '';
|
||||
const estimatedWidth = content.length * fontSize * 0.6;
|
||||
|
||||
// Add text box bounds
|
||||
updateBounds(pt.x, pt.y);
|
||||
updateBounds(pt.x + estimatedWidth, pt.y);
|
||||
updateBounds(pt.x, pt.y - fontSize);
|
||||
updateBounds(pt.x + estimatedWidth, pt.y - fontSize);
|
||||
updateBounds(pt.x, pt.y + fontSize * 0.2); // descenders
|
||||
updateBounds(pt.x + estimatedWidth, pt.y + fontSize * 0.2);
|
||||
});
|
||||
|
||||
if (minX === Infinity || minY === Infinity || maxX === -Infinity || maxY === -Infinity) {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1124,8 +1124,8 @@ div.tikz-editor-modal .modal-close-button {
|
|||
}
|
||||
|
||||
.tikz-editor-ui .canvas-workspace {
|
||||
width: 1120px;
|
||||
height: 720px;
|
||||
width: 4000px;
|
||||
height: 4000px;
|
||||
position: relative;
|
||||
transform-origin: 0 0;
|
||||
cursor: crosshair;
|
||||
|
|
@ -1139,7 +1139,7 @@ div.tikz-editor-modal .modal-close-button {
|
|||
bottom: 0;
|
||||
background-image: radial-gradient(circle at 0px 0px, var(--text-muted) 1.8px, transparent 1.8px);
|
||||
background-size: 80px 80px;
|
||||
background-position: 120px 360px;
|
||||
background-position: 2000px 2000px;
|
||||
opacity: 0.35;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
|
@ -1147,7 +1147,7 @@ div.tikz-editor-modal .modal-close-button {
|
|||
.tikz-editor-ui .grid-background.half-grid {
|
||||
background-image: radial-gradient(circle at 0px 0px, var(--text-muted) 1.5px, transparent 1.5px);
|
||||
background-size: 40px 40px;
|
||||
background-position: 120px 360px;
|
||||
background-position: 2000px 2000px;
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
|
|
|
|||
145
test_helpers/tikz-codec.test.ts
Normal file
145
test_helpers/tikz-codec.test.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { TikzCodec } from '../src/features/tikz-editor/utils/tikz-codec';
|
||||
import { type EditorElementStyle } from '../src/features/tikz-editor/types';
|
||||
|
||||
describe('TikzCodec robust parsing tests', () => {
|
||||
const defaultStyle: EditorElementStyle = {
|
||||
bold: false,
|
||||
italic: false,
|
||||
math: false,
|
||||
color: '#f8e7ad',
|
||||
fontSize: 12,
|
||||
thickness: 1.0
|
||||
};
|
||||
|
||||
const codec = new TikzCodec(
|
||||
x => 120 + x * 80,
|
||||
y => 360 - y * 80,
|
||||
x => (x - 120) / 80,
|
||||
y => -(y - 360) / 80,
|
||||
() => 'test_id',
|
||||
defaultStyle
|
||||
);
|
||||
|
||||
it('parses complex multi-line TikZ snippet with loops, plots, colors, and named coordinates', () => {
|
||||
const source = `
|
||||
\\begin{tikzpicture}[scale=1.1]
|
||||
% Axes
|
||||
\\draw[thick] (-2.5,0) -- (7.2,0);
|
||||
\\draw[thick,-{Triangle[length=10pt,width=10pt]}]
|
||||
(6, -2.9) -- (6,5);
|
||||
|
||||
% ticks
|
||||
\\foreach \\y in {-2,-1,1,2,3,4}
|
||||
\\draw[thick] (5.86,\\y) -- (6.14,\\y);
|
||||
|
||||
\\foreach \\x in {-2,1}
|
||||
\\draw[thick] (\\x,-0.12) -- (\\x,0.12);
|
||||
|
||||
% Blue curve
|
||||
\\draw[
|
||||
line width=7pt,
|
||||
color=blue!70!black
|
||||
]
|
||||
plot[smooth]
|
||||
coordinates{
|
||||
(-0.9,2.9)
|
||||
(0.2,2.0)
|
||||
(1.5,1.1)
|
||||
(3.2,0.35)
|
||||
(5.3,0.02)
|
||||
(6.0,0.00)
|
||||
(7.3,0.12)
|
||||
};
|
||||
|
||||
% Dotted tangent line
|
||||
\\draw[
|
||||
dotted,
|
||||
line width=2pt,
|
||||
color=purple!70!red
|
||||
]
|
||||
(-3.2,4.8) -- (7.2,-3.1);
|
||||
|
||||
\\node[
|
||||
color=purple!70!red,
|
||||
scale=1.6
|
||||
]
|
||||
at (4.75,-0.45)
|
||||
{$\\mathrm{slope}=p$};
|
||||
|
||||
% Tangency point
|
||||
\\coordinate (A) at (-1.0,2.25);
|
||||
|
||||
\\fill[green!70!cyan] (A) circle (0.12);
|
||||
|
||||
% Left vertical decomposition
|
||||
\\draw[
|
||||
line width=7pt,
|
||||
color=cyan!90!blue
|
||||
]
|
||||
(-1,-2.25) -- (-1,0);
|
||||
|
||||
\\draw[
|
||||
line width=7pt,
|
||||
color=green!80!cyan
|
||||
]
|
||||
(-1,0) -- (A);
|
||||
|
||||
\\fill[green!70!cyan] (-1,0) circle (0.13);
|
||||
|
||||
\\node[color=green!60!black,scale=2.2]
|
||||
at (-1.45,1.15) {$f$};
|
||||
|
||||
\\node[color=cyan!80!blue,scale=2.2]
|
||||
at (-0.58,-1.2) {$g$};
|
||||
|
||||
% Right vertical segment
|
||||
\\draw[
|
||||
line width=7pt,
|
||||
color=cyan!90!blue
|
||||
]
|
||||
(6,-2.25) -- (6,0);
|
||||
|
||||
\\node[color=cyan!80!blue,scale=2.2]
|
||||
at (6.45,-1.05) {$g$};
|
||||
|
||||
%--------------------------------------------------
|
||||
% Bottom dotted distance x
|
||||
%--------------------------------------------------
|
||||
\\draw[dotted,line width=2pt]
|
||||
(-1,-2.25) -- (6,-2.25);
|
||||
|
||||
\\node[scale=2.4]
|
||||
at (2.15,-1.8)
|
||||
{$x$};
|
||||
\\end{tikzpicture}
|
||||
`;
|
||||
|
||||
const result = codec.parse(source);
|
||||
expect(result.elements.length).toBeGreaterThan(15);
|
||||
|
||||
// Verify text nodes were parsed
|
||||
const textNodes = result.elements.filter(e => e.type === 'text');
|
||||
expect(textNodes.map(t => t.label)).toContain('f');
|
||||
expect(textNodes.map(t => t.label)).toContain('g');
|
||||
expect(textNodes.map(t => t.label)).toContain('x');
|
||||
expect(textNodes.map(t => t.label)).toContain('\\mathrm{slope}=p');
|
||||
|
||||
// Verify curve plot segments were parsed
|
||||
const blueSegments = result.elements.filter(
|
||||
e => e.type === 'wire' && e.style.rawColor === 'blue!70!black'
|
||||
);
|
||||
expect(blueSegments.length).toBe(6);
|
||||
|
||||
// Verify named coordinate A was resolved for circle fill
|
||||
const circleA = result.elements.find(
|
||||
e => e.type === 'component' && e.style.rawColor === 'green!70!cyan'
|
||||
);
|
||||
expect(circleA).toBeDefined();
|
||||
|
||||
// Verify dotted lines
|
||||
const dottedLine = result.elements.find(
|
||||
e => e.type === 'wire' && e.style.rawColor === 'purple!70!red'
|
||||
);
|
||||
expect(dottedLine?.style.lineStyle).toBe('dotted');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue