qubicl-cli 0.1.0-dev.0 → 0.1.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 (91) hide show
  1. package/README.md +129 -19
  2. package/dist/SBOM.spdx.json +222 -0
  3. package/dist/THIRD_PARTY_NOTICES.txt +244 -0
  4. package/dist/assets/chromium-seccomp.json +181 -0
  5. package/dist/assets/computer/BROWSER_SKILLS_THIRD_PARTY_NOTICES.txt +23 -0
  6. package/dist/assets/computer/Dockerfile +191 -49
  7. package/dist/assets/computer/PLAYWRIGHT_THIRD_PARTY_NOTICES.txt +217 -0
  8. package/dist/assets/computer/SKILLS_THIRD_PARTY_NOTICES.txt +29 -0
  9. package/dist/assets/computer/THIRD_PARTY_NOTICES.txt +28 -0
  10. package/dist/assets/computer/WEB_THIRD_PARTY_NOTICES.txt +42 -0
  11. package/dist/assets/computer/browser-skills-requirements.txt +12 -0
  12. package/dist/assets/computer/chromium-wrapper.sh +12 -0
  13. package/dist/assets/computer/control.mjs +112 -52
  14. package/dist/assets/computer/entrypoint.sh +272 -35
  15. package/dist/assets/computer/libreoffice-registrymodifications.xcu +8 -0
  16. package/dist/assets/computer/manifests/browser.json +72 -0
  17. package/dist/assets/computer/manifests/computer.json +76 -0
  18. package/dist/assets/computer/manifests/file-system.json +42 -0
  19. package/dist/assets/computer/manifests/workstation.json +78 -0
  20. package/dist/assets/computer/node_modules/playwright-core/LICENSE +202 -0
  21. package/dist/assets/computer/node_modules/playwright-core/NOTICE +5 -0
  22. package/dist/assets/computer/node_modules/playwright-core/ThirdPartyNotices.txt +13 -0
  23. package/dist/assets/computer/node_modules/playwright-core/browsers.json +75 -0
  24. package/dist/assets/computer/node_modules/playwright-core/index.js +17 -0
  25. package/dist/assets/computer/node_modules/playwright-core/index.mjs +28 -0
  26. package/dist/assets/computer/node_modules/playwright-core/lib/bootstrap.js +88 -0
  27. package/dist/assets/computer/node_modules/playwright-core/lib/coreBundle.js +74830 -0
  28. package/dist/assets/computer/node_modules/playwright-core/lib/utilsBundle.js +90764 -0
  29. package/dist/assets/computer/node_modules/playwright-core/lib/utilsBundle.js.LICENSE +2179 -0
  30. package/dist/assets/computer/node_modules/playwright-core/lib/webp_codec.LICENSE +173 -0
  31. package/dist/assets/computer/node_modules/playwright-core/lib/webp_codec.wasm +0 -0
  32. package/dist/assets/computer/node_modules/playwright-core/lib/xdg-open +1267 -0
  33. package/dist/assets/computer/node_modules/playwright-core/package.json +34 -0
  34. package/dist/assets/computer/skills/core/docx/LICENSE +21 -0
  35. package/dist/assets/computer/skills/core/docx/SKILL.md +21 -0
  36. package/dist/assets/computer/skills/core/docx/references/revisions-and-comments.md +88 -0
  37. package/dist/assets/computer/skills/core/docx/scripts/docx_comments.py +289 -0
  38. package/dist/assets/computer/skills/core/docx/scripts/docx_common.py +94 -0
  39. package/dist/assets/computer/skills/core/docx/scripts/docx_create.py +177 -0
  40. package/dist/assets/computer/skills/core/docx/scripts/docx_edit.py +250 -0
  41. package/dist/assets/computer/skills/core/docx/scripts/docx_read.py +149 -0
  42. package/dist/assets/computer/skills/core/docx/scripts/docx_revisions.py +147 -0
  43. package/dist/assets/computer/skills/core/docx/scripts/docx_template.py +70 -0
  44. package/dist/assets/computer/skills/core/docx/scripts/docx_validate.py +156 -0
  45. package/dist/assets/computer/skills/core/ocr-and-documents/LICENSE +21 -0
  46. package/dist/assets/computer/skills/core/ocr-and-documents/SKILL.md +24 -0
  47. package/dist/assets/computer/skills/core/ocr-and-documents/scripts/ocr_document.py +132 -0
  48. package/dist/assets/computer/skills/core/pdf/LICENSE +21 -0
  49. package/dist/assets/computer/skills/core/pdf/SKILL.md +25 -0
  50. package/dist/assets/computer/skills/core/pdf/references/forms.md +100 -0
  51. package/dist/assets/computer/skills/core/pdf/scripts/_raster.py +76 -0
  52. package/dist/assets/computer/skills/core/pdf/scripts/pdf_create.py +130 -0
  53. package/dist/assets/computer/skills/core/pdf/scripts/pdf_fill_form.py +97 -0
  54. package/dist/assets/computer/skills/core/pdf/scripts/pdf_form_layout.py +168 -0
  55. package/dist/assets/computer/skills/core/pdf/scripts/pdf_make_form.py +145 -0
  56. package/dist/assets/computer/skills/core/pdf/scripts/pdf_merge.py +50 -0
  57. package/dist/assets/computer/skills/core/pdf/scripts/pdf_meta.py +115 -0
  58. package/dist/assets/computer/skills/core/pdf/scripts/pdf_page_image.py +99 -0
  59. package/dist/assets/computer/skills/core/pdf/scripts/pdf_read.py +153 -0
  60. package/dist/assets/computer/skills/core/pdf/scripts/pdf_secure.py +71 -0
  61. package/dist/assets/computer/skills/core/pdf/scripts/pdf_split.py +84 -0
  62. package/dist/assets/computer/skills/core/pdf/scripts/pdf_stamp.py +143 -0
  63. package/dist/assets/computer/skills/core/pdf/scripts/pdf_watermark.py +51 -0
  64. package/dist/assets/computer/skills/core/plan/LICENSE +21 -0
  65. package/dist/assets/computer/skills/core/plan/SKILL.md +21 -0
  66. package/dist/assets/computer/skills/core/powerpoint/LICENSE +21 -0
  67. package/dist/assets/computer/skills/core/powerpoint/SKILL.md +18 -0
  68. package/dist/assets/computer/skills/core/powerpoint/scripts/pptx_create.py +214 -0
  69. package/dist/assets/computer/skills/core/powerpoint/scripts/pptx_edit.py +436 -0
  70. package/dist/assets/computer/skills/core/powerpoint/scripts/pptx_from_template.py +88 -0
  71. package/dist/assets/computer/skills/core/powerpoint/scripts/pptx_read.py +131 -0
  72. package/dist/assets/computer/skills/core/powerpoint/scripts/pptx_render.py +93 -0
  73. package/dist/assets/computer/skills/core/xlsx/LICENSE +21 -0
  74. package/dist/assets/computer/skills/core/xlsx/SKILL.md +22 -0
  75. package/dist/assets/computer/skills/core/xlsx/references/restructuring.md +71 -0
  76. package/dist/assets/computer/skills/core/xlsx/scripts/csv_to_xlsx.py +104 -0
  77. package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_create.py +259 -0
  78. package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_edit.py +263 -0
  79. package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_read.py +160 -0
  80. package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_recalc.py +110 -0
  81. package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_restructure.py +337 -0
  82. package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_to_csv.py +69 -0
  83. package/dist/assets/computer/skills/core-catalog.json +246 -0
  84. package/dist/assets/computer/skills-requirements.txt +18 -0
  85. package/dist/assets/computer/web-provider.py +592 -0
  86. package/dist/assets/computer/web-requirements.txt +37 -0
  87. package/dist/assets/gateway/Dockerfile +3 -1
  88. package/dist/assets/gateway/gateway.mjs +69 -40
  89. package/dist/assets/image-catalog.json +183 -0
  90. package/dist/qubicl.mjs +297 -167
  91. package/package.json +8 -8
