dsh-office-kit 0.4.4 → 0.4.6

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
@@ -138,10 +138,11 @@ never guessed.
138
138
  | PDF create / read / render / `metadata.py` / `page_ops.py` / `redact_region.py` | ok — CJK font applied, metadata written, merge→remove→rotate, region redaction removed 170 chars and 11 drawings, `page-1.png` produced |
139
139
  | Annotation geometry helpers | pymupdf rect conversion, python-pptx overlap scan, A1 text matching run against those files |
140
140
  | Forbidden-vocabulary grep over the delivered docs | empty |
141
+ | macOS 26 arm64, DSH 0.2.1-rc.2, `DSH_OFFICE_PYTHON` = Homebrew 3.13 | full vitest suite with `DSH_OFFICE_REQUIRE_RENDERER=1` passes; docx/pptx/xlsx render and xlsx recalc report `engine: native`, also with the app's Electron binary as the recorded node; PDF create/stamp pick STHeiti |
141
142
 
142
143
  Not claimed (no evidence here): installer or self-heal timings, live GUI
143
- interaction (`/` menu, chip, sidebar preview, annotation overlay), and macOS
144
- renderer support.
144
+ interaction (`/` menu, chip, sidebar preview, annotation overlay), and automatic
145
+ Python discovery on macOS (see #23).
145
146
 
146
147
  ## Explicitly out of scope
147
148
 
@@ -152,6 +153,6 @@ renderer support.
152
153
  so); OCR and scanned-PDF text extraction.
153
154
  - PPTX animations, speaker notes, slide masters; XLSX pivots, macros, data
154
155
  validation, conditional formatting — no spec keys or verbs in the helper.
155
- - Formula evaluation without the DSH LibreOffice kit; macOS/Linux installation
156
- was exercised in CI (Linux runs the WASM engine), but only Windows was checked
157
- by hand.
156
+ - Formula evaluation without the DSH LibreOffice kit; Linux installation was
157
+ exercised in CI (Linux runs the WASM engine); Windows and macOS were checked by
158
+ hand.
@@ -5,7 +5,9 @@ Word's TOC field needs a refresh before its links work. This script instead
5
5
  writes static hyperlinks that any renderer follows: bookmarks on every heading,
6
6
  a contents list at the top (with quick links and back links), and optional
7
7
  Top/Bottom anchors. Because the entries are literal text there is nothing to
8
- update before the file is read.
8
+ update before the file is read. Rerunning on a file this script already
9
+ processed strips the previous navigation first, so the output is rebuilt
10
+ cleanly instead of stacking a second contents block.
9
11
 
10
12
  Exit codes: 0 success, 1 no headings, 2 bad input.
