07013335b9
Implement the improve_auto-agents_pipeline plan end-to-end and apply post-implementation hardening fixes so master can only advance via SHAs that passed CI against the exact current master.
Key changes:
- Add deterministic merge driver in `tools/merge_drive.py`:
- Single-instance lock (`fcntl.flock`) + heartbeat/status surfaces.
- Train-merge with bisect-on-failure and independent bisect/restart budgets.
- `head_commit_id` optimistic lock enforcement on merge endpoint.
- Single-PR strategy switched to `Do=merge` (sha-stable) to avoid unverified rewritten commits.
- Restart throttling/sleeps to prevent burning retry budget under master churn.
- Persistent clone management (`ensure_repo`: fetch/reset/clean with fresh-clone fallback on corruption).
- Cooperative SIGTERM/SIGINT stop propagation through long CI polling.
- Explicit claim lifecycle for `auto/claimed-merge`:
- claim/release comments with TTL,
- expired-claim sweep,
- operational labels on release (`auto/ci-timeout`, `auto/restart-throttled`,
`auto/needs-implementer`, `auto/needs-conflict-resolution`).
- Claim-marker interoperability: sweep recognizes both driver and `claim_pr.ts` markers.
- Hardened API semantics: split idempotent GET retries vs state-change semantics.
- Remove token-in-URL clone pattern; use git `http.extraheader` auth instead.
- Adopt structured module logging + env-configurable levels.
- Add/expand invariant auditor in `tools/verify_invariant.py`:
- Non-zero exit when violations exist (cron/alert correctness).
- Robust auto-close routine using forward patch applicability on current `origin/master`.
- Merge-bot commit validation now checks:
- required CI contexts passed,
- associated PR has non-dismissed APPROVED review.
- Improve close-path wording/docs to match forward-apply algorithm.
- Add logger-based output and verbosity controls.
- Add operational setup/audit tooling:
- `tools/forgejo_audit.py` (preconditions/audit report).
- `tools/setup_auto_labels.py` (idempotent `auto/*` label provisioning).
- `tools/setup_branch_protection.py` (direct-push allow-list enforcement).
- `tools/audit_branch_protection.py` (dismiss_stale_approvals audit/flip support).
- `tools/migrate_to_new_driver.py` (claim/schedule/train cleanup migration).
- `tools/flag_stale_prs.py` (idle PR triage flow).
- `tools/local_ci_gate.sh` canonical local gate runner with `--continue-on-fail`.
- Add claim orchestration support in skills scripts:
- New `claim_pr.ts` helper (claim/release + TTL comments).
- `list_prs.ts` gains `--exclude-claimed` filter.
- Update script reference docs accordingly.
- Telemetry/schema upgrades in `tools/_forgejo_cache.py`:
- Add `merge_cycle`, `ci_gate_events`, `llm_activity`.
- Add batched `ci_gate_events` insertion API with rollback semantics.
- Ensure `bisect_depth` default handling is safe.
- Surface merge-driver telemetry in velocity reporting pipeline.
- Agent prompt/behavior updates:
- Review supervisor idle loop tuned (300s -> 60s).
- Review worker cycle cap/escalation behavior refined.
- Task implementor guidance updated to use local CI gate wrapper.
- Documentation and operational guidance:
- Expand `AGENTS.md` with merge invariant runbook, label registry, tool links,
and full merge-driver env var catalog (including logging/restart/claim TTL knobs).
- Update `CHANGELOG.md` with implementation and hardening entries, plus deferred TS-test note.
- Repo hygiene:
- Correct `.gitignore` to stop blanket ignoring `tools/*`; keep only generated artifacts ignored.
Testing/validation:
- Add comprehensive unit suite under `tests/auto_agents/` covering:
- merge driver recursion/restarts/409 paths/signal handling/claim sweeps,
- verifier auto-close logic with real local git fixtures,
- schema migration + telemetry batch writes,
- branch protection and setup/audit helpers.
- Current result: `100 passed` in `tests/auto_agents/`.
641 lines
20 KiB
Python
Executable File
641 lines
20 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Export a Cursor canvas (`*.canvas.tsx`) to a standalone PDF.
|
|
|
|
Cursor canvases are React components that import from the host-provided
|
|
`cursor/canvas` module. This tool renders them outside the IDE by:
|
|
|
|
1. Vendoring React + ReactDOM + Babel-standalone into a local cache on
|
|
first run (one-time download from unpkg).
|
|
2. Providing a browser-side shim (`tools/_canvas_pdf_shim.js`) that
|
|
implements every exported component/hook/token in the canvas SDK.
|
|
3. Wrapping the target `.canvas.tsx` in a self-contained HTML harness
|
|
that compiles the TSX in-browser with Babel-standalone and renders
|
|
it into `#root`.
|
|
4. Printing the harness to PDF with headless Chrome.
|
|
|
|
Usage
|
|
-----
|
|
|
|
# List known canvases in the default canvases folder
|
|
python3 tools/export-canvas-pdf.py --list
|
|
|
|
# Export a canvas by short name (auto-located in the canvases folder)
|
|
python3 tools/export-canvas-pdf.py milestone-completion
|
|
|
|
# Explicit path
|
|
python3 tools/export-canvas-pdf.py path/to/my.canvas.tsx
|
|
|
|
# Export all canvases in the canvases folder to PDFs beside them
|
|
python3 tools/export-canvas-pdf.py --all
|
|
|
|
# Custom output path (directory or file)
|
|
python3 tools/export-canvas-pdf.py milestone-completion --output ~/reports/
|
|
|
|
# Paper / theme knobs
|
|
python3 tools/export-canvas-pdf.py pr-velocity --format A4 --landscape
|
|
python3 tools/export-canvas-pdf.py pr-velocity --theme dark
|
|
|
|
# Keep the intermediate HTML for debugging
|
|
python3 tools/export-canvas-pdf.py pr-velocity --keep-html
|
|
|
|
The tool is fully offline after the one-time vendor step. No LLM or
|
|
Cursor runtime is required.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
TOOLS_DIR = Path(__file__).resolve().parent
|
|
REPO_ROOT = TOOLS_DIR.parent
|
|
CACHE_DIR = TOOLS_DIR / ".cache" / "canvas-pdf"
|
|
SHIM_PATH = TOOLS_DIR / "_canvas_pdf_shim.js"
|
|
DEFAULT_CANVAS_DIR = (
|
|
Path.home()
|
|
/ ".cursor/projects/home-drew-repos-cleveragents-core/canvases"
|
|
)
|
|
|
|
# Pinned versions — downloaded once, cached forever.
|
|
VENDOR_ASSETS = {
|
|
"react.production.min.js": (
|
|
"https://unpkg.com/react@18.3.1/umd/react.production.min.js"
|
|
),
|
|
"react-dom.production.min.js": (
|
|
"https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"
|
|
),
|
|
"babel.min.js": "https://unpkg.com/@babel/standalone@7.24.7/babel.min.js",
|
|
}
|
|
|
|
# Which Chrome executable to use. First hit wins.
|
|
CHROME_CANDIDATES = [
|
|
"google-chrome",
|
|
"google-chrome-stable",
|
|
"chromium",
|
|
"chromium-browser",
|
|
"chrome",
|
|
]
|
|
|
|
# Paper presets understood by Chrome's --print-to-pdf via CSS @page rules.
|
|
# Values are (width_mm, height_mm) at portrait orientation.
|
|
PAPER_SIZES = {
|
|
"Letter": (215.9, 279.4),
|
|
"Legal": (215.9, 355.6),
|
|
"Tabloid": (279.4, 431.8),
|
|
"A4": (210.0, 297.0),
|
|
"A3": (297.0, 420.0),
|
|
"A5": (148.0, 210.0),
|
|
}
|
|
|
|
|
|
# ── vendor management ───────────────────────────────────────────────────────
|
|
|
|
|
|
def _log(msg: str, quiet: bool) -> None:
|
|
if not quiet:
|
|
print(f"# {msg}", file=sys.stderr)
|
|
|
|
|
|
def ensure_vendor(quiet: bool) -> None:
|
|
"""Populate CACHE_DIR/<asset> for every entry in VENDOR_ASSETS.
|
|
|
|
Downloads from unpkg once; no-ops on subsequent runs.
|
|
"""
|
|
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
for name, url in VENDOR_ASSETS.items():
|
|
dest = CACHE_DIR / name
|
|
if dest.exists() and dest.stat().st_size > 0:
|
|
continue
|
|
_log(f"downloading {name} → {dest}", quiet)
|
|
try:
|
|
with urllib.request.urlopen(url, timeout=30) as resp:
|
|
data = resp.read()
|
|
dest.write_bytes(data)
|
|
except Exception as exc: # noqa: BLE001
|
|
dest.unlink(missing_ok=True)
|
|
raise SystemExit(
|
|
f"ERROR: failed to fetch {url}: {exc}\n"
|
|
f"If this machine is offline, run the tool once while online "
|
|
f"to populate the cache, then re-run."
|
|
)
|
|
|
|
|
|
# ── canvas source preprocessing ─────────────────────────────────────────────
|
|
|
|
|
|
_IMPORT_RE = re.compile(
|
|
r"""import\s* # import keyword
|
|
\{([^}]+)\}\s* # { A, B, C }
|
|
from\s*['"]cursor/canvas['"]\s*;? # from "cursor/canvas";
|
|
""",
|
|
re.VERBOSE,
|
|
)
|
|
|
|
|
|
def preprocess_canvas(src: str) -> str:
|
|
"""Rewrite ES module syntax → globals for Babel-standalone.
|
|
|
|
- `import { A, B } from "cursor/canvas"` → `const { A, B } = window.CursorCanvas;`
|
|
- `export default function Foo(...)` → `window.__CANVAS_COMPONENT__ = function Foo(...)`
|
|
- `export default <ident>;` → `window.__CANVAS_COMPONENT__ = <ident>;`
|
|
|
|
Raises if no default export is found — every canvas must export one.
|
|
"""
|
|
original = src
|
|
|
|
def _repl_import(m: re.Match) -> str:
|
|
names = m.group(1).strip()
|
|
return f"const {{ {names} }} = window.CursorCanvas;"
|
|
|
|
src = _IMPORT_RE.sub(_repl_import, src)
|
|
|
|
# export default function Foo() {...}
|
|
src, n1 = re.subn(
|
|
r"export\s+default\s+function\s+(\w+)\s*\(",
|
|
r"window.__CANVAS_COMPONENT__ = function \1(",
|
|
src,
|
|
count=1,
|
|
)
|
|
# export default Foo;
|
|
if n1 == 0:
|
|
src, n2 = re.subn(
|
|
r"export\s+default\s+(\w+)\s*;",
|
|
r"window.__CANVAS_COMPONENT__ = \1;",
|
|
src,
|
|
count=1,
|
|
)
|
|
# export default class Foo extends ... {...}
|
|
if n2 == 0:
|
|
src, n3 = re.subn(
|
|
r"export\s+default\s+class\s+(\w+)",
|
|
r"window.__CANVAS_COMPONENT__ = class \1",
|
|
src,
|
|
count=1,
|
|
)
|
|
if n3 == 0:
|
|
raise ValueError(
|
|
"No `export default` found — every canvas must default-export "
|
|
"a React component."
|
|
)
|
|
|
|
# Any stray `import ... from "..."` remaining (e.g. relative imports)
|
|
# won't resolve in the harness. Keep it loud rather than silently failing.
|
|
leftover = re.search(
|
|
r'^\s*import\s+[^;]*?from\s+[\'"][^\'"]+[\'"]\s*;?\s*$',
|
|
src,
|
|
re.MULTILINE,
|
|
)
|
|
if leftover:
|
|
raise ValueError(
|
|
f"Canvas contains unsupported import statement:\n {leftover.group(0).strip()}\n"
|
|
f"Only `import {{ ... }} from 'cursor/canvas'` is supported in exported canvases."
|
|
)
|
|
|
|
if src == original:
|
|
raise ValueError("Preprocessing did not modify the source — unexpected structure.")
|
|
return src
|
|
|
|
|
|
# ── canvas discovery ────────────────────────────────────────────────────────
|
|
|
|
|
|
def discover_canvases(canvas_dir: Path) -> list[Path]:
|
|
if not canvas_dir.exists():
|
|
return []
|
|
return sorted(canvas_dir.glob("*.canvas.tsx"))
|
|
|
|
|
|
def resolve_canvas(arg: str | None, canvas_dir: Path) -> Path:
|
|
"""Resolve a user-provided canvas arg to an absolute path.
|
|
|
|
Accepts:
|
|
- an absolute/relative path to a .canvas.tsx file
|
|
- a short name (e.g. "milestone-completion") — matched against
|
|
stems in `canvas_dir`
|
|
"""
|
|
if not arg:
|
|
raise SystemExit("No canvas specified. Try --list.")
|
|
p = Path(arg).expanduser()
|
|
if p.exists():
|
|
return p.resolve()
|
|
# Try as a short name inside canvas_dir.
|
|
candidates = list(canvas_dir.glob(f"{arg}.canvas.tsx"))
|
|
if len(candidates) == 1:
|
|
return candidates[0].resolve()
|
|
if len(candidates) > 1:
|
|
raise SystemExit(
|
|
f"Ambiguous canvas name {arg!r}; matches: "
|
|
+ ", ".join(str(c) for c in candidates)
|
|
)
|
|
# Try as a substring.
|
|
all_canvases = discover_canvases(canvas_dir)
|
|
sub = [c for c in all_canvases if arg in c.stem]
|
|
if len(sub) == 1:
|
|
return sub[0].resolve()
|
|
if len(sub) > 1:
|
|
raise SystemExit(
|
|
f"Ambiguous substring {arg!r}; matches: "
|
|
+ ", ".join(c.stem for c in sub)
|
|
)
|
|
raise SystemExit(
|
|
f"No canvas matching {arg!r}. Searched {canvas_dir}.\n"
|
|
f"Available: "
|
|
+ (", ".join(c.stem for c in all_canvases) or "(none)")
|
|
)
|
|
|
|
|
|
# ── HTML harness ────────────────────────────────────────────────────────────
|
|
|
|
|
|
HARNESS_TMPL = """<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>{title_escaped}</title>
|
|
<style>
|
|
@page {{
|
|
size: {paper_mm};
|
|
margin: {margin_mm}mm;
|
|
}}
|
|
html, body {{
|
|
margin: 0;
|
|
padding: 0;
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
|
Helvetica, Arial, sans-serif;
|
|
font-size: 14px;
|
|
line-height: 20px;
|
|
background: {bg};
|
|
color: {fg};
|
|
-webkit-print-color-adjust: exact;
|
|
print-color-adjust: exact;
|
|
}}
|
|
/* Keep cards together across page breaks when possible. */
|
|
[data-card], table, svg {{
|
|
break-inside: avoid;
|
|
page-break-inside: avoid;
|
|
}}
|
|
a {{ color: inherit; }}
|
|
/* Chrome print quirk: ensure rgba backgrounds are preserved. */
|
|
* {{
|
|
-webkit-print-color-adjust: exact;
|
|
print-color-adjust: exact;
|
|
}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="root"></div>
|
|
<script>window.__CANVAS_THEME__ = {theme_json};</script>
|
|
<script>{react_js}</script>
|
|
<script>{react_dom_js}</script>
|
|
<script>{shim_js}</script>
|
|
<script>{babel_js}</script>
|
|
<script>
|
|
(function () {{
|
|
var src = {src_json};
|
|
try {{
|
|
var out = Babel.transform(src, {{
|
|
filename: "canvas.tsx",
|
|
presets: [
|
|
["react"],
|
|
["typescript", {{ allExtensions: true, isTSX: true }}]
|
|
]
|
|
}});
|
|
(0, eval)(out.code);
|
|
if (typeof window.__CANVAS_COMPONENT__ !== "function" &&
|
|
typeof window.__CANVAS_COMPONENT__ !== "object") {{
|
|
throw new Error("canvas did not export a default component");
|
|
}}
|
|
var root = ReactDOM.createRoot(document.getElementById("root"));
|
|
root.render(React.createElement(window.__CANVAS_COMPONENT__));
|
|
window.__CANVAS_READY__ = true;
|
|
}} catch (err) {{
|
|
window.__CANVAS_ERROR__ = String(err && err.stack || err);
|
|
document.body.innerHTML =
|
|
'<pre style="padding:16px;font-family:monospace;color:#CF2D56;">' +
|
|
'Canvas render error:\\n' +
|
|
(window.__CANVAS_ERROR__ || 'unknown')
|
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') +
|
|
'</pre>';
|
|
}}
|
|
}})();
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
|
|
def build_harness(
|
|
canvas_src: str,
|
|
*,
|
|
title: str,
|
|
theme: str,
|
|
paper_mm: str,
|
|
margin_mm: float,
|
|
) -> str:
|
|
# Theme-appropriate page background/foreground so the areas around the
|
|
# canvas's own `maxWidth` content don't look mismatched in the PDF.
|
|
if theme == "dark":
|
|
bg, fg = "#181818", "#E4E4E4"
|
|
else:
|
|
bg, fg = "#FFFFFF", "#141414"
|
|
|
|
return HARNESS_TMPL.format(
|
|
title_escaped=title.replace("<", "<").replace(">", ">"),
|
|
paper_mm=paper_mm,
|
|
margin_mm=f"{margin_mm:g}",
|
|
bg=bg,
|
|
fg=fg,
|
|
theme_json=json.dumps(theme),
|
|
react_js=(CACHE_DIR / "react.production.min.js").read_text(),
|
|
react_dom_js=(CACHE_DIR / "react-dom.production.min.js").read_text(),
|
|
shim_js=SHIM_PATH.read_text(),
|
|
babel_js=(CACHE_DIR / "babel.min.js").read_text(),
|
|
src_json=json.dumps(preprocess_canvas(canvas_src)),
|
|
)
|
|
|
|
|
|
# ── Chrome invocation ───────────────────────────────────────────────────────
|
|
|
|
|
|
def find_chrome() -> str:
|
|
for cmd in CHROME_CANDIDATES:
|
|
p = shutil.which(cmd)
|
|
if p:
|
|
return p
|
|
raise SystemExit(
|
|
"No Chrome/Chromium binary found on PATH. Install `google-chrome` or "
|
|
"`chromium` and retry. Tried: " + ", ".join(CHROME_CANDIDATES)
|
|
)
|
|
|
|
|
|
def print_to_pdf(
|
|
html_path: Path,
|
|
pdf_path: Path,
|
|
*,
|
|
landscape: bool,
|
|
scale: float,
|
|
wait_ms: int,
|
|
quiet: bool,
|
|
) -> None:
|
|
chrome = find_chrome()
|
|
pdf_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
cmd = [
|
|
chrome,
|
|
"--headless=new",
|
|
"--disable-gpu",
|
|
"--no-sandbox",
|
|
"--hide-scrollbars",
|
|
"--disable-extensions",
|
|
"--no-pdf-header-footer",
|
|
# Simulated time budget so the React tree and all charts have finished
|
|
# mounting before Chrome takes the snapshot.
|
|
f"--virtual-time-budget={wait_ms}",
|
|
"--run-all-compositor-stages-before-draw",
|
|
f"--print-to-pdf={pdf_path}",
|
|
]
|
|
if landscape:
|
|
cmd.append("--landscape")
|
|
if abs(scale - 1.0) > 1e-6:
|
|
cmd.append(f"--force-device-scale-factor={scale:g}")
|
|
|
|
# Ensure file:// URL is well-formed even for paths with spaces.
|
|
url = "file://" + str(html_path.resolve())
|
|
cmd.append(url)
|
|
|
|
_log(f"launching: {chrome} --print-to-pdf={pdf_path}", quiet)
|
|
result = subprocess.run(
|
|
cmd,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
)
|
|
# Chrome emits a lot of noise on stderr that isn't fatal.
|
|
if result.returncode != 0:
|
|
raise SystemExit(
|
|
f"Chrome exited with code {result.returncode}.\n"
|
|
f"stdout:\n{result.stdout}\n\nstderr:\n{result.stderr}"
|
|
)
|
|
if not pdf_path.exists() or pdf_path.stat().st_size == 0:
|
|
raise SystemExit(
|
|
f"Chrome returned 0 but produced no PDF at {pdf_path}.\n"
|
|
f"stderr:\n{result.stderr}"
|
|
)
|
|
|
|
|
|
# ── export orchestration ────────────────────────────────────────────────────
|
|
|
|
|
|
def export_canvas(
|
|
canvas_path: Path,
|
|
*,
|
|
output: Path,
|
|
theme: str,
|
|
paper: str,
|
|
landscape: bool,
|
|
margin_mm: float,
|
|
scale: float,
|
|
wait_ms: int,
|
|
keep_html: bool,
|
|
quiet: bool,
|
|
) -> Path:
|
|
src = canvas_path.read_text()
|
|
if paper not in PAPER_SIZES:
|
|
raise SystemExit(
|
|
f"Unknown --format {paper!r}. Known: {', '.join(PAPER_SIZES)}"
|
|
)
|
|
w, h = PAPER_SIZES[paper]
|
|
paper_mm = f"{w:g}mm {h:g}mm"
|
|
|
|
html = build_harness(
|
|
src,
|
|
title=canvas_path.stem,
|
|
theme=theme,
|
|
paper_mm=paper_mm,
|
|
margin_mm=margin_mm,
|
|
)
|
|
|
|
# If caller passed a directory, derive the filename from the canvas stem.
|
|
if output.exists() and output.is_dir():
|
|
output = output / (canvas_path.stem.replace(".canvas", "") + ".pdf")
|
|
elif str(output).endswith("/") or (not output.suffix and output.name and not output.exists()):
|
|
output.mkdir(parents=True, exist_ok=True)
|
|
output = output / (canvas_path.stem.replace(".canvas", "") + ".pdf")
|
|
|
|
# Write harness to a sibling-of-output temp file so file:// can find all
|
|
# the inlined assets if we ever externalize them later.
|
|
tmpfile = tempfile.NamedTemporaryFile(
|
|
mode="w",
|
|
suffix=".harness.html",
|
|
prefix=canvas_path.stem + "-",
|
|
delete=False,
|
|
)
|
|
try:
|
|
tmpfile.write(html)
|
|
tmpfile.flush()
|
|
tmpfile.close()
|
|
html_path = Path(tmpfile.name)
|
|
|
|
print_to_pdf(
|
|
html_path,
|
|
output,
|
|
landscape=landscape,
|
|
scale=scale,
|
|
wait_ms=wait_ms,
|
|
quiet=quiet,
|
|
)
|
|
_log(f"wrote {output} ({output.stat().st_size:,} bytes)", quiet)
|
|
|
|
if keep_html:
|
|
kept = output.with_suffix(".harness.html")
|
|
shutil.copy2(html_path, kept)
|
|
_log(f"kept harness at {kept}", quiet)
|
|
finally:
|
|
if not keep_html:
|
|
try:
|
|
os.unlink(tmpfile.name)
|
|
except OSError:
|
|
pass
|
|
|
|
return output
|
|
|
|
|
|
# ── CLI ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
p = argparse.ArgumentParser(description=__doc__.split("\n\n", 1)[0])
|
|
p.add_argument(
|
|
"canvas",
|
|
nargs="?",
|
|
help="Canvas to export: a path to `*.canvas.tsx`, or a short name "
|
|
"(e.g. `milestone-completion`) resolved in --canvas-dir.",
|
|
)
|
|
p.add_argument(
|
|
"--canvas-dir",
|
|
default=str(DEFAULT_CANVAS_DIR),
|
|
help=f"Where to search for canvases by short name. Default: {DEFAULT_CANVAS_DIR}",
|
|
)
|
|
p.add_argument(
|
|
"--output",
|
|
"-o",
|
|
default=None,
|
|
help="Output PDF path (or directory). Default: `<canvas-stem>.pdf` next "
|
|
"to the canvas file.",
|
|
)
|
|
p.add_argument(
|
|
"--all",
|
|
action="store_true",
|
|
help="Export every `*.canvas.tsx` in --canvas-dir. Output becomes the "
|
|
"destination directory (defaults to the canvas dir itself).",
|
|
)
|
|
p.add_argument(
|
|
"--list", action="store_true", help="List discoverable canvases and exit."
|
|
)
|
|
p.add_argument(
|
|
"--theme",
|
|
choices=["light", "dark"],
|
|
default="light",
|
|
help="Theme the canvas renders in. Default: light (better for print).",
|
|
)
|
|
p.add_argument(
|
|
"--format",
|
|
dest="paper",
|
|
default="Letter",
|
|
choices=sorted(PAPER_SIZES),
|
|
help="Paper size. Default: Letter.",
|
|
)
|
|
p.add_argument("--landscape", action="store_true", help="Print in landscape.")
|
|
p.add_argument(
|
|
"--margin-mm",
|
|
type=float,
|
|
default=12.0,
|
|
help="Page margin in mm. Default: 12.",
|
|
)
|
|
p.add_argument(
|
|
"--scale",
|
|
type=float,
|
|
default=1.0,
|
|
help="Device scale factor (1.0 = normal). Bump to 1.25 for denser PDFs.",
|
|
)
|
|
p.add_argument(
|
|
"--wait-ms",
|
|
type=int,
|
|
default=8000,
|
|
help="Simulated time budget before snapshotting (ms). Bump if charts "
|
|
"or React hydration look unfinished. Default: 8000.",
|
|
)
|
|
p.add_argument(
|
|
"--keep-html",
|
|
action="store_true",
|
|
help="Keep the intermediate HTML harness next to the PDF for debugging.",
|
|
)
|
|
p.add_argument("--quiet", action="store_true", help="Suppress progress messages.")
|
|
return p.parse_args()
|
|
|
|
|
|
def main() -> None:
|
|
args = parse_args()
|
|
canvas_dir = Path(args.canvas_dir).expanduser()
|
|
|
|
if args.list:
|
|
for c in discover_canvases(canvas_dir):
|
|
print(c)
|
|
return
|
|
|
|
ensure_vendor(args.quiet)
|
|
|
|
if args.all:
|
|
all_canvases = discover_canvases(canvas_dir)
|
|
if not all_canvases:
|
|
raise SystemExit(f"No canvases found in {canvas_dir}")
|
|
out_dir = Path(args.output).expanduser() if args.output else canvas_dir
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
for c in all_canvases:
|
|
_log(f"exporting {c.name}", args.quiet)
|
|
export_canvas(
|
|
c,
|
|
output=out_dir,
|
|
theme=args.theme,
|
|
paper=args.paper,
|
|
landscape=args.landscape,
|
|
margin_mm=args.margin_mm,
|
|
scale=args.scale,
|
|
wait_ms=args.wait_ms,
|
|
keep_html=args.keep_html,
|
|
quiet=args.quiet,
|
|
)
|
|
return
|
|
|
|
canvas_path = resolve_canvas(args.canvas, canvas_dir)
|
|
if args.output:
|
|
output = Path(args.output).expanduser()
|
|
else:
|
|
output = canvas_path.with_suffix("").with_suffix(".pdf")
|
|
# canvas_path is `foo.canvas.tsx` → `foo.canvas` → `foo.pdf`
|
|
if output.name.endswith(".canvas"):
|
|
output = output.with_name(output.name[: -len(".canvas")] + ".pdf")
|
|
export_canvas(
|
|
canvas_path,
|
|
output=output,
|
|
theme=args.theme,
|
|
paper=args.paper,
|
|
landscape=args.landscape,
|
|
margin_mm=args.margin_mm,
|
|
scale=args.scale,
|
|
wait_ms=args.wait_ms,
|
|
keep_html=args.keep_html,
|
|
quiet=args.quiet,
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|