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:
JK 2026-07-21 15:50:06 +02:00
parent ec2e6f7dda
commit f0ce1930b8
8 changed files with 774 additions and 235 deletions

View file

@ -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;

View file

@ -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);
}
}

View file

@ -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;

View file

@ -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}]`);
}
}

View file

@ -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() {

View file

@ -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;
}

View file

@ -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;
}

View 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');
});
});