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.
@@ -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))