pdfly 0.3.3__tar.gz → 0.4.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.
Files changed (77) hide show
  1. {pdfly-0.3.3 → pdfly-0.4.0}/.github/workflows/create-github-release.yaml +1 -1
  2. {pdfly-0.3.3 → pdfly-0.4.0}/.github/workflows/github-ci.yaml +2 -2
  3. {pdfly-0.3.3 → pdfly-0.4.0}/.pre-commit-config.yaml +7 -7
  4. {pdfly-0.3.3 → pdfly-0.4.0}/.readthedocs.yaml +1 -1
  5. {pdfly-0.3.3 → pdfly-0.4.0}/CHANGELOG.md +18 -1
  6. {pdfly-0.3.3 → pdfly-0.4.0}/Makefile +2 -2
  7. {pdfly-0.3.3 → pdfly-0.4.0}/PKG-INFO +7 -2
  8. {pdfly-0.3.3 → pdfly-0.4.0}/README.md +3 -0
  9. {pdfly-0.3.3 → pdfly-0.4.0}/docs/conf.py +1 -0
  10. {pdfly-0.3.3 → pdfly-0.4.0}/make_release.py +34 -17
  11. {pdfly-0.3.3 → pdfly-0.4.0}/pdfly/__init__.py +1 -0
  12. pdfly-0.4.0/pdfly/_version.py +1 -0
  13. pdfly-0.4.0/pdfly/booklet.py +159 -0
  14. {pdfly-0.3.3 → pdfly-0.4.0}/pdfly/cat.py +38 -14
  15. pdfly-0.4.0/pdfly/cli.py +318 -0
  16. {pdfly-0.3.3 → pdfly-0.4.0}/pdfly/metadata.py +46 -20
  17. {pdfly-0.3.3 → pdfly-0.4.0}/pdfly/pagemeta.py +1 -3
  18. pdfly-0.4.0/pdfly/rm.py +50 -0
  19. pdfly-0.4.0/pdfly/uncompress.py +52 -0
  20. {pdfly-0.3.3 → pdfly-0.4.0}/pdfly/up2.py +3 -1
  21. pdfly-0.4.0/pdfly/update_offsets.py +290 -0
  22. pdfly-0.4.0/pdfly/x2pdf.py +51 -0
  23. {pdfly-0.3.3 → pdfly-0.4.0}/pyproject.toml +6 -2
  24. {pdfly-0.3.3 → pdfly-0.4.0}/requirements/ci.txt +14 -34
  25. {pdfly-0.3.3 → pdfly-0.4.0}/requirements/dev.txt +58 -71
  26. {pdfly-0.3.3 → pdfly-0.4.0}/requirements/docs.txt +15 -15
  27. pdfly-0.4.0/resources/b.pdf +0 -0
  28. pdfly-0.4.0/resources/c.pdf +0 -0
  29. pdfly-0.4.0/resources/file-with-fixed-offsets.pdf +52 -0
  30. pdfly-0.4.0/resources/file-with-invalid-offsets.pdf +52 -0
  31. pdfly-0.4.0/resources/input8.pdf +0 -0
  32. {pdfly-0.3.3 → pdfly-0.4.0}/setup.py +1 -0
  33. {pdfly-0.3.3 → pdfly-0.4.0}/tests/conftest.py +10 -7
  34. pdfly-0.4.0/tests/test_booklet.py +121 -0
  35. {pdfly-0.3.3 → pdfly-0.4.0}/tests/test_cat.py +1 -3
  36. {pdfly-0.3.3 → pdfly-0.4.0}/tests/test_extract_images.py +0 -2
  37. pdfly-0.4.0/tests/test_rm.py +229 -0
  38. pdfly-0.4.0/tests/test_uncompress.py +40 -0
  39. pdfly-0.4.0/tests/test_up2.py +63 -0
  40. pdfly-0.4.0/tests/test_update_offsets.py +104 -0
  41. pdfly-0.4.0/tests/test_x2pdf.py +86 -0
  42. pdfly-0.3.3/pdfly/_version.py +0 -1
  43. pdfly-0.3.3/pdfly/cli.py +0 -227
  44. pdfly-0.3.3/pdfly/x2pdf.py +0 -54
  45. pdfly-0.3.3/tests/test_x2pdf.py +0 -31
  46. {pdfly-0.3.3 → pdfly-0.4.0}/.github/dependabot.yml +0 -0
  47. {pdfly-0.3.3 → pdfly-0.4.0}/.github/workflows/publish-to-pypi.yaml +0 -0
  48. {pdfly-0.3.3 → pdfly-0.4.0}/.github/workflows/release.yaml +0 -0
  49. {pdfly-0.3.3 → pdfly-0.4.0}/.gitignore +0 -0
  50. {pdfly-0.3.3 → pdfly-0.4.0}/.gitmodules +0 -0
  51. {pdfly-0.3.3 → pdfly-0.4.0}/.isort.cfg +0 -0
  52. {pdfly-0.3.3 → pdfly-0.4.0}/CONTRIBUTORS.md +0 -0
  53. {pdfly-0.3.3 → pdfly-0.4.0}/LICENSE +0 -0
  54. {pdfly-0.3.3 → pdfly-0.4.0}/docs/Makefile +0 -0
  55. {pdfly-0.3.3 → pdfly-0.4.0}/docs/index.rst +0 -0
  56. {pdfly-0.3.3 → pdfly-0.4.0}/docs/make.bat +0 -0
  57. {pdfly-0.3.3 → pdfly-0.4.0}/docs/meta/project-governance.md +0 -0
  58. {pdfly-0.3.3 → pdfly-0.4.0}/docs/user/installation.md +0 -0
  59. {pdfly-0.3.3 → pdfly-0.4.0}/docs/user/subcommand-cat.md +0 -0
  60. {pdfly-0.3.3 → pdfly-0.4.0}/docs/user/subcommand-meta.md +0 -0
  61. {pdfly-0.3.3 → pdfly-0.4.0}/docs/user/subcommand-x2pdf.md +0 -0
  62. {pdfly-0.3.3 → pdfly-0.4.0}/mypy.ini +0 -0
  63. {pdfly-0.3.3 → pdfly-0.4.0}/pdfly/__main__.py +0 -0
  64. {pdfly-0.3.3 → pdfly-0.4.0}/pdfly/_utils.py +0 -0
  65. {pdfly-0.3.3 → pdfly-0.4.0}/pdfly/compress.py +0 -0
  66. {pdfly-0.3.3 → pdfly-0.4.0}/pdfly/extract_images.py +0 -0
  67. {pdfly-0.3.3 → pdfly-0.4.0}/requirements/ci.in +0 -0
  68. {pdfly-0.3.3 → pdfly-0.4.0}/requirements/dev.in +0 -0
  69. {pdfly-0.3.3 → pdfly-0.4.0}/requirements/docs.in +0 -0
  70. {pdfly-0.3.3 → pdfly-0.4.0}/resources/GeoBase_NHNC1_Data_Model_UML_EN.pdf +0 -0
  71. {pdfly-0.3.3 → pdfly-0.4.0}/resources/baleines.jpg +0 -0
  72. {pdfly-0.3.3 → pdfly-0.4.0}/resources/box.pdf +0 -0
  73. {pdfly-0.3.3 → pdfly-0.4.0}/resources/jpeg.pdf +0 -0
  74. {pdfly-0.3.3 → pdfly-0.4.0}/resources/pythonknight.png +0 -0
  75. {pdfly-0.3.3 → pdfly-0.4.0}/setup.cfg +0 -0
  76. {pdfly-0.3.3 → pdfly-0.4.0}/tests/__init__.py +0 -0
  77. {pdfly-0.3.3 → pdfly-0.4.0}/tests/test_cli.py +0 -0
