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

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/package.json +1 -1
  3. package/bin/runtime/sandbox/dim-sandbox-runner +0 -0
  4. package/bin/runtime/sandbox/manifest.json +0 -15
  5. package/bin/skills-assets/deep-investigate/SKILL.md +0 -101
  6. package/bin/skills-assets/deep-investigate/references/prompts.md +0 -75
  7. package/bin/skills-assets/deep-investigate/references/templates.md +0 -73
  8. package/bin/skills-assets/deep-investigate/references/thinking-tools.md +0 -36
  9. package/bin/skills-assets/docs-sprint/SKILL.md +0 -73
  10. package/bin/skills-assets/docs-sprint/agents/openai.yaml +0 -4
  11. package/bin/skills-assets/docs-sprint/references/contract-discipline.md +0 -30
  12. package/bin/skills-assets/docs-sprint/references/delivery-plan.md +0 -162
  13. package/bin/skills-assets/docs-sprint/references/documentation-system.md +0 -109
  14. package/bin/skills-assets/docs-sprint/references/ui-layout.md +0 -73
  15. package/bin/skills-assets/docs-sprint/references/worktree-guide.md +0 -45
  16. package/bin/skills-assets/docx/SKILL.md +0 -273
  17. package/bin/skills-assets/docx/assets/styles/academic_styles.xml +0 -250
  18. package/bin/skills-assets/docx/assets/styles/corporate_styles.xml +0 -284
  19. package/bin/skills-assets/docx/assets/styles/default_styles.xml +0 -449
  20. package/bin/skills-assets/docx/assets/xsd/aesthetic-rules.xsd +0 -470
  21. package/bin/skills-assets/docx/assets/xsd/business-rules.xsd +0 -130
  22. package/bin/skills-assets/docx/assets/xsd/common-types.xsd +0 -159
  23. package/bin/skills-assets/docx/assets/xsd/wml-subset.xsd +0 -589
  24. package/bin/skills-assets/docx/references/cjk_typography.md +0 -357
  25. package/bin/skills-assets/docx/references/cjk_university_template_guide.md +0 -184
  26. package/bin/skills-assets/docx/references/comments_guide.md +0 -191
  27. package/bin/skills-assets/docx/references/design_good_bad_examples.md +0 -829
  28. package/bin/skills-assets/docx/references/design_principles.md +0 -819
  29. package/bin/skills-assets/docx/references/openxml_element_order.md +0 -308
  30. package/bin/skills-assets/docx/references/openxml_encyclopedia_part1.md +0 -4061
  31. package/bin/skills-assets/docx/references/openxml_encyclopedia_part2.md +0 -2820
  32. package/bin/skills-assets/docx/references/openxml_encyclopedia_part3.md +0 -3381
  33. package/bin/skills-assets/docx/references/openxml_namespaces.md +0 -82
  34. package/bin/skills-assets/docx/references/openxml_units.md +0 -72
  35. package/bin/skills-assets/docx/references/scenario_a_create.md +0 -284
  36. package/bin/skills-assets/docx/references/scenario_b_edit_content.md +0 -295
  37. package/bin/skills-assets/docx/references/scenario_c_apply_template.md +0 -456
  38. package/bin/skills-assets/docx/references/track_changes_guide.md +0 -200
  39. package/bin/skills-assets/docx/references/troubleshooting.md +0 -506
  40. package/bin/skills-assets/docx/references/typography_guide.md +0 -294
  41. package/bin/skills-assets/docx/references/xsd_validation_guide.md +0 -158
  42. package/bin/skills-assets/docx/scripts/doc_to_docx.sh +0 -40
  43. package/bin/skills-assets/docx/scripts/docx_preview.sh +0 -37
  44. package/bin/skills-assets/docx/scripts/dotnet/Docx.Cli/Docx.Cli.csproj +0 -19
  45. package/bin/skills-assets/docx/scripts/dotnet/Docx.Cli/Program.cs +0 -18
  46. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Commands/AnalyzeCommand.cs +0 -147
  47. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Commands/ApplyTemplateCommand.cs +0 -322
  48. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Commands/CreateCommand.cs +0 -324
  49. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Commands/DiffCommand.cs +0 -155
  50. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Commands/EditContentCommand.cs +0 -487
  51. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Commands/FixOrderCommand.cs +0 -108
  52. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Commands/MergeRunsCommand.cs +0 -122
  53. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Commands/ValidateCommand.cs +0 -107
  54. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Docx.Core.csproj +0 -15
  55. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/OpenXml/CommentSynchronizer.cs +0 -169
  56. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/OpenXml/ElementOrder.cs +0 -80
  57. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/OpenXml/NamespaceConstants.cs +0 -42
  58. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/OpenXml/RunMerger.cs +0 -81
  59. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/OpenXml/StyleAnalyzer.cs +0 -81
  60. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/OpenXml/TrackChangesHelper.cs +0 -99
  61. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/OpenXml/UnitConverter.cs +0 -23
  62. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Samples/AestheticRecipeSamples.cs +0 -1832
  63. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Samples/AestheticRecipeSamples_Batch1.cs +0 -910
  64. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Samples/AestheticRecipeSamples_Batch2.cs +0 -999
  65. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Samples/AestheticRecipeSamples_Batch3.cs +0 -1048
  66. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Samples/AestheticRecipeSamples_Batch4.cs +0 -1038
  67. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Samples/CharacterFormattingSamples.cs +0 -1020
  68. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Samples/DocumentCreationSamples.cs +0 -1121
  69. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Samples/FieldAndTocSamples.cs +0 -624
  70. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Samples/FootnoteAndCommentSamples.cs +0 -675
  71. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Samples/HeaderFooterSamples.cs +0 -838
  72. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Samples/ImageSamples.cs +0 -917
  73. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Samples/ListAndNumberingSamples.cs +0 -826
  74. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Samples/ParagraphFormattingSamples.cs +0 -1199
  75. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Samples/StyleSystemSamples.cs +0 -1487
  76. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Samples/TableSamples.cs +0 -1163
  77. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Samples/TrackChangesSamples.cs +0 -595
  78. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Typography/CjkHelper.cs +0 -39
  79. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Typography/FontDefaults.cs +0 -24
  80. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Typography/PageSizes.cs +0 -20
  81. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Validation/BusinessRuleValidator.cs +0 -224
  82. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Validation/GateCheckValidator.cs +0 -148
  83. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Validation/ValidationResult.cs +0 -23
  84. package/bin/skills-assets/docx/scripts/dotnet/Docx.Core/Validation/XsdValidator.cs +0 -69
  85. package/bin/skills-assets/docx/scripts/dotnet/Docx.slnx +0 -4
  86. package/bin/skills-assets/docx/scripts/env_check.sh +0 -196
  87. package/bin/skills-assets/docx/scripts/setup.ps1 +0 -274
  88. package/bin/skills-assets/docx/scripts/setup.sh +0 -504
  89. package/bin/skills-assets/pdf/README.md +0 -222
  90. package/bin/skills-assets/pdf/SKILL.md +0 -191
  91. package/bin/skills-assets/pdf/design/design.md +0 -381
  92. package/bin/skills-assets/pdf/scripts/cover.py +0 -1579
  93. package/bin/skills-assets/pdf/scripts/fill_inspect.py +0 -200
  94. package/bin/skills-assets/pdf/scripts/fill_write.py +0 -242
  95. package/bin/skills-assets/pdf/scripts/make.sh +0 -491
  96. package/bin/skills-assets/pdf/scripts/merge.py +0 -112
  97. package/bin/skills-assets/pdf/scripts/palette.py +0 -521
  98. package/bin/skills-assets/pdf/scripts/reformat_parse.py +0 -374
  99. package/bin/skills-assets/pdf/scripts/render_body.py +0 -1052
  100. package/bin/skills-assets/pdf/scripts/render_cover.js +0 -111
  101. package/bin/skills-assets/pptx-generator/SKILL.md +0 -248
  102. package/bin/skills-assets/pptx-generator/references/design-system.md +0 -392
  103. package/bin/skills-assets/pptx-generator/references/editing.md +0 -162
  104. package/bin/skills-assets/pptx-generator/references/pitfalls.md +0 -112
  105. package/bin/skills-assets/pptx-generator/references/pptxgenjs.md +0 -420
  106. package/bin/skills-assets/pptx-generator/references/slide-types.md +0 -413
  107. package/bin/skills-assets/skill-creator/SKILL.md +0 -368
  108. package/bin/skills-assets/skill-creator/agents/openai.yaml +0 -5
  109. package/bin/skills-assets/skill-creator/assets/skill-creator-small.svg +0 -3
  110. package/bin/skills-assets/skill-creator/assets/skill-creator.png +0 -0
  111. package/bin/skills-assets/skill-creator/license.txt +0 -202
  112. package/bin/skills-assets/skill-creator/references/openai_yaml.md +0 -49
  113. package/bin/skills-assets/skill-creator/scripts/generate_openai_yaml.py +0 -226
  114. package/bin/skills-assets/skill-creator/scripts/init_skill.py +0 -397
  115. package/bin/skills-assets/skill-creator/scripts/quick_validate.py +0 -101
  116. package/bin/skills-assets/skill-installer/LICENSE.txt +0 -202
  117. package/bin/skills-assets/skill-installer/SKILL.md +0 -58
  118. package/bin/skills-assets/skill-installer/agents/openai.yaml +0 -5
  119. package/bin/skills-assets/skill-installer/assets/skill-installer-small.svg +0 -3
  120. package/bin/skills-assets/skill-installer/assets/skill-installer.png +0 -0
  121. package/bin/skills-assets/skill-installer/scripts/github_utils.py +0 -21
  122. package/bin/skills-assets/skill-installer/scripts/install-skill-from-github.py +0 -308
  123. package/bin/skills-assets/skill-installer/scripts/list-skills.py +0 -107
  124. package/bin/skills-assets/xlsx/SKILL.md +0 -137
  125. package/bin/skills-assets/xlsx/references/create.md +0 -691
  126. package/bin/skills-assets/xlsx/references/edit.md +0 -684
  127. package/bin/skills-assets/xlsx/references/fix.md +0 -37
  128. package/bin/skills-assets/xlsx/references/format.md +0 -768
  129. package/bin/skills-assets/xlsx/references/ooxml-cheatsheet.md +0 -231
  130. package/bin/skills-assets/xlsx/references/read-analyze.md +0 -97
  131. package/bin/skills-assets/xlsx/references/validate.md +0 -772
  132. package/bin/skills-assets/xlsx/scripts/formula_check.py +0 -422
  133. package/bin/skills-assets/xlsx/scripts/libreoffice_recalc.py +0 -248
  134. package/bin/skills-assets/xlsx/scripts/shared_strings_builder.py +0 -163
  135. package/bin/skills-assets/xlsx/scripts/style_audit.py +0 -575
  136. package/bin/skills-assets/xlsx/scripts/xlsx_add_column.py +0 -395
  137. package/bin/skills-assets/xlsx/scripts/xlsx_insert_row.py +0 -274
  138. package/bin/skills-assets/xlsx/scripts/xlsx_pack.py +0 -87
  139. package/bin/skills-assets/xlsx/scripts/xlsx_reader.py +0 -362
  140. package/bin/skills-assets/xlsx/scripts/xlsx_shift_rows.py +0 -396
  141. package/bin/skills-assets/xlsx/scripts/xlsx_unpack.py +0 -130
  142. package/bin/skills-assets/xlsx/templates/minimal_xlsx/[Content_Types].xml +0 -9
  143. package/bin/skills-assets/xlsx/templates/minimal_xlsx/_rels/.rels +0 -6
  144. package/bin/skills-assets/xlsx/templates/minimal_xlsx/xl/_rels/workbook.xml.rels +0 -19
  145. package/bin/skills-assets/xlsx/templates/minimal_xlsx/xl/sharedStrings.xml +0 -33
  146. package/bin/skills-assets/xlsx/templates/minimal_xlsx/xl/styles.xml +0 -160
  147. package/bin/skills-assets/xlsx/templates/minimal_xlsx/xl/workbook.xml +0 -30
  148. package/bin/skills-assets/xlsx/templates/minimal_xlsx/xl/worksheets/sheet1.xml +0 -70
@@ -1,1052 +0,0 @@
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()