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.
- package/README.md +46 -21
- package/dist/SBOM.spdx.json +222 -0
- package/dist/THIRD_PARTY_NOTICES.txt +244 -0
- package/dist/assets/chromium-seccomp.json +181 -0
- package/dist/assets/computer/BROWSER_SKILLS_THIRD_PARTY_NOTICES.txt +23 -0
- package/dist/assets/computer/Dockerfile +191 -49
- package/dist/assets/computer/PLAYWRIGHT_THIRD_PARTY_NOTICES.txt +217 -0
- package/dist/assets/computer/SKILLS_THIRD_PARTY_NOTICES.txt +29 -0
- package/dist/assets/computer/THIRD_PARTY_NOTICES.txt +28 -0
- package/dist/assets/computer/WEB_THIRD_PARTY_NOTICES.txt +42 -0
- package/dist/assets/computer/browser-skills-requirements.txt +12 -0
- package/dist/assets/computer/chromium-wrapper.sh +12 -0
- package/dist/assets/computer/control.mjs +112 -52
- package/dist/assets/computer/entrypoint.sh +272 -35
- package/dist/assets/computer/libreoffice-registrymodifications.xcu +8 -0
- package/dist/assets/computer/manifests/browser.json +72 -0
- package/dist/assets/computer/manifests/computer.json +76 -0
- package/dist/assets/computer/manifests/file-system.json +42 -0
- package/dist/assets/computer/manifests/workstation.json +78 -0
- package/dist/assets/computer/node_modules/playwright-core/LICENSE +202 -0
- package/dist/assets/computer/node_modules/playwright-core/NOTICE +5 -0
- package/dist/assets/computer/node_modules/playwright-core/ThirdPartyNotices.txt +13 -0
- package/dist/assets/computer/node_modules/playwright-core/browsers.json +75 -0
- package/dist/assets/computer/node_modules/playwright-core/index.js +17 -0
- package/dist/assets/computer/node_modules/playwright-core/index.mjs +28 -0
- package/dist/assets/computer/node_modules/playwright-core/lib/bootstrap.js +88 -0
- package/dist/assets/computer/node_modules/playwright-core/lib/coreBundle.js +74830 -0
- package/dist/assets/computer/node_modules/playwright-core/lib/utilsBundle.js +90764 -0
- package/dist/assets/computer/node_modules/playwright-core/lib/utilsBundle.js.LICENSE +2179 -0
- package/dist/assets/computer/node_modules/playwright-core/lib/webp_codec.LICENSE +173 -0
- package/dist/assets/computer/node_modules/playwright-core/lib/webp_codec.wasm +0 -0
- package/dist/assets/computer/node_modules/playwright-core/lib/xdg-open +1267 -0
- package/dist/assets/computer/node_modules/playwright-core/package.json +34 -0
- package/dist/assets/computer/skills/core/docx/LICENSE +21 -0
- package/dist/assets/computer/skills/core/docx/SKILL.md +21 -0
- package/dist/assets/computer/skills/core/docx/references/revisions-and-comments.md +88 -0
- package/dist/assets/computer/skills/core/docx/scripts/docx_comments.py +289 -0
- package/dist/assets/computer/skills/core/docx/scripts/docx_common.py +94 -0
- package/dist/assets/computer/skills/core/docx/scripts/docx_create.py +177 -0
- package/dist/assets/computer/skills/core/docx/scripts/docx_edit.py +250 -0
- package/dist/assets/computer/skills/core/docx/scripts/docx_read.py +149 -0
- package/dist/assets/computer/skills/core/docx/scripts/docx_revisions.py +147 -0
- package/dist/assets/computer/skills/core/docx/scripts/docx_template.py +70 -0
- package/dist/assets/computer/skills/core/docx/scripts/docx_validate.py +156 -0
- package/dist/assets/computer/skills/core/ocr-and-documents/LICENSE +21 -0
- package/dist/assets/computer/skills/core/ocr-and-documents/SKILL.md +24 -0
- package/dist/assets/computer/skills/core/ocr-and-documents/scripts/ocr_document.py +132 -0
- package/dist/assets/computer/skills/core/pdf/LICENSE +21 -0
- package/dist/assets/computer/skills/core/pdf/SKILL.md +25 -0
- package/dist/assets/computer/skills/core/pdf/references/forms.md +100 -0
- package/dist/assets/computer/skills/core/pdf/scripts/_raster.py +76 -0
- package/dist/assets/computer/skills/core/pdf/scripts/pdf_create.py +130 -0
- package/dist/assets/computer/skills/core/pdf/scripts/pdf_fill_form.py +97 -0
- package/dist/assets/computer/skills/core/pdf/scripts/pdf_form_layout.py +168 -0
- package/dist/assets/computer/skills/core/pdf/scripts/pdf_make_form.py +145 -0
- package/dist/assets/computer/skills/core/pdf/scripts/pdf_merge.py +50 -0
- package/dist/assets/computer/skills/core/pdf/scripts/pdf_meta.py +115 -0
- package/dist/assets/computer/skills/core/pdf/scripts/pdf_page_image.py +99 -0
- package/dist/assets/computer/skills/core/pdf/scripts/pdf_read.py +153 -0
- package/dist/assets/computer/skills/core/pdf/scripts/pdf_secure.py +71 -0
- package/dist/assets/computer/skills/core/pdf/scripts/pdf_split.py +84 -0
- package/dist/assets/computer/skills/core/pdf/scripts/pdf_stamp.py +143 -0
- package/dist/assets/computer/skills/core/pdf/scripts/pdf_watermark.py +51 -0
- package/dist/assets/computer/skills/core/plan/LICENSE +21 -0
- package/dist/assets/computer/skills/core/plan/SKILL.md +21 -0
- package/dist/assets/computer/skills/core/powerpoint/LICENSE +21 -0
- package/dist/assets/computer/skills/core/powerpoint/SKILL.md +18 -0
- package/dist/assets/computer/skills/core/powerpoint/scripts/pptx_create.py +214 -0
- package/dist/assets/computer/skills/core/powerpoint/scripts/pptx_edit.py +436 -0
- package/dist/assets/computer/skills/core/powerpoint/scripts/pptx_from_template.py +88 -0
- package/dist/assets/computer/skills/core/powerpoint/scripts/pptx_read.py +131 -0
- package/dist/assets/computer/skills/core/powerpoint/scripts/pptx_render.py +93 -0
- package/dist/assets/computer/skills/core/xlsx/LICENSE +21 -0
- package/dist/assets/computer/skills/core/xlsx/SKILL.md +22 -0
- package/dist/assets/computer/skills/core/xlsx/references/restructuring.md +71 -0
- package/dist/assets/computer/skills/core/xlsx/scripts/csv_to_xlsx.py +104 -0
- package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_create.py +259 -0
- package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_edit.py +263 -0
- package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_read.py +160 -0
- package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_recalc.py +110 -0
- package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_restructure.py +337 -0
- package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_to_csv.py +69 -0
- package/dist/assets/computer/skills/core-catalog.json +246 -0
- package/dist/assets/computer/skills-requirements.txt +18 -0
- package/dist/assets/computer/web-provider.py +592 -0
- package/dist/assets/computer/web-requirements.txt +37 -0
- package/dist/assets/gateway/Dockerfile +3 -1
- package/dist/assets/gateway/gateway.mjs +69 -40
- package/dist/assets/image-catalog.json +183 -0
- package/dist/qubicl.mjs +297 -167
- 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())
|