shellscripts 0.23.1__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.
Files changed (45) hide show
  1. shell_scripts/__init__.py +5 -0
  2. shell_scripts/__main__.py +5 -0
  3. shell_scripts/commands/__init__.py +81 -0
  4. shell_scripts/commands/_dc_common.py +138 -0
  5. shell_scripts/commands/ai_install.py +453 -0
  6. shell_scripts/commands/bin_links.py +78 -0
  7. shell_scripts/commands/claude.py +56 -0
  8. shell_scripts/commands/clean.py +98 -0
  9. shell_scripts/commands/codex.py +107 -0
  10. shell_scripts/commands/copilot.py +54 -0
  11. shell_scripts/commands/dicom2jpg.py +98 -0
  12. shell_scripts/commands/dicomviewer.py +83 -0
  13. shell_scripts/commands/diff_cmd.py +50 -0
  14. shell_scripts/commands/doxygen_cmd.py +159 -0
  15. shell_scripts/commands/edit_cmd.py +50 -0
  16. shell_scripts/commands/gemini.py +54 -0
  17. shell_scripts/commands/kiro.py +54 -0
  18. shell_scripts/commands/opencode.py +54 -0
  19. shell_scripts/commands/pdf_crop.py +520 -0
  20. shell_scripts/commands/pdf_merge.py +180 -0
  21. shell_scripts/commands/pdf_split_by_format.py +243 -0
  22. shell_scripts/commands/pdf_split_by_toc.py +197 -0
  23. shell_scripts/commands/pdf_tiler_090.py +61 -0
  24. shell_scripts/commands/pdf_tiler_100.py +61 -0
  25. shell_scripts/commands/pdf_toc_clean.py +175 -0
  26. shell_scripts/commands/pi.py +61 -0
  27. shell_scripts/commands/req_cmd.py +362 -0
  28. shell_scripts/commands/tests_cmd.py +87 -0
  29. shell_scripts/commands/ubuntu_dark_theme.py +64 -0
  30. shell_scripts/commands/venv_cmd.py +82 -0
  31. shell_scripts/commands/video2h264.py +89 -0
  32. shell_scripts/commands/video2h265.py +87 -0
  33. shell_scripts/commands/view_cmd.py +50 -0
  34. shell_scripts/commands/vscode_cmd.py +42 -0
  35. shell_scripts/commands/vsinsider_cmd.py +42 -0
  36. shell_scripts/config.py +328 -0
  37. shell_scripts/core.py +324 -0
  38. shell_scripts/utils.py +455 -0
  39. shell_scripts/version_check.py +323 -0
  40. shellscripts-0.23.1.dist-info/METADATA +310 -0
  41. shellscripts-0.23.1.dist-info/RECORD +45 -0
  42. shellscripts-0.23.1.dist-info/WHEEL +5 -0
  43. shellscripts-0.23.1.dist-info/entry_points.txt +3 -0
  44. shellscripts-0.23.1.dist-info/licenses/LICENSE +674 -0
  45. shellscripts-0.23.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,520 @@
