codefig 0.17.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
codefig/export.py ADDED
@@ -0,0 +1,204 @@
1
+ """Export: SVG (always), PNG and PDF via cairosvg, plus alt text and save-time lint.
2
+
3
+ * ``.svg`` — text stays text (``font-family`` lists the resolved installed family
4
+ first, then the theme's stack).
5
+ * ``.png`` — px figures: ``scale=`` (2 → 2×); physical (pt) figures: ``dpi=``
6
+ (default 300 → ``width_pt × 300 / 72`` pixels).
7
+ * ``.pdf`` — vector, fonts embedded as TrueType subsets by cairo (no Type 3 for
8
+ TrueType/OpenType sources). Note: ICML/MLSys ask for Type 1; embedded TrueType is
9
+ accepted in practice and by NeurIPS/ICLR/ACM/USENIX (``docs/concepts/themes-venues.md``).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import warnings
15
+ from pathlib import Path
16
+ from typing import TYPE_CHECKING, Any, Optional, Union
17
+
18
+ if TYPE_CHECKING:
19
+ from .figure import Figure
20
+
21
+
22
+ def _cairosvg():
23
+ try:
24
+ import cairosvg
25
+ except ImportError as e: # pragma: no cover - dependency is declared
26
+ raise ImportError("PNG/PDF export requires cairosvg: pip install cairosvg") from e
27
+ return cairosvg
28
+
29
+
30
+ def _lint_mode(fig: "Figure", lint: Optional[str]) -> Optional[str]:
31
+ if lint in (None, False, "off", "none"):
32
+ return None
33
+ if lint == "auto":
34
+ return "error" if getattr(fig, "physical", False) else None
35
+ if lint not in ("error", "warn"):
36
+ raise ValueError("lint must be 'error', 'warn', 'auto' or None")
37
+ return lint
38
+
39
+
40
+ def save_figure(
41
+ fig: "Figure",
42
+ path: Union[str, Path],
43
+ *,
44
+ scale: Optional[float] = None,
45
+ dpi: Optional[float] = None,
46
+ lint: Optional[str] = "auto",
47
+ alt: Optional[str] = None,
48
+ venue: Optional[str] = None,
49
+ stacklevel: int = 3,
50
+ ) -> Path:
51
+ """Write ``.svg`` / ``.png`` / ``.pdf`` (see module docstring)."""
52
+ path = Path(path)
53
+ suffix = path.suffix.lower()
54
+ if suffix not in (".svg", ".png", ".pdf"):
55
+ raise ValueError(f"Unsupported export format: {suffix!r} (use .svg, .png or .pdf)")
56
+ if scale is not None and scale <= 0:
57
+ raise ValueError(f"scale must be > 0, got {scale!r}")
58
+ if dpi is not None and dpi <= 0:
59
+ raise ValueError(f"dpi must be > 0, got {dpi!r}")
60
+ mode = _lint_mode(fig, lint)
61
+ if mode is not None:
62
+ from .lint import LintWarning
63
+
64
+ rep = fig.lint(venue=venue, alt=alt)
65
+ if mode == "error" and any(f.severity == "error" for f in rep.findings):
66
+ _raise_with_preview(fig, rep, path, alt)
67
+ path.parent.mkdir(parents=True, exist_ok=True)
68
+ for ext in (".png", ".svg"): # a successful save clears a stale lint preview
69
+ stale = path.with_name(path.stem + ".lint_preview" + ext)
70
+ if stale.exists():
71
+ stale.unlink()
72
+ if mode == "warn":
73
+ prev = None
74
+ if any(f.severity == "error" for f in rep.findings):
75
+ prev = write_lint_preview(fig, rep, path, alt) # iteration mode: figure + preview
76
+ for f in rep.findings:
77
+ if f.severity in ("error", "warn"):
78
+ warnings.warn(f"{f.rule_id} {f.severity}: {f.message}"
79
+ + (f" (fix: {f.fix})" if f.fix else "")
80
+ + (f" [outlined in {prev}]" if prev is not None and f.severity == "error"
81
+ else ""), LintWarning, stacklevel=stacklevel)
82
+ svg = fig.to_svg(alt=alt)
83
+ if alt:
84
+ path.with_suffix(".alt.txt").write_text(alt.strip() + "\n", encoding="utf-8")
85
+ if suffix == ".svg":
86
+ path.write_text(svg, encoding="utf-8")
87
+ return path
88
+ cairosvg = _cairosvg()
89
+ data = svg.encode("utf-8")
90
+ if suffix == ".png":
91
+ if getattr(fig, "physical", False):
92
+ if scale is not None and dpi is None:
93
+ dpi = 72.0 * scale
94
+ px_w = fig.width * float(dpi or 300.0) / 72.0
95
+ cairosvg.svg2png(bytestring=data, write_to=str(path), output_width=round(px_w))
96
+ else:
97
+ cairosvg.svg2png(bytestring=data, write_to=str(path), scale=float(scale or 1.0))
98
+ return path
99
+ cairosvg.svg2pdf(bytestring=data, write_to=str(path))
100
+ if mode is not None:
101
+ _check_pdf_fonts(path, mode)
102
+ return path
103
+
104
+
105
+ def lint_preview_svg(fig: "Figure", report: Any, *, alt: Optional[str] = None,
106
+ warnings_too: bool = True) -> str:
107
+ """v0.12.0 (A7): the figure's SVG with every lint finding that has a location
108
+ outlined — errors in red, warnings in orange — and its rule id beside the box."""
109
+ svg = fig.to_svg(alt=alt)
110
+ marks = []
111
+ for f in report.findings:
112
+ if f.severity == "error":
113
+ col = "#e41a1c"
114
+ elif f.severity == "warn" and warnings_too:
115
+ col = "#ff7f00"
116
+ else:
117
+ continue
118
+ for j, b in enumerate(f.boxes):
119
+ x0, y0, x1, y1 = b
120
+ pad = 0.8
121
+ marks.append(f'<rect x="{x0 - pad:.2f}" y="{y0 - pad:.2f}" width="{x1 - x0 + 2 * pad:.2f}" '
122
+ f'height="{y1 - y0 + 2 * pad:.2f}" fill="none" stroke="{col}" '
123
+ f'stroke-width="0.6" />')
124
+ if j == 0: # the rule id once per finding, at its first location
125
+ marks.append(f'<text x="{x0:.2f}" y="{y0 - 1.4:.2f}" font-size="3.2" '
126
+ f'fill="{col}" font-family="Liberation Sans, Arial, sans-serif">'
127
+ f'{f.rule_id}</text>')
128
+ if not marks:
129
+ return svg
130
+ i = svg.rfind("</svg>")
131
+ return svg[:i] + '<g id="codefig-lint-preview">' + "".join(marks) + "</g>\n" + svg[i:]
132
+
133
+
134
+ def write_lint_preview(fig: "Figure", rep: Any, path: Path, alt: Optional[str] = None) -> Optional[Path]:
135
+ """Write ``<stem>.lint_preview.png`` (``.svg`` when PNG export is unavailable) with
136
+ every located finding outlined; returns its path (``None`` if it could not be written)."""
137
+ prev = None
138
+ try:
139
+ path.parent.mkdir(parents=True, exist_ok=True)
140
+ svg = lint_preview_svg(fig, rep, alt=alt)
141
+ try:
142
+ cairosvg = _cairosvg()
143
+ prev = path.with_name(path.stem + ".lint_preview.png")
144
+ px_w = fig.width * 400.0 / 72.0 if getattr(fig, "physical", False) else fig.width * 2
145
+ cairosvg.svg2png(bytestring=svg.encode("utf-8"), write_to=str(prev),
146
+ output_width=round(px_w))
147
+ except Exception:
148
+ prev = path.with_name(path.stem + ".lint_preview.svg")
149
+ prev.write_text(svg, encoding="utf-8")
150
+ except Exception: # pragma: no cover - the preview must never hide the real error
151
+ prev = None
152
+ return prev
153
+
154
+
155
+ def _raise_with_preview(fig: "Figure", rep: Any, path: Path, alt: Optional[str]) -> None:
156
+ """Write the lint preview, then raise LintError naming it (summary on the last line)."""
157
+ from .lint import LintError
158
+
159
+ prev = write_lint_preview(fig, rep, path, alt)
160
+ raise LintError(rep, preview=prev, target=path)
161
+
162
+
163
+ def _check_pdf_fonts(path: Path, mode: str) -> None:
164
+ """CF-EXP-002 on the written file: every font embedded, none Type 3."""
165
+ from .lint import LintError, LintWarning
166
+
167
+ bad = [f for f in pdf_fonts(path) if not f["embedded"] or "Type 3" in f["type"]]
168
+ if not bad:
169
+ return
170
+ msg = "CF-EXP-002 error: " + ", ".join(f"{f['name']} ({f['type']}, embedded={f['embedded']})"
171
+ for f in bad)
172
+ if mode == "error":
173
+ raise LintError(msg)
174
+ warnings.warn(msg, LintWarning, stacklevel=3)
175
+
176
+
177
+ def pdf_fonts(path: Union[str, Path]) -> list:
178
+ """Fonts of a PDF as ``[{"name", "type", "embedded"}]`` (``pdffonts`` if present,
179
+ else a byte scan of the font dictionaries)."""
180
+ import re
181
+ import shutil
182
+ import subprocess
183
+
184
+ path = Path(path)
185
+ if shutil.which("pdffonts"):
186
+ out = subprocess.run(["pdffonts", str(path)], capture_output=True, text=True).stdout
187
+ rows = []
188
+ for line in out.splitlines()[2:]:
189
+ parts = line.split()
190
+ if len(parts) < 7:
191
+ continue
192
+ # name type [subtype words] encoding emb sub uni object ID
193
+ name = parts[0]
194
+ emb = parts[-5]
195
+ ftype = " ".join(parts[1:-6]) if len(parts) > 7 else parts[1]
196
+ rows.append({"name": name, "type": ftype, "embedded": emb == "yes"})
197
+ return rows
198
+ raw = path.read_bytes()
199
+ rows = []
200
+ for m in re.finditer(rb"/Subtype\s*/(Type3|Type1|TrueType|Type0|CIDFontType2|CIDFontType0)"
201
+ rb"[^>]*?/BaseFont\s*/([^\s/>]+)", raw):
202
+ rows.append({"name": m.group(2).decode("latin-1"), "type": m.group(1).decode(),
203
+ "embedded": b"/FontFile" in raw})
204
+ return rows