082928
This commit is contained in:
@@ -0,0 +1,372 @@
|
||||
"""论文级图表工具:统一配色、场景图 DSL、SVG/PNG 双出与来源清单。
|
||||
|
||||
设计约束(对应 docs/figures/README.md 的"可复现"承诺):
|
||||
|
||||
- **零新增运行时依赖**:几何/连线由本模块直接写 SVG,PNG 由 ``rsvg-convert``
|
||||
光栅化(与项目既有可视化链路一致);中文与西文文字层用 Pillow 以论文自带
|
||||
的 ``docs/paper/fonts/`` 字体单独合成,保证与 ``main.tex`` 同字体、同字号;
|
||||
- **一份场景图两处消费**:SVG 用于 WebUI/文档内联预览(走系统字体栈),
|
||||
PNG 用于论文插图(走 bundled 字体);两者坐标完全一致;
|
||||
- **来源可追溯**:每张图强制携带 ``sources``(文件路径 + sha256 前 12 位 +
|
||||
行数)与生成命令,写入 ``manifest.json``,避免"图与数据对不上"。
|
||||
|
||||
文字排版约定:**主风格只提供 SVG 精度的文字**,Pillow 侧复用同一套
|
||||
``(x, y, anchor, font, size, dy)`` 参数重画一遍;``dy`` 表示"文字视觉中心相对
|
||||
基线的偏移比例",由 font-size 缩放,用来让框内文字纵向居中。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import shutil
|
||||
import subprocess
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Sequence
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
FONT_DIR = ROOT / "docs" / "paper" / "fonts"
|
||||
DEFAULT_RASTERIZER = "rsvg-convert"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 论文配色:低饱和、单色可打印,正文/强调/真值/成功/失败五类语义色 + 顺序色阶
|
||||
# ---------------------------------------------------------------------------
|
||||
PALETTE: dict[str, str] = {
|
||||
"ink": "#1c2430", # 主文字/坐标轴
|
||||
"muted": "#6b7684", # 次要文字与网格
|
||||
"faint": "#dfe4ea", # 分隔线
|
||||
"paper": "#ffffff",
|
||||
"primary": "#1f4e79", # 主线:协议/流程
|
||||
"primary_soft": "#dce6f1",
|
||||
"accent": "#b03a2e", # 强调:真值源/关键结论
|
||||
"accent_soft": "#f6dcd8",
|
||||
"success": "#1e7a5f", # 成功:清除/全清
|
||||
"success_soft": "#d8ece5",
|
||||
"warn": "#b8860b", # 警示:后备/未达标
|
||||
"warn_soft": "#f6ecd2",
|
||||
"series": ("#1f4e79", "#b03a2e", "#1e7a5f", "#b8860b", "#5b4b8a", "#3d7ea6"),
|
||||
}
|
||||
|
||||
FONTS: dict[str, str] = {
|
||||
"song": "SongtiSC-Regular.ttf", # 中文正文:宋体
|
||||
"song_bold": "SongtiSC-Bold.ttf",
|
||||
"hei": "HeitiSC-Light.ttf", # 中文标题:黑体
|
||||
"hei_bold": "HeitiSC-Medium.ttf",
|
||||
"times": "Times New Roman.ttf", # 西文与数字
|
||||
"times_bold": "Times New Roman Bold.ttf",
|
||||
}
|
||||
|
||||
_SVG_FAMILY = {
|
||||
"song": "'Songti SC','SimSun',serif",
|
||||
"song_bold": "'Songti SC','SimSun',serif",
|
||||
"hei": "'Heiti SC','Microsoft YaHei',sans-serif",
|
||||
"hei_bold": "'Heiti SC','Microsoft YaHei',sans-serif",
|
||||
"times": "'Times New Roman',Times,serif",
|
||||
"times_bold": "'Times New Roman',Times,serif",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 场景图 DSL
|
||||
# ---------------------------------------------------------------------------
|
||||
@dataclass
|
||||
class Figure:
|
||||
"""一张图的场景描述:宽高以 CSS 像素(≈1/96 inch)计。"""
|
||||
|
||||
width: float
|
||||
height: float
|
||||
title: str = ""
|
||||
caption: str = ""
|
||||
background: str = PALETTE["paper"]
|
||||
elements: list[dict[str, Any]] = field(default_factory=list)
|
||||
sources: list[dict[str, Any]] = field(default_factory=list)
|
||||
notes: list[str] = field(default_factory=list)
|
||||
|
||||
# ---------------------------------------------------------------- 图元
|
||||
def rect(self, x: float, y: float, w: float, h: float, **kw: Any) -> "Figure":
|
||||
self.elements.append(dict(kind="rect", x=x, y=y, w=w, h=h, **kw))
|
||||
return self
|
||||
|
||||
def circle(self, cx: float, cy: float, r: float, **kw: Any) -> "Figure":
|
||||
self.elements.append(dict(kind="circle", cx=cx, cy=cy, r=r, **kw))
|
||||
return self
|
||||
|
||||
def line(self, x1: float, y1: float, x2: float, y2: float, **kw: Any) -> "Figure":
|
||||
self.elements.append(dict(kind="line", x1=x1, y1=y1, x2=x2, y2=y2, **kw))
|
||||
return self
|
||||
|
||||
def polyline(self, points: Sequence[Sequence[float]], **kw: Any) -> "Figure":
|
||||
self.elements.append(dict(kind="polyline", points=[list(p) for p in points], **kw))
|
||||
return self
|
||||
|
||||
def polygon(self, points: Sequence[Sequence[float]], **kw: Any) -> "Figure":
|
||||
self.elements.append(dict(kind="polygon", points=[list(p) for p in points], **kw))
|
||||
return self
|
||||
|
||||
def path(self, d: str, **kw: Any) -> "Figure":
|
||||
self.elements.append(dict(kind="path", d=d, **kw))
|
||||
return self
|
||||
|
||||
def arrow(self, x1: float, y1: float, x2: float, y2: float, **kw: Any) -> "Figure":
|
||||
"""直线箭头:``head`` 为箭头边长(像素),``dash`` 支持虚线。"""
|
||||
self.elements.append(dict(kind="arrow", x1=x1, y1=y1, x2=x2, y2=y2, **kw))
|
||||
return self
|
||||
|
||||
def text(self, x: float, y: float, s: str, *, size: float = 12.0,
|
||||
font: str = "song", color: str | None = None, anchor: str = "start",
|
||||
align: str = "top", bold: bool = False, rotate: float | None = None,
|
||||
dy: float | None = None, opacity: float = 1.0) -> "Figure":
|
||||
"""文字。``(x, y)`` 为参考点;``align`` ∈ {top, middle, bottom} 决定
|
||||
参考点落在文字块的哪条水平线上(``anchor`` 决定左右)。
|
||||
"""
|
||||
self.elements.append(dict(
|
||||
kind="text", x=x, y=y, s=s, size=size, font=font,
|
||||
color=color or PALETTE["ink"], anchor=anchor, align=align,
|
||||
bold=bold, rotate=rotate, dy=dy, opacity=opacity))
|
||||
return self
|
||||
|
||||
def legend_chips(self, x: float, y: float, items: Iterable[tuple[str, str]],
|
||||
*, size: float = 11.0, gap: float = 16.0, box: float = 10.0,
|
||||
font: str = "song") -> float:
|
||||
"""一行"色块 + 文字"图例,返回结束时的 x 坐标。"""
|
||||
cur = x
|
||||
for label, color in items:
|
||||
self.rect(cur, y - box * 0.75, box, box, fill=color, stroke=None, radius=2.0)
|
||||
self.text(cur + box + 5.0, y + box * 0.35, label, size=size, font=font,
|
||||
align="middle")
|
||||
cur += box + 5.0 + _estimate_width(label, size, font) + gap
|
||||
return cur
|
||||
|
||||
def source(self, path: str | Path, **extra: Any) -> "Figure":
|
||||
"""登记本图的数据来源(相对仓库根),含 sha256 前 12 位与行数。"""
|
||||
p = Path(path)
|
||||
p = p if p.is_absolute() else ROOT / p
|
||||
digest, lines = "-", 0
|
||||
if p.is_file():
|
||||
data = p.read_bytes()
|
||||
digest = hashlib.sha256(data).hexdigest()[:12]
|
||||
lines = data.count(b"\n") + (0 if data.endswith(b"\n") else 1)
|
||||
self.sources.append(dict(path=str(p.relative_to(ROOT)) if p.is_relative_to(ROOT) else str(p),
|
||||
sha256_12=digest, lines=lines, **extra))
|
||||
return self
|
||||
|
||||
# ---------------------------------------------------------------- 输出
|
||||
def to_svg(self) -> str:
|
||||
body: list[str] = [_svg_element(el) for el in self.elements]
|
||||
head = (f'<svg xmlns="http://www.w3.org/2000/svg" '
|
||||
f'width="{_n(self.width)}" height="{_n(self.height)}" '
|
||||
f'viewBox="0 0 {_n(self.width)} {_n(self.height)}" '
|
||||
f'font-family="{_SVG_FAMILY["song"]}">')
|
||||
meta = f"<title>{_esc(self.title)}</title>\n"
|
||||
if self.caption:
|
||||
meta += f"<desc>{_esc(self.caption)}</desc>\n"
|
||||
return "\n".join([head, meta, f'<rect width="{_n(self.width)}" '
|
||||
f'height="{_n(self.height)}" '
|
||||
f'fill="{self.background}"/>', *body, "</svg>", ""])
|
||||
|
||||
def write(self, out_dir: str | Path, stem: str, *, scale: float = 2.0,
|
||||
rasterizer: str = DEFAULT_RASTERIZER, png: bool = True) -> dict[str, Any]:
|
||||
"""写出 ``<stem>.svg``(+ ``<stem>.png``),返回产出清单。"""
|
||||
out = Path(out_dir)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
svg_path = out / f"{stem}.svg"
|
||||
svg_path.write_text(self.to_svg(), encoding="utf-8")
|
||||
produced: dict[str, Any] = {"stem": stem, "title": self.title,
|
||||
"caption": self.caption,
|
||||
"svg": str(svg_path.relative_to(ROOT)),
|
||||
"size": [self.width, self.height],
|
||||
"sources": self.sources, "notes": self.notes}
|
||||
if png:
|
||||
png_path = out / f"{stem}.png"
|
||||
_rasterize(svg_path, png_path, scale=scale, rasterizer=rasterizer)
|
||||
_compose_text(self, png_path, scale=scale)
|
||||
produced["png"] = str(png_path.relative_to(ROOT))
|
||||
produced["png_scale"] = scale
|
||||
return produced
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SVG 生成
|
||||
# ---------------------------------------------------------------------------
|
||||
def _n(value: float) -> str:
|
||||
"""紧凑数字:去掉多余小数位,避免 SVG 体积膨胀。"""
|
||||
if abs(value - round(value)) < 1e-9:
|
||||
return str(int(round(value)))
|
||||
return f"{value:.3f}".rstrip("0").rstrip(".")
|
||||
|
||||
|
||||
def _esc(text: str) -> str:
|
||||
return (str(text).replace("&", "&").replace("<", "<").replace(">", ">"))
|
||||
|
||||
|
||||
def _paint(kw: dict[str, Any]) -> str:
|
||||
parts: list[str] = []
|
||||
fill = kw.get("fill", "none")
|
||||
if fill is None:
|
||||
fill = "none"
|
||||
parts.append(f'fill="{fill}"')
|
||||
stroke = kw.get("stroke")
|
||||
if stroke:
|
||||
parts.append(f'stroke="{stroke}" stroke-width="{_n(kw.get("sw", 1.0))}"')
|
||||
if kw.get("dash"):
|
||||
dash = kw["dash"] if isinstance(kw["dash"], str) else \
|
||||
" ".join(_n(v) for v in kw["dash"])
|
||||
parts.append(f'stroke-dasharray="{dash}"')
|
||||
parts.append(f'stroke-linejoin="{kw.get("linejoin", "miter")}"')
|
||||
if kw.get("opacity") is not None:
|
||||
parts.append(f'opacity="{_n(kw["opacity"])}"')
|
||||
if kw.get("radius"):
|
||||
parts.append(f'rx="{_n(kw["radius"])}" ry="{_n(kw["radius"])}"')
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def _text_svg(el: dict[str, Any]) -> str:
|
||||
size = el["size"]
|
||||
dy = _baseline_offset(el)
|
||||
anchor = {"start": "start", "middle": "middle", "end": "end"}[el["anchor"]]
|
||||
family = _SVG_FAMILY[el["font"]]
|
||||
weight = ' font-weight="bold"' if el["bold"] or el["font"].endswith("_bold") else ""
|
||||
style = ' font-style="italic"' if el.get("italic") else ""
|
||||
extra = f' opacity="{_n(el["opacity"])}"' if el.get("opacity", 1.0) < 1 else ""
|
||||
rotate = f' transform="rotate({_n(el["rotate"])} {_n(el["x"])} {_n(el["y"])})"' \
|
||||
if el.get("rotate") else ""
|
||||
return (f'<text x="{_n(el["x"])}" y="{_n(el["y"] + dy)}" font-size="{_n(size)}" '
|
||||
f'font-family="{family}"{weight}{style} fill="{el["color"]}" '
|
||||
f'text-anchor="{anchor}"{extra}{rotate}>{_esc(el["s"])}</text>')
|
||||
|
||||
|
||||
def _baseline_offset(el: dict[str, Any]) -> float:
|
||||
"""参考点 → SVG 基线(alphabetic)的 y 偏移。"""
|
||||
if el.get("dy") is not None:
|
||||
return float(el["dy"])
|
||||
if el["align"] == "middle":
|
||||
return el["size"] * 0.35
|
||||
if el["align"] == "bottom":
|
||||
return 0.0
|
||||
return el["size"] * 0.82
|
||||
|
||||
|
||||
def _svg_element(el: dict[str, Any]) -> str:
|
||||
kind = el["kind"]
|
||||
if kind == "text":
|
||||
return _text_svg(el)
|
||||
if kind == "rect":
|
||||
return (f'<rect x="{_n(el["x"])}" y="{_n(el["y"])}" width="{_n(el["w"])}" '
|
||||
f'height="{_n(el["h"])}" {_paint(el)}/>')
|
||||
if kind == "circle":
|
||||
return f'<circle cx="{_n(el["cx"])}" cy="{_n(el["cy"])}" r="{_n(el["r"])}" {_paint(el)}/>'
|
||||
if kind == "line":
|
||||
return (f'<line x1="{_n(el["x1"])}" y1="{_n(el["y1"])}" x2="{_n(el["x2"])}" '
|
||||
f'y2="{_n(el["y2"])}" {_paint(el)}/>')
|
||||
if kind in ("polyline", "polygon"):
|
||||
pts = " ".join(f"{_n(px)},{_n(py)}" for px, py in el["points"])
|
||||
return f'<{kind} points="{pts}" {_paint(el)}/>'
|
||||
if kind == "path":
|
||||
return f'<path d="{el["d"]}" {_paint(el)}/>'
|
||||
if kind == "arrow":
|
||||
return _arrow_svg(el)
|
||||
raise ValueError(f"未知图元:{kind}")
|
||||
|
||||
|
||||
def _arrow_svg(el: dict[str, Any]) -> str:
|
||||
x1, y1, x2, y2 = el["x1"], el["y1"], el["x2"], el["y2"]
|
||||
color = el.get("stroke") or PALETTE["ink"]
|
||||
sw = el.get("sw", 1.4)
|
||||
head = el.get("head", 8.0)
|
||||
ang = math.atan2(y2 - y1, x2 - x1)
|
||||
bx, by = x2 - head * math.cos(ang), y2 - head * math.sin(ang)
|
||||
left = (bx - head * 0.5 * math.sin(ang), by + head * 0.5 * math.cos(ang))
|
||||
right = (bx + head * 0.5 * math.sin(ang), by - head * 0.5 * math.cos(ang))
|
||||
dash = f' stroke-dasharray="{el["dash"]}"' if el.get("dash") else ""
|
||||
return "\n".join([
|
||||
f'<line x1="{_n(x1)}" y1="{_n(y1)}" x2="{_n(bx)}" y2="{_n(by)}" '
|
||||
f'stroke="{color}" stroke-width="{_n(sw)}"{dash}/>',
|
||||
f'<polygon points="{_n(x2)},{_n(y2)} {_n(left[0])},{_n(left[1])} '
|
||||
f'{_n(right[0])},{_n(right[1])}" fill="{color}"/>',
|
||||
])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PNG:rsvg-convert 出几何层 + Pillow 出文字层(论文同款字体)
|
||||
# ---------------------------------------------------------------------------
|
||||
def _rasterize(svg_path: Path, png_path: Path, *, scale: float,
|
||||
rasterizer: str) -> None:
|
||||
exe = shutil.which(rasterizer)
|
||||
if not exe:
|
||||
raise RuntimeError(f"未找到 {rasterizer}:PNG 输出需要它(brew install librsvg)")
|
||||
cmd = [exe, "-f", "png", "-o", str(png_path), "--zoom", str(scale), str(svg_path)]
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"{rasterizer} 失败:{proc.stderr.strip()}")
|
||||
|
||||
|
||||
def _compose_text(fig: Figure, png_path: Path, *, scale: float) -> None:
|
||||
"""把文字层用 Pillow 重画在光栅图上:与基础几何同色同坐标,字体来自
|
||||
``docs/paper/fonts/``(缺失时回退系统字体)。"""
|
||||
from PIL import Image, ImageDraw, ImageFont # 可选依赖:仅生成图时导入
|
||||
|
||||
texts = [el for el in fig.elements if el["kind"] == "text"]
|
||||
if not texts:
|
||||
return
|
||||
img = Image.open(png_path).convert("RGBA")
|
||||
layer = Image.new("RGBA", img.size, (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(layer)
|
||||
cache: dict[tuple[str, float], Any] = {}
|
||||
for el in texts:
|
||||
key = (el["font"], round(el["size"] * scale, 2))
|
||||
if key not in cache:
|
||||
cache[key] = _load_font(el["font"], el["size"] * scale, ImageFont)
|
||||
font = cache[key]
|
||||
x, y = el["x"] * scale, (el["y"] + _baseline_offset(el)) * scale
|
||||
anchor = {"start": "ls", "middle": "ms", "end": "rs"}[el["anchor"]]
|
||||
draw.text((x, y), el["s"], font=font, fill=_rgba(el["color"], el.get("opacity", 1.0)),
|
||||
anchor=anchor)
|
||||
Image.alpha_composite(img, layer).convert("RGB").save(png_path, optimize=True)
|
||||
|
||||
|
||||
def _load_font(name: str, size: float, image_font: Any) -> Any:
|
||||
path = FONT_DIR / FONTS[name]
|
||||
if path.is_file():
|
||||
return image_font.truetype(str(path), max(6, int(round(size))))
|
||||
fallback = {
|
||||
"song": "/System/Library/Fonts/Supplemental/Songti.ttc",
|
||||
"song_bold": "/System/Library/Fonts/Supplemental/Songti.ttc",
|
||||
"hei": "/System/Library/Fonts/Supplemental/Songti.ttc",
|
||||
"hei_bold": "/System/Library/Fonts/Supplemental/Songti.ttc",
|
||||
"times": "/System/Library/Fonts/Supplemental/Times New Roman.ttf",
|
||||
"times_bold": "/System/Library/Fonts/Supplemental/Times New Roman Bold.ttf",
|
||||
}[name]
|
||||
return image_font.truetype(fallback, max(6, int(round(size))))
|
||||
|
||||
|
||||
def _rgba(color: str, opacity: float) -> tuple[int, int, int, int]:
|
||||
value = color.lstrip("#")
|
||||
return (int(value[0:2], 16), int(value[2:4], 16), int(value[4:6], 16),
|
||||
max(0, min(255, int(round(opacity * 255)))))
|
||||
|
||||
|
||||
def _estimate_width(text: str, size: float, font: str) -> float:
|
||||
"""粗略宽度估计(仅用于图例排版留白):CJK 按 1.0 em,西文按 0.5 em。"""
|
||||
wide = sum(1 for ch in text if ord(ch) > 0x2E80)
|
||||
narrow = len(text) - wide
|
||||
factor = 1.0 if font.startswith(("song", "hei")) else 0.98
|
||||
return (wide * size + narrow * size * 0.52) * factor
|
||||
|
||||
|
||||
def write_manifest(out_dir: str | Path, entries: Sequence[dict[str, Any]],
|
||||
command: str) -> Path:
|
||||
"""落盘 manifest.json:图清单 + 数据来源 + 复现命令。"""
|
||||
out = Path(out_dir)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
doc = {
|
||||
"generator": "scripts/build_docs_figures.py",
|
||||
"command": command,
|
||||
"palette": {k: v for k, v in PALETTE.items() if k != "series"},
|
||||
"fonts": {"dir": "docs/paper/fonts", "map": FONTS},
|
||||
"figures": list(entries),
|
||||
}
|
||||
path = out / "manifest.json"
|
||||
path.write_text(json.dumps(doc, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
return path
|
||||
Reference in New Issue
Block a user