From 18517dc56b2f4de0ea482e687385f630fe59ddb9 Mon Sep 17 00:00:00 2001 From: ElyousfiMohamed Date: Tue, 28 Jul 2026 19:58:43 +0100 Subject: [PATCH 1/2] Unify password support across pdfly and add page-range selection to extract-text --- docs/user/subcommand-2-up.md | 9 +- docs/user/subcommand-booklet.md | 8 + docs/user/subcommand-cat.md | 4 +- docs/user/subcommand-compress.md | 12 +- .../subcommand-extract-annotated-pages.md | 11 +- docs/user/subcommand-extract-images.md | 17 +- docs/user/subcommand-extract-text.md | 171 +++++++++- docs/user/subcommand-rm.md | 8 + docs/user/subcommand-rotate.md | 11 +- pdfly/_utils.py | 17 + pdfly/booklet.py | 5 + pdfly/cat.py | 12 +- pdfly/cli.py | 48 ++- pdfly/compress.py | 6 +- pdfly/extract_annotated_pages.py | 6 +- pdfly/extract_images.py | 6 +- pdfly/extract_text.py | 107 ++++++ pdfly/rm.py | 4 +- pdfly/rotate.py | 4 + pdfly/up2.py | 6 +- tests/test_extract_text.py | 205 ++++++++++++ tests/test_password_support.py | 315 ++++++++++++++++++ 22 files changed, 941 insertions(+), 51 deletions(-) create mode 100644 pdfly/extract_text.py create mode 100644 tests/test_extract_text.py create mode 100644 tests/test_password_support.py diff --git a/docs/user/subcommand-2-up.md b/docs/user/subcommand-2-up.md index a4a5f632..b850fc9c 100644 --- a/docs/user/subcommand-2-up.md +++ b/docs/user/subcommand-2-up.md @@ -19,7 +19,8 @@ $ pdfly 2-up --help │ * out PATH [default: None] [required] │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Options ────────────────────────────────────────────────────────────────────╮ -│ --help Show this message and exit. │ +│ --password TEXT Document's user or owner password. [default: None] │ +│ --help Show this message and exit. │ ╰──────────────────────────────────────────────────────────────────────────────╯ ``` @@ -30,3 +31,9 @@ Convert `document.pdf` into a booklet and write the output in `booklet.pdf`. pdfly 2-up document.pdf booklet.pdf ``` + +Convert a password-protected `document.pdf` into a booklet. +``` +pdfly 2-up --password=SECRET document.pdf booklet.pdf + +``` \ No newline at end of file diff --git a/docs/user/subcommand-booklet.md b/docs/user/subcommand-booklet.md index e94288b7..e5ee1855 100644 --- a/docs/user/subcommand-booklet.md +++ b/docs/user/subcommand-booklet.md @@ -30,6 +30,8 @@ $ pdfly booklet --help │ --centerfold-file -c FILE double-page added if input is missing >= 2 │ │ pages │ │ [default: None] │ +│ --password TEXT Document's user or owner password. │ +│ [default: None] │ │ --help Show this message and exit. │ ╰──────────────────────────────────────────────────────────────────────────────╯ @@ -42,3 +44,9 @@ Convert `document.pdf` into a booklet and write the output in `booklet.pdf`. pdfly booklet document.pdf booklet.pdf ``` + +Convert a password-protected `document.pdf` into a booklet. +``` +pdfly booklet --password=SECRET document.pdf booklet.pdf + +``` \ No newline at end of file diff --git a/docs/user/subcommand-cat.md b/docs/user/subcommand-cat.md index e3d8e3b2..3c2c91d8 100644 --- a/docs/user/subcommand-cat.md +++ b/docs/user/subcommand-cat.md @@ -59,6 +59,8 @@ pdfly cat --help ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Options ────────────────────────────────────────────────────────────────────╮ │ * --output -o PATH [default: None] [required] │ +│ --password TEXT Document's user or owner password. │ +│ [default: None] │ │ --verbose --no-verbose show page ranges as they are being │ │ read │ │ [default: no-verbose] │ @@ -108,4 +110,4 @@ pdfly cat input1.pdf input2.pdf -o out.pdf ``` pdfly cat --password=SECRET doc.pdf -o doc-decrypted.pdf -``` +``` \ No newline at end of file diff --git a/docs/user/subcommand-compress.md b/docs/user/subcommand-compress.md index 449bbb7f..75ba261f 100644 --- a/docs/user/subcommand-compress.md +++ b/docs/user/subcommand-compress.md @@ -15,8 +15,10 @@ $ pdfly compress --help ╭─ Arguments ───────────────────────────────────────────╮ │ * pdf FILE [default: None] [required] │ │ * output PATH [default: None] [required] │ -╰───────────────────────────────────────────────────────╯ +╰─────────────────────────────────────────────────────────╯ ╭─ Options ─────────────────────────────────────────────╮ +│ --password TEXT Document's user or owner │ +│ password. [default: None] │ │ --help Show this message and exit. │ ╰───────────────────────────────────────────────────────╯ ``` @@ -28,6 +30,12 @@ Compress the file `document.pdf` and output `document_compressed.pdf` pdfly compress document.pdf document_compressed.pdf ``` +Compress a password-protected file: + +``` +pdfly compress --password=SECRET document.pdf document_compressed.pdf +``` + Example output when compression succeeds: ``` Original Size : 1,996,123 @@ -38,4 +46,4 @@ Example output when compression would increase file size: ``` Original Size : 887 Final Size : 887 (No compression applied (would increase size)) -``` +``` \ No newline at end of file diff --git a/docs/user/subcommand-extract-annotated-pages.md b/docs/user/subcommand-extract-annotated-pages.md index 03273027..88f06ab0 100644 --- a/docs/user/subcommand-extract-annotated-pages.md +++ b/docs/user/subcommand-extract-annotated-pages.md @@ -18,8 +18,9 @@ pdfly extract-annotated-pages --help │ * input_pdf FILE Input PDF file. [required] │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─ Options ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ --output -o PATH Output PDF file. Defaults to 'input_pdf_annotated'. │ -│ --help Show this message and exit. │ +│ --output -o PATH Output PDF file. Defaults to 'input_pdf_annotated'. │ +│ --password TEXT Document's user or owner password. [default: None] │ +│ --help Show this message and exit. │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ``` @@ -42,3 +43,9 @@ Extracts only pages containing annotations from a file `input.pdf` into the give ``` pdfly extract-annotated-pages input.pdf -o pages_to_rework.pdf ``` + +### Decrypt a PDF document + +``` +pdfly extract-annotated-pages --password=SECRET input.pdf -o pages_to_rework.pdf +``` \ No newline at end of file diff --git a/docs/user/subcommand-extract-images.md b/docs/user/subcommand-extract-images.md index 89bed3c4..2a70d22a 100644 --- a/docs/user/subcommand-extract-images.md +++ b/docs/user/subcommand-extract-images.md @@ -1,6 +1,6 @@ # extract-images -Extract text from a PDF file. +Extract images from a PDF file. ## Usage ``` @@ -17,20 +17,27 @@ $ pdfly extract-images --help │ * pdf FILE [default: None] [required] │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Options ────────────────────────────────────────────────────────────────────╮ -│ --help Show this message and exit. │ +│ --password TEXT Document's user or owner password. [default: None] │ +│ --help Show this message and exit. │ ╰──────────────────────────────────────────────────────────────────────────────╯ ``` ## Examples -Extract the first page of `document.pdf` and extract the images present in it. +Extract the tenth page of `document.pdf` and extract the images present in it. ``` pdfly cat document.pdf 9 -o page.pdf -pdfly extract-text page.pdf +pdfly extract-images page.pdf Extracted 1 images: - - 0-Im0.png + - 0000-Im0.png ``` + +Extract images from a password-protected document: + +``` +pdfly extract-images --password=SECRET document.pdf +``` \ No newline at end of file diff --git a/docs/user/subcommand-extract-text.md b/docs/user/subcommand-extract-text.md index ff84c581..9b959fa0 100644 --- a/docs/user/subcommand-extract-text.md +++ b/docs/user/subcommand-extract-text.md @@ -1,31 +1,190 @@ # extract-text -Extract text from a PDF file. +Extract text from PDF files, optionally restricted to specific pages. + ## Usage ``` $ pdfly extract-text --help - Usage: pdfly extract-text [OPTIONS] PDF + Usage: pdfly extract-text [OPTIONS] FILENAME [FN_PGRGS]... + + Extract text from PDF files, optionally restricted to specific pages. + + Multiple files may be given, each optionally followed by a page range + that applies to the file named immediately before it. A file not + followed by a page range means all the pages of that file. Text is + printed in the order the files and page ranges are given. + + PAGE RANGES are like Python slices. + + Remember, page indices start with zero. + + When using page ranges that start with a negative value a + two-hyphen symbol -- must be used to separate them from + the command line options. + + 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. - Extract text from a PDF file. + 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. + + Examples + pdfly extract-text report.pdf + Print the text of every page of report.pdf. + + pdfly extract-text report.pdf :5 + Print the text of the first five pages of report.pdf. + + pdfly extract-text intro.pdf :3 body.pdf -- -1 + Print the text of the first three pages of intro.pdf, followed + by the text of the last page of body.pdf. + + pdfly extract-text report.pdf --output extracted.txt + Write the extracted text to extracted.txt instead of stdout. ╭─ Arguments ──────────────────────────────────────────────────────────────────╮ -│ * pdf FILE [default: None] [required] │ +│ * filename FILE [default: None] [required] │ +│ fn_pgrgs FN_PGRGS... filenames and/or page ranges [default: None] │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Options ────────────────────────────────────────────────────────────────────╮ -│ --help Show this message and exit. │ +│ --output -o PATH Write text to a file instead of stdout. │ +│ [default: None] │ +│ --password TEXT Document's user or owner password. │ +│ [default: None] │ +│ --help Show this message and exit. │ ╰──────────────────────────────────────────────────────────────────────────────╯ ``` ## Examples -Extract the text from the 10th page of `document.pdf`, redirecting the output into `page.txt`. +### Extract the text of an entire document + +If you give `extract-text` just a filename and no page range, it treats +that as "all pages" — the same as an explicit `:` range — and prints the +text of every page, in order, to stdout. + +``` +pdfly extract-text document.pdf +``` + +### Extract the text of a single page + +Page ranges use zero-based indices, so the 10th page of a document is +index `9`, not `10`. You can pull a single page out into its own file +first with `cat` and then run `extract-text` on that: ``` pdfly cat document.pdf 9 -o page.pdf pdfly extract-text page.pdf +``` + +But since `extract-text` accepts a page range directly, you don't need +the intermediate file at all — just give the filename followed by the +page index: ``` +pdfly extract-text document.pdf 9 +``` + +Both commands produce identical output; the second is just shorter. + +### Extract text from a range of pages + +Page ranges follow Python slice syntax. `:5` means "everything before +index 5", i.e. the first five pages (indices 0-4): + +``` +pdfly extract-text report.pdf :5 +``` + +Other useful ranges: `5:` (from the sixth page to the end), `2:8` +(pages 3 through 8), `::2` (every other page), and `::-1` (all pages, +reversed). See the full slice-syntax table in the `--help` output above. + +### Extract a page counted from the end + +Negative indices count backward from the last page (`-1` is the last +page, `-2` is the second-to-last, and so on). Because a leading `-` +would otherwise be parsed as a command-line option, you must insert a +bare `--` immediately before any negative range to tell pdfly "the rest +of these are positional arguments, not options": + +``` +pdfly extract-text report.pdf -- -1 +``` + +This prints just the text of the last page of `report.pdf`. + +### Extract text across multiple files + +You can pass several files in one command, each optionally followed by +its own page range. A file with no range after it means "all of that +file's pages." Ranges apply only to the file named immediately before +them, and output is printed in the order you list things: + +``` +pdfly extract-text intro.pdf :3 body.pdf -- -1 +``` + +This prints the first three pages of `intro.pdf` (indices 0-2), +followed by the last page of `body.pdf`. Note that only one `--` is +needed even though it applies to the last range in the list — see the +next section for why it has to go there. + +### Write extracted text to a file + +By default, text is printed to stdout. Use `--output`/`-o` to write it +to a file instead: + +``` +pdfly extract-text report.pdf --output extracted.txt +``` + +### Decrypt a password-protected document + +If the PDF is encrypted, supply its user or owner password with +`--password`. pdfly decrypts the file in memory before extracting text +— it doesn't modify the original file: + +``` +pdfly extract-text --password=SECRET document.pdf +``` + +## Important: ordering `--password`/`--output` with negative page ranges + +The `--` marker doesn't just apply to the one negative number next to +it — once pdfly's argument parser sees `--`, it treats **everything +after it** as positional arguments (filenames and page ranges), not as +options. That means `--password` and `--output` must appear **before** +the `--`, and the `-- ` portion should be the last +thing on the command line. + +**Correct** — options come first, `-- -1` comes last: + +``` +pdfly extract-text --password=SECRET --output extracted.txt document.pdf -- -1 +``` + +**Incorrect** — putting `--output` after the `--` means it gets +swallowed up as a positional argument (a bogus "filename") instead of +being parsed as an option, and the command will fail or behave +unexpectedly: + +``` +pdfly extract-text document.pdf -- -1 --output extracted.txt +``` + +The same rule applies any time you combine `--password`/`--output` with +a file that ends in a negative page range — always finish the command +with the `--`-prefixed negative range, and put every option ahead of it. \ No newline at end of file diff --git a/docs/user/subcommand-rm.md b/docs/user/subcommand-rm.md index f024562a..efab80b9 100644 --- a/docs/user/subcommand-rm.md +++ b/docs/user/subcommand-rm.md @@ -55,6 +55,7 @@ Usage: pdfly rm [OPTIONS] FILENAME FN_PGRGS... ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─ Options ───────────────────────────────────────────────────────────────────────────────────────────────╮ │ * --output -o PATH [default: None] [required] │ +│ --password TEXT Document's user or owner password. [default: None] │ │ --verbose --no-verbose show page ranges as they are being read [default: no-verbose] │ │ --help Show this message and exit. │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ @@ -75,3 +76,10 @@ Remove the first and last page of `document.pdf`, producing `output.pdf`. pdfly rm -o output.pdf document.pdf 1:-1 ``` + +Remove pages from a password-protected document. + +``` +pdfly rm --password=SECRET -o output.pdf document.pdf 1:-1 + +``` \ No newline at end of file diff --git a/docs/user/subcommand-rotate.md b/docs/user/subcommand-rotate.md index ee1dea0d..56f56780 100644 --- a/docs/user/subcommand-rotate.md +++ b/docs/user/subcommand-rotate.md @@ -49,8 +49,9 @@ pdfly rotate --help │ pgrgs [PGRGS] page range [default: :] │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─ Options ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ * --output -o PATH [required] │ -│ --help Show this message and exit. │ +│ * --output -o PATH [required] │ +│ --password TEXT Document's user or owner password. [default: None] │ +│ --help Show this message and exit. │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ``` @@ -79,3 +80,9 @@ Rotate last page from `input.pdf` by 90 degrees (clockwise) and write the result ``` pdfly rotate --output output.pdf input.pdf 90 -- -1 ``` + +### Rotate pages in a password-protected document + +``` +pdfly rotate --output output.pdf --password=SECRET input.pdf 90 +``` \ No newline at end of file diff --git a/pdfly/_utils.py b/pdfly/_utils.py index 0d25432f..62548e7b 100644 --- a/pdfly/_utils.py +++ b/pdfly/_utils.py @@ -1,6 +1,23 @@ +import sys from enum import Enum +from pypdf import PasswordType, PdfReader +from rich.console import Console + class OutputOptions(Enum): json = "json" text = "text" + +def decrypt_or_exit( + reader: PdfReader, password: str | None, console: Console +) -> None: + """Decrypt reader with password, or print the standard error and exit(1).""" + if ( + password is not None + and reader.decrypt(password) == PasswordType.NOT_DECRYPTED + ): + console.print( + "[red]Error: the decrypting password provided is invalid" + ) + sys.exit(1) diff --git a/pdfly/booklet.py b/pdfly/booklet.py index 665e262f..95860104 100644 --- a/pdfly/booklet.py +++ b/pdfly/booklet.py @@ -26,6 +26,9 @@ PdfWriter, ) from pypdf.generic import FloatObject, RectangleObject +from rich.console import Console + +from pdfly._utils import decrypt_or_exit def main( @@ -33,10 +36,12 @@ def main( output: Path, inside_cover_file: Path | None, centerfold_file: Path | None, + password: str | None = None ) -> None: try: # Set up the streams reader = PdfReader(filename) + decrypt_or_exit(reader, password, Console()) pages = list(reader.pages) writer = PdfWriter() diff --git a/pdfly/cat.py b/pdfly/cat.py index 20671ec8..7a9a2d6e 100644 --- a/pdfly/cat.py +++ b/pdfly/cat.py @@ -52,13 +52,14 @@ from pypdf import ( PageRange, - PasswordType, PdfReader, PdfWriter, parse_filename_page_ranges, ) from rich.console import Console +from pdfly._utils import decrypt_or_exit + def main( filename: Path, @@ -88,14 +89,7 @@ def main( in_fs[filepath] = open(filepath, "rb") reader = PdfReader(in_fs[filepath]) - if ( - password is not None - and reader.decrypt(password) == PasswordType.NOT_DECRYPTED - ): - console.print( - "[red]Error: the decrypting password provided is invalid" - ) - sys.exit(1) + decrypt_or_exit(reader, password, console) num_pages = len(reader.pages) start, end, _step = page_range.indices(num_pages) if ( diff --git a/pdfly/cli.py b/pdfly/cli.py index 99f9280d..36e4f470 100644 --- a/pdfly/cli.py +++ b/pdfly/cli.py @@ -15,6 +15,7 @@ import pdfly.compress import pdfly.extract_annotated_pages import pdfly.extract_images +import pdfly.extract_text import pdfly.metadata import pdfly.pagemeta import pdfly.rm @@ -63,8 +64,10 @@ def up2( ), ], out: Path, + password: str = typer.Option(None, help="Document's user or owner password."), + ) -> None: - pdfly.up2.main(pdf, out) + pdfly.up2.main(pdf, out, password=password) @entry_point.command(name="booklet", help=pdfly.booklet.__doc__) # type: ignore[misc] @@ -107,9 +110,9 @@ def booklet( resolve_path=True, ), ] = None, + password: str = typer.Option(None, help="Document's user or owner password."), ) -> None: - pdfly.booklet.main(filename, output, blank_page, centerfold) - + pdfly.booklet.main(filename, output, blank_page, centerfold, password=password) @entry_point.command(name="cat", help=pdfly.cat.__doc__) # type: ignore[misc] def cat( @@ -176,8 +179,9 @@ def compress( writable=True, ), ], + password: str = typer.Option(None, help="Document's user or owner password."), ) -> None: - pdfly.compress.main(pdf, output) + pdfly.compress.main(pdf, output, password=password) @entry_point.command(name="extract-annotated-pages", help=pdfly.extract_annotated_pages.__doc__) # type: ignore[misc] @@ -200,8 +204,9 @@ def extract_annotated_pages( help="Output PDF file. Defaults to 'input_pdf_annotated'.", ), ] = None, + password: str = typer.Option(None, help="Document's user or owner password."), ) -> None: - pdfly.extract_annotated_pages.main(input_pdf, output_pdf) + pdfly.extract_annotated_pages.main(input_pdf, output_pdf, password=password) @entry_point.command(name="extract-images", help=pdfly.extract_images.__doc__) # type: ignore[misc] @@ -214,13 +219,14 @@ def extract_images( resolve_path=True, ), ], + password: str = typer.Option(None, help="Document's user or owner password."), ) -> None: - pdfly.extract_images.main(pdf) + pdfly.extract_images.main(pdf, password=password) -@entry_point.command(name="extract-text") # type: ignore[misc] +@entry_point.command(name="extract-text", help=pdfly.extract_text.__doc__) # type: ignore[misc] def extract_text( - pdf: Annotated[ + filename: Annotated[ Path, typer.Argument( dir_okay=False, @@ -228,13 +234,19 @@ def extract_text( resolve_path=True, ), ], + fn_pgrgs: list[str] | None = typer.Argument( # noqa: B008 + None, allow_dash=True, help="filenames and/or page ranges" + ), + output: Path = typer.Option( # noqa: B008 + None, "-o", "--output", help="Write text to a file instead of stdout." + ), + password: str = typer.Option( + None, help="Document's user or owner password." + ), ) -> None: - """Extract text from a PDF file.""" - from pypdf import PdfReader - - reader = PdfReader(str(pdf)) - for page in reader.pages: - typer.echo(page.extract_text()) + pdfly.extract_text.main( + filename, fn_pgrgs, output=output, password=password + ) @entry_point.command(name="meta", help=pdfly.metadata.__doc__) # type: ignore[misc] @@ -298,11 +310,12 @@ def rm( fn_pgrgs: list[str] = typer.Argument( # noqa ..., help="filenames and/or page ranges" ), + password: str = typer.Option(None, help="Document's user or owner password."), verbose: bool = typer.Option( - False, help="show page ranges as they are being read" + False, help="show page ranges as they are being read" ), ) -> None: - pdfly.rm.main(filename, fn_pgrgs, output, verbose) + pdfly.rm.main(filename, fn_pgrgs, output, verbose, password=password) @entry_point.command(name="rotate", help=pdfly.rotate.__doc__) # type: ignore[misc] @@ -318,8 +331,9 @@ def rotate( degrees: Annotated[int, typer.Argument(..., help="degrees to rotate")], pgrgs: Annotated[str, typer.Argument(..., help="page range")] = ":", output: Path = typer.Option(..., "-o", "--output"), # noqa + password: str = typer.Option(None, help="Document's user or owner password."), ) -> None: - pdfly.rotate.main(filename, output, degrees, pgrgs) + pdfly.rotate.main(filename, output, degrees, pgrgs, password=password) @entry_point.command(name="sign", help=pdfly.sign.__doc__) diff --git a/pdfly/compress.py b/pdfly/compress.py index 7b8c053c..90254ce5 100644 --- a/pdfly/compress.py +++ b/pdfly/compress.py @@ -5,10 +5,14 @@ from pathlib import Path from pypdf import PdfReader, PdfWriter +from rich.console import Console +from pdfly._utils import decrypt_or_exit -def main(pdf: Path, output: Path) -> None: + +def main(pdf: Path, output: Path, password: str | None = None) -> None: reader = PdfReader(pdf) + decrypt_or_exit(reader, password, Console()) writer = PdfWriter() for page in reader.pages: writer.add_page(page) diff --git a/pdfly/extract_annotated_pages.py b/pdfly/extract_annotated_pages.py index 87b3847b..470ce61e 100644 --- a/pdfly/extract_annotated_pages.py +++ b/pdfly/extract_annotated_pages.py @@ -10,6 +10,9 @@ from pypdf import PdfReader, PdfWriter from pypdf.annotations import AnnotationDictionary +from rich.console import Console + +from pdfly._utils import decrypt_or_exit if TYPE_CHECKING: from pypdf.generic import ArrayObject @@ -21,10 +24,11 @@ def is_manipulable(annot: AnnotationDictionary) -> bool: # Main function. -def main(input_pdf: Path, output_pdf: Path | None) -> None: +def main(input_pdf: Path, output_pdf: Path | None, password: str | None = None) -> None: if not output_pdf: output_pdf = input_pdf.with_name(input_pdf.stem + "_annotated.pdf") input = PdfReader(input_pdf) + decrypt_or_exit(input, password, Console()) output = PdfWriter() output_pages = 0 # Copy only the pages with annotations diff --git a/pdfly/extract_images.py b/pdfly/extract_images.py index 6ce32086..26755892 100644 --- a/pdfly/extract_images.py +++ b/pdfly/extract_images.py @@ -8,10 +8,14 @@ from pathlib import Path from pypdf import PdfReader +from rich.console import Console +from pdfly._utils import decrypt_or_exit -def main(pdf: Path) -> None: + +def main(pdf: Path, password: str | None = None) -> None: reader = PdfReader(str(pdf)) + decrypt_or_exit(reader, password, Console()) extracted_images = [] for page_index, page0 in enumerate(reader.pages): for image_file_object in page0.images: diff --git a/pdfly/extract_text.py b/pdfly/extract_text.py new file mode 100644 index 00000000..8fa71b9c --- /dev/null +++ b/pdfly/extract_text.py @@ -0,0 +1,107 @@ +""" +Extract text from PDF files, optionally restricted to specific pages. + +Multiple files may be given, each optionally followed by a page range +that applies to the file named immediately before it. A file not +followed by a page range means all the pages of that file. Text is +printed in the order the files and page ranges are given. + +PAGE RANGES are like Python slices. + + Remember, page indices start with zero. + + When using page ranges that start with a negative value a + two-hyphen symbol -- must be used to separate them from + the command line options. + + 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. + +Examples + pdfly extract-text report.pdf + + Print the text of every page of report.pdf. + + pdfly extract-text report.pdf :5 + + Print the text of the first five pages of report.pdf. + + pdfly extract-text intro.pdf :3 body.pdf -- -1 + + Print the text of the first three pages of intro.pdf, followed + by the text of the last page of body.pdf. + + pdfly extract-text report.pdf --output extracted.txt + + Write the extracted text to extracted.txt instead of stdout. + +""" + +import sys +from pathlib import Path + +from pypdf import PdfReader +from rich.console import Console + +from pdfly._utils import decrypt_or_exit +from pdfly.cat import parse_filepaths_and_pagerange_args + + +def main( + filename: Path, + fn_pgrgs: list[str] | None, + output: Path | None = None, + password: str | None = None, +) -> None: + console = Console() + filename_page_ranges = parse_filepaths_and_pagerange_args( + console, filename, fn_pgrgs + ) + + if output: + output_fh = open(output, "w", encoding="utf-8") + else: + output_fh = sys.stdout + + in_fs = {} + try: + for filepath, page_range in filename_page_ranges: + if filepath not in in_fs: + in_fs[filepath] = open(filepath, "rb") + + reader = PdfReader(in_fs[filepath]) + decrypt_or_exit(reader, password, Console()) + + num_pages = len(reader.pages) + start, end, _step = page_range.indices(num_pages) + if ( + start < 0 + or end < 0 + or start >= num_pages + or end > num_pages + or start > end + ): + print( + f"WARNING: Page range {page_range} is out of bounds", + file=sys.stderr, + ) + + for page_num in range(*page_range.indices(num_pages)): + text = reader.pages[page_num].extract_text() + print(text, file=output_fh) + finally: + if output: + output_fh.close() + for fh in in_fs.values(): + fh.close() diff --git a/pdfly/rm.py b/pdfly/rm.py index e5db6695..e478b2f9 100644 --- a/pdfly/rm.py +++ b/pdfly/rm.py @@ -52,6 +52,6 @@ def main( - filename: Path, fn_pgrgs: list[str], output: Path, verbose: bool + filename: Path, fn_pgrgs: list[str], output: Path, verbose: bool, password: str | None = None ) -> None: - cat_main(filename, fn_pgrgs, output, verbose, inverted_page_selection=True) + cat_main(filename, fn_pgrgs, output, verbose, inverted_page_selection=True, password=password) diff --git a/pdfly/rotate.py b/pdfly/rotate.py index eefeaf22..76d47198 100644 --- a/pdfly/rotate.py +++ b/pdfly/rotate.py @@ -47,16 +47,20 @@ ) from rich.console import Console +from pdfly._utils import decrypt_or_exit + def main( filename: Path, output: Path, degrees: int, page_range: str, + password: str | None = None ) -> None: try: # set up the streams reader = PdfReader(filename) + decrypt_or_exit(reader, password, Console()) pages = list(reader.pages) writer = PdfWriter() diff --git a/pdfly/up2.py b/pdfly/up2.py index 9181daf9..5c0c58cf 100644 --- a/pdfly/up2.py +++ b/pdfly/up2.py @@ -10,10 +10,14 @@ from pypdf import PdfReader, PdfWriter from pypdf.generic import FloatObject +from rich.console import Console +from pdfly._utils import decrypt_or_exit -def main(pdf: Path, output: Path) -> None: + +def main(pdf: Path, output: Path, password: str | None = None) -> None: reader = PdfReader(str(pdf)) + decrypt_or_exit(reader, password, Console()) writer = PdfWriter() for i in range(0, len(reader.pages), 2): lhs = reader.pages[i] diff --git a/tests/test_extract_text.py b/tests/test_extract_text.py new file mode 100644 index 00000000..94db9ae7 --- /dev/null +++ b/tests/test_extract_text.py @@ -0,0 +1,205 @@ +"""Tests for extract-text's page-range, multi-file, and output-to-file +support. + +Covers slicing, negative/out-of-range indices, combining multiple +files (each with its own range) in argument order, repeated files, +writing to a file instead of stdout, and the malformed-range / +invalid-filepath error contract shared with cat and rm. +extract-text's --password support is covered in +test_password_support alongside the other commands that +gained it in this change. +""" + +from pathlib import Path + +import pytest + +from .conftest import RESOURCES_ROOT, chdir, run_cli + + +def get_extracted_numbers(output: str) -> list[int]: + """pdf_file_100 pages each contain only the page index as text.""" + return [int(line) for line in output.splitlines() if line.strip()] + + +def get_extracted_letters(output: str) -> list[str]: + """pdf_file_abc pages each contain only a single letter as text.""" + return [line for line in output.splitlines() if line.strip()] + +@pytest.mark.parametrize( + ("slice", "expected"), + [ + (":", list(range(100))), # every page + (":5", [0, 1, 2, 3, 4]), # first five + ("95:", [95, 96, 97, 98, 99]), # from page 95 onward + ("::2", list(range(0, 100, 2))), # every other, even index + ("1::2", list(range(1, 100, 2))), # every other, odd index + ("2::-1", [2, 1, 0]), # reversed, first three + ("::-1", list(range(99, -1, -1))), # full-document reverse order + ("95:200", [95, 96, 97, 98, 99]), # out-of-bounds end, clamps like slice + ], +) +def test_extract_text_slices( + capsys: pytest.CaptureFixture, + pdf_file_100: Path, + slice: str, + expected: list[int], +) -> None: + args = [ + "extract-text", + str(pdf_file_100), + "--", # end options, so negative slice values work + slice, + ] + exit_code = run_cli(args) + captured = capsys.readouterr() + + assert exit_code == 0, captured.err + assert get_extracted_numbers(captured.out) == expected + + +def test_extract_text_negative_index( + capsys: pytest.CaptureFixture, pdf_file_100: Path +) -> None: + args = ["extract-text", str(pdf_file_100), "--", "-1"] + exit_code = run_cli(args) + captured = capsys.readouterr() + + assert exit_code == 0, captured.err + assert get_extracted_numbers(captured.out) == [99] + + +def test_extract_text_single_page( + capsys: pytest.CaptureFixture, pdf_file_100: Path +) -> None: + exit_code = run_cli(["extract-text", str(pdf_file_100), "42"]) + captured = capsys.readouterr() + + assert exit_code == 0, captured.err + assert get_extracted_numbers(captured.out) == [42] + + +def test_extract_text_malformed_range( + capsys: pytest.CaptureFixture, pdf_file_100: Path, tmp_path: Path +) -> None: + """A malformed range should fail the same way cat/rm do today: a + clean exit code 2 plus an "invalid file path or page range" message, + since extract-text now shares cat's multi-file/multi-range parser.""" + with chdir(tmp_path): + exit_code = run_cli( + ["extract-text", str(pdf_file_100), "not-a-range"] + ) + captured = capsys.readouterr() + assert exit_code == 2 + assert "Error: invalid file path or page range provided" in captured.out + + +def test_extract_text_invalid_filepath( + capsys: pytest.CaptureFixture, tmp_path: Path +) -> None: + """A nonexistent path given as a second (or later) file argument goes + through the shared cat/rm parser and must fail the same way: exit + code 2 with the exact "invalid file path or page range" message on + stdout. (The first, primary filename argument is validated earlier + by typer's own exists=True check and is covered separately.)""" + with chdir(tmp_path): + exit_code = run_cli( + [ + "extract-text", + str(RESOURCES_ROOT / "box.pdf"), + "does-not-exist.pdf", + ] + ) + captured = capsys.readouterr() + assert exit_code == 2 + assert ( + "Error: invalid file path or page range provided" in captured.out + ) + assert "does-not-exist.pdf" in captured.out + + +def test_extract_text_combine_files( + capsys: pytest.CaptureFixture, + pdf_file_100: Path, + pdf_file_abc: Path, + tmp_path: Path, +) -> None: + """Multiple files, each with its own page range, print in the order + they were given -- same convention as `cat`.""" + with chdir(tmp_path): + exit_code = run_cli( + [ + "extract-text", + str(pdf_file_100), + "1:4", + str(pdf_file_abc), + "::2", + ] + ) + captured = capsys.readouterr() + assert exit_code == 0, captured.err + + lines = [line for line in captured.out.splitlines() if line.strip()] + assert lines[:3] == ["1", "2", "3"] + assert lines[3:] == list("acegikmoqsuwy") + + +def test_extract_text_repeated_file( + capsys: pytest.CaptureFixture, pdf_file_abc: Path, tmp_path: Path +) -> None: + """The same file may be named twice, each time with a different + page range applying only to the pages named after it.""" + with chdir(tmp_path): + exit_code = run_cli( + [ + "extract-text", + str(pdf_file_abc), + "0", + str(pdf_file_abc), + "--", + "-1", + ] + ) + captured = capsys.readouterr() + assert exit_code == 0, captured.err + assert get_extracted_letters(captured.out) == ["a", "z"] + + +def test_extract_text_output_to_file( + capsys: pytest.CaptureFixture, pdf_file_100: Path, tmp_path: Path +) -> None: + out_path = tmp_path / "extracted.txt" + exit_code = run_cli( + [ + "extract-text", + str(pdf_file_100), + ":3", + "--output", + str(out_path), + ] + ) + captured = capsys.readouterr() + assert exit_code == 0, captured.err + assert not captured.out + + assert get_extracted_numbers(out_path.read_text()) == [0, 1, 2] + + +def test_extract_text_output_to_file_short_option( + capsys: pytest.CaptureFixture, pdf_file_100: Path, tmp_path: Path +) -> None: + out_path = tmp_path / "extracted.txt" + exit_code = run_cli( + [ + "extract-text", + str(pdf_file_100), + ":3", + "-o", + str(out_path), + ] + ) + captured = capsys.readouterr() + assert exit_code == 0, captured.err + assert not captured.out + + assert get_extracted_numbers(out_path.read_text()) == [0, 1, 2] \ No newline at end of file diff --git a/tests/test_password_support.py b/tests/test_password_support.py new file mode 100644 index 00000000..d1cde599 --- /dev/null +++ b/tests/test_password_support.py @@ -0,0 +1,315 @@ +"""Tests for --password support across pdfly commands that read PDFs. + +Covers cat's siblings that gained --password in this change: rm, +extract-text, extract-images, extract-annotated-pages, compress, +rotate, 2-up, and booklet. cat itself already had password support +before this change and is covered separately by test_cat.py. +""" + +from pathlib import Path + +import pytest + +from .conftest import chdir, run_cli + +INVALID_PASSWORD_MSG = "Error: the decrypting password provided is invalid" + + +# ---------- extract-text ---------- + +def get_extracted_numbers(output: str) -> list[int]: + """pdf_file_100 pages each contain only the page index as text.""" + return [int(line) for line in output.splitlines() if line.strip()] + + +def test_extract_text_password_ok( + capsys: pytest.CaptureFixture, encrypted_pdf_filepath: Path +) -> None: + exit_code = run_cli( + [ + "extract-text", + "--password=openpassword", + str(encrypted_pdf_filepath), + ] + ) + captured = capsys.readouterr() + assert exit_code == 0, captured + assert not captured.err + assert get_extracted_numbers(captured.out) == [0, 1, 2] + + +def test_extract_text_password_invalid( + capsys: pytest.CaptureFixture, encrypted_pdf_filepath: Path +) -> None: + exit_code = run_cli( + [ + "extract-text", + "--password=WRONG", + str(encrypted_pdf_filepath), + ] + ) + captured = capsys.readouterr() + assert exit_code == 1, captured + assert INVALID_PASSWORD_MSG in captured.out + + +# ---------- rm ---------- + +def test_rm_password_ok( + capsys: pytest.CaptureFixture, encrypted_pdf_filepath: Path, tmp_path: Path +) -> None: + output = tmp_path / "out.pdf" + exit_code = run_cli( + [ + "rm", + str(encrypted_pdf_filepath), + "-o", + str(output), + "--password=openpassword", + "0", + ] + ) + captured = capsys.readouterr() + assert exit_code in (0, None), captured.err + assert output.exists() + + +def test_rm_password_invalid( + capsys: pytest.CaptureFixture, encrypted_pdf_filepath: Path, tmp_path: Path +) -> None: + output = tmp_path / "out.pdf" + exit_code = run_cli( + [ + "rm", + str(encrypted_pdf_filepath), + "-o", + str(output), + "--password=WRONG", + "0", + ] + ) + captured = capsys.readouterr() + assert exit_code == 1 + assert INVALID_PASSWORD_MSG in captured.out + + +# ---------- extract-images ---------- + +def test_extract_images_password_ok( + capsys: pytest.CaptureFixture, + encrypted_pdf_filepath: Path, + tmp_path: Path, +) -> None: + with chdir(tmp_path): + exit_code = run_cli( + [ + "extract-images", + str(encrypted_pdf_filepath), + "--password=openpassword", + ] + ) + captured = capsys.readouterr() + assert exit_code in (0, None), captured.err + + +def test_extract_images_password_invalid( + capsys: pytest.CaptureFixture, + encrypted_pdf_filepath: Path, + tmp_path: Path, +) -> None: + with chdir(tmp_path): + exit_code = run_cli( + [ + "extract-images", + str(encrypted_pdf_filepath), + "--password=WRONG", + ] + ) + captured = capsys.readouterr() + assert exit_code == 1 + assert INVALID_PASSWORD_MSG in captured.out + + +# ---------- extract-annotated-pages ---------- + +def test_extract_annotated_pages_password_ok( + capsys: pytest.CaptureFixture, encrypted_pdf_filepath: Path, tmp_path: Path +) -> None: + output = tmp_path / "annotated.pdf" + exit_code = run_cli( + [ + "extract-annotated-pages", + str(encrypted_pdf_filepath), + "-o", + str(output), + "--password=openpassword", + ] + ) + captured = capsys.readouterr() + assert exit_code in (0, None), captured.err + + +def test_extract_annotated_pages_password_invalid( + capsys: pytest.CaptureFixture, encrypted_pdf_filepath: Path, tmp_path: Path +) -> None: + output = tmp_path / "annotated.pdf" + exit_code = run_cli( + [ + "extract-annotated-pages", + str(encrypted_pdf_filepath), + "-o", + str(output), + "--password=WRONG", + ] + ) + captured = capsys.readouterr() + assert exit_code == 1 + assert INVALID_PASSWORD_MSG in captured.out + + +# ---------- compress ---------- + +def test_compress_password_ok( + capsys: pytest.CaptureFixture, encrypted_pdf_filepath: Path, tmp_path: Path +) -> None: + output = tmp_path / "compressed.pdf" + exit_code = run_cli( + [ + "compress", + str(encrypted_pdf_filepath), + str(output), + "--password=openpassword", + ] + ) + captured = capsys.readouterr() + assert exit_code in (0, None), captured.err + assert output.exists() + + +def test_compress_password_invalid( + capsys: pytest.CaptureFixture, encrypted_pdf_filepath: Path, tmp_path: Path +) -> None: + output = tmp_path / "compressed.pdf" + exit_code = run_cli( + [ + "compress", + str(encrypted_pdf_filepath), + str(output), + "--password=WRONG", + ] + ) + captured = capsys.readouterr() + assert exit_code == 1 + assert INVALID_PASSWORD_MSG in captured.out + + +# ---------- rotate ---------- + +def test_rotate_password_ok( + capsys: pytest.CaptureFixture, encrypted_pdf_filepath: Path, tmp_path: Path +) -> None: + output = tmp_path / "rotated.pdf" + exit_code = run_cli( + [ + "rotate", + str(encrypted_pdf_filepath), + "90", + "-o", + str(output), + "--password=openpassword", + ] + ) + captured = capsys.readouterr() + assert exit_code in (0, None), captured.err + assert output.exists() + + +def test_rotate_password_invalid( + capsys: pytest.CaptureFixture, encrypted_pdf_filepath: Path, tmp_path: Path +) -> None: + output = tmp_path / "rotated.pdf" + exit_code = run_cli( + [ + "rotate", + str(encrypted_pdf_filepath), + "90", + "-o", + str(output), + "--password=WRONG", + ] + ) + captured = capsys.readouterr() + assert exit_code == 1 + assert INVALID_PASSWORD_MSG in captured.out + + +# ---------- 2-up ---------- + +def test_up2_password_ok( + capsys: pytest.CaptureFixture, encrypted_pdf_filepath: Path, tmp_path: Path +) -> None: + output = tmp_path / "up2.pdf" + exit_code = run_cli( + [ + "2-up", + str(encrypted_pdf_filepath), + str(output), + "--password=openpassword", + ] + ) + captured = capsys.readouterr() + assert exit_code in (0, None), captured.err + assert output.exists() + + +def test_up2_password_invalid( + capsys: pytest.CaptureFixture, encrypted_pdf_filepath: Path, tmp_path: Path +) -> None: + output = tmp_path / "up2.pdf" + exit_code = run_cli( + [ + "2-up", + str(encrypted_pdf_filepath), + str(output), + "--password=WRONG", + ] + ) + captured = capsys.readouterr() + assert exit_code == 1 + assert INVALID_PASSWORD_MSG in captured.out + + +# ---------- booklet ---------- + +def test_booklet_password_ok( + capsys: pytest.CaptureFixture, encrypted_pdf_filepath: Path, tmp_path: Path +) -> None: + output = tmp_path / "booklet.pdf" + exit_code = run_cli( + [ + "booklet", + str(encrypted_pdf_filepath), + str(output), + "--password=openpassword", + ] + ) + captured = capsys.readouterr() + assert exit_code in (0, None), captured.err + assert output.exists() + + +def test_booklet_password_invalid( + capsys: pytest.CaptureFixture, encrypted_pdf_filepath: Path, tmp_path: Path +) -> None: + output = tmp_path / "booklet.pdf" + exit_code = run_cli( + [ + "booklet", + str(encrypted_pdf_filepath), + str(output), + "--password=WRONG", + ] + ) + captured = capsys.readouterr() + assert exit_code == 1 + assert INVALID_PASSWORD_MSG in captured.out \ No newline at end of file From 3e63fcf69dbf1601a9f7615f70fa840ffcd8bdde Mon Sep 17 00:00:00 2001 From: ElyousfiMohamed Date: Tue, 28 Jul 2026 20:09:58 +0100 Subject: [PATCH 2/2] Add change to CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0feb20ba..f7ad50e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ ### New Features (ENH) - `pagemeta` now displays the name of a known page format that is close to the page dimensions - +- Unified password support across `pdfly` commands and added page-range selection to `extract-text` ([PR #269](https://github.com/py-pdf/pdfly/pull/269)) ## Version 0.5.1, 2025-10-13