1
+ #!/usr/bin/env python3
2
+ import os
3
+ import sys
4
+ import re
5
+ import subprocess
6
+ import time
7
+ from typing import NoReturn
8
+
9
+ from shell_scripts.utils import (
10
+ require_commands,
11
+ color_enabled,
12
+ RESET,
13
+ BOLD,
14
+ BRIGHT_RED,
15
+ BRIGHT_GREEN,
16
+ BRIGHT_YELLOW,
17
+ BRIGHT_CYAN,
18
+ BRIGHT_WHITE,
19
+ WHITE,
20
+ c,
21
+ )
22
+
23
+ PROGRAM = "shellscripts"
24
+ DESCRIPTION = "Crop PDF pages using Ghostscript with auto or manual bounding box."
25
+
26
+ LABEL_WIDTH = 19
27
+ TERM_COLS = 80
28
+
29
+
30
+ def _init_ui():
31
+ global TERM_COLS
32
+ try:
33
+ TERM_COLS = os.get_terminal_size().columns
34
+ except OSError:
35
+ TERM_COLS = 80
36
+ TERM_COLS = max(60, min(120, TERM_COLS))
37
+
38
+
39
+ def _use_unicode():
40
+ lang = os.environ.get(
41
+ "LC_ALL", os.environ.get("LC_CTYPE", os.environ.get("LANG", ""))
42
+ )
43
+ return "UTF-8" in lang.upper() or "utf8" in lang.lower() or "utf-8" in lang.lower()
44
+
45
+
46
+ def _icons():
47
+ if _use_unicode():
48
+ return {
49
+ "thick": "\u2550",
50
+ "thin": "\u2500",
51
+ "section": "\u25b6",
52
+ "done": "\u2714",
53
+ "warn": "\u26a0",
54
+ "bbox": "\u25fc",
55
+ }
56
+ return {
57
+ "thick": "=",
58
+ "thin": "-",
59
+ "section": ">",
60
+ "done": "OK",
61
+ "warn": "!!",
62
+ "bbox": "#",
63
+ }
64
+
65
+
66
+ def print_help(version):
67
+ print(f"Usage: {PROGRAM} pdf-crop [options] ({version})")
68
+ print()
69
+ print("pdf-crop options:")
70
+ print(" --in <file> - Input PDF file (required).")
71
+ print(" --out <file> - Output PDF file (default: crop-<input>).")
72
+ print(' --bbox "L B R T" - Manual bounding box in PDF points.')
73
+ print(' --margins "L T R B" - Margin adjustments in PDF points.')
74
+ print(" --analyze-pages <range> - Pages to use for bbox estimation.")
75
+ print(" --pages <range> - Page range to export (N, N-, -N, N-M).")
76
+ print(" --help - Show this help message.")
77
+
78
+
79
+ def _fmt(n):
80
+ return f"{float(n):.2f}"
81
+
82
+
83
+ def _fmt_quad(a, b, cc, d):
84
+ return f"{_fmt(a)} {_fmt(b)} {_fmt(cc)} {_fmt(d)}"
85
+
86
+
87
+ def _fmt_size(w, h):
88
+ return f"{_fmt(w)} x {_fmt(h)} pt"
89
+
90
+
91
+ def _fmt_bbox_line(left, bottom, right, top):
92
+ return f"L={_fmt(left)} B={_fmt(bottom)} R={_fmt(right)} T={_fmt(top)}"
93
+
94
+
95
+ def _hr(char):
96
+ width = max(50, TERM_COLS - 2)
97
+ print(c(char * width, BRIGHT_WHITE))
98
+
99
+
100
+ def _section(title):
101
+ icons = _icons()
102
+ _hr(icons["thick"])
103
+ print(c(f"{icons['section']} {title}", BRIGHT_YELLOW + BOLD))
104
+ _hr(icons["thin"])
105
+
106
+
107
+ def _kv(key, value):
108
+ if color_enabled():
109
+ print(
110
+ f" {BRIGHT_CYAN}{BOLD}{key:<{LABEL_WIDTH}}{RESET} {BRIGHT_WHITE}{BOLD}:{RESET} {value}"
111
+ )
112
+ else:
113
+ print(f" {key:<{LABEL_WIDTH}} : {value}")
114
+
115
+
116
+ def _die(msg) -> NoReturn:
117
+ print(c(f"Error: {msg}", BRIGHT_RED + BOLD), file=sys.stderr)
118
+ sys.exit(1)
119
+
120
+
121
+ def _warn(msg):
122
+ icons = _icons()
123
+ print(c(f"{icons['warn']} {msg}", BRIGHT_YELLOW + BOLD), file=sys.stderr)
124
+
125
+
126
+ def _parse_page_range(spec, max_pages, opt_name):
127
+ if not spec:
128
+ _die(f"{opt_name} requires a value")
129
+
130
+ start = 0
131
+ end = 0
132
+ match = re.fullmatch(r"(\d+)", spec)
133
+ if match:
134
+ start = int(match.group(1))
135
+ end = start
136
+ else:
137
+ match = re.fullmatch(r"(\d+)-", spec)
138
+ if match:
139
+ start = int(match.group(1))
140
+ end = max_pages
141
+ else:
142
+ match = re.fullmatch(r"-(\d+)", spec)
143
+ if match:
144
+ start = 1
145
+ end = int(match.group(1))
146
+ else:
147
+ match = re.fullmatch(r"(\d+)-(\d+)", spec)
148
+ if match:
149
+ start = int(match.group(1))
150
+ end = int(match.group(2))
151
+ else:
152
+ _die(f"{opt_name} must be: N, N-, -N, or N-M")
153
+
154
+ if start < 1:
155
+ _die(f"{opt_name} start must be >= 1")
156
+ if end < start:
157
+ _die(f"{opt_name} invalid: end < start")
158
+ if start > max_pages:
159
+ _die(f"{opt_name} starts beyond document pages ({max_pages})")
160
+ if end > max_pages:
161
+ _die(f"{opt_name} ends beyond document pages ({max_pages})")
162
+
163
+ return start, end
164
+
165
+
166
+ def _get_page_count(pdf):
167
+ r = subprocess.run(["pdfinfo", pdf], capture_output=True, text=True)
168
+ for line in r.stdout.splitlines():
169
+ if line.startswith("Pages:"):
170
+ return int(line.split(":")[1].strip())
171
+ return None
172
+
173
+
174
+ def _get_mediabox(pdf, page=1):
175
+ r = subprocess.run(
176
+ ["pdfinfo", "-f", str(page), "-l", str(page), "-box", pdf],
177
+ capture_output=True,
178
+ text=True,
179
+ )
180
+ for line in r.stdout.splitlines():
181
+ if "MediaBox:" in line:
182
+ parts = line.split()
183
+ return tuple(float(x) for x in parts[-4:])
184
+ return None
185
+
186
+
187
+ def _compute_auto_bbox(pdf, first_page, last_page):
188
+ r = subprocess.run(
189
+ [
190
+ "gs",
191
+ "-dSAFER",
192
+ "-dNOPAUSE",
193
+ "-dBATCH",
194
+ "-sDEVICE=bbox",
195
+ f"-dFirstPage={first_page}",
196
+ f"-dLastPage={last_page}",
197
+ "-f",
198
+ pdf,
199
+ ],
200
+ capture_output=True,
201
+ text=True,
202
+ )
203
+ output = r.stderr + "\n" + r.stdout
204
+ minx = miny = float("inf")
205
+ maxx = maxy = float("-inf")
206
+ found = False
207
+ for line in output.splitlines():
208
+ if line.startswith("%%HiResBoundingBox:"):
209
+ parts = line.split()
210
+ llx, lly, urx, ury = (
211
+ float(parts[1]),
212
+ float(parts[2]),
213
+ float(parts[3]),
214
+ float(parts[4]),
215
+ )
216
+ if urx <= llx or ury <= lly:
217
+ continue
218
+ if not found:
219
+ minx, miny, maxx, maxy = llx, lly, urx, ury
220
+ found = True
221
+ else:
222
+ minx = min(minx, llx)
223
+ miny = min(miny, lly)
224
+ maxx = max(maxx, urx)
225
+ maxy = max(maxy, ury)
226
+ if not found:
227
+ return None
228
+ return minx, miny, maxx, maxy
229
+
230
+
231
+ def _render_progress(current, total, label):
232
+ is_tty = sys.stdout.isatty()
233
+ bar_w = max(20, min(42, TERM_COLS - 42))
234
+ pct = int(current * 100 / total) if total > 0 else 0
235
+ filled = int(current * bar_w / total) if total > 0 else 0
236
+ pct = min(pct, 100)
237
+ filled = min(filled, bar_w)
238
+
239
+ if not is_tty:
240
+ print(f" {label:<{LABEL_WIDTH}} : {pct:3d}% ({current}/{total})")
241
+ return
242
+
243
+ if current >= total:
244
+ body = "=" * bar_w
245
+ tail = ""
246
+ empty = ""
247
+ elif filled <= 0:
248
+ body = tail = ""
249
+ empty = "-" * bar_w
250
+ else:
251
+ body = "=" * max(0, filled - 1)
252
+ tail = ">"
253
+ empty = "-" * (bar_w - filled)
254
+
255
+ if color_enabled():
256
+ bar = f"{BRIGHT_GREEN}{BOLD}{body}{BRIGHT_YELLOW}{BOLD}{tail}{WHITE}{empty}"
257
+ sys.stdout.write(
258
+ f"\r\033[2K {BRIGHT_CYAN}{BOLD}{label:<{LABEL_WIDTH}}{RESET}"
259
+ f" {BRIGHT_WHITE}{BOLD}:{RESET}"
260
+ f" {BRIGHT_WHITE}[{bar}{RESET}{BRIGHT_WHITE}]{RESET}"
261
+ f" {BRIGHT_YELLOW}{BOLD}{pct:3d}%{RESET}"
262
+ f" {BRIGHT_WHITE}{current}/{total}{RESET}"
263
+ )
264
+ else:
265
+ sys.stdout.write(
266
+ f"\r {label:<{LABEL_WIDTH}} : [{body}{tail}{empty}] {pct:3d}% {current}/{total}"
267
+ )
268
+ sys.stdout.flush()
269
+
270
+
271
+ def _convert_pdf_with_progress(input_f, output_f, first, last, cw, ch, cl, cb, total):
272
+ _render_progress(0, total, "Progress")
273
+
274
+ result = subprocess.run(
275
+ [
276
+ "gs",
277
+ "-dSAFER",
278
+ "-dNOPAUSE",
279
+ "-dBATCH",
280
+ "-sDEVICE=pdfwrite",
281
+ "-dAutoRotatePages=/None",
282
+ "-dFIXEDMEDIA",
283
+ "-dModifiesPageSize=true",
284
+ f"-dDEVICEWIDTHPOINTS={cw}",
285
+ f"-dDEVICEHEIGHTPOINTS={ch}",
286
+ f"-dFirstPage={first}",
287
+ f"-dLastPage={last}",
288
+ "-o",
289
+ output_f,
290
+ "-c",
291
+ f"<</BeginPage{{0 0 {cw} {ch} rectclip -{cl} -{cb} translate}}>> setpagedevice",
292
+ "-f",
293
+ input_f,
294
+ ],
295
+ stdout=subprocess.PIPE,
296
+ stderr=subprocess.STDOUT,
297
+ text=True,
298
+ )
299
+
300
+ count = 0
301
+ stdout_stream = result.stdout or ""
302
+ for line in stdout_stream.splitlines():
303
+ if re.match(r"^Page\s+\d+\s*$", line):
304
+ count += 1
305
+ _render_progress(min(count, total), total, "Progress")
306
+
307
+ if sys.stdout.isatty():
308
+ sys.stdout.write("\r\033[2K")
309
+ sys.stdout.flush()
310
+
311
+ return result.returncode
312
+
313
+
314
+ def run(args):
315
+ _init_ui()
316
+ require_commands("gs", "pdfinfo")
317
+
318
+ input_file = out_file = bbox_custom = export_range = analyze_pages_spec = ""
319
+ margleft = margtop = margright = margbot = 0.0
320
+ default_analyze_pages = 10
321
+
322
+ i = 0
323
+ while i < len(args):
324
+ a = args[i]
325
+ if a == "--in":
326
+ input_file = args[i + 1]
327
+ i += 2
328
+ elif a == "--out":
329
+ out_file = args[i + 1]
330
+ i += 2
331
+ elif a == "--bbox":
332
+ bbox_custom = args[i + 1]
333
+ i += 2
334
+ elif a == "--margins":
335
+ parts = args[i + 1].split()
336
+ if len(parts) != 4:
337
+ _die("--margins requires 4 values: left top right bot")
338
+ margleft, margtop, margright, margbot = (float(x) for x in parts)
339
+ i += 2
340
+ elif a == "--analyze-pages":
341
+ analyze_pages_spec = args[i + 1]
342
+ i += 2
343
+ elif a == "--pages":
344
+ export_range = args[i + 1]
345
+ i += 2
346
+ elif a in ("-h", "--help"):
347
+ print_help("")
348
+ return 0
349
+ else:
350
+ _die(f"Unknown option: {a}")
351
+
352
+ if not input_file:
353
+ print_help("")
354
+ return 1
355
+ if not os.path.isfile(input_file):
356
+ _die(f"File not found: {input_file}")
357
+
358
+ if not out_file:
359
+ out_file = f"crop-{os.path.basename(input_file)}"
360
+
361
+ page_count = _get_page_count(input_file)
362
+ if page_count is None:
363
+ _die("Cannot read page count")
364
+ npages = page_count
365
+
366
+ page_mediabox = _get_mediabox(input_file, 1)
367
+ if page_mediabox is None:
368
+ _die("Cannot read MediaBox of page 1")
369
+ page_llx, page_lly, page_urx, page_ury = page_mediabox
370
+ orig_w = page_urx - page_llx
371
+ orig_h = page_ury - page_lly
372
+
373
+ export_first, export_last = 1, npages
374
+ if export_range:
375
+ export_first, export_last = _parse_page_range(export_range, npages, "--pages")
376
+ export_total = export_last - export_first + 1
377
+
378
+ if analyze_pages_spec:
379
+ analyze_first, analyze_last = _parse_page_range(
380
+ analyze_pages_spec, npages, "--analyze-pages"
381
+ )
382
+ else:
383
+ analyze_first = export_first
384
+ analyze_last = min(export_last, export_first + default_analyze_pages - 1)
385
+ analyze_total = analyze_last - analyze_first + 1
386
+
387
+ if bbox_custom:
388
+ parts = bbox_custom.split()
389
+ if len(parts) != 4:
390
+ _die("--bbox requires 4 values: left bot right top")
391
+ left, bot, right, top = (float(x) for x in parts)
392
+ bbox_source = "manual"
393
+ if analyze_pages_spec:
394
+ _warn("--analyze-pages ignored because --bbox was specified")
395
+ else:
396
+ result = _compute_auto_bbox(input_file, analyze_first, analyze_last)
397
+ if result is None:
398
+ _warn("No content detected; using full MediaBox")
399
+ left, bot, right, top = page_llx, page_lly, page_urx, page_ury
400
+ else:
401
+ left, bot, right, top = result
402
+ bbox_source = "automatic"
403
+
404
+ raw_left, raw_bot, raw_right, raw_top = left, bot, right, top
405
+
406
+ left += margleft
407
+ bot += margbot
408
+ right -= margright
409
+ top -= margtop
410
+
411
+ left = max(left, page_llx)
412
+ bot = max(bot, page_lly)
413
+ right = min(right, page_urx)
414
+ top = min(top, page_ury)
415
+
416
+ if left < page_llx:
417
+ _die("left is outside MediaBox")
418
+ if bot < page_lly:
419
+ _die("bot is outside MediaBox")
420
+ if left >= right:
421
+ _die("Invalid bbox: left must be < right")
422
+ if bot >= top:
423
+ _die("Invalid bbox: bot must be < top")
424
+ if right > page_urx:
425
+ _die("right exceeds MediaBox")
426
+ if top > page_ury:
427
+ _die("top exceeds MediaBox")
428
+
429
+ crop_w = right - left
430
+ crop_h = top - bot
431
+ start_time = time.time()
432
+
433
+ icons = _icons()
434
+ _section("PDF Conversion")
435
+ _kv("Input", c(input_file, BRIGHT_WHITE))
436
+ _kv("Output", c(out_file, BRIGHT_WHITE))
437
+ _kv("Document pages", c(str(npages), BRIGHT_WHITE))
438
+ _kv(
439
+ "Export pages",
440
+ f"{c(f'{export_first}-{export_last}', BRIGHT_WHITE)} {c(f'[{export_total} pages]', BRIGHT_YELLOW)}",
441
+ )
442
+ _kv(
443
+ "Analysis pages",
444
+ f"{c(f'{analyze_first}-{analyze_last}', BRIGHT_WHITE)} {c(f'[{analyze_total} pages]', BRIGHT_YELLOW)}",
445
+ )
446
+ _kv(
447
+ "Page 1 MediaBox",
448
+ c(_fmt_quad(page_llx, page_lly, page_urx, page_ury), BRIGHT_WHITE),
449
+ )
450
+ _kv("Page 1 size", c(_fmt_size(orig_w, orig_h), BRIGHT_WHITE))
451
+ bbox_str = c(
452
+ f"{icons['bbox']} {_fmt_bbox_line(raw_left, raw_bot, raw_right, raw_top)}",
453
+ BRIGHT_YELLOW + BOLD,
454
+ )
455
+ _kv("BBox detected", f"{bbox_str} {c(f'[{bbox_source}]', BRIGHT_WHITE)}")
456
+ if any(v != 0 for v in (margleft, margtop, margright, margbot)):
457
+ _kv(
458
+ "Margins applied",
459
+ c(
460
+ f"L={_fmt(margleft)} T={_fmt(margtop)} R={_fmt(margright)} B={_fmt(margbot)}",
461
+ BRIGHT_WHITE,
462
+ ),
463
+ )
464
+ _kv("Final crop", c(_fmt_size(crop_w, crop_h), BRIGHT_GREEN + BOLD))
465
+ print()
466
+
467
+ rc = _convert_pdf_with_progress(
468
+ input_file,
469
+ out_file,
470
+ export_first,
471
+ export_last,
472
+ crop_w,
473
+ crop_h,
474
+ left,
475
+ bot,
476
+ export_total,
477
+ )
478
+ if rc != 0:
479
+ _die("Ghostscript conversion failed")
480
+
481
+ out_mbox = _get_mediabox(out_file, 1)
482
+ out_media_w = out_media_h = 0
483
+ if out_mbox:
484
+ out_media_w = out_mbox[2] - out_mbox[0]
485
+ out_media_h = out_mbox[3] - out_mbox[1]
486
+
487
+ r = subprocess.run(
488
+ ["pdfinfo", "-f", "1", "-l", "1", "-box", out_file],
489
+ capture_output=True,
490
+ text=True,
491
+ )
492
+ out_crop_w = out_crop_h = 0
493
+ for line in r.stdout.splitlines():
494
+ if "CropBox:" in line:
495
+ parts = line.split()
496
+ vals = [float(x) for x in parts[-4:]]
497
+ out_crop_w = vals[2] - vals[0]
498
+ out_crop_h = vals[3] - vals[1]
499
+ break
500
+
501
+ elapsed = int(time.time() - start_time)
502
+
503
+ _section("Execution summary")
504
+ _kv("Status", c(f"{icons['done']} Completed", BRIGHT_GREEN + BOLD))
505
+ _kv("Input", c(input_file, BRIGHT_WHITE))
506
+ _kv("Output", c(out_file, BRIGHT_WHITE))
507
+ _kv(
508
+ "Pages converted",
509
+ f"{c(str(export_total), BRIGHT_WHITE)} {c(f'[{export_first}-{export_last}]', BRIGHT_YELLOW)}",
510
+ )
511
+ _kv(
512
+ "BBox used",
513
+ f"{c(_fmt_bbox_line(left, bot, right, top), BRIGHT_YELLOW + BOLD)} {c(f'[{bbox_source}]', BRIGHT_WHITE)}",
514
+ )
515
+ _kv("Output MediaBox", c(_fmt_size(out_media_w, out_media_h), BRIGHT_WHITE))
516
+ _kv("Output CropBox", c(_fmt_size(out_crop_w, out_crop_h), BRIGHT_WHITE))
517
+ _kv("Duration", c(f"{elapsed}s", BRIGHT_WHITE))
518
+ _hr(_icons()["thick"])
519
+
520
+ return 0
@@ -0,0 +1,180 @@
1
+ #!/usr/bin/env python3
2
+ import os
3
+ import subprocess
4
+ import tempfile
5
+
6
+ from shell_scripts.utils import require_commands, print_error
7
+
8
+ PROGRAM = "shellscripts"
9
+ DESCRIPTION = "Merge multiple PDF files preserving table of contents."
10
+
11
+
12
+ def print_help(version):
13
+ print(
14
+ f"Usage: {PROGRAM} pdf-merge [-o output.pdf] file1.pdf file2.pdf ... ({version})"
15
+ )
16
+ print()
17
+ print("pdf-merge options:")
18
+ print(" -o, --output <file> - Output filename (default: documento_unito.pdf).")
19
+ print(" <files...> - Input PDF files to merge.")
20
+ print(" --help - Show this help message.")
21
+
22
+
23
+ def _parse_bookmarks(dump_file):
24
+ entries = []
25
+ level = title = page = ""
26
+ with open(dump_file) as f:
27
+ for line in f:
28
+ line = line.rstrip("\n")
29
+ if line == "BookmarkBegin":
30
+ if level and page:
31
+ entries.append((level, title, page))
32
+ level = title = page = ""
33
+ elif line.startswith("BookmarkTitle: "):
34
+ title = line[15:]
35
+ elif line.startswith("BookmarkLevel: "):
36
+ level = line.split()[1]
37
+ elif line.startswith("BookmarkPageNumber: "):
38
+ page = line.split()[1]
39
+ if level and page:
40
+ entries.append((level, title, page))
41
+ return entries
42
+
43
+
44
+ def _get_num_pages(dump_file):
45
+ with open(dump_file) as f:
46
+ for line in f:
47
+ if line.startswith("NumberOfPages:"):
48
+ return int(line.split()[1])
49
+ return None
50
+
51
+
52
+ def run(args):
53
+ require_commands("pdftk", "qpdf")
54
+
55
+ output_file = "documento_unito.pdf"
56
+ input_files = []
57
+
58
+ i = 0
59
+ while i < len(args):
60
+ if args[i] in ("-o", "--output"):
61
+ output_file = args[i + 1]
62
+ i += 2
63
+ else:
64
+ input_files.append(args[i])
65
+ i += 1
66
+
67
+ if not input_files:
68
+ print_error("No PDF files specified.")
69
+ print_help("")
70
+ return 1
71
+
72
+ for f in input_files:
73
+ if not os.path.isfile(f):
74
+ print_error(f"File not found: {f}")
75
+ return 1
76
+
77
+ with tempfile.TemporaryDirectory() as tmp_dir:
78
+ print(f"Preparing {len(input_files)} files for merge...")
79
+ print("(Temporarily disabling compression to preserve TOC)")
80
+
81
+ decompressed = []
82
+ for idx, f in enumerate(input_files):
83
+ safe_name = f"{idx:04d}_part.pdf"
84
+ tmp_file = os.path.join(tmp_dir, safe_name)
85
+ r = subprocess.run(["qpdf", "--object-streams=disable", f, tmp_file])
86
+ if r.returncode != 0:
87
+ print_error(f"Error preparing file: {f}")
88
+ return 1
89
+ decompressed.append(tmp_file)
90
+
91
+ merged_raw = os.path.join(tmp_dir, "merged_unoptimized.pdf")
92
+ r = subprocess.run(["pdftk"] + decompressed + ["cat", "output", merged_raw])
93
+ if r.returncode != 0:
94
+ print_error("Error during merge with pdftk.")
95
+ return 1
96
+
97
+ merge_input = merged_raw
98
+ bookmarks_file = os.path.join(tmp_dir, "merged_bookmarks.info")
99
+ page_offset = 0
100
+ seen = set()
101
+ all_bookmarks = []
102
+
103
+ for f in decompressed:
104
+ dump_file = os.path.join(
105
+ tmp_dir, os.path.basename(f).replace(".pdf", ".dump.txt")
106
+ )
107
+ r = subprocess.run(
108
+ ["pdftk", f, "dump_data_utf8", "output", dump_file],
109
+ capture_output=True,
110
+ )
111
+ if r.returncode != 0:
112
+ subprocess.run(
113
+ ["pdftk", f, "dump_data", "output", dump_file],
114
+ capture_output=True,
115
+ )
116
+
117
+ file_pages = _get_num_pages(dump_file)
118
+ if file_pages is None:
119
+ print_error("Unable to read NumberOfPages while rebuilding TOC.")
120
+ return 1
121
+
122
+ entries = _parse_bookmarks(dump_file)
123
+ for level, title, page in entries:
124
+ adj_page = int(page) + page_offset
125
+ key = f"{level}|{adj_page}|{title}"
126
+ if key not in seen:
127
+ seen.add(key)
128
+ all_bookmarks.append((level, title, str(adj_page)))
129
+
130
+ page_offset += file_pages
131
+
132
+ with open(bookmarks_file, "w") as bf:
133
+ for level, title, page in all_bookmarks:
134
+ bf.write("BookmarkBegin\n")
135
+ bf.write(f"BookmarkTitle: {title}\n")
136
+ bf.write(f"BookmarkLevel: {level}\n")
137
+ bf.write(f"BookmarkPageNumber: {page}\n")
138
+
139
+ if os.path.getsize(bookmarks_file) > 0:
140
+ merged_bm = os.path.join(tmp_dir, "merged_with_bookmarks.pdf")
141
+ r = subprocess.run(
142
+ [
143
+ "pdftk",
144
+ merge_input,
145
+ "update_info_utf8",
146
+ bookmarks_file,
147
+ "output",
148
+ merged_bm,
149
+ ],
150
+ capture_output=True,
151
+ )
152
+ if r.returncode == 0:
153
+ merge_input = merged_bm
154
+ else:
155
+ r = subprocess.run(
156
+ [
157
+ "pdftk",
158
+ merge_input,
159
+ "update_info",
160
+ bookmarks_file,
161
+ "output",
162
+ merged_bm,
163
+ ],
164
+ capture_output=True,
165
+ )
166
+ if r.returncode == 0:
167
+ merge_input = merged_bm
168
+ else:
169
+ print_error("Unable to apply rebuilt TOC to merged PDF.")
170
+ return 1
171
+
172
+ print("Optimizing and compressing final file...")
173
+ r = subprocess.run(["qpdf", "--linearize", merge_input, output_file])
174
+ if r.returncode != 0:
175
+ print_error("Error during final optimization.")
176
+ return 1
177
+
178
+ print("---")
179
+ print(f"Merge completed successfully with TOC preserved! Saved as: {output_file}")
180
+ return 0