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 +6 -5
- package/assets/skills/office-documents/scripts/internal_nav.py +62 -2
- package/assets/skills/office-pdf/scripts/forms.py +209 -5
- package/assets/skills/office-pdf/scripts/pdf_fonts.py +67 -0
- package/assets/skills/office-pdf/scripts/stamp_overlay.py +24 -4
- package/assets/skills/office-pdf/tasks/forms.md +9 -0
- package/assets/skills/office-pdf/tasks/stamp.md +2 -1
- package/dist/index.js +12 -2
- package/package.json +1 -1
- package/python/office_cli.py +102 -5
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
|
|
144
|
-
|
|
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;
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
if
|
|
338
|
-
|
|
339
|
-
|
|
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
|
|
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(
|
|
160
|
+
stamp.setFont(font_name, args.font_size)
|
|
142
161
|
stamp.setFillAlpha(args.opacity)
|
|
143
|
-
text_width = stamp.stringWidth(label,
|
|
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":
|
|
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
|
|
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
package/python/office_cli.py
CHANGED
|
@@ -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
|
-
|
|
369
|
-
|
|
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(
|
|
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",
|