dsh-office-kit 0.4.6 → 0.4.8
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/CAPABILITY.md +5 -2
- package/assets/skills/office-documents/SKILL.md +1 -0
- package/assets/skills/office-documents/scripts/replace_text.py +42 -21
- package/assets/skills/office-documents/tasks/create_edit.md +6 -9
- package/icon.svg +11 -0
- package/package.json +3 -1
- package/python/office_cli.py +72 -1
package/CAPABILITY.md
CHANGED
|
@@ -46,8 +46,11 @@ The four routers register through `ctx.skills.registerProvider` in
|
|
|
46
46
|
- **DOCX** — create from a block spec: headings, paragraphs, bullets, numbered
|
|
47
47
|
lists, tables, images, LaTeX equations (OMML, fallbacks counted in
|
|
48
48
|
`equation_fallbacks`), quotes, page breaks, spacers, header/footer, page size,
|
|
49
|
-
margins
|
|
50
|
-
|
|
49
|
+
margins, and an optional `font` family shared by Western, East Asian and
|
|
50
|
+
complex-script text. Heading styles stay consistent when later extended. Headings
|
|
51
|
+
and titles are black. Read returns styled text,
|
|
52
|
+
tables, images, equations, counts. Edits run as pack scripts (text replacement
|
|
53
|
+
preserving run formatting, block append, core properties).
|
|
51
54
|
- **PDF** — create with reportlab; the CJK font is auto-detected (named in the
|
|
52
55
|
report, e.g. `OfficeCJK`). Read returns page text, metadata, word counts and
|
|
53
56
|
form fields; OCR is not bundled (`ocr.available: false`). Edits run as pack
|
|
@@ -63,6 +63,7 @@ Where no verb exists, use the listed script or a short `python-docx`/`lxml` snip
|
|
|
63
63
|
## Document defaults
|
|
64
64
|
|
|
65
65
|
- Letter 8.5×11 portrait (A4 when the user or template says so), 1in margins, body 11–12pt, single column.
|
|
66
|
+
- `docx create` accepts an optional top-level `"font": "Microsoft YaHei"` family string; omit it to retain Calibri. The selected family applies consistently to Western, East Asian and complex-script text through document defaults and styles used by generated blocks, with East Asian language `zh-CN`. Choose an installed font with the glyph coverage the content needs; the helper does not install or embed fonts. Native equations retain their math font.
|
|
66
67
|
- Black headings and body; colour only for deliberate emphasis, links and chart series.
|
|
67
68
|
- Tables: `#D9D9D9` borders, bold header row, `w:tblHeader` repeat, no vertical merges in wide tables.
|
|
68
69
|
- No callout boxes, drop shadows or coloured sidebars — structure comes from headings, lists and tables.
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""Replace text in a Word document without touching the original.
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
Only changed spans are rewritten, preserving the original text nodes and run
|
|
5
|
+
formatting even when a match straddles runs. Changed text uses the formatting
|
|
6
|
+
at the start of its span. Body, tables, and every section header and footer
|
|
7
|
+
are covered.
|
|
8
8
|
|
|
9
9
|
Usage:
|
|
10
10
|
replace_text.py --input IN.docx [--out OUT.docx] --find OLD --replace NEW [--find ...]
|
|
@@ -21,6 +21,7 @@ from __future__ import annotations
|
|
|
21
21
|
import argparse
|
|
22
22
|
import json
|
|
23
23
|
import sys
|
|
24
|
+
from difflib import SequenceMatcher
|
|
24
25
|
from pathlib import Path
|
|
25
26
|
|
|
26
27
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
@@ -41,26 +42,46 @@ def paragraph_text_nodes(paragraph) -> list:
|
|
|
41
42
|
|
|
42
43
|
|
|
43
44
|
def replace_in_paragraph(paragraph, find: str, replacement: str) -> int:
|
|
44
|
-
|
|
45
|
-
if not nodes:
|
|
45
|
+
if not find:
|
|
46
46
|
return 0
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
ranges = []
|
|
48
|
+
joined = ""
|
|
49
|
+
for node in paragraph_text_nodes(paragraph):
|
|
49
50
|
text = node.text or ""
|
|
50
|
-
if
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
51
|
+
if text:
|
|
52
|
+
ranges.append((node, len(joined), len(joined) + len(text)))
|
|
53
|
+
joined += text
|
|
54
|
+
matches = []
|
|
55
|
+
offset = joined.find(find)
|
|
56
|
+
while offset != -1:
|
|
57
|
+
matches.append(offset)
|
|
58
|
+
offset = joined.find(find, offset + len(find))
|
|
59
|
+
if not matches:
|
|
58
60
|
return 0
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
|
|
62
|
+
changes = [opcode for opcode in SequenceMatcher(
|
|
63
|
+
None, find, replacement, autojunk=False
|
|
64
|
+
).get_opcodes() if opcode[0] != "equal"]
|
|
65
|
+
# All offsets refer to the original text. Working backwards also prevents
|
|
66
|
+
# inserted text from being matched again during this replacement pair.
|
|
67
|
+
for offset in reversed(matches):
|
|
68
|
+
for _, old_start, old_end, new_start, new_end in reversed(changes):
|
|
69
|
+
start, end = offset + old_start, offset + old_end
|
|
70
|
+
first = next((i for i, (_, _, stop) in enumerate(ranges)
|
|
71
|
+
if stop > start), len(ranges) - 1)
|
|
72
|
+
for i in range(first, len(ranges)):
|
|
73
|
+
node, node_start, node_end = ranges[i]
|
|
74
|
+
if i != first and node_start >= end:
|
|
75
|
+
break
|
|
76
|
+
text = node.text or ""
|
|
77
|
+
left = max(0, start - node_start)
|
|
78
|
+
right = min(node_end, end) - node_start
|
|
79
|
+
node.text = (text[:left]
|
|
80
|
+
+ (replacement[new_start:new_end] if i == first else "")
|
|
81
|
+
+ text[right:])
|
|
82
|
+
if node.text and (node.text[0].isspace() or node.text[-1].isspace()):
|
|
83
|
+
node.set("{http://www.w3.org/XML/1998/namespace}space", "preserve")
|
|
84
|
+
return len(matches)
|
|
64
85
|
|
|
65
86
|
|
|
66
87
|
def iter_paragraphs(document):
|
|
@@ -29,6 +29,8 @@ Write a JSON spec next to the output, then run:
|
|
|
29
29
|
}
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
+
The optional top-level `font` string sets the same family for Western, East Asian and complex-script text (for example, `"font": "Microsoft YaHei"`). The default is `Calibri`; choose an installed family with the glyph coverage your document needs. Body, headings, lists, tables, headers and footers share this family. Heading styles are prepared even when unused, so later `append_blocks.py` headings keep the same family. Headings and titles are black, and East Asian language metadata is `zh-CN`. Native equations keep their math font.
|
|
33
|
+
|
|
32
34
|
Block types and keys: `heading` (`level`, `text`), `paragraph` (`text`, `bold`, `italic`, `size`, `color`, `align`, `style`), `bullets` / `numbered` (`items`), `table` (`headers`, `rows`, optional `style`, default `Table Grid`), `image` (`path`, optional `width` in inches), `equation` (`latex`), `quote`, `page_break`, `spacer`. Page keys: `page_size` (`Letter` or `A4`), `orientation`, and `top_margin` / `bottom_margin` / `left_margin` / `right_margin` in inches.
|
|
33
35
|
|
|
34
36
|
The command prints counts for headings, paragraphs, list items, tables, images and equations. Check `equation_fallbacks`: a non-zero value means some LaTeX was emitted as styled text instead of OMML.
|
|
@@ -44,7 +46,7 @@ Replace text — body, tables, and every header and footer:
|
|
|
44
46
|
--find "18%" --replace "21%" --find "Hardware flat" --replace "Hardware steady"
|
|
45
47
|
```
|
|
46
48
|
|
|
47
|
-
`--find` / `--replace` pair up in order; `--spec edits.json` takes `{"replace": [{"find": "...", "replace": "..."}]}` instead. The command prints a count per pair.
|
|
49
|
+
`--find` / `--replace` pair up in order; `--spec edits.json` takes `{"replace": [{"find": "...", "replace": "..."}]}` instead. The command prints a count per pair. Matches preserve existing run formatting, including unchanged text on either side of a replacement. Within a cross-run match, unchanged text keeps its original run; inserted or changed text inherits the formatting at the start of that changed span (an insertion at a run boundary uses the following run, or the preceding run at the paragraph end). `--require-all` exits 1 if a pair matched nothing — the check for a scripted edit.
|
|
48
50
|
|
|
49
51
|
Append blocks — the same block types `docx create` accepts:
|
|
50
52
|
|
|
@@ -74,19 +76,14 @@ Set properties:
|
|
|
74
76
|
| Edit inside a header, footer, footnote or comment | `docx_kit.py xml <file> <part>` to inspect, then the snippet below |
|
|
75
77
|
| Mark edits as tracked changes | `add_tracked_replacements.py` (`tasks/clean_tracked_changes.md`) |
|
|
76
78
|
| Header date, footer page numbers, first hyperlink | `docx_ooxml_patch.py` |
|
|
77
|
-
| Replace
|
|
79
|
+
| Replace text across styled runs | `replace_text.py` above |
|
|
78
80
|
|
|
79
81
|
```bash
|
|
80
82
|
"<python>" - <<'PY'
|
|
81
83
|
from docx import Document
|
|
82
84
|
d = Document(r"<file.docx>")
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
runs = p.runs or [p.add_run()]
|
|
86
|
-
runs[0].text = p.text.replace("18%", "21%")
|
|
87
|
-
for r in runs[1:]:
|
|
88
|
-
r.text = ""
|
|
89
|
-
break
|
|
85
|
+
# Structural edits use python-docx; text substitutions use replace_text.py above.
|
|
86
|
+
d.sections[0].header.paragraphs[0].add_run(" — Reviewed")
|
|
90
87
|
d.save(r"<file-edited.docx>")
|
|
91
88
|
PY
|
|
92
89
|
```
|
package/icon.svg
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<path d="M10.5 7.5H20L26 13.5V28C26 28.83 25.33 29.5 24.5 29.5H10.5C9.67 29.5 9 28.83 9 28V9C9 8.17 9.67 7.5 10.5 7.5Z" fill="url(#office-page)" fill-opacity="0.85" />
|
|
3
|
+
<path d="M20 7.5V12.5C20 13.33 20.67 14 21.5 14H26L20 7.5Z" fill="#8CC5FD" fill-opacity="0.6" />
|
|
4
|
+
<path d="M12.75 17.5H22M12.75 22H18.75" stroke="#FFFFFF" stroke-width="1.9" stroke-linecap="round" />
|
|
5
|
+
<defs>
|
|
6
|
+
<linearGradient id="office-page" x1="9.5" y1="9" x2="27" y2="29" gradientUnits="userSpaceOnUse">
|
|
7
|
+
<stop stop-color="#67C7FE" />
|
|
8
|
+
<stop offset="1" stop-color="#3F77D8" />
|
|
9
|
+
</linearGradient>
|
|
10
|
+
</defs>
|
|
11
|
+
</svg>
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-office-kit",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.4.
|
|
4
|
+
"version": "0.4.8",
|
|
5
5
|
"description": "Office documents, PDF, presentations, and spreadsheets for DeepSeek Harness",
|
|
6
6
|
"author": "Hairy",
|
|
7
7
|
"license": "MIT",
|
|
@@ -29,12 +29,14 @@
|
|
|
29
29
|
"main": "./dist/index.js",
|
|
30
30
|
"module": "./dist/index.js",
|
|
31
31
|
"types": "./dist/index.d.ts",
|
|
32
|
+
"icon": "./icon.svg",
|
|
32
33
|
"files": [
|
|
33
34
|
"CAPABILITY.md",
|
|
34
35
|
"README.md",
|
|
35
36
|
"assets",
|
|
36
37
|
"cordis.patch.yml",
|
|
37
38
|
"dist",
|
|
39
|
+
"icon.svg",
|
|
38
40
|
"postinstall.mjs",
|
|
39
41
|
"python"
|
|
40
42
|
],
|
package/python/office_cli.py
CHANGED
|
@@ -631,13 +631,83 @@ def _docx_add_blocks(document, blocks: Iterable[dict[str, Any]], counters: dict[
|
|
|
631
631
|
_docx_add_block(document, block, counters)
|
|
632
632
|
|
|
633
633
|
|
|
634
|
+
def _docx_set_font(properties, family: str) -> None:
|
|
635
|
+
from docx.oxml import OxmlElement
|
|
636
|
+
from docx.oxml.ns import qn
|
|
637
|
+
|
|
638
|
+
fonts = properties.get_or_add_rFonts()
|
|
639
|
+
# Theme attributes take precedence over literal font names in Word.
|
|
640
|
+
for attribute in list(fonts.attrib):
|
|
641
|
+
if attribute.lower().endswith("theme"):
|
|
642
|
+
del fonts.attrib[attribute]
|
|
643
|
+
for slot in ("ascii", "hAnsi", "eastAsia", "cs"):
|
|
644
|
+
fonts.set(qn(f"w:{slot}"), family)
|
|
645
|
+
language = properties.find(qn("w:lang"))
|
|
646
|
+
if language is None:
|
|
647
|
+
language = OxmlElement("w:lang")
|
|
648
|
+
properties.append(language)
|
|
649
|
+
language.set(qn("w:eastAsia"), "zh-CN")
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def _docx_normalize_styles(document, family: str) -> None:
|
|
653
|
+
from docx.oxml.ns import qn
|
|
654
|
+
|
|
655
|
+
styles = document.styles.element
|
|
656
|
+
defaults = styles.find(qn("w:docDefaults")).find(qn("w:rPrDefault")).find(qn("w:rPr"))
|
|
657
|
+
_docx_set_font(defaults, family)
|
|
658
|
+
normal = document.styles["Normal"].element
|
|
659
|
+
_docx_set_font(normal.get_or_add_rPr(), family)
|
|
660
|
+
|
|
661
|
+
# Normalize used styles and headings for content appended after creation.
|
|
662
|
+
headings = {"Title", "TitleChar"} | {
|
|
663
|
+
f"Heading{level}{suffix}" for level in range(1, 10) for suffix in ("", "Char")
|
|
664
|
+
}
|
|
665
|
+
roots = [document.element]
|
|
666
|
+
roots.extend(
|
|
667
|
+
part.element for part in document.part.related_parts.values()
|
|
668
|
+
if str(part.partname).startswith(("/word/header", "/word/footer"))
|
|
669
|
+
)
|
|
670
|
+
used = {"Normal"} | headings
|
|
671
|
+
for root in roots:
|
|
672
|
+
for node in root.iter(qn("w:pStyle"), qn("w:rStyle"), qn("w:tblStyle")):
|
|
673
|
+
used.add(node.get(qn("w:val")))
|
|
674
|
+
by_id = {style.get(qn("w:styleId")): style for style in styles.findall(qn("w:style"))}
|
|
675
|
+
pending = list(used)
|
|
676
|
+
while pending:
|
|
677
|
+
style = by_id[pending.pop()]
|
|
678
|
+
for tag in ("w:basedOn", "w:link"):
|
|
679
|
+
reference = style.find(qn(tag))
|
|
680
|
+
if reference is not None:
|
|
681
|
+
target = reference.get(qn("w:val"))
|
|
682
|
+
if target not in used:
|
|
683
|
+
used.add(target)
|
|
684
|
+
pending.append(target)
|
|
685
|
+
|
|
686
|
+
for style_id in used:
|
|
687
|
+
style = by_id[style_id]
|
|
688
|
+
for fonts in style.iter(qn("w:rFonts")):
|
|
689
|
+
_docx_set_font(fonts.getparent(), family)
|
|
690
|
+
for language in style.iter(qn("w:lang")):
|
|
691
|
+
language.set(qn("w:eastAsia"), "zh-CN")
|
|
692
|
+
if style_id in headings:
|
|
693
|
+
color = style.get_or_add_rPr().get_or_add_color()
|
|
694
|
+
color.attrib.clear()
|
|
695
|
+
color.set(qn("w:val"), "000000")
|
|
696
|
+
if style_id == "Title":
|
|
697
|
+
for border in style.xpath("./w:pPr/w:pBdr"):
|
|
698
|
+
border.getparent().remove(border)
|
|
699
|
+
|
|
700
|
+
|
|
634
701
|
def docx_create(spec: dict[str, Any], out: Path) -> dict[str, Any]:
|
|
635
702
|
from docx import Document
|
|
636
703
|
from docx.shared import Pt
|
|
637
704
|
|
|
705
|
+
family = spec.get("font", "Calibri")
|
|
706
|
+
if not isinstance(family, str) or not family.strip():
|
|
707
|
+
fail("font must be a non-empty font family string")
|
|
708
|
+
family = family.strip()
|
|
638
709
|
document = Document()
|
|
639
710
|
style = document.styles["Normal"]
|
|
640
|
-
style.font.name = "Calibri"
|
|
641
711
|
style.font.size = Pt(11)
|
|
642
712
|
section = document.sections[0]
|
|
643
713
|
page_size = str(spec.get("page_size", "Letter")).lower()
|
|
@@ -670,6 +740,7 @@ def docx_create(spec: dict[str, Any], out: Path) -> dict[str, Any]:
|
|
|
670
740
|
|
|
671
741
|
counters = {key: 0 for key in ("headings", "paragraphs", "list_items", "tables", "images", "equations", "equation_fallbacks")}
|
|
672
742
|
_docx_add_blocks(document, spec.get("blocks", spec.get("sections", [])), counters)
|
|
743
|
+
_docx_normalize_styles(document, family)
|
|
673
744
|
ensure_parent(str(out))
|
|
674
745
|
document.save(str(out))
|
|
675
746
|
return {"ok": True, "kind": "docx", "command": "create", "output": str(out), "counts": counters}
|