doctopdf 2.0.5b0__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.
- doctopdf/__init__.py +10 -0
- doctopdf/__main__.py +13 -0
- doctopdf/applescript/check_word.applescript +19 -0
- doctopdf/applescript/export_document.applescript +116 -0
- doctopdf/applescript/export_document.js +123 -0
- doctopdf/cli.py +295 -0
- doctopdf/config.py +54 -0
- doctopdf/converter.py +462 -0
- doctopdf/errors.py +98 -0
- doctopdf/logger.py +170 -0
- doctopdf/orchestrator.py +343 -0
- doctopdf/scanner.py +162 -0
- doctopdf-2.0.5b0.dist-info/METADATA +573 -0
- doctopdf-2.0.5b0.dist-info/RECORD +17 -0
- doctopdf-2.0.5b0.dist-info/WHEEL +5 -0
- doctopdf-2.0.5b0.dist-info/entry_points.txt +2 -0
- doctopdf-2.0.5b0.dist-info/top_level.txt +1 -0
doctopdf/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""
|
|
2
|
+
doctopdf — macOS batch document converter using Microsoft Word.
|
|
3
|
+
|
|
4
|
+
Converts .doc and .docx files to PDF via AppleScript/JXA automation of
|
|
5
|
+
Microsoft Word. Runs entirely offline with no cloud dependencies.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = '2.0.5-beta'
|
|
9
|
+
__author__ = 'DocToPDF Team'
|
|
10
|
+
__description__ = 'Offline batch .doc/.docx → PDF converter using Microsoft Word on macOS'
|
doctopdf/__main__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
(*
|
|
2
|
+
check_word.applescript — Probe for Microsoft Word installation.
|
|
3
|
+
|
|
4
|
+
Returns JSON to stdout indicating whether Word is installed and
|
|
5
|
+
scriptable via AppleScript/JXA.
|
|
6
|
+
|
|
7
|
+
Output:
|
|
8
|
+
{"installed": true, "version": "16.72"}
|
|
9
|
+
{"installed": false, "error": "…"}
|
|
10
|
+
*)
|
|
11
|
+
|
|
12
|
+
try
|
|
13
|
+
tell application "Microsoft Word"
|
|
14
|
+
set ver to version
|
|
15
|
+
end tell
|
|
16
|
+
return "{\"installed\":true,\"version\":\"" & ver & "\"}"
|
|
17
|
+
on error errMsg
|
|
18
|
+
return "{\"installed\":false,\"error\":\"" & errMsg & "\"}"
|
|
19
|
+
end try
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
(*
|
|
2
|
+
export_document.applescript — Bridge between Python orchestrator and Microsoft Word.
|
|
3
|
+
|
|
4
|
+
Opens a document file in Microsoft Word on macOS, exports it as PDF,
|
|
5
|
+
and closes the document without saving changes to the original.
|
|
6
|
+
|
|
7
|
+
Usage via osascript:
|
|
8
|
+
osascript export_document.applescript "<inputPath>" "<outputPath>" [timeoutSec]
|
|
9
|
+
|
|
10
|
+
Output (stdout):
|
|
11
|
+
{"status":"success","input":"...","output":"..."}
|
|
12
|
+
{"status":"error","input":"...","error":"..."}
|
|
13
|
+
|
|
14
|
+
Returns JSON to stdout so the Python orchestrator can parse results reliably.
|
|
15
|
+
|
|
16
|
+
Note: Paths must be absolute POSIX paths. The script converts them to
|
|
17
|
+
Alias objects for Word using `POSIX file as alias` coercion.
|
|
18
|
+
*)
|
|
19
|
+
|
|
20
|
+
on run argv
|
|
21
|
+
-- ── Parse arguments ────────────────────────────────────────
|
|
22
|
+
if (count of argv) < 2 then
|
|
23
|
+
return "{\"status\":\"error\",\"error\":\"Missing arguments. Usage: export_document.applescript <inputPath> <outputPath> [timeoutSec]\"}"
|
|
24
|
+
end if
|
|
25
|
+
|
|
26
|
+
set inputPath to item 1 of argv
|
|
27
|
+
set outputPath to item 2 of argv
|
|
28
|
+
|
|
29
|
+
-- ── Validate input file exists ─────────────────────────────
|
|
30
|
+
try
|
|
31
|
+
set inputFile to inputPath as POSIX file as alias
|
|
32
|
+
on error
|
|
33
|
+
return "{\"status\":\"error\",\"input\":\"" & inputPath & "\",\"error\":\"File not found: " & inputPath & "\"}"
|
|
34
|
+
end try
|
|
35
|
+
|
|
36
|
+
-- ── Convert ────────────────────────────────────────────────
|
|
37
|
+
try
|
|
38
|
+
tell application "Microsoft Word"
|
|
39
|
+
-- Open the document
|
|
40
|
+
open inputFile
|
|
41
|
+
|
|
42
|
+
-- Wait briefly for Word to finish loading the document.
|
|
43
|
+
-- The document may not be immediately scriptable after open().
|
|
44
|
+
delay 1
|
|
45
|
+
|
|
46
|
+
-- Capture a stable reference to the document before exporting.
|
|
47
|
+
-- After save-as, active document may change, so we hold a
|
|
48
|
+
-- persistent reference here.
|
|
49
|
+
set targetDoc to active document
|
|
50
|
+
if targetDoc is missing value then
|
|
51
|
+
-- File needs more time to load
|
|
52
|
+
delay 2
|
|
53
|
+
set targetDoc to active document
|
|
54
|
+
end if
|
|
55
|
+
|
|
56
|
+
-- Export as PDF using Word's built-in PDF format constant
|
|
57
|
+
save as targetDoc file name outputPath file format format PDF
|
|
58
|
+
|
|
59
|
+
-- Close the document without saving changes to the original.
|
|
60
|
+
-- We use the captured reference rather than 'active document'
|
|
61
|
+
-- because after save-as the active document may have shifted.
|
|
62
|
+
try
|
|
63
|
+
close targetDoc saving no
|
|
64
|
+
on error
|
|
65
|
+
-- If the captured reference is stale, try active document
|
|
66
|
+
try
|
|
67
|
+
close active document saving no
|
|
68
|
+
end try
|
|
69
|
+
-- If both fail, the document may have auto-closed after save.
|
|
70
|
+
-- This is not a conversion failure — the PDF was already created.
|
|
71
|
+
end try
|
|
72
|
+
end tell
|
|
73
|
+
|
|
74
|
+
-- ── Verify output was created ──────────────────────────
|
|
75
|
+
try
|
|
76
|
+
set outputFile to outputPath as POSIX file as alias
|
|
77
|
+
return "{\"status\":\"success\",\"input\":\"" & inputPath & "\",\"output\":\"" & outputPath & "\"}"
|
|
78
|
+
on error
|
|
79
|
+
return "{\"status\":\"error\",\"input\":\"" & inputPath & "\",\"error\":\"PDF file was not created at: " & outputPath & "\"}"
|
|
80
|
+
end try
|
|
81
|
+
|
|
82
|
+
on error errMsg
|
|
83
|
+
-- ── Cleanup ────────────────────────────────────────────
|
|
84
|
+
-- Attempt to close any open document without saving
|
|
85
|
+
try
|
|
86
|
+
tell application "Microsoft Word"
|
|
87
|
+
close active document saving no
|
|
88
|
+
end tell
|
|
89
|
+
end try
|
|
90
|
+
|
|
91
|
+
-- Check for common permission error
|
|
92
|
+
if errMsg contains "-1743" then
|
|
93
|
+
return "{\"status\":\"error\",\"input\":\"" & inputPath & "\",\"error\":\"macOS Automation permission denied (-1743). Grant Terminal access to control Microsoft Word in System Settings → Privacy & Security → Automation.\"}"
|
|
94
|
+
end if
|
|
95
|
+
|
|
96
|
+
-- Escape quotes in the error message for JSON
|
|
97
|
+
set escapedMsg to my escape_json(errMsg)
|
|
98
|
+
return "{\"status\":\"error\",\"input\":\"" & inputPath & "\",\"error\":\"" & escapedMsg & "\"}"
|
|
99
|
+
end try
|
|
100
|
+
end run
|
|
101
|
+
|
|
102
|
+
-- Helper: escape special characters for JSON output
|
|
103
|
+
on escape_json(str)
|
|
104
|
+
set AppleScript's text item delimiters to "\\"
|
|
105
|
+
set str to text items of str
|
|
106
|
+
set AppleScript's text item delimiters to "\\\\"
|
|
107
|
+
set str to str as string
|
|
108
|
+
|
|
109
|
+
set AppleScript's text item delimiters to "\""
|
|
110
|
+
set str to text items of str
|
|
111
|
+
set AppleScript's text item delimiters to "\\\""
|
|
112
|
+
set str to str as string
|
|
113
|
+
|
|
114
|
+
set AppleScript's text item delimiters to ""
|
|
115
|
+
return str
|
|
116
|
+
end escape_json
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env osascript -l JavaScript
|
|
2
|
+
/**
|
|
3
|
+
* export_document.js — JXA bridge to Microsoft Word
|
|
4
|
+
*
|
|
5
|
+
* Opens a document in Microsoft Word on macOS and exports it as PDF.
|
|
6
|
+
* Returns a JSON-encoded result object to stdout so the Python
|
|
7
|
+
* orchestrator can parse it reliably.
|
|
8
|
+
*
|
|
9
|
+
* Usage (via osascript):
|
|
10
|
+
* osascript -l JavaScript export_document.js <inputPath> <outputPath> [timeoutSec]
|
|
11
|
+
*
|
|
12
|
+
* Output (stdout):
|
|
13
|
+
* {"status":"success","input":"...","output":"..."}
|
|
14
|
+
* {"status":"error","input":"...","error":"..."}
|
|
15
|
+
*
|
|
16
|
+
* Exit codes:
|
|
17
|
+
* 0 — JSON result written to stdout (check "status" key)
|
|
18
|
+
* 1 — osascript-level failure (script error, not Word error)
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
function run(argv) {
|
|
22
|
+
// ── Parse arguments ────────────────────────────────────────
|
|
23
|
+
if (argv.length < 2) {
|
|
24
|
+
return JSON.stringify({
|
|
25
|
+
status: 'error',
|
|
26
|
+
error: 'Missing arguments. Usage: export_document.js <inputPath> <outputPath> [timeoutSec]'
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
var inputPath = String(argv[0]);
|
|
31
|
+
var outputPath = String(argv[1]);
|
|
32
|
+
var timeoutSec = argv.length > 2 ? parseInt(argv[2], 10) : 60;
|
|
33
|
+
|
|
34
|
+
if (isNaN(timeoutSec) || timeoutSec < 1) {
|
|
35
|
+
timeoutSec = 60;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Validate file exists ───────────────────────────────────
|
|
39
|
+
var fm = $.NSFileManager.defaultManager;
|
|
40
|
+
if (!fm.fileExistsAtPath(inputPath)) {
|
|
41
|
+
return JSON.stringify({
|
|
42
|
+
status: 'error',
|
|
43
|
+
input: inputPath,
|
|
44
|
+
error: 'File not found: ' + inputPath
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Perform conversion ─────────────────────────────────────
|
|
49
|
+
try {
|
|
50
|
+
var app = Application('Microsoft Word');
|
|
51
|
+
app.includeStandardAdditions = true;
|
|
52
|
+
|
|
53
|
+
// -- Open the document -----------------------------------
|
|
54
|
+
var doc = app.open(inputPath);
|
|
55
|
+
if (!doc) {
|
|
56
|
+
return JSON.stringify({
|
|
57
|
+
status: 'error',
|
|
58
|
+
input: inputPath,
|
|
59
|
+
error: 'Word returned null when opening document'
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// -- Export as PDF ---------------------------------------
|
|
64
|
+
// The 'as' parameter value may vary by Word locale/version.
|
|
65
|
+
// Common values: 'PDF', 'Microsoft Word Document (*.pdf)', or
|
|
66
|
+
// internal constant. We default to 'PDF'.
|
|
67
|
+
var exportResult = doc.export({
|
|
68
|
+
to: outputPath,
|
|
69
|
+
as: 'PDF'
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// -- Close without saving changes to original ------------
|
|
73
|
+
doc.close({ saving: 'no' });
|
|
74
|
+
|
|
75
|
+
// -- Verify output was created ---------------------------
|
|
76
|
+
if (!fm.fileExistsAtPath(outputPath)) {
|
|
77
|
+
return JSON.stringify({
|
|
78
|
+
status: 'error',
|
|
79
|
+
input: inputPath,
|
|
80
|
+
error: 'Word reported success but PDF was not created at: ' + outputPath
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return JSON.stringify({
|
|
85
|
+
status: 'success',
|
|
86
|
+
input: inputPath,
|
|
87
|
+
output: outputPath
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
} catch (e) {
|
|
91
|
+
// ── Cleanup ────────────────────────────────────────────
|
|
92
|
+
// Attempt to close any document that may still be open.
|
|
93
|
+
try {
|
|
94
|
+
var activeDoc = app.activeDocument;
|
|
95
|
+
if (activeDoc) {
|
|
96
|
+
activeDoc.close({ saving: 'no' });
|
|
97
|
+
}
|
|
98
|
+
} catch (ignore) {
|
|
99
|
+
// Best-effort cleanup; ignore further errors.
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
var errorMsg = String(e.message || e);
|
|
103
|
+
// Detect common macOS permission error
|
|
104
|
+
if (errorMsg.indexOf('-1743') !== -1) {
|
|
105
|
+
errorMsg = 'macOS Automation permission denied (-1743). ' +
|
|
106
|
+
'Grant Terminal/automation access to control Microsoft Word.';
|
|
107
|
+
} else if (errorMsg.indexOf('Microsoft Word') === -1 && errorMsg.indexOf('Error') === -1) {
|
|
108
|
+
// Some JXA errors omit the app name — prefix for clarity
|
|
109
|
+
errorMsg = 'Microsoft Word: ' + errorMsg;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return JSON.stringify({
|
|
113
|
+
status: 'error',
|
|
114
|
+
input: inputPath,
|
|
115
|
+
error: errorMsg,
|
|
116
|
+
suggestions: [
|
|
117
|
+
'Ensure the document is not already open in Word',
|
|
118
|
+
'Ensure the output path is writable',
|
|
119
|
+
'Check the document is not corrupt'
|
|
120
|
+
]
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
doctopdf/cli.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
CLI module — command-line interface for DocToPDF.
|
|
4
|
+
|
|
5
|
+
Provides argument parsing and dispatches to the orchestrator.
|
|
6
|
+
Supports both standard CLI usage and Automator Quick Action invocation.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
convert-word-pdf --input ./docs --output ./pdfs
|
|
10
|
+
convert-word-pdf --input ./docs --output ./pdfs --recursive --flat
|
|
11
|
+
convert-word-pdf --check
|
|
12
|
+
convert-word-pdf --input ./docs --output ./pdfs --dry-run
|
|
13
|
+
convert-word-pdf --version
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import logging
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import List, Optional
|
|
21
|
+
|
|
22
|
+
from . import __version__
|
|
23
|
+
from .orchestrator import Orchestrator
|
|
24
|
+
from .converter import WordConverter
|
|
25
|
+
from .errors import WordNotInstalledError, WordPermissionError, FileAccessError
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
29
|
+
"""Construct the argument parser with all CLI options."""
|
|
30
|
+
parser = argparse.ArgumentParser(
|
|
31
|
+
prog='convert-word-pdf',
|
|
32
|
+
description=(
|
|
33
|
+
'Batch convert .doc and .docx files to PDF using Microsoft Word. '
|
|
34
|
+
'Runs entirely offline with no cloud dependencies.'
|
|
35
|
+
),
|
|
36
|
+
epilog=(
|
|
37
|
+
'Examples:\n'
|
|
38
|
+
' %(prog)s --input ./docs --output ./pdfs\n'
|
|
39
|
+
' %(prog)s --input ./docs --output ./pdfs --recursive --flat\n'
|
|
40
|
+
' %(prog)s --check\n'
|
|
41
|
+
' %(prog)s --input ./docs --output ./pdfs --dry-run\n'
|
|
42
|
+
'\n'
|
|
43
|
+
'Requires Microsoft Word for macOS. Automator Quick Action:\n'
|
|
44
|
+
' See README.md for Finder right-click integration.'
|
|
45
|
+
),
|
|
46
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# ── Required (or alternative) ───────────────────────────────
|
|
50
|
+
parser.add_argument(
|
|
51
|
+
'--input', '-i',
|
|
52
|
+
type=Path,
|
|
53
|
+
metavar='DIR',
|
|
54
|
+
help='Input directory containing .doc/.docx files',
|
|
55
|
+
)
|
|
56
|
+
parser.add_argument(
|
|
57
|
+
'--output', '-o',
|
|
58
|
+
type=Path,
|
|
59
|
+
metavar='DIR',
|
|
60
|
+
help='Output directory for generated PDF files',
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# ── Scanning options ────────────────────────────────────────
|
|
64
|
+
parser.add_argument(
|
|
65
|
+
'--recursive', '-r',
|
|
66
|
+
action='store_true',
|
|
67
|
+
default=True,
|
|
68
|
+
help='Recursively scan input subdirectories (default: on)',
|
|
69
|
+
)
|
|
70
|
+
parser.add_argument(
|
|
71
|
+
'--no-recursive',
|
|
72
|
+
action='store_false',
|
|
73
|
+
dest='recursive',
|
|
74
|
+
help='Do not scan subdirectories (top-level only)',
|
|
75
|
+
)
|
|
76
|
+
parser.add_argument(
|
|
77
|
+
'--flat',
|
|
78
|
+
action='store_true',
|
|
79
|
+
default=False,
|
|
80
|
+
help=(
|
|
81
|
+
'Write all PDFs flat into output directory instead of '
|
|
82
|
+
'mirroring the input folder structure'
|
|
83
|
+
),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# ── Conversion options ──────────────────────────────────────
|
|
87
|
+
parser.add_argument(
|
|
88
|
+
'--timeout',
|
|
89
|
+
type=int,
|
|
90
|
+
default=60,
|
|
91
|
+
metavar='SEC',
|
|
92
|
+
help='Seconds to wait per document before timing out (default: 60)',
|
|
93
|
+
)
|
|
94
|
+
parser.add_argument(
|
|
95
|
+
'--retry',
|
|
96
|
+
type=int,
|
|
97
|
+
default=2,
|
|
98
|
+
metavar='N',
|
|
99
|
+
help='Number of retries per failed conversion (default: 2)',
|
|
100
|
+
)
|
|
101
|
+
parser.add_argument(
|
|
102
|
+
'--restart-every',
|
|
103
|
+
type=int,
|
|
104
|
+
default=10,
|
|
105
|
+
metavar='N',
|
|
106
|
+
help='Restart Word after N successful conversions (0 = never restart, '
|
|
107
|
+
'the default). Useful to prevent state degradation in very large '
|
|
108
|
+
'batches (100+ files).',
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# ── Output options ──────────────────────────────────────────
|
|
112
|
+
parser.add_argument(
|
|
113
|
+
'--dry-run', '-n',
|
|
114
|
+
action='store_true',
|
|
115
|
+
help='Scan and report files without performing conversion',
|
|
116
|
+
)
|
|
117
|
+
parser.add_argument(
|
|
118
|
+
'--log-file',
|
|
119
|
+
type=Path,
|
|
120
|
+
metavar='FILE',
|
|
121
|
+
help='Write structured log to FILE (.json or .csv)',
|
|
122
|
+
)
|
|
123
|
+
parser.add_argument(
|
|
124
|
+
'--quiet', '-q',
|
|
125
|
+
action='store_true',
|
|
126
|
+
help='Suppress per-file status output (summary only)',
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# ── Utility ─────────────────────────────────────────────────
|
|
130
|
+
parser.add_argument(
|
|
131
|
+
'--verbose',
|
|
132
|
+
action='store_true',
|
|
133
|
+
help='Enable verbose debug logging (shows internal Word cleanup/restart diagnostics)',
|
|
134
|
+
)
|
|
135
|
+
parser.add_argument(
|
|
136
|
+
'--check',
|
|
137
|
+
action='store_true',
|
|
138
|
+
help='Check if Microsoft Word is installed and scriptable, then exit',
|
|
139
|
+
)
|
|
140
|
+
parser.add_argument(
|
|
141
|
+
'--version', '-v',
|
|
142
|
+
action='version',
|
|
143
|
+
version=f'%(prog)s {__version__}',
|
|
144
|
+
help='Show version number and exit',
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
return parser
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _configure_logging(verbose: bool) -> None:
|
|
151
|
+
"""Configure stdlib logging: warnings visible by default, debug with --verbose."""
|
|
152
|
+
logging.basicConfig(
|
|
153
|
+
level=logging.DEBUG if verbose else logging.WARNING,
|
|
154
|
+
format='%(levelname)s %(name)s: %(message)s',
|
|
155
|
+
stream=sys.stderr,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def cmd_check() -> int:
|
|
160
|
+
"""Probe for Microsoft Word and report status."""
|
|
161
|
+
installed, info = WordConverter.check_word_installed()
|
|
162
|
+
|
|
163
|
+
if installed:
|
|
164
|
+
print(f'✓ Microsoft Word is installed (version: {info})')
|
|
165
|
+
print(' The automation bridge is operational.')
|
|
166
|
+
return 0
|
|
167
|
+
else:
|
|
168
|
+
print('✗ Microsoft Word is NOT installed or not scriptable.')
|
|
169
|
+
print()
|
|
170
|
+
print(' To use this tool, install Microsoft Word for macOS:')
|
|
171
|
+
print(' - Microsoft 365 (subscription)')
|
|
172
|
+
print(' - Microsoft Office 2019 or later (one-time purchase)')
|
|
173
|
+
print()
|
|
174
|
+
print(' After installing Word, grant Automation permissions:')
|
|
175
|
+
print(' System Settings → Privacy & Security → Automation')
|
|
176
|
+
print(' → Allow Terminal (or your app) to control "Microsoft Word"')
|
|
177
|
+
print()
|
|
178
|
+
print(f' Probe details: {info}')
|
|
179
|
+
return 1
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def cmd_validate_args(args: argparse.Namespace) -> Optional[str]:
|
|
183
|
+
"""Validate mutually-dependent arguments. Returns error string or None."""
|
|
184
|
+
if not args.input and not args.check:
|
|
185
|
+
return '--input is required (use --check to probe for Word)'
|
|
186
|
+
|
|
187
|
+
if args.input and not args.input.is_dir():
|
|
188
|
+
return f'Input path is not a directory or does not exist: {args.input}'
|
|
189
|
+
|
|
190
|
+
if not args.output and not args.check and not args.dry_run:
|
|
191
|
+
# In dry-run mode, output is not strictly needed
|
|
192
|
+
if args.input:
|
|
193
|
+
return '--output is required for conversion (omit with --dry-run)'
|
|
194
|
+
|
|
195
|
+
if args.log_file:
|
|
196
|
+
parent = Path(args.log_file).parent
|
|
197
|
+
if not parent.exists():
|
|
198
|
+
try:
|
|
199
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
200
|
+
except PermissionError:
|
|
201
|
+
return f'Cannot create directory for log file: {args.log_file}'
|
|
202
|
+
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def cmd_convert(args: argparse.Namespace) -> int:
|
|
207
|
+
"""Run the batch conversion."""
|
|
208
|
+
# If no --output was given but we have --input, use a default
|
|
209
|
+
output_dir = args.output
|
|
210
|
+
if not output_dir and args.input:
|
|
211
|
+
output_dir = Path.home() / 'Documents' / 'PDF_Conversions'
|
|
212
|
+
print(f' Output directory not specified, using: {output_dir}')
|
|
213
|
+
|
|
214
|
+
orchestrator = Orchestrator(
|
|
215
|
+
input_dir=args.input,
|
|
216
|
+
output_dir=output_dir,
|
|
217
|
+
recursive=args.recursive,
|
|
218
|
+
flat=args.flat,
|
|
219
|
+
timeout=args.timeout,
|
|
220
|
+
retry=args.retry,
|
|
221
|
+
restart_every=args.restart_every,
|
|
222
|
+
dry_run=args.dry_run,
|
|
223
|
+
log_file=args.log_file,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
orchestrator.run()
|
|
228
|
+
except KeyboardInterrupt:
|
|
229
|
+
print('\n\nCancelled by user.')
|
|
230
|
+
return 130
|
|
231
|
+
|
|
232
|
+
success = orchestrator.success_count
|
|
233
|
+
errors = orchestrator.error_count
|
|
234
|
+
|
|
235
|
+
if orchestrator.dry_run:
|
|
236
|
+
return 0
|
|
237
|
+
|
|
238
|
+
# Print opening line if output was created
|
|
239
|
+
if success > 0:
|
|
240
|
+
print(f'\n PDFs written to: {output_dir}')
|
|
241
|
+
|
|
242
|
+
# Return non-zero if any errors occurred
|
|
243
|
+
return 0 if errors == 0 else 1
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
247
|
+
"""
|
|
248
|
+
CLI entry point.
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
argv: Argument list (defaults to sys.argv[1:]).
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
Exit code (0 = success, 1 = error, 130 = cancelled).
|
|
255
|
+
"""
|
|
256
|
+
parser = build_parser()
|
|
257
|
+
args = parser.parse_args(argv)
|
|
258
|
+
|
|
259
|
+
# Configure stdlib logging before any work; warnings show by default,
|
|
260
|
+
# debug diagnostics require --verbose.
|
|
261
|
+
_configure_logging(args.verbose)
|
|
262
|
+
|
|
263
|
+
# ── Mode dispatch ──────────────────────────────────────────
|
|
264
|
+
if args.check:
|
|
265
|
+
return cmd_check()
|
|
266
|
+
|
|
267
|
+
# ── Validate ────────────────────────────────────────────────
|
|
268
|
+
error_msg = cmd_validate_args(args)
|
|
269
|
+
if error_msg:
|
|
270
|
+
parser.error(error_msg)
|
|
271
|
+
return 2 # pragma: no cover
|
|
272
|
+
|
|
273
|
+
# ── Convert ─────────────────────────────────────────────────
|
|
274
|
+
try:
|
|
275
|
+
return cmd_convert(args)
|
|
276
|
+
except WordNotInstalledError:
|
|
277
|
+
print('ERROR: Microsoft Word is not installed or not scriptable.')
|
|
278
|
+
print(' Run with --check for diagnostic information.')
|
|
279
|
+
print(' Alternatively, install Microsoft Word for macOS.')
|
|
280
|
+
return 1
|
|
281
|
+
except WordPermissionError as e:
|
|
282
|
+
print(f'ERROR: {e}')
|
|
283
|
+
return 1
|
|
284
|
+
except FileAccessError as e:
|
|
285
|
+
print(f'ERROR: {e}')
|
|
286
|
+
return 1
|
|
287
|
+
except Exception as e:
|
|
288
|
+
print(f'ERROR: Unexpected error: {e}', file=sys.stderr)
|
|
289
|
+
import traceback
|
|
290
|
+
traceback.print_exc(file=sys.stderr)
|
|
291
|
+
return 1
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
if __name__ == '__main__':
|
|
295
|
+
sys.exit(main())
|
doctopdf/config.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration defaults and constants for the DocToPDF converter.
|
|
3
|
+
|
|
4
|
+
Centralises all tunable parameters so they can be adjusted without
|
|
5
|
+
digging through implementation modules.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
# ── File discovery ──────────────────────────────────────────────
|
|
11
|
+
SUPPORTED_EXTENSIONS: set = {'.doc', '.docx'}
|
|
12
|
+
"""File extensions treated as convertible documents (case-insensitive)."""
|
|
13
|
+
|
|
14
|
+
MIN_FILE_SIZE_BYTES: int = 1
|
|
15
|
+
"""Files smaller than this (in bytes) are skipped as likely empty."""
|
|
16
|
+
|
|
17
|
+
# ── Conversion defaults ─────────────────────────────────────────
|
|
18
|
+
DEFAULT_TIMEOUT: int = 60
|
|
19
|
+
"""Seconds to wait for Word to convert a single document before timing out."""
|
|
20
|
+
|
|
21
|
+
DEFAULT_RETRY_COUNT: int = 2
|
|
22
|
+
"""Number of additional attempts per file after the first failure."""
|
|
23
|
+
|
|
24
|
+
DEFAULT_RETRY_DELAY: float = 2.0
|
|
25
|
+
"""Base seconds to wait between retries (actual = delay × attempt_number)."""
|
|
26
|
+
|
|
27
|
+
# ── Output structure ────────────────────────────────────────────
|
|
28
|
+
DEFAULT_OUTPUT_DIR: Path = Path.home() / 'Documents' / 'PDF_Conversions'
|
|
29
|
+
"""Fallback output directory when --output is not specified."""
|
|
30
|
+
|
|
31
|
+
DEFAULT_FLAT: bool = False
|
|
32
|
+
"""If True, write all PDFs to a single flat output directory instead of mirrored tree."""
|
|
33
|
+
|
|
34
|
+
# ── Logging ─────────────────────────────────────────────────────
|
|
35
|
+
DEFAULT_LOG_FORMAT: str = 'text'
|
|
36
|
+
"""One of 'text', 'json', 'csv'."""
|
|
37
|
+
|
|
38
|
+
# ── Path to bundled AppleScript/JXA assets ──────────────────────
|
|
39
|
+
SCRIPT_DIR: Path = Path(__file__).parent / 'applescript'
|
|
40
|
+
|
|
41
|
+
APPLESCRIPT_EXPORT_SCRIPT: Path = SCRIPT_DIR / 'export_document.applescript'
|
|
42
|
+
"""Primary: AppleScript that opens a document in Word and exports it as PDF."""
|
|
43
|
+
|
|
44
|
+
JXA_EXPORT_SCRIPT: Path = SCRIPT_DIR / 'export_document.js'
|
|
45
|
+
"""Fallback: JXA script that opens a document in Word and exports it as PDF."""
|
|
46
|
+
|
|
47
|
+
APPLESCRIPT_CHECK_WORD: Path = SCRIPT_DIR / 'check_word.applescript'
|
|
48
|
+
"""AppleScript that returns JSON indicating whether Word is installed."""
|
|
49
|
+
|
|
50
|
+
# ── Word version support ────────────────────────────────────────
|
|
51
|
+
# Some Word versions use different constant names for PDF format.
|
|
52
|
+
# This is probed at runtime but can be overridden.
|
|
53
|
+
WORD_FORMAT_PDF: str = 'PDF'
|
|
54
|
+
"""The format identifier used in Word's export command."""
|