feat: add constrained circuitikz export prototype

This commit is contained in:
aliyun1121003339 2026-07-05 18:29:41 +08:00
parent 2f6dc8c36a
commit 2d688db7c1
24 changed files with 1122 additions and 23 deletions

View file

@ -39,6 +39,7 @@ This directory contains repository-level documentation for maintainers and contr
- [Diagram Reference Integration And Figure Generation Plan](./brainstorms/2026-07-04-diagram-reference-integration-and-figure-generation-plan.md)
- [circuitikz Figure Generation Roadmap](./maintainer/circuitikz-figure-generation-roadmap.md)
- [circuitikz Export Prototype](./maintainer/circuitikz-export-prototype.md)
- [Diagram Platform Phase 2 Requirements](./brainstorms/2026-04-14-diagram-platform-phase-2-requirements.md)
- [Local KB Retrieval Decision And Quality Truth](./brainstorms/2026-06-09-local-kb-retrieval-decision-and-quality-truth.md)
- [Local KB RAG Quality And Execution Truth](./brainstorms/2026-06-09-local-kb-rag-quality-and-execution-truth.md)

View file

@ -37,6 +37,7 @@
- [图形参考项目集成与 Figure 生成扩展方案](./brainstorms/2026-07-04-diagram-reference-integration-and-figure-generation-plan.zh-CN.md)
- [circuitikz Figure Generation Roadmap](./maintainer/circuitikz-figure-generation-roadmap.zh-CN.md)
- [circuitikz 导出原型](./maintainer/circuitikz-export-prototype.zh-CN.md)
- [Diagram Platform Phase 2 Requirements](./brainstorms/2026-04-14-diagram-platform-phase-2-requirements.zh-CN.md)
- [Local KB Retrieval 方案决策与质量真值](./brainstorms/2026-06-09-local-kb-retrieval-decision-and-quality-truth.zh-CN.md)
- [Local KB RAG 质量与执行链路真值](./brainstorms/2026-06-09-local-kb-rag-quality-and-execution-truth.zh-CN.md)

View file

@ -189,6 +189,17 @@ Phase D implementation status on 2026-07-05: the Drawnix spike is implemented as
Phase E implementation status on 2026-07-05: `scripts/export-diagram-artifact.js` and `npm run diagram:export-artifact` now provide an offline artifact exporter. The CLI reads a `DiagramSpec` JSON file, validates the requested target, builds a temporary internal `esbuild` bundle from the existing TypeScript exporters, and writes `editable-html-svg`, `drawio`, or `drawnix` output. `src/tests/diagramArtifactExportCli.test.ts` verifies real HTML/XML/JSON output, normalized-id collision handling, Draw.io visible-label preservation, Drawnix `geometry`/`arrow-line` output, and unsupported-target failure semantics. `docs/maintainer/diagram-artifact-export-cli.md` and `docs/maintainer/diagram-artifact-export-cli.zh-CN.md` document the command contract and explicitly state that no Obsidian runtime is required.
### Phase F: Constrained circuitikz Prototype
- [x] Keep circuit diagrams outside `DiagramSpec` until the circuit-specific topology/layout fields prove reusable.
- [x] Define a minimal `CircuitSpec` with `circuitKind`, named nets, typed components, terminal references, layout hints, style, and `goldenReferenceId`.
- [x] Add deterministic golden-template export for `common-source-amplifier` and `cmos-inverter`.
- [x] Add an offline CLI that writes `.tex` without Obsidian, TikZJax, LaTeX, or browser runtime dependencies.
- [x] Add topology-rejection tests so invalid circuits fail before output creation.
- [x] Add bilingual maintainer docs and website progress notes.
Phase F implementation status on 2026-07-05: `src/diagram/adapters/circuitikz/circuitSpec.ts` and `src/diagram/adapters/circuitikz/circuitikzExporter.ts` now implement a constrained `CircuitSpec -> circuitikz` prototype. `scripts/export-circuitikz.js` and `npm run diagram:export-circuitikz` export deterministic LaTeX for `common-source-nmos-v1` and `cmos-inverter-v1`. `src/tests/circuitikzExporter.test.ts` and `src/tests/circuitikzExportCli.test.ts` verify deterministic output, topology rejection, package-script exposure, UTF-8 BOM input handling, and no output write for invalid topology. The implementation deliberately stops before TikZJax/LaTeX compilation, screenshot inspection, and visual repair loops.
## Tradeoffs
| Decision | Benefit | Cost |
@ -196,6 +207,7 @@ Phase E implementation status on 2026-07-05: `scripts/export-diagram-artifact.js
| HTML/SVG target before Drawnix | Fastest path to richer figures and editable export | Requires owning SVG layout and text fitting |
| Drawnix as export target only | Avoids full whiteboard host complexity | No in-plugin board editing |
| Keep `DiagramSpec` primary | Preserves semantic quality and testability | Requires writing target adapters instead of reusing converters blindly |
| Separate `CircuitSpec` for circuitikz | Keeps terminal/topology rules explicit and testable | Requires a second spec boundary until common fields prove reusable |
| Manual/release visual gate | Avoids flaky CI from diagrams.net/font differences | Maintainer must run richer checks before release claims |
## Pitfalls To Avoid
@ -206,6 +218,7 @@ Phase E implementation status on 2026-07-05: `scripts/export-diagram-artifact.js
- Do not add Google Fonts as a hard runtime dependency inside Obsidian preview. Use system fallbacks and keep external font loading optional.
- Do not put heavy export code in `main.js` until packaging isolation is real.
- Do not widen `DiagramSpec` prematurely. Use an internal `SemanticFigureModel` first; only promote fields after two or more targets need them.
- Do not treat `diagram:export-circuitikz` as proof of visual rendering. It proves topology-constrained LaTeX export, not TikZJax compilation or screenshot quality.
## Best-Practice Boundary
@ -223,12 +236,12 @@ Cloudy is valuable because it proves a disciplined HTML/SVG + Draw.io export con
## Recommended Next Move
Do not start by adding dependencies. Start by adding a minimal editable HTML/SVG renderer behind the experimental diagram path and prove:
The HTML/SVG, Draw.io, Drawnix, and first circuitikz export boundaries now exist. The next move should still avoid adding runtime dependencies by default:
1. current `DiagramSpec` can express a useful architecture/runtime mechanism figure;
2. the renderer can generate a self-contained nonblank HTML artifact;
3. visible text fits and remains searchable/editable;
4. Draw.io export coverage can be audited structurally;
5. the existing Mermaid, JSON Canvas, Vega-Lite, and HTML renderers are unaffected.
1. add optional local TikZJax/LaTeX smoke verification for the two circuitikz golden families;
2. parse compile logs into actionable diagnostics without requiring TikZJax as a plugin runtime dependency;
3. add screenshot-level checks for nonblank render, expected labels, bounded canvas, and obvious overlap;
4. keep topology locked during any repair prompt so visual repair cannot change the circuit;
5. only then consider more circuit families or a plugin-side circuit preview target.
Only after those claims are verified should Notemd consider a `.drawnix` target. Drawnix is a good future export format, not a reason to embed a whiteboard product in the plugin.
Drawnix remains a good export format, not a reason to embed a whiteboard product in the plugin. circuitikz remains a constrained circuit target, not a reason to accept arbitrary TikZ from the LLM.

View file

