From 2d688db7c1b813842f7e996e715a58678e87af0d Mon Sep 17 00:00:00 2001 From: aliyun1121003339 Date: Sun, 5 Jul 2026 18:29:41 +0800 Subject: [PATCH] feat: add constrained circuitikz export prototype --- docs/README.md | 1 + docs/README.zh-CN.md | 1 + ...-integration-and-figure-generation-plan.md | 27 +- ...ration-and-figure-generation-plan.zh-CN.md | 27 +- docs/index.md | 2 + docs/index.zh-CN.md | 2 + .../maintainer/circuitikz-export-prototype.md | 80 +++++ .../circuitikz-export-prototype.zh-CN.md | 80 +++++ .../circuitikz-figure-generation-roadmap.md | 11 + ...cuitikz-figure-generation-roadmap.zh-CN.md | 11 + package.json | 1 + scripts/export-circuitikz.js | 148 +++++++++ .../adapters/circuitikz/circuitSpec.ts | 61 ++++ .../adapters/circuitikz/circuitikzExporter.ts | 289 ++++++++++++++++++ src/tests/circuitikzExportCli.test.ts | 146 +++++++++ .../circuitikzExportDocsContract.test.ts | 54 ++++ src/tests/circuitikzExporter.test.ts | 127 ++++++++ src/tests/docsHubContract.test.ts | 18 ++ src/tests/websiteDocsContract.test.ts | 7 +- website/docs/features/diagrams.mdx | 21 +- website/docs/intro.mdx | 2 +- .../current/features/diagrams.mdx | 21 +- .../current/intro.mdx | 2 +- website/scripts/audit-build.cjs | 6 + 24 files changed, 1122 insertions(+), 23 deletions(-) create mode 100644 docs/maintainer/circuitikz-export-prototype.md create mode 100644 docs/maintainer/circuitikz-export-prototype.zh-CN.md create mode 100644 scripts/export-circuitikz.js create mode 100644 src/diagram/adapters/circuitikz/circuitSpec.ts create mode 100644 src/diagram/adapters/circuitikz/circuitikzExporter.ts create mode 100644 src/tests/circuitikzExportCli.test.ts create mode 100644 src/tests/circuitikzExportDocsContract.test.ts create mode 100644 src/tests/circuitikzExporter.test.ts diff --git a/docs/README.md b/docs/README.md index 4b92b6f0..53517ba8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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) diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md index a8735c21..1bf2f3f9 100644 --- a/docs/README.zh-CN.md +++ b/docs/README.zh-CN.md @@ -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) diff --git a/docs/brainstorms/2026-07-04-diagram-reference-integration-and-figure-generation-plan.md b/docs/brainstorms/2026-07-04-diagram-reference-integration-and-figure-generation-plan.md index 96485d1a..d16811f6 100644 --- a/docs/brainstorms/2026-07-04-diagram-reference-integration-and-figure-generation-plan.md +++ b/docs/brainstorms/2026-07-04-diagram-reference-integration-and-figure-generation-plan.md @@ -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. diff --git a/docs/brainstorms/2026-07-04-diagram-reference-integration-and-figure-generation-plan.zh-CN.md b/docs/brainstorms/2026-07-04-diagram-reference-integration-and-figure-generation-plan.zh-CN.md index 72ccf2d5..7792a49d 100644 --- a/docs/brainstorms/2026-07-04-diagram-reference-integration-and-figure-generation-plan.zh-CN.md +++ b/docs/brainstorms/2026-07-04-diagram-reference-integration-and-figure-generation-plan.zh-CN.md @@ -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 输出的理由。 diff --git a/docs/index.md b/docs/index.md index b4050f64..82d5454d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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) diff --git a/docs/index.zh-CN.md b/docs/index.zh-CN.md index 2859ecd6..12b82cc4 100644 --- a/docs/index.zh-CN.md +++ b/docs/index.zh-CN.md @@ -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) diff --git a/docs/maintainer/circuitikz-export-prototype.md b/docs/maintainer/circuitikz-export-prototype.md new file mode 100644 index 00000000..ab068e8b --- /dev/null +++ b/docs/maintainer/circuitikz-export-prototype.md @@ -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. diff --git a/docs/maintainer/circuitikz-export-prototype.zh-CN.md b/docs/maintainer/circuitikz-export-prototype.zh-CN.md new file mode 100644 index 00000000..1129406b --- /dev/null +++ b/docs/maintainer/circuitikz-export-prototype.zh-CN.md @@ -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。 diff --git a/docs/maintainer/circuitikz-figure-generation-roadmap.md b/docs/maintainer/circuitikz-figure-generation-roadmap.md index 75a4b42b..c40f04c9 100644 --- a/docs/maintainer/circuitikz-figure-generation-roadmap.md +++ b/docs/maintainer/circuitikz-figure-generation-roadmap.md @@ -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: diff --git a/docs/maintainer/circuitikz-figure-generation-roadmap.zh-CN.md b/docs/maintainer/circuitikz-figure-generation-roadmap.zh-CN.md index dad4a7ef..4384656d 100644 --- a/docs/maintainer/circuitikz-figure-generation-roadmap.zh-CN.md +++ b/docs/maintainer/circuitikz-figure-generation-roadmap.zh-CN.md @@ -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: diff --git a/package.json b/package.json index dc6aff5f..14c03227 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/export-circuitikz.js b/scripts/export-circuitikz.js new file mode 100644 index 00000000..fbead455 --- /dev/null +++ b/scripts/export-circuitikz.js @@ -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 --output + +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 +}; diff --git a/src/diagram/adapters/circuitikz/circuitSpec.ts b/src/diagram/adapters/circuitikz/circuitSpec.ts new file mode 100644 index 00000000..6322bb5a --- /dev/null +++ b/src/diagram/adapters/circuitikz/circuitSpec.ts @@ -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; +} + +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); +} diff --git a/src/diagram/adapters/circuitikz/circuitikzExporter.ts b/src/diagram/adapters/circuitikz/circuitikzExporter.ts new file mode 100644 index 00000000..20f258f3 --- /dev/null +++ b/src/diagram/adapters/circuitikz/circuitikzExporter.ts @@ -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 { + const componentById = new Map(); + + 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, + nets: Set, + 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, + 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, + 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, + 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'); + } +} diff --git a/src/tests/circuitikzExportCli.test.ts b/src/tests/circuitikzExportCli.test.ts new file mode 100644 index 00000000..cd759901 --- /dev/null +++ b/src/tests/circuitikzExportCli.test.ts @@ -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 }); + } + }); +}); diff --git a/src/tests/circuitikzExportDocsContract.test.ts b/src/tests/circuitikzExportDocsContract.test.ts new file mode 100644 index 00000000..f0a17baf --- /dev/null +++ b/src/tests/circuitikzExportDocsContract.test.ts @@ -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'); + } + }); +}); diff --git a/src/tests/circuitikzExporter.test.ts b/src/tests/circuitikzExporter.test.ts new file mode 100644 index 00000000..2341c409 --- /dev/null +++ b/src/tests/circuitikzExporter.test.ts @@ -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.' + ); + }); +}); diff --git a/src/tests/docsHubContract.test.ts b/src/tests/docsHubContract.test.ts index 64521325..f400aaeb 100644 --- a/src/tests/docsHubContract.test.ts +++ b/src/tests/docsHubContract.test.ts @@ -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)' + ); }); }); diff --git a/src/tests/websiteDocsContract.test.ts b/src/tests/websiteDocsContract.test.ts index 9e98ea72..f2f906da 100644 --- a/src/tests/websiteDocsContract.test.ts +++ b/src/tests/websiteDocsContract.test.ts @@ -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'); } }); diff --git a/website/docs/features/diagrams.mdx b/website/docs/features/diagrams.mdx index 457ce2f4..3c69cdf7 100644 --- a/website/docs/features/diagrams.mdx +++ b/website/docs/features/diagrams.mdx @@ -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 diff --git a/website/docs/intro.mdx b/website/docs/intro.mdx index 3e503d93..dd199cb5 100644 --- a/website/docs/intro.mdx +++ b/website/docs/intro.mdx @@ -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). diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/diagrams.mdx b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/diagrams.mdx index 034fb343..d5b441b6 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/diagrams.mdx +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/diagrams.mdx @@ -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 diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/intro.mdx b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/intro.mdx index 624acd53..e7688c4d 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/intro.mdx +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/intro.mdx @@ -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)。 diff --git a/website/scripts/audit-build.cjs b/website/scripts/audit-build.cjs index b4f0ad0c..2fe3b96d 100644 --- a/website/scripts/audit-build.cjs +++ b/website/scripts/audit-build.cjs @@ -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');