pdf-helper 0.1.0__tar.gz

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,172 @@
1
+ Metadata-Version: 2.3
2
+ Name: pdf-helper
3
+ Version: 0.1.0
4
+ Summary: A simple python script that helps with doing simple stuff with PDFs.
5
+ Author: CodeWriter21(Mehrad Pooryoussof)
6
+ Author-email: CodeWriter21(Mehrad Pooryoussof) <CodeWriter21@gmail.com>
7
+ License: MIT
8
+ Requires-Dist: log21>=3.0.0
9
+ Requires-Dist: pypdfium2>=4.30.0
10
+ Requires-Dist: pillow>=11.0.0
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+
14
+ PDF-Helper
15
+ ==========
16
+
17
+ A simple python package that helps with doing simple stuff with PDFs.
18
+
19
+ Features
20
+ --------
21
+
22
+ + [x] **Merge PDFs**: Merge multiple PDFs into one PDF
23
+ + [x] **Split PDFs**: Split a PDF into multiple PDFs, each containing a range of pages from
24
+ the original PDF
25
+ + [x] **Export as image**: Export designated pages from a PDF as image files
26
+ + [x] **Remove pages**: Remove designated pages from a PDF
27
+ + [ ] Encrypt a PDF
28
+ + [ ] Decrypt a PDF
29
+ + [ ] Add watermark to a PDF
30
+ + [ ] Extract images from a PDF
31
+ + [x] **Extract text**: Export text from a PDF file and optionally save it to a text file
32
+ + [ ] Extract links from a PDF
33
+ + [x] **Image to PDF**: Export one or multiple images as a PDF file
34
+
35
+ If you want any other feature to be added, feel free to open an [issue](https://github.com/MPCodeWriter21/PDF-To-Image/issues)
36
+ or fork the repo and make a [pull request](https://github.com/MPCodeWriter21/PDF-To-Image/pulls)
37
+ after adding your contribution.
38
+
39
+ Usage
40
+ -----
41
+
42
+ ### Installation
43
+
44
+ You can install PDF-Helper via pip:
45
+
46
+ ```bash
47
+ pip install pdf-helper
48
+ ```
49
+
50
+ And run it using the command line:
51
+
52
+ ```bash
53
+ pdf-helper <command> [options]
54
+ ```
55
+
56
+ Or you can use uvx to run the package without installing it in a specific python environment:
57
+
58
+ ```bash
59
+ uvx pdf-helper <command> [options]
60
+ ```
61
+
62
+ You can also clone the repository and use `uv run`:
63
+
64
+ ```bash
65
+ git clone https://github.com/MPCodeWriter21/PDF-Helper.git
66
+ cd PDF-Helper
67
+ uv run pdf-helper <command> [options]
68
+ ```
69
+
70
+ ### Merge PDFs
71
+
72
+ Merge multiple PDFs into one PDF:
73
+
74
+ ```bash
75
+ pdf-helper merge -i <input_file_1> <input_file_2>... <input_file_n> -o <output_file>
76
+
77
+ # E.g. Merge PDFs 1, 2 and 3 into a new PDF
78
+ pdf-helper merge -i 1.pdf 2.pdf 3.pdf -o new.pdf
79
+ ```
80
+
81
+ ### Split PDFs
82
+
83
+ Split a PDF into multiple PDFs, each containing a range of pages:
84
+
85
+ ```bash
86
+ pdf-helper split -i <input_file> -o <output_folder> -s <split_point_1>,<split_point_2>
87
+
88
+ # E.g. Split a PDF into three PDFs, one with pages 1-10, the second with pages 11-20 and
89
+ # the third with pages 21-end
90
+ pdf-helper split -i my-pdf.pdf -o my-split-pdfs -s 10,20
91
+
92
+ # E.g. Split a PDF into PDFs each containing one page
93
+ pdf-helper split -i my-pdf.pdf -o my-split-pdfs # No need to specify split points
94
+ ```
95
+
96
+ ### Export PDF pages as image files
97
+
98
+ Export PDF pages as image files:
99
+
100
+ ```bash
101
+ pdf-helper to-image -i <input_file> -o <output_folder> \
102
+ -p <page_number_1>,<page_number_2>,...,<page_number_n> -s <scale_factor>
103
+
104
+ # E.g. Export pages 1, 2, 3 and 6 from a PDF with scale factor 1
105
+ pdf-helper to-image -i 1.pdf -o images -p 1-3,6 -s 1
106
+
107
+ # E.g. Export all pages from a PDF with scale 2
108
+ pdf-helper to-image -i my-pdf.pdf -o my-images
109
+ ```
110
+
111
+ ### Remove pages from a PDF
112
+
113
+ Remove pages from a PDF:
114
+
115
+ ```bash
116
+ pdf-helper remove-pages -i <input_file> -o <output_file> -p <page_number_1>,<page_number_2>,...,<page_number_n>
117
+
118
+ # E.g. Remove pages 1, 2, 3 and 6 from a PDF
119
+ pdf-helper remove-pages -i 1.pdf -o new.pdf -p 1-3,6
120
+ ```
121
+
122
+ ### Export text from a PDF
123
+
124
+ To extract text from a PDF file and export them to text files you can do as follows:
125
+
126
+ ```bash
127
+ pdf-helper extract-text -i <input_file> -o <output_file_name>
128
+
129
+ # E.g. Extract text from a PDF named my-pdf.pdf and save it to my-text.txt
130
+ pdf-helper extract-text -i my-pdf.pdf -o my-text.txt
131
+ ```
132
+
133
+ ### Export one or multiple images as a PDF file
134
+
135
+ You simply provide the script with your images, and it will create a PDF file with them:
136
+
137
+ ```bash
138
+ pdf-helper image-to-pdf -i <image_1> <image_2> <image_3> ... -o <output_file>
139
+
140
+ # E.g. Take 1.png, 2.jpg, and 3.png and create a PDF named 123.pdf and override
141
+ # if already exists
142
+ pdf-helper image-to-pdf -i 1.png 2.jpg 3.png -o 123.pdf -f
143
+ ```
144
+
145
+ About
146
+ -----
147
+
148
+ Author: [CodeWriter21](https://github.com/MPCodeWriter21)
149
+
150
+ GitHub: [MPCodeWriter21/PDF-Helper](https://github.com/MPCodeWriter21/PDF-Helper)
151
+
152
+ Donations
153
+ ---------
154
+
155
+ Your donations are very welcome: [nowpayments.io](https://nowpayments.io/donation/MehradP21)
156
+
157
+ You can also consider donating a
158
+ [Star](https://github.com/MPCodeWriter21/PDF-Helper) to the repo.
159
+
160
+ License
161
+ -------
162
+
163
+ This project is licensed under the MIT License.
164
+
165
+ See the [LICENSE](LICENSE)
166
+
167
+ References
168
+ ----------
169
+
170
+ + [pypdfium2](https://pypdfium2.readthedocs.io/en/stable/readme.html)
171
+ + [PILlow](https://pillow.readthedocs.io/en/stable/)
172
+ + [log21](https://GitHub.com/MPCodeWriter21/log21)
@@ -0,0 +1,159 @@
1
+ PDF-Helper
2
+ ==========
3
+
4
+ A simple python package that helps with doing simple stuff with PDFs.
5
+
6
+ Features
7
+ --------
8
+
9
+ + [x] **Merge PDFs**: Merge multiple PDFs into one PDF
10
+ + [x] **Split PDFs**: Split a PDF into multiple PDFs, each containing a range of pages from
11
+ the original PDF
12
+ + [x] **Export as image**: Export designated pages from a PDF as image files
13
+ + [x] **Remove pages**: Remove designated pages from a PDF
14
+ + [ ] Encrypt a PDF
15
+ + [ ] Decrypt a PDF
16
+ + [ ] Add watermark to a PDF
17
+ + [ ] Extract images from a PDF
18
+ + [x] **Extract text**: Export text from a PDF file and optionally save it to a text file
19
+ + [ ] Extract links from a PDF
20
+ + [x] **Image to PDF**: Export one or multiple images as a PDF file
21
+
22
+ If you want any other feature to be added, feel free to open an [issue](https://github.com/MPCodeWriter21/PDF-To-Image/issues)
23
+ or fork the repo and make a [pull request](https://github.com/MPCodeWriter21/PDF-To-Image/pulls)
24
+ after adding your contribution.
25
+
26
+ Usage
27
+ -----
28
+
29
+ ### Installation
30
+
31
+ You can install PDF-Helper via pip:
32
+
33
+ ```bash
34
+ pip install pdf-helper
35
+ ```
36
+
37
+ And run it using the command line:
38
+
39
+ ```bash
40
+ pdf-helper <command> [options]
41
+ ```
42
+
43
+ Or you can use uvx to run the package without installing it in a specific python environment:
44
+
45
+ ```bash
46
+ uvx pdf-helper <command> [options]
47
+ ```
48
+
49
+ You can also clone the repository and use `uv run`:
50
+
51
+ ```bash
52
+ git clone https://github.com/MPCodeWriter21/PDF-Helper.git
53
+ cd PDF-Helper
54
+ uv run pdf-helper <command> [options]
55
+ ```
56
+
57
+ ### Merge PDFs
58
+
59
+ Merge multiple PDFs into one PDF:
60
+
61
+ ```bash
62
+ pdf-helper merge -i <input_file_1> <input_file_2>... <input_file_n> -o <output_file>
63
+
64
+ # E.g. Merge PDFs 1, 2 and 3 into a new PDF
65
+ pdf-helper merge -i 1.pdf 2.pdf 3.pdf -o new.pdf
66
+ ```
67
+
68
+ ### Split PDFs
69
+
70
+ Split a PDF into multiple PDFs, each containing a range of pages:
71
+
72
+ ```bash
73
+ pdf-helper split -i <input_file> -o <output_folder> -s <split_point_1>,<split_point_2>
74
+
75
+ # E.g. Split a PDF into three PDFs, one with pages 1-10, the second with pages 11-20 and
76
+ # the third with pages 21-end
77
+ pdf-helper split -i my-pdf.pdf -o my-split-pdfs -s 10,20
78
+
79
+ # E.g. Split a PDF into PDFs each containing one page
80
+ pdf-helper split -i my-pdf.pdf -o my-split-pdfs # No need to specify split points
81
+ ```
82
+
83
+ ### Export PDF pages as image files
84
+
85
+ Export PDF pages as image files:
86
+
87
+ ```bash
88
+ pdf-helper to-image -i <input_file> -o <output_folder> \
89
+ -p <page_number_1>,<page_number_2>,...,<page_number_n> -s <scale_factor>
90
+
91
+ # E.g. Export pages 1, 2, 3 and 6 from a PDF with scale factor 1
92
+ pdf-helper to-image -i 1.pdf -o images -p 1-3,6 -s 1
93
+
94
+ # E.g. Export all pages from a PDF with scale 2
95
+ pdf-helper to-image -i my-pdf.pdf -o my-images
96
+ ```
97
+
98
+ ### Remove pages from a PDF
99
+
100
+ Remove pages from a PDF:
101
+
102
+ ```bash
103
+ pdf-helper remove-pages -i <input_file> -o <output_file> -p <page_number_1>,<page_number_2>,...,<page_number_n>
104
+
105
+ # E.g. Remove pages 1, 2, 3 and 6 from a PDF
106
+ pdf-helper remove-pages -i 1.pdf -o new.pdf -p 1-3,6
107
+ ```
108
+
109
+ ### Export text from a PDF
110
+
111
+ To extract text from a PDF file and export them to text files you can do as follows:
112
+
113
+ ```bash
114
+ pdf-helper extract-text -i <input_file> -o <output_file_name>
115
+
116
+ # E.g. Extract text from a PDF named my-pdf.pdf and save it to my-text.txt
117
+ pdf-helper extract-text -i my-pdf.pdf -o my-text.txt
118
+ ```
119
+
120
+ ### Export one or multiple images as a PDF file
121
+
122
+ You simply provide the script with your images, and it will create a PDF file with them:
123
+
124
+ ```bash
125
+ pdf-helper image-to-pdf -i <image_1> <image_2> <image_3> ... -o <output_file>
126
+
127
+ # E.g. Take 1.png, 2.jpg, and 3.png and create a PDF named 123.pdf and override
128
+ # if already exists
129
+ pdf-helper image-to-pdf -i 1.png 2.jpg 3.png -o 123.pdf -f
130
+ ```
131
+
132
+ About
133
+ -----
134
+
135
+ Author: [CodeWriter21](https://github.com/MPCodeWriter21)
136
+
137
+ GitHub: [MPCodeWriter21/PDF-Helper](https://github.com/MPCodeWriter21/PDF-Helper)
138
+
139
+ Donations
140
+ ---------
141
+
142
+ Your donations are very welcome: [nowpayments.io](https://nowpayments.io/donation/MehradP21)
143
+
144
+ You can also consider donating a
145
+ [Star](https://github.com/MPCodeWriter21/PDF-Helper) to the repo.
146
+
147
+ License
148
+ -------
149
+
150
+ This project is licensed under the MIT License.
151
+
152
+ See the [LICENSE](LICENSE)
153
+
154
+ References
155
+ ----------
156
+
157
+ + [pypdfium2](https://pypdfium2.readthedocs.io/en/stable/readme.html)
158
+ + [PILlow](https://pillow.readthedocs.io/en/stable/)
159
+ + [log21](https://GitHub.com/MPCodeWriter21/log21)
@@ -0,0 +1,48 @@
1
+ [project]
2
+ name = "PDF-Helper"
3
+ version = "0.1.0"
4
+ authors = [
5
+ {name = "CodeWriter21(Mehrad Pooryoussof)", email = "CodeWriter21@gmail.com"}
6
+ ]
7
+ description = "A simple python script that helps with doing simple stuff with PDFs."
8
+ readme = {file = "README.md", content-type = "text/markdown"}
9
+ license = {text = "MIT", file = "LICENSE"}
10
+ requires-python = ">=3.9"
11
+ dependencies = [
12
+ "log21>=3.0.0",
13
+ "pypdfium2>=4.30.0",
14
+ "Pillow>=11.0.0"
15
+ ]
16
+
17
+ [project.scripts]
18
+ pdf-helper = "pdf_helper:__main__.main"
19
+
20
+ [build-system]
21
+ requires = ["uv_build>=0.8.22,<0.9.0"]
22
+ build-backend = "uv_build"
23
+
24
+ [tool.yapf]
25
+ column_limit = 88
26
+ split_before_dot = true
27
+ split_before_first_argument = true
28
+ dedent_closing_brackets = true
29
+
30
+ [tool.isort]
31
+ line_length = 88
32
+ combine_as_imports = true
33
+ length_sort = true
34
+ order_by_type = true
35
+
36
+ [tool.docformatter]
37
+ recursive = true
38
+ wrap-summaries = 88
39
+ wrap-descriptions = 88
40
+
41
+ [tool.ruff]
42
+ show-fixes = true
43
+ exclude = ["migrations"]
44
+ target-version = "py39"
45
+ line-length = 88
46
+
47
+ [tool.ruff.lint]
48
+ extend-select = ["C4", "SIM", "TCH", "ANN", "N", "B"]
@@ -0,0 +1,294 @@
1
+ # PDF-Helper
2
+
3
+ import io
4
+ import os
5
+ import sys
6
+ from typing import Optional, Sequence, Collection
7
+ from pathlib import Path
8
+
9
+ import log21
10
+ import pypdfium2 as pdfium
11
+ from PIL import Image
12
+ from pypdfium2 import PdfImage, PdfBitmap, PdfDocument
13
+
14
+ __all__ = [
15
+ 'merge_pdfs', 'remove_pages', 'pdf_to_image', 'extract_text', 'image_to_pdf',
16
+ 'split_pdf', 'watermark_pdf'
17
+ ]
18
+
19
+
20
+ def merge_pdfs(
21
+ input_files: Sequence[str | Path | io.TextIOWrapper],
22
+ output_stream: str | Path | io.BytesIO | io.BufferedWriter
23
+ ) -> int:
24
+ """Merge PDF files.
25
+
26
+ :param input_files: List of PDF files to concatenate.
27
+ :param output_stream: Output stream to write to.
28
+ :return: Number of pages of the merged PDF.
29
+ """
30
+ writer = PdfDocument.new()
31
+ for input_file in input_files:
32
+ log21.info(f'Adding {input_file}...')
33
+ reader = PdfDocument(input_file)
34
+ writer.import_pages(reader)
35
+ writer.save(output_stream)
36
+ return len(writer)
37
+
38
+
39
+ def remove_pages(
40
+ input_file: str | Path | io.BytesIO | io.TextIOWrapper,
41
+ pages_to_remove: Collection[int],
42
+ output_stream: str | Path | io.BytesIO | io.BufferedWriter
43
+ ) -> int:
44
+ """Remove pages from a PDF file.
45
+
46
+ :param input_file: PDF file to remove pages from.
47
+ :param pages_to_remove: List of pages to remove. A one based collection of indices.
48
+ :param output_stream: Output stream to write to.
49
+ :return: Number of pages removed.
50
+ """
51
+ writer = PdfDocument.new()
52
+ reader = PdfDocument(input_file)
53
+ pages_to_remove = tuple((i - 1 for i in pages_to_remove))
54
+ pages_to_add = [i for i in range(len(reader)) if i not in pages_to_remove]
55
+ writer.import_pages(reader, pages_to_add)
56
+ writer.save(output_stream, version=reader.get_version())
57
+ return len(reader) - len(pages_to_add)
58
+
59
+
60
+ def pdf_to_image(
61
+ input_file: str | Path,
62
+ output_directory: str | Path,
63
+ pages_to_convert: Optional[Collection[int]] = None,
64
+ scale: int = 2
65
+ ) -> int:
66
+ """Convert a PDF file to a series of images.
67
+
68
+ :param input_file: PDF file to convert.
69
+ :param output_directory: Directory to write images to.
70
+ :param scale: Scale of each image.
71
+ :return: Number of pages converted to image
72
+ """
73
+ if isinstance(input_file, str):
74
+ input_file = Path(input_file)
75
+ if isinstance(output_directory, str):
76
+ output_directory = Path(output_directory)
77
+ if not output_directory.exists():
78
+ output_directory.mkdir(parents=True)
79
+
80
+ pdf = pdfium.PdfDocument(input_file)
81
+ name = input_file.name.rsplit('.', maxsplit=1)[0]
82
+ # Number of digits each number in the filename should have
83
+ length = len(str(len(pdf)))
84
+ if not pages_to_convert:
85
+ for i, page in enumerate(pdf, start=1):
86
+ log21.info(f'Converting page {i}...', end='\r')
87
+ image = page.render(scale=scale).to_pil()
88
+ image.save(output_directory / f'{name}-{i:0>{length}}.png')
89
+ return len(pdf)
90
+ for i, page in enumerate(pdf, start=1):
91
+ if i not in pages_to_convert:
92
+ continue
93
+ log21.info(f'Converting page {i}...', end='\r')
94
+ image = page.render(scale=scale).to_pil()
95
+ image.save(output_directory / f'{name}-{i:0>{length}}.png')
96
+ return len(pages_to_convert)
97
+
98
+
99
+ def extract_text(
100
+ input_file: str | Path | io.BytesIO | io.TextIOWrapper,
101
+ pages_to_extract_from: Optional[Collection[int]] = None,
102
+ max_number_of_characters: int = -1,
103
+ reverse_lines: bool = False
104
+ ) -> str:
105
+ """Extract text from a PDF file.
106
+
107
+ :param input_file: PDF file to extract text from.
108
+ :param pages_to_extract_from: Pages to extract text from.
109
+ :param max_number_of_characters: Maximum number of characters to extract in total.
110
+ :param reverse_lines: Reverse the characters in each line (Useful for Persian text)
111
+ :return: Extracted text.
112
+ """
113
+ pdf = pdfium.PdfDocument(input_file)
114
+ text = ''
115
+ if pages_to_extract_from:
116
+ pages_to_extract_from = sorted(pages_to_extract_from)
117
+ if pages_to_extract_from[0] < 1:
118
+ log21.critical('Pages must be >= 1')
119
+ sys.exit(1)
120
+ if pages_to_extract_from[-1] > len(pdf):
121
+ log21.critical(
122
+ f'Page {pages_to_extract_from[-1]} does not exist in `{input_file}`'
123
+ )
124
+ sys.exit(1)
125
+ log21.info(
126
+ f'Extracting text from {len(pages_to_extract_from)} page' +
127
+ ('s' if len(pages_to_extract_from) > 1 else '') + f' from `{input_file}`...'
128
+ )
129
+ count = max_number_of_characters
130
+ for i, page in enumerate(pdf):
131
+ i = i + 1
132
+ if i not in pages_to_extract_from:
133
+ continue
134
+ log21.info(f'Extracting text from page {i}...', end='\r')
135
+ text += page.get_textpage().get_text_range(count=count)
136
+ if count == 0:
137
+ break
138
+ if count > 0:
139
+ count = max_number_of_characters - len(text)
140
+ else:
141
+ count = max_number_of_characters
142
+ for page in pdf:
143
+ text += page.get_textpage().get_text_range(count=count)
144
+ if count == 0:
145
+ break
146
+ if count > 0:
147
+ count = max_number_of_characters - len(text)
148
+ text += '\n'
149
+ log21.info('\rDone!')
150
+
151
+ if reverse_lines:
152
+ reversed_text = ''
153
+ log21.info('Reversing the lines...', end='\r')
154
+ for i, line in enumerate(text.split(os.linesep), start=1):
155
+ log21.info('Reversing line %d...', args=(i, ), end='\r')
156
+ reversed_text += ''.join(reversed(line)) + os.linesep
157
+ log21.info('\rReversed every line!')
158
+ text = reversed_text
159
+
160
+ return text
161
+
162
+
163
+ def image_to_pdf(
164
+ input_files: Sequence[str | bytes | Path | os.PathLike[str] | io.BytesIO],
165
+ output_stream: str | Path | io.BytesIO | io.BufferedWriter
166
+ ) -> int:
167
+ """Convert images to a PDF file.
168
+
169
+ :param input_files: List of images to convert.
170
+ :param output_stream: Output stream to write to.
171
+ :return: Number of pages in the output PDF
172
+ """
173
+ writer = PdfDocument.new()
174
+ for input_file in input_files:
175
+ log21.info(f'Adding {input_file}...')
176
+ # Open the image file
177
+ image = Image.open(input_file)
178
+ # Create a bitmap from the image
179
+ bitmap = PdfBitmap.from_pil(image)
180
+ # Create a PdfImage object from the bitmap
181
+ pdf_image = PdfImage.new(writer)
182
+ pdf_image.set_bitmap(bitmap)
183
+ matrix = pdfium.PdfMatrix().scale(bitmap.width, bitmap.height)
184
+ pdf_image.set_matrix(matrix)
185
+ # Create a new page and insert the PdfImage object
186
+ page = writer.new_page(bitmap.width, bitmap.height)
187
+ page.insert_obj(pdf_image)
188
+ page.gen_content()
189
+ # Close the objects
190
+ page.close()
191
+ pdf_image.close()
192
+ bitmap.close()
193
+ image.close()
194
+ writer.save(output_stream)
195
+ return len(writer)
196
+
197
+
198
+ def split_pdf(
199
+ input_file: str | Path,
200
+ output_directory: str | Path,
201
+ split_points: Optional[Collection[int]] = None
202
+ ) -> int:
203
+ """Split a PDF file into multiple files.
204
+
205
+ :param input_file: PDF file to split.
206
+ :param output_directory: Directory to write split files to.
207
+ :param split_points: Pages to split. If None, splits every page into a separate
208
+ file.
209
+ :return: Number of pages split.
210
+ """
211
+ if isinstance(input_file, str):
212
+ input_file = Path(input_file)
213
+ if isinstance(output_directory, str):
214
+ output_directory = Path(output_directory)
215
+ if not output_directory.exists():
216
+ output_directory.mkdir(parents=True)
217
+
218
+ pdf = pdfium.PdfDocument(input_file)
219
+ if not split_points:
220
+ split_points = range(1, len(pdf) - 1)
221
+ split_points = [0] + sorted(set(split_points)) + [len(pdf)]
222
+
223
+ for i in range(len(split_points) - 1):
224
+ start = split_points[i]
225
+ end = split_points[i + 1]
226
+ if start < 0 or end > len(pdf):
227
+ log21.warning(
228
+ f'Split points {start + 1} to {end} are out of bounds for '
229
+ f'input file `{input_file}`.'
230
+ )
231
+ continue
232
+
233
+ log21.info(f'Splitting pages {start + 1} to {end}...')
234
+ writer = PdfDocument.new()
235
+ writer.import_pages(pdf, range(start, end))
236
+ output_file = output_directory / f'{input_file.stem}_part_{i + 1}.pdf'
237
+ try:
238
+ writer.save(output_file)
239
+ log21.info(f'Saved split file to {output_file}')
240
+ except PermissionError:
241
+ log21.critical(
242
+ f'Cannot write to output file `{output_file}`.\n'
243
+ 'Check the file permissions and close any applications that may be '
244
+ 'using the file, then try again.'
245
+ )
246
+ sys.exit(1)
247
+ finally:
248
+ writer.close()
249
+
250
+ return len(split_points) - 1
251
+
252
+
253
+ def watermark_pdf(
254
+ input_file: str | Path | io.BytesIO | io.TextIOWrapper,
255
+ output_file: str | Path,
256
+ watermark_text: str,
257
+ position: str = 'center',
258
+ font_size: int = 36,
259
+ opacity: float = 0.1,
260
+ rotation: float = 45.0
261
+ ) -> int:
262
+ """Split a PDF file into multiple files.
263
+
264
+ :param input_file: PDF file to split.
265
+ :param output_file:
266
+ :param watermark_text: Text to use as watermark.
267
+ :param position: Position of watermark. One of 'center' or '50% 50%', 'top-left' or
268
+ '0% 0%', 'top-right' or '100% 0%', 'bottom-left' or '0% 100%', etc.
269
+ You can also use absolute values like '100 100'.
270
+ Note: The position is relative to the center of the watermark text.
271
+ :param font_size: Font size of watermark text.
272
+ :param opacity: Opacity of watermark text. Between 0.0 and 1.0.
273
+ :param rotation: Rotation of watermark text in degrees.
274
+ :return: Number of pages split.
275
+ """
276
+ if isinstance(input_file, str):
277
+ input_file = Path(input_file)
278
+ if isinstance(output_file, str):
279
+ output_file = Path(output_file)
280
+ if len(watermark_text) == 0:
281
+ raise ValueError('Watermark text cannot be empty.')
282
+ if font_size < 1:
283
+ raise ValueError('Font size must be at least 1.')
284
+ if opacity < 0.0 or opacity > 1.0:
285
+ raise ValueError('Opacity must be between 0.0 and 1.0.')
286
+
287
+ pdf = pdfium.PdfDocument(input_file)
288
+ watermark = pdfium.PdfDocument.new()
289
+ _page = watermark.new_page(512, 512)
290
+ for i, _page in enumerate(pdf):
291
+ log21.info(f'Adding watermark to page {i + 1}...', end='\r')
292
+ log21.info('\rDone!')
293
+ pdf.save(output_file)
294
+ return len(pdf)
@@ -0,0 +1,422 @@
1
+ #!/usr/bin/env python3
2
+
3
+ # yapf: disable
4
+
5
+ import os
6
+ import sys
7
+ import importlib.util
8
+ from typing import Optional, Sequence
9
+ from pathlib import Path
10
+
11
+ import log21
12
+ from log21.colors import RED, GREEN, RESET
13
+
14
+ from . import (split_pdf, merge_pdfs, extract_text, image_to_pdf, pdf_to_image,
15
+ remove_pages, watermark_pdf)
16
+ from .utils import parse_pages
17
+
18
+ # yapf: ensable
19
+
20
+ def merge_pdfs_entry_point(
21
+ input_paths: Sequence[Path],
22
+ output_path: Path,
23
+ /,
24
+ force: bool = False,
25
+ verbose: bool = False
26
+ ) -> None:
27
+ """Merge PDF files.
28
+
29
+ :param output_path: Path to write concatenated PDF file to.
30
+ :param input_paths: List of PDF files to concatenate.
31
+ :param force: Force overwrite of output file.
32
+ :param verbose: Print verbose output.
33
+ """
34
+ if len(input_paths) < 2:
35
+ log21.critical('Must provide at least two input files.')
36
+ sys.exit(1)
37
+ if output_path.exists() and not force:
38
+ log21.critical('Output file already exists.')
39
+ sys.exit(1)
40
+ if output_path.absolute() in (path.absolute() for path in input_paths):
41
+ log21.critical('Input and output files cannot be the same.')
42
+ sys.exit(1)
43
+ if verbose:
44
+ log21.basic_config(level=log21.INFO)
45
+
46
+ for path in input_paths:
47
+ if not path.exists():
48
+ log21.critical(f'Input file `{path}` does not exist.')
49
+ sys.exit(1)
50
+
51
+ log21.info(f'Concatenating {len(input_paths)} files to {output_path}')
52
+ try:
53
+ with open(output_path, 'wb') as output_file:
54
+ merge_pdfs(input_paths, output_file)
55
+ except PermissionError:
56
+ log21.critical(
57
+ f'Cannot write to output file `{output_path}`.\n'
58
+ 'Check the file permissions and close any applications that may be using '
59
+ 'the file, then try again.'
60
+ )
61
+ sys.exit(1)
62
+
63
+
64
+ def remove_pages_entry_point(
65
+ input_path: Path,
66
+ output_path: Path,
67
+ pages_to_remove: str,
68
+ /,
69
+ force: bool = False,
70
+ verbose: bool = False
71
+ ) -> None:
72
+ """Remove pages from a PDF file.
73
+
74
+ :param input_path: Path to PDF file to remove pages from.
75
+ :param output_path: Path to write PDF file to.
76
+ :param pages_to_remove: Comma-separated list of pages to remove.
77
+ Example: '1-5,7,9-11'
78
+ :param force: Force overwrite of output file.
79
+ :param verbose: Print verbose output.
80
+ """
81
+ if not input_path.exists():
82
+ log21.critical(f'Input file `{input_path}` does not exist.')
83
+ sys.exit(1)
84
+ if output_path.exists() and not force:
85
+ log21.critical('Output file already exists.')
86
+ sys.exit(1)
87
+ if input_path.absolute() == output_path.absolute():
88
+ log21.critical('Input and output files cannot be the same.')
89
+ sys.exit(1)
90
+ if verbose:
91
+ log21.basic_config(level=log21.INFO)
92
+
93
+ try:
94
+ pages_to_remove_ = parse_pages(pages_to_remove)
95
+ except ValueError:
96
+ log21.critical(f'Invalid pages string: `{pages_to_remove}`')
97
+ sys.exit(1)
98
+
99
+ log21.info(
100
+ f'Removing {len(pages_to_remove_)} page' + (
101
+ 's: ' + ', '.join(str(page) for page in pages_to_remove_[:-1]) +
102
+ ' and' if len(pages_to_remove_) > 2 else ':'
103
+ ) + f' {pages_to_remove_[-1]} from `{input_path}`'
104
+ )
105
+ try:
106
+ with open(output_path, 'wb') as output_file:
107
+ number_of_removed_pages = remove_pages(
108
+ input_path, pages_to_remove_, output_file
109
+ )
110
+ log21.info(
111
+ f'Removed {number_of_removed_pages} page' +
112
+ ('s' if number_of_removed_pages > 1 else '') + '!'
113
+ )
114
+ except PermissionError:
115
+ log21.critical(
116
+ f'Cannot write to output file `{output_path}`.\n'
117
+ 'Check the file permissions and close any applications that may be using '
118
+ 'the file, then try again.'
119
+ )
120
+ sys.exit(1)
121
+
122
+
123
+ def pdf_to_image_entry_point(
124
+ input_path: Path,
125
+ output_directory: Path,
126
+ /,
127
+ pages_to_convert: Optional[str] = None,
128
+ scale: int = 2,
129
+ force: bool = False,
130
+ verbose: bool = False
131
+ ) -> None:
132
+ """Convert a PDF file to a series of images.
133
+
134
+ :param input_path: Path to PDF file to convert.
135
+ :param output_directory: Path to directory to write images to.
136
+ :param pages_to_convert: Comma-separated list of pages to convert.
137
+ Example: '1-5,7,9-11'
138
+ :param scale: Scale of each image.
139
+ :param force: Force overwrite of output directory.
140
+ :param verbose: Print verbose output.
141
+ """
142
+ if importlib.util.find_spec('PIL') is None:
143
+ log21.error('PIL must be installed to use this feature.')
144
+ cmd = 'python -m pip install "Pillow>=10.1.0"'
145
+ answer = log21.input(
146
+ f'[{RED}?{RESET}] Do you want to install it? ({GREEN}{cmd}{RESET})'
147
+ )
148
+ if answer.lower().startswith('y'):
149
+ log21.print(f'[{GREEN}+{RESET}] Installing Pillow...')
150
+ os.system(cmd)
151
+ sys.exit(1)
152
+ if not input_path.exists():
153
+ log21.critical(f'Input file `{input_path}` does not exist.')
154
+ sys.exit(1)
155
+ if output_directory.exists() and not output_directory.is_dir():
156
+ log21.critical(f'Output path `{output_directory}` is not a directory.')
157
+ sys.exit(1)
158
+ if output_directory.exists() and os.listdir(output_directory) and not force:
159
+ log21.critical(f'Output directory `{output_directory}` already exists.')
160
+ sys.exit(1)
161
+ if verbose:
162
+ log21.basic_config(level=log21.INFO)
163
+
164
+ pages_to_convert_ = None
165
+ if pages_to_convert:
166
+ try:
167
+ pages_to_convert_ = parse_pages(pages_to_convert)
168
+ except ValueError:
169
+ log21.critical(f'Invalid pages string: `{pages_to_convert}`')
170
+ sys.exit(1)
171
+ log21.info(
172
+ f'Converting {len(pages_to_convert_)} page' +
173
+ ('s' if len(pages_to_convert_) > 2 else '') +
174
+ f' from `{input_path}` to image...'
175
+ )
176
+ else:
177
+ log21.info(f'Converting `{input_path}` to images...')
178
+
179
+ pdf_to_image(input_path, output_directory, pages_to_convert_, scale)
180
+ log21.info('\rDone!')
181
+
182
+
183
+ def extract_text_entry_point(
184
+ input_path: Path,
185
+ /,
186
+ output_path: Optional[Path] = None,
187
+ pages_to_extract_from: Optional[str] = None,
188
+ max_number_of_characters: int = -1,
189
+ characters_to_split: int = 0,
190
+ reverse_lines: bool = False,
191
+ force: bool = False,
192
+ verbose: bool = False
193
+ ) -> None:
194
+ """Extract text from a PDF file.
195
+
196
+ :param input_path: Path to PDF file to extract text from.
197
+ :param output_path: Path to write extracted text to. (Writes to stdout if not
198
+ provided)
199
+ :param pages_to_extract_from: Pages to extract text from. Example: '1-5,7,9-11'
200
+ :param max_number_of_characters: Maximum number of characters to extract in total.
201
+ :param characters_to_split: Create a new file if the number of characters in the
202
+ extracted text exceeds this value.
203
+ :param reverse_lines: Reverse the characters in each line (Useful for Persian text)
204
+ :param force: Force overwrite of output file.
205
+ :param verbose: Print verbose output.
206
+ """
207
+ if not input_path.exists():
208
+ log21.critical(f'Input file `{input_path}` does not exist.')
209
+ sys.exit(1)
210
+ if output_path and output_path.exists() and not force:
211
+ log21.critical(f'Output `{output_path}` already exists.')
212
+ sys.exit(1)
213
+ if verbose:
214
+ log21.basic_config(level=log21.INFO)
215
+
216
+ pages_to_extract_from_ = None
217
+ if pages_to_extract_from:
218
+ try:
219
+ pages_to_extract_from_ = parse_pages(pages_to_extract_from)
220
+ except ValueError:
221
+ log21.critical(f'Invalid pages string: `{pages_to_extract_from}`')
222
+ sys.exit(1)
223
+ log21.info(
224
+ f'Extracting text from {len(pages_to_extract_from_)} page' +
225
+ ('s' if len(pages_to_extract_from_) > 2 else '') +
226
+ f' from `{input_path}`...'
227
+ )
228
+ else:
229
+ log21.info(f'Extracting text from `{input_path}`...')
230
+
231
+ text = extract_text(
232
+ input_path, pages_to_extract_from_, max_number_of_characters, reverse_lines
233
+ )
234
+ if output_path:
235
+ if not characters_to_split:
236
+ with output_path.open('w', encoding='utf-8') as file:
237
+ file.write(text)
238
+ return
239
+ i = 1
240
+ stem = output_path.stem
241
+ while len(text) > characters_to_split:
242
+ output_path_ = output_path.with_name(stem + f'_{i}{output_path.suffix}')
243
+ with output_path_.open('w', encoding='utf-8') as file:
244
+ file.write(text[:characters_to_split])
245
+ text = text[characters_to_split:]
246
+ output_path = output_path_
247
+ i += 1
248
+ with output_path.open('w', encoding='utf-8') as file:
249
+ file.write(text)
250
+ else:
251
+ print(text)
252
+ log21.info('\rDone!')
253
+
254
+
255
+ def image_to_pdf_entry_point(
256
+ input_paths: Sequence[Path],
257
+ output_path: Path,
258
+ /,
259
+ force: bool = False,
260
+ verbose: bool = False
261
+ ) -> None:
262
+ """Convert images to a PDF file.
263
+
264
+ :param input_paths: List of images to convert.
265
+ :param output_path: Path to write PDF file to.
266
+ :param force: Force overwrite of output file.
267
+ :param verbose: Print verbose output.
268
+ """
269
+ if len(input_paths) < 1:
270
+ log21.critical('Must provide at least one input file.')
271
+ sys.exit(1)
272
+ if output_path.exists() and not force:
273
+ log21.critical('Output file already exists.')
274
+ sys.exit(1)
275
+ if verbose:
276
+ log21.basic_config(level=log21.INFO)
277
+
278
+ for path in input_paths:
279
+ if not path.exists():
280
+ log21.critical(f'Input file `{path}` does not exist.')
281
+ sys.exit(1)
282
+
283
+ log21.info(f'Converting {len(input_paths)} images to `{output_path}`...')
284
+ try:
285
+ with open(output_path, 'wb') as output_file:
286
+ image_to_pdf(input_paths, output_file)
287
+ except PermissionError:
288
+ log21.critical(
289
+ f'Cannot write to output file `{output_path}`.\n'
290
+ 'Check the file permissions and close any applications that may be using '
291
+ 'the file, then try again.'
292
+ )
293
+ sys.exit(1)
294
+
295
+
296
+ def split_pdf_entry_point(
297
+ input_path: Path,
298
+ output_directory: Path,
299
+ /,
300
+ split_points: Optional[str] = None,
301
+ force: bool = False,
302
+ verbose: bool = False
303
+ ) -> None:
304
+ """Split a PDF file into multiple files.
305
+
306
+ :param input_path: Path to PDF file to split.
307
+ :param output_directory: Path to directory to write split files to.
308
+ :param split_points: Comma-separated list of pages to split. Example: '5,7,9'
309
+ :param force: Force overwrite of output directory.
310
+ :param verbose: Print verbose output.
311
+ """
312
+ if not input_path.exists():
313
+ log21.critical(f'Input file `{input_path}` does not exist.')
314
+ sys.exit(1)
315
+ if output_directory.exists() and not output_directory.is_dir():
316
+ log21.critical(f'Output path `{output_directory}` is not a directory.')
317
+ sys.exit(1)
318
+ if output_directory.exists() and os.listdir(output_directory) and not force:
319
+ log21.critical(f'Output directory `{output_directory}` already exists.')
320
+ sys.exit(1)
321
+ if verbose:
322
+ log21.basic_config(level=log21.INFO)
323
+
324
+ split_points_ = None
325
+ if split_points:
326
+ try:
327
+ split_points_ = parse_pages(split_points)
328
+ except ValueError:
329
+ log21.critical(f'Invalid pages string: `{split_points}`')
330
+ sys.exit(1)
331
+
332
+ log21.info(f'Splitting `{input_path}`...')
333
+ number_of_pdfs = split_pdf(input_path, output_directory, split_points_)
334
+ if number_of_pdfs > 1:
335
+ log21.info(f'Split {input_path} to {number_of_pdfs} PDF files!')
336
+
337
+
338
+ def watermark_pdf_entry_point(
339
+ input_path: Path,
340
+ output_path: Path,
341
+ watermark_text: str,
342
+ /,
343
+ position: str = 'center',
344
+ font_size: int = 36,
345
+ opacity: float = 0.1,
346
+ rotation: float = 45.0,
347
+ force: bool = False,
348
+ verbose: bool = False
349
+ ) -> None:
350
+ """Add a watermark to a PDF file.
351
+
352
+ :param input_path: Path to PDF file to add watermark to.
353
+ :param output_path: Path to write watermarked PDF file to.
354
+ :param watermark_text: Text to use as watermark.
355
+ :param position: Position of watermark. One of 'center' or '50% 50%', 'top-left' or
356
+ '0% 0%', 'top-right' or '100% 0%', 'bottom-left' or '0% 100%', etc.
357
+ You can also use absolute values like '100 100'.
358
+ Note: The position is relative to the center of the watermark text.
359
+ :param font_size: Font size of watermark text.
360
+ :param opacity: Opacity of watermark text. Between 0.0 and 1.0.
361
+ :param rotation: Rotation of watermark text in degrees.
362
+ :param force: Force overwrite of output file.
363
+ :param verbose: Print verbose output.
364
+ """
365
+ if not input_path.exists():
366
+ log21.critical(f'Input file `{input_path}` does not exist.')
367
+ sys.exit(1)
368
+ if output_path.exists() and not force:
369
+ log21.critical('Output file already exists.')
370
+ sys.exit(1)
371
+ if input_path.absolute() == output_path.absolute():
372
+ log21.critical('Input and output files cannot be the same.')
373
+ sys.exit(1)
374
+ if verbose:
375
+ log21.basic_config(level=log21.INFO)
376
+
377
+ log21.info(f'Adding watermark to `{input_path}`...')
378
+ try:
379
+ number_of_pages = watermark_pdf(
380
+ input_path, output_path, watermark_text, position, font_size, opacity,
381
+ rotation
382
+ )
383
+ log21.info(
384
+ f'Added watermark to {number_of_pages} page' +
385
+ ('s' if number_of_pages > 1 else '') + '!'
386
+ )
387
+ except PermissionError:
388
+ log21.critical(
389
+ f'Cannot write to output file `{output_path}`.\n'
390
+ 'Check the file permissions and close any applications that may be using '
391
+ 'the file, then try again.'
392
+ )
393
+ sys.exit(1)
394
+ except ValueError as e:
395
+ log21.critical(str(e))
396
+ sys.exit(1)
397
+
398
+
399
+ def main() -> None:
400
+ try:
401
+ if sys.platform == 'win32':
402
+ import msvcrt
403
+ msvcrt.setmode(sys.stdout.fileno(), os.O_BINARY)
404
+ log21.basic_config(level=log21.ERROR)
405
+ log21.argumentify(
406
+ {
407
+ 'merge': merge_pdfs_entry_point,
408
+ 'remove-pages': remove_pages_entry_point,
409
+ 'to-image': pdf_to_image_entry_point,
410
+ 'add-watermark': watermark_pdf_entry_point,
411
+ 'extract-text': extract_text_entry_point,
412
+ 'image-to-pdf': image_to_pdf_entry_point,
413
+ 'split': split_pdf_entry_point
414
+ }
415
+ )
416
+ except KeyboardInterrupt:
417
+ log21.critical('\nKeyboardInterrupt: Exiting...')
418
+ sys.exit(1)
419
+
420
+
421
+ if __name__ == '__main__':
422
+ main()
@@ -0,0 +1,94 @@
1
+ import re
2
+
3
+ POSITION_PATTERN = re.compile(
4
+ r'^(top|bottom|center|left|right|\d{1,3}%?)\s+(top|bottom|center|left|right|\d{1,3}%?)?$'
5
+ )
6
+
7
+
8
+ def parse_pages(pages: str) -> list[int]:
9
+ """Parses the pages string into a list of pages.
10
+
11
+ :param pages: The pages string
12
+ Example: '1-5,7,9-11'
13
+ :return: A list of pages
14
+ Example: [1, 2, 3, 4, 5, 7, 9, 10, 11]
15
+ """
16
+ pages_ = pages.replace(' ', '')
17
+ pages_ = pages_.split(',')
18
+ pages_ = [x.split('-') for x in pages_]
19
+ pages_ = [x if len(x) == 1 else range(int(x[0]), int(x[1]) + 1) for x in pages_]
20
+ pages_ = [int(x) for y in pages_ for x in y]
21
+ return pages_
22
+
23
+
24
+ def parse_position(
25
+ position: str, page_width: float, page_height: float, text_width: float,
26
+ text_height: float
27
+ ) -> tuple[float, float]:
28
+ """Parse position string into x and y coordinates.
29
+
30
+ :param position: Position string.
31
+ :param page_width: Width of the page.
32
+ :param page_height: Height of the page.
33
+ :param text_width: Width of the text.
34
+ :param text_height: Height of the text.
35
+ :return: x and y coordinates.
36
+ """
37
+ position = position.lower().strip()
38
+ match = POSITION_PATTERN.match(position)
39
+ if not match:
40
+ raise ValueError(f'Invalid position string: {position}')
41
+
42
+ groups = match.groups()
43
+ if len(groups) == 1:
44
+ value = groups[0]
45
+ if value == 'center':
46
+ return (page_width - text_width) / 2, (page_height - text_height) / 2
47
+ elif value == 'top':
48
+ return (page_width - text_width) / 2, 0
49
+ elif value == 'bottom':
50
+ return (page_width - text_width) / 2, page_height - text_height
51
+ elif value == 'left':
52
+ return 0, (page_height - text_height) / 2
53
+ elif value == 'right':
54
+ return page_width - text_width, (page_height - text_height) / 2
55
+ elif value.endswith('%'):
56
+ percent = float(value[:-1]) / 100.0
57
+ return (page_width -
58
+ text_width) * percent, (page_height - text_height) * percent
59
+ else:
60
+ absolute = float(value)
61
+ return absolute, absolute
62
+ elif len(groups) == 2:
63
+ x_value, y_value = groups
64
+ if x_value == 'center':
65
+ x = (page_width - text_width) / 2
66
+ elif x_value == 'top' or x_value == 'bottom':
67
+ raise ValueError(f'Invalid x position: {x_value}')
68
+ elif x_value == 'left':
69
+ x = 0
70
+ elif x_value == 'right':
71
+ x = page_width - text_width
72
+ elif x_value.endswith('%'):
73
+ percent = float(x_value[:-1]) / 100.0
74
+ x = (page_width - text_width) * percent
75
+ else:
76
+ x = float(x_value)
77
+
78
+ if y_value == 'center':
79
+ y = (page_height - text_height) / 2
80
+ elif y_value == 'left' or y_value == 'right':
81
+ raise ValueError(f'Invalid y position: {y_value}')
82
+ elif y_value == 'top':
83
+ y = 0
84
+ elif y_value == 'bottom':
85
+ y = page_height - text_height
86
+ elif y_value.endswith('%'):
87
+ percent = float(y_value[:-1]) / 100.0
88
+ y = (page_height - text_height) * percent
89
+ else:
90
+ y = float(y_value)
91
+
92
+ return x, y
93
+ else:
94
+ raise ValueError(f'Invalid position string: {position}')