qubicl-cli 0.1.0-dev.0 → 0.1.0

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 +46 -21
  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 +7 -7
@@ -0,0 +1,177 @@
1
+ #!/usr/bin/env python3
2
+ # MIT-licensed helper adapted for Qubicl's native DOCX skill.
3
+ """Create a .docx document from a JSON spec.
4
+
5
+ Usage: docx_create.py spec.json output.docx
6
+ Run with --help for the spec format summary.
7
+
8
+ Spec (JSON object):
9
+ {
10
+ "page": {"width_mm": 210, "height_mm": 297,
11
+ "margins_mm": {"top": 25, "bottom": 25, "left": 20, "right": 20}},
12
+ "header": "text shown in page header",
13
+ "footer": "text shown in page footer",
14
+ "styles": [{"name": "MyStyle", "base": "Normal", "font": "Arial",
15
+ "size_pt": 12, "bold": true, "color": "1F4E79"}],
16
+ "blocks": [
17
+ {"type": "heading", "text": "Title", "level": 1},
18
+ {"type": "paragraph", "style": "MyStyle", "runs": [
19
+ {"text": "plain "}, {"text": "bold", "bold": true},
20
+ {"text": " italic", "italic": true},
21
+ {"text": " under", "underline": true}]},
22
+ {"type": "paragraph", "text": "shortcut: single plain run"},
23
+ {"type": "bullet_list", "items": ["a", "b"]},
24
+ {"type": "numbered_list", "items": ["one", "two"]},
25
+ {"type": "table", "header": ["Col1", "Col2"],
26
+ "rows": [["1", "2"]], "style": "Light Grid Accent 1",
27
+ "header_bold": true},
28
+ {"type": "image", "path": "pic.png", "width_mm": 60},
29
+ {"type": "page_break"},
30
+ {"type": "toc"}
31
+ ]
32
+ }
33
+
34
+ Extras: `"footer_page_numbers": true` at the top level adds a
35
+ "Page X of Y" footer built from PAGE/NUMPAGES fields, and a `toc` block
36
+ inserts a Table of Contents field. Field results are computed by
37
+ Word/LibreOffice when the file is opened, not by python-docx.
38
+ """
39
+ from __future__ import annotations
40
+
41
+ import argparse
42
+ import json
43
+ import sys
44
+
45
+ from docx import Document
46
+ from docx.enum.style import WD_STYLE_TYPE
47
+ from docx.enum.text import WD_BREAK
48
+ from docx.shared import Mm, Pt, RGBColor
49
+
50
+
51
+ def apply_page(doc, page: dict) -> None:
52
+ section = doc.sections[0]
53
+ if "width_mm" in page:
54
+ section.page_width = Mm(page["width_mm"])
55
+ if "height_mm" in page:
56
+ section.page_height = Mm(page["height_mm"])
57
+ m = page.get("margins_mm", {})
58
+ for side in ("top", "bottom", "left", "right"):
59
+ if side in m:
60
+ setattr(section, f"{side}_margin", Mm(m[side]))
61
+
62
+
63
+ def add_styles(doc, styles: list) -> None:
64
+ for s in styles:
65
+ style = doc.styles.add_style(s["name"], WD_STYLE_TYPE.PARAGRAPH)
66
+ if s.get("base"):
67
+ style.base_style = doc.styles[s["base"]]
68
+ font = style.font
69
+ if s.get("font"):
70
+ font.name = s["font"]
71
+ if s.get("size_pt"):
72
+ font.size = Pt(s["size_pt"])
73
+ if s.get("bold") is not None:
74
+ font.bold = s["bold"]
75
+ if s.get("italic") is not None:
76
+ font.italic = s["italic"]
77
+ if s.get("color"):
78
+ font.color.rgb = RGBColor.from_string(s["color"])
79
+
80
+
81
+ def add_runs(para, block: dict) -> None:
82
+ runs = block.get("runs")
83
+ if runs is None:
84
+ runs = [{"text": block.get("text", "")}]
85
+ for r in runs:
86
+ run = para.add_run(r.get("text", ""))
87
+ if r.get("bold"):
88
+ run.bold = True
89
+ if r.get("italic"):
90
+ run.italic = True
91
+ if r.get("underline"):
92
+ run.underline = True
93
+
94
+
95
+ def add_block(doc, block: dict) -> None:
96
+ btype = block["type"]
97
+ if btype == "heading":
98
+ doc.add_heading(block.get("text", ""), level=block.get("level", 1))
99
+ elif btype == "paragraph":
100
+ para = doc.add_paragraph(style=block.get("style"))
101
+ add_runs(para, block)
102
+ elif btype == "bullet_list":
103
+ for item in block.get("items", []):
104
+ doc.add_paragraph(item, style="List Bullet")
105
+ elif btype == "numbered_list":
106
+ for item in block.get("items", []):
107
+ doc.add_paragraph(item, style="List Number")
108
+ elif btype == "table":
109
+ header = block.get("header", [])
110
+ rows = block.get("rows", [])
111
+ ncols = len(header) if header else (len(rows[0]) if rows else 1)
112
+ table = doc.add_table(rows=0, cols=ncols)
113
+ table.style = block.get("style", "Table Grid")
114
+ if header:
115
+ cells = table.add_row().cells
116
+ for i, text in enumerate(header):
117
+ cells[i].text = str(text)
118
+ if block.get("header_bold", True):
119
+ for para in cells[i].paragraphs:
120
+ for run in para.runs:
121
+ run.bold = True
122
+ for row in rows:
123
+ cells = table.add_row().cells
124
+ for i, text in enumerate(row):
125
+ cells[i].text = str(text)
126
+ elif btype == "image":
127
+ width = Mm(block["width_mm"]) if block.get("width_mm") else None
128
+ doc.add_picture(block["path"], width=width)
129
+ elif btype == "page_break":
130
+ doc.add_paragraph().add_run().add_break(WD_BREAK.PAGE)
131
+ elif btype == "toc":
132
+ from docx_edit import _add_field
133
+ para = doc.add_paragraph()
134
+ _add_field(para, r' TOC \o "1-3" \h \z \u ',
135
+ "Table of contents - open in Word/LibreOffice and "
136
+ "update fields to populate.")
137
+ else:
138
+ raise ValueError(f"unknown block type: {btype}")
139
+
140
+
141
+ def main() -> int:
142
+ ap = argparse.ArgumentParser(
143
+ description="Create a .docx from a JSON spec.",
144
+ epilog="See the module docstring (top of this file) for the spec format.")
145
+ ap.add_argument("spec", help="path to JSON spec file")
146
+ ap.add_argument("output", help="path of .docx to write")
147
+ args = ap.parse_args()
148
+
149
+ with open(args.spec, encoding="utf-8") as f:
150
+ spec = json.load(f)
151
+
152
+ doc = Document()
153
+ if spec.get("page"):
154
+ apply_page(doc, spec["page"])
155
+ if spec.get("styles"):
156
+ add_styles(doc, spec["styles"])
157
+ if spec.get("header"):
158
+ doc.sections[0].header.paragraphs[0].text = spec["header"]
159
+ if spec.get("footer"):
160
+ doc.sections[0].footer.paragraphs[0].text = spec["footer"]
161
+ for block in spec.get("blocks", []):
162
+ add_block(doc, block)
163
+ if spec.get("footer_page_numbers"):
164
+ from docx_edit import _add_field
165
+ para = doc.sections[0].footer.paragraphs[0]
166
+ para.add_run("Page ")
167
+ _add_field(para, " PAGE ", "1")
168
+ para.add_run(" of ")
169
+ _add_field(para, " NUMPAGES ", "1")
170
+ doc.save(args.output)
171
+ print(json.dumps({"ok": True, "output": args.output,
172
+ "blocks": len(spec.get("blocks", []))}))
173
+ return 0
174
+
175
+
176
+ if __name__ == "__main__":
177
+ sys.exit(main())
@@ -0,0 +1,250 @@
1
+ #!/usr/bin/env python3
2
+ # MIT-licensed helper adapted for Qubicl's native DOCX skill.
3
+ """Edit an existing .docx in place (or to a new file).
4
+
5
+ Subcommands:
6
+ replace find-and-replace text, preserving run formatting
7
+ set-cell set the text of a table cell
8
+ insert insert a paragraph before a given body paragraph index
9
+ delete delete a body paragraph by index
10
+ style apply a paragraph style to a body paragraph by index
11
+ normalize merge adjacent runs with identical formatting
12
+ toc insert a Table of Contents field at a body paragraph index
13
+ page-numbers add "Page X of Y" (PAGE/NUMPAGES fields) to the footer
14
+
15
+ Examples:
16
+ docx_edit.py replace in.docx --find old --replace new -o out.docx
17
+ docx_edit.py set-cell in.docx --table 0 --row 1 --col 2 --text "42"
18
+ docx_edit.py insert in.docx --index 3 --text "New para" --style Normal
19
+ docx_edit.py delete in.docx --index 3
20
+ docx_edit.py style in.docx --index 0 --style "Heading 1"
21
+ docx_edit.py normalize in.docx -o out.docx
22
+ docx_edit.py toc in.docx --index 1 -o out.docx
23
+ docx_edit.py page-numbers in.docx -o out.docx
24
+
25
+ Field results (TOC entries, page numbers) are computed by Word or
26
+ LibreOffice when the document is opened, not by python-docx; until then
27
+ the fields show placeholder text.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import argparse
32
+ import json
33
+ import sys
34
+
35
+ from docx import Document
36
+
37
+ from docx_common import iter_all_paragraphs, replace_in_paragraph
38
+
39
+ W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
40
+
41
+
42
+ def _q(tag: str) -> str:
43
+ return f"{{{W}}}{tag}"
44
+
45
+
46
+ def cmd_replace(doc, args) -> dict:
47
+ n = 0
48
+ for para in iter_all_paragraphs(doc):
49
+ n += replace_in_paragraph(para, args.find, args.replace)
50
+ return {"replacements": n}
51
+
52
+
53
+ def cmd_set_cell(doc, args) -> dict:
54
+ cell = doc.tables[args.table].cell(args.row, args.col)
55
+ cell.text = args.text
56
+ return {"table": args.table, "row": args.row, "col": args.col}
57
+
58
+
59
+ def cmd_insert(doc, args) -> dict:
60
+ paras = doc.paragraphs
61
+ if args.index < len(paras):
62
+ anchor = paras[args.index]
63
+ new_para = anchor.insert_paragraph_before(args.text, style=args.style)
64
+ else:
65
+ new_para = doc.add_paragraph(args.text, style=args.style)
66
+ return {"inserted_at": args.index, "text": new_para.text}
67
+
68
+
69
+ def cmd_delete(doc, args) -> dict:
70
+ para = doc.paragraphs[args.index]
71
+ el = para._element
72
+ el.getparent().remove(el)
73
+ return {"deleted_index": args.index}
74
+
75
+
76
+ def cmd_style(doc, args) -> dict:
77
+ doc.paragraphs[args.index].style = doc.styles[args.style]
78
+ return {"index": args.index, "style": args.style}
79
+
80
+
81
+ def _run_format_key(r_el) -> str:
82
+ """Canonical string for a run's w:rPr (None when absent)."""
83
+ from lxml import etree
84
+ rpr = r_el.find(_q("rPr"))
85
+ return "" if rpr is None else etree.tostring(rpr).decode("utf-8")
86
+
87
+
88
+ def cmd_normalize(doc) -> dict:
89
+ """Merge adjacent sibling runs with identical formatting."""
90
+ merged = 0
91
+ for para in iter_all_paragraphs(doc):
92
+ prev = None
93
+ for r_el in list(para._p):
94
+ if r_el.tag != _q("r"):
95
+ prev = None
96
+ continue
97
+ # only merge plain-text runs (no breaks, tabs, drawings...)
98
+ kids = {c.tag for c in r_el} - {_q("rPr"), _q("t")}
99
+ if kids:
100
+ prev = None
101
+ continue
102
+ if (prev is not None
103
+ and _run_format_key(prev) == _run_format_key(r_el)):
104
+ pt = prev.find(_q("t"))
105
+ ct = r_el.find(_q("t"))
106
+ if pt is None:
107
+ pt = prev.makeelement(_q("t"), {})
108
+ prev.append(pt)
109
+ pt.text = (pt.text or "") + ((ct.text or "")
110
+ if ct is not None else "")
111
+ pt.set("{http://www.w3.org/XML/1998/namespace}space",
112
+ "preserve")
113
+ r_el.getparent().remove(r_el)
114
+ merged += 1
115
+ else:
116
+ prev = r_el
117
+ return {"runs_merged": merged}
118
+
119
+
120
+ def _add_field(para, instr: str, placeholder: str) -> None:
121
+ """Append a complex field (begin/instrText/separate/result/end)."""
122
+ p = para._p
123
+ for ftype, extra in (("begin", None), (None, instr),
124
+ ("separate", None), (None, placeholder),
125
+ ("end", None)):
126
+ r = p.makeelement(_q("r"), {})
127
+ p.append(r)
128
+ if ftype is not None:
129
+ fld = r.makeelement(_q("fldChar"), {_q("fldCharType"): ftype})
130
+ r.append(fld)
131
+ elif extra is instr:
132
+ it = r.makeelement(_q("instrText"), {})
133
+ it.set("{http://www.w3.org/XML/1998/namespace}space", "preserve")
134
+ it.text = instr
135
+ r.append(it)
136
+ else:
137
+ t = r.makeelement(_q("t"), {})
138
+ t.text = extra
139
+ r.append(t)
140
+
141
+
142
+ def cmd_toc(doc, args) -> dict:
143
+ paras = doc.paragraphs
144
+ if args.index < len(paras):
145
+ para = paras[args.index].insert_paragraph_before("")
146
+ else:
147
+ para = doc.add_paragraph("")
148
+ _add_field(para, r' TOC \o "1-3" \h \z \u ',
149
+ "Table of contents - open in Word/LibreOffice and update "
150
+ "fields to populate.")
151
+ return {"toc_inserted_at": args.index}
152
+
153
+
154
+ def cmd_page_numbers(doc, args) -> dict:
155
+ footer = doc.sections[0].footer
156
+ para = footer.paragraphs[0] if footer.paragraphs \
157
+ else footer.add_paragraph()
158
+ para.add_run("Page ")
159
+ _add_field(para, " PAGE ", "1")
160
+ para.add_run(" of ")
161
+ _add_field(para, " NUMPAGES ", "1")
162
+ return {"footer_fields": ["PAGE", "NUMPAGES"]}
163
+
164
+
165
+ def main() -> int:
166
+ ap = argparse.ArgumentParser(description="Edit a .docx file.")
167
+ sub = ap.add_subparsers(dest="cmd", required=True)
168
+
169
+ def common(p):
170
+ p.add_argument("path", help="input .docx")
171
+ p.add_argument("-o", "--output",
172
+ help="output path (default: overwrite input)")
173
+
174
+ p = sub.add_parser("replace", help="find-and-replace text")
175
+ common(p)
176
+ p.add_argument("--find", required=True)
177
+ p.add_argument("--replace", required=True)
178
+ p.add_argument("--body-only", action="store_true",
179
+ help="skip headers/footers")
180
+
181
+ p = sub.add_parser("set-cell", help="set table cell text")
182
+ common(p)
183
+ p.add_argument("--table", type=int, required=True, help="table index")
184
+ p.add_argument("--row", type=int, required=True)
185
+ p.add_argument("--col", type=int, required=True)
186
+ p.add_argument("--text", required=True)
187
+
188
+ p = sub.add_parser("insert", help="insert paragraph at body index")
189
+ common(p)
190
+ p.add_argument("--index", type=int, required=True)
191
+ p.add_argument("--text", required=True)
192
+ p.add_argument("--style", default=None)
193
+
194
+ p = sub.add_parser("delete", help="delete body paragraph by index")
195
+ common(p)
196
+ p.add_argument("--index", type=int, required=True)
197
+
198
+ p = sub.add_parser("style", help="apply style to body paragraph")
199
+ common(p)
200
+ p.add_argument("--index", type=int, required=True)
201
+ p.add_argument("--style", required=True)
202
+
203
+ p = sub.add_parser("normalize",
204
+ help="merge adjacent runs with identical formatting")
205
+ common(p)
206
+
207
+ p = sub.add_parser("toc", help="insert a TOC field (Word computes it)")
208
+ common(p)
209
+ p.add_argument("--index", type=int, default=0,
210
+ help="body paragraph index to insert before (default 0)")
211
+
212
+ p = sub.add_parser("page-numbers",
213
+ help="add PAGE/NUMPAGES fields to the footer")
214
+ common(p)
215
+
216
+ args = ap.parse_args()
217
+ doc = Document(args.path)
218
+
219
+ if args.cmd == "replace":
220
+ if args.body_only:
221
+ n = 0
222
+ for para in iter_all_paragraphs(doc, include_headers_footers=False):
223
+ n += replace_in_paragraph(para, args.find, args.replace)
224
+ result = {"replacements": n}
225
+ else:
226
+ result = cmd_replace(doc, args)
227
+ elif args.cmd == "set-cell":
228
+ result = cmd_set_cell(doc, args)
229
+ elif args.cmd == "insert":
230
+ result = cmd_insert(doc, args)
231
+ elif args.cmd == "delete":
232
+ result = cmd_delete(doc, args)
233
+ elif args.cmd == "normalize":
234
+ result = cmd_normalize(doc)
235
+ elif args.cmd == "toc":
236
+ result = cmd_toc(doc, args)
237
+ elif args.cmd == "page-numbers":
238
+ result = cmd_page_numbers(doc, args)
239
+ else:
240
+ result = cmd_style(doc, args)
241
+
242
+ out = args.output or args.path
243
+ doc.save(out)
244
+ result.update({"ok": True, "output": out})
245
+ print(json.dumps(result, ensure_ascii=False))
246
+ return 0
247
+
248
+
249
+ if __name__ == "__main__":
250
+ sys.exit(main())
@@ -0,0 +1,149 @@
1
+ #!/usr/bin/env python3
2
+ # MIT-licensed helper adapted for Qubicl's native DOCX skill.
3
+ """Read a .docx: text, structure outline, styles, images, revision detection.
4
+
5
+ Usage:
6
+ docx_read.py file.docx --text # full text incl. tables + headers/footers
7
+ docx_read.py file.docx --structure # JSON outline (headings, tables, counts)
8
+ docx_read.py file.docx --styles # JSON list of styles actually used
9
+ docx_read.py file.docx --images DIR # extract embedded images into DIR
10
+ docx_read.py file.docx --revisions # JSON: tracked changes / comments present?
11
+
12
+ Text output is JSON: {"body": [...], "tables": [[...rows]], "headers": [...],
13
+ "footers": [...]}. Body text is the accepted/as-is text (python-docx ignores
14
+ deleted-in-revision text and shows inserted text).
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import os
21
+ import sys
22
+ import zipfile
23
+
24
+ from docx import Document
25
+
26
+
27
+ def table_to_rows(table) -> list:
28
+ return [[cell.text for cell in row.cells] for row in table.rows]
29
+
30
+
31
+ def extract_text(doc) -> dict:
32
+ out = {"body": [p.text for p in doc.paragraphs],
33
+ "tables": [table_to_rows(t) for t in doc.tables],
34
+ "headers": [], "footers": []}
35
+ for section in doc.sections:
36
+ out["headers"].extend(p.text for p in section.header.paragraphs)
37
+ out["footers"].extend(p.text for p in section.footer.paragraphs)
38
+ for t in section.header.tables:
39
+ out["headers"].append(json.dumps(table_to_rows(t), ensure_ascii=False))
40
+ for t in section.footer.tables:
41
+ out["footers"].append(json.dumps(table_to_rows(t), ensure_ascii=False))
42
+ return out
43
+
44
+
45
+ def extract_structure(doc) -> dict:
46
+ outline = []
47
+ for i, para in enumerate(doc.paragraphs):
48
+ style = para.style.name if para.style else ""
49
+ if style.startswith("Heading"):
50
+ try:
51
+ level = int(style.split()[-1])
52
+ except ValueError:
53
+ level = 1
54
+ outline.append({"index": i, "level": level, "text": para.text})
55
+ return {
56
+ "outline": outline,
57
+ "paragraph_count": len(doc.paragraphs),
58
+ "table_count": len(doc.tables),
59
+ "tables": [{"rows": len(t.rows), "cols": len(t.columns)}
60
+ for t in doc.tables],
61
+ "section_count": len(doc.sections),
62
+ }
63
+
64
+
65
+ def styles_used(doc) -> list:
66
+ used = set()
67
+ for para in doc.paragraphs:
68
+ if para.style:
69
+ used.add(para.style.name)
70
+ for run in para.runs:
71
+ if run.style:
72
+ used.add(run.style.name)
73
+ for table in doc.tables:
74
+ if table.style:
75
+ used.add(table.style.name)
76
+ for row in table.rows:
77
+ for cell in row.cells:
78
+ for para in cell.paragraphs:
79
+ if para.style:
80
+ used.add(para.style.name)
81
+ return sorted(used)
82
+
83
+
84
+ def extract_images(path: str, outdir: str) -> list:
85
+ os.makedirs(outdir, exist_ok=True)
86
+ written = []
87
+ with zipfile.ZipFile(path) as zf:
88
+ for name in zf.namelist():
89
+ if name.startswith("word/media/"):
90
+ target = os.path.join(outdir, os.path.basename(name))
91
+ with open(target, "wb") as f:
92
+ f.write(zf.read(name))
93
+ written.append(target)
94
+ return written
95
+
96
+
97
+ def detect_revisions(path: str) -> dict:
98
+ """Detect tracked changes and comments by scanning the raw XML parts."""
99
+ markers = {"insertions": b"<w:ins ", "deletions": b"<w:del ",
100
+ "format_changes": b"<w:rPrChange"}
101
+ result = {k: False for k in markers}
102
+ result["comments"] = False
103
+ with zipfile.ZipFile(path) as zf:
104
+ names = zf.namelist()
105
+ result["comments"] = any(n.startswith("word/comments") for n in names)
106
+ for name in names:
107
+ if name.startswith("word/") and name.endswith(".xml"):
108
+ data = zf.read(name)
109
+ for key, marker in markers.items():
110
+ if marker in data:
111
+ result[key] = True
112
+ result["has_tracked_changes"] = any(
113
+ result[k] for k in ("insertions", "deletions", "format_changes"))
114
+ return result
115
+
116
+
117
+ def main() -> int:
118
+ ap = argparse.ArgumentParser(description="Read/inspect a .docx file.")
119
+ ap.add_argument("path", help=".docx file to read")
120
+ g = ap.add_mutually_exclusive_group(required=True)
121
+ g.add_argument("--text", action="store_true", help="extract all text as JSON")
122
+ g.add_argument("--structure", action="store_true", help="outline JSON")
123
+ g.add_argument("--styles", action="store_true", help="styles used, JSON")
124
+ g.add_argument("--images", metavar="DIR", help="extract images to DIR")
125
+ g.add_argument("--revisions", action="store_true",
126
+ help="detect tracked changes / comments")
127
+ args = ap.parse_args()
128
+
129
+ if args.images:
130
+ print(json.dumps({"images": extract_images(args.path, args.images)},
131
+ ensure_ascii=False))
132
+ return 0
133
+ if args.revisions:
134
+ print(json.dumps(detect_revisions(args.path), ensure_ascii=False))
135
+ return 0
136
+
137
+ doc = Document(args.path)
138
+ if args.text:
139
+ out = extract_text(doc)
140
+ elif args.structure:
141
+ out = extract_structure(doc)
142
+ else:
143
+ out = {"styles": styles_used(doc)}
144
+ print(json.dumps(out, ensure_ascii=False, indent=2))
145
+ return 0
146
+
147
+
148
+ if __name__ == "__main__":
149
+ sys.exit(main())