@ -189,6 +189,17 @@ Phase D 于 2026-07-05 的实现状态Drawnix spike 以 `src/diagram/adapters
Phase E 于 2026-07-05 的实现状态:`scripts/export-diagram-artifact.js` 与 `npm run diagram:export-artifact` 已提供离线 artifact exporter。CLI 读取 `DiagramSpec` JSON 文件,先验证 target再用临时内部 `esbuild` bundle 复用现有 TypeScript exporters写出 `editable-html-svg`、`drawio` 或 `drawnix` 输出。`src/tests/diagramArtifactExportCli.test.ts` 验证真实 HTML/XML/JSON 输出、normalized id collision handling、Draw.io visible-label preservation、Drawnix `geometry`/`arrow-line` 输出,以及 unsupported-target failure semantics。`docs/maintainer/diagram-artifact-export-cli.md` 与 `docs/maintainer/diagram-artifact-export-cli.zh-CN.md` 记录命令契约,并明确 no Obsidian runtime is required。
### Phase F受约束 circuitikz 原型
- [x] 在 circuit-specific topology/layout 字段证明可复用前,不把电路图塞进 `DiagramSpec`
- [x] 定义最小 `CircuitSpec`,包含 `circuitKind`、命名 nets、typed components、terminal references、layout hints、style 和 `goldenReferenceId`
- [x] 为 `common-source-amplifier``cmos-inverter` 增加 deterministic golden-template export。
- [x] 增加离线 CLI在不依赖 Obsidian、TikZJax、LaTeX 或 browser runtime 的情况下写出 `.tex`
- [x] 增加 topology-rejection tests确保无效电路在创建输出前失败。
- [x] 增加双语 maintainer docs 与 website progress notes。
Phase F 于 2026-07-05 的实现状态:`src/diagram/adapters/circuitikz/circuitSpec.ts` 与 `src/diagram/adapters/circuitikz/circuitikzExporter.ts` 已实现受约束的 `CircuitSpec -> circuitikz` 原型。`scripts/export-circuitikz.js` 与 `npm run diagram:export-circuitikz` 可以为 `common-source-nmos-v1``cmos-inverter-v1` 导出确定性 LaTeX。`src/tests/circuitikzExporter.test.ts` 与 `src/tests/circuitikzExportCli.test.ts` 验证确定性输出、拓扑拒绝、package-script 暴露、UTF-8 BOM 输入处理,以及无效拓扑不会写出 output file。实现仍有意停在 TikZJax/LaTeX compilation、screenshot inspection 和 visual repair loop 之前。
## 权衡
| 决策 | 收益 | 成本 |
@ -196,6 +207,7 @@ Phase E 于 2026-07-05 的实现状态:`scripts/export-diagram-artifact.js`
| HTML/SVG target 先于 Drawnix | 最快获得更丰富 figure 与可编辑导出路径 | 必须自己拥有 SVG layout 与 text fitting |
| Drawnix 仅作为 export target | 避免完整白板 host 复杂度 | 不提供插件内 board editing |
| 保持 `DiagramSpec` 为主边界 | 保留语义质量与可测试性 | 需要编写 target adapters不能盲目复用 converters |
| 为 circuitikz 单独使用 `CircuitSpec` | 让 terminal/topology 规则保持显式且可测试 | 在公共字段证明可复用前需要维护第二个 spec 边界 |
| manual/release visual gate | 避免 diagrams.net/font 差异导致 flaky CI | release 前维护者需要跑更重的检查 |
## 需要避免的坑
@ -206,6 +218,7 @@ Phase E 于 2026-07-05 的实现状态:`scripts/export-diagram-artifact.js`
- 不要在 Obsidian preview 里把 Google Fonts 做成硬运行时依赖。使用 system fallback外部 font loading 保持可选。
- 不要在 packaging isolation 成立前,把重型导出代码放进 `main.js`
- 不要过早扩宽 `DiagramSpec`。先使用内部 `SemanticFigureModel`;只有两个以上 target 都需要同一字段时,再提升为公共字段。
- 不要把 `diagram:export-circuitikz` 当成视觉渲染证明。它证明的是 topology-constrained LaTeX export不是 TikZJax compilation 或 screenshot quality。
## 最佳实践边界
@ -223,12 +236,12 @@ Cloudy 的价值在于证明严格的 HTML/SVG + Draw.io export contract 可以
## 建议的下一步
不要从增加依赖开始。先在 experimental diagram path 后面增加最小可编辑 HTML/SVG renderer并证明
HTML/SVG、Draw.io、Drawnix 和第一个 circuitikz export 边界现在都已经存在。下一步仍然不应默认增加 runtime dependencies
1. 当前 `DiagramSpec` 能表达有用的 architecture / runtime mechanism figure
2. renderer 能生成自包含、非空 HTML artifact
3. 可见文本能正确 fit并保持 searchable / editable
4. Draw.io export coverage 可结构化审计
5. 现有 Mermaid、JSON Canvas、Vega-Lite 与 HTML renderer 不受影响
1. 为两个 circuitikz golden families 增加可选本地 TikZJax/LaTeX smoke verification
2. 将 compile logs 解析为 actionable diagnostics且不要求 TikZJax 成为插件 runtime 硬依赖
3. 增加 screenshot-level 检查nonblank render、expected labels、bounded canvas、obvious overlap
4. repair prompt 中锁定拓扑,避免视觉修复改变电路
5. 只有到这一步之后,再考虑更多 circuit families 或插件侧 circuit preview target
只有这些 claim 被验证后Notemd 才应该考虑 `.drawnix` target。Drawnix 是好的未来导出格式,不是把白板产品嵌入插件的理由。
Drawnix 仍然是好的导出格式,不是把白板产品嵌入插件的理由。circuitikz 仍然是受约束 circuit target不是接受 LLM 任意 TikZ 输出的理由。

View file

@ -38,6 +38,8 @@ Repository docs preview for `obsidian-NotEMD`.
- [Diagram Artifact Export CLI (zh-CN)](./maintainer/diagram-artifact-export-cli.zh-CN.md)
- [circuitikz Figure Generation Roadmap (EN)](./maintainer/circuitikz-figure-generation-roadmap.md)
- [circuitikz Figure Generation Roadmap (zh-CN)](./maintainer/circuitikz-figure-generation-roadmap.zh-CN.md)
- [circuitikz Export Prototype (EN)](./maintainer/circuitikz-export-prototype.md)
- [circuitikz Export Prototype (zh-CN)](./maintainer/circuitikz-export-prototype.zh-CN.md)
- [Draw.io Export Visual Regression (EN)](./maintainer/drawio-export-visual-regression.md)
- [Draw.io Export Visual Regression (zh-CN)](./maintainer/drawio-export-visual-regression.zh-CN.md)
- [Drawnix Export Spike (EN)](./maintainer/drawnix-export-spike.md)

View file

@ -38,6 +38,8 @@
- [Diagram Artifact Export CLI中文](./maintainer/diagram-artifact-export-cli.zh-CN.md)
- [circuitikz Figure Generation Roadmap英文](./maintainer/circuitikz-figure-generation-roadmap.md)
- [circuitikz Figure Generation Roadmap中文](./maintainer/circuitikz-figure-generation-roadmap.zh-CN.md)
- [circuitikz 导出原型(英文)](./maintainer/circuitikz-export-prototype.md)
- [circuitikz 导出原型(中文)](./maintainer/circuitikz-export-prototype.zh-CN.md)
- [draw.io 导出视觉回归 Runbook英文](./maintainer/drawio-export-visual-regression.md)
- [draw.io 导出视觉回归 Runbook中文](./maintainer/drawio-export-visual-regression.zh-CN.md)
- [Drawnix 导出 Spike英文](./maintainer/drawnix-export-spike.md)

View file

@ -0,0 +1,80 @@
---
date: 2026-07-05
topic: circuitikz-export-prototype
---
# circuitikz Export Prototype
Language: **English** | [简体中文](./circuitikz-export-prototype.zh-CN.md)
## Contract
`scripts/export-circuitikz.js` is the offline export boundary for the first constrained circuit-diagram prototype.
It accepts a checked `CircuitSpec` JSON file and writes deterministic `circuitikz` LaTeX without requiring Obsidian, TikZJax, a LaTeX installation, or a browser runtime:
```bash
npm run diagram:export-circuitikz -- --input cmos-inverter.json --output cmos-inverter.tex
```
Direct entrypoint:
```bash
node scripts/export-circuitikz.js --input common-source.json --output common-source.tex
```
Input may be UTF-8 with or without a BOM, so JSON written from Windows PowerShell remains usable.
## Supported Circuit Families
This is not a generic TikZ generator. The current prototype supports only golden-reference families whose topology and layout can be validated before export:
| `circuitKind` | `goldenReferenceId` | Template |
|---|---|---|
| `common-source-amplifier` | `common-source-nmos-v1` | NMOS common-source amplifier with `R_D`, `VDD`, `vin`, `vout`, and grounded source |
| `cmos-inverter` | `cmos-inverter-v1` | PMOS-over-NMOS CMOS inverter with shared gate input and shared drain output |
The adapter validates the structural invariant first, then emits a fixed layout. For example, the CMOS inverter requires:
- `MP` as `pmos` and `MN` as `nmos`;
- `VDD -> MP.S`;
- `MN.S -> GND`;
- `vin -> MP.G` and `vin -> MN.G`;
- `MP.D` and `MN.D` on the shared output drain path;
- both transistor drains connected to `vout`.
## Why `CircuitSpec` Is Separate
`DiagramSpec` is intentionally not widened for circuit diagrams yet. Circuit diagrams need topology, terminal references, layout lanes, and package conventions that generic flowcharts and data charts do not use.
The boundary is:
```text
CircuitSpec
-> topology/template validation
-> deterministic golden-reference circuitikz adapter
-> .tex artifact
```
This keeps the model-facing contract narrow and makes topology drift testable. The exporter rejects invalid topology before writing an output file.
## Verification
Canonical regression commands:
```bash
npm test -- --runInBand src/tests/circuitikzExporter.test.ts src/tests/circuitikzExportCli.test.ts --runTestsByPath
```
The tests verify:
- deterministic common-source LaTeX;
- constrained CMOS inverter output;
- topology rejection before export;
- CLI exposure through `package.json`;
- UTF-8 BOM input handling;
- no output file is written for invalid topology.
## Non-Goals
This prototype does not compile LaTeX, call TikZJax, run screenshot inspection, or use rendered-image feedback. Those are later gates. It also does not accept arbitrary natural-language circuit requests. The important current claim is narrower: validated `CircuitSpec` input can produce stable, readable circuitikz for two high-value golden families.

View file

@ -0,0 +1,80 @@
---
date: 2026-07-05
topic: circuitikz-export-prototype
---
# circuitikz 导出原型
语言: [English](./circuitikz-export-prototype.md) | **简体中文**
## 契约
`scripts/export-circuitikz.js` 是第一个强约束电路图原型的离线导出边界。
它接收经过校验的 `CircuitSpec` JSON 文件,并写出确定性的 `circuitikz` LaTeX不需要 Obsidian、TikZJax、LaTeX 安装或浏览器 runtime
```bash
npm run diagram:export-circuitikz -- --input cmos-inverter.json --output cmos-inverter.tex
```
直接入口:
```bash
node scripts/export-circuitikz.js --input common-source.json --output common-source.tex
```
输入可以是带 BOM 或不带 BOM 的 UTF-8因此 Windows PowerShell 写出的 JSON 不需要额外归一化。
## 已支持的电路族
这不是通用 TikZ 生成器。当前原型只支持能够在导出前验证拓扑与布局约束的 golden-reference families
| `circuitKind` | `goldenReferenceId` | Template |
|---|---|---|
| `common-source-amplifier` | `common-source-nmos-v1` | 带 `R_D`、`VDD`、`vin`、`vout` 和源极接地的 NMOS 共源放大器 |
| `cmos-inverter` | `cmos-inverter-v1` | PMOS 在上、NMOS 在下,共用 gate 输入、共用 drain 输出的 CMOS 反相器 |
adapter 会先验证结构 invariant再输出固定布局。例如 CMOS 反相器要求:
- `MP``pmos``MN` 是 `nmos`
- `VDD -> MP.S`
- `MN.S -> GND`
- `vin -> MP.G``vin -> MN.G`
- `MP.D``MN.D` 位于共享输出 drain path
- 两个晶体管 drain 都连接到 `vout`
## 为什么 `CircuitSpec` 独立存在
当前不会为了电路图过早扩展 `DiagramSpec`。电路图需要拓扑、terminal references、layout lanes 和 package conventions这些字段并不属于普通 flowchart 或 data chart。
当前边界是:
```text
CircuitSpec
-> topology/template validation
-> deterministic golden-reference circuitikz adapter
-> .tex artifact
```
这能让 model-facing contract 保持窄边界并让拓扑漂移可测试。exporter 会在写出文件前拒绝无效拓扑。
## 验证
标准回归命令:
```bash
npm test -- --runInBand src/tests/circuitikzExporter.test.ts src/tests/circuitikzExportCli.test.ts --runTestsByPath
```
测试覆盖:
- 确定性的 common-source LaTeX
- 强约束 CMOS inverter 输出;
- 导出前拒绝拓扑错误;
- `package.json` 中的 CLI 暴露;
- UTF-8 BOM 输入处理;
- 无效拓扑不会写出 output file。
## 非目标
这个原型不编译 LaTeX、不调用 TikZJax、不做截图检查也不使用渲染图像反馈。这些是后续 gate。它也不接受任意自然语言电路请求。当前重要声明更窄经过验证的 `CircuitSpec` 输入可以为两个高价值 golden families 生成稳定、可读的 circuitikz。

View file

@ -135,6 +135,17 @@ Screenshot feedback can be manual first. Automated screenshot checks should star
| 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 |
## Implementation Status
Phase A is documented. Phase B/C now have a constrained repository prototype:
- `src/diagram/adapters/circuitikz/circuitSpec.ts` defines the separate circuit-only spec boundary.
- `src/diagram/adapters/circuitikz/circuitikzExporter.ts` validates topology and emits deterministic `circuitikz` LaTeX for `common-source-amplifier` and `cmos-inverter`.
- `scripts/export-circuitikz.js` and `npm run diagram:export-circuitikz` provide the offline export command.
- `src/tests/circuitikzExporter.test.ts` and `src/tests/circuitikzExportCli.test.ts` verify deterministic output, topology rejection, package-script exposure, and UTF-8 BOM handling.
The implementation still deliberately stops before Phase D/E. It does not compile LaTeX, call TikZJax, inspect screenshots, or run a visual repair loop.
## Best Current Practice
Until `CircuitSpec` exists, use constrained prompts:

View file

@ -135,6 +135,17 @@ screenshot feedback 可以先保持人工执行。自动 screenshot 检查应从
| D. render feedback | 增加可选的本地 TikZJax/LaTeX smoke path | compile failures 返回 actionable diagnostics |
| E. visual repair loop | 将 rendered image 或 overlap report 反馈给 repair prompt | 不改变拓扑的前提下修正重复 layout errors |
## 实现状态
Phase A 已经文档化。Phase B/C 现在有了受约束的仓库内原型:
- `src/diagram/adapters/circuitikz/circuitSpec.ts` 定义独立的 circuit-only spec 边界。
- `src/diagram/adapters/circuitikz/circuitikzExporter.ts` 会验证拓扑,并为 `common-source-amplifier``cmos-inverter` 输出确定性的 `circuitikz` LaTeX。
- `scripts/export-circuitikz.js``npm run diagram:export-circuitikz` 提供离线导出命令。
- `src/tests/circuitikzExporter.test.ts``src/tests/circuitikzExportCli.test.ts` 验证确定性输出、拓扑拒绝、package-script 暴露和 UTF-8 BOM 处理。
实现仍有意停在 Phase D/E 之前。它不会编译 LaTeX、调用 TikZJax、检查截图或运行视觉修复闭环。
## Best Current Practice
`CircuitSpec` 尚未存在前,使用强约束 prompt

View file

@ -16,6 +16,7 @@
"cli:help": "node scripts/invoke-maintainer-cli-operation.js --help",
"cli:invoke": "node scripts/invoke-maintainer-cli-operation.js",
"diagram:export-artifact": "node scripts/export-diagram-artifact.js",
"diagram:export-circuitikz": "node scripts/export-circuitikz.js",
"chronicle:sync-repo-saga": "node scripts/repo-saga/update-quarterly-saga.mjs --sync-only",
"chronicle:update": "node scripts/repo-saga/update-quarterly-saga.mjs",
"audit:i18n-ui": "node scripts/audit-hardcoded-ui-strings.js",

View file

@ -0,0 +1,148 @@
#!/usr/bin/env node
const fs = require('fs');
const os = require('os');
const path = require('path');
const { buildSync } = require('esbuild');
function printUsage() {
console.log(`Notemd circuitikz export
Usage:
node scripts/export-circuitikz.js --input <circuit-spec.json> --output <circuit.tex>
Example:
node scripts/export-circuitikz.js --input cmos-inverter.json --output cmos-inverter.tex
`);
}
function parseArgs(argv) {
const args = {};
for (let index = 0; index < argv.length; index += 1) {
const token = argv[index];
switch (token) {
case '--input':
args.input = argv[++index];
break;
case '--output':
args.output = argv[++index];
break;
case '--help':
case '-h':
args.help = true;
break;
default:
throw new Error(`Unknown argument: ${token}`);
}
}
return args;
}
function assertRequiredArgs(args) {
if (!args.input) {
throw new Error('Missing required --input.');
}
if (!args.output) {
throw new Error('Missing required --output.');
}
}
function loadCircuitSpec(inputPath) {
const source = fs.readFileSync(inputPath, 'utf8').replace(/^\uFEFF/, '');
const spec = JSON.parse(source);
if (!spec || typeof spec !== 'object' || Array.isArray(spec)) {
throw new Error('CircuitSpec JSON must be an object.');
}
return spec;
}
function ensureOutputDirectory(outputPath) {
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
}
function buildExporterBundle(repoRoot) {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'notemd-circuitikz-exporter-'));
const outfile = path.join(tempRoot, 'circuitikz-exporter.cjs');
const entrySource = `
import { exportCircuitSpecToCircuitikz } from './src/diagram/adapters/circuitikz/circuitikzExporter';
export function exportCircuitikz(spec) {
return exportCircuitSpecToCircuitikz(spec);
}
`;
buildSync({
stdin: {
contents: entrySource,
resolveDir: repoRoot,
loader: 'ts',
sourcefile: 'circuitikz-exporter.ts'
},
bundle: true,
platform: 'node',
format: 'cjs',
outfile,
logLevel: 'silent'
});
return {
outfile,
cleanup() {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
};
}
async function run(args, repoRoot = path.resolve(__dirname, '..')) {
assertRequiredArgs(args);
const inputPath = path.resolve(args.input);
const outputPath = path.resolve(args.output);
const spec = loadCircuitSpec(inputPath);
const bundle = buildExporterBundle(repoRoot);
try {
const { exportCircuitikz } = require(bundle.outfile);
const content = exportCircuitikz(spec);
ensureOutputDirectory(outputPath);
fs.writeFileSync(outputPath, content, 'utf8');
return {
circuitKind: spec.circuitKind,
goldenReferenceId: spec.goldenReferenceId,
inputPath,
outputPath,
componentCount: Array.isArray(spec.components) ? spec.components.length : 0,
connectionCount: Array.isArray(spec.connections) ? spec.connections.length : 0
};
} finally {
bundle.cleanup();
}
}
async function main() {
try {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
printUsage();
return;
}
const result = await run(args);
process.stdout.write(`${JSON.stringify(result)}\n`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`${message}\n`);
process.exit(1);
}
}
if (require.main === module) {
main();
}
module.exports = {
parseArgs,
run
};

View file

@ -0,0 +1,61 @@
export const SUPPORTED_CIRCUIT_KINDS = [
'common-source-amplifier',
'cmos-inverter'
] as const;
export type CircuitKind = typeof SUPPORTED_CIRCUIT_KINDS[number];
export const SUPPORTED_CIRCUIT_COMPONENT_TYPES = [
'nmos',
'pmos',
'resistor'
] as const;
export type CircuitComponentType = typeof SUPPORTED_CIRCUIT_COMPONENT_TYPES[number];
export type CircuitVoltageConvention = 'american voltages' | 'european voltages';
export interface CircuitComponent {
id: string;
type: CircuitComponentType;
label?: string;
terminals: Record<string, string>;
}
export interface CircuitConnection {
from: string;
to: string;
label?: string;
}
export interface CircuitStyle {
package: 'circuitikz';
voltageConvention: CircuitVoltageConvention;
}
export interface CircuitLayoutHints {
inputSide?: 'left' | 'right';
outputSide?: 'left' | 'right';
routingStyle?: 'orthogonal';
}
export interface CircuitSpec {
circuitKind: CircuitKind;
title: string;
goldenReferenceId: string;
style: CircuitStyle;
nets: string[];
components: CircuitComponent[];
connections: CircuitConnection[];
layoutHints?: CircuitLayoutHints;
}
export function isSupportedCircuitKind(value: unknown): value is CircuitKind {
return typeof value === 'string'
&& (SUPPORTED_CIRCUIT_KINDS as readonly string[]).includes(value);
}
export function isSupportedCircuitComponentType(value: unknown): value is CircuitComponentType {
return typeof value === 'string'
&& (SUPPORTED_CIRCUIT_COMPONENT_TYPES as readonly string[]).includes(value);
}

View file

@ -0,0 +1,289 @@
import { ValidationError } from '../../../types';
import {
CircuitComponent,
CircuitConnection,
CircuitSpec,
isSupportedCircuitComponentType,
isSupportedCircuitKind,
SUPPORTED_CIRCUIT_KINDS
} from './circuitSpec';
export interface CircuitSpecValidationResult {
valid: boolean;
errors: string[];
}
function normalizeLabel(value: string | undefined, fallback: string): string {
const trimmed = value?.trim();
return trimmed || fallback;
}
function collectComponentIds(components: CircuitComponent[], errors: string[]): Map<string, CircuitComponent> {
const componentById = new Map<string, CircuitComponent>();
for (const component of components) {
const id = component.id?.trim();
if (!id) {
errors.push('Circuit component is missing an id.');
continue;
}
if (componentById.has(id)) {
errors.push(`Circuit component id "${id}" is duplicated.`);
continue;
}
if (!isSupportedCircuitComponentType(component.type)) {
errors.push(`Circuit component "${id}" uses unsupported type "${String(component.type)}".`);
}
if (!component.terminals || Object.keys(component.terminals).length === 0) {
errors.push(`Circuit component "${id}" is missing terminals.`);
}
componentById.set(id, component);
}
return componentById;
}
function parseTerminalReference(reference: string): { componentId: string; terminal: string } | null {
const trimmed = reference.trim();
const separatorIndex = trimmed.indexOf('.');
if (separatorIndex <= 0 || separatorIndex === trimmed.length - 1) {
return null;
}
return {
componentId: trimmed.slice(0, separatorIndex),
terminal: trimmed.slice(separatorIndex + 1)
};
}
function validateReference(
reference: string,
componentById: Map<string, CircuitComponent>,
nets: Set<string>,
errors: string[]
): void {
const terminalReference = parseTerminalReference(reference);
if (!terminalReference) {
if (!nets.has(reference.trim())) {
errors.push(`Circuit connection references unknown net "${reference}".`);
}
return;
}
const component = componentById.get(terminalReference.componentId);
if (!component) {
errors.push(`Circuit connection references unknown component "${terminalReference.componentId}".`);
return;
}
if (!Object.prototype.hasOwnProperty.call(component.terminals, terminalReference.terminal)) {
errors.push(
`Circuit connection references missing terminal "${terminalReference.terminal}" `
+ `on component "${terminalReference.componentId}".`
);
}
}
function hasConnection(connections: CircuitConnection[], first: string, second: string): boolean {
return connections.some(connection =>
(connection.from === first && connection.to === second)
|| (connection.from === second && connection.to === first)
);
}
function requireComponent(
componentById: Map<string, CircuitComponent>,
id: string,
type: CircuitComponent['type'],
errors: string[]
): void {
const component = componentById.get(id);
if (!component) {
errors.push(`Circuit kind requires component "${id}".`);
return;
}
if (component.type !== type) {
errors.push(`Circuit component "${id}" must use type "${type}".`);
}
}
function validateCommonSourceTemplate(
spec: CircuitSpec,
componentById: Map<string, CircuitComponent>,
errors: string[]
): void {
if (spec.goldenReferenceId !== 'common-source-nmos-v1') {
errors.push('Common-source amplifier requires goldenReferenceId "common-source-nmos-v1".');
}
requireComponent(componentById, 'M1', 'nmos', errors);
requireComponent(componentById, 'RD', 'resistor', errors);
if (!hasConnection(spec.connections, 'vin', 'M1.G')) {
errors.push('Common-source amplifier requires vin to connect to M1.G.');
}
if (!hasConnection(spec.connections, 'M1.D', 'vout')) {
errors.push('Common-source amplifier requires M1.D to connect to vout.');
}
if (!hasConnection(spec.connections, 'M1.S', 'GND')) {
errors.push('Common-source amplifier requires M1.S to connect to GND.');
}
if (!hasConnection(spec.connections, 'VDD', 'RD.top') || !hasConnection(spec.connections, 'RD.bottom', 'M1.D')) {
errors.push('Common-source amplifier requires VDD -> RD -> M1.D drain path.');
}
}
function validateCmosInverterTemplate(
spec: CircuitSpec,
componentById: Map<string, CircuitComponent>,
errors: string[]
): void {
if (spec.goldenReferenceId !== 'cmos-inverter-v1') {
errors.push('CMOS inverter requires goldenReferenceId "cmos-inverter-v1".');
}
requireComponent(componentById, 'MP', 'pmos', errors);
requireComponent(componentById, 'MN', 'nmos', errors);
if (!hasConnection(spec.connections, 'MP.D', 'MN.D')) {
errors.push('CMOS inverter requires MP.D and MN.D to share the output drain path.');
}
if (!hasConnection(spec.connections, 'vin', 'MP.G') || !hasConnection(spec.connections, 'vin', 'MN.G')) {
errors.push('CMOS inverter requires vin to drive both MP.G and MN.G.');
}
if (!hasConnection(spec.connections, 'VDD', 'MP.S')) {
errors.push('CMOS inverter requires VDD to connect to MP.S.');
}
if (!hasConnection(spec.connections, 'MN.S', 'GND')) {
errors.push('CMOS inverter requires MN.S to connect to GND.');
}
if (!hasConnection(spec.connections, 'MP.D', 'vout') || !hasConnection(spec.connections, 'MN.D', 'vout')) {
errors.push('CMOS inverter requires both transistor drains to connect to vout.');
}
}
export function validateCircuitSpec(spec: CircuitSpec): CircuitSpecValidationResult {
const errors: string[] = [];
if (!isSupportedCircuitKind(spec.circuitKind)) {
errors.push(
`Circuit spec uses unsupported circuitKind "${String(spec.circuitKind)}". `
+ `Supported kinds: ${SUPPORTED_CIRCUIT_KINDS.join(', ')}.`
);
}
if (!spec.title?.trim()) {
errors.push('Circuit spec title is required.');
}
if (spec.style?.package !== 'circuitikz') {
errors.push('Circuit spec style.package must be "circuitikz".');
}
if (spec.style?.voltageConvention !== 'american voltages' && spec.style?.voltageConvention !== 'european voltages') {
errors.push('Circuit spec style.voltageConvention must be "american voltages" or "european voltages".');
}
if (spec.layoutHints?.routingStyle && spec.layoutHints.routingStyle !== 'orthogonal') {
errors.push('CircuitSpec circuitikz prototype only supports orthogonal routing.');
}
const nets = new Set((spec.nets ?? []).map(net => net.trim()).filter(Boolean));
for (const requiredNet of ['VDD', 'GND', 'vin', 'vout']) {
if (!nets.has(requiredNet)) {
errors.push(`Circuit spec requires net "${requiredNet}".`);
}
}
const componentById = collectComponentIds(spec.components ?? [], errors);
for (const connection of spec.connections ?? []) {
validateReference(connection.from, componentById, nets, errors);
validateReference(connection.to, componentById, nets, errors);
}
if (spec.circuitKind === 'common-source-amplifier') {
validateCommonSourceTemplate(spec, componentById, errors);
} else if (spec.circuitKind === 'cmos-inverter') {
validateCmosInverterTemplate(spec, componentById, errors);
}
return {
valid: errors.length === 0,
errors
};
}
export function assertValidCircuitSpec(spec: CircuitSpec): CircuitSpec {
const result = validateCircuitSpec(spec);
if (!result.valid) {
throw new ValidationError(result.errors.join(' '), 'INVALID_INPUT');
}
return spec;
}
function renderCommonSourceTemplate(spec: CircuitSpec): string {
const m1 = spec.components.find(component => component.id === 'M1');
const rd = spec.components.find(component => component.id === 'RD');
const m1Label = normalizeLabel(m1?.label, '$M_1$');
const rdLabel = normalizeLabel(rd?.label, '$R_D$');
return `\\usepackage{circuitikz}
\\begin{document}
\\begin{circuitikz}[${spec.style.voltageConvention}]
\\draw
(3,5) node[vcc]{$V_{DD}$}
to [R, l=${rdLabel}] (3,3)
to [short, *-o] (5,3) node[right]{$v_{out}$}
(3,3) to [short] (3,2.2)
node[nmos, anchor=D] (M1) {${m1Label}}
(M1.S) to [short] (3,0.5)
node[ground]{}
(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}
`;
}
function renderCmosInverterTemplate(spec: CircuitSpec): string {
const mp = spec.components.find(component => component.id === 'MP');
const mn = spec.components.find(component => component.id === 'MN');
const mpLabel = normalizeLabel(mp?.label, '$M_P$');
const mnLabel = normalizeLabel(mn?.label, '$M_N$');
return `\\usepackage{circuitikz}
\\begin{document}
\\begin{circuitikz}[${spec.style.voltageConvention}]
\\draw
(3,5) node[vcc]{$V_{DD}$}
to [short] (3,4.4)
node[pmos, anchor=S] (MP) {${mpLabel}}
(MP.D) to [short] (3,2.75)
to [short] (3,2.6)
node[nmos, anchor=D] (MN) {${mnLabel}}
(MN.S) to [short] (3,0.8)
node[ground]{};
\\draw
(MP.G) to [short] (1.5,3.5)
(MN.G) to [short] (1.5,2.0)
(1.5,3.5) to [short] (1.5,2.0)
to [short, -o] (0.8,2.75)
node[left]{$v_{in}$};
\\draw
(3,2.75) to [short, *-o] (5,2.75) node[right]{$v_{out}$};
\\end{circuitikz}
\\end{document}
`;
}
export function exportCircuitSpecToCircuitikz(spec: CircuitSpec): string {
assertValidCircuitSpec(spec);
switch (spec.circuitKind) {
case 'common-source-amplifier':
return renderCommonSourceTemplate(spec);
case 'cmos-inverter':
return renderCmosInverterTemplate(spec);
default:
throw new ValidationError('Unsupported circuitKind.', 'INVALID_INPUT');
}
}

View file

@ -0,0 +1,146 @@
import { execFileSync, spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CircuitSpec } from '../diagram/adapters/circuitikz/circuitSpec';
function createSpec(): CircuitSpec {
return {
circuitKind: 'cmos-inverter',
title: 'CMOS Inverter',
goldenReferenceId: 'cmos-inverter-v1',
style: {
package: 'circuitikz',
voltageConvention: 'american voltages'
},
nets: ['VDD', 'GND', 'vin', 'vout', 'shared_gate', 'shared_drain'],
components: [
{ id: 'MP', type: 'pmos', label: '$M_P$', terminals: { S: 'VDD', G: 'shared_gate', D: 'shared_drain' } },
{ id: 'MN', type: 'nmos', label: '$M_N$', terminals: { D: 'shared_drain', G: 'shared_gate', S: 'GND' } }
],
connections: [
{ from: 'VDD', to: 'MP.S' },
{ from: 'MP.D', to: 'MN.D' },
{ from: 'MN.S', to: 'GND' },
{ from: 'vin', to: 'MP.G' },
{ from: 'vin', to: 'MN.G' },
{ from: 'MP.D', to: 'vout' },
{ from: 'MN.D', to: 'vout' }
],
layoutHints: {
inputSide: 'left',
outputSide: 'right',
routingStyle: 'orthogonal'
}
};
}
describe('circuitikz export CLI', () => {
const repoRoot = path.join(__dirname, '..', '..');
const packageJsonPath = path.join(repoRoot, 'package.json');
const scriptPath = path.join(repoRoot, 'scripts', 'export-circuitikz.js');
test('is exposed through package scripts', () => {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
expect(packageJson.scripts['diagram:export-circuitikz']).toBe('node scripts/export-circuitikz.js');
});
test('exports circuitikz LaTeX from a BOM-prefixed CircuitSpec file', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'notemd-circuitikz-cli-'));
const specPath = path.join(tempRoot, 'circuit.json');
const outputPath = path.join(tempRoot, 'circuit.tex');
fs.writeFileSync(specPath, `\uFEFF${JSON.stringify(createSpec(), null, 2)}`, 'utf8');
try {
const stdout = execFileSync(
process.execPath,
[
scriptPath,
'--input', specPath,
'--output', outputPath
],
{
cwd: repoRoot,
encoding: 'utf8'
}
);
const result = JSON.parse(stdout);
expect(result).toEqual(expect.objectContaining({
circuitKind: 'cmos-inverter',
goldenReferenceId: 'cmos-inverter-v1',
outputPath,
componentCount: 2,
connectionCount: 7
}));
const tex = fs.readFileSync(outputPath, 'utf8');
expect(tex).toContain('\\usepackage{circuitikz}');
expect(tex).toContain('node[pmos, anchor=S] (MP) {$M_P$}');
expect(tex).toContain('node[nmos, anchor=D] (MN) {$M_N$}');
expect(tex).toContain('node[right]{$v_{out}$};');
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
}, 30000);
test('fails before writing output for invalid topology', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'notemd-circuitikz-cli-invalid-'));
const specPath = path.join(tempRoot, 'circuit.json');
const outputPath = path.join(tempRoot, 'circuit.tex');
const spec = createSpec();
spec.connections = spec.connections.filter(connection => connection.from !== 'MP.D' || connection.to !== 'MN.D');
fs.writeFileSync(specPath, JSON.stringify(spec, null, 2), 'utf8');
try {
const result = spawnSync(
process.execPath,
[
scriptPath,
'--input', specPath,
'--output', outputPath
],
{
cwd: repoRoot,
encoding: 'utf8'
}
);
expect(result.status).toBe(1);
expect(result.stdout).toBe('');
expect(result.stderr).toContain('CMOS inverter requires MP.D and MN.D');
expect(fs.existsSync(outputPath)).toBe(false);
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
test('fails before writing output when the input JSON is not an object', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'notemd-circuitikz-cli-malformed-'));
const specPath = path.join(tempRoot, 'circuit.json');
const outputPath = path.join(tempRoot, 'circuit.tex');
fs.writeFileSync(specPath, 'null', 'utf8');
try {
const result = spawnSync(
process.execPath,
[
scriptPath,
'--input', specPath,
'--output', outputPath
],
{
cwd: repoRoot,
encoding: 'utf8'
}
);
expect(result.status).toBe(1);
expect(result.stdout).toBe('');
expect(result.stderr).toContain('CircuitSpec JSON must be an object.');
expect(fs.existsSync(outputPath)).toBe(false);
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
});

View file

@ -0,0 +1,54 @@
import * as fs from 'fs';
import * as path from 'path';
describe('circuitikz export docs contract', () => {
const repoRoot = path.join(__dirname, '..', '..');
const englishDoc = fs.readFileSync(
path.join(repoRoot, 'docs', 'maintainer', 'circuitikz-export-prototype.md'),
'utf8'
);
const chineseDoc = fs.readFileSync(
path.join(repoRoot, 'docs', 'maintainer', 'circuitikz-export-prototype.zh-CN.md'),
'utf8'
);
const progressDoc = fs.readFileSync(
path.join(repoRoot, 'docs', 'brainstorms', '2026-07-04-diagram-reference-integration-and-figure-generation-plan.md'),
'utf8'
);
const progressDocZh = fs.readFileSync(
path.join(repoRoot, 'docs', 'brainstorms', '2026-07-04-diagram-reference-integration-and-figure-generation-plan.zh-CN.md'),
'utf8'
);
test('documents the CLI contract, supported families, and explicit non-goals in both languages', () => {
for (const doc of [englishDoc, chineseDoc]) {
expect(doc).toContain('scripts/export-circuitikz.js');
expect(doc).toContain('npm run diagram:export-circuitikz');
expect(doc).toContain('CircuitSpec');
expect(doc).toContain('common-source-amplifier');
expect(doc).toContain('common-source-nmos-v1');
expect(doc).toContain('cmos-inverter');
expect(doc).toContain('cmos-inverter-v1');
expect(doc).toContain('UTF-8');
expect(doc).toContain('BOM');
expect(doc).toContain('src/tests/circuitikzExporter.test.ts');
expect(doc).toContain('src/tests/circuitikzExportCli.test.ts');
}
expect(englishDoc).toContain('This is not a generic TikZ generator.');
expect(chineseDoc).toContain('这不是通用 TikZ 生成器。');
});
test('records Phase F implementation status in the bilingual progress plan', () => {
for (const doc of [progressDoc, progressDocZh]) {
expect(doc).toContain('Phase F');
expect(doc).toContain('CircuitSpec -> circuitikz');
expect(doc).toContain('scripts/export-circuitikz.js');
expect(doc).toContain('npm run diagram:export-circuitikz');
expect(doc).toContain('src/tests/circuitikzExporter.test.ts');
expect(doc).toContain('src/tests/circuitikzExportCli.test.ts');
expect(doc).toContain('TikZJax/LaTeX');
expect(doc).toContain('screenshot');
}
});
});

View file

@ -0,0 +1,127 @@
import {
assertValidCircuitSpec,
exportCircuitSpecToCircuitikz,
validateCircuitSpec
} from '../diagram/adapters/circuitikz/circuitikzExporter';
import { CircuitSpec } from '../diagram/adapters/circuitikz/circuitSpec';
function createCommonSourceSpec(): CircuitSpec {
return {
circuitKind: 'common-source-amplifier',
title: 'Common Source Amplifier',
goldenReferenceId: 'common-source-nmos-v1',
style: {
package: 'circuitikz',
voltageConvention: 'american voltages'
},
nets: ['VDD', 'GND', 'vin', 'vout', 'drain', 'source'],
components: [
{ id: 'M1', type: 'nmos', label: '$M_1$', terminals: { D: 'drain', G: 'vin', S: 'source' } },
{ id: 'RD', type: 'resistor', label: '$R_D$', terminals: { top: 'VDD', bottom: 'drain' } }
],
connections: [
{ from: 'VDD', to: 'RD.top' },
{ from: 'RD.bottom', to: 'M1.D' },
{ from: 'M1.D', to: 'vout' },
{ from: 'M1.S', to: 'GND' },
{ from: 'vin', to: 'M1.G' }
],
layoutHints: {
inputSide: 'left',
outputSide: 'right',
routingStyle: 'orthogonal'
}
};
}
function createCmosInverterSpec(): CircuitSpec {
return {
circuitKind: 'cmos-inverter',
title: 'CMOS Inverter',
goldenReferenceId: 'cmos-inverter-v1',
style: {
package: 'circuitikz',
voltageConvention: 'american voltages'
},
nets: ['VDD', 'GND', 'vin', 'vout', 'p_source', 'n_source', 'shared_gate', 'shared_drain'],
components: [
{ id: 'MP', type: 'pmos', label: '$M_P$', terminals: { S: 'VDD', G: 'shared_gate', D: 'shared_drain' } },
{ id: 'MN', type: 'nmos', label: '$M_N$', terminals: { D: 'shared_drain', G: 'shared_gate', S: 'GND' } }
],
connections: [
{ from: 'VDD', to: 'MP.S' },
{ from: 'MP.D', to: 'MN.D' },
{ from: 'MN.S', to: 'GND' },
{ from: 'vin', to: 'MP.G' },
{ from: 'vin', to: 'MN.G' },
{ from: 'MP.D', to: 'vout' },
{ from: 'MN.D', to: 'vout' }
],
layoutHints: {
inputSide: 'left',
outputSide: 'right',
routingStyle: 'orthogonal'
}
};
}
describe('circuitikz exporter', () => {
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}
\\begin{document}
\\begin{circuitikz}[american voltages]
\\draw
(3,5) node[vcc]{$V_{DD}$}
to [R, l=$R_D$] (3,3)
to [short, *-o] (5,3) node[right]{$v_{out}$}
(3,3) to [short] (3,2.2)
node[nmos, anchor=D] (M1) {$M_1$}
(M1.S) to [short] (3,0.5)
node[ground]{}
(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}
`);
});
test('validates and exports a constrained CMOS inverter template deterministically', () => {
const spec = createCmosInverterSpec();
const output = exportCircuitSpecToCircuitikz(spec);
expect(assertValidCircuitSpec(spec)).toBe(spec);
expect(output).toContain('\\begin{circuitikz}[american voltages]');
expect(output).toContain('(3,5) node[vcc]{$V_{DD}$}');
expect(output).toContain('node[pmos, anchor=S] (MP) {$M_P$}');
expect(output).toContain('node[nmos, anchor=D] (MN) {$M_N$}');
expect(output).toContain('(MP.G) to [short] (1.5,3.5)');
expect(output).toContain('(MN.G) to [short] (1.5,2.0)');
expect(output).toContain('(3,2.75) to [short, *-o] (5,2.75) node[right]{$v_{out}$};');
expect(output).toContain('node[left]{$v_{in}$};');
expect(output).toContain('node[ground]{};');
});
test('rejects CMOS inverter specs that do not lock shared drain topology', () => {
const spec = createCmosInverterSpec();
spec.connections = spec.connections.filter(connection => connection.from !== 'MP.D' || connection.to !== 'MN.D');
expect(validateCircuitSpec(spec).errors).toContain(
'CMOS inverter requires MP.D and MN.D to share the output drain path.'
);
expect(() => exportCircuitSpecToCircuitikz(spec)).toThrow(/MP\.D and MN\.D/);
});
test('rejects common-source specs that do not connect the input to the NMOS gate', () => {
const spec = createCommonSourceSpec();
spec.connections = spec.connections.filter(connection => connection.to !== 'M1.G');
expect(validateCircuitSpec(spec).errors).toContain(
'Common-source amplifier requires vin to connect to M1.G.'
);
});
});