@@ -0,0 +1,436 @@
1
+ #!/usr/bin/env python3
2
+ """Edit a .pptx in place (or save to --output).
3
+
4
+ Operations (repeatable / combinable):
5
+ --replace-text OLD NEW Replace text everywhere (slides, tables, notes).
6
+ Adjacent runs with identical formatting are
7
+ merged first, so matches PowerPoint split across
8
+ identically-formatted runs keep their formatting.
9
+ Only a match spanning genuinely different
10
+ formats falls back to a paragraph rewrite with
11
+ the first run's font (documented caveat).
12
+ --chart-data SPEC.json Update a chart. Full replace spec:
13
+ {"slide": 0, "chart": 0,
14
+ "categories": ["Q1", "Q2"],
15
+ "series": {"North": [1, 2], "South": [3, 4]}}
16
+ Or surgical ops (existing data is read, modified,
17
+ and written back via replace_data):
18
+ {"slide": 0, "chart": 0, "ops": [
19
+ {"op": "update_series", "name": "North",
20
+ "values": [5, 6]},
21
+ {"op": "add_series", "name": "East",
22
+ "values": [1, 2]},
23
+ {"op": "remove_series", "name": "South"},
24
+ {"op": "rename_category", "from": "Q1",
25
+ "to": "Q1 FY26"},
26
+ {"op": "set_title", "title": "New title"}]}
27
+ --swap-image SLIDE SHAPE_NAME NEW_IMAGE
28
+ Replace a picture's bits, keeping position/size.
29
+ --remove-slide N Delete slide at index N (0-based).
30
+ --move-slide FROM TO Reorder: move slide FROM to position TO.
31
+ --duplicate-slide N Append an independent deep copy of slide N
32
+ (text, images, tables, shapes, notes). Refuses
33
+ slides containing charts (a chart embeds an XLSX
34
+ workbook part that cannot be cloned reliably).
35
+ --set-background N HEX Solid background color for slide N.
36
+ --hyperlink N TEXT URL Make runs containing TEXT on slide N links.
37
+ --enable-slide-number N Copy the layout's slide-number placeholder in.
38
+ --set-footer N TEXT Enable the layout's footer placeholder with TEXT.
39
+ --set-notes N TEXT Replace slide N's speaker notes.
40
+ --append-notes N TEXT Append a paragraph to slide N's speaker notes.
41
+ """
42
+ import argparse
43
+ import copy
44
+ import json
45
+ import sys
46
+
47
+ from lxml import etree
48
+ from pptx import Presentation
49
+ from pptx.chart.data import CategoryChartData
50
+ from pptx.dml.color import RGBColor
51
+ from pptx.enum.shapes import MSO_SHAPE_TYPE
52
+ from pptx.oxml.ns import qn
53
+
54
+ R_EMBED = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}"
55
+
56
+
57
+ def _run_format_key(r):
58
+ """Canonical string for a run's <a:rPr>; None when absent."""
59
+ rPr = r.find(qn("a:rPr"))
60
+ if rPr is None:
61
+ return None
62
+ return etree.tostring(rPr)
63
+
64
+
65
+ def normalize_runs(para):
66
+ """Merge adjacent runs whose formatting is byte-identical.
67
+
68
+ PowerPoint splits paragraph text into runs at spell-check and edit
69
+ boundaries even when formatting never changes; merging them back makes
70
+ cross-run text replacement lossless for the common case.
71
+ """
72
+ runs = list(para.runs)
73
+ i = 0
74
+ while i + 1 < len(runs):
75
+ a, b = runs[i], runs[i + 1]
76
+ if (_run_format_key(a._r) == _run_format_key(b._r)
77
+ and a._r.getnext() is b._r):
78
+ a.text = a.text + b.text
79
+ b._r.getparent().remove(b._r)
80
+ runs.pop(i + 1)
81
+ else:
82
+ i += 1
83
+
84
+
85
+ def replace_in_text_frame(text_frame, old, new):
86
+ count = 0
87
+ for para in text_frame.paragraphs:
88
+ joined = "".join(run.text for run in para.runs)
89
+ if old not in joined:
90
+ continue
91
+ if not any(old in run.text for run in para.runs):
92
+ # Match spans runs: merge identically-formatted neighbours
93
+ # first, which resolves pure spell-check splits losslessly.
94
+ normalize_runs(para)
95
+ if any(old in run.text for run in para.runs):
96
+ # Run-level replace: preserves each run's formatting exactly.
97
+ for run in para.runs:
98
+ if old in run.text:
99
+ count += run.text.count(old)
100
+ run.text = run.text.replace(old, new)
101
+ else:
102
+ # Match spans genuinely differently-formatted runs -> rewrite
103
+ # paragraph, keeping only the first run's formatting (caveat).
104
+ joined = "".join(run.text for run in para.runs)
105
+ count += joined.count(old)
106
+ first = para.runs[0]
107
+ first.text = joined.replace(old, new)
108
+ for run in para.runs[1:]:
109
+ run._r.getparent().remove(run._r)
110
+ return count
111
+
112
+
113
+ def iter_text_frames(slide):
114
+ for shape in slide.shapes:
115
+ if shape.has_text_frame:
116
+ yield shape.text_frame
117
+ if shape.has_table:
118
+ for row in shape.table.rows:
119
+ for cell in row.cells:
120
+ yield cell.text_frame
121
+ if slide.has_notes_slide:
122
+ yield slide.notes_slide.notes_text_frame
123
+
124
+
125
+ def replace_text(prs, old, new):
126
+ total = 0
127
+ for slide in prs.slides:
128
+ for tf in iter_text_frames(slide):
129
+ total += replace_in_text_frame(tf, old, new)
130
+ return total
131
+
132
+
133
+ def _read_chart_data(chart):
134
+ """Current categories and ordered (name, values) pairs of a chart."""
135
+ categories = [str(c) for c in chart.plots[0].categories]
136
+ series = []
137
+ for plot in chart.plots:
138
+ for s in plot.series:
139
+ try:
140
+ name = s.name
141
+ except (AttributeError, KeyError):
142
+ name = ""
143
+ series.append([name, list(s.values)])
144
+ return categories, series
145
+
146
+
147
+ def update_chart(prs, spec_path):
148
+ """Full replace ("categories"+"series") or surgical "ops".
149
+
150
+ python-pptx can only swap a chart's entire dataset (replace_data), so
151
+ surgical ops are implemented as read-existing -> modify -> replace.
152
+ """
153
+ with open(spec_path, encoding="utf-8") as fh:
154
+ spec = json.load(fh)
155
+ slide = prs.slides[spec.get("slide", 0)]
156
+ charts = [s.chart for s in slide.shapes if s.has_chart]
157
+ if not charts:
158
+ raise SystemExit(f"no chart on slide {spec.get('slide', 0)}")
159
+ chart = charts[spec.get("chart", 0)]
160
+
161
+ if "ops" in spec:
162
+ categories, series = _read_chart_data(chart)
163
+ dirty = False
164
+ for op in spec["ops"]:
165
+ kind = op["op"]
166
+ if kind == "update_series":
167
+ match = [s for s in series if s[0] == op["name"]]
168
+ if not match:
169
+ raise SystemExit(f"no series named {op['name']!r}")
170
+ match[0][1] = op["values"]
171
+ dirty = True
172
+ elif kind == "add_series":
173
+ series.append([op["name"], op["values"]])
174
+ dirty = True
175
+ elif kind == "remove_series":
176
+ before = len(series)
177
+ series = [s for s in series if s[0] != op["name"]]
178
+ if len(series) == before:
179
+ raise SystemExit(f"no series named {op['name']!r}")
180
+ dirty = True
181
+ elif kind == "rename_category":
182
+ if "index" in op:
183
+ idx = int(op["index"])
184
+ else:
185
+ if op["from"] not in categories:
186
+ raise SystemExit(
187
+ f"no category named {op['from']!r}")
188
+ idx = categories.index(op["from"])
189
+ categories[idx] = op["to"]
190
+ dirty = True
191
+ elif kind == "set_title":
192
+ chart.has_title = True
193
+ chart.chart_title.text_frame.text = op["title"]
194
+ else:
195
+ raise SystemExit(f"unknown chart op {kind!r}")
196
+ if dirty:
197
+ data = CategoryChartData()
198
+ data.categories = categories
199
+ for name, values in series:
200
+ data.add_series(name, values)
201
+ chart.replace_data(data)
202
+ return
203
+
204
+ data = CategoryChartData()
205
+ data.categories = spec["categories"]
206
+ for name, values in spec["series"].items():
207
+ data.add_series(name, values)
208
+ chart.replace_data(data)
209
+
210
+
211
+ def swap_image(prs, slide_idx, shape_name, new_path):
212
+ slide = prs.slides[int(slide_idx)]
213
+ for shape in slide.shapes:
214
+ if (shape.shape_type == MSO_SHAPE_TYPE.PICTURE
215
+ and shape.name == shape_name):
216
+ image_part, rid = slide.part.get_or_add_image_part(new_path)
217
+ blip = shape._element.blipFill.blip
218
+ blip.set(R_EMBED + "embed", rid)
219
+ return True
220
+ raise SystemExit(f"no picture named {shape_name!r} on slide {slide_idx}")
221
+
222
+
223
+ def remove_slide(prs, index):
224
+ sldIdLst = prs.slides._sldIdLst
225
+ slide_id = list(sldIdLst)[int(index)]
226
+ rid = slide_id.get(R_EMBED + "id")
227
+ prs.part.drop_rel(rid)
228
+ sldIdLst.remove(slide_id)
229
+
230
+
231
+ def move_slide(prs, src, dst):
232
+ """Reorder by moving the <p:sldId> element inside <p:sldIdLst>."""
233
+ sldIdLst = prs.slides._sldIdLst
234
+ ids = list(sldIdLst)
235
+ element = ids[int(src)]
236
+ sldIdLst.remove(element)
237
+ sldIdLst.insert(int(dst), element)
238
+
239
+
240
+ def duplicate_slide(prs, index):
241
+ """Append an independent deep copy of slide `index`.
242
+
243
+ Copies the shape tree XML and re-creates image/media relationships on
244
+ the new slide part, remapping rIds. Charts are refused: each chart
245
+ relationship embeds a separate XLSX workbook part, and cloning that
246
+ graph reliably is not supported — better to refuse than corrupt.
247
+ """
248
+ source = prs.slides[int(index)]
249
+ if any(sh.has_chart for sh in source.shapes):
250
+ raise SystemExit(
251
+ f"slide {index} contains a chart; duplication of chart slides "
252
+ "is not supported (chart XML embeds a workbook part that "
253
+ "cannot be cloned safely). Rebuild the chart on a new slide "
254
+ "with pptx_create.py / pptx_from_template.py instead.")
255
+
256
+ dest = prs.slides.add_slide(source.slide_layout)
257
+ # drop the placeholders add_slide seeded from the layout
258
+ for shape in list(dest.shapes):
259
+ shape._element.getparent().remove(shape._element)
260
+
261
+ for shape in source.shapes:
262
+ dest.shapes._spTree.append(copy.deepcopy(shape._element))
263
+
264
+ # re-create the source slide's part relationships on the copy
265
+ rid_map = {}
266
+ for rel in list(source.part.rels.values()):
267
+ if rel.reltype.endswith(("/slideLayout", "/notesSlide")):
268
+ continue
269
+ if rel.is_external:
270
+ new_rid = dest.part.rels.get_or_add_ext_rel(
271
+ rel.reltype, rel.target_ref)
272
+ else:
273
+ new_rid = dest.part.relate_to(rel.target_part, rel.reltype)
274
+ rid_map[rel.rId] = new_rid
275
+
276
+ for el in dest.shapes._spTree.iter():
277
+ for attr, val in el.attrib.items():
278
+ if attr.startswith(R_EMBED) and val in rid_map:
279
+ el.set(attr, rid_map[val])
280
+
281
+ if source.has_notes_slide:
282
+ dest.notes_slide.notes_text_frame.text = (
283
+ source.notes_slide.notes_text_frame.text)
284
+ return len(prs.slides._sldIdLst) - 1
285
+
286
+
287
+ def set_background(slide, hex_color):
288
+ fill = slide.background.fill
289
+ fill.solid()
290
+ fill.fore_color.rgb = RGBColor.from_string(hex_color)
291
+
292
+
293
+ def add_hyperlink(prs, slide_idx, text, url):
294
+ """Turn every run containing `text` on the slide into a hyperlink.
295
+
296
+ The link applies to the whole run (python-pptx links whole runs).
297
+ """
298
+ slide = prs.slides[int(slide_idx)]
299
+ hits = 0
300
+ for shape in slide.shapes:
301
+ if not shape.has_text_frame:
302
+ continue
303
+ for para in shape.text_frame.paragraphs:
304
+ for run in para.runs:
305
+ if text in run.text:
306
+ run.hyperlink.address = url
307
+ hits += 1
308
+ if not hits:
309
+ raise SystemExit(f"no run containing {text!r} on slide {slide_idx}")
310
+ return hits
311
+
312
+
313
+ def _copy_layout_placeholder(slide, ph_idx):
314
+ """Copy the layout placeholder with idx `ph_idx` onto the slide.
315
+
316
+ Slide-number (idx 12) and footer (idx 11) placeholders exist on the
317
+ layout but are not inherited by a slide until the slide carries its
318
+ own copy — this enables them. Returns the new shape, or None when the
319
+ layout does not provide that placeholder.
320
+ """
321
+ for ph in slide.slide_layout.placeholders:
322
+ if ph.placeholder_format.idx == ph_idx:
323
+ el = copy.deepcopy(ph._element)
324
+ slide.shapes._spTree.append(el)
325
+ for shape in slide.placeholders:
326
+ if shape.placeholder_format.idx == ph_idx:
327
+ return shape
328
+ return None
329
+ return None
330
+
331
+
332
+ def enable_slide_number(slide):
333
+ if any(ph.placeholder_format.idx == 12 for ph in slide.placeholders):
334
+ return True
335
+ return _copy_layout_placeholder(slide, 12) is not None
336
+
337
+
338
+ def set_footer(slide, text):
339
+ shape = next((ph for ph in slide.placeholders
340
+ if ph.placeholder_format.idx == 11), None)
341
+ if shape is None:
342
+ shape = _copy_layout_placeholder(slide, 11)
343
+ if shape is None:
344
+ raise SystemExit("layout provides no footer placeholder; add a "
345
+ "textbox instead")
346
+ shape.text_frame.text = text
347
+ return True
348
+
349
+
350
+ def set_notes(slide, text, append=False):
351
+ tf = slide.notes_slide.notes_text_frame
352
+ if append and tf.text:
353
+ para = tf.add_paragraph()
354
+ para.text = text
355
+ else:
356
+ tf.text = text
357
+
358
+
359
+ def main(argv=None):
360
+ if hasattr(sys.stdout, "reconfigure"):
361
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
362
+ parser = argparse.ArgumentParser(
363
+ description="Edit a .pptx: replace text, update chart data, swap "
364
+ "images, duplicate/remove/reorder slides, backgrounds, "
365
+ "hyperlinks, footers, slide numbers, speaker notes.",
366
+ epilog=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
367
+ parser.add_argument("pptx", help="path to the .pptx file")
368
+ parser.add_argument("--output", help="save to this path instead of "
369
+ "overwriting the input")
370
+ parser.add_argument("--replace-text", nargs=2, action="append",
371
+ metavar=("OLD", "NEW"), default=[])
372
+ parser.add_argument("--chart-data", metavar="SPEC_JSON")
373
+ parser.add_argument("--swap-image", nargs=3,
374
+ metavar=("SLIDE", "SHAPE_NAME", "IMAGE"))
375
+ parser.add_argument("--remove-slide", type=int, metavar="N")
376
+ parser.add_argument("--move-slide", nargs=2, type=int,
377
+ metavar=("FROM", "TO"))
378
+ parser.add_argument("--duplicate-slide", type=int, metavar="N")
379
+ parser.add_argument("--set-background", nargs=2,
380
+ metavar=("SLIDE", "HEX"))
381
+ parser.add_argument("--hyperlink", nargs=3,
382
+ metavar=("SLIDE", "TEXT", "URL"))
383
+ parser.add_argument("--enable-slide-number", type=int, metavar="N")
384
+ parser.add_argument("--set-footer", nargs=2, metavar=("SLIDE", "TEXT"))
385
+ parser.add_argument("--set-notes", nargs=2, metavar=("SLIDE", "TEXT"))
386
+ parser.add_argument("--append-notes", nargs=2, metavar=("SLIDE", "TEXT"))
387
+ args = parser.parse_args(argv)
388
+
389
+ prs = Presentation(args.pptx)
390
+ report = {"ok": True, "replacements": 0}
391
+
392
+ for old, new in args.replace_text:
393
+ report["replacements"] += replace_text(prs, old, new)
394
+ if args.chart_data:
395
+ update_chart(prs, args.chart_data)
396
+ report["chart_updated"] = True
397
+ if args.swap_image:
398
+ swap_image(prs, *args.swap_image)
399
+ report["image_swapped"] = True
400
+ if args.duplicate_slide is not None:
401
+ report["duplicated_to"] = duplicate_slide(prs, args.duplicate_slide)
402
+ if args.set_background:
403
+ set_background(prs.slides[int(args.set_background[0])],
404
+ args.set_background[1])
405
+ report["background_set"] = True
406
+ if args.hyperlink:
407
+ report["hyperlinked_runs"] = add_hyperlink(prs, *args.hyperlink)
408
+ if args.enable_slide_number is not None:
409
+ report["slide_number_enabled"] = enable_slide_number(
410
+ prs.slides[args.enable_slide_number])
411
+ if args.set_footer:
412
+ set_footer(prs.slides[int(args.set_footer[0])], args.set_footer[1])
413
+ report["footer_set"] = True
414
+ if args.set_notes:
415
+ set_notes(prs.slides[int(args.set_notes[0])], args.set_notes[1])
416
+ report["notes_set"] = True
417
+ if args.append_notes:
418
+ set_notes(prs.slides[int(args.append_notes[0])],
419
+ args.append_notes[1], append=True)
420
+ report["notes_appended"] = True
421
+ if args.remove_slide is not None:
422
+ remove_slide(prs, args.remove_slide)
423
+ report["slide_removed"] = args.remove_slide
424
+ if args.move_slide:
425
+ move_slide(prs, *args.move_slide)
426
+ report["slide_moved"] = args.move_slide
427
+
428
+ out = args.output or args.pptx
429
+ prs.save(out)
430
+ report["output"] = out
431
+ print(json.dumps(report))
432
+ return 0
433
+
434
+
435
+ if __name__ == "__main__":
436
+ sys.exit(main())
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env python3
2
+ """Build a deck from a .pptx template (brand deck) and fill placeholders.
3
+
4
+ Two modes:
5
+ 1) Token fill (default): open TEMPLATE, replace every {{token}} across
6
+ slides, tables, and notes using --values JSON ({"token": "value"}),
7
+ save to OUTPUT. Formatting of the token's run is preserved.
8
+ 2) --add-slides SPEC.json: additionally append slides built from the
9
+ template's own layouts (referenced by layout name or index), so new
10
+ slides inherit the brand master. Spec:
11
+ {"slides": [{"layout": "Title and Content", "title": "New",
12
+ "bullets": ["a", {"text": "b", "level": 1}],
13
+ "notes": "presenter text"}]}
14
+ """
15
+ import argparse
16
+ import json
17
+ import sys
18
+
19
+ from pptx import Presentation
20
+
21
+
22
+ def fill_tokens(prs, values):
23
+ from pptx_edit import replace_text # same scripts/ directory
24
+ total = 0
25
+ for token, value in values.items():
26
+ total += replace_text(prs, "{{%s}}" % token, str(value))
27
+ return total
28
+
29
+
30
+ def find_layout(prs, ref):
31
+ if isinstance(ref, int):
32
+ return prs.slide_layouts[ref]
33
+ for layout in prs.slide_layouts:
34
+ if layout.name == ref:
35
+ return layout
36
+ raise SystemExit(f"layout {ref!r} not found; available: "
37
+ f"{[la.name for la in prs.slide_layouts]}")
38
+
39
+
40
+ def add_slides(prs, spec):
41
+ from pptx_create import add_bullets
42
+ for slide_spec in spec.get("slides", []):
43
+ layout = find_layout(prs, slide_spec.get("layout", 1))
44
+ slide = prs.slides.add_slide(layout)
45
+ if slide_spec.get("title") is not None and slide.shapes.title:
46
+ slide.shapes.title.text = slide_spec["title"]
47
+ if slide_spec.get("bullets"):
48
+ body = next((ph for ph in slide.placeholders
49
+ if ph.placeholder_format.idx != 0), None)
50
+ if body is not None:
51
+ add_bullets(body.text_frame, slide_spec["bullets"])
52
+ if slide_spec.get("notes"):
53
+ slide.notes_slide.notes_text_frame.text = slide_spec["notes"]
54
+
55
+
56
+ def main(argv=None):
57
+ if hasattr(sys.stdout, "reconfigure"):
58
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
59
+ parser = argparse.ArgumentParser(
60
+ description="Fill {{tokens}} in a .pptx template and optionally "
61
+ "append slides using the template's own layouts.",
62
+ epilog=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
63
+ parser.add_argument("template", help="path to the template .pptx")
64
+ parser.add_argument("output", help="output .pptx path")
65
+ parser.add_argument("--values", metavar="JSON",
66
+ help="JSON file mapping token -> replacement value")
67
+ parser.add_argument("--add-slides", metavar="SPEC_JSON",
68
+ help="JSON spec of slides to append")
69
+ args = parser.parse_args(argv)
70
+
71
+ prs = Presentation(args.template)
72
+ filled = 0
73
+ if args.values:
74
+ with open(args.values, encoding="utf-8") as fh:
75
+ filled = fill_tokens(prs, json.load(fh))
76
+ if args.add_slides:
77
+ with open(args.add_slides, encoding="utf-8") as fh:
78
+ add_slides(prs, json.load(fh))
79
+ prs.save(args.output)
80
+ print(json.dumps({"ok": True, "output": args.output,
81
+ "tokens_filled": filled}))
82
+ return 0
83
+
84
+
85
+ if __name__ == "__main__":
86
+ import os
87
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
88
+ sys.exit(main())
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env python3
2
+ """Read a .pptx file: JSON outline, notes, or export embedded images.
3
+
4
+ Modes:
5
+ --outline JSON with per-slide layout, texts, tables, notes,
6
+ chart data, and image inventory (default mode).
7
+ --notes JSON list of speaker notes per slide.
8
+ --images DIR Export every embedded picture to DIR as files.
9
+ """
10
+ import argparse
11
+ import json
12
+ import os
13
+ import sys
14
+
15
+ from pptx import Presentation
16
+ from pptx.enum.shapes import MSO_SHAPE_TYPE
17
+ from pptx.util import Emu
18
+
19
+
20
+ def iter_shapes(shapes):
21
+ """Yield shapes, descending into groups."""
22
+ for shape in shapes:
23
+ if shape.shape_type == MSO_SHAPE_TYPE.GROUP:
24
+ yield from iter_shapes(shape.shapes)
25
+ else:
26
+ yield shape
27
+
28
+
29
+ def chart_info(chart):
30
+ info = {"type": str(chart.chart_type),
31
+ "categories": [str(c) for c in chart.plots[0].categories],
32
+ "series": []}
33
+ for plot in chart.plots:
34
+ for series in plot.series:
35
+ try:
36
+ name = series.name
37
+ except (AttributeError, KeyError):
38
+ name = None
39
+ info["series"].append({"name": name,
40
+ "values": list(series.values)})
41
+ return info
42
+
43
+
44
+ def slide_record(index, slide):
45
+ rec = {"index": index, "layout": slide.slide_layout.name,
46
+ "texts": [], "tables": [], "images": [], "charts": [],
47
+ "notes": None}
48
+ for shape in iter_shapes(slide.shapes):
49
+ if shape.has_text_frame and shape.text_frame.text.strip():
50
+ rec["texts"].append(shape.text_frame.text)
51
+ if shape.has_table:
52
+ rec["tables"].append(
53
+ [[cell.text for cell in row.cells]
54
+ for row in shape.table.rows])
55
+ if shape.shape_type == MSO_SHAPE_TYPE.PICTURE:
56
+ try:
57
+ img = shape.image
58
+ rec["images"].append({"filename": img.filename,
59
+ "ext": img.ext,
60
+ "size_bytes": len(img.blob)})
61
+ except (KeyError, ValueError):
62
+ rec["images"].append({"filename": None, "ext": None,
63
+ "size_bytes": None,
64
+ "note": "linked or unreadable"})
65
+ if shape.has_chart:
66
+ rec["charts"].append(chart_info(shape.chart))
67
+ if slide.has_notes_slide:
68
+ rec["notes"] = slide.notes_slide.notes_text_frame.text
69
+ return rec
70
+
71
+
72
+ def export_images(prs, out_dir):
73
+ os.makedirs(out_dir, exist_ok=True)
74
+ written = []
75
+ for i, slide in enumerate(prs.slides):
76
+ for j, shape in enumerate(iter_shapes(slide.shapes)):
77
+ if shape.shape_type != MSO_SHAPE_TYPE.PICTURE:
78
+ continue
79
+ try:
80
+ img = shape.image
81
+ except (KeyError, ValueError):
82
+ continue
83
+ path = os.path.join(out_dir, f"slide{i}_img{j}.{img.ext}")
84
+ with open(path, "wb") as fh:
85
+ fh.write(img.blob)
86
+ written.append(path)
87
+ return written
88
+
89
+
90
+ def main(argv=None):
91
+ if hasattr(sys.stdout, "reconfigure"):
92
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
93
+ parser = argparse.ArgumentParser(
94
+ description="Read a .pptx: outline/notes as JSON, export images.")
95
+ parser.add_argument("pptx", help="path to the .pptx file")
96
+ parser.add_argument("--outline", action="store_true",
97
+ help="print full JSON outline (default)")
98
+ parser.add_argument("--notes", action="store_true",
99
+ help="print speaker notes only")
100
+ parser.add_argument("--images", metavar="DIR",
101
+ help="export embedded images into DIR")
102
+ args = parser.parse_args(argv)
103
+
104
+ prs = Presentation(args.pptx)
105
+
106
+ if args.images:
107
+ written = export_images(prs, args.images)
108
+ print(json.dumps({"ok": True, "exported": written}, indent=2))
109
+ return 0
110
+ if args.notes:
111
+ notes = [slide.notes_slide.notes_text_frame.text
112
+ if slide.has_notes_slide else None
113
+ for slide in prs.slides]
114
+ print(json.dumps({"ok": True, "notes": notes},
115
+ indent=2, ensure_ascii=True))
116
+ return 0
117
+
118
+ outline = {
119
+ "ok": True,
120
+ "slide_size_inches": [round(Emu(prs.slide_width).inches, 3),
121
+ round(Emu(prs.slide_height).inches, 3)],
122
+ "slide_count": len(prs.slides._sldIdLst),
123
+ "layouts_available": [lay.name for lay in prs.slide_layouts],
124
+ "slides": [slide_record(i, s) for i, s in enumerate(prs.slides)],
125
+ }
126
+ print(json.dumps(outline, indent=2, ensure_ascii=True))
127
+ return 0
128
+
129
+
130
+ if __name__ == "__main__":
131
+ sys.exit(main())