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.
- shell_scripts/__init__.py +5 -0
- shell_scripts/__main__.py +5 -0
- shell_scripts/commands/__init__.py +81 -0
- shell_scripts/commands/_dc_common.py +138 -0
- shell_scripts/commands/ai_install.py +453 -0
- shell_scripts/commands/bin_links.py +78 -0
- shell_scripts/commands/claude.py +56 -0
- shell_scripts/commands/clean.py +98 -0
- shell_scripts/commands/codex.py +107 -0
- shell_scripts/commands/copilot.py +54 -0
- shell_scripts/commands/dicom2jpg.py +98 -0
- shell_scripts/commands/dicomviewer.py +83 -0
- shell_scripts/commands/diff_cmd.py +50 -0
- shell_scripts/commands/doxygen_cmd.py +159 -0
- shell_scripts/commands/edit_cmd.py +50 -0
- shell_scripts/commands/gemini.py +54 -0
- shell_scripts/commands/kiro.py +54 -0
- shell_scripts/commands/opencode.py +54 -0
- shell_scripts/commands/pdf_crop.py +520 -0
- shell_scripts/commands/pdf_merge.py +180 -0
- shell_scripts/commands/pdf_split_by_format.py +243 -0
- shell_scripts/commands/pdf_split_by_toc.py +197 -0
- shell_scripts/commands/pdf_tiler_090.py +61 -0
- shell_scripts/commands/pdf_tiler_100.py +61 -0
- shell_scripts/commands/pdf_toc_clean.py +175 -0
- shell_scripts/commands/pi.py +61 -0
- shell_scripts/commands/req_cmd.py +362 -0
- shell_scripts/commands/tests_cmd.py +87 -0
- shell_scripts/commands/ubuntu_dark_theme.py +64 -0
- shell_scripts/commands/venv_cmd.py +82 -0
- shell_scripts/commands/video2h264.py +89 -0
- shell_scripts/commands/video2h265.py +87 -0
- shell_scripts/commands/view_cmd.py +50 -0
- shell_scripts/commands/vscode_cmd.py +42 -0
- shell_scripts/commands/vsinsider_cmd.py +42 -0
- shell_scripts/config.py +328 -0
- shell_scripts/core.py +324 -0
- shell_scripts/utils.py +455 -0
- shell_scripts/version_check.py +323 -0
- shellscripts-0.23.1.dist-info/METADATA +310 -0
- shellscripts-0.23.1.dist-info/RECORD +45 -0
- shellscripts-0.23.1.dist-info/WHEEL +5 -0
- shellscripts-0.23.1.dist-info/entry_points.txt +3 -0
- shellscripts-0.23.1.dist-info/licenses/LICENSE +674 -0
- 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
|