@@ -32,7 +32,7 @@ jobs:
32
32
  git --no-pager tag -l "${latest_tag}" --format='%(contents:body)' >> "$GITHUB_ENV"
33
33
  echo "$EOF" >> "$GITHUB_ENV"
34
34
  - name: Create GitHub Release 🚀
35
- uses: softprops/action-gh-release@v1
35
+ uses: softprops/action-gh-release@v2
36
36
  with:
37
37
  tag_name: ${{ env.latest_tag }}
38
38
  name: Version ${{ env.latest_tag }}, ${{ env.date }}
@@ -22,7 +22,7 @@ jobs:
22
22
  runs-on: ubuntu-20.04
23
23
  strategy:
24
24
  matrix:
25
- python-version: ["3.8", "3.9", "3.10", "3.11"]
25
+ python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
26
26
 
27
27
  steps:
28
28
  - name: Checkout Code
@@ -73,7 +73,7 @@ jobs:
73
73
  - name: Test with ruff
74
74
  run: |
75
75
  echo `ruff --version`
76
- ruff pdfly/
76
+ ruff check pdfly/
77
77
 
78
78
  package:
79
79
  name: Build & verify package
@@ -1,7 +1,7 @@
1
1
  # pre-commit run --all-files
2
2
  repos:
3
3
  - repo: https://github.com/pre-commit/pre-commit-hooks
