| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001 |
- #!/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
- # ══════════════════════════════════════════════════════════════════════════════
- 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.
- """
- 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.
- """
- 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.
- """
- 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.
- """
- 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()
|