dimcode-darwin-x64 0.1.1-beta.0 → 0.1.2-beta.1

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.
Files changed (148) hide show
  1. package/bin/dimcode +0 -0
  2. package/bin/runtime/sandbox/dim-sandbox-runner +0 -0
  3. package/bin/runtime/sandbox/manifest.json +15 -0
  4. package/bin/skills-assets/deep-investigate/SKILL.md +101 -0
  5. package/bin/skills-assets/deep-investigate/references/prompts.md +75 -0
  6. package/bin/skills-assets/deep-investigate/references/templates.md +73 -0
  7. package/bin/skills-assets/deep-investigate/references/thinking-tools.md +36 -0
  8. package/bin/skills-assets/docs-sprint/SKILL.md +73 -0
  9. package/bin/skills-assets/docs-sprint/agents/openai.yaml +4 -0
  10. package/bin/skills-assets/docs-sprint/references/contract-discipline.md +30 -0
  11. package/bin/skills-assets/docs-sprint/references/delivery-plan.md +162 -0
  12. package/bin/skills-assets/docs-sprint/references/documentation-system.md +109 -0
  13. package/bin/skills-assets/docs-sprint/references/ui-layout.md +73 -0
  14. package/bin/skills-assets/docs-sprint/references/worktree-guide.md +45 -0
  15. package/bin/skills-assets/docx/SKILL.md +273 -0
  16. package/bin/skills-assets/docx/assets/styles/academic_styles.xml +250 -0
  17. package/bin/skills-assets/docx/assets/styles/corporate_styles.xml +284 -0
  18. package/bin/skills-assets/docx/assets/styles/default_styles.xml +449 -0
  19. package/bin/skills-assets/docx/assets/xsd/aesthetic-rules.xsd +470 -0
  20. package/bin/skills-assets/docx/assets/xsd/business-rules.xsd +130 -0
  21. package/bin/skills-assets/docx/assets/xsd/common-types.xsd +159 -0
  22. package/bin/skills-assets/docx/assets/xsd/wml-subset.xsd +589 -0
  23. package/bin/skills-assets/docx/references/cjk_typography.md +357 -0
  24. package/bin/skills-assets/docx/references/cjk_university_template_guide.md +184 -0
  25. package/bin/skills-assets/docx/references/comments_guide.md +191 -0
  26. package/bin/skills-assets/docx/references/design_good_bad_examples.md +829 -0
  27. package/bin/skills-assets/docx/references/design_principles.md +819 -0
  28. package/bin/skills-assets/docx/references/openxml_element_order.md +308 -0
  29. package/bin/skills-assets/docx/references/openxml_encyclopedia_part1.md +4061 -0
  30. package/bin/skills-assets/docx/references/openxml_encyclopedia_part2.md +2820 -0
  31. package/bin/skills-assets/docx/references/openxml_encyclopedia_part3.md +3381 -0
  32. package/bin/skills-assets/docx/references/openxml_namespaces.md +82 -0
  33. package/bin/skills-assets/docx/references/openxml_units.md +72 -0
  34. package/bin/skills-assets/docx/references/scenario_a_create.md +284 -0
  35. package/bin/skills-assets/docx/references/scenario_b_edit_content.md +295 -0
  36. package/bin/skills-assets/docx/references/scenario_c_apply_template.md +456 -0
  37. package/bin/skills-assets/docx/references/track_changes_guide.md +200 -0
  38. package/bin/skills-assets/docx/references/troubleshooting.md +506 -0
  39. package/bin/skills-assets/docx/references/typography_guide.md +294 -0
  40. package/bin/skills-assets/docx/references/xsd_validation_guide.md +158 -0
  41. package/bin/skills-assets/docx/scripts/doc_to_docx.sh +40 -0
  42. package/bin/skills-assets/docx/scripts/docx_preview.sh +37 -0
  43. package/bin/skills-assets/docx/scripts/dotnet/Docx.Cli/Docx.Cli.csproj +19 -0
  44. package/bin/skills-assets/docx/scripts/dotnet/Docx.Cli/Program.cs +18 -0
  45. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Commands/AnalyzeCommand.cs +147 -0
  46. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Commands/ApplyTemplateCommand.cs +322 -0
  47. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Commands/CreateCommand.cs +324 -0
  48. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Commands/DiffCommand.cs +155 -0
  49. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Commands/EditContentCommand.cs +487 -0
  50. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Commands/FixOrderCommand.cs +108 -0
  51. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Commands/MergeRunsCommand.cs +122 -0
  52. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Commands/ValidateCommand.cs +107 -0
  53. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Docx.Core.csproj +15 -0
  54. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/OpenXml/CommentSynchronizer.cs +169 -0
  55. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/OpenXml/ElementOrder.cs +80 -0
  56. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/OpenXml/NamespaceConstants.cs +42 -0
  57. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/OpenXml/RunMerger.cs +81 -0
  58. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/OpenXml/StyleAnalyzer.cs +81 -0
  59. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/OpenXml/TrackChangesHelper.cs +99 -0
  60. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/OpenXml/UnitConverter.cs +23 -0
  61. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Samples/AestheticRecipeSamples.cs +1832 -0
  62. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Samples/AestheticRecipeSamples_Batch1.cs +910 -0
  63. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Samples/AestheticRecipeSamples_Batch2.cs +999 -0
  64. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Samples/AestheticRecipeSamples_Batch3.cs +1048 -0
  65. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Samples/AestheticRecipeSamples_Batch4.cs +1038 -0
  66. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Samples/CharacterFormattingSamples.cs +1020 -0
  67. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Samples/DocumentCreationSamples.cs +1121 -0
  68. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Samples/FieldAndTocSamples.cs +624 -0
  69. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Samples/FootnoteAndCommentSamples.cs +675 -0
  70. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Samples/HeaderFooterSamples.cs +838 -0
  71. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Samples/ImageSamples.cs +917 -0
  72. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Samples/ListAndNumberingSamples.cs +826 -0
  73. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Samples/ParagraphFormattingSamples.cs +1199 -0
  74. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Samples/StyleSystemSamples.cs +1487 -0
  75. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Samples/TableSamples.cs +1163 -0
  76. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Samples/TrackChangesSamples.cs +595 -0
  77. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Typography/CjkHelper.cs +39 -0
  78. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Typography/FontDefaults.cs +24 -0
  79. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Typography/PageSizes.cs +20 -0
  80. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Validation/BusinessRuleValidator.cs +224 -0
  81. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Validation/GateCheckValidator.cs +148 -0
  82. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Validation/ValidationResult.cs +23 -0
  83. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Validation/XsdValidator.cs +69 -0
  84. package/bin/skills-assets/docx/scripts/dotnet/Docx.slnx +4 -0
  85. package/bin/skills-assets/docx/scripts/env_check.sh +196 -0
  86. package/bin/skills-assets/docx/scripts/setup.ps1 +274 -0
  87. package/bin/skills-assets/docx/scripts/setup.sh +504 -0
  88. package/bin/skills-assets/pdf/README.md +222 -0
  89. package/bin/skills-assets/pdf/SKILL.md +191 -0
  90. package/bin/skills-assets/pdf/design/design.md +381 -0
  91. package/bin/skills-assets/pdf/scripts/cover.py +1579 -0
  92. package/bin/skills-assets/pdf/scripts/fill_inspect.py +200 -0
  93. package/bin/skills-assets/pdf/scripts/fill_write.py +242 -0
  94. package/bin/skills-assets/pdf/scripts/make.sh +491 -0
  95. package/bin/skills-assets/pdf/scripts/merge.py +112 -0
  96. package/bin/skills-assets/pdf/scripts/palette.py +521 -0
  97. package/bin/skills-assets/pdf/scripts/reformat_parse.py +374 -0
  98. package/bin/skills-assets/pdf/scripts/render_body.py +1052 -0
  99. package/bin/skills-assets/pdf/scripts/render_cover.js +111 -0
  100. package/bin/skills-assets/pptx-generator/SKILL.md +248 -0
  101. package/bin/skills-assets/pptx-generator/references/design-system.md +392 -0
  102. package/bin/skills-assets/pptx-generator/references/editing.md +162 -0
  103. package/bin/skills-assets/pptx-generator/references/pitfalls.md +112 -0
  104. package/bin/skills-assets/pptx-generator/references/pptxgenjs.md +420 -0
  105. package/bin/skills-assets/pptx-generator/references/slide-types.md +413 -0
  106. package/bin/skills-assets/skill-creator/SKILL.md +368 -0
  107. package/bin/skills-assets/skill-creator/agents/openai.yaml +5 -0
  108. package/bin/skills-assets/skill-creator/assets/skill-creator-small.svg +3 -0
  109. package/bin/skills-assets/skill-creator/assets/skill-creator.png +0 -0
  110. package/bin/skills-assets/skill-creator/license.txt +202 -0
  111. package/bin/skills-assets/skill-creator/references/openai_yaml.md +49 -0
  112. package/bin/skills-assets/skill-creator/scripts/generate_openai_yaml.py +226 -0
  113. package/bin/skills-assets/skill-creator/scripts/init_skill.py +397 -0
  114. package/bin/skills-assets/skill-creator/scripts/quick_validate.py +101 -0
  115. package/bin/skills-assets/skill-installer/LICENSE.txt +202 -0
  116. package/bin/skills-assets/skill-installer/SKILL.md +58 -0
  117. package/bin/skills-assets/skill-installer/agents/openai.yaml +5 -0
  118. package/bin/skills-assets/skill-installer/assets/skill-installer-small.svg +3 -0
  119. package/bin/skills-assets/skill-installer/assets/skill-installer.png +0 -0
  120. package/bin/skills-assets/skill-installer/scripts/github_utils.py +21 -0
  121. package/bin/skills-assets/skill-installer/scripts/install-skill-from-github.py +308 -0
  122. package/bin/skills-assets/skill-installer/scripts/list-skills.py +107 -0
  123. package/bin/skills-assets/xlsx/SKILL.md +137 -0
  124. package/bin/skills-assets/xlsx/references/create.md +691 -0
  125. package/bin/skills-assets/xlsx/references/edit.md +684 -0
  126. package/bin/skills-assets/xlsx/references/fix.md +37 -0
  127. package/bin/skills-assets/xlsx/references/format.md +768 -0
  128. package/bin/skills-assets/xlsx/references/ooxml-cheatsheet.md +231 -0
  129. package/bin/skills-assets/xlsx/references/read-analyze.md +97 -0
  130. package/bin/skills-assets/xlsx/references/validate.md +772 -0
  131. package/bin/skills-assets/xlsx/scripts/formula_check.py +422 -0
  132. package/bin/skills-assets/xlsx/scripts/libreoffice_recalc.py +248 -0
  133. package/bin/skills-assets/xlsx/scripts/shared_strings_builder.py +163 -0
  134. package/bin/skills-assets/xlsx/scripts/style_audit.py +575 -0
  135. package/bin/skills-assets/xlsx/scripts/xlsx_add_column.py +395 -0
  136. package/bin/skills-assets/xlsx/scripts/xlsx_insert_row.py +274 -0
  137. package/bin/skills-assets/xlsx/scripts/xlsx_pack.py +87 -0
  138. package/bin/skills-assets/xlsx/scripts/xlsx_reader.py +362 -0
  139. package/bin/skills-assets/xlsx/scripts/xlsx_shift_rows.py +396 -0
  140. package/bin/skills-assets/xlsx/scripts/xlsx_unpack.py +130 -0
  141. package/bin/skills-assets/xlsx/templates/minimal_xlsx/[Content_Types].xml +9 -0
  142. package/bin/skills-assets/xlsx/templates/minimal_xlsx/_rels/.rels +6 -0
  143. package/bin/skills-assets/xlsx/templates/minimal_xlsx/xl/_rels/workbook.xml.rels +19 -0
  144. package/bin/skills-assets/xlsx/templates/minimal_xlsx/xl/sharedStrings.xml +33 -0
  145. package/bin/skills-assets/xlsx/templates/minimal_xlsx/xl/styles.xml +160 -0
  146. package/bin/skills-assets/xlsx/templates/minimal_xlsx/xl/workbook.xml +30 -0
  147. package/bin/skills-assets/xlsx/templates/minimal_xlsx/xl/worksheets/sheet1.xml +70 -0
  148. package/package.json +1 -1