View file

@ -120,20 +120,38 @@ describe('docs hub contract', () => {
expect(docsHub).toContain(
'[circuitikz Figure Generation Roadmap](./maintainer/circuitikz-figure-generation-roadmap.md)'
);
expect(docsHub).toContain(
'[circuitikz Export Prototype](./maintainer/circuitikz-export-prototype.md)'
);
expect(docsHubZh).toContain(
'[circuitikz Figure Generation Roadmap](./maintainer/circuitikz-figure-generation-roadmap.zh-CN.md)'
);
expect(docsHubZh).toContain(
'[circuitikz 导出原型](./maintainer/circuitikz-export-prototype.zh-CN.md)'
);
expect(docsIndex).toContain(
'[circuitikz Figure Generation Roadmap (EN)](./maintainer/circuitikz-figure-generation-roadmap.md)'
);
expect(docsIndex).toContain(
'[circuitikz Figure Generation Roadmap (zh-CN)](./maintainer/circuitikz-figure-generation-roadmap.zh-CN.md)'
);
expect(docsIndex).toContain(
'[circuitikz Export Prototype (EN)](./maintainer/circuitikz-export-prototype.md)'
);
expect(docsIndex).toContain(
'[circuitikz Export Prototype (zh-CN)](./maintainer/circuitikz-export-prototype.zh-CN.md)'
);
expect(docsIndexZh).toContain(
'[circuitikz Figure Generation Roadmap英文](./maintainer/circuitikz-figure-generation-roadmap.md)'
);
expect(docsIndexZh).toContain(
'[circuitikz Figure Generation Roadmap中文](./maintainer/circuitikz-figure-generation-roadmap.zh-CN.md)'
);
expect(docsIndexZh).toContain(
'[circuitikz 导出原型(英文)](./maintainer/circuitikz-export-prototype.md)'
);
expect(docsIndexZh).toContain(
'[circuitikz 导出原型(中文)](./maintainer/circuitikz-export-prototype.zh-CN.md)'
);
});
});

