office-export 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,3 @@
1
+ __all__ = ["__version__"]
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ from office_export.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
office_export/cli.py ADDED
@@ -0,0 +1,577 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ import tempfile
7
+ from pathlib import Path
8
+ from typing import Any, TextIO
9
+
10
+ from office_export import __version__
11
+ from office_export.core import ExportOptions, default_output_for, export_document, format_capabilities, inspect_document
12
+ from office_export.doctor import run_doctor
13
+ from office_export.errors import EXIT_INTERNAL, OfficeExportError, UsageError
14
+ from office_export.naming import ensure_distinct_paths
15
+ from office_export.results import SCHEMA_VERSION, base_result, write_manifest
16
+ from office_export.skill import install_skill, remove_skill
17
+
18
+ PROGRAM_NAME = "office-export"
19
+
20
+
21
+ class CliArgumentParser(argparse.ArgumentParser):
22
+ def error(self, message: str) -> None:
23
+ raise UsageError(message)
24
+
25
+
26
+ def build_root_help() -> str:
27
+ return f"""{PROGRAM_NAME} {__version__}
28
+
29
+ Export Microsoft Office documents through desktop Office, or rasterize PDFs.
30
+
31
+ Usage:
32
+ {PROGRAM_NAME} INPUT --to pdf|png|jpeg [OPTIONS]
33
+ {PROGRAM_NAME} inspect INPUT [--json]
34
+ {PROGRAM_NAME} doctor [--json]
35
+ {PROGRAM_NAME} formats [--json]
36
+ {PROGRAM_NAME} batch PATH --to pdf|png|jpeg [OPTIONS]
37
+ {PROGRAM_NAME} skill install [--skills-dir DIR] [--force] [--json]
38
+ {PROGRAM_NAME} skill remove [--skills-dir DIR] [--force] [--json]
39
+
40
+ Examples:
41
+ {PROGRAM_NAME} report.docx --to pdf
42
+ {PROGRAM_NAME} deck.pptx --to png --slides 1,3-5 --dpi 200
43
+ {PROGRAM_NAME} model.xlsx --to jpeg --chart "Dashboard!Revenue"
44
+ {PROGRAM_NAME} document.pdf --to png --pages 1-4
45
+
46
+ Run `{PROGRAM_NAME} INPUT --to FORMAT --help` for export options.
47
+ """
48
+
49
+
50
+ def build_export_parser() -> CliArgumentParser:
51
+ parser = CliArgumentParser(prog=PROGRAM_NAME, add_help=False)
52
+ parser.add_argument("source", type=Path)
53
+ parser.add_argument("--to", dest="output_format", choices=("pdf", "png", "jpeg"), required=True)
54
+ parser.add_argument("--output", type=Path)
55
+ parser.add_argument("--force", action="store_true")
56
+ parser.add_argument("--dpi", type=int)
57
+ parser.add_argument("--jpeg-quality", type=int)
58
+ parser.add_argument("--background")
59
+ parser.add_argument("--quality", choices=("screen", "print"), default="print")
60
+ parser.add_argument("--timeout", type=float, default=120.0)
61
+ parser.add_argument("--keep-intermediate", action="store_true")
62
+ parser.add_argument("--json", action="store_true")
63
+ parser.add_argument("--manifest", type=Path)
64
+ parser.add_argument("--verbose", action="store_true")
65
+ parser.add_argument("--image-engine", choices=("pdfium", "office"), default="pdfium")
66
+ parser.add_argument("--max-megapixels", type=float, default=50.0)
67
+ parser.add_argument("--pages")
68
+ parser.add_argument("--include-markup", action="store_true")
69
+ parser.add_argument("--bookmarks", choices=("none", "headings", "word"), default="headings")
70
+ parser.add_argument("--pdf-a", action="store_true")
71
+ parser.add_argument("--no-update-toc", dest="update_toc", action="store_false", default=True)
72
+ parser.add_argument("--slides")
73
+ parser.add_argument("--include-hidden", action="store_true")
74
+ parser.add_argument(
75
+ "--output-type",
76
+ choices=("slides", "notes", "outline", "handout1", "handout2", "handout3", "handout4", "handout6", "handout9"),
77
+ default="slides",
78
+ )
79
+ parser.add_argument("--frame-slides", action="store_true")
80
+ parser.add_argument("--sheet", dest="sheets", action="append", default=[])
81
+ parser.add_argument("--range", dest="range_value")
82
+ parser.add_argument("--chart", dest="charts", action="append", default=[])
83
+ parser.add_argument("--charts", dest="charts_mode", choices=("all",))
84
+ parser.add_argument("--ignore-print-area", action="store_true")
85
+ parser.add_argument("--show-formulas", action="store_true")
86
+ parser.add_argument("--show-headings", action="store_true")
87
+ parser.add_argument("--recalculate", choices=("never", "auto", "full"), default="never")
88
+ parser.add_argument("--update-links", action="store_true")
89
+ parser.add_argument("--refresh-data", action="store_true")
90
+ parser.add_argument("--exclude-annotations", action="store_true")
91
+ return parser
92
+
93
+
94
+ def build_export_help() -> str:
95
+ return f"""Usage: {PROGRAM_NAME} INPUT --to pdf|png|jpeg [OPTIONS]
96
+
97
+ Common options:
98
+ --output PATH Override the deterministic default output path.
99
+ --force Replace only planned output collisions.
100
+ --dpi INTEGER Image resolution from 36 through 2400. Default: 150.
101
+ --jpeg-quality INTEGER JPEG quality from 1 through 100. Default: 92.
102
+ --background COLOR JPEG transparency background. Default: white.
103
+ --quality screen|print Native Office PDF quality. Default: print.
104
+ --timeout SECONDS Office worker timeout. Default: 120.
105
+ --keep-intermediate Keep the Office PDF used for image output.
106
+ --image-engine pdfium|office Office is valid only for PowerPoint slides.
107
+ --max-megapixels NUMBER Per-page image safety limit. Default: 50.
108
+ --manifest PATH Persist the JSON conversion result.
109
+ --json Write the JSON conversion result to stdout.
110
+ --verbose Include internal exception details for unexpected failures.
111
+
112
+ Word options:
113
+ --pages LIST One-based pages such as 1,3-5.
114
+ --include-markup
115
+ --bookmarks none|headings|word
116
+ --pdf-a
117
+ --no-update-toc
118
+
119
+ PowerPoint options:
120
+ --slides LIST One-based slides such as 1,3-5.
121
+ --include-hidden
122
+ --output-type TYPE Slides, notes, outline, or a handout layout.
123
+ --frame-slides
124
+ --pdf-a
125
+
126
+ Excel options:
127
+ --sheet NAME_OR_INDEX Repeat to select sheets.
128
+ --range SHEET!A1:H40
129
+ --chart SHEET!NAME Repeat to select charts.
130
+ --charts all
131
+ --ignore-print-area
132
+ --show-formulas
133
+ --show-headings
134
+ --recalculate never|auto|full
135
+ --update-links
136
+ --refresh-data
137
+
138
+ PDF input options:
139
+ --pages LIST
140
+ --exclude-annotations
141
+ """
142
+
143
+
144
+ def main(
145
+ argv: list[str] | None = None,
146
+ *,
147
+ stdout: TextIO | None = None,
148
+ stderr: TextIO | None = None,
149
+ ) -> int:
150
+ args = list(sys.argv[1:] if argv is None else argv)
151
+ stdout = stdout or sys.stdout
152
+ stderr = stderr or sys.stderr
153
+ json_mode = "--json" in args
154
+ verbose = "--verbose" in args
155
+ try:
156
+ if not args:
157
+ stdout.write(build_root_help())
158
+ return 0
159
+ if args == ["--version"]:
160
+ stdout.write(f"{PROGRAM_NAME} {__version__}\n")
161
+ return 0
162
+ if args == ["--about"]:
163
+ stdout.write(_about_text())
164
+ return 0
165
+ if args[0] in {"-h", "--help"}:
166
+ stdout.write(build_root_help())
167
+ return 0
168
+ if args[0] == "skill":
169
+ return _run_skill(args[1:], stdout=stdout)
170
+ if args[0] == "inspect":
171
+ return _run_inspect(args[1:], stdout=stdout)
172
+ if args[0] == "doctor":
173
+ return _run_doctor(args[1:], stdout=stdout)
174
+ if args[0] == "formats":
175
+ return _run_formats(args[1:], stdout=stdout)
176
+ if args[0] == "batch":
177
+ return _run_batch(args[1:], stdout=stdout)
178
+ if "--help" in args or "-h" in args:
179
+ stdout.write(build_export_help())
180
+ return 0
181
+ parsed = build_export_parser().parse_args(args)
182
+ options = _options_from_args(parsed)
183
+ result = export_document(options)
184
+ _write_export_result(result, json_mode=parsed.json, stdout=stdout)
185
+ return 0
186
+ except OfficeExportError as exc:
187
+ _write_error(exc, json_mode=json_mode, stdout=stdout, stderr=stderr)
188
+ return exc.context.exit_code
189
+ except Exception as exc:
190
+ message = f"Unexpected internal error ({type(exc).__name__})."
191
+ details = {"exception": repr(exc)} if verbose else None
192
+ payload: dict[str, Any] = {
193
+ "ok": False,
194
+ "schema_version": SCHEMA_VERSION,
195
+ "error": {"code": "internal_error", "message": message},
196
+ }
197
+ if details:
198
+ payload["error"]["details"] = details
199
+ if json_mode:
200
+ stdout.write(json.dumps(payload, indent=2) + "\n")
201
+ else:
202
+ stderr.write(f"error: {message}\n")
203
+ if verbose:
204
+ stderr.write(f"details: {exc!r}\n")
205
+ return EXIT_INTERNAL
206
+
207
+
208
+ def _run_skill(args: list[str], *, stdout: TextIO) -> int:
209
+ if not args or args[0] in {"-h", "--help"}:
210
+ stdout.write(
211
+ f"Usage:\n {PROGRAM_NAME} skill install [--skills-dir DIR] [--force] [--json]\n"
212
+ f" {PROGRAM_NAME} skill remove [--skills-dir DIR] [--force] [--json]\n"
213
+ )
214
+ return 0
215
+ parser = CliArgumentParser(prog=f"{PROGRAM_NAME} skill", add_help=False)
216
+ parser.add_argument("action", choices=("install", "remove"))
217
+ parser.add_argument("--skills-dir", type=Path)
218
+ parser.add_argument("--force", action="store_true")
219
+ parser.add_argument("--json", action="store_true")
220
+ parsed = parser.parse_args(args)
221
+ root = parsed.skills_dir.expanduser().resolve() if parsed.skills_dir else None
222
+ result = (
223
+ install_skill(root, force=parsed.force)
224
+ if parsed.action == "install"
225
+ else remove_skill(root, force=parsed.force)
226
+ )
227
+ payload = {"ok": True, "schema_version": SCHEMA_VERSION, "mode": f"skill_{parsed.action}", **result}
228
+ if parsed.json:
229
+ stdout.write(json.dumps(payload, indent=2, ensure_ascii=False) + "\n")
230
+ else:
231
+ verb = "Installed" if parsed.action == "install" else "Removed"
232
+ if parsed.action == "remove" and not result["removed"]:
233
+ stdout.write(f"Skill is not installed at {result['path']}\n")
234
+ else:
235
+ stdout.write(f"{verb} {result['path']}\n")
236
+ return 0
237
+
238
+
239
+ def _run_inspect(args: list[str], *, stdout: TextIO) -> int:
240
+ if not args or args[0] in {"-h", "--help"}:
241
+ stdout.write(f"Usage: {PROGRAM_NAME} inspect INPUT [--timeout SECONDS] [--json]\n")
242
+ return 0
243
+ parser = CliArgumentParser(prog=f"{PROGRAM_NAME} inspect", add_help=False)
244
+ parser.add_argument("source", type=Path)
245
+ parser.add_argument("--timeout", type=float, default=120.0)
246
+ parser.add_argument("--json", action="store_true")
247
+ parsed = parser.parse_args(args)
248
+ if parsed.timeout <= 0:
249
+ raise UsageError("--timeout must be greater than zero.")
250
+ result = inspect_document(parsed.source, timeout=parsed.timeout)
251
+ if parsed.json:
252
+ stdout.write(json.dumps(result, indent=2, ensure_ascii=False) + "\n")
253
+ else:
254
+ stdout.write(_inspection_text(result))
255
+ return 0
256
+
257
+
258
+ def _run_doctor(args: list[str], *, stdout: TextIO) -> int:
259
+ if args and args[0] in {"-h", "--help"}:
260
+ stdout.write(
261
+ f"Usage: {PROGRAM_NAME} doctor [--timeout SECONDS] [--smoke-word FILE] "
262
+ "[--smoke-excel FILE] [--smoke-powerpoint FILE] [--json]\n"
263
+ )
264
+ return 0
265
+ parser = CliArgumentParser(prog=f"{PROGRAM_NAME} doctor", add_help=False)
266
+ parser.add_argument("--timeout", type=float, default=30.0)
267
+ parser.add_argument("--smoke-word", type=Path)
268
+ parser.add_argument("--smoke-excel", type=Path)
269
+ parser.add_argument("--smoke-powerpoint", type=Path)
270
+ parser.add_argument("--json", action="store_true")
271
+ parsed = parser.parse_args(args)
272
+ if parsed.timeout <= 0:
273
+ raise UsageError("--timeout must be greater than zero.")
274
+ payload = base_result(mode="doctor")
275
+ payload["diagnostics"] = run_doctor(timeout=parsed.timeout)
276
+ smoke_sources = {
277
+ "word": parsed.smoke_word,
278
+ "excel": parsed.smoke_excel,
279
+ "powerpoint": parsed.smoke_powerpoint,
280
+ }
281
+ if any(smoke_sources.values()):
282
+ payload["diagnostics"]["smoke_tests"] = _doctor_smoke(smoke_sources, timeout=parsed.timeout)
283
+ if parsed.json:
284
+ stdout.write(json.dumps(payload, indent=2, ensure_ascii=False) + "\n")
285
+ else:
286
+ stdout.write(_doctor_text(payload["diagnostics"]))
287
+ return 0
288
+
289
+
290
+ def _run_formats(args: list[str], *, stdout: TextIO) -> int:
291
+ if args and args[0] in {"-h", "--help"}:
292
+ stdout.write(f"Usage: {PROGRAM_NAME} formats [--json]\n")
293
+ return 0
294
+ parser = CliArgumentParser(prog=f"{PROGRAM_NAME} formats", add_help=False)
295
+ parser.add_argument("--json", action="store_true")
296
+ parsed = parser.parse_args(args)
297
+ payload = base_result(mode="formats")
298
+ payload["formats"] = format_capabilities()
299
+ if parsed.json:
300
+ stdout.write(json.dumps(payload, indent=2) + "\n")
301
+ else:
302
+ stdout.write("Input Application Outputs\n")
303
+ for name, detail in payload["formats"]["inputs"].items():
304
+ stdout.write(f"{name:<6} {(detail['application'] or 'none'):<12} {', '.join(detail['outputs'])}\n")
305
+ return 0
306
+
307
+
308
+ def _run_batch(args: list[str], *, stdout: TextIO) -> int:
309
+ if not args or args[0] in {"-h", "--help"}:
310
+ stdout.write(
311
+ f"Usage: {PROGRAM_NAME} batch PATH --to pdf|png|jpeg "
312
+ "[--output-dir DIR] [--recursive] [--continue-on-error] [--json]\n"
313
+ )
314
+ return 0
315
+ parser = CliArgumentParser(prog=f"{PROGRAM_NAME} batch", add_help=False)
316
+ parser.add_argument("path", type=Path)
317
+ parser.add_argument("--to", dest="output_format", choices=("pdf", "png", "jpeg"), required=True)
318
+ parser.add_argument("--output-dir", type=Path)
319
+ parser.add_argument("--recursive", action="store_true")
320
+ parser.add_argument("--continue-on-error", action="store_true")
321
+ parser.add_argument("--jobs", type=int, default=1)
322
+ parser.add_argument("--force", action="store_true")
323
+ parser.add_argument("--dpi", type=int)
324
+ parser.add_argument("--jpeg-quality", type=int)
325
+ parser.add_argument("--background")
326
+ parser.add_argument("--quality", choices=("screen", "print"), default="print")
327
+ parser.add_argument("--timeout", type=float, default=120.0)
328
+ parser.add_argument("--keep-intermediate", action="store_true")
329
+ parser.add_argument("--image-engine", choices=("pdfium", "office"), default="pdfium")
330
+ parser.add_argument("--max-megapixels", type=float, default=50.0)
331
+ parser.add_argument("--manifest", type=Path)
332
+ parser.add_argument("--json", action="store_true")
333
+ parsed = parser.parse_args(args)
334
+ if parsed.jobs != 1:
335
+ raise UsageError("Version 0.1.0 supports only --jobs 1.")
336
+ sources = _batch_sources(parsed.path, recursive=parsed.recursive)
337
+ output_root = parsed.output_dir.expanduser().resolve() if parsed.output_dir else None
338
+ if output_root:
339
+ output_root.mkdir(parents=True, exist_ok=True)
340
+ manifest_target = parsed.manifest.expanduser().resolve() if parsed.manifest else None
341
+ if manifest_target is not None:
342
+ if manifest_target.exists() and manifest_target.is_dir():
343
+ raise UsageError(f"Manifest path is a directory: {manifest_target}.", code="manifest_is_directory")
344
+ if manifest_target.exists() and not parsed.force:
345
+ raise UsageError(
346
+ f"Manifest already exists: {manifest_target}. Use --force to replace it.",
347
+ code="manifest_exists",
348
+ )
349
+ for source in sources:
350
+ ensure_distinct_paths(source, manifest_target)
351
+ results: list[dict[str, Any]] = []
352
+ failures: list[dict[str, Any]] = []
353
+ for source in sources:
354
+ destination = None
355
+ if output_root:
356
+ destination = output_root / (
357
+ f"{source.stem}.pdf"
358
+ if parsed.output_format == "pdf"
359
+ else f"{source.stem} - {'PNG' if parsed.output_format == 'png' else 'JPEG'} export"
360
+ )
361
+ if manifest_target is not None:
362
+ ensure_distinct_paths(destination or default_output_for(source, parsed.output_format), manifest_target)
363
+ options = ExportOptions(
364
+ source=source,
365
+ output_format=parsed.output_format,
366
+ output=destination,
367
+ force=parsed.force,
368
+ dpi=parsed.dpi,
369
+ jpeg_quality=parsed.jpeg_quality,
370
+ background=parsed.background,
371
+ quality=parsed.quality,
372
+ timeout=parsed.timeout,
373
+ keep_intermediate=parsed.keep_intermediate,
374
+ image_engine=parsed.image_engine,
375
+ max_megapixels=parsed.max_megapixels,
376
+ )
377
+ try:
378
+ results.append(export_document(options))
379
+ except OfficeExportError as exc:
380
+ failure = {"source": str(source), "error": exc.context.to_dict()}
381
+ failures.append(failure)
382
+ if not parsed.continue_on_error:
383
+ raise
384
+ payload = base_result(mode="batch")
385
+ payload.update(
386
+ {
387
+ "ok": not failures,
388
+ "path": str(parsed.path.expanduser().resolve()),
389
+ "jobs": 1,
390
+ "converted": len(results),
391
+ "failed": len(failures),
392
+ "results": results,
393
+ "failures": failures,
394
+ }
395
+ )
396
+ if manifest_target is not None:
397
+ payload["manifest"] = str(manifest_target)
398
+ write_manifest(manifest_target, payload, force=parsed.force)
399
+ if parsed.json:
400
+ stdout.write(json.dumps(payload, indent=2, ensure_ascii=False) + "\n")
401
+ else:
402
+ stdout.write(f"Converted {len(results)} file(s). Failed: {len(failures)}.\n")
403
+ for result in results:
404
+ for output in result["outputs"]:
405
+ stdout.write(f"{output['path']}\n")
406
+ return 0 if not failures else 6
407
+
408
+
409
+ def _options_from_args(args: argparse.Namespace) -> ExportOptions:
410
+ return ExportOptions(
411
+ source=args.source,
412
+ output_format=args.output_format,
413
+ output=args.output,
414
+ force=args.force,
415
+ dpi=args.dpi,
416
+ jpeg_quality=args.jpeg_quality,
417
+ background=args.background,
418
+ quality=args.quality,
419
+ timeout=args.timeout,
420
+ keep_intermediate=args.keep_intermediate,
421
+ manifest=args.manifest,
422
+ verbose=args.verbose,
423
+ image_engine=args.image_engine,
424
+ pages=args.pages,
425
+ include_markup=args.include_markup,
426
+ bookmarks=args.bookmarks,
427
+ pdf_a=args.pdf_a,
428
+ update_toc=args.update_toc,
429
+ slides=args.slides,
430
+ include_hidden=args.include_hidden,
431
+ output_type=args.output_type,
432
+ frame_slides=args.frame_slides,
433
+ sheets=args.sheets,
434
+ range_value=args.range_value,
435
+ charts=args.charts,
436
+ charts_all=args.charts_mode == "all",
437
+ ignore_print_area=args.ignore_print_area,
438
+ show_formulas=args.show_formulas,
439
+ show_headings=args.show_headings,
440
+ recalculate=args.recalculate,
441
+ update_links=args.update_links,
442
+ refresh_data=args.refresh_data,
443
+ exclude_annotations=args.exclude_annotations,
444
+ max_megapixels=args.max_megapixels,
445
+ )
446
+
447
+
448
+ def _write_export_result(result: dict[str, Any], *, json_mode: bool, stdout: TextIO) -> None:
449
+ if json_mode:
450
+ stdout.write(json.dumps(result, indent=2, ensure_ascii=False) + "\n")
451
+ return
452
+ for output in result["outputs"]:
453
+ stdout.write(f"{output['path']}\n")
454
+ for warning in result.get("warnings", []):
455
+ stdout.write(f"warning: {warning['message']}\n")
456
+
457
+
458
+ def _write_error(exc: OfficeExportError, *, json_mode: bool, stdout: TextIO, stderr: TextIO) -> None:
459
+ if json_mode:
460
+ stdout.write(
461
+ json.dumps(
462
+ {
463
+ "ok": False,
464
+ "schema_version": SCHEMA_VERSION,
465
+ "tool": {"name": PROGRAM_NAME, "version": __version__},
466
+ "error": exc.context.to_dict(),
467
+ },
468
+ indent=2,
469
+ ensure_ascii=False,
470
+ )
471
+ + "\n"
472
+ )
473
+ else:
474
+ stderr.write(f"error [{exc.context.code}]: {exc.context.message}\n")
475
+
476
+
477
+ def _inspection_text(payload: dict[str, Any]) -> str:
478
+ source = payload["source"]
479
+ detail = payload["inspection"]
480
+ lines = [f"Source: {source['path']}", f"Format: {source['format']}"]
481
+ if "page_count" in detail:
482
+ lines.append(f"Pages: {detail['page_count']}")
483
+ if "slide_count" in detail:
484
+ lines.append(f"Slides: {detail['slide_count']}")
485
+ if "sheets" in detail:
486
+ lines.append(f"Sheets: {len(detail['sheets'])}")
487
+ for sheet in detail["sheets"]:
488
+ lines.append(f" {sheet['position']}: {sheet['name']} ({sheet['kind']}, {sheet['visibility']})")
489
+ if detail.get("charts"):
490
+ lines.append(f"Charts: {len(detail['charts'])}")
491
+ for warning in detail.get("warnings", []):
492
+ lines.append(f"Warning: {warning['message']}")
493
+ return "\n".join(lines) + "\n"
494
+
495
+
496
+ def _doctor_text(diagnostics: dict[str, Any]) -> str:
497
+ lines = [
498
+ f"office-export {diagnostics['tool_version']}",
499
+ f"Platform: {diagnostics['platform']['system']} {diagnostics['platform']['release']}",
500
+ f"Python: {diagnostics['platform']['python']}",
501
+ f"PDF rasterization: {'available' if diagnostics['pdf_rasterization']['available'] else 'unavailable'}",
502
+ ]
503
+ for name, item in diagnostics["office_export"]["applications"].items():
504
+ if item.get("available"):
505
+ lines.append(f"{name.capitalize()}: {item['application'].get('version') or 'available'}")
506
+ else:
507
+ lines.append(f"{name.capitalize()}: unavailable")
508
+ printing = diagnostics["printing"]
509
+ lines.append(f"Print Spooler: {printing.get('spooler') or 'not applicable'}")
510
+ lines.append(f"Printers: {len(printing.get('printers', []))}")
511
+ for warning in diagnostics["warnings"]:
512
+ lines.append(f"Warning: {warning['message']}")
513
+ for name, result in diagnostics.get("smoke_tests", {}).items():
514
+ lines.append(f"{name.capitalize()} smoke: {'passed' if result['ok'] else 'failed'}")
515
+ return "\n".join(lines) + "\n"
516
+
517
+
518
+ def _doctor_smoke(sources: dict[str, Path | None], *, timeout: float) -> dict[str, Any]:
519
+ results: dict[str, Any] = {}
520
+ with tempfile.TemporaryDirectory(prefix="office-export-smoke-") as directory:
521
+ root = Path(directory)
522
+ for application, source in sources.items():
523
+ if source is None:
524
+ continue
525
+ try:
526
+ conversion = export_document(
527
+ ExportOptions(
528
+ source=source,
529
+ output_format="pdf",
530
+ output=root / f"{application}.pdf",
531
+ timeout=timeout,
532
+ )
533
+ )
534
+ results[application] = {
535
+ "ok": True,
536
+ "source": str(source.expanduser().resolve()),
537
+ "size": conversion["outputs"][0]["size"],
538
+ "warnings": conversion["warnings"],
539
+ }
540
+ except OfficeExportError as exc:
541
+ results[application] = {
542
+ "ok": False,
543
+ "source": str(source.expanduser().resolve()),
544
+ "error": exc.context.to_dict(),
545
+ }
546
+ return results
547
+
548
+
549
+ def _batch_sources(path: Path, *, recursive: bool) -> list[Path]:
550
+ target = path.expanduser().resolve()
551
+ if target.is_file():
552
+ return [target]
553
+ if not target.exists():
554
+ raise UsageError(f"Batch path does not exist: {target}", code="batch_path_not_found")
555
+ if not target.is_dir():
556
+ raise UsageError(f"Batch path is not a directory or file: {target}")
557
+ iterator = target.rglob("*") if recursive else target.iterdir()
558
+ supported = {".docx", ".doc", ".xlsx", ".xls", ".pptx", ".ppt", ".pdf"}
559
+ sources = sorted(
560
+ (item for item in iterator if item.is_file() and item.suffix.lower() in supported),
561
+ key=lambda p: str(p).casefold(),
562
+ )
563
+ if not sources:
564
+ raise UsageError(f"No supported input files were found in: {target}", code="batch_empty")
565
+ return sources
566
+
567
+
568
+ def _about_text() -> str:
569
+ return f"""{PROGRAM_NAME} {__version__}
570
+ Export Office documents through installed desktop Microsoft Office.
571
+ Repository: https://github.com/pseudosavant/office-export
572
+ License: MIT
573
+ """
574
+
575
+
576
+ if __name__ == "__main__":
577
+ raise SystemExit(main())