dsh-office-kit 0.4.7 → 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 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. Read returns styled text, tables, images, equations, counts. Edits run
50
- as pack scripts (text replacement, block append, core properties).
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
- Pairs run inside single runs first; when a match straddles runs the paragraph
5
- text is rewritten into its first run, so ordinary prose survives intact and a
6
- paragraph that mixes formats collapses. Body, tables, and every section header
7
- and footer are covered.
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
- nodes = paragraph_text_nodes(paragraph)
45
- if not nodes:
45
+ if not find:
46
46
  return 0
47
- count = 0
48
- for node in nodes:
47
+ ranges = []
48
+ joined = ""
49
+ for node in paragraph_text_nodes(paragraph):
49
50
  text = node.text or ""
50
- if find in text:
51
- hits = text.count(find)
52
- node.text = text.replace(find, replacement)
53
- count += hits
54
- if count:
55
- return count
56
- joined = "".join(node.text or "" for node in nodes)
57
- if find not in joined:
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
- hits = joined.count(find)
60
- nodes[0].text = joined.replace(find, replacement)
61
- for node in nodes[1:]:
62
- node.text = ""
63
- return hits
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. A match inside one run keeps that run's formatting; a match straddling runs collapses the paragraph into its first run, so keep needles inside a single styled span when formatting matters. `--require-all` exits 1 if a pair matched nothing — the check for a scripted edit.
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 a paragraph but keep its formatting | `python-docx` snippet below |
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
- for p in d.paragraphs:
84
- if "18%" in p.text:
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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-office-kit",
3
3
  "type": "module",
4
- "version": "0.4.7",
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",
@@ -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}