From f0ce1930b8c6da1ff7bd2aa60fa556a6b9b400d4 Mon Sep 17 00:00:00 2001 From: JK Date: Tue, 21 Jul 2026 15:50:06 +0200 Subject: [PATCH] fix(canvas): resolve text node translation, library resolution, and canvas panning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- .../tikz-editor/components/canvas-grid.ts | 45 +- src/features/tikz-editor/tikz-editor-modal.ts | 179 ++++-- src/features/tikz-editor/types.ts | 6 + src/features/tikz-editor/utils/tikz-codec.ts | 546 ++++++++++++++---- src/features/tikz/renderer.ts | 24 +- src/features/tikz/tikzjax/loader.ts | 56 +- src/styles/main.css | 8 +- test_helpers/tikz-codec.test.ts | 145 +++++ 8 files changed, 774 insertions(+), 235 deletions(-) create mode 100644 test_helpers/tikz-codec.test.ts diff --git a/src/features/tikz-editor/components/canvas-grid.ts b/src/features/tikz-editor/components/canvas-grid.ts index 839727e..97113c8 100644 --- a/src/features/tikz-editor/components/canvas-grid.ts +++ b/src/features/tikz-editor/components/canvas-grid.ts @@ -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; diff --git a/src/features/tikz-editor/tikz-editor-modal.ts b/src/features/tikz-editor/tikz-editor-modal.ts index 4bc948b..bdf2690 100644 --- a/src/features/tikz-editor/tikz-editor-modal.ts +++ b/src/features/tikz-editor/tikz-editor-modal.ts @@ -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( ``, @@ -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( ``, @@ -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( + ``, + '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( ``, @@ -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( ``, @@ -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); } } diff --git a/src/features/tikz-editor/types.ts b/src/features/tikz-editor/types.ts index 5523271..bae5dbb 100644 --- a/src/features/tikz-editor/types.ts +++ b/src/features/tikz-editor/types.ts @@ -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; diff --git a/src/features/tikz-editor/utils/tikz-codec.ts b/src/features/tikz-editor/utils/tikz-codec.ts index 0384414..ac82e47 100644 --- a/src/features/tikz-editor/utils/tikz-codec.ts +++ b/src/features/tikz-editor/utils/tikz-codec.ts @@ -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 = { + 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 = {}; + + 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 + ): { 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 + ): 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 ): 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 + ): 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(); - 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(); 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}]`); } } diff --git a/src/features/tikz/renderer.ts b/src/features/tikz/renderer.ts index 49f33ae..3071bbe 100644 --- a/src/features/tikz/renderer.ts +++ b/src/features/tikz/renderer.ts @@ -48,15 +48,7 @@ export class TikzRenderer { public async render(source: string): Promise { 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() { diff --git a/src/features/tikz/tikzjax/loader.ts b/src/features/tikz/tikzjax/loader.ts index d6d19f3..c12763f 100644 --- a/src/features/tikz/tikzjax/loader.ts +++ b/src/features/tikz/tikzjax/loader.ts @@ -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(//g) || []).length; @@ -58,8 +55,11 @@ class CustomHTMLMachine extends HTMLMachine { `` ); - 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; } diff --git a/src/styles/main.css b/src/styles/main.css index 30386d6..d4bff0c 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -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; } diff --git a/test_helpers/tikz-codec.test.ts b/test_helpers/tikz-codec.test.ts new file mode 100644 index 0000000..73d035a --- /dev/null +++ b/test_helpers/tikz-codec.test.ts @@ -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'); + }); +});