dsh-office-kit 0.4.4 → 0.4.5
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/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/package.json +1 -1
- package/python/office_cli.py +93 -4
|
@@ -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/PingFang.ttc",
|
|
29
|
+
"/System/Library/Fonts/STHeiti Medium.ttc",
|
|
30
|
+
"/System/Library/Fonts/Hiragino Sans GB.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/package.json
CHANGED
package/python/office_cli.py
CHANGED
|
@@ -314,6 +314,94 @@ def xlsx_render(input_path: Path, out_dir: Path, dpi: int) -> dict[str, Any]:
|
|
|
314
314
|
}
|
|
315
315
|
|
|
316
316
|
|
|
317
|
+
def docx_used_fonts(path: Path) -> set[str]:
|
|
318
|
+
"""Font names the document actually applies.
|
|
319
|
+
|
|
320
|
+
The kit reports every font in the package, including latent styles inherited
|
|
321
|
+
from python-docx's template (e.g. Courier in the unused MacroText style), so
|
|
322
|
+
a document with no Courier text still warns about it. Keep only fonts that
|
|
323
|
+
appear on a run, in a referenced style, in the defaults, or in the theme.
|
|
324
|
+
"""
|
|
325
|
+
import zipfile
|
|
326
|
+
|
|
327
|
+
from lxml import etree
|
|
328
|
+
|
|
329
|
+
w = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
|
|
330
|
+
fonts: set[str] = set()
|
|
331
|
+
styles: dict[str, tuple[set[str], str | None]] = {}
|
|
332
|
+
referenced: set[str] = set()
|
|
333
|
+
theme_fonts: set[str] = set()
|
|
334
|
+
|
|
335
|
+
def rfonts(element) -> set[str]:
|
|
336
|
+
found = set()
|
|
337
|
+
for node in element.iter(f"{w}rFonts"):
|
|
338
|
+
for attribute in ("ascii", "hAnsi", "eastAsia", "cs"):
|
|
339
|
+
value = node.get(f"{w}{attribute}")
|
|
340
|
+
if value:
|
|
341
|
+
found.add(value)
|
|
342
|
+
return found
|
|
343
|
+
|
|
344
|
+
with zipfile.ZipFile(path) as archive:
|
|
345
|
+
names = set(archive.namelist())
|
|
346
|
+
|
|
347
|
+
def load(name: str):
|
|
348
|
+
return etree.fromstring(archive.read(name))
|
|
349
|
+
|
|
350
|
+
if "word/styles.xml" in names:
|
|
351
|
+
root = load("word/styles.xml")
|
|
352
|
+
defaults = root.find(f"{w}docDefaults")
|
|
353
|
+
if defaults is not None:
|
|
354
|
+
fonts.update(rfonts(defaults))
|
|
355
|
+
for style in root.iter(f"{w}style"):
|
|
356
|
+
style_id = style.get(f"{w}styleId")
|
|
357
|
+
based_on = style.find(f"{w}basedOn")
|
|
358
|
+
styles[style_id] = (rfonts(style), based_on.get(f"{w}val") if based_on is not None else None)
|
|
359
|
+
|
|
360
|
+
for name in names:
|
|
361
|
+
if not name.startswith("word/") or not name.endswith(".xml"):
|
|
362
|
+
continue
|
|
363
|
+
if name.startswith(("word/styles", "word/theme/")):
|
|
364
|
+
continue
|
|
365
|
+
root = load(name)
|
|
366
|
+
fonts.update(rfonts(root))
|
|
367
|
+
for tag in ("pStyle", "rStyle", "tblStyle"):
|
|
368
|
+
for element in root.iter(f"{w}{tag}"):
|
|
369
|
+
value = element.get(f"{w}val")
|
|
370
|
+
if value:
|
|
371
|
+
referenced.add(value)
|
|
372
|
+
|
|
373
|
+
if "word/theme/theme1.xml" in names:
|
|
374
|
+
theme = load("word/theme/theme1.xml")
|
|
375
|
+
for tag in ("majorFont", "minorFont"):
|
|
376
|
+
for element in theme.iter(tag):
|
|
377
|
+
latin = element.find("latin")
|
|
378
|
+
if latin is not None and latin.get("typeface"):
|
|
379
|
+
theme_fonts.add(latin.get("typeface"))
|
|
380
|
+
|
|
381
|
+
pending = list(referenced)
|
|
382
|
+
seen = set()
|
|
383
|
+
while pending:
|
|
384
|
+
style_id = pending.pop()
|
|
385
|
+
if style_id in seen:
|
|
386
|
+
continue
|
|
387
|
+
seen.add(style_id)
|
|
388
|
+
entry = styles.get(style_id)
|
|
389
|
+
if entry is None:
|
|
390
|
+
continue
|
|
391
|
+
style_fonts, based_on = entry
|
|
392
|
+
fonts.update(style_fonts)
|
|
393
|
+
if based_on:
|
|
394
|
+
pending.append(based_on)
|
|
395
|
+
return fonts | theme_fonts
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def filter_missing_fonts(input_path: Path, kind: str, missing: list[str]) -> list[str]:
|
|
399
|
+
if kind != "docx" or not missing:
|
|
400
|
+
return missing
|
|
401
|
+
used = {name.lower() for name in docx_used_fonts(input_path)}
|
|
402
|
+
return [name for name in missing if name.lower() in used]
|
|
403
|
+
|
|
404
|
+
|
|
317
405
|
def render_office(input_path: Path, kind: str, out_dir: Path, dpi: int, emit_pdf: bool = False) -> dict[str, Any]:
|
|
318
406
|
"""Rasterise an Office file page by page through the kit.
|
|
319
407
|
|
|
@@ -346,7 +434,7 @@ def render_office(input_path: Path, kind: str, out_dir: Path, dpi: int, emit_pdf
|
|
|
346
434
|
shutil.rmtree(staging, ignore_errors=True)
|
|
347
435
|
if not images:
|
|
348
436
|
fail(f"the LibreOffice kit rendered no pages for {input_path.name}")
|
|
349
|
-
missing = [str(name) for name in report.get("missingFonts") or []]
|
|
437
|
+
missing = filter_missing_fonts(input_path, kind, [str(name) for name in report.get("missingFonts") or []])
|
|
350
438
|
warnings = glyph_warnings(missing)
|
|
351
439
|
report_warnings(warnings)
|
|
352
440
|
result: dict[str, Any] = {
|
|
@@ -365,10 +453,11 @@ def render_office(input_path: Path, kind: str, out_dir: Path, dpi: int, emit_pdf
|
|
|
365
453
|
if emit_pdf:
|
|
366
454
|
converted = export_pdf(input_path, out_dir, dpi)
|
|
367
455
|
result["pdf"] = converted["pdf"]
|
|
368
|
-
|
|
369
|
-
|
|
456
|
+
converted_missing = filter_missing_fonts(input_path, kind, converted["missing_fonts"])
|
|
457
|
+
if not missing and converted_missing:
|
|
458
|
+
result["missing_fonts"] = converted_missing
|
|
370
459
|
result["glyph_fallback"] = True
|
|
371
|
-
result["warnings"] = glyph_warnings(
|
|
460
|
+
result["warnings"] = glyph_warnings(converted_missing)
|
|
372
461
|
report_warnings(result["warnings"])
|
|
373
462
|
return result
|
|
374
463
|
|