Claude Skill

make-figures

Generate publication-ready figures and visual abstracts for medical research papers. Supports ROC curves, forest plots, CONSORT/STARD/PRISMA flow diagrams, calibration plots, Kaplan-Meier curves, Bland-Altman plots, confusion matrices, pipeline diagrams, and journal-specific visu

LLM Mart · 0 points · 4 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download Aperivue-medsci-skills-skills_make-figures-55a3f75.zip · 1709 KB
Part of aperivue/medsci-skills — 47 skills

Install

skills CLI npx skills add https://github.com/Aperivue/medsci-skills/tree/main/skills/make-figures
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install aperivue-medsci-skills@llmmart
Git git clone https://github.com/Aperivue/medsci-skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole aperivue/medsci-skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Make-Figures Skill

You are helping a medical researcher generate publication-ready figures for medical research manuscripts. Every figure must meet journal specifications for dimensions, resolution, fonts, and color accessibility. Produce clean, data-focused visuals with no chartjunk.

Credits

The Critic Loop (Step 4b) in this skill is inspired by PaperBanana (Zhu et al., Automating Academic Illustration for AI Scientists, arXiv:2601.23265, 2025) and by prior self-refinement research — Self-Refine (Madaan et al., 2023), Reflexion (Shinn et al., 2023), and Constitutional AI (Anthropic, 2022). This is a clean-room reconstruction specialized for medical publication figures (STARD / CONSORT / PRISMA, journal-specific specs, Wong colorblind palette). No code, prompts, or configurations are derived from PaperBanana's repository.

Communication Rules

  • Communicate with the user in their preferred language.
  • All figure text (labels, legends, annotations) must be in English.
  • Medical terminology is always in English.

Data Privacy Check

Before reading any data file, check whether it might contain Protected Health Information (PHI):

  1. If *_deidentified.* files exist in the working directory, use those preferentially.
  2. If only raw CSV/Excel files exist (no *_deidentified.* counterpart), warn the user (ask in the user's preferred language):

    "Does this data contain patient identifiers (names, national ID / RRN, contact details, etc.)? If so, please de-identify it first with the /deidentify skill."

  3. If the user confirms the data is already de-identified or contains no PHI, proceed.

Reference Files

  • Figure specifications: ${CLAUDE_SKILL_DIR}/references/figure_specs.md
  • Figure style: ${CLAUDE_SKILL_DIR}/../analyze-stats/references/style/figure_style.mplstyle (or project's CLAUDE.md if available)
  • Project data: See CLAUDE.md for data locations under 2_Data/

Read figure_specs.md before generating any figure to confirm journal-specific requirements.


Journal AI-Image Policies (CRITICAL — check BEFORE generation)

Synced with the user's global rule ~/.claude/rules/journal-ai-image-policies.md. The table below is the local copy used during autonomous workflow; the global rule is authoritative when conflicts arise.

Journal family Policy on AI-generated images Disclosure required
JACC family (incl. JACC: Asia, JACC Imaging, JACC EP, JACC BTS) Prohibited without prior Editor-in-Chief permission (JACC pathway, PMC10167500) Cover-letter pre-submission inquiry + ICMJE-style declaration
NEJM AI image generation prohibited N/A
Radiology / Radiology AI Allowed with disclosure Manuscript disclosure block
Nature family Allowed with disclosure + license check Methods + figure legend
Lancet family Disclosure required, generation discouraged Manuscript disclosure
Default (target unknown) Treat as prohibited until confirmed N/A

Hard rule: For JACC, NEJM, or any "unknown" target journal, never use Gemini / DALL-E / Midjourney / Stable Diffusion / Nano Banana to create images that will appear in figures, Central Illustrations, or graphical abstracts. AI text-editing of the manuscript prose remains acceptable subject to standard disclosure.

Default workflow when AI images are not allowed

  1. SMART Servier Medical Art — https://smart.servier.com/, CC BY 4.0, free, 3,000+ vector medical icons (anatomy, organs, ethnicity-specific human figures, drugs, devices). Commercial / journal use allowed. Required attribution (1 line in figure legend OR methods):

    Anatomical icons modified from SMART Servier Medical Art (CC BY 4.0).

  2. NIAID BioArt (https://bioart.niaid.nih.gov) — public domain (US Govt), microbiology / immunology / lab-tech focus.
  3. BioRender (https://www.biorender.com) — institutional license usually required; use the exported "Publication-ready" PNG/TIFF and cite per BioRender publication policy.
  4. For "diseased" variants not directly available (e.g., calcified vessel from a clean vessel): reuse the healthy asset and overlay disease markers via matplotlib scatter / Circle / PathPatch. Keeps the entire pipeline non-AI and reproducible.

Asset directory convention

manuscript/figures/_assets_servier/      # CC BY 4.0 source PNGs
manuscript/figures/_assets_servier/CITATION.md   # source URL + download date per asset
manuscript/figures/_assets_data/         # data-driven raster (R / matplotlib heat maps, KM, etc.)
manuscript/figures/_legacy/              # archived prior versions

Composition scripts should load only from _assets_servier/ and _assets_data/. If a script imports from _assets_ai/, treat it as a policy violation for JACC/NEJM/unknown targets.

When a figure is produced by a data-driven .py/.R script (ROC, forest, KM, calibration, heat maps), lint that script before finalizing with the /analyze-stats code-quality gate (check_generated_code.py {script} --strict): it catches a missing plotting seed for any bootstrapped CI band, a hardcoded absolute data path, or a hand-typed data literal that should have been read from the analysis CSV.

Decoration vs information

Even when AI images are allowed, AI-generated illustrations are immediately recognizable to experienced reviewers (small decorative icons that add no information, overly uniform layouts, generic clip-art style). For high-impact submissions, prefer Servier / BioArt / BioRender + matplotlib overlays over AI.


DPI and Resolution Guide

Output Minimum DPI Notes
Journal halftone (photos, screenshots) 300 Standard for most journals
Journal line art (diagrams, graphs) 600 Required by Radiology, most Elsevier journals
Poster presentation 150-200 Lower is acceptable for large-format prints
Screen/web only 72-150 Not for print submission

Practical workflow for screen captures:

  • Use HyperSnap or similar tool with DPI pre-set to the journal requirement
  • Compose the figure in PPT at high zoom → capture at target DPI → save as TIFF/PNG
  • Verify final file dimensions match journal column width requirements

Visual Abstract / Graphical Abstract

Many journals now require or strongly encourage visual abstracts. European Radiology made graphical abstracts mandatory for all Original Articles from first revision (Jan 2025). Submitting one voluntarily signals effort and can improve editorial impression.

Journal Requirements

Status Example Journals
Mandatory European Radiology (from 1st revision, all Original Articles)
Encouraged Abdominal Radiology, JCO, Annals of Internal Medicine
Voluntary Most other journals — improves social media visibility

Check the target journal profile (write-paper/references/journal_profiles/) for specific visual abstract requirements before starting.

Workflow

  1. Check journal template. Look for an official PPTX template in ${CLAUDE_SKILL_DIR}/references/visual_abstract_templates/{journal}.pptx. If no journal-specific template exists, use medsci_default.pptx.
  2. Extract content from the manuscript:
    • Title: Full article title
    • Hypothesis/Question: Derived from Key Point 1 or study objective (max 1 sentence)
    • Methodology: Brief flowchart or ≤3 bullets, <6 words each
    • Visual element: Study's own figure (ROC curve, flow diagram, representative image)
    • Badges: Patient cohort (N=...) | Modality/organ | Single/Multi-center
    • Main finding: Derived from Key Point 3 (<20 words)
    • Citation: Journal (year) Authors; DOI
  3. Select visual element (priority order — no API needed for top options):
    1. Study's own figures (ROC, flow diagram, representative image) — always preferred
    2. Free illustration from Servier Medical Art or NIAID BioArt (see ${CLAUDE_SKILL_DIR}/references/medical_illustration_sources.md)
    3. Manual drawing in PPT/Keynote/Figma
    4. AI generation via generate_image.py --style medical (only if GEMINI_API_KEY set)
  4. Generate using the script:
    python ${CLAUDE_SKILL_DIR}/scripts/generate_visual_abstract.py \
      --template medsci_default \
      --title "Article Title" \
      --hypothesis "Research question" \
      --methods "Method 1|Method 2|Method 3" \
      --finding "Main finding statement" \
      --citation "Eur Radiol (2026) Author A et al; DOI:..." \
      --visual figures/fig1_roc_curve.png \
      --badges "N=450|CT chest|Multi-center" \
      --output figures/visual_abstract.pptx
    
  5. Review with user. Open the PPTX to verify layout and content. Iterate.
  6. Export. PPTX is the primary deliverable. For PNG: open in PowerPoint/Keynote → export, or use LibreOffice CLI (soffice --headless --convert-to png).

Design Principles

  • One page, landscape (16:9) or per journal template specification
  • Three sections: Study question → Key method → Main result
  • Use the study's actual figures rather than generic graphics
  • Minimize text — let visuals carry the message
  • Every visual element must serve a purpose (no decorative clip-art)

Available Templates

Template File Use When
MedSci Default medsci_default.pptx Any journal without an official template
JACC Central Illustration jacc_central_illustration.pptx JACC family journals (use --type central-illustration)

Using a journal's own template. Several journals publish one — European Radiology requires a graphical abstract from first revision and supplies EURA-GA-Jan2025.pptx. We do not redistribute them: a template you may download is not a template we may ship. Use yours directly instead:

python ${CLAUDE_SKILL_DIR}/scripts/generate_visual_abstract.py \
  --template /absolute/path/to/EURA-GA-Jan2025.pptx  ...

--template takes an absolute path to any .pptx. The script locates the fields by their text content rather than by shape name, so a journal's own template works unmodified. If the path does not exist it falls back to medsci_default.pptx.

To add a new journal template: see ${CLAUDE_SKILL_DIR}/references/visual_abstract_templates/template_guide.md.


Central Illustration vs Visual Abstract

A Central Illustration (CI) is not a Visual Abstract (VA). They serve different purposes and follow different rules. JACC family journals (JACC, JACC: Asia, JACC: Cardiovascular Imaging, JACC: Heart Failure, JACC: CardioOncology, JACC: Clinical Electrophysiology, JACC: Basic to Translational Science) require a Central Illustration with every Original Article. Reference: Fuster V, Mann DL. JACC. 2019;74(22):2816–2820.

Aspect Central Illustration Visual Abstract
Purpose Single key finding / take-home message Methods + Results pictorial summary
Where in paper End of Results / start of Discussion Beginning of paper
Methods content None Required
Audience Cardiovascular clinicians + journal-issue readers Broad including non-specialists / social media
Used by All JACC family + JACC: Asia Originally JACC: Basic to Translational Science
Text density Minimal (graphical priority) More allowed (methods labels)
Bar graphs OK if they capture entire message Avoid — use ↑↓ arrows
Default complexity 1–3 visual zones Q→M→R three blocks

Fuster-Mann five rules (CI must pass all)

  1. Know the message. One finding, not study design + multiple findings.
  2. Convey graphically, not textually. Even a simple KM curve is OK.
  3. Avoid using too much text. Replace with icons or arrows.
  4. Avoid secondary messages. ≤ 5 seconds for a viewer to state the main finding.
  5. Simplicity is superior. Default to fewer panels.

Full guidance and validation thresholds: ${CLAUDE_SKILL_DIR}/references/jacc_central_illustration_principles.md.

CI mode invocation

python ${CLAUDE_SKILL_DIR}/scripts/generate_visual_abstract.py \
  --type central-illustration \
  --visual figures/central_illustration_v2.png \
  --citation "FirstAuthor Last et al. Journal Name 2026; vol(issue):pages." \
  --output submission/jacc_asia/central_illustration.pptx \
  --ci-zones 3 --ci-label-words 22 --ci-numerical-points 2 \
  --ci-raw-text "warranty drops to 3 years in age 45+ with cardiometabolic burden; MASLD HR 1.77"

CI mode validates before rendering and rejects (exit 2) if any of: zones > 3, label words > 30, numerical points > 4, or methodology terms (cohort flow / inclusion / exclusion / study design / enrollment / randomized / sample size / CONSORT / PRISMA / STARD) appear in --ci-raw-text. Override individual rules with --ci-allow {zones|words|numerical|methods} only when you have a defensible reason.

The JACC submission PPTX is a 10×7.5 in slide with 4 placeholders (citation textbox, content picture, footer textbox reserved, JACC logo). The red border + blue "CENTRAL ILLUSTRATION:" header are applied by JACC editorial after acceptance — authors submit only the content figure + citation.


Workflow

Step 1: Specify

Before specifying figure type, read ${CLAUDE_SKILL_DIR}/references/design_principles.md — identify (1) the one-sentence key message, (2) audience and reading-time budget, and (3) whether a figure is the right vehicle (vs a small table or in-line text). The five strategies in that file shift Step 1 from "which chart fits the data" to "what should the reader remember 10 seconds later." Skip only when the figure is mandated by a reporting guideline (e.g., PRISMA / CONSORT flow), and even then apply the cognitive-load checklist.

For reporting-guideline figures, also load ${CLAUDE_SKILL_DIR}/references/reporting_guideline_figure_map.md — the 14-row table tells you which guideline mandates which figures and whether this skill ships an official template (✅), generic flow only (⚠️), or needs manual production (❌). Critical for AI-extension guidelines (CONSORT-AI, STARD-AI, TRIPOD+AI, CLAIM 2024, DECIDE-AI).

For medical AI / engineering pipeline figures (DICOM workflow, annotation pipeline, federated learning topology, model architecture), also load ${CLAUDE_SKILL_DIR}/references/pipeline_concepts_medical_ai.md — canonical layouts, required annotations, and tool selection per type.

Optional flags:

  • --study-type <type>: One of: diagnostic-accuracy, ai-validation, meta-analysis, dta-meta-analysis, observational-cohort, rct, case-report. When set, auto-generate the full figure set from the Study-Type Figure Sets table below without prompting for individual figure types.
  • --data-dir <path>: Directory containing analysis outputs (CSVs, _analysis_outputs.md). Default: current working directory.

Ask the user for:

  1. Figure type (from the supported types below) — skipped when --study-type is provided
  2. Data source (file path, DataFrame, or manual values)
  3. Target journal (for dimension/font requirements)
  4. Panel layout (single panel, multi-panel, or let you decide)
  5. Any special requests (annotations, highlights, reference lines)
  6. Study type (if not passed via --study-type): determines the required figure set

If the user provides enough context, infer missing parameters and confirm before proceeding.

Step 2: Configure

  1. Load the figure style file:
    import matplotlib.pyplot as plt
    import os
    style_path = os.path.join(os.environ.get('CLAUDE_SKILL_DIR', '.'), '../analyze-stats/references/style/figure_style.mplstyle')
    if os.path.exists(style_path):
        plt.style.use(style_path)
    
  2. Look up journal-specific dimensions from ${CLAUDE_SKILL_DIR}/references/figure_specs.md.
  3. Set the colorblind-safe palette (Wong palette by default).
  4. Configure font sizes per element type (title, axis label, tick label, legend, annotation).

Step 3: Generate

Create the figure using Python (matplotlib/seaborn as primary, with specialized libraries as needed).

Script structure:

"""
Figure: {description}
Date: {YYYY-MM-DD}
Target: {journal}
Dimensions: {width} x {height} inches @ {DPI} DPI
"""
import numpy as np
import matplotlib.pyplot as plt
import os

style_path = os.path.join(os.environ.get('CLAUDE_SKILL_DIR', '.'), '../analyze-stats/references/style/figure_style.mplstyle')
if os.path.exists(style_path):
    plt.style.use(style_path)

# Wong colorblind-safe palette
WONG = ['#000000', '#E69F00', '#56B4E9', '#009E73',
        '#F0E442', '#0072B2', '#D55E00', '#CC79A7']

np.random.seed(42)

Step 4: Review

Present the figure to the user and ask:

  • Does the layout work?
  • Are labels and annotations correct?
  • Any adjustments to colors, sizing, or emphasis?

Iterate until the user approves.

Step 4b: Critic Loop (self-critique before final export)

Before Step 5 Export, run the automated Critic Loop. This is two stages — deterministic quantitative checks via Python, then qualitative review by Claude itself — and the combined output tells us whether to re-render or hand off to the user.

Stage 1: Quantitative checks (critic_figure.py)

python ${CLAUDE_SKILL_DIR}/scripts/critic_figure.py \
    figures/fig1_stard.png \
    --type stard \
    --spec-min-dpi 600 \
    --spec-width-in 7.0 \
    --source-text figures/fig1_stard.txt \   # optional: expected strings for OCR coverage
    --out figures/fig1_stard.critique.json

This produces a JSON report covering:

  • DPI and physical width vs. journal spec
  • Dominant-color breakdown and out-of-Wong-palette fraction
  • OCR-detected word count, minimum text height, and (if a source-text file was provided) source-word coverage

Stage 2: Qualitative review (Claude session)

  1. Use the Read tool to load the generated PNG.
  2. Read the corresponding rubric file:
    • Flow diagrams: ${CLAUDE_SKILL_DIR}/references/critic_rubrics/flow_diagram.md (sections A–G; section G adds cognitive-load and template-fidelity checks)
    • Data plots: ${CLAUDE_SKILL_DIR}/references/critic_rubrics/data_plot.md (sections A–G; section G adds calibration / fairness / colorblind+redundant / dataset-flow / decision-curve checks for medical AI papers)
    • For PRISMA / CONSORT / STARD / STROBE specifically, also read ${CLAUDE_SKILL_DIR}/references/flow_diagram_lessons.md — five production lessons covering official-template fidelity, PDF export fidelity (VML fallback), docx XML escape, sequential placeholder mapping, and frozen-version sync with the manuscript.
    • For AI-extension guidelines (CONSORT-AI, STARD-AI, TRIPOD+AI, CLAIM 2024, DECIDE-AI), also read ${CLAUDE_SKILL_DIR}/references/reporting_guideline_figure_map.md — the row for the target guideline lists mandatory figures and which ones this skill cannot template (production path documented per row).
    • For medical-AI pipeline / DICOM / federated / architecture figures, also read ${CLAUDE_SKILL_DIR}/references/pipeline_concepts_medical_ai.md.
  3. Read the _why.md design notes in ${CLAUDE_SKILL_DIR}/references/exemplar_diagrams/{type}/ — hierarchy, whitespace, typography, emphasis, colour. They are the anchors. Where a rendered exemplar is bundled (template_output*.png, produced by this skill's own R script), Read 1–2 of those too; the figures cropped from published papers were removed in 2026-07 because an MIT-licensed package cannot redistribute them (see that directory's README). If you have your own exemplars locally, point the loop at them — they stay on your machine. For a non-flow data plot (forest, ROC, KM, calibration), read the matching anatomy model in ${CLAUDE_SKILL_DIR}/references/exemplar_plots/ (e.g., forest_plot.md).
  4. Score every rubric item as PASS / PARTIAL / FAIL with a one-line note, using the format at the bottom of the rubric file.
  5. Emit a "Required edits before next render" list of concrete source-code changes (D2 node renames, count corrections, matplotlib parameter tweaks).

Refinement loop

  • If all items are PASS → proceed to Step 5 Export with critic_pass: yes.
  • If any item is FAIL → apply the required edits to the source (D2 file or matplotlib script), re-render, and re-run Stage 1 + Stage 2. Default maximum is T=2 rounds; the user may request up to T=3.
  • If after the max rounds some items remain PARTIAL, proceed with critic_pass: partial and record the residual items in the manifest's critic_notes field.

Record the final state in _figure_manifest.md (see the manifest format below) so downstream steps (/write-paper Phase 2 embedding and Phase 7 DOCX build) and future critic passes can see the history.

Step 5: Export

Save final outputs:

  • PDF (vector format, preferred for journal submission)
  • PNG (300 DPI raster, for review and presentation)
  • TIFF (if the journal requires it, 300 DPI LZW compression)

Name files descriptively: fig1_roc_curve.pdf, fig2_consort_flow.pdf, etc.

For PPTX outputs (visual abstract, central illustration, or any deck the figure will live in): run the Mac-compatibility validator before delivery. PowerPoint Mac silently drops TIFF, renders <a:sp3d> 3-D bevels as red outlines that PDF export does not show, and refuses to open files whose app.xml slide count disagrees with the actual slide XML files. This script catches all four classes of defect codified in ~/.claude/rules/pptx-mac-compatibility.md:

python ${CLAUDE_SKILL_DIR}/scripts/validate_pptx_mac_compat.py \
    figures/visual_abstract.pptx \
    --json figures/visual_abstract.mac_compat.json \
    --strict

Exit code 1 means at least one FAIL — fix per the fix: field in the JSON report and re-render the PPTX before delivery. Exit code 0 with WARN is acceptable. Skip this step when the figure is PNG/PDF only (no PPTX).

Step 6: Design QC Checklist

Before delivering the final figure, verify all items:

  • Font: Sans-serif (Arial/Helvetica), minimum 7pt, axis labels ≥ 9pt
  • Color: Wong/Okabe-Ito colorblind-safe palette used
  • Colorblind test: Would the figure work for deuteranopia? (no red-green only distinctions)
  • Grayscale test: Information preserved when printed in black & white
  • Alignment: All elements on a consistent grid; panels aligned
  • Vector output: PDF/SVG saved (not just PNG)
  • Resolution: ≥ 300 DPI for raster elements, ≥ 600 DPI for line art
  • Journal specs: Dimensions, font, and format match target journal requirements
  • No chartjunk: No 3D effects, unnecessary gridlines, gradient fills, or decorative elements
  • Caption: Drafted with key finding, abbreviations, statistical details, and sample size

Study-Type Figure Sets

When the study type is known (from /write-paper Phase 0 or user specification), auto-detect and generate the complete required figure set without asking for each figure individually.

Study Type (Guideline) Required Figures
Diagnostic accuracy (STARD) STARD flow diagram, ROC curve, confusion matrix, calibration plot
AI validation (TRIPOD+AI / CLAIM) Flow diagram, ROC curve, confusion matrix, calibration plot, feature importance or SHAP, Grad-CAM (if imaging)
Meta-analysis (PRISMA) PRISMA flow diagram, forest plot, funnel plot
DTA meta-analysis (PRISMA-DTA) PRISMA flow diagram, paired forest plot (Se + Sp), SROC curve, Deeks funnel plot
Observational cohort (STROBE) Flow diagram, Kaplan-Meier curves (if survival endpoint)
RCT (CONSORT) CONSORT flow diagram, primary endpoint figure
Case report / series (CARE) Clinical timeline figure (exemplar_plots/clinical_timeline.md), annotated multimodality imaging panel when visually load-bearing (exemplar_plots/imaging_panel.md); for a series, an all-cases summary table

The manifest is mandatory. After generating all figures, write figures/_figure_manifest.md — one row per figure (Figure | Path | Type | Tool | Critic | Rounds | Description) plus a ## Critic notes section recording any residual PARTIAL items and why they were accepted. It is consumed by /write-paper Phase 2 (figure embedding) and Phase 7 (DOCX build); verify it exists and is non-empty before finishing. Format and field definitions: ${CLAUDE_SKILL_DIR}/references/figure_manifest.md.

Flow diagram generation rule. STARD / CONSORT / PRISMA / STROBE flow diagrams MUST use the standardized R pipeline scripts/generate_flow_diagram.R (DiagrammeR + Graphviz dot + rsvg) — the single canonical tool for all four. Do NOT use matplotlib FancyBboxPatch (manual coordinates break when text changes, and patches distort when embedded in DOCX). Do NOT use D2 for new flow diagrams (weak font control, overlap needs manual post-processing). Numbers in labels must be CSV-derived, or hand-written only when the value lives in a commit-tracked data artifact.

Read on demand:

File Read it when Cost if read blindly
references/flow_diagram_recipe.md you are generating a STARD / CONSORT / PRISMA / STROBE flow diagram ~2,200 tokens — a ROC curve or forest plot needs none of it
references/figure_manifest.md you are writing _figure_manifest.md ~700 tokens of output format

Tool Selection Guide

Choose the right tool for each figure type. Using matplotlib for flow diagrams leads to hard-coded coordinates that break when text changes — use auto-layout tools instead.

Data Visualization → matplotlib/seaborn (this skill)

Best for figures where data drives the layout. This skill handles these directly:

Type Use Case Key Library
ROC Curve Diagnostic accuracy matplotlib, sklearn
Forest Plot Meta-analysis matplotlib
Calibration Plot Prediction model matplotlib
KM Curve Survival analysis lifelines, matplotlib
Bland-Altman Agreement matplotlib
Confusion Matrix Classification seaborn
Box/Violin Plot Group comparison seaborn
Bar Chart Categorical comparison matplotlib
Heatmap Correlation/agreement seaborn

Flow Diagrams → Dedicated Tools (NOT matplotlib)

Flow diagrams require auto-layout engines. Do NOT use matplotlib patches with manual coordinates — this causes the "absolute coordinate hell" problem where changing one box breaks all downstream positions.

Type Recommended Tool Why
STROBE (cohort / cross-sectional) scripts/generate_flow_diagram.R --type strobe Single canonical tool; auto-layout; vector PDF + 300/600 dpi PNG
CONSORT (RCT) scripts/generate_flow_diagram.R --type consort Same pipeline; monochrome Arial default
PRISMA 2020 (SR/MA) scripts/generate_flow_diagram.R --type prisma Faithfully implements PRISMA 2020 structure; avoids PRISMA2020 R package's webshot-based raster PDF issue
STARD (DTA) scripts/generate_flow_diagram.R --type stard Same pipeline; supports 2x2 reference-standard split
Pipeline Diagram D2 (legacy) Until pipeline-diagram support is added to the R script

R workflow for flow diagrams: See the "R flow diagram recipe" above in the Flow diagram generation rule. Key points: YAML config → Rscript scripts/generate_flow_diagram.R --type <t> --config <yaml> --out <prefix> → PDF + 300/600 dpi PNG. Templates in references/exemplar_diagrams/{strobe,consort,prisma,stard}/template_input.yaml.

Official Reporting Guideline Templates → templates/official/

When a journal requires the canonical, statement-issued template (rather than the auto-laid-out R version), use the bundled official files in templates/official/{prisma2020,consort2010,stard2015,spirit2013}/.

Guideline What ships When to use
PRISMA 2020 Locally built .pptx (4 variants) + fill_prisma_template.py Reviewer asks for the official PRISMA 2020 layout, or you want editable PowerPoint instead of an R-rendered PDF.
STROBE (cohort) Parametric .pptx builder build_strobe_template.py (single-script, takes YAML config) Cohort/case-control study Figure 1 when co-authors want PowerPoint they can hand-edit. Auto-fits text, content-fits slide, dashed-border exclusion side-branches with strictly-horizontal connectors. Optional left-side phase column (omit stages: for the plain STROBE convention; include it for the PRISMA-style Identification/Screening/Inclusion/Analysis column). Pair with generate_flow_diagram.R --type strobe for the vector PDF/TIFF submission file.
CONSORT 2025 Official .docx flow diagram + checklist RCT submissions to journals that mandate the consort-spirit.org template.
STARD 2015 Official .pdf flow diagram + .docx checklist Diagnostic accuracy studies; flow diagram is fixed PDF, checklist is editable.
SPIRIT 2025 Official .docx participant timeline + checklist Trial protocols.

Refresh / fill workflow:

# Refresh from canonical sources (CC-BY 4.0 / public-statement licenses)
bash ${CLAUDE_SKILL_DIR}/scripts/fetch_official_templates.sh

# Build PRISMA 2020 .pptx (one-time; site blocks programmatic .docx fetch)
python3 ${CLAUDE_SKILL_DIR}/scripts/build_prisma2020_template.py \
    --variant new \
    --out ${CLAUDE_SKILL_DIR}/templates/official/prisma2020/PRISMA_2020_flow_new_v1.pptx

# Fill counts — positional 10-tuple matching most SR/MA workflows:
#   n_db, n_dup, n_screened, n_screen_excluded,
#   n_sought, n_assessed, n_excl_r1, n_excl_r2, n_excl_r3, n_studies
python3 ${CLAUDE_SKILL_DIR}/scripts/fill_prisma_template.py \
    --template ${CLAUDE_SKILL_DIR}/templates/official/prisma2020/PRISMA_2020_flow_new_v1.pptx \
    --counts "315,122,186,7,111,204,102,84,3,15" \
    --out fig1_prisma_filled.pptx

# Or use full JSON mapping for studies with non-standard PRISMA splits
python3 ${CLAUDE_SKILL_DIR}/scripts/fill_prisma_template.py \
    --template ${CLAUDE_SKILL_DIR}/templates/official/prisma2020/PRISMA_2020_flow_new_v1.pptx \
    --counts-file my_counts.json \
    --out fig1_prisma_filled.pptx

# STROBE — parametric single-script builder (cohort study; spine structure varies per study).
# YAML schema: stages, spine (id/stage/text), exclusions (after/text). Consecutive same-stage
# rows share one phase label automatically. Stage box fills auto-pick readable text color.
python3 ${CLAUDE_SKILL_DIR}/scripts/build_strobe_template.py \
    --config figures/figure1_strobe.yaml \
    --out    figures/figure1_strobe.pptx

The builder checks that the exclusion cascade closes — the count in a spine box, minus the exclusions declared after it, must equal the next spine box (A - Σ(exclusions after A) == B), for every link that declares an exclusion. It warns loudly on any imbalance and, with --strict-cascade, refuses to build. This catches the figure-image arithmetic drift that text-grep and prose gates miss (a dropped exclusion leaving the figure short of the analytic N). Run scripts/_strobe_cascade.py --config figure1_strobe.yaml --strict to check a config without rebuilding the diagram.

For STROBE the canonical KJR/Radiology/BMJ submission flow is:

  1. Render the vector submission file via the auto-fitting Graphviz path: Rscript ${CLAUDE_SKILL_DIR}/scripts/generate_flow_diagram.R --type strobe --config figures/figure1_strobe_graphviz.yaml --out figures/figure1
  2. Build the editable PowerPoint companion via build_strobe_template.py so co-authors and senior reviewers can adjust prose/positioning before sign-off.
  3. Re-export the final PPTX to PDF/TIFF only after co-author edits are integrated.

See templates/official/NOTES.md for licenses, attribution, and refresh notes.

Visual / Graphical Abstracts → python-pptx Template Generator

Type Recommended Tool
Visual Abstract (any journal) generate_visual_abstract.py with PPTX template
Visual element illustration Study's own figures (preferred), or free libraries (Servier/NIAID)
Medical Illustration See ${CLAUDE_SKILL_DIR}/references/medical_illustration_sources.md

See the Visual Abstract section above for the full workflow.

Hybrid Workflow (recommended for publication)

Data plots:    matplotlib/seaborn → PDF + PNG (this skill)
Flow diagrams: generate_flow_diagram.R (DiagrammeR + rsvg) → PDF + 300/600 dpi PNG
Final assembly: pandoc or python-docx (auto-embedded in DOCX)

Supported Figure Types (matplotlib/seaborn)

Type Use Case Key Library Output
ROC Curve Diagnostic accuracy matplotlib, sklearn Single/multi-model ROC with AUC
Forest Plot Meta-analysis matplotlib Effect sizes with CIs, diamond summary
Calibration Plot Prediction model matplotlib Observed vs predicted with Hosmer-Lemeshow
KM Curve Survival analysis lifelines, matplotlib With risk table, log-rank p
Bland-Altman Agreement matplotlib With mean diff, +/-1.96 SD limits
Confusion Matrix Classification seaborn Heatmap with percentages
Box/Violin Plot Group comparison seaborn With individual data points
Pipeline Diagram Methods figure D2 (preferred) or matplotlib Processing/workflow steps
Bar Chart Categorical comparison matplotlib With error bars (CI or SD)
Heatmap Correlation/agreement seaborn Color-coded matrix

Figure Type Templates

ROC Curve

from sklearn.metrics import roc_curve, auc

fig, ax = plt.subplots(figsize=(3.5, 3.5))
fpr, tpr, _ = roc_curve(y_true, y_score)
roc_auc = auc(fpr, tpr)
ax.plot(fpr, tpr, color=WONG[5], lw=1.5,
        label=f'Model (AUC = {roc_auc:.3f})')
ax.plot([0, 1], [0, 1], 'k--', lw=0.8, alpha=0.5)
ax.set(xlabel='1 - Specificity', ylabel='Sensitivity',
       xlim=[-0.02, 1.02], ylim=[-0.02, 1.02])
ax.legend(loc='lower right', frameon=False)
  • For multiple models: use distinct Wong palette colors, include AUC + 95% CI in legend.
  • For comparison: report DeLong p-value in annotation.

Forest Plot

  • Horizontal layout: effect sizes as squares (sized by weight), CIs as lines.
  • Diamond at bottom for pooled estimate.
  • Vertical dashed line at null effect (OR=1 or MD=0).
  • Axis label: "Favours A | Favours B" or appropriate.
  • Include heterogeneity stats (I-squared, p) below the diamond.

Flow Diagrams (STROBE / CONSORT / PRISMA / STARD)

Single canonical tool: scripts/generate_flow_diagram.R (see the R flow diagram recipe above). Do not fall back to matplotlib for flow diagrams — manual coordinates break when text changes and patches distort in DOCX. D2 remains a documented legacy fallback only when R is unavailable.

Layout invariants:

  • Rectangular boxes with rounded corners for stages; notes (shape: note) for exclusion side-boxes.
  • Vertical top-down flow by default; horizontal only when the manuscript layout demands it.
  • Every box label contains the count (e.g., "Assessed for eligibility\n(n = 450)").
  • Numbers are CSV-derived (numerical-safety) — author the YAML from an R/Python script that reads the upstream data, or cite the source file in a comment when a literal value is unavoidable.
  • Follow the official template layout from each guideline.
  • Use relative positioning — never hard-code absolute y-coordinates. Calculate each box position from the previous box's bottom edge plus a consistent gap constant.
  • Define gap constants at the top of the script (e.g., GAP_SMALL = 1.5, GAP_BRANCH = 2.2).
  • Avoid magic number padding in arrow endpoints — use named constants.

D2 approach (legacy fallback — use only when R is unavailable; the R script above is canonical):

d2 --layout elk --theme 0 flow.d2 output.svg
# Then: open SVG in Figma → grid-snap → font swap → export PDF

Caption ↔ flow-SSOT reconciliation (before Step 5 Export). The flow-diagram config (the YAML/script that generate_flow_diagram.R consumes) is the single source of truth for participant counts. A hand-written Figure 1 caption drifts from it whenever the cohort is re-locked but the caption is not — the classic "caption says n = 1,284 analytic, diagram box says n = 998" defect, which surfaces only at submission. Re-derive the caption counts from the flow config and reconcile:

python3 ${CLAUDE_SKILL_DIR}/scripts/derive_figure_legend_counts.py \
  --flow-config figures/figure1_strobe_graphviz.yaml \
  --manuscript manuscript/index.qmd \
  --out qc/figure_legend_counts.json --strict

Any n = N in the caption that is not a box count in the flow config is a MISMATCH (stale caption) — update the caption from the config, never the reverse. This pairs with numerical-safety's "re-derive prose counts every revision" rule and with /sync-submission's cross-document N checks. (The reconciler is stdlib-only and parses the config as text, so it works regardless of the flow tool.)

Calibration Plot

  • 45-degree reference line (perfect calibration).
  • Grouped observed vs predicted with error bars.
  • Report Hosmer-Lemeshow statistic and Brier score in annotation.
  • Optional: histogram of predicted probabilities at the bottom.

Kaplan-Meier Curve

  • Step function with distinct colors per group.
  • Censoring marks as small vertical ticks.
  • Number-at-risk table below the plot (aligned with x-axis ticks).
  • Log-rank p-value in annotation.
  • Median survival with 95% CI if applicable.

Bland-Altman Plot

  • X-axis: mean of two measurements.
  • Y-axis: difference between measurements.
  • Horizontal lines: mean difference (solid), +/-1.96 SD (dashed).
  • Annotate the mean diff and limits of agreement values.
  • Optional: proportional bias check (regression line through points).

Confusion Matrix

  • Heatmap with both counts and percentages in each cell.
  • Row-normalized percentages preferred (sensitivity per class).
  • Clear axis labels: "Predicted" (x) and "Actual" (y).
  • Use sequential colormap (Blues or Greens), not diverging.

Box/Violin Plot

  • Show individual data points (jittered) overlaid on box or violin.
  • Mark median and mean distinctly.
  • Statistical annotation brackets with significance stars.
  • Stars: * p<0.05, ** p<0.01, *** p<0.001, ns for non-significant.

Pipeline Diagram

  • Horizontal or vertical flow of processing stages.
  • Boxes: rounded rectangles with stage name and brief description.
  • Arrows: labeled with data counts or transformation type.
  • Color-code stages by category (data collection, processing, validation).
  • Keep text minimal; use supplementary caption for details.

Bar Chart

  • Error bars: 95% CI (preferred) or SD, stated in caption.
  • Individual data points overlaid if n < 30.
  • Horizontal orientation for many categories.
  • Sort by value (descending) unless order is meaningful.

Heatmap

  • Annotate cells with values.
  • Use sequential colormap for correlation (coolwarm diverging if centered at zero).
  • Mask diagonal for correlation matrices.
  • Cluster rows/columns if appropriate.

Style Rules

Colors

Wong colorblind-safe palette (default):

WONG = ['#000000', '#E69F00', '#56B4E9', '#009E73',
        '#F0E442', '#0072B2', '#D55E00', '#CC79A7']

Sequential palettes (for heatmaps):

  • Positive values: Blues or Greens
  • Diverging (centered at 0): coolwarm or RdBu_r
  • Agreement matrices: YlOrRd

Rules:

  • Never use red-green only distinctions.
  • Use line style (solid, dashed, dotted) in addition to color for line plots.
  • Use marker shape in addition to color for scatter plots.

Typography

Element Font Size Weight
Figure title (if any) 10 pt Bold
Axis label 9 pt Regular
Tick label 8 pt Regular
Legend text 8 pt Regular
Annotation 8 pt Regular
Panel label (A, B, C) 12 pt Bold
  • Font family: Arial or Helvetica (sans-serif).
  • Panel labels: uppercase bold letter, top-left of each panel.

Layout

  • Minimize white space while maintaining readability.
  • Align multi-panel figures on a grid.
  • Consistent axis ranges across comparable panels.
  • No figure titles in the plot itself (title goes in the caption below).

Statistical Annotations

  • Significance stars: * p<0.05, ** p<0.01, *** p<0.001
  • Place above comparison brackets.
  • Report exact p-value in the figure legend or caption, not in the plot.
  • For AUC, correlation, or agreement: display in the legend with 95% CI.

Journal Specifications

Default dimensions (override from figure_specs.md if journal-specific):

  • Single column: 3.5 in (88 mm) width
  • 1.5 column: 5.0 in (127 mm) width
  • Double column: 7.0 in (178 mm) width
  • Full page: 7.0 x 9.5 in (178 x 241 mm)
  • DPI: 300 minimum for halftone, 600 for line art
  • File formats: PDF (vector, preferred) + PNG (300 DPI)
  • No chartjunk: no 3D effects, no unnecessary gridlines, no decorative elements, no gradient fills

Multi-Panel Figures

For composite figures with multiple panels:

fig, axes = plt.subplots(nrows, ncols, figsize=(width, height))

# Label each panel
for ax, label in zip(axes.flat, 'ABCDEFGH'):
    ax.text(-0.15, 1.05, label, transform=ax.transAxes,
            fontsize=12, fontweight='bold', va='top')

Common layouts:

  • 2-panel horizontal: figsize=(7.0, 3.5), 1 row x 2 cols
  • 2-panel vertical: figsize=(3.5, 7.0), 2 rows x 1 col
  • 2x2 grid: figsize=(7.0, 7.0), 2 rows x 2 cols
  • 3-panel: figsize=(7.0, 3.0), 1 row x 3 cols

Use plt.tight_layout() or fig.subplots_adjust() for spacing.


Caption Writing

After generating each figure, draft a caption following these rules:

  1. First sentence: Describe what the figure shows (type + key finding).
  2. Subsequent sentences: Define abbreviations, explain symbols, state sample sizes.
  3. Statistical details: Note the test used and significance threshold.
  4. Format: "Figure . " -- no bold, no title case.

Example:

Figure 1. Receiver operating characteristic curves comparing the diagnostic performance of the multi-agent pipeline (blue) and single-agent baseline (orange) for identifying incorrect Anki flashcard content. The area under the curve was 0.92 (95% CI: 0.89-0.95) for the multi-agent pipeline and 0.84 (95% CI: 0.80-0.88) for the single-agent baseline (DeLong test, p = 0.003). The dashed diagonal line represents chance performance.


Skill Interactions

When Call Purpose
Need statistical values for plot /analyze-stats Get computed values (AUC, CI, p-values)
Flow diagram for manuscript /write-paper Phase 2 Coordinate with Tables & Figures plan
Caption review /write-paper Phase 7 Final polish pass

Error Handling

  • If data is insufficient for the requested figure type, explain what is needed and ask the user.
  • If a figure exceeds journal dimension limits, resize and report the adjustment.
  • If text overlaps in the figure, try tight_layout(), reduce font size, or adjust spacing.
  • Never fabricate data points. If sample data is needed for a template demo, explicitly label it as "example data."

CLI Tools Available

ImageMagick, Ghostscript, FFmpeg are installed and can be used for post-processing:

# Figure DPI/format conversion for journal submission
magick input.png -density 300 -units PixelsPerInch output.tiff
magick input.png -resize 1200x -quality 95 output.jpg

# CMYK conversion (some print journals require this)
magick input.png -colorspace CMYK output.tiff

Portal-ready TIFF (SNAPP .png-not-accepted / 25 MB cap)

A raw magick ... output.tiff keeps the alpha channel (transparent regions print black on many production pipelines) and stays uncompressed (a 600-dpi RGBA TIFF blows past a portal's 25 MB cap). export_portal_tiff.py does the flatten-and-compress a human otherwise does by hand and verifies the result is pixel-identical to that white-flatten before handing it over — use it when a portal accepts only .tiff/.jpeg/.eps (Springer Nature SNAPP) or caps figure size (JACC: Asia):

python3 scripts/export_portal_tiff.py --in figure.png --out figure.tiff --max-mb 25
# LZW-compressed, RGBA→RGB white-flattened, pixel-identity-verified; exit 1 if still over the cap
# Multi-panel figure assembly (A/B/C/D panels)
magick montage panelA.png panelB.png panelC.png panelD.png \
  -tile 2x2 -geometry +10+10 -density 300 combined.png

# Animated figure (GIF from frame sequence)
ffmpeg -framerate 2 -i frame_%03d.png -vf "scale=800:-1" output.gif

# Video from figure sequence (for supplementary materials)
ffmpeg -framerate 1 -i slide_%03d.png -c:v libx264 -pix_fmt yuv420p supplementary_video.mp4

AI Image Generation (Optional)

AI illustration is a supplementary option, not a requirement. Visual abstracts and figures can be completed without any API key using study figures and free illustration libraries.

If GEMINI_API_KEY is set, the generate_image.py script can generate illustrations:

python ${CLAUDE_SKILL_DIR}/scripts/generate_image.py \
  "Clean medical illustration of a CT-guided lung biopsy procedure, \
   flat vector style, white background, no text" \
  --output output.png --aspect 16:9

Use for: procedural schematics, anatomical illustrations, pipeline diagrams. Always review AI output against the AI-Generated Figure Warning section above.

If GEMINI_API_KEY is not set, guide the user to free illustration resources: see ${CLAUDE_SKILL_DIR}/references/medical_illustration_sources.md.

Language

  • Code and figure text: English
  • Communication with user: Match user's preferred language
  • Medical terms: English only

Anti-Hallucination

  • Never fabricate references. All citations must be verified via /search-lit with confirmed DOI or PMID. Mark unverified references as [UNVERIFIED - NEEDS MANUAL CHECK].
  • Never invent clinical definitions, diagnostic criteria, or guideline recommendations. If uncertain, flag with [VERIFY] and ask the user.

Global-rule references

Some passages in this skill cite a path of the form ~/.claude/rules/<name>.md. Those are the maintainer's personal global rules, kept outside this repository. They are not shipped with this skill and will not exist on your machine; they appear only as provenance for where a convention came from. If one of them looks like it is standing in for an instruction you actually need, that is a bug — please open an issue, because the instruction belongs here.

Files (medsci-skills)
  • references
    • critic_rubrics
      • data_plot.md 7.5 KB
        # Critic Rubric — Data Plots
        
        Apply this rubric when the generated figure is a data visualization (not a
        flow diagram): ROC, forest, KM, calibration, Bland-Altman, confusion matrix,
        Manhattan/volcano, box/violin, bar, heatmap. The Claude session should Read the rendered PNG
        plus any available exemplars from `references/exemplar_diagrams/{type}/`,
        then mark each item below as **PASS / PARTIAL / FAIL** with a one-line
        justification.
        
        After scoring, produce a list of concrete matplotlib/seaborn edits that
        would resolve every FAIL or PARTIAL item. Return the scored rubric + edit
        list to the user.
        
        ---
        
        ## A. Axes, labels, units
        
        1. **X-axis label** present with units where applicable.
        2. **Y-axis label** present with units where applicable.
        3. **Axis tick labels** readable (no overlap, sensible density, no scientific
           notation where plain numbers fit).
        4. **Axis limits** sensible — no wasted whitespace (e.g., ROC axes should
           be 0–1 exactly, forest plot x-axis should frame all CIs without
           clipping).
        5. **Tick direction inward**, tick marks minor/major where needed. No
           chart-spine clutter.
        
        ## B. Legend / caption
        
        6. **Legend present** when ≥2 series are shown; placed where it does not
           obscure data.
        7. **Legend labels** are descriptive (not "Series 1"); include N or group
           size where relevant.
        8. **Legend ordering** matches visual order of series (top to bottom).
        9. **Caption-ready** — plot does not rely on external notes the caption
           cannot repeat concisely.
        
        ## C. Figure-type–specific requirements
        
        ### ROC curve
        - AUC and 95% CI displayed on plot or in legend
        - Diagonal reference line (chance) shown
        - Sensitivity on Y-axis (0–1), 1−Specificity on X-axis (0–1)
        - Multiple models distinguished by color AND line style (grayscale-safe)
        
        ### Forest plot
        - Effect estimates aligned to a vertical reference line (null effect)
        - CIs drawn as horizontal bars with appropriate caps
        - Box size proportional to study weight
        - Diamond summary at the bottom; pooled estimate and CI printed
        - Left column: study labels; right column: effect (95% CI) numeric
        - Heterogeneity statistics (I², τ², Q p-value) reported in caption or plot
        
        ### KM curve
        - Number at risk table below the plot
        - Median survival and its CI shown or reported in caption
        - Log-rank p-value or Cox HR reported
        - Curves distinguishable in grayscale (line style + color)
        - Censoring marks visible
        
        ### Calibration plot
        - Ideal diagonal line (y=x) shown
        - Binned observed vs. predicted points
        - Hosmer-Lemeshow p-value or Brier score reported
        - Histogram of predicted probabilities overlaid or beside the plot
        
        ### Bland-Altman
        - Mean difference line
        - ±1.96 SD limits of agreement
        - LoA values printed
        - Scatter points not clipped at plot edges
        
        ### Confusion matrix
        - Cells annotated with counts AND percentages
        - Axis labels "Predicted" / "Actual" (or "Reference")
        - Diagonal emphasized (darker color or heavier stroke) if useful
        
        ### Manhattan / volcano (agnostic many-exposure scan: ExWAS / EWAS / MWAS)
        - Significance threshold line drawn AND its basis stated (FWER/Bonferroni or FDR), with the **number of tests** in the caption
        - Y-axis is −log10(p) (Manhattan) or −log10(p) vs effect size (volcano) — a volcano must show effect size, not significance alone
        - Hits labeled sparingly (top/threshold-crossing only); the full tested set is in a supplement, not crowded onto the plot
        - Direction of effect distinguishable (volcano: up/down; Manhattan: a sign/colour track if signed)
        - Caption states whether hits are **replicated** (discovery-only scans are exploratory)
        
        ## D. Typography and accessibility
        
        10. **Font size ≥ 8pt at final dimensions** (most journals require 6–7pt min
            for axis numerics, 8pt for labels). At 300 DPI in a 3.5×3.5 inch
            figure, this corresponds to roughly ≥25 px — use `critic_figure.py`
            OCR min-height flag as a proxy.
        11. **Single font family** throughout (typically sans-serif). No mixed
            Arial/Helvetica/Times.
        12. **Colors from Wong palette** or equivalent colorblind-safe scheme.
            `critic_figure.py` flags out-of-palette fractions >15%.
        13. **Grayscale-safe** — every series is distinguishable in grayscale
            conversion, either through line style, marker shape, or luminosity
            differences.
        
        ## E. Publication readiness
        
        14. **Vector PDF produced** in addition to PNG.
        15. **Dimensions match journal spec** — width per figure_specs.md for the
            target journal (single column ≈3.5 in, double ≈7.0 in).
        16. **DPI ≥ 300 (halftone) or ≥ 600 (line art)**.
        17. **No duplicate data encoding** (e.g., color + shape + size all mapped
            to the same variable adds clutter without information).
        18. **Statistical annotations** — where significance markers are used
            (`*`, `**`, `***`), the caption defines thresholds. Prefer actual
            p-values for publication.
        
        ## F. Exemplar comparison (if exemplars exist for this type)
        
        For non-flow types, the worked anatomy models live in `../exemplar_plots/` (e.g.,
        `forest_plot.md`) — read the matching one and confirm the draft has every element it lists.
        
        19. **Visual density** comparable to exemplars — not significantly sparser
            or more cluttered.
        20. **Annotation style** — placement of summary statistics, N labels,
            p-values consistent with exemplars.
        
        ## G. Medical AI / prediction-model checks (added v1.1.0)
        
        Apply when the figure supports a prediction-model or medical-AI claim
        (TRIPOD+AI, CLAIM 2024, STARD-AI, CONSORT-AI). Source:
        `reporting_guideline_figure_map.md` "AI-specific figures most often
        missing."
        
        21. **Calibration plot accompanies discrimination** — when the manuscript
            reports AUC/c-statistic, a calibration plot is also presented (or the
            figure is paired with one). TRIPOD+AI mandates calibration; AUC alone
            is insufficient evidence of model fitness.
        22. **Subgroup / fairness panel** — when the deployment claim covers
            multiple demographic groups, sites, or scanner vendors, performance
            is shown stratified by at least one such axis. CLAIM 2024 §C and
            TRIPOD+AI both require this.
        23. **Colorblind-safe + redundant encoding** — color carrying diagnostic
            meaning is paired with at least one non-color cue (line style, marker
            shape, or direct label) so the figure survives deuteranopia
            simulation and grayscale conversion. (Crameri 2024.) Stronger than
            the existing item D.13.
        24. **Dataset-flow visible** — for AI papers reporting performance on a
            test set, the manuscript also includes a dataset-flow diagram with
            counts at training / tuning / internal-test / external-test splits.
            Required by STARD-AI, CLAIM 2024, TRIPOD+AI. If the figure under
            review is not the dataset-flow itself, confirm one exists elsewhere
            in the manuscript.
        25. **Decision-curve analysis (when claiming clinical utility)** — papers
            that argue "this model would change clinical management" must
            accompany discrimination/calibration with a decision-curve plot
            (Vickers & Elkin, *Med Decis Making* 2006). Recommended by TRIPOD+AI.
        
        ---
        
        ## Scoring output format
        
        ```
        ## Critic report (data plot, round T)
        
        | Item | Score | Note |
        |------|-------|------|
        | A.1 X-axis label | PASS | — |
        | A.2 Y-axis label | FAIL | Missing units ("Sensitivity" should be unitless OK; but "Time" needs "(months)") |
        | ...
        
        ### Required edits before next render
        1. Add unit "(months)" to Y-axis label.
        2. Increase legend font from 6pt to 8pt.
        3. ...
        
        ### Overall verdict
        [ ] PASS — ready for manuscript
        [ ] REFINE — items above must be fixed before next round
        ```
        
        Record `critic_pass: yes | partial | no` and `refine_rounds: N` in the
        `_figure_manifest.md` for this figure after the final round.
        
      • flow_diagram.md 7.5 KB
        # Critic Rubric — Flow Diagrams
        
        Apply this rubric when the generated figure is a flow diagram: STARD,
        CONSORT, PRISMA, or pipeline/methods. The Claude session should Read the
        rendered PNG plus any available exemplars from
        `references/exemplar_diagrams/{stard|consort|prisma|pipeline}/`, then mark
        each item below as **PASS / PARTIAL / FAIL** with a one-line justification.
        
        After scoring, produce a concrete list of source edits (D2 code changes,
        node label fixes, count corrections) that would resolve every FAIL or
        PARTIAL item. Return the scored rubric + edit list to the user.
        
        ---
        
        ## A. Structural integrity
        
        1. **Box overlap** — No two boxes or text labels overlap. Arrows do not cross
           through node text.
        2. **Readable edges** — Every edge has a clear source and target; direction is
           unambiguous; no dangling arrows.
        3. **Hierarchy preserved** — Vertical or horizontal flow reads in a single
           dominant direction; upstream nodes appear before downstream ones.
        4. **Alignment** — Peer nodes at the same logical level are aligned
           (baseline or center).
        5. **Whitespace** — No excessive vertical gap (>2× box height) between
           sequential steps. No cramped collisions at decision points.
        
        ## B. Required elements (per figure type)
        
        ### STARD flow (diagnostic accuracy)
        - Enrollment count (consecutive vs. random, eligible n)
        - Included n (with inclusion criteria applied)
        - Excluded n with itemized reasons
        - Index test performed (n analyzed)
        - Reference standard performed (n analyzed)
        - Final analysis cohort (n with both tests)
        
        ### CONSORT flow (RCT)
        - **Enrollment** section: Assessed for eligibility (n), Excluded (n with
          reasons: did not meet criteria, declined, other)
        - **Allocation** section: Randomized (n), Allocated to each arm (n received /
          n did not receive intervention)
        - **Follow-up** section: Lost to follow-up (n, reasons), Discontinued (n,
          reasons) per arm
        - **Analysis** section: Analyzed (n), Excluded from analysis (n, reasons)
          per arm
        
        ### PRISMA 2020 flow (systematic review)
        - Records identified from databases (n per database)
        - Records identified from other sources (registers, citation search, etc.)
        - Records after duplicates removed (n)
        - Records screened (n) → Records excluded (n)
        - Reports sought for retrieval (n) → Reports not retrieved (n)
        - Reports assessed for eligibility (n) → Reports excluded with reasons (n, reason categories)
        - Studies included in review (n) / Reports of included studies (n)
        
        ### Pipeline / methods
        - Input data block clearly named (modality, cohort)
        - Preprocessing steps in sequence (resampling, normalization, augmentation)
        - Model / algorithm block with name or architecture
        - Output / task block (segmentation, classification, regression)
        - Evaluation metric block where applicable
        
        ## C. Numerical consistency
        
        6. **Arithmetic balance** — At each branching node, `in = out + excluded`.
           Verify every subtraction explicitly. Flag any mismatch.
        7. **Column totals consistent** — For parallel arms (CONSORT), arm totals
           sum to the randomized total at every stage.
        8. **No duplicate counts** — A participant should not appear in two leaf
           nodes unless the diagram explicitly models this.
        9. **Manuscript consistency** — Numbers in the diagram match the
           corresponding text in Methods/Results (apply the [VERIFY-CSV] rule —
           every count should trace to a CSV cell or source query).
        
        ## D. Typography and accessibility
        
        10. **Font size ≥ 18pt** at the compact-recipe render dimensions (main boxes
            ≥ 20pt, exclusion boxes ≥ 18pt, italic notes ≥ 17pt). Check against the
            `critic_figure.py` OCR min-height flag.
        11. **No truncated text** — No box shows clipped text ("Assessed for eli...").
            OCR coverage check (source_word_coverage ≥ 0.95).
        12. **Consistent casing** — Sentence case everywhere, or Title Case
            everywhere — not mixed.
        13. **Wong palette or neutral only** — Fill colors drawn from the Wong
            colorblind-safe set plus whites/light grays. No red-green only
            distinctions.
        14. **Grayscale-safe** — Conversion to grayscale preserves all distinctions
            between box categories (intent vs. exclusion vs. outcome).
        
        ## E. Publication readiness
        
        15. **Vector format available** — Both PNG (for DOCX embedding) and PDF
            (for journal submission) were produced. If PDF missing, flag.
        16. **Dimensions match journal spec** — Width matches the target journal's
            single-column or double-column specification (±0.3 in).
        17. **DPI meets spec** — ≥600 DPI for line-art submissions, ≥300 DPI for
            halftone.
        18. **Policy compliance** — Tool is D2 or a permitted auto-layout engine,
            not matplotlib FancyBboxPatch and not Mermaid. (Mermaid is forbidden
            in papers per the project's figure-toolchain policy.)
        
        ## F. Exemplar comparison (if exemplars exist for this type)
        
        19. **Hierarchy depth** matches one of the exemplars within ±1 level.
        20. **Typographic weight** — Main step labels are visually heavier
            (bold/larger) than parenthetical / exclusion labels, as in the
            exemplars.
        21. **Emphasis placement** — Key cohort counts (final analysis n, primary
            outcome n) are visually emphasized (thicker stroke, larger font, or
            fill distinction), consistent with exemplar conventions.
        
        ## G. Communication-first checks (added v1.1.0)
        
        These checks operationalize `references/design_principles.md` (Nature Hum
        Behav 2026) and `references/flow_diagram_lessons.md`. Apply when the
        diagram will be circulated to senior co-authors or submitted to a peer-
        reviewed venue.
        
        22. **Cognitive load** — Each column has ≤7 boxes; each diagram uses ≤3
            distinct shapes (e.g., rectangle / rounded rectangle / note) and ≤3
            fill colors. If the count is exceeded, fold detail into supplementary
            or split into a multi-panel figure.
        23. **Key-message visibility** — The analytic cohort (final n included in
            primary analysis) is visually emphasized via thicker stroke
            (`penwidth ≥ 1.8`), distinct fill, or larger font, so the reader's
            eye lands on it within 2 seconds.
        24. **Official-template fidelity** (when applicable) — Layout matches the
            canonical PRISMA 2020 / CONSORT 2010 / STARD 2015 / STROBE template
            used by the corresponding statement group. Custom layouts are
            acceptable for exploratory drafts but must be replaced before
            circulation. (See `flow_diagram_lessons.md` Lesson 1.)
        25. **Exclusion-box geometry** — Exclusion side-boxes are rectangles (not
            `shape: note` / "dog-ear" style) when the diagram aims to match
            PRISMA / CONSORT canonical look. Bullets within exclusion boxes are
            left-aligned (Graphviz `\l`, not `\n`).
        26. **Frozen-version sync** — The figure file path includes the manuscript
            version (`figures/v{N}/figure_1.pdf`) and the value of `v{N}` matches
            the current manuscript version. Edits after circulation must branch
            to `v{N+1}/`, never overwrite `v{N}/`. (See `flow_diagram_lessons.md`
            Lesson 5.)
        
        ---
        
        ## Scoring output format
        
        ```
        ## Critic report (flow diagram, round T)
        
        | Item | Score | Note |
        |------|-------|------|
        | A.1 Box overlap | PASS | — |
        | A.2 Readable edges | PASS | — |
        | ...
        | C.6 Arithmetic balance | FAIL | Enrollment 500 ≠ Included 420 + Excluded 85 (off by 5) |
        | ...
        
        ### Required edits before next render
        1. Correct Excluded count in node X from 85 to 80.
        2. Increase font-size of "Analysis" subsection header from 18 to 22.
        3. ...
        
        ### Overall verdict
        [ ] PASS — ready for manuscript
        [ ] REFINE — items above must be fixed before next round
        ```
        
        Record `critic_pass: yes | partial | no` and `refine_rounds: N` in the
        `_figure_manifest.md` for this figure after the final round.
        
    • exemplar_diagrams
      • consort
        • README.md 385 B
          # consort Exemplars
          
          This directory will hold 3–5 hand-curated consort flow/figure exemplars.
          Use the extraction script to add one:
          
          ```bash
          python skills/make-figures/scripts/extract_exemplar_from_pdf.py \
              --pdf "/path/to/paper.pdf" \
              --page N \
              --type consort \
              --label LastnameYEAR_CONSORT \
              --doi 10.xxxx/yyyy
          ```
          
          See `../README.md` for curation guidelines.
          
        • template_input.yaml 1.4 KB
          # CONSORT parallel-arm RCT flow diagram template.
          # Render with:
          #   Rscript ../../scripts/generate_flow_diagram.R \
          #       --type consort --config template_input.yaml --out template_output
          rankdir: TB
          nodes:
            - id: assessed
              label: "Assessed for eligibility\n(n = 500)"
            - id: excl
              shape: note
              fontsize: 10
              label: "Excluded (n = 100):\n• Not meeting inclusion criteria: 60\n• Declined to participate: 25\n• Other reasons: 15"
              rank_same_with: assessed
            - id: rand
              label: "Randomized\n(n = 400)"
              highlight: true
            - id: alloc_tx
              label: "Allocated to intervention (n = 200)\n• Received intervention (n = 195)\n• Did not receive intervention (n = 5)"
            - id: alloc_ctrl
              label: "Allocated to control (n = 200)\n• Received control (n = 198)\n• Did not receive control (n = 2)"
            - id: fu_tx
              label: "Lost to follow-up (n = 8)\nDiscontinued intervention (n = 4)"
            - id: fu_ctrl
              label: "Lost to follow-up (n = 6)\nDiscontinued control (n = 3)"
            - id: ana_tx
              label: "Analyzed (n = 200)\n• Excluded from analysis (n = 0)"
            - id: ana_ctrl
              label: "Analyzed (n = 200)\n• Excluded from analysis (n = 0)"
          edges:
            - {from: assessed, to: rand}
            - {from: rand, to: alloc_tx}
            - {from: rand, to: alloc_ctrl}
            - {from: alloc_tx, to: fu_tx}
            - {from: alloc_ctrl, to: fu_ctrl}
            - {from: fu_tx, to: ana_tx}
            - {from: fu_ctrl, to: ana_ctrl}
            - {from: assessed, to: excl, style: dashed, arrow: false, constraint: false}
          
        • template_output.pdf 37.4 KB · in bundle
        • template_output.png 121.1 KB · in bundle
        • template_output_600.png 118.7 KB · in bundle
      • other
        • other_02_why.md 1.6 KB
          # Why this exemplar is good — other_02 (bubble chart landscape)
          
          Hierarchy / structure: A landscape scatter/bubble chart with time on the X-axis (pre-2022 → 2025) and MMLU score on the Y-axis. A dashed logistic-like trajectory connects the frontier models across years, implicitly communicating the field-wide progression without a separate annotation layer.
          
          Whitespace & balance: The pre-2022 region is nearly empty by design — the sparseness is informative, not wasted. High-performance cluster in 2024–2025 is where bubbles densely overlap, and the axis range (0–100 MMLU) leaves enough headroom above the top models to signal the 90%+ ceiling.
          
          Typography (font size, weight, alignment): Model names are placed adjacent to their bubbles with consistent font size; no leader lines are needed because the labels sit directly on or beside each dot. The reference lines ("85% = human expert", "70% = ideal") are low-contrast gray so they guide without competing.
          
          Emphasis (which elements are visually strongest, why): Bubble size encodes release prominence, and color encodes the parent organization — two independent visual channels that give the reader a comparative read without returning to the legend.
          
          Color usage: Eight distinct organizational colors placed against a white field. The palette is high-saturation but the bubbles are translucent, which lets overlapping dots remain legible.
          
          Weaknesses (if any — nothing is perfect): The legend (top) is cramped and would benefit from more horizontal space. A few labels ("DeepSeek", "Claude 3.5 Sonnet") clip against neighbors in the 2024 cluster. No explicit units or source note in the rendered crop.
          
      • pipeline
        • pipeline_01_why.md 1.5 KB
          # Why this exemplar is good — pipeline_01 (pipeline)
          
          Hierarchy / structure: A clean left-to-right pipeline — paired inputs (CXR image on top, clinical text on bottom) merge into a single downstream processor (MLLM) and terminate in a single output box. The two parallel encoder boxes are aligned horizontally, which communicates multimodal symmetry at a glance.
          
          Whitespace & balance: Generous horizontal whitespace around the central MLLM chip icon makes it the visual anchor. Input column and output column carry roughly equal ink weight, avoiding a lopsided read.
          
          Typography (font size, weight, alignment): Box labels (Image encoder / Text encoder) are bolded sans-serif; patient demographics use a smaller mono-like face to read as tabular data rather than prose. Illustrative "clinical question" is italicized and color-accented to flag that it is an example, not a label.
          
          Emphasis (which elements are visually strongest, why): Pink-tinted inputs and green output speech bubble draw attention to the clinically meaningful endpoints (question in → answer out), while the technical processing blocks sit in neutral gray.
          
          Color usage: Two saturated tints (pink for input, green for output) plus neutral gray for machinery. Colorblind-safe if converted — the saturation contrast survives grayscale.
          
          Weaknesses (if any — nothing is perfect): The MLLM "chip" icon is decorative rather than structural; could confuse readers expecting an architectural diagram. CXR thumbnail is small relative to the clinical-info panel, slightly under-weighting the image modality.
          
        • pipeline_03_why.md 1.7 KB
          # Why this exemplar is good — pipeline_03 (pipeline)
          
          Hierarchy / structure: Two-panel layout (A and B) separated by a thin vertical rule, with each panel self-contained. A shows contrastive pre-training: text encoder (top) and image encoder (bottom) converge on a similarity matrix. B shows a VQA inference pipeline flowing left → right. Panel labels sit in the top-left corner of each panel at a consistent position.
          
          Whitespace & balance: Margins around the similarity matrix give it room to read as a data object rather than decoration. The two panels carry roughly equal visual mass so neither dominates.
          
          Typography (font size, weight, alignment): Box labels use uniform sans-serif at one size; the similarity matrix cells use a mono face so Tᵢ·Iⱼ products align as a grid. The CXR/report thumbnails carry subscripts (T₁, T₂, I₁, I₂) in the same font as the matrix cells — visually tying the inputs to the matrix rows/columns.
          
          Emphasis (which elements are visually strongest, why): The diagonal of the similarity matrix is shaded darker, pulling the eye to the positive pairs — this is the pedagogical point of contrastive learning and the figure lets design carry the explanation.
          
          Color usage: Pink for text-side components, blue for image-side components. This two-channel color coding is consistent across both panels and makes the multimodal symmetry self-evident.
          
          Weaknesses (if any — nothing is perfect): Panel B's projection matrix W and token boxes are compressed; readers unfamiliar with contrastive-language-image architecture may need to read the caption closely. The "language instruction" box on the far right of B is visually detached from the main flow.
          
        • pipeline_04_why.md 1.6 KB
          # Why this exemplar is good — pipeline_04 (pipeline)
          
          Hierarchy / structure: Top row is the macro view (modality inputs → encoder → connector → LLM → generator → outputs), bottom row zooms into the "connector" block with four named variants (A MLP, B Q-Former, C MH-Attn, D Expert Captioning). A light-blue downward arrow visually tethers the macro block to its expansion — the classic "zoom-in detail" device applied cleanly.
          
          Whitespace & balance: Even horizontal spacing between connector variants. The expanded lower panel is framed in a subtle tinted rectangle that contains it without drawing attention.
          
          Typography (font size, weight, alignment): Consistent sans-serif labels throughout. Capital letters A/B/C/D tag the four variants in one size; component labels (MLP, Q-Former, MH-Attn) use the same type family as the macro row for continuity.
          
          Emphasis (which elements are visually strongest, why): The central LLM box is the largest and darkest block — correctly, because this is the figure's conceptual center. The connector variants underneath share equal weight, communicating their parallel status as alternatives rather than a sequence.
          
          Color usage: Restrained three-tone palette (blue for neural modules, orange for learnable queries/tokens, gray for data I/O). Dashed outlines mark optional/repeated elements. Colorblind-safe.
          
          Weaknesses (if any — nothing is perfect): Many modality icons (image/audio/video) repeat at both input and output; a single output cluster would reduce clutter. The Q/K/V labels inside panel C are small relative to neighboring elements.
          
        • pipeline_05_why.md 1.7 KB
          # Why this exemplar is good — pipeline_05 (pipeline / tasks illustration)
          
          Hierarchy / structure: Two stacked panels (A and B), each a four-column table-like layout. A shows report generation for two cases (pelvic radiograph and abdominal CT) side-by-side: input → reference report → generated report. B shows VQA with closed-ended vs. open-ended question variants for each case. The uniform column structure makes the cross-row comparison immediate.
          
          Whitespace & balance: Narrow column gutters (not wasteful) but generous vertical spacing between cases. Each row reads as a discrete case study.
          
          Typography (font size, weight, alignment): The strongest typographic choice is the use of colored highlights on specific phrases in the generated reports — green for confirmed findings, red for hallucinated content, orange for partial matches. This converts what would be plain prose into an annotated diff without extra legend burden, provided the reader notices the color key.
          
          Emphasis (which elements are visually strongest, why): The color-annotated report text is the primary didactic payload and is the strongest element, correctly. The input images serve as context and are sized accordingly smaller.
          
          Color usage: Color communicates semantic agreement/disagreement in text — a rare and effective use. Panel A uses gray scaffolding (headers, borders) to keep attention on the colored content.
          
          Weaknesses (if any — nothing is perfect): Red and green are the primary discriminators, which is not colorblind-safe. A shape or weight encoding (bold for hallucinations) would make it more accessible. Font size of the report text is small; readability at small figure widths could suffer.
          
        • pipeline_06_why.md 1.6 KB
          # Why this exemplar is good — pipeline_06 (pipeline / M3D architecture)
          
          Hierarchy / structure: Strict top-to-bottom flow. Volumetric CT input (single modality box with three anatomical sub-panels) feeds a 3D image encoder; parallel text-side question templates feed the text tokenizer. Both streams converge on a single LLM-with-LoRA block, then split into two downstream heads (report generation, VQA). The shape (funnel in → funnel out) mirrors the dual-task architecture.
          
          Whitespace & balance: Equal space allocated to image-side and text-side inputs; the LLM block spans the full width to signal it is the shared backbone. Tight but not cramped.
          
          Typography (font size, weight, alignment): Bolded section headers (Report Generation / VQA) for the prompt boxes; prompt contents in a slightly smaller, clearly italic-or-lighter weight. The special tokens (<|eot_id|>, <|start_header_id|>) use a monospace face — the right choice for communicating that they are literal code strings.
          
          Emphasis (which elements are visually strongest, why): The fire/snow icons (trainable/frozen) are placed on individual components, making the training-recipe claim visually inspectable. This is a small but high-value design choice — readers can audit the method by looking at the figure.
          
          Color usage: Cool blues for vision modules, warm pinks for language/prompt modules. Two-tone palette scales well to grayscale (luminance differs).
          
          Weaknesses (if any — nothing is perfect): The "trainable/frozen" legend sits in the upper right and may be missed by readers who scan left-to-right top-down. The three CT sub-views (chest/abdomen/pelvis) are small enough that anatomy is only suggested.
          
        • pipeline_07_why.md 1.6 KB
          # Why this exemplar is good — pipeline_07 (pipeline / MAIRA-Seg)
          
          Hierarchy / structure: A horizontal pipeline with input row (CXRs, pseudo-masks, task instruction, textual context) on top, processing row (image encoder, segmentation model, tokenizer/embedding) in the middle, and convergence on a single LLM block at the bottom. The vertical progression communicates "modalities in → unified tokens → one model out" without ambiguity.
          
          Whitespace & balance: The LLM block spans almost full width as a visual foundation. Upper row inputs are grouped by modality with clear gaps, avoiding the "everything dumps in" look.
          
          Typography (font size, weight, alignment): Small but uniform sans-serif labels; auxiliary labels ("Only in multi", "Pseudo-labels") are italic to mark them as conditional annotations without adding a legend.
          
          Emphasis (which elements are visually strongest, why): The LLM base block is the largest uniform colored rectangle — visually grounding the whole system as one model. Fire icons on trainable components again let the figure double as a training-recipe specification.
          
          Color usage: Warm pinks for segmentation/prompt-side modules; cool blues for vision/LLM modules. Dashed borders denote optional paths ("Only in multi"). Consistent with the color code used in other architecture figures in the paper.
          
          Weaknesses (if any — nothing is perfect): The dense upper input row competes for attention; a subtle grouping box or lane separator would help. The segmentation-tokens-extractor sits off the main flow axis, which introduces a jog in the layout that briefly interrupts the top-down read.
          
        • pipeline_08_why.md 1.7 KB
          # Why this exemplar is good — pipeline_08 (pipeline / CLIMB foundation model)
          
          Hierarchy / structure: Three-stage landscape layout separated by vertical rules: Training (left) | Specialized Encoder (center) | Testing + Downstream (right). Inside the Training panel, three modality lanes (Graphs, 1D signals, 2D&3D images) each carry three exemplar thumbnails, creating a grid-of-grids that communicates breadth without listing every dataset.
          
          Whitespace & balance: Equal vertical space per modality lane; the encoder column is visually narrow to read as a bottleneck/convergence point. Right-side testing block has enough margin around the OOD dataset callout to keep it distinct.
          
          Typography (font size, weight, alignment): Modality headers (Graphs, 1D signals, 2D&3D images) are larger and bolder than thumbnail captions. Section headers (Training / Testing) sit above their column in matching weight.
          
          Emphasis (which elements are visually strongest, why): The thumbnail grid at training time is the strongest visual payload — it answers "what does multimodal breadth look like?" in one glance. The encoder column is intentionally understated because it is a conceptual stage, not a specific architecture.
          
          Color usage: Subtle pastel backgrounds separate stages (soft pink for training, green for testing, peach for downstream) without compromising the thumbnails' own color. Downstream task illustrations (physician + patient) add warmth without clutter.
          
          Weaknesses (if any — nothing is perfect): Ten or more thumbnail captions at small sizes risk clipping on reprints. The "transfer" and "finetuned" arrows in the testing column could be slightly thicker to read as a pipeline rather than decoration.
          
        • pipeline_09_why.md 1.7 KB
          # Why this exemplar is good — pipeline_09 (evaluation-metrics comparison)
          
          Hierarchy / structure: A 3×2 grid where all six evaluation methods share identical sub-layout — method title (bar header), candidate excerpt (middle), and two-bullet summary (O = what is captured, △ = what is missed). One shared reference sentence sits above the grid, establishing the common ground-truth all six metrics are being compared against.
          
          Whitespace & balance: The top reference strip spans full width and is visually distinct from the grid below. Within each cell, internal padding is consistent and there is clear separation between the colored header bar and the body — giving each metric equal visual real-estate.
          
          Typography (font size, weight, alignment): Bold uppercase metric names in colored header bars; candidate text below in regular weight with discriminating phrases colored inline (e.g., "right-sided" in red for a miss). The O/△ legend uses symbols instead of words, keeping each cell compact.
          
          Emphasis (which elements are visually strongest, why): The six colored header bars act as chapter tabs, making the grid scannable. Colored phrases inside the candidate text are the didactic focus — they show precisely what each metric is scoring.
          
          Color usage: Six distinct pastel headers, chosen for differentiation rather than semantic meaning. Candidate-text color (red/green/blue) is semantic. Palette is varied but low-saturation enough to avoid fatigue.
          
          Weaknesses (if any — nothing is perfect): Six colors with no ordinal relationship could be reduced; the reader doesn't need to distinguish cells by color since the grid layout already separates them. Semantic text colors (red for missed) overlap with header colors in a few cells.
          
        • pipeline_10_why.md 1.9 KB
          # Why this exemplar is good — pipeline_10 (clinical integration overview)
          
          Hierarchy / structure: Three horizontal strips. Top = clinical workflow (Patient → Triage → Consultation → Diagnosis → Treatment → Monitoring) rendered as a linear process diagram. Middle = radial diagram with a radiologist at center surrounded by MLLM support tasks. Bottom = radial diagram with an AI icon at center surrounded by clinical-integration challenges. Two radial compositions mirror each other visually — a deliberate parallel that frames "capabilities vs. challenges".
          
          Whitespace & balance: Each strip is framed in a light background panel with its title on the left (Clinical Workflow / MLLM Support for Radiologist / Key Challenges for Clinical Integration) — the row-label placement anchors the reader and allows side-by-side reading without a separate key.
          
          Typography (font size, weight, alignment): Row-label italics on the left are consistent. Radial labels (Visual Question Answer, Clinical Decision Support, etc.) use sentence case and are placed at consistent radial distances from center.
          
          Emphasis (which elements are visually strongest, why): The two central icons (radiologist, AI chip) draw the eye first, making each radial panel's subject unmistakable. The triage/diagnosis/treatment icons in the top strip are sized uniformly so no single step is over-emphasized — correctly, because this is a generic workflow.
          
          Color usage: Warm peach/cream for support, cool blue for challenges — a clean semantic contrast. Individual radial spokes are lightly tinted to differentiate themes within each group.
          
          Weaknesses (if any — nothing is perfect): The radial metaphor collapses any ordering between spokes — a reader cannot tell which challenge is most pressing or which MLLM role is primary. Some icon glyphs (e.g., "Final Decision by Radiologist") are small and may not read clearly when printed at single-column width.
          
        • README.md 389 B
          # pipeline Exemplars
          
          This directory will hold 3–5 hand-curated pipeline flow/figure exemplars.
          Use the extraction script to add one:
          
          ```bash
          python skills/make-figures/scripts/extract_exemplar_from_pdf.py \
              --pdf "/path/to/paper.pdf" \
              --page N \
              --type pipeline \
              --label LastnameYEAR_PIPELINE \
              --doi 10.xxxx/yyyy
          ```
          
          See `../README.md` for curation guidelines.
          
      • prisma
        • README.md 381 B
          # prisma Exemplars
          
          This directory will hold 3–5 hand-curated prisma flow/figure exemplars.
          Use the extraction script to add one:
          
          ```bash
          python skills/make-figures/scripts/extract_exemplar_from_pdf.py \
              --pdf "/path/to/paper.pdf" \
              --page N \
              --type prisma \
              --label LastnameYEAR_PRISMA \
              --doi 10.xxxx/yyyy
          ```
          
          See `../README.md` for curation guidelines.
          
        • template_input.yaml 1.9 KB
          # PRISMA 2020 systematic review / meta-analysis flow diagram template.
          # Render with:
          #   Rscript ../../scripts/generate_flow_diagram.R \
          #       --type prisma --config template_input.yaml --out template_output
          rankdir: TB
          nodes:
            - id: id_db
              label: "Records identified from databases\n(n = 3,500)\n• PubMed: 1,800\n• Embase: 1,200\n• Cochrane: 500"
            - id: id_other
              label: "Records identified from other sources\n(n = 45)\n• Citation search: 30\n• Registers: 15"
              rank_same_with: id_db
            - id: dedup
              label: "Records after duplicates removed\n(n = 2,700)"
            - id: screened
              label: "Records screened (title/abstract)\n(n = 2,700)"
            - id: excl_ta
              shape: note
              fontsize: 10
              label: "Records excluded\n(n = 2,480)"
              rank_same_with: screened
            - id: ft_sought
              label: "Reports sought for retrieval\n(n = 220)"
            - id: ft_notret
              shape: note
              fontsize: 10
              label: "Reports not retrieved\n(n = 12)"
              rank_same_with: ft_sought
            - id: ft_assessed
              label: "Reports assessed for eligibility\n(n = 208)"
            - id: ft_excl
              shape: note
              fontsize: 10
              label: "Reports excluded (n = 180):\n• Wrong population: 72\n• Wrong index test: 48\n• Wrong outcome: 35\n• Abstract only: 15\n• Duplicate cohort: 10"
              rank_same_with: ft_assessed
            - id: included
              label: "Studies included in review\n(n = 28)\nStudies in quantitative synthesis\n(n = 24)"
              highlight: true
          edges:
            - {from: id_db, to: dedup}
            - {from: id_other, to: dedup, constraint: false}
            - {from: dedup, to: screened}
            - {from: screened, to: ft_sought}
            - {from: ft_sought, to: ft_assessed}
            - {from: ft_assessed, to: included}
            - {from: screened, to: excl_ta, style: dashed, arrow: false, constraint: false}
            - {from: ft_sought, to: ft_notret, style: dashed, arrow: false, constraint: false}
            - {from: ft_assessed, to: ft_excl, style: dashed, arrow: false, constraint: false}
          
        • template_output.pdf 40.5 KB · in bundle
        • template_output.png 115.1 KB · in bundle
        • template_output_600.png 113.5 KB · in bundle
      • stard
        • README.md 377 B
          # stard Exemplars
          
          This directory will hold 3–5 hand-curated stard flow/figure exemplars.
          Use the extraction script to add one:
          
          ```bash
          python skills/make-figures/scripts/extract_exemplar_from_pdf.py \
              --pdf "/path/to/paper.pdf" \
              --page N \
              --type stard \
              --label LastnameYEAR_STARD \
              --doi 10.xxxx/yyyy
          ```
          
          See `../README.md` for curation guidelines.
          
        • template_input.yaml 1.2 KB
          # STARD 2015 diagnostic-accuracy flow diagram template.
          # Render with:
          #   Rscript ../../scripts/generate_flow_diagram.R \
          #       --type stard --config template_input.yaml --out template_output
          rankdir: TB
          nodes:
            - id: eligible
              label: "Eligible patients\n(n = 500)"
            - id: excl
              shape: note
              fontsize: 10
              label: "Excluded (n = 50):\n• Contraindication to index test: 20\n• No reference standard available: 20\n• Declined: 10"
              rank_same_with: eligible
            - id: index
              label: "Received index test\n(n = 450)"
              highlight: true
            - id: ref
              label: "Received reference standard\n(n = 450)"
            - id: idx_pos
              label: "Index test positive\n(n = 180)"
            - id: idx_neg
              label: "Index test negative\n(n = 270)"
            - id: tp
              label: "Reference positive\nTP = 160"
            - id: fp
              label: "Reference negative\nFP = 20"
            - id: fn
              label: "Reference positive\nFN = 15"
            - id: tn
              label: "Reference negative\nTN = 255"
          edges:
            - {from: eligible, to: index}
            - {from: index, to: ref}
            - {from: ref, to: idx_pos}
            - {from: ref, to: idx_neg}
            - {from: idx_pos, to: tp}
            - {from: idx_pos, to: fp}
            - {from: idx_neg, to: fn}
            - {from: idx_neg, to: tn}
            - {from: eligible, to: excl, style: dashed, arrow: false, constraint: false}
          
        • template_output.pdf 33.9 KB · in bundle
        • template_output.png 78.4 KB · in bundle
        • template_output_600.png 76.9 KB · in bundle
      • strobe
        • template_input.yaml 1.4 KB
          # STROBE cohort-study flow diagram template.
          # Render with:
          #   Rscript ../../scripts/generate_flow_diagram.R \
          #       --type strobe --config template_input.yaml --out template_output
          rankdir: TB
          nodes:
            - id: source
              label: "Source population\n(Database XYZ, 2010–2023; total records = 120,000)"
            - id: unique
              label: "Unique subjects after deduplication\nN = 85,000"
            - id: excl1
              shape: note
              fontsize: 10
              label: "Excluded (n = 5,000):\n• Missing age/sex: 1,200\n• Outside age range: 3,800"
              rank_same_with: unique
            - id: eligible
              label: "Meeting eligibility criteria\nN = 80,000"
            - id: excl2
              shape: note
              fontsize: 10
              label: "Excluded (n = 2,500):\n• Prior event at baseline: 1,500\n• Follow-up ≤ 0 days: 1,000"
              rank_same_with: eligible
            - id: cohort
              label: "Analytic cohort\nN = 77,500"
              highlight: true
            - id: exp
              label: "Exposed\nn = 20,000"
            - id: unexp
              label: "Unexposed\nn = 57,500"
            - id: out_exp
              label: "Incident events\n1,200 (6.0%)"
            - id: out_unexp
              label: "Incident events\n2,300 (4.0%)"
          edges:
            - {from: source, to: unique}
            - {from: unique, to: eligible}
            - {from: eligible, to: cohort}
            - {from: cohort, to: exp}
            - {from: cohort, to: unexp}
            - {from: exp, to: out_exp}
            - {from: unexp, to: out_unexp}
            - {from: unique,   to: excl1, style: dashed, arrow: false, constraint: false}
            - {from: eligible, to: excl2, style: dashed, arrow: false, constraint: false}
          
        • template_input_pptx.yaml 1.5 KB
          # STROBE cohort-study flow diagram — editable PPTX schema.
          #
          # Render with:
          #   python3 ../../scripts/build_strobe_template.py \
          #       --config template_input_pptx.yaml \
          #       --out    template_output.pptx
          #
          # Output is a fully-editable PowerPoint slide (13.33 × 10 in) with:
          #   - left-side colored phase column (one merged label per consecutive same-stage spine block)
          #   - center white-fill spine of cohort-stage boxes
          #   - right-side white-fill exclusion boxes, connected by arrows
          #
          # Stage box text colour auto-picks BLACK or WHITE from fill luminance.
          # All text frames have word_wrap = True so prose never overflows.
          
          slide_size: [13.33, 10.0]
          title: "Figure 1. STROBE participant flow diagram"
          
          stages:
            - {name: Identification, color: "#cfe1f5"}
            - {name: Screening,      color: "#cfe1f5"}
            - {name: Inclusion,      color: "#cfe1f5"}
            - {name: Analysis,       color: "#cfe1f5"}
          
          spine:
            - id: source
              stage: Identification
              text: "Source population\n(Database XYZ, 2010–2023)\ntotal records = 120,000"
            - id: unique
              stage: Screening
              text: "Unique subjects after deduplication\nN = 85,000"
            - id: eligible
              stage: Inclusion
              text: "Meeting eligibility criteria\nN = 80,000"
            - id: cohort
              stage: Analysis
              text: "Analytic cohort\nN = 77,500"
          
          exclusions:
            - after: unique
              text: "Excluded (n = 5,000):\n- missing age/sex (n = 1,200)\n- outside age range (n = 3,800)"
            - after: eligible
              text: "Excluded (n = 2,500):\n- prior event at baseline (n = 1,500)\n- follow-up ≤ 0 days (n = 1,000)"
          
        • template_output.pdf 47.2 KB · in bundle
        • template_output.png 111.1 KB · in bundle
        • template_output.pptx 28.7 KB · in bundle
        • template_output_600.png 108.6 KB · in bundle
      • README.md 3.8 KB
        # Exemplar Diagrams — Quality Anchors for the Critic Loop
        
        Visual anchors the Critic Loop reads before judging a newly generated figure: *what does a
        well-composed one actually look like?*
        
        ## What is here — and what is deliberately not
        
        | | |
        |---|---|
        | **`{type}/template_output*.png`** | Diagrams **this skill renders itself** (`scripts/generate_flow_diagram.R` + the `template_input.yaml` beside them). Ours. Safe to ship. |
        | **`{label}_why.md`** | **The teaching content.** 50–100 words on *why* an exemplar works — hierarchy, whitespace, typography, emphasis, colour. Written by us. This is what the Critic Loop actually learns from. |
        | ~~`{label}.png` cropped from a published paper~~ | **Removed 2026-07-14 — see below.** |
        
        ## Why the paper figures are gone
        
        This directory held ten PNGs **cropped from published papers**. The old README said so plainly, and
        promised each carried a `.meta.yaml` recording *"source PDF, page, DOI, crop coords"*, and that the
        sidecar *"records DOI and source for every exemplar."*
        
        **It did not.** The files recorded `label`, `figure_type` and `dpi`. No source. No DOI. No licence.
        Eight of the eighteen images had no metadata at all. The safeguard the README described had never
        been implemented.
        
        The old README also argued fair use, on the grounds that the exemplars are *"not redistributed as
        part of generated figures"* — the Critic Loop only looks at them. That is true, and it is not the
        question. **They were redistributed as part of the package**: this repository is **MIT-licensed** and
        ships on npm and as a classroom ZIP that every user downloads. MIT tells the world it may *"use,
        copy, modify, merge, publish, distribute, sublicense, and sell"* what is inside. We were granting
        those rights over other people's figures — without knowing whose, without a licence, without credit.
        
        Some were probably open-access and freely reusable with attribution. We cannot say which, because
        the provenance was never recorded, and **a permission you cannot demonstrate is not a permission.**
        
        The `_why.md` notes stay. They are ours, and they are where the value was: a paragraph explaining
        *why* a two-tone palette survives greyscale teaches more than the picture it was written about.
        
        **A figure you may legally read is not a figure you may legally ship.** That distinction is the whole
        reason for this file.
        
        ## Bringing your own visual anchors
        
        The Critic Loop reads whatever exemplars it finds here.
        
        1. Drop them into `{type}/` **on your own machine.** They stay local; nothing here is uploaded
           anywhere, and a local file you never commit is never redistributed.
        2. Give each one a sidecar:
        
           ```yaml
           label: "pipeline_11"
           figure_type: "pipeline"
           source: "Author et al., Journal Name, 2025"
           doi: "10.1234/example"
           license: "CC-BY-4.0"        # must be true, and must permit redistribution
           ```
        
        3. If you want to **contribute** an exemplar back to the project, the licence has to permit
           redistribution — CC-BY, CC0, or your own work. `scripts/check_bundled_media_license.py` enforces
           that in CI: an image that ships without a declared, redistributable licence fails the build.
        
        ## Layout
        
        ```
        exemplar_diagrams/
        ├── strobe/      # cohort / cross-sectional / case-control flow
        ├── stard/       # diagnostic-accuracy flow
        ├── consort/     # RCT participant flow
        ├── prisma/      # systematic-review selection flow
        ├── pipeline/    # methods / algorithm flow   (design notes only)
        └── other/       #                            (design notes only)
        ```
        
        Each type directory holds `template_input.yaml` (the config the R script consumes),
        `template_output*.png` (what it renders), and any `_why.md` design notes.
        
        Render one yourself:
        
        ```bash
        Rscript ../../scripts/generate_flow_diagram.R \
          --type prisma --config prisma/template_input.yaml --out prisma/template_output
        ```
        
    • exemplar_plots
      • ai_assistance_effect.md 3.5 KB
        # Exemplar anatomy — AI-assistance effect (reader-level paired improvement)
        
        A worked **anatomy model** for the signature figure of an **AI-assistance reader study** — the one
        that answers "does the AI change what each reader does, and **for whom**". Where `mrmc_roc.md` shows
        the reader-study comparison in ROC space, this figure shows the **within-reader change**: each
        reader's performance **unaided → AI-aided** on the same cases, as a paired slopegraph (or dumbbell),
        so the reader-level effect, its spread, and any reader who is *harmed* are all visible. It carries
        the flagship claim of these studies ("AI narrows the non-radiologist gap"). Pairs `mrmc_roc.md`,
        `analyze-stats` `table-standards/table-types/reader_study.md`, and the sizing in `calc-sample-size`
        (Test 14 / `references/mrmc_reader_study_sample_size.md`). Synthetic — describes *what each element
        must show* and the errors to avoid; not an image to copy, no real citations.
        
        ## Elements
        - **One line (or dumbbell) per reader**, connecting the reader's metric **unaided → aided** on the
          **same cases** (a within-reader, crossover comparison). State the metric — the clinically relevant
          one: accuracy, sensitivity/specificity at the operating point actually used, or per-reader AUC.
        - **Stratification by reader group** (experience: resident → attending; or specialty: radiologist vs
          non-radiologist) by colour or small-multiple panels — the "who benefits" axis is usually the point
          of the study.
        - The **reader-averaged change (ΔAUC / Δaccuracy) with its MRMC 95% CI** (reader **and** case
          variance — Obuchowski–Rockette), and the **non-inferiority margin** if that is the design.
        - **Readers whose performance declined with AI marked** — automation bias / over-reliance is a real
          effect; the figure must be able to show harm, not only benefit.
        - A **reading-time companion** (unaided vs aided, same paired layout) when efficiency is a stated
          endpoint.
        - Caption states the **design** (crossover + washout), that the **cases were held constant**, and the
          **reader sample** and its generalisation limit.
        
        ## Discipline (what the figure must not do)
        - **Do not show only the group mean** (a single before/after bar pair) — it hides the reader spread,
          who benefits, and any reader who got **worse** with AI; the per-reader detail is the finding.
        - **Do not use an unpaired comparison or a fixed-reader CI** — the readers read the same cases, so the
          uncertainty is the **paired MRMC** variance, not two independent groups.
        - **Do not claim "AI helps readers" from an upward mean** without the stratified effect — a mean gain
          can be one subgroup improving while another is unaffected or harmed.
        - **Do not omit the harmed readers or the washout** — an unwashed second read is confounded by case
          recognition, and hiding declines overstates benefit.
        - **Do not read a subgroup the study was not sized for** as a firm effect — reader subgroups are
          usually underpowered (ties to `calc-sample-size` Test 14).
        
        ## Common omission
        - The **per-reader lines**, the **stratification by experience/specialty**, the **harmed-reader
          cases**, and the **paired MRMC CI** on the averaged change — the elements this figure most often
          drops, and the ones that turn "AI helps on average" into the defensible, *for-whom* claim a reader
          study exists to make. Cross-reference `mrmc_roc.md`, `critic_rubrics/data_plot.md`, the
          diagnostic-accuracy probes `peer-review/references/domain-probes/diagnostic_accuracy.md`, and
          `analyze-stats` `table-standards/table-types/reader_study.md`.
        
      • bland_altman.md 3.4 KB
        # Exemplar anatomy — Bland–Altman agreement plot (two continuous methods)
        
        A worked **anatomy model** for a Bland–Altman figure — the agreement counterpart to a method-
        comparison scatter. Complements the `critic_rubrics/data_plot.md` §C *Bland–Altman* checklist
        (this composes; the rubric scores). Synthetic — describes *what each element must show* and the
        errors to avoid; not an image to copy. Pairs with `analyze-stats`
        `table-standards/table-types/agreement.md` (the LoA / ICC reliability table).
        
        ## Elements
        - **Difference (y) vs mean of the two methods (x)** — y = (method A − method B), x = (A + B)/2.
          Plotting the difference against *one* method (e.g., the reference) induces a spurious slope; the
          mean of the two is the correct abscissa.
        - **Bias line** = the mean difference, drawn horizontally, with its value and **95% CI** printed.
        - **95% limits of agreement (LoA)** = bias ± 1.96·SD of the differences, drawn as two horizontal
          lines, with **a confidence interval on each limit** (the LoA are themselves estimates and are
          wide at small n) — show the CI as a footnote value or a shaded band around each LoA.
        - **The a-priori clinically acceptable difference band** overlaid, so the reader can see at a glance
          whether the LoA fall inside the margin that was defined *before* the analysis.
        - **Proportional-bias check**: a regression of the difference on the mean; if the slope is non-zero
          (the cloud fans out or tilts), state it and model the SD or log-transform rather than quoting a
          single constant LoA.
        - **% of points within the LoA** annotated (≈95% expected) and **scatter points not clipped** at the
          plot edges — outliers beyond the LoA are the most informative points and must remain visible.
        - **Units stated on both axes** (the difference is in measurement units, not %), and n pairs given.
        
        ## Discipline (what the figure must not do)
        - **It is not a correlation/regression plot** — do not report Pearson r or R² as evidence of
          agreement. High correlation is fully compatible with large systematic bias; correlation measures
          association along a line, not closeness to identity.
        - **Do not quote a single constant LoA when bias is proportional** — if the differences widen with
          magnitude (heteroscedasticity), constant ±1.96·SD limits are wrong across the range; log-transform
          or model the SD as a function of the mean.
        - **Do not omit the CI on the LoA** — at small n the limits are imprecise, and an LoA that looks
          inside the acceptability margin may not be once its upper CI is shown.
        - **Handle repeated measures correctly** — with multiple pairs per subject, the naïve SD of all
          differences understates variability; use a repeated-measures Bland–Altman (variance-components)
          method and report the number of replicates per subject.
        - **Clinical acceptability is a pre-specified judgement**, not read off the plot after the fact —
          state the margin and its source, and conclude agreement only if the LoA (with CI) fall inside it.
        
        ## Common omission
        - The **CI on the limits of agreement**, the **proportional-bias check**, and the **pre-defined
          clinical-acceptability band** — the elements Bland–Altman figures most often drop, and the ones
          that decide whether two methods are interchangeable rather than merely correlated. Cross-reference
          `critic_rubrics/data_plot.md` §C (Bland–Altman) and `analyze-stats`
          `table-standards/table-types/agreement.md`.
        
      • calibration_plot.md 2.5 KB
        # Exemplar anatomy — calibration plot (risk-prediction model)
        
        A worked **anatomy model** for a calibration figure — the probability counterpart to the ROC/PR
        discrimination plot. Complements the `critic_rubrics/data_plot.md` §C / §G calibration checks
        (this composes; the rubric scores). Synthetic — describes *what each element must show* and the
        errors to avoid; not an image to copy. Pairs with `analyze-stats` `templates/dca_plot.R` and the
        TRIPOD+AI / `exemplar_plots/roc_pr.md` discrimination side.
        
        ## Elements
        - **Predicted probability (x) vs observed frequency (y)**, both 0–1, with the **45° ideal line**.
        - A **smooth/flexible calibration curve** (loess or restricted cubic spline) rather than only a
          handful of binned points — binning into deciles hides shape and is sensitive to the cut-points.
        - **Calibration slope and intercept** reported in the panel: slope < 1 signals overfitting
          (extreme predictions too extreme); the **intercept is calibration-in-the-large** (mean predicted
          vs mean observed risk), and intercept ≠ 0 signals systematic over/under-prediction.
        - A **distribution of predicted risks** (rug/histogram under the axis), so the reader sees where
          the data actually live — calibration in a region with no patients is uninformative.
        - **On the validation/external set**, not the development set; with a CI band on the curve.
        
        ## Discipline (what the figure must not do)
        - **Do not present discrimination (AUROC) as evidence of calibration** — a model can rank well yet
          be systematically miscalibrated; both are required when a probability drives a decision.
        - **Do not rely on the Hosmer–Lemeshow test alone** — it is low-powered, depends on arbitrary
          grouping, and a non-significant p is not evidence of good calibration; show the curve + slope/
          intercept.
        - **Do not extrapolate** the curve into probability ranges with no observations (see the rug).
        - If recalibration was applied, say so and show calibration **after** it on held-out data.
        - Where a threshold/decision is proposed, pair calibration with a **decision-curve (net-benefit)**
          analysis.
        
        ## Common omission
        - The **slope/intercept**, the **predicted-risk distribution**, and **calibration on the external
          set** — the elements calibration figures most often drop, and the ones that decide whether the
          probabilities can be trusted at the bedside. Cross-reference `critic_rubrics/data_plot.md` §C/§G,
          the `peer-review/references/exemplar_reviews/calibration_missing.md` finding, and `analyze-stats`
          `templates/dca_plot.R`.
        
      • clinical_timeline.md 3.7 KB
        # Exemplar anatomy — clinical timeline for a case report
        
        A worked **anatomy model** for a CARE case-report timeline figure. This is a synthetic teaching
        model: it describes what the figure must show, not a layout to copy from a published report. Use it
        when a case report has more than two meaningful events or when the diagnostic reasoning depends on
        the order of symptoms, tests, treatment, and outcomes.
        
        ## Elements
        
        - **Relative time axis** — use `Day 0`, `Postoperative day 3`, `Week 6`, or `Month 12` rather than
          exact calendar dates unless exact dates are essential and approved for publication.
        - **Event lanes** — separate the course into readable lanes such as symptoms/examination, diagnostic
          tests, imaging/pathology, treatment/intervention, and outcome/follow-up. Avoid a single overloaded
          line with every event stacked on top of it.
        - **Index presentation marker** — make the initial presentation or referral point visually clear so
          readers can orient the pre-presentation history and post-intervention course.
        - **Diagnostic assessment markers** — include the decisive test modality, key finding, and diagnostic
          impression in a compact label. If imaging is central, pair the timeline with an annotated image
          panel rather than forcing all visual detail into the timeline.
        - **Intervention markers** — show the intervention start, changes, dose/procedure/device details only
          when they alter interpretation, and discontinuation or complication points.
        - **Follow-up endpoint** — include the final follow-up interval and outcome. A timeline that ends at
          discharge when the manuscript claims recovery at 3 months is incomplete.
        - **Uncertainty/negative findings** — include only the negative tests that changed diagnostic
          reasoning. Routine normal findings belong in the text or table, not the figure.
        - **Legend linkage** — the legend should state that all labels are de-identified and relative to the
          index presentation; define abbreviations and outcome scales.
        
        ## Annotated imaging panel pairing
        
        For imaging-heavy case reports, use a two-part figure when possible:
        
        - **Panel A: clinical timeline** — concise event sequence and outcome.
        - **Panels B-D: representative images** — modality/sequence, anatomical location, arrows or labels for
          the key finding, and an image-level note that all identifiers are removed.
        - **No decorative anatomy icons** unless they carry information. If an icon is used, it must clarify
          anatomical location or treatment pathway and have a license source.
        
        ## Discipline (what the figure must not do)
        
        - **Do not expose identifiers** — no dates, accession numbers, institution names, patient initials,
          faces, tattoos, embedded DICOM overlays, or geography-specific labels.
        - **Do not imply causality with arrow grammar** — use sequence arrows for chronology, but avoid visual
          wording that makes treatment look proven to cause the outcome unless the text supports that claim.
        - **Do not overpack the timeline** — if more than 10-12 events are needed, split into lanes or move
          secondary laboratory values to a table.
        - **Do not omit scale/units** — laboratory trends need units and outcome scores need the named scale.
        - **Do not let the image panel become decorative** — every arrow should correspond to a finding named
          in the case presentation or legend.
        
        ## Common omission
        
        - The **final follow-up endpoint**, **relative-time de-identification**, and **diagnostic-reasoning
          sequence**. These are the elements that make the figure satisfy CARE's timeline expectation rather
          than serving as a decorative chronology. Cross-reference `write-paper/references/exemplar_case_report.md`,
          `write-paper/references/paper_types/case_report.md`, and the CARE checklist in `/check-reporting`.
        
      • confusion_matrix.md 3.3 KB
        # Exemplar anatomy — confusion matrix (classifier error structure)
        
        A worked **anatomy model** for a confusion-matrix figure — the per-class error breakdown behind a
        single accuracy number. Complements the `critic_rubrics/data_plot.md` §C *confusion matrix*
        checklist (this composes; the rubric scores). Synthetic — describes *what each element must show*
        and the errors to avoid; not an image to copy. Pairs with `exemplar_plots/roc_pr.md` (the
        threshold-free discrimination side) and `analyze-stats`
        `table-standards/table-types/diagnostic_accuracy.md`.
        
        ## Elements
        - **TP / FP / FN / TN laid out as a 2×2 grid** (binary case) with **explicit axis labels**: one
          axis **Predicted (model)**, the other **Actual / Reference (truth)** — never leave the reader to
          guess which axis is which, and state the positive class.
        - **Raw counts in every cell**, so totals and prevalence are recoverable; a percentage-only matrix
          hides the n behind each rate.
        - **Row-normalized (recall / sensitivity) and column-normalized (precision / PPV)** views shown
          alongside the counts — recall normalizes over true class (rows), precision over predicted class
          (columns); the two answer different questions and must not be conflated.
        - **The operating threshold stated** — a confusion matrix is a single point on the ROC/PR curve;
          give the probability cut-off used and how it was chosen (and on which data, not the test set).
        - **Per-class metrics derivable and reported** (sensitivity, specificity, PPV, NPV, F1) with their
          CIs, rather than one global accuracy.
        - **Diagonal emphasized** (heavier stroke or luminosity) only as a reading aid — correct predictions
          on the diagonal, errors off it.
        
        ## Discipline (what the figure must not do)
        - **Do not let a high overall accuracy stand in for performance** — under class imbalance a
          classifier that always predicts the majority class scores high accuracy while the minority class
          fails completely; the off-diagonal recall for the rare class is the load-bearing number, and a
          class-imbalance caveat belongs in the caption.
        - **Do not normalize ambiguously** — label whether percentages are over rows (recall), columns
          (precision), or the grand total; an unlabeled "%" cell is uninterpretable.
        - **Do not present the matrix as threshold-free** — it is threshold-dependent; pair it with the
          ROC/PR curve (`roc_pr.md`) so the reader sees the whole operating range, not one chosen point.
        - **Do not report the matrix on the data used to pick the threshold** — fix the threshold on
          derivation data and report the matrix on the held-out/test set.
        
        ## Multi-class extension
        - For K classes, show the **K×K matrix** with the same Predicted/Actual axes; report **per-class**
          recall and precision plus a **macro-average** (unweighted over classes, so rare classes are not
          drowned out) alongside any micro/weighted average, and name which average each headline number is.
        
        ## Common omission
        - The **dual row/column normalization (recall vs precision)**, the **stated operating threshold**,
          and the **class-imbalance caveat** — the elements confusion-matrix figures most often drop, and
          the ones that stop a high accuracy from hiding a failing minority class. Cross-reference
          `critic_rubrics/data_plot.md` §C (confusion matrix), `exemplar_plots/roc_pr.md`, and
          `analyze-stats` `table-standards/table-types/diagnostic_accuracy.md`.
        
      • decision_curve.md 3.9 KB
        # Exemplar anatomy — decision curve (net-benefit / DCA)
        
        A worked **anatomy model** for a decision-curve (net-benefit) figure — the clinical-utility
        counterpart to the ROC/PR discrimination plot and the calibration probability plot. A model can
        discriminate and calibrate well yet still add no *decision* value over treating everyone or no one;
        the decision curve is what shows whether acting on the model helps across a clinically plausible
        range of thresholds. Complements `critic_rubrics/data_plot.md` §C/§G (this composes; the rubric
        scores) and pairs with `analyze-stats` `references/templates/dca_plot.R` and the
        `analyze-stats` `table-standards/table-types/incremental_value.md` added-value table. Synthetic —
        describes *what each element must show* and the errors to avoid; not an image to copy, no real
        citations.
        
        ## Elements
        - **Threshold probability (x) vs net benefit (y)**. The x-axis is the risk threshold at which a
          patient/clinician would opt for the action (treat, biopsy, admit); the y-axis is net benefit, in
          units of *true positives per patient*, already penalised for false positives at that threshold.
        - The **two reference strategies on every panel**: **treat-all** (a sloping line that crosses zero
          at the prevalence) and **treat-none** (the horizontal line at net benefit = 0). The model is
          useful only over the threshold range where its curve sits **above both** references.
        - The **model curve(s)** across a stated, clinically justified **threshold range** (e.g. 5–40%),
          not a single point — the whole point is to show utility across the plausible decision region, and
          to name where that region comes from.
        - When models are compared, **each model as its own curve** on the same axes (and, if shown, the
          same external data), so the reader sees over which thresholds one strategy dominates.
        - The **operating threshold** the paper actually proposes, annotated on the curve, with the net
          benefit there — tying the figure back to the deployment decision.
        - Built on the **validation/external** data with the model **calibrated** (see below), at a single
          stated horizon for time-to-event outcomes.
        
        ## Discipline (what the figure must not do)
        - **Do not read net benefit as accuracy** — it is true positives minus weighted false positives;
          a curve only marginally above treat-all means little added utility even if AUROC looks strong.
        - **Do not omit the treat-all and treat-none references** — a model curve alone is uninterpretable;
          utility is defined *relative* to acting on everyone or no one.
        - **Do not show an uncalibrated model** — net benefit depends on the predicted probabilities, so a
          miscalibrated model gives a misleading curve; pair with `calibration_plot.md` and recalibrate
          first if needed.
        - **Do not extend the threshold range past where decisions are actually made**, and do not let the
          curve wander into thresholds with no patients (cross-reference the predicted-risk distribution).
        - **Do not quote a single "the model is better" threshold** without showing the range; report the
          interval over which the model dominates and name the proposed operating threshold.
        - For time-to-event outcomes, **state the horizon** and use a censoring-aware net benefit; a naïve
          complete-case DCA over a censored cohort is biased.
        
        ## Common omission
        - The **treat-all/treat-none references**, the **justified threshold range** (where the
          probabilities come from clinically), and **calibration before the curve** — the elements decision
          curves most often drop, and the ones that decide whether the figure supports a real
          *use-the-model* claim rather than a discrimination claim. Cross-reference
          `critic_rubrics/data_plot.md` §C/§G, the `peer-review/references/exemplar_reviews/calibration_missing.md`
          finding, `analyze-stats` `references/templates/dca_plot.R`, and the added-value table standard
          `analyze-stats` `references/table-standards/table-types/incremental_value.md`.
        
      • external_validation_comparison.md 3.1 KB
        # Exemplar anatomy — external-validation performance across cohorts
        
        A worked **anatomy model** for the figure that carries the **#1 acceptance lever** of a clinical
        DL validation study: performance **across cohorts** — internal vs one or more external sets, or by
        **site / scanner / vendor / sequence** — shown with its **honest drop**. It is the visual proof of
        generalization, and the figure a reviewer scans first. Pairs
        `design-study/references/venue_accept_recipe.md` (external validation = top lever),
        `combine_models_ablation_design.md`, and `analyze-stats` `table-standards/table-types/model_comparison.md`.
        Synthetic — describes *what each element must show* and the errors to avoid; not an image to copy,
        no real citations.
        
        ## Elements
        - The **metric by cohort** (Dice / AUC / HD95) as a **forest or dumbbell** (one row per cohort), with
          **internal as the labelled reference** and each external cohort below it — so the **drop** is read
          at a glance, not buried in a table.
        - **Per-cohort N and a CI on each estimate** — for Dice, a **bootstrap (BCa) CI on per-case values**
          (a t-interval is closed-form but the metric is bounded and bunches near the ceiling, so resample
          whole patients instead); the CI width makes a small external cohort's uncertainty honest.
        - The **Δ from internal** annotated per external cohort (the generalization gap), and, if the design
          is non-inferiority, the **margin**.
        - **Faceting by structure / subgroup / sequence** where the endpoint demands it, so a per-organ or
          per-sequence weakness is visible rather than averaged away.
        - **The failure mode marked** — the cohort / structure / sequence where the model broke (e.g., a
          contrast-sequence collapse, a small-structure floor) shown, not dropped.
        
        ## Discipline (what the figure must not do)
        - **Do not show only the internal result** — a validation figure without the external axis is not a
          validation figure.
        - **Do not plot an external number ≈ internal without its N and CI** — a suspiciously **flat**
          external result is the visual signature of leakage / tuning-on-test; the CI and N are what make a
          flat result credible or expose it.
        - **Do not pool cohorts into one bar** — report **per-cohort**; pooling hides which site/vendor
          drove the drop.
        - **Do not omit the failed subgroups / structures / sequences** — hiding the decline overstates
          generalization and invites the reviewer's first probe (an honest negative is an acceptance asset).
        - **Do not use a naïve normal CI for Dice** — bounded, skewed per-case scores need a bootstrap CI.
        
        ## Common omission
        - The **per-cohort N + CI**, the **Δ-from-internal** annotation, and the **failed subgroups** — the
          elements this figure most often drops, and the ones that turn "it generalizes" into a claim a
          reviewer can check. The case count behind a precise external estimate is a design-time decision
          (`calc-sample-size` `references/segmentation_metric_sample_size.md`). Cross-reference
          `forest_plot.md` (layout kin), `critic_rubrics/data_plot.md`, and `/model-validation` (internal vs
          external, tuning-on-test).
        
      • forest_plot.md 3.1 KB
        # Exemplar anatomy — meta-analysis forest plot
        
        A worked **anatomy model** for a publication-grade pairwise meta-analysis forest plot,
        complementing the `critic_rubrics/data_plot.md` §C *forest* checklist (this shows the full
        composition; the rubric scores a draft against it). Synthetic — describes *what each element
        must show* and the errors to avoid; not an image to copy. Pairs with `analyze-stats`
        `templates/forest_plot.py` / `meta_analysis.R`.
        
        ## Elements (top to bottom)
        - **Per-study row**: study label (author year), the effect estimate as a **square sized by
          weight**, and its **95% CI** as a horizontal line; the numeric estimate (95% CI) in a right
          column. Weight (%) shown numerically, not only by square size.
        - **Pooled diamond**: the random-effects summary, its width = the summary 95% CI. State the
          **model** (e.g., DerSimonian–Laird or REML random-effects) in the caption.
        - **Prediction interval**: show it (a bar through the diamond) whenever k is sufficient — it
          conveys where a *new study's true (study-specific) effect* would likely fall, which the
          summary CI (a confidence interval for the pooled mean) does not. Its absence is the single
          most common forest-plot omission.
        - **Heterogeneity line**: report **I², τ² (or τ), and Cochran's Q with its p** beneath the
          diamond — τ² is the actual between-study variance and must not be left implicit.
        - **Reference line** at the null (OR/RR/HR = 1, or 0 for mean differences); a log scale for
          ratio measures so CIs are symmetric.
        - **Axis**: framed to include all CIs without wasted whitespace; label the effect measure and
          which direction favours which arm.
        
        ## Discipline (what the figure must not do)
        - **Do not pool when pooling is not defensible**: when heterogeneity is extreme *and
          unexplained* (a high I² with a large τ² and no subgroup/meta-regression that accounts for it),
          or k is very small, a single summary can be clinically and statistically misleading — then
          present studies without a summary diamond, or restrict to a justified subgroup, rather than
          showing a precise diamond that implies an agreement the studies do not have. (I² is a guide,
          not an automatic cut-off — judge it with τ² and whether the heterogeneity is explained.)
        - **Subgroups**: plot each subgroup with its own diamond and a **test for subgroup differences**;
          do not narrate subgroup effects that are not shown as plotted strata.
        - **Small-study effects**: a funnel plot + Egger's test belong with the forest **only when
          k ≥ 10**; below that, say tests are underpowered rather than over-interpreting asymmetry.
        - **Certainty**: where GRADE is used, a certainty column (or caption note) keeps a tight CI from
          being read as strong evidence when risk of bias/imprecision is high.
        
        ## Common omission
        - A **prediction interval** and an **explicit τ²**, plus an honest **no-pool / restrict
          decision under extreme heterogeneity** — the elements forest plots most often skip, and the
          ones that turn a precise-looking diamond into an over-statement of agreement. Cross-reference
          `critic_rubrics/data_plot.md` §C (forest) and the SR/MA reporting items via
          `/check-reporting` (PRISMA).
        
      • imaging_panel.md 3.1 KB
        # Exemplar anatomy — annotated multimodality imaging panel (case report / series)
        
        A worked **anatomy model** for the composite imaging figure that carries a radiology case report or
        series — the figure where the diagnosis lives. This is a synthetic teaching model describing what the
        panel must show, not a layout to copy. Use it when the teaching point is an imaging finding, a
        cross-modality discordance, or a treatment response. Complements `clinical_timeline.md` (which carries
        chronology); this file carries the **images themselves**.
        
        ## Elements
        
        - **Panel grid with explicit labels** — one sub-panel per modality, sequence, or timepoint (e.g.,
          mammography / ultrasound / FDG PET-CT; or T2 / FLAIR / DWI / post-contrast). Letter each sub-panel
          (A, B, C…) and state the modality/sequence and plane in the caption, not only the image.
        - **Arrow or marker to the key finding** in every sub-panel — the reader must see what to look at.
          An unannotated image is a decoration, not evidence.
        - **Quantitative labels where a number is the point** — lesion size, SUVmax, BI-RADS/standardized
          category, signal characteristics, degree of stenosis. These anchor the description in the text.
        - **Same-lesion correspondence across panels** — when showing discordance (e.g., visible on one
          modality, occult on another) or response (pre- vs post-treatment), keep the same lesion/orientation
          so the comparison is read at a glance; mark the same anatomical landmark in each.
        - **Modality-appropriate orientation and scale** — laterality labels (L/R), a scale where size is
          load-bearing, and consistent windowing across compared panels.
        - **Histopathology/immunostain sub-panel when origin hinges on it** — stain name and magnification in
          the caption (e.g., the EBER/IHC panel that settles a mimic).
        - **Caption that states de-identification and defines every abbreviation/scale** used in the labels.
        
        ## Discipline (what the figure must not do)
        
        - **Do not expose identifiers** — strip dates, accession numbers, institution banners, patient
          initials, faces, and embedded DICOM overlays before export.
        - **Do not show unannotated images** — every sub-panel needs an arrow/label tying it to a finding
          named in the case presentation.
        - **Do not imply causality or response without correspondence** — a "before/after" pair must be the
          same lesion/plane; mismatched views overstate the change.
        - **Do not omit the quantitative anchor** when the teaching point is a number (an "occult on PET"
          claim needs the SUVmax; a "stenosis" needs the degree or velocity).
        - **Do not overcrowd** — split into more sub-panels rather than stacking arrows on one image.
        
        ## Common omission
        
        - **Cross-modality / cross-timepoint correspondence** and the **quantitative label**. A case whose
          lesson is discordance or response fails if the panels are not the same lesion or the decisive number
          is missing. Cross-reference `write-paper/references/exemplar_case_report.md` (diagnostic-assessment
          and adverse-event/mimic subtypes), `clinical_timeline.md` for the chronology figure, and the CARE
          checklist in `/check-reporting`.
        
      • km_curve.md 2.9 KB
        # Exemplar anatomy — Kaplan–Meier survival curve
        
        A worked **anatomy model** for a publication-grade Kaplan–Meier figure, completing the figure
        side of the survival pair (the table side is `analyze-stats/.../table-types/survival_results.md`).
        Synthetic — describes *what each element must show* and the errors to avoid; not an image to
        copy. Pairs with `analyze-stats` `templates/survival_analysis.py` / `survival` (R).
        
        ## Elements
        - **Step curves**, one per group, visually distinguishable by line style as well as colour
          (colourblind-safe; do not rely on colour alone).
        - **Number-at-risk table** aligned under the x-axis at each labelled time — the single most
          important KM element; without it a reader cannot judge how much of the tail is real.
        - **Censoring marks** (ticks on the curve at censoring times), so attrition is visible.
        - **Confidence band** around each curve, or at least the median's CI — a curve without
          uncertainty overstates precision in the sparse tail. Say which kind: a **pointwise** band
          covers each time separately and does *not* give simultaneous coverage over the whole curve;
          a **simultaneous** (e.g., Hall–Wellner / equal-precision) band does.
        - **Group contrast** annotated: the **log-rank p** and/or the **HR (95% CI)** with its model,
          and **median survival per group with 95% CI** (or "not reached").
        - **Axes**: y from 0 to 1 (or 0–100%) labelled with the estimand (overall survival, PFS, …);
          x labelled with the time unit; a reference line is optional.
        
        ## Discipline (what the figure must not do)
        - **Do not extend the x-axis past where the risk set is thin.** Truncate at (or annotate) the
          point where estimation stops being supported — judge this by the **number at risk and the
          remaining events**, and by where the **CI widens sharply** (e.g., once n-at-risk falls below a
          small fraction of baseline) — the far tail is driven by a few patients and the steps are noise.
        - **Do not read a survival probability off a horizon beyond the data**; if a fixed-time estimate
          (e.g., 5-year survival) is quoted, the number at risk at that time must support it.
        - **Do not omit censoring** — heavy early censoring with a flat curve can masquerade as good
          survival.
        - For **competing risks**, a KM of one cause overestimates its incidence — use a cumulative
          incidence function (CIF) instead, and say so.
        - If proportional hazards is violated, the curves crossing is itself the message; pair with an
          RMST or a time-window statement rather than a single HR (see the survival table-type).
        
        ## Common omission
        - The **number-at-risk table** and **censoring marks** — the two elements KM figures most often
          drop, and the two a reviewer checks first, because both govern whether the tail can be
          believed. Cross-reference `critic_rubrics/data_plot.md` §C (KM) and the survival table-type in
          `analyze-stats/references/table-standards/table-types/survival_results.md`.
        
      • manhattan_plot.md 4 KB
        # Exemplar anatomy — Manhattan / volcano plot (agnostic many-exposure scan)
        
        A worked **anatomy model** for the figure that summarizes an **agnostic many-exposure
        association scan** — an exposome-/environment-/metabolome-/proteome-/nutrient-wide association
        study (ExWAS / EWAS / MWAS), or any "we screened N predictors" pass. Complements the
        `critic_rubrics/data_plot.md` §C *Manhattan / volcano* checklist (this composes; the rubric
        scores). Synthetic — describes *what each element must show* and the errors to avoid; not an
        image to copy. Pairs with `analyze-stats` `analysis_guides/multiplicity.md` and review probe
        **O17** in `observational_confounding.md`.
        
        ## Which plot
        
        - **Manhattan** — x = the exposures (grouped/ordered by domain or category), y = **−log10(p)**.
          Best when there are many tests and you want to show *which* exposures cross threshold across
          domains.
        - **Volcano** — x = **effect size** (β / log-OR / log-HR), y = **−log10(p)**. Best when the
          effect *magnitude and direction* matter as much as significance — it forces effect size onto
          the figure so a trivially small but "significant" hit is visibly trivial.
        
        Prefer a **volcano** (or pair the two) whenever the manuscript will make an effect-size claim; a
        Manhattan plot alone shows significance without magnitude.
        
        ## Elements
        
        - **A significance threshold line** drawn across the plot, **and its basis named** — family-wise
          (Bonferroni / permutation-based study-wide threshold) for a confirmatory framing, or **FDR**
          (a horizontal line at the p corresponding to the q-cutoff) for discovery — with the **number of
          tests `m`** stated in the caption (the denominator the threshold was computed against).
        - **Effect size on the axis (volcano)** or a **signed direction track (Manhattan)** so up- vs
          down-associations are distinguishable; never significance alone.
        - **Sparse labelling** — annotate only the threshold-crossing / top hits; the **full tested set
          (every exposure with effect size + p/q) lives in a supplementary table**, not crowded onto the
          plot.
        - **Domain/category banding (Manhattan)** — group exposures (diet, chemicals, lifestyle,
          socio-economic …) with alternating colour bands and category labels on the x-axis, so a reader
          sees *where* signal concentrates.
        - **Replication encoded** — when the scan has a discovery + replication design, mark which hits
          replicated (e.g. filled = replicated, open = discovery-only) or show discovery and replication
          as paired panels; the caption states the replication rate.
        - **Grayscale-safe** — distinguish categories/direction by shape or band as well as colour.
        
        ## Discipline (what the figure must not do)
        
        - **Do not omit the threshold's basis or the test count** — a threshold line with no stated
          correction method and no `m` is uninterpretable; the same −log10(p) means different things at
          m = 30 vs m = 3000.
        - **Do not present a discovery-only Manhattan as confirmatory** — without replication the crossing
          hits are candidates; say so in the caption (frame as screening — review probe O17).
        - **Do not let significance stand in for effect** — a Manhattan with no effect-size channel hides
          that the top hit may be clinically trivial at the study's large N; use a volcano or add an
          effect-size panel/track.
        - **Do not interpret a single crossing hit causally when exposures are correlated** — it may be a
          marker for a correlated true cause; that is a co-exposure/mixture-model question, not something
          the plot resolves (`analysis_guides/multiplicity.md`).
        - **Do not hand-pick the plotted exposures** — plot the whole tested set; selective inclusion is
          the visual form of selective top-hit reporting.
        
        ## Common omission
        
        The **named threshold basis + test count `m`**, the **effect-size channel** (volcano axis or a
        Manhattan direction track), and the **replication encoding** — the three elements that separate a
        defensible agnostic-scan figure from a significance-only screen. Pair with a supplementary
        full-results table (every exposure, effect size, p and q).
        
      • model_comparison_leaderboard.md 4 KB
        # Exemplar anatomy — model-comparison leaderboard (across models, one cohort)
        
        A worked **anatomy model** for the figure that carries a fair **model-vs-model** comparison: several
        architectures / families ranked **on the same test cohort**. It is the mirror of
        `external_validation_comparison.md` — that one is **one model across cohorts**; this one is **many
        models on one cohort** — and it is the visual proof that the comparison was fair rather than a "we
        beat everyone" table. Pairs `design-study/references/multi_model_comparison_design.md`,
        `calc-sample-size/references/multi_model_comparison_sample_size.md`, and the `analyze-stats`
        `table-standards/table-types/model_comparison.md`. Synthetic — describes *what each element must
        show* and the errors to avoid; not an image to copy, no real citations.
        
        ## Elements
        - The **metric per model** as a **forest / dumbbell**, one row per model, with the **strong,
          fairly-tuned reference baseline** as the labelled reference row — so each model is read as a
          **difference from the baseline**, not as an isolated number.
        - **A CI on every model's estimate** — a **bootstrap (BCa) CI on per-case values** for Dice (bounded
          and skewed near ceiling, so resample whole patients rather than trust a t-interval), a **DeLong CI**
          for AUC (DeLong gives the AUC's standard error and the correlated-AUC contrast — it is not a
          confidence band around the ROC curve). The CI width is what turns a stack of point estimates into a
          comparison a reviewer can judge.
        - **The paired Δ vs the reference** annotated per model (Δ + its **CI / significance**), because the
          models were run on the **same cases** — the paired difference, not two marginal numbers placed
          side by side, is the comparison.
        - **Ranking honesty**: where models are near-tied, add a **rank-stability strip** (seed-to-seed spread)
          or a **critical-difference** view so models the test does not separate read as **unranked** rather
          than ordered — and are not captioned as a demonstrated tie.
        - **Faceting by structure / subgroup / class** where the endpoint demands it (per-organ Dice, per-class
          AUC), so a model that wins on average but loses on the hard structure is visible.
        - **Matched-budget disclosure in the caption** — same data, patient-level split, preprocessing, and
          **HPO / training budget** across all models, and the number of seeds. The figure's credibility rests
          on this line; without it a leaderboard is unfalsifiable.
        
        ## Discipline (what the figure must not do)
        - **Do not bold / highlight your own row as the winner unless the paired Δ vs the runner-up excludes
          zero** — and read that Δ, not the overlap of the two marginal CIs, which can overlap while the paired
          difference is real; where the Δ does not clear zero, encode **not separated** instead of a winner.
        - **Do not plot one point per model with no CI or seed spread** — a single-run leaderboard ranks by
          *skill + luck*; a bare ranked list is a leaderboard of luck.
        - **Do not put models trained on different data / split / budget in the same ranking** without saying
          so — an unfair comparison drawn as a clean leaderboard is the core failure this figure exists to
          prevent.
        - **Do not show only the metric / facet where you win** — lead with the **pre-specified primary
          metric**; hiding the facets where the proposed model loses overstates the result.
        - **Do not rank by point estimate alone** — order by estimate but let the CIs / critical difference
          govern what counts as a real gap.
        
        ## Common omission
        - The **paired-Δ CI**, the **seed / rank-stability encoding**, and the **matched-budget caption** — the
          three elements a leaderboard most often drops, and the ones that separate a fair comparison from a
          marketing chart. The case count behind a *separable* comparison is a design-time decision
          (`calc-sample-size/references/multi_model_comparison_sample_size.md`). Cross-reference
          `external_validation_comparison.md` (across-cohort sibling), `forest_plot.md` (layout kin),
          `critic_rubrics/data_plot.md`, and `/model-validation` (fair comparison, tuning-on-test).
        
      • mrmc_roc.md 3 KB
        # Exemplar anatomy — MRMC reader-study ROC (multi-reader multi-case)
        
        A worked **anatomy model** for the ROC figure of a **multi-reader multi-case (MRMC)** study — the
        reader-study counterpart to the single-model `roc_pr.md`. The point of an MRMC figure is to show
        that readers are a *sample*: the curve must communicate both the reader-averaged performance and
        the spread across readers, so a comparison (e.g., AI-aided vs unaided, or modality A vs B) is read
        as generalising to the reader population, not to two specific experts. Complements
        `critic_rubrics/data_plot.md` §C and pairs `analyze-stats`
        `table-standards/table-types/reader_study.md`. Synthetic — describes *what each element must show*
        and the errors to avoid; not an image to copy, no real citations.
        
        ## Elements
        - **Fixed 0–1 axes**, sensitivity (y) vs 1 − specificity (x), with the chance diagonal.
        - **Per-reader curves** (thin, one per reader) **and** the **reader-averaged curve** (bold) for each
          condition being compared — so the reader spread is visible, not hidden inside one mean line.
        - The **reader-averaged AUC with an MRMC 95% CI** (accounting for **both reader and case** variance —
          Obuchowski–Rockette / DBM), per condition, in the legend or panel.
        - For a comparison, **both conditions on the same axes** (e.g., unaided vs AI-aided) and the
          **ΔAUC with its MRMC CI / p**; if the design is non-inferiority, mark the **pre-specified margin**.
        - The **operating point(s)** actually used by readers (e.g., recommend-biopsy threshold), and a note
          of the **unit of analysis** (per-patient vs per-lesion) and the **fully-crossed** design.
        
        ## Discipline (what the figure must not do)
        - **Do not show only the reader-averaged curve** — without per-reader curves (or a reader-spread band)
          the figure hides whether the gain is uniform or driven by one weak reader.
        - **Do not quote a naïve (fixed-reader) AUC CI** for a generalising claim — the CI must reflect reader
          sampling (MRMC variance); a DeLong CI that ignores reader variance understates uncertainty.
        - **Do not pool all readers' reads as if independent**, and do not mix per-patient and per-lesion units
          on one curve without saying so.
        - **Do not imply a population-level "AI matches radiologists" claim from 2–3 expert readers** — the
          figure (and caption) should make the reader sample and its generalisation limit explicit.
        - If readers were aided by AI, **state the reading order/washout** so the comparison is not confounded
          by case recognition.
        
        ## Common omission
        - The **per-reader curves**, the **MRMC (reader+case) CI** on the averaged AUC, and the **unit of
          analysis / fully-crossed design** note — the elements MRMC figures most often drop, and the ones
          that decide whether the comparison generalises to readers rather than to the specific panel.
          Cross-reference `critic_rubrics/data_plot.md` §C, the diagnostic-accuracy probes
          `peer-review/references/domain-probes/diagnostic_accuracy.md` (D5/D6), and
          `analyze-stats` `table-standards/table-types/reader_study.md`.
        
      • README.md 8.5 KB
        # Exemplar plots — non-flow figure anatomy models
        
        `make-figures` carries flow-diagram exemplars (`exemplar_diagrams/{consort,prisma,stard,strobe}/`)
        and a non-flow **checklist** (`critic_rubrics/data_plot.md` §C: ROC, forest, KM, calibration,
        Bland–Altman, confusion matrix). What it lacked is a worked **anatomy model** for the non-flow
        figures — what a complete, publication-grade plot of each type contains, element by element.
        This directory fills the gap that `data_plot.md` §F ("Exemplar comparison, if exemplars exist")
        anticipates.
        
        These are **authored from scratch as teaching models**, not extracted from any published
        figure. Use them to compose a figure that has every load-bearing element, then score the draft
        against `critic_rubrics/data_plot.md`; do not copy an image.
        
        ## Runnable render layer (tested)
        
        Ten of the highest-yield clinical figures — **Kaplan–Meier, ROC, calibration,
        decision-curve, forest, Bland–Altman, confusion matrix, multi-reader ROC (MRMC),
        Manhattan, and clinical timeline** — have a matching **runnable, deterministic generator**
        in `../../scripts/render_core_figures.py`. It renders already-computed inputs
        (the statistical estimation stays in `/analyze-stats`; the render layer never recomputes a
        number) into the canonical anatomy and asserts each figure's load-bearing elements
        (number-at-risk table, chance diagonal, identity line, treat-all/treat-none references,
        no extrapolation past follow-up; forest per-study CI rows + null line + pooled diamond;
        Bland–Altman bias + 95% limits of agreement; confusion Predicted/Actual axes + annotated
        cells; MRMC per-reader + averaged curves; Manhattan significance-threshold line; timeline
        baseline + event markers). A network-free render-regression challenge
        (`scripts/render_core_figures_challenge/`, wired into `skill.yml` validation) renders all
        ten from a synthetic fixture and confirms the structural gate fails on a malformed figure.
        
        `imaging_panel.md` stays a **prose-only** exemplar by design: it composes real medical
        images with panel labels, scale bars, and arrows — an image-arrangement task, not a plot of
        computed numbers — so a synthetic generator would only draw placeholder boxes. Use the
        prose model to compose it against real images.
        Use the generator to produce these four directly; use the prose models below to compose
        the figure types that do not yet have a generator.
        
        ## Contents
        
        - `forest_plot.md` — meta-analysis forest plot: per-study square-by-weight + CI, pooled diamond
          with the model named, prediction interval, I²/τ²/Q, no-pool discipline under extreme
          heterogeneity, subgroup-difference test, funnel/Egger only at k ≥ 10.
        - `external_validation_comparison.md` — performance across cohorts (internal vs external / by
          site·scanner·vendor·sequence) with the **honest drop**: forest/dumbbell, internal as reference,
          per-cohort N + bootstrap-BCa CI, Δ-from-internal annotation, subgroup/structure faceting, failed
          cohorts marked; a suspiciously flat external result is the leakage signature. The visual of the
          #1 acceptance lever; pairs `/model-validation` and `calc-sample-size` segmentation-metric sizing.
        - `model_comparison_leaderboard.md` — model-vs-model leaderboard (**across models, one cohort** — the
          mirror of `external_validation_comparison.md`): one row per model with a bootstrap-Dice / DeLong
          CI, the **paired Δ vs a strong fairly-tuned reference baseline** with its CI/significance, ranking
          honesty (the paired Δ decides, not marginal-CI overlap; critical-difference / seed-stability), per-structure faceting, and a
          **matched-budget caption**; no "bold-our-row" cherry-pick. Pairs `design-study` multi-model-comparison
          design + `calc-sample-size` Test 16 + the `analyze-stats` model_comparison table-type.
        - `segmentation_failure_panel.md` — segmentation usability (**across cases, one model** — the third
          axis beside the two above): every case plotted rather than a bar of means, the **acceptability
          threshold drawn with the fraction below it labelled**, failures coloured by the pre-specified class
          (boundary drift / missed / hallucinated / catastrophic), stratification that explains the tail, and
          a **qualitative panel of the actual worst cases** — an anatomically impossible output is invisible
          in every summary statistic. Pairs `design-study` segmentation-usability design + `calc-sample-size`
          Test 17.
        - `km_curve.md` — Kaplan–Meier survival curve: number-at-risk table, censoring marks, CI band,
          median/log-rank/HR annotation, no extrapolation past the thin-risk-set tail, CIF for competing
          risks. Pairs the survival table-type.
        - `roc_pr.md` — ROC + precision–recall: fixed 0–1 axes, AUC CI / curve band, marked operating
          point, DeLong for AUC differences, PR + AUPRC (baseline = prevalence) under imbalance.
        - `calibration_plot.md` — calibration: predicted-vs-observed with 45° line, flexible curve,
          slope/intercept, predicted-risk distribution, external set, not HL-test-alone; pairs roc_pr.
        - `decision_curve.md` — decision curve (net-benefit/DCA): threshold-probability vs net benefit,
          treat-all/treat-none references, justified threshold range, calibrated model curve, proposed
          operating threshold; the clinical-utility counterpart to roc_pr/calibration. Pairs the
          `analyze-stats` incremental_value table-type.
        - `mrmc_roc.md` — multi-reader multi-case (MRMC) reader-study ROC: per-reader curves + bold
          reader-averaged curve, MRMC (reader+case) AUC CI, ΔAUC with margin, per-patient/per-lesion unit,
          fully-crossed/washout note. Pairs the analyze-stats reader-study table-type.
        - `ai_assistance_effect.md` — AI-assistance effect (reader-level paired improvement): one line per
          reader **unaided → aided** on the same cases, stratified by experience/specialty, reader-averaged
          ΔAUC/Δaccuracy with paired MRMC CI, **harmed readers marked** (automation bias), reading-time
          companion, crossover/washout. The within-reader-change counterpart to `mrmc_roc.md` (ROC space);
          pairs the analyze-stats reader-study table-type and `calc-sample-size` Test 14.
        - `bland_altman.md` — Bland–Altman agreement: difference vs mean-of-the-two-methods, bias line +
          CI, ±1.96·SD limits of agreement with CIs on each limit, proportional-bias check, % within LoA,
          pre-defined clinical-acceptability band, not a correlation plot, repeated-measures handling.
          Pairs the analyze-stats agreement table-type.
        - `confusion_matrix.md` — confusion matrix: TP/FP/FN/TN with explicit Predicted/Actual axes, raw
          counts AND row-normalized (recall) / column-normalized (precision) views, class-imbalance caveat,
          stated operating threshold, per-class metrics, multi-class macro-average. Pairs roc_pr.
        - `clinical_timeline.md` — CARE case-report timeline: relative time axis, symptoms/tests/treatment/
          outcome lanes, index presentation marker, final follow-up endpoint, de-identification discipline,
          and annotated imaging-panel pairing when imaging is the teaching point. Pairs `write-paper`
          `exemplar_case_report.md`.
        - `imaging_panel.md` — annotated multimodality/multi-sequence imaging panel for a radiology case
          report or series: lettered sub-panels per modality/sequence/timepoint, arrow to the key finding in
          each, quantitative labels (size/SUVmax/category), same-lesion correspondence for discordance or
          treatment response, and de-identification discipline. Pairs `clinical_timeline.md` (chronology) and
          `write-paper` `exemplar_case_report.md`.
        - `manhattan_plot.md` — Manhattan / volcano figure for an agnostic many-exposure scan (ExWAS / EWAS /
          MWAS / proteome-/nutrient-wide): named significance-threshold line + the number of tests, effect-size
          channel (volcano axis or Manhattan direction track), sparse hit labelling with the full set in a
          supplement, domain banding, and replication encoding. Pairs `analyze-stats` `multiplicity.md` and
          review probe O17.
        
        ## Curator guidelines (for adding more)
        
        - **Synthetic only.** Describe the anatomy with placeholder specifics; never paste or trace a
          real figure, use no real citations, no PII, English only.
        - **One figure type per file**, element by element, each line stating *what the element must
          show* — plus a "Discipline" block of what the figure must not do and the type's most common
          omission.
        - **Complement, do not duplicate, `critic_rubrics/data_plot.md` §C** — the rubric scores; the
          exemplar composes. Cross-reference the rubric and the relevant `analyze-stats` template
          (e.g., `forest_plot.py` / `meta_analysis.R` for the forest) rather than restating them.
        - Keep each file ~40–60 lines. Future candidates (see `reverse_engineer/gap_register.md`):
          `visual_abstract` anatomy.
        
      • roc_pr.md 2.4 KB
        # Exemplar anatomy — ROC and precision–recall (PR) curves
        
        A worked **anatomy model** for ROC and PR figures, complementing the `critic_rubrics/data_plot.md`
        §C *ROC* checklist (this composes; the rubric scores). Synthetic — describes *what each element
        must show* and the errors to avoid; not an image to copy. Pairs with `analyze-stats`
        `templates/diagnostic_accuracy.py`.
        
        ## Elements
        - **ROC curve** with axes **fixed 0–1** (TPR/sensitivity vs FPR/1−specificity); a diagonal
          chance line; **AUROC with its 95% CI** in the panel.
        - **Confidence band** around the curve (bootstrap), or at least the **AUC CI** (DeLong or
          Hanley–McNeil for the AUC; bootstrap for the curve band) — a bare curve hides sampling error,
          especially at small n.
        - **Operating point(s)** marked on the curve — the threshold actually proposed for use, with its
          sensitivity/specificity; state how it was chosen (and on which data, not the test set).
        - **Multiple models**: distinguish by line style as well as colour; report a **test of the AUC
          difference** (DeLong) rather than two standalone AUCs.
        - **A precision–recall (PR) curve alongside ROC when classes are imbalanced** — ROC looks
          optimistic under low prevalence because FPR has a huge negative denominator; report **AUPRC**
          and note the **baseline = prevalence** (the PR "chance" line is not 0.5).
        
        ## Discipline (what the figure must not do)
        - **Do not read ROC as clinical performance under imbalance** — a high AUROC can coexist with a
          low PPV at the real base rate; pair it with PR / PPV-at-threshold (see `calibration_plot.md` for
          the probability side).
        - **Do not pick the operating point on the test set** (threshold optimism); fix it on training/
          derivation data and report it.
        - **Do not compare AUCs without a paired test** (DeLong) on the same cases; whether the two
          AUCs' CIs overlap is not a valid test of their difference.
        - For a model that outputs probabilities, ROC/PR show **discrimination only** — discrimination is
          not calibration; a deployable probability still needs a calibration plot.
        
        ## Common omission
        - The **AUC confidence interval / curve band**, the **marked operating point**, and **AUPRC under
          imbalance** — the elements ROC figures most often drop, and the ones that turn a pretty curve
          into an overstatement of real-world performance. Cross-reference `critic_rubrics/data_plot.md`
          §C (ROC) and `analyze-stats` `templates/diagnostic_accuracy.py`.
        
      • segmentation_failure_panel.md 4.5 KB
        # Exemplar anatomy — segmentation failure panel (across cases, one model)
        
        A worked **anatomy model** for the figure that carries a segmentation **usability** claim: one model,
        one cohort, and the **distribution across cases with its tail visible**. It is the third axis of the
        comparison family — `model_comparison_leaderboard.md` is *many models, one cohort*,
        `external_validation_comparison.md` is *one model, many cohorts*, and this one is **one model, many
        cases**. It exists because a bar of mean Dice is the single most common way a segmentation paper
        shows nothing about whether the model is usable. Pairs
        `design-study/references/segmentation_failure_characterization_design.md` and
        `calc-sample-size/references/segmentation_acceptability_sample_size.md` (Test 17). Synthetic —
        describes *what each element must show* and the errors to avoid; not an image to copy, no real
        citations.
        
        ## Elements
        - **Every case plotted** — a **strip / jitter / dot plot** (or violin *with* the points overlaid) of
          the per-case metric, one point per case, faceted by structure. The mean and its CI may be drawn on
          top; they may not replace the points. If the reader cannot count the bad cases, the figure has not
          reported them.
        - **The acceptability threshold drawn as a line**, with the **fraction below it labelled** — the
          figure's actual claim ("N of M cases, 78%, met the acceptability rule"). The threshold must be the
          pre-specified one, and its definition belongs in the caption, not the reader's imagination.
        - **Failure cases marked by class** — colour or facet by the pre-specified taxonomy (boundary drift /
          missed structure / hallucinated structure / catastrophic). A tail of boundary drift and a tail of
          catastrophic outputs look identical on a metric axis and mean opposite things clinically.
        - **A qualitative panel of the actual worst cases** — image + contour overlay for the bottom cases,
          with the reference contour shown. This is not decoration: an anatomically impossible output is
          invisible in every summary statistic, and the small panel is the only place the reader can see
          whether the tail is "slightly ragged edge" or "contoured the wrong organ".
        - **Stratification that explains the tail** — the same distribution split by the attribute the design
          pre-specified (structure size, contrast phase, pathology present, scanner / site), so the figure
          answers *where* the model fails and not only *how often*.
        - **Caption carries N, the rating rule, and the raters** — number of cases, who judged acceptability
          and how many judged each case, and the metric's per-case definition. An acceptability fraction
          without its adjudication rule is not reproducible.
        
        ## Discipline (what the figure must not do)
        - **Do not plot a bar of means with an SD whisker** — the default segmentation figure, and the one
          that hides everything this figure exists to show: a mean of 0.90 over a 5% catastrophic tail draws
          identically to a uniform 0.90.
        - **Do not truncate or clip the y-axis to hide the tail** — the low outliers *are* the finding. An
          axis starting at 0.7 to "make the differences visible" deletes exactly the cases a reviewer wants.
        - **Do not report the acceptability fraction without the threshold's provenance** — a threshold set
          after seeing the distribution converts the figure into a post-hoc claim.
        - **Do not show only the structure that performed well** — facet every structure the claim covers;
          acceptability for one pipeline can differ by fifty points between target volumes and normal tissue,
          and showing the winner is the cherry-pick a reviewer flags first.
        - **Do not average across readers without saying so** — a per-case point that is silently a
          3-reader mean, or a fraction pooled over reader×case as if independent, misstates both the
          distribution and its precision (Test 17).
        
        ## Common omission
        - The **qualitative worst-case panel**, the **failure-class encoding**, and the **threshold line with
          its labelled fraction** — the three elements that turn a metric distribution into a usability
          claim, and the ones a segmentation figure most often drops. The case count behind a *bounded*
          failure rate is a design-time decision
          (`calc-sample-size/references/segmentation_acceptability_sample_size.md`). Cross-reference
          `model_comparison_leaderboard.md` (across-models sibling), `external_validation_comparison.md`
          (across-cohorts sibling), `critic_rubrics/data_plot.md`, and `/uncertainty-imaging` when the tail
          is handled by abstention rather than reported as a rate.
        
    • visual_abstract_templates
      • jacc_central_illustration.pptx 28.2 KB · in bundle
      • medsci_default.pptx 28.3 KB · in bundle
      • template_guide.md 6.6 KB
        # Visual Abstract Template Guide
        
        How `generate_visual_abstract.py` maps content to PPTX template shapes.
        
        ## Matching Rules
        
        The script iterates through all shapes on slide index 0 and matches by **text content**
        (case-insensitive substring match). Each shape is filled exactly once.
        
        | Content Field | Match Pattern (in shape.text) | CLI Flag |
        |---|---|---|
        | Article title | `ArticleTitle` | `--title` |
        | Hypothesis / research question | `Hypothesis` or `Question` | `--hypothesis` |
        | Methodology | `Methodology` or `flowchart` or `bullet` | `--methods` |
        | Visual element (image) | `Visual element` or `Image` or `Illustration` | `--visual` |
        | Main finding | `Main finding` or `relevance` | `--finding` |
        | Citation | `Eur Radiol` or `DOI` or `Author` | `--citation` |
        | Patient cohort badge | `Patient` or `cohort` | `--badges` (1st) |
        | Modality badge | `Modality` or `organ` | `--badges` (2nd) |
        | Center type badge | `Single` or `Multi-center` or `center` | `--badges` (3rd) |
        
        ## European Radiology Template — **not bundled; supply your own**
        
        European Radiology requires a graphical abstract from first revision and publishes its own template,
        `EURA-GA-Jan2025.pptx`. **Download it from the journal** and point the generator at it:
        
        ```bash
        python scripts/generate_visual_abstract.py --template /absolute/path/to/EURA-GA-Jan2025.pptx ...
        ```
        
        We used to ship a copy. We no longer do, and the reason is worth stating plainly: what we were
        shipping was the journal's file with a **published paper's graphical abstract still filled into
        slide 2** — the ESR wordmark, and that paper's four-panel patient CT figure, eight images in all,
        inside an MIT-licensed package that anyone may copy and sell. `docProps/app.xml` still carried the
        article's title. It came with no licence, and nothing in this repository could see it, because the
        image-licence gate globbed the filesystem and a `.pptx` is a zip.
        
        A template you may download is not a template we may ship. The gate now opens containers
        (`scripts/check_bundled_media_license.py`), and the generator has always accepted an absolute path —
        so nothing is lost except a file we had no right to hand out.
        
        The shape map below still applies to the journal's file, which is why it is kept.
        
        ### Shape Map (Slide 1)
        
        | Shape # | Name | Content Field | Position |
        |---------|------|---------------|----------|
        | 0 | Title 1 | Article title | Top, full width |
        | 5 | Abgerundetes Rechteck 7 | Hypothesis/Question | Below title, full width |
        | 3 | Abgerundetes Rechteck 7 | Methodology | Left panel, below hypothesis |
        | 4 | Abgerundetes Rechteck 7 | Visual element (image) | Right panel, large area |
        | 7 | Abgerundetes Rechteck 7 | Patient cohort badge | Left, below methodology |
        | 8 | Abgerundetes Rechteck 7 | Modality / organ badge | Center-left, below methodology |
        | 9 | Abgerundetes Rechteck 7 | Single / Multi-center badge | Center, below methodology |
        | 6 | Abgerundetes Rechteck 7 | Main finding | Bottom area, full width |
        | 2 | Abgerundetes Rechteck 2 | Citation line | Bottom bar |
        | 1 | Content Placeholder 4 | (Logo area — leave empty or add journal logo) |
        
        ### Notes
        
        - All shape names in the EUR template are generic German ("Abgerundetes Rechteck" = rounded
          rectangle). The script identifies shapes by their **text content**, not by name.
        - The Visual element shape (Shape 4) should have its text cleared and an image inserted.
          The script places the image within the shape's bounding box, maintaining aspect ratio.
        - Badge shapes (7, 8, 9) have small icon images in the EUR example slides — the script
          replaces text only. Icons can be added manually in PowerPoint after generation.
        
        ## MedSci Default Template
        
        **File:** `medsci_default.pptx`
        **Use slide:** Index 0
        
        A journal-neutral template following the same structure as European Radiology but without
        journal-specific branding. Uses neutral colors (dark gray accent, white background).
        
        ### Shape Map
        
        Same field mapping as EUR, with these differences:
        - No journal logo placeholder
        - Neutral accent color (#404040 dark gray)
        - Slightly wider visual element area
        
        ## Adding a New Journal Template
        
        1. Obtain the journal's official visual abstract template (PPTX or PPT format).
        2. Copy to `visual_abstract_templates/{journal_name}.pptx`.
        3. Ensure the template slide has placeholder text matching the patterns in the
           Matching Rules table above. If not, either:
           - Manually edit the template to add matching placeholder text, OR
           - Add custom matching rules to `generate_visual_abstract.py`
        4. Add a section to this guide documenting the shape map.
        5. Update the journal profile in `write-paper/references/journal_profiles/` with
           the visual abstract requirement status and template name.
        
        ## JACC Central Illustration Template
        
        `jacc_central_illustration.pptx` — for JACC family journals (`--type central-illustration`).
        
        Built reproducibly via `scripts/build_jacc_template.py` to match the official JACC submission PPTX layout (verified against doi:10.1016/j.jacc.2019.10.035 Figures 1–4).
        
        ### Layout (10 × 7.5 in slide)
        
        | Slot | Type | Position (left, top) | Size (W × H) | Placeholder text | Filled by `--type central-illustration` |
        |---|---|---|---|---|---|
        | 1 | TEXT_BOX | (0.4, 5.3) | 9.4 × 0.5 in | `ARTICLECITATION` | `--citation` |
        | 2 | RECTANGLE → image | (3.0, 0.8) | 4.0 × 4.2 in | `VISUALELEMENT` | `--visual` (PNG/TIFF, ≥600 DPI) |
        | 3 | TEXT_BOX | (0.4, 7.0) | 4.1 × 0.5 in | `FOOTERNOTE` | optional, usually empty |
        | 4 | RECTANGLE | (7.3, 6.7) | 2.7 × 0.8 in | `JACCLOGO` | leave for editorial |
        
        ### Why a separate template
        
        The JACC editorial team applies the red outer border and the blue "CENTRAL ILLUSTRATION:" header bar after acceptance. Author submissions must contain only:
        - The content figure (slot 2)
        - The citation line (slot 1)
        
        Pre-rendering JACC house elements (red border, blue header) is incorrect — JACC will replace them anyway, and pre-rendered versions clash with the family branding.
        
        ### Validation rules
        
        The CI mode in `generate_visual_abstract.py` validates structural simplicity per Fuster-Mann 2019:
        - ≤ 3 visual zones in the content figure (`--ci-zones`)
        - ≤ 30 total label words (`--ci-label-words`)
        - ≤ 4 numerical highlights (`--ci-numerical-points`)
        - No methodology terms in `--ci-raw-text` (CI ≠ Visual Abstract)
        
        Override with `--ci-allow {zones|words|numerical|methods}` only when justified.
        
        ### Future cardiology templates
        
        For Circulation, EHJ, JAHA, or other journals with similar CI requirements, copy this template and rename. The Fuster-Mann 5 rules and CI mode validation apply unchanged; only the slide dimensions and citation footer pattern may need adjustment.
        
    • design_principles.md 8.2 KB
      # Effective Scientific Figure Design
      
      > **Primary source**: Brunner et al., "Designing effective figures for
      > scientific communication." *Nat Hum Behav* (2026).
      > DOI: 10.1038/s41562-026-02466-9 — communication-context strategies.
      >
      > **Companion sources** (cite in figure legends / Methods when used):
      > - Rougier et al., "Ten simple rules for better figures." *PLoS Comput
      >   Biol* 2014;10:e1003833 (PMID 25210732). General-purpose, foundational
      >   ten-item checklist.
      > - Crameri F., "Choosing the right colors: a perceptually uniform,
      >   colorblind-safe approach." *Curr Protoc* 2024;4:e1126
      >   (DOI 10.1002/cpz1.1126). Definitive 2024 reference for `viridis`,
      >   `cividis`, `batlow` palettes and redundant encoding.
      >
      > **Triggered from**: SKILL.md Step 1 ("Specify"). Read this file **before**
      > choosing a figure type — it shifts focus from "which chart fits the data" to
      > "what message should the reader walk away with."
      
      Most figure-design guidance focuses on technical execution (axis ranges,
      palettes, DPI). This file complements that by adding a communication-first
      layer: who reads the figure, under what time pressure, and what should they
      remember 10 seconds later. Apply these strategies in Step 1 when specifying a
      figure; revisit during Step 4 / 4b when reviewing.
      
      ---
      
      ## The 5 strategies (read in order)
      
      ### 1. Identify the key message *(most important)*
      
      Before opening a plotting library, write **one sentence** describing what the
      figure must convey. If you cannot, the figure is premature. Examples:
      
      - "Model A outperforms Model B at every operating threshold."
      - "Adverse-event rates differ by route of administration but not by dose."
      - "The proposed pipeline runs end-to-end in under 30 seconds per case."
      
      Pin that sentence as a comment at the top of the generation script. Every
      panel, color, annotation, and label exists to support that message; anything
      that does not should be removed or moved to supplementary material.
      
      ### 2. Consider time and interaction (audience-aware)
      
      Different reading contexts allow different amounts of inspection time:
      
      | Context | Reading time | Implication |
      |---|---|---|
      | Journal article (specialist) | 30–120 s per figure | Dense detail acceptable; legend lookups OK |
      | Conference slide | 15–30 s, narrated | Direct labels mandatory; one message per slide |
      | Visual / graphical abstract | 5–10 s, no narration | One panel; minimal text; readable thumbnail |
      | Social-media share | 2–5 s | Self-contained; large fonts; high contrast |
      | Public lecture / press | 10–30 s, narrated | Plain-language axis labels; analogy via icon |
      
      Set the **reading-time budget** in Step 1, then design backwards. If the same
      result will appear in a journal article *and* a conference talk, build two
      distinct versions — the conference version usually drops half of what the
      journal version contains.
      
      ### 3. Choose the right graph type and use color intentionally
      
      The graph type should match the structure of the data, not aesthetic
      preference.
      
      | Data structure | Default | Avoid |
      |---|---|---|
      | One continuous variable, one group | Histogram, density | Pie chart |
      | Two continuous variables | Scatter (+ regression line if appropriate) | Bar chart |
      | Continuous over time | Line | Stacked bar |
      | Categorical proportions | Bar (sorted), waffle | 3-D pie |
      | Distribution by group | Box / violin / strip | Bar with SD error |
      | Diagnostic performance | ROC, PR curve | Single accuracy bar |
      | Effect size with CI | Forest plot, dot-and-whisker | Bar with asterisks |
      | Workflow / cohort | Flow diagram (PRISMA / CONSORT / STARD) | Free-form arrows |
      
      Color rules (compatible with this skill's `figure_specs.md`; full
      justification in Crameri 2024):
      
      - **Categorical groups**: Wong palette (8 colorblind-safe colors).
      - **Sequential magnitude**: `viridis` or `cividis` (perceptually uniform,
        colorblind-safe). Avoid `jet` and `rainbow` — they introduce false
        perceptual edges.
      - **Diverging around zero**: `RdBu`, `PuOr`, or `vik` (Crameri).
      - **Encode meaning, not decoration.** If the same conclusion holds in
        grayscale, color is decorative — remove or use neutral grays.
      - **Redundant encoding** when color carries diagnostic information: pair
        color with line style, marker shape, or direct label so the figure
        survives a deuteranopia simulation and a black-and-white print.
      - **Maximum 3 colors per panel** unless the data structure genuinely demands
        more (and then label each directly, not via legend).
      
      ### 4. Reduce cognitive load
      
      Every visual element competes for attention. The reader's working memory is
      roughly 7 items; design under that ceiling.
      
      - ≤7 distinct visual elements per panel (curves, boxes, annotations).
      - ≤3 distinct shapes (e.g., square, circle, triangle).
      - ≤3 colors as above.
      - **Direct labels on series > legend.** Legend lookups cost ~2 seconds each.
      - No 3-D, drop-shadow, gradient fill, or rotated axis labels unless they
        encode data.
      - Sans-serif font ≥ 9 pt at print size; ≥ 18 pt for slides; ≥ 24 pt for
        posters.
      - Whitespace is not wasted space — it groups related elements.
      
      If a panel violates two or more of these, split it into multiple panels or
      move detail to supplementary material.
      
      ### 5. Ask whether a figure is really needed
      
      Sometimes a table, a single sentence, or a caption-only number conveys the
      result more clearly. Use a figure when **at least one** of the following is
      true:
      
      - The reader needs to perceive a *shape* (trend, distribution, threshold).
      - The reader needs to *compare* across many groups simultaneously.
      - The result depends on a *spatial* or *anatomical* relationship.
      - The audience will not read the prose carefully and needs a visual hook.
      
      Otherwise, prefer a small table or in-line text. A 3-row × 2-column results
      table beats a bar chart of two values.
      
      ---
      
      ## Decision: figure vs table
      
      | Use a **figure** when… | Use a **table** when… |
      |---|---|
      | Trend or shape matters | Exact values matter (e.g., baseline characteristics) |
      | ≥4 groups / conditions | ≤3 groups *and* ≤8 metrics |
      | Distribution shape conveys meaning | Categorical labels with counts |
      | Comparison across many dimensions | Reader will reuse the numbers (re-analysis, replication) |
      | Visual-abstract / hero panel | Supplementary detail |
      
      When in doubt, sketch both on paper for 60 seconds and decide which the eye
      finishes first.
      
      ---
      
      ## Cognitive load checklist (Step 4 quick scan)
      
      - [ ] One sentence describes the key message in the script comment.
      - [ ] Reading-time budget matches the deployment context.
      - [ ] ≤7 visual elements per panel.
      - [ ] ≤3 colors carrying meaning (not decoration).
      - [ ] No 3-D / shadow / gradient unless data-driven.
      - [ ] Direct labels on series; legend has ≤4 entries.
      - [ ] Font size meets context minimum (9 pt print / 18 pt slide / 24 pt poster).
      - [ ] Same figure works in grayscale (run a `convert -colorspace Gray` test).
      
      If two or more boxes are unchecked, return to Step 1 before exporting.
      
      ---
      
      ## Anti-patterns (drawn from this skill's critic rubrics)
      
      These compose with the more granular checks in
      `critic_rubrics/data_plot.md` and `critic_rubrics/flow_diagram.md`:
      
      - **Default-palette syndrome** — using the matplotlib `tab10` palette without
        thought. Reads as a quick draft, not a finished figure.
      - **Legend-dependence** — colored series without direct labels, forcing
        back-and-forth between legend and data.
      - **Decorative 3-D** — bars or pies in 3-D with no third data dimension.
      - **Chart-of-three-values** — figure where a sentence would be clearer.
      - **Caption-as-Methods** — caption explains how the data were generated
        rather than what to look at; methodology belongs in the Methods section.
      - **Mismatched detail** — slide-deck figure rendered at 6 pt because it was
        copied from the manuscript without adjustment.
      
      ---
      
      ## Cross-references
      
      - `critic_rubrics/data_plot.md` — quantitative critic checks for non-flow figures
      - `critic_rubrics/flow_diagram.md` — extended checks for flow diagrams
      - `figure_specs.md` — journal-specific dimensions and DPI
      - `flow_diagram_lessons.md` — production lessons specific to PRISMA / CONSORT / STARD
      - `reporting_guideline_figure_map.md` — which figures CONSORT-AI / TRIPOD+AI / CLAIM 2024 / STARD-AI mandate
      - `pipeline_concepts_medical_ai.md` — DICOM workflow, annotation, federated learning, model architecture conventions
      
    • figure_manifest.md 2.2 KB
      # `figures/_figure_manifest.md` — format and field definitions
      
      Load-on-demand companion to `/make-figures`. SKILL.md states that the manifest is
      mandatory and who consumes it; this file is the literal format.
      
      Read it when you are writing the manifest.
      
      After generating all figures, create a structured manifest file at `figures/_figure_manifest.md`:
      
      ```markdown
      # Figure Manifest
      Generated: {YYYY-MM-DD}
      Study type: {study type or "custom"}
      
      | Figure | Path | Type | Tool | Critic | Rounds | Description |
      |--------|------|------|------|--------|--------|-------------|
      | Figure 1 | figures/fig1_stard_flow.svg | flow-diagram | D2 | yes | 2 | STARD participant flow diagram |
      | Figure 2 | figures/fig2_roc.pdf | roc-curve | matplotlib | yes | 1 | ROC curves for Model A vs B |
      | Figure 3 | figures/fig3_calibration.pdf | calibration | matplotlib | partial | 3 | Calibration plot; legend still crowded (see notes) |
      
      ## Critic notes
      - Figure 3: after 3 rounds, legend placement remains crowded at the
        double-column width. Candidate remediations documented but not applied
        to avoid reducing data-point visibility.
      ```
      
      **Manifest field definitions:**
      - **Path**: Relative path from project root
      - **Type**: One of: `flow-diagram`, `roc-curve`, `forest-plot`, `funnel-plot`, `calibration`, `km-curve`, `bland-altman`, `confusion-matrix`, `box-violin`, `bar-chart`, `heatmap`, `pipeline`, `visual-abstract`, `sroc-curve`, `other`
      - **Tool**: Tool used to generate (`matplotlib`, `D2`, `python-pptx`, `seaborn`, etc.)
      - **Critic**: `yes` (all rubric items PASS) / `partial` (some PARTIAL after max rounds) / `no` (never critiqued — avoid for submission figures) / `skip` (deliberately bypassed, e.g., panel figure assembled externally)
      - **Rounds**: Number of Critic Loop rounds executed (0 if skipped)
      - **Description**: One-line description suitable for figure legend context
      
      A `## Critic notes` section at the bottom of the manifest records any
      residual PARTIAL items and the rationale for accepting them.
      
      This manifest is consumed by `/write-paper` Phase 2 (figure embedding) and Phase 7 (DOCX build). It **MUST** exist after figure generation completes. Verify the file is non-empty before finishing.
      
    • figure_specs.md 11.3 KB
      # Figure Specifications Reference
      
      ## Journal Dimension Requirements
      
      | Journal | Single Column | 1.5 Column | Double Column | Full Page | Max Height |
      |---------|--------------|------------|---------------|-----------|------------|
      | AJR | 3.3 in (84 mm) | 5.0 in (127 mm) | 6.83 in (174 mm) | 6.83 x 9.19 in | 9.19 in |
      | Radiology | 3.37 in (85.6 mm) | 5.04 in (128 mm) | 6.83 in (173.5 mm) | 6.83 x 9.19 in | 9.19 in |
      | Radiology: AI | 3.37 in (85.6 mm) | 5.04 in (128 mm) | 6.83 in (173.5 mm) | 6.83 x 9.19 in | 9.19 in |
      | European Radiology | 3.35 in (85 mm) | -- | 6.85 in (174 mm) | 6.85 x 9.13 in | 9.13 in |
      | KJR | 3.27 in (83 mm) | -- | 6.89 in (175 mm) | 6.89 x 9.21 in | 9.21 in |
      | **Default** | **3.5 in (88 mm)** | **5.0 in (127 mm)** | **7.0 in (178 mm)** | **7.0 x 9.5 in** | **9.5 in** |
      
      ### Notes
      
      - Dimensions are the maximum printable area; figures should not exceed these.
      - When in doubt, use single-column width (3.5 in) for simple plots and double-column (7.0 in) for complex or multi-panel figures.
      - Height is flexible but should not exceed the max height listed above.
      
      ---
      
      ## DPI Requirements
      
      | Content Type | Minimum DPI | Recommended DPI | Notes |
      |-------------|-------------|-----------------|-------|
      | Line art (diagrams, flow charts) | 600 | 1200 | Vector PDF preferred |
      | Halftone (photographs, medical images) | 300 | 300 | TIFF or PNG |
      | Combination (line art + halftone) | 600 | 600 | |
      | Color figures | 300 | 300 | |
      | Review/presentation | 150 | 300 | PNG acceptable |
      
      ---
      
      ## File Format Requirements by Journal
      
      | Journal | Preferred Format | Accepted Formats | Notes |
      |---------|-----------------|------------------|-------|
      | AJR | TIFF | TIFF, EPS, PDF, JPEG | TIFF LZW compression; JPEG only for photos |
      | Radiology | TIFF | TIFF, EPS, PDF, PNG | EPS for vector; TIFF for raster |
      | Radiology: AI | PDF | PDF, TIFF, EPS, PNG | PDF preferred for vector graphics |
      | European Radiology | TIFF | TIFF, EPS, PDF | EPS or PDF for line art |
      | KJR | TIFF | TIFF, JPEG, PPT | PPT accepted but not recommended |
      | **Default** | **PDF + PNG** | PDF (vector), PNG (raster) | Always produce both |
      
      ---
      
      ## Color Palettes
      
      ### Wong Colorblind-Safe Palette (Default)
      
      | Index | Name | Hex | Use |
      |-------|------|-----|-----|
      | 0 | Black | `#000000` | Text, axes, reference lines |
      | 1 | Orange | `#E69F00` | Primary category 1 |
      | 2 | Sky Blue | `#56B4E9` | Primary category 2 |
      | 3 | Bluish Green | `#009E73` | Primary category 3 |
      | 4 | Yellow | `#F0E442` | Highlight (use sparingly) |
      | 5 | Blue | `#0072B2` | Primary model/group |
      | 6 | Vermillion | `#D55E00` | Secondary model/group |
      | 7 | Reddish Purple | `#CC79A7` | Tertiary model/group |
      
      ```python
      WONG = ['#000000', '#E69F00', '#56B4E9', '#009E73',
              '#F0E442', '#0072B2', '#D55E00', '#CC79A7']
      ```
      
      ### Sequential Palettes (Heatmaps)
      
      | Use Case | Matplotlib Colormap | Direction |
      |----------|-------------------|-----------|
      | Positive values only | `Blues`, `Greens`, `Oranges` | Light to dark |
      | Diverging (centered at 0) | `coolwarm`, `RdBu_r` | Negative=blue, positive=red |
      | Correlation matrix | `coolwarm` | -1 to +1 centered |
      | Agreement matrix | `YlOrRd` | Low=yellow, high=red |
      | Confusion matrix | `Blues` | Low=white, high=blue |
      | p-value heatmap | `Reds_r` | Significant=dark |
      
      ### Rules
      
      - Never rely on color alone; combine with line style, marker shape, or hatching.
      - Test figures in grayscale to ensure readability.
      - Avoid pure red (#FF0000) and pure green (#00FF00) adjacent to each other.
      
      ---
      
      ## Font Size Table
      
      | Element | Size (pt) | Weight | Font Family |
      |---------|-----------|--------|-------------|
      | Figure title (rare, prefer caption) | 10 | Bold | Arial / Helvetica |
      | Axis label | 9 | Regular | Arial / Helvetica |
      | Tick label | 8 | Regular | Arial / Helvetica |
      | Legend text | 8 | Regular | Arial / Helvetica |
      | Annotation text | 8 | Regular | Arial / Helvetica |
      | Panel label (A, B, C) | 12 | Bold | Arial / Helvetica |
      | Inset text | 7 | Regular | Arial / Helvetica |
      | Table text (in figure) | 7-8 | Regular | Arial / Helvetica |
      
      ### Rules
      
      - Minimum readable size after print: 6 pt.
      - All text in figures must be embedded (not rasterized) for vector formats.
      - Use `plt.rcParams['font.family'] = 'sans-serif'` and `plt.rcParams['font.sans-serif'] = ['Arial', 'Helvetica']`.
      
      ---
      
      ## Common Figure Layouts
      
      ### Single Panel
      
      ```
      +-----------+
      |           |
      |   Plot    |
      |           |
      +-----------+
      ```
      - Width: single column (3.5 in) or double column (7.0 in)
      - Height: proportional, typically 1:1 or 4:3 aspect ratio
      - Use: ROC curve, calibration plot, KM curve, Bland-Altman
      
      ### 2-Panel Horizontal
      
      ```
      +-----+-----+
      |  A  |  B  |
      +-----+-----+
      ```
      - Width: double column (7.0 in), each panel ~3.3 in
      - Height: 3.0-3.5 in
      - Use: comparing two related plots (e.g., ROC for two endpoints)
      
      ### 2-Panel Vertical
      
      ```
      +-----+
      |  A  |
      +-----+
      |  B  |
      +-----+
      ```
      - Width: single column (3.5 in)
      - Height: 6.0-7.0 in
      - Use: same variable, different conditions (e.g., training vs test set)
      
      ### 2x2 Grid
      
      ```
      +-----+-----+
      |  A  |  B  |
      +-----+-----+
      |  C  |  D  |
      +-----+-----+
      ```
      - Width: double column (7.0 in), each panel ~3.3 in
      - Height: 6.0-7.0 in
      - Use: four related analyses (e.g., subgroup comparisons)
      
      ### 3-Panel Horizontal
      
      ```
      +---+---+---+
      | A | B | C |
      +---+---+---+
      ```
      - Width: double column (7.0 in), each panel ~2.1 in
      - Height: 2.5-3.0 in
      - Use: three conditions or progressive stages
      
      ### Unequal Panels (GridSpec)
      
      ```
      +---------+----+
      |         | B  |
      |    A    +----+
      |         | C  |
      +---------+----+
      ```
      - Use `matplotlib.gridspec.GridSpec` for custom layouts
      - Use: main result (A) with supporting details (B, C)
      
      ---
      
      ## Caption Writing Guidelines
      
      ### Structure
      
      1. **Sentence 1**: What the figure shows -- figure type and key finding.
      2. **Sentences 2-3**: Define symbols, abbreviations, colors, and line styles.
      3. **Sentence 3-4**: State sample sizes, statistical tests, significance levels.
      4. **Final sentence**: Interpretation aid (if not self-evident from the plot).
      
      ### Format
      
      ```
      Figure {N}. {Description starting with capital letter, ending with period.}
      ```
      
      - No bold title. No title case (except proper nouns and abbreviations).
      - Use sentence case throughout.
      - Spell out abbreviations on first use in the caption.
      
      ### Multi-Panel Captions
      
      ```
      Figure {N}. {Overall description.} (A) {Panel A description.} (B) {Panel B description.}
      ```
      
      ### Examples
      
      **ROC Curve:**
      > Figure 2. Receiver operating characteristic curves for the multi-agent validation pipeline. The area under the curve was 0.92 (95% CI: 0.89-0.95) for the full pipeline and 0.84 (95% CI: 0.80-0.88) for the single-agent baseline (DeLong test, p = 0.003). The dashed diagonal line represents chance performance (AUC = 0.5). AUC = area under the curve, CI = confidence interval.
      
      **Flow Diagram:**
      > Figure 1. Study flow diagram following the Standards for Reporting Diagnostic Accuracy Studies (STARD) 2015 guidelines. Numbers in each box represent the count of Anki flashcards at each stage of the validation pipeline.
      
      **Multi-Panel:**
      > Figure 3. Agreement analysis between human reviewers and the multi-agent pipeline. (A) Bland-Altman plot showing the difference in quality scores between reviewer 1 and the pipeline against their mean. Horizontal dashed lines indicate the mean difference and 95% limits of agreement. (B) Scatter plot of reviewer 1 versus pipeline scores with the line of identity (dashed).
      
      ---
      
      ## Figure Numbering Conventions
      
      - Number figures sequentially as they appear in the text (Figure 1, Figure 2, ...).
      - Supplemental figures: Figure S1, Figure S2, ...
      - Reference every figure at least once in the main text.
      - Place figures after first mention (or at end, per journal preference).
      - Do not include a figure title inside the plot area; use the caption below instead.
      
      ---
      
      ## Flow Diagram Tool Selection (STROBE / CONSORT / PRISMA / STARD)
      
      All reporting-guideline flow diagrams use a single canonical pipeline: `scripts/generate_flow_diagram.R` (DiagrammeR DOT → DiagrammeRsvg → rsvg).
      
      ### Why this stack
      
      | Requirement | Requirement detail | DiagrammeR + rsvg |
      |---|---|---|
      | Vector PDF (editable, journal-grade) | Radiology/NEJM/Eur Radiol require EPS/AI/PDF | **True vector via `rsvg_pdf()`** |
      | 300 / 600 / 1200 dpi PNG | RSNA line-art = 1200 dpi; Eur Radiol = 300–1000 | **Arbitrary DPI via `rsvg_png(width=...)`** |
      | Arial font embedded | AMA/RSNA style | `fontname="Arial"` enforced in DOT header |
      | Single-color monochrome outline | BMJ/Annals IM convention | `color=black, fillcolor=white, style=filled` |
      | Auto-overlap resolution | Labels change size; manual coords fail | Graphviz `dot` hierarchical engine |
      | 4 reporting guidelines in one tool | Avoid stack sprawl | Generic DOT template switch |
      
      ### Why not the obvious alternatives
      
      | Rejected tool | Reason |
      |---|---|
      | matplotlib `FancyBboxPatch` (manual coords) | Overlap on label change; DOCX embed distortion. Root cause of an STROBE Figure 1 rework. |
      | D2 + post-processing | Weak Arial enforcement; PNG needs 85% vertical compression hack; font-size must be manually set 20–24. Retained as legacy fallback only. |
      | R `consort` v1.2.2 | CONSORT/STROBE only; STARD/PRISMA not covered; box style parameters not officially exposed (requires gpar override). |
      | R `PRISMA2020` v1.1.1 | `PRISMA_save()` uses webshot → PDF rasterized; no DPI parameter. Not suitable for journal submission. |
      | Mermaid / PlantUML | Font control weak; hard to enforce Arial + monochrome outline. |
      
      ### PRISMA 2020 compliance
      
      The generic DOT template in `generate_flow_diagram.R` implements the PRISMA 2020 structure (two identification streams, duplicates-removed box, title/abstract screening, full-text retrieval, full-text assessment, final inclusion) and can reproduce the official template shape. When a journal explicitly requires use of the PRISMA2020 R package or Shiny app for provenance, run that tool separately; the DiagrammeR pipeline is the default for all other submissions.
      
      ### File outputs
      
      Every render emits three files at the same prefix:
      
      ```
      <prefix>.pdf        true vector (journal submission, figure_manifest primary)
      <prefix>.png        300 dpi (2400 px wide; review copy, DOCX embed)
      <prefix>_600.png    600 dpi (4800 px wide; RSNA/Eur Radiol line-art)
      ```
      
      ### System dependency
      
      `brew install librsvg` (macOS; one-time). On Linux: `apt-get install librsvg2-bin`.
      
      ## Central Illustration Dimensions
      
      Used by the JACC family and all journals that distinguish a Central Illustration from a Visual Abstract (Fuster V, Mann DL. *JACC.* 2019;74(22):2816–2820). See `references/jacc_central_illustration_principles.md`.
      
      | Spec | Value |
      |---|---|
      | PPTX slide size | 10 × 7.5 in (4:3 standard) |
      | Content figure area | ~4 × 4.2 in (top-center of slide) |
      | Citation textbox | 9.4 × 0.5 in at (0.4, 5.3) |
      | Content figure resolution | ≥ 600 DPI PNG; PDF vector preferred |
      | Font | Sans-serif (Arial or equivalent), ≥ 9 pt at print scale |
      | Color | Color allowed; figure should remain interpretable in grayscale |
      | Visual zones in content figure | 1–3 (enforced by `--type central-illustration`) |
      | Total label word count | ≤ 30 |
      | Numerical highlights | ≤ 4 |
      
      **Do not pre-render**: the red outer border and the blue "CENTRAL ILLUSTRATION:" header bar visible in published JACC issues are applied by JACC editorial after acceptance. Authors submit only the content figure and citation footer.
      
    • flow_diagram_lessons.md 10 KB
      # Flow Diagram Production — Hard-Earned Lessons
      
      > **Triggered from**: SKILL.md Step 4b (Critic Loop). Read this file when
      > generating PRISMA / CONSORT / STARD / STROBE diagrams, especially before a
      > circulation round with senior co-authors.
      >
      > **Source**: Distilled from a multi-revision meta-analysis project where
      > Figure 1 (PRISMA flow) went through four major iterations before clearing
      > a senior reviewer round. Each lesson maps to a concrete failure mode.
      
      Flow diagrams look simple but consume disproportionate revision time. The
      underlying causes are rarely "wrong numbers" — they are template fidelity,
      PDF export fidelity, and version drift between the diagram and the
      manuscript. The five lessons below address each in turn.
      
      ---
      
      ## Lesson 1: Use the official template once a senior reviewer is in the loop
      
      **Failure mode**: Custom Graphviz / DiagrammeR layout offers more freedom
      (left-aligned bullets via `\l`, `penwidth=1.8` highlights on the analytic
      cohort, free positioning of side-boxes). Senior systematic-review reviewers,
      however, expect the **PRISMA 2020 standard layout** as published by the
      PRISMA Statement group. A custom layout reads as "the authors did not follow
      the guideline," even when every count is correct.
      
      **Resolution**:
      
      - Prototype freely with Graphviz / DiagrammeR for early drafts.
      - **Switch to an official template before circulation.** The R package
        `PRISMA2020` (Haddaway et al.) renders the canonical layout from a CSV.
        Equivalent canonical templates exist for CONSORT 2010, STARD 2015, and
        STROBE.
      - If the official template lacks a feature you need (e.g., a third
        exclusion sub-branch), prefer to fold the detail into the existing slots
        rather than restructure.
      
      **Cross-link**: `critic_rubrics/flow_diagram.md` — "Official template
      fidelity" check.
      
      ---
      
      ## Lesson 2: PDF export — the VML fallback breaks under headless converters
      
      **Failure mode**: The official PRISMA 2020 docx contains VML (Vector Markup
      Language) fallback pairs that older Word versions render. When converted to
      PDF via headless LibreOffice (`soffice --headless --convert-to pdf`), the
      phase labels can shift by several pixels, columns misalign, and on some
      systems text reflows out of its box. The PDF looks "almost right" — the
      kind of bug that survives a quick visual scan.
      
      **Resolution by platform**:
      
      | Platform | Approach | Notes |
      |---|---|---|
      | **macOS** | AppleScript driving Microsoft Word for Mac | Deterministic native render; can be invoked headlessly via `osascript`. |
      | **Windows** | PowerShell / VBScript COM driving Word | `Word.Application` object → `.SaveAs2` with `wdFormatPDF`. |
      | **Linux / CI** | Office Online Server (paid), or commit a pre-rendered PDF produced on macOS / Windows | Headless LibreOffice is **not** safe for VML-heavy templates. |
      | Any platform (fallback) | Open in desktop Word → "Save As PDF" manually | Slow but always correct; document the manual step in the manifest. |
      
      **Verify visually**: After export, open the PDF and the source docx
      side-by-side. Compare phase-label positions, column widths, and the Y
      position of every numeric box. If anything has shifted, do not submit that
      PDF.
      
      ---
      
      ## Lesson 3: docx XML editing requires entity escape
      
      **Failure mode**: A docx file is a ZIP archive containing
      `word/document.xml`. Programmatic placeholder substitution (e.g., replacing
      `{{n_screened}}` with `1,234` plus a comment, or `{{study_title}}` with a
      title containing `&`) by raw `str.replace` produces invalid XML. Word then
      shows "We're sorry, we can't open the file because there's a problem with
      the contents." dialog on next open, or — worse — opens with a "repair"
      prompt that silently drops content.
      
      **Resolution**:
      
      - Use an entity-escaping helper before injecting any user-provided string.
        Python: `xml.sax.saxutils.escape(value)`. JavaScript: `he.encode` or a
        small regex (`& → &amp;`, `< → &lt;`, `> → &gt;`).
      - High-risk fields:
        - Study titles containing `&` (`Smith & Jones, 2020`).
        - Range strings containing `<` or `>` (`age <5 years`, `loss >50%`).
        - Author lists with apostrophes (`O'Brien`).
      - After substitution, validate with `python -c "import zipfile,
        xml.etree.ElementTree as E;
        E.fromstring(zipfile.ZipFile('out.docx').read('word/document.xml'))"`.
        An exception means the docx is broken.
      
      ---
      
      ## Lesson 4: VML fallback templates need sequential placeholder maps
      
      **Failure mode**: The official PRISMA 2020 docx duplicates each numeric box
      as a `<w:t>` pair: a primary text element plus a VML fallback. Branch
      ordering in XML DOM does **not** match rendering order — the "Other"
      identification branch and the "Database" branch are interleaved in the
      document, and the VML fallbacks appear elsewhere again. A naive
      "replace each placeholder with the next number" loop misaligns most boxes.
      
      **Resolution**:
      
      1. Dump every `<w:t>` in document order with a script
         (`python -c "from docx import Document; ..."` or direct XML walk).
      2. Annotate each `<w:t>` with column / branch / role
         (`identification.databases.records_n`, `identification.databases.records_n.fallback`).
      3. Build a static `VALUES` list with one entry per `<w:t>` slot, **with a
         comment per slot** documenting what it represents.
      4. Validate by filling all slots with a sentinel value (e.g., `999`) and
         visually checking that all 60+ boxes show `999` in the rendered PDF
         before re-running with real data.
      
      This sentinel-render is cheap (one extra `999` pass) and catches mapping
      errors before they propagate into a circulation round.
      
      ---
      
      ## Lesson 5: Freeze figure versions alongside manuscript versions
      
      **Failure mode**: A reviewer asks about Figure 1 in revision round 2, but
      the figure file in `manuscript/figures/figure_1.pdf` was edited in place
      between rounds. The version visible in v3 of the manuscript no longer
      exists, and tracing what changed requires diffing PDFs (lossy and slow).
      
      **Resolution**:
      
      - Treat figures as part of the manuscript artifact — they freeze with each
        manuscript version under the **`v_N` rule** (manuscript-versioning
        protocol).
      - Maintain `figures/v{N}/` directories. Each contains the source script
        (`generate_figure_1.R`), the input data CSV, and the rendered docx + PDF
        pair.
      - An `INDEX.md` at `figures/INDEX.md` maps figure version ↔ manuscript
        version, e.g.:
        ```
        | Manuscript | Figure 1 | Figure 2 | Figure 3 |
        |---|---|---|---|
        | v3 (circulated 2025-09-01) | figures/v3/figure_1.pdf | figures/v3/figure_2.pdf | figures/v2/figure_3.pdf |
        | v4 (submitted 2025-10-15)  | figures/v4/figure_1.pdf | figures/v3/figure_2.pdf | figures/v3/figure_3.pdf |
        ```
      - **Never edit `figures/v3/*.pdf` after circulation.** Branch to
        `figures/v4/` and rebuild.
      
      ---
      
      ## Lesson 6: Any flowchart-shaped figure uses the monochrome Graphviz convention — not colorful boxes-and-arrows
      
      **Failure mode**: A study-design / reader-flow / pipeline schematic (i.e. NOT a
      reporting-guideline flow) gets hand-built in matplotlib (or slides) with filled
      color boxes, accent palettes, and manually positioned text. It reads as
      "AI-generated", text is poorly aligned inside the boxes (matplotlib anchors text
      by point, not to the box geometry, so multi-line bodies overflow the border), and
      it does not match the journal house style of the group's accepted papers.
      
      **Resolution** — treat *every* flowchart-shaped figure (CONSORT/PRISMA/STARD/STROBE
      **and** study-design, reader-flow, cohort-assembly, MRMC/reader-study, generation-
      pipeline schematics) as the same object and render it with `scripts/generate_flow_diagram.R`
      (DiagrammeR → Graphviz `dot`). The house convention, already encoded in that
      script's `STYLE_HEADER`, is the journal standard:
      
      - `fillcolor=white, color=black` (white fill, **black outline only — no color**)
      - `fontname="Arial"`, `fontsize` 10–11
      - `shape=box, style="rounded,filled"`, `penwidth=1.2` (emphasis boxes `penwidth=1.8`)
      - `splines=ortho` (right-angle edges), `nodesep`/`ranksep` ≈ 0.4–0.55
      - left-aligned sub-lists with `\l` and `•` (`•`); side panels / exclusions via
        `{ rank=same; main; side }` and an invisible edge to anchor them
      - Graphviz lays text out *relative to the node box*, so alignment and centering are
        automatic — this is why it beats hand-positioned matplotlib/slide boxes.
      
      Reconcile every count to the manuscript with `stopifnot()` assertions in the script
      header (e.g. `stopifnot(N_auth + N_v16 + N_v12 == N_pool)`); leave data-dependent
      counts as a single find-replaceable token (e.g. `[N_COMPLETED]`) filled at data lock.
      
      **Do not** reach for matplotlib/PowerPoint/AI-image tools for a box-and-arrow figure.
      matplotlib is for *data* figures (ROC, forest, calibration, KM); Graphviz is for
      *flow/structure* figures. Mixing them produces the colorful, misaligned look this
      lesson exists to prevent.
      
      > Motivation: a reader-study Figure 1 was first built in colorful matplotlib and
      > rejected by the author as "too AI-looking, text not aligned in the boxes." Rebuilt
      > with `generate_flow_diagram.R` in the monochrome house style — matching the same
      > style used across the group's accepted meta-analysis, cohort, and reader-study flow
      > figures — it passed immediately.
      
      ---
      
      ## When to use which approach
      
      | Stage | Tool | Output | Risk if you skip |
      |---|---|---|---|
      | Early draft | DiagrammeR + Graphviz custom | PNG, fast iteration | None — flexibility wins here |
      | Internal QC | R `PRISMA2020` package or equivalent | Official-template PNG / PDF | Custom layout will be flagged in circulation |
      | Circulation | Filled official-template docx + native-Word PDF (macOS AppleScript / Windows COM) | Frozen docx + PDF pair | Headless export drift; reviewer notices misaligned labels |
      | Submission | `figures/v_N/` frozen pair | Immutable docx + PDF | Edit-in-place breaks the manuscript-figure version map |
      
      ---
      
      ## Quick cross-references
      
      - `critic_rubrics/flow_diagram.md` — extended critic checklist (rounds T=1…3)
      - `design_principles.md` — communication-first design strategies
      - `figure_specs.md` — journal-specific dimensions and DPI
      - `exemplar_diagrams/{prisma,consort,stard,strobe}/` — reference layouts
      
    • flow_diagram_recipe.md 5 KB
      # Flow diagrams (STARD / CONSORT / PRISMA / STROBE) — the R recipe
      
      Load-on-demand companion to `/make-figures`. SKILL.md states the mandatory rule (the
      standardized R pipeline, never matplotlib `FancyBboxPatch`, never D2 for new diagrams);
      this file is the recipe: the YAML schema, the fixed style, the per-project
      `create_figure1.R` pattern, and the legacy D2 fallback.
      
      Read it when you are actually generating a reporting-guideline flow diagram. A figure set
      with no flow diagram (a ROC curve, a forest plot, a calibration plot) needs none of it.
      
      **Flow diagram generation rule:** STARD/CONSORT/PRISMA/STROBE flow diagrams **MUST** use the standardized R pipeline `scripts/generate_flow_diagram.R` (DiagrammeR + Graphviz dot + rsvg). This is the single canonical tool for all four reporting-guideline flow diagrams. Do NOT use matplotlib `FancyBboxPatch` (manual coordinates break when text changes, and patches distort when embedded in DOCX). Do NOT use D2 for new flow diagrams (font control is weak, overlap requires manual post-processing). The legacy D2 recipe remains documented below as a fallback only when R is unavailable.
      
      **R flow diagram recipe (mandatory for all flow diagrams):**
      
      The pipeline reads a YAML config describing nodes/edges and produces: a true vector PDF (journal submission), a 300 dpi PNG (review copy), and a 600 dpi PNG (RSNA/Eur Radiol line-art). Default style is single-color black outline with white fill in Arial, overriding D2's colored defaults and matplotlib's manual coordinates.
      
      ```bash
      # 1. One-time system dependency:
      brew install librsvg
      Rscript -e 'install.packages(c("DiagrammeR","DiagrammeRsvg","rsvg","yaml"))'
      
      # 2. Author a YAML config. Templates for each type live at
      #    references/exemplar_diagrams/{strobe,consort,prisma,stard}/template_input.yaml
      # 3. Render:
      Rscript ${CLAUDE_SKILL_DIR}/scripts/generate_flow_diagram.R \
          --type   {strobe|consort|prisma|stard} \
          --config path/to/counts.yaml \
          --out    figures/figure1_flow
      # Outputs: figure1_flow.pdf, figure1_flow.png (300 dpi), figure1_flow_600.png
      ```
      
      **YAML schema highlights:**
      - `rankdir: TB` (top-down, default) or `LR` (left-to-right).
      - `nodes:` list with `id`, `label` (use literal `\n` for line breaks, real Unicode `–`, `≤`, `−`, `•`).
      - Optional per-node: `highlight: true` (thicker border), `shape: note` (side boxes), `rank_same_with: <other_id>` (place on same horizontal rank).
      - `edges:` list with `from`, `to`, optional `style: dashed`, `arrow: false` (no arrowhead), `constraint: false` (edge ignored by layout engine — use for exclusion side-links).
      - Numbers in labels **MUST** be CSV-derived in an upstream R script that emits the YAML, or hand-written only when the value lives in a commit-tracked data artifact. Follow numerical-safety rules.
      
      **Style is fixed (do not override in the YAML):**
      - Monochrome: all boxes `color=black, fillcolor=white, fontname="Arial"`.
      - Penwidth 1.2 default, 1.8 for highlighted cohort box.
      - Arrow style: black solid, arrowsize 0.75. Dashed without arrowhead for exclusion side-links.
      - Bullet alignment in multi-item labels: Graphviz `\l` (left-align), never `\n` (center). Each `\l` applies to text preceding it.
      - **No HTML-like labels** (`label=<...>` with `<B>`, `<I>`, `&#8226;`). Plain quoted labels with `\l` bullets produce tighter, more readable structure than HTML ragged wrapping. Do not reintroduce without explicit approval.
      - To add one emphasis color (e.g., Wong blue `#0072B2` for a single highlighted box), edit `scripts/generate_flow_diagram.R` — do not inline hex colors in YAML.
      
      **Per-project `create_figure1.R` pattern (preferred for complex flows):**
      
      When the flow has derived counts, `stopifnot()` reconciliation, multi-rank `{rank=same; ... }` constraints, or exclusion side-cars that the generic YAML dispatcher cannot express cleanly, write a per-project `create_figure1.R` directly (same DiagrammeR + DiagrammeRsvg + rsvg stack, sprintf'd `dot` string). This is the dominant pattern when the generic YAML dispatcher cannot capture the flow:
      
      - STROBE cohort: `<project>/manuscript/figures/create_figure1.R`
      - STARD: `<project>/Analysis/figures/create_figure1.R` or `<project>/figures/v2_monochrome/create_figure1.R`
      - PRISMA / PRISMA-DTA: `<project>/5_Figures/create_figure1.R` or `<project>/analysis/create_figure1.R`
      - CONSORT-edu (naturalistic allocation): `<project>/figures/v2_monochrome/create_figure1.R`
      
      Copy the `STYLE_HEADER` (graph/node/edge attrs) verbatim from any exemplar; then customise nodes, edges, and `{rank=same}` blocks. Use `read.csv()` for cohort counts when possible; if hardcoded, every number must have a source comment referencing manuscript line / CSV cell / screening log row.
      
      **Legacy D2 fallback (only when R unavailable):**
      
      ```bash
      d2 --layout elk --theme 0 --pad 20 flow.d2 /tmp/raw.png --scale 2
      # Resize + 85% vertical compression via Pillow; then render PDF:
      d2 --layout elk --theme 0 --pad 20 flow.d2 figures/fig1_flow.pdf
      ```
      
      Use `font-size: 20-24`, `stroke: black`, `fill: white`. D2 PDF is vector; D2 PNG needs the resize step to match publication density.
      
      ---
      
    • jacc_central_illustration_principles.md 5.8 KB
      # JACC Central Illustration — Principles & Layout Spec
      
      Distilled from Fuster V, Mann DL. **The Art and Challenge of Crafting a Central Illustration or Visual Abstract.** *J Am Coll Cardiol.* 2019;74(22):2816–2820. doi:10.1016/j.jacc.2019.10.035
      
      This document is the canonical reference when generating Central Illustrations for JACC, JACC: Asia, JACC: Cardiovascular Imaging, JACC: Heart Failure, JACC: Basic to Translational Science, JACC: CardioOncology, and JACC: Clinical Electrophysiology.
      
      ## CI vs Visual Abstract — they are different artifacts
      
      | Aspect | Central Illustration | Visual Abstract |
      |---|---|---|
      | Purpose | Single key finding / take-home message | Methods + Results pictorial summary |
      | Where referenced in paper | End of Results / start of Discussion | Beginning of paper |
      | Methods content | **None** | Required |
      | Audience | Cardiovascular clinicians + readers of the journal issue | Broad including non-specialists / social media |
      | Used by | All JACC family journals + JACC: Asia | Originally JACC: Basic to Translational Science |
      | Text density | Minimal (graphical priority) | More allowed (methods labels) |
      | Bar graphs | OK if they capture entire message | Avoid — use ↑↓ arrows |
      | Default complexity | Simple (1–3 visual zones) | Simple (Q→M→R three blocks) |
      
      If a paper requires *both* a central illustration and a visual abstract (some JACC sister journals), they must convey complementary content; do not duplicate.
      
      ## Five Fuster-Mann rules (must pass all)
      
      1. **Know the message.** Decide one finding the figure must convey. Do not stuff in the study design + multiple findings + take-homes — that "causes confusion and excess that does not inform the reader."
      2. **Convey graphically, not textually.** Even a simple Kaplan–Meier curve is acceptable if it captures the entire message. Bar graphs are tolerated if they earn their space.
      3. **Avoid using too much text.** Any text that can be replaced by an icon or arrow should be.
      4. **Avoid secondary messages.** If a viewer cannot state the main finding within 5 seconds of looking at the figure, the figure has too much.
      5. **Simplicity is superior.** Authors "truly struggle with the concept" early; default to fewer panels rather than more.
      
      ## CI mode validation rules (enforced by `generate_visual_abstract.py --type central-illustration`)
      
      A submission rejects with a warning if any of the following holds:
      
      | Rule | Threshold | Rationale (Fuster-Mann) |
      |---|---|---|
      | `n_visual_zones` | ≤ 3 | "Simplicity is superior" |
      | `total_label_word_count` | ≤ 30 | "Avoid using too much text" |
      | `methods_terms_present` | none of: `cohort flow`, `inclusion`, `exclusion`, `study design`, `enrollment`, `randomized`, `sample size` | CI ≠ VA; methods belong in VA |
      | `numerical_data_points` | ≤ 4 | "Avoid incorporating secondary messages" |
      
      Override a single rule with `--allow rule=name` and a justification note recorded in the output PPTX speaker notes.
      
      ## JACC PPTX layout (verified from official submission templates)
      
      Reference: 4 official JACC PPTX submission files (Figures 1–4 of Fuster-Mann editorial, doi:10.1016/j.jacc.2019.10.035). All four share an identical layout:
      
      | Slot | Type | Position (left, top) inches | Size (W × H) inches | Content |
      |---|---|---|---|---|
      | 1 | TEXT_BOX | (0.4, 5.3) | 9.4 × 0.5 | Citation: `"FirstAuthor et al. JACC YYYY; vol(issue):pages."` |
      | 2 | PICTURE | (3.0, 0.8) | 4.0 × 4.2 | **Author-provided content figure** (the only thing the author owns) |
      | 3 | TEXT_BOX | (0.4, 7.0) | 4.1 × 0.5 | Reserved (often empty in production templates) |
      | 4 | PICTURE | (7.3, 6.7) | 2.7 × 0.8 | JACC family logo (placeholder; supplied by editorial) |
      
      Slide size: **10 × 7.5 inches (4:3)**. Background white.
      
      The red border + the blue "CENTRAL ILLUSTRATION:" header bar visible in published JACC issues are **applied by the JACC editorial team after acceptance** — authors should not pre-render those elements in their submission.
      
      Authors submit:
      - Slot 1 (citation text)
      - Slot 2 (the content figure as PNG/TIFF, ≥600 DPI, content area ~4 × 4.2 in at print)
      
      Slots 3 and 4 stay as placeholders.
      
      ## Author-provided content figure spec
      
      | Spec | Value |
      |---|---|
      | Aspect ratio | ~1:1 to slightly portrait (4 × 4.2 in) |
      | Print resolution | 600 DPI for PNG, vector preferred (PDF) |
      | Font | Sans-serif; minimum 9 pt at print scale |
      | Color | Allowed; high-contrast for grayscale fallback |
      | Visual zones | 1–3 (rule above) |
      | Total label words | ≤ 30 |
      | Numerical highlights | ≤ 4 |
      | Photographs | Allowed (Fuster-Mann Figure 4 example) |
      
      Reference good examples cited in the editorial:
      - *Moccetti F, et al. J Am Coll Cardiol. 2018;72(9):1015–26.* — heart anatomy + 3 concept boxes + vessel cross-section + brain image. Total ~20 words. (Fuster-Mann Figure 1)
      - *Brugada J, et al. J Am Coll Cardiol. 2018;72(9):1046–59.* — 3-column "Diagnosis / Pathophysiology / Management" grid with ECG, sequencing, defibrillator imagery. (Fuster-Mann Figure 2)
      
      ## Common author mistakes (rejected examples)
      
      - Embedding the cohort flow / CONSORT-style diagram → that is a study Figure 1 or a Visual Abstract, never a Central Illustration.
      - Side-by-side forest plot + KM curve + heat map → too many secondary messages.
      - Text-heavy "Clinical takeaway" tile boxes summarizing multiple bullets → reduce to one icon-anchored sentence or move to Discussion narrative.
      - Multiple HR/CI/p-value annotations across rows → keep ≤ 4 numerical highlights total.
      - Methodology labels ("Inclusion criteria", "N=...") → CI must not look like Methods.
      
      ## Adding a non-JACC cardiology CI template
      
      Other journals using a CI-style figure (Circulation family, EHJ, JAHA) tend to follow the same simplicity rules. For these, reuse the Fuster-Mann 5 rules above and the validation thresholds, but adjust the PPTX template to the journal's published submission size and citation footer pattern.
      
    • medical_illustration_sources.md 4 KB
      # Medical Illustration Sources
      
      Reference for finding medical illustrations for visual abstracts, graphical abstracts, and
      manuscript figures. Consult this file when a standalone illustration (not a data plot) is needed.
      
      ## Priority Order
      
      1. **Study's own figures** — ROC curve, flow diagram, representative images from the study.
         Always preferred. No licensing issues. Most relevant to the paper.
      2. **Free illustration libraries** — Download and insert into the visual abstract template.
      3. **Manual drawing** — Simple shapes in Figma, PowerPoint, or Keynote.
      4. **AI generation** — Only if GEMINI_API_KEY is available. Use `generate_image.py --style medical`.
      
      ---
      
      ## Free Resources (CC BY or Public Domain)
      
      ### Servier Medical Art (SMART)
      
      - **URL:** https://smart.servier.com/
      - **License:** CC BY 4.0 (attribution required)
      - **Assets:** 3000+ medical illustrations
      - **Categories:** Anatomy (11 systems), cellular biology, medical specialties, equipment
      - **Format:** PowerPoint (editable vectors), PNG
      - **Best for:** Organ systems, cells, molecules, medical devices, surgical instruments
      - **Attribution:** "Illustrations adapted from Servier Medical Art (https://smart.servier.com/),
        licensed under CC BY 4.0"
      - **Access:** Browse categories on website. No API. Download slide sets per category.
      
      ### NIAID Visual & Medical Arts (BioArt)
      
      - **URL:** https://bioart.niaid.nih.gov/
      - **License:** Public domain (US Government work — no attribution legally required, but recommended)
      - **Assets:** 2000+ illustrations
      - **Categories:** Viruses, bacteria, parasites, anatomy, cells, lab equipment, animals
      - **Format:** SVG, PNG, TIFF (high resolution)
      - **Best for:** Infectious disease, pathogens, immune cells, laboratory scenes
      - **Access:** Browse/search on website. No API.
      
      ### BioIcons
      
      - **URL:** https://bioicons.com/
      - **License:** Various CC licenses (check per icon)
      - **Format:** SVG
      - **Best for:** Schematic diagrams, pathway illustrations, icons for flow diagrams
      
      ### Reactome Icon Library
      
      - **URL:** https://reactome.org/icon-lib
      - **License:** CC BY 4.0
      - **Format:** SVG, PNG
      - **Best for:** Molecular pathways, biochemistry, cell signaling
      
      ---
      
      ## Paid Resources
      
      ### BioRender
      
      - **URL:** https://biorender.com/
      - **License:** Subscription (academic plans available, ~$99/year student)
      - **Best for:** Professional graphical abstracts, pathway diagrams, figure panels
      - **Note:** Industry standard. Output must include BioRender watermark on free tier.
        Academic publications require paid license for copyright clearance.
      
      ### Medi-Sketch
      
      - **URL:** https://www.medi-sketch.com/
      - **License:** Per-illustration purchase
      - **Best for:** High-quality Korean medical illustrations, graphical abstract commissions
      - **Note:** Korean marketplace connecting researchers with professional medical illustrators
      
      ---
      
      ## Keyword → Source Mapping
      
      | Need | Recommended Source |
      |------|--------------------|
      | Organ anatomy (heart, lung, brain, kidney) | Servier Medical Art |
      | Cell biology (membrane, organelles, DNA) | Servier Medical Art |
      | Infectious agents (virus, bacteria) | NIAID BioArt |
      | Lab equipment (microscope, pipette, scanner) | NIAID BioArt or Servier |
      | CT/MRI scanner illustration | Servier Medical Art |
      | Molecular pathway | Reactome or BioRender |
      | Schematic flow icons | BioIcons |
      | Custom professional illustration | Medi-Sketch or BioRender |
      
      ---
      
      ## Usage Notes
      
      - **Editable vectors preferred.** Servier provides PowerPoint files with ungroupable vector shapes.
        Extract individual elements and recolor to match your visual abstract palette.
      - **Resolution check.** Ensure downloaded PNGs are ≥300 DPI for print. SVGs scale infinitely.
      - **Consistency.** Within one visual abstract, use illustrations from the same source to maintain
        visual coherence (mixing Servier flat style with NIAID 3D renders looks inconsistent).
      - **AI generation warning.** See the AI-Generated Figure Warning section in SKILL.md.
        AI-generated medical illustrations are recognizable to reviewers. Use sparingly and customize.
      
    • pipeline_concepts_medical_ai.md 9.2 KB
      # Pipeline & Architecture Diagrams — Medical AI / Engineering
      
      > **Triggered from**: SKILL.md Step 1 (Specify) when the figure is a
      > pipeline / architecture / workflow for a medical AI or imaging
      > engineering paper. Read alongside `design_principles.md` (key-message)
      > and `flow_diagram_lessons.md` (production lessons).
      
      This file covers the four most common diagram types in medical-imaging AI
      and engineering manuscripts that the standard reporting-guideline flows
      (PRISMA / CONSORT / STARD / STROBE) do not address: DICOM workflow,
      annotation pipeline, federated-learning topology, and model architecture.
      For each, this file gives the canonical layout, common pitfalls, and a
      preferred tool.
      
      ---
      
      ## 1. DICOM workflow diagram
      
      **Use when**: the manuscript describes how DICOM images flow from scanner
      → PACS → research environment → preprocessing → model.
      
      **Canonical layout** (left-to-right):
      
      ```
      [Modality / scanner]  →  [PACS]  →  [Research VNA / mirror]
             ↓                                       ↓
        acquisition                       de-identification
        parameters                        + DICOM tag scrub
                                                    ↓
                                    [Preprocessed cohort store]
                                                    ↓
                                    [Train / tune / test split]
                                                    ↓
                                           [Model input]
      ```
      
      **Required annotations**:
      
      - Modality (CT, MR, US, X-ray, OCT) and key acquisition parameters
        (kVp / mAs for CT, sequence and TE/TR for MR).
      - De-identification step explicitly named — DICOM tag list (e.g., remove
        PatientName, PatientID, AccessionNumber, InstitutionName) or reference
        to a published profile (DICOM PS3.15 Annex E "Basic Profile").
      - Whether pixel-data redaction (burned-in PHI) was performed.
      - Cohort filtering criteria with counts at each step (this is also a
        STARD / TRIPOD requirement).
      
      **Common pitfalls**:
      
      - Drawing "PACS → model" with no de-identification box (privacy reviewers
        reject).
      - Counts only at the final analytic cohort, not at each filter step
        (reproducibility reviewers reject).
      - Using "DICOM" as a single block when the manuscript actually transforms
        to NIfTI / NRRD partway through — show the conversion explicitly.
      
      **Preferred tool**: D2 with `--layout elk` for left-to-right; or Graphviz
      `rankdir=LR`. Avoid matplotlib `FancyBboxPatch` (manual coordinates break
      when text changes).
      
      ---
      
      ## 2. Annotation / labeling pipeline
      
      **Use when**: the manuscript reports a labeled dataset built by human
      annotators (segmentation, classification, bounding boxes).
      
      **Canonical layout**:
      
      ```
      [Raw image cohort]
             ↓
      [Annotation tool / platform]  ←  [Annotator pool: N readers, expertise]
             ↓
      [Round 1 labels]  →  [QC / consensus]  →  [Adjudication: senior reader]
                                                             ↓
                                              [Final reference standard]
                                                             ↓
                                              [Inter-rater agreement: κ / Dice]
      ```
      
      **Required annotations**:
      
      - Number and expertise level of annotators (e.g., "3 board-certified
        radiologists, 5–18 years of post-board experience").
      - Annotation tool (e.g., 3D Slicer, ITK-SNAP, MD.ai, RIL-Contour, custom).
      - Consensus rule (majority vote / unanimous / senior arbitration).
      - Inter-rater agreement metric and value reported separately.
      - Whether annotators were blinded to model output / clinical history.
      
      **Common pitfalls**:
      
      - Drawing "labeled by experts" with no count / qualification (reviewers
        ask for quantification).
      - Omitting the QC arrow (looks like single-pass labeling, low quality).
      - Conflating "reference standard" with "ground truth" — reference
        standard is the imperfect human label; ground truth is what we wish
        we had.
      
      **Preferred tool**: D2; nodes shaped as rectangles for steps, dashed
      border for human-in-the-loop steps to distinguish from automated.
      
      ---
      
      ## 3. Federated-learning topology
      
      **Use when**: the manuscript trains a model across multiple sites without
      centralizing raw data.
      
      **Canonical layout** (radial, central server in middle):
      
      ```
                    [Site A: cohort A_n, scanner type]
                                    ↑↓
                             (model weights only,
                              not images)
                                    ↑↓
                    [Central aggregator / parameter server]
                                    ↑↓
         [Site B: cohort B_n] ←→ [Site C: cohort C_n] ←→ [Site D: cohort D_n]
      ```
      
      **Required annotations**:
      
      - Per-site cohort size, scanner / vendor, demographic summary.
      - What is exchanged (gradients / weights / encrypted updates) — and
        what is **not** (raw images, intermediate features).
      - Aggregation algorithm (FedAvg / FedProx / FedBN / etc.).
      - Number of communication rounds and local epochs per round.
      - Privacy / security mechanism (differential privacy noise, secure
        aggregation) if claimed.
      
      **Common pitfalls**:
      
      - Bidirectional arrows without labeling what flows each way.
      - No site-level demographic table — federated claims fall flat without
        evidence of distribution shift across sites.
      - Labeling raw images crossing site boundaries — if that happens it is
        not federated learning, it is centralized.
      
      **Preferred tool**: D2 with explicit `near: center` for the aggregator
      node, then sites at compass points. Or matplotlib polar layout for
      manuscripts that want radial symmetry.
      
      ---
      
      ## 4. Model architecture diagram
      
      **Use when**: the manuscript proposes or modifies a neural-network
      architecture and the message depends on the structural change.
      
      **Canonical layout** (left-to-right block diagram):
      
      ```
      [Input: 3D volume H×W×D, 1 channel]
                      ↓
      [Backbone: ResNet-50 / ViT-B / nnU-Net]   (cite + checkpoint)
                      ↓
      [Neck / feature pyramid / skip connections]
                      ↓
      [Head: classification / segmentation / detection]
                      ↓
      [Output: class probability / mask / bounding boxes]
      ```
      
      **Required annotations**:
      
      - Input shape (H × W × D × C) and modality.
      - Backbone family + variant + initialization (random / ImageNet /
        domain-pretrained — cite the pretraining work).
      - Output shape and post-processing (softmax / argmax / non-max
        suppression).
      - Loss function (cross-entropy / Dice / focal / compound).
      - Trainable parameter count.
      
      **Style conventions** (medical-AI / engineering):
      
      - Convolutional / linear blocks: rectangles with channel count below.
      - Feature maps: trapezoids that shrink/grow to convey resolution change
        (optional but well-recognized).
      - Skip connections: dashed arrows.
      - Attention or transformer blocks: rectangles with internal split into
        Q / K / V annotation only when the message depends on attention.
      
      **Common pitfalls**:
      
      - "We used ResNet-50" with no diagram — fine for a methods paper, not
        for an architecture-contribution paper.
      - 3-D rendered "Convolution" pictograms with no dimension info — looks
        decorative, says nothing.
      - Drawing every layer when only the modified blocks matter — the eye
        cannot tell what changed. Show the modification, abstract the rest as
        "ResNet-50 backbone (frozen)".
      
      **Preferred tools**:
      
      - **Diagrams.net (drawio)** — fastest for one-off architecture figures.
      - **NN-SVG** (https://alexlenail.me/NN-SVG/) — generates clean SVG for
        fully-connected, LeNet, AlexNet variants.
      - **PlotNeuralNet** (LaTeX/TikZ) — used in the academic CV/ML community
        for publication-quality 3-D block diagrams.
      - D2 with `shape: hexagon` / `shape: cylinder` if the architecture is
        unusual enough that custom blocks are needed.
      
      For medical-AI papers targeting *Radiology AI*, *npj Digital Medicine*,
      or *Nature Medicine*, prefer NN-SVG or PlotNeuralNet output over
      hand-drawn blocks — reviewers in this venue are sensitive to alignment
      quality.
      
      ---
      
      ## When to use which (quick selector)
      
      | The figure is showing… | Use this section |
      |---|---|
      | How images move from scanner to model input | DICOM workflow |
      | How a labeled dataset was built | Annotation pipeline |
      | Multi-site training without raw-data sharing | Federated topology |
      | The structure of the proposed neural network | Model architecture |
      | Cohort filtering with counts at each stage | `flow_diagram_lessons.md` (PRISMA / STARD style) |
      | Training / tuning / test data splits | dataset-flow (see `reporting_guideline_figure_map.md` §AI-specific) |
      
      If the figure tries to do two of the above at once, split into two panels
      or two figures — combining DICOM workflow + architecture in one panel
      violates the cognitive-load budget (`design_principles.md` §4).
      
      ---
      
      ## Cross-references
      
      - `design_principles.md` — communication-first checks
      - `flow_diagram_lessons.md` — production lessons (template fidelity, PDF
        export, version freeze)
      - `reporting_guideline_figure_map.md` — which figures CLAIM / TRIPOD+AI
        / STARD-AI mandate
      - `critic_rubrics/data_plot.md` — calibration / fairness / colorblind
        checks
      - `exemplar_diagrams/pipeline/*_why.md` — design notes (the rendered figures were cropped from published papers and were removed; an MIT-licensed package cannot redistribute them) —
        worked exemplars (multimodal MLLM, contrastive learning, VQA, report
        generation)
      
    • reporting_guideline_figure_map.md 7 KB
      # Reporting Guideline → Figure Requirements Map
      
      > **Bridge**: this file connects `/make-figures` to `/check-reporting`
      > (49 reporting guidelines). Each row tells you which figures the guideline
      > **mandates** and how this skill currently supports them. Use during
      > Step 1 (Specify) once the study type is known.
      
      When `/check-reporting` flags missing figures for a target guideline, look
      up the row here and either render the supported template or fall back to
      the documented manual approach.
      
      ---
      
      ## Mandatory-figure map
      
      Legend for **Status** column:
      - ✅ Official template + R/Python generator shipped with this skill
      - ⚠️ Generic flow generator (`generate_flow_diagram.R`) covers the layout
        but no official-template fidelity check
      - ❌ No template; user produces with D2 / Graphviz / Inkscape from the
        guideline document, then runs critic_rubric manually
      
      | Guideline (year) | Study type | Mandatory figure(s) | Status | Source / where the official asset lives |
      |---|---|---|---|---|
      | **PRISMA 2020** | Systematic review | Flow diagram (4-phase: identification → screening → eligibility → included) | ✅ | `templates/official/prisma2020/`; R `PRISMA2020` package |
      | **PRISMA-DTA** | DTA systematic review | Modified PRISMA flow + DTA-specific exclusion reasons | ⚠️ | Salameh et al., *BMJ* 2020 (PMID 32312813); use generic flow + extra columns |
      | **PRISMA-NMA** | Network MA | PRISMA flow + network plot | ❌ | Hutton et al., *Ann Intern Med* 2015; network plot via R `netmeta::netgraph()` |
      | **PRISMA-ScR** | Scoping review | PRISMA-ScR flow diagram (sources of evidence: identification → screening → eligibility → included; item 14) | ⚠️ | Tricco et al., *Ann Intern Med* 2018 (DOI 10.7326/M18-0850); use the generic PRISMA flow with "sources of evidence" wording |
      | **PRISMA-P** | Protocol of SR | (none mandated; PRISMA-S search strategy figure recommended) | ❌ | Rethlefsen et al., *Syst Rev* 2021 |
      | **CONSORT 2025** | RCT | Participant-flow diagram (enrollment → allocation → follow-up → analysis) | ✅ | `templates/official/consort2010/` (now CONSORT 2025); R generator |
      | **CONSORT-AI 2020** | AI intervention RCT | CONSORT flow extended with AI training/validation/deployment dataset boxes | ❌ | Liu et al., *Nat Med* 2020 (PMID 32908283), Fig. 1; D2 / Graphviz custom |
      | **STARD 2015** | Diagnostic accuracy | Flow diagram (eligible → index test → reference standard → 2×2) + ROC | ✅ | `templates/official/stard2015/`; R generator |
      | **STARD-AI 2025** | AI diagnostic accuracy | STARD flow + dataset-flow (training / tuning / test) + subgroup-overlaid ROC/PR | ❌ | Sounderajah et al., *Nat Med* 2025 (PMID 40954311); produce manually, see `flow_diagram_lessons.md` |
      | **STROBE** | Observational cohort/case-control | (Flow diagram **recommended** but not strictly mandated) | ⚠️ | von Elm et al., *Ann Intern Med* 2007; use generic flow generator |
      | **TRIPOD 2015** | Prediction model | Calibration plot (mandatory) + discrimination (ROC, c-stat with CI) | ✅ (data plots) | Collins et al., *Ann Intern Med* 2015 |
      | **TRIPOD+AI 2024** | AI prediction model | TRIPOD figures + **fairness/subgroup panels** + **dataset-flow** + **decision-curve analysis** | ❌ (subgroup, DCA) | Collins et al., *BMJ* 2024 (PMID 38636956); produce manually |
      | **CLAIM 2024** | Medical imaging AI | Architecture diagram (model card style) + dataset-flow + calibration + per-subgroup performance + saliency/attention | ❌ | Tejani et al., *Radiology: AI* 2024 (PMID 38809149); 44 items total |
      | **DECIDE-AI 2022** | AI clinical eval (Stage 1–2) | Human-AI interaction diagram + safety-signal plot + override-rate over time | ❌ (uncertain — verify in full text) | Vasey et al., *Nat Med* 2022 (PMID 35585198) |
      | **CHEERS 2022** | Economic evaluation | Cost-effectiveness plane + cost-effectiveness acceptability curve | ❌ | Husereau et al., *BMJ* 2022 |
      | **SPIRIT 2025** | Trial protocol | Schedule-of-enrollment timeline (Figure 1 in published trials) | ✅ | `templates/official/spirit2013/` (updated to 2025); see also Robinson timeline figures |
      | **CARE 2013** | Case report | Timeline of patient course (recommended) | ⚠️ | Gagnier et al., *J Clin Epidemiol* 2014; use `exemplar_plots/clinical_timeline.md` |
      | **SQUIRE 2.0** | Quality improvement | Run chart / SPC chart | ❌ | Ogrinc et al., *BMJ Qual Saf* 2016 |
      
      ---
      
      ## When the status is ⚠️ (generic flow only)
      
      The R `generate_flow_diagram.R` script handles the layout, but the layout
      will not match the canonical guideline document exactly. For circulation
      to senior co-authors, that is usually acceptable; for reviewer-facing
      journals where fidelity is checked (esp. in *BMJ*, *Lancet*, *Ann Intern
      Med*, *JAMA*), prefer the official template route documented in
      `flow_diagram_lessons.md` Lesson 1.
      
      ## When the status is ❌
      
      For AI-extension guidelines (CONSORT-AI, STARD-AI, TRIPOD+AI, CLAIM 2024,
      DECIDE-AI), there is no shipped official template **as of 2026-05**.
      Production path:
      
      1. Read the original article to extract the canonical figure layout.
      2. Sketch in D2 (`flow.d2`) or Graphviz DOT, layout via ELK.
      3. Render to PDF with the platform-appropriate exporter
         (`flow_diagram_lessons.md` Lesson 2).
      4. Apply the cognitive-load + key-message-visibility checks from
         `critic_rubrics/flow_diagram.md` Section G.
      5. Cross-check against the corresponding `/check-reporting` checklist
         (item-by-item).
      
      When 2 or more projects need the same custom guideline figure, propose
      adding a new template under `templates/official/{guideline}/` so the
      deterministic generator can support it.
      
      ---
      
      ## AI-specific figures most often missing (priority for new templates)
      
      These are the figures that medical AI manuscripts most often omit, ranked
      by how frequently a reviewer-facing checklist (CLAIM 2024 / TRIPOD+AI)
      flags them:
      
      1. **Dataset-flow diagram** — patient/image counts at each split
         (training / tuning / internal test / external test). Required by
         STARD-AI, CLAIM 2024, TRIPOD+AI.
      2. **Calibration plot** — supported by `critic_rubrics/data_plot.md` §C.
      3. **Fairness / subgroup panel** — performance by sex / race / device /
         site. Required by TRIPOD+AI, CLAIM 2024.
      4. **Decision-curve analysis** — net benefit vs threshold. Required by
         TRIPOD+AI; recommended by CLAIM 2024.
      5. **Architecture diagram** — input modality → preprocessing → backbone →
         head → output. See `pipeline_concepts_medical_ai.md`.
      6. **Saliency / attention overlay** — qualitative panels showing model
         attention. Required by CLAIM 2024 §4.
      
      ---
      
      ## Cross-references
      
      - `/check-reporting` skill — supports all 49 guidelines, item-level audit
      - `flow_diagram_lessons.md` — production lessons that apply across all flows
      - `pipeline_concepts_medical_ai.md` — DICOM / annotation / federated /
        architecture diagram conventions
      - `design_principles.md` — communication-first design (Nat Hum Behav 2026)
      - `critic_rubrics/flow_diagram.md` — extended checklist (Sections A–G)
      - `critic_rubrics/data_plot.md` — calibration / fairness / colorblind checks
      
  • scripts
    • export_portal_tiff_challenge
      • problem.md 1.7 KB
        # Challenge — portal-ready TIFF export (LZW + RGBA→RGB white-flatten)
        
        A submission portal rejects a figure and the author cannot see why. Two facts collide at
        the upload button:
        
        1. The portal accepts only `.jpeg` / `.tiff` / `.eps` — **not** the `.png` the figure was
           rendered as (Springer Nature SNAPP does exactly this).
        2. The portal caps a figure at 25 MB (JACC: Asia). A raw, uncompressed 600-dpi **RGBA**
           TIFF sails past the cap; the same pixels saved **LZW-compressed** with the alpha channel
           **flattened onto white** are a fraction of the size — and a TIFF that keeps its alpha
           renders the transparent regions **black** on many production pipelines.
        
        `export_portal_tiff.py` does the conversion a human otherwise does by hand in Photoshop, and
        then **proves** the result is pixel-identical to that white-flatten before handing it over.
        
        ## What `verify.sh` asserts (network-free, Pillow-only)
        
        Positive — on a synthetic RGBA PNG with a transparent quadrant and a colour gradient:
        
        - the output is a **TIFF**, mode **RGB** (no alpha), **LZW**-compressed (Compression tag 5);
        - the once-transparent region is now **white** and the opaque pixels are unchanged;
        - the LZW output is strictly **smaller** than an uncompressed TIFF of the same pixels.
        
        Negative — the assertions must bite, not merely pass:
        
        - flattening the same source onto **black** yields **different** bytes, so the pixel-identity
          check would have caught a wrong background or an ignored alpha channel;
        - with `--max-mb` set below the output size, the exporter **refuses (exit 1)** rather than
          handing back a file that will bounce at the portal.
        
        Skips cleanly if Pillow is unavailable (the same runtime dependency every raster figure
        helper in this skill already carries).
        
      • verify.sh 3.6 KB
        #!/usr/bin/env bash
        # Deterministic verifier for the portal-TIFF export challenge (make-figures).
        # Network-free. Generates a synthetic RGBA PNG, exports it to a portal-ready TIFF, and
        # asserts the output is LZW + RGB + white-flattened + pixel-identical + smaller than raw;
        # then confirms the flatten and the size-cap assertions actually BITE. Exit 0 = all hold.
        set -euo pipefail
        HERE="$(cd "$(dirname "$0")" && pwd)"
        GEN="$HERE/../export_portal_tiff.py"
        TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
        
        [ -f "$GEN" ] || { echo "ENV-ERR: export_portal_tiff.py missing" >&2; exit 2; }
        python3 -c "import PIL" 2>/dev/null \
          || { echo "SKIP: Pillow unavailable on this host"; exit 0; }
        
        # --- synthetic fixture: RGBA with a fully transparent top-left quadrant + a gradient -----
        python3 - "$TMP" <<'PY'
        import sys
        from PIL import Image
        d = sys.argv[1]
        img = Image.new("RGBA", (200, 200), (0, 0, 0, 0))
        px = img.load()
        for y in range(200):
            for x in range(200):
                a = 0 if (x < 100 and y < 100) else 255      # top-left transparent
                px[x, y] = ((x * 255) // 200, (y * 255) // 200, 128, a)
        img.save(f"{d}/fig.png")
        PY
        
        # (1) Positive: the exporter runs and self-verifies.
        python3 "$GEN" --in "$TMP/fig.png" --out "$TMP/fig.tiff" --max-mb 25 >"$TMP/log" 2>&1 \
          || { echo "FAIL: valid figure did not export/verify" >&2; cat "$TMP/log" >&2; exit 1; }
        [ -s "$TMP/fig.tiff" ] || { echo "FAIL: fig.tiff not written" >&2; exit 1; }
        grep -q "OK: portal-ready TIFF" "$TMP/log" || { echo "FAIL: export did not report success" >&2; cat "$TMP/log" >&2; exit 1; }
        
        # (2) Independent structural assertions on the produced TIFF.
        python3 - "$TMP" <<'PY'
        import os, sys
        from PIL import Image
        d = sys.argv[1]
        im = Image.open(f"{d}/fig.tiff"); im.load()
        assert im.mode == "RGB", f"expected RGB, got {im.mode}"
        assert im.tag_v2.get(259) == 5, f"expected LZW (Compression 5), got {im.tag_v2.get(259)}"
        rgb = im.convert("RGB")
        assert rgb.getpixel((10, 10)) == (255, 255, 255), f"transparent quadrant not white: {rgb.getpixel((10,10))}"
        assert rgb.getpixel((150, 150)) == (191, 191, 128), f"opaque pixel altered: {rgb.getpixel((150,150))}"
        # LZW must be genuinely smaller than an uncompressed TIFF of the same pixels.
        rgb.save(f"{d}/raw.tiff", format="TIFF", compression="none")
        lzw, raw = os.path.getsize(f"{d}/fig.tiff"), os.path.getsize(f"{d}/raw.tiff")
        assert lzw < raw, f"LZW ({lzw}) not smaller than uncompressed ({raw})"
        print(f"OK-STRUCT: RGB + LZW + white-flatten + {lzw} < {raw} bytes")
        PY
        
        # (3) Negative — the flatten discriminates: onto BLACK yields different bytes, so the
        #     pixel-identity check would have caught a wrong background / ignored alpha.
        python3 - "$TMP" "$GEN" <<'PY'
        import importlib.util, sys
        from PIL import Image
        d, gen = sys.argv[1], sys.argv[2]
        spec = importlib.util.spec_from_file_location("ept", gen)
        ept = importlib.util.module_from_spec(spec); spec.loader.exec_module(ept)
        src = Image.open(f"{d}/fig.png"); src.load()
        white = ept.flatten_to_rgb(src, (255, 255, 255)).tobytes()
        black = ept.flatten_to_rgb(src, (0, 0, 0)).tobytes()
        assert white != black, "flatten ignored alpha — white and black backgrounds produced identical bytes"
        print("OK-NEG-FLATTEN: white != black flatten (alpha is genuinely composited)")
        PY
        
        # (4) Negative — the size cap bites: a cap below the output size must exit 1.
        if python3 "$GEN" --in "$TMP/fig.png" --out "$TMP/tiny.tiff" --max-mb 0.001 >/dev/null 2>&1; then
          echo "FAIL: --max-mb 0.001 did not refuse an over-cap output" >&2; exit 1
        fi
        echo "OK-NEG-CAP: --max-mb below output size refuses (exit 1)"
        
        echo "PASS: portal TIFF is LZW + RGB + white-flattened + pixel-identical + under cap; the flatten and size-cap assertions both bite."
        
    • render_core_figures_challenge
      • fixture
        • synthetic_inputs.json 3.5 KB
          {
            "_comment": "Synthetic, hand-authored figure inputs (no real patient data). Already-computed step/curve coordinates and summary statistics — this fixture exercises the RENDER layer, not any statistical estimation.",
            "km": {
              "title": "Synthetic two-arm survival",
              "max_follow_up": 24,
              "groups": [
                {
                  "name": "Arm A",
                  "time": [0, 6, 12, 18, 24],
                  "surv": [1.0, 0.92, 0.81, 0.74, 0.68],
                  "censor_times": [10, 20],
                  "at_risk": {"time": [0, 6, 12, 18, 24], "n": [200, 170, 140, 110, 80]}
                },
                {
                  "name": "Arm B",
                  "time": [0, 6, 12, 18, 24],
                  "surv": [1.0, 0.85, 0.70, 0.58, 0.50],
                  "censor_times": [8, 16],
                  "at_risk": {"time": [0, 6, 12, 18, 24], "n": [180, 150, 118, 90, 60]}
                }
              ]
            },
            "roc": {
              "fpr": [0.0, 0.05, 0.10, 0.20, 0.35, 0.60, 1.0],
              "tpr": [0.0, 0.45, 0.62, 0.78, 0.88, 0.95, 1.0],
              "auc": 0.862,
              "operating_point": {"fpr": 0.10, "tpr": 0.62, "label": "Youden point"}
            },
            "calibration": {
              "pred_mean": [0.10, 0.30, 0.50, 0.70, 0.90],
              "obs_freq": [0.12, 0.28, 0.52, 0.66, 0.85],
              "slope": 0.93,
              "intercept": 0.02,
              "ci_low": [0.06, 0.21, 0.44, 0.58, 0.77],
              "ci_high": [0.19, 0.36, 0.60, 0.74, 0.91]
            },
            "dca": {
              "thresholds": [0.05, 0.10, 0.20, 0.30, 0.40, 0.50],
              "net_benefit_model": [0.34, 0.30, 0.22, 0.15, 0.09, 0.04],
              "prevalence": 0.35
            },
            "forest": {
              "effect_label": "Odds ratio (95% CI)",
              "null_value": 1.0,
              "log_x": true,
              "studies": [
                {"name": "Study A 2019", "est": 1.20, "lo": 0.90, "hi": 1.60, "weight": 25},
                {"name": "Study B 2020", "est": 1.45, "lo": 1.10, "hi": 1.92, "weight": 30},
                {"name": "Study C 2021", "est": 0.95, "lo": 0.70, "hi": 1.29, "weight": 20},
                {"name": "Study D 2022", "est": 1.60, "lo": 1.15, "hi": 2.22, "weight": 25}
              ],
              "pooled": {"est": 1.30, "lo": 1.10, "hi": 1.54, "label": "Random-effects pooled"}
            },
            "bland_altman": {
              "mean_vals": [10, 12, 14, 16, 18, 20, 22, 24, 26, 28, 30, 32],
              "diff_vals": [0.2, -0.5, 0.8, 1.1, -0.3, 0.6, -1.2, 0.9, 0.1, 1.5, -0.7, 0.4],
              "bias": 0.24,
              "sd_diff": 0.80
            },
            "confusion": {
              "matrix": [[85, 15], [10, 90]],
              "labels": ["Negative", "Positive"]
            },
            "mrmc_roc": {
              "readers": [
                {"name": "Reader 1", "fpr": [0.0, 0.10, 0.25, 0.50, 1.0], "tpr": [0.0, 0.55, 0.72, 0.88, 1.0], "auc": 0.80},
                {"name": "Reader 2", "fpr": [0.0, 0.08, 0.20, 0.45, 1.0], "tpr": [0.0, 0.62, 0.78, 0.90, 1.0], "auc": 0.84},
                {"name": "Reader 3", "fpr": [0.0, 0.12, 0.30, 0.55, 1.0], "tpr": [0.0, 0.50, 0.68, 0.85, 1.0], "auc": 0.77}
              ],
              "averaged": {"fpr": [0.0, 0.10, 0.25, 0.50, 1.0], "tpr": [0.0, 0.56, 0.73, 0.88, 1.0], "auc": 0.805, "label": "Reader-averaged"},
              "delta_auc": {"value": 0.06, "margin": 0.05}
            },
            "manhattan": {
              "xlabel": "Candidate exposure (index)",
              "threshold": 2.6,
              "x": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20],
              "neglogp": [0.4, 1.2, 0.8, 3.5, 0.6, 1.1, 2.1, 0.9, 4.2, 0.5, 1.3, 0.7, 2.8, 1.0, 0.4, 5.1, 0.9, 1.4, 0.6, 2.2],
              "labels": ["", "", "", "Exposure D", "", "", "", "", "Exposure I", "", "", "", "Exposure M", "", "", "Exposure P", "", "", "", ""]
            },
            "timeline": {
              "time_unit": "days from admission",
              "events": [
                {"time": 0, "label": "Admission"},
                {"time": 2, "label": "CT scan"},
                {"time": 5, "label": "Biopsy"},
                {"time": 9, "label": "Diagnosis"},
                {"time": 14, "label": "Treatment start"},
                {"time": 30, "label": "Follow-up"}
              ]
            }
          }
          
      • problem.md 3.4 KB
        # Challenge card — core clinical-figure render regression (make-figures)
        
        ## Problem
        The highest-yield clinical figures — Kaplan–Meier, ROC, calibration, decision-curve,
        forest, Bland–Altman, and confusion matrix — were documented only as **prose anatomy** in
        `references/exemplar_plots/`, and the actual matplotlib rendering had **no deterministic
        test of any kind**. A regression in figure code (a dropped number-at-risk table, a
        missing chance diagonal, a calibration plot without its identity line, a DCA without the
        treat-all / treat-none references, a KM curve extrapolated past follow-up, a forest without
        its pooled diamond or null line, a Bland–Altman without its limits of agreement, or a
        confusion matrix without annotated cells) would pass every prose read and only be caught by
        a reviewer — the gap that left the suite's self-identified weakest area with the same
        defense/enablement asymmetry the rest of the repo has closed (integrity detectors have
        challenge fixtures; the figure generators did not).
        
        ## What the generator does
        `scripts/render_core_figures.py` is the **render** layer for the exemplar anatomies. It
        turns each prose model into a runnable, deterministic matplotlib generator that takes
        **already-computed inputs** (the analysis SoT stays in `/analyze-stats`; this never
        recomputes a statistic) and renders the canonical anatomy. `assert_structure` then
        introspects the actual matplotlib artists and asserts each figure's load-bearing
        elements are present:
        
        - **KM** — step curve(s), number-at-risk table, monotonic non-increasing survival,
          x-axis clipped to follow-up (no extrapolation).
        - **ROC** — chance diagonal, AUC annotation, operating-point marker.
        - **Calibration** — identity (y = x) line, slope + intercept annotation,
          predicted-vs-observed axes.
        - **Decision curve** — model + treat-all + treat-none strategies, the treat-none
          (net benefit = 0) reference, a net-benefit y-axis.
        - **Forest** — a per-study CI whisker for every study, the null reference line, and the
          pooled diamond; study + pooled row labels.
        - **Bland–Altman** — the difference scatter, the bias line, and the 95% limits of
          agreement (bias ± 1.96·SD); difference-vs-mean axes.
        - **Confusion matrix** — a matrix image with every cell annotated and Predicted/Actual axes.
        - **MRMC ROC** — a curve per reader + the reader-averaged curve, the chance diagonal, and
          the averaged-AUC annotation.
        - **Manhattan** — the point scatter, the named significance-threshold line, and a
          −log10(p) y-axis.
        - **Clinical timeline** — the time baseline, an event marker + label at each event, and a
          time x-axis.
        
        (`imaging_panel` stays a prose-only exemplar — it composes real images, not computed
        numbers, so it has no synthetic generator.)
        
        ## Fixture (synthetic only — no real data)
        - `fixture/synthetic_inputs.json` — hand-authored coordinates and summary statistics for
          all ten figures.
        
        ## Expected (`verify.sh`, network-free)
        - All ten figures render to PNGs (each > 2 KB) **and** every structural invariant holds
          → exit 0.
        - Mutated inputs that drop a load-bearing element (a non-monotonic KM curve; a non-square
          confusion matrix) raise `AssertionError` → the negative cases in `verify.sh` confirm the
          gate actually fails when it should.
        
        Requires matplotlib + numpy (already make-figures runtime deps); the verifier skips with
        a clear message if matplotlib is unavailable, so it never hard-fails a minimal host.
        
      • verify.sh 2.3 KB
        #!/usr/bin/env bash
        # Deterministic verifier for the core-figure render challenge (make-figures).
        # Network-free. Renders the ten canonical clinical figures from a synthetic fixture and
        # asserts each figure's load-bearing elements; then confirms the structural gate FAILS on
        # mutated inputs (so the assertions are proven to bite). Exit 0 = all expectations hold.
        set -euo pipefail
        HERE="$(cd "$(dirname "$0")" && pwd)"
        GEN="$HERE/../render_core_figures.py"
        FIX="$HERE/fixture/synthetic_inputs.json"
        TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
        
        [ -f "$GEN" ] || { echo "ENV-ERR: render_core_figures.py missing" >&2; exit 2; }
        python3 -c "import matplotlib, numpy" 2>/dev/null \
          || { echo "SKIP: matplotlib/numpy unavailable on this host"; exit 0; }
        
        # (1) Positive: all ten figures render + every structural invariant holds.
        python3 "$GEN" --inputs "$FIX" --out-dir "$TMP/out" >"$TMP/log" 2>&1 \
          || { echo "FAIL: valid fixture did not render/verify" >&2; cat "$TMP/log" >&2; exit 1; }
        for k in km roc calibration dca forest bland_altman confusion mrmc_roc manhattan timeline; do
          [ -s "$TMP/out/$k.png" ] || { echo "FAIL: $k.png not written" >&2; exit 1; }
        done
        grep -q "PASS: 10 figure" "$TMP/log" || { echo "FAIL: not all ten figures verified" >&2; cat "$TMP/log" >&2; exit 1; }
        
        # (2) Negative — the assertions must actually fire on malformed inputs.
        # (2a) a KM survival curve mutated to be non-monotonic.
        python3 - "$FIX" "$TMP/bad_km.json" <<'PY'
        import json, sys
        d = json.load(open(sys.argv[1]))
        d["km"]["groups"][0]["surv"] = [1.0, 0.92, 0.95, 0.74, 0.68]  # 0.81 -> 0.95 (increasing)
        json.dump(d, open(sys.argv[2], "w"))
        PY
        if python3 "$GEN" --inputs "$TMP/bad_km.json" --out-dir "$TMP/bk" >/dev/null 2>&1; then
          echo "FAIL: a non-monotonic KM survival curve was NOT caught" >&2; exit 1
        fi
        # (2b) a non-square confusion matrix (label/shape mismatch).
        python3 - "$FIX" "$TMP/bad_cm.json" <<'PY'
        import json, sys
        d = json.load(open(sys.argv[1]))
        d["confusion"]["matrix"] = [[85, 15, 0], [10, 90, 0]]  # 2x3, not square
        json.dump(d, open(sys.argv[2], "w"))
        PY
        if python3 "$GEN" --inputs "$TMP/bad_cm.json" --out-dir "$TMP/bc" >/dev/null 2>&1; then
          echo "FAIL: a non-square confusion matrix was NOT caught" >&2; exit 1
        fi
        
        echo "PASS: 10 core figures render + structurally verify; the gate fails on a non-monotonic KM curve and a non-square confusion matrix."
        
    • build_jacc_template.py 2.9 KB
      """Build the JACC Central Illustration PPTX template.
      
      Layout matches official JACC submission templates (verified from
      doi:10.1016/j.jacc.2019.10.035 Figures 1–4 PPTX files).
      
      Run once. Output:
        references/visual_abstract_templates/jacc_central_illustration.pptx
      """
      from pathlib import Path
      from pptx import Presentation
      from pptx.util import Inches, Pt, Emu
      from pptx.dml.color import RGBColor
      from pptx.enum.shapes import MSO_SHAPE
      
      ROOT = Path(__file__).resolve().parents[1]
      OUT = ROOT / "references" / "visual_abstract_templates" / "jacc_central_illustration.pptx"
      
      # JACC submission slide is 10 x 7.5 in (4:3 standard PPT)
      prs = Presentation()
      prs.slide_width = Inches(10)
      prs.slide_height = Inches(7.5)
      
      # Blank layout
      blank = prs.slide_layouts[6]
      slide = prs.slides.add_slide(blank)
      
      # ---- Slot 1: citation text box ----
      cite = slide.shapes.add_textbox(Inches(0.4), Inches(5.3), Inches(9.4), Inches(0.5))
      tf = cite.text_frame
      tf.text = "ARTICLECITATION — replace with: FirstAuthor et al. JACC: Asia YYYY; vol(issue):pages."
      p = tf.paragraphs[0]
      p.font.size = Pt(11)
      p.font.bold = True
      p.font.color.rgb = RGBColor(0x1F, 0x4E, 0x79)
      
      # ---- Slot 2: content picture placeholder (rectangle as marker) ----
      ph = slide.shapes.add_shape(
          MSO_SHAPE.RECTANGLE, Inches(3.0), Inches(0.8), Inches(4.0), Inches(4.2)
      )
      ph.fill.solid()
      ph.fill.fore_color.rgb = RGBColor(0xF2, 0xF2, 0xF2)
      ph.line.color.rgb = RGBColor(0xBF, 0xBF, 0xBF)
      ph.line.width = Pt(1.5)
      ph.text_frame.text = "VISUALELEMENT — replace this rectangle with the author content figure (PNG/TIFF, 600 DPI, ~4×4.2 in)."
      for para in ph.text_frame.paragraphs:
          para.font.size = Pt(10)
          para.font.color.rgb = RGBColor(0x59, 0x59, 0x59)
          para.alignment = 1  # center
      
      # ---- Slot 3: footer text box (reserved; usually empty in JACC templates) ----
      foot = slide.shapes.add_textbox(Inches(0.4), Inches(7.0), Inches(4.1), Inches(0.5))
      foot.text_frame.text = "FOOTERNOTE — optional secondary caption (often empty)."
      for para in foot.text_frame.paragraphs:
          para.font.size = Pt(9)
          para.font.color.rgb = RGBColor(0x80, 0x80, 0x80)
          para.font.italic = True
      
      # ---- Slot 4: logo placeholder (rectangle as marker) ----
      logo = slide.shapes.add_shape(
          MSO_SHAPE.RECTANGLE, Inches(7.3), Inches(6.7), Inches(2.7), Inches(0.8)
      )
      logo.fill.solid()
      logo.fill.fore_color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
      logo.line.color.rgb = RGBColor(0xBF, 0xBF, 0xBF)
      logo.line.width = Pt(0.75)
      logo.text_frame.text = "JACCLOGO — JACC family logo (provided by editorial)."
      for para in logo.text_frame.paragraphs:
          para.font.size = Pt(8)
          para.font.color.rgb = RGBColor(0x80, 0x80, 0x80)
          para.alignment = 1
      
      # Save
      OUT.parent.mkdir(parents=True, exist_ok=True)
      prs.save(OUT)
      print(f"Saved: {OUT}")
      print(f"Slide size: {Emu(prs.slide_width).inches:.2f} x {Emu(prs.slide_height).inches:.2f} in")
      print("Placeholders: ARTICLECITATION, VISUALELEMENT, FOOTERNOTE, JACCLOGO")
      
    • build_prisma2020_template.py 11.2 KB
      #!/usr/bin/env python3
      """Build a PRISMA 2020 flow diagram template (.pptx) programmatically.
      
      Why this script exists
      ----------------------
      prismastatement.org serves the official .docx templates behind a Squarespace
      JavaScript fingerprint redirect that blocks programmatic download (curl, wget,
      WebFetch, Wayback Machine all return the redirect HTML stub, not the file).
      
      We reproduce the published layout from Page MJ et al. *BMJ* 2021;372:n71
      (Fig 1, "PRISMA 2020 flow diagram"), which the PRISMA Group releases under
      CC-BY 4.0. Box positions, labels, and arrow topology mirror the official
      template; only the per-line counts are placeholders that fill_prisma_template.py
      populates.
      
      Variants
      --------
      - ``new``     : new systematic reviews (no "Studies included in previous
                      version" branch)
      - ``updated`` : updated systematic reviews (adds the previous-version branch
                      on the right)
      
      The "registers/databases only" (v1) vs "+ other sources" (v2) distinction is
      handled by toggling the ``--include-other-sources`` flag, which adds the
      secondary identification column on the left.
      
      Usage
      -----
          python3 build_prisma2020_template.py \
              --variant new --include-other-sources \
              --out templates/official/prisma2020/PRISMA_2020_flow_new_v2.pptx
      """
      from __future__ import annotations
      
      import argparse
      from pathlib import Path
      
      from pptx import Presentation
      from pptx.dml.color import RGBColor
      from pptx.enum.shapes import MSO_SHAPE
      from pptx.util import Emu, Inches, Pt
      
      # ── Layout constants (inches) ────────────────────────────────────────────────
      SLIDE_W = 13.33
      SLIDE_H = 10.0
      
      BOX_W = 3.4
      BOX_H = 0.95
      COL_GAP = 0.4
      ROW_GAP = 0.45
      
      # Phase column on the far left
      PHASE_COL_X = 0.3
      PHASE_COL_W = 1.3
      
      # Identification block columns
      IDENT_COL1_X = PHASE_COL_X + PHASE_COL_W + 0.2  # registers / databases
      IDENT_COL2_X = IDENT_COL1_X + BOX_W + COL_GAP   # other sources (v2 only)
      
      # Vertical positions
      TITLE_Y = 0.25
      ROW1_Y = 1.1   # records identified
      ROW2_Y = ROW1_Y + BOX_H + ROW_GAP  # records removed before screening (right inset)
      
      SCREENING_Y = 3.3
      SCREENING_EXCL_X_OFFSET = BOX_W + COL_GAP
      
      ELIGIBILITY_Y = SCREENING_Y + (BOX_H + ROW_GAP) * 2  # reports retrieved + sought
      INCLUDED_Y = SLIDE_H - BOX_H - 0.6
      
      # Colors
      NAVY = RGBColor(0x1F, 0x3A, 0x68)
      LIGHT = RGBColor(0xE8, 0xEE, 0xF7)
      BLACK = RGBColor(0x00, 0x00, 0x00)
      GREY = RGBColor(0x55, 0x55, 0x55)
      
      
      # ── Helpers ──────────────────────────────────────────────────────────────────
      def add_box(
          slide,
          left: float,
          top: float,
          width: float,
          height: float,
          text: str,
          *,
          fill=LIGHT,
          border=NAVY,
          bold_first_line: bool = True,
          font_size: int = 10,
      ):
          """Add a rounded rectangle with the given text. Returns the shape."""
          shape = slide.shapes.add_shape(
              MSO_SHAPE.RECTANGLE,
              Inches(left),
              Inches(top),
              Inches(width),
              Inches(height),
          )
          shape.fill.solid()
          shape.fill.fore_color.rgb = fill
          shape.line.color.rgb = border
          shape.line.width = Pt(0.75)
      
          tf = shape.text_frame
          tf.word_wrap = True
          tf.margin_left = tf.margin_right = Inches(0.06)
          tf.margin_top = tf.margin_bottom = Inches(0.04)
      
          lines = text.split("\n")
          for i, line in enumerate(lines):
              para = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
              run = para.add_run()
              run.text = line
              run.font.size = Pt(font_size)
              run.font.color.rgb = BLACK
              run.font.bold = bold_first_line and i == 0
          return shape
      
      
      def add_phase_label(slide, top: float, text: str):
          shape = slide.shapes.add_shape(
              MSO_SHAPE.RECTANGLE,
              Inches(PHASE_COL_X),
              Inches(top),
              Inches(PHASE_COL_W),
              Inches(BOX_H),
          )
          shape.fill.solid()
          shape.fill.fore_color.rgb = NAVY
          shape.line.color.rgb = NAVY
          tf = shape.text_frame
          tf.word_wrap = True
          p = tf.paragraphs[0]
          p.alignment = 2  # center
          run = p.add_run()
          run.text = text
          run.font.size = Pt(12)
          run.font.bold = True
          run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
      
      
      def add_arrow(slide, x1, y1, x2, y2):
          line = slide.shapes.add_connector(1, Inches(x1), Inches(y1), Inches(x2), Inches(y2))
          line.line.color.rgb = NAVY
          line.line.width = Pt(1.25)
      
      
      # ── Builders ─────────────────────────────────────────────────────────────────
      def build_template(variant: str, include_other_sources: bool, out_path: Path) -> None:
          prs = Presentation()
          prs.slide_width = Inches(SLIDE_W)
          prs.slide_height = Inches(SLIDE_H)
          blank_layout = prs.slide_layouts[6]
          slide = prs.slides.add_slide(blank_layout)
      
          # Title
          title_box = slide.shapes.add_textbox(
              Inches(0.3), Inches(TITLE_Y), Inches(SLIDE_W - 0.6), Inches(0.6)
          )
          tf = title_box.text_frame
          p = tf.paragraphs[0]
          run = p.add_run()
          run.text = (
              f"PRISMA 2020 flow diagram for "
              f"{'updated' if variant == 'updated' else 'new'} systematic reviews"
              + (" (databases, registers, and other sources)"
                 if include_other_sources else " (databases and registers only)")
          )
          run.font.size = Pt(14)
          run.font.bold = True
          run.font.color.rgb = NAVY
      
          # Phase labels (left column)
          add_phase_label(slide, ROW1_Y, "Identification")
          add_phase_label(slide, SCREENING_Y, "Screening")
          add_phase_label(slide, INCLUDED_Y, "Included")
      
          # ── Identification row ───────────────────────────────────────────────────
          add_box(
              slide,
              IDENT_COL1_X,
              ROW1_Y,
              BOX_W,
              BOX_H,
              "Records identified from:\n"
              "  Databases (n = {n_db})\n"
              "  Registers (n = {n_reg})",
              font_size=10,
          )
      
          # Right inset: records removed before screening
          removed_x = IDENT_COL1_X + BOX_W + COL_GAP + (BOX_W + COL_GAP if include_other_sources else 0)
          add_box(
              slide,
              removed_x,
              ROW1_Y,
              BOX_W,
              BOX_H,
              "Records removed before screening:\n"
              "  Duplicate records removed (n = {n_dup})\n"
              "  Records marked as ineligible by automation tools (n = {n_auto})\n"
              "  Records removed for other reasons (n = {n_other_removed})",
              font_size=9,
          )
          add_arrow(slide, IDENT_COL1_X + BOX_W, ROW1_Y + BOX_H / 2, removed_x, ROW1_Y + BOX_H / 2)
      
          if include_other_sources:
              add_box(
                  slide,
                  IDENT_COL2_X,
                  ROW1_Y,
                  BOX_W,
                  BOX_H,
                  "Records identified from:\n"
                  "  Websites (n = {n_web})\n"
                  "  Organisations (n = {n_org})\n"
                  "  Citation searching (n = {n_cite})\n"
                  "  etc.",
                  font_size=9,
              )
      
          # ── Screening row ────────────────────────────────────────────────────────
          add_box(
              slide,
              IDENT_COL1_X,
              SCREENING_Y,
              BOX_W,
              BOX_H,
              "Records screened\n(n = {n_screened})",
          )
          add_box(
              slide,
              IDENT_COL1_X + SCREENING_EXCL_X_OFFSET,
              SCREENING_Y,
              BOX_W,
              BOX_H,
              "Records excluded\n(n = {n_screen_excluded})",
          )
          add_arrow(slide, IDENT_COL1_X + BOX_W / 2, ROW1_Y + BOX_H, IDENT_COL1_X + BOX_W / 2, SCREENING_Y)
          add_arrow(
              slide,
              IDENT_COL1_X + BOX_W,
              SCREENING_Y + BOX_H / 2,
              IDENT_COL1_X + SCREENING_EXCL_X_OFFSET,
              SCREENING_Y + BOX_H / 2,
          )
      
          # Reports sought / not retrieved
          add_box(
              slide,
              IDENT_COL1_X,
              SCREENING_Y + BOX_H + ROW_GAP,
              BOX_W,
              BOX_H,
              "Reports sought for retrieval\n(n = {n_sought})",
          )
          add_box(
              slide,
              IDENT_COL1_X + SCREENING_EXCL_X_OFFSET,
              SCREENING_Y + BOX_H + ROW_GAP,
              BOX_W,
              BOX_H,
              "Reports not retrieved\n(n = {n_not_retrieved})",
          )
          add_arrow(
              slide,
              IDENT_COL1_X + BOX_W / 2,
              SCREENING_Y + BOX_H,
              IDENT_COL1_X + BOX_W / 2,
              SCREENING_Y + BOX_H + ROW_GAP,
          )
          add_arrow(
              slide,
              IDENT_COL1_X + BOX_W,
              SCREENING_Y + BOX_H + ROW_GAP + BOX_H / 2,
              IDENT_COL1_X + SCREENING_EXCL_X_OFFSET,
              SCREENING_Y + BOX_H + ROW_GAP + BOX_H / 2,
          )
      
          # Reports assessed / excluded with reasons
          assess_y = SCREENING_Y + (BOX_H + ROW_GAP) * 2
          add_box(
              slide,
              IDENT_COL1_X,
              assess_y,
              BOX_W,
              BOX_H,
              "Reports assessed for eligibility\n(n = {n_assessed})",
          )
          add_box(
              slide,
              IDENT_COL1_X + SCREENING_EXCL_X_OFFSET,
              assess_y,
              BOX_W,
              BOX_H + 0.4,
              "Reports excluded:\n"
              "  Reason 1 (n = {n_excl_r1})\n"
              "  Reason 2 (n = {n_excl_r2})\n"
              "  Reason 3 (n = {n_excl_r3})\n"
              "  etc.",
              font_size=9,
          )
          add_arrow(
              slide,
              IDENT_COL1_X + BOX_W / 2,
              SCREENING_Y + (BOX_H + ROW_GAP) * 2 - ROW_GAP,
              IDENT_COL1_X + BOX_W / 2,
              assess_y,
          )
          add_arrow(
              slide,
              IDENT_COL1_X + BOX_W,
              assess_y + BOX_H / 2,
              IDENT_COL1_X + SCREENING_EXCL_X_OFFSET,
              assess_y + BOX_H / 2,
          )
      
          # ── Included row ─────────────────────────────────────────────────────────
          add_box(
              slide,
              IDENT_COL1_X,
              INCLUDED_Y,
              BOX_W * 1.3,
              BOX_H + 0.3,
              "Studies included in review (n = {n_studies})\n"
              "Reports of included studies (n = {n_reports})",
              font_size=11,
          )
          add_arrow(
              slide,
              IDENT_COL1_X + BOX_W / 2,
              assess_y + BOX_H + 0.4,
              IDENT_COL1_X + BOX_W / 2,
              INCLUDED_Y,
          )
      
          # Footer attribution (CC-BY)
          foot = slide.shapes.add_textbox(
              Inches(0.3), Inches(SLIDE_H - 0.45), Inches(SLIDE_W - 0.6), Inches(0.35)
          )
          fp = foot.text_frame.paragraphs[0]
          r = fp.add_run()
          r.text = (
              "Layout adapted from Page MJ et al. PRISMA 2020 flow diagram, BMJ 2021;372:n71. "
              "Distributed under CC-BY 4.0."
          )
          r.font.size = Pt(8)
          r.font.italic = True
          r.font.color.rgb = GREY
      
          out_path.parent.mkdir(parents=True, exist_ok=True)
          prs.save(out_path)
          print(f"✓ wrote {out_path}")
      
      
      def main():
          ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
          ap.add_argument("--variant", choices=["new", "updated"], default="new")
          ap.add_argument(
              "--include-other-sources",
              action="store_true",
              help="Include the 'other sources' identification column (v2 layout).",
          )
          ap.add_argument("--out", type=Path, required=True)
          args = ap.parse_args()
          build_template(args.variant, args.include_other_sources, args.out)
      
      
      if __name__ == "__main__":
          main()
      
    • build_strobe_template.py 14.3 KB
      #!/usr/bin/env python3
      """Build an editable STROBE participant flow diagram (.pptx) from a YAML config.
      
      Why this script exists
      ----------------------
      The Graphviz path (``generate_flow_diagram.R --type strobe``) renders an
      auto-fitting monochrome diagram suitable for journal submission, but
      co-authors often want to nudge box positions, edit prose, or recolor stage
      labels in PowerPoint. This script produces a fully-editable .pptx in the
      classical Identification → Screening → Inclusion → Analysis convention used
      by the BMJ / Radiology / KJR cohort-study examples (Randolph 2018,
      PLoS ONE 2021).
      
      Unlike PRISMA, STROBE flow diagrams have study-specific spine structure
      (number of cohort-stage boxes and exclusions varies per study), so this is
      a single parametric builder rather than the PRISMA two-step
      (``build_prisma2020_template.py`` then ``fill_prisma_template.py``).
      
      YAML schema
      -----------
          output_pptx: figures/figure1_strobe.pptx          # required
          slide_size: [10.5, 11.0]                           # inches; auto-computed if omitted
          title: "Figure 1. STROBE participant flow diagram"  # optional
      
          # `stages:` is OPTIONAL. STROBE itself does not mandate a phase column —
          # most cohort papers omit it (the column is a PRISMA 2020 convention). When
          # `stages:` is present, the script renders a left phase column and merges
          # consecutive same-stage spine rows under one label. When omitted (or empty),
          # the spine shifts left, the slide narrows, and the figure looks like the
          # plainer STROBE convention.
          stages:
            - {name: Identification, color: "#cfe1f5"}      # color = stage box fill
            - {name: Screening,      color: "#cfe1f5"}
            - {name: Inclusion,      color: "#cfe1f5"}
            - {name: Analysis,       color: "#cfe1f5"}
          spine:
            - {id: enrolled,  stage: Identification, text: "..."}
            - {id: screened,  stage: Screening,      text: "..."}
            - {id: eligible,  stage: Inclusion,      text: "..."}
            - {id: primary,   stage: Analysis,       text: "..."}
            - {id: landmark,  stage: Analysis,       text: "..."}  # consecutive same-stage rows share one stage label
          exclusions:
            - {after: enrolled, text: "Excluded (n = 1,200):\\n- did not meet the index finding on any screening"}
            - {after: screened, text: "Excluded (n = 3):\\n- prior outcome event / zero post-baseline follow-up"}
      
      Usage
      -----
          python3 build_strobe_template.py \
              --config figures/figure1_strobe.yaml \
              --out    figures/figure1_strobe.pptx
      
      Open the resulting .pptx in PowerPoint to fine-tune positions or styling
      before saving as PDF / TIFF for submission.
      """
      from __future__ import annotations
      
      import argparse
      import json
      import sys
      from pathlib import Path
      
      from _strobe_cascade import check_cascade
      
      try:
          import yaml
          HAS_YAML = True
      except ImportError:
          HAS_YAML = False
      
      from lxml import etree
      from pptx import Presentation
      from pptx.dml.color import RGBColor
      from pptx.enum.shapes import MSO_SHAPE
      from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
      from pptx.oxml.ns import qn
      from pptx.util import Inches, Pt
      
      
      # ── Defaults ────────────────────────────────────────────────────────────────
      DEFAULT_SLIDE_W = 10.5    # default width when slide_size unset; height auto-computed
      FALLBACK_SLIDE_H = 11.0   # only used if auto-compute can't fit
      ABSOLUTE_MAX_SLIDE_H = 14.0  # PowerPoint hard upper bound for legible single-slide layouts
      
      # Column geometry (inches) — tighter than widescreen; better for vertical flow
      PHASE_X = 0.4
      PHASE_W = 1.4
      SPINE_X = 2.2
      SPINE_W = 3.6
      EXCL_X  = 6.4
      EXCL_W  = 3.7
      
      # Vertical spacing
      TITLE_Y = 0.25
      TITLE_H = 0.5
      TOP_PAD = 0.35           # below title before first row
      ROW_GAP = 0.35           # vertical gap between adjacent rows
      DEFAULT_ROW_H = 1.20     # slightly taller boxes for better text breathing room
      DEFAULT_EXCL_H = 1.05
      BOTTOM_PAD = 0.4         # margin below last row
      
      # Colors
      NAVY   = RGBColor(0x1F, 0x3A, 0x68)
      WHITE  = RGBColor(0xFF, 0xFF, 0xFF)
      BLACK  = RGBColor(0x00, 0x00, 0x00)
      LBLUE  = RGBColor(0xCF, 0xE1, 0xF5)
      
      
      # ── Helpers ─────────────────────────────────────────────────────────────────
      def _parse_color(hex_str: str | None, default: RGBColor) -> RGBColor:
          if not hex_str:
              return default
          h = hex_str.lstrip("#")
          return RGBColor(int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
      
      
      def _readable_text_color(bg: RGBColor) -> RGBColor:
          """Return BLACK or WHITE depending on background luminance."""
          # Relative luminance per WCAG (sRGB approx without gamma)
          r, g, b = bg[0], bg[1], bg[2]
          lum = 0.299 * r + 0.587 * g + 0.114 * b
          return RGBColor(0x1F, 0x3A, 0x68) if lum > 160 else WHITE
      
      
      def add_box(slide, x, y, w, h, text, *,
                  fill=WHITE, border=BLACK, font_color=BLACK,
                  font_size=11, bold_first=True, anchor_middle=True,
                  align_center=True, line_pt=1.0, dash=False):
          shape = slide.shapes.add_shape(
              MSO_SHAPE.ROUNDED_RECTANGLE,
              Inches(x), Inches(y), Inches(w), Inches(h),
          )
          shape.fill.solid()
          shape.fill.fore_color.rgb = fill
          shape.line.color.rgb = border
          shape.line.width = Pt(line_pt)
          if dash:
              ln = shape.line._get_or_add_ln()
              # Remove any existing prstDash to keep idempotent
              for existing in ln.findall(qn("a:prstDash")):
                  ln.remove(existing)
              prst = etree.SubElement(ln, qn("a:prstDash"))
              prst.set("val", "dash")
      
          tf = shape.text_frame
          tf.word_wrap = True
          tf.margin_left = tf.margin_right = Inches(0.10)
          tf.margin_top = tf.margin_bottom = Inches(0.06)
          if anchor_middle:
              tf.vertical_anchor = MSO_ANCHOR.MIDDLE
      
          lines = str(text).split("\n")
          for i, line in enumerate(lines):
              para = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
              para.alignment = PP_ALIGN.CENTER if align_center else PP_ALIGN.LEFT
              run = para.add_run()
              run.text = line
              run.font.size = Pt(font_size)
              run.font.color.rgb = font_color
              run.font.bold = bool(bold_first and i == 0)
          return shape
      
      
      def add_arrow(slide, x1, y1, x2, y2, *, color=BLACK, width_pt=1.25):
          line = slide.shapes.add_connector(1, Inches(x1), Inches(y1), Inches(x2), Inches(y2))
          line.line.color.rgb = color
          line.line.width = Pt(width_pt)
          # Arrow head on the destination end
          line_xml = line.line._get_or_add_ln()
          tail = etree.SubElement(line_xml, qn("a:tailEnd"))
          tail.set("type", "triangle")
          tail.set("w", "med")
          tail.set("len", "med")
      
      
      def add_right_angle_arrow(slide, x1, y1, x2, y2, *, color=BLACK, width_pt=1.0):
          """Spine right edge → exclusion left edge with a horizontal stub then arrow."""
          # Use a single straight horizontal connector since both ends share y
          add_arrow(slide, x1, y1, x2, y2, color=color, width_pt=width_pt)
      
      
      # ── Loader ──────────────────────────────────────────────────────────────────
      def load_config(path: Path) -> dict:
          text = path.read_text(encoding="utf-8")
          if path.suffix in (".yaml", ".yml"):
              if not HAS_YAML:
                  sys.exit("PyYAML not installed; install or use JSON config.")
              return yaml.safe_load(text)
          if path.suffix == ".json":
              return json.loads(text)
          try:
              return json.loads(text)
          except json.JSONDecodeError:
              if HAS_YAML:
                  return yaml.safe_load(text)
              sys.exit("Config not JSON and PyYAML unavailable.")
      
      
      # ── Builder ────────────────────────────────────────────────────────────────
      def build(cfg: dict, out_path: Path) -> None:
          # Layout dimensions decided BEFORE slide creation so we can right-size the
          # canvas to the content (no large empty bottom strip).
          spine = cfg["spine"]
          n_spine = len(spine)
          spine_h = float(cfg.get("spine_box_height", DEFAULT_ROW_H))
          excl_h  = float(cfg.get("exclusion_box_height", DEFAULT_EXCL_H))
          title_text = cfg.get("title")
      
          # Phase column is optional. STROBE itself does not mandate one (PRISMA does);
          # most cohort papers omit it. Render only when the user supplies a non-empty
          # `stages:` list in the YAML config.
          stages_cfg = cfg.get("stages") or []
          has_phase_column = bool(stages_cfg)
      
          # Effective horizontal positions (collapse left margin when no phase column)
          if has_phase_column:
              spine_x_eff = SPINE_X
              excl_x_eff  = EXCL_X
          else:
              excl_gap = EXCL_X - SPINE_X - SPINE_W   # preserve the spine→excl gap
              spine_x_eff = PHASE_X
              excl_x_eff  = spine_x_eff + SPINE_W + excl_gap
      
          avail_top = TITLE_Y + TITLE_H + TOP_PAD if title_text else TITLE_Y
          row_pitch = spine_h + ROW_GAP
          last_row_end = avail_top + (n_spine - 1) * row_pitch + spine_h
          auto_h = last_row_end + BOTTOM_PAD
          auto_w = excl_x_eff + EXCL_W + PHASE_X      # right margin == left margin
      
          if "slide_size" in cfg:
              slide_w, slide_h = cfg["slide_size"]
          else:
              slide_w = max(auto_w, 4.0)
              slide_h = min(max(auto_h, 4.0), ABSOLUTE_MAX_SLIDE_H)
      
          # If the user gave a slide_size that's too short for the content, scale boxes down
          needed = last_row_end + BOTTOM_PAD
          if needed > slide_h:
              scale = (slide_h - avail_top - BOTTOM_PAD) / (n_spine * row_pitch - ROW_GAP)
              spine_h = max(0.7, spine_h * scale)
              excl_h  = max(0.6, excl_h * scale)
              row_pitch = spine_h + ROW_GAP
      
          prs = Presentation()
          prs.slide_width = Inches(slide_w)
          prs.slide_height = Inches(slide_h)
          blank_layout = prs.slide_layouts[6]
          slide = prs.slides.add_slide(blank_layout)
      
          # Title
          if title_text:
              title_box = slide.shapes.add_textbox(
                  Inches(PHASE_X), Inches(TITLE_Y),
                  Inches(slide_w - PHASE_X * 2), Inches(TITLE_H),
              )
              tf = title_box.text_frame
              p = tf.paragraphs[0]
              run = p.add_run()
              run.text = title_text
              run.font.size = Pt(13)
              run.font.bold = True
              run.font.color.rgb = NAVY
      
          spine_y = {}
          for i, b in enumerate(spine):
              spine_y[b["id"]] = avail_top + i * row_pitch
      
          # Phase column (optional) — group consecutive same-stage rows under one merged label.
          if has_phase_column:
              stage_color_lookup = {s["name"]: _parse_color(s.get("color"), NAVY) for s in stages_cfg}
              stages_seq = [b.get("stage") for b in spine]
              i = 0
              while i < n_spine:
                  j = i
                  while j + 1 < n_spine and stages_seq[j + 1] == stages_seq[i]:
                      j += 1
                  sname = stages_seq[i]
                  y_top = spine_y[spine[i]["id"]]
                  y_bot = spine_y[spine[j]["id"]] + spine_h
                  h = y_bot - y_top
                  stage_fill = stage_color_lookup.get(sname, NAVY)
                  add_box(
                      slide, PHASE_X, y_top, PHASE_W, h,
                      sname,
                      fill=stage_fill,
                      border=stage_fill,
                      font_color=_readable_text_color(stage_fill),
                      font_size=14,
                      bold_first=True,
                      line_pt=0.0,
                  )
                  i = j + 1
      
          # Spine boxes + arrows
          for k, b in enumerate(spine):
              y = spine_y[b["id"]]
              add_box(
                  slide, spine_x_eff, y, SPINE_W, spine_h,
                  b["text"],
                  fill=WHITE, border=BLACK, font_color=BLACK,
                  font_size=11, bold_first=True,
              )
              if k > 0:
                  prev_y = spine_y[spine[k - 1]["id"]]
                  add_arrow(
                      slide,
                      spine_x_eff + SPINE_W / 2, prev_y + spine_h,
                      spine_x_eff + SPINE_W / 2, y,
                  )
      
          # Exclusion boxes + connector arrows
          # Vertically center each exclusion on its spine row's mid-height so the
          # connector arrow can be strictly horizontal (no diagonal segments).
          for excl in cfg.get("exclusions", []):
              after_id = excl["after"]
              y_after = spine_y[after_id]
              y_mid = y_after + spine_h / 2
              y_excl_top = y_mid - excl_h / 2
              add_box(
                  slide, excl_x_eff, y_excl_top, EXCL_W, excl_h,
                  excl["text"],
                  fill=WHITE, border=BLACK, font_color=BLACK,
                  font_size=10, bold_first=False,
                  align_center=False,  # left-align text in exclusions
                  dash=True,           # dashed border to visually distinguish exclusion side-branches
              )
              # Strictly horizontal connector: both endpoints at y_mid
              add_arrow(
                  slide,
                  spine_x_eff + SPINE_W, y_mid,
                  excl_x_eff,            y_mid,
              )
      
          out_path.parent.mkdir(parents=True, exist_ok=True)
          prs.save(out_path)
      
      
      # ── CLI ─────────────────────────────────────────────────────────────────────
      def main():
          p = argparse.ArgumentParser(description="Build an editable STROBE flow diagram .pptx from a YAML config.")
          p.add_argument("--config", required=True, type=Path, help="YAML/JSON STROBE config")
          p.add_argument("--out",    required=True, type=Path, help="Output .pptx path")
          p.add_argument("--strict-cascade", action="store_true",
                         help="refuse to build if the exclusion cascade does not close "
                              "(A - sum(exclusions after A) != next box)")
          args = p.parse_args()
      
          cfg = load_config(args.config)
          if not isinstance(cfg, dict):
              sys.exit(f"Config root must be a mapping; got {type(cfg)}")
      
          # The numbers a reviewer sees are in the figure, not the prose — assert the cascade closes
          # before rendering. Warn loudly by default; refuse under --strict-cascade.
          cascade = check_cascade(cfg)
          for f in cascade:
              sys.stderr.write(f"⚠️  {f['detail']}\n")
          if cascade and args.strict_cascade:
              sys.exit("STROBE cascade does not close; refusing to build (drop --strict-cascade "
                       "to build anyway).")
      
          build(cfg, args.out)
          print(f"Wrote {args.out}")
      
      
      if __name__ == "__main__":
          main()
      
    • critic_figure.py 9.3 KB
      #!/usr/bin/env python3
      """critic_figure.py — Quantitative checks for make-figures skill output.
      
      Part of the medsci-skills Critic Loop (Phase 1). This script runs deterministic
      checks that do NOT require a language model:
      
        - DPI and physical dimensions vs. journal specification
        - Dominant-color analysis against the Wong colorblind-safe palette
        - OCR text extraction for minimum font-size estimation and
          optional coverage comparison against a source-text file
      
      The qualitative side of the Critic Loop (layout balance, hierarchy,
      readability, exemplar comparison) is handled by the Claude session afterward
      using the rubrics in references/critic_rubrics/.
      
      Usage:
          python critic_figure.py figures/fig1_stard.png \
              --type stard --spec-min-dpi 600 --spec-width-in 7.0 \
              --source-text figures/fig1_stard.txt \
              --out figures/fig1_stard.critique.json
      """
      from __future__ import annotations
      
      import argparse
      import json
      import sys
      from pathlib import Path
      from typing import Optional
      
      from PIL import Image
      
      WONG_PALETTE: dict[str, tuple[int, int, int]] = {
          "black":          (0, 0, 0),
          "orange":         (230, 159, 0),
          "sky_blue":       (86, 180, 233),
          "bluish_green":   (0, 158, 115),
          "yellow":         (240, 228, 66),
          "blue":           (0, 114, 178),
          "vermillion":     (213, 94, 0),
          "reddish_purple": (204, 121, 167),
      }
      NEUTRAL_TOLERANCE = 25
      PALETTE_DISTANCE_THRESHOLD = 60
      DOMINANT_COLOR_TOP_N = 12
      DOMINANT_COLOR_MIN_FRACTION = 0.002
      OUT_OF_PALETTE_TOLERANCE = 0.15
      MIN_READABLE_PX = 14
      
      
      def color_distance(c1, c2) -> float:
          return sum((a - b) ** 2 for a, b in zip(c1, c2)) ** 0.5
      
      
      def classify_color(rgb) -> str:
          r, g, b = rgb
          if max(r, g, b) - min(r, g, b) < NEUTRAL_TOLERANCE:
              return "neutral"
          best_name, best_dist = None, float("inf")
          for name, palette_rgb in WONG_PALETTE.items():
              d = color_distance(rgb, palette_rgb)
              if d < best_dist:
                  best_name, best_dist = name, d
          if best_dist < PALETTE_DISTANCE_THRESHOLD:
              return f"wong:{best_name}"
          return "out_of_palette"
      
      
      def check_palette(img: Image.Image) -> dict:
          rgb_img = img.convert("RGB")
          total = rgb_img.size[0] * rgb_img.size[1]
          quant = rgb_img.quantize(colors=64).convert("RGB")
          pixels = quant.getcolors(maxcolors=64 * 64)
          if not pixels:
              return {"passed": None, "note": "too many colors to quantize"}
      
          dominant = []
          out_frac = 0.0
          for count, rgb in sorted(pixels, key=lambda x: -x[0])[:DOMINANT_COLOR_TOP_N]:
              fraction = count / total
              if fraction < DOMINANT_COLOR_MIN_FRACTION:
                  continue
              label = classify_color(rgb)
              dominant.append({"rgb": list(rgb), "fraction": round(fraction, 4), "class": label})
              if label == "out_of_palette":
                  out_frac += fraction
      
          return {
              "passed": out_frac < OUT_OF_PALETTE_TOLERANCE,
              "out_of_palette_fraction": round(out_frac, 4),
              "tolerance": OUT_OF_PALETTE_TOLERANCE,
              "dominant_colors": dominant,
          }
      
      
      def check_dimensions(img: Image.Image, spec: dict) -> dict:
          w_px, h_px = img.size
          dpi = img.info.get("dpi", (None, None))
          dpi_x = dpi[0] if dpi and dpi[0] else None
          dpi_y = dpi[1] if dpi and dpi[1] else None
          result = {
              "width_px": w_px,
              "height_px": h_px,
              "dpi_x": dpi_x,
              "dpi_y": dpi_y,
          }
          min_dpi = spec.get("min_dpi")
          width_in = spec.get("width_in")
          if min_dpi and dpi_x:
              result["dpi_meets_spec"] = dpi_x >= min_dpi
              result["required_dpi"] = min_dpi
          if width_in and dpi_x:
              actual_width_in = w_px / dpi_x
              result["width_in"] = round(actual_width_in, 2)
              result["width_matches_spec"] = abs(actual_width_in - width_in) < 0.3
              result["required_width_in"] = width_in
          return result
      
      
      def check_ocr(image_path: Path, source_text: Optional[str] = None) -> dict:
          try:
              import pytesseract
          except ImportError:
              return {"passed": None, "note": "pytesseract not installed; install with `pip install pytesseract` (tesseract binary already at /opt/homebrew/bin/tesseract)"}
          try:
              data = pytesseract.image_to_data(
                  str(image_path), output_type=pytesseract.Output.DICT
              )
          except Exception as exc:
              return {"passed": None, "note": f"tesseract failed: {exc}"}
      
          words = []
          heights = []
          for i, word in enumerate(data.get("text", [])):
              if not word.strip():
                  continue
              conf_raw = data["conf"][i]
              try:
                  conf = int(conf_raw)
              except (TypeError, ValueError):
                  conf = -1
              if conf < 50:
                  continue
              words.append(word.strip())
              heights.append(data["height"][i])
      
          result: dict = {
              "word_count": len(words),
          }
          if heights:
              heights_sorted = sorted(heights)
              result["median_word_height_px"] = heights_sorted[len(heights_sorted) // 2]
              result["min_word_height_px"] = min(heights)
              result["min_readable_threshold_px"] = MIN_READABLE_PX
      
          if source_text:
              source_words = {w.lower().strip(".,:;()[]") for w in source_text.split() if len(w) > 2}
              ocr_words = {w.lower().strip(".,:;()[]") for w in words if len(w) > 2}
              missing = sorted(source_words - ocr_words)
              result["missing_source_words"] = missing[:20]
              result["missing_source_word_count"] = len(missing)
              result["source_word_coverage"] = round(
                  len(source_words & ocr_words) / max(1, len(source_words)), 2
              )
          return result
      
      
      def critique(
          image_path: Path,
          figure_type: Optional[str] = None,
          journal: Optional[str] = None,
          source_text: Optional[str] = None,
          spec: Optional[dict] = None,
      ) -> dict:
          img = Image.open(image_path)
          spec = spec or {}
          checks = {
              "dimensions": check_dimensions(img, spec),
              "palette": check_palette(img),
              "ocr": check_ocr(image_path, source_text=source_text),
          }
      
          flags = []
          dim = checks["dimensions"]
          if dim.get("dpi_meets_spec") is False:
              flags.append(
                  f"DPI below journal spec ({dim.get('dpi_x')} < {dim.get('required_dpi')})"
              )
          if dim.get("width_matches_spec") is False:
              flags.append(
                  f"Width deviates from journal spec ({dim.get('width_in')} in vs {dim.get('required_width_in')} in)"
              )
          pal = checks["palette"]
          if pal.get("passed") is False:
              flags.append(
                  f"{pal['out_of_palette_fraction'] * 100:.1f}% pixels outside Wong palette (tolerance {OUT_OF_PALETTE_TOLERANCE * 100:.0f}%)"
              )
          ocr = checks["ocr"]
          min_h = ocr.get("min_word_height_px")
          if min_h and min_h < MIN_READABLE_PX:
              flags.append(f"Smallest OCR text only {min_h}px tall (<{MIN_READABLE_PX}px threshold)")
          if ocr.get("missing_source_word_count") and ocr["missing_source_word_count"] > 3:
              flags.append(
                  f"{ocr['missing_source_word_count']} source words not detected by OCR (possible cropping/truncation)"
              )
      
          return {
              "image": str(image_path),
              "figure_type": figure_type,
              "journal": journal,
              "checks": checks,
              "flags": flags,
              "summary": "PASS" if not flags else f"{len(flags)} issue(s) flagged",
          }
      
      
      def main() -> int:
          p = argparse.ArgumentParser(description="Quantitative figure critic (medsci-skills Critic Loop, Phase 1).")
          p.add_argument("image", help="Path to generated figure (PNG)")
          p.add_argument(
              "--type", dest="figure_type",
              choices=[
                  "stard", "consort", "prisma", "pipeline",
                  "roc", "forest", "km", "calibration",
                  "bland_altman", "confusion_matrix",
                  "visual_abstract", "other",
              ],
          )
          p.add_argument("--journal", help="Journal key (e.g., radiology, radiology_ai, eur_radiol)")
          p.add_argument("--source-text", help="Path to text file with expected source strings for OCR coverage check")
          p.add_argument("--spec-width-in", type=float, help="Expected figure width in inches (per figure_specs.md)")
          p.add_argument("--spec-min-dpi", type=int, help="Minimum DPI (600 for line art, 300 for halftone)")
          p.add_argument("--out", default=None, help="Path to JSON report (default: {image}.critique.json)")
          args = p.parse_args()
      
          image_path = Path(args.image)
          if not image_path.exists():
              print(f"ERROR: {image_path} not found", file=sys.stderr)
              return 2
      
          source_text = None
          if args.source_text:
              source_path = Path(args.source_text)
              if not source_path.exists():
                  print(f"WARN: source text {source_path} not found; skipping OCR coverage check", file=sys.stderr)
              else:
                  source_text = source_path.read_text(encoding="utf-8", errors="ignore")
      
          report = critique(
              image_path,
              figure_type=args.figure_type,
              journal=args.journal,
              source_text=source_text,
              spec={"width_in": args.spec_width_in, "min_dpi": args.spec_min_dpi},
          )
          out_path = Path(args.out) if args.out else image_path.with_suffix(image_path.suffix + ".critique.json")
          out_path.write_text(json.dumps(report, indent=2, ensure_ascii=False))
      
          print(json.dumps({"summary": report["summary"], "flags": report["flags"]}, indent=2, ensure_ascii=False))
          print(f"\nFull report: {out_path}", file=sys.stderr)
          return 0 if not report["flags"] else 1
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • derive_figure_legend_counts.py 5.4 KB
      #!/usr/bin/env python3
      """Reconcile a flow-diagram figure caption against the flow-diagram SSOT.
      
      A recurring submission error: the Figure 1 caption states participant counts
      ("n = 1,284 assessed ... n = 998 in the analytic cohort") that disagree with the
      boxes of the flow diagram itself. It happens because the flow-diagram config (the
      SSOT, e.g. `figure1_strobe_graphviz.yaml` consumed by generate_flow_diagram.R) is
      updated for a final cohort lock while the hand-written caption is not. This script
      re-derives the counts from the flow config and flags any `n = N` in the caption
      that the diagram does not contain.
      
      INPUTS
        --flow-config   the flow-diagram config file (YAML / R / text) whose box labels
                        carry the counts. Parsed as raw text (regex), so no YAML
                        dependency and it works for any flow-tool config.
        --manuscript    manuscript markdown (the Figure 1 caption is located by header)
                        OR --caption to pass the caption text directly.
      
      OUTPUT  (--out path)
        {flow_counts, caption_counts, stale_in_caption, missing_in_caption, verdict}
        `stale_in_caption` (a caption count absent from the flow SSOT) is the Major
        finding. Exit 1 (with --strict) when any stale count exists.
      
      Stdlib-only (re / json / argparse). Exit codes: 0 clean (or report-only),
      1 caption count not in the flow SSOT (with --strict), 2 input/usage error.
      """
      
      from __future__ import annotations
      
      import argparse
      import json
      import re
      import sys
      from pathlib import Path
      
      # "n = 1,284", "n=998", "N = 1284" — the standard flow-box / caption notation.
      N_RE = re.compile(r"\bn\s*=\s*([0-9][0-9,]*)", re.I)
      
      CAPTION_HDR_RE = re.compile(
          r"^#{0,4}\s*\**\s*(Figure\s*1|Fig\.?\s*1|Figure\s+1\.)\b", re.I | re.M
      )
      
      
      def _ints(text: str) -> list[int]:
          out = []
          for m in N_RE.finditer(text):
              try:
                  out.append(int(m.group(1).replace(",", "")))
              except ValueError:
                  pass
          return out
      
      
      def extract_caption(manuscript_text: str) -> str:
          """Return the Figure 1 caption block (header line to the next blank line / header)."""
          m = CAPTION_HDR_RE.search(manuscript_text)
          if not m:
              return ""
          start = m.start()
          rest = manuscript_text[m.end():]
          # caption ends at the next blank line followed by a header, or a new "Figure N"
          end_rel = len(rest)
          nxt = re.search(r"\n\s*\n|^#{1,4}\s|\bFigure\s*[2-9]\b", rest, re.M)
          if nxt:
              end_rel = nxt.start()
          return manuscript_text[start:m.end() + end_rel]
      
      
      def main() -> int:
          ap = argparse.ArgumentParser(description="Reconcile Figure 1 caption counts against the flow SSOT.")
          ap.add_argument("--flow-config", required=True, help="flow-diagram config (YAML/R/text)")
          ap.add_argument("--manuscript", help="manuscript markdown (Figure 1 caption auto-located)")
          ap.add_argument("--caption", help="caption text directly (alternative to --manuscript)")
          ap.add_argument("--out", help="write JSON artifact to this path")
          ap.add_argument("--strict", action="store_true", help="exit 1 if a caption count is not in the SSOT")
          args = ap.parse_args()
      
          fc = Path(args.flow_config)
          if not fc.is_file():
              sys.stderr.write(f"ERROR: flow config not found: {args.flow_config}\n")
              return 2
          flow_counts = sorted(set(_ints(fc.read_text(encoding="utf-8"))))
      
          if args.caption:
              caption = args.caption
          elif args.manuscript:
              mp = Path(args.manuscript)
              if not mp.is_file():
                  sys.stderr.write(f"ERROR: manuscript not found: {args.manuscript}\n")
                  return 2
              caption = extract_caption(mp.read_text(encoding="utf-8"))
              if not caption:
                  sys.stderr.write("WARN: no 'Figure 1' caption located; pass --caption to override.\n")
          else:
              sys.stderr.write("ERROR: provide --manuscript or --caption\n")
              return 2
      
          caption_counts = sorted(set(_ints(caption)))
          flow_set = set(flow_counts)
          stale = [n for n in caption_counts if n not in flow_set]          # caption cites a number the diagram lacks
          missing = [n for n in flow_counts if n not in set(caption_counts)]  # diagram box not mentioned in caption
      
          result = {
              "flow_config": str(fc),
              "flow_counts": flow_counts,
              "caption_counts": caption_counts,
              "stale_in_caption": stale,
              "missing_in_caption": missing,
              "verdict": "MISMATCH" if stale else "OK",
              "suggested_fix": (
                  "Re-derive the caption counts from the flow-diagram config and update the caption; "
                  "the flow diagram is the single source of truth."
              ) if stale else None,
          }
      
          print("=" * 41)
          print(" Figure 1 caption ↔ flow SSOT reconciliation")
          print("=" * 41)
          print(f"flow counts (SSOT): {flow_counts}")
          print(f"caption counts:     {caption_counts}")
          if stale:
              print(f"\nMISMATCH: caption cites {stale} not present in the flow diagram.")
              print(result["suggested_fix"])
          else:
              print("\nOK: every caption count is present in the flow diagram.")
          if missing:
              print(f"(note: flow boxes not mentioned in the caption: {missing})")
      
          if args.out:
              Path(args.out).parent.mkdir(parents=True, exist_ok=True)
              Path(args.out).write_text(json.dumps({"detector": "derive_figure_legend_counts", **result}, indent=2), encoding="utf-8")
              print(f"wrote {args.out}")
      
          return 1 if (args.strict and stale) else 0
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • export_portal_tiff.py 7.5 KB
      #!/usr/bin/env python3
      """Export a figure to a portal-ready TIFF — LZW-compressed, RGBA→RGB white-flattened.
      
      Why this exists. Two submission-portal facts collide on figure upload:
      
        1. Some portals accept only a fixed raster set and NOT PNG. Springer Nature's SNAPP,
           for one, takes `.jpeg` / `.tiff` / `.eps` — a PNG has to be converted on the spot.
        2. A portal caps figure size (JACC: Asia rejects a figure over 25 MB). A raw,
           uncompressed 600-dpi RGBA TIFF blows straight past that; the *same* image saved
           LZW-compressed with the alpha channel flattened away is a fraction of the size and
           pixel-identical.
      
      The naive conversion also introduces a print defect: a TIFF that keeps an alpha channel
      renders the transparent regions BLACK on many print/production pipelines. Flattening the
      alpha onto a white background (the paper) is what a human does by hand in Photoshop; this
      does it deterministically and then PROVES the result is pixel-identical to that flatten
      before it hands you the file.
      
      What it does:
        * opens the input raster (any PIL-readable format — PNG, TIFF, BMP, …);
        * if it carries alpha/transparency (RGBA / LA / palette-with-transparency), composites
          it onto a solid background (white by default) to get RGB; a plain RGB/L image is kept;
        * saves TIFF with LZW compression (lossless), preserving dpi;
        * VERIFIES the output by independently re-flattening the source and comparing bytes —
          refuses (exit 1) if the produced TIFF is not pixel-identical to the expected flatten;
        * reports the before/after byte size and, with --max-mb, refuses an output that still
          exceeds the portal cap (so the failure surfaces here, not at the upload button).
      
      This is a figure PRODUCER (like render_core_figures.py), not a manuscript detector. It
      requires Pillow — the same runtime dependency every raster figure helper in this skill
      already has — and does nothing over the network.
      
      Usage:
        export_portal_tiff.py --in figure.png                    # -> figure.tiff (white bg, LZW)
        export_portal_tiff.py --in fig.png --out fig.tiff --max-mb 25
        export_portal_tiff.py --in fig.png --background 255,255,255 --dpi 600
      
      Exit codes: 0 success (verified), 1 verification mismatch / over the --max-mb cap,
      2 input/usage error (missing file, unreadable image, Pillow absent).
      """
      
      from __future__ import annotations
      
      import argparse
      import sys
      from pathlib import Path
      
      # A palette image can carry per-pixel transparency via a `transparency` info key.
      ALPHA_MODES = ("RGBA", "LA", "PA", "La")
      
      
      def _load_pillow():
          try:
              from PIL import Image  # noqa: F401
          except ImportError:
              sys.stderr.write(
                  "ERROR: Pillow is required (pip install Pillow) — the same dependency the "
                  "other raster figure helpers in /make-figures use.\n")
              sys.exit(2)
          from PIL import Image
          return Image
      
      
      def _has_alpha(img) -> bool:
          """True if the image carries per-pixel transparency that a flatten must resolve."""
          if img.mode in ALPHA_MODES:
              return True
          # A palette (P) or grayscale image can still declare a transparent colour.
          return "transparency" in img.info
      
      
      def flatten_to_rgb(img, background):
          """Return an RGB copy with any alpha composited onto `background` (an (R,G,B) tuple).
          A plain RGB/grayscale image (no transparency) is converted straight to RGB. This is the
          single definition of the flatten, used both to produce the output and to verify it."""
          if not _has_alpha(img):
              return img.convert("RGB")
          rgba = img.convert("RGBA")
          Image = _load_pillow()
          bg = Image.new("RGB", rgba.size, background)
          bg.paste(rgba, mask=rgba.split()[-1])  # last band is alpha
          return bg
      
      
      def _parse_bg(s: str):
          parts = s.split(",")
          if len(parts) != 3:
              raise argparse.ArgumentTypeError("background must be R,G,B (e.g. 255,255,255)")
          try:
              vals = tuple(int(p) for p in parts)
          except ValueError:
              raise argparse.ArgumentTypeError("background components must be integers 0-255")
          if not all(0 <= v <= 255 for v in vals):
              raise argparse.ArgumentTypeError("background components must be 0-255")
          return vals
      
      
      def export(in_path: Path, out_path: Path, background, dpi, max_mb):
          Image = _load_pillow()
          try:
              src = Image.open(in_path)
              src.load()
          except Exception as e:  # noqa: BLE001 — PIL raises a variety of decode errors
              sys.stderr.write(f"ERROR: could not open image {in_path}: {e}\n")
              sys.exit(2)
      
          expected = flatten_to_rgb(src, background)
      
          save_kwargs = {"format": "TIFF", "compression": "tiff_lzw"}
          src_dpi = dpi or src.info.get("dpi")
          if src_dpi:
              save_kwargs["dpi"] = tuple(src_dpi) if not isinstance(src_dpi, (int, float)) else (src_dpi, src_dpi)
          out_path.parent.mkdir(parents=True, exist_ok=True)
          expected.save(out_path, **save_kwargs)
      
          # Verify: reopen the produced TIFF and require it to be pixel-identical to the flatten,
          # LZW-compressed, and alpha-free. A silent decode surprise fails here, not at upload.
          with Image.open(out_path) as produced:
              produced.load()
              compression = produced.tag_v2.get(259) if hasattr(produced, "tag_v2") else None
              prod_rgb = produced.convert("RGB")
              identical = prod_rgb.tobytes() == expected.tobytes()
      
          problems = []
          if compression != 5:  # TIFF Compression tag: 5 == LZW
              problems.append(f"output is not LZW-compressed (Compression tag={compression}, expected 5)")
          if not identical:
              problems.append("output TIFF is NOT pixel-identical to the white-flattened source")
      
          in_mb = in_path.stat().st_size / (1024 * 1024)
          out_mb = out_path.stat().st_size / (1024 * 1024)
          if max_mb is not None and out_mb > max_mb:
              problems.append(f"output is {out_mb:.1f} MB, over the --max-mb {max_mb} portal cap")
      
          print("=" * 52)
          print(" Portal TIFF export")
          print("=" * 52)
          print(f"  in:          {in_path}  ({in_mb:.2f} MB, mode {src.mode})")
          print(f"  out:         {out_path}  ({out_mb:.2f} MB, mode RGB, LZW)")
          print(f"  flattened:   {'alpha composited onto ' + str(background) if _has_alpha(src) else 'no alpha (RGB kept)'}")
          print(f"  pixel-check: {'identical to source flatten' if identical else 'MISMATCH'}")
          if problems:
              print("\nFAIL: " + "; ".join(problems))
              return 1
          print("\nOK: portal-ready TIFF (LZW, RGB, pixel-identical to source).")
          return 0
      
      
      def main() -> int:
          ap = argparse.ArgumentParser(
              description="Export a figure to a portal-ready TIFF (LZW, RGBA→RGB white-flatten).")
          ap.add_argument("--in", dest="inp", required=True, help="input raster image (PNG/TIFF/…)")
          ap.add_argument("--out", default=None, help="output .tiff (default: input stem + .tiff)")
          ap.add_argument("--background", type=_parse_bg, default=(255, 255, 255),
                          help="RGB fill for transparent regions (default 255,255,255 = white)")
          ap.add_argument("--dpi", type=int, default=None, help="override dpi (default: keep source dpi)")
          ap.add_argument("--max-mb", type=float, default=None,
                          help="refuse (exit 1) if the output still exceeds this many MB (e.g. 25 for a portal cap)")
          args = ap.parse_args()
      
          in_path = Path(args.inp)
          if not in_path.is_file():
              sys.stderr.write(f"ERROR: input not found: {in_path}\n")
              return 2
          out_path = Path(args.out) if args.out else in_path.with_suffix(".tiff")
          return export(in_path, out_path, args.background, args.dpi, args.max_mb)
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • extract_exemplar_from_pdf.py 6.8 KB
      #!/usr/bin/env python3
      r"""extract_exemplar_from_pdf.py — Extract a figure region from a PDF page to
      build the exemplar reference set used by the make-figures Critic Loop.
      
      Renders a single PDF page at 300 DPI (configurable), optionally crops a
      sub-region, and writes the PNG + a YAML metadata sidecar + a _why.md stub
      into references/exemplar_diagrams/{type}/.
      
      Example:
          python extract_exemplar_from_pdf.py \
              --pdf ~/Zotero/storage/ABCD/Yan2017.pdf \
              --page 3 \
              --type stard \
              --label Yan2017_STARD \
              --doi 10.1148/radiol.2017170371 \
              --crop 0.05,0.1,0.95,0.6 \
              --dpi 300
      """
      from __future__ import annotations
      
      import argparse
      import sys
      from pathlib import Path
      from typing import Optional
      
      import fitz  # PyMuPDF
      
      DEFAULT_EXEMPLAR_DIR = Path(__file__).resolve().parent.parent / "references" / "exemplar_diagrams"
      VALID_TYPES = {"stard", "consort", "prisma", "pipeline", "roc", "forest", "km", "other"}
      
      
      def render_page(pdf_path: Path, page_num: int, dpi: int, crop: Optional[tuple] = None) -> bytes:
          doc = fitz.open(pdf_path)
          try:
              if page_num < 1 or page_num > doc.page_count:
                  raise ValueError(f"Page {page_num} out of range (1-{doc.page_count})")
              page = doc[page_num - 1]
              zoom = dpi / 72.0
              matrix = fitz.Matrix(zoom, zoom)
              if crop:
                  rect = page.rect
                  x0, y0, x1, y1 = crop
                  clip = fitz.Rect(
                      rect.x0 + rect.width * x0,
                      rect.y0 + rect.height * y0,
                      rect.x0 + rect.width * x1,
                      rect.y0 + rect.height * y1,
                  )
                  pix = page.get_pixmap(matrix=matrix, clip=clip, alpha=False)
              else:
                  pix = page.get_pixmap(matrix=matrix, alpha=False)
              return pix.tobytes("png")
          finally:
              doc.close()
      
      
      def parse_crop(s: str) -> tuple:
          parts = [float(p.strip()) for p in s.split(",")]
          if len(parts) != 4:
              raise argparse.ArgumentTypeError("crop must be 'x0,y0,x1,y1' as fractions 0.0–1.0")
          for v in parts:
              if not 0.0 <= v <= 1.0:
                  raise argparse.ArgumentTypeError(f"crop fraction {v} out of range 0.0–1.0")
          if parts[0] >= parts[2] or parts[1] >= parts[3]:
              raise argparse.ArgumentTypeError("crop x0<x1 and y0<y1 required")
          return tuple(parts)
      
      
      def write_metadata(meta_path: Path, **fields) -> None:
          lines = ["# Exemplar metadata (YAML)"]
          for k, v in fields.items():
              if v is None:
                  continue
              if isinstance(v, str):
                  lines.append(f'{k}: "{v}"')
              elif isinstance(v, (list, tuple)):
                  lines.append(f"{k}: {list(v)}")
              else:
                  lines.append(f"{k}: {v}")
          meta_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
      
      
      def write_why_stub(why_path: Path, label: str, figure_type: str) -> None:
          if why_path.exists():
              return  # don't clobber existing notes
          why_path.write_text(
              f"""# Why this exemplar is good — {label} ({figure_type})
      
      <!-- 50–100 words on why this figure is a quality anchor. Fill in during curation. -->
      
      Hierarchy / structure:
      
      Whitespace & balance:
      
      Typography (font size, weight, alignment):
      
      Emphasis (which elements are visually strongest, why):
      
      Color usage:
      
      Weaknesses (if any — nothing is perfect):
      """,
              encoding="utf-8",
          )
      
      
      def extract_one(
          pdf: Path,
          page: int,
          figure_type: str,
          label: str,
          doi: str,
          license_: str,
          crop: Optional[tuple],
          dpi: int,
          exemplar_dir: Path,
      ) -> Path:
          if figure_type not in VALID_TYPES:
              raise ValueError(f"type must be one of {sorted(VALID_TYPES)}, got {figure_type}")
          target_dir = exemplar_dir / figure_type
          target_dir.mkdir(parents=True, exist_ok=True)
      
          png_bytes = render_page(pdf, page, dpi, crop)
          safe_label = label.replace("/", "_").replace(" ", "_")
          png_path = target_dir / f"{safe_label}.png"
          meta_path = target_dir / f"{safe_label}.meta.yaml"
          why_path = target_dir / f"{safe_label}_why.md"
      
          png_path.write_bytes(png_bytes)
          write_metadata(
              meta_path,
              label=label,
              figure_type=figure_type,
              source_pdf=str(pdf),
              page=page,
              crop=list(crop) if crop else None,
              dpi=dpi,
              doi=doi,
              license=license_,
          )
          write_why_stub(why_path, label, figure_type)
      
          return png_path
      
      
      def main() -> int:
          p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
          p.add_argument("--pdf", required=True, type=Path, help="Path to source PDF")
          p.add_argument("--page", required=True, type=int, help="1-based page number")
          p.add_argument("--type", dest="figure_type", required=True, choices=sorted(VALID_TYPES),
                         help="Exemplar category (subdirectory under exemplar_diagrams/)")
          p.add_argument("--label", required=True, help="Short label, used as filename stem (e.g., Yan2017_STARD)")
          # --doi used to be optional, and that is exactly how ten figures ended up in a public,
          # MIT-licensed package with no record of whose they were. A tool that CAN produce an
          # unattributable exemplar eventually will.
          p.add_argument("--doi", required=True, help="DOI of the source paper. Required — no exception.")
          p.add_argument(
              "--license", required=True, dest="license_",
              help="The licence of the FIGURE (not the paper): CC-BY-4.0, CC0, public-domain, own-work. "
                   "If you do not know it, you do not have permission to ship it — keep the exemplar "
                   "local and uncommitted instead.",
          )
          p.add_argument("--crop", default=None, type=parse_crop,
                         help="Optional crop as 'x0,y0,x1,y1' fractions 0.0–1.0")
          p.add_argument("--dpi", type=int, default=300, help="Render DPI (default: 300)")
          p.add_argument("--exemplar-dir", type=Path, default=DEFAULT_EXEMPLAR_DIR,
                         help="Base exemplar directory (default: references/exemplar_diagrams/)")
          args = p.parse_args()
      
          if not args.pdf.exists():
              print(f"ERROR: PDF not found at {args.pdf}", file=sys.stderr)
              return 2
      
          try:
              out = extract_one(
                  pdf=args.pdf,
                  page=args.page,
                  figure_type=args.figure_type,
                  label=args.label,
                  doi=args.doi,
                  license_=args.license_,
                  crop=args.crop,
                  dpi=args.dpi,
                  exemplar_dir=args.exemplar_dir,
              )
          except Exception as exc:
              print(f"ERROR: {exc}", file=sys.stderr)
              return 1
      
          print(f"Extracted: {out}")
          print(f"Metadata:  {out.with_suffix('')}.meta.yaml")
          print(f"Why stub:  {out.with_name(out.stem + '_why.md')}")
          print("\nNext: open the _why.md and add a 50–100 word note on why this figure is a quality anchor.")
          return 0
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • fetch_official_templates.sh 4.2 KB
      #!/usr/bin/env bash
      # Fetch official reporting guideline flow diagram / figure templates from
      # the canonical statement sites (PRISMA, CONSORT, STARD, SPIRIT).
      #
      # Idempotent: skips downloads when target already exists with non-zero size.
      # Network failures are non-fatal; the script reports per-target status at the end.
      #
      # Usage:
      #   bash scripts/fetch_official_templates.sh                  # fetch all
      #   bash scripts/fetch_official_templates.sh prisma2020       # one target
      #   FORCE=1 bash scripts/fetch_official_templates.sh          # re-download
      set -uo pipefail
      
      SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
      DEST_ROOT="${SKILL_DIR}/templates/official"
      mkdir -p "${DEST_ROOT}"
      
      UA='Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Claude-Code-medsci-skills/1.0'
      
      # target_id|filename|url
      # URLs verified 2026-04-27. PRISMA 2020 from prismastatement.org/s/.
      # CONSORT/SPIRIT migrated to consort-spirit.org (formerly consort-statement.org +
      # spirit-statement.org). STARD provides PDF only — checklist .docx fetched as
      # the closest official Word artifact; flow diagram remains PDF.
      TARGETS=(
        # PRISMA 2020 — both new-SR and updated-SR, both v1 (databases/registers only)
        # and v2 (incl. other sources). Official .docx, CC-BY 4.0.
        "prisma2020|PRISMA_2020_flow_new_SR_v1.docx|https://prismastatement.org/s/PRISMA_2020_flow_diagram_new_SRs_v1-lml8.docx"
        "prisma2020|PRISMA_2020_flow_new_SR_v2.docx|https://prismastatement.org/s/PRISMA_2020_flow_diagram_new_SRs_v2-t3jp.docx"
        "prisma2020|PRISMA_2020_flow_updated_SR_v1.docx|https://prismastatement.org/s/PRISMA_2020_flow_diagram_updated_SRs_v1-f8ly.docx"
        "prisma2020|PRISMA_2020_flow_updated_SR_v2.docx|https://prismastatement.org/s/PRISMA_2020_flow_diagram_updated_SRs_v2-dbrh.docx"
      
        # CONSORT 2025 (supersedes 2010) — main flow diagram + editable checklist
        "consort2010|CONSORT_2025_flow_diagram.docx|https://www.consort-spirit.org/_files/ugd/b5740e_019fabb52c9a4894abb681afcbff41f8.docx?dn=CONSORT_2025_flow_diagram.docx"
        "consort2010|CONSORT_2025_editable_checklist.docx|https://www.consort-spirit.org/_files/ugd/b628c8_6fa59b2c6db04efb9a9d0bc967a9fcf7.docx?dn=CONSORT%20%202025%20editable%20checklist.docx"
      
        # STARD 2015 — flow diagram is published as PDF only (no official Word source).
        # Ship the checklist .docx + the flow PDF as the canonical bundle.
        "stard2015|STARD_2015_flow_diagram.pdf|https://www.equator-network.org/wp-content/uploads/2015/03/STARD-2015-flow-diagram.pdf"
        "stard2015|STARD_2015_checklist.docx|https://www.equator-network.org/wp-content/uploads/2015/10/STARD-2015-Checklist.docx"
      
        # SPIRIT 2025 (supersedes 2013) — participant timeline + editable checklist
        "spirit2013|SPIRIT_2025_participant_timeline.docx|https://www.consort-spirit.org/_files/ugd/b5740e_5fad4e7a16bf44f6a2a0a809ad2a2e9d.docx?dn=SPIRIT%202025%20participant%20timeline.docx"
        "spirit2013|SPIRIT_2025_editable_checklist.docx|https://www.consort-spirit.org/_files/ugd/b5740e_667c45b02102408ab983c9704525597b.docx?dn=SPIRIT%202025%20editable%20checklist.docx"
      )
      
      WANT="${1:-all}"
      FORCE="${FORCE:-0}"
      
      declare -a ok_list fail_list skip_list
      
      for entry in "${TARGETS[@]}"; do
        IFS='|' read -r tid fname url <<<"${entry}"
        if [[ "${WANT}" != "all" && "${WANT}" != "${tid}" ]]; then
          continue
        fi
        out="${DEST_ROOT}/${tid}/${fname}"
        if [[ "${FORCE}" != "1" && -s "${out}" ]]; then
          skip_list+=("${tid}/${fname}")
          continue
        fi
        echo "→ ${tid}/${fname}"
        if curl -fsSL --retry 2 --max-time 30 -A "${UA}" -o "${out}.tmp" "${url}"; then
          if [[ -s "${out}.tmp" ]]; then
            mv "${out}.tmp" "${out}"
            ok_list+=("${tid}/${fname}")
          else
            rm -f "${out}.tmp"
            fail_list+=("${tid}/${fname} (empty)")
          fi
        else
          rm -f "${out}.tmp"
          fail_list+=("${tid}/${fname} (HTTP)")
        fi
      done
      
      echo
      echo "── fetch summary ─────────────────────────────"
      printf 'OK   : %s\n' "${ok_list[@]:-(none)}"
      printf 'SKIP : %s\n' "${skip_list[@]:-(none)}"
      printf 'FAIL : %s\n' "${fail_list[@]:-(none)}"
      
      if [[ ${#fail_list[@]} -gt 0 ]]; then
        echo
        echo "Some downloads failed. Statement sites occasionally rotate URLs;"
        echo "verify at: https://www.equator-network.org/reporting-guidelines/"
        exit 2
      fi
      
    • fill_prisma_template.py 4.7 KB
      #!/usr/bin/env python3
      """Populate count placeholders in a PRISMA 2020 .pptx template.
      
      Reads a template built by ``build_prisma2020_template.py`` (or any .pptx whose
      text frames contain ``{key}``-style tokens) and substitutes integer counts.
      
      Two input modes
      ---------------
      1. **Positional CSV** — ``--counts "315,122,186,7,111,204,102,84,3,15"``
         maps to a fixed sequence of keys appropriate for the most common DTA / IR
         meta-analysis workflow:
      
             n_db, n_dup, n_screened, n_screen_excluded,
             n_sought, n_assessed, n_excl_r1, n_excl_r2, n_excl_r3,
             n_studies
      
         This is a convenience for quick CLI use; remaining placeholders fall back
         to ``-`` so the figure remains visually balanced.
      
      2. **JSON / YAML** — ``--counts-file counts.json`` provides a full mapping of
         every placeholder used in the template. Any keys not supplied render as
         ``-``.
      
      Usage
      -----
          python3 fill_prisma_template.py \
              --template templates/official/prisma2020/PRISMA_2020_flow_new_v1.pptx \
              --counts "315,122,186,7,111,204,102,84,3,15" \
              --out fig1_prisma_filled.pptx
      
          python3 fill_prisma_template.py \
              --template templates/official/prisma2020/PRISMA_2020_flow_new_v2.pptx \
              --counts-file my_counts.json \
              --out fig1_prisma_filled.pptx
      """
      from __future__ import annotations
      
      import argparse
      import json
      import re
      from pathlib import Path
      
      from pptx import Presentation
      
      POSITIONAL_KEYS = [
          "n_db",
          "n_dup",
          "n_screened",
          "n_screen_excluded",
          "n_sought",
          "n_assessed",
          "n_excl_r1",
          "n_excl_r2",
          "n_excl_r3",
          "n_studies",
      ]
      
      ALL_KNOWN_KEYS = {
          "n_db", "n_reg", "n_dup", "n_auto", "n_other_removed",
          "n_web", "n_org", "n_cite",
          "n_screened", "n_screen_excluded",
          "n_sought", "n_not_retrieved",
          "n_assessed", "n_excl_r1", "n_excl_r2", "n_excl_r3",
          "n_studies", "n_reports",
      }
      
      TOKEN_RE = re.compile(r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}")
      
      
      def parse_counts(args) -> dict[str, str]:
          counts: dict[str, str] = {}
          if args.counts_file:
              data = json.loads(Path(args.counts_file).read_text())
              if not isinstance(data, dict):
                  raise SystemExit("--counts-file must contain a JSON object mapping key→count")
              counts.update({k: str(v) for k, v in data.items()})
          if args.counts:
              values = [v.strip() for v in args.counts.split(",")]
              if len(values) != len(POSITIONAL_KEYS):
                  raise SystemExit(
                      f"--counts expects {len(POSITIONAL_KEYS)} comma-separated values "
                      f"(got {len(values)}). Order: {', '.join(POSITIONAL_KEYS)}"
                  )
              for k, v in zip(POSITIONAL_KEYS, values):
                  counts.setdefault(k, v)
          return counts
      
      
      def substitute(prs, counts: dict[str, str]) -> tuple[set[str], set[str]]:
          """Replace ``{key}`` tokens in every text run. Returns (filled, unfilled)."""
          filled: set[str] = set()
          unfilled: set[str] = set()
          for slide in prs.slides:
              for shape in slide.shapes:
                  if not shape.has_text_frame:
                      continue
                  for para in shape.text_frame.paragraphs:
                      for run in para.runs:
                          tokens = TOKEN_RE.findall(run.text)
                          if not tokens:
                              continue
                          new_text = run.text
                          for tok in tokens:
                              if tok in counts:
                                  new_text = new_text.replace("{" + tok + "}", counts[tok])
                                  filled.add(tok)
                              else:
                                  new_text = new_text.replace("{" + tok + "}", "-")
                                  unfilled.add(tok)
                          run.text = new_text
          return filled, unfilled
      
      
      def main():
          ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
          ap.add_argument("--template", type=Path, required=True)
          ap.add_argument("--counts", help="Comma-separated positional counts (10 values).")
          ap.add_argument("--counts-file", type=Path, help="JSON file mapping key→count.")
          ap.add_argument("--out", type=Path, required=True)
          args = ap.parse_args()
      
          if not args.counts and not args.counts_file:
              ap.error("supply --counts or --counts-file (or both)")
      
          counts = parse_counts(args)
          unknown = set(counts) - ALL_KNOWN_KEYS
          if unknown:
              print(f"⚠ unknown keys (will still substitute if matched): {sorted(unknown)}")
      
          prs = Presentation(args.template)
          filled, unfilled = substitute(prs, counts)
          args.out.parent.mkdir(parents=True, exist_ok=True)
          prs.save(args.out)
      
          print(f"✓ wrote {args.out}")
          print(f"  filled  : {sorted(filled)}")
          if unfilled:
              print(f"  blanks  : {sorted(unfilled)}  (rendered as '-')")
      
      
      if __name__ == "__main__":
          main()
      
    • generate_flow_diagram.R 5 KB · in bundle
    • generate_image.py 3.4 KB
      #!/usr/bin/env python3
      """
      AI Image Generation via Gemini API (optional supplementary tool).
      Visual abstracts and figures can be created without this tool.
      
      Usage:
          python generate_image.py "prompt text" --output path/to/output.png
          python generate_image.py "prompt text" --output path/to/output.png --aspect 16:9
          python generate_image.py "CT-guided lung biopsy procedure" -o biopsy.png --style medical
      
      Environment variable required:
          GEMINI_API_KEY
      """
      
      import argparse
      import base64
      import os
      import sys
      from pathlib import Path
      
      MEDICAL_STYLE_PREFIX = (
          "Create a clean medical illustration in the style of Servier Medical Art. "
          "Use flat vector-style graphics with clear outlines. No text in the image. "
          "White or transparent background. Anatomically accurate but simplified. "
          "Suitable for use in a peer-reviewed journal graphical abstract. "
      )
      
      
      def generate_image(prompt: str, output_path: str, aspect_ratio: str = "1:1",
                         style: str | None = None) -> str:
          import google.generativeai as genai
      
          genai.configure(api_key=os.environ["GEMINI_API_KEY"])
      
          model = genai.GenerativeModel("gemini-2.0-flash-preview-image-generation")
      
          # Prepend medical style prefix if requested
          full_prompt = prompt
          if style == "medical":
              full_prompt = MEDICAL_STYLE_PREFIX + prompt
      
          # Include aspect ratio instruction in the prompt
          if aspect_ratio != "1:1":
              full_prompt += f" Output aspect ratio: {aspect_ratio}."
      
          response = model.generate_content(
              full_prompt,
              generation_config=genai.types.GenerationConfig(
                  response_mime_type="image/png",
              ),
          )
      
          # Extract image data from response
          if response.candidates and response.candidates[0].content.parts:
              for part in response.candidates[0].content.parts:
                  if hasattr(part, "inline_data") and part.inline_data:
                      image_data = part.inline_data.data
                      Path(output_path).write_bytes(image_data)
                      print(f"Image saved: {output_path}", file=sys.stderr)
                      print(output_path)
                      return output_path
      
          # Fallback: check if response has image in different format
          print("Error: No image data in response.", file=sys.stderr)
          if response.text:
              print(f"Response text: {response.text[:200]}", file=sys.stderr)
          sys.exit(1)
      
      
      def main():
          parser = argparse.ArgumentParser(description="Generate images via Gemini API")
          parser.add_argument("prompt", help="Image generation prompt")
          parser.add_argument("--output", "-o", required=True, help="Output file path (.png)")
          parser.add_argument(
              "--aspect",
              default="1:1",
              choices=["1:1", "16:9", "9:16", "4:3", "3:4"],
              help="Aspect ratio (default: 1:1)",
          )
          parser.add_argument(
              "--style",
              choices=["medical"],
              help="Prepend a style prefix to the prompt (medical: flat vector medical illustration)",
          )
          args = parser.parse_args()
      
          if not os.environ.get("GEMINI_API_KEY"):
              print("Error: GEMINI_API_KEY not set", file=sys.stderr)
              print("This tool is optional — visual abstracts can be created without it.", file=sys.stderr)
              sys.exit(1)
      
          output_path = Path(args.output).resolve()
          output_path.parent.mkdir(parents=True, exist_ok=True)
      
          generate_image(args.prompt, str(output_path), args.aspect, args.style)
      
      
      if __name__ == "__main__":
          main()
      
    • generate_visual_abstract.py 16.8 KB
      #!/usr/bin/env python3
      """
      Visual Abstract Generator for Medical Research Papers.
      Fills journal-specific PPTX templates with study content using python-pptx.
      
      Usage:
          python generate_visual_abstract.py \
            --template european_radiology.pptx \
            --title "Effect of smoking on biopsy outcomes" \
            --hypothesis "What is the association of smoking with PTNB outcomes?" \
            --methods "Retrospective cohort|N=1200 patients|Logistic regression" \
            --finding "Smoking was associated with higher complication rates" \
            --citation "Journal Name (2026) FirstAuthor Last et al; DOI: 10.xxxx/..." \
            --visual figures/fig1_roc_curve.png \
            --badges "N=1200|CT chest|Single-center" \
            --output visual_abstract.pptx
      
          # With PNG conversion (requires LibreOffice):
          python generate_visual_abstract.py ... --output visual_abstract.pptx --png
      
      Templates are stored in:
          ${CLAUDE_SKILL_DIR}/references/visual_abstract_templates/
      
      If --template is a bare name (no path), the script looks for it in the templates directory.
      If not found, falls back to medsci_default.pptx.
      """
      
      import argparse
      import os
      import re
      import subprocess
      import sys
      from pathlib import Path
      
      
      # --- Shape matching patterns ---
      # Each field maps to a list of substrings to search in shape.text (case-insensitive).
      FIELD_PATTERNS = {
          "title": ["articletitle"],
          "hypothesis": ["hypothesis", "question"],
          "methods": ["methodology", "flowchart", "bullet point"],
          "visual": ["visual element", "image/illustration", "illustration/graph", "visualelement"],
          "finding": ["main finding", "relevance statement", "main result"],
          "citation": ["authors names", "doi", "eur radiol (year)", "journal (year)", "articlecitation"],
          "badge_patient": ["patient cohort", "patient"],
          "badge_modality": ["modality", "organ"],
          "badge_center": ["single", "multi-center", "center"],
          "footer": ["footernote"],
      }
      
      
      # --- Central Illustration validation rules (Fuster-Mann 2019, JACC 74:2816) ---
      # See references/jacc_central_illustration_principles.md for full rationale.
      CI_FORBIDDEN_METHODS_TERMS = (
          "cohort flow",
          "inclusion criteria",
          "exclusion criteria",
          "study design",
          "enrollment",
          "randomized",
          "sample size",
          "consort",
          "prisma",
          "stard",
      )
      
      
      def validate_central_illustration(zones: int, label_words: int,
                                        numerical_points: int, raw_text: str,
                                        allow_overrides: list[str] | None = None) -> tuple[bool, list[str]]:
          """Apply CI mode validation rules. Returns (passes, reasons)."""
          allow = set(allow_overrides or [])
          failures: list[str] = []
      
          if "zones" not in allow and zones > 3:
              failures.append(
                  f"more than 3 visual zones (got {zones}); Fuster-Mann: 'simplicity is superior'"
              )
          if "words" not in allow and label_words > 30:
              failures.append(
                  f"label word count {label_words} > 30; Fuster-Mann: 'avoid using too much text'"
              )
          if "numerical" not in allow and numerical_points > 4:
              failures.append(
                  f"{numerical_points} numerical highlights > 4; Fuster-Mann: 'avoid secondary messages'"
              )
          if "methods" not in allow:
              lower = (raw_text or "").lower()
              offenders = [t for t in CI_FORBIDDEN_METHODS_TERMS if t in lower]
              if offenders:
                  failures.append(
                      f"methodology terms detected ({', '.join(offenders)}); CI is not a Visual Abstract"
                  )
          return (len(failures) == 0, failures)
      
      
      def match_field(shape_text: str) -> str | None:
          """Return the field name that matches the shape's text, or None."""
          text_lower = shape_text.lower().strip()
          if not text_lower:
              return None
          for field, patterns in FIELD_PATTERNS.items():
              for pattern in patterns:
                  if pattern in text_lower:
                      return field
          return None
      
      
      def replace_shape_text(shape, new_text: str, preserve_formatting: bool = True):
          """Replace all text in a shape while preserving font formatting."""
          tf = shape.text_frame
          if not tf.paragraphs:
              return
      
          if preserve_formatting and tf.paragraphs[0].runs:
              # Preserve the first run's formatting
              first_run = tf.paragraphs[0].runs[0]
              font_name = first_run.font.name
              font_size = first_run.font.size
              font_bold = first_run.font.bold
              try:
                  font_color = first_run.font.color.rgb
              except (AttributeError, TypeError):
                  font_color = None
      
              # Handle multi-line text (split by \n)
              lines = new_text.split("\n")
              # Clear all existing paragraphs except first
              for i in range(len(tf.paragraphs) - 1, 0, -1):
                  p = tf.paragraphs[i]._p
                  p.getparent().remove(p)
      
              # Set first paragraph
              para = tf.paragraphs[0]
              para.clear()
              run = para.add_run()
              run.text = lines[0]
              if font_name:
                  run.font.name = font_name
              if font_size:
                  run.font.size = font_size
              if font_bold is not None:
                  run.font.bold = font_bold
              if font_color:
                  run.font.color.rgb = font_color
      
              # Add remaining lines as new paragraphs
              for line in lines[1:]:
                  from pptx.oxml.ns import qn
                  from copy import deepcopy
                  new_p = deepcopy(para._p)
                  new_p.clear()
                  # Re-add run with text
                  from lxml import etree
                  r_elem = deepcopy(para._p.findall(qn("a:r"))[0]) if para._p.findall(qn("a:r")) else None
                  if r_elem is not None:
                      for t in r_elem.findall(qn("a:t")):
                          t.text = line
                      new_p.append(r_elem)
                  tf._txBody.append(new_p)
          else:
              # Simple replacement without formatting preservation
              tf.paragraphs[0].text = new_text
      
      
      def insert_image_into_shape(slide, shape, image_path: str):
          """Replace a shape with an image, maintaining the shape's position and size."""
          from pptx.util import Emu
          from PIL import Image
      
          # Get shape bounds
          left = shape.left
          top = shape.top
          box_w = shape.width
          box_h = shape.height
      
          # Calculate image dimensions maintaining aspect ratio
          with Image.open(image_path) as img:
              img_w, img_h = img.size
              aspect = img_w / img_h
              box_aspect = box_w / box_h
      
              if aspect > box_aspect:
                  # Image is wider than box — fit to width
                  new_w = box_w
                  new_h = int(box_w / aspect)
                  new_left = left
                  new_top = top + (box_h - new_h) // 2  # center vertically
              else:
                  # Image is taller — fit to height
                  new_h = box_h
                  new_w = int(box_h * aspect)
                  new_left = left + (box_w - new_w) // 2  # center horizontally
                  new_top = top
      
          # Clear shape text
          if hasattr(shape, "text_frame"):
              for para in shape.text_frame.paragraphs:
                  para.clear()
      
          # Add image on top of shape
          slide.shapes.add_picture(image_path, new_left, new_top, new_w, new_h)
      
      
      def fill_template(template_path: str, content: dict, output_path: str,
                        visual_path: str | None = None, slide_index: int = 0):
          """Fill a PPTX template with content and save."""
          from pptx import Presentation
      
          prs = Presentation(template_path)
      
          if slide_index >= len(prs.slides):
              print(f"Error: Slide index {slide_index} not found in template "
                    f"(template has {len(prs.slides)} slides)", file=sys.stderr)
              sys.exit(1)
      
          slide = prs.slides[slide_index]
          matched_fields = set()
      
          for shape in slide.shapes:
              if not hasattr(shape, "text"):
                  continue
      
              field = match_field(shape.text)
              if field is None:
                  continue
      
              if field == "visual" and visual_path and Path(visual_path).exists():
                  insert_image_into_shape(slide, shape, visual_path)
                  matched_fields.add(field)
              elif field in content and content[field]:
                  replace_shape_text(shape, content[field])
                  matched_fields.add(field)
      
          # Report matching results
          all_fields = set(content.keys())
          if visual_path:
              all_fields.add("visual")
          unmatched = all_fields - matched_fields
          if unmatched:
              print(f"Warning: No matching shape found for: {', '.join(unmatched)}",
                    file=sys.stderr)
      
          # Remove unused slides (keep only the target slide)
          # python-pptx slide deletion requires direct XML manipulation
          sldIdLst = prs.slides._sldIdLst
          indices_to_remove = sorted(
              [i for i in range(len(sldIdLst)) if i != slide_index],
              reverse=True  # remove from end to preserve indices
          )
          for i in indices_to_remove:
              sldId = sldIdLst[i]
              rId = sldId.rId
              sldIdLst.remove(sldId)
              try:
                  prs.part.drop_rel(rId)
              except KeyError:
                  pass  # relationship already cleaned up
      
          prs.save(output_path)
          print(f"Visual abstract saved: {output_path}", file=sys.stderr)
          print(output_path)
      
      
      def convert_to_png(pptx_path: str, dpi: int = 300) -> str | None:
          """Convert PPTX to PNG using LibreOffice CLI. Returns PNG path or None."""
          output_dir = str(Path(pptx_path).parent)
          try:
              subprocess.run(
                  ["soffice", "--headless", "--convert-to", "png", pptx_path,
                   "--outdir", output_dir],
                  check=True, capture_output=True, timeout=30
              )
              png_path = str(Path(pptx_path).with_suffix(".png"))
              if Path(png_path).exists():
                  print(f"PNG exported: {png_path}", file=sys.stderr)
                  return png_path
          except FileNotFoundError:
              print("Warning: LibreOffice not found. PPTX saved but PNG conversion skipped.",
                    file=sys.stderr)
              print("Install with: brew install --cask libreoffice", file=sys.stderr)
          except subprocess.TimeoutExpired:
              print("Warning: LibreOffice conversion timed out.", file=sys.stderr)
          except subprocess.CalledProcessError as e:
              print(f"Warning: LibreOffice conversion failed: {e.stderr.decode()[:200]}",
                    file=sys.stderr)
          return None
      
      
      def resolve_template(template_arg: str) -> str:
          """Resolve template name to full path."""
          # If it's already a full path, use it
          if os.path.isabs(template_arg) and Path(template_arg).exists():
              return template_arg
      
          # Look in the skill's template directory
          skill_dir = os.environ.get("CLAUDE_SKILL_DIR", "")
          if skill_dir:
              templates_dir = Path(skill_dir) / "references" / "visual_abstract_templates"
          else:
              templates_dir = Path(__file__).parent.parent / "references" / "visual_abstract_templates"
      
          # Try exact match
          candidate = templates_dir / template_arg
          if candidate.exists():
              return str(candidate)
      
          # Try with .pptx extension
          candidate = templates_dir / f"{template_arg}.pptx"
          if candidate.exists():
              return str(candidate)
      
          # Fallback to medsci_default
          default = templates_dir / "medsci_default.pptx"
          if default.exists():
              print(f"Template '{template_arg}' not found, using medsci_default.pptx",
                    file=sys.stderr)
              return str(default)
      
          print(f"Error: No template found. Searched in: {templates_dir}", file=sys.stderr)
          sys.exit(1)
      
      
      def parse_methods(methods_str: str) -> str:
          """Convert pipe-separated methods to bullet-point format."""
          items = [m.strip() for m in methods_str.split("|") if m.strip()]
          if len(items) <= 1:
              return methods_str
          return "\n".join(f"• {item}" for item in items)
      
      
      def main():
          parser = argparse.ArgumentParser(
              description="Generate visual abstract from PPTX template"
          )
          parser.add_argument("--type", choices=["visual-abstract", "central-illustration"],
                              default="visual-abstract",
                              help="Artifact type: 'visual-abstract' (methods+results) or "
                                   "'central-illustration' (single key finding, JACC house style)")
          parser.add_argument("--template", "-t", default=None,
                              help="Template name or path. Default depends on --type: "
                                   "visual-abstract→medsci_default, central-illustration→jacc_central_illustration")
          parser.add_argument("--title", help="Article title (required for VA mode)")
          parser.add_argument("--hypothesis", help="Research question or hypothesis (VA mode)")
          parser.add_argument("--methods", help="Methodology (pipe-separated for bullets, VA mode)")
          parser.add_argument("--finding", help="Main finding (<20 words, VA mode)")
          parser.add_argument("--citation", help="Citation line (journal, year, authors, DOI)")
          parser.add_argument("--visual", help="Path to visual element image (PNG/JPG)")
          parser.add_argument("--badges", help="Three pipe-separated badge texts: cohort|modality|center (VA mode)")
          parser.add_argument("--output", "-o", required=True, help="Output PPTX path")
          parser.add_argument("--png", action="store_true",
                              help="Also convert to PNG (requires LibreOffice)")
          parser.add_argument("--slide-index", type=int, default=0,
                              help="Template slide index to use (default: 0)")
          parser.add_argument("--ci-zones", type=int, default=None,
                              help="CI mode: declared visual zone count (for validation)")
          parser.add_argument("--ci-label-words", type=int, default=None,
                              help="CI mode: total label word count (for validation)")
          parser.add_argument("--ci-numerical-points", type=int, default=None,
                              help="CI mode: count of numerical highlights (for validation)")
          parser.add_argument("--ci-raw-text", default="",
                              help="CI mode: raw text content for forbidden-methods-term scan")
          parser.add_argument("--ci-allow", action="append", default=[],
                              choices=["zones", "words", "numerical", "methods"],
                              help="CI mode: override a single rule (repeatable)")
          args = parser.parse_args()
      
          # Default template per type
          if args.template is None:
              args.template = ("jacc_central_illustration"
                               if args.type == "central-illustration" else "medsci_default")
      
          # CI mode: run validation BEFORE generating the PPTX
          if args.type == "central-illustration":
              zones = args.ci_zones if args.ci_zones is not None else 1
              words = args.ci_label_words if args.ci_label_words is not None else 0
              nums = args.ci_numerical_points if args.ci_numerical_points is not None else 0
              passes, reasons = validate_central_illustration(
                  zones=zones, label_words=words, numerical_points=nums,
                  raw_text=args.ci_raw_text, allow_overrides=args.ci_allow,
              )
              if not passes:
                  print("Central Illustration validation FAILED:", file=sys.stderr)
                  for r in reasons:
                      print(f"  - {r}", file=sys.stderr)
                  print("\nSee references/jacc_central_illustration_principles.md for guidance.",
                        file=sys.stderr)
                  print("Override with --ci-allow {zones|words|numerical|methods} (use sparingly).",
                        file=sys.stderr)
                  sys.exit(2)
              # CI mode does not require --title (only --visual + --citation)
              if not args.visual:
                  print("CI mode requires --visual (the author content figure).", file=sys.stderr)
                  sys.exit(2)
              # Synthesize a placeholder title so downstream code does not fail
              if not args.title:
                  args.title = "(central illustration — title carried by manuscript)"
      
          # Resolve template
          template_path = resolve_template(args.template)
      
          # Build content dict
          content = {
              "title": args.title,
              "hypothesis": args.hypothesis or "",
              "methods": parse_methods(args.methods) if args.methods else "",
              "finding": args.finding or "",
              "citation": args.citation or "",
          }
      
          # Parse badges
          if args.badges:
              badges = [b.strip() for b in args.badges.split("|")]
              content["badge_patient"] = badges[0] if len(badges) > 0 else ""
              content["badge_modality"] = badges[1] if len(badges) > 1 else ""
              content["badge_center"] = badges[2] if len(badges) > 2 else ""
      
          # Validate visual path
          visual_path = None
          if args.visual:
              if not Path(args.visual).exists():
                  print(f"Warning: Visual file not found: {args.visual}", file=sys.stderr)
              else:
                  visual_path = str(Path(args.visual).resolve())
      
          # Ensure output directory exists
          Path(args.output).parent.mkdir(parents=True, exist_ok=True)
      
          # Fill template
          fill_template(template_path, content, args.output, visual_path, args.slide_index)
      
          # Optional PNG conversion
          if args.png:
              convert_to_png(args.output)
      
      
      if __name__ == "__main__":
          main()
      
    • render_core_figures.py 29 KB
      #!/usr/bin/env python3
      """Render the four highest-yield clinical figures as tested, deterministic generators.
      
      This is the *render* layer for the canonical figure anatomies described in prose under
      ``references/exemplar_plots/`` (km_curve / roc_pr / calibration_plot / decision_curve).
      It turns those anatomy models into RUNNABLE code so a regression in a publication figure
      is caught by a test, not by a reviewer — the gap that left make-figures with no
      deterministic render check for any data plot.
      
      Separation of concerns (do not duplicate the analysis SoT):
        - ``/analyze-stats`` *computes* the estimates (KM via lifelines, AUC, calibration
          slope/intercept, net benefit). Those numbers are the single source of truth.
        - this module *renders* already-computed inputs into the canonical anatomy, and
          asserts the load-bearing elements are present. It never recomputes a statistic and
          never invents a number.
      
      Each renderer returns a matplotlib ``Figure`` so a verifier can introspect the actual
      artists (lines, texts, collections) rather than pixel-diffing a PNG. ``assert_structure``
      encodes the load-bearing-element invariants for each figure type.
      
      Matplotlib (Agg, headless) + numpy only — no seaborn, no network, no RNG at import.
      CLI:  render_core_figures.py --inputs fixture/synthetic_inputs.json --out-dir OUT
            (renders all four PNGs, runs every structural assertion; exit 1 on any failure).
      """
      from __future__ import annotations
      
      import argparse
      import json
      import sys
      from pathlib import Path
      
      import matplotlib
      matplotlib.use("Agg")  # headless, deterministic
      import matplotlib.pyplot as plt  # noqa: E402
      import numpy as np  # noqa: E402
      from matplotlib.patches import Polygon  # noqa: E402
      
      
      # --------------------------------------------------------------------------- KM
      def km_curve(groups: list[dict], max_follow_up: float, *, title: str = "") -> plt.Figure:
          """Kaplan–Meier step curve(s) with a number-at-risk table and censor marks.
      
          groups: [{name, time:[...], surv:[0..1], censor_times:[...],
                    at_risk:{time:[...], n:[...]}}]  — already-computed step coordinates.
          The x-axis is clipped to ``max_follow_up`` so the curve is never extrapolated past
          observed follow-up (a recurrent KM error the exemplar anatomy warns against).
          """
          fig, (ax, ax_risk) = plt.subplots(
              2, 1, figsize=(6.4, 5.2), gridspec_kw={"height_ratios": [4, 1]}
          )
          for g in groups:
              t = np.asarray(g["time"], float)
              s = np.asarray(g["surv"], float)
              line, = ax.step(t, s, where="post", label=g["name"])
              # censor tick marks on the step curve
              for ct in g.get("censor_times", []):
                  idx = np.searchsorted(t, ct, side="right") - 1
                  idx = max(0, min(idx, len(s) - 1))
                  ax.plot([ct], [s[idx]], marker="|", markersize=8, color=line.get_color())
          ax.set_xlim(0, max_follow_up)          # no extrapolation past follow-up
          ax.set_ylim(0, 1.02)
          ax.set_ylabel("Survival probability")
          ax.set_title(title or "Kaplan–Meier survival")
          ax.legend(loc="lower left", frameon=False)
      
          # number-at-risk table on the lower axes
          ax_risk.axis("off")
          ax_risk.set_xlim(0, max_follow_up)
          ax_risk.text(0, len(groups) + 0.5, "No. at risk", fontsize=9, fontweight="bold",
                       transform=ax_risk.get_yaxis_transform())
          for row, g in enumerate(groups):
              risk = g["at_risk"]
              y = len(groups) - row - 0.5
              for tt, nn in zip(risk["time"], risk["n"]):
                  ax_risk.text(tt, y, str(int(nn)), ha="center", va="center", fontsize=8)
              ax_risk.text(0, y, g["name"], ha="right", va="center", fontsize=8,
                           transform=ax_risk.get_yaxis_transform())
          ax_risk.set_xlabel("Time (months)")
          fig.tight_layout()
          fig._mf_kind = "km"  # tag for assert_structure
          fig._mf_max_follow_up = max_follow_up
          return fig
      
      
      # -------------------------------------------------------------------------- ROC
      def roc_curve(fpr, tpr, auc: float, *, operating_point: dict | None = None,
                    title: str = "") -> plt.Figure:
          """ROC curve with the chance diagonal, an AUC annotation, and an operating point."""
          fpr = np.asarray(fpr, float)
          tpr = np.asarray(tpr, float)
          fig, ax = plt.subplots(figsize=(5.2, 5.0))
          ax.plot(fpr, tpr, label=f"Model (AUC = {auc:.3f})")
          ax.plot([0, 1], [0, 1], linestyle="--", color="0.5", label="Chance")  # diagonal
          if operating_point:
              ax.scatter([operating_point["fpr"]], [operating_point["tpr"]],
                         color="crimson", zorder=5, s=40,
                         label=operating_point.get("label", "Operating point"))
          ax.set_xlim(0, 1)
          ax.set_ylim(0, 1.02)
          ax.set_xlabel("1 − specificity (false-positive rate)")
          ax.set_ylabel("Sensitivity (true-positive rate)")
          ax.set_title(title or "Receiver operating characteristic")
          ax.text(0.55, 0.10, f"AUC = {auc:.3f}", fontsize=11,
                  bbox=dict(boxstyle="round", fc="white", ec="0.7"))
          ax.legend(loc="lower right", frameon=False)
          fig.tight_layout()
          fig._mf_kind = "roc"
          return fig
      
      
      # ------------------------------------------------------------------ calibration
      def calibration_plot(pred_mean, obs_freq, *, slope: float, intercept: float,
                           ci_low=None, ci_high=None, title: str = "") -> plt.Figure:
          """Calibration plot: binned observed-vs-predicted, the y=x identity line, and the
          fitted calibration slope/intercept annotation (the load-bearing calibration metrics)."""
          pred_mean = np.asarray(pred_mean, float)
          obs_freq = np.asarray(obs_freq, float)
          fig, ax = plt.subplots(figsize=(5.2, 5.2))
          ax.plot([0, 1], [0, 1], linestyle="--", color="0.5", label="Ideal (y = x)")  # identity
          if ci_low is not None and ci_high is not None:
              yerr = np.vstack([obs_freq - np.asarray(ci_low, float),
                                np.asarray(ci_high, float) - obs_freq])
              ax.errorbar(pred_mean, obs_freq, yerr=yerr, fmt="o", capsize=3, label="Observed")
          else:
              ax.plot(pred_mean, obs_freq, marker="o", label="Observed")
          # fitted calibration line
          xs = np.linspace(0, 1, 50)
          ax.plot(xs, intercept + slope * xs, color="crimson", label="Calibration fit")
          ax.set_xlim(0, 1)
          ax.set_ylim(0, 1)
          ax.set_xlabel("Predicted probability")
          ax.set_ylabel("Observed frequency")
          ax.set_title(title or "Calibration")
          ax.text(0.05, 0.88, f"slope = {slope:.2f}\nintercept = {intercept:.2f}", fontsize=10,
                  bbox=dict(boxstyle="round", fc="white", ec="0.7"))
          ax.legend(loc="lower right", frameon=False)
          fig.tight_layout()
          fig._mf_kind = "calibration"
          return fig
      
      
      # --------------------------------------------------------------- decision curve
      def decision_curve(thresholds, net_benefit_model, prevalence: float, *,
                         model_label: str = "Model", title: str = "") -> plt.Figure:
          """Decision-curve (net-benefit) plot with the treat-all and treat-none reference
          strategies — the two references without which a DCA is uninterpretable."""
          pt = np.asarray(thresholds, float)
          nb_model = np.asarray(net_benefit_model, float)
          # treat-all net benefit = prev - (1-prev) * pt/(1-pt); treat-none = 0
          with np.errstate(divide="ignore", invalid="ignore"):
              nb_all = prevalence - (1 - prevalence) * pt / (1 - pt)
          fig, ax = plt.subplots(figsize=(6.0, 4.8))
          ax.plot(pt, nb_model, label=model_label, color="crimson")
          ax.plot(pt, nb_all, label="Treat all", color="0.4", linestyle="-.")
          ax.plot(pt, np.zeros_like(pt), label="Treat none", color="0.0", linestyle=":")
          ax.set_xlim(pt.min(), pt.max())
          lo = float(min(0.0, np.nanmin(nb_model)))
          ax.set_ylim(lo - 0.02, float(np.nanmax(nb_model)) + 0.05)
          ax.set_xlabel("Threshold probability")
          ax.set_ylabel("Net benefit")
          ax.set_title(title or "Decision-curve analysis")
          ax.legend(loc="upper right", frameon=False)
          fig.tight_layout()
          fig._mf_kind = "dca"
          return fig
      
      
      # ------------------------------------------------------------------- forest
      def forest_plot(studies: list[dict], pooled: dict, *, null_value: float = 1.0,
                      effect_label: str = "Effect (95% CI)", title: str = "",
                      log_x: bool = True) -> plt.Figure:
          """Meta-analysis forest plot from already-computed per-study estimates.
      
          studies: [{name, est, lo, hi, weight?}] — each study's point estimate + CI.
          pooled:  {est, lo, hi, label}          — the pooled estimate + CI + model name.
          Draws a weight-scaled marker + CI whisker per study, the null reference line, and a
          pooled diamond; the pooled row is always last. For a ratio measure keep log_x=True and
          null_value=1.0; for a mean difference pass log_x=False, null_value=0.0."""
          n = len(studies)
          fig, ax = plt.subplots(figsize=(6.6, 0.5 * n + 2.0))
          ys = list(range(n, 0, -1))  # top study at the highest y
          weights = np.asarray([s.get("weight", 1.0) for s in studies], float)
          wnorm = weights / weights.max() if weights.max() > 0 else np.ones(n)
          for y, s, w in zip(ys, studies, wnorm):
              ax.plot([s["lo"], s["hi"]], [y, y], color="0.3")             # CI whisker
              ax.scatter([s["est"]], [y], s=30 + 120 * w, marker="s",
                         color="steelblue", zorder=4)                       # weight-scaled box
          ax.axvline(null_value, linestyle="--", color="0.5")               # null reference
          # pooled diamond on a row below the studies
          yd = 0
          d = pooled
          ax.add_patch(Polygon([[d["lo"], yd], [d["est"], yd + 0.35],
                                [d["hi"], yd], [d["est"], yd - 0.35]],
                               closed=True, facecolor="crimson", edgecolor="black", zorder=5))
          labels = [s["name"] for s in studies] + [pooled.get("label", "Pooled")]
          ax.set_yticks(ys + [yd])
          ax.set_yticklabels(labels)
          ax.set_ylim(-1, n + 1)
          if log_x:
              ax.set_xscale("log")
          ax.set_xlabel(effect_label)
          ax.set_title(title or "Meta-analysis forest plot")
          fig.tight_layout()
          fig._mf_kind = "forest"
          fig._mf_n_studies = n
          fig._mf_null = null_value
          return fig
      
      
      # -------------------------------------------------------------- Bland–Altman
      def bland_altman(mean_vals, diff_vals, *, bias: float, sd_diff: float,
                       title: str = "") -> plt.Figure:
          """Bland–Altman agreement plot: difference vs mean, with the bias line and the
          95% limits of agreement (bias ± 1.96·SD) — the load-bearing agreement elements."""
          mean_vals = np.asarray(mean_vals, float)
          diff_vals = np.asarray(diff_vals, float)
          loa_hi, loa_lo = bias + 1.96 * sd_diff, bias - 1.96 * sd_diff
          fig, ax = plt.subplots(figsize=(6.0, 5.0))
          ax.scatter(mean_vals, diff_vals, s=25, color="steelblue", alpha=0.8)
          ax.axhline(bias, color="crimson", label=f"Bias {bias:.2f}")
          ax.axhline(loa_hi, linestyle="--", color="0.4", label=f"+1.96 SD {loa_hi:.2f}")
          ax.axhline(loa_lo, linestyle="--", color="0.4", label=f"−1.96 SD {loa_lo:.2f}")
          ax.set_xlabel("Mean of the two measurements")
          ax.set_ylabel("Difference between measurements")
          ax.set_title(title or "Bland–Altman agreement")
          ax.legend(loc="upper right", frameon=False)
          fig.tight_layout()
          fig._mf_kind = "bland_altman"
          fig._mf_loa = (loa_lo, loa_hi)
          return fig
      
      
      # ---------------------------------------------------------- confusion matrix
      def confusion_matrix(matrix, labels, *, title: str = "") -> plt.Figure:
          """Confusion matrix from an already-computed count grid. Rows = actual, cols =
          predicted; every cell is annotated with its count (for a 2×2, TN/FP/FN/TP)."""
          m = np.asarray(matrix, float)
          k = m.shape[0]
          if m.shape[0] != m.shape[1] or k != len(labels):
              raise AssertionError("confusion matrix must be square and match the label count")
          fig, ax = plt.subplots(figsize=(1.4 * k + 2, 1.4 * k + 2))
          ax.imshow(m, cmap="Blues")
          thresh = m.max() / 2.0 if m.max() else 0.5
          for i in range(k):
              for j in range(k):
                  ax.text(j, i, str(int(m[i, j])), ha="center", va="center",
                          color="white" if m[i, j] > thresh else "black")
          ax.set_xticks(range(k)); ax.set_xticklabels(labels)
          ax.set_yticks(range(k)); ax.set_yticklabels(labels)
          ax.set_xlabel("Predicted")
          ax.set_ylabel("Actual")
          ax.set_title(title or "Confusion matrix")
          fig.tight_layout()
          fig._mf_kind = "confusion"
          fig._mf_k = k
          return fig
      
      
      # ------------------------------------------------------------------ MRMC ROC
      def mrmc_roc(readers: list[dict], averaged: dict, *, delta_auc: dict | None = None,
                   title: str = "") -> plt.Figure:
          """Multi-reader multi-case ROC: each reader's ROC curve plus the reader-averaged
          curve and the chance diagonal (the load-bearing MRMC-reader-study elements).
      
          readers:  [{name, fpr, tpr, auc}]  — per-reader ROC coordinates.
          averaged: {fpr, tpr, auc, label}   — the reader-averaged curve.
          delta_auc (optional): {value, margin} — a ΔAUC-vs-margin annotation."""
          fig, ax = plt.subplots(figsize=(5.4, 5.2))
          for r in readers:
              ax.plot(np.asarray(r["fpr"], float), np.asarray(r["tpr"], float),
                      color="0.7", linewidth=1)                              # thin per-reader
          a = averaged
          ax.plot(np.asarray(a["fpr"], float), np.asarray(a["tpr"], float),
                  color="crimson", linewidth=2.4,
                  label=f"{a.get('label', 'Reader-averaged')} (AUC = {a['auc']:.3f})")
          ax.plot([0, 1], [0, 1], linestyle="--", color="0.5", label="Chance")
          ax.set_xlim(0, 1); ax.set_ylim(0, 1.02)
          ax.set_xlabel("1 − specificity (false-positive rate)")
          ax.set_ylabel("Sensitivity (true-positive rate)")
          ax.set_title(title or "Multi-reader multi-case ROC")
          txt = f"averaged AUC = {a['auc']:.3f}"
          if delta_auc:
              txt += f"\nΔAUC = {delta_auc['value']:.3f} (margin {delta_auc['margin']:.3f})"
          ax.text(0.55, 0.08, txt, fontsize=10,
                  bbox=dict(boxstyle="round", fc="white", ec="0.7"))
          ax.legend(loc="lower right", frameon=False)
          fig.tight_layout()
          fig._mf_kind = "mrmc_roc"
          fig._mf_n_readers = len(readers)
          return fig
      
      
      # ------------------------------------------------------------------ Manhattan
      def manhattan(x, neglogp, threshold: float, *, labels=None,
                    ylabel: str = "−log10(p)", xlabel: str = "Exposure / position",
                    title: str = "") -> plt.Figure:
          """Manhattan / *-wide-scan plot: −log10(p) vs position with the significance
          threshold line (the two load-bearing elements of an agnostic many-test scan)."""
          x = np.asarray(x, float)
          y = np.asarray(neglogp, float)
          fig, ax = plt.subplots(figsize=(7.0, 4.4))
          ax.scatter(x, y, s=14, color="steelblue", alpha=0.8)
          ax.axhline(threshold, color="crimson", linestyle="--",
                     label=f"significance threshold (−log10 = {threshold:.2f})")
          if labels:  # sparse labelling of hits above the threshold
              for xi, yi, lab in zip(x, y, labels):
                  if lab and yi >= threshold:
                      ax.annotate(lab, (xi, yi), fontsize=8,
                                  xytext=(0, 4), textcoords="offset points", ha="center")
          ax.set_xlabel(xlabel)
          ax.set_ylabel(ylabel)
          ax.set_title(title or "Manhattan plot")
          ax.legend(loc="upper right", frameon=False)
          fig.tight_layout()
          fig._mf_kind = "manhattan"
          fig._mf_threshold = threshold
          return fig
      
      
      # --------------------------------------------------------- clinical timeline
      def clinical_timeline(events: list[dict], *, time_unit: str = "days from admission",
                            title: str = "") -> plt.Figure:
          """Case-report clinical timeline: an event marker + label at each time on a single
          time axis (the load-bearing elements of a longitudinal case figure)."""
          times = [float(e["time"]) for e in events]
          fig, ax = plt.subplots(figsize=(max(6.0, 0.9 * len(events)), 3.4))
          lo, hi = (min(times), max(times)) if times else (0, 1)
          pad = max(1.0, (hi - lo) * 0.08)
          ax.axhline(0, color="0.4")                                        # the timeline
          for i, e in enumerate(events):
              t = float(e["time"])
              up = 1 if i % 2 == 0 else -1
              ax.plot([t, t], [0, up * 0.6], color="0.6")                  # stem
              ax.scatter([t], [0], s=40, color="crimson", zorder=5)         # event marker
              ax.annotate(str(e["label"]), (t, up * 0.65), ha="center",
                          va="bottom" if up > 0 else "top", fontsize=8)
          ax.set_xlim(lo - pad, hi + pad)
          ax.set_ylim(-1.3, 1.3)
          ax.get_yaxis().set_visible(False)
          for spine in ("left", "right", "top"):
              ax.spines[spine].set_visible(False)
          ax.set_xlabel(f"Time ({time_unit})")
          ax.set_title(title or "Clinical timeline")
          fig.tight_layout()
          fig._mf_kind = "timeline"
          fig._mf_n_events = len(events)
          return fig
      
      
      # ----------------------------------------------------- structural invariants
      def assert_structure(fig: plt.Figure) -> list[str]:
          """Assert the load-bearing elements for the figure's kind. Returns the list of
          checks that PASSED; raises AssertionError on the first violated invariant."""
          kind = getattr(fig, "_mf_kind", None)
          passed: list[str] = []
      
          def has_text(ax, needle: str) -> bool:
              n = needle.lower()
              return any(n in t.get_text().lower() for t in ax.texts) \
                  or (ax.get_legend() and any(n in t.get_text().lower()
                                              for t in ax.get_legend().get_texts()))
      
          if kind == "km":
              ax, ax_risk = fig.axes[0], fig.axes[1]
              steps = [ln for ln in ax.lines if len(ln.get_xdata()) > 2]
              assert steps, "KM: no step curve drawn"
              passed.append("KM step curve present")
              # no extrapolation past follow-up
              muf = getattr(fig, "_mf_max_follow_up")
              assert abs(ax.get_xlim()[1] - muf) < 1e-9, "KM: x-axis extends past follow-up"
              passed.append("KM x-axis clipped to follow-up (no extrapolation)")
              # monotonic non-increasing survival on each step curve
              for ln in steps:
                  y = np.asarray(ln.get_ydata(), float)
                  assert np.all(np.diff(y) <= 1e-9), "KM: survival curve is not non-increasing"
              passed.append("KM survival monotonic non-increasing")
              assert any(t.get_text().lower().startswith("no. at risk") or "at risk"
                         in t.get_text().lower() for t in ax_risk.texts), \
                  "KM: number-at-risk table missing"
              passed.append("KM number-at-risk table present")
              assert "survival" in ax.get_ylabel().lower(), "KM: y-label not survival"
              passed.append("KM survival y-label")
      
          elif kind == "roc":
              ax = fig.axes[0]
              diag = [ln for ln in ax.lines
                      if len(ln.get_xdata()) == 2
                      and np.allclose(ln.get_xdata(), [0, 1])
                      and np.allclose(ln.get_ydata(), [0, 1])]
              assert diag, "ROC: chance diagonal (0,0)-(1,1) missing"
              passed.append("ROC chance diagonal present")
              assert has_text(ax, "auc"), "ROC: AUC annotation missing"
              passed.append("ROC AUC annotation present")
              assert ax.collections, "ROC: operating-point marker missing"
              passed.append("ROC operating point present")
              assert "sensitiv" in ax.get_ylabel().lower(), "ROC: y-label not sensitivity"
              passed.append("ROC sensitivity y-label")
      
          elif kind == "calibration":
              ax = fig.axes[0]
              identity = [ln for ln in ax.lines
                          if len(ln.get_xdata()) == 2
                          and np.allclose(ln.get_xdata(), [0, 1])
                          and np.allclose(ln.get_ydata(), [0, 1])]
              assert identity, "Calibration: identity y=x line missing"
              passed.append("Calibration identity line present")
              assert has_text(ax, "slope") and has_text(ax, "intercept"), \
                  "Calibration: slope/intercept annotation missing"
              passed.append("Calibration slope+intercept annotation present")
              assert "predicted" in ax.get_xlabel().lower(), "Calibration: x not predicted"
              assert "observed" in ax.get_ylabel().lower(), "Calibration: y not observed"
              passed.append("Calibration predicted-vs-observed axes")
      
          elif kind == "dca":
              ax = fig.axes[0]
              assert len(ax.lines) >= 3, "DCA: fewer than 3 strategies (model/all/none) drawn"
              passed.append("DCA has model + treat-all + treat-none")
              treat_none = [ln for ln in ax.lines if np.allclose(ln.get_ydata(), 0.0)]
              assert treat_none, "DCA: treat-none (net benefit = 0) reference missing"
              passed.append("DCA treat-none reference present")
              assert "net benefit" in ax.get_ylabel().lower(), "DCA: y-label not net benefit"
              passed.append("DCA net-benefit y-label")
              assert has_text(ax, "treat all") and has_text(ax, "treat none"), \
                  "DCA: treat-all / treat-none not labelled"
              passed.append("DCA reference strategies labelled")
      
          elif kind == "forest":
              ax = fig.axes[0]
              n = getattr(fig, "_mf_n_studies")
              # one horizontal CI whisker (2-point line, equal y) per study
              whiskers = [ln for ln in ax.lines
                          if len(ln.get_xdata()) == 2 and np.allclose(np.diff(ln.get_ydata()), 0.0)]
              assert len(whiskers) >= n, "forest: missing per-study CI whiskers"
              passed.append(f"forest per-study CI rows present ({n})")
              null = getattr(fig, "_mf_null")
              assert any(len(ln.get_xdata()) == 2 and np.allclose(ln.get_xdata(), [null, null])
                         for ln in ax.lines), "forest: null reference line missing"
              passed.append("forest null reference line present")
              assert any(isinstance(p, Polygon) and len(p.get_xy()) >= 4 for p in ax.patches), \
                  "forest: pooled diamond missing"
              passed.append("forest pooled diamond present")
              assert len(ax.get_yticklabels()) >= n + 1, "forest: study/pooled row labels missing"
              passed.append("forest study + pooled row labels present")
      
          elif kind == "bland_altman":
              ax = fig.axes[0]
              assert ax.collections, "Bland–Altman: scatter of differences missing"
              passed.append("Bland–Altman difference scatter present")
              hlines = [ln for ln in ax.lines if np.allclose(np.diff(ln.get_ydata()), 0.0)]
              assert len(hlines) >= 3, "Bland–Altman: need bias + two limits-of-agreement lines"
              passed.append("Bland–Altman bias + 2 LoA lines present")
              loa_lo, loa_hi = getattr(fig, "_mf_loa")
              yvals = [float(ln.get_ydata()[0]) for ln in hlines]
              assert any(abs(y - loa_hi) < 1e-6 for y in yvals) and any(abs(y - loa_lo) < 1e-6 for y in yvals), \
                  "Bland–Altman: LoA lines not at bias ± 1.96·SD"
              passed.append("Bland–Altman LoA at bias ± 1.96·SD")
              assert "difference" in ax.get_ylabel().lower() and "mean" in ax.get_xlabel().lower(), \
                  "Bland–Altman: axes not difference-vs-mean"
              passed.append("Bland–Altman difference-vs-mean axes")
      
          elif kind == "confusion":
              ax = fig.axes[0]
              k = getattr(fig, "_mf_k")
              assert ax.images, "confusion: matrix image missing"
              passed.append("confusion matrix image present")
              cells = [t for t in ax.texts if t.get_text().strip().lstrip("-").isdigit()]
              assert len(cells) >= k * k, f"confusion: expected {k * k} annotated cells"
              passed.append(f"confusion all {k}×{k} cells annotated")
              assert "predicted" in ax.get_xlabel().lower() and "actual" in ax.get_ylabel().lower(), \
                  "confusion: axes not Predicted/Actual"
              passed.append("confusion Predicted/Actual axes")
      
          elif kind == "mrmc_roc":
              ax = fig.axes[0]
              n = getattr(fig, "_mf_n_readers")
              curves = [ln for ln in ax.lines
                        if len(ln.get_xdata()) > 2]                          # multi-point ROC curves
              assert len(curves) >= n + 1, "MRMC-ROC: fewer curves than readers + averaged"
              passed.append(f"MRMC-ROC per-reader + averaged curves present ({n}+1)")
              diag = [ln for ln in ax.lines
                      if len(ln.get_xdata()) == 2 and np.allclose(ln.get_xdata(), [0, 1])
                      and np.allclose(ln.get_ydata(), [0, 1])]
              assert diag, "MRMC-ROC: chance diagonal missing"
              passed.append("MRMC-ROC chance diagonal present")
              assert has_text(ax, "auc"), "MRMC-ROC: averaged-AUC annotation missing"
              passed.append("MRMC-ROC averaged-AUC annotation present")
              assert "sensitiv" in ax.get_ylabel().lower(), "MRMC-ROC: y-label not sensitivity"
              passed.append("MRMC-ROC sensitivity y-label")
      
          elif kind == "manhattan":
              ax = fig.axes[0]
              assert ax.collections, "Manhattan: point scatter missing"
              passed.append("Manhattan scatter present")
              thr = getattr(fig, "_mf_threshold")
              assert any(np.allclose(ln.get_ydata(), thr) for ln in ax.lines
                         if np.allclose(np.diff(ln.get_ydata()), 0.0)), \
                  "Manhattan: significance threshold line missing"
              passed.append("Manhattan significance threshold line present")
              yl = ax.get_ylabel().lower()
              assert "log" in yl and ("10" in yl or "log10" in yl or "−log" in yl or "-log" in yl), \
                  "Manhattan: y-label not −log10(p)"
              passed.append("Manhattan −log10(p) y-label")
      
          elif kind == "timeline":
              ax = fig.axes[0]
              ne = getattr(fig, "_mf_n_events")
              assert any(np.allclose(ln.get_ydata(), 0.0) for ln in ax.lines), \
                  "timeline: baseline axis missing"
              passed.append("timeline baseline present")
              assert ax.collections, "timeline: event markers missing"
              passed.append("timeline event markers present")
              assert len([t for t in ax.texts if t.get_text().strip()]) >= ne, \
                  "timeline: an event label is missing"
              passed.append(f"timeline all {ne} event labels present")
              assert "time" in ax.get_xlabel().lower(), "timeline: x-axis not a time axis"
              passed.append("timeline time x-axis")
      
          else:
              raise AssertionError(f"unknown figure kind: {kind!r}")
          return passed
      
      
      # ----------------------------------------------------------------- driver
      def render_all(inputs: dict, out_dir: Path) -> dict:
          """Render each figure kind present in ``inputs``, save PNGs, and assert structure.
          Returns {kind: [passed checks]}. Raises on any structural violation."""
          out_dir.mkdir(parents=True, exist_ok=True)
          results: dict[str, list[str]] = {}
          builders = {
              "km": lambda d: km_curve(d["groups"], d["max_follow_up"], title=d.get("title", "")),
              "roc": lambda d: roc_curve(d["fpr"], d["tpr"], d["auc"],
                                         operating_point=d.get("operating_point")),
              "calibration": lambda d: calibration_plot(
                  d["pred_mean"], d["obs_freq"], slope=d["slope"], intercept=d["intercept"],
                  ci_low=d.get("ci_low"), ci_high=d.get("ci_high")),
              "dca": lambda d: decision_curve(d["thresholds"], d["net_benefit_model"],
                                              d["prevalence"]),
              "forest": lambda d: forest_plot(d["studies"], d["pooled"],
                                              null_value=d.get("null_value", 1.0),
                                              effect_label=d.get("effect_label", "Effect (95% CI)"),
                                              log_x=d.get("log_x", True)),
              "bland_altman": lambda d: bland_altman(d["mean_vals"], d["diff_vals"],
                                                     bias=d["bias"], sd_diff=d["sd_diff"]),
              "confusion": lambda d: confusion_matrix(d["matrix"], d["labels"]),
              "mrmc_roc": lambda d: mrmc_roc(d["readers"], d["averaged"],
                                             delta_auc=d.get("delta_auc")),
              "manhattan": lambda d: manhattan(d["x"], d["neglogp"], d["threshold"],
                                               labels=d.get("labels"),
                                               xlabel=d.get("xlabel", "Exposure / position")),
              "timeline": lambda d: clinical_timeline(d["events"],
                                                      time_unit=d.get("time_unit", "days from admission")),
          }
          for kind, build in builders.items():
              if kind not in inputs:
                  continue
              fig = build(inputs[kind])
              results[kind] = assert_structure(fig)
              fig.savefig(out_dir / f"{kind}.png", dpi=120)
              png = out_dir / f"{kind}.png"
              assert png.exists() and png.stat().st_size > 2000, f"{kind}: PNG not written"
              plt.close(fig)
          return results
      
      
      def main(argv=None) -> int:
          ap = argparse.ArgumentParser(description="Render + structurally verify core clinical figures.")
          ap.add_argument("--inputs", required=True, help="JSON with km/roc/calibration/dca inputs")
          ap.add_argument("--out-dir", required=True, help="directory for rendered PNGs")
          args = ap.parse_args(argv)
          inputs = json.loads(Path(args.inputs).read_text(encoding="utf-8"))
          try:
              results = render_all(inputs, Path(args.out_dir))
          except AssertionError as e:
              print(f"RENDER-FAIL: {e}", file=sys.stderr)
              return 1
          for kind, checks in results.items():
              print(f"OK [{kind}] {len(checks)} structural invariants: {'; '.join(checks)}")
          print(f"PASS: {len(results)} figure(s) rendered + structurally verified.")
          return 0
      
      
      if __name__ == "__main__":
          raise SystemExit(main())
      
    • validate_pptx_mac_compat.py 8.5 KB
      #!/usr/bin/env python3
      """Validate a .pptx for Mac PowerPoint compatibility.
      
      Detects four classes of defect that PowerPoint Mac renders differently
      from PowerPoint Windows / Keynote / LibreOffice and that PDF export
      alone does not catch:
      
      1. TIFF images embedded in ppt/media/ — Mac PowerPoint silently drops them.
      2. <a:sp3d> 3-D bevel inside text rPr — Mac renders as red outline that
         does not appear in PDF or on Windows.
      3. docProps/app.xml count mismatch with actual slide count — triggers a
         "PowerPoint found a problem" recovery dialog on first open.
      4. <a:srcRect> values exceeding 100000 (1/1000-percent) — image is
         over-cropped (sometimes 99 % cut off) only on Mac.
      
      Reference rule: ~/.claude/rules/pptx-mac-compatibility.md.
      
      Usage:
          python validate_pptx_mac_compat.py path/to/deck.pptx
          python validate_pptx_mac_compat.py deck.pptx --json out.json
          python validate_pptx_mac_compat.py deck.pptx --strict   # exit 1 on any FAIL
      
      Exit codes:
          0 — all checks PASS
          1 — at least one FAIL (only with --strict; otherwise still 0 with WARN)
          2 — input invalid (file missing, not a zip, etc.)
      """
      
      from __future__ import annotations
      
      import argparse
      import json
      import re
      import sys
      import zipfile
      from pathlib import Path
      
      
      SP3D_RE = re.compile(r"<a:sp3d\b", re.IGNORECASE)
      SRC_RECT_RE = re.compile(
          r'<a:srcRect\b[^/>]*?(?:l|t|r|b)="(\d+)"', re.IGNORECASE
      )
      APP_XML_SLIDES_RE = re.compile(r"<Slides>(\d+)</Slides>", re.IGNORECASE)
      APP_XML_VECTOR_SIZE_RE = re.compile(
          r'<vt:vector\b[^>]*?size="(\d+)"', re.IGNORECASE
      )
      
      
      def _check_tiff(zf: zipfile.ZipFile) -> list[str]:
          found = [
              name
              for name in zf.namelist()
              if name.lower().startswith("ppt/media/")
              and name.lower().endswith((".tif", ".tiff"))
          ]
          return found
      
      
      def _check_sp3d(zf: zipfile.ZipFile) -> list[tuple[str, int]]:
          hits: list[tuple[str, int]] = []
          for name in zf.namelist():
              if not (name.startswith("ppt/slides/") and name.endswith(".xml")):
                  continue
              try:
                  text = zf.read(name).decode("utf-8", errors="replace")
              except Exception:
                  continue
              count = len(SP3D_RE.findall(text))
              if count > 0:
                  hits.append((name, count))
          return hits
      
      
      def _check_app_xml(zf: zipfile.ZipFile) -> dict:
          result = {"present": False, "declared_slides": None, "actual_slides": None,
                    "vector_size_max": None, "mismatch": False}
          actual = sum(
              1
              for n in zf.namelist()
              if n.startswith("ppt/slides/slide") and n.endswith(".xml")
          )
          result["actual_slides"] = actual
          try:
              app = zf.read("docProps/app.xml").decode("utf-8", errors="replace")
          except KeyError:
              return result
          result["present"] = True
          m = APP_XML_SLIDES_RE.search(app)
          if m:
              declared = int(m.group(1))
              result["declared_slides"] = declared
              if declared != actual:
                  result["mismatch"] = True
          sizes = [int(s) for s in APP_XML_VECTOR_SIZE_RE.findall(app)]
          if sizes:
              result["vector_size_max"] = max(sizes)
          return result
      
      
      def _check_src_rect(zf: zipfile.ZipFile) -> list[tuple[str, int]]:
          """Return [(slide_path, max_value)] for any srcRect value > 100000.
      
          srcRect uses 1/1000-percent units; legitimate values are 0–100000.
          Anything above 100000 indicates a unit-conversion bug (e.g., a
          percent-to-1/1000-percent miscalculation) and produces severe over-crop
          on Mac PowerPoint.
          """
          hits: list[tuple[str, int]] = []
          for name in zf.namelist():
              if not (name.startswith("ppt/slides/") and name.endswith(".xml")):
                  continue
              try:
                  text = zf.read(name).decode("utf-8", errors="replace")
              except Exception:
                  continue
              values = [int(v) for v in SRC_RECT_RE.findall(text)]
              bad = [v for v in values if v > 100000]
              if bad:
                  hits.append((name, max(bad)))
          return hits
      
      
      def validate(pptx_path: Path) -> dict:
          if not pptx_path.exists():
              return {"ok": False, "error": f"file not found: {pptx_path}"}
          if not zipfile.is_zipfile(pptx_path):
              return {"ok": False, "error": f"not a zip / pptx: {pptx_path}"}
      
          with zipfile.ZipFile(pptx_path) as zf:
              tiffs = _check_tiff(zf)
              sp3d = _check_sp3d(zf)
              app = _check_app_xml(zf)
              srcrect = _check_src_rect(zf)
      
          findings: list[dict] = []
      
          if tiffs:
              findings.append({
                  "id": "TIFF",
                  "severity": "FAIL",
                  "message": f"{len(tiffs)} TIFF image(s) embedded; Mac PowerPoint will silently drop these.",
                  "files": tiffs,
                  "fix": "Convert each to PNG (`sips -s format png in.tif --out out.png` on macOS) and update _rels/*.rels to reference the new .png filenames.",
              })
      
          if sp3d:
              total = sum(c for _, c in sp3d)
              findings.append({
                  "id": "SP3D",
                  "severity": "FAIL",
                  "message": f"<a:sp3d> 3-D bevel found in {len(sp3d)} slide(s) ({total} occurrence(s)); renders as red outline on Mac PowerPoint and is invisible in PDF export.",
                  "files": [{"slide": n, "count": c} for n, c in sp3d],
                  "fix": "Strip <a:sp3d>...</a:sp3d> blocks via regex in each slide XML. Verify on Mac PowerPoint after fix.",
              })
      
          if app["present"] and app["mismatch"]:
              findings.append({
                  "id": "APP_XML_COUNT",
                  "severity": "FAIL",
                  "message": f"docProps/app.xml declares <Slides>{app['declared_slides']}</Slides> but the package contains {app['actual_slides']} slide XML files; PowerPoint Mac will show a 'recover file' dialog on first open.",
                  "fix": "Update <Slides>, <HeadingPairs>, and <TitlesOfParts> in docProps/app.xml to match the actual slide count.",
              })
          elif not app["present"]:
              findings.append({
                  "id": "APP_XML_MISSING",
                  "severity": "WARN",
                  "message": "docProps/app.xml not found in package; PowerPoint Mac may show a recovery dialog or open with broken metadata.",
                  "fix": "Re-export the deck from PowerPoint (which writes app.xml) or generate one programmatically.",
              })
      
          if srcrect:
              findings.append({
                  "id": "SRC_RECT_OVERFLOW",
                  "severity": "FAIL",
                  "message": f"<a:srcRect> values >100000 (1/1000-percent maximum) found in {len(srcrect)} slide(s); image will be over-cropped on Mac PowerPoint (often >99 % cut off).",
                  "files": [{"slide": n, "max_value": v} for n, v in srcrect],
                  "fix": "Recompute srcRect: percentages stay 0–100; 1/1000-percent units stay 0–100000. Compare to original .pptx srcRect values if a build script regressed.",
              })
      
          fail_count = sum(1 for f in findings if f["severity"] == "FAIL")
          warn_count = sum(1 for f in findings if f["severity"] == "WARN")
      
          return {
              "ok": fail_count == 0,
              "file": str(pptx_path),
              "summary": {
                  "fail": fail_count,
                  "warn": warn_count,
                  "slides": app["actual_slides"],
              },
              "findings": findings,
          }
      
      
      def _format_human(report: dict) -> str:
          if not report.get("ok") and "error" in report:
              return f"ERROR: {report['error']}"
          lines = [f"PPTX Mac compatibility check — {report['file']}"]
          s = report["summary"]
          lines.append(f"  slides={s['slides']}  fail={s['fail']}  warn={s['warn']}")
          if not report["findings"]:
              lines.append("  ✓ All four checks PASS (TIFF, sp3d, app.xml count, srcRect).")
              return "\n".join(lines)
          for f in report["findings"]:
              lines.append(f"  [{f['severity']}] {f['id']}: {f['message']}")
              lines.append(f"        fix: {f['fix']}")
          return "\n".join(lines)
      
      
      def main(argv: list[str] | None = None) -> int:
          ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
          ap.add_argument("pptx", help="path to .pptx")
          ap.add_argument("--json", help="write JSON report to this path")
          ap.add_argument("--strict", action="store_true",
                          help="exit 1 if any FAIL (default exits 0 with WARN)")
          args = ap.parse_args(argv)
      
          report = validate(Path(args.pptx))
          if not report.get("ok") and "error" in report:
              print(_format_human(report), file=sys.stderr)
              return 2
      
          if args.json:
              Path(args.json).write_text(json.dumps(report, indent=2))
          print(_format_human(report))
      
          if args.strict and not report["ok"]:
              return 1
          return 0
      
      
      if __name__ == "__main__":
          raise SystemExit(main())
      
    • _strobe_cascade.py 4.8 KB
      #!/usr/bin/env python3
      """STROBE participant-flow cascade closure check for a build_strobe_template.py config.
      
      A STROBE flow diagram's exclusion cascade must balance: the count in a spine box, minus the
      exclusions declared after it, must equal the count in the next spine box. A real cohort
      figure once read "500 excluded -> N = 9,470" while the enrolled box said 10,000, so
      10,000 - 500 = 9,500, not 9,470 — a second exclusion, present in the legend, had been
      dropped from the figure. It survived a full round of peer review and was found only by
      rendering the submission PDF to an image and reading it by eye, because figure-image numbers
      are text-grep blind.
      
      `check_cohort_arithmetic.py` already asserts this closure in manuscript prose, GFM tables and
      committed CSVs. The number that a reviewer actually sees, though, lives as text in the flow
      diagram, generated here from a structured YAML — so the diagram can drift from the prose.
      This makes the figure carry its own assertion.
      
      Low false-positive by construction: a spine link is checked ONLY when at least one exclusion
      is declared after that box (the author is asserting "A minus these gives B"), and only when
      every count involved is extractable. A branching Analysis leaf (two boxes sharing a parent,
      no exclusion between them) is never treated as a cascade step. A box with no "n = …" count is
      skipped, not guessed.
      
      Reused by build_strobe_template.py (a loud warning during the build; fatal under
      --strict-cascade) and runnable standalone (`_strobe_cascade.py --config figure1.yaml
      --strict`) so the check travels without python-pptx.
      """
      from __future__ import annotations
      
      import argparse
      import re
      import sys
      from pathlib import Path
      
      # The box TOTAL is the first "n = X" / "N = X" in the box text — the parenthetical after the
      # label ("Enrolled (n = 10,000)", "Excluded (n = 500):"). Sub-bullet counts come after it.
      _COUNT_RE = re.compile(r"[nN]\s*=\s*([\d,]+)")
      
      
      def extract_count(text: str | None) -> int | None:
          """First `n = X` in the box text as an int, or None when the box carries no count."""
          if not text:
              return None
          m = _COUNT_RE.search(str(text))
          return int(m.group(1).replace(",", "")) if m else None
      
      
      def check_cascade(cfg: dict) -> list[dict]:
          """Return an imbalance finding for every declared exclusion link A -> B where
          ``A.count - sum(exclusions after A) != B.count``."""
          spine = cfg.get("spine") or []
          exclusions = cfg.get("exclusions") or []
          if len(spine) < 2:
              return []
      
          counts = {b.get("id"): extract_count(b.get("text")) for b in spine if isinstance(b, dict)}
          excl_after: dict[str, list[int | None]] = {}
          for e in exclusions:
              if isinstance(e, dict) and e.get("after"):
                  excl_after.setdefault(e["after"], []).append(extract_count(e.get("text")))
      
          findings: list[dict] = []
          for i in range(len(spine) - 1):
              a, b = spine[i], spine[i + 1]
              if not (isinstance(a, dict) and isinstance(b, dict)):
                  continue
              aid = a.get("id")
              excls = excl_after.get(aid)
              if not excls:                       # only a DECLARED exclusion link is a cascade step
                  continue
              a_n, b_n = counts.get(aid), counts.get(b.get("id"))
              if a_n is None or b_n is None or any(x is None for x in excls):
                  continue                        # never guess a missing count
              got = a_n - sum(excls)
              if got != b_n:
                  findings.append({
                      "after": aid,
                      "next": b.get("id"),
                      "detail": (f"STROBE cascade does not close after '{aid}': {a_n:,} - "
                                 f"{'+'.join(f'{x:,}' for x in excls)} = {got:,}, but the next box "
                                 f"'{b.get('id')}' says {b_n:,} (off by {b_n - got:+,})"),
                  })
          return findings
      
      
      def _load(path: Path) -> dict:
          text = path.read_text(encoding="utf-8")
          if path.suffix.lower() in {".yaml", ".yml"}:
              try:
                  import yaml  # noqa: PLC0415
              except ModuleNotFoundError:
                  sys.exit("PyYAML not installed; install it or pass a JSON config.")
              return yaml.safe_load(text) or {}
          import json  # noqa: PLC0415
          return json.loads(text)
      
      
      def main() -> int:
          ap = argparse.ArgumentParser(description="STROBE flow cascade-closure check.")
          ap.add_argument("--config", required=True, help="build_strobe_template.py YAML/JSON config")
          ap.add_argument("--strict", action="store_true", help="exit 1 if the cascade does not close")
          a = ap.parse_args()
          findings = check_cascade(_load(Path(a.config)))
          if findings:
              for f in findings:
                  print(f"CASCADE_IMBALANCE: {f['detail']}")
          else:
              print("OK: STROBE exclusion cascade closes at every declared link.")
          return 1 if (findings and a.strict) else 0
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
  • templates
    • official
      • consort2010
        • CONSORT_2025_editable_checklist.docx 36.1 KB · in bundle
        • CONSORT_2025_flow_diagram.docx 66.5 KB · in bundle
      • prisma2020
        • PRISMA_2020_flow_new_v1.pptx 29 KB · in bundle
        • PRISMA_2020_flow_new_v2.pptx 29.1 KB · in bundle
        • PRISMA_2020_flow_updated_v2.pptx 29.1 KB · in bundle
      • spirit2013
        • SPIRIT_2025_editable_checklist.docx 33 KB · in bundle
        • SPIRIT_2025_participant_timeline.docx 19.6 KB · in bundle
      • stard2015
        • STARD_2015_checklist.docx 29.1 KB · in bundle
        • STARD_2015_flow_diagram.pdf 311.4 KB · in bundle
      • NOTES.md 2.8 KB
        # Official Reporting Guideline Templates
        
        This directory ships canonical flow diagram and figure templates for the four
        most commonly required reporting guidelines in clinical research:
        
        | Guideline | Source | What we ship | Why |
        |-----------|--------|--------------|-----|
        | PRISMA 2020 | prismastatement.org (CC-BY 4.0) | Locally built `.pptx` (4 variants: new/updated × v1/v2) | Site uses Squarespace JS-fingerprint redirect that blocks programmatic download. We reproduce the published layout (Page MJ et al. *BMJ* 2021;372:n71, Fig 1) and supply a `fill_prisma_template.py` to populate counts. |
        | CONSORT 2025 | consort-spirit.org | Official `.docx` (flow diagram + editable checklist) | Direct fetch works; supersedes CONSORT 2010 per the SPIRIT-CONSORT 2025 update. |
        | STARD 2015 | equator-network.org | Official `.pdf` flow diagram + `.docx` checklist | Flow diagram is published as PDF only; no Word source exists upstream. |
        | SPIRIT 2025 | consort-spirit.org | Official `.docx` (participant timeline + editable checklist) | Direct fetch works; supersedes SPIRIT 2013 per the 2025 update. |
        
        ## Refresh / verify
        
        ```bash
        bash scripts/fetch_official_templates.sh           # all targets
        bash scripts/fetch_official_templates.sh consort2010
        FORCE=1 bash scripts/fetch_official_templates.sh   # ignore cache
        ```
        
        The fetcher reports per-target OK/SKIP/FAIL. URLs are versioned in the script
        header — if a target rotates, update `TARGETS=` and rerun.
        
        ## PRISMA 2020 build/fill workflow
        
        ```bash
        # (one-time) generate template variants
        python3 scripts/build_prisma2020_template.py --variant new \
            --out templates/official/prisma2020/PRISMA_2020_flow_new_v1.pptx
        python3 scripts/build_prisma2020_template.py --variant new --include-other-sources \
            --out templates/official/prisma2020/PRISMA_2020_flow_new_v2.pptx
        
        # fill with study counts (positional 10-tuple)
        python3 scripts/fill_prisma_template.py \
            --template templates/official/prisma2020/PRISMA_2020_flow_new_v1.pptx \
            --counts "315,122,186,7,111,204,102,84,3,15" \
            --out fig1_prisma_filled.pptx
        
        # or with a JSON file giving every key
        python3 scripts/fill_prisma_template.py \
            --template templates/official/prisma2020/PRISMA_2020_flow_new_v1.pptx \
            --counts-file my_counts.json \
            --out fig1_prisma_filled.pptx
        ```
        
        Render to PDF/PNG via LibreOffice headless:
        
        ```bash
        soffice --headless --convert-to pdf fig1_prisma_filled.pptx
        soffice --headless --convert-to png fig1_prisma_filled.pptx
        ```
        
        ## Attribution
        
        PRISMA layout: Page MJ, McKenzie JE, Bossuyt PM, et al. The PRISMA 2020
        statement: an updated guideline for reporting systematic reviews. *BMJ*
        2021;372:n71. doi:10.1136/bmj.n71. CC-BY 4.0.
        
        CONSORT 2025 / SPIRIT 2025: see https://www.consort-spirit.org/.
        
        STARD 2015: Bossuyt PM, Reitsma JB, Bruns DE, et al. STARD 2015. *BMJ*
        2015;351:h5527.
        
  • tests
    • fixtures
      • figure1_flow.yaml 242 B
        # STROBE flow config (SSOT) consumed by generate_flow_diagram.R
        boxes:
          - id: assessed
            label: "Assessed for eligibility (n = 1284)"
          - id: excluded
            label: "Excluded (n = 286)"
          - id: analytic
            label: "Analytic cohort (n = 998)"
        
      • manuscript_ok.md 230 B
        ## Results
        The analytic cohort comprised 998 participants.
        
        ## Figure Legends
        
        **Figure 1.** STROBE flow diagram. Of n = 1,284 assessed for eligibility, n = 286 were
        excluded, leaving an analytic cohort of n = 998.
        
        ## References
        
      • manuscript_stale.md 157 B
        ## Figure Legends
        
        **Figure 1.** STROBE flow diagram. Of n = 1,284 assessed for eligibility, n = 286 were
        excluded, leaving an analytic cohort of n = 1,150.
        
      • strobe_cascade_balanced.yaml 346 B
        # The same cascade, closing exactly: 10,000 - 500 = 9,500.
        title: "Figure 1. STROBE participant flow diagram"
        spine:
          - {id: enrolled, text: "Participants enrolled (n = 10,000)"}
          - {id: analyzed, text: "Included in the primary analysis (n = 9,500)"}
        exclusions:
          - {after: enrolled, text: "Excluded (n = 500):\n- missing baseline covariate"}
        
      • strobe_cascade_branching.yaml 519 B
        # A branching Analysis leaf: primary and landmark share the eligible parent, with no
        # exclusion declared between them. The landmark subset (4,200) must NOT be read as an
        # unbalanced cascade step off the primary (5,000).
        title: "Figure 1. STROBE participant flow diagram"
        spine:
          - {id: eligible, text: "Eligible cohort (n = 5,000)"}
          - {id: primary,  text: "Primary Cox model (n = 5,000)"}
          - {id: landmark, text: "Landmark subset analysis (n = 4,200)"}
        exclusions:
          - {after: eligible, text: "Excluded (n = 0)"}
        
      • strobe_cascade_imbalanced.yaml 648 B
        # Synthetic, PII-free. One exclusion box drops out of the arithmetic: enrolled 10,000 minus
        # the single declared exclusion 500 is 9,500, but the analysis box reads 9,470 — off by 30
        # (a second exclusion, present in the legend, was dropped from the figure). This is the
        # real-world class: a cascade that is short of the analytic N by one omitted exclusion.
        title: "Figure 1. STROBE participant flow diagram"
        spine:
          - {id: enrolled, text: "Participants enrolled (n = 10,000)"}
          - {id: analyzed, text: "Included in the primary analysis (N = 9,470)"}
        exclusions:
          - {after: enrolled, text: "Excluded (n = 500):\n- missing baseline covariate"}
        
    • test_legend_reconcile.sh 1.5 KB
      #!/usr/bin/env bash
      # Regression test for the Figure 1 caption ↔ flow-SSOT reconciler.
      # Synthetic fixtures: a flow config with counts {1284, 286, 998}; an OK caption
      # that matches, and a stale caption citing 1,150 (absent from the diagram).
      # Stdlib-only (python3).
      set -u
      
      HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
      SCRIPT="$HERE/../scripts/derive_figure_legend_counts.py"
      FLOW="$HERE/fixtures/figure1_flow.yaml"
      OK="$HERE/fixtures/manuscript_ok.md"
      STALE="$HERE/fixtures/manuscript_stale.md"
      OUT="$(mktemp -t fl_XXXX).json"
      trap 'rm -f "$OUT"' EXIT
      
      fail=0
      check() { local label="$1"; shift
          if "$@" >/dev/null 2>&1; then printf '  PASS  %s\n' "$label"
          else printf '  FAIL  %s\n' "$label"; fail=$((fail+1)); fi
      }
      
      [[ -f "$SCRIPT" ]] || { echo "ENV-ERR: script missing" >&2; exit 2; }
      
      python3 "$SCRIPT" --flow-config "$FLOW" --manuscript "$OK" --strict >/dev/null 2>&1
      check "exit 0 when caption matches the flow SSOT" test "$?" -eq 0
      
      python3 "$SCRIPT" --flow-config "$FLOW" --manuscript "$STALE" --out "$OUT" --strict >/dev/null 2>&1
      check "exit 1 when caption cites a count absent from the flow SSOT" test "$?" -eq 1
      check "stale count 1150 flagged" python3 -c "
      import json; d=json.load(open('$OUT'))
      assert 1150 in d['stale_in_caption'], d['stale_in_caption']"
      check "verdict MISMATCH" python3 -c "
      import json; assert json.load(open('$OUT'))['verdict']=='MISMATCH'"
      
      echo "fail=$fail"; [[ "$fail" -eq 0 ]] && echo "ALL PASS" || echo "FAILURES: $fail"
      exit "$fail"
      
    • test_pptx_mac_compat.py 4.9 KB
      #!/usr/bin/env python3
      """Regression test for scripts/validate_pptx_mac_compat.py.
      
      Builds a clean .pptx (python-pptx, with a corrected docProps/app.xml slide
      count) that must PASS, then injects each of the four Mac-incompatibility defect
      classes into a copy and asserts the validator FAILs under --strict:
      
          TIFF media, <a:sp3d> 3-D bevel, app.xml slide-count mismatch, srcRect
          over-crop (> 100000).
      
      Also asserts a missing input exits 2. Requires python-pptx (a CI dependency).
      """
      
      from __future__ import annotations
      
      import re
      import subprocess
      import sys
      import tempfile
      import zipfile
      from pathlib import Path
      
      HERE = Path(__file__).resolve().parent
      VALIDATOR = HERE.parent / "scripts" / "validate_pptx_mac_compat.py"
      
      _pass = 0
      _fail = 0
      
      
      def ck(label: str, expected: int, actual: int) -> None:
          global _pass, _fail
          if expected == actual:
              print(f"  PASS  {label:<48} exit={actual}")
              _pass += 1
          else:
              print(f"  FAIL  {label:<48} expected={expected} actual={actual}")
              _fail += 1
      
      
      def run(pptx: Path, strict: bool = True) -> int:
          cmd = [sys.executable, str(VALIDATOR), str(pptx)]
          if strict:
              cmd.append("--strict")
          return subprocess.run(cmd, capture_output=True, text=True).returncode
      
      
      def build_clean(path: Path, n_slides: int = 2) -> None:
          """A python-pptx deck with app.xml <Slides> corrected to match — should PASS."""
          from pptx import Presentation
      
          prs = Presentation()
          for _ in range(n_slides):
              prs.slides.add_slide(prs.slide_layouts[6])  # blank
          prs.save(str(path))
          # python-pptx writes <Slides>0</Slides>; fix it so the clean baseline passes.
          _rewrite_member(path, "docProps/app.xml",
                           lambda b: re.sub(rb"<Slides>\d+</Slides>",
                                            f"<Slides>{n_slides}</Slides>".encode(), b))
      
      
      def _read_member(path: Path, member: str) -> bytes:
          with zipfile.ZipFile(path) as z:
              return z.read(member)
      
      
      def _rewrite_member(path: Path, member: str, transform, extra: dict | None = None) -> None:
          """Rewrite the zip, replacing `member` (via transform) and adding `extra` files."""
          with zipfile.ZipFile(path) as z:
              items = {n: z.read(n) for n in z.namelist()}
          if member in items:
              items[member] = transform(items[member])
          if extra:
              items.update(extra)
          tmp = path.with_suffix(".tmp.pptx")
          with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as z:
              for n, data in items.items():
                  z.writestr(n, data)
          tmp.replace(path)
      
      
      def first_slide(path: Path) -> str:
          with zipfile.ZipFile(path) as z:
              for n in sorted(z.namelist()):
                  if n.startswith("ppt/slides/slide") and n.endswith(".xml"):
                      return n
          raise AssertionError("no slide XML in fixture")
      
      
      def main() -> int:
          if VALIDATOR.exists() is False:
              print(f"validator missing: {VALIDATOR}")
              return 1
          tmp = Path(tempfile.mkdtemp())
      
          clean = tmp / "clean.pptx"
          build_clean(clean)
          ck("clean deck (app.xml fixed) passes", 0, run(clean))
      
          # 1) TIFF embedded in ppt/media/
          tiff = tmp / "tiff.pptx"
          build_clean(tiff)
          _rewrite_member(tiff, "docProps/app.xml", lambda b: b,
                          extra={"ppt/media/image1.tiff": b"II*\x00 fake tiff"})
          ck("TIFF media -> FAIL", 1, run(tiff))
      
          # 2) <a:sp3d> 3-D bevel inside a slide
          sp3d = tmp / "sp3d.pptx"
          build_clean(sp3d)
          sl = first_slide(sp3d)
          _rewrite_member(sp3d, sl, lambda b: b.replace(b"</p:sld>", b"<a:sp3d/></p:sld>"))
          ck("sp3d bevel -> FAIL", 1, run(sp3d))
      
          # 3) app.xml slide-count mismatch (declare a wrong count)
          appx = tmp / "appx.pptx"
          build_clean(appx)
          _rewrite_member(appx, "docProps/app.xml",
                          lambda b: re.sub(rb"<Slides>\d+</Slides>", b"<Slides>99</Slides>", b))
          ck("app.xml slide-count mismatch -> FAIL", 1, run(appx))
      
          # 4) srcRect over-crop (> 100000)
          src = tmp / "srcrect.pptx"
          build_clean(src)
          sl2 = first_slide(src)
          _rewrite_member(src, sl2, lambda b: b.replace(b"</p:sld>", b'<a:srcRect l="997171"/></p:sld>'))
          ck("srcRect over-crop (>100000) -> FAIL", 1, run(src))
      
          # 5) missing input -> exit 2
          ck("missing input -> exit 2", 2, run(tmp / "does_not_exist.pptx"))
      
          # 6) non-strict tolerates a WARN-only deck (no app.xml -> WARN, exit 0)
          nowarn = tmp / "noappxml.pptx"
          build_clean(nowarn)
          _rewrite_member(nowarn, "docProps/app.xml", lambda b: b, extra=None)
          # remove app.xml entirely
          with zipfile.ZipFile(nowarn) as z:
              items = {n: z.read(n) for n in z.namelist() if n != "docProps/app.xml"}
          with zipfile.ZipFile(nowarn, "w", zipfile.ZIP_DEFLATED) as z:
              for n, data in items.items():
                  z.writestr(n, data)
          ck("missing app.xml is WARN, tolerated without --strict", 0, run(nowarn, strict=False))
      
          print("----")
          print(f"test_pptx_mac_compat: {_pass} passed, {_fail} failed")
          return 0 if _fail == 0 else 1
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • test_strobe_cascade.sh 3 KB
      #!/usr/bin/env bash
      # Regression test for the STROBE flow cascade-closure check (make-figures).
      # Synthetic, PII-free YAML fixtures modelled on a real cohort-figure defect:
      #   imbalanced -> 10,000 - 500 = 9,500, but the analysis box says 9,470 (off by 30)
      #   balanced   -> the same cascade closing exactly (9,500)
      #   branching  -> a landmark-subset leaf with no exclusion between it and its parent, which
      #                 must NOT be read as an unbalanced cascade step (low-false-positive guard)
      # The helper (_strobe_cascade.py) is checked directly (no python-pptx needed); the
      # build_strobe_template.py --strict-cascade integration is checked only when pptx is present.
      set -u
      HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
      CHK="$HERE/../scripts/_strobe_cascade.py"
      BUILD="$HERE/../scripts/build_strobe_template.py"
      FX="$HERE/fixtures"
      fail=0
      ck() { if [ "$2" = "$3" ]; then printf '  PASS  %s\n' "$1"; else printf '  FAIL  %s (want %s got %s)\n' "$1" "$2" "$3"; fail=$((fail+1)); fi; }
      
      [ -f "$CHK" ] || { echo "ENV-ERR: helper missing" >&2; exit 2; }
      
      # (1) imbalanced cascade -> exit 1 under --strict, and the message names the offending link.
      out="$(python3 "$CHK" --config "$FX/strobe_cascade_imbalanced.yaml" --strict 2>&1)"; rc=$?
      ck "imbalanced exits 1 under --strict" 1 "$rc"
      printf '%s\n' "$out" | grep -q 'CASCADE_IMBALANCE' && ck "imbalanced reports CASCADE_IMBALANCE" yes yes || ck "imbalanced reports CASCADE_IMBALANCE" yes no
      printf '%s\n' "$out" | grep -q "off by -30" && ck "imbalanced names the -30 offset" yes yes || ck "imbalanced names the -30 offset" yes no
      
      # (2) balanced cascade -> exit 0, no imbalance.
      python3 "$CHK" --config "$FX/strobe_cascade_balanced.yaml" --strict >/dev/null 2>&1; ck "balanced exits 0" 0 "$?"
      
      # (3) branching leaf -> exit 0 (the 4,200 subset is not a cascade step off the 5,000 parent).
      out3="$(python3 "$CHK" --config "$FX/strobe_cascade_branching.yaml" --strict 2>&1)"; ck "branching leaf exits 0 (no false positive)" 0 "$?"
      printf '%s\n' "$out3" | grep -q 'CASCADE_IMBALANCE' && { echo "  FAIL  branching leaf falsely flagged" >&2; fail=$((fail+1)); } || printf '  PASS  branching leaf not flagged\n'
      
      # (4) build integration (only if python-pptx is installed): --strict-cascade refuses the
      #     imbalanced config and builds the balanced one.
      if python3 -c "import pptx" 2>/dev/null; then
        tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT
        python3 "$BUILD" --config "$FX/strobe_cascade_imbalanced.yaml" --out "$tmp/x.pptx" --strict-cascade >/dev/null 2>&1
        ck "build --strict-cascade refuses the imbalanced config" 1 "$?"
        python3 "$BUILD" --config "$FX/strobe_cascade_balanced.yaml" --out "$tmp/ok.pptx" --strict-cascade >/dev/null 2>&1
        ck "build --strict-cascade builds the balanced config" 0 "$?"
        [ -f "$tmp/ok.pptx" ] && ck "balanced build wrote the pptx" yes yes || ck "balanced build wrote the pptx" yes no
      else
        echo "  SKIP  build integration (python-pptx not installed)"
      fi
      
      echo "fail=$fail"; [ "$fail" -eq 0 ] && echo "ALL PASS" || echo "FAILURES: $fail"
      exit "$fail"
      
  • SKILL.md 46.7 KB
    ---
    name: make-figures
    description: Generate publication-ready figures and visual abstracts for medical research papers. Supports ROC curves, forest plots, CONSORT/STARD/PRISMA flow diagrams, calibration plots, Kaplan-Meier curves, Bland-Altman plots, confusion matrices, pipeline diagrams, and journal-specific visual/graphical abstracts (python-pptx template-based).
    triggers: figure, plot, graph, diagram, ROC curve, forest plot, flow diagram, CONSORT diagram, PRISMA flow, visualization, chart, visual abstract, graphical abstract, key message, figure design, figure planning, effective figure, cognitive load
    tools: Read, Write, Edit, Bash, Grep, Glob
    model: inherit
    ---
    
    # Make-Figures Skill
    
    You are helping a medical researcher generate publication-ready figures for medical research
    manuscripts. Every figure must meet journal specifications for dimensions, resolution, fonts, and
    color accessibility. Produce clean, data-focused visuals with no chartjunk.
    
    ## Credits
    
    The Critic Loop (Step 4b) in this skill is inspired by PaperBanana (Zhu et al., *Automating
    Academic Illustration for AI Scientists*, arXiv:2601.23265, 2025) and by prior self-refinement
    research — Self-Refine (Madaan et al., 2023), Reflexion (Shinn et al., 2023), and Constitutional
    AI (Anthropic, 2022). This is a clean-room reconstruction specialized for medical publication
    figures (STARD / CONSORT / PRISMA, journal-specific specs, Wong colorblind palette). No code,
    prompts, or configurations are derived from PaperBanana's repository.
    
    ## Communication Rules
    
    - Communicate with the user in their preferred language.
    - All figure text (labels, legends, annotations) must be in English.
    - Medical terminology is always in English.
    
    ## Data Privacy Check
    
    Before reading any data file, check whether it might contain Protected Health Information (PHI):
    
    1. If `*_deidentified.*` files exist in the working directory, use those preferentially.
    2. If only raw CSV/Excel files exist (no `*_deidentified.*` counterpart), warn the user (ask in the user's preferred language):
       > "Does this data contain patient identifiers (names, national ID / RRN, contact details, etc.)?
       > If so, please de-identify it first with the `/deidentify` skill."
    3. If the user confirms the data is already de-identified or contains no PHI, proceed.
    
    ## Reference Files
    
    - **Figure specifications**: `${CLAUDE_SKILL_DIR}/references/figure_specs.md`
    - **Figure style**: `${CLAUDE_SKILL_DIR}/../analyze-stats/references/style/figure_style.mplstyle` (or project's CLAUDE.md if available)
    - **Project data**: See CLAUDE.md for data locations under `2_Data/`
    
    Read `figure_specs.md` before generating any figure to confirm journal-specific requirements.
    
    ---
    
    ## Journal AI-Image Policies (CRITICAL — check BEFORE generation)
    
    > Synced with the user's global rule `~/.claude/rules/journal-ai-image-policies.md`. The table below is the local copy used during autonomous workflow; the global rule is authoritative when conflicts arise.
    
    
    | Journal family | Policy on AI-generated images | Disclosure required |
    |---|---|---|
    | **JACC family (incl. JACC: Asia, JACC Imaging, JACC EP, JACC BTS)** | **Prohibited without prior Editor-in-Chief permission** ([JACC pathway, PMC10167500](https://pmc.ncbi.nlm.nih.gov/articles/PMC10167500/)) | Cover-letter pre-submission inquiry + ICMJE-style declaration |
    | NEJM | AI image generation prohibited | N/A |
    | Radiology / Radiology AI | Allowed with disclosure | Manuscript disclosure block |
    | Nature family | Allowed with disclosure + license check | Methods + figure legend |
    | Lancet family | Disclosure required, generation discouraged | Manuscript disclosure |
    | Default (target unknown) | Treat as prohibited until confirmed | N/A |
    
    **Hard rule**: For JACC, NEJM, or any "unknown" target journal, **never** use Gemini / DALL-E / Midjourney / Stable Diffusion / Nano Banana to create images that will appear in figures, Central Illustrations, or graphical abstracts. AI text-editing of the manuscript prose remains acceptable subject to standard disclosure.
    
    ### Default workflow when AI images are not allowed
    
    1. **SMART Servier Medical Art** — https://smart.servier.com/, CC BY 4.0, free, 3,000+ vector medical icons (anatomy, organs, ethnicity-specific human figures, drugs, devices). Commercial / journal use allowed. **Required attribution** (1 line in figure legend OR methods):
       > Anatomical icons modified from SMART Servier Medical Art (CC BY 4.0).
    2. **NIAID BioArt** (https://bioart.niaid.nih.gov) — public domain (US Govt), microbiology / immunology / lab-tech focus.
    3. **BioRender** (https://www.biorender.com) — institutional license usually required; use the exported "Publication-ready" PNG/TIFF and cite per BioRender publication policy.
    4. For "diseased" variants not directly available (e.g., calcified vessel from a clean vessel): reuse the healthy asset and overlay disease markers via matplotlib `scatter` / `Circle` / `PathPatch`. Keeps the entire pipeline non-AI and reproducible.
    
    ### Asset directory convention
    
    ```
    manuscript/figures/_assets_servier/      # CC BY 4.0 source PNGs
    manuscript/figures/_assets_servier/CITATION.md   # source URL + download date per asset
    manuscript/figures/_assets_data/         # data-driven raster (R / matplotlib heat maps, KM, etc.)
    manuscript/figures/_legacy/              # archived prior versions
    ```
    
    Composition scripts should load only from `_assets_servier/` and `_assets_data/`. If a script imports from `_assets_ai/`, treat it as a policy violation for JACC/NEJM/unknown targets.
    
    When a figure is produced by a data-driven `.py`/`.R` script (ROC, forest, KM, calibration, heat maps), lint that script before finalizing with the `/analyze-stats` code-quality gate (`check_generated_code.py {script} --strict`): it catches a missing plotting seed for any bootstrapped CI band, a hardcoded absolute data path, or a hand-typed data literal that should have been read from the analysis CSV.
    
    ### Decoration vs information
    
    Even when AI images are allowed, AI-generated illustrations are immediately recognizable to experienced reviewers (small decorative icons that add no information, overly uniform layouts, generic clip-art style). For high-impact submissions, prefer Servier / BioArt / BioRender + matplotlib overlays over AI.
    
    ---
    
    ## DPI and Resolution Guide
    
    | Output | Minimum DPI | Notes |
    |--------|------------|-------|
    | Journal halftone (photos, screenshots) | 300 | Standard for most journals |
    | Journal line art (diagrams, graphs) | 600 | Required by Radiology, most Elsevier journals |
    | Poster presentation | 150-200 | Lower is acceptable for large-format prints |
    | Screen/web only | 72-150 | Not for print submission |
    
    **Practical workflow for screen captures**:
    - Use HyperSnap or similar tool with DPI pre-set to the journal requirement
    - Compose the figure in PPT at high zoom → capture at target DPI → save as TIFF/PNG
    - Verify final file dimensions match journal column width requirements
    
    ---
    
    ## Visual Abstract / Graphical Abstract
    
    Many journals now require or strongly encourage visual abstracts. European Radiology made
    graphical abstracts mandatory for all Original Articles from first revision (Jan 2025).
    Submitting one voluntarily signals effort and can improve editorial impression.
    
    ### Journal Requirements
    
    | Status | Example Journals |
    |--------|-----------------|
    | **Mandatory** | European Radiology (from 1st revision, all Original Articles) |
    | **Encouraged** | Abdominal Radiology, JCO, Annals of Internal Medicine |
    | **Voluntary** | Most other journals — improves social media visibility |
    
    Check the target journal profile (`write-paper/references/journal_profiles/`) for specific
    visual abstract requirements before starting.
    
    ### Workflow
    
    1. **Check journal template.** Look for an official PPTX template in
       `${CLAUDE_SKILL_DIR}/references/visual_abstract_templates/{journal}.pptx`.
       If no journal-specific template exists, use `medsci_default.pptx`.
    2. **Extract content from the manuscript:**
       - **Title:** Full article title
       - **Hypothesis/Question:** Derived from Key Point 1 or study objective (max 1 sentence)
       - **Methodology:** Brief flowchart or ≤3 bullets, <6 words each
       - **Visual element:** Study's own figure (ROC curve, flow diagram, representative image)
       - **Badges:** Patient cohort (N=...) | Modality/organ | Single/Multi-center
       - **Main finding:** Derived from Key Point 3 (<20 words)
       - **Citation:** Journal (year) Authors; DOI
    3. **Select visual element** (priority order — no API needed for top options):
       1. Study's own figures (ROC, flow diagram, representative image) — **always preferred**
       2. Free illustration from Servier Medical Art or NIAID BioArt
          (see `${CLAUDE_SKILL_DIR}/references/medical_illustration_sources.md`)
       3. Manual drawing in PPT/Keynote/Figma
       4. AI generation via `generate_image.py --style medical` (only if GEMINI_API_KEY set)
    4. **Generate using the script:**
       ```bash
       python ${CLAUDE_SKILL_DIR}/scripts/generate_visual_abstract.py \
         --template medsci_default \
         --title "Article Title" \
         --hypothesis "Research question" \
         --methods "Method 1|Method 2|Method 3" \
         --finding "Main finding statement" \
         --citation "Eur Radiol (2026) Author A et al; DOI:..." \
         --visual figures/fig1_roc_curve.png \
         --badges "N=450|CT chest|Multi-center" \
         --output figures/visual_abstract.pptx
       ```
    5. **Review with user.** Open the PPTX to verify layout and content. Iterate.
    6. **Export.** PPTX is the primary deliverable. For PNG: open in PowerPoint/Keynote → export,
       or use LibreOffice CLI (`soffice --headless --convert-to png`).
    
    ### Design Principles
    
    - One page, landscape (16:9) or per journal template specification
    - Three sections: Study question → Key method → Main result
    - Use the study's actual figures rather than generic graphics
    - Minimize text — let visuals carry the message
    - Every visual element must serve a purpose (no decorative clip-art)
    
    ### Available Templates
    
    | Template | File | Use When |
    |----------|------|----------|
    | MedSci Default | `medsci_default.pptx` | Any journal without an official template |
    | JACC Central Illustration | `jacc_central_illustration.pptx` | JACC family journals (use `--type central-illustration`) |
    
    **Using a journal's own template.** Several journals publish one — European Radiology requires a
    graphical abstract from first revision and supplies `EURA-GA-Jan2025.pptx`. We do not redistribute
    them: a template you may download is not a template we may ship. Use yours directly instead:
    
    ```bash
    python ${CLAUDE_SKILL_DIR}/scripts/generate_visual_abstract.py \
      --template /absolute/path/to/EURA-GA-Jan2025.pptx  ...
    ```
    
    `--template` takes an absolute path to any `.pptx`. The script locates the fields by their text
    content rather than by shape name, so a journal's own template works unmodified. If the path does
    not exist it falls back to `medsci_default.pptx`.
    
    To add a new journal template: see `${CLAUDE_SKILL_DIR}/references/visual_abstract_templates/template_guide.md`.
    
    ---
    
    ## Central Illustration vs Visual Abstract
    
    A Central Illustration (CI) is **not** a Visual Abstract (VA). They serve different purposes and follow different rules. JACC family journals (JACC, JACC: Asia, JACC: Cardiovascular Imaging, JACC: Heart Failure, JACC: CardioOncology, JACC: Clinical Electrophysiology, JACC: Basic to Translational Science) require a Central Illustration with every Original Article. Reference: Fuster V, Mann DL. *JACC.* 2019;74(22):2816–2820.
    
    | Aspect | Central Illustration | Visual Abstract |
    |---|---|---|
    | Purpose | Single key finding / take-home message | Methods + Results pictorial summary |
    | Where in paper | End of Results / start of Discussion | Beginning of paper |
    | Methods content | **None** | Required |
    | Audience | Cardiovascular clinicians + journal-issue readers | Broad including non-specialists / social media |
    | Used by | All JACC family + JACC: Asia | Originally JACC: Basic to Translational Science |
    | Text density | Minimal (graphical priority) | More allowed (methods labels) |
    | Bar graphs | OK if they capture entire message | Avoid — use ↑↓ arrows |
    | Default complexity | 1–3 visual zones | Q→M→R three blocks |
    
    ### Fuster-Mann five rules (CI must pass all)
    
    1. **Know the message.** One finding, not study design + multiple findings.
    2. **Convey graphically, not textually.** Even a simple KM curve is OK.
    3. **Avoid using too much text.** Replace with icons or arrows.
    4. **Avoid secondary messages.** ≤ 5 seconds for a viewer to state the main finding.
    5. **Simplicity is superior.** Default to fewer panels.
    
    Full guidance and validation thresholds: `${CLAUDE_SKILL_DIR}/references/jacc_central_illustration_principles.md`.
    
    ### CI mode invocation
    
    ```bash
    python ${CLAUDE_SKILL_DIR}/scripts/generate_visual_abstract.py \
      --type central-illustration \
      --visual figures/central_illustration_v2.png \
      --citation "FirstAuthor Last et al. Journal Name 2026; vol(issue):pages." \
      --output submission/jacc_asia/central_illustration.pptx \
      --ci-zones 3 --ci-label-words 22 --ci-numerical-points 2 \
      --ci-raw-text "warranty drops to 3 years in age 45+ with cardiometabolic burden; MASLD HR 1.77"
    ```
    
    CI mode validates before rendering and rejects (exit 2) if any of: zones > 3, label words > 30, numerical points > 4, or methodology terms (cohort flow / inclusion / exclusion / study design / enrollment / randomized / sample size / CONSORT / PRISMA / STARD) appear in `--ci-raw-text`. Override individual rules with `--ci-allow {zones|words|numerical|methods}` only when you have a defensible reason.
    
    The JACC submission PPTX is a 10×7.5 in slide with 4 placeholders (citation textbox, content picture, footer textbox reserved, JACC logo). The red border + blue "CENTRAL ILLUSTRATION:" header are applied by JACC editorial after acceptance — authors submit only the content figure + citation.
    
    ---
    
    ## Workflow
    
    ### Step 1: Specify
    
    **Before specifying figure type, read `${CLAUDE_SKILL_DIR}/references/design_principles.md`** —
    identify (1) the one-sentence key message, (2) audience and reading-time budget, and
    (3) whether a figure is the right vehicle (vs a small table or in-line text). The
    five strategies in that file shift Step 1 from "which chart fits the data" to
    "what should the reader remember 10 seconds later." Skip only when the figure
    is mandated by a reporting guideline (e.g., PRISMA / CONSORT flow), and even
    then apply the cognitive-load checklist.
    
    **For reporting-guideline figures**, also load
    `${CLAUDE_SKILL_DIR}/references/reporting_guideline_figure_map.md` — the
    14-row table tells you which guideline mandates which figures and whether
    this skill ships an official template (✅), generic flow only (⚠️), or
    needs manual production (❌). Critical for AI-extension guidelines
    (CONSORT-AI, STARD-AI, TRIPOD+AI, CLAIM 2024, DECIDE-AI).
    
    **For medical AI / engineering pipeline figures** (DICOM workflow,
    annotation pipeline, federated learning topology, model architecture),
    also load `${CLAUDE_SKILL_DIR}/references/pipeline_concepts_medical_ai.md` —
    canonical layouts, required annotations, and tool selection per type.
    
    **Optional flags:**
    - `--study-type <type>`: One of: `diagnostic-accuracy`, `ai-validation`, `meta-analysis`, `dta-meta-analysis`, `observational-cohort`, `rct`, `case-report`. When set, auto-generate the full figure set from the Study-Type Figure Sets table below without prompting for individual figure types.
    - `--data-dir <path>`: Directory containing analysis outputs (CSVs, `_analysis_outputs.md`). Default: current working directory.
    
    Ask the user for:
    1. **Figure type** (from the supported types below) — skipped when `--study-type` is provided
    2. **Data source** (file path, DataFrame, or manual values)
    3. **Target journal** (for dimension/font requirements)
    4. **Panel layout** (single panel, multi-panel, or let you decide)
    5. **Any special requests** (annotations, highlights, reference lines)
    6. **Study type** (if not passed via `--study-type`): determines the required figure set
    
    If the user provides enough context, infer missing parameters and confirm before proceeding.
    
    ### Step 2: Configure
    
    1. Load the figure style file:
       ```python
       import matplotlib.pyplot as plt
       import os
       style_path = os.path.join(os.environ.get('CLAUDE_SKILL_DIR', '.'), '../analyze-stats/references/style/figure_style.mplstyle')
       if os.path.exists(style_path):
           plt.style.use(style_path)
       ```
    2. Look up journal-specific dimensions from `${CLAUDE_SKILL_DIR}/references/figure_specs.md`.
    3. Set the colorblind-safe palette (Wong palette by default).
    4. Configure font sizes per element type (title, axis label, tick label, legend, annotation).
    
    ### Step 3: Generate
    
    Create the figure using Python (matplotlib/seaborn as primary, with specialized libraries as needed).
    
    **Script structure:**
    ```python
    """
    Figure: {description}
    Date: {YYYY-MM-DD}
    Target: {journal}
    Dimensions: {width} x {height} inches @ {DPI} DPI
    """
    import numpy as np
    import matplotlib.pyplot as plt
    import os
    
    style_path = os.path.join(os.environ.get('CLAUDE_SKILL_DIR', '.'), '../analyze-stats/references/style/figure_style.mplstyle')
    if os.path.exists(style_path):
        plt.style.use(style_path)
    
    # Wong colorblind-safe palette
    WONG = ['#000000', '#E69F00', '#56B4E9', '#009E73',
            '#F0E442', '#0072B2', '#D55E00', '#CC79A7']
    
    np.random.seed(42)
    ```
    
    ### Step 4: Review
    
    Present the figure to the user and ask:
    - Does the layout work?
    - Are labels and annotations correct?
    - Any adjustments to colors, sizing, or emphasis?
    
    Iterate until the user approves.
    
    ### Step 4b: Critic Loop (self-critique before final export)
    
    Before Step 5 Export, run the automated Critic Loop. This is two stages —
    deterministic quantitative checks via Python, then qualitative review by
    Claude itself — and the combined output tells us whether to re-render or
    hand off to the user.
    
    **Stage 1: Quantitative checks (`critic_figure.py`)**
    
    ```bash
    python ${CLAUDE_SKILL_DIR}/scripts/critic_figure.py \
        figures/fig1_stard.png \
        --type stard \
        --spec-min-dpi 600 \
        --spec-width-in 7.0 \
        --source-text figures/fig1_stard.txt \   # optional: expected strings for OCR coverage
        --out figures/fig1_stard.critique.json
    ```
    
    This produces a JSON report covering:
    - DPI and physical width vs. journal spec
    - Dominant-color breakdown and out-of-Wong-palette fraction
    - OCR-detected word count, minimum text height, and (if a source-text file
      was provided) source-word coverage
    
    **Stage 2: Qualitative review (Claude session)**
    
    1. Use the Read tool to load the generated PNG.
    2. Read the corresponding rubric file:
       - Flow diagrams: `${CLAUDE_SKILL_DIR}/references/critic_rubrics/flow_diagram.md`
         (sections A–G; section G adds cognitive-load and template-fidelity checks)
       - Data plots:    `${CLAUDE_SKILL_DIR}/references/critic_rubrics/data_plot.md`
         (sections A–G; section G adds calibration / fairness / colorblind+redundant /
         dataset-flow / decision-curve checks for medical AI papers)
       - For PRISMA / CONSORT / STARD / STROBE specifically, also read
         `${CLAUDE_SKILL_DIR}/references/flow_diagram_lessons.md` — five
         production lessons covering official-template fidelity, PDF export
         fidelity (VML fallback), docx XML escape, sequential placeholder
         mapping, and frozen-version sync with the manuscript.
       - For AI-extension guidelines (CONSORT-AI, STARD-AI, TRIPOD+AI,
         CLAIM 2024, DECIDE-AI), also read
         `${CLAUDE_SKILL_DIR}/references/reporting_guideline_figure_map.md` —
         the row for the target guideline lists mandatory figures and which
         ones this skill cannot template (production path documented per
         row).
       - For medical-AI pipeline / DICOM / federated / architecture figures,
         also read `${CLAUDE_SKILL_DIR}/references/pipeline_concepts_medical_ai.md`.
    3. Read the `_why.md` design notes in `${CLAUDE_SKILL_DIR}/references/exemplar_diagrams/{type}/`
       — hierarchy, whitespace, typography, emphasis, colour. **They are the anchors.** Where a rendered
       exemplar is bundled (`template_output*.png`, produced by this skill's own R script), Read 1–2 of
       those too; the figures cropped from published papers were removed in 2026-07 because an
       MIT-licensed package cannot redistribute them (see that directory's README). If you have your own
       exemplars locally, point the loop at them — they stay on your machine.
       For a non-flow data plot (forest, ROC, KM, calibration), read the matching anatomy model in
       `${CLAUDE_SKILL_DIR}/references/exemplar_plots/` (e.g., `forest_plot.md`).
    4. Score every rubric item as PASS / PARTIAL / FAIL with a one-line note,
       using the format at the bottom of the rubric file.
    5. Emit a **"Required edits before next render"** list of concrete
       source-code changes (D2 node renames, count corrections, matplotlib
       parameter tweaks).
    
    **Refinement loop**
    
    - If all items are PASS → proceed to Step 5 Export with `critic_pass: yes`.
    - If any item is FAIL → apply the required edits to the source (D2 file or
      matplotlib script), re-render, and re-run Stage 1 + Stage 2. Default
      maximum is **T=2 rounds**; the user may request up to T=3.
    - If after the max rounds some items remain PARTIAL, proceed with
      `critic_pass: partial` and record the residual items in the manifest's
      `critic_notes` field.
    
    Record the final state in `_figure_manifest.md` (see the manifest format
    below) so downstream steps (`/write-paper` Phase 2 embedding and Phase 7
    DOCX build) and future critic passes can see the history.
    
    ### Step 5: Export
    
    Save final outputs:
    - **PDF** (vector format, preferred for journal submission)
    - **PNG** (300 DPI raster, for review and presentation)
    - **TIFF** (if the journal requires it, 300 DPI LZW compression)
    
    Name files descriptively: `fig1_roc_curve.pdf`, `fig2_consort_flow.pdf`, etc.
    
    **For PPTX outputs (visual abstract, central illustration, or any deck the figure
    will live in)**: run the Mac-compatibility validator before delivery. PowerPoint
    Mac silently drops TIFF, renders `<a:sp3d>` 3-D bevels as red outlines that PDF
    export does not show, and refuses to open files whose `app.xml` slide count
    disagrees with the actual slide XML files. This script catches all four classes
    of defect codified in `~/.claude/rules/pptx-mac-compatibility.md`:
    
    ```bash
    python ${CLAUDE_SKILL_DIR}/scripts/validate_pptx_mac_compat.py \
        figures/visual_abstract.pptx \
        --json figures/visual_abstract.mac_compat.json \
        --strict
    ```
    
    Exit code 1 means at least one FAIL — fix per the `fix:` field in the JSON
    report and re-render the PPTX before delivery. Exit code 0 with WARN is
    acceptable. Skip this step when the figure is PNG/PDF only (no PPTX).
    
    ### Step 6: Design QC Checklist
    
    Before delivering the final figure, verify all items:
    
    - [ ] **Font**: Sans-serif (Arial/Helvetica), minimum 7pt, axis labels ≥ 9pt
    - [ ] **Color**: Wong/Okabe-Ito colorblind-safe palette used
    - [ ] **Colorblind test**: Would the figure work for deuteranopia? (no red-green only distinctions)
    - [ ] **Grayscale test**: Information preserved when printed in black & white
    - [ ] **Alignment**: All elements on a consistent grid; panels aligned
    - [ ] **Vector output**: PDF/SVG saved (not just PNG)
    - [ ] **Resolution**: ≥ 300 DPI for raster elements, ≥ 600 DPI for line art
    - [ ] **Journal specs**: Dimensions, font, and format match target journal requirements
    - [ ] **No chartjunk**: No 3D effects, unnecessary gridlines, gradient fills, or decorative elements
    - [ ] **Caption**: Drafted with key finding, abbreviations, statistical details, and sample size
    
    ---
    
    ## Study-Type Figure Sets
    
    When the study type is known (from `/write-paper` Phase 0 or user specification), auto-detect and generate the complete required figure set without asking for each figure individually.
    
    | Study Type (Guideline) | Required Figures |
    |---|---|
    | Diagnostic accuracy (STARD) | STARD flow diagram, ROC curve, confusion matrix, calibration plot |
    | AI validation (TRIPOD+AI / CLAIM) | Flow diagram, ROC curve, confusion matrix, calibration plot, feature importance or SHAP, Grad-CAM (if imaging) |
    | Meta-analysis (PRISMA) | PRISMA flow diagram, forest plot, funnel plot |
    | DTA meta-analysis (PRISMA-DTA) | PRISMA flow diagram, paired forest plot (Se + Sp), SROC curve, Deeks funnel plot |
    | Observational cohort (STROBE) | Flow diagram, Kaplan-Meier curves (if survival endpoint) |
    | RCT (CONSORT) | CONSORT flow diagram, primary endpoint figure |
    | Case report / series (CARE) | Clinical timeline figure (`exemplar_plots/clinical_timeline.md`), annotated multimodality imaging panel when visually load-bearing (`exemplar_plots/imaging_panel.md`); for a series, an all-cases summary table |
    
    **The manifest is mandatory.** After generating all figures, write
    `figures/_figure_manifest.md` — one row per figure (`Figure | Path | Type | Tool | Critic |
    Rounds | Description`) plus a `## Critic notes` section recording any residual PARTIAL items and
    why they were accepted. It is consumed by `/write-paper` Phase 2 (figure embedding) and Phase 7
    (DOCX build); verify it exists and is non-empty before finishing. Format and field definitions:
    `${CLAUDE_SKILL_DIR}/references/figure_manifest.md`.
    
    **Flow diagram generation rule.** STARD / CONSORT / PRISMA / STROBE flow diagrams **MUST** use the
    standardized R pipeline `scripts/generate_flow_diagram.R` (DiagrammeR + Graphviz dot + rsvg) — the
    single canonical tool for all four. Do **NOT** use matplotlib `FancyBboxPatch` (manual coordinates
    break when text changes, and patches distort when embedded in DOCX). Do **NOT** use D2 for new
    flow diagrams (weak font control, overlap needs manual post-processing). Numbers in labels must be
    CSV-derived, or hand-written only when the value lives in a commit-tracked data artifact.
    
    **Read on demand:**
    
    | File | Read it when | Cost if read blindly |
    |---|---|---|
    | `references/flow_diagram_recipe.md` | you are generating a STARD / CONSORT / PRISMA / STROBE flow diagram | ~2,200 tokens — a ROC curve or forest plot needs none of it |
    | `references/figure_manifest.md` | you are writing `_figure_manifest.md` | ~700 tokens of output format |
    
    ## Tool Selection Guide
    
    Choose the right tool for each figure type. Using matplotlib for flow diagrams leads to
    hard-coded coordinates that break when text changes — use auto-layout tools instead.
    
    ### Data Visualization → matplotlib/seaborn (this skill)
    
    Best for figures where data drives the layout. This skill handles these directly:
    
    | Type | Use Case | Key Library |
    |------|----------|-------------|
    | ROC Curve | Diagnostic accuracy | matplotlib, sklearn |
    | Forest Plot | Meta-analysis | matplotlib |
    | Calibration Plot | Prediction model | matplotlib |
    | KM Curve | Survival analysis | lifelines, matplotlib |
    | Bland-Altman | Agreement | matplotlib |
    | Confusion Matrix | Classification | seaborn |
    | Box/Violin Plot | Group comparison | seaborn |
    | Bar Chart | Categorical comparison | matplotlib |
    | Heatmap | Correlation/agreement | seaborn |
    
    ### Flow Diagrams → Dedicated Tools (NOT matplotlib)
    
    Flow diagrams require auto-layout engines. Do NOT use matplotlib patches with manual coordinates
    — this causes the "absolute coordinate hell" problem where changing one box breaks all
    downstream positions.
    
    | Type | Recommended Tool | Why |
    |------|-----------------|-----|
    | STROBE (cohort / cross-sectional) | **`scripts/generate_flow_diagram.R --type strobe`** | Single canonical tool; auto-layout; vector PDF + 300/600 dpi PNG |
    | CONSORT (RCT) | **`scripts/generate_flow_diagram.R --type consort`** | Same pipeline; monochrome Arial default |
    | PRISMA 2020 (SR/MA) | **`scripts/generate_flow_diagram.R --type prisma`** | Faithfully implements PRISMA 2020 structure; avoids PRISMA2020 R package's webshot-based raster PDF issue |
    | STARD (DTA) | **`scripts/generate_flow_diagram.R --type stard`** | Same pipeline; supports 2x2 reference-standard split |
    | Pipeline Diagram | **D2** (legacy) | Until pipeline-diagram support is added to the R script |
    
    **R workflow for flow diagrams:** See the "R flow diagram recipe" above in the Flow diagram generation rule. Key points: YAML config → `Rscript scripts/generate_flow_diagram.R --type <t> --config <yaml> --out <prefix>` → PDF + 300/600 dpi PNG. Templates in `references/exemplar_diagrams/{strobe,consort,prisma,stard}/template_input.yaml`.
    
    ### Official Reporting Guideline Templates → `templates/official/`
    
    When a journal requires the canonical, statement-issued template (rather than
    the auto-laid-out R version), use the bundled official files in
    `templates/official/{prisma2020,consort2010,stard2015,spirit2013}/`.
    
    | Guideline | What ships | When to use |
    |-----------|-----------|-------------|
    | PRISMA 2020 | Locally built `.pptx` (4 variants) + `fill_prisma_template.py` | Reviewer asks for the official PRISMA 2020 layout, or you want editable PowerPoint instead of an R-rendered PDF. |
    | STROBE (cohort) | Parametric `.pptx` builder `build_strobe_template.py` (single-script, takes YAML config) | Cohort/case-control study Figure 1 when co-authors want PowerPoint they can hand-edit. Auto-fits text, content-fits slide, dashed-border exclusion side-branches with strictly-horizontal connectors. Optional left-side phase column (omit `stages:` for the plain STROBE convention; include it for the PRISMA-style Identification/Screening/Inclusion/Analysis column). Pair with `generate_flow_diagram.R --type strobe` for the vector PDF/TIFF submission file. |
    | CONSORT 2025 | Official `.docx` flow diagram + checklist | RCT submissions to journals that mandate the consort-spirit.org template. |
    | STARD 2015 | Official `.pdf` flow diagram + `.docx` checklist | Diagnostic accuracy studies; flow diagram is fixed PDF, checklist is editable. |
    | SPIRIT 2025 | Official `.docx` participant timeline + checklist | Trial protocols. |
    
    Refresh / fill workflow:
    
    ```bash
    # Refresh from canonical sources (CC-BY 4.0 / public-statement licenses)
    bash ${CLAUDE_SKILL_DIR}/scripts/fetch_official_templates.sh
    
    # Build PRISMA 2020 .pptx (one-time; site blocks programmatic .docx fetch)
    python3 ${CLAUDE_SKILL_DIR}/scripts/build_prisma2020_template.py \
        --variant new \
        --out ${CLAUDE_SKILL_DIR}/templates/official/prisma2020/PRISMA_2020_flow_new_v1.pptx
    
    # Fill counts — positional 10-tuple matching most SR/MA workflows:
    #   n_db, n_dup, n_screened, n_screen_excluded,
    #   n_sought, n_assessed, n_excl_r1, n_excl_r2, n_excl_r3, n_studies
    python3 ${CLAUDE_SKILL_DIR}/scripts/fill_prisma_template.py \
        --template ${CLAUDE_SKILL_DIR}/templates/official/prisma2020/PRISMA_2020_flow_new_v1.pptx \
        --counts "315,122,186,7,111,204,102,84,3,15" \
        --out fig1_prisma_filled.pptx
    
    # Or use full JSON mapping for studies with non-standard PRISMA splits
    python3 ${CLAUDE_SKILL_DIR}/scripts/fill_prisma_template.py \
        --template ${CLAUDE_SKILL_DIR}/templates/official/prisma2020/PRISMA_2020_flow_new_v1.pptx \
        --counts-file my_counts.json \
        --out fig1_prisma_filled.pptx
    
    # STROBE — parametric single-script builder (cohort study; spine structure varies per study).
    # YAML schema: stages, spine (id/stage/text), exclusions (after/text). Consecutive same-stage
    # rows share one phase label automatically. Stage box fills auto-pick readable text color.
    python3 ${CLAUDE_SKILL_DIR}/scripts/build_strobe_template.py \
        --config figures/figure1_strobe.yaml \
        --out    figures/figure1_strobe.pptx
    ```
    
    The builder checks that the exclusion cascade closes — the count in a spine box, minus the
    exclusions declared after it, must equal the next spine box (`A - Σ(exclusions after A) ==
    B`), for every link that declares an exclusion. It warns loudly on any imbalance and, with
    `--strict-cascade`, refuses to build. This catches the figure-image arithmetic drift that
    text-grep and prose gates miss (a dropped exclusion leaving the figure short of the analytic
    N). Run `scripts/_strobe_cascade.py --config figure1_strobe.yaml --strict` to check a config
    without rebuilding the diagram.
    
    For STROBE the canonical KJR/Radiology/BMJ submission flow is:
    
    1. Render the vector submission file via the auto-fitting Graphviz path:
       `Rscript ${CLAUDE_SKILL_DIR}/scripts/generate_flow_diagram.R --type strobe --config figures/figure1_strobe_graphviz.yaml --out figures/figure1`
    2. Build the editable PowerPoint companion via `build_strobe_template.py` so co-authors and senior reviewers can adjust prose/positioning before sign-off.
    3. Re-export the final PPTX to PDF/TIFF only after co-author edits are integrated.
    
    See `templates/official/NOTES.md` for licenses, attribution, and refresh notes.
    
    ### Visual / Graphical Abstracts → python-pptx Template Generator
    
    | Type | Recommended Tool |
    |------|-----------------|
    | Visual Abstract (any journal) | `generate_visual_abstract.py` with PPTX template |
    | Visual element illustration | Study's own figures (preferred), or free libraries (Servier/NIAID) |
    | Medical Illustration | See `${CLAUDE_SKILL_DIR}/references/medical_illustration_sources.md` |
    
    See the Visual Abstract section above for the full workflow.
    
    ### Hybrid Workflow (recommended for publication)
    
    ```
    Data plots:    matplotlib/seaborn → PDF + PNG (this skill)
    Flow diagrams: generate_flow_diagram.R (DiagrammeR + rsvg) → PDF + 300/600 dpi PNG
    Final assembly: pandoc or python-docx (auto-embedded in DOCX)
    ```
    
    ---
    
    ## Supported Figure Types (matplotlib/seaborn)
    
    | Type | Use Case | Key Library | Output |
    |------|----------|-------------|--------|
    | ROC Curve | Diagnostic accuracy | matplotlib, sklearn | Single/multi-model ROC with AUC |
    | Forest Plot | Meta-analysis | matplotlib | Effect sizes with CIs, diamond summary |
    | Calibration Plot | Prediction model | matplotlib | Observed vs predicted with Hosmer-Lemeshow |
    | KM Curve | Survival analysis | lifelines, matplotlib | With risk table, log-rank p |
    | Bland-Altman | Agreement | matplotlib | With mean diff, +/-1.96 SD limits |
    | Confusion Matrix | Classification | seaborn | Heatmap with percentages |
    | Box/Violin Plot | Group comparison | seaborn | With individual data points |
    | Pipeline Diagram | Methods figure | D2 (preferred) or matplotlib | Processing/workflow steps |
    | Bar Chart | Categorical comparison | matplotlib | With error bars (CI or SD) |
    | Heatmap | Correlation/agreement | seaborn | Color-coded matrix |
    
    ---
    
    ## Figure Type Templates
    
    ### ROC Curve
    
    ```python
    from sklearn.metrics import roc_curve, auc
    
    fig, ax = plt.subplots(figsize=(3.5, 3.5))
    fpr, tpr, _ = roc_curve(y_true, y_score)
    roc_auc = auc(fpr, tpr)
    ax.plot(fpr, tpr, color=WONG[5], lw=1.5,
            label=f'Model (AUC = {roc_auc:.3f})')
    ax.plot([0, 1], [0, 1], 'k--', lw=0.8, alpha=0.5)
    ax.set(xlabel='1 - Specificity', ylabel='Sensitivity',
           xlim=[-0.02, 1.02], ylim=[-0.02, 1.02])
    ax.legend(loc='lower right', frameon=False)
    ```
    
    - For multiple models: use distinct Wong palette colors, include AUC + 95% CI in legend.
    - For comparison: report DeLong p-value in annotation.
    
    ### Forest Plot
    
    - Horizontal layout: effect sizes as squares (sized by weight), CIs as lines.
    - Diamond at bottom for pooled estimate.
    - Vertical dashed line at null effect (OR=1 or MD=0).
    - Axis label: "Favours A | Favours B" or appropriate.
    - Include heterogeneity stats (I-squared, p) below the diamond.
    
    ### Flow Diagrams (STROBE / CONSORT / PRISMA / STARD)
    
    **Single canonical tool: `scripts/generate_flow_diagram.R`** (see the R flow diagram recipe above). Do not fall back to matplotlib for flow diagrams — manual coordinates break when text changes and patches distort in DOCX. D2 remains a documented legacy fallback only when R is unavailable.
    
    Layout invariants:
    - Rectangular boxes with rounded corners for stages; notes (`shape: note`) for exclusion side-boxes.
    - Vertical top-down flow by default; horizontal only when the manuscript layout demands it.
    - Every box label contains the count (e.g., `"Assessed for eligibility\n(n = 450)"`).
    - Numbers are CSV-derived (numerical-safety) — author the YAML from an R/Python script that reads the upstream data, or cite the source file in a comment when a literal value is unavoidable.
    - Follow the official template layout from each guideline.
    - **Use relative positioning** — never hard-code absolute y-coordinates. Calculate each box
      position from the previous box's bottom edge plus a consistent gap constant.
    - **Define gap constants** at the top of the script (e.g., `GAP_SMALL = 1.5`, `GAP_BRANCH = 2.2`).
    - **Avoid magic number padding** in arrow endpoints — use named constants.
    
    **D2 approach (legacy fallback — use only when R is unavailable; the R script above is canonical):**
    ```bash
    d2 --layout elk --theme 0 flow.d2 output.svg
    # Then: open SVG in Figma → grid-snap → font swap → export PDF
    ```
    
    **Caption ↔ flow-SSOT reconciliation (before Step 5 Export).** The flow-diagram config (the YAML/script that `generate_flow_diagram.R` consumes) is the single source of truth for participant counts. A hand-written Figure 1 caption drifts from it whenever the cohort is re-locked but the caption is not — the classic "caption says n = 1,284 analytic, diagram box says n = 998" defect, which surfaces only at submission. Re-derive the caption counts from the flow config and reconcile:
    
    ```bash
    python3 ${CLAUDE_SKILL_DIR}/scripts/derive_figure_legend_counts.py \
      --flow-config figures/figure1_strobe_graphviz.yaml \
      --manuscript manuscript/index.qmd \
      --out qc/figure_legend_counts.json --strict
    ```
    
    Any `n = N` in the caption that is not a box count in the flow config is a `MISMATCH` (stale caption) — update the caption from the config, never the reverse. This pairs with numerical-safety's "re-derive prose counts every revision" rule and with `/sync-submission`'s cross-document N checks. (The reconciler is stdlib-only and parses the config as text, so it works regardless of the flow tool.)
    
    ### Calibration Plot
    
    - 45-degree reference line (perfect calibration).
    - Grouped observed vs predicted with error bars.
    - Report Hosmer-Lemeshow statistic and Brier score in annotation.
    - Optional: histogram of predicted probabilities at the bottom.
    
    ### Kaplan-Meier Curve
    
    - Step function with distinct colors per group.
    - Censoring marks as small vertical ticks.
    - Number-at-risk table below the plot (aligned with x-axis ticks).
    - Log-rank p-value in annotation.
    - Median survival with 95% CI if applicable.
    
    ### Bland-Altman Plot
    
    - X-axis: mean of two measurements.
    - Y-axis: difference between measurements.
    - Horizontal lines: mean difference (solid), +/-1.96 SD (dashed).
    - Annotate the mean diff and limits of agreement values.
    - Optional: proportional bias check (regression line through points).
    
    ### Confusion Matrix
    
    - Heatmap with both counts and percentages in each cell.
    - Row-normalized percentages preferred (sensitivity per class).
    - Clear axis labels: "Predicted" (x) and "Actual" (y).
    - Use sequential colormap (Blues or Greens), not diverging.
    
    ### Box/Violin Plot
    
    - Show individual data points (jittered) overlaid on box or violin.
    - Mark median and mean distinctly.
    - Statistical annotation brackets with significance stars.
    - Stars: * p<0.05, ** p<0.01, *** p<0.001, ns for non-significant.
    
    ### Pipeline Diagram
    
    - Horizontal or vertical flow of processing stages.
    - Boxes: rounded rectangles with stage name and brief description.
    - Arrows: labeled with data counts or transformation type.
    - Color-code stages by category (data collection, processing, validation).
    - Keep text minimal; use supplementary caption for details.
    
    ### Bar Chart
    
    - Error bars: 95% CI (preferred) or SD, stated in caption.
    - Individual data points overlaid if n < 30.
    - Horizontal orientation for many categories.
    - Sort by value (descending) unless order is meaningful.
    
    ### Heatmap
    
    - Annotate cells with values.
    - Use sequential colormap for correlation (coolwarm diverging if centered at zero).
    - Mask diagonal for correlation matrices.
    - Cluster rows/columns if appropriate.
    
    ---
    
    ## Style Rules
    
    ### Colors
    
    **Wong colorblind-safe palette (default):**
    ```python
    WONG = ['#000000', '#E69F00', '#56B4E9', '#009E73',
            '#F0E442', '#0072B2', '#D55E00', '#CC79A7']
    ```
    
    **Sequential palettes (for heatmaps):**
    - Positive values: `Blues` or `Greens`
    - Diverging (centered at 0): `coolwarm` or `RdBu_r`
    - Agreement matrices: `YlOrRd`
    
    **Rules:**
    - Never use red-green only distinctions.
    - Use line style (solid, dashed, dotted) in addition to color for line plots.
    - Use marker shape in addition to color for scatter plots.
    
    ### Typography
    
    | Element | Font Size | Weight |
    |---------|-----------|--------|
    | Figure title (if any) | 10 pt | Bold |
    | Axis label | 9 pt | Regular |
    | Tick label | 8 pt | Regular |
    | Legend text | 8 pt | Regular |
    | Annotation | 8 pt | Regular |
    | Panel label (A, B, C) | 12 pt | Bold |
    
    - Font family: Arial or Helvetica (sans-serif).
    - Panel labels: uppercase bold letter, top-left of each panel.
    
    ### Layout
    
    - Minimize white space while maintaining readability.
    - Align multi-panel figures on a grid.
    - Consistent axis ranges across comparable panels.
    - No figure titles in the plot itself (title goes in the caption below).
    
    ### Statistical Annotations
    
    - Significance stars: * p<0.05, ** p<0.01, *** p<0.001
    - Place above comparison brackets.
    - Report exact p-value in the figure legend or caption, not in the plot.
    - For AUC, correlation, or agreement: display in the legend with 95% CI.
    
    ---
    
    ## Journal Specifications
    
    Default dimensions (override from `figure_specs.md` if journal-specific):
    
    - **Single column**: 3.5 in (88 mm) width
    - **1.5 column**: 5.0 in (127 mm) width
    - **Double column**: 7.0 in (178 mm) width
    - **Full page**: 7.0 x 9.5 in (178 x 241 mm)
    - **DPI**: 300 minimum for halftone, 600 for line art
    - **File formats**: PDF (vector, preferred) + PNG (300 DPI)
    - **No chartjunk**: no 3D effects, no unnecessary gridlines, no decorative elements, no gradient fills
    
    ---
    
    ## Multi-Panel Figures
    
    For composite figures with multiple panels:
    
    ```python
    fig, axes = plt.subplots(nrows, ncols, figsize=(width, height))
    
    # Label each panel
    for ax, label in zip(axes.flat, 'ABCDEFGH'):
        ax.text(-0.15, 1.05, label, transform=ax.transAxes,
                fontsize=12, fontweight='bold', va='top')
    ```
    
    Common layouts:
    - 2-panel horizontal: `figsize=(7.0, 3.5)`, 1 row x 2 cols
    - 2-panel vertical: `figsize=(3.5, 7.0)`, 2 rows x 1 col
    - 2x2 grid: `figsize=(7.0, 7.0)`, 2 rows x 2 cols
    - 3-panel: `figsize=(7.0, 3.0)`, 1 row x 3 cols
    
    Use `plt.tight_layout()` or `fig.subplots_adjust()` for spacing.
    
    ---
    
    ## Caption Writing
    
    After generating each figure, draft a caption following these rules:
    
    1. **First sentence**: Describe what the figure shows (type + key finding).
    2. **Subsequent sentences**: Define abbreviations, explain symbols, state sample sizes.
    3. **Statistical details**: Note the test used and significance threshold.
    4. **Format**: "Figure {N}. {Caption text}" -- no bold, no title case.
    
    Example:
    > Figure 1. Receiver operating characteristic curves comparing the diagnostic performance of
    > the multi-agent pipeline (blue) and single-agent baseline (orange) for identifying incorrect
    > Anki flashcard content. The area under the curve was 0.92 (95% CI: 0.89-0.95) for the
    > multi-agent pipeline and 0.84 (95% CI: 0.80-0.88) for the single-agent baseline (DeLong
    > test, p = 0.003). The dashed diagonal line represents chance performance.
    
    ---
    
    ## Skill Interactions
    
    | When | Call | Purpose |
    |------|------|---------|
    | Need statistical values for plot | `/analyze-stats` | Get computed values (AUC, CI, p-values) |
    | Flow diagram for manuscript | `/write-paper` Phase 2 | Coordinate with Tables & Figures plan |
    | Caption review | `/write-paper` Phase 7 | Final polish pass |
    
    ---
    
    ## Error Handling
    
    - If data is insufficient for the requested figure type, explain what is needed and ask the user.
    - If a figure exceeds journal dimension limits, resize and report the adjustment.
    - If text overlaps in the figure, try `tight_layout()`, reduce font size, or adjust spacing.
    - Never fabricate data points. If sample data is needed for a template demo, explicitly label it as "example data."
    
    ## CLI Tools Available
    
    ImageMagick, Ghostscript, FFmpeg are installed and can be used for post-processing:
    
    ```bash
    # Figure DPI/format conversion for journal submission
    magick input.png -density 300 -units PixelsPerInch output.tiff
    magick input.png -resize 1200x -quality 95 output.jpg
    
    # CMYK conversion (some print journals require this)
    magick input.png -colorspace CMYK output.tiff
    ```
    
    ### Portal-ready TIFF (SNAPP `.png`-not-accepted / 25 MB cap)
    
    A raw `magick ... output.tiff` keeps the alpha channel (transparent regions print **black**
    on many production pipelines) and stays uncompressed (a 600-dpi RGBA TIFF blows past a
    portal's 25 MB cap). `export_portal_tiff.py` does the flatten-and-compress a human otherwise
    does by hand and **verifies the result is pixel-identical** to that white-flatten before
    handing it over — use it when a portal accepts only `.tiff`/`.jpeg`/`.eps` (Springer Nature
    SNAPP) or caps figure size (JACC: Asia):
    
    ```bash
    python3 scripts/export_portal_tiff.py --in figure.png --out figure.tiff --max-mb 25
    # LZW-compressed, RGBA→RGB white-flattened, pixel-identity-verified; exit 1 if still over the cap
    ```
    
    ```bash
    # Multi-panel figure assembly (A/B/C/D panels)
    magick montage panelA.png panelB.png panelC.png panelD.png \
      -tile 2x2 -geometry +10+10 -density 300 combined.png
    
    # Animated figure (GIF from frame sequence)
    ffmpeg -framerate 2 -i frame_%03d.png -vf "scale=800:-1" output.gif
    
    # Video from figure sequence (for supplementary materials)
    ffmpeg -framerate 1 -i slide_%03d.png -c:v libx264 -pix_fmt yuv420p supplementary_video.mp4
    ```
    
    ## AI Image Generation (Optional)
    
    AI illustration is a **supplementary option**, not a requirement. Visual abstracts and figures
    can be completed without any API key using study figures and free illustration libraries.
    
    If `GEMINI_API_KEY` is set, the `generate_image.py` script can generate illustrations:
    ```bash
    python ${CLAUDE_SKILL_DIR}/scripts/generate_image.py \
      "Clean medical illustration of a CT-guided lung biopsy procedure, \
       flat vector style, white background, no text" \
      --output output.png --aspect 16:9
    ```
    
    Use for: procedural schematics, anatomical illustrations, pipeline diagrams.
    Always review AI output against the AI-Generated Figure Warning section above.
    
    If `GEMINI_API_KEY` is not set, guide the user to free illustration resources:
    see `${CLAUDE_SKILL_DIR}/references/medical_illustration_sources.md`.
    
    ## Language
    
    - Code and figure text: English
    - Communication with user: Match user's preferred language
    - Medical terms: English only
    
    ## Anti-Hallucination
    
    - **Never fabricate references.** All citations must be verified via `/search-lit` with confirmed DOI or PMID. Mark unverified references as `[UNVERIFIED - NEEDS MANUAL CHECK]`.
    - **Never invent clinical definitions, diagnostic criteria, or guideline recommendations.** If uncertain, flag with `[VERIFY]` and ask the user.
    
    ## Global-rule references
    
    Some passages in this skill cite a path of the form `~/.claude/rules/<name>.md`. Those are the
    maintainer's personal global rules, kept outside this repository. They are **not shipped with
    this skill** and will not exist on your machine; they appear only as provenance for where a
    convention came from. If one of them looks like it is standing in for an instruction you actually
    need, that is a bug — please open an issue, because the instruction belongs here.
    
  • skill.yml 2.3 KB
    schema_version: 2
    name: make-figures
    version: 1.1.0
    layer: B
    owner_domain: figure_generation
    maturity: official
    when_to_use:
      - User requests a figure, plot, diagram, or visual abstract for a medical research manuscript
      - Triggered by /write-paper Phase 5 (figure generation step)
      - Following /analyze-stats output that requires visualization
      - Generating PRISMA / CONSORT / STARD / STROBE flow diagrams
      - Producing journal-specific visual or graphical abstracts
    when_NOT_to_use:
      - Self-contained tabular results (use /analyze-stats markdown output)
      - Decorative slides for talks (use /present-paper)
      - Logos, branding, or non-research illustrations (out of scope)
      - AI-generated images for journals that prohibit them (JACC family, NEJM) — see Journal AI-Image Policies in SKILL.md
    inputs:
      - analysis/_analysis_outputs.md
      - analysis/tables/*.csv
    outputs:
      - analysis/figures/_figure_manifest.md
      - analysis/figures/*.png
      - analysis/figures/*.pdf
    deterministic_scripts:
      - scripts/generate_flow_diagram.R
      - scripts/critic_figure.py
      - scripts/generate_visual_abstract.py
      - scripts/validate_pptx_mac_compat.py
    side_effects:
      - writes_figure_artifacts
      - reads_medical_illustration_assets_from_servier_or_bioart
    downstream_consumers:
      - write-paper
      - sync-submission
      - present-paper
    forbidden_actions:
      - fabricate_figure_numbers
      - create_flow_diagrams_without_source_counts
      - generate_AI_images_for_prohibited_targets
    
    # v2.1 quality card
    purpose: "Generate publication-ready figures and visual/graphical abstracts (ROC, forest, CONSORT/STARD/PRISMA flow, KM, Bland-Altman, etc.)."
    safety_boundaries:
      - "Figure numbers are not fabricated; flow diagrams are built from real source counts."
      - "Honors journal AI-image policies; no AI images for prohibited targets."
    known_limitations:
      - "Figure correctness depends on correct input data/counts supplied by upstream skills."
      - "PPTX visual abstracts need the Mac-compatibility check before sharing."
    validation_commands:
      - "Rscript scripts/generate_flow_diagram.R"
      - "python3 scripts/validate_pptx_mac_compat.py <file>"
      - "python3 tests/test_pptx_mac_compat.py"
      - "bash scripts/render_core_figures_challenge/verify.sh"
      - "bash scripts/export_portal_tiff_challenge/verify.sh"
    evidence_surface: demo
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related