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/__init__.py +210 -0
- codefig/_validate.py +12 -0
- codefig/autowrap.py +277 -0
- codefig/charts.py +1806 -0
- codefig/code.py +602 -0
- codefig/compose.py +374 -0
- codefig/constraints.py +1571 -0
- codefig/dim.py +214 -0
- codefig/edge_align.py +231 -0
- codefig/edges.py +2824 -0
- codefig/export.py +204 -0
- codefig/figure.py +1923 -0
- codefig/fills.py +146 -0
- codefig/flows.py +102 -0
- codefig/glyphs.py +395 -0
- codefig/icon.py +288 -0
- codefig/icons/LICENSE-tabler-icons.txt +21 -0
- codefig/icons/__init__.py +101 -0
- codefig/icons/tabler.json +1 -0
- codefig/legend.py +483 -0
- codefig/lint.py +1737 -0
- codefig/lint_geom.py +528 -0
- codefig/node.py +864 -0
- codefig/notices.py +34 -0
- codefig/overlaps.py +392 -0
- codefig/policy.py +137 -0
- codefig/primitives.py +2721 -0
- codefig/py.typed +0 -0
- codefig/regions.py +727 -0
- codefig/render_svg.py +512 -0
- codefig/routing.py +932 -0
- codefig/shapes.py +642 -0
- codefig/solids.py +514 -0
- codefig/steps.py +203 -0
- codefig/styles.py +893 -0
- codefig/textutil.py +889 -0
- codefig/timeline.py +1288 -0
- codefig/tree.py +277 -0
- codefig/venues.py +93 -0
- codefig-0.17.0.dist-info/METADATA +542 -0
- codefig-0.17.0.dist-info/RECORD +44 -0
- codefig-0.17.0.dist-info/WHEEL +5 -0
- codefig-0.17.0.dist-info/licenses/LICENSE +21 -0
- codefig-0.17.0.dist-info/top_level.txt +1 -0
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
|