@@ -0,0 +1,1052 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ render_body.py — Build the inner-page PDF from tokens.json + content.json.
4
+
5
+ Usage:
6
+ python3 render_body.py --tokens tokens.json --content content.json --out body.pdf
7
+
8
+ Block types:
9
+ h1 h2 h3 Headings (h1 adds a full-width accent rule below)
10
+ body Justified prose paragraph
11
+ bullet Bullet list item (• prefix)
12
+ numbered Auto-numbered list item (resets when interrupted)
13
+ callout Highlighted insight box with left accent bar
14
+ table Data table with accent header + alternating rows
15
+ image Inline image from file path
16
+ figure Image with auto-numbered "Figure N:" caption
17
+ code Monospace code block with accent left border
18
+ math Display math formula via matplotlib mathtext
19
+ chart Bar / line / pie chart rendered via matplotlib
20
+ flowchart Process diagram rendered via matplotlib
21
+ bibliography Numbered reference list
22
+ divider Full-width accent rule
23
+ caption Small muted text (e.g., under a figure)
24
+ pagebreak Force a new page
25
+ spacer Vertical whitespace (pt field, default 12)
26
+
27
+ Exit codes: 0 success, 1 bad args/missing file, 2 missing dep, 3 render error
28
+ """
29
+
30
+ import argparse
31
+ import io
32
+ import json
33
+ import os
34
+ import sys
35
+ import importlib.util
36
+
37
+
38
+ # ── Dependency bootstrap ───────────────────────────────────────────────────────
39
+ def ensure_deps():
40
+ missing = [p for p in ("reportlab", "pypdf")
41
+ if importlib.util.find_spec(p) is None]
42
+ if missing:
43
+ import subprocess
44
+ subprocess.check_call(
45
+ [sys.executable, "-m", "pip", "install",
46
+ "--break-system-packages", "-q"] + missing
47
+ )
48
+
49
+
50
+ ensure_deps()
51
+
52
+ from reportlab.platypus import (
53
+ BaseDocTemplate, PageTemplate, Frame,
54
+ Paragraph, Spacer, Table, TableStyle,
55
+ HRFlowable, PageBreak, Flowable, KeepTogether,
56
+ Preformatted, Image as RLImage,
57
+ )
58
+ from reportlab.lib.pagesizes import A4
59
+ from reportlab.lib.styles import ParagraphStyle
60
+ from reportlab.lib.colors import HexColor
61
+ from reportlab.lib.enums import TA_JUSTIFY, TA_CENTER
62
+ from reportlab.pdfbase import pdfmetrics
63
+ from reportlab.pdfbase.ttfonts import TTFont
64
+
65
+
66
+ # ── Font registration ──────────────────────────────────────────────────────────
67
+ def register_fonts(tokens: dict):
68
+ """Register TTF fonts from token font_paths if present."""
69
+ for name, fpath in tokens.get("font_paths", {}).items():
70
+ if os.path.exists(fpath):
71
+ try:
72
+ pdfmetrics.registerFont(TTFont(name, fpath))
73
+ except Exception:
74
+ pass
75
+
76
+
77
+ # ══════════════════════════════════════════════════════════════════════════════
78
+ # Custom Flowables
79
+ # ══════════════════════════════════════════════════════════════════════════════
80
+
81
+ class CalloutBox(Flowable):
82
+ """Highlighted insight box: coloured background + 4px left accent bar."""
83
+
84
+ def __init__(self, text: str, style, accent: str, bg: str):
85
+ super().__init__()
86
+ self._para = Paragraph(text, style)
87
+ self._accent = HexColor(accent)
88
+ self._bg = HexColor(bg)
89
+
90
+ def wrap(self, aw, ah):
91
+ self._w = aw
92
+ _, ph = self._para.wrap(aw - 36, ah)
93
+ self._h = ph + 22
94
+ return aw, self._h
95
+
96
+ def draw(self):
97
+ c = self.canv
98
+ c.setFillColor(self._bg)
99
+ c.roundRect(0, 0, self._w, self._h, 5, fill=1, stroke=0)
100
+ c.setFillColor(self._accent)
101
+ c.rect(0, 0, 4, self._h, fill=1, stroke=0)
102
+ self._para.drawOn(c, 18, 11)
103
+
104
+
105
+ class BibliographyItem(Flowable):
106
+ """Single hanging-indent bibliography entry rendered as [N] text."""
107
+
108
+ LABEL_W = 28
109
+
110
+ def __init__(self, ref_id: str, text: str, style, dark: str):
111
+ super().__init__()
112
+ self._id = ref_id
113
+ self._text = text
114
+ self._style = style
115
+ self._dark = HexColor(dark)
116
+
117
+ def wrap(self, aw, ah):
118
+ self._w = aw
119
+ self._para = Paragraph(self._text, self._style)
120
+ _, ph = self._para.wrap(aw - self.LABEL_W, ah)
121
+ self._h = ph + 4
122
+ return aw, self._h
123
+
124
+ def draw(self):
125
+ c = self.canv
126
+ c.setFillColor(self._dark)
127
+ c.setFont("Helvetica-Bold", 8.5)
128
+ c.drawString(0, self._h - 12, f"[{self._id}]")
129
+ self._para.drawOn(c, self.LABEL_W, 2)
130
+
131
+
132
+ # ══════════════════════════════════════════════════════════════════════════════
133
+ # Page template (header + footer)
134
+ # ══════════════════════════════════════════════════════════════════════════════
135
+
136
+ class BeautifulDoc(BaseDocTemplate):
137
+ def __init__(self, path: str, tokens: dict, **kw):
138
+ self._t = tokens
139
+ super().__init__(path, **kw)
140
+ fr = Frame(
141
+ self.leftMargin, self.bottomMargin,
142
+ self.width, self.height, id="body",
143
+ )
144
+ tmpl = PageTemplate(id="main", frames=fr, onPage=self._decorate)
145
+ self.addPageTemplates([tmpl])
146
+
147
+ def _decorate(self, canv, doc):
148
+ t = self._t
149
+ lm = doc.leftMargin
150
+ rm = doc.rightMargin
151
+ pw = doc.pagesize[0]
152
+ ph = doc.pagesize[1]
153
+ top = ph - doc.topMargin
154
+
155
+ canv.saveState()
156
+
157
+ # Header accent rule
158
+ canv.setStrokeColor(HexColor(t["accent"]))
159
+ canv.setLineWidth(1.5)
160
+ canv.line(lm, top + 12, pw - rm, top + 12)
161
+
162
+ # Header: title (left) + date (right)
163
+ canv.setFillColor(HexColor(t["muted"]))
164
+ canv.setFont(t["font_body_rl"], t["size_meta"])
165
+ canv.drawString(lm, top + 16, t["title"].upper())
166
+ canv.drawRightString(pw - rm, top + 16, t.get("date", ""))
167
+
168
+ # Footer rule
169
+ canv.setStrokeColor(HexColor("#DDDDDD"))
170
+ canv.setLineWidth(0.5)
171
+ canv.line(lm, doc.bottomMargin - 12, pw - rm, doc.bottomMargin - 12)
172
+
173
+ # Footer: author (left) + page number (right)
174
+ canv.setFillColor(HexColor(t["muted"]))
175
+ canv.setFont(t["font_body_rl"], t["size_meta"])
176
+ canv.drawString(lm, doc.bottomMargin - 22, t.get("author", ""))
177
+ canv.drawRightString(pw - rm, doc.bottomMargin - 22, str(doc.page))
178
+
179
+ canv.restoreState()
180
+
181
+
182
+ # ══════════════════════════════════════════════════════════════════════════════
183
+ # Style factory
184
+ # ══════════════════════════════════════════════════════════════════════════════
185
+
186
+ def make_styles(t: dict) -> dict:
187
+ hf = t["font_display_rl"]
188
+ bf = t["font_body_rl"]
189
+ bfb = t["font_body_b_rl"]
190
+ dk = t["body_text"]
191
+ d = t["dark"]
192
+ mu = t["muted"]
193
+
194
+ return {
195
+ "h1": ParagraphStyle("H1",
196
+ fontName=hf, fontSize=t["size_h1"],
197
+ leading=t["size_h1"] * 1.3,
198
+ textColor=HexColor(d),
199
+ spaceBefore=t["section_gap"], spaceAfter=4,
200
+ ),
201
+ "h2": ParagraphStyle("H2",
202
+ fontName=hf, fontSize=t["size_h2"],
203
+ leading=t["size_h2"] * 1.4,
204
+ textColor=HexColor(d),
205
+ spaceBefore=18, spaceAfter=5,
206
+ ),
207
+ "h3": ParagraphStyle("H3",
208
+ fontName=bfb, fontSize=t["size_h3"],
209
+ leading=t["size_h3"] * 1.5,
210
+ textColor=HexColor(d),
211
+ spaceBefore=12, spaceAfter=3,
212
+ ),
213
+ "body": ParagraphStyle("Body",
214
+ fontName=bf, fontSize=t["size_body"],
215
+ leading=t["line_gap"],
216
+ textColor=HexColor(dk),
217
+ spaceAfter=t["para_gap"], alignment=TA_JUSTIFY,
218
+ ),
219
+ "bullet": ParagraphStyle("Bullet",
220
+ fontName=bf, fontSize=t["size_body"],
221
+ leading=t["line_gap"] - 1,
222
+ textColor=HexColor(dk),
223
+ spaceAfter=4, leftIndent=14,
224
+ ),
225
+ "numbered": ParagraphStyle("Numbered",
226
+ fontName=bf, fontSize=t["size_body"],
227
+ leading=t["line_gap"] - 1,
228
+ textColor=HexColor(dk),
229
+ spaceAfter=4, leftIndent=22, firstLineIndent=-22,
230
+ ),
231
+ "callout": ParagraphStyle("Callout",
232
+ fontName=bfb, fontSize=t["size_body"] + 0.5, leading=16,
233
+ textColor=HexColor(d),
234
+ ),
235
+ "caption": ParagraphStyle("Caption",
236
+ fontName=bf, fontSize=t["size_caption"], leading=13,
237
+ textColor=HexColor(mu), spaceAfter=6,
238
+ alignment=TA_CENTER,
239
+ ),
240
+ "table_header": ParagraphStyle("TblH",
241
+ fontName=bfb, fontSize=9.5, leading=13,
242
+ textColor=HexColor("#FFFFFF"),
243
+ ),
244
+ "table_cell": ParagraphStyle("TblC",
245
+ fontName=bf, fontSize=9.5, leading=13,
246
+ textColor=HexColor(dk),
247
+ ),
248
+ "code": ParagraphStyle("Code",
249
+ fontName="Courier", fontSize=8.5, leading=12.5,
250
+ textColor=HexColor(dk),
251
+ ),
252
+ "code_lang": ParagraphStyle("CodeLang",
253
+ fontName="Courier", fontSize=7, leading=10,
254
+ textColor=HexColor(mu),
255
+ ),
256
+ "bib": ParagraphStyle("Bib",
257
+ fontName=bf, fontSize=9, leading=14,
258
+ textColor=HexColor(dk),
259
+ ),
260
+ "bib_title": ParagraphStyle("BibTitle",
261
+ fontName=hf, fontSize=t["size_h2"],
262
+ leading=t["size_h2"] * 1.4,
263
+ textColor=HexColor(d),
264
+ spaceBefore=t["section_gap"], spaceAfter=8,
265
+ ),
266
+ "math_fallback": ParagraphStyle("MathFb",
267
+ fontName="Courier", fontSize=9, leading=13,
268
+ textColor=HexColor(dk),
269
+ ),
270
+ "eq_label": ParagraphStyle("EqLabel",
271
+ fontName="Helvetica", fontSize=9, leading=12,
272
+ textColor=HexColor(mu),
273
+ ),
274
+ }
275
+
276
+
277
+ # ══════════════════════════════════════════════════════════════════════════════
278
+ # Shared helpers
279
+ # ══════════════════════════════════════════════════════════════════════════════
280
+
281
+ def _divider(accent: str) -> HRFlowable:
282
+ return HRFlowable(
283
+ width="100%", thickness=1.2,
284
+ color=HexColor(accent),
285
+ spaceBefore=14, spaceAfter=14,
286
+ )
287
+
288
+
289
+ def _image_from_bytes(png_bytes: bytes, usable_w: float,
290
+ max_frac: float = 0.88) -> RLImage:
291
+ """Create a scaled RLImage from PNG bytes, bounded to max_frac of usable_w."""
292
+ img = RLImage(io.BytesIO(png_bytes))
293
+ max_w = usable_w * max_frac
294
+ if img.drawWidth > max_w:
295
+ scale = max_w / img.drawWidth
296
+ img.drawWidth = max_w
297
+ img.drawHeight = img.drawHeight * scale
298
+ return img
299
+
300
+
301
+ # ══════════════════════════════════════════════════════════════════════════════
302
+ # PNG renderers (matplotlib)
303
+ # ══════════════════════════════════════════════════════════════════════════════
304
+
305
+ def _render_math_png(expr: str, dpi: int = 180) -> bytes | None:
306
+ """
307
+ Render a LaTeX math expression via matplotlib mathtext.
308
+ No LaTeX binary required — uses matplotlib's built-in math parser.
309
+ Supports: fractions (\\frac), integrals (\\int), sums (\\sum),
310
+ Greek letters, sub/superscripts, etc.
311
+ """
312
+ try:
313
+ import matplotlib
314
+ matplotlib.use("Agg")
315
+ import matplotlib.pyplot as plt
316
+
317
+ fig = plt.figure(figsize=(8, 1.2))
318
+ fig.patch.set_facecolor("white")
319
+ ax = fig.add_axes([0, 0, 1, 1])
320
+ ax.set_axis_off()
321
+ ax.set_facecolor("white")
322
+ ax.text(0.5, 0.5, f"${expr}$",
323
+ fontsize=16, ha="center", va="center",
324
+ transform=ax.transAxes)
325
+ buf = io.BytesIO()
326
+ fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight",
327
+ facecolor="white", pad_inches=0.1)
328
+ plt.close(fig)
329
+ buf.seek(0)
330
+ return buf.read()
331
+ except Exception:
332
+ return None
333
+
334
+
335
+ def _render_chart_png(item: dict, accent: str, dpi: int = 150) -> bytes | None:
336
+ """
337
+ Render bar / line / pie chart to PNG using matplotlib.
338
+
339
+ Required fields:
340
+ chart_type "bar" | "line" | "pie" (default "bar")
341
+ labels list of category strings
342
+ datasets list of {label?, values: list[number]}
343
+
344
+ Optional fields:
345
+ title chart title
346
+ x_label X-axis label
347
+ y_label Y-axis label
348
+ """
349
+ try:
350
+ import matplotlib
351
+ matplotlib.use("Agg")
352
+ import matplotlib.pyplot as plt
353
+ import matplotlib.colors as mcolors
354
+ import colorsys
355
+ import numpy as np
356
+
357
+ chart_type = item.get("chart_type", "bar")
358
+ title_text = item.get("title", "")
359
+ labels = item.get("labels", [])
360
+ datasets = item.get("datasets", [])
361
+
362
+ # Derive a consistent palette from the document accent color
363
+ r, g, b = mcolors.to_rgb(accent)
364
+ h, s, v = colorsys.rgb_to_hsv(r, g, b)
365
+ palette = [
366
+ colorsys.hsv_to_rgb(
367
+ (h + i * 0.13) % 1.0,
368
+ max(0.35, s - i * 0.08),
369
+ min(0.92, v + i * 0.04),
370
+ )
371
+ for i in range(max(len(datasets), 1))
372
+ ]
373
+
374
+ fig, ax = plt.subplots(figsize=(7, 3.6), dpi=dpi)
375
+ fig.patch.set_facecolor("white")
376
+ ax.set_facecolor("white")
377
+
378
+ if chart_type == "bar":
379
+ x = np.arange(len(labels))
380
+ n = max(len(datasets), 1)
381
+ width = 0.68 / n
382
+ for i, ds in enumerate(datasets):
383
+ offset = (i - (n - 1) / 2) * width
384
+ ax.bar(x + offset, ds.get("values", []), width * 0.88,
385
+ label=ds.get("label", f"Series {i+1}"),
386
+ color=palette[i % len(palette)], edgecolor="none")
387
+ ax.set_xticks(x)
388
+ ax.set_xticklabels(labels, fontsize=8.5)
389
+ ax.yaxis.grid(True, alpha=0.25, color="#CCCCCC", linewidth=0.7)
390
+ ax.set_axisbelow(True)
391
+ if item.get("x_label"):
392
+ ax.set_xlabel(item["x_label"], fontsize=8.5)
393
+ if item.get("y_label"):
394
+ ax.set_ylabel(item["y_label"], fontsize=8.5)
395
+
396
+ elif chart_type == "line":
397
+ x = np.arange(len(labels))
398
+ for i, ds in enumerate(datasets):
399
+ ax.plot(x, ds.get("values", []), marker="o", markersize=3.5,
400
+ label=ds.get("label", f"Series {i+1}"),
401
+ color=palette[i % len(palette)], linewidth=1.8)
402
+ ax.set_xticks(x)
403
+ ax.set_xticklabels(labels, fontsize=8.5)
404
+ ax.yaxis.grid(True, alpha=0.25, color="#CCCCCC", linewidth=0.7)
405
+ ax.set_axisbelow(True)
406
+ if item.get("x_label"):
407
+ ax.set_xlabel(item["x_label"], fontsize=8.5)
408
+ if item.get("y_label"):
409
+ ax.set_ylabel(item["y_label"], fontsize=8.5)
410
+
411
+ elif chart_type == "pie":
412
+ vals = datasets[0].get("values", []) if datasets else []
413
+ colors = [
414
+ colorsys.hsv_to_rgb(
415
+ (h + i * 0.11) % 1.0,
416
+ max(0.30, s - i * 0.06),
417
+ min(0.92, v + i * 0.03),
418
+ )
419
+ for i in range(len(vals))
420
+ ]
421
+ ax.pie(vals, labels=labels, colors=colors,
422
+ autopct="%1.1f%%", pctdistance=0.82,
423
+ wedgeprops=dict(edgecolor="white", linewidth=1.4),
424
+ textprops=dict(fontsize=8.5))
425
+
426
+ # Shared styling
427
+ for spine in ax.spines.values():
428
+ spine.set_linewidth(0.5)
429
+ spine.set_color("#CCCCCC")
430
+ ax.tick_params(axis="both", length=0, labelsize=8.5)
431
+ if title_text:
432
+ ax.set_title(title_text, fontsize=10, pad=8,
433
+ color="#333333", fontweight="bold")
434
+ if len(datasets) > 1 and chart_type != "pie":
435
+ ax.legend(frameon=False, fontsize=8, loc="upper right")
436
+
437
+ plt.tight_layout(pad=0.4)
438
+ buf = io.BytesIO()
439
+ fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight",
440
+ facecolor="white", pad_inches=0.06)
441
+ plt.close(fig)
442
+ buf.seek(0)
443
+ return buf.read()
444
+ except Exception:
445
+ return None
446
+
447
+
448
+ def _render_flowchart_png(item: dict, accent: str, dark: str,
449
+ muted: str, dpi: int = 130) -> bytes | None:
450
+ """
451
+ Render a top-to-bottom flowchart using matplotlib patches and arrows.
452
+
453
+ Node schema: {id, label, shape?}
454
+ shape: "rect" (default) | "diamond" | "oval" | "parallelogram"
455
+
456
+ Edge schema: {from, to, label?}
457
+ Forward edges (to a later node) draw straight arrows.
458
+ Back edges (to an earlier node) draw a curved arc to the right.
459
+ """
460
+ try:
461
+ import matplotlib
462
+ matplotlib.use("Agg")
463
+ import matplotlib.pyplot as plt
464
+ import matplotlib.patches as mpatch
465
+ from matplotlib.patches import FancyBboxPatch
466
+ import matplotlib.colors as mcolors
467
+
468
+ nodes_list = item.get("nodes", [])
469
+ edges = item.get("edges", [])
470
+ if not nodes_list:
471
+ return None
472
+
473
+ nodes = {n["id"]: n for n in nodes_list}
474
+ order = {n["id"]: i for i, n in enumerate(nodes_list)}
475
+
476
+ n_nodes = len(nodes_list)
477
+ BOX_W = 4.2
478
+ BOX_H = 0.58
479
+ STEP_Y = 1.25
480
+ CX = 5.0
481
+
482
+ fig_h = max(3.5, n_nodes * STEP_Y + 0.8)
483
+ fig, ax = plt.subplots(figsize=(6, fig_h), dpi=dpi)
484
+ fig.patch.set_facecolor("white")
485
+ ax.set_facecolor("white")
486
+ ax.set_xlim(0, 10)
487
+ ax.set_ylim(-0.6, n_nodes * STEP_Y + 0.2)
488
+ ax.invert_yaxis()
489
+ ax.axis("off")
490
+
491
+ acc_rgb = mcolors.to_rgb(accent)
492
+ dark_rgb = mcolors.to_rgb(dark)
493
+ muted_rgb = mcolors.to_rgb(muted)
494
+
495
+ # Node positions (cx, cy) — preserves input order
496
+ pos = {nid: (CX, i * STEP_Y) for nid, i in order.items()}
497
+
498
+ # ── Draw edges (behind nodes) ──────────────────────────────────────────
499
+ for edge in edges:
500
+ src, dst = edge.get("from"), edge.get("to")
501
+ if src not in pos or dst not in pos:
502
+ continue
503
+ x1, y1 = pos[src]
504
+ x2, y2 = pos[dst]
505
+ lbl = edge.get("label", "")
506
+
507
+ src_shape = nodes.get(src, {}).get("shape", "rect")
508
+ dst_shape = nodes.get(dst, {}).get("shape", "rect")
509
+ dy_src = BOX_H * (0.80 if src_shape == "diamond" else 0.50)
510
+ dy_dst = BOX_H * (0.80 if dst_shape == "diamond" else 0.50)
511
+
512
+ y_start = y1 + dy_src
513
+ y_end = y2 - dy_dst
514
+
515
+ # Forward edge: straight; back-edge: curved arc
516
+ conn = "arc3,rad=0.0" if y_end > y_start + 0.01 else "arc3,rad=0.42"
517
+
518
+ ax.annotate("",
519
+ xy=(x2, y_end), xytext=(x1, y_start),
520
+ arrowprops=dict(
521
+ arrowstyle="-|>", color=muted_rgb,
522
+ lw=1.0, mutation_scale=10,
523
+ connectionstyle=conn,
524
+ ),
525
+ )
526
+ if lbl:
527
+ mid_x = (x1 + x2) / 2 + 0.28
528
+ mid_y = (y_start + y_end) / 2
529
+ ax.text(mid_x, mid_y, lbl, fontsize=7.5,
530
+ color=muted_rgb, ha="left", va="center")
531
+
532
+ # ── Draw nodes (in front of edges) ────────────────────────────────────
533
+ for nid, (cx, cy) in pos.items():
534
+ node = nodes[nid]
535
+ shape = node.get("shape", "rect")
536
+ label = node.get("label", nid)
537
+ left = cx - BOX_W / 2
538
+ bot = cy - BOX_H / 2
539
+
540
+ if shape in ("oval", "terminal"):
541
+ el = mpatch.Ellipse(
542
+ (cx, cy), BOX_W * 0.78, BOX_H * 1.15,
543
+ facecolor=acc_rgb, edgecolor=acc_rgb, linewidth=0,
544
+ )
545
+ ax.add_patch(el)
546
+ ax.text(cx, cy, label, ha="center", va="center",
547
+ fontsize=8.5, fontweight="bold", color="white")
548
+
549
+ elif shape == "diamond":
550
+ d = BOX_W * 0.44
551
+ diamond = plt.Polygon(
552
+ [(cx, cy - d * 0.72), (cx + d, cy),
553
+ (cx, cy + d * 0.72), (cx - d, cy)],
554
+ facecolor="#FFFCF0",
555
+ edgecolor=accent, linewidth=1.2,
556
+ )
557
+ ax.add_patch(diamond)
558
+ ax.text(cx, cy, label, ha="center", va="center",
559
+ fontsize=8, color=dark_rgb)
560
+
561
+ elif shape == "parallelogram":
562
+ skew = 0.30
563
+ para = plt.Polygon(
564
+ [(left + skew, bot), (left + BOX_W + skew, bot),
565
+ (left + BOX_W, bot + BOX_H), (left, bot + BOX_H)],
566
+ facecolor="white",
567
+ edgecolor=accent, linewidth=1.2,
568
+ )
569
+ ax.add_patch(para)
570
+ ax.text(cx, cy, label, ha="center", va="center",
571
+ fontsize=8.5, color=dark_rgb)
572
+
573
+ else: # rect (default)
574
+ rect = FancyBboxPatch(
575
+ (left, bot), BOX_W, BOX_H,
576
+ boxstyle="round,pad=0.04",
577
+ facecolor="white",
578
+ edgecolor=accent, linewidth=1.2,
579
+ )
580
+ ax.add_patch(rect)
581
+ ax.text(cx, cy, label, ha="center", va="center",
582
+ fontsize=8.5, color=dark_rgb)
583
+
584
+ plt.tight_layout(pad=0.2)
585
+ buf = io.BytesIO()
586
+ fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight",
587
+ facecolor="white", pad_inches=0.08)
588
+ plt.close(fig)
589
+ buf.seek(0)
590
+ return buf.read()
591
+ except Exception:
592
+ return None
593
+
594
+
595
+ # ══════════════════════════════════════════════════════════════════════════════
596
+ # Block renderers
597
+ #
598
+ # All functions share the same signature:
599
+ # _add_XXX(story: list, item: dict, ctx: dict)
600
+ #
601
+ # ctx keys:
602
+ # tokens dict design tokens from palette.py
603
+ # styles dict ParagraphStyle objects from make_styles()
604
+ # usable_w float usable page width in points
605
+ # acc str accent hex color
606
+ # acc_lt str light accent hex color
607
+ # mu str muted hex color
608
+ # dark str dark hex color
609
+ # figure_n int auto-incrementing figure counter (mutable)
610
+ # numbered_n int auto-incrementing list counter (mutable)
611
+ # ══════════════════════════════════════════════════════════════════════════════
612
+
613
+ def _add_heading(story: list, item: dict, ctx: dict, level: int):
614
+ key = f"h{level}"
615
+ para = Paragraph(item["text"], ctx["styles"][key])
616
+ if level == 1:
617
+ story.append(KeepTogether([para, _divider(ctx["acc"])]))
618
+ else:
619
+ story.append(para)
620
+
621
+
622
+ def _add_body(story: list, item: dict, ctx: dict):
623
+ story.append(Paragraph(item["text"], ctx["styles"]["body"]))
624
+
625
+
626
+ def _add_bullet(story: list, item: dict, ctx: dict):
627
+ story.append(Paragraph(
628
+ f"\u2022\u2002{item['text']}", ctx["styles"]["bullet"]
629
+ ))
630
+
631
+
632
+ def _add_numbered(story: list, item: dict, ctx: dict):
633
+ ctx["numbered_n"] += 1
634
+ story.append(Paragraph(
635
+ f"{ctx['numbered_n']}.\u2002{item['text']}",
636
+ ctx["styles"]["numbered"],
637
+ ))
638
+
639
+
640
+ def _add_callout(story: list, item: dict, ctx: dict):
641
+ story.append(Spacer(1, 8))
642
+ story.append(CalloutBox(
643
+ item["text"], ctx["styles"]["callout"], ctx["acc"], ctx["acc_lt"]
644
+ ))
645
+ story.append(Spacer(1, 8))
646
+
647
+
648
+ def _add_table(story: list, item: dict, ctx: dict):
649
+ t = ctx["tokens"]
650
+ styles = ctx["styles"]
651
+ usable_w = ctx["usable_w"]
652
+ acc = ctx["acc"]
653
+ acc_lt = ctx["acc_lt"]
654
+
655
+ headers = [Paragraph(h, styles["table_header"]) for h in item["headers"]]
656
+ rows = [
657
+ [Paragraph(str(c), styles["table_cell"]) for c in row]
658
+ for row in item.get("rows", [])
659
+ ]
660
+ n_cols = len(item["headers"])
661
+
662
+ # Optional col_widths as fractions summing to 1.0
663
+ if "col_widths" in item and len(item["col_widths"]) == n_cols:
664
+ col_w = [usable_w * f for f in item["col_widths"]]
665
+ else:
666
+ col_w = [usable_w / n_cols] * n_cols
667
+
668
+ tbl = Table([headers] + rows, colWidths=col_w)
669
+ tbl.setStyle(TableStyle([
670
+ ("BACKGROUND", (0, 0), (-1, 0), HexColor(acc)),
671
+ ("TEXTCOLOR", (0, 0), (-1, 0), HexColor("#FFFFFF")),
672
+ ("FONTNAME", (0, 0), (-1, 0), t["font_body_b_rl"]),
673
+ ("FONTSIZE", (0, 0), (-1, 0), 9.5),
674
+ ("TOPPADDING", (0, 0), (-1, 0), 7),
675
+ ("BOTTOMPADDING", (0, 0), (-1, 0), 7),
676
+ ("ROWBACKGROUNDS", (0, 1), (-1, -1),
677
+ [HexColor("#FFFFFF"), HexColor(acc_lt)]),
678
+ ("FONTNAME", (0, 1), (-1, -1), t["font_body_rl"]),
679
+ ("FONTSIZE", (0, 1), (-1, -1), 9.5),
680
+ ("TOPPADDING", (0, 1), (-1, -1), 6),
681
+ ("BOTTOMPADDING", (0, 1), (-1, -1), 6),
682
+ ("LEFTPADDING", (0, 0), (-1, -1), 10),
683
+ ("RIGHTPADDING", (0, 0), (-1, -1), 10),
684
+ ("BOX", (0, 0), (-1, -1), 0.5, HexColor("#CCCCCC")),
685
+ ("LINEBELOW", (0, 0), (-1, 0), 1.2, HexColor(acc)),
686
+ ("TEXTCOLOR", (0, 1), (-1, -1), HexColor(t["body_text"])),
687
+ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
688
+ ]))
689
+ story.append(tbl)
690
+ if item.get("caption"):
691
+ story.append(Spacer(1, 4))
692
+ story.append(Paragraph(item["caption"], styles["caption"]))
693
+ story.append(Spacer(1, 12))
694
+
695
+
696
+ def _add_image(story: list, item: dict, ctx: dict):
697
+ path = str(item.get("path", item.get("src", "")))
698
+ if not os.path.exists(path):
699
+ story.append(Paragraph(
700
+ f"[Image not found: {path}]", ctx["styles"]["caption"]
701
+ ))
702
+ return
703
+ try:
704
+ img = RLImage(path)
705
+ uw = ctx["usable_w"]
706
+ if img.drawWidth > uw:
707
+ scale = uw / img.drawWidth
708
+ img.drawWidth = uw
709
+ img.drawHeight = img.drawHeight * scale
710
+ story.append(img)
711
+ except Exception as e:
712
+ story.append(Paragraph(f"[Image error: {e}]", ctx["styles"]["caption"]))
713
+ return
714
+ if item.get("caption"):
715
+ story.append(Spacer(1, 4))
716
+ story.append(Paragraph(item["caption"], ctx["styles"]["caption"]))
717
+ story.append(Spacer(1, 8))
718
+
719
+
720
+ def _add_figure(story: list, item: dict, ctx: dict):
721
+ """Like image but auto-numbers the caption as 'Figure N: ...'."""
722
+ ctx["figure_n"] += 1
723
+ raw_cap = item.get("caption", "")
724
+ caption = f"Figure {ctx['figure_n']}: {raw_cap}" if raw_cap \
725
+ else f"Figure {ctx['figure_n']}"
726
+ _add_image(story, {**item, "caption": caption}, ctx)
727
+
728
+
729
+ def _add_code(story: list, item: dict, ctx: dict):
730
+ acc = ctx["acc"]
731
+ acc_lt = ctx["acc_lt"]
732
+ mu = ctx["mu"]
733
+ uw = ctx["usable_w"]
734
+ lang = item.get("language", "")
735
+
736
+ pre = Preformatted(item.get("text", ""), ctx["styles"]["code"])
737
+ tbl = Table([[pre]], colWidths=[uw])
738
+ tbl.setStyle(TableStyle([
739
+ ("BACKGROUND", (0, 0), (-1, -1), HexColor(acc_lt)),
740
+ ("LINEBEFORE", (0, 0), ( 0, -1), 3, HexColor(acc)),
741
+ ("BOX", (0, 0), (-1, -1), 0.5, HexColor(mu)),
742
+ ("LEFTPADDING", (0, 0), (-1, -1), 14),
743
+ ("RIGHTPADDING", (0, 0), (-1, -1), 10),
744
+ ("TOPPADDING", (0, 0), (-1, -1), 8),
745
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 8),
746
+ ]))
747
+ story.append(Spacer(1, 6))
748
+ if lang:
749
+ story.append(Paragraph(lang.upper(), ctx["styles"]["code_lang"]))
750
+ story.append(tbl)
751
+ story.append(Spacer(1, 6))
752
+
753
+
754
+ def _add_math(story: list, item: dict, ctx: dict):
755
+ """
756
+ Display math block.
757
+
758
+ Fields:
759
+ text LaTeX math expression (without enclosing $)
760
+ label optional equation label, e.g. "(1)" — displayed right-aligned
761
+ caption optional caption below the formula
762
+
763
+ Example:
764
+ {"type": "math", "text": "E = mc^2", "label": "(1)"}
765
+ {"type": "math", "text": "\\\\int_0^\\\\infty e^{-x^2}\\\\,dx = \\\\frac{\\\\sqrt{\\\\pi}}{2}"}
766
+ """
767
+ acc = ctx["acc"]
768
+ acc_lt = ctx["acc_lt"]
769
+ uw = ctx["usable_w"]
770
+ expr = item.get("text", "").strip()
771
+ label = item.get("label", "").strip()
772
+
773
+ png = _render_math_png(expr)
774
+
775
+ if png is None:
776
+ # Graceful text fallback if matplotlib unavailable
777
+ story.append(Spacer(1, 6))
778
+ pre = Preformatted(f" {expr}", ctx["styles"]["math_fallback"])
779
+ tbl = Table([[pre]], colWidths=[uw])
780
+ tbl.setStyle(TableStyle([
781
+ ("BACKGROUND", (0, 0), (-1, -1), HexColor(acc_lt)),
782
+ ("LEFTPADDING", (0, 0), (-1, -1), 14),
783
+ ("RIGHTPADDING", (0, 0), (-1, -1), 14),
784
+ ("TOPPADDING", (0, 0), (-1, -1), 8),
785
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 8),
786
+ ]))
787
+ story.append(tbl)
788
+ story.append(Spacer(1, 6))
789
+ return
790
+
791
+ img = _image_from_bytes(png, uw, max_frac=0.72)
792
+ story.append(Spacer(1, 10))
793
+
794
+ if label:
795
+ label_w = 44
796
+ formula_w = uw - label_w
797
+ lbl_para = Paragraph(label, ctx["styles"]["eq_label"])
798
+ row_tbl = Table([[img, lbl_para]], colWidths=[formula_w, label_w])
799
+ row_tbl.setStyle(TableStyle([
800
+ ("ALIGN", (0, 0), (0, 0), "CENTER"),
801
+ ("ALIGN", (1, 0), (1, 0), "RIGHT"),
802
+ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
803
+ ]))
804
+ story.append(row_tbl)
805
+ else:
806
+ row_tbl = Table([[img]], colWidths=[uw])
807
+ row_tbl.setStyle(TableStyle([
808
+ ("ALIGN", (0, 0), (-1, -1), "CENTER"),
809
+ ]))
810
+ story.append(row_tbl)
811
+
812
+ if item.get("caption"):
813
+ story.append(Spacer(1, 4))
814
+ story.append(Paragraph(item["caption"], ctx["styles"]["caption"]))
815
+ story.append(Spacer(1, 10))
816
+
817
+
818
+ def _add_chart(story: list, item: dict, ctx: dict):
819
+ """
820
+ Render a chart (bar / line / pie) via matplotlib.
821
+
822
+ Fields:
823
+ chart_type "bar" | "line" | "pie" (default "bar")
824
+ title chart title
825
+ labels list of category strings
826
+ datasets list of {label?, values: list[number]}
827
+ x_label X-axis label (bar/line)
828
+ y_label Y-axis label (bar/line)
829
+ caption caption text below chart
830
+ figure bool (default true) — prefix caption with "Figure N:"
831
+ """
832
+ uw = ctx["usable_w"]
833
+ png = _render_chart_png(item, ctx["acc"])
834
+
835
+ if png is None:
836
+ story.append(Paragraph(
837
+ "[Chart: install matplotlib to render — pip install matplotlib]",
838
+ ctx["styles"]["caption"],
839
+ ))
840
+ return
841
+
842
+ img = _image_from_bytes(png, uw, max_frac=0.95)
843
+ story.append(Spacer(1, 8))
844
+ row_tbl = Table([[img]], colWidths=[uw])
845
+ row_tbl.setStyle(TableStyle([("ALIGN", (0, 0), (-1, -1), "CENTER")]))
846
+ story.append(row_tbl)
847
+
848
+ raw_cap = item.get("caption", "")
849
+ use_fig = item.get("figure", True)
850
+ if raw_cap or use_fig:
851
+ ctx["figure_n"] += 1
852
+ prefix = f"Figure {ctx['figure_n']}: " if use_fig else ""
853
+ story.append(Spacer(1, 4))
854
+ story.append(Paragraph(prefix + raw_cap, ctx["styles"]["caption"]))
855
+ story.append(Spacer(1, 10))
856
+
857
+
858
+ def _add_flowchart(story: list, item: dict, ctx: dict):
859
+ """
860
+ Render a flowchart via matplotlib.
861
+
862
+ Fields:
863
+ nodes list of {id, label, shape?}
864
+ shape: "rect" (default) | "diamond" | "oval" | "parallelogram"
865
+ edges list of {from, to, label?}
866
+ caption caption below the diagram
867
+ figure bool (default true) — prefix caption with "Figure N:"
868
+ """
869
+ uw = ctx["usable_w"]
870
+ png = _render_flowchart_png(item, ctx["acc"], ctx["dark"], ctx["mu"])
871
+
872
+ if png is None:
873
+ story.append(Paragraph(
874
+ "[Flowchart: install matplotlib to render — pip install matplotlib]",
875
+ ctx["styles"]["caption"],
876
+ ))
877
+ return
878
+
879
+ img = _image_from_bytes(png, uw, max_frac=0.78)
880
+ story.append(Spacer(1, 8))
881
+ row_tbl = Table([[img]], colWidths=[uw])
882
+ row_tbl.setStyle(TableStyle([("ALIGN", (0, 0), (-1, -1), "CENTER")]))
883
+ story.append(row_tbl)
884
+
885
+ raw_cap = item.get("caption", "")
886
+ use_fig = item.get("figure", True)
887
+ if raw_cap or use_fig:
888
+ ctx["figure_n"] += 1
889
+ prefix = f"Figure {ctx['figure_n']}: " if use_fig else ""
890
+ story.append(Spacer(1, 4))
891
+ story.append(Paragraph(prefix + raw_cap, ctx["styles"]["caption"]))
892
+ story.append(Spacer(1, 10))
893
+
894
+
895
+ def _add_bibliography(story: list, item: dict, ctx: dict):
896
+ """
897
+ Numbered reference list with hanging indent.
898
+
899
+ Fields:
900
+ title section heading (default "References"); set "" to suppress
901
+ items list of {id, text}
902
+
903
+ Example:
904
+ {"type": "bibliography",
905
+ "items": [
906
+ {"id": "1", "text": "Smith, J. (2023). Title. Journal, 10(2), 1–15."},
907
+ {"id": "2", "text": "Doe, A. (2022). Another title. Publisher."}
908
+ ]}
909
+ """
910
+ heading = item.get("title", "References")
911
+ if heading:
912
+ story.append(KeepTogether([
913
+ Paragraph(heading, ctx["styles"]["bib_title"]),
914
+ _divider(ctx["acc"]),
915
+ ]))
916
+
917
+ for ref in item.get("items", []):
918
+ story.append(Spacer(1, 4))
919
+ story.append(BibliographyItem(
920
+ str(ref.get("id", "")),
921
+ ref.get("text", ""),
922
+ ctx["styles"]["bib"],
923
+ ctx["dark"],
924
+ ))
925
+
926
+
927
+ # ══════════════════════════════════════════════════════════════════════════════
928
+ # Story builder
929
+ # ══════════════════════════════════════════════════════════════════════════════
930
+
931
+ # Block types that break a numbered list sequence
932
+ _RESETS_NUMBERED = frozenset({
933
+ "h1", "h2", "h3", "body", "bullet", "callout", "table",
934
+ "image", "figure", "code", "math", "chart", "flowchart",
935
+ "bibliography", "divider", "caption", "pagebreak", "spacer",
936
+ })
937
+
938
+
939
+ def build_story(content: list, tokens: dict, styles: dict) -> list:
940
+ usable_w = A4[0] - tokens["margin_left"] - tokens["margin_right"]
941
+
942
+ ctx: dict = {
943
+ "tokens": tokens,
944
+ "styles": styles,
945
+ "usable_w": usable_w,
946
+ "acc": tokens["accent"],
947
+ "acc_lt": tokens["accent_lt"],
948
+ "mu": tokens["muted"],
949
+ "dark": tokens["dark"],
950
+ "figure_n": 0,
951
+ "numbered_n": 0,
952
+ }
953
+
954
+ story: list = []
955
+
956
+ for item in content:
957
+ kind = item.get("type", "body")
958
+
959
+ if kind in _RESETS_NUMBERED:
960
+ ctx["numbered_n"] = 0
961
+
962
+ if kind == "h1": _add_heading(story, item, ctx, 1)
963
+ elif kind == "h2": _add_heading(story, item, ctx, 2)
964
+ elif kind == "h3": _add_heading(story, item, ctx, 3)
965
+ elif kind == "body": _add_body(story, item, ctx)
966
+ elif kind == "bullet": _add_bullet(story, item, ctx)
967
+ elif kind == "numbered": _add_numbered(story, item, ctx)
968
+ elif kind == "callout": _add_callout(story, item, ctx)
969
+ elif kind == "table": _add_table(story, item, ctx)
970
+ elif kind == "image": _add_image(story, item, ctx)
971
+ elif kind == "figure": _add_figure(story, item, ctx)
972
+ elif kind == "code": _add_code(story, item, ctx)
973
+ elif kind == "math": _add_math(story, item, ctx)
974
+ elif kind == "chart": _add_chart(story, item, ctx)
975
+ elif kind == "flowchart": _add_flowchart(story, item, ctx)
976
+ elif kind == "bibliography": _add_bibliography(story, item, ctx)
977
+ elif kind == "divider": story.append(_divider(ctx["acc"]))
978
+ elif kind == "caption":
979
+ story.append(Paragraph(item["text"], styles["caption"]))
980
+ elif kind == "pagebreak": story.append(PageBreak())
981
+ elif kind == "spacer": story.append(Spacer(1, item.get("pt", 12)))
982
+
983
+ return story
984
+
985
+
986
+ # ══════════════════════════════════════════════════════════════════════════════
987
+ # Main build
988
+ # ══════════════════════════════════════════════════════════════════════════════
989
+
990
+ def build(tokens: dict, content: list, out_path: str) -> dict:
991
+ register_fonts(tokens)
992
+ styles = make_styles(tokens)
993
+
994
+ doc = BeautifulDoc(
995
+ out_path, tokens,
996
+ pagesize=A4,
997
+ leftMargin=tokens["margin_left"],
998
+ rightMargin=tokens["margin_right"],
999
+ topMargin=tokens["margin_top"],
1000
+ bottomMargin=tokens["margin_bottom"],
1001
+ )
1002
+ doc.build(build_story(content, tokens, styles))
1003
+
1004
+ size = os.path.getsize(out_path)
1005
+ return {"status": "ok", "out": out_path, "size_kb": size // 1024}
1006
+
1007
+
1008
+ # ══════════════════════════════════════════════════════════════════════════════
1009
+ # CLI
1010
+ # ══════════════════════════════════════════════════════════════════════════════
1011
+
1012
+ def main():
1013
+ parser = argparse.ArgumentParser(
1014
+ description="Render body PDF from tokens.json + content.json"
1015
+ )
1016
+ parser.add_argument("--tokens", default="tokens.json")
1017
+ parser.add_argument("--content", default="content.json")
1018
+ parser.add_argument("--out", default="body.pdf")
1019
+ args = parser.parse_args()
1020
+
1021
+ for fpath in (args.tokens, args.content):
1022
+ if not os.path.exists(fpath):
1023
+ print(
1024
+ json.dumps({"status": "error",
1025
+ "error": f"File not found: {fpath}"}),
1026
+ file=sys.stderr,
1027
+ )
1028
+ sys.exit(1)
1029
+
1030
+ with open(args.tokens, encoding="utf-8") as f:
1031
+ tokens = json.load(f)
1032
+ with open(args.content, encoding="utf-8") as f:
1033
+ content = json.load(f)
1034
+
1035
+ try:
1036
+ result = build(tokens, content, args.out)
1037
+ print(json.dumps(result))
1038
+ except Exception as e:
1039
+ import traceback
1040
+ print(
1041
+ json.dumps({
1042
+ "status": "error",
1043
+ "error": str(e),
1044
+ "trace": traceback.format_exc(),
1045
+ }),
1046
+ file=sys.stderr,
1047
+ )
1048
+ sys.exit(3)
1049
+
1050
+
1051
+ if __name__ == "__main__":
1052
+ main()