From ba0f38d22741935fa64e0bc3949ae72dcba1a858 Mon Sep 17 00:00:00 2001 From: lostlight530 <13957086206@163.com> Date: Sun, 23 Aug 2026 10:12:10 +0800 Subject: [PATCH 1/8] feat(calibration): integrate Round 4 scientific contract with accessibility runtime and CI --- .github/workflows/ci.yml | 23 ++ .gitignore | 2 + AGENTS.md | 194 ++++------ ARCHITECTURE.md | 262 ++++++-------- CITATION.cff | 13 + CONTRIBUTING.md | 73 ++-- MANIFEST.yaml | 141 +++++--- Makefile | 9 +- README.md | 222 ++++++++---- backends/matplotlib_adapter.py | 448 ++++++----------------- backends/matplotlib_base.py | 382 ++++++++++++++++++++ core/accessibility.py | 99 ++++++ examples/README.md | 104 +++--- metadata/recipe.schema.yaml | 91 ++--- profiles/README.md | 87 ++--- quality/gates.yaml | 26 +- recipes/accessible-line-chart.yaml | 28 ++ sci_render.py | 548 +++++++++++++++-------------- tests/test_accessibility.py | 146 ++++++++ 19 files changed, 1715 insertions(+), 1183 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 CITATION.cff create mode 100644 backends/matplotlib_base.py create mode 100644 core/accessibility.py create mode 100644 recipes/accessible-line-chart.yaml create mode 100644 tests/test_accessibility.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..94efce7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,23 @@ +name: research-contract + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - name: Install Python dependencies + run: python -m pip install pyyaml jsonschema matplotlib numpy pillow + - name: Run render and accessibility contracts + run: make test diff --git a/.gitignore b/.gitignore index beb3038..2b2a9b3 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,8 @@ output/ *.pdf *.html *.manifest.json +*.prov.json +*.a11y.json # 临时生成脚本 _generated_render.py diff --git a/AGENTS.md b/AGENTS.md index 89fc3bc..0b8a6bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,131 +1,85 @@ -# Agent 快速上手指引 / Agent Guide +# Agent Guide — sci-render-kit -面向被指派来扩展、修改或定制 `sci-render-kit` 的 Agent/Bot。读完本文即可正确动手。本文只写**操作约定与边界**;设计原理见 `ARCHITECTURE.md`,人类贡献者流程见 `CONTRIBUTING.md`。 +This file is the operational contract for agents modifying the repository. Public capability claims must remain aligned across `README.md`, `ARCHITECTURE.md`, `MANIFEST.yaml`, schemas, gates, adapters, and tests. -## 0. 铁律(先读这个) +## 1. Canonical architecture -1. **声明优先**:用户写 YAML 配方(`recipes/*.yaml`),不写绘图代码。 -2. **唯一入口**:一切渲染必须走 `python3 sci_render.py`。**直接调用后端适配器已废弃**——会绕过全部质量门。 -3. **校验集中在 CLI**:适配器是 dumb 的代码生成器,不做校验;所有校验在 `sci_render.py`。 -4. **每次渲染必须产出** `manifest.json`;matplotlib 后端另产出 `.prov.json` 溯源旁车(P2 门禁强制)。 -5. **收尾前必须** `python3 tests/test_all.py` 全绿(当前 26/26;依赖 node/R 的用例在缺环境时自动 SKIP,不算失败)。 +```text +recipe.schema.yaml + -> sci_render.py (P0/P1 + backend capability) + -> backend adapter + -> output + -> P2/P3 -## 1. 目录导览 +Matplotlib path: +backends/matplotlib_adapter.py + -> backends/matplotlib_base.py -``` -sci_render.py 统一 CLI:读配方 → 校验 → 分发 → 渲染后检查 -core/ - color_encoding.py [已实现] 语义色彩编码 + WCAG 对比度计算 - palettes.py [已实现] 命名色板注册表(okabe-ito/petroff10/viridis/cividis/Crameri 系) - cvd_simulation.py [已实现] 色盲模拟(供 P1 cvd-contrast 门禁使用) - projection.py / time_crystal.py / superposition.py / - uncertainty_legend.py / observer_dashboard.py [EXPERIMENTAL] 未接入主流程,勿当已实现引用 -backends/ - matplotlib_adapter.py Python 后端(png/svg/pdf;含溯源内嵌 + .prov.json) - ggplot2_adapter.R R 后端(png/svg/pdf) - observable_adapter.js JS 后端(仅 html) -profiles/ 期刊配置:nature / science / cell / ieee / presentation -quality/gates.yaml 质量门规则声明(P0–P3) -metadata/ recipe.schema.yaml(配方 Schema)、reproducibility.schema.yaml(manifest Schema) -recipes/ 示例配方(每种图表类型至少一个) -tests/test_all.py 全量测试套件 -examples/README.md 示例命令集 +Accessibility evidence: +core/accessibility.py + -> .a11y.json ``` -## 2. 怎么渲染 +## 2. Full deterministic contract ```bash -# 环境:pip install pyyaml jsonschema matplotlib numpy -# (observable 后端另需 node + npm install;ggplot2 后端另需本机 R 及 yaml/jsonlite/digest/ggplot2 包) - -python3 sci_render.py recipes/line-chart.yaml --profile presentation --backend matplotlib -python3 sci_render.py recipes/semantic-line-chart.yaml --profile presentation --backend matplotlib # 语义色板演示 -python3 sci_render.py recipes/line-chart-interactive.yaml --profile presentation --backend observable # 交互式 HTML +python -m pip install pyyaml jsonschema matplotlib numpy pillow +make test ``` -- CLI 参数:`sci_render.py [--profile nature|science|cell|ieee|presentation] [--backend matplotlib|ggplot2|observable]`(默认 `--profile nature --backend matplotlib`)。 -- 产物在配方 `output.dir` 声明的目录下:图件 + 同名 `.manifest.json`;matplotlib 后端另有同名 `.prov.json`(溯源旁车:配方/输入数据/输出文件 SHA-256、后端版本、时间戳)。 -- 后端能力边界(dispatch 前强制校验,声明于 `sci_render.py.BACKEND_CAPABILITIES`,与 `MANIFEST.yaml` 一致):matplotlib/ggplot2 → `png/svg/pdf`,observable → `html` 仅。越界即 `BACKEND_CAPABILITY_MISMATCH`,exit 1。 -- **边界**:ggplot2 (R) 路径未在本仓库自动化测试中做运行时验证;observable E2E 依赖 node 环境,缺失时测试自动 SKIP。宣称能力时不得超出上述边界。 - -## 3. 配方字段速查 - -Schema 以 `metadata/recipe.schema.yaml` 为准。必需顶层字段:`id, type, data, aesthetics, output`。 - -- `type`:`line-chart | bar-chart | scatter-plot | heatmap | boxplot | histogram`(`3d-*` 被 P1 禁止)。 -- `aesthetics`(均可被 profile 默认值合并,配方优先): - - 基础:`title, x_label, y_label, palette(hex 数组, ≤8), font_size(≥5), figsize([w,h] 英寸), dpi(≥72), line_width, cmap(热力图色阶), bins(直方图)` - - 色彩增强(触发对应 P1 门禁,未声明则不启用,向后兼容): - - `semantic_palette: true` — 系列名命中语义标签(positive/negative/stable 等)时自动分配语义色;仅系列图(line/bar/scatter/boxplot/histogram)生效 - - `background` — 画布背景色 hex;声明后启用 palette-contrast 与 cvd-contrast 门禁 - - `text_color` — 文字颜色 hex;声明后启用 text-contrast 门禁 - - `adjacency_check: true` — 启用分类色板两两对比度门禁 - - `palette_name` — 引用 `core/palettes.py` 注册色板(如 `okabe-ito`、`petroff10`);系列图仅接受 categorical 色板,顺序/发散色阶请用 `cmap` -- `output`:`dir, filename` 必需;`format: png|svg|pdf|eps|html`(默认 png)。注意 `nature/science/cell` profile 在 P3 强制 pdf/eps。 - -## 4. 质量门体系(P0–P3) - -规则声明在 `quality/gates.yaml`,执行在 `sci_render.py`。任一失败 → 打印规则冲突并 exit 1。 - -| 级别 | 时机 | 检查 id | 触发条件与规则 | -|---|---|---|---| -| P0 | 渲染前 | schema-compliance / required-fields / data-type / output-config | 配方符合 `recipe.schema.yaml`;含 `id,type,data,aesthetics,output`;output 含 `dir,filename` | -| P1 | 渲染前 | color-count | 有效色板颜色数 ≤ 8 | -| P1 | 渲染前 | font-size | nature ≥ 5pt;science ≥ 6pt | -| P1 | 渲染前 | forbidden-pairs | 显式 palette 不得红绿(#ff0000/#00ff00)并置 | -| P1 | 渲染前 | palette-contrast | 声明 `background` 或启用 `semantic_palette` 时:各色板色 vs 背景 WCAG 对比度 ≥ 3.0(未声明 background 按 #FFFFFF) | -| P1 | 渲染前 | text-contrast | 声明 `text_color` 时:文字 vs 背景 ≥ 4.5(WCAG SC 1.4.3) | -| P1 | 渲染前 | palette-adjacency | 声明 `adjacency_check: true` 时:分类色板两两 ≥ 3.0(SC 1.4.11),报告失败色对 | -| P1 | 渲染前 | cvd-contrast | 同 palette-contrast 触发条件:三种色盲模拟(protanopia/deuteranopia/tritanopia)下各色 vs 背景保持 ≥ 3.0 | -| P1 | 渲染前 | palette-name | 声明 `palette_name` 时必须命中 `core/palettes.py` 注册表;系列图仅接受 categorical | -| P1 | 渲染前 | no-3d | `type` 不得以 `3d-` 开头 | -| — | 分发前 | backend capability | `output.format` 必须在所选后端能力集内(见 §2),否则 `BACKEND_CAPABILITY_MISMATCH` | -| P2 | 渲染后 | file-exists / non-empty / format-match | 输出文件存在、非空、扩展名与 `output.format` 一致 | -| P2 | 渲染后 | manifest-exists | 同名 `.manifest.json` 必须存在 | -| P2 | 渲染后 | prov-exists | matplotlib 后端必须产出同名 `.prov.json`(R/JS 侧为可选跟进,不强制) | -| P3 | 渲染后 | vector-format | profile 为 nature/science/cell 时输出须为 .pdf/.eps | -| P3 | 渲染后 | dpi-check | 合并后 dpi 不得低于 profile 声明的 `aesthetics.dpi` | -| P3 | 渲染后 | size-check | figsize 为正值二元组;不得超过 profile 的 `max_width_in`/`max_height_in` | - -失败标志(stdout 可 grep):`P0_SCHEMA_FAILURE`、`YAML_PARSE_FAILURE`、`MISSING_PROFILE`、`BACKEND_CAPABILITY_MISMATCH`、`MANIFEST_MISSING`。 - -## 5. 怎么扩展 - -### A. 新增图表类型 -1. `metadata/recipe.schema.yaml` 的 `type` enum 加新类型。 -2. `recipes/` 加一个演示配方。 -3. **三个后端全部实现**:`backends/matplotlib_adapter.py`、`backends/ggplot2_adapter.R`、`backends/observable_adapter.js`;某后端确实无法支持时,显式失败并留 TODO,不得静默跳过。 -4. 若涉及系列离散着色,把类型加入 `sci_render.py.SERIES_CHART_TYPES` 与适配器内同名集合。 -5. `tests/test_all.py` 加覆盖(存在性 + 端到端渲染 + 至少一个门禁拦截用例)。 - -### B. 新增质量门 -1. `quality/gates.yaml` 声明规则(id/level/rule)。 -2. 渲染前规则加进 `run_quality_gates()`;渲染后规则加进 `main()` 的 P2/P3 段。 -3. 规则必须有明确的触发条件声明(如「声明 X 时启用」),保持向后兼容:旧配方不声明即不启用。 -4. 测试:一个拦截用例 + 一个通过用例。 - -### C. 新增命名色板 -1. 只改 `core/palettes.py` 的 `PALETTE_REGISTRY`:必填 `kind`(categorical/sequential/diverging)、`cvd_safety`(high/medium/unverified)、`source`、`availability`。 -2. **诚实声明**:非后端内置的色板在 `availability` 中标注「不可用」,`mpl_name: None`;未独立验证 CVD 安全性的标 `unverified`。测试强制此约定。 - -### D. 新增 Profile -1. 建 `profiles/.yaml`(结构见 `profiles/README.md`),必须含顶层 `source_url` + `verified_date`(YYYY-MM-DD;内部默认配置 `source_url: null`)。 -2. `aesthetics` 中的 `dpi`、`max_width_in/max_height_in`、`font_size` 会被 P1/P3 门禁机器校验;`constraints` 只是人类可读摘要,必须与门禁规则一致。 -3. 用 `--profile ` 引用;文件缺失即 `MISSING_PROFILE`。 - -### E. 新增后端适配器 -1. 建 `backends/_adapter.`:吃配方数据 + 合并 aesthetics → 生成目标语言代码 → 执行 → 写图件 + `.manifest.json`。 -2. 在 `sci_render.py` 的 `backend_script_map` 与 `BACKEND_CAPABILITIES` 注册,并同步 `MANIFEST.yaml` 的 `backends[].capabilities`。 -3. 适配器保持 dumb:不做校验,不覆盖 profile 样式。 - -## 6. 编码约定(不许做清单) - -- 禁止绕过 `sci_render.py` 直连适配器。 -- 禁止在适配器里加校验逻辑;禁止在 CLI 里加绘图逻辑。 -- 禁止 `shell=True`:分发一律列表式 `subprocess.run`。 -- 临时生成的渲染脚本(`_generated_render.*`)执行后必须清理,不留脏文件。 -- **依赖政策**:Python 侧只用 `pyyaml / jsonschema / matplotlib / numpy`;JS 侧仅 `package.json` 声明的 `yaml`;R 侧仅 `yaml/jsonlite/digest/ggplot2`。不得引入新依赖。CDN 链接不做版本锁定(用 `@observablehq/plot` 而非 `@observablehq/plot@0.6`),除非严格必要。 -- **能力诚实**:未实现/未验证的能力如实标注(注册表 availability、README 边界说明、R 路径未运行时验证等),不得伪装。 -- **测试纪律**:改动后跑 `python3 tests/test_all.py`(或 `make test`),26/26 方可收尾;node/R 相关用例缺环境自动 SKIP 属正常。R/JS 适配器内嵌的语义色常量必须与 `core/color_encoding.py` 保持一致(有静态一致性测试)。 -- 未接入主流程的新模块必须标 `[EXPERIMENTAL]`。 -- 提交信息用 Conventional Commits(`feat:` / `fix:` / `docs:` / `test:` / `chore:`)。 +GitHub Actions runs the same contract with Python 3.12. R/Node runtime remains optional unless the workflow explicitly provisions those ecosystems. + +## 3. Hard rules + +1. **Unified validation.** New public policy goes through `sci_render.py`; adapters should not each invent a different validation model. +2. **Backend truth.** Schema support does not imply backend support. Update `BACKEND_ACCESSIBILITY_CAPABILITIES` only after actual rendering behavior and tests exist. +3. **Use of Color.** If `redundant_encoding: required`, supported multi-series charts must expose non-color cues. Do not satisfy this by merely adding another color. +4. **WCAG scope.** `adjacent_pairs` models actual graphical adjacency for SC 1.4.11 support. The legacy all-pairs adjacency gate is a stricter project policy, not the normative WCAG scope. +5. **Text alternatives.** `require_alt_text: true` must gate missing short alternatives. Do not claim that a sidecar alone makes a final website/PDF accessible; publishing-layer association still matters. +6. **CVD is separate.** Machado simulation is a project safeguard, not a WCAG success criterion. +7. **Matplotlib layering.** Keep `matplotlib_base.py` as the stable render/provenance core. The public adapter may add policy behavior but must preserve existing exported APIs. +8. **Dumb adapters.** Backend adapters translate validated intent into backend-specific rendering; they are not alternative policy engines. +9. **No fake reproducibility.** Checksums/provenance improve traceability but do not justify “100% reproducible” claims. +10. **No fake conformance.** `sci-render-kit/a11y@1` explicitly carries `conformance_claim: false`. +11. **Experimental stays Experimental.** projection/time_crystal/uncertainty_legend/observer_dashboard/superposition are not integrated capabilities. +12. **Optional ecosystems stay optional.** A skipped R/Node E2E test is neither a failure nor proof of runtime parity. + +## 4. Where to change what + +| Goal | Primary files | Required follow-up | +|---|---|---| +| recipe field | `metadata/recipe.schema.yaml` | gate/backend/tests/docs | +| accessibility rule | `core/accessibility.py`, `sci_render.py`, `quality/gates.yaml` | `tests/test_accessibility.py` | +| Matplotlib non-color encoding | `backends/matplotlib_adapter.py` | preserve base API + E2E test | +| base Matplotlib rendering/provenance | `backends/matplotlib_base.py` | old `tests/test_all.py` contract | +| new backend capability | adapter + capability matrix | real E2E or explicit optional-runtime test | +| publication profile | `profiles/*.yaml`, `profiles/README.md` | source/verification date + P3 tests | +| named palette | `core/palettes.py` | type/availability/CVD metadata + tests | +| provenance | Matplotlib base + P2 gate | checksum/readback tests | +| public capability | README/ARCHITECTURE/MANIFEST | update together | + +## 5. Accessibility invariants + +For `redundant_encoding: required` on a supported multi-series chart: + +- every series label resolves to a style signature, +- signatures are distinct across the visible series, +- color remains available but is not the only series cue, +- `.a11y.json` records the actual cues used. + +For `adjacent_pairs`: + +- labels must exist in the recipe data, +- only declared pairs are checked by the WCAG-scoped adjacency gate, +- a separate legacy all-pairs project gate may still run if `aesthetics.adjacency_check` is explicitly enabled. + +## 6. Completion gate + +Before a PR is ready: + +- `make test` is the intended contract, +- capability matrices match actual backend behavior, +- new recipe fields are in schema and docs, +- generated sidecars are gitignored, +- no external standard is paraphrased more strongly than its real scope, +- no experimental module was silently promoted. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4488657..5d32df6 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,210 +1,158 @@ -# 架构设计、科研完整性与可复现边界 / Architecture, Scientific Integrity & Reproducibility Boundaries +# Architecture & Philosophy — sci-render-kit -> 当前架构校准日期 / Calibration date: 2026-08-23 +## 1. Thesis: a scientific figure is a contract, not a screenshot -`sci-render-kit` 是一个**声明式、能力有界、多后端的科学可视化工具包**。它把 recipe、profile、质量门、后端能力和溯源信息分离,使图表生成过程更可检查、更容易重放,也更容易发现不支持的组合。 +A research figure is simultaneously: -`sci-render-kit` is a **declarative, capability-bounded, multi-backend scientific visualization toolkit**. It separates recipes, profiles, quality gates, backend capabilities, and provenance so that rendering decisions are inspectable and unsupported combinations remain explicit. +- a visual encoding of data, +- an artifact constrained by a publication profile, +- a reproducibility object with provenance, +- and non-text content that may need redundant cues and textual alternatives. -它不承诺“同一 recipe 在所有后端完全等价”,也不把成功渲染描述成科学正确性、期刊接受或独立复现的证明。 +The architecture therefore separates **declaration, policy, rendering, and evidence**. A backend should not invent hidden policy, and a policy file should not claim support that no backend actually renders. ---- - -## 1. 规范数据流 / Canonical data flow - -```text -recipe + profile + data - | - v -schema validation (P0) - | - v -declared visual/accessibility gates (P1) - | - v -backend capability resolution - | - v -backend render - | - v -output / metadata checks (P2/P3) - | - v -figure + reproducibility metadata -``` - -“Declaration First” 的准确含义是:用户可以通过统一 recipe 描述图表意图,而不是每次手写完整后端代码。它**不等于**每种图表、格式、profile、后端组合都可用;组合必须先落在对应 adapter 的声明能力集内。 - -## 2. 统一入口与能力解析 / Unified dispatcher and capability resolution - -`sci_render.py` 负责: - -1. 读取 recipe; -2. 使用 `metadata/recipe.schema.yaml` 做结构验证; -3. 读取 profile; -4. 执行 P0/P1 前置门禁; -5. 检查输出格式是否在 `BACKEND_CAPABILITIES` 中; -6. 调用对应 adapter; -7. 对输出执行 P2/P3 检查。 - -当前声明的输出能力是: - -| Backend | Declared output formats | -| --- | --- | -| Matplotlib | PNG, SVG, PDF | -| ggplot2 | PNG, SVG, PDF | -| Observable | HTML | - -“Backend agnostic” 因此应理解为**统一声明模型 + 明确 capability negotiation**,而不是后端完全互换或像素级一致。 - -## 3. Recipe 与 Profile 的责任边界 / Recipe and profile responsibilities - -### Recipe - -Recipe 表达数据、图表类型、输出意图和部分视觉语义。它是待验证输入,不因为采用 YAML 就自动安全、正确或可发表。 - -### Profile - -Profile 编码仓库选择实现的一部分目标出版约束,例如字体、尺寸或格式规则。它不是目标期刊全部实时规则的权威镜像,也不能代替投稿指南、编辑审查或人工核对。 - -因此: +## 2. Pipeline ```text -profile pass != journal acceptance -profile pass != complete journal compliance +[YAML Recipe] + ↓ P0 schema +[Profile + Accessibility Contract] + ↓ P1 policy gates +[Backend Capability Gate] + ↓ +[Backend Adapter] + ↓ +[Rendered Figure] + + .manifest.json + + .prov.json (Matplotlib) + + .a11y.json (when accessibility is declared) + ↓ P2 integrity +[Journal Profile Gate] + ↓ P3 publication constraints ``` -## 4. Quality Gates 的严格含义 / Exact meaning of quality gates +## 3. Declaration layer -质量门只证明**实际执行的谓词**。 +### 3.1 Recipe -### P0 — Schema +`metadata/recipe.schema.yaml` defines data, aesthetics, output, and accessibility intent. Accessibility fields are deliberately explicit: -验证 recipe 的结构与类型。结构合法不代表数据真实或研究设计正确。 +- `alt_text` +- `long_description` +- `require_alt_text` +- `redundant_encoding` +- `series_styles` +- `adjacent_pairs` -### P1 — Visual and accessibility-oriented checks +The schema describes intent; it does not prove that every backend can realize every field. -包括颜色数量、文字对比、背景对比、语义色板、CVD 模拟和可选邻接色检查等。 +### 3.2 Profile -#### WCAG 2.2 scope calibration +Publication profiles encode measurable external constraints such as dimensions, fonts, DPI, and vector-format expectations. Their `source_url` and `verified_date` fields are snapshots, not promises that publishers will never update guidance. -WCAG 2.2 SC 1.4.11 要求理解内容所必需的非文本图形对象与相邻颜色之间具备足够对比。它不是“所有分类色板任意两色必须 3:1”的通用要求。 +## 4. Policy layer — `sci_render.py` -本仓 `palette-adjacency` 在启用 `adjacency_check: true` 时执行更严格的**分类色板两两对比**策略。该策略受 SC 1.4.11 / Technique G209 启发,但属于**项目自定义的 stricter safeguard**,不是 WCAG 原文的普遍性要求。 +The unified CLI owns validation. Adapters remain rendering implementations rather than independent policy engines. -### P2 — Output integrity +### 4.1 Accessibility scope -检查输出文件、格式和声明的 metadata/provenance 产物是否存在并满足已实现条件。存在文件不代表图中的科学结论正确。 +The 2026-08-23 calibration separates three WCAG-related concerns that were previously conflated with palette checks: -### P3 — Profile-oriented constraints +1. **SC 1.1.1 support — Text alternatives.** A recipe can require a short `alt_text`; a long description can carry richer trends/interpretation or point to the data table. +2. **SC 1.4.1 support — Use of Color.** When `redundant_encoding: required`, multiple visual series cannot rely on color alone. Matplotlib maps the same labels to distinct marker / line-style / hatch cues. +3. **SC 1.4.11 support — Non-text Contrast.** `adjacent_pairs` identifies graphical series that are actually adjacent and required for understanding; those declared boundaries are checked at ≥ 3:1. -检查仓库当前编码的目标规范子集,例如某些尺寸、DPI 或矢量格式条件。通过 P3 仍然只是“通过了本仓实现的规则”,不是期刊官方认证。 +The older `aesthetics.adjacency_check` remains available as an intentionally stricter **all-pairs project policy**. It must never be described as WCAG requiring every categorical color pair to contrast 3:1. -## 5. Provenance 与 Reproducibility 的区别 +Machado CVD simulation stays a separate project safeguard. CVD resilience is valuable, but it is not itself a WCAG success criterion. -旧架构文档使用过“保证 100% 实验可溯源 / reproducibility”一类绝对表述。当前架构明确拆开: +### 4.2 Backend capability truth -### 5.1 Reproducibility manifest +A schema field is not backend implementation evidence. Today: -用于记录 recipe/profile/backend/environment 等渲染上下文。它提高可追踪性与 replay 可寻址性。 +- Matplotlib can render redundant line/marker/hatch cues. +- ggplot2 and Observable can participate in the backend-independent text-alternative sidecar, but their redundant-series-style mapping is **Not Integrated**. +- If a recipe requests `auto` or `required` redundant encoding on a backend without that capability, the CLI fails with `BACKEND_ACCESSIBILITY_MISMATCH` before dispatch. -### 5.2 Matplotlib provenance path +This preserves the repository's central doctrine: **unsupported must be explicit, not silently downgraded.** -Matplotlib 后端当前实现了图件 metadata 与同名 `.prov.json` 旁车,其中可记录配方、输入、后端和输出摘要。 +## 5. Matplotlib two-layer adapter -### 5.3 不能从 metadata 推导出的结论 +The mature renderer is retained byte-for-byte as `backends/matplotlib_base.py`. The public `backends/matplotlib_adapter.py` imports that implementation and adds accessibility policy behavior. ```text -manifest exists != independent reproduction -SHA-256 matches != semantic equivalence -render succeeded != scientific validity -timestamp exists != trusted timestamping +matplotlib_adapter.py + ├─ re-exports existing public renderer API + ├─ patches render logic only during one accessibility-aware render + ├─ maps labels -> marker / line_style / hatch + ├─ embeds alt text in PNG/SVG/PDF metadata where supported + └─ extends the existing manifest with accessibility linkage + +matplotlib_base.py + ├─ profile merge + ├─ code generation + ├─ Matplotlib execution + ├─ reproducibility manifest + └─ provenance sidecar + embedded provenance metadata ``` -真正的“已复现”必须发生一次独立重跑并按声明判据比较结果,而不是仅生成一个 metadata 文件。 - -## 6. 科研完整性规则 / Scientific-integrity rules - -视觉系统必须避免通过默认样式暗示不存在的证据: - -- 不用视觉编码暗示未被数据支持的显著性; -- 不把相关性画法包装成因果结论; -- 不因置信区间/误差带存在就假定不确定性模型正确; -- 缺失值、聚合、过滤、变换和剔除若影响解释,应在上层研究对象或 manifest 中保留可追踪语义; -- 颜色可访问性是最低保障之一,不能替代文字、标记形状、线型和完整图注等多通道表达。 - -## 7. 后端卫生与失败语义 / Backend hygiene and failure semantics +This is an adapter-policy split: new policy can evolve without rewriting the stable rendering core or breaking existing direct imports. -Adapter 应保持“dumb”:只负责把已验证 payload 映射为具体后端行为,不偷偷扩张上层语义。 +## 6. Evidence sidecars -系统必须区分: +### 6.1 `.manifest.json` -- supported and rendered; -- unsupported capability; -- optional runtime missing; -- backend execution failure; -- post-render gate failure。 +Records generator, profile, parameters, output checksum, and — when accessibility is declared — a pointer to the accessibility profile/sidecar. -Skip、fallback 或缺失可选运行时都不能被计作“该后端已验证成功”。不同 Matplotlib/R/JS 版本也不承诺像素级完全一致。 +### 6.2 `.prov.json` -## 8. Research Contract / 科研契约 +Matplotlib provenance records recipe/input/output SHA-256 and environment metadata. It provides traceability evidence, not a mathematical guarantee that the experiment is perfectly reproducible on every future machine. -根目录新增 [RESEARCH_CONTRACT.md](RESEARCH_CONTRACT.md),用于把本仓与 `auto-doc-engine`、`epistemic-pipeline` 的 artifact/evidence/provenance 语义统一起来。 +### 6.3 `.a11y.json` -推荐上游 handoff 至少能够表达: +`core/accessibility.py` emits `sci-render-kit/a11y@1` containing: -```text -artifact/source identity -content/data digest -analysis/run reference -uncertainty semantics -provenance reference -validation status -``` - -本仓输出侧则优先表达 recipe、数据、profile、backend、figure、manifest 与 provenance 的可检查关系。 - -这仍然是**contract-only interoperability**;三个仓没有因为这份文件就变成运行时强耦合系统。 +- text alternatives, +- redundant-encoding mode, +- actual series colors, +- actual non-color cues, +- declared adjacent pairs, +- explicit `conformance_claim: false`. -## 9. RO-Crate 1.3 作为未来互操作目标 +The sidecar makes the figure's accessibility intent inspectable and machine-readable. Association of that sidecar with a figure in a final website/PDF remains the responsibility of the publishing layer. -RO-Crate 1.3 于 2026-06-22 发布为 Recommendation。它适合未来把 figure、recipe、profile、数据、软件环境和 render action 打包成一个机器可读 Research Object。 +## 7. Quality gates -当前 `.manifest.json` 与 `.prov.json` **不是 RO-Crate**。本仓状态明确为 `proposed_mapping`。只有实现符合规范的 exporter/validator 并增加可执行测试后,才可升级为 implemented。 +- **P0:** JSON Schema / recipe structure +- **P1:** aesthetics + accessibility semantics +- **P2:** output, manifest, provenance, accessibility evidence +- **P3:** publication-profile format / DPI / dimensions -## 10. 可复现性分级 / Reproducibility levels +Each gate has a different failure meaning. A P1 accessibility failure is not the same class of problem as a P3 journal-size mismatch. -本仓采用项目内部术语: +## 8. Verification architecture -- `R0 Traceable` — 图件能关联其声明 recipe/profile; -- `R1 Replay-addressable` — 数据/spec/profile/output 标识和摘要以及工具版本足以定位 replay; -- `R2 Environment-bounded` — 同时记录 runtime/backend/dependency 环境; -- `R3 Reproduced` — 已真正执行独立重跑并按声明判据比较。 +`make test` runs the legacy 26-test renderer/gate/provenance suite plus `tests/test_accessibility.py`. GitHub Actions executes the same deterministic Python contract with Python 3.12 and the Matplotlib/Pillow stack. -这些不是外部标准,不能把 manifest 或 `.prov.json` 的存在直接翻译成 `R3`。 +Node and R runtime tests remain optional because the core CI must not turn missing external ecosystems into false failures or false passes. -## 11. 2026-08-23 外部校准 / Ecosystem calibration +## 9. Hard rules -- Matplotlib 当前 stable 最新观察版本为 3.11.1(2026-07-17)。这是一条上游事实,不会自动升级仓库已有测试证据;只有实际运行验证后才能声明对应版本兼容性。 -- WCAG 基线仍按 W3C WCAG 2.2 的相关 Success Criteria 解释,并将本仓更严格的 project policy 与标准原义分开。 +1. Declaration does not imply backend support. +2. Color cannot be the only required series cue when redundant encoding is required. +3. WCAG wording must preserve the actual success-criterion scope. +4. Project-strict policies must be labelled as project policies. +5. Matplotlib base rendering stays policy-light; validation belongs to the unified CLI. +6. Provenance, reproducibility, and accessibility are related evidence layers but not interchangeable claims. +7. No output sidecar justifies saying a paper is “100% reproducible” or “WCAG conformant”. +8. Experimental modules remain Experimental until wired into the canonical render path with tests. -## 12. 架构 doctrine / Architecture doctrine +## 10. Direction -1. **能力矩阵高于“后端无关”口号。** 不支持的组合必须显式拒绝。 -2. **质量门只证明实际谓词。** 不能从格式检查外推科研正确性。 -3. **Profile 不是期刊认证。** 外部规则会变化,必须保留来源与验证日期。 -4. **Provenance 不是 reproduction。** 记录生成过程与真正独立重跑是两个层级。 -5. **项目严格策略要标明是项目策略。** 不扩大 WCAG 等外部标准的原始范围。 -6. **实验模块不能靠文档晋级。** 进入 canonical path 需要代码接线和测试。 -7. **跨仓互操作先统一语义,再决定耦合。** artifact/evidence/provenance contract 优先于直接依赖。 +The project is moving from “multi-backend chart generator” toward an **auditable scientific-figure compiler**: -## 13. 主要参考 / Primary references - -检索日期 / Retrieved: 2026-08-23 +```text +intent -> constraint -> encoding -> render -> evidence -> publication +``` -- [RO-Crate 1.3 Specification](https://www.researchobject.org/ro-crate/1.3/) -- [FAIR Principle R1.2](https://www.go-fair.org/fair-principles/r1-2-metadata-associated-detailed-provenance/) -- [W3C WCAG 2.2 — Non-text Contrast](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html) -- [W3C Technique G209](https://www.w3.org/WAI/WCAG22/Techniques/general/G209) -- [Matplotlib release notes](https://matplotlib.org/stable/users/release_notes.html) +Future work should extend backend parity and evidence quality before adding speculative visualization modules. diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 0000000..f2951f7 --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,13 @@ +cff-version: 1.2.0 +message: "If you use this research software, please cite it using the metadata below." +title: "sci-render-kit" +type: software +authors: + - alias: lostlight530 +repository-code: "https://github.com/lostlight530/sci-render-kit" +license: MIT +keywords: + - scientific visualization + - reproducibility + - accessibility + - research software diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ea8066a..ccfa5c1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,45 +1,28 @@ -# Contributing to sci-render-kit - -## Getting Started - -1. Clone the repository. -2. Create an isolated environment: - ```bash - python3 -m venv .venv - source .venv/bin/activate # or .venv\Scripts\activate on Windows - pip install pyyaml jsonschema matplotlib numpy - ``` -3. (Optional) For the observable (JS) backend, install Node.js dependencies: - ```bash - npm install - ``` - The ggplot2 (R) backend requires a local R installation with the - `yaml`, `jsonlite`, `digest`, and `ggplot2` packages. Tests that need - node/R are skipped automatically when the environment lacks them. -4. Run tests to verify baseline: - ```bash - make test - ``` - -## Development Principles - -- **Declaration first**: Users write YAML recipes, not code. New chart types must be expressible as a recipe. -- **Backend agnostic**: New chart types must be implemented in ALL backends (matplotlib, ggplot2, observable). If a backend cannot support a feature, fail gracefully with a TODO comment. -- **Quality ahead**: Recipes are validated statically before rendering. Do not bypass `sci_render.py` CLI workflow. -- **Output hygiene**: Generated render scripts must be cleaned up after execution (see `matplotlib_adapter.py` for the pattern). `ggplot2_adapter.R` follows the same convention. -- **Mandatory metadata**: A `manifest.json` must be generated next to every output for reproducibility. - -## Pull Request Checklist - -- [ ] All tests pass (`make test`) -- [ ] New chart types have recipes in `recipes/` -- [ ] New chart types implemented in all 3 backends -- [ ] Generated scripts are cleaned up after execution -- [ ] Manifest is written for every output (matplotlib also writes the `.prov.json` provenance sidecar — P2 `prov-exists` gate) -- [ ] R/JS adapters' embedded semantic-color constants stay in parity with `core/color_encoding.py` -- [ ] New modules are marked as `[EXPERIMENTAL]` if not integrated -- [ ] Documentation updated if behavior changes - -## License - -By contributing, you agree that your contributions are licensed under the MIT License. +# Contributing + +Contributions should strengthen the figure compiler's explicit contracts rather than only add another renderer or experimental module. + +## Setup + +```bash +python -m pip install pyyaml jsonschema matplotlib numpy pillow +make test +``` + +## Rules + +- Update schema, quality gates, implementation, tests, and docs together for new recipe fields. +- Keep policy centralized in `sci_render.py`; adapters render validated intent. +- Do not mark a backend capability Implemented until the backend actually realizes it. +- Preserve WCAG scope: color must not be the only required cue (SC 1.4.1), short text alternatives support non-text content (SC 1.1.1), and SC 1.4.11 applies to graphical objects/boundaries required for understanding rather than every palette pair by default. +- Label project-strict rules as project rules. +- Keep Matplotlib provenance/reproducibility behavior in the base adapter and accessibility policy in the public adapter where possible. +- New generated sidecars must be ignored and included in output-integrity reasoning. +- Do not use “100% reproducible” or “WCAG conformant” unless a much broader system-level claim has actual evidence. +- Experimental modules remain Experimental until wired into the canonical dispatcher with tests. + +## Testing + +Legacy renderer/gate/profile/provenance behavior belongs in `tests/test_all.py`. Accessibility contract and sidecar behavior belongs in `tests/test_accessibility.py`. + +Optional R/Node runtime tests may skip when their ecosystems are absent; do not interpret a skip as verified parity. diff --git a/MANIFEST.yaml b/MANIFEST.yaml index 7391495..b7e7512 100644 --- a/MANIFEST.yaml +++ b/MANIFEST.yaml @@ -1,86 +1,119 @@ name: sci-render-kit -purpose: 声明式、能力有界、可检查溯源的学术可视化工具包 | Declarative, capability-bounded academic visualization toolkit with inspectable provenance +purpose: 声明式、可溯源、可访问性可审计的学术可视化工具包 | Declarative scientific visualization with provenance and auditable accessibility contracts principles: - - 声明优先: 图表意图由 YAML recipe 描述,渲染行为由受支持 schema 与适配器解释 - - 后端能力有界: 同一 recipe 模型可面向多后端,但只在各后端声明的 capability 集内成立 - - 元数据可检查: 渲染结果关联 reproducibility manifest;Matplotlib 路径另有已实现 provenance sidecar/embedded metadata - - 质量前置: schema 与 P0/P1 检查在渲染前执行,P2/P3 检查验证已实现的输出/配置谓词 - - 科研语义克制: gate/profile/provenance 不证明统计显著性、因果、期刊接受或独立复现 - - 标准范围诚实: WCAG 启发的更严格项目策略必须标成 project policy,而不是扩大外部标准原义 + - 声明优先: 图表数据、出版约束与可访问性意图由 YAML 声明 + - 后端能力诚实: 同一配方可切换后端,但不伪装尚未实现的 accessibility/backend 能力 + - 质量前置: P0 schema 与 P1 美学/可访问性门在渲染前执行 + - 输出可验证: P2 检查图件、manifest、provenance 与 accessibility sidecar + - 出版规范显式: P3 profile 约束 DPI、尺寸与矢量格式 + - 颜色不是唯一通道: required redundant encoding 使用 marker / line_style / hatch 提供非颜色视觉线索 + - 标准边界诚实: WCAG 条款用于设计与门禁映射,不声明整篇论文/PDF/网站自动达到 WCAG conformance + - 科学边界: gate/profile/provenance 只证明实现的谓词与记录,不证明统计结论、因果或独立复现 + +research_contract: + file: RESEARCH_CONTRACT.md + status: active + local_reproducibility_levels: [R0, R1, R2, R3] + note: local project terminology; R3 requires a separate rerun and declared comparison criterion + preferred_upstream_fields: + - artifact_id + - source_refs + - content_or_data_sha256 + - run_or_analysis_ref + - uncertainty_semantics + - provenance_ref + - validation_status + preferred_figure_outputs: + - figure_id + - recipe_sha256 + - input_data_sha256 + - profile_id + - backend_id + - output_sha256 + - manifest_ref + - provenance_ref + - quality_gate_status + +interoperability_targets: + ro_crate: + status: proposed + version: "1.3" + published: "2026-06-22" + note: current manifest/prov/a11y sidecars are not RO-Crates; exporter + validator + tests required backends: - name: matplotlib language: python adapter: backends/matplotlib_adapter.py + base_adapter: backends/matplotlib_base.py capabilities: [png, svg, pdf] + accessibility: [text-alternative-metadata, text-alternative-sidecar, redundant-series-style] default: true - name: ggplot2 language: r adapter: backends/ggplot2_adapter.R capabilities: [png, svg, pdf] + accessibility: [text-alternative-sidecar] + redundant_series_style: not_integrated - name: observable language: javascript adapter: backends/observable_adapter.js capabilities: [html] + accessibility: [text-alternative-sidecar] + redundant_series_style: not_integrated -profiles: - - nature - - science - - cell - - ieee - - presentation +profiles: [nature, science, cell, ieee, presentation] recipes: types: [line-chart, bar-chart, scatter-plot, heatmap, boxplot, histogram] schema: metadata/recipe.schema.yaml + accessibility_example: recipes/accessible-line-chart.yaml + +accessibility: + profile: sci-render-kit/a11y@1 + module: core/accessibility.py + sidecar: .a11y.json + fields: [alt_text, long_description, require_alt_text, redundant_encoding, series_styles, adjacent_pairs] + wcag_2_2_design_support: + - "1.1.1: text alternative contract" + - "1.4.1: non-color redundant series cues" + - "1.4.11: contrast for explicitly declared adjacent graphical series" + project_strict_policy: + aesthetics.adjacency_check: "all unique palette pairs >= 3.0; stricter than WCAG actual-adjacency scope" + conformance_claim: false quality: gates: quality/gates.yaml levels: [P0, P1, P2, P3] - semantics: - passed_gate_proves_scientific_validity: false - profile_proves_journal_acceptance: false - palette_adjacency: - status: project_stricter_opt_in_policy - external_scope_reference: WCAG_2_2_SC_1_4_11_and_G209 - universal_wcag_all_pairs_claim: false metadata: - required: true - output: manifest.json - schema: metadata/reproducibility.schema.yaml - semantics: traceability_and_replay_context_not_independent_reproduction_proof + reproducibility: + required: true + schema: metadata/reproducibility.schema.yaml + sidecar: .manifest.json + provenance: + matplotlib: implemented + sidecar: .prov.json + other_backends: optional_follow_up + accessibility: + when_declared: required + sidecar: .a11y.json -provenance: - matplotlib_sidecar: - pattern: .prov.json - status: implemented - embedded_matplotlib_metadata: implemented - r_js_equivalent_sidecar: not_claimed - immutable_ledger_claim: false +research_software: + citation: CITATION.cff + cff_version: "1.2.0" -research_contract: - document: RESEARCH_CONTRACT.md - status: active - updated: 2026-08-23 - interoperability: - ro_crate: - target_version: "1.3" - status: proposed_mapping - implemented: false - cross_repo_handoff: - status: contract_only - direct_runtime_coupling: false - reproducibility_terms: - namespace: local_project_doctrine - levels: [R0_traceable, R1_replay_addressable, R2_environment_bounded, R3_reproduced] +verification: + local_contract: make test + github_actions: .github/workflows/ci.yml + python: "3.12" + ci_python_dependencies: [pyyaml, jsonschema, matplotlib, numpy, pillow] + node_and_r_runtime: optional_and_not_required_for_core_ci -external_evidence_observed: - retrieved: 2026-08-23 - matplotlib: - latest_stable_observed: "3.11.1" - latest_release_compatibility_verified_here: false - wcag: - baseline: "2.2" - non_text_contrast_scope: adjacent_required_graphical_objects +experimental_modules: + - projection + - time_crystal + - uncertainty_legend + - observer_dashboard + - superposition diff --git a/Makefile b/Makefile index c651a66..e540683 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,13 @@ -.PHONY: clean test +.PHONY: clean test test-core test-accessibility clean: rm -rf __pycache__ .pytest_cache output/* @echo "🧹 缓存和生成文件已清理" -test: +test: test-core test-accessibility + +test-core: python3 tests/test_all.py + +test-accessibility: + python3 -m unittest tests.test_accessibility -v diff --git a/README.md b/README.md index f3016f0..4fa6ec0 100644 --- a/README.md +++ b/README.md @@ -1,90 +1,192 @@ # sci-render-kit -**学术渲染工具包 — 配方驱动的多后端科学可视化** -*Academic Render Toolkit — Recipe-driven multi-backend scientific visualization* +**声明式学术可视化:把配方、出版规范、可复现性与可访问性放进同一条可审计渲染链** +*Declarative scientific visualization with publishing profiles, provenance, and auditable accessibility contracts.* + +## 当前定位 + +`sci-render-kit` 不是“自动把图画漂亮”的包装器。它把科研图件看成一个需要同时满足四类契约的研究产物: + +```text +Recipe Schema (P0) + ↓ +Aesthetics + Accessibility (P1) + ↓ +Backend capability + render + ↓ +Output integrity / metadata / provenance (P2) + ↓ +Journal profile (P3) +``` + +核心思想是:**图的颜色、线型、marker、文本替代、尺寸、DPI、输出格式和溯源都应成为可声明、可检查、可复查的事实,而不是散落在脚本里的隐含习惯。** ---- +## 快速开始 -## 核心差异 / Key Differences (vs `college-draw`) +普通配方: -| 维度 / Dimension | `college-draw` | `sci-render-kit` | -| :--- | :--- | :--- | -| **驱动模式 / Driver** | Python 工厂类 | **YAML 配方声明** | -| **渲染后端 / Backend** | 仅 Matplotlib | **Python + R + JS** | -| **代码量 / Code Volume** | ~800 行 Python | **极简适配器 + YAML** | -| **配置方式 / Config** | 硬编码期刊色板 | **声明式 profiles** | -| **质量门禁 / Quality Gate** | 运行时检查 | **配方静态验证** | -| **元数据 / Metadata** | 无 | **渲染溯源清单** | -| **输出格式 / Output** | PDF/EPS/SVG | **PNG + SVG + HTML** | +```bash +python3 sci_render.py recipes/line-chart.yaml --profile presentation --backend matplotlib +``` -## 快速开始 / Quick Start +带可访问性契约的配方: ```bash -# 1. 编写配方 / Write a recipe -# See recipes/line-chart.yaml +python3 sci_render.py recipes/accessible-line-chart.yaml --profile presentation --backend matplotlib +``` -# 2. 渲染 / Render (matplotlib 后端) -python3 sci_render.py recipes/line-chart.yaml --profile presentation --backend matplotlib +该示例会生成图件,并在既有 `.manifest.json` / `.prov.json` 之外生成同名 `.a11y.json`。 + +## 四层质量门 + +### P0 — Recipe schema + +`metadata/recipe.schema.yaml` 校验图表类型、数据、美学、输出以及新的 `accessibility` 对象。 -# 3. 语义色彩编码 / Semantic color encoding -python3 sci_render.py recipes/semantic-line-chart.yaml --profile presentation --backend matplotlib +### P1 — 美学与可访问性 -# 4. 交互式 / Interactive (需先 npm install,且本机有 node) -python3 sci_render.py recipes/line-chart-interactive.yaml --profile presentation --backend observable +已接入的主要规则: + +- `palette-contrast`:需要被辨认的色板颜色与背景保持项目要求的 ≥ 3.0 对比度 +- `text-contrast`:声明 `text_color` 时对背景 ≥ 4.5(WCAG 2.2 SC 1.4.3) +- `text-alternative`:`require_alt_text: true` 时必须提供 `alt_text`,用于支撑 SC 1.1.1 的文本替代设计 +- `non-color-cue`:`redundant_encoding: required` 时,多系列 line / scatter / bar 必须有可区分 marker / line style / hatch,支撑 SC 1.4.1“颜色不能是唯一信息通道”的设计原则 +- `declared-adjacency`:只对 `accessibility.adjacent_pairs` 声明的实际相邻且需要辨认的系列边界做 ≥ 3.0 检查,贴近 SC 1.4.11 / G209 的真实作用域 +- `palette-adjacency`:旧的 `aesthetics.adjacency_check: true` 仍保留为**项目更严格的全色对策略**;它不是 WCAG 要求所有分类色两两 3:1 +- `cvd-contrast`:Machado 2009 三类 CVD 模拟下的额外项目防护,不包装成 WCAG 条文本身 +- `palette-name`:命名色板必须命中注册表,系列图必须使用 categorical 类型 + +### P2 — 输出完整性 + +统一检查: + +- 图件存在且非空 +- 扩展名与声明格式一致 +- `.manifest.json` 存在 +- Matplotlib `.prov.json` 存在 +- 配方声明 `accessibility` 时 `.a11y.json` 存在 + +### P3 — 期刊 profile + +Nature / Science / Cell / IEEE / presentation profile 保持出版约束。P3 负责矢量格式、DPI、宽高上限等明确规则,而不是把“期刊名”当成一个模糊 style preset。 + +## Accessibility contract + +Recipe 新增: + +```yaml +accessibility: + require_alt_text: true + alt_text: "Two series diverge over four observations." + long_description: "Optional longer interpretation or data-table pointer." + redundant_encoding: required + adjacent_pairs: + - [control, treatment] ``` -门禁演示 / Gate demo(预期被拦截,展示质量门在工作): +还可以按系列覆盖非颜色线索: + +```yaml +accessibility: + redundant_encoding: required + series_styles: + control: + marker: o + line_style: "-" + treatment: + marker: s + line_style: "--" +``` -```bash -# Nature/Science profile 在 P3 门禁强制矢量输出;PNG 配方会被拒绝(exit 1) -python3 sci_render.py recipes/line-chart.yaml --profile nature --backend matplotlib -# → [期刊规范对齐] 矢量格式: nature 期望矢量格式 (.pdf/.eps),但得到 .png +Matplotlib 当前真正消费这些 series style;如果 R/Observable 配方要求 `auto` / `required` 冗余编码,统一 CLI 会返回 `BACKEND_ACCESSIBILITY_MISMATCH`,直到对应后端完成真实接线。**能力矩阵不再把“schema 支持”误写成“后端已实现”。** + +## `.a11y.json` + +当 recipe 声明 accessibility 时,统一 CLI 写出: + +```text +
.a11y.json ``` -后端依赖说明 / Backend requirements: +内容包括: + +- `sci-render-kit/a11y@1` profile +- `alt_text` / `long_description` +- redundant-encoding 模式 +- 每个系列的颜色与非颜色 cue +- 声明的 adjacent pairs +- 明确的 `conformance_claim: false` -- **matplotlib**:`pip install pyyaml jsonschema matplotlib numpy` -- **ggplot2**(可选):本机 R + `yaml`/`jsonlite`/`digest`/`ggplot2` 包;该路径未在本仓库自动化测试中做运行时验证 -- **observable**:本机 node + `npm install`(声明于 `package.json`,仅 `yaml` 一个依赖);输出能力为 HTML +这是可访问性设计与审计旁车,不等于一张 PNG、一个 PDF 或整篇论文自动获得 WCAG 合规认证。 + +## Matplotlib adapter architecture + +为避免把成熟渲染逻辑和政策规则揉成一个巨型文件: + +```text +backends/matplotlib_base.py + └─ 原有渲染 / manifest / provenance 基础实现 + +backends/matplotlib_adapter.py + └─ 公共兼容层 + + marker / line-style / hatch 冗余编码 + + alt-text metadata(格式支持时) + + accessibility manifest linkage +``` -## 核心概念 / Core Concepts +原有 `generate_python_code`、`resolve_palette` 等公共接口继续由 adapter 重导出,降低架构升级对既有调用者和测试的破坏。 -- **配方 (Recipe)**: 声明式图表定义(YAML) -- **统一调度器 (CLI)**: `sci_render.py` — 加载配方、应用 profile、执行校验、分发引擎 -- **Profile**: 期刊规范声明(字体、色板、尺寸) -- **语义色彩编码 (Semantic Color Encoding)**: 配方声明 `semantic_palette: true` 后,系列名命中语义标签(`positive`/`negative`/`stable` 等)即由 `core/color_encoding.py` 自动分配语义色(三后端一致);声明 `background` 或启用语义色板时,P1 质量门 `palette-contrast` 强制校验色板与背景的 WCAG 对比度 ≥ 3.0。示例:`recipes/semantic-line-chart.yaml` -- **可访问性校验族 (P1)**: `palette-contrast`(色板 vs 背景 ≥ 3.0,面向需要被辨认的图形对象);`text-contrast`(声明 `text_color` 时文字 vs 背景 ≥ 4.5,SC 1.4.3);`palette-adjacency`(声明 `adjacency_check: true` 时启用项目自定义的严格“分类色板两两 ≥ 3.0”策略并报告失败色对;该策略受 SC 1.4.11 / G209 的相邻边界原则启发,但比 WCAG 对“实际相邻且为理解所必需”的图形对象要求更严格,并非 WCAG 的一般性两两要求);`cvd-contrast`(Machado 2009 三色盲模拟下色板 vs 背景保持 ≥ 3.0,属于项目额外防护) -- **命名色板注册表 (`core/palettes.py`)**: `aesthetics.palette_name` 引用策展色板 —— `okabe-ito`(Okabe-Ito 2008)、`petroff10`(Petroff 2021,matplotlib ≥3.10 官方可访问色环)、`viridis`/`cividis`(顺序、CVD 安全)、Crameri 发散系 `berlin`/`managua`/`vanimo`;每条目标注语义类型(categorical/sequential/diverging)与 CVD 安全级别,非 matplotlib 内置的色阶在能力矩阵中如实标注「不可用」 -- **图件溯源 (Provenance, FAIR R1.2)**: matplotlib 后端每次渲染将溯源记录(配方 SHA-256、输入数据 SHA-256、后端版本、时间戳)内嵌进图件 metadata(PNG/SVG/PDF 原生支持),并输出同名 `.prov.json` 旁车文件(含输出文件 SHA-256);P2 门禁 `prov-exists` 强制校验。R/JS 侧为可选跟进,未声明已实现 +## Provenance 与 reproducibility + +Matplotlib 仍生成: + +- `.manifest.json`:运行参数与输出 checksum +- `.prov.json`:recipe / input / output SHA-256、运行环境与生成时间 +- 图件内嵌 provenance metadata(格式支持范围内) + +这些能力提高可追溯性与复现证据质量,但 README 不再使用“100% 可复现”这种无法由单一 manifest 保证的绝对表述。 + +## 实现状态 + +| 能力 | 状态 | +|---|---| +| Unified `sci_render.py` dispatcher | **Implemented** | +| Matplotlib base renderer | **Implemented** | +| Matplotlib accessibility policy adapter | **Implemented** | +| R ggplot2 renderer | **Implemented / runtime environment optional** | +| Observable renderer | **Implemented / node environment optional** | +| Recipe / profile / P0–P3 gates | **Implemented** | +| semantic palette / named palettes / CVD simulation | **Implemented** | +| text alternative contract + `.a11y.json` | **Implemented** | +| redundant marker/line-style/hatch rendering | **Implemented (Matplotlib)** | +| redundant series style on R/Observable | **Not Integrated** | +| Matplotlib provenance sidecar + embedded metadata | **Implemented** | +| R/Observable provenance parity | **Optional follow-up** | +| projection / time_crystal / uncertainty_legend / observer_dashboard / superposition | **Experimental** | + +## 验证 + +```bash +python -m pip install pyyaml jsonschema matplotlib numpy pillow +make test +``` -## 实现状态 / Implementation Status +`make test` 运行既有渲染/门禁/色板/期刊/provenance 测试以及新的 accessibility 契约测试。`.github/workflows/ci.yml` 在 PR 和 `main` push 上用 Python 3.12 跑同一契约。 -| Module | Status | -|--------|--------| -| `sci_render.py` CLI | **Implemented** | -| Matplotlib backend | **Implemented** | -| R ggplot2 backend | **Implemented** | -| JS Observable backend | **Implemented** | -| Profile static validation | **Implemented** | -| `core/color_encoding.py` 语义色彩编码(semantic_palette + `palette-contrast` 质量门,三后端) | **Implemented** | -| `core/cvd_simulation.py` 色盲模拟(Machado 2009 矩阵,接入 P1 `cvd-contrast`) | **Implemented** | -| `core/palettes.py` 命名色板注册表(petroff10 / viridis / cividis / Crameri 系,CVD 安全标注) | **Implemented** | -| 图件溯源内嵌 + `.prov.json` 旁车(FAIR R1.2,matplotlib;R/JS 可选跟进) | **Implemented (matplotlib)** | -| `collapse_view.py` → `core/projection.py` | **Experimental** | -| `time_crystal.py` → `core/time_crystal.py` | **Experimental** | -| `uncertainty_legend.py` → `core/uncertainty_legend.py` | **Experimental** | -| `observer_dashboard.py` → `core/observer_dashboard.py` | **Experimental** | -| `quantum_layer.py` → `core/superposition.py` | **Experimental** | +Node/R 端到端环境仍保持可选;没有对应 runtime 时不能把跳过测试包装成通过验证。 -## 配方示例 / Recipe Example +## 科研软件引用 -See `recipes/` directory for sample recipes. +仓库提供 `CITATION.cff`(Citation File Format 1.2.0)。 -## 文档 / Documentation +## 文档 - [Architecture](ARCHITECTURE.md) - [Profile Guide](profiles/README.md) +- [Examples](examples/README.md) +- [Agent Guide](AGENTS.md) +- [Manifest](MANIFEST.yaml) -## 许可 / License +## License MIT License diff --git a/backends/matplotlib_adapter.py b/backends/matplotlib_adapter.py index 40acffa..bf7177f 100644 --- a/backends/matplotlib_adapter.py +++ b/backends/matplotlib_adapter.py @@ -1,382 +1,150 @@ #!/usr/bin/env python3 -""" -Matplotlib 后端适配器 — 将 YAML 配方渲染为图表 -直接依赖 PyYAML、NumPy 和 Matplotlib;项目级还包含 jsonschema -""" - -import yaml -import json -import numpy as np -import hashlib -import sys -import os -import subprocess -from pathlib import Path -from datetime import datetime -from string import Template - -# 允许以脚本方式从任意工作目录运行(python3 backends/matplotlib_adapter.py) -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) -from core.color_encoding import CognitiveColorEncoder -from core.palettes import resolve_categorical - -# 默认色板(Okabe-Ito 色盲友好) -DEFAULT_PALETTE = [ - "#E69F00", - "#56B4E9", - "#009E73", - "#F0E442", - "#0072B2", - "#D55E00", - "#CC79A7", - "#000000", -] - -# 采用离散系列着色的图表类型(语义色板按系列名解析) -SERIES_CHART_TYPES = ("line-chart", "bar-chart", "scatter-plot", "boxplot", "histogram") - - -def resolve_palette(aesthetics: dict, chart_type: str, data: dict) -> list: - """解析有效色板。 - - 当 ``semantic_palette: true`` 时,由 core.color_encoding 的认知色彩编码器 - 按系列名的语义标签生成色板(优先于显式 palette)。 - """ - if aesthetics.get("semantic_palette") and chart_type in SERIES_CHART_TYPES: - labels = list(data.keys()) - if labels: - return CognitiveColorEncoder().resolve_series_palette(labels) - if aesthetics.get("palette_name"): - # 注册色板(core/palettes.py);名称合法性由 P1 palette-name 门禁集中校验 - colors = resolve_categorical(str(aesthetics["palette_name"])) - labels = list(data.keys()) if chart_type in SERIES_CHART_TYPES else [] - return colors[: len(labels)] if labels else colors - return aesthetics.get("palette", DEFAULT_PALETTE) - - -def load_recipe(path: str) -> dict: - with open(path, "r", encoding="utf-8") as f: - return yaml.safe_load(f) - - -def load_profile(name: str) -> dict: - profile_path = Path("profiles") / f"{name}.yaml" - if not profile_path.exists(): - return {} - with open(profile_path, "r", encoding="utf-8") as f: - return yaml.safe_load(f) - +"""Accessibility-aware Matplotlib adapter. -def validate_recipe(recipe: dict) -> list[str]: - """静态验证:配方合规检查""" - errors = [] - required = ["type", "data", "aesthetics"] - for key in required: - if key not in recipe: - errors.append(f"缺少必需字段: {key}") - if "output" not in recipe: - errors.append("缺少 output 配置") - return errors - - -def build_provenance(recipe: dict, profile: dict, recipe_path: str = None) -> dict: - """构建图件溯源记录(FAIR R1.2;同时内嵌图件 + 输出 .prov.json 旁车)。""" - import platform - - import matplotlib - - recipe_bytes = None - if recipe_path and Path(recipe_path).exists(): - recipe_bytes = Path(recipe_path).read_bytes() - data_json = json.dumps(recipe.get("data", {}), sort_keys=True, ensure_ascii=False) - return { - "schema": "sci-render-kit/provenance@1", - "standards": ["FAIR R1.2 (provenance metadata)", "log-sidecar pattern"], - "recipe_id": recipe.get("id", "unknown"), - "recipe_sha256": "sha256:" + hashlib.sha256(recipe_bytes).hexdigest() - if recipe_bytes - else "none", - "input_data_sha256": "sha256:" - + hashlib.sha256(data_json.encode("utf-8")).hexdigest(), - "generator": "sci-render-kit", - "backend": "matplotlib", - "backend_version": matplotlib.__version__, - "python_version": platform.python_version(), - "numpy_version": np.__version__, - "profile": profile.get("name", "default"), - "generated_at": datetime.now().isoformat(), - } - - -def _savefig_metadata(provenance: dict, fmt: str, recipe: dict): - """按输出格式构造 savefig metadata(matplotlib 3.10+ 原生支持)。 - - PNG 支持任意自定义文本键(PIL 可读回验证);PDF/SVG 仅接受标准键, - 溯源 JSON 放入 Keywords。返回的 dict 将 repr 进生成脚本。 - """ - if not provenance: - return None - prov_json = json.dumps(provenance, ensure_ascii=False, separators=(",", ":")) - creator = f"sci-render-kit/matplotlib@{provenance['backend_version']}" - title = str(recipe.get("id", "figure")) - if fmt == "png": - return {"srk:provenance": prov_json, "Software": creator} - if fmt == "pdf": - return { - "Title": title, - "Author": "sci-render-kit", - "Subject": "recipe-driven scientific rendering", - "Keywords": prov_json, - "Creator": creator, - } - if fmt == "svg": - return { - "Title": title, - "Creator": creator, - "Description": "recipe-driven scientific rendering", - "Keywords": prov_json, - } - return None +The mature renderer/provenance implementation lives in ``matplotlib_base.py``. +This public adapter preserves the established module API while adding a policy +layer for non-color redundant encoding and text-alternative metadata. +""" +from __future__ import annotations -def generate_python_code(recipe: dict, profile: dict, provenance: dict = None) -> str: - """将配方 + 配置转换为 Python 代码""" - code_template = '''#!/usr/bin/env python3 -"""自动生成 — 由 sci-render-kit 从配方渲染""" -import matplotlib -matplotlib.use('Agg') -import matplotlib.pyplot as plt -import numpy as np +import argparse +import json from pathlib import Path -# 配置 -plt.rcParams.update(${rc_params}) - -# 数据 -data = ${data} - -# 创建图表 -fig, ax = plt.subplots(figsize=${figsize}) - -${render_logic} - -# 保存 -Path('${output_dir}').mkdir(parents=True, exist_ok=True) -fig.savefig('${output_path}', dpi=${dpi}, format='${format}', - bbox_inches='tight', pad_inches=0.05, metadata=${savefig_metadata}) -plt.close(fig) -print(f"已保存: ${output_path}") -''' +from backends import matplotlib_base as base +from core.accessibility import accessibility_config, resolve_series_styles - # 合并配置 - aesthetics = {**profile.get("aesthetics", {}), **recipe.get("aesthetics", {})} - rc_params = { - "font.family": aesthetics.get("font", "sans-serif"), - "font.size": aesthetics.get("font_size", 10), - "axes.linewidth": aesthetics.get("axes_linewidth", 0.8), - "lines.linewidth": aesthetics.get("line_width", 1.2), - "savefig.dpi": max(aesthetics.get("dpi", 300), 300), - } +# Preserve the public API used by repository tests and downstream callers. +DEFAULT_PALETTE = base.DEFAULT_PALETTE +SERIES_CHART_TYPES = base.SERIES_CHART_TYPES +resolve_palette = base.resolve_palette +load_recipe = base.load_recipe +load_profile = base.load_profile +validate_recipe = base.validate_recipe +build_provenance = base.build_provenance +generate_python_code = base.generate_python_code +generate_render_logic = base.generate_render_logic +write_manifest = base.write_manifest +write_provenance_sidecar = base.write_provenance_sidecar - figsize = aesthetics.get("figsize", [6.0, 4.0]) +_ACTIVE_ACCESSIBILITY = {} +_ORIGINAL_RENDER_LOGIC = base.generate_render_logic +_ORIGINAL_METADATA = base._savefig_metadata - # 根据图表类型生成渲染逻辑 - chart_type = recipe["type"] - render_logic = generate_render_logic(chart_type, recipe["data"], aesthetics) - output = recipe["output"] - output_path = Path(output.get("dir", "output")) / output.get( - "filename", "figure.png" - ) +def _accessible_render_logic(chart_type: str, data: dict, aesthetics: dict) -> str: + labels = list(data.keys()) + styles = resolve_series_styles(labels, _ACTIVE_ACCESSIBILITY) + if not styles or chart_type not in {"line-chart", "scatter-plot", "bar-chart"}: + return _ORIGINAL_RENDER_LOGIC(chart_type, data, aesthetics) - savefig_metadata = _savefig_metadata( - provenance, output.get("format", "png").lower(), recipe - ) - - t = Template(code_template) - return t.substitute( - rc_params=json.dumps(rc_params), - data=json.dumps(recipe["data"]), - figsize=figsize, - render_logic=render_logic, - output_dir=str(output_path.parent), - output_path=str(output_path), - dpi=rc_params["savefig.dpi"], - format=output.get("format", "png").lower(), - savefig_metadata=repr(savefig_metadata), - ) - - -def generate_render_logic(chart_type: str, data: dict, aesthetics: dict) -> str: - """根据图表类型生成 matplotlib 渲染逻辑""" - palette = resolve_palette(aesthetics, chart_type, data) + palette = base.resolve_palette(aesthetics, chart_type, data) + lines = [] if chart_type == "line-chart": - lines = [] for i, (label, values) in enumerate(data.items()): color = palette[i % len(palette)] + style = styles[label] lines.append(f"x = np.arange(len({json.dumps(values)}))") lines.append( - f'ax.plot(x, {json.dumps(values)}, color="{color}", linewidth=1.0, label="{label}", marker="o", markersize=2.5)' + "ax.plot(x, {values}, color={color}, linewidth=1.0, label={label}, " + "marker={marker}, linestyle={line_style}, markersize=3.5)".format( + values=json.dumps(values), + color=json.dumps(color), + label=json.dumps(label), + marker=json.dumps(style.get("marker", "o")), + line_style=json.dumps(style.get("line_style", "-")), + ) ) lines.append("ax.legend(frameon=False)") return "\n".join(lines) - elif chart_type == "bar-chart": - lines = [] - categories = list(data.keys()) - values = list(data.values()) - n = len(categories) - bar_width = 0.6 - lines.append(f"x = np.arange({n})") - for i, (label, val) in enumerate(zip(categories, values)): - color = palette[i % len(palette)] - lines.append( - f'ax.bar(x[{i}] + {bar_width / 2}, {val}, width={bar_width}, color="{color}", edgecolor="black", linewidth=0.5)' - ) - lines.append(f"ax.set_xticks(x + {bar_width / 2})") - lines.append(f"ax.set_xticklabels({json.dumps(categories)})") - return "\n".join(lines) - - elif chart_type == "scatter-plot": - lines = [] - for i, (label, (x, y)) in enumerate(data.items()): + if chart_type == "scatter-plot": + for i, (label, pair) in enumerate(data.items()): + x, y = pair color = palette[i % len(palette)] + style = styles[label] lines.append( - f'ax.scatter({json.dumps(x)}, {json.dumps(y)}, c="{color}", s=16, label="{label}", edgecolors="black", linewidths=0.3)' + "ax.scatter({x}, {y}, c={color}, s=22, label={label}, marker={marker}, " + "edgecolors=\"black\", linewidths=0.4)".format( + x=json.dumps(x), y=json.dumps(y), color=json.dumps(color), + label=json.dumps(label), marker=json.dumps(style.get("marker", "o")), + ) ) lines.append("ax.legend(frameon=False)") return "\n".join(lines) - elif chart_type == "heatmap": - lines = [] - matrix = data.get("matrix", []) - row_labels = data.get("row_labels", []) - col_labels = data.get("col_labels", []) - cmap = aesthetics.get("cmap", "viridis") - lines.append(f'cax = ax.imshow({json.dumps(matrix)}, cmap="{cmap}")') - lines.append(f"fig.colorbar(cax)") - if col_labels: - lines.append(f"ax.set_xticks(np.arange(len({json.dumps(col_labels)})))") - lines.append(f"ax.set_xticklabels({json.dumps(col_labels)})") - if row_labels: - lines.append(f"ax.set_yticks(np.arange(len({json.dumps(row_labels)})))") - lines.append(f"ax.set_yticklabels({json.dumps(row_labels)})") - return "\n".join(lines) - - elif chart_type == "boxplot": - lines = [] - labels = list(data.keys()) - values = list(data.values()) - lines.append( - f"bplot = ax.boxplot({json.dumps(values)}, patch_artist=True, labels={json.dumps(labels)})" - ) - lines.append(f"colors = {json.dumps(palette[: len(labels)])}") - lines.append('for patch, color in zip(bplot["boxes"], colors):') - lines.append(" patch.set_facecolor(color)") - lines.append(" patch.set_alpha(0.7)") - return "\n".join(lines) - - elif chart_type == "histogram": - lines = [] - values = data.get("values", []) - bins = aesthetics.get("bins", 10) - color = palette[0] if palette else "#1f77b4" + categories = list(data.keys()) + values = list(data.values()) + bar_width = 0.6 + lines.append(f"x = np.arange({len(categories)})") + for i, (label, value) in enumerate(zip(categories, values)): + color = palette[i % len(palette)] + hatch = styles[label].get("hatch", "") lines.append( - f'ax.hist({json.dumps(values)}, bins={bins}, color="{color}", edgecolor="black", alpha=0.7)' + "ax.bar(x[{i}] + {offset}, {value}, width={width}, color={color}, " + "edgecolor=\"black\", linewidth=0.6, hatch={hatch})".format( + i=i, offset=bar_width / 2, value=json.dumps(value), width=bar_width, + color=json.dumps(color), hatch=json.dumps(hatch), + ) ) - return "\n".join(lines) + lines.append(f"ax.set_xticks(x + {bar_width / 2})") + lines.append(f"ax.set_xticklabels({json.dumps(categories)})") + return "\n".join(lines) - else: - return f"# TODO: 实现 {chart_type} 的渲染逻辑" - -def write_manifest(recipe: dict, profile: dict, output_path: str) -> None: - """输出渲染溯源元数据""" - manifest = { - "generated_at": datetime.now().isoformat(), - "generator": "sci-render-kit/matplotlib", - "recipe": recipe.get("id", "unknown"), - "profile": profile.get("name", "default"), - "backend": "matplotlib", - "output": output_path, - "checksum": "sha256:" - + hashlib.sha256(open(output_path, "rb").read()).hexdigest() - if Path(output_path).exists() - else "none", - "parameters": { - "aesthetics": recipe.get("aesthetics", {}), - "data_keys": list(recipe.get("data", {}).keys()), - }, - } - manifest_path = Path(output_path).with_suffix(".manifest.json") - with open(manifest_path, "w", encoding="utf-8") as f: - json.dump(manifest, f, indent=2, ensure_ascii=False) - - -def write_provenance_sidecar(provenance: dict, output_path: str) -> None: - """输出 .prov.json 溯源旁车文件(含输出文件 SHA-256)。""" - record = dict(provenance) - record["output"] = output_path - record["output_sha256"] = ( - "sha256:" + hashlib.sha256(open(output_path, "rb").read()).hexdigest() - if Path(output_path).exists() - else "none" - ) - sidecar_path = Path(output_path).with_suffix(".prov.json") - with open(sidecar_path, "w", encoding="utf-8") as f: - json.dump(record, f, indent=2, ensure_ascii=False) +def _accessible_metadata(provenance: dict, fmt: str, recipe: dict): + metadata = dict(_ORIGINAL_METADATA(provenance, fmt, recipe) or {}) + alt_text = str(accessibility_config(recipe).get("alt_text") or "").strip() + if not alt_text: + return metadata or None + if fmt == "png": + metadata["srk:alt-text"] = alt_text + elif fmt == "svg": + metadata["Description"] = alt_text + elif fmt == "pdf": + metadata["Subject"] = alt_text + return metadata def render(recipe_path: str, profile_name: str = "nature") -> None: - """主渲染入口""" - recipe = load_recipe(recipe_path) - - profile = load_profile(profile_name) - - # 溯源记录:内嵌图件 metadata + .prov.json 旁车(FAIR R1.2) - provenance = build_provenance(recipe, profile, recipe_path) - - # 生成代码 - code = generate_python_code(recipe, profile, provenance) - - # 输出到临时文件 - output = recipe["output"] - output_dir = Path(output.get("dir", "output")) - output_dir.mkdir(parents=True, exist_ok=True) - - script_path = output_dir / "_generated_render.py" - with open(script_path, "w", encoding="utf-8") as f: - f.write(code) - - print(f"✅ 已生成渲染脚本: {script_path}") - print(f"📋 运行以下命令执行渲染:") - print(f" python {script_path}") + global _ACTIVE_ACCESSIBILITY + recipe = base.load_recipe(recipe_path) + _ACTIVE_ACCESSIBILITY = accessibility_config(recipe) + base.generate_render_logic = _accessible_render_logic + base._savefig_metadata = _accessible_metadata try: - subprocess.run(["python3", str(script_path)], check=True) + base.render(recipe_path, profile_name) finally: - # Hygiene: clean up generated execute script (best-effort; - # missing_ok 容忍网络/overlay 文件系统上 exists→unlink 的竞态) - try: - script_path.unlink(missing_ok=True) - print(f"🧹 已清理临时脚本: {script_path}") - except OSError: - pass - - # 写入 manifest(预览版) - output_path = output_dir / output.get("filename", "figure.png") - write_manifest(recipe, profile, str(output_path)) - write_provenance_sidecar(provenance, str(output_path)) + base.generate_render_logic = _ORIGINAL_RENDER_LOGIC + base._savefig_metadata = _ORIGINAL_METADATA + _ACTIVE_ACCESSIBILITY = {} + + # Extend, never replace, the existing reproducibility manifest. + output = recipe.get("output", {}) + output_path = Path(output.get("dir", "output")) / output.get("filename", "figure.png") + manifest_path = output_path.with_suffix(".manifest.json") + if manifest_path.exists() and "accessibility" in recipe: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["accessibility"] = { + "profile": "sci-render-kit/a11y@1", + "sidecar": output_path.with_suffix(".a11y.json").name, + "redundant_encoding": accessibility_config(recipe).get("redundant_encoding", "off"), + "alt_text_embedded_where_supported": bool( + str(accessibility_config(recipe).get("alt_text") or "").strip() + ), + } + manifest_path.write_text( + json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" + ) if __name__ == "__main__": - import argparse - - parser = argparse.ArgumentParser(description="sci-render-kit matplotlib 后端") - parser.add_argument("action", choices=["render"], help="操作") - parser.add_argument("recipe", help="配方文件路径") - parser.add_argument("--profile", default="nature", help="配置文件名") + parser = argparse.ArgumentParser(description="sci-render-kit accessibility-aware matplotlib backend") + parser.add_argument("action", choices=["render"]) + parser.add_argument("recipe") + parser.add_argument("--profile", default="nature") args = parser.parse_args() - if args.action == "render": render(args.recipe, args.profile) diff --git a/backends/matplotlib_base.py b/backends/matplotlib_base.py new file mode 100644 index 0000000..40acffa --- /dev/null +++ b/backends/matplotlib_base.py @@ -0,0 +1,382 @@ +#!/usr/bin/env python3 +""" +Matplotlib 后端适配器 — 将 YAML 配方渲染为图表 +直接依赖 PyYAML、NumPy 和 Matplotlib;项目级还包含 jsonschema +""" + +import yaml +import json +import numpy as np +import hashlib +import sys +import os +import subprocess +from pathlib import Path +from datetime import datetime +from string import Template + +# 允许以脚本方式从任意工作目录运行(python3 backends/matplotlib_adapter.py) +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from core.color_encoding import CognitiveColorEncoder +from core.palettes import resolve_categorical + +# 默认色板(Okabe-Ito 色盲友好) +DEFAULT_PALETTE = [ + "#E69F00", + "#56B4E9", + "#009E73", + "#F0E442", + "#0072B2", + "#D55E00", + "#CC79A7", + "#000000", +] + +# 采用离散系列着色的图表类型(语义色板按系列名解析) +SERIES_CHART_TYPES = ("line-chart", "bar-chart", "scatter-plot", "boxplot", "histogram") + + +def resolve_palette(aesthetics: dict, chart_type: str, data: dict) -> list: + """解析有效色板。 + + 当 ``semantic_palette: true`` 时,由 core.color_encoding 的认知色彩编码器 + 按系列名的语义标签生成色板(优先于显式 palette)。 + """ + if aesthetics.get("semantic_palette") and chart_type in SERIES_CHART_TYPES: + labels = list(data.keys()) + if labels: + return CognitiveColorEncoder().resolve_series_palette(labels) + if aesthetics.get("palette_name"): + # 注册色板(core/palettes.py);名称合法性由 P1 palette-name 门禁集中校验 + colors = resolve_categorical(str(aesthetics["palette_name"])) + labels = list(data.keys()) if chart_type in SERIES_CHART_TYPES else [] + return colors[: len(labels)] if labels else colors + return aesthetics.get("palette", DEFAULT_PALETTE) + + +def load_recipe(path: str) -> dict: + with open(path, "r", encoding="utf-8") as f: + return yaml.safe_load(f) + + +def load_profile(name: str) -> dict: + profile_path = Path("profiles") / f"{name}.yaml" + if not profile_path.exists(): + return {} + with open(profile_path, "r", encoding="utf-8") as f: + return yaml.safe_load(f) + + +def validate_recipe(recipe: dict) -> list[str]: + """静态验证:配方合规检查""" + errors = [] + required = ["type", "data", "aesthetics"] + for key in required: + if key not in recipe: + errors.append(f"缺少必需字段: {key}") + if "output" not in recipe: + errors.append("缺少 output 配置") + return errors + + +def build_provenance(recipe: dict, profile: dict, recipe_path: str = None) -> dict: + """构建图件溯源记录(FAIR R1.2;同时内嵌图件 + 输出 .prov.json 旁车)。""" + import platform + + import matplotlib + + recipe_bytes = None + if recipe_path and Path(recipe_path).exists(): + recipe_bytes = Path(recipe_path).read_bytes() + data_json = json.dumps(recipe.get("data", {}), sort_keys=True, ensure_ascii=False) + return { + "schema": "sci-render-kit/provenance@1", + "standards": ["FAIR R1.2 (provenance metadata)", "log-sidecar pattern"], + "recipe_id": recipe.get("id", "unknown"), + "recipe_sha256": "sha256:" + hashlib.sha256(recipe_bytes).hexdigest() + if recipe_bytes + else "none", + "input_data_sha256": "sha256:" + + hashlib.sha256(data_json.encode("utf-8")).hexdigest(), + "generator": "sci-render-kit", + "backend": "matplotlib", + "backend_version": matplotlib.__version__, + "python_version": platform.python_version(), + "numpy_version": np.__version__, + "profile": profile.get("name", "default"), + "generated_at": datetime.now().isoformat(), + } + + +def _savefig_metadata(provenance: dict, fmt: str, recipe: dict): + """按输出格式构造 savefig metadata(matplotlib 3.10+ 原生支持)。 + + PNG 支持任意自定义文本键(PIL 可读回验证);PDF/SVG 仅接受标准键, + 溯源 JSON 放入 Keywords。返回的 dict 将 repr 进生成脚本。 + """ + if not provenance: + return None + prov_json = json.dumps(provenance, ensure_ascii=False, separators=(",", ":")) + creator = f"sci-render-kit/matplotlib@{provenance['backend_version']}" + title = str(recipe.get("id", "figure")) + if fmt == "png": + return {"srk:provenance": prov_json, "Software": creator} + if fmt == "pdf": + return { + "Title": title, + "Author": "sci-render-kit", + "Subject": "recipe-driven scientific rendering", + "Keywords": prov_json, + "Creator": creator, + } + if fmt == "svg": + return { + "Title": title, + "Creator": creator, + "Description": "recipe-driven scientific rendering", + "Keywords": prov_json, + } + return None + + +def generate_python_code(recipe: dict, profile: dict, provenance: dict = None) -> str: + """将配方 + 配置转换为 Python 代码""" + code_template = '''#!/usr/bin/env python3 +"""自动生成 — 由 sci-render-kit 从配方渲染""" +import matplotlib +matplotlib.use('Agg') +import matplotlib.pyplot as plt +import numpy as np +from pathlib import Path + +# 配置 +plt.rcParams.update(${rc_params}) + +# 数据 +data = ${data} + +# 创建图表 +fig, ax = plt.subplots(figsize=${figsize}) + +${render_logic} + +# 保存 +Path('${output_dir}').mkdir(parents=True, exist_ok=True) +fig.savefig('${output_path}', dpi=${dpi}, format='${format}', + bbox_inches='tight', pad_inches=0.05, metadata=${savefig_metadata}) +plt.close(fig) +print(f"已保存: ${output_path}") +''' + + # 合并配置 + aesthetics = {**profile.get("aesthetics", {}), **recipe.get("aesthetics", {})} + rc_params = { + "font.family": aesthetics.get("font", "sans-serif"), + "font.size": aesthetics.get("font_size", 10), + "axes.linewidth": aesthetics.get("axes_linewidth", 0.8), + "lines.linewidth": aesthetics.get("line_width", 1.2), + "savefig.dpi": max(aesthetics.get("dpi", 300), 300), + } + + figsize = aesthetics.get("figsize", [6.0, 4.0]) + + # 根据图表类型生成渲染逻辑 + chart_type = recipe["type"] + render_logic = generate_render_logic(chart_type, recipe["data"], aesthetics) + + output = recipe["output"] + output_path = Path(output.get("dir", "output")) / output.get( + "filename", "figure.png" + ) + + savefig_metadata = _savefig_metadata( + provenance, output.get("format", "png").lower(), recipe + ) + + t = Template(code_template) + return t.substitute( + rc_params=json.dumps(rc_params), + data=json.dumps(recipe["data"]), + figsize=figsize, + render_logic=render_logic, + output_dir=str(output_path.parent), + output_path=str(output_path), + dpi=rc_params["savefig.dpi"], + format=output.get("format", "png").lower(), + savefig_metadata=repr(savefig_metadata), + ) + + +def generate_render_logic(chart_type: str, data: dict, aesthetics: dict) -> str: + """根据图表类型生成 matplotlib 渲染逻辑""" + palette = resolve_palette(aesthetics, chart_type, data) + + if chart_type == "line-chart": + lines = [] + for i, (label, values) in enumerate(data.items()): + color = palette[i % len(palette)] + lines.append(f"x = np.arange(len({json.dumps(values)}))") + lines.append( + f'ax.plot(x, {json.dumps(values)}, color="{color}", linewidth=1.0, label="{label}", marker="o", markersize=2.5)' + ) + lines.append("ax.legend(frameon=False)") + return "\n".join(lines) + + elif chart_type == "bar-chart": + lines = [] + categories = list(data.keys()) + values = list(data.values()) + n = len(categories) + bar_width = 0.6 + lines.append(f"x = np.arange({n})") + for i, (label, val) in enumerate(zip(categories, values)): + color = palette[i % len(palette)] + lines.append( + f'ax.bar(x[{i}] + {bar_width / 2}, {val}, width={bar_width}, color="{color}", edgecolor="black", linewidth=0.5)' + ) + lines.append(f"ax.set_xticks(x + {bar_width / 2})") + lines.append(f"ax.set_xticklabels({json.dumps(categories)})") + return "\n".join(lines) + + elif chart_type == "scatter-plot": + lines = [] + for i, (label, (x, y)) in enumerate(data.items()): + color = palette[i % len(palette)] + lines.append( + f'ax.scatter({json.dumps(x)}, {json.dumps(y)}, c="{color}", s=16, label="{label}", edgecolors="black", linewidths=0.3)' + ) + lines.append("ax.legend(frameon=False)") + return "\n".join(lines) + + elif chart_type == "heatmap": + lines = [] + matrix = data.get("matrix", []) + row_labels = data.get("row_labels", []) + col_labels = data.get("col_labels", []) + cmap = aesthetics.get("cmap", "viridis") + lines.append(f'cax = ax.imshow({json.dumps(matrix)}, cmap="{cmap}")') + lines.append(f"fig.colorbar(cax)") + if col_labels: + lines.append(f"ax.set_xticks(np.arange(len({json.dumps(col_labels)})))") + lines.append(f"ax.set_xticklabels({json.dumps(col_labels)})") + if row_labels: + lines.append(f"ax.set_yticks(np.arange(len({json.dumps(row_labels)})))") + lines.append(f"ax.set_yticklabels({json.dumps(row_labels)})") + return "\n".join(lines) + + elif chart_type == "boxplot": + lines = [] + labels = list(data.keys()) + values = list(data.values()) + lines.append( + f"bplot = ax.boxplot({json.dumps(values)}, patch_artist=True, labels={json.dumps(labels)})" + ) + lines.append(f"colors = {json.dumps(palette[: len(labels)])}") + lines.append('for patch, color in zip(bplot["boxes"], colors):') + lines.append(" patch.set_facecolor(color)") + lines.append(" patch.set_alpha(0.7)") + return "\n".join(lines) + + elif chart_type == "histogram": + lines = [] + values = data.get("values", []) + bins = aesthetics.get("bins", 10) + color = palette[0] if palette else "#1f77b4" + lines.append( + f'ax.hist({json.dumps(values)}, bins={bins}, color="{color}", edgecolor="black", alpha=0.7)' + ) + return "\n".join(lines) + + else: + return f"# TODO: 实现 {chart_type} 的渲染逻辑" + + +def write_manifest(recipe: dict, profile: dict, output_path: str) -> None: + """输出渲染溯源元数据""" + manifest = { + "generated_at": datetime.now().isoformat(), + "generator": "sci-render-kit/matplotlib", + "recipe": recipe.get("id", "unknown"), + "profile": profile.get("name", "default"), + "backend": "matplotlib", + "output": output_path, + "checksum": "sha256:" + + hashlib.sha256(open(output_path, "rb").read()).hexdigest() + if Path(output_path).exists() + else "none", + "parameters": { + "aesthetics": recipe.get("aesthetics", {}), + "data_keys": list(recipe.get("data", {}).keys()), + }, + } + manifest_path = Path(output_path).with_suffix(".manifest.json") + with open(manifest_path, "w", encoding="utf-8") as f: + json.dump(manifest, f, indent=2, ensure_ascii=False) + + +def write_provenance_sidecar(provenance: dict, output_path: str) -> None: + """输出 .prov.json 溯源旁车文件(含输出文件 SHA-256)。""" + record = dict(provenance) + record["output"] = output_path + record["output_sha256"] = ( + "sha256:" + hashlib.sha256(open(output_path, "rb").read()).hexdigest() + if Path(output_path).exists() + else "none" + ) + sidecar_path = Path(output_path).with_suffix(".prov.json") + with open(sidecar_path, "w", encoding="utf-8") as f: + json.dump(record, f, indent=2, ensure_ascii=False) + + +def render(recipe_path: str, profile_name: str = "nature") -> None: + """主渲染入口""" + recipe = load_recipe(recipe_path) + + profile = load_profile(profile_name) + + # 溯源记录:内嵌图件 metadata + .prov.json 旁车(FAIR R1.2) + provenance = build_provenance(recipe, profile, recipe_path) + + # 生成代码 + code = generate_python_code(recipe, profile, provenance) + + # 输出到临时文件 + output = recipe["output"] + output_dir = Path(output.get("dir", "output")) + output_dir.mkdir(parents=True, exist_ok=True) + + script_path = output_dir / "_generated_render.py" + with open(script_path, "w", encoding="utf-8") as f: + f.write(code) + + print(f"✅ 已生成渲染脚本: {script_path}") + print(f"📋 运行以下命令执行渲染:") + print(f" python {script_path}") + try: + subprocess.run(["python3", str(script_path)], check=True) + finally: + # Hygiene: clean up generated execute script (best-effort; + # missing_ok 容忍网络/overlay 文件系统上 exists→unlink 的竞态) + try: + script_path.unlink(missing_ok=True) + print(f"🧹 已清理临时脚本: {script_path}") + except OSError: + pass + + # 写入 manifest(预览版) + output_path = output_dir / output.get("filename", "figure.png") + write_manifest(recipe, profile, str(output_path)) + write_provenance_sidecar(provenance, str(output_path)) + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description="sci-render-kit matplotlib 后端") + parser.add_argument("action", choices=["render"], help="操作") + parser.add_argument("recipe", help="配方文件路径") + parser.add_argument("--profile", default="nature", help="配置文件名") + args = parser.parse_args() + + if args.action == "render": + render(args.recipe, args.profile) diff --git a/core/accessibility.py b/core/accessibility.py new file mode 100644 index 0000000..565aafc --- /dev/null +++ b/core/accessibility.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Accessibility contract helpers for recipe-driven scientific figures. + +The module implements project-level support for WCAG 2.2 design principles: +- SC 1.1.1: text alternatives for non-text content +- SC 1.4.1: do not use color as the only visual means of conveying information +- SC 1.4.11: contrast for graphical objects required for understanding + +These helpers support better figures; they do not by themselves constitute a +WCAG conformance claim for a paper, PDF, website, or publishing platform. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Dict, Iterable, List + +PROFILE = "sci-render-kit/a11y@1" + +STYLE_CYCLE = [ + {"marker": "o", "line_style": "-", "hatch": ""}, + {"marker": "s", "line_style": "--", "hatch": "//"}, + {"marker": "^", "line_style": "-.", "hatch": "\\\\"}, + {"marker": "D", "line_style": ":", "hatch": "xx"}, + {"marker": "v", "line_style": "-", "hatch": ".."}, + {"marker": "P", "line_style": "--", "hatch": "++"}, + {"marker": "X", "line_style": "-.", "hatch": "oo"}, + {"marker": "*", "line_style": ":", "hatch": "**"}, +] + + +def accessibility_config(recipe: dict) -> dict: + value = recipe.get("accessibility") or {} + return value if isinstance(value, dict) else {} + + +def resolve_series_styles(labels: Iterable[str], accessibility: dict) -> Dict[str, dict]: + """Resolve declared or generated non-color cues for series labels.""" + labels = list(labels) + declared = accessibility.get("series_styles") or {} + mode = accessibility.get("redundant_encoding", "off") + if mode == "off" and not declared: + return {} + + resolved: Dict[str, dict] = {} + for index, label in enumerate(labels): + base = dict(STYLE_CYCLE[index % len(STYLE_CYCLE)]) + override = declared.get(label) or {} + if isinstance(override, dict): + base.update({k: v for k, v in override.items() if k in {"marker", "line_style", "hatch"}}) + resolved[label] = base + return resolved + + +def distinct_style_signatures(styles: Dict[str, dict]) -> int: + return len({ + (style.get("marker"), style.get("line_style"), style.get("hatch")) + for style in styles.values() + }) + + +def build_accessibility_manifest(recipe: dict, palette: List[str]) -> dict: + cfg = accessibility_config(recipe) + labels = list((recipe.get("data") or {}).keys()) + styles = resolve_series_styles(labels, cfg) + series = [] + for index, label in enumerate(labels): + item = {"label": label} + if palette: + item["color"] = palette[index % len(palette)] + if label in styles: + item["non_color_cue"] = styles[label] + series.append(item) + + return { + "profile": PROFILE, + "recipe_id": recipe.get("id", "unknown"), + "chart_type": recipe.get("type"), + "alt_text": cfg.get("alt_text"), + "long_description": cfg.get("long_description"), + "redundant_encoding": cfg.get("redundant_encoding", "off"), + "adjacent_pairs": cfg.get("adjacent_pairs", []), + "series": series, + "standards_scope": { + "wcag_2_2": ["1.1.1 text alternative support", "1.4.1 non-color cue support", "1.4.11 declared adjacent-object contrast support"], + "conformance_claim": False, + }, + } + + +def write_accessibility_manifest(output_path: Path, manifest: dict) -> Path: + path = output_path.with_suffix(".a11y.json") + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(".a11y.json.tmp") + tmp.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + os.replace(tmp, path) + return path diff --git a/examples/README.md b/examples/README.md index 5883ee4..59133b9 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,54 +1,70 @@ -# sci-render-kit 示例 / Example +# Examples -本目录包含 sci-render-kit 的配方使用示例。 -所有渲染均应通过统一 CLI `sci_render.py` 进行(直接调用后端适配器已废弃, -会直接绕过 P0–P3 质量门)。`--profile` 接收 profile **名称** -(如 `nature`),CLI 自动从 `profiles/` 目录解析。 - -## 示例命令 / Example Commands +## Baseline render ```bash -# 1. 渲染折线图(matplotlib 后端,presentation 配置) python3 sci_render.py recipes/line-chart.yaml --profile presentation --backend matplotlib +``` + +## Accessible multi-series render + +```bash +python3 sci_render.py recipes/accessible-line-chart.yaml --profile presentation --backend matplotlib +``` + +The accessible example declares: -# 2. 语义色彩编码演示(语义色板 + palette-contrast 对比度门禁) -python3 sci_render.py recipes/semantic-line-chart.yaml --profile presentation --backend matplotlib +- short and long text alternatives, +- `redundant_encoding: required`, +- an actual adjacent series pair, +- explicit output/provenance settings. -# 3. 交互式 HTML(observable 后端,需 node + npm install) -python3 sci_render.py recipes/line-chart-interactive.yaml --profile presentation --backend observable +The Matplotlib path produces the figure plus `.manifest.json`, `.prov.json`, and `.a11y.json`. The series are differentiated by color **and** marker/line style. -# 4. ggplot2 后端(可选,需本机 R 环境) -python3 sci_render.py recipes/line-chart.yaml --profile presentation --backend ggplot2 +## Strict project-wide palette-pair policy + +The older all-pairs policy is still available: + +```yaml +aesthetics: + palette: ["#000000", "#0072B2"] + adjacency_check: true +``` + +This is intentionally stricter than WCAG SC 1.4.11's actual-adjacent-object scope. Use `accessibility.adjacent_pairs` when you want the WCAG-scoped boundary model. + +## Text alternative requirement + +```yaml +accessibility: + require_alt_text: true + alt_text: "Treatment increases more quickly than control." +``` -# 5. 批量渲染所有 PNG 配方 -for recipe in recipes/*.yaml; do - case "$recipe" in - *interactive*) continue ;; # html 配方仅 observable 后端支持 - esac - python3 sci_render.py "$recipe" --profile presentation --backend matplotlib -done +A missing `alt_text` in this mode is rejected before rendering. + +## Backend capability honesty + +A recipe requesting non-color redundant encoding on a backend that has not implemented those style mappings fails before dispatch: + +```text +BACKEND_ACCESSIBILITY_MISMATCH +``` + +Today Matplotlib implements redundant series styling. R/Observable support for that specific capability remains Not Integrated. + +## Journal gate demo + +```bash +python3 sci_render.py recipes/line-chart.yaml --profile nature --backend matplotlib +``` + +A PNG recipe can be rejected by the Nature P3 vector-format gate. This is a deliberate contract failure, not a renderer crash. + +## Verification + +```bash +make test ``` -## 配方类型 / Recipe Types - -| 配方 | 适用场景 | 后端 | -|------|---------|------| -| line-chart | 时间序列趋势 | matplotlib / ggplot2 / observable | -| bar-chart | 分类对比 | matplotlib / ggplot2 / observable | -| scatter-plot | 相关性分析 | matplotlib / ggplot2 / observable | -| heatmap | 矩阵热力图 | matplotlib / ggplot2 / observable | -| boxplot | 分布对比 | matplotlib / ggplot2 / observable | -| histogram | 单变量分布 | matplotlib / ggplot2 / observable | -| semantic-line-chart | 语义色彩编码 | matplotlib / ggplot2 / observable | -| line-chart-interactive | 交互式 HTML 输出 | observable | - -注:输出格式受后端能力约束(dispatch 前强制校验): -matplotlib / ggplot2 支持 `png`/`svg`/`pdf`,observable 支持 `html`。 - -## 期刊配置 / Journal Profiles - -- nature.yaml — Nature 期刊风格(P3 强制矢量输出 PDF/EPS,DPI ≥ 300,版宽 ≤ 7.2in) -- science.yaml — Science 期刊风格(P3 强制矢量输出 PDF/EPS,DPI ≥ 600,版宽 ≤ 6.85in) -- cell.yaml — Cell 期刊风格(P3 强制矢量输出 PDF/EPS,仅 Arial,线图 DPI ≥ 1000) -- ieee.yaml — IEEE 会议论文风格(版宽 ≤ 7.17in,版面高度 ≤ 8.8in) -- presentation.yaml — 演示文稿风格(位图友好,适合快速验证配方) +The repository contract covers legacy rendering/provenance and the new accessibility profile. R/Node E2E checks remain environment-dependent and may skip when runtimes are absent. diff --git a/metadata/recipe.schema.yaml b/metadata/recipe.schema.yaml index 529d0fe..282b29b 100644 --- a/metadata/recipe.schema.yaml +++ b/metadata/recipe.schema.yaml @@ -1,12 +1,7 @@ title: Recipe Schema description: "图表配方的 YAML Schema 定义 | Chart recipe YAML schema definition" type: object -required: -- id -- type -- data -- aesthetics -- output +required: [id, type, data, aesthetics, output] properties: id: type: string @@ -16,13 +11,7 @@ properties: description: "人类可读名称" type: type: string - enum: - - line-chart - - bar-chart - - scatter-plot - - heatmap - - boxplot - - histogram + enum: [line-chart, bar-chart, scatter-plot, heatmap, boxplot, histogram] description: "图表类型" data: type: object @@ -31,23 +20,18 @@ properties: type: object description: "美学参数" properties: - title: - type: string - x_label: - type: string - y_label: - type: string + title: {type: string} + x_label: {type: string} + y_label: {type: string} palette: type: array - items: - type: string + items: {type: string} font_size: type: integer minimum: 5 figsize: type: array - items: - type: number + items: {type: number} minItems: 2 maxItems: 2 dpi: @@ -64,13 +48,13 @@ properties: description: "画布背景色(hex),用于 palette-contrast 对比度门禁" text_color: type: string - description: "文字颜色(hex),声明后由 P1 text-contrast 门禁校验与背景对比度 ≥ 4.5(WCAG SC 1.4.3)" + description: "文字颜色(hex),声明后由 P1 text-contrast 校验与背景对比度 ≥ 4.5(WCAG SC 1.4.3)" adjacency_check: type: boolean - description: "启用分类色板两两相邻对比度检查(P1 palette-adjacency,WCAG SC 1.4.11,≥ 3.0)" + description: "启用项目自定义的严格全分类色对 ≥ 3.0 检查;受 WCAG SC 1.4.11 相邻边界原则启发,但不是 WCAG 的一般性全色板要求" palette_name: type: string - description: "注册色板名称(core/palettes.py,如 okabe-ito / petroff10);P1 palette-name 门禁校验" + description: "注册色板名称(core/palettes.py,如 okabe-ito / petroff10)" cmap: type: string description: "热力图色阶名称" @@ -78,28 +62,53 @@ properties: type: integer minimum: 1 description: "直方图的柱数" - output: + accessibility: type: object - required: - - dir - - filename + description: "可访问性契约;支持文本替代、非颜色冗余编码与声明式相邻对象检查,不等于整份出版物的 WCAG 合规声明" properties: - dir: + alt_text: type: string - description: "输出目录" - filename: + minLength: 1 + description: "图表短文本替代,面向 WCAG 2.2 SC 1.1.1 设计支持" + long_description: type: string - description: "输出文件名" + minLength: 1 + description: "可选长描述;复杂图表可用于补充趋势、含义或数据表位置" + require_alt_text: + type: boolean + description: "为 true 时 P1 门禁要求 alt_text 非空" + redundant_encoding: + type: string + enum: [off, auto, required] + default: off + description: "非颜色冗余编码策略;required 表示颜色不能作为系列区分的唯一视觉线索" + series_styles: + type: object + description: "按系列标签覆盖 marker / line_style / hatch;未声明项在 auto/required 模式由 core/accessibility.py 生成" + additionalProperties: + type: object + properties: + marker: {type: string} + line_style: {type: string} + hatch: {type: string} + adjacent_pairs: + type: array + description: "实际需要辨认的相邻系列标签对;每对按 SC 1.4.11 边界原则检查 ≥ 3.0" + items: + type: array + minItems: 2 + maxItems: 2 + items: {type: string} + output: + type: object + required: [dir, filename] + properties: + dir: {type: string} + filename: {type: string} format: type: string - enum: - - png - - svg - - pdf - - eps - - html + enum: [png, svg, pdf, eps, html] default: png - description: "输出格式" notes: type: string description: "人类注释" diff --git a/profiles/README.md b/profiles/README.md index 98f6b89..c33bee0 100644 --- a/profiles/README.md +++ b/profiles/README.md @@ -1,62 +1,33 @@ -# sci-render-kit Profiles - -Profile files declare journal-specific rendering constraints: fonts, color palettes, figure dimensions, and output format requirements. - -## Available Profiles - -| Profile | File | Target | -|---------|------|--------| -| Nature | `nature.yaml` | Nature journal standards | -| Science | `science.yaml` | Science journal standards | -| Cell | `cell.yaml` | Cell journal standards (Arial only, line art ≥ 1000 DPI) | -| IEEE | `ieee.yaml` | IEEE conference/journal standards | -| Presentation | `presentation.yaml` | Slide-friendly defaults | - -All profiles carry top-level `source_url` + `verified_date` fields recording -the editorial source the values were checked against (`presentation` 为内部默认, -`source_url: null`). - -## Profile Structure - -Each profile YAML follows this structure (all fields under `aesthetics` are -merged with — and can be overridden by — the recipe's own `aesthetics`): - -```yaml -name: nature -journal: Nature -aesthetics: - font: "Arial" - font_size: 5 # 期刊最小字号(P1 font-size 门禁校验) - figsize: [3.5, 2.45] # 推荐图幅(英寸) - dpi: 300 # 期刊最低 DPI(P3 dpi-check 门禁校验) - max_width_in: 7.2 # 双栏版宽上限(P3 size-check 门禁校验) - line_width: 1.0 - axes_linewidth: 0.5 - palette: - - "#E69F00" - - "#56B4E9" - - "#009E73" - - "#F0E442" - - "#0072B2" - - "#D55E00" - - "#CC79A7" - - "#000000" - constraints: # 人类可读的约束摘要(与质量门规则对应) - - "矢量格式优先 (PDF/EPS)" - - "字号 ≥ 5pt" -``` +# Publication Profiles + +Profiles encode measurable publication constraints; they are not opaque visual themes. + +Current profiles: + +| Profile | Purpose | +|---|---| +| `nature` | Nature-family figure constraints snapshot | +| `science` | Science figure constraints snapshot | +| `cell` | Cell figure constraints snapshot | +| `ieee` | IEEE figure constraints snapshot | +| `presentation` | Internal/general presentation defaults, not an external journal specification | -Enforcement notes: +Each externally sourced profile should carry `source_url` and `verified_date`. These values document the research snapshot used by the repository; publishers may update instructions later. -- `font_size`, `dpi`, `max_width_in` / `max_height_in` are machine-checked by - quality gates (see `quality/gates.yaml`); `constraints` is the human-readable summary. -- For `nature` / `science` / `cell`, the P3 `vector-format` gate additionally - requires the recipe to declare `output.format: pdf` (or `eps`). +P3 currently uses profile fields for checks such as: -## Adding a Custom Profile +- vector-format expectations, +- minimum DPI, +- `max_width_in`, +- `max_height_in` where declared, +- font/size constraints represented by the profile. + +Accessibility is intentionally **not hidden inside journal profiles**. A publication profile answers “what does this target venue constrain?”; the recipe-level `accessibility` object answers “what non-text/text-alternative and redundant-encoding contract does this figure declare?”. Keeping them separate prevents a journal name from being treated as an automatic accessibility certification. + +Example: + +```bash +python3 sci_render.py recipes/accessible-line-chart.yaml --profile presentation --backend matplotlib +``` -1. Create `profiles/your_profile.yaml` following the structure above. -2. Reference it via `--profile your_profile` when invoking `sci_render.py`. -3. The CLI loads the profile and merges it into both the pre-render (P0/P1) - and post-render (P2/P3) quality-gate evaluation. A missing profile file - aborts the run with `MISSING_PROFILE`. +For a journal-targeted recipe, combine the same accessibility contract with a current journal profile and let P1/P2/P3 report independent failures. diff --git a/quality/gates.yaml b/quality/gates.yaml index fd60c24..f05334b 100644 --- a/quality/gates.yaml +++ b/quality/gates.yaml @@ -21,9 +21,9 @@ gates: rule: "output 必须包含 dir 和 filename" - id: P1-aesthetics-check - name: "美学规范检查" + name: "美学与可访问性检查" level: P1 - description: "渲染前的美学参数验证" + description: "渲染前的美学、可读性与视觉编码契约" checks: - id: color-count name: "颜色数量" @@ -33,19 +33,28 @@ gates: rule: "font_size ≥ 5 (Nature) 或 ≥ 6 (Science)" - id: forbidden-pairs name: "禁止配色" - rule: "不包含红绿并置组合" + rule: "显式 palette 不包含高饱和红绿并置组合" - id: palette-contrast - name: "色板对比度" + name: "色板对背景" rule: "声明 background 或启用 semantic_palette 时,色板颜色与背景的 WCAG 对比度 ≥ 3.0" - id: text-contrast name: "文字对比度" rule: "声明 text_color 时,文字与背景(未声明则按 #FFFFFF)的 WCAG 对比度 ≥ 4.5(SC 1.4.3)" - id: palette-adjacency - name: "相邻色对" - rule: "声明 adjacency_check: true 时,启用项目的严格分类色板两两对比度策略(≥ 3.0)并报告失败色对;该策略比 WCAG 2.2 SC 1.4.11 对实际相邻、且为理解所必需的图形对象要求更严格,不等同于 WCAG 的一般性两两要求" + name: "严格全色对策略" + rule: "声明 aesthetics.adjacency_check: true 时,分类色板全部唯一色对 ≥ 3.0;这是比 WCAG SC 1.4.11 实际相邻对象要求更严格的项目策略" + - id: declared-adjacency + name: "声明式相邻对象对比度" + rule: "accessibility.adjacent_pairs 中实际需要辨认的系列对,其颜色边界对比度 ≥ 3.0(面向 SC 1.4.11 / G209)" + - id: text-alternative + name: "文本替代" + rule: "accessibility.require_alt_text: true 时必须提供非空 alt_text(面向 SC 1.1.1)" + - id: non-color-cue + name: "非颜色冗余编码" + rule: "accessibility.redundant_encoding: required 时,多系列 line/scatter/bar 必须具有可区分 marker/line_style/hatch(面向 SC 1.4.1)" - id: cvd-contrast name: "色盲模拟对比度" - rule: "声明 background 或启用 semantic_palette 时,色板颜色在 Machado 2009 三色盲模拟下与背景对比度 ≥ 3.0" + rule: "声明 background 或启用 semantic_palette 时,色板颜色在 Machado 2009 三色盲模拟下与背景对比度 ≥ 3.0;属于项目额外防护" - id: palette-name name: "注册色板" rule: "声明 palette_name 时必须命中 core/palettes.py 注册表;系列图仅接受 categorical 类型色板" @@ -73,6 +82,9 @@ gates: - id: prov-exists name: "溯源旁车存在" rule: "matplotlib 后端渲染必须产出同名 .prov.json(FAIR R1.2 溯源旁车;R/JS 侧为可选跟进)" + - id: a11y-exists + name: "可访问性旁车存在" + rule: "配方声明 accessibility 时,统一 CLI 必须产出同名 .a11y.json 视觉编码/文本替代契约" - id: P3-journal-compliance name: "期刊规范对齐" diff --git a/recipes/accessible-line-chart.yaml b/recipes/accessible-line-chart.yaml new file mode 100644 index 0000000..f0756d3 --- /dev/null +++ b/recipes/accessible-line-chart.yaml @@ -0,0 +1,28 @@ +id: accessible-line-chart +name: Accessible redundant-encoding demo +type: line-chart +data: + control: [1.0, 1.8, 2.4, 3.0] + treatment: [1.0, 2.2, 3.5, 4.9] +aesthetics: + title: Control vs treatment + x_label: Observation + y_label: Response + palette: ["#000000", "#0072B2"] + background: "#FFFFFF" + text_color: "#000000" + font_size: 10 + figsize: [6.0, 4.0] + dpi: 300 +accessibility: + require_alt_text: true + alt_text: "Line chart where control rises gradually and treatment rises more steeply across four observations." + long_description: "Both series start at 1.0. Control ends at 3.0 while treatment ends at 4.9. Color is not the only series cue: the Matplotlib accessibility adapter also assigns distinct markers and line styles." + redundant_encoding: required + adjacent_pairs: + - [control, treatment] +output: + dir: output + filename: accessible-line-chart.png + format: png +notes: "Demonstrates text alternative support, non-color redundant encoding, declared adjacency contrast, provenance, and accessibility sidecars." diff --git a/sci_render.py b/sci_render.py index 2b2a5fa..c8090be 100644 --- a/sci_render.py +++ b/sci_render.py @@ -1,246 +1,281 @@ #!/usr/bin/env python3 -""" -sci-render-kit 主入口 (CLI) -负责统一调度:配置读取、严格验证 (Schema & Quality Gates)、后端分发 -""" +"""sci-render-kit unified CLI: schema, accessibility, quality gates, backend dispatch.""" import argparse -import sys import os -import yaml -import json import subprocess +import sys from pathlib import Path -from jsonschema import validate, ValidationError +import yaml +from jsonschema import ValidationError, validate + +from core.accessibility import ( + accessibility_config, + build_accessibility_manifest, + distinct_style_signatures, + resolve_series_styles, + write_accessibility_manifest, +) from core.color_encoding import CognitiveColorEncoder from core.palettes import describe_palette, resolve_categorical -# 采用离散系列着色的图表类型(语义色板按系列名解析) SERIES_CHART_TYPES = {"line-chart", "bar-chart", "scatter-plot", "boxplot", "histogram"} +REDUNDANT_STYLE_TYPES = {"line-chart", "bar-chart", "scatter-plot"} -# 后端输出能力声明(与 MANIFEST.yaml 一致;dispatch 前强制校验) BACKEND_CAPABILITIES = { - 'matplotlib': {'png', 'svg', 'pdf'}, - 'ggplot2': {'png', 'svg', 'pdf'}, - 'observable': {'html'}, + "matplotlib": {"png", "svg", "pdf"}, + "ggplot2": {"png", "svg", "pdf"}, + "observable": {"html"}, +} + +# Today only matplotlib consumes the non-color series-style contract end-to-end. +# Other backends remain available for recipes that do not request it. +BACKEND_ACCESSIBILITY_CAPABILITIES = { + "matplotlib": {"text-alternative-sidecar", "redundant-series-style"}, + "ggplot2": {"text-alternative-sidecar"}, + "observable": {"text-alternative-sidecar"}, } def _hex_to_rgb(color: str): - """解析 #RRGGBB / #RGB 十六进制颜色为 (r, g, b) 元组;无法解析返回 None""" if not isinstance(color, str): return None - c = color.strip().lstrip('#') - if len(c) == 3: - c = ''.join(ch * 2 for ch in c) - if len(c) != 6: + value = color.strip().lstrip("#") + if len(value) == 3: + value = "".join(ch * 2 for ch in value) + if len(value) != 6: return None try: - return (int(c[0:2], 16), int(c[2:4], 16), int(c[4:6], 16)) + return tuple(int(value[i:i + 2], 16) for i in (0, 2, 4)) except ValueError: return None def resolve_effective_palette(recipe: dict, aesthetics: dict) -> list: - """计算渲染实际使用的色板。 - - 当配方/配置声明 ``semantic_palette: true`` 时,色板由 - core.color_encoding.CognitiveColorEncoder 按系列名的语义标签生成 - (优先于显式 palette);否则使用显式声明的 palette。 - """ - chart_type = str(recipe.get('type', '')) - labels = list(recipe.get('data', {}).keys()) if chart_type in SERIES_CHART_TYPES else [] - if aesthetics.get('semantic_palette'): + chart_type = str(recipe.get("type", "")) + labels = list(recipe.get("data", {}).keys()) if chart_type in SERIES_CHART_TYPES else [] + if aesthetics.get("semantic_palette"): if labels: return CognitiveColorEncoder().resolve_series_palette(labels) - elif aesthetics.get('palette_name'): - # 注册色板(core/palettes.py,含 CVD 安全标注);P1 palette-name 门禁负责校验名称 + elif aesthetics.get("palette_name"): try: - colors = resolve_categorical(str(aesthetics['palette_name'])) + colors = resolve_categorical(str(aesthetics["palette_name"])) except ValueError: return [] return colors[:len(labels)] if labels else colors - return [str(c) for c in aesthetics.get('palette', [])] + return [str(c) for c in aesthetics.get("palette", [])] def load_yaml(path: str) -> dict: try: - with open(path, 'r', encoding='utf-8') as f: - return yaml.safe_load(f) - except yaml.YAMLError as e: + with open(path, "r", encoding="utf-8") as handle: + return yaml.safe_load(handle) + except yaml.YAMLError as exc: print("YAML_PARSE_FAILURE") - print(f"Error parsing YAML file {path}: {e}") + print(f"Error parsing YAML file {path}: {exc}") sys.exit(1) + def run_quality_gates(recipe: dict, profile: dict, gates_def: dict): - """根据 quality/gates.yaml 运行静态检查 (P0, P1)""" + """Run deterministic pre-render P0/P1 rules.""" errors = [] - - # 获取美学配置 (合并) - aesthetics = {**profile.get('aesthetics', {}), **recipe.get('aesthetics', {})} - - for gate in gates_def.get('gates', []): - level = gate.get('level') - # 目前只在渲染前做静态检查 (P0, P1) - if level in ['P0', 'P1']: - for check in gate.get('checks', []): - cid = check.get('id') - - # 实现具体规则 - if cid == 'color-count': - palette = resolve_effective_palette(recipe, aesthetics) - if len(palette) > 8: - errors.append(f"[{gate['name']}] {check['name']}: palette 中颜色数({len(palette)})不能超过 8") - - elif cid == 'palette-contrast': - # 仅在声明 background 或启用 semantic_palette 时启用(向后兼容) - background = aesthetics.get('background') - use_semantic = bool(aesthetics.get('semantic_palette', False)) - if background or use_semantic: - bg_value = background or '#FFFFFF' - bg_rgb = _hex_to_rgb(bg_value) - if bg_rgb is not None: - encoder = CognitiveColorEncoder() - for color in resolve_effective_palette(recipe, aesthetics): - rgb = _hex_to_rgb(color) - if rgb is None: - continue - ratio = encoder.contrast_ratio(rgb, bg_rgb) - if ratio < 3.0: - errors.append( - f"[{gate['name']}] {check['name']}: 颜色 {color} 与背景 {bg_value} " - f"的 WCAG 对比度 {ratio:.2f} 低于 3.0") - - elif cid == 'text-contrast': - # WCAG SC 1.4.3:声明 text_color 时,文字与背景对比度必须 ≥ 4.5 - text_color = aesthetics.get('text_color') - if text_color: - bg_value = aesthetics.get('background') or '#FFFFFF' - fg_rgb = _hex_to_rgb(text_color) - bg_rgb = _hex_to_rgb(bg_value) - if fg_rgb is not None and bg_rgb is not None: - ratio = CognitiveColorEncoder().contrast_ratio(fg_rgb, bg_rgb) - if ratio < 4.5: + aesthetics = {**profile.get("aesthetics", {}), **recipe.get("aesthetics", {})} + access = accessibility_config(recipe) + chart_type = str(recipe.get("type", "")) + labels = list(recipe.get("data", {}).keys()) if chart_type in SERIES_CHART_TYPES else [] + + for gate in gates_def.get("gates", []): + if gate.get("level") not in ["P0", "P1"]: + continue + for check in gate.get("checks", []): + cid = check.get("id") + + if cid == "color-count": + palette = resolve_effective_palette(recipe, aesthetics) + if len(palette) > 8: + errors.append(f"[{gate['name']}] {check['name']}: palette 中颜色数({len(palette)})不能超过 8") + + elif cid == "palette-contrast": + background = aesthetics.get("background") + use_semantic = bool(aesthetics.get("semantic_palette", False)) + if background or use_semantic: + bg_value = background or "#FFFFFF" + bg_rgb = _hex_to_rgb(bg_value) + if bg_rgb is not None: + encoder = CognitiveColorEncoder() + for color in resolve_effective_palette(recipe, aesthetics): + rgb = _hex_to_rgb(color) + if rgb is None: + continue + ratio = encoder.contrast_ratio(rgb, bg_rgb) + if ratio < 3.0: errors.append( - f"[{gate['name']}] {check['name']}: 文字颜色 {text_color} 与背景 {bg_value} " - f"的 WCAG 对比度 {ratio:.2f} 低于 4.5(SC 1.4.3)") - - elif cid == 'palette-adjacency': - # WCAG SC 1.4.11:声明 adjacency_check 时,分类色板两两对比度必须 ≥ 3.0 - if aesthetics.get('adjacency_check'): + f"[{gate['name']}] {check['name']}: 颜色 {color} 与背景 {bg_value} " + f"的 WCAG 对比度 {ratio:.2f} 低于 3.0" + ) + + elif cid == "text-contrast": + text_color = aesthetics.get("text_color") + if text_color: + bg_value = aesthetics.get("background") or "#FFFFFF" + fg_rgb = _hex_to_rgb(text_color) + bg_rgb = _hex_to_rgb(bg_value) + if fg_rgb is not None and bg_rgb is not None: + ratio = CognitiveColorEncoder().contrast_ratio(fg_rgb, bg_rgb) + if ratio < 4.5: + errors.append( + f"[{gate['name']}] {check['name']}: 文字颜色 {text_color} 与背景 {bg_value} " + f"的 WCAG 对比度 {ratio:.2f} 低于 4.5(SC 1.4.3)" + ) + + elif cid == "palette-adjacency": + # Legacy project-strict mode: every unique categorical pair is tested. + # This is intentionally stronger than WCAG's actual-adjacent-object scope. + if aesthetics.get("adjacency_check"): + encoder = CognitiveColorEncoder() + colors = [c for c in resolve_effective_palette(recipe, aesthetics) if _hex_to_rgb(c) is not None] + seen = [] + for color in colors: + if color.upper() in [s.upper() for s in seen]: + continue + for other in seen: + ratio = encoder.contrast_ratio(_hex_to_rgb(color), _hex_to_rgb(other)) + if ratio < 3.0: + errors.append( + f"[{gate['name']}] {check['name']}: 色对 ({other}, {color}) 的 WCAG 对比度 " + f"{ratio:.2f} 低于 3.0(项目严格策略,参考 SC 1.4.11)" + ) + seen.append(color) + + elif cid == "declared-adjacency": + pairs = access.get("adjacent_pairs") or [] + if pairs and labels: + palette = resolve_effective_palette(recipe, aesthetics) + color_by_label = { + label: palette[i % len(palette)] + for i, label in enumerate(labels) + } if palette else {} + encoder = CognitiveColorEncoder() + for pair in pairs: + if not isinstance(pair, list) or len(pair) != 2: + continue # JSON Schema owns shape validation. + left, right = pair + if left not in color_by_label or right not in color_by_label: + errors.append( + f"[{gate['name']}] {check['name']}: adjacent_pairs 引用了未知系列 ({left}, {right})" + ) + continue + left_rgb = _hex_to_rgb(color_by_label[left]) + right_rgb = _hex_to_rgb(color_by_label[right]) + if left_rgb is None or right_rgb is None: + continue + ratio = encoder.contrast_ratio(left_rgb, right_rgb) + if ratio < 3.0: + errors.append( + f"[{gate['name']}] {check['name']}: 实际相邻系列 ({left}, {right}) 的颜色边界对比度 " + f"{ratio:.2f} 低于 3.0(SC 1.4.11 / G209)" + ) + + elif cid == "text-alternative": + if access.get("require_alt_text") and not str(access.get("alt_text", "")).strip(): + errors.append( + f"[{gate['name']}] {check['name']}: require_alt_text=true 时必须提供 alt_text(SC 1.1.1)" + ) + + elif cid == "non-color-cue": + mode = access.get("redundant_encoding", "off") + if mode == "required" and chart_type in REDUNDANT_STYLE_TYPES and len(labels) > 1: + styles = resolve_series_styles(labels, access) + if len(styles) != len(labels) or distinct_style_signatures(styles) < len(labels): + errors.append( + f"[{gate['name']}] {check['name']}: 多系列图缺少可区分的非颜色视觉线索(SC 1.4.1)" + ) + + elif cid == "cvd-contrast": + background = aesthetics.get("background") + use_semantic = bool(aesthetics.get("semantic_palette", False)) + if background or use_semantic: + from core.cvd_simulation import cvd_contrast_report + bg_value = background or "#FFFFFF" + bg_rgb = _hex_to_rgb(bg_value) + if bg_rgb is not None: encoder = CognitiveColorEncoder() - colors = [c for c in resolve_effective_palette(recipe, aesthetics) - if _hex_to_rgb(c) is not None] - seen = [] - for color in colors: - if color.upper() in [s.upper() for s in seen]: + for color in resolve_effective_palette(recipe, aesthetics): + rgb = _hex_to_rgb(color) + if rgb is None: continue - for other in seen: - ratio = encoder.contrast_ratio(_hex_to_rgb(color), _hex_to_rgb(other)) - if ratio < 3.0: - errors.append( - f"[{gate['name']}] {check['name']}: 色对 ({other}, {color}) " - f"的 WCAG 对比度 {ratio:.2f} 低于 3.0(SC 1.4.11)") - seen.append(color) - - elif cid == 'cvd-contrast': - # Machado 2009 CVD 模拟:声明 background 或启用 semantic_palette 时, - # 色板颜色在三种色盲模拟下与背景的对比度必须保持 ≥ 3.0 - background = aesthetics.get('background') - use_semantic = bool(aesthetics.get('semantic_palette', False)) - if background or use_semantic: - from core.cvd_simulation import cvd_contrast_report # 延迟导入(numpy) - bg_value = background or '#FFFFFF' - bg_rgb = _hex_to_rgb(bg_value) - if bg_rgb is not None: - encoder = CognitiveColorEncoder() - for color in resolve_effective_palette(recipe, aesthetics): - rgb = _hex_to_rgb(color) - if rgb is None: - continue - worst_type, worst_ratio = cvd_contrast_report( - rgb, bg_rgb, encoder.contrast_ratio)[0] - if worst_ratio < 3.0: - errors.append( - f"[{gate['name']}] {check['name']}: 颜色 {color} 在 {worst_type} " - f"模拟(Machado 2009)下与背景 {bg_value} 的对比度 " - f"{worst_ratio:.2f} 低于 3.0") - - elif cid == 'palette-name': - # 声明 palette_name 时必须命中注册表;系列图要求 categorical 类型 - name = aesthetics.get('palette_name') - if name: - try: - entry = describe_palette(str(name)) - chart_type = str(recipe.get('type', '')) - if chart_type in SERIES_CHART_TYPES and entry['kind'] != 'categorical': + worst_type, worst_ratio = cvd_contrast_report(rgb, bg_rgb, encoder.contrast_ratio)[0] + if worst_ratio < 3.0: errors.append( - f"[{gate['name']}] {check['name']}: 色板 '{name}' 是 {entry['kind']} 色阶," - f"不能用于系列图分类着色;顺序/发散色阶请用 aesthetics.cmap") - except ValueError as e: - errors.append(f"[{gate['name']}] {check['name']}: {e}") - - elif cid == 'font-size': - font_size = aesthetics.get('font_size', 10) - profile_name = profile.get('name', '') - if profile_name == 'nature' and font_size < 5: - errors.append(f"[{gate['name']}] {check['name']}: Nature 期刊字号要求 >= 5 (当前 {font_size})") - elif profile_name == 'science' and font_size < 6: - errors.append(f"[{gate['name']}] {check['name']}: Science 期刊字号要求 >= 6 (当前 {font_size})") - - elif cid == 'forbidden-pairs': - # 简单检查红绿并存 - palette = [c.lower() for c in aesthetics.get('palette', [])] - has_red = any(c in ['#ff0000', 'red'] for c in palette) - has_green = any(c in ['#00ff00', 'green'] for c in palette) - if has_red and has_green: - errors.append(f"[{gate['name']}] {check['name']}: 不建议同时包含高饱和度的红绿色") - - elif cid == 'no-3d': - if str(recipe.get('type', '')).startswith('3d-'): - errors.append(f"[{gate['name']}] {check['name']}: 严禁使用 3D 图表") + f"[{gate['name']}] {check['name']}: 颜色 {color} 在 {worst_type} 模拟(Machado 2009)下" + f"与背景 {bg_value} 的对比度 {worst_ratio:.2f} 低于 3.0" + ) + + elif cid == "palette-name": + name = aesthetics.get("palette_name") + if name: + try: + entry = describe_palette(str(name)) + if chart_type in SERIES_CHART_TYPES and entry["kind"] != "categorical": + errors.append( + f"[{gate['name']}] {check['name']}: 色板 '{name}' 是 {entry['kind']} 色阶," + "不能用于系列图分类着色;顺序/发散色阶请用 aesthetics.cmap" + ) + except ValueError as exc: + errors.append(f"[{gate['name']}] {check['name']}: {exc}") + + elif cid == "font-size": + font_size = aesthetics.get("font_size", 10) + profile_name = profile.get("name", "") + if profile_name == "nature" and font_size < 5: + errors.append(f"[{gate['name']}] {check['name']}: Nature 期刊字号要求 >= 5 (当前 {font_size})") + elif profile_name == "science" and font_size < 6: + errors.append(f"[{gate['name']}] {check['name']}: Science 期刊字号要求 >= 6 (当前 {font_size})") + + elif cid == "forbidden-pairs": + palette = [c.lower() for c in aesthetics.get("palette", [])] + has_red = any(c in ["#ff0000", "red"] for c in palette) + has_green = any(c in ["#00ff00", "green"] for c in palette) + if has_red and has_green: + errors.append(f"[{gate['name']}] {check['name']}: 不建议同时包含高饱和度的红绿色") + + elif cid == "no-3d": + if chart_type.startswith("3d-"): + errors.append(f"[{gate['name']}] {check['name']}: 严禁使用 3D 图表") return errors + def main(): parser = argparse.ArgumentParser(description="sci-render-kit 统一入口") - parser.add_argument('recipe', help="YAML 配方文件路径") - parser.add_argument('--profile', default='nature', help="配置文件名,例如 nature, science, presentation") - parser.add_argument('--backend', default='matplotlib', choices=['matplotlib', 'ggplot2', 'observable'], help="渲染后端") - + parser.add_argument("recipe", help="YAML 配方文件路径") + parser.add_argument("--profile", default="nature", help="配置文件名,例如 nature, science, presentation") + parser.add_argument("--backend", default="matplotlib", choices=["matplotlib", "ggplot2", "observable"], help="渲染后端") args = parser.parse_args() - recipe_path = args.recipe - if not os.path.exists(recipe_path): - print(f"❌ 错误: 找不到配方文件 {recipe_path}") + if not os.path.exists(args.recipe): + print(f"❌ 错误: 找不到配方文件 {args.recipe}") sys.exit(1) + recipe = load_yaml(args.recipe) - recipe = load_yaml(recipe_path) - - # 1. Schema 验证 - schema_path = 'metadata/recipe.schema.yaml' - schema = load_yaml(schema_path) + schema = load_yaml("metadata/recipe.schema.yaml") try: validate(instance=recipe, schema=schema) print("✅ P0 Schema 验证通过") - except ValidationError as e: + except ValidationError as exc: print("P0_SCHEMA_FAILURE") print("❌ [P0-recipe-valid] Schema 验证失败:") - print(f" - {e.message}") + print(f" - {exc.message}") sys.exit(1) - # 2. 统一读取 Profile - profile_path = f'profiles/{args.profile}.yaml' + profile_path = f"profiles/{args.profile}.yaml" if not os.path.exists(profile_path): print("MISSING_PROFILE") print(f"Error: Profile file not found: {profile_path}") sys.exit(1) profile = load_yaml(profile_path) - - # 3. 执行 Quality Gates (P0/P1) - gates_path = 'quality/gates.yaml' - gates = load_yaml(gates_path) + gates = load_yaml("quality/gates.yaml") gate_errors = run_quality_gates(recipe, profile, gates) if gate_errors: @@ -248,107 +283,110 @@ def main(): for err in gate_errors: print(f" - {err}") sys.exit(1) - else: - print("✅ P1 美学规范检查通过") + print("✅ P1 美学/可访问性检查通过") - # 3.5 后端能力校验:声明的输出格式必须在后端能力集内 - output_format = str(recipe.get('output', {}).get('format', 'png')).lower() + output_format = str(recipe.get("output", {}).get("format", "png")).lower() allowed_formats = BACKEND_CAPABILITIES[args.backend] if output_format not in allowed_formats: print("BACKEND_CAPABILITY_MISMATCH") - print(f"❌ 后端 {args.backend} 不支持输出格式 '{output_format}'" - f"(支持: {', '.join(sorted(allowed_formats))})") + print(f"❌ 后端 {args.backend} 不支持输出格式 '{output_format}'(支持: {', '.join(sorted(allowed_formats))})") + sys.exit(1) + + access = accessibility_config(recipe) + if (access.get("redundant_encoding") in {"auto", "required"} + and str(recipe.get("type")) in REDUNDANT_STYLE_TYPES + and "redundant-series-style" not in BACKEND_ACCESSIBILITY_CAPABILITIES[args.backend]): + print("BACKEND_ACCESSIBILITY_MISMATCH") + print(f"❌ 后端 {args.backend} 尚未实现 accessibility.redundant_encoding 的实际系列样式映射") sys.exit(1) - # 4. 调用对应的 Backend Adapter backend_script_map = { - 'matplotlib': ('python3', 'backends/matplotlib_adapter.py'), - 'ggplot2': ('Rscript', 'backends/ggplot2_adapter.R'), - 'observable': ('node', 'backends/observable_adapter.js') + "matplotlib": ("python3", "backends/matplotlib_adapter.py"), + "ggplot2": ("Rscript", "backends/ggplot2_adapter.R"), + "observable": ("node", "backends/observable_adapter.js"), } - cmd, script = backend_script_map[args.backend] - print(f"🚀 将使用后端 {args.backend} 渲染配方...") try: - # 我们把参数传递给后端的 CLI subprocess.run([cmd, script, "render", args.recipe, "--profile", args.profile], check=True) - except subprocess.CalledProcessError as e: + except subprocess.CalledProcessError as exc: print("BACKEND_EXECUTION_FAILURE") - print(f"❌ 后端执行失败, 返回码: {e.returncode}") + print(f"❌ 后端执行失败, 返回码: {exc.returncode}") sys.exit(1) - # 5. 执行 Quality Gates (P2/P3 输出后检查) - output_cfg = recipe.get('output', {}) - output_dir = output_cfg.get('dir', 'output') - output_file = output_cfg.get('filename', 'figure.png') - output_path = Path(output_dir) / output_file - manifest_path = output_path.with_suffix('.manifest.json') + output_cfg = recipe.get("output", {}) + output_path = Path(output_cfg.get("dir", "output")) / output_cfg.get("filename", "figure.png") + manifest_path = output_path.with_suffix(".manifest.json") - post_errors = [] + # A single backend-independent accessibility sidecar keeps the contract portable. + if "accessibility" in recipe: + merged_aesthetics_for_a11y = {**profile.get("aesthetics", {}), **recipe.get("aesthetics", {})} + a11y_manifest = build_accessibility_manifest( + recipe, resolve_effective_palette(recipe, merged_aesthetics_for_a11y) + ) + write_accessibility_manifest(output_path, a11y_manifest) - # 合并后的美学参数(recipe 覆盖 profile),供 P3 期刊合规检查使用 - merged_aesthetics = {**profile.get('aesthetics', {}), **recipe.get('aesthetics', {})} - profile_aesthetics = profile.get('aesthetics', {}) - - for gate in gates.get('gates', []): - level = gate.get('level') - if level in ['P2', 'P3']: - for check in gate.get('checks', []): - cid = check.get('id') - if cid == 'file-exists': - if not output_path.exists(): - post_errors.append(f"[{gate['name']}] {check['name']}: 输出文件未生成") - elif cid == 'non-empty': - if output_path.exists() and output_path.stat().st_size == 0: - post_errors.append(f"[{gate['name']}] {check['name']}: 输出文件为空") - elif cid == 'format-match': - expected_ext = '.' + output_cfg.get('format', 'png').lower() - if output_path.suffix.lower() != expected_ext: - post_errors.append(f"[{gate['name']}] {check['name']}: 期望扩展名 {expected_ext} 但得到 {output_path.suffix.lower()}") - elif cid == 'manifest-exists': - if not manifest_path.exists(): - print("MANIFEST_MISSING") - post_errors.append(f"[{gate['name']}] {check['name']}: 溯源元数据文件未生成") - elif cid == 'prov-exists': - # FAIR R1.2 溯源旁车:matplotlib 后端必须产出同名 .prov.json - # (R/JS 侧为可选跟进,能力边界如实声明,见 README) - if args.backend == 'matplotlib': - prov_path = output_path.with_suffix('.prov.json') - if not prov_path.exists(): - post_errors.append(f"[{gate['name']}] {check['name']}: 溯源旁车文件 {prov_path} 未生成") - elif cid == 'vector-format': - if args.profile in ['nature', 'science', 'cell']: - if output_path.suffix.lower() not in ['.pdf', '.eps']: - post_errors.append(f"[{gate['name']}] {check['name']}: {args.profile} 期望矢量格式 (.pdf/.eps),但得到 {output_path.suffix.lower()}") - elif cid == 'dpi-check': - # 合并后的 DPI 不得低于 profile 声明的期刊最低 DPI - min_dpi = profile_aesthetics.get('dpi') - actual_dpi = merged_aesthetics.get('dpi', min_dpi) - if min_dpi is not None and actual_dpi is not None and actual_dpi < min_dpi: - post_errors.append(f"[{gate['name']}] {check['name']}: DPI {actual_dpi} 低于 {args.profile} 最低要求 {min_dpi}") - elif cid == 'size-check': - # figsize 必须为正值二元组;profile 声明 max_width_in 时宽度不得超限 - figsize = merged_aesthetics.get('figsize') - if figsize is not None: - if (not isinstance(figsize, list) or len(figsize) != 2 - or any(not isinstance(v, (int, float)) or v <= 0 for v in figsize)): - post_errors.append(f"[{gate['name']}] {check['name']}: figsize 必须为正值二元组,当前 {figsize}") - else: - max_width = profile_aesthetics.get('max_width_in') - if max_width is not None and figsize[0] > max_width: - post_errors.append(f"[{gate['name']}] {check['name']}: 图宽 {figsize[0]}in 超过 {args.profile} 版宽上限 {max_width}in") - max_height = profile_aesthetics.get('max_height_in') - if max_height is not None and figsize[1] > max_height: - post_errors.append(f"[{gate['name']}] {check['name']}: 图高 {figsize[1]}in 超过 {args.profile} 版面上限 {max_height}in") + post_errors = [] + merged_aesthetics = {**profile.get("aesthetics", {}), **recipe.get("aesthetics", {})} + profile_aesthetics = profile.get("aesthetics", {}) + + for gate in gates.get("gates", []): + if gate.get("level") not in ["P2", "P3"]: + continue + for check in gate.get("checks", []): + cid = check.get("id") + if cid == "file-exists": + if not output_path.exists(): + post_errors.append(f"[{gate['name']}] {check['name']}: 输出文件未生成") + elif cid == "non-empty": + if output_path.exists() and output_path.stat().st_size == 0: + post_errors.append(f"[{gate['name']}] {check['name']}: 输出文件为空") + elif cid == "format-match": + expected_ext = "." + output_cfg.get("format", "png").lower() + if output_path.suffix.lower() != expected_ext: + post_errors.append(f"[{gate['name']}] {check['name']}: 期望扩展名 {expected_ext} 但得到 {output_path.suffix.lower()}") + elif cid == "manifest-exists": + if not manifest_path.exists(): + print("MANIFEST_MISSING") + post_errors.append(f"[{gate['name']}] {check['name']}: 溯源元数据文件未生成") + elif cid == "prov-exists": + if args.backend == "matplotlib": + prov_path = output_path.with_suffix(".prov.json") + if not prov_path.exists(): + post_errors.append(f"[{gate['name']}] {check['name']}: 溯源旁车文件 {prov_path} 未生成") + elif cid == "a11y-exists": + if "accessibility" in recipe: + a11y_path = output_path.with_suffix(".a11y.json") + if not a11y_path.exists(): + post_errors.append(f"[{gate['name']}] {check['name']}: 可访问性旁车文件 {a11y_path} 未生成") + elif cid == "vector-format": + if args.profile in ["nature", "science", "cell"] and output_path.suffix.lower() not in [".pdf", ".eps"]: + post_errors.append(f"[{gate['name']}] {check['name']}: {args.profile} 期望矢量格式 (.pdf/.eps),但得到 {output_path.suffix.lower()}") + elif cid == "dpi-check": + min_dpi = profile_aesthetics.get("dpi") + actual_dpi = merged_aesthetics.get("dpi", min_dpi) + if min_dpi is not None and actual_dpi is not None and actual_dpi < min_dpi: + post_errors.append(f"[{gate['name']}] {check['name']}: DPI {actual_dpi} 低于 {args.profile} 最低要求 {min_dpi}") + elif cid == "size-check": + figsize = merged_aesthetics.get("figsize") + if figsize is not None: + if (not isinstance(figsize, list) or len(figsize) != 2 + or any(not isinstance(v, (int, float)) or v <= 0 for v in figsize)): + post_errors.append(f"[{gate['name']}] {check['name']}: figsize 必须为正值二元组,当前 {figsize}") + else: + max_width = profile_aesthetics.get("max_width_in") + if max_width is not None and figsize[0] > max_width: + post_errors.append(f"[{gate['name']}] {check['name']}: 图宽 {figsize[0]}in 超过 {args.profile} 版宽上限 {max_width}in") + max_height = profile_aesthetics.get("max_height_in") + if max_height is not None and figsize[1] > max_height: + post_errors.append(f"[{gate['name']}] {check['name']}: 图高 {figsize[1]}in 超过 {args.profile} 版面上限 {max_height}in") if post_errors: print("❌ 渲染后质量门检查失败:") for err in post_errors: print(f" - {err}") sys.exit(1) - else: - print("✅ P2/P3 输出检查通过") + print("✅ P2/P3 输出检查通过") if __name__ == "__main__": diff --git a/tests/test_accessibility.py b/tests/test_accessibility.py new file mode 100644 index 0000000..b2f0b42 --- /dev/null +++ b/tests/test_accessibility.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Accessibility contract tests for sci-render-kit.""" + +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +import yaml + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from core.accessibility import ( + PROFILE, + build_accessibility_manifest, + distinct_style_signatures, + resolve_series_styles, +) +from sci_render import load_yaml, run_quality_gates + + +class TestAccessibilityContract(unittest.TestCase): + def setUp(self): + self.gates = load_yaml("quality/gates.yaml") + + def test_required_alt_text_is_enforced(self): + recipe = { + "type": "line-chart", + "data": {"a": [1, 2]}, + "aesthetics": {"palette": ["#0072B2"]}, + "accessibility": {"require_alt_text": True}, + "output": {}, + } + errors = run_quality_gates(recipe, {}, self.gates) + self.assertTrue(any("SC 1.1.1" in error for error in errors), errors) + recipe["accessibility"]["alt_text"] = "Series a increases from 1 to 2." + errors = run_quality_gates(recipe, {}, self.gates) + self.assertFalse(any("SC 1.1.1" in error for error in errors), errors) + + def test_redundant_styles_are_unique_for_multiseries(self): + labels = ["control", "treatment", "baseline"] + styles = resolve_series_styles(labels, {"redundant_encoding": "required"}) + self.assertEqual(len(styles), len(labels)) + self.assertEqual(distinct_style_signatures(styles), len(labels)) + + def test_declared_adjacency_checks_only_declared_pairs(self): + recipe = { + "type": "line-chart", + "data": {"a": [1, 2], "b": [2, 3], "c": [3, 4]}, + "aesthetics": {"palette": ["#000000", "#0072B2", "#D55E00"]}, + "accessibility": {"adjacent_pairs": [["b", "c"]]}, + "output": {}, + } + errors = run_quality_gates(recipe, {}, self.gates) + self.assertTrue(any("SC 1.4.11" in error and "(b, c)" in error for error in errors), errors) + # a/b are not declared adjacent, so their relationship is not tested by this gate. + self.assertFalse(any("(a, b)" in error for error in errors), errors) + + def test_manifest_keeps_text_and_non_color_cues(self): + recipe = { + "id": "accessible", + "type": "line-chart", + "data": {"a": [1], "b": [2]}, + "accessibility": { + "alt_text": "Two-series comparison.", + "redundant_encoding": "required", + }, + } + manifest = build_accessibility_manifest(recipe, ["#000000", "#0072B2"]) + self.assertEqual(manifest["profile"], PROFILE) + self.assertEqual(manifest["alt_text"], "Two-series comparison.") + self.assertFalse(manifest["standards_scope"]["conformance_claim"]) + self.assertTrue(all("non_color_cue" in series for series in manifest["series"])) + + def test_matplotlib_cli_emits_accessibility_sidecar_and_metadata(self): + with tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) + recipe_path = out / "recipe.yaml" + recipe = { + "id": "accessible-e2e", + "type": "line-chart", + "data": {"control": [1, 2, 3], "treatment": [1, 3, 5]}, + "aesthetics": { + "palette": ["#000000", "#0072B2"], + "background": "#FFFFFF", + }, + "accessibility": { + "require_alt_text": True, + "alt_text": "Control rises slowly while treatment rises faster.", + "redundant_encoding": "required", + "adjacent_pairs": [["control", "treatment"]], + }, + "output": { + "dir": str(out), + "filename": "accessible.png", + "format": "png", + }, + } + recipe_path.write_text(yaml.safe_dump(recipe, sort_keys=False), encoding="utf-8") + proc = subprocess.run( + ["python3", "sci_render.py", str(recipe_path), "--profile", "presentation", "--backend", "matplotlib"], + capture_output=True, + text=True, + ) + self.assertEqual(proc.returncode, 0, proc.stdout + "\n" + proc.stderr) + image = out / "accessible.png" + sidecar = out / "accessible.a11y.json" + self.assertTrue(image.exists()) + self.assertTrue(sidecar.exists()) + payload = json.loads(sidecar.read_text(encoding="utf-8")) + self.assertEqual(payload["alt_text"], recipe["accessibility"]["alt_text"]) + self.assertEqual(payload["redundant_encoding"], "required") + self.assertTrue(all("non_color_cue" in item for item in payload["series"])) + try: + from PIL import Image + with Image.open(image) as opened: + self.assertEqual(opened.info.get("srk:alt-text"), recipe["accessibility"]["alt_text"]) + except ImportError: + pass + + def test_non_matplotlib_backend_does_not_fake_redundant_style_support(self): + with tempfile.TemporaryDirectory() as tmp: + recipe_path = Path(tmp) / "recipe.yaml" + recipe = { + "id": "backend-a11y", + "type": "line-chart", + "data": {"a": [1], "b": [2]}, + "aesthetics": {"palette": ["#000000", "#0072B2"]}, + "accessibility": {"redundant_encoding": "required"}, + "output": {"dir": tmp, "filename": "x.html", "format": "html"}, + } + recipe_path.write_text(yaml.safe_dump(recipe, sort_keys=False), encoding="utf-8") + proc = subprocess.run( + ["python3", "sci_render.py", str(recipe_path), "--profile", "presentation", "--backend", "observable"], + capture_output=True, + text=True, + ) + self.assertNotEqual(proc.returncode, 0) + self.assertIn("BACKEND_ACCESSIBILITY_MISMATCH", proc.stdout) + + +if __name__ == "__main__": + unittest.main() From 852d468ba710839361667369bf7425a4e4d8d905 Mon Sep 17 00:00:00 2001 From: lostlight530 <13957086206@163.com> Date: Sun, 23 Aug 2026 10:14:37 +0800 Subject: [PATCH 2/8] ci: remove invalid pip cache prerequisite --- .github/workflows/ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 94efce7..499366b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,6 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.12" - cache: pip - name: Install Python dependencies run: python -m pip install pyyaml jsonschema matplotlib numpy pillow - name: Run render and accessibility contracts From 4b7cd582d8f4603b808f797e8274a01c0c6d09f5 Mon Sep 17 00:00:00 2001 From: lostlight530 <13957086206@163.com> Date: Sun, 23 Aug 2026 10:43:13 +0800 Subject: [PATCH 3/8] chore: remove repository CI gate --- .github/workflows/ci.yml | 22 ---------------------- 1 file changed, 22 deletions(-) delete mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 499366b..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: research-contract - -on: - push: - branches: [main] - pull_request: - -permissions: - contents: read - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - name: Install Python dependencies - run: python -m pip install pyyaml jsonschema matplotlib numpy pillow - - name: Run render and accessibility contracts - run: make test From 993fc748333e16a8de21674f4baf28665898663e Mon Sep 17 00:00:00 2001 From: lostlight530 <13957086206@163.com> Date: Sun, 23 Aug 2026 10:43:31 +0800 Subject: [PATCH 4/8] fix: preserve direct-run backend imports --- backends/matplotlib_adapter.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/backends/matplotlib_adapter.py b/backends/matplotlib_adapter.py index bf7177f..77f366d 100644 --- a/backends/matplotlib_adapter.py +++ b/backends/matplotlib_adapter.py @@ -10,8 +10,13 @@ import argparse import json +import sys from pathlib import Path +# Preserve direct script execution from any working directory: +# python3 backends/matplotlib_adapter.py ... +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from backends import matplotlib_base as base from core.accessibility import accessibility_config, resolve_series_styles From 0b4ccca8a0db802a71ad7b2b66ae3a2a399673be Mon Sep 17 00:00:00 2001 From: lostlight530 <13957086206@163.com> Date: Sun, 23 Aug 2026 10:46:29 +0800 Subject: [PATCH 5/8] docs: separate runtime quality rules from repository gates --- README.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 4fa6ec0..fae5722 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ## 当前定位 -`sci-render-kit` 不是“自动把图画漂亮”的包装器。它把科研图件看成一个需要同时满足四类契约的研究产物: +`sci-render-kit` 不是“自动把图画漂亮”的包装器。它把科研图件看成一个需要同时满足四类运行时约束的研究产物: ```text Recipe Schema (P0) @@ -37,7 +37,7 @@ python3 sci_render.py recipes/accessible-line-chart.yaml --profile presentation 该示例会生成图件,并在既有 `.manifest.json` / `.prov.json` 之外生成同名 `.a11y.json`。 -## 四层质量门 +## 四层运行时质量规则 ### P0 — Recipe schema @@ -134,7 +134,7 @@ backends/matplotlib_adapter.py + accessibility manifest linkage ``` -原有 `generate_python_code`、`resolve_palette` 等公共接口继续由 adapter 重导出,降低架构升级对既有调用者和测试的破坏。 +公共 adapter 保留直接脚本执行的 repo-root import bootstrap,因此 `python3 backends/matplotlib_adapter.py ...` 与模块导入两种方式都继续可用。原有 `generate_python_code`、`resolve_palette` 等公共接口继续由 adapter 重导出,降低架构升级对既有调用者的破坏。 ## Provenance 与 reproducibility @@ -155,7 +155,7 @@ Matplotlib 仍生成: | Matplotlib accessibility policy adapter | **Implemented** | | R ggplot2 renderer | **Implemented / runtime environment optional** | | Observable renderer | **Implemented / node environment optional** | -| Recipe / profile / P0–P3 gates | **Implemented** | +| Recipe / profile / P0–P3 runtime checks | **Implemented** | | semantic palette / named palettes / CVD simulation | **Implemented** | | text alternative contract + `.a11y.json` | **Implemented** | | redundant marker/line-style/hatch rendering | **Implemented (Matplotlib)** | @@ -164,16 +164,18 @@ Matplotlib 仍生成: | R/Observable provenance parity | **Optional follow-up** | | projection / time_crystal / uncertainty_legend / observer_dashboard / superposition | **Experimental** | -## 验证 +## 本地检查 + +需要时可以手动运行: ```bash python -m pip install pyyaml jsonschema matplotlib numpy pillow make test ``` -`make test` 运行既有渲染/门禁/色板/期刊/provenance 测试以及新的 accessibility 契约测试。`.github/workflows/ci.yml` 在 PR 和 `main` push 上用 Python 3.12 跑同一契约。 +这些检查覆盖既有渲染、运行时质量规则、色板、期刊 profile、provenance 与 accessibility 行为,只是本地维护工具,**不是 GitHub 合并门禁**。 -Node/R 端到端环境仍保持可选;没有对应 runtime 时不能把跳过测试包装成通过验证。 +Node/R 端到端环境仍保持可选;没有对应 runtime 时不能把“未运行”包装成已验证。 ## 科研软件引用 From 70f527ba36b1cd2925eda3b9cbc380fbe8aef003 Mon Sep 17 00:00:00 2001 From: lostlight530 <13957086206@163.com> Date: Sun, 23 Aug 2026 10:46:40 +0800 Subject: [PATCH 6/8] docs: remove completion-gate and CI language --- AGENTS.md | 49 ++++++++++++++++++++++--------------------------- 1 file changed, 22 insertions(+), 27 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0b8a6bc..85d1a6d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Agent Guide — sci-render-kit -This file is the operational contract for agents modifying the repository. Public capability claims must remain aligned across `README.md`, `ARCHITECTURE.md`, `MANIFEST.yaml`, schemas, gates, adapters, and tests. +This file is the operational guide for agents modifying the repository. Public capability claims must remain aligned across `README.md`, `ARCHITECTURE.md`, `MANIFEST.yaml`, schemas, runtime quality rules, adapters, and tests. ## 1. Canonical architecture @@ -20,42 +20,44 @@ core/accessibility.py -> .a11y.json ``` -## 2. Full deterministic contract +## 2. Local checks + +When useful: ```bash python -m pip install pyyaml jsonschema matplotlib numpy pillow make test ``` -GitHub Actions runs the same contract with Python 3.12. R/Node runtime remains optional unless the workflow explicitly provisions those ecosystems. +These are manual maintenance checks, not an automated merge gate. R/Node runtime remains optional unless a developer explicitly provisions those ecosystems locally. ## 3. Hard rules 1. **Unified validation.** New public policy goes through `sci_render.py`; adapters should not each invent a different validation model. -2. **Backend truth.** Schema support does not imply backend support. Update `BACKEND_ACCESSIBILITY_CAPABILITIES` only after actual rendering behavior and tests exist. +2. **Backend truth.** Schema support does not imply backend support. Update `BACKEND_ACCESSIBILITY_CAPABILITIES` only after actual rendering behavior exists. 3. **Use of Color.** If `redundant_encoding: required`, supported multi-series charts must expose non-color cues. Do not satisfy this by merely adding another color. -4. **WCAG scope.** `adjacent_pairs` models actual graphical adjacency for SC 1.4.11 support. The legacy all-pairs adjacency gate is a stricter project policy, not the normative WCAG scope. -5. **Text alternatives.** `require_alt_text: true` must gate missing short alternatives. Do not claim that a sidecar alone makes a final website/PDF accessible; publishing-layer association still matters. +4. **WCAG scope.** `adjacent_pairs` models actual graphical adjacency for SC 1.4.11 support. The legacy all-pairs adjacency rule is a stricter project policy, not the normative WCAG scope. +5. **Text alternatives.** `require_alt_text: true` must reject missing short alternatives. Do not claim that a sidecar alone makes a final website/PDF accessible; publishing-layer association still matters. 6. **CVD is separate.** Machado simulation is a project safeguard, not a WCAG success criterion. -7. **Matplotlib layering.** Keep `matplotlib_base.py` as the stable render/provenance core. The public adapter may add policy behavior but must preserve existing exported APIs. +7. **Matplotlib layering.** Keep `matplotlib_base.py` as the stable render/provenance core. The public adapter may add policy behavior but must preserve existing exported APIs and direct-script import bootstrap. 8. **Dumb adapters.** Backend adapters translate validated intent into backend-specific rendering; they are not alternative policy engines. 9. **No fake reproducibility.** Checksums/provenance improve traceability but do not justify “100% reproducible” claims. 10. **No fake conformance.** `sci-render-kit/a11y@1` explicitly carries `conformance_claim: false`. 11. **Experimental stays Experimental.** projection/time_crystal/uncertainty_legend/observer_dashboard/superposition are not integrated capabilities. -12. **Optional ecosystems stay optional.** A skipped R/Node E2E test is neither a failure nor proof of runtime parity. +12. **Optional ecosystems stay optional.** An unexecuted R/Node E2E path is neither a failure nor proof of runtime parity. ## 4. Where to change what | Goal | Primary files | Required follow-up | |---|---|---| -| recipe field | `metadata/recipe.schema.yaml` | gate/backend/tests/docs | -| accessibility rule | `core/accessibility.py`, `sci_render.py`, `quality/gates.yaml` | `tests/test_accessibility.py` | -| Matplotlib non-color encoding | `backends/matplotlib_adapter.py` | preserve base API + E2E test | -| base Matplotlib rendering/provenance | `backends/matplotlib_base.py` | old `tests/test_all.py` contract | -| new backend capability | adapter + capability matrix | real E2E or explicit optional-runtime test | -| publication profile | `profiles/*.yaml`, `profiles/README.md` | source/verification date + P3 tests | -| named palette | `core/palettes.py` | type/availability/CVD metadata + tests | -| provenance | Matplotlib base + P2 gate | checksum/readback tests | +| recipe field | `metadata/recipe.schema.yaml` | runtime rule/backend/docs; nearest local check when useful | +| accessibility rule | `core/accessibility.py`, `sci_render.py`, `quality/gates.yaml` | keep scope explicit | +| Matplotlib non-color encoding | `backends/matplotlib_adapter.py` | preserve base API + direct-run bootstrap | +| base Matplotlib rendering/provenance | `backends/matplotlib_base.py` | preserve existing public behavior | +| new backend capability | adapter + capability matrix | actual implementation or explicit optional-runtime status | +| publication profile | `profiles/*.yaml`, `profiles/README.md` | source/verification date + P3 semantics | +| named palette | `core/palettes.py` | type/availability/CVD metadata | +| provenance | Matplotlib base + P2 rule | checksum/readback semantics | | public capability | README/ARCHITECTURE/MANIFEST | update together | ## 5. Accessibility invariants @@ -70,16 +72,9 @@ For `redundant_encoding: required` on a supported multi-series chart: For `adjacent_pairs`: - labels must exist in the recipe data, -- only declared pairs are checked by the WCAG-scoped adjacency gate, -- a separate legacy all-pairs project gate may still run if `aesthetics.adjacency_check` is explicitly enabled. - -## 6. Completion gate +- only declared pairs are checked by the WCAG-scoped adjacency rule, +- a separate legacy all-pairs project rule may still run if `aesthetics.adjacency_check` is explicitly enabled. -Before a PR is ready: +## 6. Consistency -- `make test` is the intended contract, -- capability matrices match actual backend behavior, -- new recipe fields are in schema and docs, -- generated sidecars are gitignored, -- no external standard is paraphrased more strongly than its real scope, -- no experimental module was silently promoted. +Keep capability matrices aligned with actual backend behavior, keep new recipe fields synchronized across schema and docs, keep generated sidecars ignored, and do not paraphrase external standards more strongly than their real scope. None of these consistency rules require GitHub Actions or a merge gate. From f682ab8d392ada40f9637fdf6bbdb492ebcba1e5 Mon Sep 17 00:00:00 2001 From: lostlight530 <13957086206@163.com> Date: Sun, 23 Aug 2026 10:46:46 +0800 Subject: [PATCH 7/8] docs: make verification local and optional --- CONTRIBUTING.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ccfa5c1..037d072 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,28 +1,28 @@ # Contributing -Contributions should strengthen the figure compiler's explicit contracts rather than only add another renderer or experimental module. +Contributions should strengthen the figure compiler's explicit research and rendering semantics rather than only add another renderer or experimental module. ## Setup ```bash python -m pip install pyyaml jsonschema matplotlib numpy pillow -make test ``` +`make test` is available as an optional local maintenance check. It is not a GitHub merge gate. + ## Rules -- Update schema, quality gates, implementation, tests, and docs together for new recipe fields. +- Update schema, runtime quality rules, implementation, and docs together for new recipe fields. - Keep policy centralized in `sci_render.py`; adapters render validated intent. - Do not mark a backend capability Implemented until the backend actually realizes it. - Preserve WCAG scope: color must not be the only required cue (SC 1.4.1), short text alternatives support non-text content (SC 1.1.1), and SC 1.4.11 applies to graphical objects/boundaries required for understanding rather than every palette pair by default. - Label project-strict rules as project rules. - Keep Matplotlib provenance/reproducibility behavior in the base adapter and accessibility policy in the public adapter where possible. +- Preserve the public Matplotlib adapter's direct-script repository-root import bootstrap. - New generated sidecars must be ignored and included in output-integrity reasoning. - Do not use “100% reproducible” or “WCAG conformant” unless a much broader system-level claim has actual evidence. -- Experimental modules remain Experimental until wired into the canonical dispatcher with tests. - -## Testing +- Experimental modules remain Experimental until wired into the canonical dispatcher. -Legacy renderer/gate/profile/provenance behavior belongs in `tests/test_all.py`. Accessibility contract and sidecar behavior belongs in `tests/test_accessibility.py`. +## Local checks -Optional R/Node runtime tests may skip when their ecosystems are absent; do not interpret a skip as verified parity. +Legacy renderer/profile/provenance behavior can be inspected with `tests/test_all.py`; accessibility contract and sidecar behavior can be inspected with `tests/test_accessibility.py` when useful. Optional R/Node runtime paths may remain unexecuted when their ecosystems are absent; do not interpret that as verified parity. From c23d1e0936e6a1605fd9bd9d2e30b415b522ee42 Mon Sep 17 00:00:00 2001 From: lostlight530 <13957086206@163.com> Date: Sun, 23 Aug 2026 10:46:57 +0800 Subject: [PATCH 8/8] manifest: remove GitHub Actions verification layer --- MANIFEST.yaml | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/MANIFEST.yaml b/MANIFEST.yaml index b7e7512..4f3f7c0 100644 --- a/MANIFEST.yaml +++ b/MANIFEST.yaml @@ -4,12 +4,12 @@ purpose: 声明式、可溯源、可访问性可审计的学术可视化工具 principles: - 声明优先: 图表数据、出版约束与可访问性意图由 YAML 声明 - 后端能力诚实: 同一配方可切换后端,但不伪装尚未实现的 accessibility/backend 能力 - - 质量前置: P0 schema 与 P1 美学/可访问性门在渲染前执行 + - 质量前置: P0 schema 与 P1 美学/可访问性规则在渲染前执行 - 输出可验证: P2 检查图件、manifest、provenance 与 accessibility sidecar - 出版规范显式: P3 profile 约束 DPI、尺寸与矢量格式 - 颜色不是唯一通道: required redundant encoding 使用 marker / line_style / hatch 提供非颜色视觉线索 - - 标准边界诚实: WCAG 条款用于设计与门禁映射,不声明整篇论文/PDF/网站自动达到 WCAG conformance - - 科学边界: gate/profile/provenance 只证明实现的谓词与记录,不证明统计结论、因果或独立复现 + - 标准边界诚实: WCAG 条款用于设计与运行时规则映射,不声明整篇论文/PDF/网站自动达到 WCAG conformance + - 科学边界: runtime checks/profile/provenance 只证明实现的谓词与记录,不证明统计结论、因果或独立复现 research_contract: file: RESEARCH_CONTRACT.md @@ -40,13 +40,14 @@ interoperability_targets: status: proposed version: "1.3" published: "2026-06-22" - note: current manifest/prov/a11y sidecars are not RO-Crates; exporter + validator + tests required + note: current manifest/prov/a11y sidecars are not RO-Crates; exporter + validator support is required before claiming one backends: - name: matplotlib language: python adapter: backends/matplotlib_adapter.py base_adapter: backends/matplotlib_base.py + direct_script_bootstrap: preserved capabilities: [png, svg, pdf] accessibility: [text-alternative-metadata, text-alternative-sidecar, redundant-series-style] default: true @@ -84,7 +85,7 @@ accessibility: conformance_claim: false quality: - gates: quality/gates.yaml + rules: quality/gates.yaml levels: [P0, P1, P2, P3] metadata: @@ -104,12 +105,12 @@ research_software: citation: CITATION.cff cff_version: "1.2.0" -verification: - local_contract: make test - github_actions: .github/workflows/ci.yml - python: "3.12" - ci_python_dependencies: [pyyaml, jsonschema, matplotlib, numpy, pillow] - node_and_r_runtime: optional_and_not_required_for_core_ci +local_checks: + command: make test + mode: manual_optional + python_dependencies: [pyyaml, jsonschema, matplotlib, numpy, pillow] + node_and_r_runtime: optional + note: local checks are maintenance aids and are not an automated merge gate experimental_modules: - projection