View file

@ -42,20 +42,25 @@ describe('website documentation contract', () => {
}
});
test('diagram pages document circuitikz as a constrained future target in both languages', () => {
test('diagram pages document circuitikz as a constrained prototype in both languages', () => {
expect(markdownHeadings(chineseDiagrams)).toHaveLength(markdownHeadings(englishDiagrams).length);
expect(englishDiagrams).toContain('Current circuitikz Prototype');
expect(chineseDiagrams).toContain('当前 circuitikz 原型');
for (const content of [englishDiagrams, chineseDiagrams]) {
expect(content).toContain('circuitikz / TikZJax Direction');
expect(content).toContain('Golden Reference Prompt Shape');
expect(content).toContain('Current Progress And Next Phases');
expect(content).toContain('CircuitSpec');
expect(content).toContain('common-source-amplifier');
expect(content).toContain('cmos-inverter-v1');
expect(content).toContain('Golden Reference Template');
expect(content).toContain('TikZJax Render');
expect(content).toContain('\\usepackage{circuitikz}');
expect(content).toContain('pmos');
expect(content).toContain('nmos');
expect(content).toContain('npm run diagram:export-artifact');
expect(content).toContain('npm run diagram:export-circuitikz');
}
});

View file

@ -203,6 +203,23 @@ The first-class model should describe circuit topology and layout separately:
| Style | package, voltage convention, labels, anchors | `\begin{circuitikz}[american voltages]` |
| Validation | compile log, missing anchors, overlap/screenshot checks | TikZJax/LaTeX diagnostics plus visual review |
### Current circuitikz Prototype
Notemd now includes the first constrained repository prototype for this direction. It is intentionally offline and template-bound:
```bash
npm run diagram:export-circuitikz -- --input cmos-inverter.json --output cmos-inverter.tex
```
The prototype adds a separate `CircuitSpec` boundary and deterministic exporter for two golden-reference families:
| Circuit kind | Golden reference | Current guarantee |
|--------------|------------------|-------------------|
| `common-source-amplifier` | `common-source-nmos-v1` | validates `VDD -> R_D -> M1.D`, `vin -> M1.G`, `M1.S -> GND`, and `M1.D -> vout` before writing LaTeX |
| `cmos-inverter` | `cmos-inverter-v1` | validates PMOS-over-NMOS topology, shared gate input, shared drain output, `VDD -> MP.S`, and `MN.S -> GND` before writing LaTeX |
This is not a general TikZ generator yet. It does not compile LaTeX, call TikZJax, inspect screenshots, or run image-feedback repair. Those remain later gates.
### Golden Reference Prompt Shape
For near-term usage, provide a renderable golden reference before asking for a circuit variant. A constrained prompt should preserve the preamble, coordinate scale, anchor style, and routing conventions:
@ -235,8 +252,6 @@ For a CMOS inverter, the prompt should request an explicit topology plus layout
- use named anchors (`PM1.G`, `NM1.G`, `PM1.D`, `NM1.D`) instead of visually inferred coordinates;
- avoid diagonal or crossing wires unless electrically required.
This is not yet a shipped renderer target. It is the recommended constraint system for the next implementation phase.
### Current Progress And Next Phases
| Area | Current status | Next move |
@ -244,7 +259,7 @@ This is not yet a shipped renderer target. It is the recommended constraint syst
| General diagrams | Spec-first pipeline implemented for Mermaid, JSON Canvas, Vega-Lite, HTML | Keep expanding semantic verification coverage |
| Editable figures | `editable-html-svg`, Draw.io XML, and Drawnix JSON artifact boundaries implemented | Add richer primitives only after tests prove editability |
| CLI support | `npm run diagram:export-artifact` exports editable HTML/SVG, Draw.io, and Drawnix from one `DiagramSpec` | Add target-specific smoke fixtures when new targets ship |
| circuitikz | Researched as a constrained future target, not a free-form generation path | Define `CircuitSpec`, golden templates, compile-log parsing, and screenshot feedback loop |
| circuitikz | `CircuitSpec -> circuitikz` prototype exports common-source and CMOS inverter golden templates through `npm run diagram:export-circuitikz` | Add compile/screenshot feedback and topology-preserving repair |
| TikZJax integration | Candidate render host for Obsidian-side display | Keep it optional; do not make TikZJax a hard plugin runtime dependency |
## Configuration

View file

@ -110,7 +110,7 @@ graph LR
E --> F[Preview or Export]
```
The current implementation already supports Mermaid, JSON Canvas, Vega-Lite, HTML fallback, editable HTML/SVG, Draw.io XML artifacts, and a minimal Drawnix JSON subset. Circuit diagrams are a harder class: circuitikz can express accurate electrical topology, but unconstrained LLM output often produces unreadable routing or non-rendering LaTeX. The planned direction is to treat circuitikz as a constrained target with golden-reference templates, node-grid layout rules, render diagnostics, and screenshot feedback loops.
The current implementation already supports Mermaid, JSON Canvas, Vega-Lite, HTML fallback, editable HTML/SVG, Draw.io XML artifacts, a minimal Drawnix JSON subset, and an offline `CircuitSpec -> circuitikz` prototype for common-source and CMOS inverter golden templates. Circuit diagrams are a harder class: circuitikz can express accurate electrical topology, but unconstrained LLM output often produces unreadable routing or non-rendering LaTeX. The next direction is to keep circuitikz constrained with golden-reference templates, node-grid layout rules, render diagnostics, and screenshot feedback loops.
Read the details in [Diagrams](./features/diagrams).

View file

@ -203,6 +203,23 @@ graph LR
| Style | package、电压约定、labels、anchors | `\begin{circuitikz}[american voltages]` |
| Validation | compile log、missing anchors、overlap/screenshot checks | TikZJax/LaTeX diagnostics plus visual review |
### 当前 circuitikz 原型
Notemd 现在已经包含这个方向的第一个受约束仓库内原型。它有意保持为离线、template-bound 的导出能力:
```bash
npm run diagram:export-circuitikz -- --input cmos-inverter.json --output cmos-inverter.tex
```
这个原型新增了独立的 `CircuitSpec` 边界,并为两个 golden-reference families 提供确定性 exporter
| Circuit kind | Golden reference | Current guarantee |
|--------------|------------------|-------------------|
| `common-source-amplifier` | `common-source-nmos-v1` | 写出 LaTeX 前验证 `VDD -> R_D -> M1.D`、`vin -> M1.G`、`M1.S -> GND` 和 `M1.D -> vout` |
| `cmos-inverter` | `cmos-inverter-v1` | 写出 LaTeX 前验证 PMOS-over-NMOS 拓扑、shared gate input、shared drain output、`VDD -> MP.S` 和 `MN.S -> GND` |
这还不是通用 TikZ 生成器。它不会编译 LaTeX、调用 TikZJax、检查截图或运行图像反馈修复这些仍是后续 gate。
### Golden Reference Prompt Shape
近期使用时,应该在要求生成电路变体前给出一个可渲染 golden reference。强约束 prompt 应保留 preamble、coordinate scale、anchor style 和 routing conventions
@ -235,8 +252,6 @@ graph LR
- 使用 named anchors`PM1.G`、`NM1.G`、`PM1.D`、`NM1.D`),不要依赖视觉推断坐标;
- 除非电气上必要,避免 diagonal 或 crossing wires。
这还不是已发布的 renderer target。它是下一阶段实现的推荐约束系统。
### Current Progress And Next Phases
| Area | Current status | Next move |
@ -244,7 +259,7 @@ graph LR
| General diagrams | Mermaid、JSON Canvas、Vega-Lite、HTML 的 spec-first pipeline 已实现 | 继续扩展 semantic verification coverage |
| Editable figures | `editable-html-svg`、Draw.io XML、Drawnix JSON artifact boundaries 已实现 | 只有在测试证明 editability 后再加入更丰富 primitives |
| CLI support | `npm run diagram:export-artifact` 可从同一个 `DiagramSpec` 导出 editable HTML/SVG、Draw.io、Drawnix | 新 target 发布时增加 target-specific smoke fixtures |
| circuitikz | 已作为强约束 future target 研究,不作为自由生成路径 | 定义 `CircuitSpec`、golden templates、compile-log parsing 与 screenshot feedback loop |
| circuitikz | `CircuitSpec -> circuitikz` 原型已经可通过 `npm run diagram:export-circuitikz` 导出 common-source 与 CMOS inverter golden templates | 增加 compile/screenshot feedback 与 topology-preserving repair |
| TikZJax integration | Obsidian-side display 的候选 render host | 保持可选,不把 TikZJax 变成硬 runtime 依赖 |
## Configuration

View file

@ -110,7 +110,7 @@ graph LR
E --> F[Preview or Export]
```
当前实现已经支持 Mermaid、JSON Canvas、Vega-Lite、HTML fallback、editable HTML/SVG、Draw.io XML artifact,以及最小 Drawnix JSON subset。电路图是更难的一类circuitikz 可以准确表达电路拓扑,但不受约束的 LLM 输出经常产生不可读的布线或无法渲染的 LaTeX。后续方向是把 circuitikz 作为强约束 target使用 golden-reference 模板、节点网格布局规则、渲染诊断和截图反馈闭环。
当前实现已经支持 Mermaid、JSON Canvas、Vega-Lite、HTML fallback、editable HTML/SVG、Draw.io XML artifact、最小 Drawnix JSON subset以及面向 common-source 和 CMOS inverter golden templates 的离线 `CircuitSpec -> circuitikz` 原型。电路图是更难的一类circuitikz 可以准确表达电路拓扑,但不受约束的 LLM 输出经常产生不可读的布线或无法渲染的 LaTeX。下一步方向是继续把 circuitikz 作为强约束 target使用 golden-reference 模板、节点网格布局规则、渲染诊断和截图反馈闭环。
详细内容见 [图表与可编辑 Figure](./features/diagrams)。

View file

@ -355,6 +355,9 @@ function auditDiagramDocs() {
assertContains(englishDiagrams, 'TikZJax', 'English diagrams doc');
assertContains(englishDiagrams, 'preferredDiagramRenderTarget', 'English diagrams doc');
assertContains(englishDiagrams, 'Render Target Selection', 'English diagrams doc');
assertContains(englishDiagrams, 'diagram:export-circuitikz', 'English diagrams doc');
assertContains(englishDiagrams, 'CircuitSpec', 'English diagrams doc');
assertContains(englishDiagrams, 'cmos-inverter-v1', 'English diagrams doc');
assertContains(englishDiagrams, 'Golden Reference Prompt Shape', 'English diagrams doc');
assertContains(englishDiagrams, 'scripts/diagram-semantic-verification.js', 'English diagrams doc');
@ -366,6 +369,9 @@ function auditDiagramDocs() {
assertContains(zhCnDiagrams, 'TikZJax', 'zh-CN diagrams doc');
assertContains(zhCnDiagrams, 'preferredDiagramRenderTarget', 'zh-CN diagrams doc');
assertContains(zhCnDiagrams, '渲染目标选择', 'zh-CN diagrams doc');
assertContains(zhCnDiagrams, 'diagram:export-circuitikz', 'zh-CN diagrams doc');
assertContains(zhCnDiagrams, 'CircuitSpec', 'zh-CN diagrams doc');
assertContains(zhCnDiagrams, 'cmos-inverter-v1', 'zh-CN diagrams doc');
assertContains(zhCnDiagrams, 'Golden Reference Prompt Shape', 'zh-CN diagrams doc');
assertContains(zhCnDiagrams, 'SemanticFigureModel', 'zh-CN diagrams doc');
assertNotContains(zhCnDiagrams, 'content="noindex,follow"', 'zh-CN diagrams doc');