11
13
  """
@@ -59,6 +61,63 @@ def paragraph(style: str | None = None):
59
61
  return node
60
62
 
61
63
 
64
+ def _remove_bookmark(node) -> None:
65
+ """Drop one bookmarkStart and its matching bookmarkEnd wherever they live."""
66
+ ident = node.get(kit.q("id"))
67
+ parent = node.getparent()
68
+ if parent is not None:
69
+ parent.remove(node)
70
+ if ident is None:
71
+ return
72
+ root = parent if parent is not None else node
73
+ while root.getparent() is not None:
74
+ root = root.getparent()
75
+ for end in list(root.iter(kit.q("bookmarkEnd"))):
76
+ if end.get(kit.q("id")) == ident:
77
+ end.getparent().remove(end)
78
+
79
+
80
+ def strip_previous_nav(body) -> int:
81
+ """Remove navigation inserted by an earlier run, so reruns rebuild cleanly.
82
+
83
+ Without this, a second run stacks a second contents block, re-collects the
84
+ first block's title as a chapter, and duplicates the nav_top/nav_toc/
85
+ nav_bottom bookmark names, which makes every back link resolve to the stale
86
+ block. A paragraph is treated as generated when it links to a nav_ anchor or
87
+ carries a structural nav bookmark; a real heading only loses its nav_h
88
+ bookmarks. Returns the number of paragraphs removed.
89
+ """
90
+ removed = 0
91
+ for paragraph_node in list(body.iter(kit.q("p"))):
92
+ bookmarks = [
93
+ node
94
+ for node in paragraph_node.iter(kit.q("bookmarkStart"))
95
+ if (node.get(kit.q("name")) or "").startswith("nav_")
96
+ ]
97
+ links = [
98
+ node
99
+ for node in paragraph_node.iter(kit.q("hyperlink"))
100
+ if (node.get(kit.q("anchor")) or "").startswith("nav_")
101
+ ]
102
+ if not bookmarks and not links:
103
+ continue
104
+ generated = bool(links) or any(
105
+ node.get(kit.q("name")) in (TOP_BOOKMARK, BOTTOM_BOOKMARK, TOC_BOOKMARK)
106
+ for node in bookmarks
107
+ )
108
+ if generated:
109
+ for node in bookmarks:
110
+ _remove_bookmark(node)
111
+ parent = paragraph_node.getparent()
112
+ if parent is not None:
113
+ parent.remove(paragraph_node)
114
+ removed += 1
115
+ else:
116
+ for node in bookmarks:
117
+ _remove_bookmark(node)
118
+ return removed
119
+
120
+
62
121
  def main(argv: list[str] | None = None) -> int:
63
122
  parser = argparse.ArgumentParser(description="Add bookmark anchors and static internal links to headings.")
64
123
  parser.add_argument("input_docx", help="Input .docx")
@@ -77,6 +136,7 @@ def main(argv: list[str] | None = None) -> int:
77
136
  return 2
78
137
 
79
138
  body = doc.body()
139
+ stripped = strip_previous_nav(body)
80
140
  headings = []
81
141
  ident = doc.next_id()
82
142
  for paragraph_node in list(body.iter(kit.q("p"))):
@@ -147,7 +207,7 @@ def main(argv: list[str] | None = None) -> int:
147
207
  body.append(last)
148
208
 
149
209
  doc.save(args.out)
150
- kit.emit({"ok": True, "output": str(Path(args.out)), "headings": headings, "entries": len(block)})
210
+ kit.emit({"ok": True, "output": str(Path(args.out)), "headings": headings, "entries": len(block), "stripped": stripped})
151
211
  return 0
152
212
 
153
213
 
@@ -15,6 +15,7 @@ Exit codes: 0 success, 1 bad arguments, 2 unreadable input or write failure,
15
15
  from __future__ import annotations
16
16
 
17
17
  import argparse
18
+ import io
18
19
  import json
19
20
  import sys
20
21
  from pathlib import Path
@@ -31,6 +32,8 @@ except ImportError: # pragma: no cover - pypdf is a required dependency
31
32
  IndirectObject = None # type: ignore[assignment]
32
33
  NameObject = None # type: ignore[assignment]
33
34
 
35
+ from pdf_fonts import find_cjk_font, has_non_latin, register_cjk_font
36
+
34
37
 
35
38
  def emit(value, out=None):
36
39
  text = json.dumps(value, ensure_ascii=False, indent=2, default=str)
@@ -49,6 +52,114 @@ def fail(message, code=1, detail=None):
49
52
  raise SystemExit(code)
50
53
 
51
54
 
55
+ def rebuild_cjk_appearances(path, values, font_file):
56
+ """Replace the appearance of text widgets whose value is non-Latin.
57
+
58
+ pypdf can only generate appearance streams with the base-14 fonts, so a
59
+ Chinese value would paint as question marks even though /V is correct.
60
+ Each such widget gets a fresh form XObject drawn with a real CJK font,
61
+ with the field border and background kept.
62
+ """
63
+ from pypdf import PdfReader, PdfWriter
64
+ from pypdf.generic import ArrayObject, DictionaryObject, FloatObject, NameObject, NumberObject, StreamObject
65
+ from reportlab.lib.colors import Color
66
+ from reportlab.pdfbase import pdfmetrics
67
+ from reportlab.pdfgen import canvas as rl_canvas
68
+
69
+ reader = PdfReader(str(path))
70
+ report = inspect_document(reader)
71
+ targets = []
72
+ for widget in report["widgets"]:
73
+ name = widget["name"]
74
+ value = values.get(name)
75
+ if not has_non_latin(value) or widget["field_type"] != "/Tx":
76
+ continue
77
+ rect = widget["rect"]
78
+ if len(rect) != 4:
79
+ continue
80
+ width = float(rect[2]) - float(rect[0])
81
+ height = float(rect[3]) - float(rect[1])
82
+ if width <= 1 or height <= 1:
83
+ continue
84
+ targets.append({"page": widget["page"], "name": name, "value": value, "width": width, "height": height})
85
+ if not targets:
86
+ return []
87
+
88
+ try:
89
+ font_path = find_cjk_font(font_file)
90
+ except FileNotFoundError as exc:
91
+ fail(str(exc), code=2)
92
+ if font_path is None:
93
+ fail("values contain non-Latin text but no CJK font was found; pass --font-file", code=2)
94
+ try:
95
+ font_name = register_cjk_font(font_path, "FormCJK")
96
+ except Exception as exc:
97
+ fail(f"cannot use font file {font_path}: {exc}", code=2)
98
+
99
+ overlay = io.BytesIO()
100
+ page = rl_canvas.Canvas(overlay)
101
+ for target in targets:
102
+ width, height = target["width"], target["height"]
103
+ page.setPageSize((width, height))
104
+ page.setFillColor(Color(1, 1, 1))
105
+ page.rect(0, 0, width, height, stroke=0, fill=1)
106
+ page.setStrokeColor(Color(0.55, 0.55, 0.55))
107
+ page.setLineWidth(0.5)
108
+ page.rect(0.25, 0.25, width - 0.5, height - 0.5, stroke=1, fill=0)
109
+ size = min(10.0, max(height - 3, 4.0))
110
+ while size > 4 and pdfmetrics.stringWidth(target["value"], font_name, size) > width - 4:
111
+ size -= 0.5
112
+ page.setFillColor(Color(0, 0, 0))
113
+ page.setFont(font_name, size)
114
+ baseline = (height - size) / 2 + size * 0.22
115
+ page.drawString(2, baseline, target["value"])
116
+ page.showPage()
117
+ page.save()
118
+ overlay.seek(0)
119
+ overlay_reader = PdfReader(overlay)
120
+
121
+ writer = PdfWriter()
122
+ writer.clone_document_from_reader(reader)
123
+ original_pages = len(writer.pages)
124
+ writer.append(overlay_reader)
125
+ appended = writer.pages[original_pages:]
126
+
127
+ for index, target in enumerate(targets):
128
+ overlay_page = appended[index]
129
+ contents = overlay_page.get_contents()
130
+ data = contents.get_data() if contents is not None else b""
131
+ resources = overlay_page.get("/Resources")
132
+ stream = StreamObject()
133
+ stream.set_data(data)
134
+ stream[NameObject("/Type")] = NameObject("/XObject")
135
+ stream[NameObject("/Subtype")] = NameObject("/Form")
136
+ stream[NameObject("/FormType")] = NumberObject(1)
137
+ stream[NameObject("/BBox")] = ArrayObject(
138
+ [FloatObject(0), FloatObject(0), FloatObject(target["width"]), FloatObject(target["height"])]
139
+ )
140
+ if resources is not None:
141
+ stream[NameObject("/Resources")] = resources
142
+ reference = writer._add_object(stream)
143
+ page = writer.pages[target["page"] - 1]
144
+ for annotation in page.get("/Annots") or []:
145
+ obj = annotation.get_object()
146
+ if obj.get("/Subtype") != "/Widget":
147
+ continue
148
+ parent = obj.get("/Parent")
149
+ source = parent.get_object() if parent is not None else obj
150
+ tag = obj.get("/T") or source.get("/T")
151
+ if str(tag) != target["name"]:
152
+ continue
153
+ obj[NameObject("/AP")] = DictionaryObject({NameObject("/N"): reference})
154
+ break
155
+
156
+ while len(writer.pages) > original_pages:
157
+ writer.remove_page(len(writer.pages) - 1)
158
+ with open(path, "wb") as handle:
159
+ writer.write(handle)
160
+ return [target["name"] for target in targets]
161
+
162
+
52
163
  def ref_id(node):
53
164
  if isinstance(node, IndirectObject):
54
165
  return node.idnum
@@ -285,6 +396,85 @@ def values_for_field(field, value):
285
396
  return str(value)
286
397
 
287
398
 
399
+ def flatten_widgets(path):
400
+ """Paint every widget appearance into page content and drop the form."""
401
+ from pypdf import PdfReader, PdfWriter
402
+ from pypdf.generic import ArrayObject, DictionaryObject, FloatObject, NameObject, StreamObject
403
+
404
+ reader = PdfReader(str(path))
405
+ writer = PdfWriter()
406
+ writer.clone_document_from_reader(reader)
407
+
408
+ for page in writer.pages:
409
+ annotations = page.get("/Annots")
410
+ if not annotations:
411
+ continue
412
+ resources = page.get("/Resources")
413
+ if resources is None:
414
+ resources = DictionaryObject()
415
+ page[NameObject("/Resources")] = resources
416
+ resources = resources.get_object()
417
+ xobjects = resources.get("/XObject")
418
+ if xobjects is None:
419
+ xobjects = DictionaryObject()
420
+ resources[NameObject("/XObject")] = xobjects
421
+ xobjects = xobjects.get_object()
422
+
423
+ calls = []
424
+ for index, annotation in enumerate(annotations):
425
+ widget = annotation.get_object()
426
+ if widget.get("/Subtype") != "/Widget":
427
+ continue
428
+ normal = widget.get("/AP")
429
+ if normal is None:
430
+ continue
431
+ normal = normal.get_object().get("/N")
432
+ if normal is None:
433
+ continue
434
+ normal = normal.get_object()
435
+ if not hasattr(normal, "get_data"):
436
+ if not hasattr(normal, "keys"):
437
+ continue
438
+ state = widget.get("/AS")
439
+ if state is None or normal.get(state) is None:
440
+ continue
441
+ normal = normal[state].get_object()
442
+ if not hasattr(normal, "get_data"):
443
+ continue
444
+ name = NameObject(f"/DSCJK{index}")
445
+ xobjects[name] = normal.indirect_reference or normal
446
+ rect = [float(value) for value in widget.get("/Rect") or []]
447
+ bbox = [float(value) for value in normal.get("/BBox") or []]
448
+ if len(rect) != 4 or len(bbox) != 4:
449
+ continue
450
+ x, y, x2, y2 = rect[0], rect[1], rect[2], rect[3]
451
+ width = x2 - x
452
+ height = y2 - y
453
+ box_width = bbox[2] - bbox[0] or width
454
+ box_height = bbox[3] - bbox[1] or height
455
+ calls.append(
456
+ f"q {width / box_width:.6f} 0 0 {height / box_height:.6f} {x:.4f} {y:.4f} cm {name} Do Q"
457
+ )
458
+ if not calls:
459
+ continue
460
+ stream = StreamObject()
461
+ stream.set_data("\n".join(calls).encode("latin-1"))
462
+ reference = writer._add_object(stream)
463
+ existing = page.get("/Contents")
464
+ if existing is None:
465
+ page[NameObject("/Contents")] = reference
466
+ elif hasattr(existing, "get_data"):
467
+ page[NameObject("/Contents")] = ArrayObject([existing.indirect_reference or existing, reference])
468
+ else:
469
+ page[NameObject("/Contents")] = ArrayObject([*existing, reference])
470
+
471
+ for page in writer.pages:
472
+ page[NameObject("/Annots")] = ArrayObject()
473
+ writer.root_object.pop(NameObject("/AcroForm"), None)
474
+ with open(path, "wb") as handle:
475
+ writer.write(handle)
476
+
477
+
288
478
  def write_fill(args, values):
289
479
  from pypdf import PdfReader, PdfWriter
290
480
 
@@ -332,16 +522,28 @@ def write_fill(args, values):
332
522
  payload = current
333
523
  payload = {name: values_for_field(fields[name], value) for name, value in payload.items()}
334
524
 
335
- writer.update_page_form_field_values(None, payload, auto_regenerate=False, flatten=args.flatten)
336
- sync_button_states(writer)
337
- if args.flatten:
338
- writer.remove_annotations(subtypes="/Widget")
339
- writer.root_object.pop(NameObject("/AcroForm"), None)
525
+ cjk_values = {name: value for name, value in expected.items() if has_non_latin(value)}
526
+ flatten = args.flatten
527
+ if flatten and cjk_values:
528
+ # pypdf can only flatten with base-14 fonts; keep the widgets, give them
529
+ # real CJK appearances, then paint every appearance into the page.
530
+ writer.update_page_form_field_values(None, payload, auto_regenerate=False, flatten=False)
531
+ sync_button_states(writer)
532
+ else:
533
+ writer.update_page_form_field_values(None, payload, auto_regenerate=False, flatten=flatten)
534
+ sync_button_states(writer)
535
+ if flatten:
536
+ writer.remove_annotations(subtypes="/Widget")
537
+ writer.root_object.pop(NameObject("/AcroForm"), None)
340
538
 
341
539
  args.out.parent.mkdir(parents=True, exist_ok=True)
342
540
  with open(args.out, "wb") as handle:
343
541
  writer.write(handle)
344
542
 
543
+ cjk_rendered = rebuild_cjk_appearances(args.out, expected, args.font_file)
544
+ if flatten and cjk_values:
545
+ flatten_widgets(args.out)
546
+
345
547
  checks = verify(args.out, expected, flatten=args.flatten)
346
548
  rendered = render(args.out, args.render_dir, args.dpi)
347
549
 
@@ -351,6 +553,7 @@ def write_fill(args, values):
351
553
  "output": str(args.out),
352
554
  "flatten": args.flatten,
353
555
  "reattached_fields": reattached,
556
+ "cjk_appearance_fields": cjk_rendered,
354
557
  "written": expected,
355
558
  "checks": checks,
356
559
  "rendered_pages": rendered,
@@ -517,6 +720,7 @@ def main():
517
720
  help='JSON object of {field: value}; accepts a file path, inline JSON, or "-" for stdin',
518
721
  )
519
722
  fill_parser.add_argument("--flatten", action="store_true", help="Paint values and remove every widget")
723
+ fill_parser.add_argument("--font-file", help="TTF/OTF/TTC file used to paint non-Latin values")
520
724
  fill_parser.add_argument("--no-reattach", action="store_true", help="Do not repair widgets missing from /AcroForm/Fields")
521
725
  fill_parser.add_argument("--allow-ambiguous", action="store_true", help="Fill even when widget and field views disagree")
522
726
  fill_parser.add_argument("--render-dir", help="Rasterise the result here for a visual check")
@@ -0,0 +1,67 @@
1
+ """Font handling shared by the PDF scripts.
2
+
3
+ reportlab's base-14 fonts cover Latin-1 only, so a Chinese, Japanese, or Korean
4
+ stamp or form value needs a real font file. These helpers find one, or report
5
+ that the caller must pass --font-file.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ CJK_FONT_CANDIDATES = {
15
+ "win32": [
16
+ "C:/Windows/Fonts/msyh.ttc",
17
+ "C:/Windows/Fonts/msyhbd.ttc",
18
+ "C:/Windows/Fonts/simhei.ttf",
19
+ "C:/Windows/Fonts/simsun.ttc",
20
+ ],
21
+ "linux": [
22
+ "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
23
+ "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
24
+ "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
25
+ "/usr/share/fonts/opentype/noto/NotoSerifCJK-Regular.ttc",
26
+ ],
27
+ "darwin": [
28
+ "/System/Library/Fonts/STHeiti Medium.ttc",
29
+ "/System/Library/Fonts/STHeiti Light.ttc",
30
+ "/System/Library/Fonts/Supplemental/Songti.ttc",
31
+ ],
32
+ }
33
+
34
+
35
+ def has_non_latin(text) -> bool:
36
+ """True when text needs a font beyond the base-14 Latin sets."""
37
+ return isinstance(text, str) and any(ord(character) > 0xFF for character in text)
38
+
39
+
40
+ def find_cjk_font(explicit=None) -> Path | None:
41
+ """Return a usable CJK font path, honouring an explicit path first."""
42
+ if explicit:
43
+ path = Path(explicit).expanduser()
44
+ if not path.is_file():
45
+ raise FileNotFoundError(f"font file not found: {path}")
46
+ return path
47
+ override = os.environ.get("DSH_OFFICE_CJK_FONT")
48
+ if override and Path(override).expanduser().is_file():
49
+ return Path(override).expanduser()
50
+ platform_key = "win32" if sys.platform.startswith("win") else "darwin" if sys.platform == "darwin" else "linux"
51
+ for candidate in CJK_FONT_CANDIDATES[platform_key]:
52
+ path = Path(candidate)
53
+ if path.is_file():
54
+ return path
55
+ return None
56
+
57
+
58
+ def register_cjk_font(path: Path, name: str) -> str:
59
+ """Register a TTF/OTF/TTC file with reportlab and return the resource name."""
60
+ from reportlab.pdfbase import pdfmetrics
61
+ from reportlab.pdfbase.ttfonts import TTFont
62
+
63
+ kwargs = {}
64
+ if path.suffix.lower() in {".ttc", ".otc", ".dfont"}:
65
+ kwargs["subfontIndex"] = 0
66
+ pdfmetrics.registerFont(TTFont(name, str(path), **kwargs))
67
+ return name
@@ -28,6 +28,8 @@ from pypdf import PdfReader, PdfWriter
28
28
  from reportlab.lib.colors import HexColor
