feat(diagrams): add managed CircuitikZ runtime and fix history scrolling

Add an optional ownership-scoped Tectonic environment with secure installation, native compile verification, and topology-preserving repair support. Harden CircuitikZ generation and preview routing, fix the history drawer scroll model, and synchronize bilingual architecture, user guidance, and localized README documentation.
This commit is contained in:
aliyun1121003339 2026-07-20 03:49:24 +08:00
parent 3e47cc5ef6
commit bac0fb8dc1
89 changed files with 6779 additions and 313 deletions

2
.gitignore vendored
View file

@ -33,7 +33,9 @@ ref
# local git worktrees
.worktrees
.cache
.tmp/
.tmp_repo_saga_tool
scripts/.tmp-*
# local trellis workflow state
.trellis/

View file

@ -268,6 +268,7 @@ Important newer settings include:
- **Diagram history retention limit**: limits the Vault history index; pruning never deletes generated files.
- **Diagram preview export PPI**: controls PNG and PDF clarity while SVG remains vector-based.
- **Preferred diagram type / source format**: separates diagram meaning from Mermaid, Draw.io, Drawnix, CircuitikZ, and other editable source formats.
- **CircuitikZ native compile environment (optional)**: checks an existing `tectonic` or `pdflatex` installation, or installs a pinned managed Tectonic runtime after explicit confirmation. SVG, PNG, and preview PDF export continue to work without LaTeX.
- **Advanced batch file selection**: enables saved folder/file-selection profiles and rule previews.
- **Automatically create missing batch target folders**: available after the user approves creation once. Existing non-empty folders still require one confirmation before each batch run.
- **Developer mode**: reveals provider diagnostics and advanced file-selection tools intended for troubleshooting.
@ -276,6 +277,10 @@ Important newer settings include:
Completed diagram previews are indexed at Vault scope and ordered by completion time, newest first. The preview keeps the diagram canvas primary: one **Export** menu provides SVG, PNG, and PDF, while **History** opens the searchable index in a right drawer without closing the preview. The same history view is available from the command palette (`Notemd: Open diagram history`) and sidebar even when no note is active. Search by title, note, diagram type, source format, or export format and browse results in pages of 20. History stores lightweight metadata and artifact paths rather than duplicating SVG, PNG, PDF, or source contents. Removing a history entry does not delete its generated files.
### Optional CircuitikZ Native Compilation
Notemd does not embed a complete TeX distribution in the plugin bundle. On desktop, open **CircuitikZ native compile environment** from diagram settings or the command palette to reuse a system `tectonic`/`pdflatex` compiler, choose a custom executable, or explicitly install/repair the pinned managed Tectonic runtime. The environment panel shows readiness, download progress, install path, bounded logs, and removal controls. Mobile and users who skip installation keep dependency-free preview plus SVG, PNG, and preview PDF export.
### Safe Batch Folder Handling
Before a title-generation batch starts, Notemd validates the selected folder once:

View file

@ -724,14 +724,15 @@ _تم آخر تحديث للإصدار ذي الوسم `1.9.3` في 2026-06-24.
</a>
<!-- notemd-settings-discovery-guide -->
## اكتشاف الإعدادات، تاريخ الرسوم البيانية، ومجلدات المجموعات الآمنة
## اكتشاف الإعدادات، تاريخ الرسوم البيانية، CircuitikZ، ومجلدات المجموعات الآمنة
توفر صفحة إعدادات Notemd خاصية البحث التقريبي، وتنقل سريع بين الفئات الكبيرة، بالإضافة إلى قائمة المفضلات لكل إعداد مخزنة للخزانة الحالية.
توفر صفحة إعدادات Notemd خاصية البحث التقريبي، وتنقل سريع بين الفئات الكبيرة، بالإضافة إلى إمكانية حفظ المفضلات لكل إعداد داخل “Vault” الحالي.
- يتم تخزين تاريخ الرسوم البيانية على مستوى الخزانة، مرتبًا من الأحدث إلى الأقدم، ويمكن البحث فيه وعرضه عبر صفحات بعدد 20 رسمًا لكل صفحة. إزالة سجل من التاريخ لا يؤدي إلى حذف الملفات التي تم إنشاؤها.
- يتحكم خيار دقة البكسل (PPI) عند تصدير عرض مسبق للرسوم البيانية في وضوح ملفات PNG وPDF، بينما تظل ملفات SVG قائمة على البيانات المتجهة.
- يتم تخزين تاريخ الرسوم البيانية على مستوى “Vault”، مرتبًا من الأحدث إلى الأقدم، ويمكن البحث فيه وعرضه عبر صفحات بحجم 20 رسمًا في كل صفحة. إزالة سجل من التاريخ لا يؤدي إلى حذف الملفات التي تم إنشاؤها.
- يتحكم خيار تصدير معاينة الرسم البياني في دقة ملفات PNG وPDF، بينما يظل تنسيق SVG قائمًا على البيانات المتجهة.
- يتم اختيار نوع الرسم البياني المفضل وتنسيق المصدر المفضل بشكل منفصل.
- تتيح خاصية اختيار ملفات المجموعات المتقدمة استخدام ملفات تعريف الاختيار المحفوظة وعروض تجريبية للقواعد.
- يمكن إنشاء مجلد هدف للمجموعات غير الموجود بعد التأكيد، مع خيار تفعيل تذكر عملية الإنشاء التلقائي للمجلدات المفقودة في المستقبل.
- يمكن لمستخدمي سطح المكتب فتح بيئة التجميع الأصلية CircuitikZ الاختيارية لإعادة استخدام أدوات Tectonic/pdflatex الموجودة في النظام، أو اختيار مُجمّع مخصص، أو تثبيت بيئة تشغيل Tectonic المُدارة بشكل صريح. لا يتطلب تصدير المعاينات أو ملفات SVG أو PNG أو PDF المُصدرة استخدام لغة LaTeX.
- تتيح خاصية اختيار ملفات المجموعات المتقدمة استخدام ملفات التهيئة المحفوظة وعرض نماذج القواعد مسبقًا.
- يمكن إنشاء مجلد الوجهة للمجموعات الغائب بعد التأكيد، مع خيار تفعيل حفظ هذا الإنشاء التلقائي للمجلدات الغائبة في المستقبل.
- يتطلب وجود مجلد غير فارغ موافقة واحدة فقط قبل معالجة المجموعة بأكملها، وليس موافقة واحدة لكل ملف يتم إنشاؤه.
- يكشف وضع المطور عن أدوات تشخيص المزود وأدوات متقدمة لحل المشكلات.
- يكشف وضع المطور عن أدوات التشخيص الخاصة بالمزود وأدوات معالجة المشكلات المتقدمة.

View file

@ -712,14 +712,15 @@ _সর্বশেষ `1.9.3` রিলিজ ট্যাগের জন্
</a>
<!-- notemd-settings-discovery-guide -->
## সেটিংস আবিষ্কার, ডায়াগ্রামের ইতিহাস, এবং নিরাপদ ব্যাচ ফোল্ডারসমূহ
## সেটিংস আবিষ্কার, ডায়াগ্রামের ইতিহাস, CircuitikZ, এবং নিরাপদ ব্যাচ ফোল্ডারসমূহ
Notemd-এর সেটিংস পৃষ্ঠাটি ফাজি সার্চ, বৃহৎ ক্যাটাগরি নেভিগেশন, এবং বর্তমান Vault-এর জন্য প্রতিটি সেটিংসের জন্য ফেভারিটগুলো সংরক্ষণ করার সুবিধা দেয়।
Notemd-এর সেটিংস পেজটি ফাজি সার্চ, বৃহৎ ক্যাটাগরি নেভিগেশন, এবং বর্তমান Vault-এর জন্য প্রতিটি সেটিংসের ফেভারিটস সংরক্ষণের সুবিধা দেয়।
- ডায়াগ্রামের ইতিহাস Vault-এর স্কোপে সংরক্ষিত থাকে; সর্বনবীনতম থেকে শুরু করে সাজানো থাকে, এবং ২০টি করে গ্রুপে অনুসন্ধান ও পেজিনেশন করা যায়। ইতিহাসের কোনো রেকর্ড মুছলেও তৈরি হওয়া ফাইলগুলো মুছে যায় না।
- ডায়াগ্রাম প্রিভিউ এক্সপোর্টের PPI ফিচারটি PNG ও PDF-এর স্পষ্টতা নিয়ন্ত্রণ করে। SVG ফরম্যাটটি ভেক্টর-ভিত্তিকই থাকে।
- “পছন্দের ডায়াগ্রাম ধরণ” এবং “পছন্দের সোর্স ফরম্যাট” হলো আলাদা দুটি বিকল্প।
- অ্যাডভান্সড ব্যাচ ফাইল সিলেকশন ফিচারটি সংরক্ষিত সিলেকশন প্রোফাইল এবং রুল প্রিভিউগুলো ব্যবহার করার সুবিধা দেয়।
- “পছন্দের ডায়াগ্রাম টাইপ” এবং “পছন্দের সোর্স ফরম্যাট” হলো আলাদা দুটি বিকল্প।
- ডেস্কটপ ব্যবহারকারীরা ঐচ্ছিক CircuitikZ নেটিভ কম্পাইল এনভায়রনমেন্ট খুলে সিস্টেমের Tectonic/pdflatex ব্যবহার করতে পারেন, নিজস্ব কম্পাইলার নির্বাচন করতে পারেন, অথবা নির্দিষ্টভাবে পিন করা Tectonic রানটাইমটি ইনস্টল করতে পারেন। প্রিভিউ, SVG, PNG, এবং PDF এক্সপোর্টের জন্য LaTeX-এর কোনো প্রয়োজন নেই।
- উন্নত ব্যাচ ফাইল নির্বাচনের ফিচারটি সংরক্ষিত সিলেকশন প্রোফাইল ও রুল প্রিভিউগুলো ব্যবহার করার সুবিধা দেয়।
- কোনো ব্যাচ টার্গেট ফোল্ডার না থাকলে নিশ্চিত হওয়ার পর সেটি তৈরি করা যায়; ভবিষ্যতে আরও কোনো ফোল্ডার না থাকলে স্বয়ংক্রিয়ভাবে এটি তৈরি করার অপশনও রয়েছে।
- ইতিমধ্যে থাকা এবং খালি নয় এমন কোনো ফোল্ডারের জন্য পুরো ব্যাচটি সংরক্ষণ করার আগে একবার নিশ্চিত হতে হয়; প্রতিটি ফাইলের জন্য আলাদাভাবে নিশ্চিত হওয়ার দরকার নেই।
- ডেভেলপার মোডটি প্রোভাইডার ডায়াগনস্টিক্স এবং অ্যাডভান্সড ট্রাবলশুটিং কন্ট্রোলগুলো দেখায়।
- “ডেভেলপার মোড”-এ প্রোভাইডার সংক্রান্ত ডায়াগনস্টিক্স এবং উন্নত ট্রাবলশুটিং কন্ট্রোলসমূহ দেখা যায়।

View file

@ -716,14 +716,15 @@ _Naposledy obnoveno pro tag vydání `1.9.3` dne 2026-06-24. Datum posledního c
</a>
<!-- notemd-settings-discovery-guide -->
## Objevování nastavení, historie diagramů a bezpečné složky pro hromadnou práci
## Objevování nastavení, historie diagramů, CircuitikZ a bezpečné složky pro hromadnou práci
Stránka s nastaveními Notemd nabízí fuzzy vyhledávání, navigaci v rozsáhlých kategoriích a možnost uložit si oblíbená nastavení pro aktuální Vault.
- Historie diagramů je ukládána na úrovni Vault, seřazena od nejnovějších po starší, je vyhledatelná a stránkována ve skupinách po 20 položkách. Odstranění záznamu z historie neodstraňuje vytvořené soubory.
- Export náhledu diagramu pomocí PPI ovlivňuje ostrost souborů ve formátech PNG a PDF. SVG zůstává vektorovým formátem.
- Preferovaný typ diagramu a preferovaný zdrojový formát jsou samostatné volby.
- Pokročilá funkce výběru souborů do skupin umožňuje použít uložené profily výběru a náhled pravidel.
- Chybějící cílovou složku pro hromadnou práci lze po potvrzení vytvořit, přičemž existuje možnost zapamatovat si automatické vytváření pro budoucí chybějící složky.
- U již existující neprázdné složky je vyžadována jedna potvrzení před zpracováním celé skupiny souborů, nikoli jedna potvrzení pro každý vytvořený soubor.
- Historie diagramů je ukládána na úrovni Vault, seřazena od nejnovějších k nejstarším, umožňuje vyhledávání a je rozdělena do skupin po 20 položkách. Odstranění záznamu z historie neodstraní vytvořené soubory.
- Export náhledu diagramu pomocí PPI ovlivňuje kvalitu obrázků ve formátech PNG a PDF. SVG zůstává vektorovým formátem.
- Preferovaný typ diagramu a preferovaný zdrojový formát jsou volby oddělené od sebe.
- Uživatelé na desktopu mohou otevřít volitelné nativní prostředí kompilace CircuitikZ, aby znovu využili systémové nástroje Tectonic/pdflatex, vybrali si vlastní kompilátor nebo explicitně nainstalovali spravovaný běh Tectonic. Pro výstupy ve formátech náhledu, SVG, PNG a PDF není potřeba LaTeX.
- Pokročilá volba souborů pro hromadnou práci umožňuje použití uložených profilů výběru a náhled pravidel.
- Chybějící cílovou složku pro hromadnou práci lze vytvořit po potvrzení, přičemž existuje možnost zapamatovat si automatické vytváření takových složek i v budoucnu.
- Pro již existující neprázdnou složku je vyžadováno jedno potvrzení před zpracováním celé skupiny souborů, nikoli jedno potvrzení pro každý vytvořený soubor.
- Režim vývojáře odhaluje diagnostické informace poskytovatele a pokročilé nástroje na řešení problémů.

View file

@ -717,14 +717,15 @@ _Senest opdateret for udgivelsestagget `1.9.3` den 2026-06-24. Seneste commit-da
</a>
<!-- notemd-settings-discovery-guide -->
## Opdagelse af indstillinger, historik over diagrammer og sikre batch-mapper
## Opdagelse af indstillinger, diagramhistorik, CircuitikZ og sikre batch-mapper
Notemds indstillingsside tilbyder uskydigt søgning, navigation gennem store kategorier samt favoriter for hver indstilling, som gemmes for den aktuelle Vault.
- Historikken over diagrammer gemmes på Vault-nivå, sorteret fra nyeste til ældste, og kan søges samt pagineres i grupper af 20. At fjerne en historiepost sletter ikke de genererede filer.
- Export af diagrampreview-stil kontrollerer klarheden i PNG- og PDF-filer. SVG forbliver vektorbaseret.
- Favorencert diagramtyp og favorencert kildemålformat er to separate valgmuligheder.
- Avanceret valg af batch-filer muliggør at bruge gemte valgprofiler og forudvisninger af regler.
- En fejlende målmappe for batch-opgaver kan skrives til efter bekræftelse, med mulighed for at huske den automatiske oprettelse for fremtidige fejlende mapper.
- Diagramhistorik gemmes på Vault-niveau, sorteret fra nyeste til ældste, og er søgbart samt pagineret i grupper af 20. At fjerne en historikshode sletter ikke de genererede filer.
- Export af diagrampreview-stillinger kontrollerer klarheden i PNG- og PDF-filer. SVG forbliver vektorbaseret.
- Favorittypen for diagram og favoritformatet for kildefiler er to separate valgmuligheder.
- Desktop-brugere kan åbne det valgfrie CircuitikZ-native kompileringsmiljø for at bruge systemets Tectonic/pdflatex igen, vælge en egen kompilator eller installere eksplisit den pinnete, managede Tectonic-runtime. Preview-, SVG-, PNG- og PDF-preview-exporter kræver ikke LaTeX.
- Avanceret valg af batch-filer muliggør at bruge gemte valgprofiler og regelpreviewer.
- En fejlende batch-målmappe kan skrives til efter bekræftelse, med mulighed for at huske den automatiske oprettelse for fremtidige fejlende mapper.
- En eksisterende, ikke tom mappe kræver én bekræftelse før hele batchen, aldrig en bekræftelse per genereret fil.
- Udviklermodus viser udbyderens diagnostik og avancerede kontroller til fejlfinding.
- Udviklermode viser udbyderens diagnostik og avancerede løsningsmuligheder for problemer.

View file

@ -724,14 +724,15 @@ _Zuletzt für das Release-Tag `1.9.3` am 2026-06-24 aktualisiert. Datum des letz
</a>
<!-- notemd-settings-discovery-guide -->
## Entdeckung von Einstellungen, Diagrammhistorie und sichere Batch-Ordnern
## Entdeckung von Einstellungen, Diagrammhistorie, CircuitikZ sowie sichere Batch-Ordner
Die Einstellungsseite von Notemd bietet eine ungenaue Suche, eine Navigation durch umfangreiche Kategorien sowie Favoriten pro Einstellung, die für den aktuellen Vault gespeichert werden.
- Die Diagrammhistorie wird im Scope des Vaults gespeichert, chronologisch nach dem Neuesten geordnet, suchbar und in Gruppen von 20 Einträgen paginiert. Das Löschen eines Historie-Eintrags löscht die generierten Dateien nicht.
- Die Exportfunktion zur Vorschau von Diagrammen ermöglicht es, über PPI die Klarheit von PNG- und PDF-Dateien zu steuern. SVG bleibt weiterhin vektorbasiert.
- Die Diagrammhistorie wird im Scope des Vaults gespeichert, chronologisch nach dem Neuesten geordnet, suchbar und in Gruppen zu 20 Einträgen paginiert. Das Löschen eines Historie-Eintrags löscht die generierten Dateien nicht.
- Die Exportfunktion zur Vorschau von Diagrammen ermöglicht es über die Einstellung PPI die Klarheit von PNG- und PDF-Dateien zu steuern. SVG bleibt weiterhin vektorbasiert.
- Der bevorzugte Diagrammtyp sowie das bevorzugte Quellformat werden als getrennte Auswahlmöglichkeiten angeboten.
- Die erweiterte Auswahl von Batch-Dateien ermöglicht das Speichern von Auswahlprofilen sowie Vorschauen von Regeln.
- Ein fehlender Batch-Zielordner kann nach Bestätigung erstellt werden; es besteht außerdem die Option, die automatische Erstellung für zukünftig fehlende Ordnner zu merken.
- Für einen bereits vorhandenen, nicht leeren Ordner ist eine Bestätigung für den gesamten Batch erforderlich niemals eine Bestätigung pro generierter Datei.
- Desktop-Nutzer können die optional verfügbare, native Compile-Umgebung von CircuitikZ öffnen, um das SystemTectonic/pdflatex erneut zu nutzen, einen eigenen Compiler auszuwählen oder explizit die festgelegte, verwaltete TectonicLaufzeitumgebung zu installieren. Für Vorschauen, SVG-, PNG- sowie PDF-Exporte ist LaTeX nicht erforderlich.
- Die erweiterte Auswahl von Batch-Dateien ermöglicht das Verwenden gespeicherter Auswahlprofile sowie die Anzeige von Regelvorschauen.
- Ein fehlender Batch-Zielordner kann nach Bestätigung erstellt werden; es besteht außerdem die Option, künftig automatisch solche fehlenden Ordner zu erstellen.
- Für einen bereits vorhandenen, nicht leeren Ordner ist nur eine einzige Bestätigung für den gesamten Batch erforderlich niemals eine Bestätigung pro generierter Datei.
- Im Entwicklermodus werden Diagnoseinformationen des Anbieters sowie erweiterte Hilfsmittel zur Fehlerbehebung angezeigt.

View file

@ -712,14 +712,15 @@ _Τελευταία ανανέωση για την ετικέτα έκδοσης
</a>
<!-- notemd-settings-discovery-guide -->
## Ανακάλυψη ρυθμίσεων, ιστορικό διαγράμματων και ασφαλείς φάκελοι σε μαζική επεξεργασία
## Ανακάλυψη ρυθμίσεων, ιστορικό διαγράμματος, CircuitikZ και ασφαλείς φάκελοι σε σειρές
Η σελίδα ρυθμίσεων του Notemd προσφέρει αναζήτηση με ασαφείς κλειδιών, πλοήγηση σε μεγάλες κατηγορίες, καθώς και αγαπημένες ρυθμίσεις για κάθε συγκεκριμένο Vault που αποθηκεύονται.
Η σελίδα ρυθμίσεων του Notemd προσφέρει αναζήτηση με άμεση αντίστοιχη αντίστοιχη λέξη, πλοήγηση σε μεγάλες κατηγορίες, καθώς και αγαπημένες ρυθμίσεις για κάθε συγκεκριμένη ρύθμιση που αποθηκεύονται για το τρέχον Vault.
- Το ιστορικό διαγράμματων αποθηκεύεται σε επίπεδο του Vault, με τα πιο πρόσφατα να εμφανίζονται πρώτα. Μπορεί να αναζητηθεί και να διατελείται σε σελίδες, με 20 διαγράμματα ανά σελίδα. Η αφαίρεση ενός ημερολογιακού εγγράφου δεν διαγράφει τα δημιουργημένα αρχεία.
- Η εξαγωγή προβολής διαγράμματος επιτρέπει τον έλεγχο της καθαρότητας των αρχείων PNG και PDF, με βάση το PPI. Τα αρχεία SVG παραμένουν βεκτόρια.
- Ο επιθυμητός τύπος διαγράμματος και ο επιθυμητός μορφάτισμος πηγής είναι διαχωριστές επιλογές.
- Η προηγμένη επιλογή αρχείων σε μαζική επεξεργασία επιτρέπει τη χρήση αποθηκευμένων προφίλ επιλογών και προβολές κανόνων.
- Ένας λείπων φάκελος στοχεύσεως μαζικής επεξεργασίας μπορεί να δημιουργηθεί μετά από επιβεβαίωση, με τη δυνατότητα να θυμηθεί η αυτόματη δημιουργία για μελλοντικούς λείποντες φάκελους.
- Για έναν υπάρχοντα, μη κενό φάκελο απαιτείται μία επιβεβαίωση για ολόκληρη τη μαζική επεξεργασία, όχι μία επιβεβαίωση για κάθε δημιουργημένο αρχείο.
- Ο λειτουργικός τρόπος προγραμματιστή αποκαλύπτει διαγνωστικά στοιχεία του πάροχου και προηγμένα εργαλεία λύσης προβλημάτων.
- Το ιστορικό διαγράμματος αποθηκεύεται σε επίπεδο Vault, με τα πιο πρόσφατα να εμφανίζονται πρώτα. Μπορεί να αναζητηθεί και να διατελείται σε σελίδες, με 20 διαγράμματα ανά σελίδα. Η αφαίρεση ενός ημερολογιακού εγγράφου δεν διαγράφει τα δημιουργημένα αρχεία.
- Η εξαγωγή προβολής διαγράμματος με ρυθμίσεις PPI ελέγχει την αναλυτικότητα των αρχείων PNG και PDF. Τα αρχεία SVG παραμένουν βασισμένα σε διανύσματα.
- Ο προτιμώμενος τύπος διαγράμματος και ο προτιμώμενος μορφότυπος πηγής είναι διαχωριστές επιλογές.
- Οι χρήστες desktop μπορούν να ανοίξουν το προαιρετικό περιβάλλον κατασκευής CircuitikZ για να επαναχρησιμοποιήσουν το συστηματικό Tectonic/pdflatex, να επιλέξουν έναν προσαρμοσμένο μεταγλωττιστή ή να εγκαταστήσουν ρητά το διαχειριζόμενο Tectonic runtime. Οι εξαγωγές προβολής, SVG, PNG και PDF δεν απαιτούν LaTeX.
- Η προηγμένη επιλογή αρχείων σε σειρές επιτρέπει τη χρήση αποθηκευμένων προφίλ επιλογών και προβολές κανόνων.
- Ένας απουσιάζοντας φάκελος στόχου για σειρές μπορεί να δημιουργηθεί μετά από επιβεβαίωση, με τη δυνατότητα να θυμηθεί η αυτόματη δημιουργία για μελλοντικούς απουσιάζοντες φάκελους.
- Για έναν υπάρχοντα, μη κενό φάκελο απαιτείται μία επιβεβαίωση πριν από την επεξεργασία ολόκληρης της σειράς, όχι μία επιβεβαίωση για κάθε δημιουργημένο αρχείο.
- Ο λειτουργικός τρόπος προβολής αποκαλύπτει διαγνωστικά στοιχεία του πάροχου και προηγμένα εργαλεία λύσης προβλημάτων.

View file

@ -722,14 +722,15 @@ _Actualizado por última vez para la etiqueta de lanzamiento `1.9.3` el 2026-06-
</a>
<!-- notemd-settings-discovery-guide -->
## Descubrimiento de configuraciones, historial de diagramas y carpetas por lotes seguras
## Descubrimiento de configuraciones, historial de diagramas, CircuitikZ y carpetas de procesamiento por lotes seguras
La página de configuraciones de Notemd ofrece búsquedas aproximadas, navegación por grandes categorías y favoritos por configuración guardados para el Vault actual.
La página de configuraciones de Notemd ofrece búsqueda difusa, navegación por grandes categorías y favoritos por configuración almacenados para el Vault actual.
- El historial de diagramas se almacena a nivel del Vault, ordenado de más reciente a más antiguo, y es posible buscarlo y paginarlo en grupos de 20 elementos. Eliminar un registro del historial no elimina los archivos generados.
- El historial de diagramas se almacena a nivel del Vault, ordenado de más reciente a más antiguo, y es posible buscarlo y visualizarlo en páginas de 20 elementos cada una. Eliminar un registro del historial no elimina los archivos generados.
- Los controles de PPI para la exportación de vistas previas de diagramas regulan la claridad de los archivos PNG y PDF. El formato SVG sigue siendo vectorial.
- El tipo de diagrama preferido y el formato de origen preferido son opciones independientes.
- Los usuarios de escritorio pueden abrir el entorno de compilación nativo opcional CircuitikZ para reutilizar Tectonic/pdflatex del sistema, seleccionar un compilador personalizado o instalar explícitamente la versión gestionada de Tectonic. Las exportaciones de vista previa, SVG, PNG y PDF no requieren LaTeX.
- La selección avanzada de archivos por lotes permite utilizar perfiles de selección guardados y vistas previas de reglas.
- Se puede crear una carpeta de destino para lotes que falte tras confirmar, con la opción de recordar la creación automática para futuras carpetas faltantes.
- Una carpeta existente y no vacía requiere una confirmación antes de procesar todo el lote; nunca se necesita una confirmación por cada archivo generado.
- Se puede crear una carpeta de destino para el procesamiento por lotes que falte tras confirmar, con la opción de recordar esta creación automática para futuras carpetas faltantes.
- Para una carpeta existente y no vacía se necesita una confirmación antes de procesar todo el lote, nunca una confirmación por cada archivo generado.
- El modo desarrollador muestra diagnósticos del proveedor y controles avanzados para solución de problemas.

View file

@ -715,14 +715,15 @@ _Päivitetty viimeksi julkaisutägille `1.9.3` 2026-06-24. Viimeisimmän commiti
</a>
<!-- notemd-settings-discovery-guide -->
## Asetuksien löytö, diagrammien historia ja turvalliset paketitaskujen kaustat
## Asetusten avastaminen, diagrammien historia, CircuitikZ ja turvalliset paketitaskujen kaustat
Notemd:n asetukset-sivu tarjoaa hämärtöisen hakun, laajan kategorian navigoinnin sekä aktiivisen Vaultin asetuksille suunnattuja lemmikkäitä.
Notemd:n asetusten sivu tarjoaa hämävyysotsintaa, laajaa kategorian navigointia sekä aktiivisen Vaultin asetuksille suunnattuja lemmikkäitä.
- Diagrammien historia tallennetaan Vaultin tasolla, jossa se on järjestetty uusimmista alhaampaan. Se on hakettavaa ja näytetään 20:stä ryhmistä. Historian kirjan poistaminen ei poista luotuja failia.
- Diagrammin esikatselun eksportointi määrittelee PNG- ja PDF-faileiden selkeyden; SVG-järjestelmä jää siirtolähtöiseeksi.
- Ehdotuun diagrammityypille ja ehdotuun lähteenormiin tehdään erilliset valinnat.
- Edistynyt paketitaskujen valinta mahdollistaa salvestettuja valintaprofiileja sekä sääntöjen esikatseluita.
- Puuttuv paketitaskun sihtokausta voi luoda vahvistuksen jälkeen, ja on mahdollisuus muistaa automaattisen luomisen tuleviin puutuville kaustille.
- Olemassa oleva, tyhjä ei ole kokonaisen paketin käyttö edellyttää yhtä vahvistusta, ei yhtä vahvistusta iga luotunnan failin jälkeen.
- Kehittäjärežiimi paljastaa tarjoajan diagnostikan sekä edistyneet vianohjelmistotulokset.
- Diagrammien historia tallennetaan Vaultin tasolla, jossa se on järjestetty uusimmista alhaammin. Se on hakettavaa ja näytetään 20:stä ryhmistä. Historian kirjan kustannuksella genereerit tiedostot ei poisteta.
- Diagrammin esikatselun eksportointi PPI-määrittelyllä kontrolloi PNG- ja PDF-tiedostojen selkeyttä. SVG-järjestelmät jäävät vektoripohiseiksi.
- Ehdotuun diagrammityypille ja ehdotuun lähteenvormatteluun tehdään erilliset valinnat.
- Tietokonekayttajat voivat avata valintaista CircuitikZ:n omat kompilaatiopimistot, jotta voivat käyttää järjestelmän Tectonic/pdflatex-tuotteita, valita omat kompilaattorit tai asentaa suoraan hallitsevan Tectonic-järjestelmän. Esikatselut, SVG-, PNG- ja PDF-esikatselut eksportoidaan ilman LaTeXia.
- Edistyneet paketitaskujen tiedostojen valintavaihtoehto mahdollistaa salattujen valintaprofiilien ja sääntöjen esikatselun.
- Puuttuv paketitaskujen sihtokausta voi luoda vahvistuksen jälkeen, ja on mahdollisuus muistaa automaattisen luomisen tuleviin puutteviin kaustoihin.
- Olemassa oleva, tyhjä ei ole täysin kausta vaatii yhtä vahvistusta koko paketitaskun suhteen, ei yhtä vahvistusta jokaisen genereerituun tiedostoon.
- Kehittäjärežiimi paljastaa tarjoajien diagnostiikan sekä edistyneet vianohjelmistovaihtoehtoja.

View file

@ -723,14 +723,15 @@ _Dernière actualisation pour le tag de publication `1.9.3` le 2026-06-24. Date
</a>
<!-- notemd-settings-discovery-guide -->
## Découverte des paramètres, historique des diagrammes et dossiers de lot sécurisés
## Découverte des paramètres, historique des diagrammes, CircuitikZ et dossiers de lot sécurisés
La page des paramètres de Notemd propose une recherche floue, une navigation par grandes catégories, ainsi que des favoris par paramètre stockés pour le Vault actuel.
- Lhistorique des diagrammes est conservé au niveau du Vault, classé du plus récent au plus ancien, et peut être recherché et paginé par groupes de 20 éléments. La suppression dun enregistrement de lhistorique ne supprime pas les fichiers générés.
- Lexport de la prévisualisation du diagramme permet de contrôler la résolution (PPI) des fichiers PNG et PDF. Les fichiers SVG restent vectoriels.
- Les paramètres de résolution PPI pour lexportation en aperçu du diagramme contrôlent la qualité des fichiers PNG et PDF. Lformat SVG reste vectoriel.
- Le type de diagramme préféré et le format source préféré sont deux choix distincts.
- La sélection avancée des fichiers de lot permet dutiliser des profils de sélection enregistrés ainsi que des prévisualisations de règles.
- Les utilisateurs de bureau peuvent ouvrir lenvironnement de compilation natif CircuitikZ (optionnel) afin de réutiliser Tectonic/pdflatex du système, de sélectionner un compilateur personnalisé, ou dinstaller explicitement le runtime Tectonic géré. Les exports en aperçu, SVG, PNG et PDF nexigent pas LaTeX.
- La sélection avancée des fichiers de lot permet dutiliser des profils de sélection enregistrés ainsi que des aperçus de règles.
- Un dossier cible de lot manquant peut être créé après confirmation, avec la possibilité de mémoriser cette création automatique pour les dossiers manquants ultérieurs.
- Un dossier existant et non vide nécessite une seule confirmation pour lensemble du lot, et jamais une confirmation par fichier généré.
- Le mode développeur affiche des diagnostics sur le fournisseur ainsi que des outils avancés de dépannage.
- Le mode développeur affiche des diagnostics sur le fournisseur ainsi que des outils avancés pour la résolution des problèmes.

View file

@ -712,14 +712,15 @@ _רוענן לאחרונה עבור תג השחרור `1.9.3` בתאריך 2026-
</a>
<!-- notemd-settings-discovery-guide -->
## גילוי הגדרות, היסטוריית תרשימים ותיקיות בצורה בטוחה
## גילוי הגדרות, היסטוריית תרשימים, CircuitikZ ותיקיות בצורה בטוחה
דף ההגדרות של Notemd מציע חיפוש עמום, ניווט בין קטגוריות רבות, ואפשרות לשמור העדפות לכל הגדרה ספציפית עבור הVault הנוכחי.
דף ההגדרות של Notemd מציע חיפוש עמום, ניווט בין קטגוריות רבות, ואפשרות לשמור העדפות לכל הגדרה בנפרד עבור הVault הנוכחי.
- ההיסטורייה של התרשימים נשמרת ברמת הVault, ממוינת לפי תאריך החדשים ביותר קודם, ניתן לחפש בה ולעבור בין עמודות של 20 רשומות. מחיקת רשומת היסטוריה אינה ממחקת את הקבצים שנוצרו.
- יצוא תצוגת מבחן של התרשים מאפשר שליטה ברמת הדיוק PPI של הPNG והPDF; הSVG נשאר בבסיס וקטורי.
- ההיסטורייה של התרשימים נשמרת ברמת הVault, ממוינת לפי תאריך החדשים ביותר קודם, ניתן לחפש בה ולעבור בין קבוצות של 20 רשומות. מחיקת רשומת היסטוריה אינה ממחקת את הקבצים שנוצרו.
- יצוא תמונת מקדימה של התרשים מאפשר שליטה ברמת הדיוק PPI של הPNG והPDF; הSVG נשאר בבסיס וקטורי.
- סוג התרשים המועדף ופורמט המקור המועדף הם בחירות נפרדות.
- אפשרות בחירה מתקדמת של קבצי בצורה מאפשרת שמירה של פרופילי בחירה ותצוגת מבחן של חוקים.
- ניתן ליצור תיקיית יעד לבצורה לאחר אישור, עם אפשרות לזכור את היצירה האוטומטית עבור תיקיות חסרות בעתיד.
- משתמשי מחשב נייד יכולים לפתוח את סביבת ההרכבה המובנית CircuitikZ האופציונלית, כדי להשתמש שוב בTectonic/pdflatex של המערכת, לבחור מרכיב מותאם אישית, או להתקין באופן מפורש את סביבת הריצה Tectonic המנוהלת. יצוא של תמונות מקדימה, SVG, PNG וPDF אינו דורש שימוש בLaTeX.
- אפשרות בחירת קבצי בצורה מתקדמת מאפשרת שמירה של פרופילי בחירה והצגת תצוגות של חוקים.
- ניתן ליצור תיקיית יעד לבצורה אחרי אישור, ויש אפשרות לזכור את היצירה האוטומטית עבור תיקיות חסרות בעתיד.
- תיקייה קיימת שאינה ריקה דורשת אישור אחד לפני עיבוד כל הקבצים בבצורה, ולא אישור נפרד עבור כל קובץ שנוצר.
- מצב המפתח מגלה נתוני אבחון של הספק ושליטות מתקדמות לפתרון בעיות.
- מצב המפתחים מגלה מידע אבחוני של הספק ואפשרויות טיפול בבעיות מתקדמות.

View file

@ -712,14 +712,15 @@ _इसे रिलीज़ टैग `1.9.3` के लिए 2026-06-24 क
</a>
<!-- notemd-settings-discovery-guide -->
## सेटिंग्स की खोज, डायग्रामों का इतिहास, एवं सुरक्षित बैच फोल्डर्स
## सेटिंग्स की खोज, डायग्रामों का इतिहास, CircuitikZ, एवं सुरक्षित बैच फोल्डर्स
Notemd के सेटिंग्स पेज में फजी सर्च, बड़ी श्रेणियों का नेविगेशन, एवं वर्तमान Vault के लिए प्रत्येक सेटिंग के लिए “पसंदीदा” विकल्प संग्रहीत होते हैं।
Notemd के सेटिंग्स पृष्ठ में फजी सर्च, बड़ी श्रेणियों का नेविगेशन, एवं वर्तमान Vault के लिए प्रत्येक सेटिंग के लिए “पसंदीदा” विकल्प संग्रहीत होते हैं।
- डायग्रामों का इतिहास Vault स्तर पर संग्रहीत होता है; यह नवीनतम से पुराने क्रम में होता है, एवं 20 डायग्रामों के समूहों में खोजा एवं पृष्ठीकृत किया जा सकता है। किसी इतिहास रिकॉर्ड को हटाने से बनाए गए फ़ाइलें नष्ट नहीं होतीं।
- डायग्राम प्रीव्यू को निर्यात करते समय PPI विकल्प PNG एवं PDF फ़ाइलों की स्पष्टता को नियंत्रित करता है; SVG फ़ाइलें हमेशा वेक्टर-आधारित ही रहती हैं।
- “पसंदीदा डायग्राम प्रकार” एवं “पसंदीदा स्रोत फॉर्मेट” दो अलग-अलग विकल्प हैं।
- उन्नत बैच फ़ाइल चयन सुविधा के द्वारा सहेजे गए चयन प्रोफ़ाइल एवं नियमों के प्रीव्यू उपलब्ध होते हैं।
- डायग्रामों का इतिहास Vault स्तर पर संग्रहीत होता है; यह नवीनतम से पुराने क्रम में होता है, एवं 20 डायग्रामों के समूहों में खोजा एवं पृष्ठीकृत किया जा सकता है। इतिहास से किसी रिकॉर्ड को हटाने से बनाए गए फ़ाइलें नष्ट नहीं होतीं।
- डायग्राम प्रीव्यू के निर्यात में PPI विकल्प PNG एवं PDF फ़ाइलों की स्पष्टता को नियंत्रित करता है; SVG फ़ाइलें हमेशा वेक्टर-आधारित ही रहती हैं।
- “पसंदीदा डायग्राम प्रकार” एवं “पसंदीदा स्रोत फ़ॉर्मेट” दो अलग-अलग विकल्प हैं।
- डेस्कटॉप उपयोगकर्ता वैकल्पिक CircuitikZ नेटिव कंपाइल वातावरण का उपयोग करके सिस्टम के Tectonic/pdflatex का पुनः उपयोग कर सकते हैं, कोई कस्टम कंपाइलर चुन सकते हैं, या सीधे ही “पिन किए गए” Tectonic रनटाइम को इंस्टॉल कर सकते हैं। प्रीव्यू, SVG, PNG, एवं PDF फ़ाइलों के निर्यात हेतु LaTeX की आवश्यकता नहीं होती।
- उन्नत बैच फ़ाइल चयन सुविधा के द्वारा संग्रहीत चयन प्रोफ़ाइल्स एवं नियमों के प्रीव्यू उपलब्ध होते हैं।
- यदि कोई बैच लक्ष्य फोल्डर अनुपस्थित हो, तो पुष्टि के बाद उसे बनाया जा सकता है; भविष्य में ऐसे अनुपस्थित फोल्डरों को स्वचालित रूप से बनाने का विकल्प भी उपलब्ध है।
- यदि कोई मौजूदा, खाली न होने वाला फोल्डर हो, तो पूरे बैच को संसाधित करने से पहले केवल एक ही बार पुष्टि आवश्यक होती है; प्रत्येक बनाई गई फ़ाइल के लिए अलग-अलग पुष्टि की आवश्यकता नहीं होती।
- “डेवलपर मोड” प्रदाता संबंधी निदान जानकार एवं उन्नत समस्या-निवारण नियंत्रण उपलब्ध कराता है।
- यदि कोई मौजूदा, खाली न होने वाला फोल्डर हो, तो पूरे बैच को संसाधित करने से पहले एक बार पुष्टि आवश्यक होती है; प्रत्येक बनाई गई फ़ाइल के लिए अलग-अलग पुष्टि की आवश्यकता नहीं होती।
- “डेवलपर मोड” प्रदाता संबंधी निदान जानकारियाँ एवं उन्नत समस्या-निवारण नियंत्रण उपलब्ध कराता है।

View file

@ -712,14 +712,15 @@ _Utoljára a(z) `1.9.3` kiadási címkéhez frissítve: 2026-06-24. A legutóbbi
</a>
<!-- notemd-settings-discovery-guide -->
## Beállítások megtalálása, diagramok története és biztonságos batch mappák
## Beállítások megtalálása, diagramok története, CircuitikZ és biztonságos batch mappák
A Notemd beállítások oldala biztosítja a zöldeléses keresést, nagy kategóriák közötti navigációt, valamint az aktuális Vault számára tárolt, egyes beállításokhoz tartozó előnépsorolásokat.
A Notemd beállítások oldala biztosítja a zöldeléses keresést, nagy kategóriák navigációját, illetve az aktuális Vault számára tárolt, egyes beállításokhoz tartozó előnépsorolásokat.
- A diagramok története a Vault szintén tárolódik, újabbakból kezdve sorrendben, és 20 egységként osztva kereshető és oldalazható. Egy történelmi leírás törlése nem törli a készült fájlokat.
- A diagramok előnézetének exportálásában a PPI értékek határozzák meg a PNG és PDF fájlok minőségét. Az SVG fájlok viszont vektortípusú maradnak.
- A diagramok története a Vault szintén tárolódik, újabbakból kezdve sorrendben, és 20-es csoportokban kereshető és oldalazható. Egy történelmi leírás törlése nem törli az előkészített fájlokat.
- A diagramok előnézetének exportálása során a PPI értékek felelősek a PNG és PDF fájlok minőségeért. Az SVG fájlok viszont vektortípusú maradnak.
- A preferált diagramtípus és a preferált forrásformátum külön kiegészítő választásokként állnak rendelkezésre.
- A fejlett batch fájlképezés lehetősége lehetővé teszi a megmentett kielegzési profillumokat és a szabályok előnézetét.
- Ha egy batch mappája nincs, ezt az igazolás után hozható létre, és lehetőség van arra, hogy a jövőben is automatikusan lépjenek létre a hiányzó mappák.
- Egy már létező, nem tömött mappához az összes fájlt elmentéhez egy igazolás szükséges, soha nem egy igazolás az egyes készült fájlokhoz.
- A fejlesztő módban láthatók a fornecederek diagnostikai információi és a fejlett hibamegtávolítási eszközök.
- A aszterális felhasználók lehetőségük van nyitni a választólagos CircuitikZ helyi kompilációs környezetét, hogy használhassák újra a rendszerben lévő Tectonic/pdflatex programot, válasszhatják egy személyre szabott kompilátorot, vagy kifejezetten telepíthetik a beállított Tectonic futtatási környezetét. Az előnézetek, SVG, PNG fájlok, valamint az előnézet PDF-ek exportálásához LaTeX nélkül is lehet.
- A fejlett batch fájl választása lehetősége lehetővé teszi a mentett választási profillumok és szabályok előnézetét.
- Ha egy batch mappája nincs, ezt az bizonyítás után hozható létre, és lehetőség van arra, hogy a jövőben is automatikusan hozzanak létre ilyen hiányzó mappákat.
- Ha már létezik egy nem türelt mappa, az összes batch fájlhoz előtt egy bizonyítás szükséges, soha nem egy bizonyítás az egyes előkészített fájlokhoz.
- A fejlesztő módban láthatók a fornalmazó diagnostikai információk és a fejlett hibajavítási eszközök.

View file

@ -724,14 +724,15 @@ _Terakhir diperbarui untuk tag rilis `1.9.3` pada 2026-06-24. Tanggal commit ter
</a>
<!-- notemd-settings-discovery-guide -->
## Penemuan pengaturan, riwayat diagram, dan folder batch yang aman
## Penemuan pengaturan, riwayat diagram, CircuitikZ, dan folder batch yang aman
Halaman pengaturan Notemd menyediakan fitur pencarian kasar, navigasi kategori yang luas, serta daftar favorit per pengaturan yang disimpan untuk Vault saat ini.
Halaman pengaturan Notemd menyediakan fitur pencarian kasar, navigasi kategori yang luas, serta daftar favorit untuk setiap pengaturan yang disimpan untuk Vault saat ini.
- Riwayat diagram disimpan pada skala Vault, diurutkan dari yang terbaru ke yang paling lama, dapat dicari, dan ditampilkan dalam kelompok berisi 20 entri. Menghapus catatan riwayat tidak akan menghapus file yang telah dihasilkan.
- Kontrol PPI saat mengekspor pratinjau diagram memengaruhi tingkat kejelasan file PNG dan PDF. File SVG tetap bersifat vektor.
- Ekspor pratinjau diagram dengan kontrol PPI mempengaruhi kualitas gambar dalam format PNG dan PDF. SVG tetap berbentuk vektor.
- Jenis diagram favorit dan format sumber favorit merupakan pilihan yang terpisah.
- Fitur pemilihan file batch lanjutan memungkinkan penggunaan profil pemilihan yang telah disimpan serta pratinjau aturan.
- Pengguna desktop dapat membuka lingkungan kompilasi bawaan CircuitikZ yang opsional untuk memanfaatkan Tectonic/pdflatex sistem yang sudah ada, memilih kompiler khusus, atau secara eksplisit menginstal runtime Tectonic yang terkelola. Ekspor pratinjau, SVG, PNG, dan PDF pratinjau tidak memerlukan LaTeX.
- Fitur pemilihan file batch tingkat lanjut memungkinkan penggunaan profil pemilihan yang telah disimpan serta pratinjau aturan.
- Jika folder tujuan batch tidak ada, folder tersebut dapat dibuat setelah mendapat konfirmasi, dengan opsi untuk mengingat pembuatan otomatisnya untuk folder yang hilang di masa depan.
- Untuk folder yang sudah ada dan tidak kosong, diperlukan satu konfirmasi untuk seluruh file dalam batch, bukan satu konfirmasi per file yang dihasilkan.
- Untuk folder yang sudah ada dan tidak kosong, diperlukan satu konfirmasi saja untuk seluruh file dalam batch, bukan satu konfirmasi per file yang dihasilkan.
- Mode pengembang menampilkan informasi diagnosis dari penyedia layanan serta kontrol pemecahan masalah tingkat lanjut.

View file

@ -723,14 +723,15 @@ _Aggiornato l'ultima volta per il tag di rilascio `1.9.3` il 2026-06-24. Data de
</a>
<!-- notemd-settings-discovery-guide -->
## Scoperta delle impostazioni, storia dei diagrammi e cartelle di lotti sicure
## Scoperta delle impostazioni, cronologia dei diagrammi, CircuitikZ e cartelle di lotti sicure
La pagina delle impostazioni di Notemd offre una ricerca sfocata, una navigazione tra le categorie numerose e la possibilità di salvare i preferiti per ciascuna impostazione allinterno del Vault corrente.
La pagina delle impostazioni di Notemd offre una ricerca sfocata, una navigazione tra le vaste categorie e la possibilità di salvare i preferiti per ciascuna impostazione allinterno del Vault corrente.
- La storia dei diagrammi viene memorizzata a livello del Vault, ordinata dal più recente al più vecchio; è possibile effettuare ricerche e visualizzarla paginata in gruppi da 20 elementi. La rimozione di un record storico non cancella i file generati.
- Lesportazione della anteprima del diagramma consente di controllare la qualità delle immagini PNG e PDF in termini di PPI; gli SVG, invece, rimangono basati su vettori.
- La cronologia dei diagrammi viene memorizzata a livello di Vault, ordinata dal più recente al più vecchio; è possibile effettuare ricerche e visualizzarla paginata in gruppi da 20 elementi. La rimozione di un record dalla cronologia non elimina i file generati.
- Lesportazione della anteprima del diagramma consente di controllare la qualità delle immagini PNG e PDF in base al PPI; lSVG, invece, rimane basato su vettori.
- Il tipo di diagramma preferito e il formato di origine preferito sono opzioni distinte.
- La selezione avanzata dei file per lotti permette di utilizzare profili di selezione salvati e anteprime delle regole.
- Gli utenti desktop possono aprire lambiente di compilazione nativo CircuitikZ (opzionale) per riutilizzare Tectonic/pdflatex del sistema, selezionare un compilatore personalizzato o installare esplicitamente lambiente runtime Tectonic gestito. Le anteprime, le esportazioni in SVG, PNG e PDF non richiedono LaTeX.
- La funzione avanzata di selezione dei file di lotto permette di utilizzare profili di selezione salvati e di visualizzare anteprime delle regole.
- È possibile creare una cartella di destinazione per i lotti assente, previa conferma; inoltre, è disponibile lopzione per memorizzare questa creazione automatica per eventuali cartelle assenti future.
- Per una cartella esistente e non vuota è necessaria una sola conferma per lintero lotto, mai una conferma per ciascun file generato.
- Il modalità sviluppatore mostra i dati diagnostici del provider e strumenti avanzati per la risoluzione dei problemi.
- Per una cartella esistente e non vuota è necessaria una sola conferma per lintero lotto, mai una conferma per ogni singolo file generato.
- Il modalità sviluppatore mostra informazioni diagnostiche sul provider e strumenti avanzati per la risoluzione dei problemi.

View file

@ -722,14 +722,15 @@ _リリースタグ `1.9.3` 向けに 2026-06-24 に最新更新しました。
</a>
<!-- notemd-settings-discovery-guide -->
## 設定の検索、図面履歴、および安全なバッチフォルダー
## 設定の検索、図面履歴、CircuitikZ、および安全なバッチフォルダー
Notemdの設定ページでは、ファジィ検索機能や大規模なカテゴリナビゲーション、そして現在のVaultに保存されている各設定のお気に入り機能が利用できます。
Notemdの設定ページでは、ファジィ検索機能や大規模なカテゴリナビゲーションに加え、現在のVaultに保存されている各設定のお気に入りを管理できます。
- 図面履歴はVault単位で保存され、最新のものから順に並び、20件ずつグループ分けして検索やページ送りが可能です。履歴レコードを削除しても生成されたファイルは削除されません。
- 図面プレビューのエクスポート時に設定されるPPI値は、PNGおよびPDFの解像度を制御します。SVGは引き続きベクターベースのままです。
- お気に入りの図面タイプとお気に入りのソースフォーマットは別々に設定できます。
- 詳細なバッチファイル選択機能により、保存された選択プロファイルやルールのプレビューが利用可能です。
- バッチ処理の対象となるフォルダーが存在しない場合は、確認後に新規作成でき、今後同様の状況が発生した際に自動的に作成するよう設定するオプションもあります。
- 既存の空でないフォルダーをバッチ処理対象とする場合は、全体を処理する前に1回の確認が必要であり、生成される各ファイルごとに確認する必要はありません。
- 開発者モードでは、プロバイダーの診断情報や高度なトラブルシューティング用のコントロールが表示されます。
- 図面履歴はVault単位で保存され、最新のものから順に並び替えられ、20件ずつグループ分けして検索やページ送りが可能です。履歴レコードを削除しても生成されたファイルは削除されません。
- 図面プレビューのエクスポート時に設定されるPPI値は、PNGおよびPDFの解像度を制御します。SVGは引き続きベクターベースの形式です。
- お気に入りの図面タイプとお気に入りのソースフォーマットは別々に選択できます。
- デスクトップユーザーは、オプションとして提供されているCircuitikZネイティブコンパイル環境を利用して、システム内のTectonic/pdflatexを再利用したり、カスタムのコンパイラを選択したり、固定された管理対象のTectonicランタイムを明示的にインストールしたりできます。プレビューやSVG、PNG、プレビュー用PDFのエクスポートにはLaTeXは不要です。
- 詳細なバッチファイル選択機能により、保存された選択プロファイルやルールのプレビューが可能になります。
- バッチ処理の対象となるフォルダーが存在しない場合は、確認後にそのフォルダーを作成でき、今後同様の状況が発生した際に自動的に作成するよう設定するオプションもあります。
- 既存の空でないフォルダーの場合、バッチ処理全体を実行する前に1回だけ確認が必要であり、生成される各ファイルごとに確認する必要はありません。
- 開発者モードでは、プロバイダーの診断情報や高度なトラブルシューティング用のコントロールが利用できます。

View file

@ -722,14 +722,15 @@ _릴리스 태그 `1.9.3` 기준으로 2026-06-24에 마지막 새로고침을
</a>
<!-- notemd-settings-discovery-guide -->
## 설정 검색, 다이어그램 기록 및 안전한 일괄 처리 폴더
## 설정 탐색, 다이어그램 기록, CircuitikZ 및 안전한 일괄 처리 폴더
Notemd의 설정 페이지에서는 모호한 키워드로도 검색할 수 있으며, 대규모 카테고리를 탐색할 수 있고, 현재 Vault에 저장된 각 설정에 대한 즐겨찾기 기능도 제공됩니다.
Notemd의 설정 페이지에서는 모호한 검색 기능, 대규모 카테고리 탐색 기능, 그리고 현재 Vault에 저장된 각 설정별 즐겨찾기 기능을 제공합니다.
- 다이어그램 기록은 Vault 범위 내에 저장되며, 가장 최신 순서대로 정렬되고 20개씩 그룹별로 검색 및 페이지 넘김이 가능합니다. 기록을 삭제해도 생성된 파일들은 삭제되지 않습니다.
- 다이어그램 미리보기 내보내기 기능의 PPI 설정을 통해 PNG와 PDF의 선명도를 조절할 수 있습니다. SVG는 벡터 기반 형식으로 유지됩니다.
- 다이어그램 기록은 Vault 범위 내에 저장되며, 가장 최신 순서대로 정렬됩니다. 20개씩 그룹화하여 검색하고 페이지를 넘길 수 있습니다. 기록을 삭제해도 생성된 파일들은 삭제되지 않습니다.
- 다이어그램 미리보기 내보내기 시 PPI 설정을 통해 PNG 및 PDF의 선명도를 조절할 수 있습니다. SVG는 벡터 기반 형식으로 유지됩니다.
- 선호하는 다이어그램 유형과 선호하는 소스 형식은 별도의 선택 항목입니다.
- 고급 일괄 처리 파일 선택 기능을 사용하면 저장된 선택 프로필과 규칙 미리보기를 활용할 수 있습니다.
- 일괄 처리 대상 폴더가 없는 경우 확인 후 새로 만들 수 있으며, 향후 또 다른 폴더가 없을 때 자동으로 생성하도록 기억하도록 하는 옵션도 제공됩니다.
- 이미 존재하는 비어 있지 않은 폴더의 경우 전체 일괄 처리를 진행하기 전에 한 번의 확인이 필요하며, 생성된 각 파일별로 별도의 확인은 요구되지 않습니다.
- 데스크톱 사용자는 선택 사항인 CircuitikZ 네이티브 컴파일 환경을 열어 시스템에 설치된 Tectonic/pdflatex를 재사용하거나, 사용자 정의 컴파일러를 선택하거나, 고정된 관리형 Tectonic 런타임을 명시적으로 설치할 수 있습니다. 미리보기, SVG, PNG, 미리보기용 PDF 내보내기에는 LaTeX가 필요하지 않습니다.
- 고급 일괄 처리 파일 선택 기능을 통해 저장된 선택 프로필과 규칙 미리보기를 활용할 수 있습니다.
- 일괄 처리 대상 폴더가 없는 경우 확인 후 새로 만들 수 있으며, 앞으로 폴더가 빠질 때 자동으로 생성하도록 기억하도록 하는 옵션도 제공됩니다.
- 이미 존재하는 비어 있지 않은 폴더의 경우 전체 일괄 처리를 진행하기 전에 한 번만 확인하면 되며, 생성되는 각 파일마다 별도로 확인할 필요는 없습니다.
- 개발자 모드에서는 공급업체 관련 진단 정보와 고급 문제 해결 도구를 확인할 수 있습니다.

View file

@ -716,14 +716,15 @@ _Kali terakhir disegarkan untuk tag keluaran `1.9.3` pada 2026-06-24. Tarikh com
</a>
<!-- notemd-settings-discovery-guide -->
## Penemuan tetapan, sejarah diagram, dan folder kumpulan yang selamat
## Penemuan tetapan, sejarah diagram, CircuitikZ, dan folder kumpulan yang selamat
Halaman tetapan Notemd menyediakan ciri carian kabur, navigasi kategori yang luas, serta senarai kegemaran untuk setiap tetapan yang disimpan untuk Vault semasa.
- Sejarah diagram disimpan pada skop Vault, dikelaskan mengikut urutan terbaru dahulu, boleh dicari dan dipaparkan secara berhalaman dalam kumpulan 20. Memadam rekod sejarah tidak akan memadamkan fail yang telah dijana.
- Ciri eksport pratonton diagram dengan kawalan PPI mempengaruhi ketajaman fail PNG dan PDF. SVG pula kekal berbentuk vektor.
- Eksport pratonton diagram menggunakan PPI mengawal ketajaman fail PNG dan PDF. SVG pula kekal berbentuk vektor.
- Jenis diagram kegemaran dan format sumber kegemaran merupakan pilihan yang berasingan.
- Ciri pemilihan fail kumpulan lanjutan membolehkan pengguna menyimpan profil pemilihan dan melihat pratonton peraturan.
- Folder sasaran kumpulan yang hilang boleh dibuat selepas pengesahan, dengan pilihan untuk menyimpan tetapan pembuatan automatik bagi folder yang hilang pada masa akan datang.
- Folder yang sudah wujud dan tidak kosong memerlukan satu pengesahan sebelum keseluruhan fail kumpulan diproses, bukan satu pengesahan untuk setiap fail yang dijana.
- Pengguna desktop boleh membuka persekitaran kompilasi asli CircuitikZ yang tersedia secara pilihan untuk menggunakan semula alat Tectonic/pdflatex sistem, memilih kompiler khusus, atau memasang runtime Tectonic yang diuruskan secara eksplisit. Pratonton, SVG, PNG, dan eksport PDF pratonton tidak memerlukan LaTeX.
- Pemilihan fail kumpulan lanjutan membolehkan pengguna menggunakan profil pemilihan yang telah disimpan serta melihat pratonton peraturan.
- Folder sasaran kumpulan yang hilang boleh dibuat selepas pengesahan, dengan pilihan untuk menyimpan pengesahan automatik bagi folder yang hilang pada masa akan datang.
- Folder yang sudah wujud dan tidak kosong memerlukan satu pengesahan sebelum keseluruhan kumpulan diproses, bukan satu pengesahan untuk setiap fail yang dijana.
- Mod pembangun memaparkan maklumat diagnostik penyedia serta alat penyelesaian masalah lanjutan.

View file

@ -716,14 +716,15 @@ _Laatst vernieuwd voor releasetag `1.9.3` op 2026-06-24. Datum van de laatste co
</a>
<!-- notemd-settings-discovery-guide -->
## Ontdekken van instellingen, diagramgeschiedenis en veilige batchmappen
## Ontdekking van instellingen, diagramgeschiedenis, CircuitikZ en veilige batchmappen
De instellingenpagina van Notemd biedt een vage zoekfunctie, navigatie door grote categorieën en favorieten per instelling die worden opgeslagen voor de huidige Vault.
- De diagramgeschiedenis wordt opgeslagen op niveau van de Vault, gerangschikt van nieuwste naar oudste, zoekbaar en pagineerbaar in groepen van 20. Het verwijderen van een historisch record verwijdert de gegenereerde bestanden niet.
- De export van diagramvoorbeelden bepaalt de scherpte van PNG- en PDF-bestanden via PPI-instellingen. SVG blijft vectorgebaseerd.
- De diagramgeschiedenis wordt opgeslagen op niveau van de Vault, gerangschikt van nieuwste naar oudste, zoekbaar en pagineerbaar in groepen van 20. Het verwijderen van een historische record verwijdert de gegenereerde bestanden niet.
- De export van diagramvoorbeelden met PPI-beheer zorgt voor een goede kwaliteit van PNG- en PDF-bestanden. SVG blijft vectorgebaseerd.
- De gewenste diagramtype en de gewenste bronformat zijn aparte keuzemogelijkheden.
- Geavanceerde selectie van batchbestanden maakt het mogelijk om opgeslagen selectieprofielen en regelvoorbeelden te gebruiken.
- Een ontbrekende batchdoelmap kan na bevestiging worden aangemaakt, met de optie om deze automatische aanmaak voor toekomstige ontbrekende mappen te onthouden.
- Voor een bestaande, niet-lege map is één bevestiging nodig voordat de hele batch wordt verwerkt; er is nooit één bevestiging per gegenereerd bestand nodig.
- In ontwikkelaarstand worden diagnostische informatie van de provider en geavanceerde oplossingsmogelijkheden weergegeven.
- Desktopgebruikers kunnen het optionele, native CircuitikZ-compilatieomgeving openen om het systeem Tectonic/pdflatex opnieuw te gebruiken, een aangepaste compiler te kiezen of expliciet de gemanageerde Tectonic-runtime te installeren. Voorbeelden, SVG-, PNG- en PDF-exporten vereisen geen LaTeX.
- Geavanceerde selectiemogelijkheden voor batchbestanden stellen het opslaan van selectieprofielen en het bekijken van regels mogelijk.
- Een ontbrekende batchdoelmap kan na bevestiging worden aangemaakt, met de optie om automatische aanmaak voor toekomstige ontbrekende mappen te onthouden.
- Voor een reeds bestaande, niet-lege map is één bevestiging nodig voordat de hele batch wordt verwerkt; er is nooit één bevestiging per gegenereerd bestand nodig.
- In ontwikkelaarsmodus worden diagnostische informatie van de provider en geavanceerde oplossingsmogelijkheden weergegeven.

View file

@ -715,14 +715,15 @@ _Sist oppdatert for utgivelsestaggen `1.9.3` 2026-06-24. Dato for siste commit:
</a>
<!-- notemd-settings-discovery-guide -->
## Oppdagelse av innstillinger, historik over diagrammer og sikre batch-mapper
## Oppdagelse av innstillinger, diagramhistorikk, CircuitikZ og sikre batch-mapper
Notemds innstillingsside tilbyr uskyttet søk, navigasjon gjennom store kategorier, samt favoritter for hver enkelt innstilling som lagres for den aktuelle Vaulten.
- Historikken over diagrammer lagres på Vault-nivå, sortert fra nyeste først, og er søkelig samt paginerbar i grupper på 20 elementer. Når en historiekpost fjernes, blir genererte filer ikke slettet.
- Ekspor av diagramforhandsvisninger gjør det mulig å kontrollere klarheten i PNG- og PDF-filer via PPI. SVG-forfiler forblir vektorbaserte.
- Favorditt diagramtyp og favorditt kildemålformat er to separate valgmuligheter.
- Avansert valg av batch-filer muliggjør å bruke lagrede valgprofiler og forhandsvisninger av regler.
- En manglende målmappe for batch-oppgifter kan erstattes etter bekreftelse, med mulighet til å huske automatisk opprettelse for fremtidige manglende mapper.
- En eksisterende, ikke tom mappe krever én bekreftelse før hele batchen kan behandles, aldri én bekreftelse per generert fil.
- Utviklermodus viser diagnostikkinformasjon fra leverandøren og avanserte kontroller for feilforskning.
- Diagramhistorikk lagres på Vault-nivå, ordnet fra nyeste først, og er søkelig samt paginerbar i grupper på 20 elementer. Når en historikkepost fjernes, fjernes ikke de genererte filene.
- Ekspor av diagramforhandsvisning gjennom PPI-styring kontrollerer klarheten i PNG- og PDF-filer. SVG-forfilene forblir vektorbaserte.
- Favorett diagramtyp og favorett kildemålformat er to separate valgmuligheter.
- Skjermarbeidsbrukere kan åpne det valgfrie CircuitikZ-native kompileringsmiljøet for å bruke systemets Tectonic/pdflatex på nytt, velge en egen kompilerer, eller installere eksplisitt den pinnede, styrte Tectonic-kjernen. Forhandsvisninger, SVG-, PNG- og PDF-ekspor krever ikke LaTeX.
- Avansert valg av batch-filer muliggjør å bruke lagrede valgprofiler og forutsyn av regler.
- En manglende batch-målmappe kan erstattes etter bekreftelse, med muligheten til å huske automatiske opprettelser for fremtidige manglende mapper.
- En eksisterende, ikke tom mappe krever én bekreftelse før hele batchen, aldri én bekreftelse per generert fil.
- Utviklermodus viser diagnostikk fra leverandøren og avanserte kontroller for feilforskning.

View file

@ -715,14 +715,15 @@ _Ostatnio odświeżono dla tagu wydania `1.9.3`: 2026-06-24. Data ostatniego com
</a>
<!-- notemd-settings-discovery-guide -->
## Odkrywanie ustawień, historia diagramów oraz bezpieczne foldery grupowe
## Odkrywanie ustawień, historia diagramów, CircuitikZ oraz bezpieczne foldery grupowe
Strona ustawień Notemd oferuje wyszukiwanie nieprecyzyjne, nawigację po dużych kategoriach oraz ulubione ustawienia przechowywane dla bieżącego Vault.
Strona ustawień Notemd oferuje wyszukiwanie nieprecyzyjne, nawigację po dużych kategoriach oraz ulubione ustawienia przechowywane dla aktualnego Vault.
- Historia diagramów jest przechowywana na poziomie Vault, uszeregowana od najnowszych do najstarszych, dostępna do wyszukiwania i paginowana w grupach po 20 elementów. Usunięcie wpisu z historii nie usuwa utworzonych plików.
- Eksport wersji wstępnej diagramu umożliwia kontrolę rozdzielczości PNG i PDF za pomocą parametru PPI. SVG pozostaje formatem wektorowym.
- Wybrany typ diagramu oraz preferowany format źródłowy to dwie odrębne opcje do wyboru.
- Zaawansowane funkcje wyboru plików grupowych umożliwiają korzystanie z zapisanych profili wyboru oraz przeglądanie reguł.
- Brakujący folder docelowy dla plików grupowych można utworzyć po potwierdzeniu, przy czym istnieje opcja zapamiętania tej automatycznej operacji na przyszłość.
- Dla istniejącego, niepustego folderu wymagane jest jedno potwierdzenie przed przetworzeniem całej grupy plików, a nie jedno potwierdzenie dla każdego utworzonego pliku.
- Eksport wersji wstępnej diagramu umożliwia kontrolę rozdzielczości plików PNG i PDF za pomocą parametru PPI. Pliki SVG pozostają wektorowe.
- Wybrany typ diagramu oraz preferowany format źródłowy to oddzielne opcje do wyboru.
- Użytkownicy komputerów stacjonarnych mogą otworzyć opcjonalne, natywne środowisko kompilacji CircuitikZ, aby ponownie wykorzystać systemowe narzędzia Tectonic/pdflatex, wybrać własnego kompilatora lub wyraźnie zainstalować zarządzany runtime Tectonic. Eksport wersji wstępnej, plików SVG, PNG oraz PDF nie wymaga użycia LaTeXa.
- Zaawansowana funkcja wyboru plików grupowych umożliwia przechowywanie profili wybranych plików oraz przeglądanie reguł.
- Brakujący folder docelowy dla plików grupowych można utworzyć po potwierdzeniu, z opcją zapamiętania tej automatycznej akcji na przyszłość w przypadku kolejnych brakujących folderów.
- Istniejący, niepusty folder wymaga jednego potwierdzenia przed przetworzeniem całej grupy plików, a nie jednego potwierdzenia dla każdego utworzonego pliku.
- Tryb programisty ujawnia informacje diagnostyczne dostawcy oraz zaawansowane narzędzia do rozwiązywania problemów.

View file

@ -722,14 +722,15 @@ _Atualizado pela última vez para a tag de release `1.9.3` em 2026-06-24. Data d
</a>
<!-- notemd-settings-discovery-guide -->
## Descoberta de configurações, histórico de diagramas e pastas em lote seguras
## Descoberta de configurações, histórico de diagramas, CircuitikZ e pastas em lote seguras
A página de configurações do Notemd oferece busca por semelhança, navegação por grandes categorias e favoritos por configuração, armazenados para o Vault atual.
- O histórico de diagramas é armazenado no escopo do Vault, organizado do mais recente para o mais antigo, sendo possível pesquisálo e visualizálo em páginas com 20 itens por página. A remoção de um registro do histórico não exclui os arquivos gerados.
- O histórico de diagramas é armazenado no escopo do Vault, ordenado do mais recente para o mais antigo, sendo possível pesquisálo e visualizálo em páginas de 20 itens cada. A remoção de um registro do histórico não exclui os arquivos gerados.
- A exportação da prévisualização do diagrama permite controlar a nitidez dos arquivos PNG e PDF por meio do PPI. O SVG permanece em formato vetorial.
- O tipo de diagrama preferido e o formato de origem preferido são opções separadas.
- A seleção avançada de arquivos em lote permite usar perfis de seleção salvos e visualizações de regras.
- Uma pasta de destino em lote ausente pode ser criada após confirmação, com a opção de lembrar a criação automática para pastas faltantes futuras.
- Os usuários de desktop podem abrir o ambiente de compilação nativo opcional CircuitikZ para reutilizar o Tectonic/pdflatex do sistema, selecionar um compilador personalizado ou instalar explicitamente a versão gerenciada do Tectonic. As exportações de prévisualização, SVG, PNG e PDF não exigem LaTeX.
- A seleção avançada de arquivos em lote permite usar perfis de seleção salvos e visualizar regras.
- Uma pasta de destino em lote ausente pode ser criada após confirmação, com a opção de lembrar a criação automática para pastas ausentes futuras.
- Uma pasta existente e não vazia requer uma confirmação antes de processar todo o lote, nunca uma confirmação por arquivo gerado.
- O modo desenvolvedor exibe diagnósticos do provedor e controles avançados de solução de problemas.

View file

@ -712,14 +712,15 @@ _Actualizat ultima dată pentru tagul de release `1.9.3` la 2026-06-24. Data ult
</a>
<!-- notemd-settings-discovery-guide -->
## Descoperirea setărilor, istoricul diagramelor și folderele în lot sigure
## Descoperirea setărilor, istoricul diagramelor, CircuitikZ și folderele sigure pentru batch-uri
Pagina de setări a Notemd oferă căutare fuzzy, navigare prin categorii extinse și preferințe pentru fiecare setare, stocate pentru Vault-ul curent.
Pagina de setări a Notemd oferă o căutare fuzzy, navigare prin categorii extinse și preferințe pentru fiecare setare, stocate pentru Vault-ul curent.
- istoricul diagramelor este stocat la nivelul Vault-ului, ordonat din cel mai recent înainte, fiind posibilă căutarea și paginarea în grupuri de 20. Ștergerea unei înregistrări din istoric nu șterge fișierele generate.
- Exportul vizualizării preliminare a diagramelor permite controlul clarității în format PNG și PDF prin parametrul PPI. SVG rămâne bazat pe vectori.
- Exportul vizualizării preliminare a diagramelor permite controlul clarității fișierelor PNG și PDF prin parametrul PPI. SVG rămâne bazat pe vectori.
- Tipul de diagramă preferat și formatul sursă preferat sunt alegeri separate.
- Opțiunea avansată de selecție a fișierelor în lot permite utilizarea profilurilor de selecție salvate și vizualizarea preliminară a regulilor.
- Dacă o folderă țintă pentru lot lipsește, aceasta poate fi creată după confirmare, cu opțiunea de a memora crearea automată pentru folderele lipsite viitoare.
- Pentru o folderă existentă și necăptușită, este necesară o singură confirmare pentru întregul lot, nu o confirmare pentru fiecare fișier generat.
- Modul de dezvoltator dezvăluie diagnosticele furnizorului și instrumentele avansate de rezolvare a problemelor.
- Utilizatorii desktop pot deschide mediul de compilare nativ CircuitikZ, opțional, pentru a reutiliza sistemul Tectonic/pdflatex, a selecta un compilator personalizat sau a instala în mod explicit runtime-ul Tectonic gestionat. Vizualizările preliminare, fișierele SVG, PNG și exporturile PDF nu necesită LaTeX.
- Selectarea avansată a fișierelor batch permite utilizarea profilurilor de selecție salvate și a vizualizărilor preliminare a regulilor.
- O foldere țintă pentru batch lipsă poate fi creată după confirmare, cu opțiunea de a memora crearea automată pentru folderele lipsă viitoare.
- O foldere existentă și necăptușită necesită o singură confirmare pentru întreaga batch, nu o confirmare pentru fiecare fișier generat.
- Modul dezvoltator dezvăluie diagnosticele furnizorului și controale avansate de rezolvare a problemelor.

View file

@ -724,14 +724,15 @@ _Последнее обновление для тега релиза `1.9.3`: 2
</a>
<!-- notemd-settings-discovery-guide -->
## Обнаружение настроек, история диаграмм и безопасные папки для пакетной обработки
## Обнаружение настроек, история диаграмм, CircuitikZ и безопасные папки для пакетной обработки
На странице настроек Notemd предусмотрены функции нечёткого поиска, навигация по большому количеству категорий, а также возможность добавления избранного для каждой настройки в раздел «Любимое» в рамках текущего хранилища.
На странице настроек Notemd предусмотрены функции нечёткого поиска, навигация по большому количеству категорий, а также возможность добавления в избранное по отдельным настройкам, сохраняемых для текущего хранилища.
- История диаграмм сохраняется на уровне всего хранилища, отсортированная по новизне; её можно искать и просматривать по страницам по 20 записей. Удаление записи из истории не приводит к удалению сгенерированных файлов.
- Параметры разрешения при экспорте предварительного просмотра диаграммы в форматах PNG и PDF влияют на чёткость изображения; формат SVG остаётся векторным.
- История диаграмм хранится в масштабе всего хранилища, отсортированная по новизне; её можно искать и просматривать по страницам по 20 записей. Удаление записи из истории не приводит к удалению сгенерированных файлов.
- Параметры разрешения при экспорте предварительного просмотра диаграмм в форматах PNG и PDF контролируют чёткость изображения; формат SVG остаётся векторным.
- Тип диаграммы и формат исходных данных выбираются отдельно друг от друга.
- Режим продвинутой обработки пакетов позволяет использовать сохранённые профили выбора файлов и просматривать правила обработки заранее.
- Если папка для обработки отсутствует, её можно создать после подтверждения; также есть опция запоминать автоматическое создание таких папок в будущем.
- Для работы с уже существующей непустой папкой требуется одно подтверждение перед обработкой всего пакета, а не по отдельности для каждого сгенерированного файла.
- В режиме разработчика отображаются диагностические данные поставщика и инструменты для устранения сложных проблем.
- Пользователи на ПК могут использовать необязательную нативную среду компиляции CircuitikZ для повторного использования системных инструментов Tectonic/pdflatex, выбрать собственный компилятор или явно установить заданный версионный рантайм Tectonic. Для экспорта предварительного просмотра, файлов в форматах SVG, PNG и PDF не требуется использование LaTeX.
- Расширенные возможности выбора файлов для пакетной обработки позволяют использовать сохранённые профили выбора и просматривать правила обработки.
- Если папка-цель для пакетной обработки отсутствует, её можно создать после подтверждения; также существует опция запоминания автоматического создания таких папок в будущем.
- Для существующей непустой папки требуется одно подтверждение перед обработкой всего пакета, а не по отдельности для каждого сгенерированного файла.
- Режим разработчика позволяет просматривать диагностику поставщиков сервисов и использовать расширенные инструменты устранения неполадок.

View file

@ -717,14 +717,15 @@ _Senast uppdaterad för release-taggen `1.9.3` den 2026-06-24. Datum för senast
</a>
<!-- notemd-settings-discovery-guide -->
## Upptäckt av inställningar, diagramhistorik och säkra batchmappar
## Upptäckt av inställningar, diagramhistorik, CircuitikZ och säkra batchmappar
Notemds inställningsseite erbjuder suddjupssökning, navigering i stora kategorier samt favoriter för varje inställning som lagras för den aktuella Vaulten.
- Diagramhistoriken lagras på Vault-nivå, sorterad från nyaste till äldsta, och kan sökas samt pagineras i grupper på 20 stycken. Att ta bort en historikpost tar inte bort de genererade filerna.
- Export av diagramförhandsvisningar med PPI-kontroller styr klarheten i PNG- och PDF-filer. SVG förblir vektorbaserat.
- Föredragna diagramtyp och föredragna källformat är separata valmöjligheter.
- Avancerad val av batchfiler möjliggör sparade valprofiler samt förhandsvisningar av regler.
- En saknad batchmålsmapp kan skapas efter bekräftelse, med möjlighet att komma ihåg automatisk skapning för framtida saknade mappar.
- En redan existerande, icke-toämig mapp kräver en bekräftelse för hela batchen, aldrig en bekräftelse per genererad fil.
- Utvecklarmönstret avslöjar leverantördiagnostik och avancerade felsökningstjänster.
- Desktop-användare kan öppna det valfria CircuitikZ-nativa kompileringsmiljön för att återanvända systemets Tectonic/pdflatex, välja en anpassad kompilator eller explicit installera den fixade, hanterade Tectonic-körningen. Förhandsvisningar, SVG-, PNG- och PDF-exporter kräver inget LaTeX.
- Avancerad val av batchfiler möjliggör sparade valprofiler och förhandsvisningar av regler.
- En saknad batchmålsmapp kan skapas efter bekräftelse, med möjlighet att komma ihåg den automatiska skapandet för framtida saknade mappar.
- En redan existerande, icke-tom mapp kräver en bekräftelse för hela batchen, aldrig en bekräftelse per genererad fil.
- Utvecklarmönstret visar leverantördiagnostik och avancerade felsökningstillgängligheter.

View file

@ -719,14 +719,15 @@ _รีเฟรชล่าสุดสำหรับแท็กรุ่น
</a>
<!-- notemd-settings-discovery-guide -->
## การค้นหาการตั้งค่า, ประวัติแผนภาพ, และโฟลเดอร์จัดการไฟล์หมู่อย่างปลอดภัย
## การค้นหาการตั้งค่า, ประวัติแผนภาพ, CircuitikZ และโฟลเดอร์จัดการไฟล์แบบกลุ่มอย่างปลอดภัย
หน้าการตั้งค่าของ Notemd มีฟีเจอร์การค้นหาแบบคลุมเครือ, ระบบนำทางหมวดหมู่จำนวนมาก, และการบันทึกรายการโปรดสำหรับแต่ละการตั้งค่าที่ใช้งานอยู่ใน Vault ปัจจุบัน
หน้าการตั้งค่าของ Notemd มีฟีเจอร์ค้นหาแบบคลุมเครือ, ระบบนำทางหมวดหมู่จำนวนมาก และการบันทึกรายการโปรดสำหรับแต่ละการตั้งค่าที่ใช้งานอยู่ใน Vault ปัจจุบัน
- ประวัติแผนภาพจะถูกเก็บไว้ในระดับ Vault โดยเรียงลำดับจากแผนภาพที่ใหม่ที่สุดก่อน สามารถค้นหาและแสดงผลเป็นกลุ่มละ 20 รายการได้ การลบรายการประวัติจะไม่ส่งผลให้ไฟล์ที่ถูกสร้างขึ้นหายไป
- ตัวควบคุมความละเอียด PPI สำหรับการส่งออกตัวอย่างแผนภาพจะช่วยปรับความชัดเจนของไฟล์ PNG และ PDF ส่วนไฟล์ SVG จะยังคงเป็นรูปแบบเวกเตอร์ต่อไป
- ประเภทแผนภาพที่ต้องการใช้และรูปแบบต้นทางที่ต้องการใช้เป็นตัวเลือกที่แยกจากกัน
- ฟีเจอร์การเลือกไฟล์หมู่ขั้นสูงช่วยให้สามารถใช้โปรไฟล์การเลือกที่บันทึกไว้และดูตัวอย่างกฎต่าง ๆ ได้
- หากไม่มีโฟลเดอร์ปลายทางสำหรับจัดการไฟล์หมู่ สามารถสร้างขึ้นมาได้หลังจากยืนยัน พร้อมตัวเลือกในการบันทึกการสร้างโฟลเดอร์โดยอัตโนมัติสำหรับโฟลเดอร์ที่ขาดหายไปในอนาคต
- หากเป็นโฟลเดอร์ที่มีเนื้อหาอยู่แล้ว จะต้องมีการยืนยันเพียงครั้งเดียวสำหรับไฟล์หมู่ทั้งหมด ไม่ใช่การยืนยันทีละไฟล์ที่ถูกสร้างขึ้น
- ประวัติแผนภาพจะถูกเก็บไว้ในระดับ Vault โดยเรียงลำดับจากใหม่ที่สุดไปเก่าที่สุด สามารถค้นหาและแสดงผลเป็นกลุ่มละ 20 รายการได้ การลบรายการประวัติจะไม่ส่งผลให้ไฟล์ที่สร้างขึ้นถูกลบไปด้วย
- ตัวควบคุม PPI สำหรับการส่งออกตัวอย่างแผนภาพจะช่วยปรับความคมชัดของไฟล์ PNG และ PDF ส่วนไฟล์ SVG จะยังคงเป็นรูปแบบเวกเตอร์ต่อไป
- ประเภทแผนภาพที่ต้องการใช้และรูปแบบแหล่งข้อมูลที่ต้องการใช้เป็นตัวเลือกที่แยกจากกัน
- ผู้ใช้คอมพิวเตอร์ตั้งโต๊ะสามารถเปิดสภาพแวดล้อมคอมไพล์แบบดั้งเดิมของ CircuitikZ ซึ่งเป็นตัวเลือกเสริม เพื่อนำ Tectonic/pdflatex ของระบบมาใช้ซ้ำ, เลือกโปรแกรมคอมไพล์ที่กำหนดเอง หรือติดตั้ง Tectonic runtime ที่ถูกกำหนดให้ใช้งานอย่างเฉพาะเจาะจงได้ การส่งออกตัวอย่างแผนภาพ, ไฟล์ SVG, ไฟล์ PNG และไฟล์ PDF สำหรับดูตัวอย่างไม่จำเป็นต้องใช้ LaTeX เลย
- ฟีเจอร์การเลือกไฟล์แบบกลุ่มขั้นสูงช่วยให้สามารถใช้โปรไฟล์การเลือกที่บันทึกไว้และดูตัวอย่างกฎต่าง ๆ ได้
- หากไม่มีโฟลเดอร์ปลายทางสำหรับจัดการไฟล์แบบกลุ่ม สามารถสร้างขึ้นมาได้หลังจากยืนยัน พร้อมทั้งมีตัวเลือกในการบันทึกการสร้างโฟลเดอร์โดยอัตโนมัติสำหรับโฟลเดอร์ที่ขาดหายไปในอนาคต
- หากเป็นโฟลเดอร์ที่มีเนื้อหาอยู่แล้ว จะต้องมีการยืนยันเพียงครั้งเดียวสำหรับไฟล์ทั้งกลุ่ม ไม่ใช่การยืนยันทีละไฟล์ที่ถูกสร้างขึ้น
- โหมดนักพัฒนาจะแสดงข้อมูลวินิจฉัยจากผู้ให้บริการและตัวเลือกสำหรับการแก้ไขปัญหาขั้นสูง

View file

@ -715,14 +715,15 @@ _`1.9.3` sürüm etiketi için son yenileme tarihi: 2026-06-24. En son commit ta
</a>
<!-- notemd-settings-discovery-guide -->
## Ayarların keşfi, diyagram geçmişi ve güvenli toplu işleme klasörleri
## Ayarların keşfi, diyagram geçmişi, CircuitikZ ve güvenli toplu işleme klasörleri
Notemd ayarlar sayfası, bulanık arama özelliği, geniş kategori navigasyonu ve mevcut Vault için her bir ayara ait favori seçenekler sunar.
Notemd ayarlar sayfası, bulanık arama özelliği, geniş kategori navigasyonu ve mevcut Vault için her bir ayara ait favorileri saklar.
- Diyagram geçmişi Vault düzeyinde saklanır; en yeni olanlardan başlayarak sıralanır, aranabilir ve 20şerli gruplar halinde sayfalandırılır. Bir geçmiş kaydının silinmesi, oluşturulan dosyaların silinmesine neden olmaz.
- Diyagram önizleme dışa aktarımında PPI ayarı, PNG ve PDF dosyalarının netliğini kontrol eder. SVG dosyaları ise vektör tabanlı kalır.
- Tercih edilen diyagram türü ile tercih edilen kaynak formatı ayrı ayrı seçilir.
- Diyagram önizleme dışa aktarımında PPI ayarı, PNG ve PDF dosyalarının netliğini kontrol eder. SVG ise vektör tabanlı kalır.
- Tercih edilen diyagram türü ile tercih edilen kaynak formatı ayrı ayrı seçeneklerdir.
- Masaüstü kullanıcıları, sistemdeki Tectonic/pdflatexi yeniden kullanmak, özel bir derleyici seçmek veya sabitlenmiş yönetilen Tectonic çalışma zamanınııkça yüklemek amacıyla isteğe bağlı CircuitikZ yerel derleme ortamını açabilirler. Önizleme, SVG, PNG ve önizleme PDF dışa aktarımları için LaTeX gerekmez.
- Gelişmiş toplu dosya seçimi özelliği, kaydedilmiş seçim profillerini ve kural önizlemelerini mümkün kılar.
- Eksik olan toplu işleme hedef klasörü, onay alındıktan sonra oluşturulabilir; gelecekteki eksik klasörler için otomatik oluşturma özelliği de seçilebilir.
- Mevcut ve boş olmayan bir klasör için tüm toplu işlem yapılabilmesi için tek bir onay yeterlidir; her oluşturulan dosya için ayrı ayrı onay gerekmez.
- Geliştirici modu, sağlayıcıya ait teşhis bilgilerini ve gelişmiş sorun giderme araçlarını gösterir.
- Mevcut ve boş olmayan bir klasör için tüm toplu işlem için tek bir onay yeterlidir; her oluşturulan dosya için ayrı ayrı onay gerekmez.
- Geliştirici modu, sağlayıcı teşhis bilgilerini ve gelişmiş sorun giderme araçlarını gösterir.

View file

@ -717,14 +717,15 @@ _Востаннє оновлено для тега релізу `1.9.3`: 2026-06
</a>
<!-- notemd-settings-discovery-guide -->
## Відкриття налаштувань, історія діаграм та безпечні папки для пакетної обробки
## Відкриття налаштувань, історія діаграм, CircuitikZ та безпечні папки для пакетної обробки
Сторінка налаштувань Notemd пропонує фаззі-пошук, навігацію за великими категоріями та можливість створення улюблених налаштувань для поточного Vault.
Сторінка налаштувань Notemd пропонує фазовий пошук, навігацію за великими категоріями та можливість створення улюблених налаштувань для поточного Vault.
- Історія діаграм зберігається на рівні Vault, впорядкована за принципом «найновіші спочатку», є доступною для пошуку та поділена на сторінки по 20 записів. Видалення запису з історії не призводить до видалення створених файлів.
- Експорт попереднього перегляду діаграми з можливістю контролю роздільної здатності PPI впливає на чіткість файлів у форматах PNG та PDF. Формат SVG залишається векторним.
- Тип діаграми, який використовується, та формат вихідних даних — це окремі параметри для вибору.
- Режим розширеної обробки файлів дозволяє використовувати збережені профілі вибору та переглядати правила обробки.
- Якщо папка для пакетної обробки відсутня, її можна створити після підтвердження; також існує опція запам’ятати це дію для майбутніх випадків відсутності папок.
- Для існуючої непорожньої папки потрібно одне підтвердження перед обробкою всього пакету, а не окреме підтвердження для кожного створеного файлу.
- Режим розробника відкриває інформацію про проблеми від постачальника та засоби для розширеного усунення несправностей.
- Історія діаграм зберігається на рівні Vault, впорядкована від найновіших до старіших; її можна шукати та переглядати у групах по 20 записів. Видалення запису з історії не призводить до видалення створених файлів.
- Експорт попереднього перегляду діаграми з можливістю контролю роздільної здатності PPI впливає на чіткість файлів у форматах PNG та PDF; SVG залишається векторним форматом.
- Вибір типу діаграми та формату вихідних даних є окремими параметрами.
- Користувачі настільних ПК можуть скористатися необов’язковим нативним середовищем компіляції CircuitikZ для повторного використання системних інструментів Tectonic/pdflatex, обрати власний компілятор або явно встановити вказаний версійний рантайм Tectonic. Для експорту попереднього перегляду, файлів у форматах SVG, PNG та PDF не потрібен LaTeX.
- Розширені функції вибору файлів для пакетної обробки дозволяють використовувати збережені профілі вибору та переглядати правила обробки.
- Папку-ціль для пакетної обробки можна створити після підтвердження, існує також опція автоматичного створення таких папок у майбутньому.
- Якщо папка вже існує та не є порожньою, потрібно підтвердити дію лише один раз для всього пакету, а не окремо для кожного створеного файлу.
- Режим розробника відкриває інформацію про проблеми від постачальників та додаткові засоби для усунення несправностей.

View file

@ -717,14 +717,15 @@ _Lần làm mới gần nhất cho thẻ phát hành `1.9.3` vào 2026-06-24. Ng
</a>
<!-- notemd-settings-discovery-guide -->
## Khám phá cài đặt, lịch sử sơ đồ và các thư mục xử lý theo nhóm an toàn
## Khám phá cài đặt, lịch sử sơ đồ, CircuitikZ và các thư mục xử lý theo nhóm an toàn
Trang cài đặt của Notemd cung cấp chức năng tìm kiếm mơ hồ, hệ thống điều hướng theo các danh mục lớn, cùng tính năng lưu các mục yêu thích cho từng cài đặt trong Vault hiện tại.
- Lịch sử sơ đồ được lưu ở cấp độ Vault, được sắp xếp theo thứ tự mới nhất trước, có thể tìm kiếm và hiển thị theo từng nhóm 20 mục. Việc xóa một bản ghi lịch sử không làm xóa các tập tin đã được tạo ra.
- Tùy chọn PPI khi xuất bản trước sơ đồ giúp kiểm soát độ rõ nét của các tập tin PNG và PDF. Còn định dạng SVG vẫn giữ nguyên tính chất vector.
- Loại sơ đồ ưu tiên và định dạng nguồn ưu tiên là hai tùy chọn riêng biệt.
- Chế độ chọn tập tin theo nhóm nâng cao cho phép sử dụng các thiết lập đã lưu và xem trước các quy tắc xử lý.
- Nếu thư mục đích dành cho việc xử lý theo nhóm chưa tồn tại, người dùng có thể tạo nó sau khi xác nhận; đồng thời có tùy chọn để hệ thống tự động tạo thư mục này mỗi khi gặp trường hợp tương tự trong tương lai.
- Đối với một thư mục đã tồn tại và không trống, chỉ cần một lần xác nhận cho toàn bộ các tập tin trong thư mục đó, chứ không phải một lần xác nhận cho từng tập tin được tạo ra.
- Người dùng máy tính để bàn có thể mở môi trường biên dịch gốc CircuitikZ (nếu có) để tái sử dụng các công cụ Tectonic/pdflatex của hệ thống, chọn công cụ biên dịch tùy chỉnh, hoặc cài đặt trực tiếp phiên bản Tectonic runtime được quản lý. Các chức năng xem trước, xuất SVG, PNG và PDF không yêu cầu sử dụng LaTeX.
- Tính năng chọn tập tin xử lý theo nhóm nâng cao cho phép lưu các thiết lập chọn lọc đã tạo và xem trước các quy tắc áp dụng.
- Nếu thư mục đích dành cho việc xử lý theo nhóm chưa tồn tại, người dùng có thể tạo nó sau khi xác nhận; đồng thời có tùy chọn để hệ thống tự động tạo thư mục đó mỗi khi gặp trường hợp tương tự trong tương lai.
- Đối với các thư mục đã tồn tại và không trống, chỉ cần xác nhận một lần cho toàn bộ nhóm tập tin, chứ không phải xác nhận riêng cho từng tập tin được tạo ra.
- Chế độ nhà phát triển giúp hiển thị thông tin chẩn đoán từ nhà cung cấp và các công cụ khắc phục sự cố nâng cao.

View file

@ -259,6 +259,7 @@ Notemd 的设置较多,是因为模型提供商、笔记工作流、图表、
- **图形历史保留上限**:限制 Vault 历史索引数量;清理索引不会删除已生成文件。
- **图形预览导出 PPI**:控制 PNG 和 PDF 清晰度SVG 始终保持矢量格式。
- **首选图表类型 / 生成格式**:把图表语义与 Mermaid、Draw.io、Drawnix、CircuitikZ 等可编辑源格式分开。
- **CircuitikZ 原生编译环境(可选)**:检查已有 `tectonic``pdflatex`,也可在用户明确确认后安装固定版本的托管 Tectonic。未安装 LaTeX 时SVG、PNG 和预览 PDF 导出仍然可用。
- **高级批处理文件选择**:启用可保存的文件夹/文件选择配置和规则预览。
- **自动创建缺失的批处理目标文件夹**:用户首次授权后可启用;已经存在且非空的文件夹仍会在每次批处理开始前确认一次。
- **开发者模式**:显示用于故障排查的 provider 诊断与高级文件选择工具。
@ -267,6 +268,10 @@ Notemd 的设置较多,是因为模型提供商、笔记工作流、图表、
完成的图形预览会写入 Vault 级索引,并默认按完成时间倒序排列。预览以图形画布为主:单一**导出**菜单提供 SVG、PNG 和 PDF点击**历史**会在不关闭预览的情况下打开右侧可搜索抽屉。即使当前没有活动笔记,也能从命令面板(`Notemd: 打开图形历史`)或侧边栏直达同一历史视图。可按标题、笔记、图表类型、源格式或导出格式搜索,并以每页 20 条浏览结果。历史只保存轻量元数据和产物路径,不重复保存 SVG、PNG、PDF 或源文件正文。删除历史记录不会删除生成文件。
### 可选的 CircuitikZ 原生编译
Notemd 不会把完整 TeX 发行版塞进插件包。桌面端可从图形设置或命令面板打开 **CircuitikZ 原生编译环境**,复用系统 `tectonic` / `pdflatex`、选择自定义可执行文件,或显式安装/修复固定版本的托管 Tectonic。环境面板会显示就绪状态、下载进度、安装目录、有限长度日志和删除入口。移动端以及选择不安装的用户仍可使用零依赖预览并导出 SVG、PNG 和预览 PDF。
### 安全的批处理文件夹处理
从标题批量生成开始前Notemd 会对所选文件夹统一检查一次:

View file

@ -789,14 +789,15 @@ _最近一次已針對發佈 tag `1.9.3` 於 2026-06-24 刷新。 最新提交
</a>
<!-- notemd-settings-discovery-guide -->
## 設定探索、圖表歷史記錄與安全批次資料夾
## 設定探索、圖表歷史記錄、CircuitikZ 與安全批次資料夾
Notemd 的設定頁面提供模糊搜尋功能、大型類導航,以及為當前 Vault 儲存的每個設定的收藏項目。
Notemd 的設定頁面提供模糊搜尋功能、大型類導航,以及為當前 Vault 儲存的每個設定的收藏項目。
- 圖表歷史記錄會儲存在 Vault 級別,以最新順序排列,可進行搜尋,並以 20 個為一組分頁顯示。移除某條歷史記錄並不會刪除已生成的檔案。
- 圖表預覽的匯出 PPI 設定可控制 PNG PDF 的清晰度,而 SVG 則仍維持向量格式。
- 圖表歷史記錄會儲存在 Vault 級別,以最新順序排列,可進行搜尋,並以 20 個為一組進行分頁顯示。移除某條歷史記錄並不會刪除已生成的檔案。
- 圖表預覽的匯出 PPI 設定可控制 PNG PDF 的清晰度,而 SVG 則仍維持向量格式。
- 首選圖表類型與首選來源格式是兩個獨立的選項。
- 進階批次檔案選擇功能可讓使用者使用已儲存的選擇設定檔以及規則預覽。
- 若缺少批次目標資料夾,可在確認後建立,並可選擇記住此動作,以便未來再出現缺失資料夾時自動建立。
- 若是已存在的非空資料夾,整個批次處理前需要一次確認,而非每個生成的檔案都需要一次確認。
- 開發者模式可顯示供應商的診斷資訊以及進階的問題排解控制項。
- 桌面端使用者可以開啟選用的 CircuitikZ 本機編譯環境,以重複使用系統中的 Tectonic/pdflatex或選擇自訂的編譯器亦或是明確安裝已固定的受管理 Tectonic 執行環境。預覽、SVG、PNG 以及預覽 PDF 的匯出功能均不需要 LaTeX。
- 進階的批次檔案選擇功能可讓使用者使用已儲存的選項設定檔與規則預覽。
- 若缺少批次處理的目標資料夾,可在確認後建立,並可選擇將未來出現的類似資料夾自動建立的功能。
- 若是已存在的非空資料夾,則需要對整個批次操作進行一次確認,而非對每個生成的檔案都單獨確認。
- 開發者模式可顯示供應商的診斷資訊以及進階的故障排除控制項。

View file

@ -14,6 +14,9 @@ This document summarizes the major functional and architectural changes implemen
- Hardened the artifact CLI for npm 11 argument rewriting on Windows by accepting the ordered positional fallback shape, and made the circuitikz SVG canvas background inline white so standalone viewers do not show transparent SVGs as black.
- Kept the circuit path topology-first: the prompt asks for structured `CircuitSpec`, validation rejects free-form/non-circuit payloads, and the preview companion is documented as review evidence rather than a LaTeX/TikZJax compile result.
- Updated the GitHub Pages diagram documentation and maintainer runbooks. The MDX synchronization policy is explicit: do not blindly commit every generated locale file for small feature increments; commit localized MDX only when published route/frontmatter/heading or visible behavior parity requires it.
- Added an optional managed CircuitikZ desktop runtime. Notemd can discover trusted system Tectonic/pdfLaTeX executables, install or repair a pinned Tectonic build outside the Vault with integrity checks, remove only the managed installation, and report smoke-test capabilities while dependency-free previews remain available on mobile.
- Fixed long diagram-history drawers so the flex container can shrink, the history body scrolls within the preview shell, and overscroll stays contained.
- Updated the maintainer CLI bridge to prefer `obsidian-cli native eval` when that wrapper is installed and fall back to the official `obsidian eval` command only when the wrapper command is unavailable.
### 中文
@ -23,6 +26,9 @@ This document summarizes the major functional and architectural changes implemen
- 加固 artifact CLI使其兼容 Windows 上 npm 11 参数重写后的有序位置参数形态;同时将 circuitikz SVG 画布背景改为 inline 白色,避免 standalone viewer 把透明 SVG 显示成黑底。
- 电路路径继续保持 topology-firstprompt 要求结构化 `CircuitSpec`,校验拒绝自由形式/非电路 payload并且文档明确 preview companion 是审查证据,不是 LaTeX/TikZJax 编译结果。
- 更新 GitHub Pages 图表文档与维护者 runbook。MDX 同步策略已明确:小功能增量不要盲目提交所有 generated locale 文件;只有公开 route/frontmatter/heading 或可见行为需要对齐时,才提交对应 localized MDX。
- 新增可选的 CircuitikZ 桌面端托管运行时。Notemd 可以发现受信任的系统 Tectonic/pdfLaTeX 可执行文件,在 Vault 之外通过完整性校验安装或修复固定版本的 Tectonic仅删除托管安装并报告 smoke-test 能力;移动端仍可使用零依赖预览。
- 修复较长图形历史抽屉的滚动flex 容器现在可以收缩,历史正文在预览 shell 内滚动,并限制 overscroll 外溢。
- 更新维护者 CLI bridge安装了 `obsidian-cli` wrapper 时优先使用 `obsidian-cli native eval`;仅当 wrapper 命令不可用时,回退到官方 `obsidian eval` 命令。
---

View file

@ -208,9 +208,11 @@ The spec-first pipeline can also force a render target independently from the in
| `editable-html-svg` | Self-contained HTML with semantic inline SVG | no external editor runtime |
| `drawio` | `.drawio` XML plus SVG/MD review companions | no diagrams.net runtime in the plugin |
| `drawnix` | `.drawnix` JSON subset plus SVG/MD review companions | no Drawnix or Plait runtime in the plugin |
| `circuitikz` | validated `.tex` source plus SVG/MD review companions | no bundled LaTeX or TikZJax runtime |
| `circuitikz` | validated `.tex` source plus SVG/MD review companions | dependency-free preview/export; optional desktop compiler or managed Tectonic |
Circuitikz support is intentionally constrained. The front-end settings now expose `Circuit (Circuitikz)` as a preferred diagram type and `Circuitikz + SVG preview` as a preferred render target without requiring Developer mode, but the renderer still accepts only a validated `DiagramSpec(intent: "circuit", circuitSpec)`. It writes deterministic circuitikz TeX and a reviewable SVG companion; real LaTeX/TikZJax compile evidence remains an optional maintainer smoke check.
Circuitikz support is intentionally constrained. The front-end settings expose `Circuit (Circuitikz)` as a preferred diagram type and `Circuitikz + SVG preview` as a preferred render target without requiring Developer mode, but the renderer accepts only a validated `DiagramSpec(intent: "circuit", circuitSpec)`. It writes deterministic circuitikz TeX and a reviewable SVG companion. Desktop users may then reuse a custom/system compiler or explicitly install pinned Tectonic 0.16.9 outside the Vault for compile diagnostics, native PDF evidence, and guarded repair acceptance; mobile and ordinary preview/export do not load desktop process code.
The managed-runtime boundary is ownership-based rather than name-based. Downloaded assets are host-allowlisted, size-bounded, checksum-verified, extracted without links or traversal, smoke-tested in staging, and activated under a filesystem lock. Existing paths must remain under the configured runtime root after canonical `realpath` resolution. Removal accepts only valid Notemd pointer/install-local ownership evidence, while stale-lock recovery atomically quarantines a claimed dead-owner lock and revalidates its owner and claim token before deletion.
## Module Map
@ -235,8 +237,9 @@ Circuitikz support is intentionally constrained. The front-end settings now expo
Current host evidence matters:
- the local stable wrapper `obsidian-cli` on this machine exposes desktop/debug entrypoints such as `help`, `version`, `vaults`, `vault`, `doctor`, `native`, `gui`, and `debug`
- the underlying official `obsidian` CLI already supports `commands` and `command id=<command-id>`, and it can list/execute plugin-registered commands
- the optional `obsidian-cli` wrapper may expose desktop/debug entrypoints such as `native`, but it is not installed on the current Windows Study host; the stale npm package with the same name is not a safe substitute because it predates the official CLI and shadows the `obsidian` executable
- the official `obsidian` CLI supports `commands`, `command id=<command-id>`, and `eval`; it can list/execute plugin-registered commands and invoke the maintainer bridge directly
- `scripts/invoke-maintainer-cli-operation.js` prefers `obsidian-cli native eval` when a compatible wrapper exists, then falls back to official `obsidian eval` only when the wrapper command is unavailable; a present-but-failing wrapper is surfaced rather than masked
- however, this is still only a **command trigger surface**, not a mature plugin integration protocol with typed arguments, result contracts, capability metadata, or stable automation semantics
That means Notemd's future CLI story still cannot stop at "reuse sidebar buttons from the terminal". The real extraction targets are lower-level capabilities that already have partial independent shape:

View file

@ -208,9 +208,11 @@ flowchart LR
| `editable-html-svg` | 带语义 inline SVG 的自包含 HTML | 不依赖外部编辑器 runtime |
| `drawio` | `.drawio` XML 加 SVG/MD review companion | 插件内不嵌入 diagrams.net runtime |
| `drawnix` | `.drawnix` JSON 子集加 SVG/MD review companion | 插件内不嵌入 Drawnix 或 Plait runtime |
| `circuitikz` | 经过验证的 `.tex` 源文件加 SVG/MD review companion | 不捆绑 LaTeX 或 TikZJax runtime |
| `circuitikz` | 经过验证的 `.tex` 源文件加 SVG/MD review companion | 预览/导出零依赖;桌面端可选本机编译器或托管 Tectonic |
Circuitikz 支持仍然是受约束的。前端设置现在无需开启 Developer mode 就会显示 `Circuit (Circuitikz)` 首选图表类型与 `Circuitikz + SVG preview` 首选渲染目标,但 renderer 仍只接受经过验证的 `DiagramSpec(intent: "circuit", circuitSpec)`。它会写出确定性的 circuitikz TeX 和可审阅的 SVG companion真实 LaTeX/TikZJax 编译证据仍属于可选的维护者 smoke check。
Circuitikz 支持仍然是受约束的。前端设置无需开启 Developer mode 就会显示 `Circuit (Circuitikz)` 首选图表类型与 `Circuitikz + SVG preview` 首选渲染目标,但 renderer 只接受经过验证的 `DiagramSpec(intent: "circuit", circuitSpec)`。它会写出确定性的 circuitikz TeX 和可审阅的 SVG companion。桌面用户随后可以复用自定义/系统编译器,或在 Vault 外显式安装固定版本 Tectonic 0.16.9,用于编译诊断、原生 PDF 证据与受保护的修复验收;移动端与常规预览/导出不会加载桌面进程代码。
托管运行时边界按所有权而不是目录名称判断。下载资产经过主机白名单、体积上限和 checksum 校验,解压拒绝链接与路径穿越,在 staging 中通过 smoke 后才在文件系统锁内激活。已有路径必须在规范化 `realpath` 解析后仍位于配置的运行时根目录内。删除只接受有效 Notemd pointer 或安装目录内所有权证据;过期锁恢复会先把已声明的死亡 owner 锁原子隔离,再复核 owner 与 claim token 后删除。
## 模块地图
@ -235,8 +237,9 @@ Circuitikz 支持仍然是受约束的。前端设置现在无需开启 Develope
当前宿主事实必须明确写清:
- 本机上的稳定包装器 `obsidian-cli` 暴露的是 `help`、`version`、`vaults`、`vault`、`doctor`、`native`、`gui`、`debug` 等桌面/调试入口
- 底层官方 `obsidian` CLI 实际已经支持 `commands``command id=<command-id>`,并且可以列出/执行插件注册命令
- 可选的 `obsidian-cli` 包装器可能提供 `native` 等桌面/调试入口,但当前 Windows Study 主机并未安装npm 上同名的旧包早于官方 CLI且会遮蔽 `obsidian` 可执行文件,因此不能作为安全替代品
- 官方 `obsidian` CLI 已支持 `commands`、`command id=<command-id>` 与 `eval`,能够列出/执行插件命令,也可直接调用 maintainer bridge
- `scripts/invoke-maintainer-cli-operation.js` 在兼容包装器存在时优先使用 `obsidian-cli native eval`,只在命令不存在时回退到官方 `obsidian eval`;如果包装器已存在但执行失败,则原样暴露失败,不会静默掩盖
- 但这仍然只是**命令触发表面**,不是成熟的插件集成协议:它还缺少类型化参数、返回结果契约、能力元数据和稳定自动化语义
因此Notemd 的未来 CLI 路线仍不能停留在“把 sidebar 按钮搬到终端”。真正值得抽取的是已经开始具备独立形态的低层能力:

View file

@ -83,21 +83,24 @@ A golden reference is mandatory for reliable circuit generation. It should defin
For example, the common-source amplifier reference should keep the working pattern:
```latex
\documentclass[border=8pt]{standalone}
\usepackage{circuitikz}
\begin{document}
\begin{circuitikz}[american voltages]
\begin{circuitikz}[american voltages, line width=0.5pt, font=\small]
\draw
(3,5) node[vcc]{$V_{DD}$}
to [R, l=$R_D$] (3,3)
to [short, *-o] (5,3) node[right]{$v_{out}$}
to [R, l=$R_D$] (3,3);
\draw
(3,3) to [short, *-o] (5,3) node[right]{$v_{out}$};
\draw
(3,3) to [short] (3,2.2)
node[nmos, anchor=D] (M1) {$M_1$}
node[nmos, anchor=D] (M1) {$M_1$};
\draw
(M1.S) to [short] (3,0.5)
node[ground]{}
node[ground]{};
\draw
(M1.G) to [short, -o] (0.8,2.2)
node[left]{$v_{in}$};
\draw
(3,0.5) node[below right]{$S$};
\end{circuitikz}
\end{document}
```
@ -134,6 +137,7 @@ Screenshot feedback can be manual first. Automated screenshot checks should star
| C. circuitikz adapter | Emit deterministic LaTeX for golden templates | generated LaTeX matches stable snapshots and contains required anchors |
| D. render feedback | Add optional local TikZJax/LaTeX smoke path | compile failures return actionable diagnostics |
| E. visual repair loop | Feed rendered image or overlap report back into repair prompt | repeated layout errors are corrected without changing topology |
| F. managed desktop environment | Discover safe local compilers and offer an explicit managed Tectonic install | UI probe/install/remove works, golden fixtures compile, and dependency-free export remains intact |
## Implementation Status
@ -154,20 +158,26 @@ Phase A is documented. Phase B/C now have a constrained repository prototype:
- `src/rendering/diagnostics.ts` summarizes `RenderArtifact.diagnostics` into error/warning/info counts, and `src/ui/DiagramPreviewModal.ts` uses that summary in both the artifact diagnostics panel and preview history entries. Any render target can attach diagnostics to `RenderArtifact.diagnostics`; the preview UI shows the summary, severity, kind, message, and advice without requiring TikZJax or LaTeX in the plugin runtime.
- `src/tests/circuitikzExporter.test.ts`, `src/tests/circuitikzRepairBrief.test.ts`, `src/tests/circuitikzCompileDiagnostics.test.ts`, `src/tests/circuitikzRenderSmoke.test.ts`, `src/tests/circuitikzCompileRunner.test.ts`, and `src/tests/circuitikzExportCli.test.ts` verify deterministic output, topology rejection, topology-preserving repair brief generation and candidate validation, package-script exposure, UTF-8 BOM handling, diagnostic parsing, shell-free compile execution, structured `compile-executable-invalid` / `compile-executable-not-found` diagnostics, diagnostics JSON output, render artifact existence/non-empty smoke checks, SVG structure checks, path-only SVG label classification, path-only glyph placement checks, definition-local glyph transform checks, path-only glyph overlap diagnostics, exact arc bounds for A/a arc extrema, exact Bezier curve bounds for C/S/Q/T curve extrema, stroke-width-aware SVG bounds and label overlap checks, `polyline` / `polygon` drawing geometry checks, positioned `tspan` label geometry checks, transform-aware SVG geometry checks, SVG label-vs-drawing overlap checks, PNG blank screenshot checks, indexed-color and grayscale PNG packed sample decoding, indexed-color PNG palette decoding, grayscale/RGB PNG tRNS transparent sample handling, format-specific unsupported PNG diagnostics, 16-bit direct PNG sample normalization, PNG foreground-bound and foreground-density reporting, PNG clipped-content diagnostics, dense-foreground diagnostics, and nonzero CLI exit for logs or smoke reports with errors.
- `src/tests/diagramPreviewModal.test.ts` verifies that artifact diagnostics are visible in the diagram preview modal and that preview history does not collapse entries with different diagnostics.
- `src/diagram/adapters/circuitikz/circuitikzRepairLoop.ts` now provides the opt-in Phase E execution boundary. It creates the dedicated repair prompt, accepts only one `CircuitSpec` JSON candidate, rejects topology drift before export, evaluates the generated TeX with caller-supplied compile/render evidence, and adopts the candidate only when every acceptance gate passes. The loop is deliberately capped at one attempt and remains disabled when a renderer is unavailable.
- The common-source golden template now uses separate terminated draw paths, a lighter `0.5pt` stroke, a smaller body font, and no redundant source-terminal annotation. This removes the earlier path crowding and label collision without changing electrical topology.
- `src/tests/circuitikzRepairLoop.test.ts` covers successful repair, topology drift, non-JSON output, failed second-pass visual acceptance, and renderer-unavailable fail-closed behavior.
- `src/latexEnvironment/` and `src/platform/desktopProcess.ts` implement Phase F without enlarging the plugin bundle with a TeX distribution. Desktop users can reuse a custom/system compiler or explicitly install pinned Tectonic `0.16.9`; downloads are HTTPS-host allowlisted, size-bounded, SHA-256 verified, extracted through traversal/link guards, smoke-tested in staging against six deterministic fixtures, and atomically activated with rollback and cancellation cleanup.
- `src/ui/CircuitikzEnvironmentModal.ts` exposes probe, install/repair, cancel, remove, capability, progress, and bounded-log states. The module loads desktop execution code lazily; mobile retains dependency-free preview and SVG/PNG/preview-PDF export.
- The diagram-history drawer now establishes the Grid minimum-size invariant with `min-height: 0`; its body owns `overflow: auto` and contains overscroll, so long Vault histories remain reachable without scrolling the preview behind the drawer.
Phase D now has log parsing, opt-in local renderer execution, explicit missing-renderer availability reports for fixture smoke evidence, artifact-level smoke checks, SVG structure smoke checks with hidden/transparent element exclusion, accessibility metadata expected-text checks through `aria-label`, `<title>`, and `<desc>`, transform-aware geometry, path-only label classification, path-only glyph placement checks, path-only glyph overlap diagnostics, close-path current-point handling, exact arc bounds for A/a arc extrema, exact Bezier curve bounds for C/S/Q/T curve extrema, stroke-width-aware SVG bounds and label overlap checks, `polyline` / `polygon` geometry coverage, positioned `tspan` label geometry, `text-anchor`-aware positioned text geometry, text/text and label/drawing overlap diagnostics, first PNG screenshot nonblank, indexed-color and grayscale packed-sample decoding, grayscale/RGB tRNS transparent sample handling, format-specific unsupported PNG guidance for Adam7 interlace and indexed bit-depth failures, 16-bit direct sample normalization, edge-clipping, and dense-foreground checks, deterministic layout-hint projection for input/output ports, a topology-preserving repair guard, a topology-preserving repair brief handoff with candidate validation, structured `repairPrompt` / `diagnosticFocus` guidance, `repairAcceptance` gate evidence for repair candidates, and a front-end diagnostics surface, but the implementation still deliberately stops before automatic renderer installation/discovery, OCR recognition for path-only glyph text, precise pixel-level overlap detection, full SVG path coverage, browser-grade text layout, and automated Phase E repair execution. It does not bundle LaTeX, make TikZJax a plugin runtime dependency, or run a visual repair loop.
Phase D now provides actionable compile and artifact diagnostics, including SVG geometry/overlap and PNG nonblank/clipping/density checks. Phase E has a bounded topology-preserving execution loop and still refuses candidates without fresh compile and render-smoke evidence. Phase F adds optional desktop discovery and user-initiated managed installation while keeping the ordinary preview/export path dependency-free. Notemd still does not embed TeX Live, MiKTeX, TinyTeX, or Tectonic archives in `main.js`. OCR for path-only glyph text, precise pixel-level overlap detection, complete SVG path coverage, and browser-grade text layout remain future evidence improvements.
The SVG geometry smoke also covers SVG number grammar for leading-dot decimals and explicit plus signs. This is a small but important renderer-compatibility gate because dvisvgm can emit compact decimals that should remain fractional during bounds checks.
## Best Current Practice
Until `CircuitSpec` exists, use constrained prompts:
For supported circuit families, use the structured `CircuitSpec` path:
1. Provide a renderable golden reference.
2. Ask for a named circuit family, not a vague "draw circuit."
3. Lock orientation, anchors, and input/output sides.
4. Require a topology checklist before LaTeX.
5. Render once, then use the screenshot or compile log for a repair pass.
4. Let the deterministic adapter own LaTeX rather than asking the model for raw TikZ.
5. When a local renderer is configured, use compile/render diagnostics for the single guarded repair attempt.
This will outperform unconstrained ChatGPT/Gemini TikZ generation because it limits the model's freedom to the parts that matter: choosing components and topology, not inventing every coordinate and route from scratch.

View file

@ -83,21 +83,24 @@ source note / prompt
例如 common-source amplifier reference 应保留这类可工作模式:
```latex
\documentclass[border=8pt]{standalone}
\usepackage{circuitikz}
\begin{document}
\begin{circuitikz}[american voltages]
\begin{circuitikz}[american voltages, line width=0.5pt, font=\small]
\draw
(3,5) node[vcc]{$V_{DD}$}
to [R, l=$R_D$] (3,3)
to [short, *-o] (5,3) node[right]{$v_{out}$}
to [R, l=$R_D$] (3,3);
\draw
(3,3) to [short, *-o] (5,3) node[right]{$v_{out}$};
\draw
(3,3) to [short] (3,2.2)
node[nmos, anchor=D] (M1) {$M_1$}
node[nmos, anchor=D] (M1) {$M_1$};
\draw
(M1.S) to [short] (3,0.5)
node[ground]{}
node[ground]{};
\draw
(M1.G) to [short, -o] (0.8,2.2)
node[left]{$v_{in}$};
\draw
(3,0.5) node[below right]{$S$};
\end{circuitikz}
\end{document}
```
@ -134,6 +137,7 @@ screenshot feedback 可以先保持人工执行。自动 screenshot 检查应从
| C. circuitikz adapter | 为 golden templates 输出 deterministic LaTeX | generated LaTeX 匹配 stable snapshots并包含 required anchors |
| D. render feedback | 增加可选的本地 TikZJax/LaTeX smoke path | compile failures 返回 actionable diagnostics |
| E. visual repair loop | 将 rendered image 或 overlap report 反馈给 repair prompt | 不改变拓扑的前提下修正重复 layout errors |
| F. 桌面托管环境 | 安全发现本机编译器,并提供显式托管 Tectonic 安装 | UI 探测/安装/删除可用golden fixtures 可编译,零依赖导出保持不变 |
## 实现状态
@ -154,20 +158,23 @@ Phase A 已经文档化。Phase B/C 现在有了受约束的仓库内原型:
- `src/rendering/diagnostics.ts` 会把 `RenderArtifact.diagnostics` 汇总为 error/warning/info 计数,`src/ui/DiagramPreviewModal.ts` 会在 artifact diagnostics panel 和 preview history entries 中使用同一份 summary。任意 render target 都可以把 diagnostics 挂到 `RenderArtifact.diagnostics`preview UI 会显示 summary、severity、kind、message 和 advice而不要求插件 runtime 内置 TikZJax 或 LaTeX。
- `src/tests/circuitikzExporter.test.ts`、`src/tests/circuitikzRepairBrief.test.ts`、`src/tests/circuitikzCompileDiagnostics.test.ts`、`src/tests/circuitikzRenderSmoke.test.ts`、`src/tests/circuitikzCompileRunner.test.ts` 与 `src/tests/circuitikzExportCli.test.ts` 验证确定性输出、拓扑拒绝、topology-preserving repair brief generation 与 candidate validation、package-script 暴露、UTF-8 BOM 处理、diagnostic parsing、shell-free compile execution、结构化 `compile-executable-invalid` / `compile-executable-not-found` diagnostics、diagnostics JSON 输出、render artifact 存在/非空 smoke checks、SVG 结构检查、path-only SVG label classification、path-only glyph placement checks、definition-local glyph transform checks、path-only glyph overlap diagnostics、针对 A/a arc extrema 的 exact arc bounds、针对 C/S/Q/T curve extrema 的 exact Bezier curve bounds、stroke-width-aware SVG bounds 与 label overlap checks、`polyline` / `polygon` drawing geometry checks、positioned `tspan` label geometry checks、`text-anchor`-aware positioned text geometry checks、transform-aware SVG geometry checks、SVG label-vs-drawing overlap checks、PNG 空白截图检查、indexed-color 与 grayscale PNG packed sample decoding、indexed-color PNG palette decoding、grayscale/RGB PNG tRNS transparent sample handling、format-specific unsupported PNG diagnostics、16-bit direct PNG sample normalization、PNG 前景包围盒与前景密度报告、PNG 裁剪内容诊断、异常密集前景诊断,以及 compile log 或 smoke report 含错误时 CLI 非零退出。
- `src/tests/diagramPreviewModal.test.ts` 验证 artifact diagnostics 会显示在 diagram preview modal 中,并且 preview history 不会把 diagnostics 不同的条目错误去重。
- `src/latexEnvironment/``src/platform/desktopProcess.ts` 实现 Phase F但不会把 TeX 发行版塞进插件包。桌面用户可以复用自定义/系统编译器,或显式安装固定版本 Tectonic `0.16.9`;下载经过 HTTPS 主机白名单、体积上限、SHA-256、路径穿越/链接防护,并在 staging 中编译六个确定性 fixture 后原子激活,取消和失败会清理临时状态并保留旧版本。
- `src/ui/CircuitikzEnvironmentModal.ts` 提供探测、安装/修复、取消、删除、能力摘要、进度与有限日志状态。桌面执行模块采用延迟加载;移动端继续保留零依赖预览和 SVG/PNG/预览 PDF 导出。
- 图形历史右侧抽屉现在显式建立 Grid 最小尺寸不变量 `min-height: 0`,由 body 负责 `overflow: auto` 并阻止 overscroll 传递,因此长历史可以完整滚动,不会带动底层预览。
Phase D 现在已经具备 log parsing、opt-in local renderer execution、fixture smoke evidence 的显式 missing-renderer availability reports、artifact-level smoke checks、带 hidden/transparent element exclusion、通过 `aria-label`、`<title>` 与 `<desc>` 执行的 accessibility metadata expected-text checks、transform-aware geometry、path-only label classification、path-only glyph placement checks、path-only glyph overlap diagnostics、close-path current-point handling、针对 A/a arc extrema 的 exact arc bounds、针对 C/S/Q/T curve extrema 的 exact Bezier curve bounds、stroke-width-aware SVG bounds 与 label overlap checks、`polyline` / `polygon` geometry coverage、positioned `tspan` label geometry、`text-anchor`-aware positioned text geometry、text/text 与 label/drawing overlap diagnostics 的 SVG structure smoke checks、第一层 PNG screenshot nonblank、indexed-color 与 grayscale packed-sample decoding、grayscale/RGB tRNS transparent sample handling、针对 Adam7 interlace 与 indexed bit-depth failures 的 format-specific unsupported PNG guidance、16-bit direct sample normalization、edge-clipping 与 dense-foreground checks、针对 input/output ports 的确定性 layout-hint projection、topology-preserving repair guard、带 candidate validation 的 topology-preserving repair brief handoff、结构化 `repairPrompt` / `diagnosticFocus` guidance、repair candidates 的 `repairAcceptance` gate evidence以及前端 diagnostics surface但实现仍有意停在 automatic renderer installation/discovery、path-only glyph text 的 OCR recognition、精确 pixel-level overlap detection、完整 SVG path coverage、browser-grade text layout 和自动化 Phase E repair execution 之前。它不会捆绑 LaTeX、不会把 TikZJax 变成插件 runtime 依赖,也不会运行视觉修复闭环。
Phase D 现在提供可操作的编译和产物诊断,包括 SVG 几何/遮挡与 PNG 非空、裁剪、密度检查。Phase E 已具备有次数上限且保持拓扑的执行闭环,并继续拒绝缺少新鲜 compile/render-smoke 证据的候选。Phase F 新增可选桌面环境发现与用户主动触发的托管安装,同时保持常规预览/导出零依赖。Notemd 仍不会把 TeX Live、MiKTeX、TinyTeX 或 Tectonic 压缩包嵌入 `main.js`。path-only glyph OCR、精确像素级遮挡、完整 SVG path 覆盖和浏览器级文字布局仍是后续证据增强项
SVG geometry smoke 也覆盖 leading-dot decimals 与 explicit plus signs 的 SVG number grammar。这是一个很小但关键的 renderer-compatibility gate因为 dvisvgm 可能输出紧凑小数bounds checks 中必须保留其小数语义。
## Best Current Practice
`CircuitSpec` 尚未存在前,使用强约束 prompt
对于已支持的电路族,使用结构化 `CircuitSpec` 链路
1. 提供一个可渲染 golden reference。
2. 请求具名 circuit family而不是笼统说“draw circuit”。
3. 固定 orientation、anchors 和 input/output sides。
4. 在 LaTeX 前要求 topology checklist
5. 渲染一次,然后用 screenshot 或 compile log 进行 repair pass
4. 让确定性 adapter 负责 LaTeX不要求模型直接编写 TikZ
5. 本地 renderer 可用时,用 compile/render diagnostics 执行一次受保护的 repair attempt
这会优于不受约束的 ChatGPT/Gemini TikZ 生成,因为它把模型自由度限制在真正重要的部分:选择元件与拓扑,而不是从零发明每个坐标和 route。
@ -177,3 +184,11 @@ SVG geometry smoke 也覆盖 leading-dot decimals 与 explicit plus signs 的 SV
- 泛化的 “TikZ renderer” 范围过大。应先从 circuitikz 和少量 circuit families 开始。
- 没有 topology locking 的 visual feedback 可能通过改电路来修布局。repair prompt 必须把 topology 视为 invariant。
- circuitikz package 版本可能不同。golden references 必须记录预期 package behavior 和 renderer environment。
## Phase E 执行边界
`src/diagram/adapters/circuitikz/circuitikzRepairLoop.ts` 现在提供可选启用的 Phase E 执行边界:构造专用修复 prompt只接受一次 `CircuitSpec` JSON 候选,在导出前拒绝拓扑漂移,并用调用方提供的实际编译/渲染证据评估生成的 TeX只有全部验收门通过才采用候选。渲染器不可用时不会调用模型。
common-source golden template 现在拆分为独立且正确终止的绘制路径,采用更轻的 `0.5pt` 线宽和较小正文字号,并移除冗余源极标签;在不改变电气拓扑的前提下消除早期的路径拥挤与标签遮挡。
Phase D 现在提供可操作的编译与产物诊断,包括 SVG 几何/遮挡以及 PNG 非空、裁剪和密度检查。Phase E 已具备有次数上限、拓扑保持的执行闭环但启用仍是显式且依赖注入的Notemd 不会捆绑 LaTeX不会自动安装或发现渲染器也不会在缺少新鲜编译与 render-smoke 证据时采用候选。path-only glyph OCR、精确像素级遮挡、完整 SVG path 覆盖和浏览器级文字布局仍是后续证据增强项。

View file

@ -0,0 +1,96 @@
---
date: 2026-07-19
topic: circuitikz-managed-runtime-and-history-scroll-plan
---
# CircuitikZ Managed Runtime And History Scroll Plan
Language: **English** | [简体中文](./circuitikz-managed-runtime-and-history-scroll-plan-2026-07-19.zh-CN.md)
## Decision
Notemd will not embed TeX Live, MiKTeX, TinyTeX, or any executable archive in `main.js`. Diagram generation and companion-SVG preview/export remain dependency-free. Native LaTeX is an explicit desktop-only enhancement for compile diagnostics, native PDF evidence, and the guarded CircuitikZ repair acceptance path.
The first managed runtime is pinned Tectonic `0.16.9`. It is small enough for an optional download, publishes platform-specific release archives with SHA-256 digests, supports an untrusted compilation mode, and can fetch TeX resources on demand. Existing system `tectonic` and `pdflatex` installations remain preferred reusable candidates. MiKTeX and TeX Live are discovered as system environments instead of being bundled.
## User Experience
The experimental diagram settings expose one compact row, **CircuitikZ native compile environment (optional)**, with a button that opens a dedicated environment sheet. The same sheet is available from the command palette.
The sheet must show:
- the selected compiler source, version, executable path, and readiness;
- the difference between dependency-free preview exports and native LaTeX compilation;
- platform, architecture, pinned runtime version, download size, install path, and license link;
- check, install or repair, cancel, retry, remove-managed-runtime, and explicit stale-install-lock recovery actions;
- live download bytes, current phase, bounded logs, and an explicit completion or failure state;
- a capability summary for compile diagnostics, native PDF, and repair acceptance.
Routine failures stay inline. They must not create generic error popups. Installation is always user initiated and never runs in the background without consent.
## Runtime Architecture
```text
settings / command palette
-> CircuitikzEnvironmentModal
-> probeCircuitikzEnvironment()
-> custom executable
-> managed Tectonic
-> system Tectonic
-> system pdflatex
-> installManagedTectonic()
-> allowlisted HTTPS redirect chain
-> size-limited streamed download
-> SHA-256 verification
-> traversal-safe executable extraction
-> staging compile smoke with --untrusted / -no-shell-escape
-> atomic version activation
-> fresh environment probe
```
Desktop process execution uses direct argument arrays with `shell: false`, bounded output, timeout handling, cancellation, and process-tree termination. UI code does not assemble arbitrary shell strings.
Managed files live in the operating-system application-data directory by default, not in the Vault. An advanced custom runtime root can move the managed directory to another disk. The Vault stores only preference values; it does not receive `node_modules`, package-manager files, compiler archives, or TeX caches.
## Security And Failure Contracts
- Only pinned assets from approved HTTPS hosts are accepted.
- Redirects are revalidated; compressed size is bounded before and during download.
- Every archive must match its pinned SHA-256 digest.
- Extraction accepts only the expected `tectonic` executable and rejects absolute paths, `..`, symlinks, and hard links.
- Downloads and extraction use sibling staging paths. Cancellation, timeout, checksum mismatch, smoke failure, and write failure remove staging state and preserve the previous active version.
- Runtime discovery is read-only. Pointer normalization and legacy ownership registration run only while holding the managed-runtime lock. Existing directories are reused only when a valid Notemd pointer or install-local ownership manifest proves ownership, the executable digest matches, and POSIX execute permission is present.
- Ownership paths must pass both lexical containment and canonical `realpath` containment. A symlink or Windows junction under the runtime root cannot turn a Notemd pointer into authority to inspect or recursively remove an external user directory.
- Automatic stale-lock deletion is forbidden. The environment panel offers an explicit recovery action that clears a lock only after an exclusive cleanup claim, a stable owner token, and repeated dead-process checks. The claimed directory is atomically renamed to a token-qualified quarantine and revalidated there before deletion; a lock replaced at the final commit boundary is restored or preserved rather than deleted.
- Tectonic uses `--untrusted`; `pdflatex` uses `-no-shell-escape`.
- Smoke compilation uses a deterministic Notemd-owned CircuitikZ fixture, not arbitrary user-provided TeX.
- Mobile keeps the current companion-SVG path and reports native compilation as unsupported without loading desktop process code.
## History Drawer Root Cause And Fix
The drawer is a CSS Grid item. Its default `min-height: auto` expands the implicit grid row to the history content height. The body therefore has no internal overflow, while the layer clips the expanded drawer with `overflow: hidden`.
The root fix is to set `min-height: 0` on `.notemd-diagram-history-drawer`. The existing body `min-height: 0` and `overflow: auto` remain, with `overscroll-behavior: contain` to prevent scroll chaining into the preview. The shared standalone history modal keeps its own scroll model.
## Test Plan
1. Add a failing CSS contract test for the drawer minimum-size invariant, then apply the minimal style fix.
2. Unit-test platform/architecture selection, PATH discovery, compiler preference, capability reporting, and mobile behavior.
3. Unit-test streamed download progress, cancellation, timeout, redirect allowlisting, size limits, checksums, archive traversal rejection, staging cleanup, and atomic activation.
4. Unit-test direct process execution and the exact untrusted/no-shell-escape argument arrays.
5. Use local HTTP fixtures for installer tests; Jest must not depend on public network availability.
6. Test the modal states and settings/command wiring in English and Simplified Chinese.
7. Run the complete plugin build, Jest suite, i18n audit, render-host audit, website build, and documentation contracts.
8. Deploy `main.js`, `manifest.json`, `styles.css`, and `README.md` to the Study Vault, reload Obsidian, verify long-history scrolling and native-LaTeX smoke, confirm that the tested flows create no error popup/notice, and record whether the host exposes the optional developer-console CLI commands.
## Phase Exit Criteria
This work adds Phase F to the CircuitikZ roadmap. Phase F is complete only when the optional environment can be probed from the UI, a supported managed artifact can be installed with integrity verification and cancellation, a real common-source fixture compiles through the selected environment, the history drawer scrolls in the real preview, documentation is synchronized, all gates pass, and `main` is pushed with a clean worktree.
## Implementation Progress
- Complete in code: compiler discovery and preference ordering, ownership-scoped managed installation/removal, read-only recovery discovery, canonical-path containment, token-quarantined stale-lock recovery, six-fixture acceptance, desktop process cancellation/timeout, environment settings and modal, command-palette entry, mobile lazy boundary, history Grid/overflow fix, and regression tests for both linked-ancestor deletion and final-boundary lock replacement.
- Complete in documentation: English/Simplified-Chinese user diagram guidance, every root README language, the CircuitikZ roadmap, this bilingual plan/progress record, and the MDX repository policy analysis.
- Complete in repository verification: production build; 233 Jest suites with 1,977 passing tests and one Windows-skipped POSIX-only process test; i18n and render-host audits; VitePress build; all-34-locale Docusaurus build; `git diff --check`; and a final independent review with no remaining Critical or Important finding.
- Complete in the Study Vault: deployed assets match the repository byte-for-byte; Notemd 1.9.3 and all diagram commands are registered; managed Tectonic 0.16.9 reports ready; the preview history drawer measures 671 px client height versus 1,139 px scroll height, reaches its 468 px bottom offset, and exposes the pager; no popup or notice appeared in the tested flows. The optional `dev:errors` / `dev:console` host commands returned exit code 1 without output, so they are recorded as unavailable host diagnostics rather than treated as proof of a plugin error.
- Frontend-law strict gate: 100/100, zero fast-gate failures, zero principle failures, and zero unknown checks. This mainline commit closes Phase F once push and clean-worktree equality with `origin/main` are confirmed.

View file

@ -0,0 +1,96 @@
---
date: 2026-07-19
topic: circuitikz-managed-runtime-and-history-scroll-plan
---
# CircuitikZ 托管运行时与历史滚动方案
语言:[English](./circuitikz-managed-runtime-and-history-scroll-plan-2026-07-19.md) | **简体中文**
## 决策
Notemd 不会把 TeX Live、MiKTeX、TinyTeX 或任何可执行压缩包嵌入 `main.js`。图形生成、companion SVG 预览和预览导出继续保持零依赖。原生 LaTeX 是显式、仅桌面端可用的增强能力,用于编译诊断、原生 PDF 证据和受保护的 CircuitikZ 修复验收链路。
第一版托管运行时固定为 Tectonic `0.16.9`。它适合作为可选下载,官方按平台发布压缩包与 SHA-256支持不受信任编译模式并能按需取得 TeX 资源。现有系统 `tectonic``pdflatex` 仍作为优先复用候选。MiKTeX 与 TeX Live 通过系统环境发现支持,不随插件捆绑。
## 用户体验
实验图形设置区新增紧凑设置项 **CircuitikZ 原生编译环境(可选)**,按钮打开独立环境面板;命令面板也可直接打开同一界面。
环境面板必须展示:
- 当前编译器来源、版本、可执行路径与就绪状态;
- 零依赖预览导出与原生 LaTeX 编译之间的区别;
- 平台、架构、固定运行时版本、下载大小、安装目录与许可证链接;
- 检查、安装或修复、取消、重试、删除托管运行时,以及显式清理过期安装锁等操作;
- 实时下载字节数、当前阶段、有限长度日志和明确的完成/失败状态;
- 编译诊断、原生 PDF 与修复验收能力摘要。
常规失败只在界面内显示,不弹出笼统错误窗口。安装必须由用户显式触发,不得静默后台下载。
## 运行时架构
```text
设置 / 命令面板
-> CircuitikzEnvironmentModal
-> probeCircuitikzEnvironment()
-> 自定义可执行文件
-> 托管 Tectonic
-> 系统 Tectonic
-> 系统 pdflatex
-> installManagedTectonic()
-> HTTPS 主机白名单与重定向复核
-> 有体积上限的流式下载
-> SHA-256 校验
-> 防路径穿越的可执行文件解压
-> staging 中使用 --untrusted / -no-shell-escape 编译 smoke
-> 原子激活版本
-> 重新执行环境探测
```
桌面进程执行使用直接参数数组与 `shell: false`限制输出缓存支持超时、取消和进程树终止。UI 不拼装任意 shell 字符串。
托管文件默认位于操作系统应用数据目录,而不是 Vault。高级自定义运行时根目录可将其迁移到其他磁盘。Vault 只保存偏好,不产生 `node_modules`、包管理文件、编译器压缩包或 TeX 缓存。
## 安全与失败契约
- 只接受固定版本且来自获准 HTTPS 主机的资产。
- 每次重定向重新验证主机;下载前后都执行压缩体积上限。
- 所有压缩包必须匹配固定 SHA-256。
- 解压时只接受预期的 `tectonic` 可执行文件,拒绝绝对路径、`..`、符号链接和硬链接。
- 下载与解压使用同级 staging。取消、超时、校验失败、smoke 失败和写入失败都清理 staging并保留旧的活动版本。
- 运行时发现保持只读pointer 规范化与旧版所有权登记只在持有托管运行时锁时执行。只有有效的 Notemd pointer 或安装目录内所有权清单能够证明所有权、可执行文件摘要匹配,且 POSIX 执行权限有效时,才允许复用已有目录。
- 所有权路径必须同时通过词法 containment 与规范化 `realpath` containment。运行时根目录下的符号链接或 Windows junction 不能把 Notemd pointer 变成读取或递归删除外部用户目录的权限。
- 禁止自动删除过期锁。环境面板提供显式恢复操作;只有取得独占清理声明、确认 owner token 稳定并重复验证 owner 进程已死亡后才清锁。声明成功的锁目录会先原子重命名到带 token 的隔离目录,并在隔离目录内再次校验后才删除;若锁在最终提交边界被替换,则恢复或保留替换锁,不会将其删除。
- Tectonic 使用 `--untrusted``pdflatex` 使用 `-no-shell-escape`
- smoke 编译使用 Notemd 自有的确定性 CircuitikZ fixture不默认编译任意用户 TeX。
- 移动端继续使用 companion SVG并在不加载桌面进程代码的前提下报告原生编译不受支持。
## 历史抽屉根因与修复
历史抽屉是 CSS Grid item。默认 `min-height: auto` 会把隐式 grid row 撑到历史内容高度,导致 body 本身没有内部 overflowlayer 再用 `overflow: hidden` 裁掉超出部分。
根因修复是在 `.notemd-diagram-history-drawer` 上设置 `min-height: 0`。body 继续保留 `min-height: 0``overflow: auto`,并增加 `overscroll-behavior: contain`,避免滚动传递到底层预览。共享的独立历史 Modal 保持自己的滚动模型。
## 测试计划
1. 先为 drawer 最小尺寸不变量增加失败的 CSS 合同测试,再应用最小样式修复。
2. 单测覆盖平台/架构选择、PATH 发现、编译器偏好、能力报告与移动端行为。
3. 单测覆盖流式下载进度、取消、超时、重定向白名单、体积上限、checksum、压缩包路径穿越拒绝、staging 清理与原子激活。
4. 单测覆盖直接进程执行,以及准确的 untrusted/no-shell-escape 参数数组。
5. 安装器使用本地 HTTP fixtureJest 不依赖公网可用性。
6. 覆盖环境面板状态,以及英文、简体中文设置/命令连线。
7. 执行完整插件 build、Jest、i18n audit、render-host audit、网站构建和文档合同。
8. 将 `main.js`、`manifest.json`、`styles.css` 与 `README.md` 部署到 Study Vault重载 Obsidian实测长历史滚动与原生 LaTeX smoke确认被测流程没有错误弹窗或 notice并记录宿主是否支持可选的开发者控制台 CLI 命令。
## Phase 退出条件
本工作在 CircuitikZ roadmap 中新增 Phase F。只有在 UI 可探测可选环境、受支持托管资产可经过完整性校验与取消流程安装、真实 common-source fixture 能通过所选环境编译、真实预览中的历史抽屉可以滚动、文档同步、全部门禁通过、`main` 已推送且工作区 clean 时Phase F 才算完成。
## 实现进度
- 代码已完成:编译器发现与偏好顺序、按所有权精确管理的托管安装/删除、只读恢复发现、规范路径 containment、token 隔离式过期锁恢复、六 fixture 验收、桌面进程取消/超时、环境设置与面板、命令面板入口、移动端延迟加载边界、历史 Grid/overflow 修复,以及 linked ancestor 外部删除和最终边界锁替换两类回归测试。
- 文档已完成:英文/简体中文用户图表指南、全部根目录 README 语言、CircuitikZ roadmap、本组双语方案/进度记录与 MDX 仓库策略分析。
- 仓库验证已完成生产构建233 个 Jest suite、1,977 项通过测试与 1 项仅 POSIX 测试在 Windows 跳过i18n 与 render-host auditVitePress 构建34 locale Docusaurus 构建;`git diff --check`;最终独立审查未发现剩余 Critical 或 Important 问题。
- Study Vault 实机验证已完成部署文件与仓库逐字节一致Notemd 1.9.3 与全部图形命令已注册;托管 Tectonic 0.16.9 显示 ready预览历史抽屉 client height 为 671 px、scroll height 为 1,139 px可到达约 468 px 的底部偏移并显示分页器;被测流程没有出现 popup 或 notice。可选 `dev:errors` / `dev:console` 宿主命令以 code 1 且无输出结束,因此记录为宿主诊断不可用,而不是把它误判为插件错误证据。
- 严格前端法则门禁为 100/1000 个快速门失败、0 个法则失败、0 个未知项。本次 mainline commit 在 push 完成并确认工作区 clean、`HEAD` 与 `origin/main` 相等后关闭 Phase F。

View file

@ -0,0 +1,70 @@
# Diagram Generation Chain And Website MDX Progress
Language: **English** | [简体中文](./diagram-generation-chain-and-website-mdx-progress-2026-07-19.zh-CN.md)
## CircuitikZ Root Cause
The current generation path does not reuse the legacy mind-map prompt when the user selects **Circuit diagram** and **CircuitikZ**. `applyDiagramIntentPreference()` pins `circuitikz` plus `best-fit`; `buildDiagramOperationInput()` preserves that pair; and `buildDiagramSpecPrompt()` restricts the provider to the `circuit` intent, requires structured `circuitSpec` JSON, and forbids raw TikZ output.
Two boundary gaps could still produce the reported failure:
1. command and maintainer-CLI input overrides carried `requestedIntent` but not an explicit `requestedRenderTarget`;
2. the constrained golden-template fallback ran only after JSON parsing, so a model response containing raw `\\begin{circuitikz}` failed before fallback was reached.
Both boundaries now preserve the explicit CircuitikZ target. Raw TeX or malformed JSON for a recognized supported circuit can fall back to the deterministic topology catalog before rendering. The prompt also includes the common-source NMOS topology contract; the renderer remains the sole owner of complete LaTeX document emission.
## Current Architecture
`UI / command / maintainer CLI``DiagramCommandInputOverrides``buildDiagramOperationInput()``runDiagramGenerateOperation()` → circuit-only `DiagramSpec` prompt → response parser or constrained circuit fallback → `CircuitikzRenderer` → complete `.tex` plus review SVG → preview/export/history.
This keeps topology decisions in `CircuitSpec`, syntax emission in the deterministic renderer, and visual review/export in the preview platform. Unsupported circuit families still fail instead of being silently replaced with an unrelated golden circuit.
The optional repair boundary is now explicit as well: `runCircuitikzRepairLoop()` accepts at most one model-produced `CircuitSpec`, rejects topology drift before export, and requires fresh compile plus render-smoke evidence before adoption. It does not run when a local renderer is unavailable, so the normal Obsidian generation path remains dependency-free and fail-closed.
Real TeX Live verification exposed and closed two additional runtime gaps. Every golden exporter now emits a standalone document beginning with `\\documentclass[border=8pt]{standalone}`, so standard `pdflatex` accepts the file and crops the PDF to the circuit bounds. Generated Obsidian wrapper notes are also resolved back to their linked `.tex` source before preview history is recorded; CircuitikZ no longer degrades to an `HTML / flowchart` history entry when its companion SVG is reopened.
## Managed LaTeX Environment And History Drawer
The plugin will not embed a complete TeX distribution or executable archive in `main.js`. The desktop enhancement instead has three ordered sources: a user-selected executable, a pinned managed Tectonic runtime, and a discovered system `tectonic` or `pdflatex`. Installation is an explicit user action in the CircuitikZ environment panel or command palette; ordinary preview, SVG, PNG, and preview-PDF export remain available without it.
The managed path pins Tectonic `0.16.9`, validates every redirect against an HTTPS host allowlist, bounds the download, verifies SHA-256, rejects unsafe archive paths and links, smoke-compiles six deterministic CircuitikZ fixtures in staging, and atomically activates the version. Cancellation, timeout, checksum failure, smoke failure, and write failure clean staging and preserve the previous active runtime. Desktop commands use argument arrays with `shell: false`, untrusted/no-shell-escape flags, bounded logs, timeout handling, and process-tree termination.
Managed-runtime discovery is now read-only. Mutating pointer recovery, legacy pointer ownership registration, activation, and removal execute under the filesystem lock. Existing same-byte directories are not adopted unless a valid Notemd pointer or install-local manifest proves ownership and the regular executable satisfies digest and POSIX execute checks. The environment panel also exposes explicit stale-lock recovery; it uses an exclusive contender claim plus stable-token and dead-owner rechecks, and preserves a replacement owner observed during cleanup.
The final ownership review closed two deletion races. Existing executable and install paths now have to remain inside the runtime root after canonical `realpath` resolution, so a symlink or Windows junction cannot redirect removal into an external user directory. Stale-lock cleanup now atomically moves the claimed directory to a token-qualified quarantine, then revalidates the owner, claim token, and dead-process state before deletion; a replacement arriving at the final commit boundary is not deleted.
The history-scroll failure was not caused by pagination or the history store. The right drawer was a Grid item whose default `min-height: auto` expanded its row to the full history content while the surrounding layer clipped overflow. The drawer now sets `min-height: 0`; its body owns `overflow: auto` and `overscroll-behavior: contain`. A CSS contract test locks this invariant.
## Website MDX Decision
The website currently has 21 canonical English MDX pages and 33 published locale mirrors, for 693 localized MDX files. Repository tests require every published locale to mirror the English route set, and Docusaurus consumes these files as tracked publication inputs. Deleting them or stopping synchronization now would break localized routes and the release contract.
Therefore:
- keep the existing MDX mirrors tracked until a separate build-time locale generation design replaces the current contract;
- update only canonical English and intentionally maintained locale pages when behavior changes;
- do not mechanically rewrite all locale mirrors for a small UI wording change;
- keep maintainer architecture and diagnostic detail under `docs/maintainer/`, while the public diagram page contains only concepts, steps, limitations, and troubleshooting.
The English and Simplified-Chinese public diagram pages now explain the optional managed environment in user terms only. The root README setting guide is synchronized across every supported README language; LM Studio `hy-mt2-7b` was used only for those documentation translations.
## Verification
- command-host target propagation has direct regression coverage;
- maintainer CLI schema, help, bridge, and examples expose `requestedRenderTarget`;
- raw CircuitikZ response fallback has regression coverage;
- prompt coverage verifies circuit-only structured generation;
- website documentation contracts verify concise English and Simplified-Chinese guidance.
- the bounded repair loop has positive and negative coverage for topology, parsing, second-pass acceptance, and renderer availability;
- the common-source golden template uses separated paths, lighter strokes, smaller text, and no redundant source label.
- environment tests cover compiler ordering, custom/system/managed discovery, mobile fail-closed behavior, pinned artifact metadata, secure download/extraction, cancellation cleanup, golden-fixture acceptance, and modal state rendering;
- managed-runtime regressions cover unowned same-byte preservation, POSIX execute permission, legacy pointer migration, read-only recovery interleavings, explicit stale-lock recovery and replacement preservation, exact removal ownership, and non-fatal post-commit cleanup diagnostics;
- final security regressions prove that a linked runtime ancestor cannot authorize deletion of an external user directory and that a replacement lock swapped in immediately before cleanup commit survives;
- the history drawer CSS contract verifies the Grid item and scroll body minimum-size/overflow invariants;
- all root README languages now document settings discovery, Vault-scoped history, safe batch-folder confirmation, and the optional CircuitikZ native environment;
- the maintainer CLI helper now prefers a compatible `obsidian-cli native eval` wrapper but falls back to official `obsidian eval` when the wrapper is absent; the real Study Vault read-only `local-knowledge.inspect` invocation completed through that fallback;
- TeX Live 2023 compiled the common-source output with zero errors and zero warnings, and the rasterized PDF showed a tight canvas with legible, non-overlapping labels;
- the final deployed Study Vault reports managed Tectonic 0.16.9 ready, registers all six diagram commands, and opens the common-source wrapper as `Circuitikz` preview without any popup or notice;
- the preview's right history drawer measures 671 px client height and 1,139 px scroll height, reaches the 468 px bottom offset, and keeps the pager visible; the standalone history view also owns vertical overflow;
- the strict frontend-law audit scores 100/100 with no failed or unknown principle, while the optional `dev:errors` and error-level `dev:console` host commands are recorded as unavailable because they returned exit code 1 without output;
- the final production build, 233-suite Jest run (1,977 passed, one POSIX-only test skipped on Windows), i18n audit, render-host audit, VitePress build, 34-locale Docusaurus build, website build audit, and independent Critical/Important review all pass.

View file

@ -0,0 +1,70 @@
# 图形生成链路与网站 MDX 进度
语言:[English](./diagram-generation-chain-and-website-mdx-progress-2026-07-19.md) | **简体中文**
## CircuitikZ 根因
用户选择**电路图**和 **CircuitikZ** 后,当前生成链路不会复用旧 mindmap prompt。`applyDiagramIntentPreference()` 会固定 `circuitikz``best-fit``buildDiagramOperationInput()` 保留这一组合;`buildDiagramSpecPrompt()` 只允许 `circuit` intent要求结构化 `circuitSpec` JSON并明确禁止模型直接输出 TikZ。
但两个边界缺口仍可能导致已报告的失败:
1. 命令与维护者 CLI override 只携带 `requestedIntent`,没有显式携带 `requestedRenderTarget`
2. 受约束 golden-template fallback 只在 JSON 解析完成后执行,模型一旦直接返回 `\\begin{circuitikz}`,会在进入 fallback 前失败。
现在两个边界都会保留显式 CircuitikZ target。对于可识别的受支持电路raw TeX 或错误 JSON 会在渲染前进入确定性拓扑 catalog fallback。prompt 也增加了共源 NMOS 拓扑 contract完整 LaTeX 文档仍只由 renderer 负责生成。
## 当前架构
`UI / 命令 / 维护者 CLI``DiagramCommandInputOverrides``buildDiagramOperationInput()``runDiagramGenerateOperation()` → Circuit-only `DiagramSpec` prompt → 响应解析或受约束电路 fallback → `CircuitikzRenderer` → 完整 `.tex` 与审阅 SVG → 预览、导出和历史。
这样可把拓扑决策留在 `CircuitSpec`,把语法输出留在确定性 renderer把视觉审阅与导出留在预览平台。对于未支持的电路族系统仍会明确失败不会静默替换成无关的 golden circuit。
可选修复边界现在也已明确:`runCircuitikzRepairLoop()` 最多接受一次模型生成的 `CircuitSpec`,在导出前拒绝拓扑漂移,并且只有拿到新鲜的编译与 render-smoke 证据后才采用候选。本地渲染器不可用时不会运行,因此 Obsidian 的常规生成路径仍无额外依赖并保持失败关闭。
TeX Live 实测还暴露并修复了两个运行时缺口。所有 golden exporter 现在都从 `\\documentclass[border=8pt]{standalone}` 开始输出可独立编译的文档,因此标准 `pdflatex` 可以直接编译,并把 PDF 裁剪到电路边界。Obsidian 生成的 wrapper note 在写入预览历史前也会回溯其链接的 `.tex` 源产物;重新打开 companion SVG 时CircuitikZ 不再退化成 `HTML / flowchart` 历史记录。
## 托管 LaTeX 环境与历史抽屉
插件不会把完整 TeX 发行版或可执行压缩包嵌入 `main.js`。桌面增强能力按顺序使用三类来源:用户指定可执行文件、固定版本的托管 Tectonic、系统中发现的 `tectonic``pdflatex`。安装只能由用户在 CircuitikZ 环境面板或命令面板中明确触发常规预览、SVG、PNG 和预览 PDF 导出无需安装。
托管链路固定 Tectonic `0.16.9`,每次重定向都经过 HTTPS 主机白名单,限制下载体积并校验 SHA-256拒绝不安全压缩路径和链接在 staging 中编译六个确定性 CircuitikZ fixture 后才原子激活。取消、超时、checksum、smoke 或写入失败都会清理 staging 并保留旧的活动运行时。桌面命令使用参数数组和 `shell: false`,带 untrusted/no-shell-escape 参数、有限日志、超时与进程树终止。
托管运行时发现现在保持只读。会修改 pointer 的恢复、旧版 pointer 所有权登记、激活与删除都在文件系统锁内执行。即使已有目录中的字节相同,也只有有效 Notemd pointer 或安装目录内清单能够证明所有权、普通可执行文件摘要匹配且 POSIX 执行权限有效时才允许复用。环境面板还提供显式过期锁恢复:通过独占 contender 声明、稳定 token 与死亡 owner 复核清锁;清理期间若观察到替换 owner则保留新锁。
最终所有权审查还关闭了两类删除竞态。现有可执行文件与安装目录必须在规范化 `realpath` 解析后仍位于运行时根目录内,因此符号链接或 Windows junction 不能把删除重定向到外部用户目录。过期锁清理会先把已声明目录原子移动到带 token 的隔离目录,再复核 owner、claim token 与死亡进程状态;即使替换锁在最终提交边界到达,也不会被删除。
历史无法滚动的根因不在分页或历史存储。右侧抽屉是 Grid item默认 `min-height: auto` 会把所在行撑到完整历史高度,外层再裁掉 overflow。现在 drawer 设置 `min-height: 0`body 负责 `overflow: auto``overscroll-behavior: contain`,并由 CSS 合同测试锁定该不变量。
## 网站 MDX 决策
网站目前包含 21 个英文规范 MDX 页面与 33 个已发布 locale 镜像,共 693 个本地化 MDX 文件。仓库测试要求所有发布 locale 镜像英文路由集合Docusaurus 也把这些文件作为受跟踪发布输入。现在删除它们或停止同步会破坏本地化路由与发布合同。
因此:
- 在新的构建期 locale 生成方案替代现有合同前,继续跟踪现有 MDX 镜像;
- 行为变化时只更新英文规范页与确实人工维护的 locale 页面;
- 小型 UI 文案变化不机械重写全部 locale 镜像;
- 维护者架构与诊断细节放在 `docs/maintainer/`,公开图表页面只保留概念、操作步骤、限制与故障排查。
英文与简体中文公开图表页现在只用用户语言说明可选托管环境。根目录 README 设置指南已同步到全部支持语言LM Studio `hy-mt2-7b` 只用于这些文档翻译。
## 验证
- 命令 Host target 传递已有直接回归测试;
- 维护者 CLI schema、帮助、bridge 与示例均公开 `requestedRenderTarget`
- raw CircuitikZ 响应 fallback 已有回归测试;
- prompt 测试覆盖 circuit-only 结构化生成;
- 网站文档合同覆盖精简后的英文与简体中文用户指南。
- 有界修复闭环覆盖拓扑、解析、第二次验收和渲染器可用性的正反测试;
- common-source golden template 使用分离路径、更轻线宽、更小文字,并移除冗余源极标签。
- 环境测试覆盖编译器顺序、自定义/系统/托管发现、移动端失败关闭、固定资产元数据、安全下载/解压、取消清理、golden fixture 验收与环境面板状态;
- 托管运行时回归覆盖未拥有的同字节目录保护、POSIX 执行权限、旧版 pointer 迁移、只读恢复并发交错、显式过期锁恢复与替换锁保护、精确所有权删除,以及提交后清理诊断不改变安装成功状态;
- 最终安全回归证明 linked runtime ancestor 不能授权删除外部用户目录,并证明在清理提交前一刻替换的新锁能够存活;
- 历史抽屉 CSS 合同验证 Grid item 与滚动 body 的最小尺寸/overflow 不变量;
- 所有根目录 README 语言都已说明设置发现、Vault 级历史、安全批处理目录确认与可选 CircuitikZ 原生环境;
- maintainer CLI helper 现在优先使用兼容的 `obsidian-cli native eval` 包装器,并在包装器不存在时回退到官方 `obsidian eval`Study Vault 的只读 `local-knowledge.inspect` 已通过该 fallback 实机完成;
- TeX Live 2023 对 common-source 输出的实编译为 0 error / 0 warning栅格化 PDF 画布紧凑,标签清晰且无重叠;
- 最终部署的 Study Vault 显示托管 Tectonic 0.16.9 ready六个图形命令均已注册共源 wrapper 能以 `Circuitikz` 预览打开,且没有 popup 或 notice
- 预览右侧历史抽屉 client height 为 671 px、scroll height 为 1,139 px可到达约 468 px 的底部偏移并保持分页器可见;独立历史视图也正确拥有纵向 overflow
- 严格前端法则审计为 100/100没有失败或未知法则可选 `dev:errors` 与 error-level `dev:console` 宿主命令因 code 1 且无输出而记录为不可用;
- 最终生产构建、233-suite Jest1,977 项通过Windows 跳过 1 项仅 POSIX 测试、i18n audit、render-host audit、VitePress 构建、34 locale Docusaurus 构建、website build audit 与独立 Critical/Important 审查全部通过。

View file

@ -44,6 +44,7 @@ Minimal examples:
npm run cli:invoke -- --vault docs --operation provider.profile.export-redacted --pretty
Notes:
- Host selection prefers obsidian-cli native eval when that wrapper exists, then falls back to the official obsidian eval command.
- Prefer --input-file for non-trivial payloads.
- Paths inside --input-json are vault-relative. For --vault docs, use "index.zh-CN.md" and "maintainer", not "docs/index.zh-CN.md" or "docs/maintainer".
- Maintainer bridge only; not a public CLI surface.
@ -140,6 +141,35 @@ function extractEvalResult(stdout) {
return evalLine.slice(3);
}
function isCommandUnavailable(result) {
const code = String(result.error?.code || '').toUpperCase();
return result.status === null && (code === 'ENOENT' || code === 'EINVAL');
}
function invokeObsidianEval(vault, code) {
const options = {
encoding: 'utf8',
maxBuffer: 10 * 1024 * 1024
};
const wrapperResult = spawnSyncWithCommandResolution(
'obsidian-cli',
['native', `vault=${vault}`, 'eval', `code=${code}`],
options
);
if (!isCommandUnavailable(wrapperResult)) {
return { commandLabel: 'obsidian-cli native eval', result: wrapperResult };
}
return {
commandLabel: 'obsidian eval',
result: spawnSyncWithCommandResolution(
'obsidian',
['eval', `vault=${vault}`, `code=${code}`],
options
)
};
}
function main() {
try {
const args = parseArgs(process.argv.slice(2));
@ -164,17 +194,11 @@ function main() {
input
};
const code = buildEvalCode(args.pluginId, request);
const child = spawnSyncWithCommandResolution(
'obsidian-cli',
['native', `vault=${args.vault}`, 'eval', `code=${code}`],
{
encoding: 'utf8',
maxBuffer: 10 * 1024 * 1024
}
);
const invocation = invokeObsidianEval(args.vault, code);
const child = invocation.result;
if (child.status !== 0) {
process.stderr.write(child.stderr || child.stdout || 'obsidian-cli native eval failed\n');
process.stderr.write(child.stderr || child.stdout || `${invocation.commandLabel} failed\n`);
process.exit(child.status || 1);
}

View file

@ -20,8 +20,9 @@ const OPERATION_HELP = {
'diagram.generate': {
summary: 'Generate a saved diagram artifact or Mermaid output.',
required: ['sourcePath'],
optional: ['executionMode', 'requestedIntent', 'compatibilityMode', 'targetLanguage'],
exampleInput: '{"sourcePath":"index.zh-CN.md","executionMode":"save-artifact","requestedIntent":"erDiagram","targetLanguage":"en"}'
optional: ['executionMode', 'requestedIntent', 'requestedRenderTarget', 'compatibilityMode', 'targetLanguage'],
exampleInput: '{"sourcePath":"index.zh-CN.md","executionMode":"save-artifact","requestedIntent":"erDiagram","targetLanguage":"en"}',
additionalExamples: ['{"sourcePath":"circuits/common-source.md","executionMode":"save-artifact","requestedIntent":"circuit","requestedRenderTarget":"circuitikz","compatibilityMode":"best-fit","targetLanguage":"en"}']
},
'local-knowledge.inspect': {
summary: 'Inspect task-scoped local knowledge retrieval inputs, paths, and context.',

View file

@ -5,13 +5,14 @@ const endpoint = 'http://100.80.17.113:301/v1/chat/completions';
const model = 'hy-mt2-7b';
const marker = '<!-- notemd-settings-discovery-guide -->';
const source = `${marker}
## Settings discovery, diagram history, and safe batch folders
## Settings discovery, diagram history, CircuitikZ, and safe batch folders
The Notemd settings page provides fuzzy search, large category navigation, and per-setting favorites stored for the current Vault.
- Diagram history is stored at Vault scope, ordered newest first, searchable and paginated in groups of 20. Removing a history record does not delete generated files.
- Diagram preview export PPI controls PNG and PDF clarity. SVG remains vector-based.
- Preferred diagram type and preferred source format are separate choices.
- Desktop users can open the optional CircuitikZ native compile environment to reuse system Tectonic/pdflatex, select a custom compiler, or explicitly install the pinned managed Tectonic runtime. Preview, SVG, PNG, and preview PDF exports do not require LaTeX.
- Advanced batch file selection enables saved selection profiles and rule previews.
- A missing batch target folder can be created after confirmation, with an option to remember automatic creation for future missing folders.
- An existing non-empty folder requires one confirmation before the whole batch, never one confirmation per generated file.
@ -59,9 +60,10 @@ async function main() {
for (const locale of Object.keys(languageNames)) {
const file = path.join(root, `README_${locale}.md`);
const current = fs.readFileSync(file, 'utf8');
if (current.includes(marker)) continue;
const translated = await translate(locale);
fs.writeFileSync(file, `${current.trimEnd()}\n\n${translated}`, 'utf8');
const markerOffset = current.indexOf(marker);
const base = markerOffset >= 0 ? current.slice(0, markerOffset).trimEnd() : current.trimEnd();
fs.writeFileSync(file, `${base}\n\n${translated}`, 'utf8');
process.stdout.write(`${locale}\n`);
}
}

View file

@ -200,6 +200,11 @@ export const DEFAULT_SETTINGS: NotemdSettings = {
experimentalDiagramCompatibilityMode: 'legacy-mermaid',
preferredDiagramRenderTarget: undefined,
diagramPreviewExportPpi: DEFAULT_PREVIEW_EXPORT_PPI,
circuitikzCompilerPreference: 'auto',
circuitikzCustomCompilerKind: 'pdflatex',
circuitikzCustomCompilerPath: '',
circuitikzManagedRuntimeRoot: '',
circuitikzCompileTimeoutMs: 120_000,
favoriteSettingIds: [],
diagramHistoryRetentionLimit: 500,
diagramHistoryEntries: [],

View file

@ -0,0 +1,286 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { DesktopCommand, DesktopCommandExit, runDesktopCommand } from '../../../platform/desktopProcess';
import { exportCircuitSpecToCircuitikz } from './circuitikzExporter';
import { resolveCircuitTemplateFromMarkdown } from './circuitTemplateCatalog';
export type CircuitikzCompilerKind = 'tectonic' | 'pdflatex';
export type CircuitikzCompilerSource = 'custom' | 'managed' | 'system';
export type CircuitikzCompilerPreference = 'auto' | 'managed' | 'system' | 'custom';
export interface CircuitikzCompilerCandidate {
kind: CircuitikzCompilerKind;
source: CircuitikzCompilerSource;
executable: string;
}
export interface CircuitikzEnvironmentAttempt {
candidate: CircuitikzCompilerCandidate;
status: 'ready' | 'missing' | 'incomplete';
version?: string;
message?: string;
}
export interface CircuitikzEnvironmentCapabilities {
compileDiagnostics: boolean;
nativePdf: boolean;
repairAcceptance: boolean;
}
export interface CircuitikzEnvironmentReport {
status: 'ready' | 'missing' | 'incomplete' | 'unsupported';
selected?: CircuitikzCompilerCandidate;
capabilities: CircuitikzEnvironmentCapabilities;
attempts: CircuitikzEnvironmentAttempt[];
}
type CommandRunner = (command: DesktopCommand) => Promise<DesktopCommandExit>;
const DEFAULT_COMPILE_TIMEOUT_MS = 120_000;
const SMOKE_JOB_NAME = 'notemd-circuitikz-smoke';
const EMPTY_CAPABILITIES: CircuitikzEnvironmentCapabilities = {
compileDiagnostics: false,
nativePdf: false,
repairAcceptance: false
};
const CIRCUITIKZ_SMOKE_TEX = String.raw`\documentclass[border=8pt]{standalone}
\usepackage{circuitikz}
\begin{document}
\begin{circuitikz}[american voltages, line width=0.5pt, font=\small]
\draw (3,5) node[vcc]{$V_{DD}$} to[R, l=$R_D$] (3,3);
\draw (3,3) to[short, *-o] (5,3) node[right]{$v_{out}$};
\draw (3,3) to[short] (3,2.2) node[nmos, anchor=D] (M1) {$M_1$};
\draw (M1.S) to[short] (3,0.5) node[ground]{};
\draw (M1.G) to[short, -o] (0.8,2.2) node[left]{$v_{in}$};
\end{circuitikz}
\end{document}
`;
const GOLDEN_FIXTURE_REQUESTS = [
{ name: 'cmos-inverter', markdown: 'CMOS inverter' },
{ name: 'common-source-amplifier', markdown: 'common-source NMOS amplifier' },
{ name: 'cmos-buffer', markdown: 'CMOS buffer' },
{ name: 'cmos-transmission-gate', markdown: 'CMOS transmission gate' },
{ name: 'cmos-nand2', markdown: 'CMOS NAND2' },
{ name: 'cmos-nor2', markdown: 'CMOS NOR2' }
] as const;
export interface CircuitikzGoldenFixtureResult {
name: string;
status: 'ready' | 'failed';
message?: string;
}
async function compileSmokeSource(input: {
candidate: CircuitikzCompilerCandidate;
name: string;
source: string;
workspace: string;
timeoutMs: number;
runCommand: CommandRunner;
}): Promise<CircuitikzGoldenFixtureResult> {
const texPath = path.join(input.workspace, `${input.name}.tex`);
const pdfPath = path.join(input.workspace, `${input.name}.pdf`);
const logPath = path.join(input.workspace, `${input.name}.log`);
fs.writeFileSync(texPath, input.source, 'utf8');
fs.rmSync(pdfPath, { force: true });
fs.rmSync(logPath, { force: true });
const execution = await input.runCommand(
buildCircuitikzCompileCommand(input.candidate, texPath, input.workspace, input.timeoutMs)
);
const pdfReady = execution.exitCode === 0
&& fs.existsSync(pdfPath)
&& fs.statSync(pdfPath).size > 0;
return pdfReady
? { name: input.name, status: 'ready' }
: { name: input.name, status: 'failed', message: summarizeCommandFailure(execution) };
}
export async function verifyCircuitikzGoldenFixtures(input: {
candidate: CircuitikzCompilerCandidate;
timeoutMs?: number;
runCommand?: CommandRunner;
}): Promise<{ ok: boolean; fixtures: CircuitikzGoldenFixtureResult[] }> {
const runCommand = input.runCommand ?? runDesktopCommand;
const timeoutMs = Math.max(1, input.timeoutMs ?? DEFAULT_COMPILE_TIMEOUT_MS);
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'notemd-circuitikz-golden-'));
const fixtures: CircuitikzGoldenFixtureResult[] = [];
try {
for (const fixture of GOLDEN_FIXTURE_REQUESTS) {
const spec = resolveCircuitTemplateFromMarkdown(fixture.markdown);
if (!spec) {
fixtures.push({ name: fixture.name, status: 'failed', message: 'Deterministic fixture template is unavailable.' });
continue;
}
fixtures.push(await compileSmokeSource({
candidate: input.candidate,
name: fixture.name,
source: exportCircuitSpecToCircuitikz(spec),
workspace,
timeoutMs,
runCommand
}));
}
return {
ok: fixtures.length === GOLDEN_FIXTURE_REQUESTS.length
&& fixtures.every(fixture => fixture.status === 'ready'),
fixtures
};
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
}
export function buildCircuitikzCompileCommand(
candidate: CircuitikzCompilerCandidate,
texPath: string,
outputDirectory: string,
timeoutMs: number
): DesktopCommand {
if (candidate.kind === 'tectonic') {
return {
executable: candidate.executable,
args: ['-X', 'compile', '--untrusted', '--keep-logs', '--outdir', outputDirectory, texPath],
cwd: outputDirectory,
timeoutMs
};
}
return {
executable: candidate.executable,
args: [
'-no-shell-escape',
'-interaction=nonstopmode',
'-halt-on-error',
`-output-directory=${outputDirectory}`,
texPath
],
cwd: outputDirectory,
timeoutMs
};
}
export function createCircuitikzCompilerCandidates(input: {
preference: CircuitikzCompilerPreference;
customCompilerKind?: CircuitikzCompilerKind;
customCompilerPath?: string;
managedExecutablePath?: string;
systemTectonicPath?: string;
systemPdflatexPath?: string;
}): CircuitikzCompilerCandidate[] {
const customPath = input.customCompilerPath?.trim();
const managedPath = input.managedExecutablePath?.trim();
const systemTectonicPath = input.systemTectonicPath?.trim();
const systemPdflatexPath = input.systemPdflatexPath?.trim();
if (input.preference === 'custom') {
return customPath && input.customCompilerKind
? [{ kind: input.customCompilerKind, source: 'custom', executable: customPath }]
: [];
}
if (input.preference === 'managed') {
return managedPath
? [{ kind: 'tectonic', source: 'managed', executable: managedPath }]
: [];
}
const candidates: CircuitikzCompilerCandidate[] = [];
if (input.preference === 'auto' && managedPath) {
candidates.push({ kind: 'tectonic', source: 'managed', executable: managedPath });
}
if (systemTectonicPath) {
candidates.push({ kind: 'tectonic', source: 'system', executable: systemTectonicPath });
}
if (systemPdflatexPath) {
candidates.push({ kind: 'pdflatex', source: 'system', executable: systemPdflatexPath });
}
return candidates;
}
function summarizeCommandFailure(execution: DesktopCommandExit): string {
if (execution.cancelled) return 'Cancelled by the user.';
if (execution.timedOut) return 'Compiler timed out.';
return execution.stderr.trim() || execution.stdout.trim() || execution.errorMessage || 'Compiler command failed.';
}
export async function probeCircuitikzEnvironment(input: {
isDesktop: boolean;
candidates: CircuitikzCompilerCandidate[];
timeoutMs?: number;
runCommand?: CommandRunner;
}): Promise<CircuitikzEnvironmentReport> {
if (!input.isDesktop) {
return { status: 'unsupported', capabilities: { ...EMPTY_CAPABILITIES }, attempts: [] };
}
if (input.candidates.length === 0) {
return { status: 'missing', capabilities: { ...EMPTY_CAPABILITIES }, attempts: [] };
}
const runCommand = input.runCommand ?? runDesktopCommand;
const timeoutMs = Math.max(1, input.timeoutMs ?? DEFAULT_COMPILE_TIMEOUT_MS);
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'notemd-circuitikz-'));
const texPath = path.join(workspace, `${SMOKE_JOB_NAME}.tex`);
const pdfPath = path.join(workspace, `${SMOKE_JOB_NAME}.pdf`);
const logPath = path.join(workspace, `${SMOKE_JOB_NAME}.log`);
const attempts: CircuitikzEnvironmentAttempt[] = [];
fs.writeFileSync(texPath, CIRCUITIKZ_SMOKE_TEX, 'utf8');
try {
for (const candidate of input.candidates) {
const versionExecution = await runCommand({
executable: candidate.executable,
args: ['--version'],
cwd: workspace,
timeoutMs: Math.min(timeoutMs, 15_000)
});
if (versionExecution.exitCode !== 0 || versionExecution.cancelled || versionExecution.timedOut) {
attempts.push({
candidate,
status: 'missing',
message: summarizeCommandFailure(versionExecution)
});
continue;
}
fs.rmSync(pdfPath, { force: true });
fs.rmSync(logPath, { force: true });
const compileExecution = await runCommand(
buildCircuitikzCompileCommand(candidate, texPath, workspace, timeoutMs)
);
const pdfReady = compileExecution.exitCode === 0
&& fs.existsSync(pdfPath)
&& fs.statSync(pdfPath).size > 0;
const version = (versionExecution.stdout || versionExecution.stderr).trim();
if (!pdfReady) {
attempts.push({
candidate,
status: 'incomplete',
version,
message: summarizeCommandFailure(compileExecution)
});
continue;
}
attempts.push({ candidate, status: 'ready', version });
return {
status: 'ready',
selected: candidate,
capabilities: {
compileDiagnostics: true,
nativePdf: true,
repairAcceptance: true
},
attempts
};
}
return {
status: attempts.some(attempt => attempt.status === 'incomplete') ? 'incomplete' : 'missing',
capabilities: { ...EMPTY_CAPABILITIES },
attempts
};
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
}

View file

@ -485,20 +485,23 @@ function renderCommonSourceTemplate(spec: CircuitSpec): string {
to [short, -o] (${extendedPortX(inputSide)},1.4)
node[${inputSide}]{$v_{in}$};`;
return `\\usepackage{circuitikz}
return `\\documentclass[border=8pt]{standalone}
\\usepackage{circuitikz}
\\begin{document}
\\begin{circuitikz}[${spec.style.voltageConvention}]
\\begin{circuitikz}[${spec.style.voltageConvention}, line width=0.5pt, font=\\small]
\\draw
(3,5) node[vcc]{$V_{DD}$}
to [R, l=${rdLabel}] (3,3)
to [short, *-o] (${commonPortX(outputSide)},3) node[${outputSide}]{$v_{out}$}
(3,3) to [short] (3,2.2)
node[nmos, anchor=D] (M1) {${m1Label}}
(M1.S) to [short] (3,0.5)
node[ground]{}
${inputRoute}
to [R, l=${rdLabel}] (3,3);
\\draw
(3,0.5) node[below right]{$S$};
(3,3) to [short, *-o] (${commonPortX(outputSide)},3) node[${outputSide}]{$v_{out}$};
\\draw
(3,3) to [short] (3,2.2)
node[nmos, anchor=D] (M1) {${m1Label}};
\\draw
(M1.S) to [short] (3,0.5)
node[ground]{};
\\draw
${inputRoute}
\\end{circuitikz}
\\end{document}
`;
@ -524,7 +527,8 @@ function renderCmosInverterTemplate(spec: CircuitSpec): string {
to [short, -o] (${extendedPortX(inputSide)},1.2)
node[${inputSide}]{$v_{in}$};`;
return `\\usepackage{circuitikz}
return `\\documentclass[border=8pt]{standalone}
\\usepackage{circuitikz}
\\begin{document}
\\begin{circuitikz}[${spec.style.voltageConvention}]
\\draw
@ -570,7 +574,8 @@ function renderCmosBufferTemplate(spec: CircuitSpec): string {
to [short, -o] (7.2,1.2)
node[right]{$v_{in}$};`;
return `\\usepackage{circuitikz}
return `\\documentclass[border=8pt]{standalone}
\\usepackage{circuitikz}
\\begin{document}
\\begin{circuitikz}[${spec.style.voltageConvention}]
\\draw
@ -617,7 +622,8 @@ function renderCmosTransmissionGateTemplate(spec: CircuitSpec): string {
const inputPortX = inputSide === 'left' ? '0.8' : '5.2';
const outputPortX = outputSide === 'right' ? '5.2' : '0.8';
return `\\usepackage{circuitikz}
return `\\documentclass[border=8pt]{standalone}
\\usepackage{circuitikz}
\\begin{document}
\\begin{circuitikz}[${spec.style.voltageConvention}]
\\draw
@ -679,7 +685,8 @@ function renderCmosNand2Template(spec: CircuitSpec): string {
to [short, -o] (${dualInputPortX(inputSide)},1.85)
node[right]{$v_B$};`;
return `\\usepackage{circuitikz}
return `\\documentclass[border=8pt]{standalone}
\\usepackage{circuitikz}
\\begin{document}
\\begin{circuitikz}[${spec.style.voltageConvention}]
\\draw
@ -742,7 +749,8 @@ function renderCmosNor2Template(spec: CircuitSpec): string {
to [short, -o] (${dualInputPortX(inputSide)},1.95)
node[right]{$v_B$};`;
return `\\usepackage{circuitikz}
return `\\documentclass[border=8pt]{standalone}
\\usepackage{circuitikz}
\\begin{document}
\\begin{circuitikz}[${spec.style.voltageConvention}]
\\draw

View file

@ -0,0 +1,157 @@
import { CircuitSpec } from './circuitSpec';
import { CircuitikzCompileDiagnosticReport } from './circuitikzDiagnostics';
import { exportCircuitSpecToCircuitikz } from './circuitikzExporter';
import {
CircuitikzRepairAcceptanceEvidence,
CircuitikzRepairAcceptanceReport,
CircuitikzRepairBrief,
createCircuitikzRepairAcceptanceReport,
createCircuitikzRepairBrief,
assertCircuitikzRepairCandidateMatchesBrief
} from './circuitikzRepairBrief';
export interface CircuitikzRepairCandidateEvaluationInput {
candidateSpec: CircuitSpec;
texContent: string;
repairBrief: CircuitikzRepairBrief;
}
export interface CircuitikzRepairLoopRequest {
referenceSpec: CircuitSpec;
sourceSpec: CircuitSpec;
initialDiagnostics: CircuitikzCompileDiagnosticReport;
requestRepairCandidate: (prompt: string) => Promise<string>;
evaluateCandidate: (
input: CircuitikzRepairCandidateEvaluationInput
) => Promise<CircuitikzRepairAcceptanceEvidence>;
}
export interface CircuitikzRepairLoopReport {
status: 'not-needed' | 'unavailable' | 'accepted' | 'rejected';
attemptCount: 0 | 1;
reason: string;
repairBrief?: CircuitikzRepairBrief;
candidateSpec?: CircuitSpec;
acceptance?: CircuitikzRepairAcceptanceReport;
}
const RENDERER_UNAVAILABLE_KINDS = new Set([
'compile-executable-invalid',
'compile-executable-not-found',
'compile-process-error'
]);
function createRepairCandidatePrompt(brief: CircuitikzRepairBrief): string {
return [
'Return exactly one revised CircuitSpec JSON object.',
'Do not return Markdown, prose, or TikZ.',
'Follow every topology invariant and acceptance criterion in this repair brief:',
JSON.stringify(brief, null, 2)
].join('\n\n');
}
function parseCircuitSpecCandidate(rawResponse: string): CircuitSpec {
const trimmed = rawResponse.trim();
const unfenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i)?.[1] ?? trimmed;
const objectStart = unfenced.indexOf('{');
const objectEnd = unfenced.lastIndexOf('}');
if (objectStart < 0 || objectEnd < objectStart) {
throw new Error('Repair response did not contain a CircuitSpec JSON object.');
}
try {
const candidate = JSON.parse(unfenced.slice(objectStart, objectEnd + 1));
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) {
throw new Error('CircuitSpec JSON must be an object.');
}
return candidate as CircuitSpec;
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Unable to parse CircuitSpec JSON: ${message}`);
}
}
function rendererIsUnavailable(diagnostics: CircuitikzCompileDiagnosticReport): boolean {
return diagnostics.diagnostics.some(diagnostic => RENDERER_UNAVAILABLE_KINDS.has(diagnostic.kind));
}
function rejectionReport(
reason: string,
repairBrief: CircuitikzRepairBrief,
acceptance?: CircuitikzRepairAcceptanceReport
): CircuitikzRepairLoopReport {
return {
status: 'rejected',
attemptCount: 1,
reason,
repairBrief,
acceptance
};
}
export async function runCircuitikzRepairLoop(
request: CircuitikzRepairLoopRequest
): Promise<CircuitikzRepairLoopReport> {
if (request.initialDiagnostics.diagnostics.length === 0) {
return {
status: 'not-needed',
attemptCount: 0,
reason: 'Initial compile and render diagnostics require no repair.'
};
}
if (rendererIsUnavailable(request.initialDiagnostics)) {
return {
status: 'unavailable',
attemptCount: 0,
reason: 'Circuitikz repair requires a configured renderer that can produce fresh acceptance evidence.'
};
}
const repairBrief = createCircuitikzRepairBrief({
referenceSpec: request.referenceSpec,
sourceSpec: request.sourceSpec,
diagnostics: request.initialDiagnostics
});
let candidateSpec: CircuitSpec;
try {
const rawCandidate = await request.requestRepairCandidate(createRepairCandidatePrompt(repairBrief));
candidateSpec = parseCircuitSpecCandidate(rawCandidate);
assertCircuitikzRepairCandidateMatchesBrief(repairBrief, candidateSpec);
} catch (error: unknown) {
const reason = error instanceof Error ? error.message : String(error);
return rejectionReport(reason, repairBrief);
}
let texContent: string;
try {
texContent = exportCircuitSpecToCircuitikz(candidateSpec);
} catch (error: unknown) {
const reason = error instanceof Error ? error.message : String(error);
return rejectionReport(reason, repairBrief);
}
let evidence: CircuitikzRepairAcceptanceEvidence;
try {
evidence = await request.evaluateCandidate({ candidateSpec, texContent, repairBrief });
} catch (error: unknown) {
const reason = error instanceof Error ? error.message : String(error);
return rejectionReport(`Candidate evaluation failed: ${reason}`, repairBrief);
}
const acceptance = createCircuitikzRepairAcceptanceReport(repairBrief, candidateSpec, evidence);
if (!acceptance.readyForVisualAcceptance) {
return rejectionReport('Repair candidate did not pass compile and render-smoke acceptance.', repairBrief, acceptance);
}
return {
status: 'accepted',
attemptCount: 1,
reason: 'Repair candidate preserved topology and passed fresh compile and render-smoke acceptance.',
repairBrief,
candidateSpec,
acceptance
};
}

View file

@ -293,9 +293,10 @@ export async function generateDiagramArtifact(
const prompt = buildGenerationPrompt(plan, options);
let rawResponse = await options.llmInvoker(prompt, markdown);
let parsedSpec = parseDiagramSpecResponse(rawResponse);
let spec = mergeSpecDefaults(parsedSpec, plan);
let spec: DiagramSpec;
try {
const parsedSpec = parseDiagramSpecResponse(rawResponse);
spec = mergeSpecDefaults(parsedSpec, plan);
assertValidDiagramSpec(spec);
} catch (validationError: unknown) {
const circuitFallback = resolveConstrainedCircuitFallback(markdown, plan, options);
@ -312,9 +313,18 @@ export async function generateDiagramArtifact(
+ `\n\nCRITICAL: Your previous response used intent "${spec.intent}" but the required intent is "${options.requestedIntent}". This is incorrect. You MUST use "${options.requestedIntent}" as the diagram intent. Do not choose any other intent. Regenerate the DiagramSpec with the correct intent.`;
rawResponse = await options.llmInvoker(retryPrompt, markdown);
parsedSpec = parseDiagramSpecResponse(rawResponse);
spec = mergeSpecDefaults(parsedSpec, plan);
assertValidDiagramSpec(spec);
try {
const parsedSpec = parseDiagramSpecResponse(rawResponse);
spec = mergeSpecDefaults(parsedSpec, plan);
assertValidDiagramSpec(spec);
} catch (retryValidationError: unknown) {
const circuitFallback = resolveConstrainedCircuitFallback(markdown, plan, options);
if (!circuitFallback) {
throw retryValidationError;
}
spec = circuitFallback;
assertValidDiagramSpec(spec);
}
if (spec.intent !== options.requestedIntent) {
const circuitFallback = resolveConstrainedCircuitFallback(markdown, plan, options);

View file

@ -99,7 +99,29 @@ CircuitSpec JSON example for a CMOS inverter request:
"routingStyle": "orthogonal"
}
}
}`
}
For a common-source NMOS request, use this exact topology contract inside circuitSpec:
{
"circuitKind": "common-source-amplifier",
"title": "Common-Source NMOS Amplifier",
"goldenReferenceId": "common-source-nmos-v1",
"style": { "package": "circuitikz", "voltageConvention": "american voltages" },
"nets": ["VDD", "GND", "vin", "vout", "drain"],
"components": [
{ "id": "RD", "type": "resistor", "label": "$R_D$", "terminals": { "top": "VDD", "bottom": "drain" } },
{ "id": "M1", "type": "nmos", "label": "$M_1$", "terminals": { "D": "drain", "G": "vin", "S": "GND" } }
],
"connections": [
{ "from": "VDD", "to": "RD.top" },
{ "from": "RD.bottom", "to": "M1.D" },
{ "from": "M1.D", "to": "vout" },
{ "from": "M1.G", "to": "vin" },
{ "from": "M1.S", "to": "GND" }
],
"layoutHints": { "inputSide": "left", "outputSide": "right", "routingStyle": "orthogonal" }
}
The deterministic renderer, not the model, emits the complete LaTeX document with the circuitikz package, document environment, voltage convention, explicit VDD/RD/M1/vin/vout/GND anchors, and terminated draw paths.`
: '';
const supportedIntentsSection = isCircuitikzRequest
? 'Supported intent: circuit'

View file

@ -14,6 +14,7 @@ export const STRINGS_EN = {
},
commands: {
openDiagramHistory: 'Open diagram history',
manageCircuitikzEnvironment: 'Manage CircuitikZ compile environment',
checkDuplicatesCurrent: 'Check for duplicates in current file',
extractConceptsAndGenerateTitles: 'Extract Concepts and Generate Titles',
createWikiLinkAndGenerateNoteFromSelection: 'Create Wiki-Link & Generate Note from Selection',
@ -159,6 +160,27 @@ export const STRINGS_EN = {
exportFormatsDesc: 'Source file, SVG, PNG, and PDF are available from diagram preview.',
exportPpiName: 'Diagram image export PPI',
exportPpiDesc: 'Resolution for diagram PNG/PDF preview exports. Default 300 ppi; maximum 600 ppi. SVG keeps vector size and ignores this value.',
nativeEnvironmentName: 'CircuitikZ native compile environment',
nativeEnvironmentDesc: 'Optional desktop enhancement for compile diagnostics and native PDF. SVG, PNG, and preview PDF exports continue to work without LaTeX.',
nativeEnvironmentButton: 'Manage environment',
compilerPreferenceName: 'CircuitikZ compiler preference',
compilerPreferenceDesc: 'Automatic discovery prefers the Notemd-managed runtime, then system Tectonic, then system pdfLaTeX. No download starts until you explicitly install it.',
compilerPreferenceAuto: 'Automatic (recommended)',
compilerPreferenceManaged: 'Notemd managed Tectonic',
compilerPreferenceSystem: 'System compiler',
compilerPreferenceCustom: 'Custom executable',
customCompilerKindName: 'Custom compiler type',
customCompilerKindDesc: 'Select the protocol used by the custom executable path.',
customCompilerKindTectonic: 'Tectonic',
customCompilerKindPdflatex: 'pdfLaTeX',
customCompilerPathName: 'Custom compiler executable',
customCompilerPathDesc: 'Absolute path to a trusted Tectonic or pdfLaTeX executable. Command-line arguments are not accepted here.',
customCompilerPathPlaceholder: 'e.g. E:\\TeXLive\\bin\\windows\\pdflatex.exe',
managedRuntimeRootName: 'Managed runtime location',
managedRuntimeRootDesc: 'Optional absolute directory for Notemd-managed LaTeX tools. Leave empty to use the operating-system application-data directory; the Vault is never used by default.',
managedRuntimeRootPlaceholder: 'Use operating-system application data',
compileTimeoutName: 'CircuitikZ compile timeout (seconds)',
compileTimeoutDesc: 'Maximum time for compiler version checks and the deterministic CircuitikZ smoke compile. Range 5-600 seconds; default 120.',
},
diagnosticBlocked: 'Cannot run developer diagnostic for {provider}: {issues}',
diagnosticRunning: 'Running developer diagnostic for {provider}...',
@ -999,6 +1021,58 @@ export const STRINGS_EN = {
deleteArtifactsCancel: 'Cancel',
deleteArtifactsConfirm: 'Move to trash'
},
circuitikzEnvironment: {
title: 'CircuitikZ native compile environment',
previewEyebrow: 'Dependency-free preview exports',
previewTitle: 'No LaTeX installation required',
previewDescription: 'SVG, PNG, and preview PDF exports still work without LaTeX. This environment only adds native compilation evidence and native PDF output.',
nativeEyebrow: 'Optional desktop enhancement',
nativeTitle: 'Compile with a verified local runtime',
nativeDescription: 'Notemd can reuse system Tectonic or pdfLaTeX, or install a pinned Tectonic runtime outside your Vault.',
statusIdle: 'Not checked',
statusChecking: 'Checking compiler and CircuitikZ packages…',
statusInstalling: 'Installing and smoke-testing the managed runtime…',
statusReady: 'Native compile environment is ready',
statusMissing: 'No compatible compiler was found',
statusIncomplete: 'A compiler was found, but the CircuitikZ smoke compile failed',
statusUnsupported: 'Native compilation is unavailable on this device',
statusFailed: 'Environment operation failed',
statusCancelled: 'Environment operation cancelled',
selectedCompiler: 'Selected compiler',
compilerSource: 'Source',
compilerVersion: 'Version',
executablePath: 'Executable',
platform: 'Platform',
architecture: 'Architecture',
managedVersion: 'Managed version',
downloadSize: 'Download size',
installPath: 'Install location',
sourceCustom: 'Custom executable',
sourceManaged: 'Notemd managed runtime',
sourceSystem: 'System installation',
noCompilerSelected: 'No compiler selected',
capabilityHeading: 'Native capabilities',
capabilityCompileDiagnostics: 'Compile diagnostics',
capabilityNativePdf: 'Native PDF',
capabilityRepairAcceptance: 'CircuitikZ repair acceptance',
capabilityAvailable: 'Available',
capabilityUnavailable: 'Unavailable',
managedUnavailable: 'A managed Tectonic build is not published for this platform and architecture. System or custom compilers can still be used.',
progressHeading: 'Installation progress',
progressBytes: '{received} of {total}',
logsHeading: 'Environment log',
noLogs: 'No environment messages yet.',
checkEnvironment: 'Check environment',
installRecommended: 'Install recommended runtime',
repairManaged: 'Repair managed runtime',
removeManaged: 'Remove managed runtime',
clearStaleLock: 'Clear stale install lock',
cancelOperation: 'Cancel current operation',
licenseLink: 'Tectonic license',
releaseLink: 'Tectonic 0.16.9 release',
removalComplete: 'The Notemd-managed runtime was removed. System installations were not changed.',
cancelled: 'The current environment operation was cancelled.'
},
diagramHistory: {
filters: 'Filters', hideFilters: 'Hide filters', loading: 'Loading history...', retry: 'Retry',
emptyTitle: 'No diagram history yet', emptyDescription: 'Completed previews are saved per Vault and appear here.',

View file

@ -18,6 +18,7 @@ export const STRINGS_ZH_CN: DeepPartial<NotemdEnglishStrings> = {
},
commands: {
openDiagramHistory: '打开图形历史',
manageCircuitikzEnvironment: '管理 CircuitikZ 编译环境',
checkDuplicatesCurrent: '检查当前文件中的重复项',
extractConceptsAndGenerateTitles: '提取概念并生成标题',
createWikiLinkAndGenerateNoteFromSelection: '从选中文本创建 Wiki-Link 并生成笔记',
@ -162,6 +163,27 @@ export const STRINGS_ZH_CN: DeepPartial<NotemdEnglishStrings> = {
exportFormatsDesc: '可在图形预览中保存源文件,并导出 SVG、PNG 或 PDF。',
exportPpiName: '图形图片导出 PPI',
exportPpiDesc: '图形 PNG/PDF 预览导出的分辨率。默认 300 ppi最高 600 ppi。SVG 保持矢量尺寸,不受此设置影响。',
nativeEnvironmentName: 'CircuitikZ 原生编译环境',
nativeEnvironmentDesc: '可选的桌面端增强,用于编译诊断与原生 PDF。SVG、PNG 和预览 PDF 导出无需安装 LaTeX 也能继续使用。',
nativeEnvironmentButton: '管理环境',
compilerPreferenceName: 'CircuitikZ 编译器偏好',
compilerPreferenceDesc: '自动发现会依次尝试 Notemd 托管运行时、系统 Tectonic 和系统 pdfLaTeX。只有你明确点击安装后才会下载。',
compilerPreferenceAuto: '自动(推荐)',
compilerPreferenceManaged: 'Notemd 托管 Tectonic',
compilerPreferenceSystem: '系统编译器',
compilerPreferenceCustom: '自定义可执行文件',
customCompilerKindName: '自定义编译器类型',
customCompilerKindDesc: '选择自定义可执行文件使用的编译协议。',
customCompilerKindTectonic: 'Tectonic',
customCompilerKindPdflatex: 'pdfLaTeX',
customCompilerPathName: '自定义编译器可执行文件',
customCompilerPathDesc: '受信任的 Tectonic 或 pdfLaTeX 可执行文件绝对路径。这里不接受命令行参数。',
customCompilerPathPlaceholder: '例如 E:\\TeXLive\\bin\\windows\\pdflatex.exe',
managedRuntimeRootName: '托管运行时位置',
managedRuntimeRootDesc: '可选的 Notemd 托管 LaTeX 工具绝对目录。留空时使用操作系统应用数据目录,默认绝不会写入 Vault。',
managedRuntimeRootPlaceholder: '使用操作系统应用数据目录',
compileTimeoutName: 'CircuitikZ 编译超时(秒)',
compileTimeoutDesc: '编译器版本检查与确定性 CircuitikZ smoke 编译的最长时间。范围 5-600 秒,默认 120 秒。',
},
diagnosticBlocked: '无法为 {provider} 运行开发者诊断:{issues}',
diagnosticRunning: '正在为 {provider} 运行开发者诊断...',
@ -982,6 +1004,58 @@ export const STRINGS_ZH_CN: DeepPartial<NotemdEnglishStrings> = {
},
diagnosticAdvice: '建议:{advice}'
},
circuitikzEnvironment: {
title: 'CircuitikZ 原生编译环境',
previewEyebrow: '零依赖预览导出',
previewTitle: '无需安装 LaTeX',
previewDescription: 'SVG、PNG 和预览 PDF 导出无需 LaTeX 也能继续使用。此环境只增加原生编译证据与原生 PDF 输出。',
nativeEyebrow: '可选桌面端增强',
nativeTitle: '使用已验证的本地运行时编译',
nativeDescription: 'Notemd 可以复用系统 Tectonic 或 pdfLaTeX也可以在 Vault 之外安装固定版本的 Tectonic。',
statusIdle: '尚未检查',
statusChecking: '正在检查编译器与 CircuitikZ 宏包…',
statusInstalling: '正在安装并 smoke 验证托管运行时…',
statusReady: '原生编译环境已就绪',
statusMissing: '未找到兼容的编译器',
statusIncomplete: '已找到编译器,但 CircuitikZ smoke 编译失败',
statusUnsupported: '此设备不支持原生编译',
statusFailed: '环境操作失败',
statusCancelled: '环境操作已取消',
selectedCompiler: '当前编译器',
compilerSource: '来源',
compilerVersion: '版本',
executablePath: '可执行文件',
platform: '平台',
architecture: '架构',
managedVersion: '托管版本',
downloadSize: '下载大小',
installPath: '安装位置',
sourceCustom: '自定义可执行文件',
sourceManaged: 'Notemd 托管运行时',
sourceSystem: '系统安装',
noCompilerSelected: '未选择编译器',
capabilityHeading: '原生能力',
capabilityCompileDiagnostics: '编译诊断',
capabilityNativePdf: '原生 PDF',
capabilityRepairAcceptance: 'CircuitikZ 修复验收',
capabilityAvailable: '可用',
capabilityUnavailable: '不可用',
managedUnavailable: '当前平台与架构没有已发布的托管 Tectonic 构建,仍可使用系统或自定义编译器。',
progressHeading: '安装进度',
progressBytes: '{received} / {total}',
logsHeading: '环境日志',
noLogs: '暂无环境消息。',
checkEnvironment: '检查环境',
installRecommended: '安装推荐运行时',
repairManaged: '修复托管运行时',
removeManaged: '删除托管运行时',
clearStaleLock: '清理过期安装锁',
cancelOperation: '取消当前操作',
licenseLink: 'Tectonic 许可证',
releaseLink: 'Tectonic 0.16.9 发布页',
removalComplete: '已删除 Notemd 托管运行时,未改动系统安装。',
cancelled: '当前环境操作已取消。'
},
diagramHistory: {
filters: '筛选', hideFilters: '收起筛选', loading: '正在加载图形历史...', retry: '重试',
emptyTitle: '还没有图形历史', emptyDescription: '已完成的预览会按 Vault 保存,并显示在这里。',

View file

@ -0,0 +1,265 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import type { NotemdSettings } from '../types';
import {
CircuitikzCompilerCandidate,
createCircuitikzCompilerCandidates,
probeCircuitikzEnvironment,
verifyCircuitikzGoldenFixtures
} from '../diagram/adapters/circuitikz/circuitikzEnvironment';
import { runDesktopCommand } from '../platform/desktopProcess';
import {
getManagedTectonicArtifact,
MANAGED_TECTONIC_VERSION,
ManagedTectonicArtifact,
resolveManagedLatexRuntimeRoot
} from './managedTectonicDistribution';
import {
installManagedTectonic,
ManagedTectonicInstallProgress
} from './managedTectonicInstaller';
import {
clearStaleManagedTectonicLock,
findOwnedManagedTectonicInstallDirectories,
managedRuntimeContainsExistingPath,
recoverActiveManagedTectonic,
withManagedTectonicLock
} from './managedTectonicActivation';
type CircuitikzEnvironmentSettings = Pick<NotemdSettings,
| 'circuitikzCompilerPreference'
| 'circuitikzCustomCompilerKind'
| 'circuitikzCustomCompilerPath'
| 'circuitikzManagedRuntimeRoot'
| 'circuitikzCompileTimeoutMs'>;
export interface CircuitikzEnvironmentDesktopContext {
platform: NodeJS.Platform;
architecture: string;
runtimeRoot: string;
managedRuntimeVersion: string;
managedArtifact: ManagedTectonicArtifact | null;
managedExecutablePath: string | null;
candidates: CircuitikzCompilerCandidate[];
}
function defaultExecutableCheck(candidate: string, platform: NodeJS.Platform): boolean {
try {
fs.accessSync(candidate, platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK);
return fs.statSync(candidate).isFile();
} catch {
return false;
}
}
export function findExecutableOnPath(name: string, input: {
platform: NodeJS.Platform;
pathValue: string;
pathExtValue?: string;
isExecutable?: (candidate: string) => boolean;
}): string | null {
const platformPath = input.platform === 'win32' ? path.win32 : path.posix;
const delimiter = input.platform === 'win32' ? ';' : ':';
const hasExtension = platformPath.extname(name).length > 0;
const extensions = input.platform === 'win32' && !hasExtension
? (input.pathExtValue || '.COM;.EXE;.BAT;.CMD')
.split(';')
.filter(extension => extension && !/^\.(?:bat|cmd)$/i.test(extension))
: [''];
const isExecutable = input.isExecutable ?? (candidate => defaultExecutableCheck(candidate, input.platform));
for (const directory of input.pathValue.split(delimiter).map(value => value.trim()).filter(Boolean)) {
for (const extension of extensions) {
const candidate = platformPath.join(directory, `${name}${extension.toLowerCase()}`);
if (isExecutable(candidate)) return candidate;
if (input.platform === 'win32') {
const upperCandidate = platformPath.join(directory, `${name}${extension.toUpperCase()}`);
if (upperCandidate !== candidate && isExecutable(upperCandidate)) return upperCandidate;
}
}
}
return null;
}
export function createCircuitikzEnvironmentDesktopContext(
settings: CircuitikzEnvironmentSettings,
input: {
platform?: NodeJS.Platform;
architecture?: string;
environment?: NodeJS.ProcessEnv;
homeDirectory?: string;
isExecutable?: (candidate: string) => boolean;
} = {}
): CircuitikzEnvironmentDesktopContext {
const platform = input.platform ?? process.platform;
const architecture = input.architecture ?? process.arch;
const environment = input.environment ?? process.env;
const homeDirectory = input.homeDirectory ?? os.homedir();
const isExecutable = input.isExecutable ?? (candidate => defaultExecutableCheck(candidate, platform));
const runtimeRoot = resolveManagedLatexRuntimeRoot({
platform,
environment,
homeDirectory,
configuredRoot: settings.circuitikzManagedRuntimeRoot
});
const managedArtifact = getManagedTectonicArtifact(platform, architecture);
const activeManagedRuntime = recoverActiveManagedTectonic({
runtimeRoot,
platform,
architecture,
isExecutable
});
const resolvedManagedPath = activeManagedRuntime?.executablePath;
const pathValue = environment.PATH || environment.Path || '';
const systemTectonicPath = findExecutableOnPath('tectonic', {
platform,
pathValue,
pathExtValue: environment.PATHEXT,
isExecutable
}) ?? undefined;
const systemPdflatexPath = findExecutableOnPath('pdflatex', {
platform,
pathValue,
pathExtValue: environment.PATHEXT,
isExecutable
}) ?? undefined;
return {
platform,
architecture,
runtimeRoot,
managedRuntimeVersion: MANAGED_TECTONIC_VERSION,
managedArtifact,
managedExecutablePath: resolvedManagedPath ?? null,
candidates: createCircuitikzCompilerCandidates({
preference: settings.circuitikzCompilerPreference,
customCompilerKind: settings.circuitikzCustomCompilerKind,
customCompilerPath: settings.circuitikzCustomCompilerPath,
managedExecutablePath: resolvedManagedPath,
systemTectonicPath,
systemPdflatexPath
})
};
}
export async function probeConfiguredCircuitikzEnvironment(
settings: CircuitikzEnvironmentSettings,
input: {
isDesktop: boolean;
signal?: AbortSignal;
onCommandOutput?: (text: string) => void;
}
) {
const context = createCircuitikzEnvironmentDesktopContext(settings);
const report = await probeCircuitikzEnvironment({
isDesktop: input.isDesktop,
candidates: context.candidates,
timeoutMs: settings.circuitikzCompileTimeoutMs,
runCommand: command => runDesktopCommand(command, {
signal: input.signal,
onProgress: progress => input.onCommandOutput?.(progress.text)
})
});
return { context, report };
}
export async function installConfiguredManagedTectonic(
settings: CircuitikzEnvironmentSettings,
input: {
signal?: AbortSignal;
onProgress?: (progress: ManagedTectonicInstallProgress) => void;
}
) {
const context = createCircuitikzEnvironmentDesktopContext(settings);
if (!context.managedArtifact) {
return { status: 'failed' as const, message: `No managed Tectonic build is available for ${context.platform}/${context.architecture}.` };
}
return installManagedTectonic({
artifact: context.managedArtifact,
runtimeRoot: context.runtimeRoot,
platform: context.platform,
architecture: context.architecture,
signal: input.signal,
onProgress: input.onProgress,
verifyExecutable: async (executablePath, signal) => {
const candidate = { kind: 'tectonic' as const, source: 'managed' as const, executable: executablePath };
const report = await probeCircuitikzEnvironment({
isDesktop: true,
candidates: [candidate],
timeoutMs: settings.circuitikzCompileTimeoutMs,
runCommand: command => runDesktopCommand(command, { signal })
});
if (report.status !== 'ready') {
return {
ok: false,
message: report.attempts[0]?.message || 'CircuitikZ smoke failed.'
};
}
const golden = await verifyCircuitikzGoldenFixtures({
candidate,
timeoutMs: settings.circuitikzCompileTimeoutMs,
runCommand: command => runDesktopCommand(command, { signal })
});
const failedFixtures = golden.fixtures
.filter(fixture => fixture.status === 'failed')
.map(fixture => fixture.name);
return {
ok: golden.ok,
message: golden.ok
? `CircuitikZ smoke passed for ${golden.fixtures.length} golden fixtures.`
: `CircuitikZ smoke failed for: ${failedFixtures.join(', ')}.`
};
}
});
}
function assertManagedTarget(runtimeRoot: string, targetPath: string): void {
const relative = path.relative(path.resolve(runtimeRoot), path.resolve(targetPath));
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error('Managed runtime target is outside the configured runtime root.');
}
if (fs.existsSync(targetPath) && !managedRuntimeContainsExistingPath(runtimeRoot, targetPath)) {
throw new Error('Managed runtime target resolves outside the configured runtime root.');
}
}
function removalWasCancelled(error: unknown, signal?: AbortSignal): boolean {
return Boolean(signal?.aborted || (error instanceof Error && error.name === 'AbortError'));
}
export async function removeConfiguredManagedTectonic(
settings: CircuitikzEnvironmentSettings,
input: { signal?: AbortSignal } = {}
): Promise<{ status: 'removed' | 'cancelled' }> {
const context = createCircuitikzEnvironmentDesktopContext(settings);
if (input.signal?.aborted) return { status: 'cancelled' };
try {
await withManagedTectonicLock(context.runtimeRoot, async () => {
const ownedInstallDirectories = findOwnedManagedTectonicInstallDirectories(context.runtimeRoot);
if (input.signal?.aborted) {
throw Object.assign(new Error('Managed runtime removal cancelled.'), { name: 'AbortError' });
}
for (const targetDirectory of ownedInstallDirectories) {
assertManagedTarget(context.runtimeRoot, targetDirectory);
fs.rmSync(targetDirectory, { recursive: true, force: true });
}
for (const name of ['managed-runtime.json', 'managed-runtime.json.new', 'managed-runtime.json.previous']) {
const targetPath = path.join(context.runtimeRoot, name);
assertManagedTarget(context.runtimeRoot, targetPath);
fs.rmSync(targetPath, { force: true });
}
}, input.signal);
return { status: 'removed' };
} catch (error) {
if (removalWasCancelled(error, input.signal)) return { status: 'cancelled' };
throw error;
}
}
export async function clearConfiguredManagedTectonicStaleLock(
settings: CircuitikzEnvironmentSettings
): Promise<ReturnType<typeof clearStaleManagedTectonicLock>> {
const context = createCircuitikzEnvironmentDesktopContext(settings);
return clearStaleManagedTectonicLock(context.runtimeRoot);
}

View file

@ -0,0 +1,665 @@
import { createHash, randomBytes } from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
const ACTIVE_MANIFEST_NAME = 'managed-runtime.json';
const PENDING_MANIFEST_NAME = `${ACTIVE_MANIFEST_NAME}.new`;
const PREVIOUS_MANIFEST_NAME = `${ACTIVE_MANIFEST_NAME}.previous`;
const INSTALL_OWNERSHIP_MANIFEST_NAME = '.notemd-managed-runtime.json';
const INSTALL_LOCK_NAME = '.managed-runtime.lock';
const STALE_LOCK_CLAIM_NAME = '.stale-cleanup-claim.json';
const LOCK_RETRY_MS = 25;
const LOCK_WAIT_TIMEOUT_MS = 30_000;
const STALE_LOCK_AGE_MS = 5 * 60_000;
export interface ManagedTectonicManifest {
schemaVersion: 'notemd.managed-latex-runtime.v1';
runtime: 'tectonic';
version: string;
platform: NodeJS.Platform;
architecture: string;
executablePath: string;
archiveUrl: string;
sha256: string;
executableSha256?: string;
installedAt: string;
}
function manifestPath(runtimeRoot: string, name: string): string {
return path.join(runtimeRoot, name);
}
function isLexicallyWithinRuntimeRoot(runtimeRoot: string, candidate: string): boolean {
const relative = path.relative(path.resolve(runtimeRoot), path.resolve(candidate));
return Boolean(relative && !relative.startsWith('..') && !path.isAbsolute(relative));
}
export function managedRuntimeContainsExistingPath(runtimeRoot: string, candidate: string): boolean {
if (!isLexicallyWithinRuntimeRoot(runtimeRoot, candidate)) return false;
try {
const canonicalRoot = fs.realpathSync(runtimeRoot);
const canonicalCandidate = fs.realpathSync(candidate);
const relative = path.relative(canonicalRoot, canonicalCandidate);
return Boolean(relative && !relative.startsWith('..') && !path.isAbsolute(relative));
} catch {
// A missing or inaccessible path cannot establish containment for ownership or deletion.
return false;
}
}
function readValidManifest(
runtimeRoot: string,
candidatePath: string,
isExecutable: (candidate: string) => boolean,
platform?: NodeJS.Platform,
architecture?: string
): ManagedTectonicManifest | null {
try {
const candidate = JSON.parse(fs.readFileSync(candidatePath, 'utf8')) as Partial<ManagedTectonicManifest>;
if (
candidate.schemaVersion !== 'notemd.managed-latex-runtime.v1'
|| candidate.runtime !== 'tectonic'
|| (platform !== undefined && candidate.platform !== platform)
|| (architecture !== undefined && candidate.architecture !== architecture)
|| typeof candidate.version !== 'string'
|| typeof candidate.platform !== 'string'
|| typeof candidate.architecture !== 'string'
|| typeof candidate.executablePath !== 'string'
|| !managedRuntimeContainsExistingPath(runtimeRoot, candidate.executablePath)
|| typeof candidate.archiveUrl !== 'string'
|| typeof candidate.sha256 !== 'string'
|| !/^[a-f0-9]{64}$/i.test(candidate.sha256)
|| (candidate.executableSha256 !== undefined && !/^[a-f0-9]{64}$/i.test(candidate.executableSha256))
|| typeof candidate.installedAt !== 'string'
|| !isExecutable(candidate.executablePath)
) {
return null;
}
return candidate as ManagedTectonicManifest;
} catch {
// Missing, malformed, or incomplete manifests are invalid ownership evidence.
return null;
}
}
export function recoverActiveManagedTectonic(input: {
runtimeRoot: string;
platform: NodeJS.Platform;
architecture: string;
isExecutable: (candidate: string) => boolean;
}): ManagedTectonicManifest | null {
const activePath = manifestPath(input.runtimeRoot, ACTIVE_MANIFEST_NAME);
const pendingPath = manifestPath(input.runtimeRoot, PENDING_MANIFEST_NAME);
const previousPath = manifestPath(input.runtimeRoot, PREVIOUS_MANIFEST_NAME);
const active = readValidManifest(
input.runtimeRoot,
activePath,
input.isExecutable,
input.platform,
input.architecture
);
if (active) return active;
const previous = readValidManifest(
input.runtimeRoot,
previousPath,
input.isExecutable,
input.platform,
input.architecture
);
if (previous) return previous;
const pending = readValidManifest(
input.runtimeRoot,
pendingPath,
input.isExecutable,
input.platform,
input.architecture
);
return pending;
}
function defaultManagedExecutableCheck(candidate: string): boolean {
try {
return fs.lstatSync(candidate).isFile();
} catch {
// An inaccessible path cannot prove an installed executable exists.
return false;
}
}
function isManagedExecutableForPlatform(candidate: string, platform: NodeJS.Platform): boolean {
if (!defaultManagedExecutableCheck(candidate)) return false;
if (platform === 'win32') return true;
try {
fs.accessSync(candidate, fs.constants.X_OK);
return true;
} catch {
// POSIX managed runtimes are unusable and cannot prove ownership without execute permission.
return false;
}
}
function readOwnedManifest(runtimeRoot: string, candidatePath: string): ManagedTectonicManifest | null {
const manifest = readValidManifest(runtimeRoot, candidatePath, defaultManagedExecutableCheck);
if (!manifest || !isManagedExecutableForPlatform(manifest.executablePath, manifest.platform)) return null;
return manifest;
}
function sha256FileSync(filePath: string): string {
return createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
}
function ownershipManifestPath(installDirectory: string): string {
return path.join(installDirectory, INSTALL_OWNERSHIP_MANIFEST_NAME);
}
function manifestOwnsInstallDirectory(
runtimeRoot: string,
manifest: ManagedTectonicManifest,
installDirectory: string,
executableDigest?: string,
platform?: NodeJS.Platform,
architecture?: string
): boolean {
if (path.dirname(path.resolve(manifest.executablePath)) !== path.resolve(installDirectory)) return false;
if (platform !== undefined && manifest.platform !== platform) return false;
if (architecture !== undefined && manifest.architecture !== architecture) return false;
if (!managedRuntimeContainsExistingPath(runtimeRoot, manifest.executablePath)) return false;
if (!isManagedExecutableForPlatform(manifest.executablePath, manifest.platform)) return false;
if (executableDigest !== undefined && sha256FileSync(manifest.executablePath) !== executableDigest) return false;
return true;
}
function installDirectoryHasOwnershipProof(input: {
runtimeRoot: string;
installDirectory: string;
executableDigest: string;
platform: NodeJS.Platform;
architecture: string;
}): boolean {
const ownership = readOwnedManifest(input.runtimeRoot, ownershipManifestPath(input.installDirectory));
if (
ownership?.executableSha256?.toLowerCase() === input.executableDigest
&& manifestOwnsInstallDirectory(
input.runtimeRoot,
ownership,
input.installDirectory,
input.executableDigest,
input.platform,
input.architecture
)
) {
return true;
}
for (const name of [ACTIVE_MANIFEST_NAME, PENDING_MANIFEST_NAME, PREVIOUS_MANIFEST_NAME]) {
const pointer = readOwnedManifest(input.runtimeRoot, manifestPath(input.runtimeRoot, name));
if (pointer && manifestOwnsInstallDirectory(
input.runtimeRoot,
pointer,
input.installDirectory,
input.executableDigest,
input.platform,
input.architecture
)) {
return true;
}
}
return false;
}
export function resolveManagedTectonicTargetDirectory(input: {
runtimeRoot: string;
canonicalTargetDirectory: string;
executableDigest: string;
platform: NodeJS.Platform;
architecture: string;
}): string {
if (!fs.existsSync(input.canonicalTargetDirectory)) return input.canonicalTargetDirectory;
if (installDirectoryHasOwnershipProof({
runtimeRoot: input.runtimeRoot,
installDirectory: input.canonicalTargetDirectory,
executableDigest: input.executableDigest,
platform: input.platform,
architecture: input.architecture
})) {
return input.canonicalTargetDirectory;
}
const canonicalReleaseDirectory = path.dirname(input.canonicalTargetDirectory);
const platformDirectoryName = path.basename(input.canonicalTargetDirectory);
const collisionReleaseDirectory = `${canonicalReleaseDirectory}-exec-${input.executableDigest}`;
while (true) {
const uniqueTargetDirectory = path.join(
`${collisionReleaseDirectory}-${randomBytes(8).toString('hex')}`,
platformDirectoryName
);
if (!fs.existsSync(uniqueTargetDirectory)) return uniqueTargetDirectory;
}
}
export function findOwnedManagedTectonicInstallDirectories(runtimeRoot: string): string[] {
const ownedDirectories = new Set<string>();
for (const name of [ACTIVE_MANIFEST_NAME, PENDING_MANIFEST_NAME, PREVIOUS_MANIFEST_NAME]) {
const manifest = readOwnedManifest(runtimeRoot, manifestPath(runtimeRoot, name));
if (manifest) ownedDirectories.add(path.dirname(path.resolve(manifest.executablePath)));
}
let releaseEntries: fs.Dirent[];
try {
releaseEntries = fs.readdirSync(runtimeRoot, { withFileTypes: true });
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
throw error;
}
for (const releaseEntry of releaseEntries) {
if (!releaseEntry.isDirectory()) continue;
const releaseDirectory = path.join(runtimeRoot, releaseEntry.name);
for (const platformEntry of fs.readdirSync(releaseDirectory, { withFileTypes: true })) {
if (!platformEntry.isDirectory()) continue;
const installDirectory = path.join(releaseDirectory, platformEntry.name);
const manifest = readOwnedManifest(runtimeRoot, ownershipManifestPath(installDirectory));
if (!manifest?.executableSha256) continue;
if (!manifestOwnsInstallDirectory(runtimeRoot, manifest, installDirectory)) continue;
const executableDigest = sha256FileSync(manifest.executablePath);
if (executableDigest !== manifest.executableSha256.toLowerCase()) continue;
ownedDirectories.add(path.resolve(installDirectory));
}
}
return [...ownedDirectories];
}
function writeFileDurably(filePath: string, contents: string): void {
const descriptor = fs.openSync(filePath, 'w');
try {
fs.writeFileSync(descriptor, contents, 'utf8');
fs.fsyncSync(descriptor);
} finally {
fs.closeSync(descriptor);
}
}
function registerPointerOwnedInstall(manifest: ManagedTectonicManifest): void {
const ownershipManifest: ManagedTectonicManifest = {
...manifest,
executableSha256: sha256FileSync(manifest.executablePath)
};
writeFileDurably(
ownershipManifestPath(path.dirname(manifest.executablePath)),
`${JSON.stringify(ownershipManifest, null, 2)}\n`
);
}
function normalizeManagedTectonicPointers(runtimeRoot: string): void {
const activePath = manifestPath(runtimeRoot, ACTIVE_MANIFEST_NAME);
const pendingPath = manifestPath(runtimeRoot, PENDING_MANIFEST_NAME);
const previousPath = manifestPath(runtimeRoot, PREVIOUS_MANIFEST_NAME);
const active = readOwnedManifest(runtimeRoot, activePath);
const previous = readOwnedManifest(runtimeRoot, previousPath);
const pending = readOwnedManifest(runtimeRoot, pendingPath);
for (const manifest of [active, previous, pending]) {
if (manifest) registerPointerOwnedInstall(manifest);
}
if (active) {
fs.rmSync(pendingPath, { force: true });
fs.rmSync(previousPath, { force: true });
return;
}
if (previous) {
fs.rmSync(activePath, { force: true });
fs.renameSync(previousPath, activePath);
fs.rmSync(pendingPath, { force: true });
return;
}
if (pending) {
fs.rmSync(activePath, { force: true });
fs.renameSync(pendingPath, activePath);
fs.rmSync(previousPath, { force: true });
return;
}
fs.rmSync(activePath, { force: true });
fs.rmSync(pendingPath, { force: true });
fs.rmSync(previousPath, { force: true });
}
export function activateManagedTectonic(input: {
runtimeRoot: string;
stagingInstallDirectory: string;
targetDirectory: string;
manifest: ManagedTectonicManifest;
onCleanupDiagnostic?: (message: string) => void;
}): void {
const activePath = manifestPath(input.runtimeRoot, ACTIVE_MANIFEST_NAME);
const pendingPath = manifestPath(input.runtimeRoot, PENDING_MANIFEST_NAME);
const previousPath = manifestPath(input.runtimeRoot, PREVIOUS_MANIFEST_NAME);
const ownershipContents = `${JSON.stringify(input.manifest, null, 2)}\n`;
normalizeManagedTectonicPointers(input.runtimeRoot);
if (!fs.existsSync(input.targetDirectory)) {
writeFileDurably(
path.join(input.stagingInstallDirectory, INSTALL_OWNERSHIP_MANIFEST_NAME),
ownershipContents
);
fs.mkdirSync(path.dirname(input.targetDirectory), { recursive: true });
fs.renameSync(input.stagingInstallDirectory, input.targetDirectory);
} else {
if (!input.manifest.executableSha256 || !installDirectoryHasOwnershipProof({
runtimeRoot: input.runtimeRoot,
installDirectory: input.targetDirectory,
executableDigest: input.manifest.executableSha256,
platform: input.manifest.platform,
architecture: input.manifest.architecture
})) {
throw new Error('Managed runtime activation target exists without valid Notemd ownership.');
}
writeFileDurably(
path.join(input.targetDirectory, INSTALL_OWNERSHIP_MANIFEST_NAME),
ownershipContents
);
}
writeFileDurably(pendingPath, `${JSON.stringify(input.manifest, null, 2)}\n`);
fs.rmSync(previousPath, { force: true });
if (fs.existsSync(activePath)) fs.renameSync(activePath, previousPath);
try {
fs.renameSync(pendingPath, activePath);
} catch (error) {
fs.rmSync(pendingPath, { force: true });
if (!fs.existsSync(activePath) && fs.existsSync(previousPath)) {
fs.renameSync(previousPath, activePath);
}
throw error;
}
try {
fs.rmSync(previousPath, { force: true });
} catch (cleanupError) {
// The authoritative pointer is committed; cleanup must not change the reported activation outcome.
if (!fs.existsSync(activePath)) throw cleanupError;
input.onCleanupDiagnostic?.(
`Managed runtime pointer cleanup warning: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`
);
}
}
function lockIsStale(lockPath: string): boolean {
try {
const owner = JSON.parse(fs.readFileSync(path.join(lockPath, 'owner.json'), 'utf8')) as { pid?: unknown };
if (typeof owner.pid === 'number') {
if (owner.pid === process.pid) return false;
try {
process.kill(owner.pid, 0);
return false;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'EPERM') return false;
return true;
}
}
return Date.now() - fs.statSync(lockPath).mtimeMs > STALE_LOCK_AGE_MS;
} catch {
try {
return Date.now() - fs.statSync(lockPath).mtimeMs > STALE_LOCK_AGE_MS;
} catch {
return false;
}
}
}
type ManagedTectonicLockOwner = { pid: number; token: string };
function readManagedTectonicLockOwner(lockPath: string): ManagedTectonicLockOwner | null {
try {
const owner = JSON.parse(fs.readFileSync(path.join(lockPath, 'owner.json'), 'utf8')) as {
pid?: unknown;
token?: unknown;
};
if (!Number.isInteger(owner.pid) || (owner.pid as number) <= 0 || typeof owner.token !== 'string' || !owner.token) {
return null;
}
return { pid: owner.pid as number, token: owner.token };
} catch {
return null;
}
}
function lockOwnerProcessState(owner: ManagedTectonicLockOwner): 'alive' | 'dead' | 'unknown' {
if (owner.pid === process.pid) return 'alive';
try {
process.kill(owner.pid, 0);
return 'alive';
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'ESRCH') return 'dead';
if (code === 'EPERM') return 'alive';
return 'unknown';
}
}
function lockOwnersMatch(left: ManagedTectonicLockOwner | null, right: ManagedTectonicLockOwner): boolean {
return Boolean(left && left.pid === right.pid && left.token === right.token);
}
function readStaleLockClaim(lockPath: string): { claimToken: string; expectedOwnerToken: string } | null {
try {
const claim = JSON.parse(fs.readFileSync(path.join(lockPath, STALE_LOCK_CLAIM_NAME), 'utf8')) as {
claimToken?: unknown;
expectedOwnerToken?: unknown;
};
if (typeof claim.claimToken !== 'string' || typeof claim.expectedOwnerToken !== 'string') return null;
return { claimToken: claim.claimToken, expectedOwnerToken: claim.expectedOwnerToken };
} catch {
return null;
}
}
function restoreQuarantinedManagedTectonicLock(lockPath: string, quarantinePath: string): boolean {
if (fs.existsSync(lockPath)) return false;
try {
fs.renameSync(quarantinePath, lockPath);
return true;
} catch {
return false;
}
}
export function clearStaleManagedTectonicLock(runtimeRoot: string): {
status: 'cleared' | 'absent' | 'active' | 'changed' | 'unsafe';
message: string;
} {
const lockPath = path.join(runtimeRoot, INSTALL_LOCK_NAME);
if (!fs.existsSync(lockPath)) return { status: 'absent', message: 'No managed runtime lock exists.' };
const expectedOwner = readManagedTectonicLockOwner(lockPath);
if (!expectedOwner) {
return { status: 'unsafe', message: 'The managed runtime lock owner metadata is not valid.' };
}
const initialState = lockOwnerProcessState(expectedOwner);
if (initialState === 'alive') return { status: 'active', message: 'The managed runtime lock owner is still active.' };
if (initialState !== 'dead') return { status: 'unsafe', message: 'The managed runtime lock owner state is unknown.' };
const claimPath = path.join(lockPath, STALE_LOCK_CLAIM_NAME);
const claimToken = randomBytes(16).toString('hex');
let claimDescriptor: number;
try {
claimDescriptor = fs.openSync(claimPath, 'wx');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
return { status: 'changed', message: 'Another stale-lock cleanup contender is active.' };
}
return { status: 'unsafe', message: error instanceof Error ? error.message : String(error) };
}
let cleared = false;
let claimContainerPath = lockPath;
let claimRecordingError: unknown;
try {
fs.writeFileSync(claimDescriptor, JSON.stringify({ claimToken, expectedOwnerToken: expectedOwner.token }), 'utf8');
fs.fsyncSync(claimDescriptor);
} catch (error) {
claimRecordingError = error;
} finally {
try {
fs.closeSync(claimDescriptor);
} catch (error) {
claimRecordingError ??= error;
}
}
if (claimRecordingError) {
try {
fs.rmSync(claimPath, { force: true });
} catch (cleanupError) {
return {
status: 'unsafe',
message: `Failed to record and clean the stale-lock claim: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`
};
}
return {
status: 'unsafe',
message: claimRecordingError instanceof Error ? claimRecordingError.message : String(claimRecordingError)
};
}
try {
const claimedOwner = readManagedTectonicLockOwner(lockPath);
if (!lockOwnersMatch(claimedOwner, expectedOwner)) {
return { status: 'changed', message: 'The managed runtime lock owner changed during cleanup.' };
}
if (lockOwnerProcessState(expectedOwner) !== 'dead') {
return { status: 'changed', message: 'The managed runtime lock owner became active during cleanup.' };
}
const finalOwner = readManagedTectonicLockOwner(lockPath);
if (!lockOwnersMatch(finalOwner, expectedOwner)) {
return { status: 'changed', message: 'The managed runtime lock owner changed before cleanup committed.' };
}
const quarantinePath = `${lockPath}.stale-${claimToken}`;
try {
fs.renameSync(lockPath, quarantinePath);
claimContainerPath = quarantinePath;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return { status: 'changed', message: 'The managed runtime lock changed before cleanup could isolate it.' };
}
return { status: 'unsafe', message: error instanceof Error ? error.message : String(error) };
}
const quarantinedOwner = readManagedTectonicLockOwner(quarantinePath);
const quarantinedClaim = readStaleLockClaim(quarantinePath);
if (
!lockOwnersMatch(quarantinedOwner, expectedOwner)
|| quarantinedClaim?.claimToken !== claimToken
|| quarantinedClaim.expectedOwnerToken !== expectedOwner.token
) {
if (restoreQuarantinedManagedTectonicLock(lockPath, quarantinePath)) claimContainerPath = lockPath;
return { status: 'changed', message: 'The managed runtime lock was replaced before cleanup committed.' };
}
if (lockOwnerProcessState(expectedOwner) !== 'dead') {
if (restoreQuarantinedManagedTectonicLock(lockPath, quarantinePath)) claimContainerPath = lockPath;
return { status: 'changed', message: 'The managed runtime lock owner became active before cleanup committed.' };
}
fs.rmSync(quarantinePath, { recursive: true, force: true });
cleared = true;
return { status: 'cleared', message: 'Cleared the stale managed runtime lock.' };
} finally {
if (!cleared) {
try {
const cleanupClaimPath = path.join(claimContainerPath, STALE_LOCK_CLAIM_NAME);
const claim = JSON.parse(fs.readFileSync(cleanupClaimPath, 'utf8')) as { claimToken?: unknown };
if (claim.claimToken === claimToken) fs.rmSync(cleanupClaimPath, { force: true });
} catch {
// Claim cleanup is best-effort: a leftover claim safely blocks another cleanup attempt,
// while replacing the stable owner outcome with a cleanup error would obscure the race result.
}
}
}
}
function createLockAbortError(): Error {
return Object.assign(new Error('Managed runtime operation cancelled while waiting for the lock.'), {
name: 'AbortError'
});
}
function abortableDelay(durationMs: number, signal?: AbortSignal): Promise<void> {
if (signal?.aborted) return Promise.reject(createLockAbortError());
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
signal?.removeEventListener('abort', cancel);
resolve();
}, durationMs);
const cancel = (): void => {
clearTimeout(timer);
signal?.removeEventListener('abort', cancel);
reject(createLockAbortError());
};
signal?.addEventListener('abort', cancel, { once: true });
});
}
export async function withManagedTectonicLock<T>(
runtimeRoot: string,
operation: () => Promise<T>,
signal?: AbortSignal,
onCleanupDiagnostic?: (message: string) => void
): Promise<T> {
if (signal?.aborted) throw createLockAbortError();
fs.mkdirSync(runtimeRoot, { recursive: true });
const lockPath = path.join(runtimeRoot, INSTALL_LOCK_NAME);
const ownerToken = randomBytes(16).toString('hex');
const deadline = Date.now() + LOCK_WAIT_TIMEOUT_MS;
while (true) {
if (signal?.aborted) throw createLockAbortError();
try {
await fs.promises.mkdir(lockPath);
try {
fs.writeFileSync(
path.join(lockPath, 'owner.json'),
JSON.stringify({ pid: process.pid, token: ownerToken }),
'utf8'
);
} catch (error) {
// mkdir succeeded exclusively, so this directory is still the unannounced lock we own.
fs.rmSync(lockPath, { recursive: true, force: true });
throw error;
}
break;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== 'EEXIST') throw error;
if (lockIsStale(lockPath)) {
throw new Error('Managed runtime lock appears stale; refusing unsafe automatic takeover.');
}
if (Date.now() >= deadline) throw new Error('Timed out waiting for the managed runtime installation lock.');
await abortableDelay(LOCK_RETRY_MS, signal);
}
}
try {
if (signal?.aborted) throw createLockAbortError();
return await operation();
} finally {
let releaseOwnedLock = false;
try {
const owner = JSON.parse(fs.readFileSync(path.join(lockPath, 'owner.json'), 'utf8')) as { token?: unknown };
if (owner.token === ownerToken) {
releaseOwnedLock = true;
fs.rmSync(lockPath, { recursive: true, force: true });
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
const message = error instanceof Error ? error.message : String(error);
onCleanupDiagnostic?.(`Managed runtime lock cleanup warning: ${message}`);
if (releaseOwnedLock) {
try {
const releasedPath = `${lockPath}.released-${ownerToken}`;
fs.renameSync(lockPath, releasedPath);
fs.rmSync(releasedPath, { recursive: true, force: true });
} catch (fallbackError) {
const fallbackMessage = fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
onCleanupDiagnostic?.(`Managed runtime released-lock cleanup warning: ${fallbackMessage}`);
}
}
}
}
}
}

View file

@ -0,0 +1,93 @@
import * as path from 'path';
export const MANAGED_TECTONIC_VERSION = '0.16.9';
export type ManagedTectonicArchiveFormat = 'zip' | 'tar-gz';
export interface ManagedTectonicArtifact {
archiveFormat: ManagedTectonicArchiveFormat;
compressedBytes: number;
executableName: 'tectonic.exe' | 'tectonic';
sha256: string;
url: string;
}
const RELEASE_ROOT = `https://github.com/tectonic-typesetting/tectonic/releases/download/tectonic%40${MANAGED_TECTONIC_VERSION}`;
const MANAGED_TECTONIC_ARTIFACTS: Record<string, ManagedTectonicArtifact> = {
'win32-x64': {
archiveFormat: 'zip',
compressedBytes: 20_035_039,
executableName: 'tectonic.exe',
sha256: '131a24604785a9600989a3d91225f597df52ac06f00aeffe86fd529f99ee5cdd',
url: `${RELEASE_ROOT}/tectonic-${MANAGED_TECTONIC_VERSION}-x86_64-pc-windows-msvc.zip`
},
'darwin-arm64': {
archiveFormat: 'tar-gz',
compressedBytes: 20_590_132,
executableName: 'tectonic',
sha256: 'edb67c61aba768289f6da441c9e6f523cfaff4f8b2a5708523ef29c543f8e88e',
url: `${RELEASE_ROOT}/tectonic-${MANAGED_TECTONIC_VERSION}-aarch64-apple-darwin.tar.gz`
},
'darwin-x64': {
archiveFormat: 'tar-gz',
compressedBytes: 20_572_838,
executableName: 'tectonic',
sha256: '79d8839fa3594bfea9b2bf2ac0a0455bcc4d0de956a5e5c403107e9a72f79e86',
url: `${RELEASE_ROOT}/tectonic-${MANAGED_TECTONIC_VERSION}-x86_64-apple-darwin.tar.gz`
},
'linux-arm64': {
archiveFormat: 'tar-gz',
compressedBytes: 9_923_433,
executableName: 'tectonic',
sha256: 'f9aa39017dbd51f111fdb93dda222178cbe51c8193508fc567b523cc74fff9c1',
url: `${RELEASE_ROOT}/tectonic-${MANAGED_TECTONIC_VERSION}-aarch64-unknown-linux-musl.tar.gz`
},
'linux-x64': {
archiveFormat: 'tar-gz',
compressedBytes: 10_146_030,
executableName: 'tectonic',
sha256: '60b13a0826ae7ad9ce34b4a2df06bff2cfcfa6dda8a915477c0cbb84e1a4a902',
url: `${RELEASE_ROOT}/tectonic-${MANAGED_TECTONIC_VERSION}-x86_64-unknown-linux-musl.tar.gz`
}
};
export function getManagedTectonicArtifact(platform: NodeJS.Platform, architecture: string): ManagedTectonicArtifact | null {
const artifact = MANAGED_TECTONIC_ARTIFACTS[`${platform}-${architecture}`];
return artifact ? { ...artifact } : null;
}
export function isSafeManagedArchiveEntry(entryName: string): boolean {
if (!entryName || entryName.includes('\0') || /^[A-Za-z]:[\\/]/.test(entryName)) {
return false;
}
const normalized = entryName.replace(/\\/g, '/');
if (normalized.startsWith('/')) {
return false;
}
return normalized.split('/').every(segment => segment !== '..');
}
export function resolveManagedLatexRuntimeRoot(input: {
platform: NodeJS.Platform;
environment: NodeJS.ProcessEnv;
homeDirectory: string;
configuredRoot?: string;
}): string {
const platformPath = input.platform === 'win32' ? path.win32 : path.posix;
if (input.configuredRoot?.trim()) {
return platformPath.resolve(input.configuredRoot.trim());
}
if (input.platform === 'win32') {
const localAppData = input.environment.LOCALAPPDATA?.trim()
|| path.win32.join(input.homeDirectory, 'AppData', 'Local');
return path.win32.resolve(localAppData, 'Notemd', 'runtimes', 'latex');
}
if (input.platform === 'darwin') {
return path.posix.resolve(input.homeDirectory, 'Library', 'Application Support', 'Notemd', 'runtimes', 'latex');
}
const dataHome = input.environment.XDG_DATA_HOME?.trim()
|| path.posix.join(input.homeDirectory, '.local', 'share');
return path.posix.resolve(dataHome, 'notemd', 'runtimes', 'latex');
}

View file

@ -0,0 +1,561 @@
import { createHash, randomBytes } from 'crypto';
import * as fs from 'fs';
import type { ClientRequest, IncomingMessage } from 'http';
import { request as httpsRequest, RequestOptions } from 'https';
import * as path from 'path';
import { gunzipSync, unzipSync } from 'fflate';
import { probeCircuitikzEnvironment } from '../diagram/adapters/circuitikz/circuitikzEnvironment';
import { runDesktopCommand } from '../platform/desktopProcess';
import {
isSafeManagedArchiveEntry,
MANAGED_TECTONIC_VERSION,
ManagedTectonicArtifact
} from './managedTectonicDistribution';
import {
activateManagedTectonic,
ManagedTectonicManifest,
resolveManagedTectonicTargetDirectory,
withManagedTectonicLock
} from './managedTectonicActivation';
const ALLOWED_DOWNLOAD_HOSTS = new Set([
'github.com',
'objects.githubusercontent.com',
'release-assets.githubusercontent.com'
]);
const MAX_REDIRECTS = 5;
const TAR_BLOCK_BYTES = 512;
export interface ManagedRuntimeDownloadTimeouts {
responseHeadersMs: number;
bodyInactivityMs: number;
totalMs: number;
}
export const DEFAULT_MANAGED_RUNTIME_DOWNLOAD_TIMEOUTS: Readonly<ManagedRuntimeDownloadTimeouts> = Object.freeze({
responseHeadersMs: 30_000,
bodyInactivityMs: 30_000,
totalMs: 10 * 60_000
});
export type ManagedRuntimeDownloadTimeoutPhase = 'response-headers' | 'body-inactivity' | 'total';
export class ManagedRuntimeDownloadTimeoutError extends Error {
readonly name = 'ManagedRuntimeDownloadTimeoutError';
constructor(
readonly phase: ManagedRuntimeDownloadTimeoutPhase,
readonly timeoutMs: number
) {
super(`Managed runtime download timed out during ${phase} after ${timeoutMs} ms.`);
}
}
export interface ManagedTectonicInstallProgress {
phase: 'download' | 'checksum' | 'extract' | 'smoke' | 'activate' | 'complete' | 'cleanup';
detail?: string;
receivedBytes?: number;
totalBytes?: number;
}
type FetchImplementation = (url: string, init: Record<string, unknown>) => Promise<any>;
type NodeHttpsRequestImplementation = (
url: URL,
options: RequestOptions,
callback: (response: IncomingMessage) => void
) => ClientRequest;
function createAbortError(): Error {
return Object.assign(new Error('Managed runtime download cancelled.'), { name: 'AbortError' });
}
function downloadAbortReason(signal: AbortSignal | undefined): Error {
const reason = signal?.reason;
return reason instanceof ManagedRuntimeDownloadTimeoutError ? reason : createAbortError();
}
function resolveManagedRuntimeDownloadTimeouts(
timeouts: ManagedRuntimeDownloadTimeouts | undefined
): Readonly<ManagedRuntimeDownloadTimeouts> {
const resolved = timeouts ?? DEFAULT_MANAGED_RUNTIME_DOWNLOAD_TIMEOUTS;
for (const [name, durationMs] of Object.entries(resolved)) {
if (!Number.isFinite(durationMs) || durationMs <= 0) {
throw new RangeError(`Managed runtime download timeout ${name} must be a positive finite duration.`);
}
}
return resolved;
}
function assertApprovedDownloadUrl(url: URL): void {
if (url.protocol !== 'https:' || !ALLOWED_DOWNLOAD_HOSTS.has(url.hostname.toLowerCase())) {
throw new Error(`Managed runtime download host is not approved: ${url.hostname || url.href}`);
}
}
function isRedirectStatus(status: number): boolean {
return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
}
async function openApprovedResponse(
initialUrl: string,
signal: AbortSignal | undefined,
fetchImpl: FetchImplementation
): Promise<any> {
let currentUrl = new URL(initialUrl);
for (let redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount++) {
assertApprovedDownloadUrl(currentUrl);
const response = await fetchImpl(currentUrl.href, { method: 'GET', redirect: 'manual', signal });
if (!isRedirectStatus(response.status)) {
if (!response.ok) throw new Error(`Managed runtime download failed with HTTP ${response.status}.`);
return response;
}
const location = response.headers?.get?.('location');
if (!location) throw new Error('Managed runtime redirect did not include a location.');
currentUrl = new URL(location, currentUrl);
}
throw new Error(`Managed runtime download exceeded ${MAX_REDIRECTS} redirects.`);
}
function openApprovedNodeResponse(
currentUrl: URL,
signal: AbortSignal | undefined,
requestImpl: NodeHttpsRequestImplementation,
responseHeadersTimeoutMs: number,
redirectCount = 0
): Promise<IncomingMessage> {
if (signal?.aborted) return Promise.reject(downloadAbortReason(signal));
if (redirectCount > MAX_REDIRECTS) {
return Promise.reject(new Error(`Managed runtime download exceeded ${MAX_REDIRECTS} redirects.`));
}
assertApprovedDownloadUrl(currentUrl);
return new Promise((resolve, reject) => {
let settled = false;
let request: ClientRequest;
let responseHeadersTimer: ReturnType<typeof setTimeout> | undefined;
const cleanup = (): void => {
if (responseHeadersTimer) clearTimeout(responseHeadersTimer);
signal?.removeEventListener('abort', cancel);
};
const rejectOnce = (error: Error): void => {
if (settled) return;
settled = true;
cleanup();
reject(error);
};
const cancel = (): void => {
const error = downloadAbortReason(signal);
request.destroy(error);
rejectOnce(error);
};
try {
request = requestImpl(currentUrl, {
method: 'GET',
headers: { 'User-Agent': 'Notemd managed Tectonic installer' }
}, response => {
if (settled) {
response.destroy();
return;
}
const status = response.statusCode ?? 0;
if (isRedirectStatus(status)) {
const location = response.headers.location;
response.resume();
if (!location) {
rejectOnce(new Error('Managed runtime redirect did not include a location.'));
return;
}
settled = true;
cleanup();
const nextUrl = new URL(location, currentUrl);
void openApprovedNodeResponse(
nextUrl,
signal,
requestImpl,
responseHeadersTimeoutMs,
redirectCount + 1
).then(resolve, reject);
return;
}
if (status < 200 || status >= 300) {
response.resume();
rejectOnce(new Error(`Managed runtime download failed with HTTP ${status}.`));
return;
}
settled = true;
cleanup();
resolve(response);
});
} catch (error) {
rejectOnce(error instanceof Error ? error : new Error(String(error)));
return;
}
request.once('error', rejectOnce);
signal?.addEventListener('abort', cancel, { once: true });
responseHeadersTimer = setTimeout(() => {
const error = new ManagedRuntimeDownloadTimeoutError('response-headers', responseHeadersTimeoutMs);
request.destroy(error);
rejectOnce(error);
}, responseHeadersTimeoutMs);
request.end();
});
}
async function downloadManagedRuntimeArchiveWithNodeHttps(input: {
artifact: ManagedTectonicArtifact;
destinationPath: string;
signal?: AbortSignal;
nodeRequestImpl?: NodeHttpsRequestImplementation;
nodeHttpsTimeouts?: ManagedRuntimeDownloadTimeouts;
onProgress?: (progress: ManagedTectonicInstallProgress) => void;
}): Promise<void> {
const timeouts = resolveManagedRuntimeDownloadTimeouts(input.nodeHttpsTimeouts);
const operationController = new AbortController();
const cancelForCaller = (): void => operationController.abort(createAbortError());
input.signal?.addEventListener('abort', cancelForCaller, { once: true });
const totalTimer = setTimeout(() => {
operationController.abort(new ManagedRuntimeDownloadTimeoutError('total', timeouts.totalMs));
}, timeouts.totalMs);
let response: IncomingMessage | undefined;
let file: fs.promises.FileHandle | undefined;
let receivedBytes = 0;
let failed = false;
let downloadError: unknown;
const cancelResponse = (): void => {
response?.destroy(downloadAbortReason(operationController.signal));
};
try {
response = await openApprovedNodeResponse(
new URL(input.artifact.url),
operationController.signal,
input.nodeRequestImpl ?? httpsRequest,
timeouts.responseHeadersMs
);
operationController.signal.addEventListener('abort', cancelResponse, { once: true });
if (operationController.signal.aborted) throw downloadAbortReason(operationController.signal);
const declaredLength = Number(response.headers['content-length'] ?? 0);
if (Number.isFinite(declaredLength) && declaredLength > input.artifact.compressedBytes) {
throw new Error(`Managed runtime archive exceeds the pinned size (${declaredLength} bytes).`);
}
file = await fs.promises.open(input.destinationPath, 'w');
if (operationController.signal.aborted) throw downloadAbortReason(operationController.signal);
const chunks = response[Symbol.asyncIterator]();
while (true) {
const inactivityError = new ManagedRuntimeDownloadTimeoutError('body-inactivity', timeouts.bodyInactivityMs);
const inactivityTimer = setTimeout(() => response?.destroy(inactivityError), timeouts.bodyInactivityMs);
let nextChunk: IteratorResult<any>;
try {
nextChunk = await chunks.next();
} finally {
clearTimeout(inactivityTimer);
}
if (nextChunk.done) break;
if (operationController.signal.aborted) throw downloadAbortReason(operationController.signal);
const chunk = nextChunk.value;
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
receivedBytes += bytes.length;
if (receivedBytes > input.artifact.compressedBytes) {
throw new Error('Managed runtime archive exceeded the pinned byte size while downloading.');
}
await file.write(bytes);
input.onProgress?.({
phase: 'download',
receivedBytes,
totalBytes: input.artifact.compressedBytes
});
}
} catch (error) {
failed = true;
downloadError = error;
} finally {
clearTimeout(totalTimer);
input.signal?.removeEventListener('abort', cancelForCaller);
operationController.signal.removeEventListener('abort', cancelResponse);
if (failed) response?.destroy();
if (file) {
try {
await file.close();
} catch (error) {
if (!failed) {
failed = true;
downloadError = error;
}
}
}
if (failed || receivedBytes !== input.artifact.compressedBytes) {
fs.rmSync(input.destinationPath, { force: true });
}
}
if (failed) throw downloadError;
if (receivedBytes !== input.artifact.compressedBytes) {
throw new Error(`Managed runtime archive size mismatch: expected ${input.artifact.compressedBytes}, received ${receivedBytes}.`);
}
}
export async function downloadManagedRuntimeArchive(input: {
artifact: ManagedTectonicArtifact;
destinationPath: string;
signal?: AbortSignal;
fetchImpl?: FetchImplementation;
nodeRequestImpl?: NodeHttpsRequestImplementation;
nodeHttpsTimeouts?: ManagedRuntimeDownloadTimeouts;
onProgress?: (progress: ManagedTectonicInstallProgress) => void;
}): Promise<void> {
if (input.signal?.aborted) throw createAbortError();
fs.mkdirSync(path.dirname(input.destinationPath), { recursive: true });
if (!input.fetchImpl) {
await downloadManagedRuntimeArchiveWithNodeHttps(input);
return;
}
const response = await openApprovedResponse(input.artifact.url, input.signal, input.fetchImpl);
const declaredLength = Number(response.headers?.get?.('content-length') ?? 0);
if (Number.isFinite(declaredLength) && declaredLength > input.artifact.compressedBytes) {
throw new Error(`Managed runtime archive exceeds the pinned size (${declaredLength} bytes).`);
}
if (!response.body?.getReader) throw new Error('Managed runtime response body is not streamable.');
const file = await fs.promises.open(input.destinationPath, 'w');
let receivedBytes = 0;
try {
const reader = response.body.getReader();
while (true) {
if (input.signal?.aborted) throw createAbortError();
const chunk = await reader.read();
if (chunk.done) break;
const bytes = Buffer.from(chunk.value);
receivedBytes += bytes.length;
if (receivedBytes > input.artifact.compressedBytes) {
throw new Error('Managed runtime archive exceeded the pinned byte size while downloading.');
}
await file.write(bytes);
input.onProgress?.({
phase: 'download',
receivedBytes,
totalBytes: input.artifact.compressedBytes
});
}
} catch (error) {
await file.close();
fs.rmSync(input.destinationPath, { force: true });
throw error;
}
await file.close();
if (receivedBytes !== input.artifact.compressedBytes) {
fs.rmSync(input.destinationPath, { force: true });
throw new Error(`Managed runtime archive size mismatch: expected ${input.artifact.compressedBytes}, received ${receivedBytes}.`);
}
}
function decodeTarString(bytes: Uint8Array): string {
const zero = bytes.indexOf(0);
return Buffer.from(zero >= 0 ? bytes.subarray(0, zero) : bytes).toString('utf8').trim();
}
function parseTarSize(bytes: Uint8Array): number {
const raw = decodeTarString(bytes).replace(/\0/g, '').trim();
return raw ? parseInt(raw, 8) : 0;
}
function extractExecutableFromTar(archiveBytes: Uint8Array, executableName: string): Uint8Array {
const tarBytes = gunzipSync(archiveBytes);
let offset = 0;
let executable: Uint8Array | null = null;
while (offset + TAR_BLOCK_BYTES <= tarBytes.length) {
const header = tarBytes.subarray(offset, offset + TAR_BLOCK_BYTES);
if (header.every(byte => byte === 0)) break;
const name = decodeTarString(header.subarray(0, 100));
const prefix = decodeTarString(header.subarray(345, 500));
const entryName = prefix ? `${prefix}/${name}` : name;
if (!isSafeManagedArchiveEntry(entryName)) throw new Error(`Unsafe managed runtime archive entry: ${entryName}`);
const size = parseTarSize(header.subarray(124, 136));
const type = String.fromCharCode(header[156] || 0);
if (type === '1' || type === '2') throw new Error(`Managed runtime archive links are not allowed: ${entryName}`);
const bodyStart = offset + TAR_BLOCK_BYTES;
const bodyEnd = bodyStart + size;
if (bodyEnd > tarBytes.length) throw new Error(`Managed runtime tar entry is truncated: ${entryName}`);
if ((type === '\0' || type === '0') && path.posix.basename(entryName.replace(/\\/g, '/')) === executableName) {
if (executable) throw new Error(`Managed runtime archive contains multiple ${executableName} files.`);
executable = tarBytes.slice(bodyStart, bodyEnd);
}
offset = bodyStart + Math.ceil(size / TAR_BLOCK_BYTES) * TAR_BLOCK_BYTES;
}
if (!executable) throw new Error(`Managed runtime archive does not contain ${executableName}.`);
return executable;
}
export function extractManagedTectonicExecutable(
archiveBytes: Uint8Array,
artifact: ManagedTectonicArtifact
): Uint8Array {
if (artifact.archiveFormat === 'tar-gz') {
return extractExecutableFromTar(archiveBytes, artifact.executableName);
}
const entries = unzipSync(archiveBytes);
let executable: Uint8Array | null = null;
for (const [entryName, bytes] of Object.entries(entries)) {
if (!isSafeManagedArchiveEntry(entryName)) throw new Error(`Unsafe managed runtime archive entry: ${entryName}`);
if (path.posix.basename(entryName.replace(/\\/g, '/')) !== artifact.executableName) continue;
if (executable) throw new Error(`Managed runtime archive contains multiple ${artifact.executableName} files.`);
executable = bytes;
}
if (!executable) throw new Error(`Managed runtime archive does not contain ${artifact.executableName}.`);
return executable;
}
async function sha256File(filePath: string): Promise<string> {
const hash = createHash('sha256');
await new Promise<void>((resolve, reject) => {
const stream = fs.createReadStream(filePath);
stream.on('data', chunk => hash.update(chunk));
stream.on('error', reject);
stream.on('end', resolve);
});
return hash.digest('hex');
}
function stagingName(): string {
return `.staging-${Date.now()}-${randomBytes(4).toString('hex')}`;
}
async function verifyManagedExecutable(executablePath: string, signal?: AbortSignal): Promise<{ ok: boolean; message: string }> {
const report = await probeCircuitikzEnvironment({
isDesktop: true,
candidates: [{ kind: 'tectonic', source: 'managed', executable: executablePath }],
runCommand: command => runDesktopCommand(command, { signal })
});
return {
ok: report.status === 'ready',
message: report.attempts[0]?.message || (report.status === 'ready' ? 'CircuitikZ smoke passed.' : 'CircuitikZ smoke failed.')
};
}
function isAbortError(error: unknown, signal?: AbortSignal): boolean {
return Boolean(signal?.aborted || (error instanceof Error && error.name === 'AbortError'));
}
type InstallManagedTectonicInput = {
artifact: ManagedTectonicArtifact;
runtimeRoot: string;
platform: NodeJS.Platform;
architecture: string;
signal?: AbortSignal;
fetchImpl?: FetchImplementation;
onProgress?: (progress: ManagedTectonicInstallProgress) => void;
verifyExecutable?: (executablePath: string, signal?: AbortSignal) => Promise<{ ok: boolean; message: string }>;
};
async function installManagedTectonicUnlocked(input: InstallManagedTectonicInput): Promise<{
status: 'installed' | 'cancelled' | 'failed';
executablePath?: string;
message: string;
}> {
if (input.signal?.aborted) return { status: 'cancelled', message: 'Installation cancelled.' };
fs.mkdirSync(input.runtimeRoot, { recursive: true });
const stagingRoot = path.join(input.runtimeRoot, stagingName());
const stagingInstallDirectory = path.join(stagingRoot, 'install');
const archivePath = path.join(stagingRoot, 'runtime.partial');
const stagingExecutablePath = path.join(stagingInstallDirectory, input.artifact.executableName);
const canonicalTargetDirectory = path.join(
input.runtimeRoot,
`tectonic-${MANAGED_TECTONIC_VERSION}-${input.artifact.sha256.toLowerCase()}`,
`${input.platform}-${input.architecture}`
);
try {
fs.mkdirSync(stagingInstallDirectory, { recursive: true });
input.onProgress?.({ phase: 'download', detail: 'Downloading the pinned Tectonic archive.' });
await downloadManagedRuntimeArchive({
artifact: input.artifact,
destinationPath: archivePath,
signal: input.signal,
fetchImpl: input.fetchImpl,
onProgress: input.onProgress
});
input.onProgress?.({ phase: 'checksum', detail: 'Verifying SHA-256.' });
const digest = await sha256File(archivePath);
if (digest !== input.artifact.sha256.toLowerCase()) {
throw new Error(`Managed runtime checksum mismatch: expected ${input.artifact.sha256}, received ${digest}.`);
}
input.onProgress?.({ phase: 'extract', detail: 'Extracting the verified executable.' });
const executableBytes = extractManagedTectonicExecutable(fs.readFileSync(archivePath), input.artifact);
fs.writeFileSync(stagingExecutablePath, executableBytes);
if (input.platform !== 'win32') fs.chmodSync(stagingExecutablePath, 0o755);
const executableDigest = await sha256File(stagingExecutablePath);
input.onProgress?.({ phase: 'smoke', detail: 'Compiling the deterministic CircuitikZ smoke fixture.' });
const verification = await (input.verifyExecutable ?? verifyManagedExecutable)(stagingExecutablePath, input.signal);
if (input.signal?.aborted) return { status: 'cancelled', message: 'Installation cancelled.' };
if (!verification.ok) throw new Error(verification.message);
input.onProgress?.({ phase: 'activate', detail: 'Activating the verified runtime.' });
const targetDirectory = resolveManagedTectonicTargetDirectory({
runtimeRoot: input.runtimeRoot,
canonicalTargetDirectory,
executableDigest,
platform: input.platform,
architecture: input.architecture
});
const targetExecutablePath = path.join(targetDirectory, input.artifact.executableName);
const manifest: ManagedTectonicManifest = {
schemaVersion: 'notemd.managed-latex-runtime.v1',
runtime: 'tectonic',
version: MANAGED_TECTONIC_VERSION,
platform: input.platform,
architecture: input.architecture,
executablePath: targetExecutablePath,
archiveUrl: input.artifact.url,
sha256: input.artifact.sha256,
executableSha256: executableDigest,
installedAt: new Date().toISOString()
};
activateManagedTectonic({
runtimeRoot: input.runtimeRoot,
stagingInstallDirectory,
targetDirectory,
manifest,
onCleanupDiagnostic: detail => input.onProgress?.({ phase: 'cleanup', detail })
});
input.onProgress?.({ phase: 'complete', detail: verification.message });
return { status: 'installed', executablePath: targetExecutablePath, message: verification.message };
} catch (error) {
if (isAbortError(error, input.signal)) return { status: 'cancelled', message: 'Installation cancelled.' };
return { status: 'failed', message: error instanceof Error ? error.message : String(error) };
} finally {
input.onProgress?.({ phase: 'cleanup', detail: 'Cleaning temporary installation files.' });
try {
fs.rmSync(stagingRoot, { recursive: true, force: true });
} catch (cleanupError) {
input.onProgress?.({
phase: 'cleanup',
detail: `Managed runtime staging cleanup warning: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`
});
}
}
}
export async function installManagedTectonic(input: InstallManagedTectonicInput): Promise<{
status: 'installed' | 'cancelled' | 'failed';
executablePath?: string;
message: string;
}> {
if (input.signal?.aborted) return { status: 'cancelled', message: 'Installation cancelled.' };
try {
return await withManagedTectonicLock(
input.runtimeRoot,
() => installManagedTectonicUnlocked(input),
input.signal,
detail => input.onProgress?.({ phase: 'cleanup', detail })
);
} catch (error) {
if (isAbortError(error, input.signal)) return { status: 'cancelled', message: 'Installation cancelled.' };
return { status: 'failed', message: error instanceof Error ? error.message : String(error) };
}
}

View file

@ -14,6 +14,7 @@ import type { DiagramHistoryEntry } from './diagram/history/diagramHistoryReposi
import { collectDiagramHistoryArtifactPaths } from './diagram/history/diagramHistoryActions';
import type { DiagramHistoryStore } from './ui/DiagramHistoryView';
import { DiagramHistoryModal } from './ui/DiagramHistoryModal';
import { CircuitikzEnvironmentModal } from './ui/CircuitikzEnvironmentModal';
import {
canonicalizeProviderConfigs,
resolveCanonicalProviderName
@ -105,6 +106,7 @@ import {
runPreviewDiagramCommandWithHost
} from './operations/diagramCommandHostAdapter';
import { stopAllServers } from './slideExport/localServer';
import { isDesktopApp } from './slideExport/platformUtils';
import type { SlideExportConfig, SlidevExportSource } from './slideExport/types';
import {
DiagramCommandExecutionHost,
@ -281,6 +283,14 @@ export default class NotemdPlugin extends Plugin {
new DiagramHistoryModal(this.app, this.createDiagramHistoryStore(), this.settings.uiLocale).open();
}
public openCircuitikzEnvironment(): void {
new CircuitikzEnvironmentModal(this.app, {
settings: this.settings,
uiLocale: this.settings.uiLocale,
isDesktop: isDesktopApp()
}).open();
}
private openDiagramPreviewModal(artifact: RenderArtifact, sourcePath: string, artifactSaved = false, existingHistoryEntryId?: string) {
const i18n = this.getUiStrings();
const targetLabel = getRenderTargetDisplayName(artifact.target);
@ -814,6 +824,12 @@ export default class NotemdPlugin extends Plugin {
callback: () => this.openDiagramHistory()
});
this.addCommand({
id: 'notemd-manage-circuitikz-environment',
name: uiStrings.commands.manageCircuitikzEnvironment,
callback: () => this.openCircuitikzEnvironment()
});
// Legacy compatibility aliases remain registered until downstream workflows
// and docs fully converge on the canonical diagram command ids.
this.registerEditorDiagramCommand(

View file

@ -247,12 +247,18 @@ function buildDiagramCommandOptions(input: Record<string, unknown>): DiagramComm
) || 'save-artifact';
const inputOverrides: DiagramCommandInputOverrides = {};
const requestedIntent = optionalString(input, 'requestedIntent');
const requestedRenderTarget = optionalEnum(input, 'requestedRenderTarget', [
'mermaid', 'json-canvas', 'vega-lite', 'html', 'editable-html-svg', 'drawio', 'drawnix', 'circuitikz'
] as const);
const compatibilityMode = optionalEnum(input, 'compatibilityMode', ['best-fit', 'legacy-mermaid'] as const);
const targetLanguage = optionalString(input, 'targetLanguage');
if (requestedIntent) {
inputOverrides.requestedIntent = requestedIntent as DiagramCommandInputOverrides['requestedIntent'];
}
if (requestedRenderTarget) {
inputOverrides.requestedRenderTarget = requestedRenderTarget;
}
if (compatibilityMode) {
inputOverrides.compatibilityMode = compatibilityMode;
}

View file

@ -1,7 +1,7 @@
import { TFile } from 'obsidian';
import { formatI18n } from '../i18n';
import { DiagramGenerationResult } from '../diagram/diagramGenerationService';
import { DiagramIntent, isSupportedDiagramIntent } from '../diagram/types';
import { DiagramIntent, isSupportedDiagramIntent, RenderTarget } from '../diagram/types';
import { LocalKnowledgeRetrievalSummary } from '../localKnowledgeBase';
import { RenderArtifact } from '../rendering/types';
import { ensureSemanticFigureSvgStandaloneStyles } from '../rendering/renderers/editableHtmlSvgRenderer';
@ -25,6 +25,7 @@ export type DiagramCommandExecutionMode = DiagramOperationExecutionMode;
export interface DiagramCommandInputOverrides {
requestedIntent?: DiagramIntent;
requestedRenderTarget?: RenderTarget;
compatibilityMode?: 'best-fit' | 'legacy-mermaid';
targetLanguage?: string;
}
@ -441,7 +442,7 @@ function buildCircuitikzPreviewArtifact(circuitikzContent: string): RenderArtifa
target: 'circuitikz',
content: circuitikzContent.trim(),
mimeType: 'text/x-tex',
sourceIntent: 'flowchart'
sourceIntent: 'circuit'
};
}
@ -719,6 +720,19 @@ function resolveEmbeddedSvgPath(markdownContent: string, sourcePath: string): st
return joinVaultPath(getVaultPathDirectory(sourcePath), target);
}
function resolveEmbeddedSourceArtifactPath(markdownContent: string, sourcePath: string): string | null {
const sourceMatch = markdownContent.match(/Source artifact:\s*\[\[([^\]|#]+)(?:[|#][^\]]*)?\]\]/i);
const rawTarget = sourceMatch?.[1];
if (!rawTarget?.trim()) {
return null;
}
const target = decodeURIComponent(rawTarget.trim()).replace(/^\/+/, '');
return target.includes('/')
? target
: joinVaultPath(getVaultPathDirectory(sourcePath), target);
}
async function readVaultTextFile(
host: Pick<DiagramCommandHostAdapter, 'getFileByPath' | 'readFile'>,
path: string
@ -757,6 +771,34 @@ async function tryBuildSvgWrapperPreview(params: {
};
}
async function tryBuildLinkedSourceArtifactPreview(params: {
host: Pick<DiagramCommandHostAdapter, 'getFileByPath' | 'readFile'>;
sourceContent: string;
sourcePath: string;
}): Promise<{ preview: DirectPreviewArtifactResult; sourcePath: string } | null> {
const sourceArtifactPath = resolveEmbeddedSourceArtifactPath(params.sourceContent, params.sourcePath);
if (!sourceArtifactPath) {
return null;
}
const artifactContent = await readVaultTextFile(params.host, sourceArtifactPath);
if (!artifactContent) {
return null;
}
try {
return {
preview: resolveDirectPreviewArtifact(artifactContent, sourceArtifactPath),
sourcePath: sourceArtifactPath
};
} catch (error) {
if (error instanceof MissingPreviewableDiagramArtifactError) {
return null;
}
throw error;
}
}
async function attachCompanionSvgPreview(params: {
host: Pick<DiagramCommandHostAdapter, 'getFileByPath' | 'readFile'>;
artifact: RenderArtifact;
@ -801,47 +843,50 @@ export async function previewArtifactFromSavedPath(params: {
const sourceContent = await params.host.readFile(savedFile);
let directPreview: DirectPreviewArtifactResult;
let resolvedSourcePath = params.sourcePath;
try {
directPreview = previewArtifactFromFile({
host: params.host,
sourceContent,
sourcePath: params.sourcePath,
artifactSavedOverride: params.artifactSavedOverride
});
directPreview = resolveDirectPreviewArtifact(sourceContent, params.sourcePath);
} catch (error) {
if (!(error instanceof MissingPreviewableDiagramArtifactError)) {
throw error;
}
const svgWrapperPreview = await tryBuildSvgWrapperPreview({
const linkedSourcePreview = await tryBuildLinkedSourceArtifactPreview({
host: params.host,
sourceContent,
sourcePath: params.sourcePath
});
if (!svgWrapperPreview) {
throw error;
if (linkedSourcePreview) {
directPreview = {
...linkedSourcePreview.preview,
detectionLabel: `${linkedSourcePreview.preview.detectionLabel} via Obsidian preview wrapper`
};
resolvedSourcePath = linkedSourcePreview.sourcePath;
} else {
const svgWrapperPreview = await tryBuildSvgWrapperPreview({
host: params.host,
sourceContent,
sourcePath: params.sourcePath
});
if (!svgWrapperPreview) {
throw error;
}
directPreview = svgWrapperPreview;
}
directPreview = svgWrapperPreview;
params.host.openPreview(directPreview.artifact, params.sourcePath, true);
}
const artifact = await attachCompanionSvgPreview({
host: params.host,
artifact: directPreview.artifact,
sourcePath: params.sourcePath
sourcePath: resolvedSourcePath
});
if (artifact !== directPreview.artifact) {
const artifactSaved = params.artifactSavedOverride ?? directPreview.artifactSaved;
params.host.openPreview(artifact, params.sourcePath, artifactSaved);
return {
...directPreview,
artifact,
artifactSaved
};
}
return directPreview;
const artifactSaved = params.artifactSavedOverride ?? directPreview.artifactSaved;
params.host.openPreview(artifact, resolvedSourcePath, artifactSaved);
return {
...directPreview,
artifact,
artifactSaved
};
}
async function previewLocalGeneratedDiagramArtifact(params: {
@ -943,6 +988,7 @@ export async function runGenerateDiagramCommandWithHost(
settings: host.getSettings(),
targetLanguage: host.getTaskLanguageCode('summarizeToMermaid'),
requestedIntentOverride: options.inputOverrides?.requestedIntent,
requestedRenderTargetOverride: options.inputOverrides?.requestedRenderTarget,
compatibilityModeOverride: options.inputOverrides?.compatibilityMode,
targetLanguageOverride: options.inputOverrides?.targetLanguage
});
@ -1064,15 +1110,36 @@ export async function runPreviewDiagramCommandWithHost(
throw error;
}
directPreview = await tryBuildSvgWrapperPreview({
const linkedSourcePreview = await tryBuildLinkedSourceArtifactPreview({
host: diagramHost,
sourceContent: fileContent,
sourcePath: file.path
});
if (directPreview) {
diagramHost.openPreview(directPreview.artifact, file.path, true);
if (linkedSourcePreview) {
const artifact = await attachCompanionSvgPreview({
host: diagramHost,
artifact: linkedSourcePreview.preview.artifact,
sourcePath: linkedSourcePreview.sourcePath
});
directPreview = {
...linkedSourcePreview.preview,
artifact,
artifactSaved: true,
detectionLabel: `${linkedSourcePreview.preview.detectionLabel} via Obsidian preview wrapper`
};
diagramHost.openPreview(artifact, linkedSourcePreview.sourcePath, true);
} else {
directPreview = await tryBuildSvgWrapperPreview({
host: diagramHost,
sourceContent: fileContent,
sourcePath: file.path
});
}
if (directPreview && !linkedSourcePreview) {
diagramHost.openPreview(directPreview.artifact, file.path, true);
} else if (!directPreview) {
directPreview = await previewLocalGeneratedDiagramArtifact({
host: diagramHost,
file,

View file

@ -111,6 +111,10 @@ const DIAGRAM_GENERATE_INPUT_SCHEMA: OperationSchema = {
sourcePath: { type: 'string' },
sourceMarkdown: { type: 'string' },
requestedIntent: { type: 'string' },
requestedRenderTarget: {
type: 'string',
enum: ['mermaid', 'json-canvas', 'vega-lite', 'html', 'editable-html-svg', 'drawio', 'drawnix', 'circuitikz']
},
compatibilityMode: {
type: 'string',
enum: ['best-fit', 'legacy-mermaid']

View file

@ -0,0 +1,176 @@
import { ChildProcess, spawn } from 'child_process';
const DEFAULT_MAX_OUTPUT_BYTES = 256 * 1024;
const TERMINATION_GRACE_MS = 250;
export interface DesktopCommand {
executable: string;
args: string[];
cwd?: string;
env?: Record<string, string>;
timeoutMs: number;
maxOutputBytes?: number;
}
export interface DesktopCommandProgress {
stream: 'stdout' | 'stderr';
text: string;
}
export interface DesktopCommandExit {
exitCode: number | null;
signal: NodeJS.Signals | null;
timedOut: boolean;
cancelled: boolean;
stdout: string;
stderr: string;
errorMessage?: string;
}
export interface DesktopCommandOptions {
signal?: AbortSignal;
onProgress?: (progress: DesktopCommandProgress) => void;
}
function appendBounded(previous: Buffer, chunk: Buffer, maximumBytes: number): Buffer {
const combined = Buffer.concat([previous, chunk]);
if (combined.length <= maximumBytes) {
return combined;
}
return combined.subarray(combined.length - maximumBytes);
}
function terminateProcessTree(child: ChildProcess): void {
if (!child.pid) {
child.kill();
return;
}
if (process.platform === 'win32') {
const terminator = spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
shell: false,
windowsHide: true,
stdio: 'ignore'
});
terminator.on('error', () => child.kill());
return;
}
try {
process.kill(-child.pid, 'SIGTERM');
} catch {
child.kill('SIGTERM');
}
}
export function runDesktopCommand(
command: DesktopCommand,
options: DesktopCommandOptions = {}
): Promise<DesktopCommandExit> {
if (options.signal?.aborted) {
return Promise.resolve({
exitCode: null,
signal: null,
timedOut: false,
cancelled: true,
stdout: '',
stderr: ''
});
}
return new Promise(resolve => {
const maximumBytes = Math.max(1, command.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES);
let stdout: Buffer = Buffer.alloc(0);
let stderr: Buffer = Buffer.alloc(0);
let timedOut = false;
let cancelled = false;
let spawnError: Error | undefined;
let settled = false;
let terminationCause: 'cancelled' | 'timeout' | undefined;
let timeoutTimer: ReturnType<typeof setTimeout> | undefined;
let forceKillTimer: ReturnType<typeof setTimeout> | undefined;
let pendingExit: { exitCode: number | null; signal: NodeJS.Signals | null } | undefined;
const child = spawn(command.executable, command.args, {
cwd: command.cwd,
env: command.env ? { ...process.env, ...command.env } : process.env,
shell: false,
windowsHide: true,
detached: process.platform !== 'win32',
stdio: ['ignore', 'pipe', 'pipe']
});
const resolveExit = (exitCode: number | null, signal: NodeJS.Signals | null): void => {
if (settled) return;
settled = true;
if (timeoutTimer) clearTimeout(timeoutTimer);
if (forceKillTimer) clearTimeout(forceKillTimer);
options.signal?.removeEventListener('abort', cancel);
resolve({
exitCode,
signal,
timedOut,
cancelled,
stdout: stdout.toString('utf8'),
stderr: stderr.toString('utf8'),
...(spawnError ? { errorMessage: spawnError.message } : {})
});
};
const finish = (exitCode: number | null, signal: NodeJS.Signals | null): void => {
if (process.platform !== 'win32' && terminationCause && forceKillTimer) {
pendingExit = { exitCode, signal };
if (timeoutTimer) clearTimeout(timeoutTimer);
options.signal?.removeEventListener('abort', cancel);
return;
}
resolveExit(exitCode, signal);
};
const requestTermination = (cause: 'cancelled' | 'timeout'): void => {
if (settled || terminationCause) return;
terminationCause = cause;
cancelled = cause === 'cancelled';
timedOut = cause === 'timeout';
if (timeoutTimer) clearTimeout(timeoutTimer);
options.signal?.removeEventListener('abort', cancel);
terminateProcessTree(child);
forceKillTimer = setTimeout(() => {
if (settled) return;
forceKillTimer = undefined;
try {
if (process.platform !== 'win32' && child.pid) process.kill(-child.pid, 'SIGKILL');
else child.kill('SIGKILL');
} catch {
child.kill('SIGKILL');
}
if (pendingExit) resolveExit(pendingExit.exitCode, pendingExit.signal);
}, TERMINATION_GRACE_MS);
};
const cancel = (): void => {
requestTermination('cancelled');
};
child.stdout?.on('data', (chunk: Buffer | string) => {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
stdout = appendBounded(stdout, buffer, maximumBytes);
options.onProgress?.({ stream: 'stdout', text: buffer.toString('utf8') });
});
child.stderr?.on('data', (chunk: Buffer | string) => {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
stderr = appendBounded(stderr, buffer, maximumBytes);
options.onProgress?.({ stream: 'stderr', text: buffer.toString('utf8') });
});
child.on('error', error => {
spawnError = error;
finish(null, null);
});
child.on('close', (exitCode, signal) => finish(exitCode, signal));
timeoutTimer = setTimeout(() => {
requestTermination('timeout');
}, Math.max(1, command.timeoutMs));
options.signal?.addEventListener('abort', cancel, { once: true });
});
}

View file

@ -302,17 +302,17 @@ export function renderCircuitSpecPreviewSvg(spec: CircuitSpec): string {
<desc id="notemd-circuit-desc">${escapeHtml(`${spec.circuitKind} circuitikz preview companion for ${spec.goldenReferenceId}`)}</desc>
<style>
.notemd-circuit-canvas { fill: #ffffff; }
.notemd-circuit-stage { fill: none; stroke: #1f2937; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
.notemd-circuit-stage { fill: none; stroke: #1f2937; stroke-width: 1.2; stroke-linecap: round; stroke-linejoin: round; }
.notemd-circuit-stage text { stroke: none; }
.notemd-circuit-component rect { fill: #ffffff; stroke: #1f2937; stroke-width: 1.6; }
.notemd-circuit-component rect { fill: #ffffff; stroke: #1f2937; stroke-width: 1.2; }
.notemd-circuit-pmos rect { fill: #fff7ed; }
.notemd-circuit-nmos rect { fill: #eff6ff; }
.notemd-circuit-mos-bubble { fill: #ffffff; stroke: #1f2937; stroke-width: 1.6; }
.notemd-circuit-port { fill: #ffffff; stroke: #1f2937; stroke-width: 1.6; }
.notemd-circuit-mos-bubble { fill: #ffffff; stroke: #1f2937; stroke-width: 1.2; }
.notemd-circuit-port { fill: #ffffff; stroke: #1f2937; stroke-width: 1.2; }
.notemd-circuit-junction { fill: #111827; stroke: none; }
.notemd-circuit-label { fill: #111827; font: 400 15px "Segoe UI", Arial, sans-serif; }
.notemd-circuit-net-label { fill: #334155; font: 400 13px "Segoe UI", Arial, sans-serif; }
.notemd-circuit-title { fill: #111827; font: 500 22px "Segoe UI", Arial, sans-serif; }
.notemd-circuit-label { fill: #111827; font-family: "Segoe UI", Arial, sans-serif; font-size: 15px; font-weight: 350; }
.notemd-circuit-net-label { fill: #334155; font-family: "Segoe UI", Arial, sans-serif; font-size: 13px; font-weight: 350; }
.notemd-circuit-title { fill: #111827; font-family: "Segoe UI", Arial, sans-serif; font-size: 22px; font-weight: 500; }
</style>
<rect class="notemd-circuit-canvas" x="0" y="0" width="720" height="580" fill="#ffffff" />
${renderCircuitMetadata(spec)}

View file

@ -145,6 +145,11 @@ export const mockSettings: NotemdSettings = {
experimentalDiagramCompatibilityMode: 'legacy-mermaid',
preferredDiagramRenderTarget: undefined,
diagramPreviewExportPpi: 300,
circuitikzCompilerPreference: 'auto',
circuitikzCustomCompilerKind: 'pdflatex',
circuitikzCustomCompilerPath: '',
circuitikzManagedRuntimeRoot: '',
circuitikzCompileTimeoutMs: 120000,
enableSlideExport: false,
slideExportDefaultFormat: 'html',
slideExportOutputSubfolder: 'slidev-export',

View file

@ -0,0 +1,194 @@
import * as fs from 'fs';
import * as path from 'path';
type Candidate = {
kind: 'tectonic' | 'pdflatex';
source: 'custom' | 'managed' | 'system';
executable: string;
};
type EnvironmentApi = {
buildCircuitikzCompileCommand: (candidate: Candidate, texPath: string, outputDirectory: string, timeoutMs: number) => {
executable: string;
args: string[];
cwd: string;
timeoutMs: number;
};
createCircuitikzCompilerCandidates: (input: {
preference: 'auto' | 'managed' | 'system' | 'custom';
customCompilerKind?: 'tectonic' | 'pdflatex';
customCompilerPath?: string;
managedExecutablePath?: string;
systemTectonicPath?: string;
systemPdflatexPath?: string;
}) => Candidate[];
verifyCircuitikzGoldenFixtures: (input: {
candidate: Candidate;
timeoutMs?: number;
runCommand?: (command: { executable: string; args: string[]; cwd?: string; timeoutMs: number }) => Promise<{
cancelled: boolean;
exitCode: number | null;
signal: NodeJS.Signals | null;
stderr: string;
stdout: string;
timedOut: boolean;
}>;
}) => Promise<{
ok: boolean;
fixtures: Array<{ name: string; status: 'ready' | 'failed'; message?: string }>;
}>;
probeCircuitikzEnvironment: (input: {
isDesktop: boolean;
candidates: Candidate[];
timeoutMs?: number;
runCommand?: (command: { executable: string; args: string[]; cwd?: string; timeoutMs: number }) => Promise<{
cancelled: boolean;
exitCode: number | null;
signal: NodeJS.Signals | null;
stderr: string;
stdout: string;
timedOut: boolean;
}>;
}) => Promise<{
status: 'ready' | 'missing' | 'incomplete' | 'unsupported';
selected?: Candidate;
capabilities: {
compileDiagnostics: boolean;
nativePdf: boolean;
repairAcceptance: boolean;
};
attempts: Array<{ candidate: Candidate; status: 'ready' | 'missing' | 'incomplete' }>;
}>;
};
function loadEnvironmentApi(): Partial<EnvironmentApi> {
try {
return require('../diagram/adapters/circuitikz/circuitikzEnvironment') as EnvironmentApi;
} catch {
return {};
}
}
describe('CircuitikZ native environment', () => {
const managedTectonic: Candidate = { kind: 'tectonic', source: 'managed', executable: 'E:/managed/tectonic.exe' };
const systemPdflatex: Candidate = { kind: 'pdflatex', source: 'system', executable: 'pdflatex' };
test('builds shell-free untrusted compiler invocations', () => {
const api = loadEnvironmentApi();
expect(api.buildCircuitikzCompileCommand?.(managedTectonic, 'E:/work/smoke.tex', 'E:/work/out', 60_000)).toEqual({
executable: 'E:/managed/tectonic.exe',
args: ['-X', 'compile', '--untrusted', '--keep-logs', '--outdir', 'E:/work/out', 'E:/work/smoke.tex'],
cwd: 'E:/work/out',
timeoutMs: 60_000
});
expect(api.buildCircuitikzCompileCommand?.(systemPdflatex, 'E:/work/smoke.tex', 'E:/work/out', 60_000)).toEqual({
executable: 'pdflatex',
args: ['-no-shell-escape', '-interaction=nonstopmode', '-halt-on-error', '-output-directory=E:/work/out', 'E:/work/smoke.tex'],
cwd: 'E:/work/out',
timeoutMs: 60_000
});
});
test('orders explicit preferences without mixing custom and system modes', () => {
const api = loadEnvironmentApi();
const allPaths = {
managedExecutablePath: 'E:/managed/tectonic.exe',
systemTectonicPath: 'C:/tools/tectonic.exe',
systemPdflatexPath: 'C:/tex/pdflatex.exe'
};
expect(api.createCircuitikzCompilerCandidates?.({ preference: 'auto', ...allPaths })).toEqual([
{ kind: 'tectonic', source: 'managed', executable: 'E:/managed/tectonic.exe' },
{ kind: 'tectonic', source: 'system', executable: 'C:/tools/tectonic.exe' },
{ kind: 'pdflatex', source: 'system', executable: 'C:/tex/pdflatex.exe' }
]);
expect(api.createCircuitikzCompilerCandidates?.({
preference: 'custom',
customCompilerKind: 'pdflatex',
customCompilerPath: 'D:/TinyTeX/bin/windows/pdflatex.exe',
...allPaths
})).toEqual([
{ kind: 'pdflatex', source: 'custom', executable: 'D:/TinyTeX/bin/windows/pdflatex.exe' }
]);
expect(api.createCircuitikzCompilerCandidates?.({ preference: 'managed', ...allPaths })).toEqual([
{ kind: 'tectonic', source: 'managed', executable: 'E:/managed/tectonic.exe' }
]);
});
test('keeps mobile dependency-free and does not run native commands', async () => {
const api = loadEnvironmentApi();
const runCommand = jest.fn();
const report = await api.probeCircuitikzEnvironment?.({
isDesktop: false,
candidates: [systemPdflatex],
runCommand
});
expect(report).toEqual(expect.objectContaining({
status: 'unsupported',
capabilities: { compileDiagnostics: false, nativePdf: false, repairAcceptance: false }
}));
expect(runCommand).not.toHaveBeenCalled();
});
test('falls back after an incomplete managed compiler and accepts fresh PDF smoke evidence', async () => {
const api = loadEnvironmentApi();
const runCommand = jest.fn(async (command: { executable: string; args: string[]; cwd?: string }) => {
if (command.args.includes('--version')) {
return { exitCode: 0, signal: null, timedOut: false, cancelled: false, stdout: 'version', stderr: '' };
}
if (command.executable.includes('managed')) {
return { exitCode: 1, signal: null, timedOut: false, cancelled: false, stdout: '', stderr: 'package download failed' };
}
const texPath = command.args[command.args.length - 1];
const pdfPath = path.join(command.cwd as string, `${path.basename(texPath, '.tex')}.pdf`);
fs.writeFileSync(pdfPath, Buffer.from('%PDF-1.4\nnotemd smoke'));
return { exitCode: 0, signal: null, timedOut: false, cancelled: false, stdout: 'compiled', stderr: '' };
});
const report = await api.probeCircuitikzEnvironment?.({
isDesktop: true,
candidates: [managedTectonic, systemPdflatex],
runCommand
});
expect(report).toEqual(expect.objectContaining({
status: 'ready',
selected: systemPdflatex,
capabilities: { compileDiagnostics: true, nativePdf: true, repairAcceptance: true }
}));
expect(report?.attempts).toEqual([
expect.objectContaining({ candidate: managedTectonic, status: 'incomplete' }),
expect.objectContaining({ candidate: systemPdflatex, status: 'ready' })
]);
});
test('smoke-compiles all six deterministic golden circuit fixtures', async () => {
const api = loadEnvironmentApi();
const runCommand = jest.fn(async (command: { args: string[]; cwd?: string }) => {
const texPath = command.args[command.args.length - 1];
const pdfPath = path.join(command.cwd as string, `${path.basename(texPath, '.tex')}.pdf`);
fs.writeFileSync(pdfPath, Buffer.from('%PDF-1.4\nnotemd golden smoke'));
return { exitCode: 0, signal: null, timedOut: false, cancelled: false, stdout: 'compiled', stderr: '' };
});
const result = await api.verifyCircuitikzGoldenFixtures?.({
candidate: managedTectonic,
timeoutMs: 60_000,
runCommand
});
expect(result?.ok).toBe(true);
expect(result?.fixtures.map(fixture => fixture.name)).toEqual([
'cmos-inverter',
'common-source-amplifier',
'cmos-buffer',
'cmos-transmission-gate',
'cmos-nand2',
'cmos-nor2'
]);
expect(runCommand).toHaveBeenCalledTimes(6);
});
});

View file

@ -0,0 +1,476 @@
import { createHash } from 'crypto';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { DEFAULT_SETTINGS } from '../constants';
type DesktopEnvironmentApi = {
findExecutableOnPath: (name: string, input: {
platform: NodeJS.Platform;
pathValue: string;
pathExtValue?: string;
isExecutable?: (candidate: string) => boolean;
}) => string | null;
createCircuitikzEnvironmentDesktopContext: (settings: typeof DEFAULT_SETTINGS, input: {
platform: NodeJS.Platform;
architecture: string;
environment: NodeJS.ProcessEnv;
homeDirectory: string;
isExecutable?: (candidate: string) => boolean;
}) => {
runtimeRoot: string;
managedRuntimeVersion: string;
managedArtifact: unknown;
managedExecutablePath: string | null;
candidates: Array<{ kind: string; source: string; executable: string }>;
};
removeConfiguredManagedTectonic: (
settings: typeof DEFAULT_SETTINGS,
input?: { signal?: AbortSignal }
) => Promise<{ status: 'removed' | 'cancelled' }>;
clearConfiguredManagedTectonicStaleLock: (settings: typeof DEFAULT_SETTINGS) => Promise<{
status: 'cleared' | 'absent' | 'active' | 'changed' | 'unsafe';
message: string;
}>;
};
function loadDesktopEnvironmentApi(): Partial<DesktopEnvironmentApi> {
try {
return require('../latexEnvironment/circuitikzEnvironmentDesktop') as DesktopEnvironmentApi;
} catch {
return {};
}
}
describe('CircuitikZ desktop environment discovery', () => {
let root: string;
beforeEach(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'notemd-circuitikz-path-'));
});
afterEach(() => {
fs.rmSync(root, { recursive: true, force: true });
});
test('resolves executable names from PATH using target-platform extension rules', () => {
const api = loadDesktopEnvironmentApi();
const bin = path.join(root, 'bin');
fs.mkdirSync(bin);
const pdflatex = path.join(bin, 'pdflatex.exe');
fs.writeFileSync(pdflatex, 'binary');
expect(api.findExecutableOnPath?.('pdflatex', {
platform: 'win32',
pathValue: `${root};${bin}`,
pathExtValue: '.COM;.EXE;.BAT;.CMD',
isExecutable: fs.existsSync
})).toBe(pdflatex);
expect(api.findExecutableOnPath?.('tectonic', {
platform: 'win32',
pathValue: bin,
pathExtValue: '.EXE',
isExecutable: fs.existsSync
})).toBeNull();
});
test('skips Windows batch shims and continues to an executable supported by shell-free spawning', () => {
const api = loadDesktopEnvironmentApi();
const bin = path.join(root, 'bin');
fs.mkdirSync(bin);
const batchShim = path.join(bin, 'tectonic.cmd');
const executable = path.join(bin, 'tectonic.exe');
fs.writeFileSync(batchShim, 'batch shim');
fs.writeFileSync(executable, 'binary');
expect(api.findExecutableOnPath?.('tectonic', {
platform: 'win32',
pathValue: bin,
pathExtValue: '.CMD;.EXE',
isExecutable: fs.existsSync
})).toBe(executable);
});
test('builds auto-discovery candidates from managed and system installations', () => {
const api = loadDesktopEnvironmentApi();
const runtimeRoot = path.join(root, 'runtime');
const managedExecutable = path.join(runtimeRoot, 'tectonic-0.16.9', 'win32-x64', 'tectonic.exe');
const systemBin = path.join(root, 'system-bin');
const systemPdflatex = path.join(systemBin, 'pdflatex.exe');
fs.mkdirSync(path.dirname(managedExecutable), { recursive: true });
fs.mkdirSync(systemBin, { recursive: true });
fs.writeFileSync(managedExecutable, 'managed');
fs.writeFileSync(systemPdflatex, 'system');
fs.writeFileSync(path.join(runtimeRoot, 'managed-runtime.json'), JSON.stringify({
schemaVersion: 'notemd.managed-latex-runtime.v1',
runtime: 'tectonic',
version: '0.16.9',
platform: 'win32',
architecture: 'x64',
executablePath: managedExecutable,
archiveUrl: 'https://github.com/notemd/fake-tectonic.zip',
sha256: '0'.repeat(64),
installedAt: new Date().toISOString()
}));
const context = api.createCircuitikzEnvironmentDesktopContext?.({
...DEFAULT_SETTINGS,
circuitikzManagedRuntimeRoot: runtimeRoot
}, {
platform: 'win32',
architecture: 'x64',
environment: { PATH: systemBin, PATHEXT: '.EXE' },
homeDirectory: root,
isExecutable: fs.existsSync
});
expect(context).toEqual(expect.objectContaining({
runtimeRoot,
managedRuntimeVersion: '0.16.9',
managedExecutablePath: managedExecutable
}));
expect(context?.candidates).toEqual([
{ kind: 'tectonic', source: 'managed', executable: managedExecutable },
{ kind: 'pdflatex', source: 'system', executable: systemPdflatex }
]);
});
test('discovers the last valid interrupted pointer without normalizing files during discovery', () => {
const api = loadDesktopEnvironmentApi();
const runtimeRoot = path.join(root, 'runtime');
const managedExecutable = path.join(
runtimeRoot,
'tectonic-0.16.9-0123456789abcdef',
'win32-x64',
'tectonic.exe'
);
fs.mkdirSync(path.dirname(managedExecutable), { recursive: true });
fs.writeFileSync(managedExecutable, 'managed');
const previousManifestPath = path.join(runtimeRoot, 'managed-runtime.json.previous');
fs.writeFileSync(previousManifestPath, JSON.stringify({
schemaVersion: 'notemd.managed-latex-runtime.v1',
runtime: 'tectonic',
version: '0.16.9',
platform: 'win32',
architecture: 'x64',
executablePath: managedExecutable,
archiveUrl: 'https://github.com/notemd/fake-tectonic.zip',
sha256: '0123456789abcdef'.padEnd(64, '0'),
installedAt: new Date().toISOString()
}));
fs.writeFileSync(path.join(runtimeRoot, 'managed-runtime.json.new'), '{interrupted');
const context = api.createCircuitikzEnvironmentDesktopContext?.({
...DEFAULT_SETTINGS,
circuitikzManagedRuntimeRoot: runtimeRoot
}, {
platform: 'win32',
architecture: 'x64',
environment: { PATH: '', PATHEXT: '.EXE' },
homeDirectory: root,
isExecutable: fs.existsSync
});
expect(context?.managedExecutablePath).toBe(managedExecutable);
expect(fs.existsSync(path.join(runtimeRoot, 'managed-runtime.json'))).toBe(false);
expect(fs.existsSync(previousManifestPath)).toBe(true);
expect(fs.existsSync(path.join(runtimeRoot, 'managed-runtime.json.new'))).toBe(true);
});
test('reads the last valid pointer without mutating an activation in progress', () => {
const api = loadDesktopEnvironmentApi();
const runtimeRoot = path.join(root, 'runtime');
const previousExecutable = path.join(runtimeRoot, 'tectonic-old', 'win32-x64', 'tectonic.exe');
const pendingExecutable = path.join(runtimeRoot, 'tectonic-new', 'win32-x64', 'tectonic.exe');
fs.mkdirSync(path.dirname(previousExecutable), { recursive: true });
fs.mkdirSync(path.dirname(pendingExecutable), { recursive: true });
fs.mkdirSync(path.join(runtimeRoot, '.managed-runtime.lock'));
fs.writeFileSync(previousExecutable, 'previous');
fs.writeFileSync(pendingExecutable, 'pending');
const manifest = (executablePath: string, sha256: string) => JSON.stringify({
schemaVersion: 'notemd.managed-latex-runtime.v1',
runtime: 'tectonic',
version: '0.16.9',
platform: 'win32',
architecture: 'x64',
executablePath,
archiveUrl: 'https://github.com/notemd/fake-tectonic.zip',
sha256,
installedAt: new Date().toISOString()
});
const previousManifestPath = path.join(runtimeRoot, 'managed-runtime.json.previous');
const pendingManifestPath = path.join(runtimeRoot, 'managed-runtime.json.new');
fs.writeFileSync(previousManifestPath, manifest(previousExecutable, '1'.repeat(64)));
fs.writeFileSync(pendingManifestPath, manifest(pendingExecutable, '2'.repeat(64)));
const context = api.createCircuitikzEnvironmentDesktopContext?.({
...DEFAULT_SETTINGS,
circuitikzManagedRuntimeRoot: runtimeRoot
}, {
platform: 'win32',
architecture: 'x64',
environment: { PATH: '', PATHEXT: '.EXE' },
homeDirectory: root,
isExecutable: fs.existsSync
});
expect(context?.managedExecutablePath).toBe(previousExecutable);
expect(fs.existsSync(previousManifestPath)).toBe(true);
expect(fs.existsSync(pendingManifestPath)).toBe(true);
expect(fs.existsSync(path.join(runtimeRoot, 'managed-runtime.json'))).toBe(false);
});
test('keeps recovery discovery read-only when a concurrent activation appears during pointer reads', () => {
const api = loadDesktopEnvironmentApi();
const runtimeRoot = path.join(root, 'runtime');
const previousExecutable = path.join(runtimeRoot, 'previous-release', 'win32-x64', 'tectonic.exe');
const pendingExecutable = path.join(runtimeRoot, 'pending-release', 'win32-x64', 'tectonic.exe');
fs.mkdirSync(path.dirname(previousExecutable), { recursive: true });
fs.mkdirSync(path.dirname(pendingExecutable), { recursive: true });
fs.writeFileSync(previousExecutable, 'previous');
fs.writeFileSync(pendingExecutable, 'pending');
const manifest = (executablePath: string, digest: string) => JSON.stringify({
schemaVersion: 'notemd.managed-latex-runtime.v1',
runtime: 'tectonic',
version: '0.16.9',
platform: 'win32',
architecture: 'x64',
executablePath,
archiveUrl: 'https://github.com/notemd/fake-tectonic.zip',
sha256: digest,
installedAt: new Date().toISOString()
});
const activePath = path.join(runtimeRoot, 'managed-runtime.json');
const previousPath = path.join(runtimeRoot, 'managed-runtime.json.previous');
const pendingPath = path.join(runtimeRoot, 'managed-runtime.json.new');
const lockPath = path.join(runtimeRoot, '.managed-runtime.lock');
fs.writeFileSync(activePath, '{invalid');
fs.writeFileSync(previousPath, manifest(previousExecutable, '6'.repeat(64)));
const readFileSync = fs.readFileSync;
let interleavingInjected = false;
const read = jest.spyOn(fs, 'readFileSync').mockImplementation((candidate, options) => {
const value = readFileSync(candidate, options as any);
if (String(candidate) === activePath && !interleavingInjected) {
interleavingInjected = true;
fs.mkdirSync(lockPath);
fs.writeFileSync(path.join(lockPath, 'owner.json'), JSON.stringify({ pid: process.pid, token: 'concurrent' }));
fs.writeFileSync(pendingPath, manifest(pendingExecutable, '7'.repeat(64)));
}
return value;
});
try {
const context = api.createCircuitikzEnvironmentDesktopContext?.({
...DEFAULT_SETTINGS,
circuitikzManagedRuntimeRoot: runtimeRoot
}, {
platform: 'win32',
architecture: 'x64',
environment: { PATH: '', PATHEXT: '.EXE' },
homeDirectory: root,
isExecutable: fs.existsSync
});
expect(context?.managedExecutablePath).toBe(previousExecutable);
expect(fs.readFileSync(activePath, 'utf8')).toBe('{invalid');
expect(fs.existsSync(previousPath)).toBe(true);
expect(fs.existsSync(pendingPath)).toBe(true);
expect(fs.existsSync(lockPath)).toBe(true);
} finally {
read.mockRestore();
}
});
test('removes only manifest-owned installs and preserves unrelated tectonic-prefixed directories', async () => {
const api = loadDesktopEnvironmentApi();
const runtimeRoot = path.join(root, 'runtime');
const ownedExecutable = path.join(runtimeRoot, 'tectonic-owned', 'win32-x64', 'tectonic.exe');
const unrelatedDirectory = path.join(runtimeRoot, 'tectonic-user-cache');
fs.mkdirSync(path.dirname(ownedExecutable), { recursive: true });
fs.mkdirSync(unrelatedDirectory, { recursive: true });
fs.writeFileSync(ownedExecutable, 'owned-runtime');
fs.writeFileSync(path.join(unrelatedDirectory, 'keep.txt'), 'user-owned');
fs.writeFileSync(path.join(runtimeRoot, 'managed-runtime.json'), JSON.stringify({
schemaVersion: 'notemd.managed-latex-runtime.v1',
runtime: 'tectonic',
version: '0.16.9',
platform: 'win32',
architecture: 'x64',
executablePath: ownedExecutable,
archiveUrl: 'https://github.com/notemd/fake-tectonic.zip',
sha256: '1'.repeat(64),
installedAt: new Date().toISOString()
}));
const removed = await api.removeConfiguredManagedTectonic?.({
...DEFAULT_SETTINGS,
circuitikzManagedRuntimeRoot: runtimeRoot
});
expect(removed).toEqual({ status: 'removed' });
expect(fs.existsSync(path.dirname(ownedExecutable))).toBe(false);
expect(fs.readFileSync(path.join(unrelatedDirectory, 'keep.txt'), 'utf8')).toBe('user-owned');
expect(fs.existsSync(path.join(runtimeRoot, 'managed-runtime.json'))).toBe(false);
});
test('preserves external directories reached through a linked runtime ancestor', async () => {
const api = loadDesktopEnvironmentApi();
const runtimeRoot = path.join(root, 'runtime');
const externalReleaseDirectory = path.join(root, 'external-user-release');
const externalInstallDirectory = path.join(externalReleaseDirectory, 'win32-x64');
const externalExecutable = path.join(externalInstallDirectory, 'tectonic.exe');
const linkedReleaseDirectory = path.join(runtimeRoot, 'linked-release');
const linkedExecutable = path.join(linkedReleaseDirectory, 'win32-x64', 'tectonic.exe');
fs.mkdirSync(runtimeRoot, { recursive: true });
fs.mkdirSync(externalInstallDirectory, { recursive: true });
fs.writeFileSync(externalExecutable, 'external-user-runtime');
fs.writeFileSync(path.join(externalInstallDirectory, 'keep.txt'), 'must-survive-removal');
fs.symlinkSync(
externalReleaseDirectory,
linkedReleaseDirectory,
process.platform === 'win32' ? 'junction' : 'dir'
);
fs.writeFileSync(path.join(runtimeRoot, 'managed-runtime.json'), JSON.stringify({
schemaVersion: 'notemd.managed-latex-runtime.v1',
runtime: 'tectonic',
version: '0.16.9',
platform: 'win32',
architecture: 'x64',
executablePath: linkedExecutable,
archiveUrl: 'https://github.com/notemd/fake-tectonic.zip',
sha256: '3'.repeat(64),
installedAt: new Date().toISOString()
}));
await api.removeConfiguredManagedTectonic?.({
...DEFAULT_SETTINGS,
circuitikzManagedRuntimeRoot: runtimeRoot
});
expect(fs.readFileSync(externalExecutable, 'utf8')).toBe('external-user-runtime');
expect(fs.readFileSync(path.join(externalInstallDirectory, 'keep.txt'), 'utf8')).toBe('must-survive-removal');
});
test('removes inactive installs only when install-local ownership metadata validates the exact directory', async () => {
const api = loadDesktopEnvironmentApi();
const runtimeRoot = path.join(root, 'runtime');
const inactiveExecutable = path.join(runtimeRoot, 'notemd-release', 'win32-x64', 'tectonic.exe');
const unrelatedExecutable = path.join(runtimeRoot, 'tectonic-lookalike', 'win32-x64', 'tectonic.exe');
fs.mkdirSync(path.dirname(inactiveExecutable), { recursive: true });
fs.mkdirSync(path.dirname(unrelatedExecutable), { recursive: true });
fs.writeFileSync(inactiveExecutable, 'owned-runtime');
fs.writeFileSync(unrelatedExecutable, 'unrelated-runtime');
fs.writeFileSync(
path.join(path.dirname(inactiveExecutable), '.notemd-managed-runtime.json'),
JSON.stringify({
schemaVersion: 'notemd.managed-latex-runtime.v1',
runtime: 'tectonic',
version: '0.16.9',
platform: 'win32',
architecture: 'x64',
executablePath: inactiveExecutable,
archiveUrl: 'https://github.com/notemd/fake-tectonic.zip',
sha256: '2'.repeat(64),
executableSha256: createHash('sha256').update('owned-runtime').digest('hex'),
installedAt: new Date().toISOString()
})
);
fs.writeFileSync(
path.join(path.dirname(unrelatedExecutable), '.notemd-managed-runtime.json'),
JSON.stringify({
schemaVersion: 'notemd.managed-latex-runtime.v1',
runtime: 'tectonic',
version: '0.16.9',
platform: 'win32',
architecture: 'x64',
executablePath: unrelatedExecutable,
archiveUrl: 'https://github.com/notemd/fake-tectonic.zip',
sha256: '9'.repeat(64),
executableSha256: '0'.repeat(64),
installedAt: new Date().toISOString()
})
);
await api.removeConfiguredManagedTectonic?.({
...DEFAULT_SETTINGS,
circuitikzManagedRuntimeRoot: runtimeRoot
});
expect(fs.existsSync(path.dirname(inactiveExecutable))).toBe(false);
expect(fs.readFileSync(unrelatedExecutable, 'utf8')).toBe('unrelated-runtime');
});
test('finishes the validated removal set when cancellation arrives after lock acquisition', async () => {
const api = loadDesktopEnvironmentApi();
const runtimeRoot = path.join(root, 'runtime');
const controller = new AbortController();
const createOwnedInstall = (releaseName: string, contents: string): string => {
const executablePath = path.join(runtimeRoot, releaseName, 'win32-x64', 'tectonic.exe');
fs.mkdirSync(path.dirname(executablePath), { recursive: true });
fs.writeFileSync(executablePath, contents);
fs.writeFileSync(
path.join(path.dirname(executablePath), '.notemd-managed-runtime.json'),
JSON.stringify({
schemaVersion: 'notemd.managed-latex-runtime.v1',
runtime: 'tectonic',
version: '0.16.9',
platform: 'win32',
architecture: 'x64',
executablePath,
archiveUrl: 'https://github.com/notemd/fake-tectonic.zip',
sha256: '4'.repeat(64),
executableSha256: createHash('sha256').update(contents).digest('hex'),
installedAt: new Date().toISOString()
})
);
return path.dirname(executablePath);
};
const firstDirectory = createOwnedInstall('first-owned-release', 'first');
const secondDirectory = createOwnedInstall('second-owned-release', 'second');
const rmSync = fs.rmSync;
const remove = jest.spyOn(fs, 'rmSync').mockImplementation((target, options) => {
const removed = rmSync(target, options);
if (String(target) === firstDirectory) controller.abort();
return removed;
});
try {
const result = await api.removeConfiguredManagedTectonic?.({
...DEFAULT_SETTINGS,
circuitikzManagedRuntimeRoot: runtimeRoot
}, { signal: controller.signal });
expect(result).toEqual({ status: 'removed' });
expect(fs.existsSync(firstDirectory)).toBe(false);
expect(fs.existsSync(secondDirectory)).toBe(false);
} finally {
remove.mockRestore();
}
});
test('clears a configured stale lock through the desktop environment boundary', async () => {
const api = loadDesktopEnvironmentApi();
expect(api.clearConfiguredManagedTectonicStaleLock).toBeDefined();
if (!api.clearConfiguredManagedTectonicStaleLock) return;
const runtimeRoot = path.join(root, 'runtime');
const lockPath = path.join(runtimeRoot, '.managed-runtime.lock');
fs.mkdirSync(lockPath, { recursive: true });
fs.writeFileSync(path.join(lockPath, 'owner.json'), JSON.stringify({
pid: 999_999_999,
token: 'configured-stale-owner'
}));
const kill = jest.spyOn(process, 'kill').mockImplementation((() => {
throw Object.assign(new Error('missing process'), { code: 'ESRCH' });
}) as typeof process.kill);
try {
await expect(api.clearConfiguredManagedTectonicStaleLock({
...DEFAULT_SETTINGS,
circuitikzManagedRuntimeRoot: runtimeRoot
})).resolves.toEqual(expect.objectContaining({ status: 'cleared' }));
expect(fs.existsSync(lockPath)).toBe(false);
} finally {
kill.mockRestore();
}
});
});

View file

@ -0,0 +1,39 @@
const mockEnvironmentModalOpen = jest.fn();
const mockIsDesktopApp = jest.fn();
jest.mock('../ui/CircuitikzEnvironmentModal', () => ({
CircuitikzEnvironmentModal: jest.fn().mockImplementation(() => ({
open: mockEnvironmentModalOpen
}))
}));
jest.mock('../slideExport/platformUtils', () => ({
...jest.requireActual('../slideExport/platformUtils'),
isDesktopApp: () => mockIsDesktopApp()
}));
import NotemdPlugin from '../main';
import { CircuitikzEnvironmentModal } from '../ui/CircuitikzEnvironmentModal';
import { mockApp } from './__mocks__/app';
import { mockSettings } from './__mocks__/settings';
describe('CircuitikZ environment main boundary', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('passes a positive desktop-runtime decision to the environment modal', () => {
mockIsDesktopApp.mockReturnValue(false);
const plugin = new NotemdPlugin(mockApp, {} as any);
plugin.app = mockApp;
plugin.settings = { ...mockSettings };
plugin.openCircuitikzEnvironment();
expect(mockIsDesktopApp).toHaveBeenCalledTimes(1);
expect(CircuitikzEnvironmentModal).toHaveBeenCalledWith(mockApp, expect.objectContaining({
isDesktop: false
}));
expect(mockEnvironmentModalOpen).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,291 @@
import { CircuitikzEnvironmentModal } from '../ui/CircuitikzEnvironmentModal';
import { mockApp } from './__mocks__/app';
import { mockSettings } from './__mocks__/settings';
type MockElement = {
text: string;
cls: string;
children: MockElement[];
attributes: Record<string, string>;
dataset: Record<string, string>;
disabled: boolean;
value: number;
empty: jest.Mock;
addClass: jest.Mock;
removeClass: jest.Mock;
setText: jest.Mock;
setAttr: jest.Mock;
setAttribute: jest.Mock;
addEventListener: jest.Mock;
createDiv: jest.Mock;
createEl: jest.Mock;
createSpan: jest.Mock;
click: () => Promise<void>;
};
function createMockElement(options: { text?: string; cls?: string } = {}): MockElement {
const listeners = new Map<string, Array<() => unknown>>();
const element = {
text: options.text ?? '',
cls: options.cls ?? '',
children: [] as MockElement[],
attributes: {} as Record<string, string>,
dataset: {} as Record<string, string>,
disabled: false,
value: 0,
empty: jest.fn(),
addClass: jest.fn(),
removeClass: jest.fn(),
setText: jest.fn(),
setAttr: jest.fn(),
setAttribute: jest.fn(),
addEventListener: jest.fn(),
createDiv: jest.fn(),
createEl: jest.fn(),
createSpan: jest.fn(),
click: async () => {
for (const listener of listeners.get('click') ?? []) await listener();
}
} as MockElement;
element.empty.mockImplementation(() => { element.children = []; element.text = ''; });
element.addClass.mockImplementation((name: string) => { element.cls = `${element.cls} ${name}`.trim(); });
element.removeClass.mockImplementation((name: string) => { element.cls = element.cls.split(' ').filter(value => value !== name).join(' '); });
element.setText.mockImplementation((text: string) => { element.text = text; });
element.setAttr.mockImplementation((name: string, value: string) => { element.attributes[name] = value; });
element.setAttribute.mockImplementation((name: string, value: string) => { element.attributes[name] = value; });
element.addEventListener.mockImplementation((name: string, listener: () => unknown) => {
listeners.set(name, [...(listeners.get(name) ?? []), listener]);
});
element.createDiv.mockImplementation((childOptions: { text?: string; cls?: string } = {}) => {
const child = createMockElement(childOptions); element.children.push(child); return child;
});
element.createEl.mockImplementation((_tag: string, childOptions: { text?: string; cls?: string; href?: string; attr?: Record<string, string> } = {}) => {
const child = createMockElement(childOptions);
child.attributes = {
...(childOptions.attr ?? {}),
...(childOptions.href ? { href: childOptions.href } : {})
};
element.children.push(child);
return child;
});
element.createSpan.mockImplementation((childOptions: { text?: string; cls?: string } = {}) => {
const child = createMockElement(childOptions);
element.children.push(child);
return child;
});
return element;
}
function mountModal(modal: CircuitikzEnvironmentModal): CircuitikzEnvironmentModal {
const mounted = modal as any;
mounted.app = mockApp;
mounted.modalEl = createMockElement();
mounted.titleEl = createMockElement();
mounted.contentEl = createMockElement();
return modal;
}
function flatten(element: MockElement): MockElement[] {
return [element, ...element.children.flatMap(flatten)];
}
async function flushPromises(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
}
describe('CircuitikZ environment modal', () => {
test('only offers cancellation while an abortable operation is active', () => {
const modal = mountModal(new CircuitikzEnvironmentModal(mockApp, {
settings: { ...mockSettings }, uiLocale: 'en', isDesktop: true,
loadDesktopModule: jest.fn()
}));
const mounted = modal as any;
mounted.state = 'checking';
mounted.activeController = null;
mounted.render();
const content = mounted.contentEl as MockElement;
expect(flatten(content).some(element => element.attributes['data-notemd-environment-action'] === 'cancel')).toBe(false);
mounted.activeController = new AbortController();
mounted.render();
expect(flatten(content).some(element => element.attributes['data-notemd-environment-action'] === 'cancel')).toBe(true);
});
test('probes the desktop environment and renders compiler capabilities', async () => {
const probeConfiguredCircuitikzEnvironment = jest.fn().mockResolvedValue({
context: {
platform: 'win32', architecture: 'x64', runtimeRoot: 'E:/Notemd/runtimes/latex',
managedArtifact: { compressedBytes: 20_035_039 }, managedExecutablePath: null, candidates: []
},
report: {
status: 'ready',
selected: { kind: 'pdflatex', source: 'system', executable: 'C:/texlive/bin/pdflatex.exe' },
capabilities: { compileDiagnostics: true, nativePdf: true, repairAcceptance: true },
attempts: [{ candidate: { kind: 'pdflatex', source: 'system', executable: 'C:/texlive/bin/pdflatex.exe' }, status: 'ready', version: 'pdfTeX 3.141592653' }]
}
});
const modal = mountModal(new CircuitikzEnvironmentModal(mockApp, {
settings: { ...mockSettings }, uiLocale: 'en', isDesktop: true,
loadDesktopModule: jest.fn().mockResolvedValue({
probeConfiguredCircuitikzEnvironment,
installConfiguredManagedTectonic: jest.fn(),
removeConfiguredManagedTectonic: jest.fn()
})
}));
modal.onOpen();
await flushPromises();
const content = (modal as any).contentEl as MockElement;
expect((modal as any).titleEl.text).toBe('CircuitikZ native compile environment');
expect(probeConfiguredCircuitikzEnvironment).toHaveBeenCalledTimes(1);
expect(flatten(content).some(element => element.attributes['data-notemd-environment-status'] === 'ready')).toBe(true);
expect(flatten(content).some(element => element.text.includes('pdfTeX 3.141592653'))).toBe(true);
expect(flatten(content).filter(element => element.attributes['data-notemd-capability'] === 'available')).toHaveLength(3);
});
test('keeps dependency-free exports available on mobile without loading desktop modules', async () => {
const loadDesktopModule = jest.fn();
const modal = mountModal(new CircuitikzEnvironmentModal(mockApp, {
settings: { ...mockSettings }, uiLocale: 'en', isDesktop: false, loadDesktopModule
}));
modal.onOpen();
await flushPromises();
const content = (modal as any).contentEl as MockElement;
expect(loadDesktopModule).not.toHaveBeenCalled();
expect(flatten(content).some(element => element.attributes['data-notemd-environment-status'] === 'unsupported')).toBe(true);
expect(flatten(content).some(element => element.text.includes('SVG, PNG, and preview PDF exports still work without LaTeX'))).toBe(true);
});
test('links the managed runtime license and pinned release as separate truthful references', () => {
const modal = mountModal(new CircuitikzEnvironmentModal(mockApp, {
settings: { ...mockSettings }, uiLocale: 'en', isDesktop: false,
loadDesktopModule: jest.fn()
}));
modal.onOpen();
const links = flatten((modal as any).contentEl as MockElement)
.filter(element => element.attributes.href)
.map(element => ({ text: element.text, href: element.attributes.href }));
expect(links).toEqual(expect.arrayContaining([
{
text: 'Tectonic license',
href: 'https://github.com/tectonic-typesetting/tectonic/blob/tectonic%400.16.9/LICENSE'
},
{
text: 'Tectonic 0.16.9 release',
href: 'https://github.com/tectonic-typesetting/tectonic/releases/tag/tectonic%400.16.9'
}
]));
});
test('keeps installation failures inline instead of opening a generic error modal', async () => {
const installConfiguredManagedTectonic = jest.fn().mockResolvedValue({ status: 'failed', message: 'CircuitikZ package download timed out.' });
const desktopModule = {
probeConfiguredCircuitikzEnvironment: jest.fn().mockResolvedValue({
context: { platform: 'win32', architecture: 'x64', runtimeRoot: 'E:/runtime', managedArtifact: { compressedBytes: 20_035_039 }, managedExecutablePath: null, candidates: [] },
report: { status: 'missing', capabilities: { compileDiagnostics: false, nativePdf: false, repairAcceptance: false }, attempts: [] }
}),
installConfiguredManagedTectonic,
removeConfiguredManagedTectonic: jest.fn()
};
const modal = mountModal(new CircuitikzEnvironmentModal(mockApp, {
settings: { ...mockSettings }, uiLocale: 'en', isDesktop: true,
loadDesktopModule: jest.fn().mockResolvedValue(desktopModule)
}));
modal.onOpen();
await flushPromises();
const content = (modal as any).contentEl as MockElement;
const installButton = flatten(content).find(element => element.attributes['data-notemd-environment-action'] === 'install');
await installButton?.click();
await flushPromises();
expect(installConfiguredManagedTectonic).toHaveBeenCalledTimes(1);
expect(flatten(content).some(element => element.attributes['data-notemd-environment-status'] === 'failed')).toBe(true);
expect(flatten(content).some(element => element.text.includes('CircuitikZ package download timed out.'))).toBe(true);
});
test('threads cancellation through managed-runtime removal and does not start a follow-up probe', async () => {
let finishRemoval!: (result: { status: 'cancelled' }) => void;
const removalFinished = new Promise<{ status: 'cancelled' }>(resolve => { finishRemoval = resolve; });
const removeConfiguredManagedTectonic = jest.fn((_settings, input?: { signal?: AbortSignal }) => {
expect(input?.signal).toBeInstanceOf(AbortSignal);
return removalFinished;
});
const probeConfiguredCircuitikzEnvironment = jest.fn();
const modal = mountModal(new CircuitikzEnvironmentModal(mockApp, {
settings: { ...mockSettings }, uiLocale: 'en', isDesktop: true,
loadDesktopModule: jest.fn().mockResolvedValue({
probeConfiguredCircuitikzEnvironment,
installConfiguredManagedTectonic: jest.fn(),
removeConfiguredManagedTectonic
})
}));
const mounted = modal as any;
const removal = mounted.removeManagedRuntime();
await flushPromises();
const controller = mounted.activeController as AbortController | null;
expect(controller).not.toBeNull();
mounted.cancelOperation();
expect(controller?.signal.aborted).toBe(true);
finishRemoval({ status: 'cancelled' });
await removal;
expect(removeConfiguredManagedTectonic).toHaveBeenCalledTimes(1);
expect(probeConfiguredCircuitikzEnvironment).not.toHaveBeenCalled();
expect(mounted.state).toBe('cancelled');
});
test('exposes explicit stale-lock recovery and clears retry state before probing again', async () => {
const clearConfiguredManagedTectonicStaleLock = jest.fn().mockResolvedValue({
status: 'cleared',
message: 'Cleared stale managed runtime lock.'
});
const probeConfiguredCircuitikzEnvironment = jest.fn().mockResolvedValue({
context: {
platform: 'win32', architecture: 'x64', runtimeRoot: 'E:/runtime',
managedArtifact: { compressedBytes: 20_035_039 }, managedExecutablePath: null, candidates: []
},
report: {
status: 'missing', capabilities: {
compileDiagnostics: false, nativePdf: false, repairAcceptance: false
}, attempts: []
}
});
const modal = mountModal(new CircuitikzEnvironmentModal(mockApp, {
settings: { ...mockSettings }, uiLocale: 'en', isDesktop: true,
loadDesktopModule: jest.fn().mockResolvedValue({
probeConfiguredCircuitikzEnvironment,
installConfiguredManagedTectonic: jest.fn(),
removeConfiguredManagedTectonic: jest.fn(),
clearConfiguredManagedTectonicStaleLock
})
}));
const mounted = modal as any;
mounted.state = 'failed';
mounted.errorMessage = 'Managed runtime lock appears stale.';
mounted.progress = { phase: 'cleanup', detail: 'old retry state' };
mounted.render();
const recoveryButton = flatten(mounted.contentEl as MockElement)
.find(element => element.attributes['data-notemd-environment-action'] === 'clear-stale-lock');
expect(recoveryButton).toBeDefined();
await recoveryButton?.click();
await flushPromises();
expect(clearConfiguredManagedTectonicStaleLock).toHaveBeenCalledTimes(1);
expect(mounted.errorMessage).toBe('');
expect(mounted.progress).toBeNull();
expect(probeConfiguredCircuitikzEnvironment).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,13 @@
import { DEFAULT_SETTINGS } from '../constants';
describe('CircuitikZ native environment settings', () => {
test('defaults to automatic discovery without forcing a managed download', () => {
expect(DEFAULT_SETTINGS).toEqual(expect.objectContaining({
circuitikzCompilerPreference: 'auto',
circuitikzCustomCompilerKind: 'pdflatex',
circuitikzCustomCompilerPath: '',
circuitikzManagedRuntimeRoot: '',
circuitikzCompileTimeoutMs: 120_000
}));
});
});

View file

@ -214,25 +214,46 @@ function createCmosNor2Spec(): CircuitSpec {
}
describe('circuitikz exporter', () => {
test('emits standalone LaTeX documents for every supported golden template', () => {
const specs = [
createCommonSourceSpec(),
createCmosInverterSpec(),
createCmosBufferSpec(),
createCmosTransmissionGateSpec(),
createCmosNand2Spec(),
createCmosNor2Spec()
];
for (const spec of specs) {
const output = exportCircuitSpecToCircuitikz(spec);
expect(output.startsWith('\\documentclass[border=8pt]{standalone}\n\\usepackage{circuitikz}\n')).toBe(true);
expect(output).toContain('\\begin{document}');
expect(output).toContain('\\end{document}');
}
});
test('validates and exports the common-source golden reference template deterministically', () => {
const spec = createCommonSourceSpec();
expect(validateCircuitSpec(spec)).toEqual({ valid: true, errors: [] });
expect(exportCircuitSpecToCircuitikz(spec)).toBe(`\\usepackage{circuitikz}
expect(exportCircuitSpecToCircuitikz(spec)).toBe(`\\documentclass[border=8pt]{standalone}
\\usepackage{circuitikz}
\\begin{document}
\\begin{circuitikz}[american voltages]
\\begin{circuitikz}[american voltages, line width=0.5pt, font=\\small]
\\draw
(3,5) node[vcc]{$V_{DD}$}
to [R, l=$R_D$] (3,3)
to [short, *-o] (5,3) node[right]{$v_{out}$}
to [R, l=$R_D$] (3,3);
\\draw
(3,3) to [short, *-o] (5,3) node[right]{$v_{out}$};
\\draw
(3,3) to [short] (3,2.2)
node[nmos, anchor=D] (M1) {$M_1$}
node[nmos, anchor=D] (M1) {$M_1$};
\\draw
(M1.S) to [short] (3,0.5)
node[ground]{}
node[ground]{};
\\draw
(M1.G) to [short, -o] (0.8,2.2)
node[left]{$v_{in}$};
\\draw
(3,0.5) node[below right]{$S$};
\\end{circuitikz}
\\end{document}
`);

View file

@ -0,0 +1,95 @@
import * as path from 'path';
type ManagedRuntimeApi = {
MANAGED_TECTONIC_VERSION: string;
getManagedTectonicArtifact: (platform: NodeJS.Platform, architecture: string) => {
archiveFormat: 'zip' | 'tar-gz';
compressedBytes: number;
executableName: string;
sha256: string;
url: string;
} | null;
isSafeManagedArchiveEntry: (entryName: string) => boolean;
resolveManagedLatexRuntimeRoot: (input: {
platform: NodeJS.Platform;
environment: NodeJS.ProcessEnv;
homeDirectory: string;
configuredRoot?: string;
}) => string;
};
function loadManagedRuntimeApi(): Partial<ManagedRuntimeApi> {
try {
return require('../latexEnvironment/managedTectonicDistribution') as ManagedRuntimeApi;
} catch {
return {};
}
}
describe('managed Tectonic distribution contract', () => {
test('pins the verified Windows x64 MSVC asset', () => {
const api = loadManagedRuntimeApi();
expect(api.MANAGED_TECTONIC_VERSION).toBe('0.16.9');
expect(api.getManagedTectonicArtifact?.('win32', 'x64')).toEqual({
archiveFormat: 'zip',
compressedBytes: 20_035_039,
executableName: 'tectonic.exe',
sha256: '131a24604785a9600989a3d91225f597df52ac06f00aeffe86fd529f99ee5cdd',
url: 'https://github.com/tectonic-typesetting/tectonic/releases/download/tectonic%400.16.9/tectonic-0.16.9-x86_64-pc-windows-msvc.zip'
});
});
test('selects supported macOS and Linux archives and rejects unsupported Windows ARM', () => {
const api = loadManagedRuntimeApi();
expect(api.getManagedTectonicArtifact?.('darwin', 'arm64')).toEqual(expect.objectContaining({
archiveFormat: 'tar-gz',
executableName: 'tectonic',
sha256: 'edb67c61aba768289f6da441c9e6f523cfaff4f8b2a5708523ef29c543f8e88e'
}));
expect(api.getManagedTectonicArtifact?.('linux', 'x64')).toEqual(expect.objectContaining({
archiveFormat: 'tar-gz',
executableName: 'tectonic',
sha256: '60b13a0826ae7ad9ce34b4a2df06bff2cfcfa6dda8a915477c0cbb84e1a4a902'
}));
expect(api.getManagedTectonicArtifact?.('win32', 'arm64')).toBeNull();
});
test('keeps the managed runtime outside the Vault by default and honors an explicit root', () => {
const api = loadManagedRuntimeApi();
expect(api.resolveManagedLatexRuntimeRoot?.({
platform: 'win32',
environment: { LOCALAPPDATA: 'D:\\Users\\Ada\\AppData\\Local' },
homeDirectory: 'D:\\Users\\Ada'
})).toBe(path.resolve('D:\\Users\\Ada\\AppData\\Local', 'Notemd', 'runtimes', 'latex'));
expect(api.resolveManagedLatexRuntimeRoot?.({
platform: 'linux',
environment: { XDG_DATA_HOME: '/mnt/data/.local-data' },
homeDirectory: '/home/ada'
})).toBe('/mnt/data/.local-data/notemd/runtimes/latex');
expect(api.resolveManagedLatexRuntimeRoot?.({
platform: 'darwin',
environment: {},
homeDirectory: '/Users/ada'
})).toBe('/Users/ada/Library/Application Support/Notemd/runtimes/latex');
expect(api.resolveManagedLatexRuntimeRoot?.({
platform: 'win32',
environment: {},
homeDirectory: 'C:\\Users\\Ada',
configuredRoot: 'E:\\Notemd Runtime'
})).toBe(path.resolve('E:\\Notemd Runtime'));
});
test('rejects archive traversal and absolute entry names', () => {
const api = loadManagedRuntimeApi();
expect(api.isSafeManagedArchiveEntry?.('tectonic.exe')).toBe(true);
expect(api.isSafeManagedArchiveEntry?.('bin/tectonic')).toBe(true);
expect(api.isSafeManagedArchiveEntry?.('../tectonic.exe')).toBe(false);
expect(api.isSafeManagedArchiveEntry?.('bin/../../tectonic')).toBe(false);
expect(api.isSafeManagedArchiveEntry?.('/usr/bin/tectonic')).toBe(false);
expect(api.isSafeManagedArchiveEntry?.('C:\\Windows\\tectonic.exe')).toBe(false);
});
});

View file

@ -64,7 +64,8 @@ describe('circuitikz renderer', () => {
expect(artifact.previewSvg?.content).toContain('vout');
expect(artifact.previewSvg?.content).not.toContain('v_{out}');
expect(artifact.previewSvg?.content).toContain('.notemd-circuit-stage text { stroke: none; }');
expect(artifact.previewSvg?.content).toContain('font: 400 15px');
expect(artifact.previewSvg?.content).toContain('font-size: 15px; font-weight: 350');
expect(artifact.previewSvg?.content).toContain('stroke-width: 1.2');
expect(artifact.previewSvg?.content).not.toContain('cmos-inverter / cmos-inverter-v1');
});

View file

@ -0,0 +1,148 @@
import { CircuitSpec } from '../diagram/adapters/circuitikz/circuitSpec';
import { CircuitikzCompileDiagnosticReport } from '../diagram/adapters/circuitikz/circuitikzDiagnostics';
import { runCircuitikzRepairLoop } from '../diagram/adapters/circuitikz/circuitikzRepairLoop';
function createSpec(): CircuitSpec {
return {
circuitKind: 'common-source-amplifier',
title: 'Common-source NMOS amplifier',
goldenReferenceId: 'common-source-nmos-v1',
style: { package: 'circuitikz', voltageConvention: 'american voltages' },
nets: ['VDD', 'GND', 'vin', 'vout', 'drain'],
components: [
{ id: 'RD', type: 'resistor', label: '$R_D$', terminals: { top: 'VDD', bottom: 'drain' } },
{ id: 'M1', type: 'nmos', label: '$M_1$', terminals: { D: 'drain', G: 'vin', S: 'GND' } }
],
connections: [
{ from: 'VDD', to: 'RD.top' },
{ from: 'RD.bottom', to: 'M1.D' },
{ from: 'M1.D', to: 'vout' },
{ from: 'M1.G', to: 'vin' },
{ from: 'M1.S', to: 'GND' }
],
layoutHints: { inputSide: 'left', outputSide: 'right', routingStyle: 'orthogonal' }
};
}
function overlapDiagnostics(): CircuitikzCompileDiagnosticReport {
return {
ok: false,
summary: '1 error(s), 0 warning(s)',
diagnostics: [{
severity: 'error',
kind: 'render-svg-label-overlap',
message: 'A label overlaps circuit geometry.',
excerpt: 'M1',
advice: 'Move the label without changing topology.'
}]
};
}
function cleanDiagnostics(): CircuitikzCompileDiagnosticReport {
return { ok: true, summary: '0 error(s), 0 warning(s)', diagnostics: [] };
}
describe('runCircuitikzRepairLoop', () => {
test('accepts one topology-preserving candidate after clean compile and render evidence', async () => {
const sourceSpec = createSpec();
const candidate = { ...createSpec(), title: 'Common-source NMOS amplifier, repaired' };
const requestRepairCandidate = jest.fn(async () => `\n\`\`\`json\n${JSON.stringify(candidate)}\n\`\`\``);
const evaluateCandidate = jest.fn(async () => ({
diagnostics: cleanDiagnostics(),
renderSmoke: { diagnostics: [] }
}));
const report = await runCircuitikzRepairLoop({
referenceSpec: sourceSpec,
sourceSpec,
initialDiagnostics: overlapDiagnostics(),
requestRepairCandidate,
evaluateCandidate
});
expect(report.status).toBe('accepted');
expect(report.attemptCount).toBe(1);
expect(report.candidateSpec?.title).toContain('repaired');
expect(report.acceptance?.readyForVisualAcceptance).toBe(true);
expect(requestRepairCandidate).toHaveBeenCalledTimes(1);
expect(evaluateCandidate).toHaveBeenCalledTimes(1);
expect(evaluateCandidate).toHaveBeenCalledWith(expect.objectContaining({
texContent: expect.stringContaining('\\begin{circuitikz}')
}));
});
test('rejects topology drift before evaluating or exporting the candidate', async () => {
const candidate = createSpec();
candidate.connections = [...candidate.connections, { from: 'VDD', to: 'M1.D' }];
const evaluateCandidate = jest.fn();
const report = await runCircuitikzRepairLoop({
referenceSpec: createSpec(),
sourceSpec: createSpec(),
initialDiagnostics: overlapDiagnostics(),
requestRepairCandidate: async () => JSON.stringify(candidate),
evaluateCandidate
});
expect(report.status).toBe('rejected');
expect(report.reason).toContain('topology');
expect(evaluateCandidate).not.toHaveBeenCalled();
});
test('rejects a non-JSON repair response without evaluating it', async () => {
const evaluateCandidate = jest.fn();
const report = await runCircuitikzRepairLoop({
referenceSpec: createSpec(),
sourceSpec: createSpec(),
initialDiagnostics: overlapDiagnostics(),
requestRepairCandidate: async () => 'Move the output label to the right.',
evaluateCandidate
});
expect(report.status).toBe('rejected');
expect(report.reason).toContain('JSON');
expect(evaluateCandidate).not.toHaveBeenCalled();
});
test('keeps the source spec when the repaired candidate still fails visual acceptance', async () => {
const report = await runCircuitikzRepairLoop({
referenceSpec: createSpec(),
sourceSpec: createSpec(),
initialDiagnostics: overlapDiagnostics(),
requestRepairCandidate: async () => JSON.stringify(createSpec()),
evaluateCandidate: async () => ({
diagnostics: overlapDiagnostics(),
renderSmoke: { diagnostics: overlapDiagnostics().diagnostics }
})
});
expect(report.status).toBe('rejected');
expect(report.acceptance?.readyForVisualAcceptance).toBe(false);
expect(report.candidateSpec).toBeUndefined();
});
test('does not call the model when the configured renderer is unavailable', async () => {
const requestRepairCandidate = jest.fn();
const report = await runCircuitikzRepairLoop({
referenceSpec: createSpec(),
sourceSpec: createSpec(),
initialDiagnostics: {
ok: false,
summary: '1 error(s), 0 warning(s)',
diagnostics: [{
severity: 'error',
kind: 'compile-executable-not-found',
message: 'Renderer executable was not found.',
excerpt: 'pdflatex',
advice: 'Configure a renderer.'
}]
},
requestRepairCandidate,
evaluateCandidate: jest.fn()
});
expect(report.status).toBe('unavailable');
expect(report.attemptCount).toBe(0);
expect(requestRepairCandidate).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,259 @@
type DesktopProcessApi = {
runDesktopCommand: (
command: {
executable: string;
args: string[];
cwd?: string;
timeoutMs: number;
maxOutputBytes?: number;
},
options?: {
signal?: AbortSignal;
onProgress?: (event: { stream: 'stdout' | 'stderr'; text: string }) => void;
}
) => Promise<{
cancelled: boolean;
errorMessage?: string;
exitCode: number | null;
signal: NodeJS.Signals | null;
stderr: string;
stdout: string;
timedOut: boolean;
}>;
};
import { EventEmitter } from 'events';
import { PassThrough } from 'stream';
function loadDesktopProcessApi(): Partial<DesktopProcessApi> {
try {
return require('../platform/desktopProcess') as DesktopProcessApi;
} catch {
return {};
}
}
describe('desktop direct process execution', () => {
test('streams and captures output without shell interpretation', async () => {
const api = loadDesktopProcessApi();
const progress: string[] = [];
const result = await api.runDesktopCommand?.({
executable: process.execPath,
args: ['-e', 'process.stdout.write(process.argv[1]); process.stderr.write("warn")', 'literal-$()-&'],
timeoutMs: 5_000
}, {
onProgress: event => progress.push(`${event.stream}:${event.text}`)
});
expect(result).toEqual(expect.objectContaining({
cancelled: false,
exitCode: 0,
stderr: 'warn',
stdout: 'literal-$()-&',
timedOut: false
}));
expect(progress.join('')).toContain('stdout:literal-$()-&');
expect(progress.join('')).toContain('stderr:warn');
});
test('distinguishes timeout from user cancellation', async () => {
const api = loadDesktopProcessApi();
const timedOut = await api.runDesktopCommand?.({
executable: process.execPath,
args: ['-e', 'setInterval(() => {}, 1000)'],
timeoutMs: 40
});
const controller = new AbortController();
const cancelledPromise = api.runDesktopCommand?.({
executable: process.execPath,
args: ['-e', 'setInterval(() => {}, 1000)'],
timeoutMs: 5_000
}, { signal: controller.signal });
setTimeout(() => controller.abort(), 30);
const cancelled = await cancelledPromise;
expect(timedOut).toEqual(expect.objectContaining({ timedOut: true, cancelled: false }));
expect(cancelled).toEqual(expect.objectContaining({ timedOut: false, cancelled: true }));
});
test('bounds captured output while preserving the latest process state', async () => {
const api = loadDesktopProcessApi();
const result = await api.runDesktopCommand?.({
executable: process.execPath,
args: ['-e', 'process.stdout.write("a".repeat(128))'],
timeoutMs: 5_000,
maxOutputBytes: 32
});
expect(result?.exitCode).toBe(0);
expect(Buffer.byteLength(result?.stdout ?? '', 'utf8')).toBeLessThanOrEqual(32);
});
test('returns a structured spawn failure for a missing executable', async () => {
const api = loadDesktopProcessApi();
const result = await api.runDesktopCommand?.({
executable: `notemd-missing-command-${Date.now()}`,
args: [],
timeoutMs: 1_000
});
expect(result).toEqual(expect.objectContaining({
cancelled: false,
exitCode: null,
timedOut: false
}));
expect(result?.errorMessage).toMatch(/ENOENT|not found|cannot find/i);
});
test('escalates a POSIX process group after the leader closes during cancellation', async () => {
jest.useFakeTimers();
const originalPlatform = process.platform;
const processKill = jest.spyOn(process, 'kill').mockImplementation((pid, signal) => {
if (signal === 'SIGTERM') {
queueMicrotask(() => child.emit('close', null, 'SIGTERM'));
}
return true;
});
const child = Object.assign(new EventEmitter(), {
pid: 4242,
stdout: new PassThrough(),
stderr: new PassThrough(),
kill: jest.fn()
});
try {
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' });
jest.resetModules();
jest.doMock('child_process', () => ({ spawn: jest.fn(() => child) }));
const api = require('../platform/desktopProcess') as DesktopProcessApi;
const controller = new AbortController();
const command = api.runDesktopCommand({
executable: '/tmp/notemd-leader',
args: [],
timeoutMs: 5_000
}, { signal: controller.signal });
controller.abort();
await Promise.resolve();
jest.advanceTimersByTime(250);
await command;
expect(processKill).toHaveBeenCalledWith(-4242, 'SIGTERM');
expect(processKill).toHaveBeenCalledWith(-4242, 'SIGKILL');
} finally {
jest.dontMock('child_process');
jest.resetModules();
processKill.mockRestore();
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform });
jest.useRealTimers();
}
});
test('preserves cancellation as the first termination cause near the command timeout', async () => {
jest.useFakeTimers();
const originalPlatform = process.platform;
const child = Object.assign(new EventEmitter(), {
pid: 4343,
stdout: new PassThrough(),
stderr: new PassThrough(),
kill: jest.fn()
});
const processKill = jest.spyOn(process, 'kill').mockImplementation((_pid, signal) => {
if (signal === 'SIGKILL') queueMicrotask(() => child.emit('close', null, 'SIGKILL'));
return true;
});
try {
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' });
jest.resetModules();
jest.doMock('child_process', () => ({ spawn: jest.fn(() => child) }));
const api = require('../platform/desktopProcess') as DesktopProcessApi;
const controller = new AbortController();
const command = api.runDesktopCommand({
executable: '/tmp/notemd-near-timeout',
args: [],
timeoutMs: 100
}, { signal: controller.signal });
jest.advanceTimersByTime(90);
controller.abort();
await Promise.resolve();
jest.advanceTimersByTime(260);
const result = await command;
expect(result).toEqual(expect.objectContaining({ cancelled: true, timedOut: false }));
expect(processKill.mock.calls.filter(([, signal]) => signal === 'SIGTERM')).toHaveLength(1);
expect(processKill.mock.calls.filter(([, signal]) => signal === 'SIGKILL')).toHaveLength(1);
} finally {
jest.dontMock('child_process');
jest.resetModules();
processKill.mockRestore();
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform });
jest.useRealTimers();
}
});
(process.platform === 'win32' ? test.skip : test)(
'kills a POSIX descendant that ignores SIGTERM after its leader exits',
async () => {
const api = loadDesktopProcessApi();
const controller = new AbortController();
let descendantPid: number | undefined;
const processExists = (pid: number): boolean => {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException).code !== 'ESRCH';
}
};
try {
const command = api.runDesktopCommand?.({
executable: process.execPath,
args: [
'-e',
[
"const { spawn } = require('child_process');",
"const child = spawn(process.execPath, ['-e', 'process.on(\\\"SIGTERM\\\", () => {}); setInterval(() => {}, 1000)'], { stdio: 'ignore' });",
"process.stdout.write(String(child.pid) + '\\n');",
"process.on('SIGTERM', () => process.exit(0));",
'setInterval(() => {}, 1000);'
].join(' ')
],
timeoutMs: 5_000
}, {
signal: controller.signal,
onProgress: event => {
const parsed = Number(event.text.trim());
if (event.stream === 'stdout' && Number.isInteger(parsed) && parsed > 0) {
descendantPid = parsed;
controller.abort();
}
}
});
const result = await command;
expect(result).toEqual(expect.objectContaining({ cancelled: true, timedOut: false }));
expect(descendantPid).toEqual(expect.any(Number));
if (!descendantPid) return;
for (let attempt = 0; attempt < 20 && processExists(descendantPid); attempt++) {
await new Promise(resolve => setTimeout(resolve, 50));
}
expect(processExists(descendantPid)).toBe(false);
} finally {
if (descendantPid && processExists(descendantPid)) {
try {
process.kill(descendantPid, 'SIGKILL');
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error;
}
}
}
},
10_000
);
});

View file

@ -237,6 +237,33 @@ describe('diagram command host adapter', () => {
);
});
test('passes an explicit CircuitikZ render target through the command boundary', async () => {
const { host, reporter } = createDiagramHost();
const file = { name: 'Topic.md', path: 'Notes/Topic.md' };
await runGenerateDiagramCommandWithHost(host as any, file as any, reporter as any, {
executionMode: 'save-artifact',
inputOverrides: {
requestedIntent: 'circuit',
requestedRenderTarget: 'circuitikz'
}
});
expect(host.executeArtifactCommand).toHaveBeenCalledWith(
file,
expect.objectContaining({
requestedIntent: 'circuit',
requestedRenderTarget: 'circuitikz'
}),
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything(),
STRINGS_EN,
'save-artifact'
);
});
test('preview wrapper supports saved circuitikz tex artifacts as source-only previews', async () => {
const { host, reporter } = createDiagramHost();
const file = { name: 'Inverter_diagram.tex', path: 'Notes/Inverter_diagram.tex' };
@ -251,7 +278,7 @@ describe('diagram command host adapter', () => {
artifact: expect.objectContaining({
target: 'circuitikz',
mimeType: 'text/x-tex',
sourceIntent: 'flowchart'
sourceIntent: 'circuit'
})
});
expect(host.createDiagramHostAdapter().openPreview).toHaveBeenCalledWith(
@ -264,6 +291,48 @@ describe('diagram command host adapter', () => {
);
});
test('resolves generated circuitikz preview notes back to the typed source artifact', async () => {
const { host, diagramHost, reporter } = createDiagramHost();
const file = { name: 'Inverter_diagram.tex.md', path: 'Notes/Inverter_diagram.tex.md' };
host.readFile.mockResolvedValue([
'# Inverter diagram preview',
'',
'![[Inverter_diagram.tex.svg]]',
'',
'Source artifact: [[Inverter_diagram.tex]]',
'Render target: circuitikz'
].join('\n'));
diagramHost.getFileByPath.mockImplementation((path: string) => ({ path }));
(diagramHost as any).readFile = jest.fn(async (loadedFile: { path: string }) => {
if (loadedFile.path === 'Notes/Inverter_diagram.tex') {
return '\\documentclass[border=8pt]{standalone}\n\\usepackage{circuitikz}\n\\begin{document}\n\\begin{circuitikz}\n\\draw (0,0) to[short] (1,0);\n\\end{circuitikz}\n\\end{document}';
}
if (loadedFile.path === 'Notes/Inverter_diagram.tex.svg') {
return '<svg><text>Inverter preview</text></svg>';
}
return '';
});
const result = await runPreviewDiagramCommandWithHost(host as any, file as any, reporter as any);
expect(result).toMatchObject({
kind: 'success',
artifact: expect.objectContaining({
target: 'circuitikz',
sourceIntent: 'circuit',
previewSvg: expect.objectContaining({
content: expect.stringContaining('Inverter preview')
})
})
});
expect(diagramHost.openPreview).toHaveBeenCalledTimes(1);
expect(diagramHost.openPreview).toHaveBeenCalledWith(
expect.objectContaining({ target: 'circuitikz', sourceIntent: 'circuit' }),
'Notes/Inverter_diagram.tex',
true
);
});
test('preview wrapper supports saved drawio artifacts as source-only previews', async () => {
const { host, reporter } = createDiagramHost();
const file = { name: 'Architecture_diagram.drawio', path: 'Notes/Architecture_diagram.drawio' };

View file

@ -515,6 +515,26 @@ Referral share: 35%
expect(result.artifact.content).toContain('\\end{document}');
});
test('falls back before JSON parsing when the model returns raw circuitikz TeX', async () => {
const llmInvoker = jest.fn().mockResolvedValue(String.raw`\begin{circuitikz}[american voltages]
\draw (3,5) node[vcc]{$V_{DD}$} to[R, l=$R_D$] (3,3);
\end{circuitikz}`);
const result = await generateDiagramArtifact(`
Draw a common-source NMOS amplifier: VDD through RD to the drain, vout at the drain, vin at the gate, and source to ground.
`, {
compatibilityMode: 'best-fit',
requestedIntent: 'circuit',
requestedRenderTarget: 'circuitikz',
targetLanguage: 'en',
llmInvoker
});
expect(result.spec.circuitSpec?.goldenReferenceId).toBe('common-source-nmos-v1');
expect(result.artifact.content).toContain('\\usepackage{circuitikz}');
expect(result.artifact.content).toContain('\\begin{circuitikz}');
});
test('does not substitute a golden circuit template for an unsupported circuit request', async () => {
const llmInvoker = jest.fn().mockResolvedValue(JSON.stringify({
intent: 'mindmap',

View file

@ -82,6 +82,9 @@ describe('diagram spec prompt builder', () => {
expect(prompt).toMatch(/"goldenReferenceId":\s*"cmos-inverter-v1"/i);
expect(prompt).toMatch(/"terminals":\s*\{\s*"S":\s*"VDD"/i);
expect(prompt).toMatch(/"from":\s*"vin",\s*"to":\s*"MP\.G"/i);
expect(prompt).toMatch(/"circuitKind":\s*"common-source-amplifier"/i);
expect(prompt).toMatch(/"goldenReferenceId":\s*"common-source-nmos-v1"/i);
expect(prompt).toMatch(/deterministic renderer, not the model/i);
});
test('uses a circuit-focused intent surface for an explicit circuitikz request', () => {

View file

@ -39,6 +39,23 @@ ${options.stderr ? `process.stderr.write(${JSON.stringify(options.stderr)});\n`
return { scriptPath, argsPath };
}
function writeFakeOfficialObsidian(
tempRoot: string,
options: { stdout?: string; stderr?: string; exitCode?: number } = {}
) {
const scriptPath = path.join(tempRoot, 'obsidian');
const cmdScriptPath = path.join(tempRoot, 'obsidian.cmd');
const argsPath = path.join(tempRoot, 'obsidian-args.json');
const scriptSource = `#!/usr/bin/env node
const fs = require('fs');
fs.writeFileSync(${JSON.stringify(argsPath)}, JSON.stringify(process.argv.slice(2), null, 2));
${options.stderr ? `process.stderr.write(${JSON.stringify(options.stderr)});\n` : ''}${options.stdout ? `process.stdout.write(${JSON.stringify(options.stdout)});\n` : ''}process.exit(${options.exitCode ?? 0});
`;
fs.writeFileSync(scriptPath, scriptSource, { encoding: 'utf8', mode: 0o755 });
fs.writeFileSync(cmdScriptPath, `@echo off\r\n"${process.execPath}" "${scriptPath}" %*\r\n`, 'utf8');
return { argsPath };
}
test('prints a simplified maintainer help surface with core commands, inputs, and operation summaries', () => {
const scriptPath = path.join(__dirname, '..', '..', 'scripts', 'invoke-maintainer-cli-operation.js');
const output = execFileSync(process.execPath, [scriptPath, '--help'], {
@ -48,6 +65,7 @@ ${options.stderr ? `process.stderr.write(${JSON.stringify(options.stderr)});\n`
expect(output).toContain('Notemd maintainer CLI helper');
expect(output).toContain('npm run cli:help');
expect(output).toContain('npm run cli:invoke -- --vault <vault> --operation <operation-id> [--input-file <path> | --input-json');
expect(output).toContain('falls back to the official obsidian eval command');
expect(output).toContain('Prefer --input-file for non-trivial payloads.');
for (const [operationId, details] of Object.entries(OPERATION_HELP as MaintainerOperationHelp)) {
@ -131,6 +149,38 @@ ${options.stderr ? `process.stderr.write(${JSON.stringify(options.stderr)});\n`
}
});
test('falls back to the official obsidian eval command when obsidian-cli is unavailable', () => {
const scriptPath = path.join(__dirname, '..', '..', 'scripts', 'invoke-maintainer-cli-operation.js');
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'notemd-cli-official-fallback-'));
const fakeCli = writeFakeOfficialObsidian(tempRoot, {
stdout: '=> {"transport":"official"}\n',
exitCode: 0
});
try {
const output = execFileSync(
process.execPath,
[
scriptPath,
'--vault', 'Study',
'--operation', 'provider.profile.export-redacted'
],
{
encoding: 'utf8',
env: { ...process.env, PATH: tempRoot, Path: tempRoot }
}
);
const argv = JSON.parse(fs.readFileSync(fakeCli.argsPath, 'utf8'));
expect(argv[0]).toBe('eval');
expect(argv).toContain('vault=Study');
expect(argv.some((value: string) => value.startsWith('code='))).toBe(true);
expect(output).toBe('{"transport":"official"}\n');
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
test('supports --input-file and custom plugin id through the real script entrypoint', () => {
const scriptPath = path.join(__dirname, '..', '..', 'scripts', 'invoke-maintainer-cli-operation.js');
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'notemd-cli-invoke-input-file-'));

View file

@ -141,6 +141,7 @@ describe('maintainer CLI bridge', () => {
sourcePath: 'docs/index.zh-CN.md',
executionMode: 'save-mermaid',
requestedIntent: 'erDiagram',
requestedRenderTarget: 'circuitikz',
compatibilityMode: 'legacy-mermaid',
targetLanguage: 'en'
}
@ -152,6 +153,7 @@ describe('maintainer CLI bridge', () => {
executionMode: 'save-mermaid',
inputOverrides: {
requestedIntent: 'erDiagram',
requestedRenderTarget: 'circuitikz',
compatibilityMode: 'legacy-mermaid',
targetLanguage: 'en'
}

File diff suppressed because it is too large Load diff

View file

@ -134,6 +134,7 @@ describe('note processing command surface', () => {
expect(ids).toContain('split-note-by-chapters');
expect(ids).toContain('create-wiki-link-and-generate-from-selection');
expect(ids).toContain('notemd-open-diagram-history');
expect(ids).toContain('notemd-manage-circuitikz-environment');
});
test('generate from title command delegates to extracted note-processing host adapter', async () => {

View file

@ -581,6 +581,7 @@ function createPlugin(overrides: Partial<any> = {}) {
app: mockApp,
settings,
saveSettings: jest.fn().mockResolvedValue(undefined),
openCircuitikzEnvironment: jest.fn(),
refreshLocalizedUi: jest.fn().mockResolvedValue(undefined),
resetSettings: jest.fn().mockResolvedValue(undefined),
...overrides
@ -633,6 +634,43 @@ describe('provider settings behavior', () => {
expect(plugin.saveSettings).toHaveBeenCalledTimes(2);
});
test('exposes searchable CircuitikZ environment management and compiler preferences', async () => {
const plugin = createPlugin();
plugin.settings.enableDeveloperMode = false;
const tab = new NotemdSettingTab(mockApp as any, plugin as any) as any;
tab.display();
const managementSetting = findSettingByName(tab.containerEl, 'CircuitikZ native compile environment');
const preferenceSetting = findSettingByName(tab.containerEl, 'CircuitikZ compiler preference');
const customKindSetting = findSettingByName(tab.containerEl, 'Custom compiler type');
expect(managementSetting).toBeDefined();
expect(preferenceSetting).toBeDefined();
expect(customKindSetting).toBeDefined();
const manageButton = managementSetting?.controls.find(control => control.kind === 'button') as MockButtonControl | undefined;
const preference = preferenceSetting?.controls.find(control => control.kind === 'dropdown') as MockDropdownControl | undefined;
const customKind = customKindSetting?.controls.find(control => control.kind === 'dropdown') as MockDropdownControl | undefined;
expect(manageButton?.text).toBe('Manage environment');
expect(preference?.options).toEqual(expect.objectContaining({
auto: 'Automatic (recommended)',
managed: 'Notemd managed Tectonic',
system: 'System compiler',
custom: 'Custom executable'
}));
expect(customKind?.options).toEqual(expect.objectContaining({ tectonic: 'Tectonic', pdflatex: 'pdfLaTeX' }));
await manageButton?.click();
await preference?.onChangeHandler?.('managed');
await customKind?.onChangeHandler?.('tectonic');
expect(plugin.openCircuitikzEnvironment).toHaveBeenCalledTimes(1);
expect(plugin.settings.circuitikzCompilerPreference).toBe('managed');
expect(plugin.settings.circuitikzCustomCompilerKind).toBe('tectonic');
});
test('keeps CircuitikZ settings compatible when the diagram type changes', async () => {
const plugin = createPlugin();
plugin.settings.enableExperimentalDiagramPipeline = true;

View file

@ -4,6 +4,12 @@ import * as path from 'path';
const stylesPath = path.join(__dirname, '..', '..', 'styles.css');
const settingTabPath = path.join(__dirname, '..', 'ui', 'NotemdSettingTab.ts');
function readRule(styles: string, selector: string): string {
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const match = styles.match(new RegExp(`${escapedSelector}\\s*\\{([^}]*)\\}`));
return match?.[1] ?? '';
}
describe('provider settings styles', () => {
test('styles ship dedicated selectors for advanced provider settings and discovered model rows', () => {
const styles = fs.readFileSync(stylesPath, 'utf8');
@ -49,6 +55,17 @@ describe('provider settings styles', () => {
expect(styles).toContain('@media (max-width: 720px)');
});
test('diagram history drawer constrains the grid item and contains its scroll region', () => {
const styles = fs.readFileSync(stylesPath, 'utf8');
const drawerRule = readRule(styles, '.notemd-diagram-history-drawer');
const bodyRule = readRule(styles, '.notemd-diagram-history-drawer-body');
expect(drawerRule).toMatch(/min-height:\s*0\s*;/);
expect(bodyRule).toMatch(/min-height:\s*0\s*;/);
expect(bodyRule).toMatch(/overflow:\s*auto\s*;/);
expect(bodyRule).toMatch(/overscroll-behavior:\s*contain\s*;/);
});
test('settings categories use one progressive selector instead of parallel heading buttons', () => {
const source = fs.readFileSync(settingTabPath, 'utf8');

View file

@ -164,9 +164,11 @@ describe('website documentation contract', () => {
expect(englishDiagrams).toContain('Draw.io and Drawnix are **source formats**, not diagram types.');
expect(englishDiagrams).toContain('Current CircuitikZ support is intentionally constrained.');
expect(englishDiagrams).toContain('Save source file');
expect(englishDiagrams).toContain('Choose **History** in the preview');
expect(chineseDiagrams).toContain('Draw.io 和 Drawnix 是**源格式**,不是图表类型。');
expect(chineseDiagrams).toContain('当前 CircuitikZ 支持有意保持受约束范围。');
expect(chineseDiagrams).toContain('保存源文件');
expect(chineseDiagrams).toContain('在预览中点击**历史**');
});
test('published locale metadata covers the README and UI locale documentation matrix', () => {

View file

@ -216,6 +216,11 @@ export interface NotemdSettings {
preferredDiagramIntent?: string;
preferredDiagramRenderTarget?: RenderTarget;
diagramPreviewExportPpi: number;
circuitikzCompilerPreference: 'auto' | 'managed' | 'system' | 'custom';
circuitikzCustomCompilerKind: 'tectonic' | 'pdflatex';
circuitikzCustomCompilerPath: string;
circuitikzManagedRuntimeRoot: string;
circuitikzCompileTimeoutMs: number;
favoriteSettingIds: string[];
diagramHistoryRetentionLimit: number;
diagramHistoryEntries: DiagramHistoryEntry[];

View file

@ -0,0 +1,407 @@
import { App, Modal } from 'obsidian';
import { formatI18n, getI18nStrings } from '../i18n';
import type { CircuitikzEnvironmentReport } from '../diagram/adapters/circuitikz/circuitikzEnvironment';
import type { CircuitikzEnvironmentDesktopContext } from '../latexEnvironment/circuitikzEnvironmentDesktop';
import type { ManagedTectonicInstallProgress } from '../latexEnvironment/managedTectonicInstaller';
import type { NotemdSettings } from '../types';
type DesktopEnvironmentModule = Pick<typeof import('../latexEnvironment/circuitikzEnvironmentDesktop'),
| 'probeConfiguredCircuitikzEnvironment'
| 'installConfiguredManagedTectonic'
| 'removeConfiguredManagedTectonic'
| 'clearConfiguredManagedTectonicStaleLock'>;
type EnvironmentState =
| 'idle'
| 'checking'
| 'installing'
| 'ready'
| 'missing'
| 'incomplete'
| 'unsupported'
| 'failed'
| 'cancelled';
export interface CircuitikzEnvironmentModalOptions {
settings: NotemdSettings;
uiLocale?: string;
isDesktop: boolean;
loadDesktopModule?: () => Promise<DesktopEnvironmentModule>;
}
const TECTONIC_RELEASE_URL = 'https://github.com/tectonic-typesetting/tectonic/releases/tag/tectonic%400.16.9';
const TECTONIC_LICENSE_URL = 'https://github.com/tectonic-typesetting/tectonic/blob/tectonic%400.16.9/LICENSE';
const MAX_LOG_ENTRIES = 80;
function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return '—';
return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
}
function trimLogLine(value: string): string {
const normalized = value.replace(/\r/g, '').trim();
return normalized.length > 500 ? `${normalized.slice(0, 497)}` : normalized;
}
export class CircuitikzEnvironmentModal extends Modal {
private state: EnvironmentState = 'idle';
private context: CircuitikzEnvironmentDesktopContext | null = null;
private report: CircuitikzEnvironmentReport | null = null;
private errorMessage = '';
private logs: string[] = [];
private progress: ManagedTectonicInstallProgress | null = null;
private activeController: AbortController | null = null;
private readonly loadDesktopModule: () => Promise<DesktopEnvironmentModule>;
constructor(app: App, private readonly options: CircuitikzEnvironmentModalOptions) {
super(app);
this.loadDesktopModule = options.loadDesktopModule
?? (() => import('../latexEnvironment/circuitikzEnvironmentDesktop'));
}
onOpen(): void {
const copy = getI18nStrings({ uiLocale: this.options.uiLocale }).circuitikzEnvironment;
this.modalEl.addClass('notemd-circuitikz-environment-shell');
this.modalEl.setAttribute('aria-label', copy.title);
this.titleEl.setText(copy.title);
if (!this.options.isDesktop) {
this.state = 'unsupported';
this.render();
return;
}
void this.probeEnvironment();
}
onClose(): void {
this.activeController?.abort();
this.activeController = null;
this.contentEl.empty();
this.modalEl.removeClass('notemd-circuitikz-environment-shell');
}
private appendLog(value: string): void {
for (const rawLine of value.split('\n')) {
const line = trimLogLine(rawLine);
if (line) this.logs.push(line);
}
if (this.logs.length > MAX_LOG_ENTRIES) {
this.logs.splice(0, this.logs.length - MAX_LOG_ENTRIES);
}
}
private async probeEnvironment(): Promise<void> {
if (!this.options.isDesktop) return;
const controller = new AbortController();
this.activeController?.abort();
this.activeController = controller;
this.state = 'checking';
this.errorMessage = '';
this.progress = null;
this.render();
try {
const desktop = await this.loadDesktopModule();
const outcome = await desktop.probeConfiguredCircuitikzEnvironment(this.options.settings, {
isDesktop: true,
signal: controller.signal,
onCommandOutput: text => this.appendLog(text)
});
if (controller.signal.aborted) {
this.state = 'cancelled';
} else {
this.context = outcome.context;
this.report = outcome.report;
this.state = outcome.report.status;
}
} catch (error) {
if (controller.signal.aborted) {
this.state = 'cancelled';
} else {
this.state = 'failed';
this.errorMessage = error instanceof Error ? error.message : String(error);
this.appendLog(this.errorMessage);
}
} finally {
if (this.activeController === controller) this.activeController = null;
this.render();
}
}
private async installManagedRuntime(): Promise<void> {
if (!this.options.isDesktop) return;
const controller = new AbortController();
this.activeController?.abort();
this.activeController = controller;
this.state = 'installing';
this.errorMessage = '';
this.progress = { phase: 'download' };
this.render();
let probeAfterInstall = false;
try {
const desktop = await this.loadDesktopModule();
const result = await desktop.installConfiguredManagedTectonic(this.options.settings, {
signal: controller.signal,
onProgress: progress => {
this.progress = progress;
if (progress.detail) this.appendLog(progress.detail);
this.render();
}
});
if (result.status === 'installed') {
this.appendLog(result.message);
probeAfterInstall = true;
} else if (result.status === 'cancelled') {
this.state = 'cancelled';
} else {
this.state = 'failed';
this.errorMessage = result.message;
this.appendLog(result.message);
}
} catch (error) {
if (controller.signal.aborted) {
this.state = 'cancelled';
} else {
this.state = 'failed';
this.errorMessage = error instanceof Error ? error.message : String(error);
this.appendLog(this.errorMessage);
}
} finally {
if (this.activeController === controller) this.activeController = null;
this.render();
}
if (probeAfterInstall) await this.probeEnvironment();
}
private async removeManagedRuntime(): Promise<void> {
const controller = new AbortController();
this.activeController?.abort();
this.activeController = controller;
this.state = 'checking';
this.errorMessage = '';
this.render();
let probeAfterRemoval = false;
try {
const desktop = await this.loadDesktopModule();
const result = await desktop.removeConfiguredManagedTectonic(this.options.settings, {
signal: controller.signal
});
if (result.status === 'cancelled' || controller.signal.aborted) {
this.state = 'cancelled';
} else {
const copy = getI18nStrings({ uiLocale: this.options.uiLocale }).circuitikzEnvironment;
this.appendLog(copy.removalComplete);
probeAfterRemoval = true;
}
} catch (error) {
if (controller.signal.aborted) {
this.state = 'cancelled';
} else {
this.state = 'failed';
this.errorMessage = error instanceof Error ? error.message : String(error);
this.appendLog(this.errorMessage);
}
} finally {
if (this.activeController === controller) this.activeController = null;
this.render();
}
if (probeAfterRemoval) await this.probeEnvironment();
}
private async clearStaleManagedRuntimeLock(): Promise<void> {
this.state = 'checking';
this.errorMessage = '';
this.progress = null;
this.render();
try {
const desktop = await this.loadDesktopModule();
const result = await desktop.clearConfiguredManagedTectonicStaleLock(this.options.settings);
this.appendLog(result.message);
if (result.status === 'cleared' || result.status === 'absent') {
await this.probeEnvironment();
return;
}
this.state = 'failed';
this.errorMessage = result.message;
} catch (error) {
this.state = 'failed';
this.errorMessage = error instanceof Error ? error.message : String(error);
this.appendLog(this.errorMessage);
}
this.render();
}
private cancelOperation(): void {
const copy = getI18nStrings({ uiLocale: this.options.uiLocale }).circuitikzEnvironment;
this.appendLog(copy.cancelled);
this.activeController?.abort();
this.state = 'cancelled';
this.render();
}
private statusLabel(copy: ReturnType<typeof getI18nStrings>['circuitikzEnvironment']): string {
const labels: Record<EnvironmentState, string> = {
idle: copy.statusIdle,
checking: copy.statusChecking,
installing: copy.statusInstalling,
ready: copy.statusReady,
missing: copy.statusMissing,
incomplete: copy.statusIncomplete,
unsupported: copy.statusUnsupported,
failed: copy.statusFailed,
cancelled: copy.statusCancelled
};
return labels[this.state];
}
private sourceLabel(copy: ReturnType<typeof getI18nStrings>['circuitikzEnvironment'], source?: string): string {
if (source === 'custom') return copy.sourceCustom;
if (source === 'managed') return copy.sourceManaged;
if (source === 'system') return copy.sourceSystem;
return '—';
}
private renderFact(list: HTMLElement, label: string, value: string): void {
const item = list.createDiv({ cls: 'notemd-circuitikz-environment-fact' });
item.createEl('dt', { text: label });
item.createEl('dd', { text: value || '—' });
}
private renderCapability(
container: HTMLElement,
label: string,
available: boolean,
availableText: string,
unavailableText: string
): void {
const item = container.createDiv({ cls: 'notemd-circuitikz-environment-capability' });
item.setAttr('data-notemd-capability', available ? 'available' : 'unavailable');
item.createSpan({ text: label });
item.createEl('strong', { text: available ? availableText : unavailableText });
}
private render(): void {
const copy = getI18nStrings({ uiLocale: this.options.uiLocale }).circuitikzEnvironment;
this.contentEl.empty();
const root = this.contentEl.createDiv({ cls: 'notemd-circuitikz-environment' });
const boundaries = root.createDiv({ cls: 'notemd-circuitikz-environment-boundaries' });
const preview = boundaries.createDiv({ cls: 'notemd-circuitikz-environment-boundary is-preview' });
preview.createEl('span', { text: copy.previewEyebrow, cls: 'notemd-circuitikz-environment-eyebrow' });
preview.createEl('h3', { text: copy.previewTitle });
preview.createEl('p', { text: copy.previewDescription });
const native = boundaries.createDiv({ cls: 'notemd-circuitikz-environment-boundary is-native' });
native.createEl('span', { text: copy.nativeEyebrow, cls: 'notemd-circuitikz-environment-eyebrow' });
native.createEl('h3', { text: copy.nativeTitle });
native.createEl('p', { text: copy.nativeDescription });
const status = root.createDiv({ cls: `notemd-circuitikz-environment-status is-${this.state}` });
status.setAttr('data-notemd-environment-status', this.state);
status.setAttr('aria-live', 'polite');
status.createEl('strong', { text: this.statusLabel(copy) });
if (this.errorMessage) status.createEl('p', { text: this.errorMessage });
const facts = root.createEl('dl', { cls: 'notemd-circuitikz-environment-facts' });
const selected = this.report?.selected;
const selectedAttempt = selected
? this.report?.attempts.find(attempt => attempt.candidate.executable === selected.executable)
: undefined;
this.renderFact(facts, copy.selectedCompiler, selected ? selected.kind : copy.noCompilerSelected);
this.renderFact(facts, copy.compilerSource, this.sourceLabel(copy, selected?.source));
this.renderFact(facts, copy.compilerVersion, selectedAttempt?.version ?? '—');
this.renderFact(facts, copy.executablePath, selected?.executable ?? '—');
this.renderFact(facts, copy.platform, this.context?.platform ?? '—');
this.renderFact(facts, copy.architecture, this.context?.architecture ?? '—');
this.renderFact(facts, copy.managedVersion, this.context?.managedRuntimeVersion ?? '—');
this.renderFact(facts, copy.downloadSize, formatBytes(this.context?.managedArtifact?.compressedBytes ?? 0));
this.renderFact(facts, copy.installPath, this.context?.runtimeRoot ?? '—');
const capabilitySection = root.createDiv({ cls: 'notemd-circuitikz-environment-capability-section' });
capabilitySection.createEl('h3', { text: copy.capabilityHeading });
const capabilityList = capabilitySection.createDiv({ cls: 'notemd-circuitikz-environment-capabilities' });
const capabilities = this.report?.capabilities;
this.renderCapability(capabilityList, copy.capabilityCompileDiagnostics, Boolean(capabilities?.compileDiagnostics), copy.capabilityAvailable, copy.capabilityUnavailable);
this.renderCapability(capabilityList, copy.capabilityNativePdf, Boolean(capabilities?.nativePdf), copy.capabilityAvailable, copy.capabilityUnavailable);
this.renderCapability(capabilityList, copy.capabilityRepairAcceptance, Boolean(capabilities?.repairAcceptance), copy.capabilityAvailable, copy.capabilityUnavailable);
if (this.context && !this.context.managedArtifact) {
root.createEl('p', { text: copy.managedUnavailable, cls: 'notemd-circuitikz-environment-inline-note' });
}
if (this.progress) {
const progressSection = root.createDiv({ cls: 'notemd-circuitikz-environment-progress' });
progressSection.createEl('h3', { text: copy.progressHeading });
progressSection.createEl('p', { text: this.progress.detail ?? this.progress.phase });
const progress = progressSection.createEl('progress');
const total = this.progress.totalBytes ?? this.context?.managedArtifact?.compressedBytes ?? 0;
const received = this.progress.receivedBytes ?? 0;
progress.setAttr('max', String(Math.max(1, total)));
progress.setAttr('value', String(Math.min(received, total)));
if (total > 0) {
progressSection.createEl('span', {
text: formatI18n(copy.progressBytes, {
received: formatBytes(received),
total: formatBytes(total)
})
});
}
}
const actions = root.createDiv({ cls: 'notemd-circuitikz-environment-actions' });
const busy = this.state === 'checking' || this.state === 'installing';
if (this.options.isDesktop) {
const check = actions.createEl('button', { text: copy.checkEnvironment });
check.setAttr('type', 'button');
check.setAttr('data-notemd-environment-action', 'check');
check.disabled = busy;
check.addEventListener('click', () => { void this.probeEnvironment(); });
if (this.context?.managedArtifact) {
const install = actions.createEl('button', {
text: this.context.managedExecutablePath ? copy.repairManaged : copy.installRecommended,
cls: 'mod-cta'
});
install.setAttr('type', 'button');
install.setAttr('data-notemd-environment-action', 'install');
install.disabled = busy;
install.addEventListener('click', () => { void this.installManagedRuntime(); });
}
if (this.context?.managedExecutablePath) {
const remove = actions.createEl('button', { text: copy.removeManaged, cls: 'mod-warning' });
remove.setAttr('type', 'button');
remove.setAttr('data-notemd-environment-action', 'remove');
remove.disabled = busy;
remove.addEventListener('click', () => { void this.removeManagedRuntime(); });
}
const clearStaleLock = actions.createEl('button', { text: copy.clearStaleLock });
clearStaleLock.setAttr('type', 'button');
clearStaleLock.setAttr('data-notemd-environment-action', 'clear-stale-lock');
clearStaleLock.disabled = busy;
clearStaleLock.addEventListener('click', () => { void this.clearStaleManagedRuntimeLock(); });
if (busy && this.activeController) {
const cancel = actions.createEl('button', { text: copy.cancelOperation });
cancel.setAttr('type', 'button');
cancel.setAttr('data-notemd-environment-action', 'cancel');
cancel.addEventListener('click', () => this.cancelOperation());
}
}
actions.createEl('a', {
text: copy.licenseLink,
href: TECTONIC_LICENSE_URL,
attr: { target: '_blank', rel: 'noopener noreferrer' }
});
actions.createEl('a', {
text: copy.releaseLink,
href: TECTONIC_RELEASE_URL,
attr: { target: '_blank', rel: 'noopener noreferrer' }
});
const logSection = root.createDiv({ cls: 'notemd-circuitikz-environment-log-section' });
logSection.createEl('h3', { text: copy.logsHeading });
logSection.createEl('pre', {
text: this.logs.length > 0 ? this.logs.join('\n') : copy.noLogs,
cls: 'notemd-circuitikz-environment-log'
});
}
}

View file

@ -2543,6 +2543,83 @@ export class NotemdSettingTab extends PluginSettingTab {
await this.plugin.saveSettings();
}));
this.createCatalogSetting(containerEl)
.setName(experimentalDiagramI18n.nativeEnvironmentName)
.setDesc(experimentalDiagramI18n.nativeEnvironmentDesc)
.addButton(button => button
.setButtonText(experimentalDiagramI18n.nativeEnvironmentButton)
.setCta()
.onClick(() => this.plugin.openCircuitikzEnvironment()));
this.createCatalogSetting(containerEl)
.setName(experimentalDiagramI18n.compilerPreferenceName)
.setDesc(experimentalDiagramI18n.compilerPreferenceDesc)
.addDropdown(dropdown => dropdown
.addOption('auto', experimentalDiagramI18n.compilerPreferenceAuto)
.addOption('managed', experimentalDiagramI18n.compilerPreferenceManaged)
.addOption('system', experimentalDiagramI18n.compilerPreferenceSystem)
.addOption('custom', experimentalDiagramI18n.compilerPreferenceCustom)
.setValue(this.plugin.settings.circuitikzCompilerPreference)
.onChange(async (value: 'auto' | 'managed' | 'system' | 'custom') => {
this.plugin.settings.circuitikzCompilerPreference = value;
await this.plugin.saveSettings();
}));
this.createCatalogSetting(containerEl)
.setName(experimentalDiagramI18n.customCompilerKindName)
.setDesc(experimentalDiagramI18n.customCompilerKindDesc)
.addDropdown(dropdown => dropdown
.addOption('tectonic', experimentalDiagramI18n.customCompilerKindTectonic)
.addOption('pdflatex', experimentalDiagramI18n.customCompilerKindPdflatex)
.setValue(this.plugin.settings.circuitikzCustomCompilerKind)
.onChange(async (value: 'tectonic' | 'pdflatex') => {
this.plugin.settings.circuitikzCustomCompilerKind = value;
await this.plugin.saveSettings();
}));
this.createCatalogSetting(containerEl)
.setName(experimentalDiagramI18n.customCompilerPathName)
.setDesc(experimentalDiagramI18n.customCompilerPathDesc)
.addText(text => text
.setPlaceholder(experimentalDiagramI18n.customCompilerPathPlaceholder)
.setValue(this.plugin.settings.circuitikzCustomCompilerPath)
.onChange(async value => {
this.plugin.settings.circuitikzCustomCompilerPath = value.trim();
await this.plugin.saveSettings();
}));
this.createCatalogSetting(containerEl)
.setName(experimentalDiagramI18n.managedRuntimeRootName)
.setDesc(experimentalDiagramI18n.managedRuntimeRootDesc)
.addText(text => text
.setPlaceholder(experimentalDiagramI18n.managedRuntimeRootPlaceholder)
.setValue(this.plugin.settings.circuitikzManagedRuntimeRoot)
.onChange(async value => {
this.plugin.settings.circuitikzManagedRuntimeRoot = value.trim();
await this.plugin.saveSettings();
}));
this.addDeferredNumberSetting(
this.createCatalogSetting(containerEl)
.setName(experimentalDiagramI18n.compileTimeoutName)
.setDesc(experimentalDiagramI18n.compileTimeoutDesc),
{
placeholder: String(DEFAULT_SETTINGS.circuitikzCompileTimeoutMs / 1000),
value: Math.round(this.plugin.settings.circuitikzCompileTimeoutMs / 1000),
onCommit: async rawValue => {
const seconds = this.sanitizePositiveInteger(
rawValue,
DEFAULT_SETTINGS.circuitikzCompileTimeoutMs / 1000,
5,
600
);
this.plugin.settings.circuitikzCompileTimeoutMs = seconds * 1000;
await this.plugin.saveSettings();
return String(seconds);
}
}
);
this.createCatalogSetting(containerEl)
.setName(i18n.settings.developer.modeName)
.setDesc(i18n.settings.developer.modeDesc)

View file

@ -636,6 +636,7 @@
grid-row: 1;
display: flex;
min-width: 0;
min-height: 0;
max-height: 100%;
flex-direction: column;
background: var(--background-primary);
@ -671,6 +672,213 @@
min-height: 0;
padding: 0.75rem 1rem 1rem;
overflow: auto;
overscroll-behavior: contain;
}
.notemd-circuitikz-environment-shell {
width: min(780px, calc(100vw - 32px));
}
.notemd-circuitikz-environment-shell .modal-content {
max-height: min(78vh, 860px);
overflow: auto;
overscroll-behavior: contain;
}
.notemd-circuitikz-environment {
display: grid;
gap: 20px;
padding-bottom: 4px;
}
.notemd-circuitikz-environment-boundaries {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
border-block: 1px solid var(--background-modifier-border);
}
.notemd-circuitikz-environment-boundary {
min-width: 0;
padding: 16px 18px 18px;
}
.notemd-circuitikz-environment-boundary + .notemd-circuitikz-environment-boundary {
border-left: 1px solid var(--background-modifier-border);
}
.notemd-circuitikz-environment-boundary h3,
.notemd-circuitikz-environment-capability-section h3,
.notemd-circuitikz-environment-progress h3,
.notemd-circuitikz-environment-log-section h3 {
margin: 4px 0 8px;
font-size: var(--font-ui-medium);
}
.notemd-circuitikz-environment-boundary p,
.notemd-circuitikz-environment-status p,
.notemd-circuitikz-environment-progress p {
margin: 0;
color: var(--text-muted);
line-height: 1.5;
}
.notemd-circuitikz-environment-eyebrow {
color: var(--text-accent);
font-size: var(--font-ui-smaller);
font-weight: 650;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.notemd-circuitikz-environment-status {
display: grid;
gap: 6px;
padding: 14px 16px;
background: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-s);
}
.notemd-circuitikz-environment-status.is-ready {
border-color: var(--color-green);
}
.notemd-circuitikz-environment-status.is-failed,
.notemd-circuitikz-environment-status.is-incomplete {
border-color: var(--color-red);
}
.notemd-circuitikz-environment-facts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0;
margin: 0;
border-top: 1px solid var(--background-modifier-border);
}
.notemd-circuitikz-environment-fact {
min-width: 0;
padding: 10px 12px;
border-bottom: 1px solid var(--background-modifier-border);
}
.notemd-circuitikz-environment-fact dt {
margin-bottom: 3px;
color: var(--text-muted);
font-size: var(--font-ui-smaller);
}
.notemd-circuitikz-environment-fact dd {
margin: 0;
overflow-wrap: anywhere;
font-size: var(--font-ui-small);
}
.notemd-circuitikz-environment-capabilities {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.notemd-circuitikz-environment-capability {
display: grid;
gap: 5px;
min-width: 0;
padding: 10px 12px;
background: var(--background-secondary);
border-radius: var(--radius-s);
font-size: var(--font-ui-small);
}
.notemd-circuitikz-environment-capability strong {
color: var(--text-muted);
}
.notemd-circuitikz-environment-capability[data-notemd-capability="available"] strong {
color: var(--color-green);
}
.notemd-circuitikz-environment-inline-note {
margin: -8px 0 0;
color: var(--text-muted);
}
.notemd-circuitikz-environment-progress {
display: grid;
gap: 8px;
}
.notemd-circuitikz-environment-progress progress {
width: 100%;
min-height: 12px;
}
.notemd-circuitikz-environment-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.notemd-circuitikz-environment-actions button,
.notemd-circuitikz-environment-actions a {
min-height: 44px;
}
.notemd-circuitikz-environment-actions a {
display: inline-flex;
align-items: center;
padding-inline: 10px;
}
.notemd-circuitikz-environment-actions button:focus-visible,
.notemd-circuitikz-environment-actions a:focus-visible {
outline: 2px solid var(--interactive-accent);
outline-offset: 2px;
}
.notemd-circuitikz-environment-log {
max-height: 180px;
margin: 0;
padding: 12px;
overflow: auto;
overscroll-behavior: contain;
background: var(--background-secondary-alt);
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-s);
color: var(--text-muted);
font-size: var(--font-ui-smaller);
line-height: 1.45;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
@media (max-width: 640px) {
.notemd-circuitikz-environment-shell {
width: calc(100vw - 16px);
}
.notemd-circuitikz-environment-boundaries,
.notemd-circuitikz-environment-facts,
.notemd-circuitikz-environment-capabilities {
grid-template-columns: minmax(0, 1fr);
}
.notemd-circuitikz-environment-boundary + .notemd-circuitikz-environment-boundary {
border-top: 1px solid var(--background-modifier-border);
border-left: 0;
}
.notemd-circuitikz-environment-actions {
display: grid;
grid-template-columns: minmax(0, 1fr);
}
.notemd-circuitikz-environment-actions button,
.notemd-circuitikz-environment-actions a {
justify-content: center;
width: 100%;
}
}
.notemd-diagram-history-actions {

View file

@ -45,7 +45,11 @@ Leave **Preferred source format** on **Auto** if you do not need a particular ed
3. Select a **diagram type**, or leave it on **Auto-detect**.
4. Select a **source format**, or leave it on **Auto**.
5. Review the preview.
6. Choose **Save source file**, **Export SVG**, **Export PNG**, or **Export PDF**.
6. Choose **Export**, then select SVG, PNG, or PDF. Use **Save source file** when you also need the editable artifact.
The Export menu provides **Export SVG**, **Export PNG**, and **Export PDF** actions for the current visual preview.
Choose **History** in the preview to open the Vault history drawer without hiding the diagram. You can search and filter completed diagrams, reopen a preview, or open saved artifacts. Diagram history is also available directly from the command palette and Notemd sidebar, even when no note is active.
PNG and PDF use the image export PPI configured in Notemd settings. SVG remains a vector image and is not affected by the PPI value.
@ -57,6 +61,8 @@ Current CircuitikZ support is intentionally constrained. Common-source NMOS ampl
The SVG preview is a review companion generated by Notemd. It is not proof that a local LaTeX or TikZJax installation compiled the `.tex` file successfully.
**Optional native compilation.** You do not need LaTeX for the in-plugin preview or SVG, PNG, and preview PDF export. On desktop, open **CircuitikZ native compile environment** from diagram settings or the command palette when you need compile diagnostics or a native LaTeX PDF. Notemd can reuse a system `tectonic` or `pdflatex`, use a custom executable, or install the pinned managed Tectonic runtime after you explicitly choose **Install**. The environment panel shows progress and errors inline and lets you remove the managed runtime later.
## Troubleshooting
### The result is the wrong diagram type
@ -67,10 +73,10 @@ Select the diagram type explicitly instead of using Auto-detect. For a circuit,
Return the source format to **Auto**, or choose a format intended for that diagram type. CircuitikZ is for circuit diagrams; Draw.io and Drawnix are intended for supported structured diagrams.
### Export buttons are missing
### The Export menu is missing
SVG, PNG, and PDF require a visual preview. If only source text is available, save the source file and open it in the matching external tool.
### A CircuitikZ file does not compile externally
Check that your LaTeX environment includes the `circuitikz` package. The plugin does not bundle a complete LaTeX distribution.
Open **CircuitikZ native compile environment** and run the environment check. Repair the managed runtime or verify that your custom/system compiler can obtain the `circuitikz` package. Notemd does not bundle a complete TeX distribution inside the plugin file.

View file

@ -45,7 +45,9 @@ Draw.io 和 Drawnix 是**源格式**不是图表类型。CircuitikZ 是面向
3. 选择**图表类型**,也可以保留为**自动检测**。
4. 选择**生成格式**,也可以保留为**自动**。
5. 检查预览结果。
6. 选择**保存源文件**、**导出 SVG**、**导出 PNG**或**导出 PDF**。
6. 点击**导出**,再选择 SVG、PNG 或 PDF如需保留可编辑产物再使用**保存源文件**。
在预览中点击**历史**,可以在不遮蔽当前图形的情况下打开 Vault 历史抽屉。你可以搜索和筛选已完成图形、重新打开预览或打开已保存产物。即使当前没有活动笔记,也能从命令面板或 Notemd 侧边栏直接进入图形历史。
PNG 和 PDF 使用 Notemd 设置中的图像导出 PPI。SVG 保持矢量格式,不受 PPI 设置影响。
@ -57,6 +59,8 @@ PNG 和 PDF 使用 Notemd 设置中的图像导出 PPI。SVG 保持矢量格式
SVG 预览是 Notemd 生成的审阅辅助文件,不代表本机 LaTeX 或 TikZJax 已成功编译 `.tex` 文件。
**可选的原生编译。** 插件内预览以及 SVG、PNG、预览 PDF 导出不需要 LaTeX。桌面端如需编译诊断或原生 LaTeX PDF可从图形设置或命令面板打开 **CircuitikZ 原生编译环境**。Notemd 可以复用系统 `tectonic` / `pdflatex`、使用自定义可执行文件,或在你明确点击**安装**后部署固定版本的托管 Tectonic。环境面板会在原位显示进度和错误也支持稍后删除托管运行时。
## 常见问题
### 生成了错误的图表类型
@ -67,10 +71,10 @@ SVG 预览是 Notemd 生成的审阅辅助文件,不代表本机 LaTeX 或 Tik
把生成格式恢复为**自动**或者选择适合该图表类型的格式。CircuitikZ 用于电路图Draw.io 和 Drawnix 用于其支持的结构化图表。
### 没有显示导出按钮
### 没有显示导出菜单
SVG、PNG 和 PDF 需要可用的视觉预览。如果当前只有源代码,请先保存源文件,再用对应的外部工具打开。
### CircuitikZ 文件在外部无法编译
确认 LaTeX 环境已经安装 `circuitikz` 包。插件不会捆绑完整 LaTeX 发行版
打开 **CircuitikZ 原生编译环境**并执行环境检查。可修复托管运行时,或确认自定义/系统编译器能够取得 `circuitikz` 包。Notemd 不会把完整 TeX 发行版捆绑进插件文件