max-cli 0.2.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.
- max_cli/__init__.py +0 -0
- max_cli/common/cache.py +145 -0
- max_cli/common/concurrent.py +83 -0
- max_cli/common/exceptions.py +40 -0
- max_cli/common/logger.py +22 -0
- max_cli/common/logging.py +24 -0
- max_cli/common/retry.py +51 -0
- max_cli/common/utils.py +40 -0
- max_cli/config.py +43 -0
- max_cli/core/ai_engine.py +541 -0
- max_cli/core/file_organizer.py +254 -0
- max_cli/core/image_processor.py +139 -0
- max_cli/core/media_engine.py +681 -0
- max_cli/core/network_engine.py +103 -0
- max_cli/core/pdf_engine.py +520 -0
- max_cli/core/system_engine.py +57 -0
- max_cli/interface/cli_ai.py +376 -0
- max_cli/interface/cli_config.py +363 -0
- max_cli/interface/cli_files.py +388 -0
- max_cli/interface/cli_images.py +176 -0
- max_cli/interface/cli_media.py +558 -0
- max_cli/interface/cli_network.py +174 -0
- max_cli/interface/cli_pdf.py +651 -0
- max_cli/interface/cli_tools.py +60 -0
- max_cli/main.py +91 -0
- max_cli/plugins/__init__.py +4 -0
- max_cli/plugins/base.py +39 -0
- max_cli/plugins/manager.py +81 -0
- max_cli-0.2.0.dist-info/METADATA +632 -0
- max_cli-0.2.0.dist-info/RECORD +34 -0
- max_cli-0.2.0.dist-info/WHEEL +5 -0
- max_cli-0.2.0.dist-info/entry_points.txt +2 -0
- max_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- max_cli-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import List, Optional
|
|
5
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn
|
|
6
|
+
|
|
7
|
+
from max_cli.core.pdf_engine import PDFEngine
|
|
8
|
+
from max_cli.common.logger import console, log_error, log_success
|
|
9
|
+
from max_cli.common.utils import natural_sort_key, format_size
|
|
10
|
+
|
|
11
|
+
app = typer.Typer()
|
|
12
|
+
engine = PDFEngine()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@app.command("merge")
|
|
16
|
+
def merge_pdfs(
|
|
17
|
+
inputs: Optional[List[Path]] = typer.Argument(
|
|
18
|
+
None, help="List of files OR a single folder."
|
|
19
|
+
),
|
|
20
|
+
output: Optional[Path] = typer.Option(
|
|
21
|
+
None, "-o", "--output", help="Output filename."
|
|
22
|
+
),
|
|
23
|
+
):
|
|
24
|
+
"""
|
|
25
|
+
Combine multiple PDFs into one.
|
|
26
|
+
"""
|
|
27
|
+
# Handle default to current directory
|
|
28
|
+
if inputs is None:
|
|
29
|
+
inputs = [Path(".")]
|
|
30
|
+
|
|
31
|
+
files_to_merge = _resolve_files(inputs)
|
|
32
|
+
|
|
33
|
+
if not output:
|
|
34
|
+
# Smart default naming
|
|
35
|
+
if inputs[0].is_dir():
|
|
36
|
+
folder_name = inputs[0].name
|
|
37
|
+
if not folder_name:
|
|
38
|
+
folder_name = inputs[0].absolute().name or inputs[0].parent.name
|
|
39
|
+
output = inputs[0] / f"{folder_name}_merged.pdf"
|
|
40
|
+
else:
|
|
41
|
+
output = inputs[0].parent / f"{inputs[0].stem}_merged.pdf"
|
|
42
|
+
|
|
43
|
+
console.print(f"Merging [bold]{len(files_to_merge)}[/bold] files...")
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
pages = engine.merge_pdfs(files_to_merge, output)
|
|
47
|
+
log_success(f"Merged {pages} pages into: [bold]{output}[/bold]")
|
|
48
|
+
except Exception as e:
|
|
49
|
+
log_error(f"Merge failed: {e}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@app.command("compress")
|
|
53
|
+
def compress_pdf(
|
|
54
|
+
target: Path = typer.Argument(..., help="PDF file OR Folder to compress."),
|
|
55
|
+
dpi: int = typer.Option(
|
|
56
|
+
150, "-d", "--dpi", help="DPI resolution (Lower = smaller file)."
|
|
57
|
+
),
|
|
58
|
+
quality: int = typer.Option(
|
|
59
|
+
80, "-q", "--quality", help="JPEG Quality 1-100 (Lower = smaller file)."
|
|
60
|
+
),
|
|
61
|
+
):
|
|
62
|
+
"""
|
|
63
|
+
Shrink PDFs. Accepts a single file OR a folder (batch mode).
|
|
64
|
+
"""
|
|
65
|
+
if not target.exists():
|
|
66
|
+
log_error(f"Target not found: {target}")
|
|
67
|
+
raise typer.Exit(1)
|
|
68
|
+
|
|
69
|
+
targets = []
|
|
70
|
+
|
|
71
|
+
# 1. Determine targets
|
|
72
|
+
if target.is_dir():
|
|
73
|
+
console.print(f"[cyan]Batch Mode: Scanning '{target.name}'...[/cyan]")
|
|
74
|
+
targets = sorted(
|
|
75
|
+
list(target.glob("*.pdf")), key=lambda f: natural_sort_key(f.name)
|
|
76
|
+
)
|
|
77
|
+
# Create a subfolder for output to avoid mess
|
|
78
|
+
output_dir = target / "compressed"
|
|
79
|
+
output_dir.mkdir(exist_ok=True)
|
|
80
|
+
else:
|
|
81
|
+
targets = [target]
|
|
82
|
+
output_dir = target.parent
|
|
83
|
+
|
|
84
|
+
if not targets:
|
|
85
|
+
log_error("No PDF files found.")
|
|
86
|
+
raise typer.Exit(1)
|
|
87
|
+
|
|
88
|
+
# 2. Process
|
|
89
|
+
success_count = 0
|
|
90
|
+
total_saved = 0
|
|
91
|
+
|
|
92
|
+
# Using Rich Progress Bar for better UX
|
|
93
|
+
with Progress(
|
|
94
|
+
SpinnerColumn(),
|
|
95
|
+
TextColumn("[progress.description]{task.description}"),
|
|
96
|
+
BarColumn(),
|
|
97
|
+
transient=True,
|
|
98
|
+
) as progress:
|
|
99
|
+
task = progress.add_task("Compressing...", total=len(targets))
|
|
100
|
+
|
|
101
|
+
for pdf in targets:
|
|
102
|
+
progress.update(task, description=f"Compressing {pdf.name}...")
|
|
103
|
+
|
|
104
|
+
if target.is_dir():
|
|
105
|
+
# Folder mode: save to ./compressed/filename.pdf
|
|
106
|
+
out_path = output_dir / pdf.name
|
|
107
|
+
else:
|
|
108
|
+
# Single file mode: save to filename_compressed.pdf
|
|
109
|
+
out_path = output_dir / f"{pdf.stem}_compressed.pdf"
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
engine.compress_pdf(pdf, out_path, dpi, quality)
|
|
113
|
+
|
|
114
|
+
# Stats calculation
|
|
115
|
+
orig = pdf.stat().st_size
|
|
116
|
+
new = out_path.stat().st_size
|
|
117
|
+
diff = orig - new
|
|
118
|
+
total_saved += diff
|
|
119
|
+
|
|
120
|
+
success_count += 1
|
|
121
|
+
except Exception as e:
|
|
122
|
+
console.print(f"[red]Failed to compress {pdf.name}: {e}[/red]")
|
|
123
|
+
|
|
124
|
+
progress.advance(task)
|
|
125
|
+
|
|
126
|
+
log_success(f"Finished! Processed {success_count}/{len(targets)} files.")
|
|
127
|
+
if total_saved > 0:
|
|
128
|
+
console.print(
|
|
129
|
+
f"[green]Total Space Saved:[/green] [bold]{format_size(total_saved)}[/bold]"
|
|
130
|
+
)
|
|
131
|
+
else:
|
|
132
|
+
# Growth scenario
|
|
133
|
+
console.print(
|
|
134
|
+
f"[yellow]⚠ Warning:[/yellow] File size increased by [bold red]{format_size(abs(total_saved))}[/bold red]."
|
|
135
|
+
)
|
|
136
|
+
console.print(
|
|
137
|
+
"[dim]Note: This PDF is likely text-based. Rasterization (image-based compression) "
|
|
138
|
+
"is best for scanned documents, not digital text documents.[/dim]"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@app.command("bundle")
|
|
143
|
+
def bundle_pdfs(
|
|
144
|
+
inputs: Optional[List[Path]] = typer.Argument(
|
|
145
|
+
None, help="Files or Folder to bundle."
|
|
146
|
+
),
|
|
147
|
+
output: Optional[Path] = typer.Option(
|
|
148
|
+
None, "-o", "--output", help="Final output path."
|
|
149
|
+
),
|
|
150
|
+
dpi: int = typer.Option(150, "-d", "--dpi", help="Compression DPI (default: 150)."),
|
|
151
|
+
quality: int = typer.Option(
|
|
152
|
+
80, "-q", "--quality", help="Compression Quality 1-100 (default: 80)."
|
|
153
|
+
),
|
|
154
|
+
no_compress: bool = typer.Option(
|
|
155
|
+
False, "--no-compress", help="Skip compression (merge only, no compress)."
|
|
156
|
+
),
|
|
157
|
+
):
|
|
158
|
+
"""
|
|
159
|
+
Pipeline: Merge multiple files -> Optionally Compress -> Save final.
|
|
160
|
+
|
|
161
|
+
Examples:
|
|
162
|
+
max pdf bundle # Merge + Compress (default)
|
|
163
|
+
max pdf bundle --no-compress # Merge only, no compression
|
|
164
|
+
max pdf bundle -d 300 -q 90 # Merge + Compress with high quality
|
|
165
|
+
max pdf bundle -d 72 -q 50 # Merge + Heavy compression
|
|
166
|
+
"""
|
|
167
|
+
# Handle default to current directory
|
|
168
|
+
if inputs is None:
|
|
169
|
+
inputs = [Path(".")]
|
|
170
|
+
|
|
171
|
+
# 1. Resolve Inputs
|
|
172
|
+
try:
|
|
173
|
+
files = _resolve_files(inputs)
|
|
174
|
+
except Exception as e:
|
|
175
|
+
log_error(str(e))
|
|
176
|
+
raise typer.Exit(1)
|
|
177
|
+
|
|
178
|
+
# 2. Smart Output Logic
|
|
179
|
+
# Determine a base name for the file
|
|
180
|
+
if inputs[0].is_dir():
|
|
181
|
+
base_name = inputs[0].name
|
|
182
|
+
if not base_name:
|
|
183
|
+
base_name = inputs[0].absolute().name or inputs[0].parent.name
|
|
184
|
+
default_parent = inputs[0].parent
|
|
185
|
+
else:
|
|
186
|
+
base_name = inputs[0].stem
|
|
187
|
+
default_parent = inputs[0].parent
|
|
188
|
+
|
|
189
|
+
# Determine filename based on whether compression is used
|
|
190
|
+
if no_compress:
|
|
191
|
+
filename = f"{base_name}_merged.pdf"
|
|
192
|
+
else:
|
|
193
|
+
filename = f"{base_name}_bundled.pdf"
|
|
194
|
+
|
|
195
|
+
if output is None:
|
|
196
|
+
output = default_parent / filename
|
|
197
|
+
elif output.is_dir():
|
|
198
|
+
output = output / filename
|
|
199
|
+
|
|
200
|
+
# Show pipeline info
|
|
201
|
+
if no_compress:
|
|
202
|
+
console.print(f"[cyan]Pipeline: Merge ({len(files)} files)[/cyan]")
|
|
203
|
+
else:
|
|
204
|
+
console.print(
|
|
205
|
+
f"[cyan]Pipeline: Merge ({len(files)} files) -> Compress (DPI:{dpi}, Q:{quality})[/cyan]"
|
|
206
|
+
)
|
|
207
|
+
console.print(f"[dim]Target: {output}[/dim]")
|
|
208
|
+
|
|
209
|
+
# Create a unique temp file to avoid collisions
|
|
210
|
+
temp_merged = output.parent / f".tmp_{base_name}_merged.pdf"
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
# Step 1: Merge
|
|
214
|
+
with console.status("Merging..."):
|
|
215
|
+
engine.merge_pdfs(files, temp_merged)
|
|
216
|
+
|
|
217
|
+
final_size = 0
|
|
218
|
+
|
|
219
|
+
if no_compress:
|
|
220
|
+
# Just move the merged file to output
|
|
221
|
+
temp_merged.rename(output)
|
|
222
|
+
final_size = output.stat().st_size
|
|
223
|
+
else:
|
|
224
|
+
# Step 2: Compress
|
|
225
|
+
with console.status("Compressing..."):
|
|
226
|
+
engine.compress_pdf(temp_merged, output, dpi, quality)
|
|
227
|
+
|
|
228
|
+
# Cleanup temp
|
|
229
|
+
if temp_merged.exists():
|
|
230
|
+
os.remove(temp_merged)
|
|
231
|
+
|
|
232
|
+
# Stats
|
|
233
|
+
final_size = output.stat().st_size
|
|
234
|
+
original_total_size = sum(f.stat().st_size for f in files)
|
|
235
|
+
|
|
236
|
+
if final_size > original_total_size:
|
|
237
|
+
growth = final_size - original_total_size
|
|
238
|
+
console.print(
|
|
239
|
+
f"[yellow]⚠ Warning:[/yellow] Bundle size increased by [bold red]{format_size(growth)}[/bold red]."
|
|
240
|
+
)
|
|
241
|
+
console.print(
|
|
242
|
+
"[dim]Note: Consider using lower quality or 'compress' command separately.[/dim]"
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
log_success("Bundle created successfully!")
|
|
246
|
+
console.print(f"Path: [bold]{output}[/bold]")
|
|
247
|
+
console.print(f"Size: {format_size(final_size)}")
|
|
248
|
+
console.print(f"Pages: [bold]{engine.get_page_count(output)}[/bold]")
|
|
249
|
+
|
|
250
|
+
except Exception as e:
|
|
251
|
+
if temp_merged.exists():
|
|
252
|
+
os.remove(temp_merged)
|
|
253
|
+
log_error(f"Bundle operation failed: {e}")
|
|
254
|
+
raise typer.Exit(1)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _resolve_files(inputs: List[Path]) -> List[Path]:
|
|
258
|
+
"""Helper to turn input arguments into a sorted list of PDF paths."""
|
|
259
|
+
files = []
|
|
260
|
+
|
|
261
|
+
# If the user passed a single directory
|
|
262
|
+
if len(inputs) == 1 and inputs[0].is_dir():
|
|
263
|
+
folder = inputs[0]
|
|
264
|
+
# Recursively or flatly find PDFs? Standard is flat to avoid deep loops.
|
|
265
|
+
# We filter out files starting with '.' or '_' to avoid hidden/temp files.
|
|
266
|
+
raw = [
|
|
267
|
+
f
|
|
268
|
+
for f in folder.iterdir()
|
|
269
|
+
if f.suffix.lower() == ".pdf" and not f.name.startswith((".", "_"))
|
|
270
|
+
]
|
|
271
|
+
files = sorted(raw, key=lambda f: natural_sort_key(f.name))
|
|
272
|
+
|
|
273
|
+
else:
|
|
274
|
+
# Explicit list of files
|
|
275
|
+
files = [f for f in inputs if f.exists() and f.suffix.lower() == ".pdf"]
|
|
276
|
+
|
|
277
|
+
if not files:
|
|
278
|
+
raise ValueError("No PDF files found in input.")
|
|
279
|
+
|
|
280
|
+
return files
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@app.command("split")
|
|
284
|
+
def split_pdf(
|
|
285
|
+
target: Path = typer.Argument(..., help="PDF file to split."),
|
|
286
|
+
start: int = typer.Option(
|
|
287
|
+
1, "-s", "--start", help="Start page (1-based, default: 1)."
|
|
288
|
+
),
|
|
289
|
+
end: int = typer.Option(
|
|
290
|
+
-1, "-e", "--end", help="End page (-1 for last page, default: last)."
|
|
291
|
+
),
|
|
292
|
+
output: Optional[Path] = typer.Option(None, "-o", help="Output filename."),
|
|
293
|
+
chunks: int = typer.Option(
|
|
294
|
+
0,
|
|
295
|
+
"-c",
|
|
296
|
+
"--chunks",
|
|
297
|
+
help="Split into chunks of N pages (0=disabled, creates multiple files).",
|
|
298
|
+
),
|
|
299
|
+
remove: bool = typer.Option(
|
|
300
|
+
False, "--remove", help="Remove the specified range instead of keeping it."
|
|
301
|
+
),
|
|
302
|
+
list_pages: bool = typer.Option(
|
|
303
|
+
False, "--list", help="Just show page count and exit."
|
|
304
|
+
),
|
|
305
|
+
):
|
|
306
|
+
"""
|
|
307
|
+
Split a PDF by page range or into chunks.
|
|
308
|
+
|
|
309
|
+
Examples:
|
|
310
|
+
max pdf split file.pdf -s 1 -e 10 Keep pages 1-10
|
|
311
|
+
max pdf split file.pdf -s 11 Keep from page 11 to end
|
|
312
|
+
max pdf split file.pdf -e 5 Keep pages 1-5
|
|
313
|
+
max pdf split file.pdf -c 10 Split into chunks of 10 pages each
|
|
314
|
+
max pdf split file.pdf --remove -s 5 -e 10 Remove pages 5-10
|
|
315
|
+
"""
|
|
316
|
+
if not target.exists() or not target.is_file():
|
|
317
|
+
log_error(f"File not found: {target}")
|
|
318
|
+
raise typer.Exit(1)
|
|
319
|
+
|
|
320
|
+
try:
|
|
321
|
+
total_pages = engine.get_page_count(target)
|
|
322
|
+
except Exception as e:
|
|
323
|
+
log_error(f"Failed to read PDF: {e}")
|
|
324
|
+
raise typer.Exit(1)
|
|
325
|
+
|
|
326
|
+
if list_pages:
|
|
327
|
+
console.print(f"[cyan]'{target.name}' has [bold]{total_pages}[/bold] pages.")
|
|
328
|
+
return
|
|
329
|
+
|
|
330
|
+
# Handle chunk mode
|
|
331
|
+
if chunks > 0:
|
|
332
|
+
output_dir = target.parent
|
|
333
|
+
if output and output.is_dir():
|
|
334
|
+
output_dir = output
|
|
335
|
+
|
|
336
|
+
output_dir.mkdir(exist_ok=True)
|
|
337
|
+
|
|
338
|
+
console.print(f"[cyan]Splitting into chunks of {chunks} pages...")
|
|
339
|
+
files = engine.split_into_chunks(target, output_dir, chunks)
|
|
340
|
+
|
|
341
|
+
console.print(f"[green]Created [bold]{len(files)}[/bold] files:")
|
|
342
|
+
for f in files:
|
|
343
|
+
size = f.stat().st_size
|
|
344
|
+
console.print(f" {f.name} ({format_size(size)})")
|
|
345
|
+
|
|
346
|
+
log_success(f"Split into {len(files)} chunks")
|
|
347
|
+
return
|
|
348
|
+
|
|
349
|
+
# Resolve end to last page
|
|
350
|
+
if end == -1 or end > total_pages:
|
|
351
|
+
end = total_pages
|
|
352
|
+
|
|
353
|
+
# Validate range
|
|
354
|
+
if start < 1 or start > end:
|
|
355
|
+
log_error(f"Invalid range: {start}-{end}. Document has {total_pages} pages.")
|
|
356
|
+
raise typer.Exit(1)
|
|
357
|
+
|
|
358
|
+
# Determine output path
|
|
359
|
+
if not output:
|
|
360
|
+
if remove:
|
|
361
|
+
output = target.parent / f"{target.stem}_without_p{start}-{end}.pdf"
|
|
362
|
+
else:
|
|
363
|
+
output = target.parent / f"{target.stem}_p{start}-{end}.pdf"
|
|
364
|
+
|
|
365
|
+
# Show what we're doing
|
|
366
|
+
if remove:
|
|
367
|
+
console.print(f"[cyan]Removing pages {start}-{end} from '{target.name}'...")
|
|
368
|
+
action_text = "Removed"
|
|
369
|
+
else:
|
|
370
|
+
console.print(f"[cyan]Extracting pages {start}-{end} from '{target.name}'...")
|
|
371
|
+
action_text = "Extracted"
|
|
372
|
+
|
|
373
|
+
try:
|
|
374
|
+
count = engine.split_by_range(target, output, start, end, keep=not remove)
|
|
375
|
+
|
|
376
|
+
size = output.stat().st_size
|
|
377
|
+
console.print(f"{action_text} [bold]{count}[/bold] pages -> {output.name}")
|
|
378
|
+
console.print(f"Size: {format_size(size)}")
|
|
379
|
+
log_success(f"Saved to: {output}")
|
|
380
|
+
|
|
381
|
+
except ValueError as e:
|
|
382
|
+
log_error(str(e))
|
|
383
|
+
except Exception as e:
|
|
384
|
+
log_error(f"Split failed: {e}")
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
@app.command("stamp")
|
|
388
|
+
def stamp_pdf(
|
|
389
|
+
target: Path = typer.Argument(..., help="PDF to watermark."),
|
|
390
|
+
text: str = typer.Argument("DRAFT", help="Text to overlay."),
|
|
391
|
+
output: Optional[Path] = typer.Option(None, "-o", help="Output filename."),
|
|
392
|
+
):
|
|
393
|
+
"""
|
|
394
|
+
Add a watermark (e.g., 'CONFIDENTIAL') to the center of every page.
|
|
395
|
+
"""
|
|
396
|
+
if not output:
|
|
397
|
+
output = target.parent / f"{target.stem}_stamped.pdf"
|
|
398
|
+
|
|
399
|
+
console.print(f"[cyan]Stamping '{text}' onto {target.name}...[/cyan]")
|
|
400
|
+
engine.watermark_pdf(target, output, text=text)
|
|
401
|
+
log_success(f"Stamped PDF saved to: {output}")
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
@app.command("lock")
|
|
405
|
+
def lock_pdf(
|
|
406
|
+
target: Path = typer.Argument(..., help="PDF to encrypt."),
|
|
407
|
+
password: str = typer.Option(
|
|
408
|
+
..., "--password", "-p", prompt=True, hide_input=True, help="Password."
|
|
409
|
+
),
|
|
410
|
+
output: Optional[Path] = typer.Option(None, "-o", help="Output filename."),
|
|
411
|
+
):
|
|
412
|
+
"""
|
|
413
|
+
Encrypt a PDF with a password.
|
|
414
|
+
"""
|
|
415
|
+
if not output:
|
|
416
|
+
output = target.parent / f"{target.stem}_locked.pdf"
|
|
417
|
+
|
|
418
|
+
engine.set_password(target, output, password)
|
|
419
|
+
log_success(f"Encrypted file saved to: {output}")
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
@app.command("rip")
|
|
423
|
+
def rip_content(
|
|
424
|
+
target: Path = typer.Argument(..., help="PDF to extract from."),
|
|
425
|
+
output_dir: Optional[Path] = typer.Option(
|
|
426
|
+
None, "-o", help="Folder to save images."
|
|
427
|
+
),
|
|
428
|
+
):
|
|
429
|
+
"""
|
|
430
|
+
Extract all images from inside the PDF.
|
|
431
|
+
"""
|
|
432
|
+
if not output_dir:
|
|
433
|
+
output_dir = target.parent / f"{target.stem}_assets"
|
|
434
|
+
|
|
435
|
+
output_dir.mkdir(exist_ok=True)
|
|
436
|
+
|
|
437
|
+
console.print(f"Extracting images from [bold]{target.name}[/bold]...")
|
|
438
|
+
count = engine.extract_assets(target, output_dir)
|
|
439
|
+
|
|
440
|
+
if count > 0:
|
|
441
|
+
log_success(f"Extracted [bold]{count}[/bold] images to: {output_dir}")
|
|
442
|
+
else:
|
|
443
|
+
console.print("[yellow]No images found in this PDF.[/yellow]")
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
@app.command("ocr")
|
|
447
|
+
def ocr_pdf(
|
|
448
|
+
target: Path = typer.Argument(..., help="PDF file to OCR."),
|
|
449
|
+
lang: str = typer.Option(
|
|
450
|
+
"eng", "--lang", "-l", help="Language code (eng, deu, fra, eng+deu)."
|
|
451
|
+
),
|
|
452
|
+
output: Optional[Path] = typer.Option(
|
|
453
|
+
None, "-o", help="Output text file (default: same name with .txt)."
|
|
454
|
+
),
|
|
455
|
+
):
|
|
456
|
+
"""
|
|
457
|
+
Extract text from scanned PDFs using OCR.
|
|
458
|
+
|
|
459
|
+
Requires pytesseract and Tesseract OCR installed.
|
|
460
|
+
Install: pip install max-cli[ocr]
|
|
461
|
+
"""
|
|
462
|
+
if not output:
|
|
463
|
+
output = target.parent / f"{target.stem}.txt"
|
|
464
|
+
|
|
465
|
+
console.print(f"[cyan]Running OCR on {target.name} (lang={lang})...[/cyan]")
|
|
466
|
+
|
|
467
|
+
try:
|
|
468
|
+
text = engine.ocr_pdf(target, output, lang=lang)
|
|
469
|
+
char_count = len(text)
|
|
470
|
+
|
|
471
|
+
log_success(f"Text extracted to: {output}")
|
|
472
|
+
console.print(f"Extracted [bold]{char_count}[/bold] characters")
|
|
473
|
+
|
|
474
|
+
except RuntimeError as e:
|
|
475
|
+
log_error(str(e))
|
|
476
|
+
console.print(
|
|
477
|
+
"[yellow]Tip: Install OCR dependencies with: pip install max-cli[ocr][/yellow]"
|
|
478
|
+
)
|
|
479
|
+
except Exception as e:
|
|
480
|
+
log_error(f"OCR failed: {e}")
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
@app.command("form-data")
|
|
484
|
+
def extract_form(
|
|
485
|
+
target: Path = typer.Argument(..., help="PDF form to extract data from."),
|
|
486
|
+
):
|
|
487
|
+
"""
|
|
488
|
+
Extract data from PDF form fields.
|
|
489
|
+
"""
|
|
490
|
+
if not target.exists():
|
|
491
|
+
log_error(f"File not found: {target}")
|
|
492
|
+
raise typer.Exit(1)
|
|
493
|
+
|
|
494
|
+
console.print(f"[cyan]Extracting form data from {target.name}...[/cyan]")
|
|
495
|
+
|
|
496
|
+
try:
|
|
497
|
+
form_data = engine.extract_form_data(target)
|
|
498
|
+
if form_data:
|
|
499
|
+
console.print("[bold]Form Fields:[/bold]")
|
|
500
|
+
for name, value in form_data.items():
|
|
501
|
+
console.print(f" {name}: [green]{value}[/green]")
|
|
502
|
+
log_success(f"Found {len(form_data)} form fields")
|
|
503
|
+
else:
|
|
504
|
+
console.print("[yellow]No form fields found in this PDF.[/yellow]")
|
|
505
|
+
except Exception as e:
|
|
506
|
+
log_error(f"Failed to extract form data: {e}")
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
@app.command("form-fill")
|
|
510
|
+
def fill_form(
|
|
511
|
+
target: Path = typer.Argument(..., help="PDF form to fill."),
|
|
512
|
+
field: str = typer.Option(
|
|
513
|
+
...,
|
|
514
|
+
"-f",
|
|
515
|
+
"--field",
|
|
516
|
+
help="Field name=value (can be specified multiple times).",
|
|
517
|
+
),
|
|
518
|
+
output: Optional[Path] = typer.Option(None, "-o", help="Output file."),
|
|
519
|
+
):
|
|
520
|
+
"""
|
|
521
|
+
Fill PDF form fields with values.
|
|
522
|
+
|
|
523
|
+
Example: max pdf form-fill form.pdf -f name="John" -f email="john@example.com"
|
|
524
|
+
"""
|
|
525
|
+
if not target.exists():
|
|
526
|
+
log_error(f"File not found: {target}")
|
|
527
|
+
raise typer.Exit(1)
|
|
528
|
+
|
|
529
|
+
if not output:
|
|
530
|
+
output = target.parent / f"{target.stem}_filled.pdf"
|
|
531
|
+
|
|
532
|
+
field_values = {}
|
|
533
|
+
for f in field:
|
|
534
|
+
if "=" in f:
|
|
535
|
+
key, value = f.split("=", 1)
|
|
536
|
+
field_values[key] = value
|
|
537
|
+
else:
|
|
538
|
+
log_error(f"Invalid field format: {f}. Use fieldname=value")
|
|
539
|
+
raise typer.Exit(1)
|
|
540
|
+
|
|
541
|
+
console.print(f"[cyan]Filling {len(field_values)} fields...[/cyan]")
|
|
542
|
+
|
|
543
|
+
try:
|
|
544
|
+
engine.fill_form(target, output, field_values)
|
|
545
|
+
log_success(f"Filled form saved to: {output}")
|
|
546
|
+
except Exception as e:
|
|
547
|
+
log_error(f"Failed to fill form: {e}")
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
@app.command("form-flatten")
|
|
551
|
+
def flatten_form(
|
|
552
|
+
target: Path = typer.Argument(..., help="PDF form to flatten."),
|
|
553
|
+
output: Optional[Path] = typer.Option(None, "-o", help="Output file."),
|
|
554
|
+
):
|
|
555
|
+
"""
|
|
556
|
+
Flatten PDF form (convert fields to regular content).
|
|
557
|
+
"""
|
|
558
|
+
if not target.exists():
|
|
559
|
+
log_error(f"File not found: {target}")
|
|
560
|
+
raise typer.Exit(1)
|
|
561
|
+
|
|
562
|
+
if not output:
|
|
563
|
+
output = target.parent / f"{target.stem}_flattened.pdf"
|
|
564
|
+
|
|
565
|
+
console.print("[cyan]Flattening form...[/cyan]")
|
|
566
|
+
|
|
567
|
+
try:
|
|
568
|
+
engine.flatten_form(target, output)
|
|
569
|
+
log_success(f"Flattened form saved to: {output}")
|
|
570
|
+
except Exception as e:
|
|
571
|
+
log_error(f"Failed to flatten form: {e}")
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
@app.command("optimize")
|
|
575
|
+
def optimize_pdf(
|
|
576
|
+
target: Path = typer.Argument(..., help="PDF file to optimize."),
|
|
577
|
+
output: Optional[Path] = typer.Option(None, "-o", help="Output file."),
|
|
578
|
+
no_compress: bool = typer.Option(
|
|
579
|
+
False, "--no-compress", help="Skip image compression."
|
|
580
|
+
),
|
|
581
|
+
no_linearize: bool = typer.Option(
|
|
582
|
+
False, "--no-linearize", help="Skip web optimization."
|
|
583
|
+
),
|
|
584
|
+
):
|
|
585
|
+
"""
|
|
586
|
+
Optimize PDF (remove unused objects, compress images, linearize).
|
|
587
|
+
"""
|
|
588
|
+
if not target.exists():
|
|
589
|
+
log_error(f"File not found: {target}")
|
|
590
|
+
raise typer.Exit(1)
|
|
591
|
+
|
|
592
|
+
if not output:
|
|
593
|
+
output = target.parent / f"{target.stem}_optimized.pdf"
|
|
594
|
+
|
|
595
|
+
orig_size = target.stat().st_size
|
|
596
|
+
|
|
597
|
+
console.print("[cyan]Optimizing PDF...[/cyan]")
|
|
598
|
+
|
|
599
|
+
try:
|
|
600
|
+
engine.optimize_pdf(
|
|
601
|
+
target,
|
|
602
|
+
output,
|
|
603
|
+
compress_images=not no_compress,
|
|
604
|
+
linearize=not no_linearize,
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
new_size = output.stat().st_size
|
|
608
|
+
reduction = ((orig_size - new_size) / orig_size) * 100
|
|
609
|
+
|
|
610
|
+
log_success(f"Optimized PDF saved to: {output}")
|
|
611
|
+
console.print(
|
|
612
|
+
f"Size: {format_size(orig_size)} -> [green]{format_size(new_size)}[/green] (-{reduction:.1f}%)"
|
|
613
|
+
)
|
|
614
|
+
except Exception as e:
|
|
615
|
+
log_error(f"Optimization failed: {e}")
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
@app.command("compare")
|
|
619
|
+
def compare_pdfs(
|
|
620
|
+
file1: Path = typer.Argument(..., help="First PDF file."),
|
|
621
|
+
file2: Path = typer.Argument(..., help="Second PDF file."),
|
|
622
|
+
):
|
|
623
|
+
"""
|
|
624
|
+
Compare two PDFs and show differences.
|
|
625
|
+
"""
|
|
626
|
+
if not file1.exists():
|
|
627
|
+
log_error(f"File not found: {file1}")
|
|
628
|
+
raise typer.Exit(1)
|
|
629
|
+
if not file2.exists():
|
|
630
|
+
log_error(f"File not found: {file2}")
|
|
631
|
+
raise typer.Exit(1)
|
|
632
|
+
|
|
633
|
+
console.print(f"[cyan]Comparing {file1.name} vs {file2.name}...[/cyan]")
|
|
634
|
+
|
|
635
|
+
try:
|
|
636
|
+
result = engine.compare_pdfs(file1, file2)
|
|
637
|
+
|
|
638
|
+
if result["pages_equal"] and not result["differences"]:
|
|
639
|
+
console.print("[green]✓ PDFs are identical![/green]")
|
|
640
|
+
else:
|
|
641
|
+
console.print("[yellow]⚠ PDFs have differences:[/yellow]")
|
|
642
|
+
for diff in result["differences"]:
|
|
643
|
+
console.print(f" - {diff}")
|
|
644
|
+
|
|
645
|
+
if result["pages_equal"]:
|
|
646
|
+
console.print("\n[green]Page count and content match.[/green]")
|
|
647
|
+
else:
|
|
648
|
+
console.print("\n[red]PDFs are different.[/red]")
|
|
649
|
+
|
|
650
|
+
except Exception as e:
|
|
651
|
+
log_error(f"Comparison failed: {e}")
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from max_cli.core.system_engine import SystemEngine
|
|
5
|
+
from max_cli.common.logger import console, log_success, log_error
|
|
6
|
+
|
|
7
|
+
app = typer.Typer()
|
|
8
|
+
engine = SystemEngine()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@app.command("share")
|
|
12
|
+
def share_qr(
|
|
13
|
+
data: str = typer.Argument(..., help="Text or URL to convert to QR Code."),
|
|
14
|
+
):
|
|
15
|
+
"""
|
|
16
|
+
Generate an ASCII QR Code in the terminal.
|
|
17
|
+
Useful for sending localhost URLs to your phone.
|
|
18
|
+
"""
|
|
19
|
+
console.print(f"[cyan]Generating QR for:[/cyan] [dim]{data}[/dim]")
|
|
20
|
+
try:
|
|
21
|
+
engine.generate_qr(data)
|
|
22
|
+
except Exception as e:
|
|
23
|
+
log_error(f"QR generation failed: {e}")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.command("paste")
|
|
27
|
+
def paste_image(
|
|
28
|
+
output: Path = typer.Argument(
|
|
29
|
+
Path("clipboard.png"), help="Filename to save the image to."
|
|
30
|
+
),
|
|
31
|
+
):
|
|
32
|
+
"""
|
|
33
|
+
Save the image currently in your clipboard to a file.
|
|
34
|
+
Great for saving screenshots quickly.
|
|
35
|
+
"""
|
|
36
|
+
# Ensure extension
|
|
37
|
+
if not output.suffix:
|
|
38
|
+
output = output.with_suffix(".png")
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
engine.save_clipboard_image(output)
|
|
42
|
+
log_success(f"Image saved to: [bold]{output}[/bold]")
|
|
43
|
+
except ValueError as e:
|
|
44
|
+
console.print(f"[yellow]{e}[/yellow]")
|
|
45
|
+
except Exception as e:
|
|
46
|
+
log_error(f"Failed to save image: {e}")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@app.command("copy")
|
|
50
|
+
def copy_file(
|
|
51
|
+
target: Path = typer.Argument(..., help="Text file to copy to clipboard."),
|
|
52
|
+
):
|
|
53
|
+
"""
|
|
54
|
+
Copy the contents of a text file to your system clipboard.
|
|
55
|
+
"""
|
|
56
|
+
try:
|
|
57
|
+
engine.copy_file_to_clipboard(target)
|
|
58
|
+
log_success(f"Copied [bold]{target.name}[/bold] to clipboard.")
|
|
59
|
+
except Exception as e:
|
|
60
|
+
log_error(str(e))
|