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 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,13 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Entry point for `python -m doctopdf`.
4
+
5
+ Allows running the converter as a module:
6
+ python -m doctopdf --input ./docs --output ./pdfs
7
+ """
8
+
9
+ import sys
10
+
11
+ from .cli import main
12
+
13
+ sys.exit(main())
@@ -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."""