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,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "playwright-core",
|
|
3
|
+
"version": "1.62.1",
|
|
4
|
+
"description": "A high-level API to automate web browsers",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/microsoft/playwright.git"
|
|
8
|
+
},
|
|
9
|
+
"homepage": "https://playwright.dev",
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=20"
|
|
12
|
+
},
|
|
13
|
+
"author": {
|
|
14
|
+
"name": "Microsoft Corporation"
|
|
15
|
+
},
|
|
16
|
+
"license": "Apache-2.0",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./index.d.ts",
|
|
20
|
+
"import": "./index.mjs",
|
|
21
|
+
"require": "./index.js",
|
|
22
|
+
"default": "./index.js"
|
|
23
|
+
},
|
|
24
|
+
"./package.json": "./package.json",
|
|
25
|
+
"./lib/bootstrap": "./lib/bootstrap.js",
|
|
26
|
+
"./lib/coreBundle": "./lib/coreBundle.js",
|
|
27
|
+
"./lib/utilsBundle": "./lib/utilsBundle.js",
|
|
28
|
+
"./lib/tools/cli-client/program": "./lib/tools/cli-client/program.js"
|
|
29
|
+
},
|
|
30
|
+
"bin": {
|
|
31
|
+
"playwright-core": "cli.js"
|
|
32
|
+
},
|
|
33
|
+
"types": "types/types.d.ts"
|
|
34
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nous Research
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: docx
|
|
3
|
+
description: Create, read, edit, template, validate, and review Word DOCX documents using Qubicl's pinned local document tools. Use for professional Word documents, tracked revisions, comments, structured edits, and DOCX templates.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# DOCX workflows
|
|
7
|
+
|
|
8
|
+
Keep inputs and outputs under `/home/qubicl`. Obtain this skill's `resourceRoot` from `skill_view` and run bundled helpers with `/opt/qubicl/skills-venv/bin/python`. Qubicl computer and workstation images contain the pinned libraries; never install dependencies as part of the workflow.
|
|
9
|
+
|
|
10
|
+
Useful helpers beneath `<resourceRoot>/scripts/`:
|
|
11
|
+
|
|
12
|
+
- `docx_read.py FILE --text|--structure|--styles|--revisions`
|
|
13
|
+
- `docx_create.py SPEC.json OUTPUT.docx`
|
|
14
|
+
- `docx_edit.py` for text, paragraph, style, and table-cell edits
|
|
15
|
+
- `docx_template.py TEMPLATE.docx VALUES.json OUTPUT.docx`
|
|
16
|
+
- `docx_comments.py` and `docx_revisions.py`
|
|
17
|
+
- `docx_validate.py FILE`
|
|
18
|
+
|
|
19
|
+
Read `references/revisions-and-comments.md` only for tracked-change or comment work.
|
|
20
|
+
|
|
21
|
+
Inspect before editing, write to a new output unless in-place work was requested, and validate after every mutation. On workstation computers, use LibreOffice for a final PDF render when visual layout matters; on computer computers, report that structured DOCX operations work but office rendering may be unavailable. Preserve unknown OOXML parts whenever a helper supports doing so.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Revisions and Comments — XML details
|
|
2
|
+
|
|
3
|
+
Deep reference for `docx_revisions.py` and `docx_comments.py`. Read this
|
|
4
|
+
when you need to reason about the raw WordprocessingML, extend the
|
|
5
|
+
scripts, or debug an unusual document. Everyday use only needs SKILL.md.
|
|
6
|
+
|
|
7
|
+
## Tracked changes (w:ins / w:del)
|
|
8
|
+
|
|
9
|
+
Word records run-level tracked changes as wrapper elements inside a
|
|
10
|
+
paragraph (`w:p`), in the `w` namespace
|
|
11
|
+
`http://schemas.openxmlformats.org/wordprocessingml/2006/main`:
|
|
12
|
+
|
|
13
|
+
```xml
|
|
14
|
+
<w:p>
|
|
15
|
+
<w:r><w:t>Base </w:t></w:r>
|
|
16
|
+
<w:ins w:id="1" w:author="Editor" w:date="2026-01-02T03:04:05Z">
|
|
17
|
+
<w:r><w:t>inserted text</w:t></w:r>
|
|
18
|
+
</w:ins>
|
|
19
|
+
<w:del w:id="2" w:author="Editor" w:date="2026-01-02T03:04:05Z">
|
|
20
|
+
<w:r><w:delText>deleted text</w:delText></w:r>
|
|
21
|
+
</w:del>
|
|
22
|
+
</w:p>
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Key facts the script relies on:
|
|
26
|
+
|
|
27
|
+
- Deleted text lives in `w:delText`, not `w:t` — that is why plain text
|
|
28
|
+
extraction naturally shows the "accepted" view (insertions visible,
|
|
29
|
+
deletions hidden).
|
|
30
|
+
- Resolution semantics:
|
|
31
|
+
- accept `w:ins` → unwrap (move child runs up, drop the wrapper)
|
|
32
|
+
- reject `w:ins` → remove the wrapper and its contents
|
|
33
|
+
- accept `w:del` → remove the wrapper and its contents
|
|
34
|
+
- reject `w:del` → rename each `w:delText` to `w:t`, then unwrap
|
|
35
|
+
- Revisions can appear anywhere block content is allowed: body, table
|
|
36
|
+
cells (nested tables too), headers, footers, text boxes. The script
|
|
37
|
+
iterates the body root plus every header/footer part root with
|
|
38
|
+
`root.iter(W+"ins", W+"del")`, which finds them at any depth.
|
|
39
|
+
- `w:id` values are unique per revision *element*, but one logical edit
|
|
40
|
+
session may produce several elements. `accept`/`reject --id` acts on
|
|
41
|
+
exactly the element(s) carrying that id.
|
|
42
|
+
|
|
43
|
+
Not handled by the script (detected by `docx_read.py --revisions` but
|
|
44
|
+
left alone): paragraph-mark revisions (`w:rPr/w:ins` on `w:pPr`), table
|
|
45
|
+
row insertions/deletions (`w:trPr/w:ins`), format-change records
|
|
46
|
+
(`w:rPrChange`, `w:pPrChange`), and moves (`w:moveFrom`/`w:moveTo`).
|
|
47
|
+
Moves are rare from typical editors; if present, treat the file with
|
|
48
|
+
Word itself rather than guessing.
|
|
49
|
+
|
|
50
|
+
## Comments
|
|
51
|
+
|
|
52
|
+
Three cooperating pieces:
|
|
53
|
+
|
|
54
|
+
1. **`word/comments.xml`** — one `w:comment` element per comment,
|
|
55
|
+
carrying `w:id`, `w:author`, `w:initials`, `w:date`, and body
|
|
56
|
+
paragraphs. Related from document.xml via the relationship type
|
|
57
|
+
`.../comments` and content type
|
|
58
|
+
`application/vnd...wordprocessingml.comments+xml` (also needs a
|
|
59
|
+
`[Content_Types].xml` override — python-docx's part machinery adds it
|
|
60
|
+
when the part is registered).
|
|
61
|
+
2. **Range markers in the story** — `w:commentRangeStart w:id="N"`
|
|
62
|
+
before the anchored runs, `w:commentRangeEnd w:id="N"` after them.
|
|
63
|
+
3. **The reference run** — a `w:r` containing `w:commentReference
|
|
64
|
+
w:id="N"`, placed right after the range end; it ties the balloon to
|
|
65
|
+
the location.
|
|
66
|
+
|
|
67
|
+
`docx_comments.py` behavior:
|
|
68
|
+
|
|
69
|
+
- **list / delete** always work at the XML level, so they handle files
|
|
70
|
+
from any producer. `anchored_text` is reconstructed by walking each
|
|
71
|
+
part root in document order and collecting `w:t` text between the
|
|
72
|
+
start and end markers for each id.
|
|
73
|
+
- **add** first isolates the target text into whole runs. If the match
|
|
74
|
+
starts or ends mid-run, the run is split at the boundary (the split
|
|
75
|
+
copies `w:rPr`, so formatting is preserved). Then:
|
|
76
|
+
- python-docx >= 1.2: the native `document.add_comment(runs, ...)`
|
|
77
|
+
API is used (it creates the comments part, markers, and reference
|
|
78
|
+
run itself).
|
|
79
|
+
- older versions or `--xml`: the script builds `word/comments.xml`,
|
|
80
|
+
registers the part + relationship through the opc layer, and
|
|
81
|
+
inserts the markers/reference manually.
|
|
82
|
+
- Deleting a comment removes the `w:comment` element and all three
|
|
83
|
+
marker kinds for that id; the anchored document text is untouched.
|
|
84
|
+
|
|
85
|
+
Modern Word also writes `commentsExtended.xml` (threading/resolved
|
|
86
|
+
state). The scripts neither read nor produce it: replies and "resolved"
|
|
87
|
+
flags are invisible here, and comments added by this skill are plain
|
|
88
|
+
top-level comments.
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# MIT-licensed helper adapted for Qubicl's native DOCX skill.
|
|
3
|
+
"""List, add, and delete comments in a .docx.
|
|
4
|
+
|
|
5
|
+
Subcommands:
|
|
6
|
+
list JSON per comment: id, author, initials, date, text, anchored_text
|
|
7
|
+
add add a comment anchored to the first occurrence of --target
|
|
8
|
+
delete remove a comment (and its range markers) by --id
|
|
9
|
+
|
|
10
|
+
Examples:
|
|
11
|
+
docx_comments.py list report.docx
|
|
12
|
+
docx_comments.py add report.docx --target "Q3 revenue" \
|
|
13
|
+
--text "Needs a source" --author "Reviewer" -o out.docx
|
|
14
|
+
docx_comments.py delete report.docx --id 0 -o out.docx
|
|
15
|
+
|
|
16
|
+
Uses the native python-docx comments API (>= 1.2) when available; falls
|
|
17
|
+
back to building word/comments.xml and the range markers directly for
|
|
18
|
+
older versions (or when --xml is passed). Listing and deletion always
|
|
19
|
+
work at the XML level so they handle documents from any producer.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import datetime as _dt
|
|
25
|
+
import json
|
|
26
|
+
import sys
|
|
27
|
+
from copy import deepcopy
|
|
28
|
+
|
|
29
|
+
from docx import Document
|
|
30
|
+
from docx.opc.constants import RELATIONSHIP_TYPE as RT
|
|
31
|
+
from lxml import etree
|
|
32
|
+
|
|
33
|
+
from docx_common import iter_part_roots
|
|
34
|
+
|
|
35
|
+
W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
|
|
36
|
+
COMMENTS_CT = ("application/vnd.openxmlformats-officedocument"
|
|
37
|
+
".wordprocessingml.comments+xml")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def q(tag: str) -> str:
|
|
41
|
+
return f"{{{W}}}{tag}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------- reading
|
|
45
|
+
|
|
46
|
+
def _comments_root(doc):
|
|
47
|
+
"""Return the XML root of the comments part, or None."""
|
|
48
|
+
for rel in doc.part.rels.values():
|
|
49
|
+
if rel.reltype == RT.COMMENTS:
|
|
50
|
+
part = rel.target_part
|
|
51
|
+
el = getattr(part, "_element", None)
|
|
52
|
+
if el is not None:
|
|
53
|
+
return el
|
|
54
|
+
return etree.fromstring(part.blob)
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _anchored_texts(doc) -> dict:
|
|
59
|
+
"""Map comment id -> document text between its range markers."""
|
|
60
|
+
anchored: dict[str, list[str]] = {}
|
|
61
|
+
for root in iter_part_roots(doc):
|
|
62
|
+
active: set[str] = set()
|
|
63
|
+
for el in root.iter():
|
|
64
|
+
if el.tag == q("commentRangeStart"):
|
|
65
|
+
cid = el.get(q("id"))
|
|
66
|
+
active.add(cid)
|
|
67
|
+
anchored.setdefault(cid, [])
|
|
68
|
+
elif el.tag == q("commentRangeEnd"):
|
|
69
|
+
active.discard(el.get(q("id")))
|
|
70
|
+
elif el.tag == q("t") and active:
|
|
71
|
+
for cid in active:
|
|
72
|
+
anchored[cid].append(el.text or "")
|
|
73
|
+
return {cid: "".join(parts) for cid, parts in anchored.items()}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def list_comments(doc) -> list:
|
|
77
|
+
root = _comments_root(doc)
|
|
78
|
+
if root is None:
|
|
79
|
+
return []
|
|
80
|
+
anchored = _anchored_texts(doc)
|
|
81
|
+
out = []
|
|
82
|
+
for c in root.iter(q("comment")):
|
|
83
|
+
cid = c.get(q("id"))
|
|
84
|
+
text = "\n".join(
|
|
85
|
+
"".join(t.text or "" for t in p.iter(q("t")))
|
|
86
|
+
for p in c.iter(q("p")))
|
|
87
|
+
out.append({"id": cid, "author": c.get(q("author")),
|
|
88
|
+
"initials": c.get(q("initials")),
|
|
89
|
+
"date": c.get(q("date")), "text": text,
|
|
90
|
+
"anchored_text": anchored.get(cid, "")})
|
|
91
|
+
return out
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ---------------------------------------------------------------- anchoring
|
|
95
|
+
|
|
96
|
+
def _split_run(para, run_el, offset: int):
|
|
97
|
+
"""Split a run element at text offset; return the new right-hand run."""
|
|
98
|
+
text = "".join(t.text or "" for t in run_el.iter(q("t")))
|
|
99
|
+
right = deepcopy(run_el)
|
|
100
|
+
run_el.addnext(right)
|
|
101
|
+
for el, s in ((run_el, text[:offset]), (right, text[offset:])):
|
|
102
|
+
for t in list(el.iter(q("t"))):
|
|
103
|
+
el.remove(t)
|
|
104
|
+
t = etree.SubElement(el, q("t"))
|
|
105
|
+
t.text = s
|
|
106
|
+
t.set("{http://www.w3.org/XML/1998/namespace}space", "preserve")
|
|
107
|
+
return right
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def find_anchor_runs(doc, target: str):
|
|
111
|
+
"""Isolate `target`'s first occurrence into whole runs; return them."""
|
|
112
|
+
from docx_common import iter_all_paragraphs
|
|
113
|
+
for para in iter_all_paragraphs(doc):
|
|
114
|
+
full = para.text
|
|
115
|
+
start = full.find(target)
|
|
116
|
+
if start < 0:
|
|
117
|
+
continue
|
|
118
|
+
end = start + len(target)
|
|
119
|
+
pos = 0
|
|
120
|
+
covered = []
|
|
121
|
+
for run_el in para._p.iter(q("r")):
|
|
122
|
+
rtext = "".join(t.text or "" for t in run_el.iter(q("t")))
|
|
123
|
+
r_start, r_end = pos, pos + len(rtext)
|
|
124
|
+
pos = r_end
|
|
125
|
+
if r_end <= start or r_start >= end:
|
|
126
|
+
continue
|
|
127
|
+
if r_start < start: # split off the left part
|
|
128
|
+
run_el = _split_run(para, run_el, start - r_start)
|
|
129
|
+
r_start = start
|
|
130
|
+
if r_end > end: # split off the right part
|
|
131
|
+
_split_run(para, run_el, end - r_start)
|
|
132
|
+
covered.append(run_el)
|
|
133
|
+
return para, covered
|
|
134
|
+
return None, []
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ---------------------------------------------------------------- adding
|
|
138
|
+
|
|
139
|
+
def _next_id(doc) -> int:
|
|
140
|
+
root = _comments_root(doc)
|
|
141
|
+
if root is None:
|
|
142
|
+
return 0
|
|
143
|
+
ids = [int(c.get(q("id"), "0")) for c in root.iter(q("comment"))
|
|
144
|
+
if c.get(q("id"), "").isdigit()]
|
|
145
|
+
return max(ids) + 1 if ids else 0
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def add_comment_native(doc, runs, text, author, initials):
|
|
149
|
+
from docx.text.run import Run
|
|
150
|
+
run_objs = [Run(r, None) for r in runs]
|
|
151
|
+
comment = doc.add_comment(run_objs, text=text, author=author,
|
|
152
|
+
initials=initials or "")
|
|
153
|
+
return str(comment.comment_id)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def add_comment_xml(doc, runs, text, author, initials) -> str:
|
|
157
|
+
cid = str(_next_id(doc))
|
|
158
|
+
root = _comments_root(doc)
|
|
159
|
+
if root is None:
|
|
160
|
+
root = etree.fromstring(
|
|
161
|
+
f'<w:comments xmlns:w="{W}"/>'.encode("utf-8"))
|
|
162
|
+
from docx.opc.packuri import PackURI
|
|
163
|
+
from docx.opc.part import Part
|
|
164
|
+
blob = etree.tostring(root, xml_declaration=True,
|
|
165
|
+
encoding="UTF-8", standalone=True)
|
|
166
|
+
part = Part(PackURI("/word/comments.xml"), COMMENTS_CT, blob,
|
|
167
|
+
doc.part.package)
|
|
168
|
+
doc.part.relate_to(part, RT.COMMENTS)
|
|
169
|
+
# keep a live element on the part so edits reach save()
|
|
170
|
+
part._element = root
|
|
171
|
+
part.blob_ = None
|
|
172
|
+
|
|
173
|
+
def _blob(self=part):
|
|
174
|
+
return etree.tostring(self._element, xml_declaration=True,
|
|
175
|
+
encoding="UTF-8", standalone=True)
|
|
176
|
+
part.__class__ = type("CommentsXmlPart", (Part,),
|
|
177
|
+
{"blob": property(lambda self: _blob(self))})
|
|
178
|
+
now = _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
179
|
+
comment = etree.SubElement(root, q("comment"))
|
|
180
|
+
comment.set(q("id"), cid)
|
|
181
|
+
comment.set(q("author"), author)
|
|
182
|
+
if initials:
|
|
183
|
+
comment.set(q("initials"), initials)
|
|
184
|
+
comment.set(q("date"), now)
|
|
185
|
+
p = etree.SubElement(comment, q("p"))
|
|
186
|
+
r = etree.SubElement(p, q("r"))
|
|
187
|
+
t = etree.SubElement(r, q("t"))
|
|
188
|
+
t.text = text
|
|
189
|
+
# range markers around the anchor runs + reference run after them
|
|
190
|
+
first, last = runs[0], runs[-1]
|
|
191
|
+
start = first.makeelement(q("commentRangeStart"), {q("id"): cid})
|
|
192
|
+
first.addprevious(start)
|
|
193
|
+
end = last.makeelement(q("commentRangeEnd"), {q("id"): cid})
|
|
194
|
+
last.addnext(end)
|
|
195
|
+
ref_run = last.makeelement(q("r"), {})
|
|
196
|
+
ref = etree.SubElement(ref_run, q("commentReference"))
|
|
197
|
+
ref.set(q("id"), cid)
|
|
198
|
+
end.addnext(ref_run)
|
|
199
|
+
return cid
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ---------------------------------------------------------------- deleting
|
|
203
|
+
|
|
204
|
+
def delete_comment(doc, cid: str) -> bool:
|
|
205
|
+
root = _comments_root(doc)
|
|
206
|
+
found = False
|
|
207
|
+
if root is not None:
|
|
208
|
+
for c in list(root.iter(q("comment"))):
|
|
209
|
+
if c.get(q("id")) == cid:
|
|
210
|
+
c.getparent().remove(c)
|
|
211
|
+
found = True
|
|
212
|
+
for part_root in iter_part_roots(doc):
|
|
213
|
+
for tag in ("commentRangeStart", "commentRangeEnd",
|
|
214
|
+
"commentReference"):
|
|
215
|
+
for el in list(part_root.iter(q(tag))):
|
|
216
|
+
if el.get(q("id")) == cid:
|
|
217
|
+
parent = el.getparent()
|
|
218
|
+
# remove the wrapping run for reference marks
|
|
219
|
+
if tag == "commentReference" and parent.tag == q("r"):
|
|
220
|
+
parent.getparent().remove(parent)
|
|
221
|
+
else:
|
|
222
|
+
parent.remove(el)
|
|
223
|
+
found = True
|
|
224
|
+
return found
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def main() -> int:
|
|
228
|
+
ap = argparse.ArgumentParser(
|
|
229
|
+
description="List, add, or delete comments in a .docx.")
|
|
230
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
231
|
+
|
|
232
|
+
p = sub.add_parser("list", help="list comments as JSON")
|
|
233
|
+
p.add_argument("path", help="input .docx")
|
|
234
|
+
|
|
235
|
+
p = sub.add_parser("add", help="add a comment anchored to text")
|
|
236
|
+
p.add_argument("path", help="input .docx")
|
|
237
|
+
p.add_argument("-o", "--output", help="output path (default: in place)")
|
|
238
|
+
p.add_argument("--target", required=True,
|
|
239
|
+
help="anchor: first occurrence of this text")
|
|
240
|
+
p.add_argument("--text", required=True, help="comment body")
|
|
241
|
+
p.add_argument("--author", default="Qubicl")
|
|
242
|
+
p.add_argument("--initials", default="")
|
|
243
|
+
p.add_argument("--xml", action="store_true",
|
|
244
|
+
help="force the XML fallback (skip native API)")
|
|
245
|
+
|
|
246
|
+
p = sub.add_parser("delete", help="delete a comment by id")
|
|
247
|
+
p.add_argument("path", help="input .docx")
|
|
248
|
+
p.add_argument("-o", "--output", help="output path (default: in place)")
|
|
249
|
+
p.add_argument("--id", required=True, help="comment id")
|
|
250
|
+
|
|
251
|
+
args = ap.parse_args()
|
|
252
|
+
doc = Document(args.path)
|
|
253
|
+
|
|
254
|
+
if args.cmd == "list":
|
|
255
|
+
print(json.dumps({"ok": True, "comments": list_comments(doc)},
|
|
256
|
+
ensure_ascii=False))
|
|
257
|
+
return 0
|
|
258
|
+
|
|
259
|
+
if args.cmd == "add":
|
|
260
|
+
para, runs = find_anchor_runs(doc, args.target)
|
|
261
|
+
if not runs:
|
|
262
|
+
print(json.dumps({"ok": False,
|
|
263
|
+
"error": f"target not found: {args.target}"}))
|
|
264
|
+
return 1
|
|
265
|
+
native = hasattr(doc, "add_comment") and not args.xml
|
|
266
|
+
if native:
|
|
267
|
+
cid = add_comment_native(doc, runs, args.text, args.author,
|
|
268
|
+
args.initials)
|
|
269
|
+
else:
|
|
270
|
+
cid = add_comment_xml(doc, runs, args.text, args.author,
|
|
271
|
+
args.initials)
|
|
272
|
+
result = {"ok": True, "comment_id": cid,
|
|
273
|
+
"native_api": native, "anchored_to": args.target}
|
|
274
|
+
else: # delete
|
|
275
|
+
if not delete_comment(doc, args.id):
|
|
276
|
+
print(json.dumps({"ok": False,
|
|
277
|
+
"error": f"no comment with id {args.id}"}))
|
|
278
|
+
return 1
|
|
279
|
+
result = {"ok": True, "deleted_id": args.id}
|
|
280
|
+
|
|
281
|
+
out = args.output or args.path
|
|
282
|
+
doc.save(out)
|
|
283
|
+
result["output"] = out
|
|
284
|
+
print(json.dumps(result, ensure_ascii=False))
|
|
285
|
+
return 0
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
if __name__ == "__main__":
|
|
289
|
+
sys.exit(main())
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# MIT License. Shared helpers for the docx skill scripts.
|
|
3
|
+
"""Shared helpers: paragraph iteration and run-preserving text replacement."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def iter_all_paragraphs(doc, include_headers_footers: bool = True):
|
|
8
|
+
"""Yield every paragraph in body, tables (recursively), headers, footers."""
|
|
9
|
+
yield from _iter_container(doc)
|
|
10
|
+
if include_headers_footers:
|
|
11
|
+
for section in doc.sections:
|
|
12
|
+
for part in (
|
|
13
|
+
section.header, section.footer,
|
|
14
|
+
section.first_page_header, section.first_page_footer,
|
|
15
|
+
section.even_page_header, section.even_page_footer,
|
|
16
|
+
):
|
|
17
|
+
if part is not None:
|
|
18
|
+
yield from _iter_container(part)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _iter_container(container):
|
|
22
|
+
for para in container.paragraphs:
|
|
23
|
+
yield para
|
|
24
|
+
for table in container.tables:
|
|
25
|
+
yield from _iter_table(table)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _iter_table(table):
|
|
29
|
+
for row in table.rows:
|
|
30
|
+
for cell in row.cells:
|
|
31
|
+
for para in cell.paragraphs:
|
|
32
|
+
yield para
|
|
33
|
+
for nested in cell.tables:
|
|
34
|
+
yield from _iter_table(nested)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def iter_part_roots(doc):
|
|
38
|
+
"""Yield the XML root of the body plus every header/footer part."""
|
|
39
|
+
yield doc.element.body
|
|
40
|
+
seen = set()
|
|
41
|
+
for section in doc.sections:
|
|
42
|
+
for part in (
|
|
43
|
+
section.header, section.footer,
|
|
44
|
+
section.first_page_header, section.first_page_footer,
|
|
45
|
+
section.even_page_header, section.even_page_footer,
|
|
46
|
+
):
|
|
47
|
+
if part is not None and id(part._element) not in seen:
|
|
48
|
+
seen.add(id(part._element))
|
|
49
|
+
yield part._element
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def replace_in_paragraph(para, old: str, new: str) -> int:
|
|
53
|
+
"""Replace `old` with `new` in a paragraph, preserving run formatting.
|
|
54
|
+
|
|
55
|
+
Strategy: first replace occurrences fully contained in a single run
|
|
56
|
+
(formatting fully preserved). If the needle spans multiple runs, the
|
|
57
|
+
matched runs are collapsed: the replacement inherits the formatting of
|
|
58
|
+
the run where the match starts. Returns number of replacements made.
|
|
59
|
+
"""
|
|
60
|
+
if not old or old not in para.text:
|
|
61
|
+
return 0
|
|
62
|
+
count = 0
|
|
63
|
+
# Pass 1: within-run replacements.
|
|
64
|
+
for run in para.runs:
|
|
65
|
+
if old in run.text:
|
|
66
|
+
count += run.text.count(old)
|
|
67
|
+
run.text = run.text.replace(old, new)
|
|
68
|
+
# Pass 2: cross-run occurrences.
|
|
69
|
+
while old in para.text:
|
|
70
|
+
runs = para.runs
|
|
71
|
+
# Map paragraph text offsets to (run_index, offset_in_run).
|
|
72
|
+
full = "".join(r.text for r in runs)
|
|
73
|
+
start = full.find(old)
|
|
74
|
+
if start < 0:
|
|
75
|
+
break
|
|
76
|
+
end = start + len(old)
|
|
77
|
+
pos = 0
|
|
78
|
+
spans = [] # (run_idx, cut_start, cut_end) portions inside the match
|
|
79
|
+
for i, r in enumerate(runs):
|
|
80
|
+
r_start, r_end = pos, pos + len(r.text)
|
|
81
|
+
if r_end > start and r_start < end:
|
|
82
|
+
spans.append((i, max(start, r_start) - r_start,
|
|
83
|
+
min(end, r_end) - r_start))
|
|
84
|
+
pos = r_end
|
|
85
|
+
first = True
|
|
86
|
+
for i, cs, ce in spans:
|
|
87
|
+
t = runs[i].text
|
|
88
|
+
if first:
|
|
89
|
+
runs[i].text = t[:cs] + new + t[ce:]
|
|
90
|
+
first = False
|
|
91
|
+
else:
|
|
92
|
+
runs[i].text = t[:cs] + t[ce:]
|
|
93
|
+
count += 1
|
|
94
|
+
return count
|