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.
@@ -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/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 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/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.5",
5
5
  "description": "Office documents, PDF, presentations, and spreadsheets for DeepSeek Harness",
6
6
  "author": "Hairy",
7
7
  "license": "MIT",
@@ -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
- if not missing and converted["missing_fonts"]:
369
- result["missing_fonts"] = converted["missing_fonts"]
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(converted["missing_fonts"])
460
+ result["warnings"] = glyph_warnings(converted_missing)
372
461
  report_warnings(result["warnings"])
373
462
  return result
374
463