mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-26 17:38:36 +00:00
Non-technical users asking for Word docs, spreadsheets, or PDF work had no bundled skill coverage — docx/xlsx creation required discovering and installing hub skills, and PDF manipulation had no skill at all beyond OCR extraction and nano-pdf edits. - skills/productivity/docx: create (docx-js), edit (unzip -> XML -> zip), tracked changes, comments, validation. Adapted from anthropics/skills. - skills/productivity/xlsx: openpyxl creation/editing, mandatory LibreOffice recalc gate, formula-compatibility rules, financial-model conventions. Points at optional excel-author for finance-grade work. - skills/productivity/pdf: merge/split/rotate/watermark/encrypt, form filling (AcroForm + flat overlay scripts), text/table extraction, reportlab creation, forms.md + reference.md companions. - skills/productivity/powerpoint: synced to current upstream pptx skill — richer pptxgenjs corruption footguns, template workflow, validate.py + validators + thumbnail.py, font-substitution QA guidance; drops the stale pack.py/editing.md/pptxgenjs.md workflow files. - Cross-linked ocr-and-documents, nano-pdf, excel-author via related_skills so each office skill routes to its siblings. - deliverable-mode docs mention the new skills; regenerated per-skill docs pages, catalogs, and sidebar. - tests/skills/test_office_document_skills.py: frontmatter contracts, referenced-script existence, schema-map integrity, cross-link resolution, script compilation. E2E validated: docx create->render->edit->validate, xlsx recalc (SUM + _xlfn.TEXTJOIN evaluate correctly), pdf create->merge->extract, pptx generate->validate->thumbnail.
368 lines
14 KiB
Python
Executable file
368 lines
14 KiB
Python
Executable file
"""Add comments to a DOCX document.
|
||
|
||
Accepts either an unpacked directory OR a .docx/.dotx file directly.
|
||
|
||
Usage:
|
||
# Against an unpacked directory (writes satellite files in place)
|
||
python comment.py unpacked/ "Comment text"
|
||
python comment.py unpacked/ "Reply text" --parent 0
|
||
|
||
# Against a .docx directly (extracts, writes satellite files, rezips)
|
||
python comment.py contract.docx "This cap is too low" -o annotated.docx
|
||
python comment.py contract.docx "Comment" --id 5 # explicit ID
|
||
|
||
The comment ID is auto-assigned (max existing + 1) unless --id is given.
|
||
Plain text is XML-escaped automatically; if you pass already-escaped text
|
||
(e.g. &, ’) use --raw to skip escaping.
|
||
|
||
After running, add markers to word/document.xml so the comment is visible:
|
||
<w:commentRangeStart w:id="N"/>
|
||
... commented content ...
|
||
<w:commentRangeEnd w:id="N"/>
|
||
<w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="N"/></w:r>
|
||
"""
|
||
|
||
import argparse
|
||
import random
|
||
import shutil
|
||
import sys
|
||
import tempfile
|
||
import zipfile
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
|
||
import defusedxml.minidom
|
||
from xml.parsers.expat import ExpatError
|
||
from xml.sax.saxutils import escape as xml_escape
|
||
|
||
from office.helpers import opc_target, rezip as _rezip, safe_extract as _safe_extract
|
||
|
||
TEMPLATE_DIR = Path(__file__).parent / "templates"
|
||
NS = {
|
||
"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
|
||
"w14": "http://schemas.microsoft.com/office/word/2010/wordml",
|
||
"w15": "http://schemas.microsoft.com/office/word/2012/wordml",
|
||
"w16cid": "http://schemas.microsoft.com/office/word/2016/wordml/cid",
|
||
"w16cex": "http://schemas.microsoft.com/office/word/2018/wordml/cex",
|
||
}
|
||
|
||
COMMENT_XML = """\
|
||
<w:comment w:id="{id}" w:author="{author}" w:date="{date}" w:initials="{initials}">
|
||
<w:p w14:paraId="{para_id}" w14:textId="77777777">
|
||
<w:r>
|
||
<w:rPr><w:rStyle w:val="CommentReference"/></w:rPr>
|
||
<w:annotationRef/>
|
||
</w:r>
|
||
<w:r>
|
||
<w:rPr>
|
||
<w:color w:val="000000"/>
|
||
<w:sz w:val="20"/>
|
||
<w:szCs w:val="20"/>
|
||
</w:rPr>
|
||
<w:t xml:space="preserve">{text}</w:t>
|
||
</w:r>
|
||
</w:p>
|
||
</w:comment>"""
|
||
|
||
COMMENT_MARKER_TEMPLATE = """
|
||
Add to word/document.xml (markers must be direct children of w:p, never inside w:r):
|
||
<w:commentRangeStart w:id="{cid}"/>
|
||
<w:r>...</w:r>
|
||
<w:commentRangeEnd w:id="{cid}"/>
|
||
<w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="{cid}"/></w:r>"""
|
||
|
||
REPLY_MARKER_TEMPLATE = """
|
||
Nest markers inside parent {pid}'s markers (direct children of w:p, never inside w:r):
|
||
<w:commentRangeStart w:id="{pid}"/><w:commentRangeStart w:id="{cid}"/>
|
||
<w:r>...</w:r>
|
||
<w:commentRangeEnd w:id="{cid}"/><w:commentRangeEnd w:id="{pid}"/>
|
||
<w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="{pid}"/></w:r>
|
||
<w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="{cid}"/></w:r>"""
|
||
|
||
SMART_QUOTE_ENTITIES = {
|
||
"“": "“",
|
||
"”": "”",
|
||
"‘": "‘",
|
||
"’": "’",
|
||
}
|
||
|
||
|
||
def _generate_hex_id() -> str:
|
||
return f"{random.randint(0, 0x7FFFFFFE):08X}"
|
||
|
||
|
||
def _encode_smart_quotes(text: str) -> str:
|
||
for char, entity in SMART_QUOTE_ENTITIES.items():
|
||
text = text.replace(char, entity)
|
||
return text
|
||
|
||
|
||
def _append_xml(xml_path: Path, root_tag: str, content: str) -> None:
|
||
dom = defusedxml.minidom.parseString(xml_path.read_text(encoding="utf-8"))
|
||
root = dom.getElementsByTagName(root_tag)[0]
|
||
ns_attrs = " ".join(f'xmlns:{k}="{v}"' for k, v in NS.items())
|
||
wrapper_dom = defusedxml.minidom.parseString(f"<root {ns_attrs}>{content}</root>")
|
||
for child in wrapper_dom.documentElement.childNodes:
|
||
if child.nodeType == child.ELEMENT_NODE:
|
||
root.appendChild(dom.importNode(child, True))
|
||
output = _encode_smart_quotes(dom.toxml(encoding="UTF-8").decode("utf-8"))
|
||
xml_path.write_text(output, encoding="utf-8")
|
||
|
||
|
||
def _find_para_id(comments_path: Path, comment_id: int) -> str | None:
|
||
dom = defusedxml.minidom.parseString(comments_path.read_text(encoding="utf-8"))
|
||
for c in dom.getElementsByTagName("w:comment"):
|
||
if c.getAttribute("w:id") == str(comment_id):
|
||
for p in c.getElementsByTagName("w:p"):
|
||
if pid := p.getAttribute("w14:paraId"):
|
||
return pid
|
||
return None
|
||
|
||
|
||
def _next_comment_id(comments_path: Path) -> int:
|
||
if not comments_path.exists():
|
||
return 0
|
||
dom = defusedxml.minidom.parseString(comments_path.read_text(encoding="utf-8"))
|
||
ids = []
|
||
for c in dom.getElementsByTagName("w:comment"):
|
||
try:
|
||
ids.append(int(c.getAttribute("w:id")))
|
||
except ValueError:
|
||
pass
|
||
return (max(ids) + 1) if ids else 0
|
||
|
||
|
||
def _get_next_rid(rels_path: Path) -> int:
|
||
dom = defusedxml.minidom.parseString(rels_path.read_text(encoding="utf-8"))
|
||
max_rid = 0
|
||
for rel in dom.getElementsByTagName("Relationship"):
|
||
rid = rel.getAttribute("Id")
|
||
if rid and rid.startswith("rId"):
|
||
try:
|
||
max_rid = max(max_rid, int(rid[3:]))
|
||
except ValueError:
|
||
pass
|
||
return max_rid + 1
|
||
|
||
|
||
def _has_relationship(rels_path: Path, target: str) -> bool:
|
||
dom = defusedxml.minidom.parseString(rels_path.read_text(encoding="utf-8"))
|
||
return any(
|
||
rel.getAttribute("Target") == target
|
||
for rel in dom.getElementsByTagName("Relationship")
|
||
)
|
||
|
||
|
||
def _has_content_type(ct_path: Path, part_name: str) -> bool:
|
||
dom = defusedxml.minidom.parseString(ct_path.read_text(encoding="utf-8"))
|
||
return any(
|
||
o.getAttribute("PartName") == part_name
|
||
for o in dom.getElementsByTagName("Override")
|
||
)
|
||
|
||
|
||
_COMMENT_RELS = [
|
||
("http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments", "comments.xml"),
|
||
("http://schemas.microsoft.com/office/2011/relationships/commentsExtended", "commentsExtended.xml"),
|
||
("http://schemas.microsoft.com/office/2016/09/relationships/commentsIds", "commentsIds.xml"),
|
||
("http://schemas.microsoft.com/office/2018/08/relationships/commentsExtensible", "commentsExtensible.xml"),
|
||
]
|
||
_COMMENT_OVERRIDES = [
|
||
("/word/comments.xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml"),
|
||
("/word/commentsExtended.xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtended+xml"),
|
||
("/word/commentsIds.xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.commentsIds+xml"),
|
||
("/word/commentsExtensible.xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtensible+xml"),
|
||
]
|
||
|
||
|
||
def _ensure_comment_relationships(unpacked_dir: Path) -> None:
|
||
rels_path = unpacked_dir / "word" / "_rels" / "document.xml.rels"
|
||
if not rels_path.exists():
|
||
return
|
||
dom = defusedxml.minidom.parseString(rels_path.read_text(encoding="utf-8"))
|
||
root = dom.documentElement
|
||
comment_types = {rel_type for rel_type, _ in _COMMENT_RELS}
|
||
existing = set()
|
||
for rel in dom.getElementsByTagName("Relationship"):
|
||
if rel.getAttribute("Type") not in comment_types:
|
||
continue
|
||
part = opc_target(
|
||
rel.getAttribute("Target"),
|
||
"word/document.xml",
|
||
rel.getAttribute("TargetMode"),
|
||
)
|
||
if part is not None:
|
||
existing.add(part)
|
||
next_rid = _get_next_rid(rels_path)
|
||
changed = False
|
||
for rel_type, target in _COMMENT_RELS:
|
||
if opc_target(target, "word/document.xml") in existing:
|
||
continue
|
||
rel = dom.createElement("Relationship")
|
||
rel.setAttribute("Id", f"rId{next_rid}")
|
||
rel.setAttribute("Type", rel_type)
|
||
rel.setAttribute("Target", target)
|
||
root.appendChild(rel)
|
||
next_rid += 1
|
||
changed = True
|
||
if changed:
|
||
rels_path.write_bytes(dom.toxml(encoding="UTF-8"))
|
||
|
||
|
||
def _ensure_comment_content_types(unpacked_dir: Path) -> None:
|
||
ct_path = unpacked_dir / "[Content_Types].xml"
|
||
if not ct_path.exists():
|
||
return
|
||
dom = defusedxml.minidom.parseString(ct_path.read_text(encoding="utf-8"))
|
||
root = dom.documentElement
|
||
existing = {
|
||
o.getAttribute("PartName")
|
||
for o in dom.getElementsByTagName("Override")
|
||
}
|
||
changed = False
|
||
for part_name, content_type in _COMMENT_OVERRIDES:
|
||
if part_name in existing:
|
||
continue
|
||
override = dom.createElement("Override")
|
||
override.setAttribute("PartName", part_name)
|
||
override.setAttribute("ContentType", content_type)
|
||
root.appendChild(override)
|
||
changed = True
|
||
if changed:
|
||
ct_path.write_bytes(dom.toxml(encoding="UTF-8"))
|
||
|
||
|
||
def add_comment(
|
||
unpacked_dir: Path | str,
|
||
text: str,
|
||
comment_id: int | None = None,
|
||
author: str = "Claude",
|
||
initials: str = "C",
|
||
parent_id: int | None = None,
|
||
raw: bool = False,
|
||
) -> tuple[int, str, str]:
|
||
unpacked_dir = Path(unpacked_dir)
|
||
if not raw:
|
||
text = xml_escape(text)
|
||
author = xml_escape(author, {'"': """})
|
||
initials = xml_escape(initials, {'"': """})
|
||
word = unpacked_dir / "word"
|
||
if not word.exists():
|
||
raise FileNotFoundError(f"{word} not found (not an unpacked .docx?)")
|
||
|
||
comments = word / "comments.xml"
|
||
if comment_id is None:
|
||
comment_id = _next_comment_id(comments)
|
||
|
||
parent_para = None
|
||
if parent_id is not None:
|
||
parent_para = _find_para_id(comments, parent_id) if comments.exists() else None
|
||
if not parent_para:
|
||
raise ValueError(f"parent comment {parent_id} not found")
|
||
|
||
para_id, durable_id = _generate_hex_id(), _generate_hex_id()
|
||
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||
|
||
if not comments.exists():
|
||
shutil.copy(TEMPLATE_DIR / "comments.xml", comments)
|
||
_ensure_comment_relationships(unpacked_dir)
|
||
_ensure_comment_content_types(unpacked_dir)
|
||
_append_xml(
|
||
comments,
|
||
"w:comments",
|
||
COMMENT_XML.format(
|
||
id=comment_id, author=author, date=ts, initials=initials,
|
||
para_id=para_id, text=text,
|
||
),
|
||
)
|
||
|
||
ext = word / "commentsExtended.xml"
|
||
if not ext.exists():
|
||
shutil.copy(TEMPLATE_DIR / "commentsExtended.xml", ext)
|
||
if parent_para is not None:
|
||
_append_xml(
|
||
ext, "w15:commentsEx",
|
||
f'<w15:commentEx w15:paraId="{para_id}" w15:paraIdParent="{parent_para}" w15:done="0"/>',
|
||
)
|
||
else:
|
||
_append_xml(
|
||
ext, "w15:commentsEx",
|
||
f'<w15:commentEx w15:paraId="{para_id}" w15:done="0"/>',
|
||
)
|
||
|
||
ids = word / "commentsIds.xml"
|
||
if not ids.exists():
|
||
shutil.copy(TEMPLATE_DIR / "commentsIds.xml", ids)
|
||
_append_xml(
|
||
ids, "w16cid:commentsIds",
|
||
f'<w16cid:commentId w16cid:paraId="{para_id}" w16cid:durableId="{durable_id}"/>',
|
||
)
|
||
|
||
extensible = word / "commentsExtensible.xml"
|
||
if not extensible.exists():
|
||
shutil.copy(TEMPLATE_DIR / "commentsExtensible.xml", extensible)
|
||
_append_xml(
|
||
extensible, "w16cex:commentsExtensible",
|
||
f'<w16cex:commentExtensible w16cex:durableId="{durable_id}" w16cex:dateUtc="{ts}"/>',
|
||
)
|
||
|
||
action = "reply" if parent_id is not None else "comment"
|
||
return comment_id, para_id, f"Added {action} id={comment_id} (paraId={para_id})"
|
||
|
||
|
||
def main() -> None:
|
||
p = argparse.ArgumentParser(description="Add a comment to a DOCX (directory or .docx file).")
|
||
p.add_argument("input", help="Unpacked DOCX directory OR a .docx/.dotx file")
|
||
p.add_argument("text", help="Comment text (plain text; XML-escaped automatically)")
|
||
p.add_argument("--raw", action="store_true",
|
||
help="Treat text as pre-escaped XML (skip automatic escaping)")
|
||
p.add_argument("--id", type=int, dest="comment_id",
|
||
help="Comment ID (default: auto-assign as max existing + 1)")
|
||
p.add_argument("--author", default="Claude", help="Author name")
|
||
p.add_argument("--initials", default="C", help="Author initials")
|
||
p.add_argument("--parent", type=int, help="Parent comment ID (makes this a reply)")
|
||
p.add_argument("-o", "--output",
|
||
help="Output .docx path (only used when input is a .docx; default: overwrite input)")
|
||
args = p.parse_args()
|
||
|
||
src = Path(args.input)
|
||
|
||
try:
|
||
if src.is_dir():
|
||
if args.output:
|
||
print("Warning: --output ignored for directory input", file=sys.stderr)
|
||
cid, _, msg = add_comment(
|
||
src, args.text, comment_id=args.comment_id,
|
||
author=args.author, initials=args.initials,
|
||
parent_id=args.parent, raw=args.raw,
|
||
)
|
||
print(msg)
|
||
elif src.is_file() and src.suffix.lower() in (".docx", ".dotx"):
|
||
out = Path(args.output) if args.output else src
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
tmp_path = Path(tmp)
|
||
with zipfile.ZipFile(src) as zf:
|
||
_safe_extract(zf, tmp_path)
|
||
cid, _, msg = add_comment(
|
||
tmp_path, args.text, comment_id=args.comment_id,
|
||
author=args.author, initials=args.initials,
|
||
parent_id=args.parent, raw=args.raw,
|
||
)
|
||
_rezip(tmp_path, out)
|
||
print(msg)
|
||
print(f"Wrote {out} (comment defined; add markers to word/document.xml to make it visible)")
|
||
else:
|
||
print(f"Error: {src} is neither a directory nor a .docx/.dotx file", file=sys.stderr)
|
||
sys.exit(1)
|
||
except (FileNotFoundError, ValueError, zipfile.BadZipFile, ExpatError) as e:
|
||
print(f"Error: {e}", file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
if args.parent is not None:
|
||
print(REPLY_MARKER_TEMPLATE.format(pid=args.parent, cid=cid))
|
||
else:
|
||
print(COMMENT_MARKER_TEMPLATE.format(cid=cid))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|