Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ celerybeat.pid

# Environments
.env
.envrc
.venv
env/
venv/
Expand Down
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ repos:
rev: v3.19.1
hooks:
- id: pyupgrade
args: [--py36-plus]
args: [--py38-plus]
- repo: https://github.com/pycqa/flake8
rev: 7.1.1
hooks:
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@

### New Features (ENH)
- New `extract-annotated-pages` to filter out only the user annotated pages ([PR #98](https://github.com/py-pdf/pdfly/pull/98))
- New `rotate` sub-command to rotate specified pages ([PR #128](https://github.com/py-pdf/pdfly/pull/128))
- Added optional `--password` argument to `cat` to perform decryption ([PR #61](https://github.com/py-pdf/pdfly/pull/61))
- `pagemeta` now display known page formats when it can detect it: A3, A4, A5, Letter, Legal
- `pagemeta` now displays the rotation value.

### Bug Fixes (BUG)
- `pypdf[full]` is now a dependency, instead of just `pypdf`, to avoid some cases of `DependencyError`
Expand Down
18 changes: 18 additions & 0 deletions pdfly/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import pdfly.metadata
import pdfly.pagemeta
import pdfly.rm
import pdfly.rotate
import pdfly.uncompress
import pdfly.up2
import pdfly.update_offsets
Expand Down Expand Up @@ -346,3 +347,20 @@ def extract_annotated_pages(
] = None,
) -> None:
pdfly.extract_annotated_pages.main(input_pdf, output_pdf)


@entry_point.command(name="rotate", help=pdfly.rotate.__doc__) # type: ignore[misc]
def rotate(
filename: Annotated[
Path,
typer.Argument(
dir_okay=False,
exists=True,
resolve_path=True,
),
],
degrees: Annotated[int, typer.Argument(..., help="degrees to rotate")],
pgrgs: Annotated[str, typer.Argument(..., help="page range")] = ":",
output: Path = typer.Option(..., "-o", "--output"), # noqa
) -> None:
pdfly.rotate.main(filename, output, degrees, pgrgs)
7 changes: 6 additions & 1 deletion pdfly/pagemeta.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ class PageMeta(BaseModel):
artbox: Tuple[float, float, float, float]
bleedbox: Tuple[float, float, float, float]
annotations: int
rotation: int


def main(pdf: Path, page_index: int, output: OutputOptions) -> None:
Expand All @@ -37,6 +38,7 @@ def main(pdf: Path, page_index: int, output: OutputOptions) -> None:
artbox=page.artbox,
bleedbox=page.bleedbox,
annotations=len(page.annotations) if page.annotations else 0,
rotation=page.rotation,
)

if output == OutputOptions.json:
Expand Down Expand Up @@ -67,7 +69,10 @@ def add_box_attr(
add_box_attr("artbox", meta.artbox)
add_box_attr("bleedbox", meta.bleedbox)

table.add_row("annotations", str(meta.annotations))
if meta.annotations:
table.add_row("annotations", str(meta.annotations))
if meta.rotation:
table.add_row("rotation", str(meta.rotation))

console.print(table)

Expand Down
74 changes: 74 additions & 0 deletions pdfly/rotate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""
Rotate specified pages by the specified amount

Example:
pdfly rotate --output output.pdf input.pdf 90
Rotate all pages by 90 degrees (clockwise)

A file not followed by a page range (PGRGS) means all the pages of the file.

PAGE RANGES are like Python slices.

Remember, page indices start with zero.

Page range expression examples:

: all pages. -1 last page.
22 just the 23rd page. :-1 all but the last page.
0:3 the first three pages. -2 second-to-last page.
:3 the first three pages. -2: last two pages.
5: from the sixth page onward. -3:-1 third & second to last.

The third, "stride" or "step" number is also recognized.

::2 0 2 4 ... to the end. 3:0:-1 3 2 1 but not 0.
1:10:2 1 3 5 7 9 2::-1 2 1 0.
::-1 all pages in reverse order.


"""

from pathlib import Path
from typing import Set

from pypdf import (
PageRange,
PdfReader,
PdfWriter,
)
from rich.console import Console


def main(
filename: Path,
output: Path,
degrees: int,
page_range: str,
) -> None:
try:
# Set up the streams
reader = PdfReader(filename)
pages = list(reader.pages)
writer = PdfWriter()

# Convert the page range into a set of page numbers
pages_to_rotate = convert_range_to_pages(page_range, len(pages))

for page_index, page in enumerate(pages):
if page_index in pages_to_rotate:
page = page.rotate(degrees)
writer.add_page(page)

# Everything looks good! Write the output file.
with open(output, "wb") as output_fh:
writer.write(output_fh)

except Exception as error:
console = Console()
console.print(f"Error while rotating {filename}")
raise error


def convert_range_to_pages(page_range: str, num_pages: int) -> Set[int]:
pages_to_rotate = {*range(*PageRange(page_range).indices(num_pages))}
return pages_to_rotate
121 changes: 121 additions & 0 deletions tests/test_rotate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
from pathlib import Path
from typing import List
import pytest
from pypdf import PdfReader

from .conftest import RESOURCES_ROOT, chdir, run_cli


def test_rotate_fewer_args(capsys, tmp_path):
with chdir(tmp_path):
exit_code = run_cli(
[
"rotate",
]
)
assert exit_code == 2
captured = capsys.readouterr()
assert "Missing argument" in captured.err


def test_rotate_extra_args(capsys, tmp_path):
with chdir(tmp_path):
exit_code = run_cli(
[
"rotate",
"-o",
"/dev/null",
str(RESOURCES_ROOT / "box.pdf"),
"37",
"extra 1",
"extra 2",
]
)
assert exit_code == 2
captured = capsys.readouterr()
assert "unexpected extra argument" in captured.err


def get_page_rotations(fname: Path) -> List[int]:
reader = PdfReader(fname)
rotations = []
for page in reader.pages:
rotations.append(page.rotation)
return rotations


def diff_rotations(
in_: List[int], out: List[int], degrees: int = 0
) -> List[int]:
diffs = []
for orig, rotated in zip(in_, out):
diffs.append(rotated - (orig + degrees))
return diffs


def test_rotate_default(capsys, tmp_path):
in_fname = str(RESOURCES_ROOT / "input8.pdf")
out_fname = "output8.pdf"
degrees = 90
Comment thread
Lucas-C marked this conversation as resolved.

with chdir(tmp_path):
print(f"{tmp_path=}")
exit_code = run_cli(
[
"rotate",
"-o",
out_fname,
in_fname,
str(degrees),
]
)
in_rotations = get_page_rotations(in_fname)
out_rotations = get_page_rotations(out_fname)

assert exit_code == 0

assert not any(diff_rotations(in_rotations, out_rotations, degrees))


@pytest.mark.parametrize(
# NB "slice" can not be specified as the empty string
("degrees", "slice", "expected_diff"),
[
(90, ":", [90, 90, 90, 90, 90, 90, 90, 90]), # every page
(90, "::2", [90, 0, 90, 0, 90, 0, 90, 0]), # every other, even index
(90, "1::2", [0, 90, 0, 90, 0, 90, 0, 90]), # every other, odd index
(90, ":2", [90, 90, 0, 0, 0, 0, 0, 0]), # first 2
(
-90,
":",
[-90, -90, -90, -90, -90, -90, -90, -90],
), # negative degrees works
(
-720,
":",
[-720, -720, -720, -720, -720, -720, -720, -720],
), # |degrees| > 360 is also supported
],
)
def test_rotate_slices(capsys, tmp_path, degrees, slice, expected_diff):
in_fname = str(RESOURCES_ROOT / "input8.pdf")
out_fname = "output.pdf"
with chdir(tmp_path):
args = [
"rotate",
"-o",
f"{out_fname}",
f"{in_fname}",
"--", # end options, so negative degree values work
f"{degrees}",
f"{slice}",
]
exit_code = run_cli(args)
captured = capsys.readouterr()
assert exit_code == 0, captured.err

in_rotations = get_page_rotations(in_fname)
out_rotations = get_page_rotations(out_fname)
actual_diff = diff_rotations(in_rotations, out_rotations)

assert not any(diff_rotations(actual_diff, expected_diff))
Loading