4
- rev: v4.5.0
4
+ rev: v5.0.0
5
5
  hooks:
6
6
  - id: check-ast
7
7
  - id: check-byte-order-marker
@@ -18,34 +18,34 @@ repos:
18
18
  - id: check-added-large-files
19
19
  args: ['--maxkb=1000']
20
20
  - repo: https://github.com/psf/black
21
- rev: 23.12.0
21
+ rev: 24.10.0
22
22
  hooks:
23
23
  - id: black
24
24
  args: [--target-version, py36]
25
25
  - repo: https://github.com/asottile/blacken-docs
26
- rev: 1.16.0
26
+ rev: 1.19.1
27
27
  hooks:
28
28
  - id: blacken-docs
29
29
  additional_dependencies: [black==22.1.0]
30
30
  - repo: https://github.com/astral-sh/ruff-pre-commit
31
- rev: v0.1.8
31
+ rev: v0.7.4
32
32
  hooks:
33
33
  - id: ruff
34
34
  args: ['--fix']
35
35
  exclude: "tests/"
36
36
  - repo: https://github.com/asottile/pyupgrade
37
- rev: v3.15.0
37
+ rev: v3.19.0
38
38
  hooks:
39
39
  - id: pyupgrade
40
40
  args: [--py36-plus]
41
41
  - repo: https://github.com/pycqa/flake8
42
- rev: 6.1.0
42
+ rev: 7.1.1
43
43
  hooks:
44
44
  - id: flake8
45
45
  args: ["--ignore", "E,W,F"]
46
46
 
47
47
  - repo: https://github.com/pre-commit/mirrors-mypy
48
- rev: 'v1.7.1'
48
+ rev: 'v1.13.0'
49
49
  hooks:
50
50
  - id: mypy
51
51
  files: ^pdfly/.*
@@ -5,7 +5,7 @@ version: 2
5
5
  build:
6
6
  os: ubuntu-22.04
7
7
  tools:
8
- python: "3.11"
8
+ python: "3.12"
9
9
 
10
10
  # Build documentation in the docs/ directory with Sphinx
11
11
  sphinx:
@@ -1,11 +1,28 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## Version 0.4.0, 2024-12-08
4
+
5
+ ### New Features (ENH)
6
+ - New `booklet` command to adjust offsets and lengths ([PR #77](https://github.com/py-pdf/pdfly/pull/77))
7
+ - New `uncompress` command ([PR #75](https://github.com/py-pdf/pdfly/pull/75))
8
+ - New `update-offsets` command to adjust offsets and lengths ([PR #15](https://github.com/py-pdf/pdfly/pull/15))
9
+ - New `rm` command ([PR #59](https://github.com/py-pdf/pdfly/pull/59))
10
+ - `metadata`: now also displaying CreationDate, Creator, Keywords & Subject ([PR #73](https://github.com/py-pdf/pdfly/pull/73))
11
+ - Add warning for out-of-bounds page range in pdfly `cat` command ([PR #58](https://github.com/py-pdf/pdfly/pull/58))
12
+
13
+ ### Bug Fixes (BUG)
14
+ - `2-up` command, that only showed one page per sheet, on the left side, with blank space on the right ([PR #78](https://github.com/py-pdf/pdfly/pull/78))
15
+
16
+ [Full Changelog](https://github.com/py-pdf/pdfly/compare/0.3.3...0.4.0)
17
+
18
+
3
19
  ## Version 0.3.3, 2024-04-14
4
20
 
5
21
  ### Developer Experience (DEV)
6
22
  - Chain workflows
7
23
 
8
- [Full Changelog](https://github.com/py-pdf/pdfly/compare/0.3.1...0.3.2)
24
+ [Full Changelog](https://github.com/py-pdf/pdfly/compare/0.3.2...0.3.3)
25
+
9
26
 
10
27
  ## Version 0.3.2, 2024-04-14
11
28
 
@@ -15,10 +15,10 @@ upload:
15
15
  clean:
16
16
  python setup.py clean --all
17
17
  pyclean .
18
- rm -rf Tests/__pycache__ pypdf/__pycache__ Image9.png htmlcov docs/_build dist dont_commit_merged.pdf dont_commit_writer.pdf pypdf.egg-info pypdf_pdfLocation.txt
18
+ rm -rf tests/__pycache__ pypdf/__pycache__ Image9.png htmlcov docs/_build dist dont_commit_merged.pdf dont_commit_writer.pdf pypdf.egg-info pypdf_pdfLocation.txt
19
19
 
20
20
  test:
21
- pytest Tests --cov --cov-report term-missing -vv --cov-report html --durations=3 --timeout=30
21
+ pytest tests --cov --cov-report term-missing -vv --cov-report html --durations=3 --timeout=30
22
22
 
23
23
  mutation-test:
24
24
  mutmut run
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.3
2
2
  Name: pdfly
3
- Version: 0.3.3
3
+ Version: 0.4.0
4
4
  Summary: A pure-python CLI application to manipulate PDF files
5
5
  Author-email: Martin Thoma <info@martin-thoma.de>
6
6
  Maintainer-email: Martin Thoma <info@martin-thoma.de>
@@ -18,6 +18,8 @@ Classifier: Programming Language :: Python :: 3.7
18
18
  Classifier: Programming Language :: Python :: 3.8
19
19
  Classifier: Programming Language :: Python :: 3.9
20
20
  Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
21
23
  Requires-Dist: pypdf>=3.8.2
22
24
  Requires-Dist: typer>=0.9.0
23
25
  Requires-Dist: pillow
@@ -62,10 +64,13 @@ $ pdfly --help
62
64
  │ 2-up Create a booklet-style PDF from a single input. │
63
65
  │ cat Concatenate pages from PDF files into a single PDF file. │
64
66
  │ compress Compress a PDF. │
67
+ | uncompress Uncompresses a PDF. │
65
68
  │ extract-images Extract images from PDF without resampling or altering. │
66
69
  │ extract-text Extract text from a PDF file. │
67
70
  │ meta Show metadata of a PDF file │
68
71
  │ pagemeta Give details about a single page. │
72
+ │ rm Remove pages from PDF files. │
73
+ │ update-offsets Updates offsets and lengths in a simple PDF file. │
69
74
  │ x2pdf Convert one or more files to PDF. Each file is a page. │
70
75
  ╰─────────────────────────────────────────────────────────────────────────────╯
71
76
  ```
@@ -34,10 +34,13 @@ $ pdfly --help
34
34
  │ 2-up Create a booklet-style PDF from a single input. │
35
35
  │ cat Concatenate pages from PDF files into a single PDF file. │
36
36
  │ compress Compress a PDF. │
37
+ | uncompress Uncompresses a PDF. │
37
38
  │ extract-images Extract images from PDF without resampling or altering. │
38
39
  │ extract-text Extract text from a PDF file. │
39
40
  │ meta Show metadata of a PDF file │
40
41
  │ pagemeta Give details about a single page. │
42
+ │ rm Remove pages from PDF files. │
43
+ │ update-offsets Updates offsets and lengths in a simple PDF file. │
41
44
  │ x2pdf Convert one or more files to PDF. Each file is a page. │
42
45
  ╰─────────────────────────────────────────────────────────────────────────────╯
43
46
  ```
@@ -5,6 +5,7 @@ This file only contains a selection of the most common options.
5
5
  For a full list see the documentation:
6
6
  https://www.sphinx-doc.org/en/master/usage/configuration.html
7
7
  """
8
+
8
9
  # -- Path setup --------------------------------------------------------------
9
10
 
10
11
  # If extensions (or modules to document with autodoc) are in another directory,
@@ -1,4 +1,4 @@
1
- """Internal tool to update the changelog."""
1
+ """Internal tool to update the CHANGELOG."""
2
2
 
3
3
  import json
4
4
  import subprocess
@@ -9,6 +9,11 @@ from typing import Any, Dict, List, Tuple
9
9
 
10
10
  from rich.prompt import Prompt
11
11
 
12
+ GH_ORG = "py-pdf"
13
+ GH_PROJECT = "pdfly"
14
+ VERSION_FILE_PATH = "pdfly/_version.py"
15
+ CHANGELOG_FILE_PATH = "CHANGELOG.md"
16
+
12
17
 
13
18
  @dataclass(frozen=True)
14
19
  class Change:
@@ -27,6 +32,7 @@ def main(changelog_path: str) -> None:
27
32
 
28
33
  Args:
29
34
  changelog_path: The location of the CHANGELOG file
35
+
30
36
  """
31
37
  changelog = get_changelog(changelog_path)
32
38
  git_tag = get_most_recent_git_tag()
@@ -41,7 +47,7 @@ def main(changelog_path: str) -> None:
41
47
 
42
48
  today = datetime.now(tz=timezone.utc)
43
49
  header = f"## Version {new_version}, {today:%Y-%m-%d}\n"
44
- url = f"https://github.com/py-pdf/pdfly/compare/{git_tag}...{new_version}"
50
+ url = f"https://github.com/{GH_ORG}/{GH_PROJECT}/compare/{git_tag}...{new_version}"
45
51
  trailer = f"\n[Full Changelog]({url})\n\n"
46
52
  new_entry = header + changes + trailer
47
53
  print(new_entry)
@@ -61,9 +67,9 @@ def main(changelog_path: str) -> None:
61
67
  def print_instructions(new_version: str) -> None:
62
68
  """Print release instructions."""
63
69
  print("=" * 80)
64
- print(f"☑ _version.py was adjusted to '{new_version}'")
65
- print("☑ CHANGELOG.md was adjusted")
66
- print("")
70
+ print(f"☑ {VERSION_FILE_PATH} was adjusted to '{new_version}'")
71
+ print(f"☑ {CHANGELOG_FILE_PATH} was adjusted")
72
+ print()
67
73
  print("Now run:")
68
74
  print(" git commit -eF RELEASE_COMMIT_MSG.md")
69
75
  print(f" git tag -s {new_version} -eF RELEASE_TAG_MSG.md")
@@ -73,7 +79,7 @@ def print_instructions(new_version: str) -> None:
73
79
 
74
80
  def adjust_version_py(version: str) -> None:
75
81
  """Adjust the __version__ string."""
76
- with open("pdfly/_version.py", "w") as fp:
82
+ with open(VERSION_FILE_PATH, "w") as fp:
77
83
  fp.write(f'__version__ = "{version}"\n')
78
84
 
79
85
 
@@ -93,8 +99,7 @@ def get_version_interactive(new_version: str, changes: str) -> str:
93
99
 
94
100
  def is_semantic_version(version: str) -> bool:
95
101
  """Check if the given version is a semantic version."""
96
- # It's not so important to cover the edge-cases like pre-releases
97
- # This is meant for pdfly only and we don't make pre-releases
102
+ # This doesn't cover the edge-cases like pre-releases
98
103
  if version.count(".") != 2:
99
104
  return False
100
105
  try:
@@ -147,6 +152,7 @@ def version_bump(git_tag: str) -> str:
147
152
 
148
153
  Returns:
149
154
  The new version where the patch version is bumped.
155
+
150
156
  """
151
157
  # just assume a patch version change
152
158
  major, minor, patch = git_tag.split(".")
@@ -162,6 +168,7 @@ def get_changelog(changelog_path: str) -> str:
162
168
 
163
169
  Returns:
164
170
  Data of the CHANGELOG
171
+
165
172
  """
166
173
  with open(changelog_path) as fh:
167
174
  changelog = fh.read()
@@ -175,6 +182,7 @@ def write_changelog(new_changelog: str, changelog_path: str) -> None:
175
182
  Args:
176
183
  new_changelog: Contents of the new CHANGELOG
177
184
  changelog_path: Path where the CHANGELOG file is
185
+
178
186
  """
179
187
  with open(changelog_path, "w") as fh:
180
188
  fh.write(new_changelog)
@@ -189,6 +197,7 @@ def get_formatted_changes(git_tag: str) -> Tuple[str, str]:
189
197
 
190
198
  Returns:
191
199
  Changes done since git_tag
200
+
192
201
  """
193
202
  commits = get_git_commits_since_tag(git_tag)
194
203
 
@@ -249,8 +258,11 @@ def get_formatted_changes(git_tag: str) -> Tuple[str, str]:
249
258
 
250
259
  if grouped:
251
260
  output += "\n### Other\n"
261
+ output_with_user += "\n### Other\n"
252
262
  for prefix in grouped:
253
- output += f"- {prefix}: {grouped[prefix]}\n"
263
+ for commit_dict in grouped[prefix]:
264
+ output += f"- {prefix}: {commit_dict['msg']}\n"
265
+ output_with_user += f"- {prefix}: {commit_dict['msg']} by @{commit_dict['author']}\n"
254
266
 
255
267
  return output, output_with_user
256
268
 
@@ -261,6 +273,7 @@ def get_most_recent_git_tag() -> str:
261
273
 
262
274
  Returns:
263
275
  Most recently created git tag.
276
+
264
277
  """
265
278
  git_tag = str(
266
279
  subprocess.check_output(
@@ -280,18 +293,18 @@ def get_author_mapping(line_count: int) -> Dict[str, str]:
280
293
 
281
294
  Returns:
282
295
  A mapping of long commit hashes to author login handles.
296
+
283
297
  """
284
298
  per_page = min(line_count, 100)
285
299
  page = 1
286
300
  mapping: Dict[str, str] = {}
287
301
  for _ in range(0, line_count, per_page):
288
- with urllib.request.urlopen( # noqa: S310
289
- f"https://api.github.com/repos/py-pdf/pdfly/commits?per_page={per_page}&page={page}"
302
+ with urllib.request.urlopen(
303
+ f"https://api.github.com/repos/{GH_ORG}/{GH_PROJECT}/commits?per_page={per_page}&page={page}"
290
304
  ) as response:
291
305
  commits = json.loads(response.read())
292
306
  page += 1
293
307
  for commit in commits:
294
- print(commit)
295
308
  if commit["author"]:
296
309
  gh_handle = commit["author"]["login"]
297
310
  else:
@@ -311,8 +324,9 @@ def get_git_commits_since_tag(git_tag: str) -> List[Change]:
311
324
 
312
325
  Returns:
313
326
  List of all changes since git_tag.
327
+
314
328
  """
315
- commits = str(
329
+ commits = (
316
330
  subprocess.check_output(
317
331
  [
318
332
  "git",
@@ -323,8 +337,10 @@ def get_git_commits_since_tag(git_tag: str) -> List[Change]:
323
337
  ],
324
338
  stderr=subprocess.STDOUT,
325
339
  )
326
- ).strip("'b\\n")
327
- lines = commits.split("\\n")
340
+ .decode("UTF-8")
341
+ .strip()
342
+ )
343
+ lines = commits.splitlines()
328
344
  authors = get_author_mapping(len(lines))
329
345
  return [parse_commit_line(line, authors) for line in lines if line != ""]
330
346
 
@@ -341,13 +357,14 @@ def parse_commit_line(line: str, authors: Dict[str, str]) -> Change:
341
357
 
342
358
  Raises:
343
359
  ValueError: The commit line is not well-structured
360
+
344
361
  """
345
362
  parts = line.split(":::")
346
363
  if len(parts) != 3:
347
364
  raise ValueError(f"Invalid commit line: '{line}'")
348
365
  commit_hash, rest, author = parts
349
366
  if ":" in rest:
350
- prefix, message = rest.split(":", 1)
367
+ prefix, message = rest.split(": ", 1)
351
368
  else:
352
369
  prefix = ""
353
370
  message = rest
@@ -374,4 +391,4 @@ def parse_commit_line(line: str, authors: Dict[str, str]) -> Change:
374
391
 
375
392
 
376
393
  if __name__ == "__main__":
377
- main("CHANGELOG.md")
394
+ main(CHANGELOG_FILE_PATH)
@@ -1,4 +1,5 @@
1
1
  """pdfly is a command line utility for manipulating PDFs and getting information about them."""
2
+
2
3
  from ._version import __version__
3
4
 
4
5
  __all__ = [
@@ -0,0 +1 @@
1
+ __version__ = "0.4.0"
@@ -0,0 +1,159 @@
1
+ """
2
+ Reorder and two-up PDF pages for booklet printing.
3
+
4
+ If the number of pages is not a multiple of four, pages are
5
+ added until it is a multiple of four. This includes a centerfold
6
+ in the middle of the booklet and a single page on the inside
7
+ back cover. The content of those pages are from the
8
+ centerfold-file and blank-page-file files, if specified, otherwise
9
+ they are blank pages.
10
+
11
+ Example:
12
+ pdfly booklet input.pdf output.pdf
13
+
14
+ """
15
+
16
+ # Copyright (c) 2014, Steve Witham <switham_github@mac-guyver.com>.
17
+ # All rights reserved. This software is available under a BSD license;
18
+ # see https://github.com/py-pdf/pypdf/LICENSE
19
+
20
+ import sys
21
+ import traceback
22
+ from pathlib import Path
23
+ from typing import Generator, Optional, Tuple
24
+
25
+ from pypdf import (
26
+ PageObject,
27
+ PdfReader,
28
+ PdfWriter,
29
+ )
30
+ from pypdf.generic import RectangleObject
31
+
32
+
33
+ def main(
34
+ filename: Path,
35
+ output: Path,
36
+ inside_cover_file: Optional[Path],
37
+ centerfold_file: Optional[Path],
38
+ ) -> None:
39
+ try:
40
+ # Set up the streams
41
+ reader = PdfReader(filename)
42
+ pages = list(reader.pages)
43
+ writer = PdfWriter()
44
+
45
+ # Add blank pages to make the number of pages a multiple of 4
46
+ # If the user specified an inside-back-cover file, use it.
47
+ blank_page = PageObject.create_blank_page(
48
+ width=pages[0].mediabox.width, height=pages[0].mediabox.height
49
+ )
50
+ if len(pages) % 2 == 1:
51
+ if inside_cover_file:
52
+ ic_reader_page = fetch_first_page(inside_cover_file)
53
+ pages.insert(-1, ic_reader_page)
54
+ else:
55
+ pages.insert(-1, blank_page)
56
+ if len(pages) % 4 == 2:
57
+ pages.insert(len(pages) // 2, blank_page)
58
+ pages.insert(len(pages) // 2, blank_page)
59
+ requires_centerfold = True
60
+ else:
61
+ requires_centerfold = False
62
+
63
+ # Reorder the pages and place two pages side by side (2-up) on each sheet
64
+ for lhs, rhs in page_iter(len(pages)):
65
+ pages[lhs].merge_translated_page(
66
+ page2=pages[rhs],
67
+ tx=pages[lhs].mediabox.width,
68
+ ty=0,
69
+ expand=True,
70
+ over=True,
71
+ )
72
+ writer.add_page(pages[lhs])
73
+
74
+ # If a centerfold was required, it is already
75
+ # present as a pair of blank pages. If the user
76
+ # specified a centerfold file, use it instead.
77
+ if requires_centerfold and centerfold_file:
78
+ centerfold_page = fetch_first_page(centerfold_file)
79
+ last_page = writer.pages[-1]
80
+ if centerfold_page.rotation != 0:
81
+ centerfold_page.transfer_rotation_to_content()
82
+ if requires_rotate(centerfold_page.mediabox, last_page.mediabox):
83
+ centerfold_page = centerfold_page.rotate(270)
84
+ if centerfold_page.rotation != 0:
85
+ centerfold_page.transfer_rotation_to_content()
86
+ last_page.merge_page(centerfold_page)
87
+
88
+ # Everything looks good! Write the output file.
89
+ with open(output, "wb") as output_fh:
90
+ writer.write(output_fh)
91
+
92
+ except Exception:
93
+ print(traceback.format_exc(), file=sys.stderr)
94
+ print(f"Error while reading {filename}", file=sys.stderr)
95
+ sys.exit(1)
96
+
97
+
98
+ def requires_rotate(a: RectangleObject, b: RectangleObject) -> bool:
99
+ """
100
+ Return True if a and b are rotated relative to each other.
101
+
102
+ Args:
103
+ a (RectangleObject): The first rectangle.
104
+ b (RectangleObject): The second rectangle.
105
+
106
+ """
107
+ a_portrait = a.height > a.width
108
+ b_portrait = b.height > b.width
109
+ return a_portrait != b_portrait
110
+
111
+
112
+ def fetch_first_page(filename: Path) -> PageObject:
113
+ """
114
+ Fetch the first page of a PDF file.
115
+
116
+ Args:
117
+ filename (Path): The path to the PDF file.
118
+
119
+ Returns:
120
+ PageObject: The first page of the PDF file.
121
+
122
+ """
123
+ return PdfReader(filename).pages[0]
124
+
125
+
126
+ # This function written with inspiration, assistance, and code
127
+ # from claude.ai & Github Copilot
128
+ def page_iter(num_pages: int) -> Generator[Tuple[int, int], None, None]:
129
+ """
130
+ Generate pairs of page numbers for printing a booklet.
131
+ This function assumes that the total number of pages is divisible by 4.
132
+ It yields tuples of page numbers that should be printed on the same sheet
133
+ of paper to create a booklet.
134
+
135
+ Args:
136
+ num_pages (int): The total number of pages in the document. Must be divisible by 4.
137
+
138
+ Yields:
139
+ Generator[Tuple[int, int], None, None]: Tuples containing pairs of page numbers.
140
+ Each tuple represents the page numbers to be printed on one side of a sheet.
141
+
142
+ Raises:
143
+ ValueError: If the number of pages is not divisible by 4.
144
+
145
+ """
146
+ if num_pages % 4 != 0:
147
+ raise ValueError("Number of pages must be divisible by 4")
148
+
149
+ for sheet in range(num_pages // 4):
150
+ # Outside the fold
151
+ last_page = num_pages - sheet * 2 - 1
152
+ first_page = sheet * 2
153
+
154
+ # Inside the fold
155
+ second_page = sheet * 2 + 1
156
+ second_to_last_page = num_pages - sheet * 2 - 2
157
+
158
+ yield last_page, first_page
159
+ yield second_page, second_to_last_page
@@ -37,11 +37,11 @@ Examples
37
37
  In case you don't want chapter 10 before chapter 2.
38
38
 
39
39
  """
40
+
40
41
  # Copyright (c) 2014, Steve Witham <switham_github@mac-guyver.com>.
41
42
  # All rights reserved. This software is available under a BSD license;
42
43
  # see https://github.com/py-pdf/pypdf/LICENSE
43
44
 
44
-
45
45
  import os
46
46
  import sys
47
47
  import traceback
@@ -52,7 +52,11 @@ from pypdf import PageRange, PdfReader, PdfWriter, parse_filename_page_ranges
52
52
 
53
53
 
54
54
  def main(
55
- filename: Path, fn_pgrgs: List[str], output: Path, verbose: bool
55
+ filename: Path,
56
+ fn_pgrgs: List[str],
57
+ output: Path,
58
+ verbose: bool,
59
+ inverted_page_selection: bool = False,
56
60
  ) -> None:
57
61
  filename_page_ranges = parse_filepaths_and_pagerange_args(
58
62
  filename, fn_pgrgs
@@ -66,15 +70,35 @@ def main(
66
70
  writer = PdfWriter()
67
71
  in_fs = {}
68
72
  try:
69
- for filename, page_range in filename_page_ranges: # type: ignore
73
+ for filepath, page_range in filename_page_ranges: # type: ignore
70
74
  if verbose:
71
- print(filename, page_range, file=sys.stderr)
72
- if filename not in in_fs:
73
- in_fs[filename] = open(filename, "rb")
74
-
75
- reader = PdfReader(in_fs[filename])
76
- for page_num in range(*page_range.indices(len(reader.pages))):
77
- writer.add_page(reader.pages[page_num])
75
+ print(filepath, page_range, file=sys.stderr)
76
+ if filepath not in in_fs:
77
+ in_fs[filepath] = open(filepath, "rb")
78
+
79
+ reader = PdfReader(in_fs[filepath])
80
+ num_pages = len(reader.pages)
81
+ start, end, step = page_range.indices(num_pages)
82
+ if (
83
+ start < 0
84
+ or end < 0
85
+ or start >= num_pages
86
+ or end > num_pages
87
+ or start > end
88
+ ):
89
+ print(
90
+ f"WARNING: Page range {page_range} is out of bounds",
91
+ file=sys.stderr,
92
+ )
93
+ if inverted_page_selection:
94
+ all_page_nums = set(range(len(reader.pages)))
95
+ page_nums = set(range(*page_range.indices(len(reader.pages))))
96
+ inverted_page_nums = all_page_nums - page_nums
97
+ for page_num in inverted_page_nums:
98
+ writer.add_page(reader.pages[page_num])
99
+ else:
100
+ for page_num in range(*page_range.indices(len(reader.pages))):
101
+ writer.add_page(reader.pages[page_num])
78
102
  writer.write(output_fh)
79
103
  except Exception:
80
104
  print(traceback.format_exc(), file=sys.stderr)
@@ -92,11 +116,11 @@ def parse_filepaths_and_pagerange_args(
92
116
  fn_pgrgs_l = list(fn_pgrgs)
93
117
  fn_pgrgs_l.insert(0, str(filename))
94
118
  filename_page_ranges, invalid_filepaths = [], []
95
- for filename, page_range in parse_filename_page_ranges(fn_pgrgs_l): # type: ignore
96
- if Path(filename).is_file():
97
- filename_page_ranges.append((filename, page_range))
119
+ for filepath, page_range in parse_filename_page_ranges(fn_pgrgs_l): # type: ignore
120
+ if Path(filepath).is_file():
121
+ filename_page_ranges.append((Path(filepath), page_range))
98
122
  else:
99
- invalid_filepaths.append(str(filename))
123
+ invalid_filepaths.append(str(filepath))
100
124
  if invalid_filepaths:
101
125
  print(
102
126
  f"Invalid file path or page range provided: {' '.join(invalid_filepaths)}",