pdfgen-juanipis 0.1.3__py3-none-any.whl

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 (33) hide show
  1. pdfgen/__init__.py +17 -0
  2. pdfgen/api.py +69 -0
  3. pdfgen/assets/banner-clean.png +0 -0
  4. pdfgen/assets/banner.png +0 -0
  5. pdfgen/assets/fonts/BCDEEE_Calibri_5.ttf +0 -0
  6. pdfgen/assets/fonts/BCDFEE_CenturyGothic-Bold_9.ttf +0 -0
  7. pdfgen/assets/fonts/BCDGEE_CenturyGothic-Bold_14.ttf +0 -0
  8. pdfgen/assets/fonts/BCDHEE_Calibri-Bold_20.ttf +0 -0
  9. pdfgen/assets/fonts/BCDIEE_Calibri-Bold_25.ttf +0 -0
  10. pdfgen/assets/fonts/BCDJEE_Calibri_27.ttf +0 -0
  11. pdfgen/assets/fonts/BCDKEE_Calibri-Italic_33.ttf +0 -0
  12. pdfgen/assets/fonts/BCDLEE_Calibri-Italic_52.ttf +0 -0
  13. pdfgen/assets/fonts/BCDMEE_SegoeUI_54.ttf +0 -0
  14. pdfgen/assets/fonts/BCDNEE_SegoeUI_60.ttf +0 -0
  15. pdfgen/assets/fonts/BCDOEE_Aptos Narrow,Bold_142.ttf +0 -0
  16. pdfgen/assets/fonts/BCDPEE_Aptos Narrow,Bold_144.ttf +0 -0
  17. pdfgen/assets/fonts/BCEAEE_Aptos Narrow_149.ttf +0 -0
  18. pdfgen/assets/fonts/BCEBEE_Aptos Narrow_154.ttf +0 -0
  19. pdfgen/assets/fonts/TimesNewRomanPS-BoldMT_38.ttf +0 -0
  20. pdfgen/assets/logo.png +0 -0
  21. pdfgen/cli.py +106 -0
  22. pdfgen/pagination.py +1045 -0
  23. pdfgen/render.py +348 -0
  24. pdfgen/schema.json +126 -0
  25. pdfgen/templates/boletin.css +389 -0
  26. pdfgen/templates/boletin_template.html.jinja +129 -0
  27. pdfgen/validator.py +247 -0
  28. pdfgen_juanipis-0.1.3.dist-info/METADATA +170 -0
  29. pdfgen_juanipis-0.1.3.dist-info/RECORD +33 -0
  30. pdfgen_juanipis-0.1.3.dist-info/WHEEL +5 -0
  31. pdfgen_juanipis-0.1.3.dist-info/entry_points.txt +2 -0
  32. pdfgen_juanipis-0.1.3.dist-info/licenses/LICENSE +21 -0
  33. pdfgen_juanipis-0.1.3.dist-info/top_level.txt +1 -0