29
29
  from reportlab.pdfgen import canvas
30
30
 
31
+ from pdf_fonts import find_cjk_font, has_non_latin, register_cjk_font
32
+
31
33
  try:
32
34
  import pymupdf
33
35
  except ImportError: # pragma: no cover - PyMuPDF is a required dependency
@@ -90,7 +92,8 @@ def main():
90
92
  default="bottom-center",
91
93
  help="Anchor of the stamp (default: bottom-center)",
92
94
  )
93
- parser.add_argument("--font", default="Helvetica", help="reportlab base-14 font name (default: Helvetica)")
95
+ parser.add_argument("--font", default="Helvetica", help="reportlab font name (default: Helvetica)")
96
+ parser.add_argument("--font-file", help="TTF/OTF/TTC file used for the stamp; required for CJK unless a system font is found")
94
97
  parser.add_argument("--font-size", type=float, default=9.0, help="Font size in points (default: 9)")
95
98
  parser.add_argument("--color", default="#666666", help="Hex text colour (default: #666666)")
96
99
  parser.add_argument("--margin", type=float, default=28.0, help="Page-edge margin in points (default: 28)")
@@ -127,6 +130,22 @@ def main():
127
130
  page_count = len(reader.pages)
128
131
  selected = parse_pages(args.pages, page_count)
