Files
skills/minimax-pdf/scripts/render_body.py
shihao 6487becf60 Initial commit: add all skills files
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:52:49 +08:00

1053 lines
40 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
render_body.py — Build the inner-page PDF from tokens.json + content.json.
Usage:
python3 render_body.py --tokens tokens.json --content content.json --out body.pdf
Block types:
h1 h2 h3 Headings (h1 adds a full-width accent rule below)
body Justified prose paragraph
bullet Bullet list item (• prefix)
numbered Auto-numbered list item (resets when interrupted)
callout Highlighted insight box with left accent bar
table Data table with accent header + alternating rows
image Inline image from file path
figure Image with auto-numbered "Figure N:" caption
code Monospace code block with accent left border
math Display math formula via matplotlib mathtext
chart Bar / line / pie chart rendered via matplotlib
flowchart Process diagram rendered via matplotlib
bibliography Numbered reference list
divider Full-width accent rule
caption Small muted text (e.g., under a figure)
pagebreak Force a new page
spacer Vertical whitespace (pt field, default 12)
Exit codes: 0 success, 1 bad args/missing file, 2 missing dep, 3 render error
"""
import argparse
import io
import json
import os
import sys
import importlib.util
# ── Dependency bootstrap ───────────────────────────────────────────────────────
def ensure_deps():
missing = [p for p in ("reportlab", "pypdf")
if importlib.util.find_spec(p) is None]
if missing:
import subprocess
subprocess.check_call(
[sys.executable, "-m", "pip", "install",
"--break-system-packages", "-q"] + missing
)
ensure_deps()
from reportlab.platypus import (
BaseDocTemplate, PageTemplate, Frame,
Paragraph, Spacer, Table, TableStyle,
HRFlowable, PageBreak, Flowable, KeepTogether,
Preformatted, Image as RLImage,
)
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle
from reportlab.lib.colors import HexColor
from reportlab.lib.enums import TA_JUSTIFY, TA_CENTER
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
# ── Font registration ──────────────────────────────────────────────────────────
def register_fonts(tokens: dict):
"""Register TTF fonts from token font_paths if present."""
for name, fpath in tokens.get("font_paths", {}).items():
if os.path.exists(fpath):
try:
pdfmetrics.registerFont(TTFont(name, fpath))
except Exception:
pass
# ══════════════════════════════════════════════════════════════════════════════
# Custom Flowables
# ══════════════════════════════════════════════════════════════════════════════
class CalloutBox(Flowable):
"""Highlighted insight box: coloured background + 4px left accent bar."""
def __init__(self, text: str, style, accent: str, bg: str):
super().__init__()
self._para = Paragraph(text, style)
self._accent = HexColor(accent)
self._bg = HexColor(bg)
def wrap(self, aw, ah):
self._w = aw
_, ph = self._para.wrap(aw - 36, ah)
self._h = ph + 22
return aw, self._h
def draw(self):
c = self.canv
c.setFillColor(self._bg)
c.roundRect(0, 0, self._w, self._h, 5, fill=1, stroke=0)
c.setFillColor(self._accent)
c.rect(0, 0, 4, self._h, fill=1, stroke=0)
self._para.drawOn(c, 18, 11)
class BibliographyItem(Flowable):
"""Single hanging-indent bibliography entry rendered as [N] text."""
LABEL_W = 28
def __init__(self, ref_id: str, text: str, style, dark: str):
super().__init__()
self._id = ref_id
self._text = text
self._style = style
self._dark = HexColor(dark)
def wrap(self, aw, ah):
self._w = aw
self._para = Paragraph(self._text, self._style)
_, ph = self._para.wrap(aw - self.LABEL_W, ah)
self._h = ph + 4
return aw, self._h
def draw(self):
c = self.canv
c.setFillColor(self._dark)
c.setFont("Helvetica-Bold", 8.5)
c.drawString(0, self._h - 12, f"[{self._id}]")
self._para.drawOn(c, self.LABEL_W, 2)
# ══════════════════════════════════════════════════════════════════════════════
# Page template (header + footer)
# ══════════════════════════════════════════════════════════════════════════════
class BeautifulDoc(BaseDocTemplate):
def __init__(self, path: str, tokens: dict, **kw):
self._t = tokens
super().__init__(path, **kw)
fr = Frame(
self.leftMargin, self.bottomMargin,
self.width, self.height, id="body",
)
tmpl = PageTemplate(id="main", frames=fr, onPage=self._decorate)
self.addPageTemplates([tmpl])
def _decorate(self, canv, doc):
t = self._t
lm = doc.leftMargin
rm = doc.rightMargin
pw = doc.pagesize[0]
ph = doc.pagesize[1]
top = ph - doc.topMargin
canv.saveState()
# Header accent rule
canv.setStrokeColor(HexColor(t["accent"]))
canv.setLineWidth(1.5)
canv.line(lm, top + 12, pw - rm, top + 12)
# Header: title (left) + date (right)
canv.setFillColor(HexColor(t["muted"]))
canv.setFont(t["font_body_rl"], t["size_meta"])
canv.drawString(lm, top + 16, t["title"].upper())
canv.drawRightString(pw - rm, top + 16, t.get("date", ""))
# Footer rule
canv.setStrokeColor(HexColor("#DDDDDD"))
canv.setLineWidth(0.5)
canv.line(lm, doc.bottomMargin - 12, pw - rm, doc.bottomMargin - 12)
# Footer: author (left) + page number (right)
canv.setFillColor(HexColor(t["muted"]))
canv.setFont(t["font_body_rl"], t["size_meta"])
canv.drawString(lm, doc.bottomMargin - 22, t.get("author", ""))
canv.drawRightString(pw - rm, doc.bottomMargin - 22, str(doc.page))
canv.restoreState()
# ══════════════════════════════════════════════════════════════════════════════
# Style factory
# ══════════════════════════════════════════════════════════════════════════════
def make_styles(t: dict) -> dict:
hf = t["font_display_rl"]
bf = t["font_body_rl"]
bfb = t["font_body_b_rl"]
dk = t["body_text"]
d = t["dark"]
mu = t["muted"]
return {
"h1": ParagraphStyle("H1",
fontName=hf, fontSize=t["size_h1"],
leading=t["size_h1"] * 1.3,
textColor=HexColor(d),
spaceBefore=t["section_gap"], spaceAfter=4,
),
"h2": ParagraphStyle("H2",
fontName=hf, fontSize=t["size_h2"],
leading=t["size_h2"] * 1.4,
textColor=HexColor(d),
spaceBefore=18, spaceAfter=5,
),
"h3": ParagraphStyle("H3",
fontName=bfb, fontSize=t["size_h3"],
leading=t["size_h3"] * 1.5,
textColor=HexColor(d),
spaceBefore=12, spaceAfter=3,
),
"body": ParagraphStyle("Body",
fontName=bf, fontSize=t["size_body"],
leading=t["line_gap"],
textColor=HexColor(dk),
spaceAfter=t["para_gap"], alignment=TA_JUSTIFY,
),
"bullet": ParagraphStyle("Bullet",
fontName=bf, fontSize=t["size_body"],
leading=t["line_gap"] - 1,
textColor=HexColor(dk),
spaceAfter=4, leftIndent=14,
),
"numbered": ParagraphStyle("Numbered",
fontName=bf, fontSize=t["size_body"],
leading=t["line_gap"] - 1,
textColor=HexColor(dk),
spaceAfter=4, leftIndent=22, firstLineIndent=-22,
),
"callout": ParagraphStyle("Callout",
fontName=bfb, fontSize=t["size_body"] + 0.5, leading=16,
textColor=HexColor(d),
),
"caption": ParagraphStyle("Caption",
fontName=bf, fontSize=t["size_caption"], leading=13,
textColor=HexColor(mu), spaceAfter=6,
alignment=TA_CENTER,
),
"table_header": ParagraphStyle("TblH",
fontName=bfb, fontSize=9.5, leading=13,
textColor=HexColor("#FFFFFF"),
),
"table_cell": ParagraphStyle("TblC",
fontName=bf, fontSize=9.5, leading=13,
textColor=HexColor(dk),
),
"code": ParagraphStyle("Code",
fontName="Courier", fontSize=8.5, leading=12.5,
textColor=HexColor(dk),
),
"code_lang": ParagraphStyle("CodeLang",
fontName="Courier", fontSize=7, leading=10,
textColor=HexColor(mu),
),
"bib": ParagraphStyle("Bib",
fontName=bf, fontSize=9, leading=14,
textColor=HexColor(dk),
),
"bib_title": ParagraphStyle("BibTitle",
fontName=hf, fontSize=t["size_h2"],
leading=t["size_h2"] * 1.4,
textColor=HexColor(d),
spaceBefore=t["section_gap"], spaceAfter=8,
),
"math_fallback": ParagraphStyle("MathFb",
fontName="Courier", fontSize=9, leading=13,
textColor=HexColor(dk),
),
"eq_label": ParagraphStyle("EqLabel",
fontName="Helvetica", fontSize=9, leading=12,
textColor=HexColor(mu),
),
}
# ══════════════════════════════════════════════════════════════════════════════
# Shared helpers
# ══════════════════════════════════════════════════════════════════════════════
def _divider(accent: str) -> HRFlowable:
return HRFlowable(
width="100%", thickness=1.2,
color=HexColor(accent),
spaceBefore=14, spaceAfter=14,
)
def _image_from_bytes(png_bytes: bytes, usable_w: float,
max_frac: float = 0.88) -> RLImage:
"""Create a scaled RLImage from PNG bytes, bounded to max_frac of usable_w."""
img = RLImage(io.BytesIO(png_bytes))
max_w = usable_w * max_frac
if img.drawWidth > max_w:
scale = max_w / img.drawWidth
img.drawWidth = max_w
img.drawHeight = img.drawHeight * scale
return img
# ══════════════════════════════════════════════════════════════════════════════
# PNG renderers (matplotlib)
# ══════════════════════════════════════════════════════════════════════════════
def _render_math_png(expr: str, dpi: int = 180) -> bytes | None:
"""
Render a LaTeX math expression via matplotlib mathtext.
No LaTeX binary required — uses matplotlib's built-in math parser.
Supports: fractions (\\frac), integrals (\\int), sums (\\sum),
Greek letters, sub/superscripts, etc.
"""
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
fig = plt.figure(figsize=(8, 1.2))
fig.patch.set_facecolor("white")
ax = fig.add_axes([0, 0, 1, 1])
ax.set_axis_off()
ax.set_facecolor("white")
ax.text(0.5, 0.5, f"${expr}$",
fontsize=16, ha="center", va="center",
transform=ax.transAxes)
buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight",
facecolor="white", pad_inches=0.1)
plt.close(fig)
buf.seek(0)
return buf.read()
except Exception:
return None
def _render_chart_png(item: dict, accent: str, dpi: int = 150) -> bytes | None:
"""
Render bar / line / pie chart to PNG using matplotlib.
Required fields:
chart_type "bar" | "line" | "pie" (default "bar")
labels list of category strings
datasets list of {label?, values: list[number]}
Optional fields:
title chart title
x_label X-axis label
y_label Y-axis label
"""
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import matplotlib.colors as mcolors
import colorsys
import numpy as np
chart_type = item.get("chart_type", "bar")
title_text = item.get("title", "")
labels = item.get("labels", [])
datasets = item.get("datasets", [])
# Derive a consistent palette from the document accent color
r, g, b = mcolors.to_rgb(accent)
h, s, v = colorsys.rgb_to_hsv(r, g, b)
palette = [
colorsys.hsv_to_rgb(
(h + i * 0.13) % 1.0,
max(0.35, s - i * 0.08),
min(0.92, v + i * 0.04),
)
for i in range(max(len(datasets), 1))
]
fig, ax = plt.subplots(figsize=(7, 3.6), dpi=dpi)
fig.patch.set_facecolor("white")
ax.set_facecolor("white")
if chart_type == "bar":
x = np.arange(len(labels))
n = max(len(datasets), 1)
width = 0.68 / n
for i, ds in enumerate(datasets):
offset = (i - (n - 1) / 2) * width
ax.bar(x + offset, ds.get("values", []), width * 0.88,
label=ds.get("label", f"Series {i+1}"),
color=palette[i % len(palette)], edgecolor="none")
ax.set_xticks(x)
ax.set_xticklabels(labels, fontsize=8.5)
ax.yaxis.grid(True, alpha=0.25, color="#CCCCCC", linewidth=0.7)
ax.set_axisbelow(True)
if item.get("x_label"):
ax.set_xlabel(item["x_label"], fontsize=8.5)
if item.get("y_label"):
ax.set_ylabel(item["y_label"], fontsize=8.5)
elif chart_type == "line":
x = np.arange(len(labels))
for i, ds in enumerate(datasets):
ax.plot(x, ds.get("values", []), marker="o", markersize=3.5,
label=ds.get("label", f"Series {i+1}"),
color=palette[i % len(palette)], linewidth=1.8)
ax.set_xticks(x)
ax.set_xticklabels(labels, fontsize=8.5)
ax.yaxis.grid(True, alpha=0.25, color="#CCCCCC", linewidth=0.7)
ax.set_axisbelow(True)
if item.get("x_label"):
ax.set_xlabel(item["x_label"], fontsize=8.5)
if item.get("y_label"):
ax.set_ylabel(item["y_label"], fontsize=8.5)
elif chart_type == "pie":
vals = datasets[0].get("values", []) if datasets else []
colors = [
colorsys.hsv_to_rgb(
(h + i * 0.11) % 1.0,
max(0.30, s - i * 0.06),
min(0.92, v + i * 0.03),
)
for i in range(len(vals))
]
ax.pie(vals, labels=labels, colors=colors,
autopct="%1.1f%%", pctdistance=0.82,
wedgeprops=dict(edgecolor="white", linewidth=1.4),
textprops=dict(fontsize=8.5))
# Shared styling
for spine in ax.spines.values():
spine.set_linewidth(0.5)
spine.set_color("#CCCCCC")
ax.tick_params(axis="both", length=0, labelsize=8.5)
if title_text:
ax.set_title(title_text, fontsize=10, pad=8,
color="#333333", fontweight="bold")
if len(datasets) > 1 and chart_type != "pie":
ax.legend(frameon=False, fontsize=8, loc="upper right")
plt.tight_layout(pad=0.4)
buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight",
facecolor="white", pad_inches=0.06)
plt.close(fig)
buf.seek(0)
return buf.read()
except Exception:
return None
def _render_flowchart_png(item: dict, accent: str, dark: str,
muted: str, dpi: int = 130) -> bytes | None:
"""
Render a top-to-bottom flowchart using matplotlib patches and arrows.
Node schema: {id, label, shape?}
shape: "rect" (default) | "diamond" | "oval" | "parallelogram"
Edge schema: {from, to, label?}
Forward edges (to a later node) draw straight arrows.
Back edges (to an earlier node) draw a curved arc to the right.
"""
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import matplotlib.patches as mpatch
from matplotlib.patches import FancyBboxPatch
import matplotlib.colors as mcolors
nodes_list = item.get("nodes", [])
edges = item.get("edges", [])
if not nodes_list:
return None
nodes = {n["id"]: n for n in nodes_list}
order = {n["id"]: i for i, n in enumerate(nodes_list)}
n_nodes = len(nodes_list)
BOX_W = 4.2
BOX_H = 0.58
STEP_Y = 1.25
CX = 5.0
fig_h = max(3.5, n_nodes * STEP_Y + 0.8)
fig, ax = plt.subplots(figsize=(6, fig_h), dpi=dpi)
fig.patch.set_facecolor("white")
ax.set_facecolor("white")
ax.set_xlim(0, 10)
ax.set_ylim(-0.6, n_nodes * STEP_Y + 0.2)
ax.invert_yaxis()
ax.axis("off")
acc_rgb = mcolors.to_rgb(accent)
dark_rgb = mcolors.to_rgb(dark)
muted_rgb = mcolors.to_rgb(muted)
# Node positions (cx, cy) — preserves input order
pos = {nid: (CX, i * STEP_Y) for nid, i in order.items()}
# ── Draw edges (behind nodes) ──────────────────────────────────────────
for edge in edges:
src, dst = edge.get("from"), edge.get("to")
if src not in pos or dst not in pos:
continue
x1, y1 = pos[src]
x2, y2 = pos[dst]
lbl = edge.get("label", "")
src_shape = nodes.get(src, {}).get("shape", "rect")
dst_shape = nodes.get(dst, {}).get("shape", "rect")
dy_src = BOX_H * (0.80 if src_shape == "diamond" else 0.50)
dy_dst = BOX_H * (0.80 if dst_shape == "diamond" else 0.50)
y_start = y1 + dy_src
y_end = y2 - dy_dst
# Forward edge: straight; back-edge: curved arc
conn = "arc3,rad=0.0" if y_end > y_start + 0.01 else "arc3,rad=0.42"
ax.annotate("",
xy=(x2, y_end), xytext=(x1, y_start),
arrowprops=dict(
arrowstyle="-|>", color=muted_rgb,
lw=1.0, mutation_scale=10,
connectionstyle=conn,
),
)
if lbl:
mid_x = (x1 + x2) / 2 + 0.28
mid_y = (y_start + y_end) / 2
ax.text(mid_x, mid_y, lbl, fontsize=7.5,
color=muted_rgb, ha="left", va="center")
# ── Draw nodes (in front of edges) ────────────────────────────────────
for nid, (cx, cy) in pos.items():
node = nodes[nid]
shape = node.get("shape", "rect")
label = node.get("label", nid)
left = cx - BOX_W / 2
bot = cy - BOX_H / 2
if shape in ("oval", "terminal"):
el = mpatch.Ellipse(
(cx, cy), BOX_W * 0.78, BOX_H * 1.15,
facecolor=acc_rgb, edgecolor=acc_rgb, linewidth=0,
)
ax.add_patch(el)
ax.text(cx, cy, label, ha="center", va="center",
fontsize=8.5, fontweight="bold", color="white")
elif shape == "diamond":
d = BOX_W * 0.44
diamond = plt.Polygon(
[(cx, cy - d * 0.72), (cx + d, cy),
(cx, cy + d * 0.72), (cx - d, cy)],
facecolor="#FFFCF0",
edgecolor=accent, linewidth=1.2,
)
ax.add_patch(diamond)
ax.text(cx, cy, label, ha="center", va="center",
fontsize=8, color=dark_rgb)
elif shape == "parallelogram":
skew = 0.30
para = plt.Polygon(
[(left + skew, bot), (left + BOX_W + skew, bot),
(left + BOX_W, bot + BOX_H), (left, bot + BOX_H)],
facecolor="white",
edgecolor=accent, linewidth=1.2,
)
ax.add_patch(para)
ax.text(cx, cy, label, ha="center", va="center",
fontsize=8.5, color=dark_rgb)
else: # rect (default)
rect = FancyBboxPatch(
(left, bot), BOX_W, BOX_H,
boxstyle="round,pad=0.04",
facecolor="white",
edgecolor=accent, linewidth=1.2,
)
ax.add_patch(rect)
ax.text(cx, cy, label, ha="center", va="center",
fontsize=8.5, color=dark_rgb)
plt.tight_layout(pad=0.2)
buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight",
facecolor="white", pad_inches=0.08)
plt.close(fig)
buf.seek(0)
return buf.read()
except Exception:
return None
# ══════════════════════════════════════════════════════════════════════════════
# Block renderers
#
# All functions share the same signature:
# _add_XXX(story: list, item: dict, ctx: dict)
#
# ctx keys:
# tokens dict design tokens from palette.py
# styles dict ParagraphStyle objects from make_styles()
# usable_w float usable page width in points
# acc str accent hex color
# acc_lt str light accent hex color
# mu str muted hex color
# dark str dark hex color
# figure_n int auto-incrementing figure counter (mutable)
# numbered_n int auto-incrementing list counter (mutable)
# ══════════════════════════════════════════════════════════════════════════════
def _add_heading(story: list, item: dict, ctx: dict, level: int):
key = f"h{level}"
para = Paragraph(item["text"], ctx["styles"][key])
if level == 1:
story.append(KeepTogether([para, _divider(ctx["acc"])]))
else:
story.append(para)
def _add_body(story: list, item: dict, ctx: dict):
story.append(Paragraph(item["text"], ctx["styles"]["body"]))
def _add_bullet(story: list, item: dict, ctx: dict):
story.append(Paragraph(
f"\u2022\u2002{item['text']}", ctx["styles"]["bullet"]
))
def _add_numbered(story: list, item: dict, ctx: dict):
ctx["numbered_n"] += 1
story.append(Paragraph(
f"{ctx['numbered_n']}.\u2002{item['text']}",
ctx["styles"]["numbered"],
))
def _add_callout(story: list, item: dict, ctx: dict):
story.append(Spacer(1, 8))
story.append(CalloutBox(
item["text"], ctx["styles"]["callout"], ctx["acc"], ctx["acc_lt"]
))
story.append(Spacer(1, 8))
def _add_table(story: list, item: dict, ctx: dict):
t = ctx["tokens"]
styles = ctx["styles"]
usable_w = ctx["usable_w"]
acc = ctx["acc"]
acc_lt = ctx["acc_lt"]
headers = [Paragraph(h, styles["table_header"]) for h in item["headers"]]
rows = [
[Paragraph(str(c), styles["table_cell"]) for c in row]
for row in item.get("rows", [])
]
n_cols = len(item["headers"])
# Optional col_widths as fractions summing to 1.0
if "col_widths" in item and len(item["col_widths"]) == n_cols:
col_w = [usable_w * f for f in item["col_widths"]]
else:
col_w = [usable_w / n_cols] * n_cols
tbl = Table([headers] + rows, colWidths=col_w)
tbl.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), HexColor(acc)),
("TEXTCOLOR", (0, 0), (-1, 0), HexColor("#FFFFFF")),
("FONTNAME", (0, 0), (-1, 0), t["font_body_b_rl"]),
("FONTSIZE", (0, 0), (-1, 0), 9.5),
("TOPPADDING", (0, 0), (-1, 0), 7),
("BOTTOMPADDING", (0, 0), (-1, 0), 7),
("ROWBACKGROUNDS", (0, 1), (-1, -1),
[HexColor("#FFFFFF"), HexColor(acc_lt)]),
("FONTNAME", (0, 1), (-1, -1), t["font_body_rl"]),
("FONTSIZE", (0, 1), (-1, -1), 9.5),
("TOPPADDING", (0, 1), (-1, -1), 6),
("BOTTOMPADDING", (0, 1), (-1, -1), 6),
("LEFTPADDING", (0, 0), (-1, -1), 10),
("RIGHTPADDING", (0, 0), (-1, -1), 10),
("BOX", (0, 0), (-1, -1), 0.5, HexColor("#CCCCCC")),
("LINEBELOW", (0, 0), (-1, 0), 1.2, HexColor(acc)),
("TEXTCOLOR", (0, 1), (-1, -1), HexColor(t["body_text"])),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
]))
story.append(tbl)
if item.get("caption"):
story.append(Spacer(1, 4))
story.append(Paragraph(item["caption"], styles["caption"]))
story.append(Spacer(1, 12))
def _add_image(story: list, item: dict, ctx: dict):
path = str(item.get("path", item.get("src", "")))
if not os.path.exists(path):
story.append(Paragraph(
f"[Image not found: {path}]", ctx["styles"]["caption"]
))
return
try:
img = RLImage(path)
uw = ctx["usable_w"]
if img.drawWidth > uw:
scale = uw / img.drawWidth
img.drawWidth = uw
img.drawHeight = img.drawHeight * scale
story.append(img)
except Exception as e:
story.append(Paragraph(f"[Image error: {e}]", ctx["styles"]["caption"]))
return
if item.get("caption"):
story.append(Spacer(1, 4))
story.append(Paragraph(item["caption"], ctx["styles"]["caption"]))
story.append(Spacer(1, 8))
def _add_figure(story: list, item: dict, ctx: dict):
"""Like image but auto-numbers the caption as 'Figure N: ...'."""
ctx["figure_n"] += 1
raw_cap = item.get("caption", "")
caption = f"Figure {ctx['figure_n']}: {raw_cap}" if raw_cap \
else f"Figure {ctx['figure_n']}"
_add_image(story, {**item, "caption": caption}, ctx)
def _add_code(story: list, item: dict, ctx: dict):
acc = ctx["acc"]
acc_lt = ctx["acc_lt"]
mu = ctx["mu"]
uw = ctx["usable_w"]
lang = item.get("language", "")
pre = Preformatted(item.get("text", ""), ctx["styles"]["code"])
tbl = Table([[pre]], colWidths=[uw])
tbl.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, -1), HexColor(acc_lt)),
("LINEBEFORE", (0, 0), ( 0, -1), 3, HexColor(acc)),
("BOX", (0, 0), (-1, -1), 0.5, HexColor(mu)),
("LEFTPADDING", (0, 0), (-1, -1), 14),
("RIGHTPADDING", (0, 0), (-1, -1), 10),
("TOPPADDING", (0, 0), (-1, -1), 8),
("BOTTOMPADDING", (0, 0), (-1, -1), 8),
]))
story.append(Spacer(1, 6))
if lang:
story.append(Paragraph(lang.upper(), ctx["styles"]["code_lang"]))
story.append(tbl)
story.append(Spacer(1, 6))
def _add_math(story: list, item: dict, ctx: dict):
"""
Display math block.
Fields:
text LaTeX math expression (without enclosing $)
label optional equation label, e.g. "(1)" — displayed right-aligned
caption optional caption below the formula
Example:
{"type": "math", "text": "E = mc^2", "label": "(1)"}
{"type": "math", "text": "\\\\int_0^\\\\infty e^{-x^2}\\\\,dx = \\\\frac{\\\\sqrt{\\\\pi}}{2}"}
"""
acc = ctx["acc"]
acc_lt = ctx["acc_lt"]
uw = ctx["usable_w"]
expr = item.get("text", "").strip()
label = item.get("label", "").strip()
png = _render_math_png(expr)
if png is None:
# Graceful text fallback if matplotlib unavailable
story.append(Spacer(1, 6))
pre = Preformatted(f" {expr}", ctx["styles"]["math_fallback"])
tbl = Table([[pre]], colWidths=[uw])
tbl.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, -1), HexColor(acc_lt)),
("LEFTPADDING", (0, 0), (-1, -1), 14),
("RIGHTPADDING", (0, 0), (-1, -1), 14),
("TOPPADDING", (0, 0), (-1, -1), 8),
("BOTTOMPADDING", (0, 0), (-1, -1), 8),
]))
story.append(tbl)
story.append(Spacer(1, 6))
return
img = _image_from_bytes(png, uw, max_frac=0.72)
story.append(Spacer(1, 10))
if label:
label_w = 44
formula_w = uw - label_w
lbl_para = Paragraph(label, ctx["styles"]["eq_label"])
row_tbl = Table([[img, lbl_para]], colWidths=[formula_w, label_w])
row_tbl.setStyle(TableStyle([
("ALIGN", (0, 0), (0, 0), "CENTER"),
("ALIGN", (1, 0), (1, 0), "RIGHT"),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
]))
story.append(row_tbl)
else:
row_tbl = Table([[img]], colWidths=[uw])
row_tbl.setStyle(TableStyle([
("ALIGN", (0, 0), (-1, -1), "CENTER"),
]))
story.append(row_tbl)
if item.get("caption"):
story.append(Spacer(1, 4))
story.append(Paragraph(item["caption"], ctx["styles"]["caption"]))
story.append(Spacer(1, 10))
def _add_chart(story: list, item: dict, ctx: dict):
"""
Render a chart (bar / line / pie) via matplotlib.
Fields:
chart_type "bar" | "line" | "pie" (default "bar")
title chart title
labels list of category strings
datasets list of {label?, values: list[number]}
x_label X-axis label (bar/line)
y_label Y-axis label (bar/line)
caption caption text below chart
figure bool (default true) — prefix caption with "Figure N:"
"""
uw = ctx["usable_w"]
png = _render_chart_png(item, ctx["acc"])
if png is None:
story.append(Paragraph(
"[Chart: install matplotlib to render — pip install matplotlib]",
ctx["styles"]["caption"],
))
return
img = _image_from_bytes(png, uw, max_frac=0.95)
story.append(Spacer(1, 8))
row_tbl = Table([[img]], colWidths=[uw])
row_tbl.setStyle(TableStyle([("ALIGN", (0, 0), (-1, -1), "CENTER")]))
story.append(row_tbl)
raw_cap = item.get("caption", "")
use_fig = item.get("figure", True)
if raw_cap or use_fig:
ctx["figure_n"] += 1
prefix = f"Figure {ctx['figure_n']}: " if use_fig else ""
story.append(Spacer(1, 4))
story.append(Paragraph(prefix + raw_cap, ctx["styles"]["caption"]))
story.append(Spacer(1, 10))
def _add_flowchart(story: list, item: dict, ctx: dict):
"""
Render a flowchart via matplotlib.
Fields:
nodes list of {id, label, shape?}
shape: "rect" (default) | "diamond" | "oval" | "parallelogram"
edges list of {from, to, label?}
caption caption below the diagram
figure bool (default true) — prefix caption with "Figure N:"
"""
uw = ctx["usable_w"]
png = _render_flowchart_png(item, ctx["acc"], ctx["dark"], ctx["mu"])
if png is None:
story.append(Paragraph(
"[Flowchart: install matplotlib to render — pip install matplotlib]",
ctx["styles"]["caption"],
))
return
img = _image_from_bytes(png, uw, max_frac=0.78)
story.append(Spacer(1, 8))
row_tbl = Table([[img]], colWidths=[uw])
row_tbl.setStyle(TableStyle([("ALIGN", (0, 0), (-1, -1), "CENTER")]))
story.append(row_tbl)
raw_cap = item.get("caption", "")
use_fig = item.get("figure", True)
if raw_cap or use_fig:
ctx["figure_n"] += 1
prefix = f"Figure {ctx['figure_n']}: " if use_fig else ""
story.append(Spacer(1, 4))
story.append(Paragraph(prefix + raw_cap, ctx["styles"]["caption"]))
story.append(Spacer(1, 10))
def _add_bibliography(story: list, item: dict, ctx: dict):
"""
Numbered reference list with hanging indent.
Fields:
title section heading (default "References"); set "" to suppress
items list of {id, text}
Example:
{"type": "bibliography",
"items": [
{"id": "1", "text": "Smith, J. (2023). Title. Journal, 10(2), 115."},
{"id": "2", "text": "Doe, A. (2022). Another title. Publisher."}
]}
"""
heading = item.get("title", "References")
if heading:
story.append(KeepTogether([
Paragraph(heading, ctx["styles"]["bib_title"]),
_divider(ctx["acc"]),
]))
for ref in item.get("items", []):
story.append(Spacer(1, 4))
story.append(BibliographyItem(
str(ref.get("id", "")),
ref.get("text", ""),
ctx["styles"]["bib"],
ctx["dark"],
))
# ══════════════════════════════════════════════════════════════════════════════
# Story builder
# ══════════════════════════════════════════════════════════════════════════════
# Block types that break a numbered list sequence
_RESETS_NUMBERED = frozenset({
"h1", "h2", "h3", "body", "bullet", "callout", "table",
"image", "figure", "code", "math", "chart", "flowchart",
"bibliography", "divider", "caption", "pagebreak", "spacer",
})
def build_story(content: list, tokens: dict, styles: dict) -> list:
usable_w = A4[0] - tokens["margin_left"] - tokens["margin_right"]
ctx: dict = {
"tokens": tokens,
"styles": styles,
"usable_w": usable_w,
"acc": tokens["accent"],
"acc_lt": tokens["accent_lt"],
"mu": tokens["muted"],
"dark": tokens["dark"],
"figure_n": 0,
"numbered_n": 0,
}
story: list = []
for item in content:
kind = item.get("type", "body")
if kind in _RESETS_NUMBERED:
ctx["numbered_n"] = 0
if kind == "h1": _add_heading(story, item, ctx, 1)
elif kind == "h2": _add_heading(story, item, ctx, 2)
elif kind == "h3": _add_heading(story, item, ctx, 3)
elif kind == "body": _add_body(story, item, ctx)
elif kind == "bullet": _add_bullet(story, item, ctx)
elif kind == "numbered": _add_numbered(story, item, ctx)
elif kind == "callout": _add_callout(story, item, ctx)
elif kind == "table": _add_table(story, item, ctx)
elif kind == "image": _add_image(story, item, ctx)
elif kind == "figure": _add_figure(story, item, ctx)
elif kind == "code": _add_code(story, item, ctx)
elif kind == "math": _add_math(story, item, ctx)
elif kind == "chart": _add_chart(story, item, ctx)
elif kind == "flowchart": _add_flowchart(story, item, ctx)
elif kind == "bibliography": _add_bibliography(story, item, ctx)
elif kind == "divider": story.append(_divider(ctx["acc"]))
elif kind == "caption":
story.append(Paragraph(item["text"], styles["caption"]))
elif kind == "pagebreak": story.append(PageBreak())
elif kind == "spacer": story.append(Spacer(1, item.get("pt", 12)))
return story
# ══════════════════════════════════════════════════════════════════════════════
# Main build
# ══════════════════════════════════════════════════════════════════════════════
def build(tokens: dict, content: list, out_path: str) -> dict:
register_fonts(tokens)
styles = make_styles(tokens)
doc = BeautifulDoc(
out_path, tokens,
pagesize=A4,
leftMargin=tokens["margin_left"],
rightMargin=tokens["margin_right"],
topMargin=tokens["margin_top"],
bottomMargin=tokens["margin_bottom"],
)
doc.build(build_story(content, tokens, styles))
size = os.path.getsize(out_path)
return {"status": "ok", "out": out_path, "size_kb": size // 1024}
# ══════════════════════════════════════════════════════════════════════════════
# CLI
# ══════════════════════════════════════════════════════════════════════════════
def main():
parser = argparse.ArgumentParser(
description="Render body PDF from tokens.json + content.json"
)
parser.add_argument("--tokens", default="tokens.json")
parser.add_argument("--content", default="content.json")
parser.add_argument("--out", default="body.pdf")
args = parser.parse_args()
for fpath in (args.tokens, args.content):
if not os.path.exists(fpath):
print(
json.dumps({"status": "error",
"error": f"File not found: {fpath}"}),
file=sys.stderr,
)
sys.exit(1)
with open(args.tokens, encoding="utf-8") as f:
tokens = json.load(f)
with open(args.content, encoding="utf-8") as f:
content = json.load(f)
try:
result = build(tokens, content, args.out)
print(json.dumps(result))
except Exception as e:
import traceback
print(
json.dumps({
"status": "error",
"error": str(e),
"trace": traceback.format_exc(),
}),
file=sys.stderr,
)
sys.exit(3)
if __name__ == "__main__":
main()