pdfgen/render.py ADDED
@@ -0,0 +1,348 @@
1
+ import os
2
+ import pathlib
3
+ import sys
4
+
5
+ from jinja2 import Environment, FileSystemLoader
6
+ from weasyprint import HTML, CSS
7
+
8
+ if __name__ == "__main__" and __package__ is None:
9
+ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "src"))
10
+
11
+ from pdfgen.pagination import LayoutConfig, Paginator
12
+ from pdfgen.validator import validate_and_normalize
13
+
14
+ ROOT = pathlib.Path(__file__).resolve().parents[2]
15
+ PACKAGE_ROOT = pathlib.Path(__file__).resolve().parent
16
+ TEMPLATE_DIR = PACKAGE_ROOT / "templates"
17
+ TEMPLATE_NAME = "boletin_template.html.jinja"
18
+ CSS_PATH = TEMPLATE_DIR / "boletin.css"
19
+ OUTPUT_PDF = ROOT / "output.pdf"
20
+ FONTS_CONF = ROOT / "fonts.conf"
21
+
22
+
23
+ def build_sample_data():
24
+ banner = str((PACKAGE_ROOT / "assets" / "banner.png").resolve())
25
+ banner_clean = str((PACKAGE_ROOT / "assets" / "banner-clean.png").resolve())
26
+ logo = str((PACKAGE_ROOT / "assets" / "logo.png").resolve())
27
+
28
+ table_header = [
29
+ {
30
+ "title": "Consumo insuficiente de alimentos (Millones)",
31
+ "months": ["Enero", "Febrero", "Marzo"],
32
+ },
33
+ {
34
+ "title": "Estrategias de afrontamiento del hambre (Millones)",
35
+ "months": ["Enero", "Febrero", "Marzo"],
36
+ },
37
+ ]
38
+
39
+ small_rows = [
40
+ {"dep": "Amazonas", "vals": ["0,05", "0,04", "0,04", "0,05", "0,04", "0,04"]},
41
+ {"dep": "Antioquia", "vals": ["1,48", "1,63", "1,76", "1,82", "1,90", "2,05"]},
42
+ {"dep": "Arauca", "vals": ["0,15", "0,14", "0,11", "0,16", "0,12", "0,11"]},
43
+ ]
44
+
45
+ medium_rows = [
46
+ {"dep": "Atlantico", "vals": ["0,81", "0,69", "0,76", "0,63", "0,76", "0,76"]},
47
+ {"dep": "Bogota D. C.", "vals": ["1,88", "2,07", "2,23", "2,30", "2,41", "2,61"]},
48
+ {"dep": "Bolivar", "vals": ["0,61", "0,67", "0,72", "0,75", "0,78", "0,85"]},
49
+ {"dep": "Boyaca", "vals": ["0,28", "0,30", "0,33", "0,34", "0,35", "0,38"]},
50
+ {"dep": "Caldas", "vals": ["0,20", "0,22", "0,24", "0,24", "0,26", "0,28"]},
51
+ {"dep": "Caqueta", "vals": ["0,21", "0,20", "0,17", "0,24", "0,18", "0,17"]},
52
+ ]
53
+
54
+ large_rows = [
55
+ {"dep": "Cundinamarca", "vals": ["0,76", "0,84", "0,90", "0,93", "0,98", "1,06"]},
56
+ {"dep": "Guainia", "vals": ["0,04", "0,03", "0,03", "0,04", "0,03", "0,03"]},
57
+ {"dep": "Guaviare", "vals": ["0,06", "0,06", "0,05", "0,07", "0,05", "0,05"]},
58
+ {"dep": "Huila", "vals": ["0,27", "0,30", "0,32", "0,33", "0,35", "0,37"]},
59
+ {"dep": "La Guajira", "vals": ["0,36", "0,30", "0,28", "0,38", "0,40", "0,43"]},
60
+ {"dep": "Meta", "vals": ["0,41", "0,38", "0,32", "0,46", "0,34", "0,32"]},
61
+ {"dep": "Santander", "vals": ["0,50", "0,56", "0,60", "0,62", "0,65", "0,70"]},
62
+ {"dep": "La Guajira", "vals": ["0,36", "0,30", "0,28", "0,38", "0,40", "0,43"]},
63
+ {"dep": "Meta", "vals": ["0,41", "0,38", "0,32", "0,46", "0,34", "0,32"]},
64
+ {"dep": "Santander", "vals": ["0,50", "0,56", "0,60", "0,62", "0,65", "0,70"]},
65
+ {"dep": "La Guajira", "vals": ["0,36", "0,30", "0,28", "0,38", "0,40", "0,43"]},
66
+ {"dep": "Meta", "vals": ["0,41", "0,38", "0,32", "0,46", "0,34", "0,32"]},
67
+ {"dep": "Santander", "vals": ["0,50", "0,56", "0,60", "0,62", "0,65", "0,70"]},
68
+ {"dep": "Meta", "vals": ["0,41", "0,38", "0,32", "0,46", "0,34", "0,32"]},
69
+ {"dep": "Santander", "vals": ["0,50", "0,56", "0,60", "0,62", "0,65", "0,70"]},
70
+ {"dep": "La Guajira", "vals": ["0,36", "0,30", "0,28", "0,38", "0,40", "0,43"]},
71
+ {"dep": "Meta", "vals": ["0,41", "0,38", "0,32", "0,46", "0,34", "0,32"]},
72
+ {"dep": "Santander", "vals": ["0,50", "0,56", "0,60", "0,62", "0,65", "0,70"]},
73
+ {"dep": "Meta", "vals": ["0,41", "0,38", "0,32", "0,46", "0,34", "0,32"]},
74
+ {"dep": "Santander", "vals": ["0,50", "0,56", "0,60", "0,62", "0,65", "0,70"]},
75
+ {"dep": "La Guajira", "vals": ["0,36", "0,30", "0,28", "0,38", "0,40", "0,43"]},
76
+ {"dep": "Meta", "vals": ["0,41", "0,38", "0,32", "0,46", "0,34", "0,32"]},
77
+ {"dep": "Santander", "vals": ["0,50", "0,56", "0,60", "0,62", "0,65", "0,70"]},
78
+ {"dep": "Santander", "vals": ["0,50", "0,56", "0,60", "0,62", "0,65", "0,70"]},
79
+ {"dep": "Meta", "vals": ["0,41", "0,38", "0,32", "0,46", "0,34", "0,32"]},
80
+ {"dep": "Santander", "vals": ["0,50", "0,56", "0,60", "0,62", "0,65", "0,70"]},
81
+ {"dep": "La Guajira", "vals": ["0,36", "0,30", "0,28", "0,38", "0,40", "0,43"]},
82
+ {"dep": "Meta", "vals": ["0,41", "0,38", "0,32", "0,46", "0,34", "0,32"]},
83
+ {"dep": "Santander", "vals": ["0,50", "0,56", "0,60", "0,62", "0,65", "0,70"]},
84
+ {"dep": "Meta", "vals": ["0,41", "0,38", "0,32", "0,46", "0,34", "0,32"]},
85
+ {"dep": "Santander", "vals": ["0,50", "0,56", "0,60", "0,62", "0,65", "0,70"]},
86
+ {"dep": "La Guajira", "vals": ["0,36", "0,30", "0,28", "0,38", "0,40", "0,43"]},
87
+ {"dep": "Meta", "vals": ["0,41", "0,38", "0,32", "0,46", "0,34", "0,32"]},
88
+ {"dep": "Santander", "vals": ["0,50", "0,56", "0,60", "0,62", "0,65", "0,70"]},
89
+ {"dep": "Santander", "vals": ["0,50", "0,56", "0,60", "0,62", "0,65", "0,70"]},
90
+ {"dep": "Meta", "vals": ["0,41", "0,38", "0,32", "0,46", "0,34", "0,32"]},
91
+ {"dep": "Santander", "vals": ["0,50", "0,56", "0,60", "0,62", "0,65", "0,70"]},
92
+ {"dep": "La Guajira", "vals": ["0,36", "0,30", "0,28", "0,38", "0,40", "0,43"]},
93
+ {"dep": "Meta", "vals": ["0,41", "0,38", "0,32", "0,46", "0,34", "0,32"]},
94
+ {"dep": "Santander", "vals": ["0,50", "0,56", "0,60", "0,62", "0,65", "0,70"]},
95
+ {"dep": "Meta", "vals": ["0,41", "0,38", "0,32", "0,46", "0,34", "0,32"]},
96
+ {"dep": "Santander", "vals": ["0,50", "0,56", "0,60", "0,62", "0,65", "0,70"]},
97
+ {"dep": "La Guajira", "vals": ["0,36", "0,30", "0,28", "0,38", "0,40", "0,43"]},
98
+ {"dep": "Meta", "vals": ["0,41", "0,38", "0,32", "0,46", "0,34", "0,32"]},
99
+ {"dep": "Santander", "vals": ["0,50", "0,56", "0,60", "0,62", "0,65", "0,70"]},
100
+ ]
101
+
102
+ data = {
103
+ "title": "Reporte de Indicadores - Ejemplo Generico 2024",
104
+ "theme": {
105
+ "header_banner_path": banner,
106
+ "header_banner_path_cont": banner_clean,
107
+ "header_logo_path": logo,
108
+ "title_line1": "Reporte de Indicadores y Tendencias",
109
+ "title_line2": "Ejemplo Generico - 2024",
110
+ "footer_site": "example.org",
111
+ "footer_phone": "Contacto: +1 555 0100",
112
+ "show_header_titles": False,
113
+ },
114
+ "sections": [
115
+ {
116
+ "title": "I. Indice de Riesgo de la situacion alimentaria en Colombia",
117
+ "subtitle": "Historico de Mapas Departamentales de la Situacion Alimentaria en Colombia durante el Primer Trimestre del 2024.",
118
+ "content": [
119
+ {
120
+ "type": "text",
121
+ "text": [
122
+ "La Red de Bancos de Alimentos de Colombia ABACO presenta un analisis comparativo de indicadores clave sobre la situacion alimentaria y nutricional del pais en el I trimestre del ano 2024.",
123
+ "Este cambio resalta la necesidad de un seguimiento continuo y detallado de las tendencias en seguridad alimentaria a lo largo del ano.",
124
+ ],
125
+ "refs": [
126
+ "1 World Food Programme (WFP). (2024). HungerMap LIVE: Colombia insight and key trends [PDF]. 31 January 2024. P. 2",
127
+ "2 World Food Programme (WFP). (2024). HungerMap LIVE: Colombia insight and key trends [PDF]. 19 February 2024. P. 2",
128
+ ],
129
+ },
130
+ {
131
+ "type": "table",
132
+ "table": {
133
+ "groups": table_header,
134
+ "rows": small_rows,
135
+ "total_width": 532.66,
136
+ "dep_width": 120.0,
137
+ },
138
+ },
139
+ ],
140
+ },
141
+ {
142
+ "title": "II. Indicadores de prevalencia de Consumo Insuficiente de Alimentos.",
143
+ "content": [
144
+ {
145
+ "type": "table",
146
+ "table": {
147
+ "groups": table_header,
148
+ "rows": medium_rows,
149
+ "total_width": 532.66,
150
+ "dep_width": 120.0,
151
+ },
152
+ "refs": [
153
+ "4 World Food Programme (WFP). (2024). HungerMap LIVE: Colombia insight and key trends [PDF]. 31 January 2024. P. 2",
154
+ "5 World Food Programme (WFP). (2024). HungerMap LIVE: Colombia insight and key trends [PDF]. 19 February 2024. P. 2",
155
+ "6 World Food Programme (WFP). (2024). HungerMap LIVE: Colombia insight and key trends [PDF]. 31 March 2024. P. 2",
156
+ ],
157
+ }
158
+ ],
159
+ },
160
+ {
161
+ "title": "III. Estrategias de Afrontamiento a las Crisis Basadas en la Alimentacion.",
162
+ "content": [
163
+ {
164
+ "type": "table",
165
+ "table": {
166
+ "groups": table_header,
167
+ "rows": large_rows,
168
+ "total_width": 532.66,
169
+ "dep_width": 120.0,
170
+ },
171
+ }
172
+ ],
173
+ },
174
+ ],
175
+ }
176
+ return data
177
+
178
+
179
+ def _paragraphs_from_text(text):
180
+ if isinstance(text, list):
181
+ return [t.strip() for t in text if t and t.strip()]
182
+ if not text:
183
+ return []
184
+ chunks = []
185
+ for block in str(text).split("\n\n"):
186
+ block = " ".join([line.strip() for line in block.splitlines()]).strip()
187
+ if block:
188
+ chunks.append(block)
189
+ return chunks
190
+
191
+
192
+ def _section_heading_html(title, subtitle):
193
+ html = f"<div class=\"section-title\">{title}</div>" if title else ""
194
+ if subtitle:
195
+ html += f"<div class=\"section-subtitle\">{subtitle}</div>"
196
+ return html
197
+
198
+
199
+ def _map_grid_html(items):
200
+ blocks = []
201
+ for item in items:
202
+ path = item.get("path")
203
+ label = item.get("label", "")
204
+ blocks.append(
205
+ "<div class=\"map-item\">"
206
+ f"<img class=\"map-img\" src=\"{path}\" alt=\"{label}\" />"
207
+ f"<div class=\"map-label\">{label}</div>"
208
+ "</div>"
209
+ )
210
+ return "<div class=\"map-grid\">" + "".join(blocks) + "</div>"
211
+
212
+
213
+ def _figure_html(path, caption, wide=False):
214
+ cls = "figure figure-wide" if wide else "figure"
215
+ html = f"<img class=\"{cls}\" src=\"{path}\" alt=\"{caption}\" />"
216
+ if caption:
217
+ html += f"<div class=\"figure-caption\">{caption}</div>"
218
+ return html
219
+
220
+
221
+ def _blocks_from_section(section):
222
+ blocks = []
223
+ title_html = _section_heading_html(section.get("title"), section.get("subtitle"))
224
+ if title_html:
225
+ blocks.append({"type": "html", "html": title_html, "keep_with_next": True})
226
+
227
+ for item in section.get("content", []):
228
+ itype = item.get("type", "text")
229
+ if itype == "text":
230
+ paragraphs = _paragraphs_from_text(item.get("text"))
231
+ if paragraphs:
232
+ html = "".join(f"<p>{p}</p>" for p in paragraphs)
233
+ block = {"type": "html", "html": html}
234
+ else:
235
+ continue
236
+ elif itype == "figure":
237
+ block = {
238
+ "type": "html",
239
+ "html": _figure_html(item.get("path"), item.get("caption", ""), item.get("wide", False)),
240
+ }
241
+ elif itype == "map_grid":
242
+ html = _map_grid_html(item.get("items", []))
243
+ if item.get("caption"):
244
+ html += f"<div class=\"figure-caption\">{item['caption']}</div>"
245
+ if item.get("source"):
246
+ html += f"<div class=\"figure-source\">{item['source']}</div>"
247
+ block = {"type": "html", "html": html}
248
+ elif itype == "table":
249
+ block = {"type": "table", "table": item.get("table", {})}
250
+ else:
251
+ block = {"type": "html", "html": item.get("html", "")}
252
+
253
+ if item.get("refs"):
254
+ block["refs"] = item.get("refs")
255
+ blocks.append(block)
256
+
257
+ if section.get("refs") and blocks:
258
+ blocks[0].setdefault("refs", [])
259
+ blocks[0]["refs"].extend(section["refs"])
260
+
261
+ return blocks
262
+
263
+
264
+ def _build_pages_from_sections(data):
265
+ theme = data.get("theme", {})
266
+ pages = []
267
+ footer_notes = []
268
+ refs_catalog = data.get("refs_catalog", {})
269
+
270
+ if data.get("cover"):
271
+ cover = dict(theme)
272
+ cover.update(data["cover"])
273
+ cover["cover"] = True
274
+ pages.append(cover)
275
+
276
+ blocks = []
277
+ for section in data.get("sections", []):
278
+ blocks.extend(_blocks_from_section(section))
279
+ if section.get("footer_notes"):
280
+ footer_notes.extend(section["footer_notes"])
281
+
282
+ pages.append({
283
+ **theme,
284
+ "intro": "",
285
+ "blocks": blocks,
286
+ "refs": [],
287
+ "refs_catalog": refs_catalog,
288
+ "footer_notes": footer_notes,
289
+ "page_number": "1",
290
+ })
291
+
292
+ data["pages"] = pages
293
+ return data
294
+
295
+
296
+ def render_pdf(
297
+ data,
298
+ output_path=OUTPUT_PDF,
299
+ paginate=True,
300
+ validate=True,
301
+ template_dir=None,
302
+ css_path=None,
303
+ fonts_conf=None,
304
+ css_extra=None,
305
+ root_dir=None,
306
+ ):
307
+ root_dir = pathlib.Path(root_dir) if root_dir else ROOT
308
+ template_dir = pathlib.Path(template_dir) if template_dir else TEMPLATE_DIR
309
+ css_path = pathlib.Path(css_path) if css_path else CSS_PATH
310
+ fonts_conf = pathlib.Path(fonts_conf) if fonts_conf else None
311
+
312
+ if fonts_conf and fonts_conf.exists():
313
+ os.environ.setdefault("FONTCONFIG_FILE", str(fonts_conf))
314
+ env = Environment(loader=FileSystemLoader(str(template_dir)))
315
+ template = env.get_template(TEMPLATE_NAME)
316
+
317
+ if "sections" in data and "pages" not in data:
318
+ data = _build_pages_from_sections(data)
319
+
320
+ if validate:
321
+ data, warnings = validate_and_normalize(data, root_dir=root_dir)
322
+ for warning in warnings:
323
+ print(f"[validate] {warning}")
324
+
325
+ layout = LayoutConfig()
326
+ paginator = Paginator(layout, str(css_path), str(root_dir), fonts_conf_path=str(fonts_conf))
327
+ if paginate:
328
+ data["pages"] = paginator.paginate(data["pages"])
329
+ data["layout"] = layout.to_template()
330
+
331
+ html = template.render(**data)
332
+
333
+ stylesheets = [CSS(filename=str(css_path))]
334
+ if css_extra:
335
+ stylesheets.append(CSS(string=str(css_extra)))
336
+
337
+ HTML(string=html, base_url=str(root_dir)).write_pdf(output_path, stylesheets=stylesheets)
338
+
339
+ print(f"Wrote {output_path}")
340
+
341
+
342
+ def main():
343
+ data = build_sample_data()
344
+ render_pdf(data)
345
+
346
+
347
+ if __name__ == "__main__":
348
+ main()
pdfgen/schema.json ADDED
@@ -0,0 +1,126 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "title": "PDFGenDocument",
4
+ "type": "object",
5
+ "additionalProperties": true,
6
+ "properties": {
7
+ "title": {"type": "string"},
8
+ "refs_catalog": {
9
+ "type": "object",
10
+ "additionalProperties": {"type": "string"}
11
+ },
12
+ "theme": {
13
+ "type": "object",
14
+ "properties": {
15
+ "header_banner_path": {"type": "string"},
16
+ "header_banner_path_cont": {"type": "string"},
17
+ "header_logo_path": {"type": "string"},
18
+ "title_line1": {"type": "string"},
19
+ "title_line2": {"type": "string"},
20
+ "footer_site": {"type": "string"},
21
+ "footer_phone": {"type": "string"},
22
+ "show_header_titles": {"type": "boolean"}
23
+ },
24
+ "additionalProperties": true
25
+ },
26
+ "sections": {
27
+ "type": "array",
28
+ "items": {"$ref": "#/definitions/section"}
29
+ },
30
+ "pages": {
31
+ "type": "array",
32
+ "items": {"$ref": "#/definitions/page"}
33
+ }
34
+ },
35
+ "anyOf": [
36
+ {"required": ["sections"]},
37
+ {"required": ["pages"]}
38
+ ],
39
+ "definitions": {
40
+ "section": {
41
+ "type": "object",
42
+ "properties": {
43
+ "title": {"type": "string"},
44
+ "subtitle": {"type": "string"},
45
+ "content": {"type": "array", "items": {"$ref": "#/definitions/block"}},
46
+ "refs": {"type": "array", "items": {"type": "string"}},
47
+ "footer_notes": {"type": "array", "items": {"type": "string"}}
48
+ },
49
+ "required": ["content"],
50
+ "additionalProperties": true
51
+ },
52
+ "page": {
53
+ "type": "object",
54
+ "properties": {
55
+ "blocks": {"type": "array", "items": {"$ref": "#/definitions/block"}},
56
+ "refs": {"type": "array", "items": {"type": "string"}},
57
+ "footer_notes": {"type": "array", "items": {"type": "string"}}
58
+ },
59
+ "required": ["blocks"],
60
+ "additionalProperties": true
61
+ },
62
+ "block": {
63
+ "type": "object",
64
+ "properties": {
65
+ "type": {"type": "string", "enum": ["text", "table", "figure", "map_grid", "html"]},
66
+ "text": {"type": ["string", "array"], "items": {"type": "string"}},
67
+ "html": {"type": "string"},
68
+ "refs": {"type": "array", "items": {"type": "string"}},
69
+ "footer_notes": {"type": "array", "items": {"type": "string"}},
70
+ "table": {"$ref": "#/definitions/table"},
71
+ "path": {"type": "string"},
72
+ "caption": {"type": "string"},
73
+ "wide": {"type": "boolean"},
74
+ "items": {
75
+ "type": "array",
76
+ "items": {
77
+ "type": "object",
78
+ "properties": {
79
+ "path": {"type": "string"},
80
+ "label": {"type": "string"}
81
+ },
82
+ "required": ["path"],
83
+ "additionalProperties": true
84
+ }
85
+ },
86
+ "source": {"type": "string"}
87
+ },
88
+ "required": ["type"],
89
+ "additionalProperties": true
90
+ },
91
+ "table": {
92
+ "type": "object",
93
+ "properties": {
94
+ "groups": {
95
+ "type": "array",
96
+ "items": {
97
+ "type": "object",
98
+ "properties": {
99
+ "title": {"type": "string"},
100
+ "months": {"type": "array", "items": {"type": "string"}}
101
+ },
102
+ "required": ["title", "months"],
103
+ "additionalProperties": true
104
+ }
105
+ },
106
+ "rows": {
107
+ "type": "array",
108
+ "items": {
109
+ "type": "object",
110
+ "properties": {
111
+ "dep": {"type": "string"},
112
+ "vals": {"type": "array", "items": {"type": "string"}}
113
+ },
114
+ "required": ["dep", "vals"],
115
+ "additionalProperties": true
116
+ }
117
+ },
118
+ "total_width": {"type": "number"},
119
+ "dep_width": {"type": "number"},
120
+ "show_header": {"type": "boolean"}
121
+ },
122
+ "required": ["groups", "rows"],
123
+ "additionalProperties": true
124
+ }
125
+ }
126
+ }