129
132
 
133
+ sample = args.text.format(page=1, pages=page_count)
134
+ font_name = args.font
135
+ font_file = None
136
+ if args.font_file or has_non_latin(sample):
137
+ try:
138
+ path = find_cjk_font(args.font_file)
139
+ except FileNotFoundError as exc:
140
+ fail(str(exc), code=2)
141
+ if path is None:
142
+ fail("stamp text needs a font beyond the base-14 Latin sets; pass --font-file with a CJK-capable TTF/TTC", code=2)
143
+ try:
144
+ font_name = register_cjk_font(path, "StampCJK")
145
+ except Exception as exc:
146
+ fail(f"cannot use font file {path}: {exc}", code=2)
147
+ font_file = str(path)
148
+
130
149
  writer = PdfWriter()
131
150
  stamped = []
132
151
  for index, page in enumerate(reader.pages, start=1):
@@ -138,9 +157,9 @@ def main():
138
157
  buffer = io.BytesIO()
139
158
  stamp = canvas.Canvas(buffer, pagesize=(width, height))
140
159
  stamp.setFillColor(colour)
141
- stamp.setFont(args.font, args.font_size)
160
+ stamp.setFont(font_name, args.font_size)
142
161
  stamp.setFillAlpha(args.opacity)
143
- text_width = stamp.stringWidth(label, args.font, args.font_size)
162
+ text_width = stamp.stringWidth(label, font_name, args.font_size)
144
163
  anchor = POSITIONS[args.position]
