-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathpdfer.py
524 lines (455 loc) · 24.1 KB
/
pdfer.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
import importlib
import os
import re
import shutil
import sys
import textwrap
from datetime import datetime
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import inquirer
import PyPDF2
def is_int(s: str) -> bool:
"""Проверяет, является ли строка `s` целым числом"""
return bool(re.match(r'^-?\d+$', s))
def clear():
"""Очищает консоль"""
os.system('cls' if os.name == 'nt' else 'clear')
def import_or_install_module(module_name: str):
"""Импортирует модуль и предустанавливает его, если он не установлен"""
try:
globals()[module_name.replace('-', '_')] = importlib.import_module(module_name.replace('-', '_'))
except ModuleNotFoundError:
print(f'{module_name} не установлен в Python. Установка...')
if os.name == 'nt':
os.system(f'py -m pip install {module_name} > {os.devnull} 2>&1')
else:
os.system(f'python3 -m pip install --break-system-packages {module_name} > {os.devnull} 2>&1')
globals()[module_name.replace('-', '_')] = importlib.import_module(module_name.replace('-', '_'))
clear()
import_or_install_module('inquirer')
from inquirer.errors import ValidationError # noqa: E402
import_or_install_module('prompt_toolkit')
from prompt_toolkit import PromptSession # noqa: E402
from prompt_toolkit.completion import WordCompleter # noqa: E402
from prompt_toolkit.validation import Validator # noqa: E402
import_or_install_module('rich')
from rich.console import Console # noqa: E402
import_or_install_module('PyPDF2')
console = Console()
session = PromptSession()
class PDFer:
"""Класс, формирующий основной функционал программы"""
@staticmethod
def extract_page_range(input_pdf: str, start_page: int, end_page: int = -1, output_pdf: str = '') -> str:
"""Извлекает страницы из PDF-файла `input_pdf` в диапазоне от `start_page`
до `end_page` включительно и сохраняет их в новый PDF-файл\n
Если `end_page` не указана, то извлекается только одна страница `start_page`"""
with open(input_pdf, 'rb') as file:
reader = PyPDF2.PdfReader(file)
writer = PyPDF2.PdfWriter()
if end_page == -1:
end_page = start_page
order, shift = (1, 0) if start_page <= end_page else (-1, -2)
start_page = max(0, start_page - 1)
end_page = min(end_page, len(reader.pages)) + shift
for page_num in range(start_page, end_page, order):
writer.add_page(reader.pages[page_num])
output_pdf = (output_pdf or input_pdf.removesuffix('.pdf')) + (
(f'_{start_page + 1}' if start_page == end_page else f'_{start_page + 1}-{end_page}') + ' [PDFer].pdf'
)
with open(output_pdf, 'wb') as output_file:
writer.write(output_file)
return output_pdf
@staticmethod
def parse_page_ranges(page_ranges_str: str):
"""Парсит строку `page_ranges_str` с диапазонами страниц в формате '1-5, 8, 11-13'"""
page_ranges = []
for part in page_ranges_str.replace(' ', '').split(','):
if '-' in part:
start, end = map(int, part.split('-'))
page_ranges.append([start, end])
else:
page_ranges.append([int(part)])
return page_ranges
@staticmethod
def merge_pdfs(input_pdfs: list[str], output_pdf: str):
"""Склеивает несколько PDF-файлов `input_pdfs` в один PDF-файл `output_pdf`"""
writer = PyPDF2.PdfWriter()
for input_pdf in input_pdfs:
with open(input_pdf, 'rb') as file:
reader = PyPDF2.PdfReader(file)
for page_num in range(len(reader.pages)):
writer.add_page(reader.pages[page_num])
with open(output_pdf, 'wb') as output_file:
writer.write(output_file)
class Validators:
"""Класс, содержащий валидаторы для полей ввода"""
@staticmethod
def is_to_exit(x: str) -> bool:
return x in COMMANDS['exit']
int_ = Validator.from_callable(lambda x: is_int(x) or Validators.is_to_exit(x), error_message='Введи только число!')
pdf = lambda pass_enter: Validator.from_callable(
lambda x: x.endswith('.pdf')
or x.endswith('.pdf"')
or Validators.is_to_exit(x)
or (x == '' if pass_enter else False),
error_message='Файл должен быть PDF-файлом!',
)
range_ = Validator.from_callable(
lambda x: all([re.match(r'^\s*\d+(\s*-\s*\d+)?\s*$', i) for i in x.replace(' ', '').split(',')])
or Validators.is_to_exit(x),
error_message='Введи через запятую только диапазоны через дефис и числа!',
)
@staticmethod
def menu(_, current):
if str(current) == ' ':
raise ValidationError('', reason='Я всего лишь разделитель... Не трогай меня :)')
return True
class Interface:
"""Класс, формирующий интерфейс программы"""
last_option = None
@staticmethod
def draw_header(full=False, compact=False):
"""Очищает консоль и отрисовывает хедер программы в консоли"""
columns = shutil.get_terminal_size().columns
clear()
console.print('[blue]┌' + '─' * (columns - 2) + '┐')
if not compact:
console.print('[blue]│' + ' ' * (columns - 2) + '│')
console.print('[blue]│[/blue][red]' + 'PDFer'.center(columns - 2) + '[/red][blue]│')
if not compact:
if full:
console.print('[blue]│[/blue]' + 'c любовью от snowlue 💙'.center(columns - 3) + '[blue]│')
console.print('[blue]│' + ' ' * (columns - 2) + '│')
console.print(
'[blue]│[/blue]'
+ 'Никогда ещё работа с PDF не была настолько простой и быстрой!'.center(columns - 2)
+ '[blue]│'
)
console.print('[blue]│' + ' ' * (columns - 2) + '│')
console.print('[blue]└' + '─' * (columns - 2) + '┘' + ('' if compact else '\n'))
@staticmethod
def override_keyboard_interrupt(func):
"""Декоратор для перехвата KeyboardInterrupt"""
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except KeyboardInterrupt:
return Interface.start()
return wrapper
@staticmethod
def start():
"""Запускает интерфейс программы"""
Interface.draw_header(full=True)
questions = [
inquirer.List(
'choice',
message='Выбери действие',
choices=MENU,
default=Interface.last_option,
validate=Validators.menu, # type: ignore
carousel=True,
)
]
answers = inquirer.prompt(questions)
try:
Interface.last_option = answers['choice']
ACTIONS_MATRIX[answers['choice']]['action']()
except TypeError:
return Interface.draw_exit()
@staticmethod
def get_pdf_file(pass_enter: bool = False, prompt_text: str = '', completer_list: set = set()) -> str | None:
"""Интерфейс для получения имени PDF-файла\n
При `pass_enter = True` валидатор пропускает пустую строку (в случае если был нажат Enter)"""
files = (
list(completer_list)
or list(set(file for file in os.listdir() if os.path.isfile(file) and file.endswith('.pdf')))
or [
'в запущенной папке нет PDF-файлов',
'указывай полный путь',
]
)
completer = WordCompleter(files)
input_pdf = session.prompt(
prompt_text or 'Введи имя входного PDF-файла: ',
completer=completer,
validator=Validators.pdf(pass_enter),
mouse_support=True,
)
if input_pdf in COMMANDS['exit']:
return 'exit' if pass_enter else None
return input_pdf.removeprefix('"').removesuffix('"')
@override_keyboard_interrupt
@staticmethod
def extract_many():
"""Интерфейс для извлечения набора страниц из PDF-файла"""
Interface.draw_header()
input_pdf = Interface.get_pdf_file()
if not input_pdf:
return Interface.start()
pages = session.prompt('Введи страницы: ', completer=WordCompleter([]), validator=Validators.range_)
if pages in COMMANDS['exit']:
return Interface.start()
try:
os.mkdir('temp')
except FileExistsError:
shutil.rmtree('temp')
os.mkdir('temp')
files: list[str] = []
for page_range in PDFer.parse_page_ranges(pages):
files.append(
PDFer.extract_page_range(input_pdf, *page_range, output_pdf=f'temp/{os.path.basename(input_pdf)}')
)
pages = ','.join([file.rsplit('_')[1].split(' ')[0] for file in files])
file_name = f'{input_pdf.removesuffix(".pdf")}_{pages} [PDFer].pdf'
PDFer.merge_pdfs(files, file_name)
shutil.rmtree('temp')
file_name = basename if (basename := os.path.basename(file_name)) in os.listdir() else file_name
console.print(f'[on dark_green]Диапазоны страниц успешно извлечены в файл {file_name}![/on dark_green]')
input()
Interface.start()
@override_keyboard_interrupt
@staticmethod
def extract_range():
"""Интерфейс для извлечения одного диапазона страниц из PDF-файла"""
Interface.draw_header()
input_pdf = Interface.get_pdf_file()
if not input_pdf:
return Interface.start()
start_page = session.prompt(
'Введи начальную страницу: ', completer=WordCompleter([]), validator=Validators.int_
)
if start_page in COMMANDS['exit']:
return Interface.start()
else:
start_page = int(start_page)
end_page = session.prompt('Введи конечную страницу: ', completer=WordCompleter([]), validator=Validators.int_)
if end_page in COMMANDS['exit']:
return Interface.start()
else:
end_page = int(end_page)
file_name = PDFer.extract_page_range(input_pdf, start_page, end_page)
file_name = basename if (basename := os.path.basename(file_name)) in os.listdir() else file_name
console.print(f'[on dark_green]Диапазон страниц успешно извлечён в файл {file_name}![/on dark_green]')
input()
Interface.start()
@override_keyboard_interrupt
@staticmethod
def extract_single():
"""Интерфейс для извлечения одной страницы из PDF-файла"""
Interface.draw_header()
input_pdf = Interface.get_pdf_file()
if not input_pdf:
return Interface.start()
page_number = session.prompt('Введи страницу: ', completer=WordCompleter([]), validator=Validators.int_)
if page_number in COMMANDS['exit']:
return Interface.start()
else:
page_number = int(page_number)
file_name = PDFer.extract_page_range(input_pdf, page_number)
file_name = basename if (basename := os.path.basename(file_name)) in os.listdir() else file_name
console.print(f'[on dark_green]Страница успешно извлечена в файл {file_name}![/on dark_green]')
input()
Interface.start()
@override_keyboard_interrupt
@staticmethod
def merge():
"""Интерфейс для склеивания нескольких PDF-файлов в один"""
not_enough = False
while True:
Interface.draw_header()
input_pdfs = []
if not_enough:
console.print('[on dark_red]Недостаточно PDF-файлов для склеивания![/on dark_red]', end='\n\n')
not_enough = False
while True:
input_pdf = Interface.get_pdf_file(
True, f'Введи имя {len(input_pdfs) + 1}-го PDF-файла для склеивания: ', set(input_pdfs)
)
if input_pdf == 'exit':
return Interface.start()
elif input_pdf:
input_pdfs.append(input_pdf)
else:
break
if len(input_pdfs) < 2:
not_enough = True
else:
break
file_name = Interface.get_pdf_file(
prompt_text='Введи имя выходного PDF-файла: ', completer_list=set(input_pdfs)
)
if not file_name:
return Interface.start()
if file_name == os.path.basename(file_name):
base_path = os.path.dirname(input_pdfs[0])
if all(os.path.dirname(file) == base_path for file in input_pdfs[1:]):
file_name = os.path.join(base_path, file_name)
file_name = file_name.removesuffix('.pdf') + ' [PDFer].pdf'
PDFer.merge_pdfs(input_pdfs, file_name)
console.print(f'[on dark_green]PDF-файлы успешно склеены в файл {file_name}![/on dark_green]')
input()
Interface.start()
@override_keyboard_interrupt
@staticmethod
def draw_help():
"""Отрисовывает помощь по программе"""
columns = shutil.get_terminal_size().columns
for action in ACTIONS_MATRIX.keys():
help_info = ACTIONS_MATRIX[action]['help']
Interface.draw_header(compact=True)
console.print(f'[b]> {action}[/b]')
for paragraph in help_info:
print(
''.join(
[
textwrap.indent(row.ljust(columns - 5), ' │ ')
for row in textwrap.wrap(paragraph, columns - 5)
]
),
end='\n\n' if paragraph.endswith('\n') else '\n',
)
print()
if ACTIONS_MATRIX[action]['flags']['help_about_exit']:
text = 'Для возврата в главное меню на любом этапе напишите ' + ', '.join(COMMANDS['exit'])
print(
''.join(
[textwrap.indent(row.ljust(columns - 5), ' │ ') for row in textwrap.wrap(text, columns - 5)]
)
)
print()
console.print('Нажми [i]Enter[/i] для продолжения или [i]Ctrl-C[/i] для выхода из справки...', end='')
input()
Interface.start()
@override_keyboard_interrupt
@staticmethod
def draw_about():
"""Отрисовывает информацию о разработчике"""
empty_line = lambda: console.print('[gold1]║' + ' ' * (columns - 2) + '║')
columns = shutil.get_terminal_size().columns
clear()
console.print('[gold1]╔' + '═' * (columns - 2) + '╗')
empty_line()
console.print('[gold1]║[/gold1][red3]' + 'PDFer'.center(columns - 2) + '[/red3][gold1]║')
console.print('[gold1]║[/gold1]' + 'c любовью от snowlue 💙'.center(columns - 3) + '[gold1]║')
empty_line()
raw, styled = (
'Никогда ещё работа с PDF не была настолько простой и быстрой!',
'Никогда ещё работа с PDF не была настолько [u]простой[/u] и [u]быстрой[/u]!',
)
console.print('[gold1]║[/gold1]' + raw.center(columns - 2).replace(raw, styled) + '[gold1]║')
empty_line()
console.print('[gold1]╠' + '═' * (columns - 2) + '╣')
empty_line()
empty_line()
raw, styled = 'Разработчик: Павел Овчинников', '[blink]Разработчик: Павел Овчинников[/blink]'
console.print('[gold1]║[/gold1]' + raw.center(columns - 2).replace(raw, styled) + '[gold1]║')
empty_line()
if os.name == 'nt':
raw, styled = (
'VK | Telegram | GitHub',
'[dodger_blue2][link=https://vk.com/snowlue]VK[/link][/dodger_blue2] | [deep_sky_blue1][link=https://t.me/snowlue]Telegram[/link][/deep_sky_blue1] | [link=https://github.com/snowlue][bright_white]GitHub[/bright_white][/link]',
)
console.print('[gold1]║[/gold1]' + raw.center(columns - 2).replace(raw, styled) + '[gold1]║')
else:
raw, styled = (
' VK' + ' ' * 12 + '│' + ' ' * 8 + 'Telegram' + ' ' * 8 + '│' + ' ' * 12 + 'GitHub',
' [dodger_blue2]VK[/dodger_blue2]'
+ ' ' * 12 + '│' + ' ' * 8
+ '[deep_sky_blue1]Telegram[/deep_sky_blue1]'
+ ' ' * 8 + '│' + ' ' * 12
+ '[bright_white]GitHub[/bright_white]',
)
console.print('[gold1]║[/gold1]' + raw.center(columns - 2).replace(raw, styled) + '[gold1]║')
links = 'https://vk.com/snowlue │ https://t.me/snowlue │ https://github.com/snowlue'
console.print('[gold1]║[/gold1]' + links.center(columns - 2) + '[gold1]║')
empty_line()
empty_line()
console.print('[gold1]╚' + '═' * (columns - 2) + '╝', end='\n\n')
raw, styled = (
f'© {datetime.now().year}, Pavel Ovchinnikov',
f'[grey66]© {datetime.now().year}, Pavel Ovchinnikov[/grey66]',
)
console.print(raw.center(columns).replace(raw, styled), end='')
input()
Interface.start()
@staticmethod
def draw_exit():
"""Отрисовывает завершение программы"""
Interface.draw_header()
console.print(' [u]Жду твоего возвращения![/u] 👋🏼', justify='center')
sys.exit(0)
class Separator:
"""Класс-разделитель для меню"""
def __repr__(self):
return 'Separator'
def __str__(self):
return ' '
COMMANDS = {'exit': ['exit', 'выход', 'выйти', 'quit', 'q']}
ACTIONS_MATRIX = {
' Извлечь набор страниц из PDF-файла': {
'action': Interface.extract_many,
'flags': {'help_about_exit': True},
'help': [
'Извлекает сразу нескольких диапазонов и страниц из PDF-файла и сохраняет их в новый.\n',
'Покрывает все основные случаи использования, т.к. извлекает страницы независимо, например: 1-5, 8, 11-13. Более того, имеется возможность извлекать страницы в обратном порядке, например: 5-1. И в дополнение, страницы можно дублировать: 1, 1, 3-5, 4, 1. Все диапазоны обрабатываются от начальной и до конечной страницы включительно.\n',
'– Для начала введи название PDF-файла, из которого хочешь извлечь страницы.',
'– Затем введи диапазоны страниц через запятую.',
],
},
' Извлечь один диапазон страниц из PDF-файла': {
'action': Interface.extract_range,
'flags': {'help_about_exit': True},
'help': [
'Извлекает один диапазон страниц из PDF-файла от начальной и до конечной страницы включительно. Если конечная страница не указана, то извлекается только одна начальная страница.\n',
'– Для начала введи название PDF-файла, из которого хочешь извлечь страницы.',
'– Затем введи начальную страницу.',
'– И в конце введи конечную страницу.',
],
},
' Извлечь одну страницу из PDF-файла': {
'action': Interface.extract_single,
'flags': {'help_about_exit': True},
'help': [
'Извлекает только одну страницу из PDF-файла.\n',
'– Для начала введи название PDF-файла, из которого хочешь извлечь страницу.',
'– Затем введи номер страницы, которую хочешь извлечь.',
],
},
' Склеить несколько PDF-файлов в один': {
'action': Interface.merge,
'flags': {'help_about_exit': True},
'help': [
'Склеивает несколько PDF-файлов в один общий. Порядок склеивания определяется введённым списком PDF-файлов. Если во введённом имени выходного файла не указан путь, то новый файл со склеенными PDF создаётся в той же папке, что и все файлы, если они расположены в одном месте — иначе в той же папке, что и этот модуль.\n',
'– Вводи имена PDF-файлов, которые хочешь склеить, по одному, нажимая Enter после каждого.',
'– Как только закончишь, нажмите Enter с пустой строкой, ничего не вводя.',
'– В конце введи имя выходного PDF-файла.',
],
},
' Помощь': {
'action': Interface.draw_help,
'flags': {'help_about_exit': False},
'help': ['Выводит эту справку по функционалу.'],
},
' О программе': {
'action': Interface.draw_about,
'flags': {'help_about_exit': False},
'help': ['Выводит информацию о программе и разработчике.'],
},
' Выход': {'action': Interface.draw_exit, 'flags': {'help_about_exit': False}, 'help': ['Завершает PDFer.']},
}
MENU = list(ACTIONS_MATRIX.keys())[:-3] + [Separator()] + list(ACTIONS_MATRIX.keys())[-3:]
def main():
"""Точка входа в программу"""
if not __loader__:
Interface.start()
elif __loader__.name != os.path.basename(__file__).removesuffix('.py'): # не запущен как модуль через флаг -m
if len(sys.argv) == 1: # не запущен через python
return Interface.start()
# TODO: здесь через argparse опишу логику для запуска с параметрами командной строки
# TODO: --extract file.pdf a-b,c,d-f
# TODO: --merge file1.pdf file2.pdf file3.pdf new_file.pdf
# TODO: --help, --about
if __name__ == '__main__':
main()