145
164
  if anchor == "left":
146
165
  x = args.margin
@@ -185,7 +204,8 @@ def main():
185
204
  "pages": page_count,
186
205
  "stamped_pages": stamped,
187
206
  "position": args.position,
188
- "font": args.font,
207
+ "font": font_name,
208
+ "font_file": font_file,
189
209
  "font_size": args.font_size,
190
210
  "color": args.color,
191
211
  "rendered_pages": rendered,
@@ -55,6 +55,15 @@ Radio groups fail most: pypdf updates the group `/V` but leaves the old button's
55
55
 
56
56
  Flatten when the file must stay uneditable: fill with `flatten=True` (the value is painted into page content), remove every widget annotation (`subtypes="/Widget"`), and drop `/AcroForm` from the catalog. Then confirm no `/Widget` and no `/AcroForm` remain; otherwise readers still treat the file as editable.
57
57
 
58
+ ## Non-Latin values
59
+
60
+ pypdf's appearance writer encodes WinAnsi, so a Chinese, Japanese, or Korean value lands in `/V` but the widget paints `?` or falls back to Helvetica. The script detects non-Latin values, registers a system CJK font (YaHei, WenQuanYi, PingFang, Noto) or the `--font-file` you pass, draws the value with reportlab, and replaces `/AP /N` with that form; the field names it repainted are listed as `cjk_appearance_fields`. `--flatten` paints those streams into the page content before stripping the widgets. Verify `/V` plus the render: the subset font has no ToUnicode map, so a text dump of the appearance is not reliable evidence.
61
+
62
+ ```bash
63
+ "<python>" "<skill>/scripts/forms.py" fill --input "<form.pdf>" --out "<filled.pdf>" \
64
+ --values '{"full_name": "张伟"}' --font-file "C:/Windows/Fonts/msyh.ttc" --render-dir "<render>"
65
+ ```
66
+
58
67
  ## What cannot be filled, and the fix
59
68
 
60
69
  - **No appearance stream and a broken `/DA`**: the reader may not show a new value. Render after filling; if invisible, rebuild that widget's appearance with reportlab and merge it over the page.
@@ -52,7 +52,8 @@ Use each page's own `mediabox` when sizes may mix; a stamp drawn for A4 lands in
52
52
  - **Existing footers**: check the bottom margin first; the render is the proof, not the coordinate math. If a page number is already there, skip the stamp or use the opposite side.
53
53
  - **Rotated pages**: a `/Rotate 90` page renders sideways, so text drawn unrotated appears rotated. Rotate the stamp (`c.rotate(90)`, adjusting the origin), or rotate the page back first.
54
54
  - **Repeated stamping**: merging the same overlay twice doubles the opacity at the same position; re-run from the original input when iterating.
55
- - **Fonts**: Helvetica handles Latin; a CJK stamp needs a registered TTF, or the text renders as blank boxes. Check the render.
55
+ - **Fonts**: Helvetica handles Latin; for CJK the script registers a system CJK font (YaHei, WenQuanYi, PingFang, Noto) and reports it as `font_file`. Pass `--font-file` with a specific TTF/OTF/TTC when the host has none; without one it exits 2 instead of drawing blank boxes. Check the render.
56
+ - **Font files**: a `.ttc` collection needs its first face; the script handles that. Keep `--font` at its default when passing `--font-file` — the file overrides the base-14 name.
56
57
  - **Opacity**: `setFillAlpha` works only in viewers that honour transparency groups; a stamp that must show on any viewer is safer at full opacity.
57
58
 
58
59
  ## Verify
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ import { createRequire } from "node:module";
2
2
  import { execFile } from "node:child_process";
3
3
  import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
4
4
  import { readFile } from "node:fs/promises";
5
- import { dirname, join } from "node:path";
5
+ import { dirname, join, sep } from "node:path";
6
6
  import process from "node:process";
7
7
  import { promisify } from "node:util";
8
8
  import { BUNDLED_SKILL_RANK } from "@deepseek-ai/dsh-skill";
@@ -55,6 +55,16 @@ const OFFICE_FEATURES = [
55
55
  const PACKAGE_ROOT$1 = fileURLToPath(new URL("..", import.meta.url));
56
56
  const KIT_CLI = "@deepseek-ai/libreoffice-kit/cli";
57
57
  /**
58
+ * Electron reads `app.asar` as a directory but nothing else can, and the helper
59
+ * runs the recorded path from Python. The packaged app ships the kit unpacked.
60
+ */
61
+ function outsideAsar(path) {
62
+ const packed = `${sep}app.asar${sep}`;
63
+ if (!path.includes(packed)) return path;
64
+ const unpacked = path.replace(packed, `${sep}app.asar.unpacked${sep}`);
65
+ return existsSync(unpacked) ? unpacked : path;
66
+ }
67
+ /**
58
68
  * Locate the LibreOffice kit CLI that DeepSeek Harness ships.
59
69
  *
60
70
  * DSH installs third-party plugins into a profile (`~/.dsh/profiles/<name>/node_modules`)
@@ -71,7 +81,7 @@ function resolveKitCli(entry = process.argv[1]) {
71
81
  if (anchor == null) continue;
72
82
  try {
73
83
  const cli = createRequire(anchor).resolve(KIT_CLI);
74
- if (existsSync(cli)) return cli;
84
+ if (existsSync(cli)) return outsideAsar(cli);
75
85
  } catch {}
76
86
  }
77
87
  return null;
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",
4
+ "version": "0.4.6",
5
5
  "description": "Office documents, PDF, presentations, and spreadsheets for DeepSeek Harness",
6
6
  "author": "Hairy",
7
7
  "license": "MIT",
@@ -184,6 +184,11 @@ def find_kit() -> tuple[str, str] | None:
184
184
  return node, str(cli)
185
185
 
186
186
 
187
+ def _kit_env() -> dict[str, str]:
188
+ """Environment for a kit run: the recorded node may be the harness's Electron binary."""
189
+ return {**os.environ, "ELECTRON_RUN_AS_NODE": "1"}
190
+
191
+
187
192
  def _kit_call(arguments: list[str], timeout: int = 300) -> dict[str, Any]:
188
193
  """Run one kit command and return its JSON report, failing with the kit's own words."""
189
194
  kit = find_kit()
@@ -195,6 +200,7 @@ def _kit_call(arguments: list[str], timeout: int = 300) -> dict[str, Any]:
195
200
  [node, cli, *arguments],
196
201
  capture_output=True,
197
202
  text=True,
203
+ env=_kit_env(),
198
204
  # The kit always speaks UTF-8; the locale code page would mangle CJK paths.
199
205
  encoding="utf-8",
200
206
  errors="replace",
@@ -231,6 +237,7 @@ def kit_report() -> dict[str, Any]:
231
237
  [node, cli, "capabilities", "--json"],
232
238
  capture_output=True,
233
239
  text=True,
240
+ env=_kit_env(),
234
241
  encoding="utf-8",
235
242
  errors="replace",
236
243
  timeout=120,
@@ -314,6 +321,94 @@ def xlsx_render(input_path: Path, out_dir: Path, dpi: int) -> dict[str, Any]:
314
321
  }
315
322
 
316
323
 
324
+ def docx_used_fonts(path: Path) -> set[str]:
325
+ """Font names the document actually applies.
326
+
327
+ The kit reports every font in the package, including latent styles inherited
328
+ from python-docx's template (e.g. Courier in the unused MacroText style), so
329
+ a document with no Courier text still warns about it. Keep only fonts that
330
+ appear on a run, in a referenced style, in the defaults, or in the theme.
331
+ """
332
+ import zipfile
333
+
334
+ from lxml import etree
335
+
336
+ w = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
337
+ fonts: set[str] = set()
338
+ styles: dict[str, tuple[set[str], str | None]] = {}
339
+ referenced: set[str] = set()
340
+ theme_fonts: set[str] = set()
341
+
342
+ def rfonts(element) -> set[str]:
343
+ found = set()
344
+ for node in element.iter(f"{w}rFonts"):
345
+ for attribute in ("ascii", "hAnsi", "eastAsia", "cs"):
346
+ value = node.get(f"{w}{attribute}")
347
+ if value:
348
+ found.add(value)
349
+ return found
350
+
351
+ with zipfile.ZipFile(path) as archive:
352
+ names = set(archive.namelist())
353
+
354
+ def load(name: str):
355
+ return etree.fromstring(archive.read(name))
356
+
357
+ if "word/styles.xml" in names:
358
+ root = load("word/styles.xml")
359
+ defaults = root.find(f"{w}docDefaults")
360
+ if defaults is not None:
361
+ fonts.update(rfonts(defaults))
362
+ for style in root.iter(f"{w}style"):
363
+ style_id = style.get(f"{w}styleId")
364
+ based_on = style.find(f"{w}basedOn")
365
+ styles[style_id] = (rfonts(style), based_on.get(f"{w}val") if based_on is not None else None)
366
+
367
+ for name in names:
368
+ if not name.startswith("word/") or not name.endswith(".xml"):
369
+ continue
370
+ if name.startswith(("word/styles", "word/theme/")):
371
+ continue
372
+ root = load(name)
373
+ fonts.update(rfonts(root))
374
+ for tag in ("pStyle", "rStyle", "tblStyle"):
375
+ for element in root.iter(f"{w}{tag}"):
376
+ value = element.get(f"{w}val")
377
+ if value:
378
+ referenced.add(value)
379
+
380
+ if "word/theme/theme1.xml" in names:
381
+ theme = load("word/theme/theme1.xml")
382
+ for tag in ("majorFont", "minorFont"):
383
+ for element in theme.iter(tag):
384
+ latin = element.find("latin")
385
+ if latin is not None and latin.get("typeface"):
386
+ theme_fonts.add(latin.get("typeface"))
387
+
388
+ pending = list(referenced)
389
+ seen = set()
390
+ while pending:
391
+ style_id = pending.pop()
392
+ if style_id in seen:
393
+ continue
394
+ seen.add(style_id)
395
+ entry = styles.get(style_id)
396
+ if entry is None:
397
+ continue
398
+ style_fonts, based_on = entry
399
+ fonts.update(style_fonts)
400
+ if based_on:
401
+ pending.append(based_on)
402
+ return fonts | theme_fonts
403
+
404
+
405
+ def filter_missing_fonts(input_path: Path, kind: str, missing: list[str]) -> list[str]:
406
+ if kind != "docx" or not missing:
407
+ return missing
408
+ used = {name.lower() for name in docx_used_fonts(input_path)}
409
+ return [name for name in missing if name.lower() in used]
410
+
411
+
317
412
  def render_office(input_path: Path, kind: str, out_dir: Path, dpi: int, emit_pdf: bool = False) -> dict[str, Any]:
318
413
  """Rasterise an Office file page by page through the kit.
319
414
 
@@ -346,7 +441,7 @@ def render_office(input_path: Path, kind: str, out_dir: Path, dpi: int, emit_pdf
346
441
  shutil.rmtree(staging, ignore_errors=True)
347
442
  if not images:
348
443
  fail(f"the LibreOffice kit rendered no pages for {input_path.name}")
349
- missing = [str(name) for name in report.get("missingFonts") or []]
444
+ missing = filter_missing_fonts(input_path, kind, [str(name) for name in report.get("missingFonts") or []])
350
445
  warnings = glyph_warnings(missing)
351
446
  report_warnings(warnings)
352
447
  result: dict[str, Any] = {
@@ -365,10 +460,11 @@ def render_office(input_path: Path, kind: str, out_dir: Path, dpi: int, emit_pdf
365
460
  if emit_pdf:
366
461
  converted = export_pdf(input_path, out_dir, dpi)
367
462
  result["pdf"] = converted["pdf"]
368
- if not missing and converted["missing_fonts"]:
369
- result["missing_fonts"] = converted["missing_fonts"]
463
+ converted_missing = filter_missing_fonts(input_path, kind, converted["missing_fonts"])
464
+ if not missing and converted_missing:
465
+ result["missing_fonts"] = converted_missing
370
466
  result["glyph_fallback"] = True
371
- result["warnings"] = glyph_warnings(converted["missing_fonts"])
467
+ result["warnings"] = glyph_warnings(converted_missing)
372
468
  report_warnings(result["warnings"])
373
469
  return result
374
470
 
@@ -674,8 +770,9 @@ _PDF_CJK_CANDIDATES = [
674
770
  r"C:\Windows\Fonts\msyh.ttf",
675
771
  r"C:\Windows\Fonts\simhei.ttf",
676
772
  r"C:\Windows\Fonts\simsun.ttc",
677
- "/System/Library/Fonts/PingFang.ttc",
678
773
  "/System/Library/Fonts/STHeiti Light.ttc",
774
+ "/System/Library/Fonts/STHeiti Medium.ttc",
775
+ "/System/Library/Fonts/Supplemental/Songti.ttc",
679
776
  "/usr/share/fonts/truetype/winfonts/msyh.ttc",
680
777
  "/usr/share/fonts/truetype/winfonts/msyhl.ttc",
681
778
  "/usr/share/fonts/truetype/winfonts/simhei.ttf",