[
  {
    "path": ".all-contributorsrc",
    "content": "{\n  \"projectName\": \"pdfly\",\n  \"projectOwner\": \"py-pdf\",\n  \"repoType\": \"github\",\n  \"repoHost\": \"https://github.com\",\n  \"files\": [\n    \"README.md\"\n  ],\n  \"imageSize\": 100,\n  \"commit\": true,\n  \"commitConvention\": \"eslint\",\n  \"contributors\": [\n    {\n      \"login\": \"MartinThoma\",\n      \"name\": \"Martin Thoma\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/1658117?v=4\",\n      \"profile\": \"http://martin-thoma.com/\",\n      \"contributions\": [\n        \"code\",\n        \"doc\",\n        \"ideas\",\n        \"infra\",\n        \"maintenance\",\n        \"projectManagement\",\n        \"tutorial\"\n      ]\n    },\n    {\n      \"login\": \"Lucas-C\",\n      \"name\": \"Lucas Cimon\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/925560?v=4\",\n      \"profile\": \"https://chezsoi.org/lucas/blog/\",\n      \"contributions\": [\n        \"bug\",\n        \"code\",\n        \"doc\",\n        \"maintenance\"\n      ]\n    },\n    {\n      \"login\": \"pastor-robert\",\n      \"name\": \"Rob Adams\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/35646090?v=4\",\n      \"profile\": \"https://github.com/pastor-robert\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"Kaos599\",\n      \"name\": \"Harsh \",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/115716485?v=4\",\n      \"profile\": \"https://github.com/Kaos599\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"srogmann\",\n      \"name\": \"Sascha Rogmann\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/59577610?v=4\",\n      \"profile\": \"https://github.com/srogmann\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"ebotiab\",\n      \"name\": \"Enrique Botía\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/62219950?v=4\",\n      \"profile\": \"https://github.com/ebotiab\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"kommade\",\n      \"name\": \"kommade\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/99523586?v=4\",\n      \"profile\": \"https://github.com/kommade\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"Zingzy\",\n      \"name\": \"Zingzy\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/90309290?v=4\",\n      \"profile\": \"https://spoo.me/\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"wolfram77\",\n      \"name\": \"Subhajit Sahu\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/3179612?v=4\",\n      \"profile\": \"https://wolfram77.github.io\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"kianmeng\",\n      \"name\": \"Kian-Meng Ang\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/134518?v=4\",\n      \"profile\": \"https://www.kianmeng.org\",\n      \"contributions\": [\n        \"ideas\"\n      ]\n    },\n    {\n      \"login\": \"hwine\",\n      \"name\": \"Hal Wine\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/132412?v=4\",\n      \"profile\": \"https://github.com/hwine\",\n      \"contributions\": [\n        \"bug\",\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"philippesamuel\",\n      \"name\": \"philippesamuel\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/32560769?v=4\",\n      \"profile\": \"https://github.com/philippesamuel\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"marcobrb\",\n      \"name\": \"marcobrb\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/219329309?v=4\",\n      \"profile\": \"https://github.com/marcobrb\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"moormaster\",\n      \"name\": \"moormaster\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/2452695?v=4\",\n      \"profile\": \"https://github.com/moormaster\",\n      \"contributions\": [\n        \"doc\",\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"geoffbeier\",\n      \"name\": \"Geoff Beier\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/133355?v=4\",\n      \"profile\": \"https://geoff.tuxpup.com/\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"georgthegreat\",\n      \"name\": \"Yuriy Chernyshov\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/1121500?v=4\",\n      \"profile\": \"https://leftparagraphs.com\",\n      \"contributions\": [\n        \"ideas\",\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"lkintact\",\n      \"name\": \"lkintact\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/24726299?v=4\",\n      \"profile\": \"https://github.com/lkintact\",\n      \"contributions\": [\n        \"bug\"\n      ]\n    }\n  ],\n  \"contributorsPerLine\": 5,\n  \"skipCi\": false,\n  \"commitType\": \"docs\"\n}\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: Report some unexpected behaviour to help us improve\ntitle: ''\nlabels: bug\nassignees: ''\n---\n<!--\nHi there! Thank you for wanting to make pdfly better 😉.\n\nPlease perform a quick search first, in order to check if your problem has already been reported:\nhttps://github.com/py-pdf/pdfly/issues\n-->\n\nDescribe the bug\n\n**Error details**\nIf an exception is raised, it is very important that you provide the full error message.\nOtherwise members of the `pdfly` community won't be able to help you with your problem.\n\n**Environment**\nPlease provide the following information:\n* **Operating System**: Windows, Mac OSX, Linux flavour...\n* **Python version**: you can get this information with `python --version`\n* **`pdfly` version used**: if you installed it with `pip`, you can get this information in `pip freeze` output\n\n<!-- Bonus / recommended:\n\nOften, there are bugfixes & other changes on pdfly git repo `master` branch\nthat have not been released yet. They are listed in the ChangeLog:\nhttps://github.com/py-pdf/pdfly/blob/master/CHANGELOG.md\n\nHence, please check that your bug is still present using the latest version of pdfly from the git repository, by installing it this way:\n\n    pip install git+https://github.com/py-pdf/pdfly.git@master\n\n-->\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "# Ref: https://help.github.com/en/github/building-a-strong-community/configuring-issue-templates-for-your-repository#configuring-the-template-chooser\nblank_issues_enabled: false\ncontact_links:\n  - name: 💬 Start a discussion\n    url: https://github.com/py-pdf/pdfly/discussions/new\n    about: Informal discussion about the project organization, considerations that do not expect a definitive answer, etc.\n#  - name: Security issue\n#    url: security@...\n#    about: Do not report security issues publicly. Email our security contact.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "content": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: ''\nlabels: enhancement\nassignees: ''\n---\n<!--\nHi there! Thank you for wanting to make pdfly better 😉.\n\nBefore you submit this, make sure that this feature wasn't already requested,\nor if it is not already implemented in the master branch but not released yet:\nhttps://github.com/py-pdf/pdfly/blob/master/CHANGELOG.md\n-->\n\n**Please explain your intent**\nDescribe what you want to achieve.\n\n**Describe the solution you'd like**\nA clear and concise description of what you want to happen.\nPlease also mention any alternative solutions or features you've considered.\n\n**Additional context**\nAdd any other context, code snippet or screenshots about the feature request.\nYou can also mention if you are willing to contribute a PR yourself to provide this feature.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/question.md",
    "content": "---\nname: I have a question\nabout: Anything that is not a bug report or a feature request\ntitle: ''\nlabels: question\nassignees: ''\n---\n<!--\nHi there! Thank you for reaching out and stepping in pdfly users community 😉.\n\nBefore submitting your question, please check:\n* that it is not covered by the documentation: https://pdfly.readthedocs.io/en/latest/\n* that it has not already been asked: https://github.com/py-pdf/pdfly/issues\n-->\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "# Set update schedule for GitHub Actions\n\nversion: 2\nupdates:\n\n  - package-ecosystem: \"github-actions\"\n    directory: \"/\"\n    schedule:\n      interval: \"daily\"\n"
  },
  {
    "path": ".github/pull_request_template.md",
    "content": "<!--\nThanks for your interest in the project.\nBugs filed and PRs submitted are appreciated!\n\nSome guidelines are provided like this, in HTML comments, to expedite the code review before merging your contribution.\n\nFirst, please make sure that you have read the documentation page on pdfly development:\nhttps://pdfly.readthedocs.io/en/latest/dev/intro.html\n\nIf you're new to contributing to open source projects,\nyou might find this free video course helpful: http://kcd.im/pull-request\n-->\n\n<!-- What changes are being made? (What feature/bug is being fixed here?) -->\n\ne.g. Fixes #0 <!-- This will automatically close issue #0 once the PR is merged: https://help.github.com/en/articles/closing-issues-using-keywords -->\n\n<!-- Have you done all of these things?  -->\n**Checklist**:\n\n<!-- To check an item, place an \"x\" in the box like so: \"- [x] Item description\"\n     Add \"N/A\" to the end of each line that's irrelevant to your changes -->\n\n- [ ] A unit test is covering the code added / modified by this PR\n\n- [ ] In case of a new feature, docstrings have been added, with also some documentation in the `docs/` folder\n\n- [ ] A mention of the change is present in `CHANGELOG.md`\n\n- [ ] This PR is ready to be merged <!-- In your opinion, can this be merged as soon as it's reviewed? Else, this can be turned into a Draft PR -->\n\n<!-- Feel free to add additional comments, and to ask questions if some of those guidelines are unclear to you! -->\n\n<!--\nOnce a PR is merged, maintainers will add your name to the contributors table in README.md.\nIf they forget, or you do not wish this to happen, please mention it.\n-->\n\nBy submitting this pull request, I confirm that my contribution is made under the terms of the [BSD 3-Clause license](https://github.com/py-pdf/pdfly/blob/master/LICENSE).\n"
  },
  {
    "path": ".github/scripts/check_pr_title.py",
    "content": "\"\"\"Check that all PR titles follow the desired scheme.\"\"\"\n\nimport os\nimport sys\n\nKNOWN_PREFIXES = (\n    \"SEC: \",\n    \"BUG: \",\n    \"ENH: \",\n    \"DEP: \",\n    \"PI: \",\n    \"ROB: \",\n    \"DOC: \",\n    \"Docs: \",  # MRs from Dependabot\n    \"TST: \",\n    \"DEV: \",\n    \"STY: \",\n    \"MAINT: \",\n    \"REL: \",\n)\nPR_TITLE = os.getenv(\"PR_TITLE\", \"\")\n\nif (\n    not PR_TITLE.startswith(KNOWN_PREFIXES)\n    or not PR_TITLE.split(\": \", maxsplit=1)[1]\n):\n    sys.stderr.write(\n        f\"The PR title '{PR_TITLE}' does not follow the projects naming scheme: \"\n        \"https://pdfly.readthedocs.io/en/latest/dev/intro.html#commit-messages\\n\",\n    )\n    sys.stderr.write(\n        \"If you do not know which one to choose or if multiple apply, make a best guess. \"\n        \"Nobody will complain if it does not quite fit :-)\\n\",\n    )\n    sys.exit(1)\nelse:\n    sys.stdout.write(f\"PR title '{PR_TITLE}' appears to be valid.\\n\")\n"
  },
  {
    "path": ".github/workflows/check-gitignored-files.yml",
    "content": "name: Check for Gitignored Files\n\non:\n  push:\n    branches:\n      - '**' # Run on all branches\n  pull_request:\n\njobs:\n  check-gitignored-files:\n    runs-on: ubuntu-latest\n\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n\n      - name: Check for gitignored files in commit\n        run: |\n          # List all files in the commit\n          git diff --name-only --cached > committed_files.txt\n\n          # Check if any of the committed files are ignored by .gitignore\n          git check-ignore -v $(cat committed_files.txt) > ignored_files.txt || true\n\n          # Fail if there are any ignored files\n          if [[ -s ignored_files.txt ]]; then\n            echo \"The following files are gitignored but committed:\"\n            cat ignored_files.txt\n            exit 1\n          fi\n"
  },
  {
    "path": ".github/workflows/create-github-release.yaml",
    "content": "name: Create a GitHub release page\n\non:\n  push:\n    tags:\n      - '*.*.*'\n  workflow_dispatch:\n  workflow_run:\n    workflows: [\"Create git tag\"]\n    types:\n      - completed\n\npermissions:\n  contents: write\n\njobs:\n  build_and_publish:\n    if: ${{ github.event.workflow_run.conclusion == 'success' }}\n    name: Create a GitHub release page\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout Repository\n        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n\n      - name: Prepare variables\n        id: prepare_variables\n        run: |\n          git fetch --tags --force\n          latest_tag=$(git describe --tags --abbrev=0)\n          echo \"latest_tag=$(git describe --tags --abbrev=0)\" >> \"$GITHUB_ENV\"\n          echo \"date=$(date +'%Y-%m-%d')\" >> \"$GITHUB_ENV\"\n          EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64)\n          echo \"tag_body<<$EOF\" >> \"$GITHUB_ENV\"\n          git --no-pager tag -l \"${latest_tag}\" --format='%(contents:body)' >> \"$GITHUB_ENV\"\n          echo \"$EOF\" >> \"$GITHUB_ENV\"\n\n      - name: Create GitHub Release 🚀\n        uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3\n        with:\n          tag_name: ${{ env.latest_tag }}\n          name: Version ${{ env.latest_tag }}, ${{ env.date }}\n          draft: false\n          prerelease: false\n          body: ${{ env.tag_body }}\n"
  },
  {
    "path": ".github/workflows/github-ci.yaml",
    "content": "# This workflow will install Python dependencies, run tests and lint with a variety of Python versions\n# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions\n\nname: CI\n\non:\n  push:\n    branches: [ main ]\n  pull_request:\n    branches: [ main ]\n  workflow_dispatch:\n\njobs:\n  tests:\n    strategy:\n      matrix:\n        python-version: [\"3.10\", \"3.11\", \"3.12\", \"3.13\", \"3.14\"]\n        platform: [ubuntu-latest, windows-latest, macos-latest]\n    name: pytest on ${{ matrix.python-version }} / ${{ matrix.platform }}\n    runs-on: ${{ matrix.platform }}\n    steps:\n    - name: Checkout Code\n      uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n      with:\n        submodules: 'recursive'\n    - name: Setup Python ${{ matrix.python-version }}\n      uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0\n      with:\n        python-version: ${{ matrix.python-version }}\n    - name: Upgrade pip\n      run: python -m pip install --upgrade pip\n    - name: Install requirements\n      run: pip install . --group dev\n    - name: Install pdfly\n      if: matrix.python-version != '3.8'\n      run: pip install .\n    - name: Install pdfly using the minimal versions of the dependencies\n      if: matrix.python-version == '3.8'\n      run: |\n        # We ensure that those minimal versions remain compatible:\n        sed -i '/dependencies = \\[/,/\\]/s/>=/==/' pyproject.toml\n        pip install .\n    - name: Run tests\n      run: pytest -vv\n\n  codestyle:\n    name: Check code with black, mypy, ruff & typos\n    runs-on: ubuntu-latest\n    steps:\n    - name: Checkout Code\n      uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n      with:\n        submodules: 'recursive'\n    - name: Cache Downloaded Files\n      id: cache-downloaded-files\n      uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5\n      with:\n        path: '**/tests/pdf_cache/*'\n        key: cache-downloaded-files\n    - name: Upgrade pip, install pdfly and its dev dependencies\n      run: |\n        python -m pip install --upgrade pip\n        pip install .\n        pip install . --group dev\n    - name: Lint with black\n      run: black --check --extend-exclude sample-files .\n    - name: Lint with mypy\n      run: mypy . --ignore-missing-imports --exclude build\n    - name: Test with ruff\n      run: ruff check pdfly/\n    - name: Spell Check Repo\n      uses: crate-ci/typos@7c572958218557a3272c2d6719629443b5cc26fd # v1.45.2\n\n  package:\n    name: Build & verify package\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n      - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0\n        with:\n          python-version: ${{env.PYTHON_LATEST}}\n      - name: Build package \n        run: |\n          python -m pip install flit check-wheel-contents\n          flit build\n          ls -l dist\n          check-wheel-contents dist/*.whl\n      - name: Test installing package\n        run: python -m pip install .\n      - name: Test running installed package\n        working-directory: /tmp\n        run: python -c \"import pdfly;print(pdfly.__version__)\"\n"
  },
  {
    "path": ".github/workflows/publish-to-pypi.yaml",
    "content": "name: Publish Python Package to PyPI\n\non:\n  push:\n    tags:\n      - '*.*.*'\n  workflow_dispatch:\n  workflow_run:\n    workflows: [\"Create git tag\"]\n    types:\n      - completed\n\npermissions:\n  contents: write\n\njobs:\n  build_and_publish:\n    if: ${{ github.event.workflow_run.conclusion == 'success' }}\n    name: Publish a new version\n    runs-on: ubuntu-latest\n    steps:\n      - name: Set up Python\n        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0\n        with:\n          python-version: 3.x\n\n      - name: Install Flit\n        run: |\n          python -m pip install --upgrade pip\n          pip install flit\n\n      - name: Checkout Repository\n        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n\n      - name: Publish Package to PyPI🚀\n        env:\n          FLIT_USERNAME: '__token__'\n          FLIT_PASSWORD: ${{ secrets.FLIT_PASSWORD }}\n        run: |\n          flit publish\n"
  },
  {
    "path": ".github/workflows/release.yaml",
    "content": "# This action assumes that there is a REL-commit which already has a\n# Markdown-formatted git tag. Hence the CHANGELOG is already adjusted\n# and it's decided what should be in the release.\n# This action only ensures the release is done with the proper contents\n# and that it's announced with a Github release.\nname: Create git tag\non:\n  push:\n    branches:\n      - main\n\npermissions:\n  contents: write\n\njobs:\n  build_and_publish:\n    name: Publish a new version\n    runs-on: ubuntu-latest\n    if: \"${{ startsWith(github.event.head_commit.message, 'REL: ') }}\"\n    steps:\n      - name: Checkout Repository\n        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n\n      - name: Extract version from commit message\n        id: extract_version\n        run: |\n          VERSION=$(echo \"${{ github.event.head_commit.message }}\" | grep -oP '(?<=REL: )\\d+\\.\\d+\\.\\d+')\n          echo \"version=$VERSION\" >> $GITHUB_OUTPUT\n\n      - name: Extract tag message from commit message\n        id: extract_message\n        run: |\n          VERSION=\"${{ steps.extract_version.outputs.version }}\"\n          delimiter=\"$(openssl rand -hex 8)\"\n          MESSAGE=$(echo \"${{ github.event.head_commit.message }}\" | sed \"0,/REL: $VERSION/s///\" )\n          echo \"message<<${delimiter}\" >> $GITHUB_OUTPUT\n          echo \"$MESSAGE\" >> $GITHUB_OUTPUT\n          echo \"${delimiter}\" >> $GITHUB_OUTPUT\n\n      - name: Create Git Tag\n        run: |\n          VERSION=\"${{ steps.extract_version.outputs.version }}\"\n          MESSAGE=\"${{ steps.extract_message.outputs.message }}\"\n          git config user.name github-actions\n          git config user.email github-actions@github.com\n          git tag \"$VERSION\" -m \"$MESSAGE\"\n          git push origin $VERSION\n"
  },
  {
    "path": ".github/workflows/title-check.yml",
    "content": "name: 'PR Title Check'\non:\n  pull_request:\n    # check when PR\n    # * is created,\n    # * title is edited, and\n    # * new commits are added (to ensure failing title blocks merging)\n    types: [opened, reopened, edited, synchronize]\n\njobs:\n  title-check:\n    name: Title check\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout Code\n        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n      - name: Check PR title\n        env:\n          PR_TITLE: ${{ github.event.pull_request.title }}\n        run: python .github/scripts/check_pr_title.py\n"
  },
  {
    "path": ".gitignore",
    "content": "# Byte-compiled / optimized / DLL files\n__pycache__/\n*.py[cod]\n*$py.class\n\n# C extensions\n*.so\n\n# Distribution / packaging\n.Python\nbuild/\ndevelop-eggs/\ndist/\ndownloads/\neggs/\n.eggs/\nlib/\nlib64/\nparts/\nsdist/\nvar/\nwheels/\npip-wheel-metadata/\nshare/python-wheels/\n*.egg-info/\n.installed.cfg\n*.egg\nMANIFEST\n\n# PyInstaller\n#  Usually these files are written by a python script from a template\n#  before PyInstaller builds the exe, so as to inject date/other infos into it.\n*.manifest\n*.spec\n\n# Installer logs\npip-log.txt\npip-delete-this-directory.txt\n\n# Unit test / coverage reports\nhtmlcov/\n.tox/\n.nox/\n.coverage\n.coverage.*\n.cache\nnosetests.xml\ncoverage.xml\n*.cover\n*.py,cover\n.hypothesis/\n.pytest_cache/\n\n# Translations\n*.mo\n*.pot\n\n# Django stuff:\n*.log\nlocal_settings.py\ndb.sqlite3\ndb.sqlite3-journal\n\n# Flask stuff:\ninstance/\n.webassets-cache\n\n# Scrapy stuff:\n.scrapy\n\n# Sphinx documentation\ndocs/_build/\n\n# PyBuilder\ntarget/\n\n# Jupyter Notebook\n.ipynb_checkpoints\n\n# IPython\nprofile_default/\nipython_config.py\n\n# pyenv\n.python-version\n\n# pipenv\n#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.\n#   However, in case of collaboration, if having platform-specific dependencies or dependencies\n#   having no cross-platform support, pipenv may install dependencies that don't work, or not\n#   install all needed dependencies.\n#Pipfile.lock\n\n# PEP 582; used by e.g. github.com/David-OConnor/pyflow\n__pypackages__/\n\n# Celery stuff\ncelerybeat-schedule\ncelerybeat.pid\n\n# SageMath parsed files\n*.sage.py\n\n# Environments\n.env\n.envrc\n.venv\nenv/\nvenv/\nENV/\nenv.bak/\nvenv.bak/\n\n# Spyder project settings\n.spyderproject\n.spyproject\n\n# IntelliJ\n.idea\n\n# Rope project settings\n.ropeproject\n\n# mkdocs documentation\n/site\n\n# mypy\n.mypy_cache/\n.dmypy.json\ndmypy.json\n\n# Pyre type checker\n.pyre/\n\n*.pdf\n.envrc\n\n# Documentation files copied when building:\ndocs/meta/CHANGELOG.md\ndocs/meta/CONTRIBUTORS.md\n\n# 'make release' creates those files:\nRELEASE_COMMIT_MSG.md\nRELEASE_TAG_MSG.md\n"
  },
  {
    "path": ".gitmodules",
    "content": "[submodule \"sample-files\"]\n\tpath = sample-files\n\turl = git@github.com:py-pdf/sample-files.git\n"
  },
  {
    "path": ".isort.cfg",
    "content": "[settings]\nline_length=79\nindent='    '\nmulti_line_output=3\nlength_sort=0\ninclude_trailing_comma=True\nskip=docs\nknown_third_party = PIL,pypdf,pydantic,setuptools,typer\n"
  },
  {
    "path": ".pre-commit-config.yaml",
    "content": "# pre-commit run --all-files\nrepos:\n-   repo: https://github.com/pre-commit/pre-commit-hooks\n    rev: v6.0.0\n    hooks:\n    -   id: check-added-large-files\n        args: ['--maxkb=1000']\n    -   id: check-ast\n    -   id: check-case-conflict\n    -   id: check-docstring-first\n    -   id: check-yaml\n    -   id: debug-statements\n    -   id: end-of-file-fixer\n        exclude: \"resources/.*|docs/make.bat\"\n    -   id: fix-byte-order-marker\n    -   id: mixed-line-ending\n        args: ['--fix=lf']\n        exclude: \"docs/make.bat\"\n    -   id: trailing-whitespace\n-   repo: https://github.com/psf/black\n    rev: 26.3.1\n    hooks:\n    -   id: black\n        args: [--target-version, py36]\n-   repo: https://github.com/asottile/blacken-docs\n    rev: 1.20.0\n    hooks:\n    -   id: blacken-docs\n        additional_dependencies: [black==22.1.0]\n-   repo: https://github.com/astral-sh/ruff-pre-commit\n    rev: v0.15.6\n    hooks:\n    -   id: ruff\n        args: ['--fix']\n        exclude: \"tests/\"\n-   repo: https://github.com/asottile/pyupgrade\n    rev: v3.21.2\n    hooks:\n    -   id: pyupgrade\n        args: [--py38-plus]\n-   repo: https://github.com/pycqa/flake8\n    rev: 7.3.0\n    hooks:\n    -   id: flake8\n        args: [\"--ignore\", \"E,W,F\"]\n\n-   repo: https://github.com/pre-commit/mirrors-mypy\n    rev: 'v1.19.1'\n    hooks:\n      - id: mypy\n        files: ^pdfly/.*\n        args: [--ignore-missing-imports]\n        additional_dependencies:\n        - \"pydantic>=1.10.4\"\n"
  },
  {
    "path": ".readthedocs.yaml",
    "content": "# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details\nversion: 2\n\n\nbuild:\n  os: ubuntu-22.04\n  tools:\n    python: \"3.12\"\n\n# Build documentation in the docs/ directory with Sphinx\nsphinx:\n   configuration: docs/conf.py\n\n# If using Sphinx, optionally build your docs in additional formats such as PDF\nformats: all\n\n# Optionally declare the Python requirements required to build your docs\npython:\n  install:\n    - method: pip\n      path: .\n      extra_requirements:\n        - docs\n"
  },
  {
    "path": ".typos.toml",
    "content": "[default]\nextend-ignore-identifiers-re = [\n    \"certifi\",\n    \"FlateDecode\",\n    # This typo appears in a .tex file in the sample-files git submodule:\n    \"exampe\"\n]\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# CHANGELOG\n\n## Version 0.6.0, not released yet\n\n### Bug Fixes (BUG)\n- `2up` incorrectly handled documents with an odd number of pages - [issue #219](https://github.com/py-pdf/pdfly/issues/218)\n\n### New Features (ENH)\n- `pagemeta` now displays the name of a known page format that is close to the page dimensions\n\n\n## Version 0.5.1, 2025-10-13\n\n### New Features (ENH)\n- `extract-images`: output filenames are now formatted using four digit for page numbers, in order for output files to be ordered alphabetically\n- ensured support for Python 3.14\n\n### Bug Fixes (BUG)\n- `requests` is now a dependency, to prevent a `ModuleNotFoundError` when running with `uv`\n\n\n## Version 0.5.0, 2025-10-13\n\n### New Features (ENH)\n- New `extract-annotated-pages` to filter out only the user annotated pages ([PR #98](https://github.com/py-pdf/pdfly/pull/98))\n- New `rotate` sub-command to rotate specified pages ([PR #128](https://github.com/py-pdf/pdfly/pull/128))\n- Added optional `--password` argument to `cat` to perform decryption ([PR #61](https://github.com/py-pdf/pdfly/pull/61))\n- `pagemeta` now displays known page formats when it can detect it: A3, A4, A5, Letter, Legal\n- `pagemeta` now displays the rotation value.\n- New `sign` sub-command to create a signed pdf from an existing pdf ([PR #165](https://github.com/py-pdf/pdfly/pull/165))\n- New `check-sign` sub-command to verify the signature of a signed pdf ([PR #166](https://github.com/py-pdf/pdfly/pull/166))\n\n### Bug Fixes (BUG)\n- `pypdf[full]` is now a dependency, instead of just `pypdf`, to avoid some cases of `DependencyError`\n\n### Deprecations (DEP)\n* support for older Python3 versions has been dropped, `pdfly` now requires Python 3.10+\n\n\n## Version 0.4.0, 2024-12-08\n\n### New Features (ENH)\n- New `booklet` command to adjust offsets and lengths ([PR #77](https://github.com/py-pdf/pdfly/pull/77))\n- New `uncompress` command ([PR #75](https://github.com/py-pdf/pdfly/pull/75))\n- New `update-offsets` command to adjust offsets and lengths ([PR #15](https://github.com/py-pdf/pdfly/pull/15))\n- New `rm` command ([PR #59](https://github.com/py-pdf/pdfly/pull/59))\n- `metadata`: now also displaying CreationDate, Creator, Keywords & Subject ([PR #73](https://github.com/py-pdf/pdfly/pull/73))\n- Add warning for out-of-bounds page range in pdfly `cat` command ([PR #58](https://github.com/py-pdf/pdfly/pull/58))\n\n### Bug Fixes (BUG)\n- `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))\n\n[Full Changelog](https://github.com/py-pdf/pdfly/compare/0.3.3...0.4.0)\n\n\n## Version 0.3.3, 2024-04-14\n\n### Developer Experience (DEV)\n-  Chain workflows\n\n[Full Changelog](https://github.com/py-pdf/pdfly/compare/0.3.2...0.3.3)\n\n\n## Version 0.3.2, 2024-04-14\n\n### Developer Experience (DEV)\n-  Decouple git tag / PyPI release / Github release page (#49, #50)\n\n\n[Full Changelog](https://github.com/py-pdf/pdfly/compare/0.3.1...0.3.2)\n\n## Version 0.3.1, 2024-03-29\n\n### Maintenance (MAINT)\n-  Update pypdf usage (#48)\n\n### Developer Experience (DEV)\n-  Release via REL commit (#48)\n-  Fix mypy issues\n-  Add make_release.py\n\n[Full Changelog](https://github.com/py-pdf/pdfly/compare/0.3.0...0.3.1)\n\n## Version 0.3.0, 2023-12-17\n\n### New Features (ENH)\n-  Add x2pdf command (#25)\n\n### Bug Fixes (BUG)\n-  boxes are floats, not int\n-  Add missing fpdf2 dependency (#29)\n\n### Documentation (DOC)\n-  cat command\n-  More examples for the cat subcommand\n-  Add cat subcommand\n-  Link to readthedocs\n-  Add project governance file\n-  Move readthedocs config file to root\n-  Add docs (#24)\n\n### Developer Experience (DEV)\n-  Checkout sample-files in CI (#30)\n-  Let dependabot update Github Actions\n-  Add action for automatic releases\n\n### Maintenance (MAINT)\n-  Update dependencies (#42)\n-  In the cat subcommand, replace the usage of the deprecated PdfMerger by PdfWriter (#34)\n-  Update .pre-commit-config.yaml\n-  Adjust x2pdf syntax\n\n### Testing (TST)\n-  cat with two files (#41)\n-  Test cat command with more parameters + validate result (#40)\n-  Adding unit tests (#28)\n\n### Other\n- : [{'msg': 'Bump actions/setup-python from 4 to 5 (#39)', 'author': 'dependabot[bot]'}, {'msg': 'test_extract_images_monochrome() is now passing', 'author': 'CimonLucas(LCM)'}, {'msg': 'Bump actions/setup-python from 3 to 4 (#27)', 'author': 'dependabot[bot]'}, {'msg': 'Bump actions/checkout from 3 to 4 (#26)', 'author': 'dependabot[bot]'}, {'msg': 'Ensure input PDF exists for cat subcommand', 'author': 'MartinThoma'}]\n\n[Full Changelog](https://github.com/py-pdf/pdfly/compare/0.2.14...0.3.0)\n"
  },
  {
    "path": "CONTRIBUTORS.md",
    "content": "# List of contributors\n\nThe list of contributors has been moved into the [README.md](https://github.com/py-pdf/pdfly/blob/main/README.md#contributors-).\n"
  },
  {
    "path": "LICENSE",
    "content": "BSD 3-Clause License\n\nCopyright (c) 2022, py-pdf\nAll rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are met:\n\n1. Redistributions of source code must retain the above copyright notice, this\n   list of conditions and the following disclaimer.\n\n2. Redistributions in binary form must reproduce the above copyright notice,\n   this list of conditions and the following disclaimer in the documentation\n   and/or other materials provided with the distribution.\n\n3. Neither the name of the copyright holder nor the names of its\n   contributors may be used to endorse or promote products derived from\n   this software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\nAND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\nIMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE\nDISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE\nFOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL\nDAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR\nSERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER\nCAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,\nOR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n"
  },
  {
    "path": "Makefile",
    "content": "maint:\n\tpre-commit autoupdate\n\tpython -m pip install --upgrade .\n\tpython -m pip lock --group dev --group docs .\n\tuv pip install -r pylock.toml\n\tgit submodule update --remote\n\nrelease:\n\tpython make_release.py\n\tgit commit -eF RELEASE_COMMIT_MSG.md\n\nupload:\n\tmake clean\n\tflit publish\n\nclean:\n\tpython setup.py clean --all\n\tpyclean .\n\trm -rf tests/__pycache__ pdfly/__pycache__ Image9.png htmlcov docs/_build dist dont_commit_merged.pdf dont_commit_writer.pdf pdfly.egg-info\n\nlint:\n\tmypy . --ignore-missing-imports --exclude build\n\truff check --fix --unsafe-fixes\n\ntest:\n\tpytest tests --cov --cov-report term-missing -vv --cov-report html --durations=3 --timeout=30\n"
  },
  {
    "path": "README.md",
    "content": "[![Pypi latest version](https://img.shields.io/pypi/v/pdfly.svg)](https://pypi.org/pypi/pdfly#history)\n[![Python Support](https://img.shields.io/pypi/pyversions/pdfly.svg)](https://pypi.org/project/pdfly/)\n[![License: BSD 3 Clause](https://img.shields.io/badge/License-BSD%203%20Clause-blue.svg)](https://opensource.org/license/bsd-3-clause)\n[![Documentation Status](https://app.readthedocs.org/projects/pdfly/badge/?version=latest)](https://pdfly.readthedocs.io/en/latest/)\n\n[![build status](https://github.com/py-pdf/pdfly/workflows/CI/badge.svg)](https://github.com/py-pdf/pdfly/actions?query=branch%3Amain)\n[![GitHub last commit](https://img.shields.io/github/last-commit/py-pdf/pdfly)](https://github.com/py-pdf/pdfly/commits/main/)\n[![issues closed](https://img.shields.io/github/issues-closed/py-pdf/pdfly)](https://github.com/py-pdf/pdfly/issues)\n[![PRs closed](https://img.shields.io/github/issues-pr-closed/py-pdf/pdfly)](https://github.com/py-pdf/pdfly/pulls)\n\n[![linters: black, ruff, mypi](https://img.shields.io/badge/linters-black,ruff,mypi-green.svg)](https://github.com/py-pdf/pdfly/actions)\n[![Pull Requests Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat)](https://makeapullrequest.com)\n[![first-timers-only Friendly](https://img.shields.io/badge/first--timers--only-friendly-blue.svg)](https://www.firsttimersonly.com/)\n\n# pdfly\n\npdfly (say: PDF-li) is a pure-python cli application for manipulating PDF files.\n\n<img src=\"docs/pdfly-logo.png\" alt=\"pdfly logo\" width=\"25%\">\n\n## Installation\n\n```bash\npip install -U pdfly\n```\n\nAs `pdfly` is an application, you might want to install it with [`pipx`](https://pypi.org/project/pipx/) or [`uv tool`](https://docs.astral.sh/uv/concepts/tools/): `uvx pdfly --help`\n\n## Usage\n\n```console\n$ pdfly --help\n\n Usage: pdfly [OPTIONS] COMMAND [ARGS]...\n\n pdfly is a pure-python cli application for manipulating PDF files.\n\n╭─ Options ──────────────────────────────────────────────────────────────────────────────────────╮\n│ --version                                                                                      │\n│ --help             Show this message and exit.                                                 │\n╰────────────────────────────────────────────────────────────────────────────────────────────────╯\n╭─ Commands ─────────────────────────────────────────────────────────────────────────────────────╮\n│ 2-up                      Create a booklet-style PDF from a single input.                      │\n│ booklet                   Reorder and two-up PDF pages for booklet printing.                   │\n│ cat                       Extract and concatenate pages from PDF files into a single PDF file. │\n│ check-sign                Verifies the signature of a signed PDF.                              │\n│ compress                  Compress a PDF.                                                      │\n│ extract-annotated-pages   Extract only the annotated pages from a PDF.                         │\n│ extract-images            Extract images from PDF without resampling or altering.              │\n│ extract-text              Extract text from a PDF file.                                        │\n│ meta                      Show metadata of a PDF file                                          │\n│ pagemeta                  Give details about a single page.                                    │\n│ rm                        Remove pages from PDF files.                                         │\n│ rotate                    Rotate specified pages by the specified amount                       │\n│ sign                      Creates a signed PDF from an existing PDF file.                      │\n│ uncompress                Module for uncompressing PDF content streams.                        │\n│ update-offsets            Updates offsets and lengths in a simple PDF file.                    │\n│ x2pdf                     Convert one or more files to PDF. Each file is a page.               │\n╰────────────────────────────────────────────────────────────────────────────────────────────────╯\n```\n\nYou can see the help of every subcommand by typing `--help`:\n\n```console\n$ pdfly 2-up --help\n\n Usage: pdfly 2-up [OPTIONS] PDF OUT\n\n Create a booklet-style PDF from a single input.\n Pairs of two pages will be put on one page (left and right)\n\n usage: python 2-up.py input_file output_file\n\n╭─ Arguments ───────────────────────────────────────╮\n│ *    pdf      PATH  [default: None] [required]    │\n│ *    out      PATH  [default: None] [required]    │\n╰───────────────────────────────────────────────────╯\n╭─ Options ─────────────────────────────────────────╮\n│ --help          Show this message and exit.       │\n╰───────────────────────────────────────────────────╯\n```\n\n**Note:** `pdfly` has nothing to do with ``pdfly.net`` or ``gopdfly.com``\n\n## Contributors ✨\n\npdfly is a free software project without any company affiliation. We cannot pay\ncontributors, but we do value their contributions 🤗\n\n<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->\n<!-- prettier-ignore-start -->\n<!-- markdownlint-disable -->\n<table>\n  <tbody>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"20%\"><a href=\"http://martin-thoma.com/\"><img src=\"https://avatars.githubusercontent.com/u/1658117?v=4?s=100\" width=\"100px;\" alt=\"Martin Thoma\"/><br /><sub><b>Martin Thoma</b></sub></a><br /><a href=\"https://github.com/py-pdf/pdfly/commits?author=MartinThoma\" title=\"Code\">💻</a> <a href=\"https://github.com/py-pdf/pdfly/commits?author=MartinThoma\" title=\"Documentation\">📖</a> <a href=\"#ideas-MartinThoma\" title=\"Ideas, Planning, & Feedback\">🤔</a> <a href=\"#infra-MartinThoma\" title=\"Infrastructure (Hosting, Build-Tools, etc)\">🚇</a> <a href=\"#maintenance-MartinThoma\" title=\"Maintenance\">🚧</a> <a href=\"#projectManagement-MartinThoma\" title=\"Project Management\">📆</a> <a href=\"#tutorial-MartinThoma\" title=\"Tutorials\">✅</a></td>\n      <td align=\"center\" valign=\"top\" width=\"20%\"><a href=\"https://chezsoi.org/lucas/blog/\"><img src=\"https://avatars.githubusercontent.com/u/925560?v=4?s=100\" width=\"100px;\" alt=\"Lucas Cimon\"/><br /><sub><b>Lucas Cimon</b></sub></a><br /><a href=\"https://github.com/py-pdf/pdfly/issues?q=author%3ALucas-C\" title=\"Bug reports\">🐛</a> <a href=\"https://github.com/py-pdf/pdfly/commits?author=Lucas-C\" title=\"Code\">💻</a> <a href=\"https://github.com/py-pdf/pdfly/commits?author=Lucas-C\" title=\"Documentation\">📖</a> <a href=\"#maintenance-Lucas-C\" title=\"Maintenance\">🚧</a></td>\n      <td align=\"center\" valign=\"top\" width=\"20%\"><a href=\"https://github.com/pastor-robert\"><img src=\"https://avatars.githubusercontent.com/u/35646090?v=4?s=100\" width=\"100px;\" alt=\"Rob Adams\"/><br /><sub><b>Rob Adams</b></sub></a><br /><a href=\"https://github.com/py-pdf/pdfly/commits?author=pastor-robert\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"20%\"><a href=\"https://github.com/Kaos599\"><img src=\"https://avatars.githubusercontent.com/u/115716485?v=4?s=100\" width=\"100px;\" alt=\"Harsh \"/><br /><sub><b>Harsh </b></sub></a><br /><a href=\"https://github.com/py-pdf/pdfly/commits?author=Kaos599\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"20%\"><a href=\"https://github.com/srogmann\"><img src=\"https://avatars.githubusercontent.com/u/59577610?v=4?s=100\" width=\"100px;\" alt=\"Sascha Rogmann\"/><br /><sub><b>Sascha Rogmann</b></sub></a><br /><a href=\"https://github.com/py-pdf/pdfly/commits?author=srogmann\" title=\"Code\">💻</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"20%\"><a href=\"https://github.com/ebotiab\"><img src=\"https://avatars.githubusercontent.com/u/62219950?v=4?s=100\" width=\"100px;\" alt=\"Enrique Botía\"/><br /><sub><b>Enrique Botía</b></sub></a><br /><a href=\"https://github.com/py-pdf/pdfly/commits?author=ebotiab\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"20%\"><a href=\"https://github.com/kommade\"><img src=\"https://avatars.githubusercontent.com/u/99523586?v=4?s=100\" width=\"100px;\" alt=\"kommade\"/><br /><sub><b>kommade</b></sub></a><br /><a href=\"https://github.com/py-pdf/pdfly/commits?author=kommade\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"20%\"><a href=\"https://spoo.me/\"><img src=\"https://avatars.githubusercontent.com/u/90309290?v=4?s=100\" width=\"100px;\" alt=\"Zingzy\"/><br /><sub><b>Zingzy</b></sub></a><br /><a href=\"https://github.com/py-pdf/pdfly/commits?author=Zingzy\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"20%\"><a href=\"https://wolfram77.github.io\"><img src=\"https://avatars.githubusercontent.com/u/3179612?v=4?s=100\" width=\"100px;\" alt=\"Subhajit Sahu\"/><br /><sub><b>Subhajit Sahu</b></sub></a><br /><a href=\"https://github.com/py-pdf/pdfly/commits?author=wolfram77\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"20%\"><a href=\"https://www.kianmeng.org\"><img src=\"https://avatars.githubusercontent.com/u/134518?v=4?s=100\" width=\"100px;\" alt=\"Kian-Meng Ang\"/><br /><sub><b>Kian-Meng Ang</b></sub></a><br /><a href=\"#ideas-kianmeng\" title=\"Ideas, Planning, & Feedback\">🤔</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"20%\"><a href=\"https://github.com/hwine\"><img src=\"https://avatars.githubusercontent.com/u/132412?v=4?s=100\" width=\"100px;\" alt=\"Hal Wine\"/><br /><sub><b>Hal Wine</b></sub></a><br /><a href=\"https://github.com/py-pdf/pdfly/issues?q=author%3Ahwine\" title=\"Bug reports\">🐛</a> <a href=\"https://github.com/py-pdf/pdfly/commits?author=hwine\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"20%\"><a href=\"https://github.com/philippesamuel\"><img src=\"https://avatars.githubusercontent.com/u/32560769?v=4?s=100\" width=\"100px;\" alt=\"philippesamuel\"/><br /><sub><b>philippesamuel</b></sub></a><br /><a href=\"https://github.com/py-pdf/pdfly/commits?author=philippesamuel\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"20%\"><a href=\"https://github.com/marcobrb\"><img src=\"https://avatars.githubusercontent.com/u/219329309?v=4?s=100\" width=\"100px;\" alt=\"marcobrb\"/><br /><sub><b>marcobrb</b></sub></a><br /><a href=\"https://github.com/py-pdf/pdfly/commits?author=marcobrb\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"20%\"><a href=\"https://github.com/moormaster\"><img src=\"https://avatars.githubusercontent.com/u/2452695?v=4?s=100\" width=\"100px;\" alt=\"moormaster\"/><br /><sub><b>moormaster</b></sub></a><br /><a href=\"https://github.com/py-pdf/pdfly/commits?author=moormaster\" title=\"Documentation\">📖</a> <a href=\"https://github.com/py-pdf/pdfly/commits?author=moormaster\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"20%\"><a href=\"https://geoff.tuxpup.com/\"><img src=\"https://avatars.githubusercontent.com/u/133355?v=4?s=100\" width=\"100px;\" alt=\"Geoff Beier\"/><br /><sub><b>Geoff Beier</b></sub></a><br /><a href=\"https://github.com/py-pdf/pdfly/commits?author=geoffbeier\" title=\"Code\">💻</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"20%\"><a href=\"https://leftparagraphs.com\"><img src=\"https://avatars.githubusercontent.com/u/1121500?v=4?s=100\" width=\"100px;\" alt=\"Yuriy Chernyshov\"/><br /><sub><b>Yuriy Chernyshov</b></sub></a><br /><a href=\"#ideas-georgthegreat\" title=\"Ideas, Planning, & Feedback\">🤔</a> <a href=\"https://github.com/py-pdf/pdfly/commits?author=georgthegreat\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"20%\"><a href=\"https://github.com/lkintact\"><img src=\"https://avatars.githubusercontent.com/u/24726299?v=4?s=100\" width=\"100px;\" alt=\"lkintact\"/><br /><sub><b>lkintact</b></sub></a><br /><a href=\"https://github.com/py-pdf/pdfly/issues?q=author%3Alkintact\" title=\"Bug reports\">🐛</a></td>\n    </tr>\n  </tbody>\n</table>\n\n<!-- markdownlint-restore -->\n<!-- prettier-ignore-end -->\n\n<!-- ALL-CONTRIBUTORS-LIST:END -->\n\nThis project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification\n([emoji key](https://allcontributors.org/docs/en/emoji-key)).\nContributions of any kind welcome!\n\nThe list might not be complete. You can find more contributors via the git\nhistory and [GitHubs 'Contributors' feature](https://github.com/py-pdf/pdfly/graphs/contributors).\n"
  },
  {
    "path": "dependabot.yml",
    "content": "# Doc: https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file\nversion: 2\nupdates:\n  - package-ecosystem: \"gitsubmodule\"\n    commit-message:\n      prefix: \"MAINT\"\n  - package-ecosystem: \"github-actions\"\n    commit-message:\n      prefix: \"MAINT\"\n  - package-ecosystem: \"pip\"\n    commit-message:\n      prefix: \"MAINT\"\n"
  },
  {
    "path": "docs/Makefile",
    "content": "# Minimal makefile for Sphinx documentation\n#\n\n# You can set these variables from the command line, and also\n# from the environment for the first two.\nSPHINXOPTS    ?=\nSPHINXBUILD   ?= sphinx-build\nSOURCEDIR     = .\nBUILDDIR      = _build\n\n# Put it first so that \"make\" without argument is like \"make help\".\nhelp:\n\t@$(SPHINXBUILD) -M help \"$(SOURCEDIR)\" \"$(BUILDDIR)\" $(SPHINXOPTS) $(O)\n\n.PHONY: help Makefile\n\n# Catch-all target: route all unknown targets to Sphinx using the new\n# \"make mode\" option.  $(O) is meant as a shortcut for $(SPHINXOPTS).\n%: Makefile\n\t@$(SPHINXBUILD) -M $@ \"$(SOURCEDIR)\" \"$(BUILDDIR)\" $(SPHINXOPTS) $(O)\n"
  },
  {
    "path": "docs/conf.py",
    "content": "\"\"\"\nConfiguration file for the Sphinx documentation builder.\n\nThis file only contains a selection of the most common options.\nFor a full list see the documentation:\nhttps://www.sphinx-doc.org/en/master/usage/configuration.html\n\"\"\"\n\n# -- Path setup --------------------------------------------------------------\n\n# If extensions (or modules to document with autodoc) are in another directory,\n# add these directories to sys.path here. If the directory is relative to the\n# documentation root, use os.path.abspath to make it absolute, like shown here.\nimport os\nimport shutil\nimport sys\n\nimport pdfly as py_pkg\n\nsys.path.insert(0, os.path.abspath(\".\"))  # noqa\nsys.path.insert(0, os.path.abspath(\"../\"))  # noqa\n\nshutil.copyfile(\"../CHANGELOG.md\", \"meta/CHANGELOG.md\")\nshutil.copyfile(\"../CONTRIBUTORS.md\", \"meta/CONTRIBUTORS.md\")\n\n# -- Project information -----------------------------------------------------\n\nproject = py_pkg.__name__\ncopyright = \"2023, pdfly contributors\"\nauthor = \"pdfly contributors\"\n\n# The version info for the project you're documenting, acts as replacement for\n# |version| and |release|, also used in various other places throughout the\n# built documents.\n#\n# The short X.Y version.\nversion = py_pkg.__version__\n# The full version, including alpha/beta/rc tags.\nrelease = py_pkg.__version__\n\n# -- General configuration ---------------------------------------------------\n# If your documentation needs a minimal Sphinx version, state it here.\nneeds_sphinx = \"4.0.0\"\n\nmyst_all_links_external = True\n# Add any Sphinx extension module names here, as strings. They can be\n# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom\n# ones.\nextensions = [\n    \"sphinx.ext.autodoc\",\n    \"sphinx.ext.intersphinx\",\n    \"sphinx.ext.autosummary\",\n    \"sphinx.ext.coverage\",\n    \"sphinx.ext.mathjax\",\n    \"sphinx.ext.viewcode\",\n    \"sphinx.ext.napoleon\",\n    # External\n    \"myst_parser\",\n]\n\nintersphinx_mapping = {\n    \"py-pdf organization\": (\"https://py-pdf.github.io/\", None),\n}\n\nnitpick_ignore_regex = [\n    # For reasons unclear at this stage the io module prefixes everything with _io\n    # and this confuses sphinx\n    (r\"py:class\", r\"_io.(FileIO|BytesIO|Buffered(Reader|Writer))\"),\n]\n\nautodoc_default_options = {\n    \"member-order\": \"bysource\",\n    \"members\": True,\n    \"show-inheritance\": True,\n    \"undoc-members\": True,\n}\nautodoc_inherit_docstrings = False\nautodoc_typehints_format = \"short\"\npython_use_unqualified_type_names = True\n\n# Add any paths that contain templates here, relative to this directory.\ntemplates_path = [\"_templates\"]\n\n# List of patterns, relative to source directory, that match files and\n# directories to ignore when looking for source files.\n# This pattern also affects html_static_path and html_extra_path.\nexclude_patterns = [\"_build\", \"Thumbs.db\", \".DS_Store\"]\n\n\n# -- Options for HTML output -------------------------------------------------\n\n# The theme to use for HTML and HTML Help pages.  See the documentation for\n# a list of builtin themes.\n#\nhtml_theme = \"sphinx_rtd_theme\"\n# Theme options are theme-specific and customize the look and feel of a theme\n# further.  For a list of options available for each theme, see the\n# documentation.\nhtml_theme_options = {\n    \"canonical_url\": \"\",\n    \"analytics_id\": \"\",\n    \"logo_only\": True,\n    \"display_version\": True,\n    \"prev_next_buttons_location\": \"bottom\",\n    \"style_external_links\": False,\n    # Toc options\n    \"collapse_navigation\": True,\n    \"sticky_navigation\": True,\n    \"navigation_depth\": 4,\n    \"includehidden\": True,\n    \"titles_only\": False,\n}\n\n# Add any paths that contain custom static files (such as style sheets) here,\n# relative to this directory. They are copied after the builtin static files,\n# so a file named \"default.css\" will overwrite the builtin \"default.css\".\nhtml_static_path = [\"_static\"]\nhtml_logo = \"pdfly-logo.png\"\n\n# -- Options for Napoleon  -----------------------------------------------------\n\nnapoleon_google_docstring = True\nnapoleon_numpy_docstring = False  # Explicitly prefer Google style docstring\nnapoleon_use_param = True  # for type hint support\nnapoleon_use_rtype = (\n    False  # False so the return type is inline with the description.\n)\n"
  },
  {
    "path": "docs/dev/intro.md",
    "content": "# Developer Intro\n\npdfly is an application and thus non-developers\nmight also use it.\n\n## Installing Requirements\n```\npip install . --group dev\n```\n\n## Running Tests\n\nSee [testing pdfly with pytest](testing.md)\n\n## Documentation\n\nTo preview the HTML documentation, you can run this command:\n```\nsphinx-autobuild docs docs/_build/html\n```\n\n## Tools: git and pre-commit\n\nGit is a command line application for version control. If you don't know it,\nyou can [play ohmygit](https://ohmygit.org/) to learn it.\n\nGitHub is the service where the pdfly project is hosted. While git is free and\nopen source, GitHub is a paid service by Microsoft, but free in a lot of\ncases.\n\n[pre-commit](https://pypi.org/project/pre-commit/) is a command line application\nthat uses git hooks to automatically execute code. This allows you to avoid\nstyle issues and other code quality issues. After you entered `pre-commit install`\nonce in your local copy of pdfly, it will automatically be executed when\nyou `git commit`.\n\n## Commit Messages\n\nHaving a clean commit message helps people to quickly understand what the commit\nis about, without actually looking at the changes. The first line of the\ncommit message is used to [auto-generate the CHANGELOG](https://github.com/py-pdf/pdfly/blob/main/make_release.py).\nFor this reason, the format should be:\n\n```\nPREFIX: DESCRIPTION\n\nBODY\n```\n\nThe `PREFIX` can be:\n\n* `SEC`: Security improvements. Typically an infinite loop that was possible.\n* `BUG`: A bug was fixed. Likely there is one or multiple issues. Then write in\n   the `BODY`: `Closes #123` where 123 is the issue number on GitHub.\n   It would be absolutely amazing if you could write a regression test in those\n   cases. That is a test that would fail without the fix.\n   A bug is always an issue for pdfly users - test code or CI that was fixed is\n   not considered a bug here.\n* `ENH`: A new feature! Describe in the body what it can be used for.\n* `DEP`: A deprecation. Either marking something as \"this is going to be removed\"\n   or actually removing it.\n* `PI`: A performance improvement. This could also be a reduction in the\n        file size of PDF files generated by pdfly.\n* `ROB`: A robustness change. Dealing better with broken PDF files.\n* `DOC`: A documentation change. `Docs:` is also allowed for commits made by DependaBot.\n* `TST`: Adding or adjusting tests.\n* `DEV`: Developer experience improvements, e.g. pre-commit or setting up CI.\n* `MAINT`: Quite a lot of different stuff. Performance improvements are for sure\n           the most interesting changes in here. Refactorings as well.\n* `STY`: A style change. Something that makes pdfly code more consistent.\n         Typically a small change. It could also be better error messages for\n         end users.\n\nThe prefix is used to generate the CHANGELOG. Every PR must have exactly one -\nif you feel like several match, take the top one from this list that matches for\nyour PR.\n\n## Pull Requests\n\nSmaller Pull Requests (PRs) are preferred as it's typically easier to merge\nthem. For example, if you have some typos, a few code-style changes, a new\nfeature, and a bug-fix, that could be 3 or 4 PRs.\n\nA PR must be complete. That means if you introduce a new feature it must be\nfinished within the PR and have a test for that feature.\n\n## Releases\n\nTo perform a new release, there is the checklist to follow:\n\n1. update `__version__` in `pdfly/_version.py` & `CHANGELOG.md` in order to specify the release date for the new version\n2. perform a `REL`-prefixed commit, _e.g;_ `REL: X.Y.0\"`, then make & merge a PR for it.\n   The Github Actions pipeline should create a new `git` tag, and then publish a new version on Pypi: <https://pypi.org/project/pdfly/#history>\n3. edit the [GitHub release note](https://github.com/py-pdf/pdfly/releases), using the `CHANGELOG.md` content for the description\n"
  },
  {
    "path": "docs/dev/testing.md",
    "content": "# Testing\n\npdfly uses [`pytest`](https://docs.pytest.org/en/latest/) for testing.\n\nTo run the tests you need to install the CI (Continuous Integration) dependencies by running `pip install . --group dev`.\n"
  },
  {
    "path": "docs/index.rst",
    "content": "Welcome to pdfly\n================\n\n.. image:: https://img.shields.io/pypi/v/pdfly.svg\n   :target: https://pypi.org/pypi/pdfly#history\n.. image:: https://img.shields.io/pypi/pyversions/pdfly.svg\n   :target: https://pypi.org/project/pdfly/\n.. image:: https://img.shields.io/badge/License-BSD%203%20Clause-blue.svg\n   :target: https://opensource.org/license/bsd-3-clause\n.. image:: https://app.readthedocs.org/projects/pdfly/badge/?version=latest\n   :target: https://pdfly.readthedocs.io/en/latest/\n\n.. image:: https://github.com/py-pdf/pdfly/workflows/CI/badge.svg\n   :target: https://github.com/py-pdf/pdfly/actions?query=branch%3Amain\n.. image:: https://img.shields.io/github/last-commit/py-pdf/pdfly\n   :target: https://github.com/py-pdf/pdfly/commits/main/\n.. image:: https://img.shields.io/github/issues-closed/py-pdf/pdfly\n   :target: https://github.com/py-pdf/pdfly/issues\n.. image:: https://img.shields.io/github/issues-pr-closed/py-pdf/pdfly\n   :target: https://github.com/py-pdf/pdfly/pulls\n\n.. image:: https://img.shields.io/badge/linters-black,ruff,mypi-green.svg\n   :target: https://github.com/py-pdf/pdfly/actions\n.. image:: https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat\n   :target: https://makeapullrequest.com\n.. image:: https://img.shields.io/badge/first--timers--only-friendly-blue.svg\n   :target: https://www.firsttimersonly.com/\n\npdfly (say: PDF-li) is a pure-python cli application for manipulating PDF files.\n\n.. image:: ./pdfly-logo.png\n   :scale: 25%\n\nRepository: `github.com/py-pdf/pdfly <https://github.com/py-pdf/pdfly>`__\n\nInstallation\n------------\n\n.. code-block::\n\n    pip install -U pdfly\n\nAs ``pdfly`` is an application, you might want to install it with `pipx <https://pypi.org/project/pipx/>`__ or `uv tool <https://docs.astral.sh/uv/concepts/tools/>`__: ``uvx pdfly --help``\n\nUsage\n-----\n\n.. code-block::\n\n    $ pdfly --help\n\n    Usage: pdfly [OPTIONS] COMMAND [ARGS]...\n\n    pdfly is a pure-python cli application for manipulating PDF files.\n\n   ╭─ Options ──────────────────────────────────────────────────────────────────────────────────────╮\n   │ --version                                                                                      │\n   │ --help             Show this message and exit.                                                 │\n   ╰────────────────────────────────────────────────────────────────────────────────────────────────╯\n   ╭─ Commands ─────────────────────────────────────────────────────────────────────────────────────╮\n   │ 2-up                      Create a booklet-style PDF from a single input.                      │\n   │ booklet                   Reorder and two-up PDF pages for booklet printing.                   │\n   │ cat                       Extract and concatenate pages from PDF files into a single PDF file. │\n   │ check-sign                Verifies the signature of a signed PDF.                              │\n   │ compress                  Compress a PDF.                                                      │\n   │ extract-annotated-pages   Extract only the annotated pages from a PDF.                         │\n   │ extract-images            Extract images from PDF without resampling or altering.              │\n   │ extract-text              Extract text from a PDF file.                                        │\n   │ meta                      Show metadata of a PDF file                                          │\n   │ pagemeta                  Give details about a single page.                                    │\n   │ rm                        Remove pages from PDF files.                                         │\n   │ rotate                    Rotate specified pages by the specified amount                       │\n   │ sign                      Creates a signed PDF from an existing PDF file.                      │\n   │ uncompress                Module for uncompressing PDF content streams.                        │\n   │ update-offsets            Updates offsets and lengths in a simple PDF file.                    │\n   │ x2pdf                     Convert one or more files to PDF. Each file is a page.               │\n   ╰────────────────────────────────────────────────────────────────────────────────────────────────╯\n\nYou can see the help of every subcommand by typing ``--help``:\n\n.. code-block::\n\n    $ pdfly 2-up --help\n\n    Usage: pdfly 2-up [OPTIONS] PDF OUT\n\n    Create a booklet-style PDF from a single input.\n    Pairs of two pages will be put on one page (left and right)\n\n    usage: python 2-up.py input_file output_file\n\n   ╭─ Arguments ───────────────────────────────────────╮\n   │ *    pdf      PATH  [default: None] [required]    │\n   │ *    out      PATH  [default: None] [required]    │\n   ╰───────────────────────────────────────────────────╯\n   ╭─ Options ─────────────────────────────────────────╮\n   │ --help          Show this message and exit.       │\n   ╰───────────────────────────────────────────────────╯\n\nGitHub ⭐️\n---------\n\n.. image:: https://api.star-history.com/svg?repos=py-pdf/pdfly&type=date&legend=top-left\n   :target: https://www.star-history.com/#py-pdf/pdfly&type=date&legend=top-left\n\n.. note:: ``pdfly`` has nothing to do with ``pdfly.net`` or ``gopdfly.com``\n\n.. toctree::\n   :caption: User Guide\n   :maxdepth: 1\n\n   user/installation\n   user/subcommand-2-up\n   user/subcommand-booklet\n   user/subcommand-cat\n   user/subcommand-check-sign\n   user/subcommand-compress\n   user/subcommand-extract-annotated-pages\n   user/subcommand-extract-images\n   user/subcommand-extract-text\n   user/subcommand-meta\n   user/subcommand-pagemeta\n   user/subcommand-rm\n   user/subcommand-rotate\n   user/subcommand-sign\n   user/subcommand-uncompress\n   user/subcommand-update-offsets\n   user/subcommand-x2pdf\n\n.. toctree::\n   :caption: Developer Guide\n   :maxdepth: 1\n\n   dev/intro\n   dev/testing\n\n.. toctree::\n   :caption: About pdfly\n   :maxdepth: 1\n\n   meta/CHANGELOG\n   meta/CONTRIBUTORS\n   meta/project-governance\n\nIndices and tables\n==================\n\n* :ref:`genindex`\n* :ref:`modindex`\n* :ref:`search`\n"
  },
  {
    "path": "docs/make.bat",
    "content": "@ECHO OFF\r\n\r\npushd %~dp0\r\n\r\nREM Command file for Sphinx documentation\r\n\r\nif \"%SPHINXBUILD%\" == \"\" (\r\n\tset SPHINXBUILD=sphinx-build\r\n)\r\nset SOURCEDIR=.\r\nset BUILDDIR=_build\r\n\r\n%SPHINXBUILD% >NUL 2>NUL\r\nif errorlevel 9009 (\r\n\techo.\r\n\techo.The 'sphinx-build' command was not found. Make sure you have Sphinx\r\n\techo.installed, then set the SPHINXBUILD environment variable to point\r\n\techo.to the full path of the 'sphinx-build' executable. Alternatively you\r\n\techo.may add the Sphinx directory to PATH.\r\n\techo.\r\n\techo.If you don't have Sphinx installed, grab it from\r\n\techo.https://www.sphinx-doc.org/\r\n\texit /b 1\r\n)\r\n\r\nif \"%1\" == \"\" goto help\r\n\r\n%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%\r\ngoto end\r\n\r\n:help\r\n%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%\r\n\r\n:end\r\npopd\r\n"
  },
  {
    "path": "docs/meta/project-governance.md",
    "content": "# Project Governance\n\nThis document describes how the pdfly project is managed. It describes the\ndifferent actors, their roles, and the responsibilities they have.\n\n`pdfly` is part of the `py-pdf` organization,\nand hence we try to follow some [maintainer guidelines](https://py-pdf.github.io/pages/maintainer-guidelines.html) & [rules](https://py-pdf.github.io/pages/py-pdf-owners.html).\n\n## Terminology\n\n* The **project** is pdfly - a free and open-source pure-python PDF command line\ntool.\n  It includes the [code, issues, and discussions on GitHub](https://github.com/py-pdf/pdfly),\n  and [the documentation on ReadTheDocs](https://pdfly.readthedocs.io/en/latest/),\n  [the package on PyPI](https://pypi.org/project/pdfly/).\n* A **maintainer** is a person who has technical permissions to change one or\n  more part of the projects. It is a person who is driven to keep the project running\n  and improving.\n* A **contributor** is a person who contributes to the project. That could be\n  through writing code - in the best case through forking and creating a pull\n  request, but that is up to the maintainer. Other contributors describe issues,\n  help to ask questions on existing issues to make them easier to answer,\n  participate in discussions, and help to improve the documentation. Contributors\n  are similar to maintainers, but without technical permissions.\n* A **user** is a person who imports pdfly into their code. All pdfly users\n  are developers, but not developers who know the internals of pdfly. They only\n  use the public interface of pdfly. They will likely have less knowledge about\n  PDF than contributors.\n* The **community** is all of that - the users, the contributors, and the maintainers.\n\n\n## Governance, Leadership, and Steering pdfly forward\n\npdfly is a free and open source project.\n\nAs pdfly does not have any formal relationship with any company and no funding,\nall the work done by the community are voluntary contributions. People don't\nget paid, but choose to spend their free time to create software of which\nmany more are profiting. This has to be honored and respected.\n\npdfly has the **Benevolent Dictator**\ngovernance model. The benevolent dictator is a maintainer with all technical permissions -\nmost importantly the permission to push new pdfly versions on PyPI.\n\nBeing benevolent, the benevolent dictator listens for decisions to the community and tries\ntheir best to make decisions from which the overall community profits - the\ncurrent one and the potential future one. Being a dictator, the benevolent dictator always has\nthe power and the right to make decisions on their own - also against some\nmembers of the community.\n\nAs pdfly is free software, parts of the community can split off (fork the code)\nand create a new community. This should limit the harm a bad benevolent dictator can do.\n\n\n## Project Language\n\nThe project language is (american) English. All documentation and issues must\nbe written in English to ensure that the community can understand it.\n\nWe appreciate the fact that large parts of the community don't have English\nas their mother tongue. We try our best to understand others -\n[automatic translators](https://translate.google.com/) might help.\n\n\n## Expectations\n\nThe community can expect the following:\n\n* The **benevolent dictator** tries their best to make decisions from which the overall\n  community profits. The benevolent dictator is aware that his/her decisions can shape the\n  overall community. Once the benevolent dictator notices that she/he doesn't have the time\n  to advance pdfly, he/she looks for a new benevolent dictator. As it is expected\n  that the benevolent dictator will step down at some point of their choice\n  (hopefully before their death), it is NOT a benevolent dictator for life\n  (BDFL).\n* Every **maintainer** (including the benevolent dictator) is aware of their permissions and\n  the harm they could do. They value security and ensure that the project is\n  not harmed. They give their technical permissions back if they don't need them\n  any longer. Any long-time contributor can become a maintainer. Maintainers\n  can - and should! - step down from their role when they realize that they\n  can no longer commit that time.\n* Every **contributor** is aware that the time of maintainers and the benevolent dictator is\n  limited. Short pull requests that briefly describe the solved issue and have\n  a unit test have a higher chance to get merged soon - simply because it's\n  easier for maintainers to see that the contribution will not harm the overall\n  project. Their contributions are documented in the git history and in the\n  public issues.\n* Every **community member** uses a respectful language. We are all human, we\n  get upset about things we care and other things than what's visible on the\n  internet go on in our live. pdfly does not pay its contributors - keep all\n  of that in mind when you interact with others. We are here because we want to\n  help others.\n\n\n### Issues and Discussions\n\nAn issue is any technical description that aims at bringing pdfly forward:\n\n* Bugs tickets: Something went wrong because pdfly developers made a mistake.\n* Feature requests: pdfly does not support all features of the PDF specifications.\n  There are certainly also convenience methods that would help users a lot.\n* Robustness requests: There are many broken PDFs around. In some cases, we can\n  deal with that. It's kind of a mixture between a bug ticket and a feature\n  request.\n* Performance tickets: pdfly could be faster - let us know about your specific\n  scenario.\n\nAny comment that is in those technical descriptions which is not helping the\ndiscussion can be deleted. This is especially true for \"me too\" comments on bugs\nor \"bump\" comments for desired features. People can express this with 👍 / 👎\nreactions.\n\n[Discussions](https://github.com/py-pdf/pdfly/discussions) are open. No comments\nwill be deleted there - except if they are clearly unrelated spam or only\ntry to insult people (luckily, the community was very respectful so far 🤞)\n\n\n### Releases\n\nThe maintainers follow [semantic versioning](https://semver.org/). Most\nimportantly, that means that breaking changes will have a major version bump.\n\nBe aware that unintentional breaking changes might still happen. The `pdfly`\nmaintainers do their best to fix that in a timely manner - please\n[report such issues](https://github.com/py-pdf/pdfly/issues)!\n\n\n## People\n\n* Martin Thoma is benevolent dictator since April 2022.\n* Maintainers:\n    * Matthew Stamy (mstamy2) was the benevolent dictator for a long time.\n      He still is around on GitHub once in a while and has permissions on PyPI and GitHub.\n    * Matthew Peveler (MasterOdin) is a maintainer on GitHub.\n"
  },
  {
    "path": "docs/user/installation.md",
    "content": "# Installation\nThere are several ways to install pdfly. The most common option is to use pip.\n\n## pip\npdfly requires Python 3.10+ to run.\n\nTypically Python comes with `pip`, a package installer. Using it you can\ninstall pdfly:\n\n```bash\npip install pdfly\n```\n\nIf you are not a super-user (a system administrator / root), you can also just\ninstall pdfly for your current user:\n\n```bash\npip install --user pdfly\n```\n\n## pipx\nWe recommend to install pdfly via [pipx](https://pypi.org/project/pipx/):\n\n```bash\npipx install pdfly\n```\n\npipx installs the pdfly application in an isolated environment. That guarantees\nthat no other applications interferes with its defpendencies.\n\n## uv\npdfly can be run without persistent installation using [uv tool run](https://docs.astral.sh/uv/guides/tools/#running-tools):\n\n```bash\nuv tool run pdfly\n```\n\nvia the [uvx](https://docs.astral.sh/uv/guides/tools/#running-tools) alias:\n\n```bash\nuvx pdfly\n```\n\nor it can be installed using [uv tool install](https://docs.astral.sh/uv/guides/tools/#installing-tools):\n\n```bash\nuv tool install pdfly\n```\n\n## Python Version Support\nIf ✓ is given, it works. It is tested via CI.\nIf ✖ is given, it is guaranteed not to work.\nIf it's not filled, we don't guarantee support, but it might still work.\n\n\n| Python                 | 3.14 | 3.13 | 3.12 | 3.11 | 3.10 | 2.7 |\n| ---------------------- | ---- | ---- | ---- | ---- | ---- | --- |\n| pdfly                  |  ✓   |  ✓   |  ✓  |  ✓   |  ✓   |  ✖  |\n\n\n## Development Version\nIn case you want to use the current version under development:\n\n```bash\npip install git+https://github.com/py-pdf/pdfly.git\n```\n"
  },
  {
    "path": "docs/user/subcommand-2-up.md",
    "content": "# 2-up\n\nCreate a booklet-style PDF from a single input.\n\n## Usage\n\n```\n$ pdfly 2-up --help\n Usage: pdfly 2-up [OPTIONS] PDF OUT\n\n Create a booklet-style PDF from a single input.\n\n Pairs of two pages will be put on one page (left and right)\n\n usage: python 2-up.py input_file output_file\n\n╭─ Arguments ──────────────────────────────────────────────────────────────────╮\n│ *    pdf      FILE  [default: None] [required]                               │\n│ *    out      PATH  [default: None] [required]                               │\n╰──────────────────────────────────────────────────────────────────────────────╯\n╭─ Options ────────────────────────────────────────────────────────────────────╮\n│ --help          Show this message and exit.                                  │\n╰──────────────────────────────────────────────────────────────────────────────╯\n```\n\n## Examples\n\nConvert `document.pdf` into a booklet and write the output in `booklet.pdf`.\n```\npdfly 2-up document.pdf booklet.pdf\n\n```\n"
  },
  {
    "path": "docs/user/subcommand-booklet.md",
    "content": "# booklet\n\nReorder and two-up PDF pages for booklet printing.\n\n## Usage\n\n```\n$ pdfly booklet --help\n Usage: pdfly booklet [OPTIONS] FILENAME OUTPUT\n\n Reorder and two-up PDF pages for booklet printing.\n\n If the number of pages is not a multiple of four, pages are\n added until it is a multiple of four. This includes a centerfold\n in the middle of the booklet and a single page on the inside\n back cover. The content of those pages are from the\n centerfold-file and blank-page-file files, if specified, otherwise\n they are blank pages.\n\n Example:\n     pdfly booklet input.pdf output.pdf\n\n╭─ Arguments ──────────────────────────────────────────────────────────────────╮\n│ *    filename      FILE  [default: None] [required]                          │\n│ *    output        FILE  [default: None] [required]                          │\n╰──────────────────────────────────────────────────────────────────────────────╯\n╭─ Options ────────────────────────────────────────────────────────────────────╮\n│ --blank-page-file  -b      FILE  page added if input is odd number of pages  │\n│                                  [default: None]                             │\n│ --centerfold-file  -c      FILE  double-page added if input is missing >= 2  │\n│                                  pages                                       │\n│                                  [default: None]                             │\n│ --help                           Show this message and exit.                 │\n╰──────────────────────────────────────────────────────────────────────────────╯\n\n```\n\n## Examples\n\nConvert `document.pdf` into a booklet and write the output in `booklet.pdf`.\n```\npdfly booklet document.pdf booklet.pdf\n\n```\n"
  },
  {
    "path": "docs/user/subcommand-cat.md",
    "content": "# cat\n\nThe cat command can split / extract pages from a PDF. It can also\njoin/merge/combine multiple PDF documents into a single one.\n\n\n## Usage\n\n```\npdfly cat --help\n\n Usage: pdfly cat [OPTIONS] FILENAME FN_PGRGS...\n\n Extract and concatenate pages from PDF files into a single PDF file.\n Page ranges refer to the previously-named file. A file not followed by a page\n range means all the pages of the file.\n PAGE RANGES are like Python slices.\n Remember, page indices start with zero.\n When using page ranges that start with a negative value a\n two-hyphen symbol -- must be used to separate them from\n the command line options.\n Page range expression examples:\n\n    :     all pages.\n    -1    last page.\n    22    just the  23rd page.\n    :-1   all but the last page.\n    0:3   the first   three pages.\n    -2    second-to-last page.\n    :3    the first      three pages.\n    -2:   last two pages.\n    5:    from the sixth page onward.\n    -3:-1 third & second to last.\n\n The third, \"stride\" or \"step\" number is also recognized.\n\n    ::2       0 2 4 ... to the end.\n    3:0:-1    3 2 1 but not 0.\n    1:10:2    1 3 5 7 9\n    2::-1     2 1 0.\n    ::-1      all  pages in reverse order.\n\n\n Examples\n    pdfly cat -o output.pdf head.pdf -- content.pdf :6 7: tail.pdf -1\n        Concatenate all of head.pdf, all but page seven of content.pdf,\n        and the last page of tail.pdf, producing output.pdf.\n\n    pdfly cat chapter*.pdf >book.pdf\n        You can specify the output file by redirection.\n\n    pdfly cat chapter?.pdf chapter10.pdf >book.pdf\n        In case you don't want chapter 10 before chapter 2.\n\n╭─ Arguments ──────────────────────────────────────────────────────────────────╮\n│ *    filename      PATH         [default: None] [required]                   │\n│ *    fn_pgrgs      FN_PGRGS...  filenames and/or page ranges [default: None] │\n│                                 [required]                                   │\n╰──────────────────────────────────────────────────────────────────────────────╯\n╭─ Options ────────────────────────────────────────────────────────────────────╮\n│ *  --output   -o                  PATH  [default: None] [required]           │\n│    --verbose      --no-verbose          show page ranges as they are being   │\n│                                         read                                 │\n│                                         [default: no-verbose]                │\n│    --help                               Show this message and exit.          │\n╰──────────────────────────────────────────────────────────────────────────────╯\n```\n\n## Examples\n\n### Split a PDF\n\nGet the second, third, and fourth page of a PDF:\n\n```\npdfly cat input.pdf 1:4 -o out.pdf\n```\n\n### Extract a Page\n\nGet the sixt page of a PDF:\n\n```\npdfly cat input.pdf 5 -o out.pdf\n```\n\nNote that it is `5`, because the page indices always start at 0.\n\n### Specify a negative index\n\nGet the last page of a PDF:\n\n```\npdfly cat -o out.pdf input.pdf -- -1\n```\n\n`--` must be used to escape negative indices.\n\n### Concatenate two PDFs\n\nJust combine two PDF files so that the pages come right after each other:\n\n```\npdfly cat input1.pdf input2.pdf -o out.pdf\n```\n\n### Decrypt a PDF document\n\n```\npdfly cat --password=SECRET doc.pdf -o doc-decrypted.pdf\n```\n"
  },
  {
    "path": "docs/user/subcommand-check-sign.md",
    "content": "# check-sign\n\nValidate that a PDF document has a digital signature matching a given certificate.\n\n## Usage\n\n```\n Usage: pdfly check-sign [OPTIONS] FILENAME\n\n Verifies the signature of a signed PDF.\n\n Examples\n pdfly verify input.pdf --pem certs.pem\n\n     Verifies the input.pdf with a PEM certificate bundle.\n\n╭─ Arguments ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮\n│ *    filename      FILE  [required]                                                                                              │\n╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯\n╭─ Options ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮\n│ *  --pem                        FILE  PEM certificate file [required]                                                            │\n│    --verbose    --no-verbose          Show signature verification details. [default: no-verbose]                                 │\n│    --help                             Show this message and exit.                                                                │\n╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯\n```\n\n## Examples\n\n### Verify PDF signature against a PEM certificate\n\nVerifies the input.pdf with a PEM certificate bundle.\n\n```\npdfly verify input.pdf --pem certs.pem\n```\n"
  },
  {
    "path": "docs/user/subcommand-compress.md",
    "content": "# compress\n\nCompress a PDF using lossless FlateDecode compression.\n\n**Note:** If compression would result in a larger file, the original file is kept unchanged to avoid file size increase.\n\n## Usage\n\n```\n$ pdfly compress --help\n Usage: pdfly compress [OPTIONS] PDF OUTPUT\n\n Compress a PDF.\n\n╭─ Arguments ───────────────────────────────────────────╮\n│ *    pdf         FILE  [default: None] [required]     │\n│ *    output      PATH  [default: None] [required]     │\n╰───────────────────────────────────────────────────────╯\n╭─ Options ─────────────────────────────────────────────╮\n│ --help          Show this message and exit.           │\n╰───────────────────────────────────────────────────────╯\n```\n## Examples\n\nCompress the file `document.pdf` and output `document_compressed.pdf`\n\n```\npdfly compress document.pdf document_compressed.pdf\n```\n\nExample output when compression succeeds:\n```\nOriginal Size  : 1,996,123\nFinal Size     : 1,234,567 (Compressed (61.8% of original))\n```\n\nExample output when compression would increase file size:\n```\nOriginal Size  : 887\nFinal Size     : 887 (No compression applied (would increase size))\n```\n"
  },
  {
    "path": "docs/user/subcommand-extract-annotated-pages.md",
    "content": "# extract-annotated-pages\n\nExtract only the annotated pages from a PDF. This can help to review or rework pages from a large document iteratively.\n\n## Usage\n\n```\npdfly extract-annotated-pages --help\n\n Usage: pdfly extract-annotated-pages [OPTIONS] INPUT_PDF\n\n Extract only the annotated pages from a PDF.\n\n Q: Why does this help?\n A: https://github.com/py-pdf/pdfly/issues/97\n\n╭─ Arguments ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮\n│ *    input_pdf      FILE  Input PDF file. [required]                                                                             │\n╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯\n╭─ Options ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮\n│ --output  -o      PATH  Output PDF file. Defaults to 'input_pdf_annotated'.                                                      │\n│ --help                  Show this message and exit.                                                                              │\n╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯\n\n```\n\n## Examples\n\n### Input file\n\nExtracts only pages containing annotations from a file `input.pdf`. Pages are written into a new file `input_annotated.pdf`.\n\n```\npdfly extract-annotated-pages input.pdf\n```\n\n### Input file with specific output file\n\nExtracts only pages containing annotations from a file `input.pdf` into the given output file `pages_to_rework.pdf`.\n\n\n```\npdfly extract-annotated-pages input.pdf -o pages_to_rework.pdf\n```\n"
  },
  {
    "path": "docs/user/subcommand-extract-images.md",
    "content": "# extract-images\n\nExtract text from a PDF file.\n## Usage\n\n```\n$ pdfly extract-images --help\n Usage: pdfly extract-images [OPTIONS] PDF\n\n Extract images from PDF without resampling or altering.\n\n Adapted from work by Sylvain Pelissier\n http://stackoverflow.com/questions/2693820/extract-images-from-pdf-without-res\n ampling-in-python\n\n╭─ Arguments ──────────────────────────────────────────────────────────────────╮\n│ *    pdf      FILE  [default: None] [required]                               │\n╰──────────────────────────────────────────────────────────────────────────────╯\n╭─ Options ────────────────────────────────────────────────────────────────────╮\n│ --help          Show this message and exit.                                  │\n╰──────────────────────────────────────────────────────────────────────────────╯\n\n```\n\n## Examples\n\nExtract the first page of `document.pdf` and extract the images present in it.\n\n```\npdfly cat document.pdf 9 -o page.pdf\n\npdfly extract-text page.pdf\n Extracted 1 images:\n - 0-Im0.png\n\n```\n"
  },
  {
    "path": "docs/user/subcommand-extract-text.md",
    "content": "# extract-text\n\nExtract text from a PDF file.\n## Usage\n\n```\n$ pdfly extract-text --help\n Usage: pdfly extract-text [OPTIONS] PDF\n\n Extract text from a PDF file.\n\n\n╭─ Arguments ──────────────────────────────────────────────────────────────────╮\n│ *    pdf      FILE  [default: None] [required]                               │\n╰──────────────────────────────────────────────────────────────────────────────╯\n╭─ Options ────────────────────────────────────────────────────────────────────╮\n│ --help          Show this message and exit.                                  │\n╰──────────────────────────────────────────────────────────────────────────────╯\n\n```\n\n## Examples\n\nExtract the text from the 10th page of `document.pdf`, redirecting the output into `page.txt`.\n\n```\npdfly cat document.pdf 9 -o page.pdf\n\npdfly extract-text page.pdf\n\n```\n"
  },
  {
    "path": "docs/user/subcommand-meta.md",
    "content": "# meta\n\nGet metadata of a PDF file.\n\n## Usage\n\n```\npdfly meta --help\n\n Usage: pdfly meta [OPTIONS] PDF\n\n Show metadata of a PDF file\n\n╭─ Arguments ───────────────────────────────────────────────────────────────────╮\n│ *    pdf      FILE  [default: None] [required]                                │\n╰───────────────────────────────────────────────────────────────────────────────╯\n╭─ Options ─────────────────────────────────────────────────────────────────────╮\n│ --output  -o      [json|text]  output format [default: text]                  │\n│ --help                         Show this message and exit.                    │\n╰───────────────────────────────────────────────────────────────────────────────╯\n```\n\n## Example\n\n```\n$pdfly meta Allianz-Versicherungsunterlagen.pdf\n\n                              Operating System Data\n┏━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓\n┃         Attribute ┃ Value                                                     ┃\n┡━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩\n│         File Name │ /home/user/Documents/Allianz-Versicherungsunterlagen.pdf  │\n│  File Permissions │ -rw-rw-r--                                                │\n│         File Size │ 874,781 bytes                                             │\n│     Creation Time │ 2023-09-02 10:00:51                                       │\n│ Modification Time │ 2023-09-02 10:00:42                                       │\n│       Access Time │ 2023-09-09 11:57:41                                       │\n└───────────────────┴───────────────────────────────────────────────────────────┘\n                                    PDF Data\n┏━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓\n┃          Attribute ┃ Value                                                    ┃\n┡━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩\n│              Title │                                                          │\n│           Producer │ itext-paulo-155 (itextpdf.sf.net-lowagie.com)            │\n│             Author │                                                          │\n│              Pages │ 34                                                       │\n│          Encrypted │ None                                                     │\n│   PDF File Version │ %PDF-1.6                                                 │\n│        Page Layout │                                                          │\n│          Page Mode │                                                          │\n│             PDF ID │ ID1=b\"'\\xc5\\x92\\xc3\\x92\\xe2\\x80\\x93--/\\xef\\xac\\x824\\xc3… │\n│                    │ ID2=b'\\xc3\\x8b\\xc3\\xaa\\xcb\\x9b\\r\\xc3\\xa2\\r\\xcb\\x99T\\xc3… │\n│                    │ \\xc3\\x96\\xc3\\x9fY2'                                      │\n│ Fonts (unembedded) │ /Helvetica                                               │\n│   Fonts (embedded) │ /ASPNQQ+TT22D6t00, /CBKSHX+Helvetica-Bold,               │\n│                    │ /CXQKAY+Helvetica, /GOCSXU+AllianzNeo-Bold,              │\n│                    │ /LKNHUL+Arial-BoldMT, /LMNFKX+ArialMT, /MWUNIP+Symbol,   │\n│                    │ /ODNMDG+TT5B6t00, /PESMKN+AllianzNeo-CondensedBold,      │\n│                    │ /PHDALA+Helvetica-Oblique, /PJEFXS+AllianzNeo-Light,     │\n│                    │ /SNDABN+Helvetica, /SNDABN+Helvetica-Bold,               │\n│                    │ /SNDABN+Times-Roman, /TXDAYK+Helvetica,                  │\n│                    │ /VORXLN+Helvetica-BoldOblique, /YTXZAH+Arial-ItalicMT    │\n│        Attachments │ []                                                       │\n│             Images │ 16 images (355,454 bytes)                                │\n└────────────────────┴──────────────────────────────────────────────────────────┘\nUse the 'pagemeta' subcommand to get details about a single page\n\n```\n"
  },
  {
    "path": "docs/user/subcommand-pagemeta.md",
    "content": "# pagemeta\n\nGive details about a PDF's single page.\n\n## Usage\n\n```\n$ pdfly pagemeta --help\n Usage: pdfly pagemeta [OPTIONS] PDF PAGE_INDEX\n\n Give details about a single page.\n\n\n╭─ Arguments ──────────────────────────────────────────────────────────────────╮\n│ *    pdf             FILE     [default: None] [required]                     │\n│ *    page_index      INTEGER  [default: None] [required]                     │\n╰──────────────────────────────────────────────────────────────────────────────╯\n╭─ Options ────────────────────────────────────────────────────────────────────╮\n│ --output  -o      [json|text]  output format [default: text]                 │\n│ --help                         Show this message and exit.                   │\n╰──────────────────────────────────────────────────────────────────────────────╯\n```\n\n## Examples\n\nGet the metadata of the 101st page of `document.pdf` in text format.\n```\npdfly pagemeta document.pdf 100\n    /home/user/.../document.pdf, page index 100\n\n    ┏━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓\n    ┃   Attribute ┃ Value                                               ┃\n    ┡━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩\n    │    mediabox │ (0.0, 0.0, 504.0, 661.5): with=504.0 x height=661.5 │\n    │     cropbox │ (0.0, 0.0, 504.0, 661.5): with=504.0 x height=661.5 │\n    │      artbox │ (0.0, 0.0, 504.0, 661.5): with=504.0 x height=661.5 │\n    │    bleedbox │ (0.0, 0.0, 504.0, 661.5): with=504.0 x height=661.5 │\n    │ annotations │ 8                                                   │\n    └─────────────┴─────────────────────────────────────────────────────┘\n    All annotations:\n    1. /Link at [232.05524, 385.79007, 343.6091, 396.29007]\n    2. /Link at [157.63988, 209.99002, 243.69913, 220.49002]\n    3. /Link at [72, 178.19678, 249.65918, 188.69678]\n    4. /Link at [196.12769, 152.40353, 361.02328, 162.90353]\n    5. /Link at [360.97717, 139.80353, 432, 150.30353]\n    6. /Link at [72, 127.20352, 213.9915, 137.70352]\n    7. /Link at [179.64218, 448.3905, 220.08231, 458.8905]\n    8. /Link at [282.84, 347.99005, 340.83148, 358.49005]\n```\n\nGet the same metadata in `json` format.\n\n```\npdfly pagemeta document.pdf 100 -o json\n\n    {\"mediabox\":[0.0,0.0,504.0,661.5],\"cropbox\":[0.0,0.0,504.0,661.5],\"artbox\":[0.0,0.0,504.0,661.5],\"bleedbox\":[0.0,0.0,504.0,661.5],\"annotations\":19}\n```\n"
  },
  {
    "path": "docs/user/subcommand-rm.md",
    "content": "# rm\n\nRemove pages from PDF files.\n\n## Usage\n\n```\n$ pdfly rm --help\nUsage: pdfly rm [OPTIONS] FILENAME FN_PGRGS...\n\n Remove pages from PDF files.\n\n Page ranges refer to the previously-named file.\n A file not followed by a page range means all the pages of the file.\n\n PAGE RANGES are like Python slices.\n\n         Remember, page indices start with zero.\n\n         When using page ranges that start with a negative value a\n         two-hyphen symbol -- must be used to separate them from\n         the command line options.\n\n         Page range expression examples:\n\n             :     all pages.                   -1    last page.\n             22    just the 23rd page.          :-1   all but the last page.\n             0:3   the first three pages.       -2    second-to-last page.\n             :3    the first three pages.       -2:   last two pages.\n             5:    from the sixth page onward.  -3:-1 third & second to last.\n\n         The third, \"stride\" or \"step\" number is also recognized.\n\n             ::2       0 2 4 ... to the end.    3:0:-1    3 2 1 but not 0.\n             1:10:2    1 3 5 7 9                2::-1     2 1 0.\n             ::-1      all pages in reverse order.\n\n Examples\n     pdfly rm -o output.pdf document.pdf 2:5\n\n         Remove pages 2 to 4 from document.pdf, producing output.pdf.\n\n     pdfly rm document.pdf :-1\n\n         Removes all pages except the last one from document.pdf, modifying the original file.\n\n     pdfly rm report.pdf :6 7:\n\n         Remove all pages except page seven from report.pdf,\n         producing a single-page report.pdf.\n\n╭─ Arguments ─────────────────────────────────────────────────────────────────────────────────────────────╮\n│ *    filename      FILE         [default: None] [required]                                              │\n│ *    fn_pgrgs      FN_PGRGS...  filenames and/or page ranges [default: None] [required]                 │\n╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯\n╭─ Options ───────────────────────────────────────────────────────────────────────────────────────────────╮\n│ *  --output   -o                  PATH  [default: None] [required]                                      │\n│    --verbose      --no-verbose          show page ranges as they are being read [default: no-verbose]   │\n│    --help                               Show this message and exit.                                     │\n╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯\n```\n\n## Examples\n\nRemove the 5th page of `document.pdf`, modifying the original file.\n\n```\npdfly rm document.pdf 4\n\n```\n\nRemove the first and last page of `document.pdf`, producing `output.pdf`.\n\n```\npdfly rm -o output.pdf document.pdf 1:-1\n\n```\n"
  },
  {
    "path": "docs/user/subcommand-rotate.md",
    "content": "# rotate\n\n## Usage\n\n```\npdfly rotate --help\n\n Usage: pdfly rotate [OPTIONS] FILENAME DEGREES [PGRGS]\n\n Rotate specified pages by the specified amount\n\n Example:\n     pdfly rotate --output output.pdf input.pdf 90\n         Rotate all pages by 90 degrees (clockwise)\n\n     pdfly rotate --output output.pdf input.pdf 90 :3\n         Rotate first three pages by 90 degrees (clockwise)\n\n     pdfly rotate --output output.pdf input.pdf 90 -- -1\n         Rotate last page by 90 degrees (clockwise)\n\n A file not followed by a page range (PGRGS) means all the pages of the file.\n\n PAGE RANGES are like Python slices.\n\n         Remember, page indices start with zero.\n\n         When using page ranges that start with a negative value a\n         two-hyphen symbol -- must be used to separate them from\n         the command line options.\n\n         Page range expression examples:\n\n             :     all pages.                   -1    last page.\n             22    just the 23rd page.          :-1   all but the last page.\n             0:3   the first three pages.       -2    second-to-last page.\n             :3    the first three pages.       -2:   last two pages.\n             5:    from the sixth page onward.  -3:-1 third & second to last.\n\n         The third, \"stride\" or \"step\" number is also recognized.\n\n             ::2       0 2 4 ... to the end.    3:0:-1    3 2 1 but not 0.\n             1:10:2    1 3 5 7 9                2::-1     2 1 0.\n             ::-1      all pages in reverse order.\n\n╭─ Arguments ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮\n│ *    filename      FILE     [required]                                                                                                                                                     │\n│ *    degrees       INTEGER  degrees to rotate [required]                                                                                                                                   │\n│      pgrgs         [PGRGS]  page range [default: :]                                                                                                                                        │\n╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯\n╭─ Options ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮\n│ *  --output  -o      PATH  [required]                                                                                                                                                      │\n│    --help                  Show this message and exit.                                                                                                                                     │\n╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯\n```\n\n## Examples\n\n### Rotate all pages by 90 degrees (clockwise)\n\nRotate all pages from `input.pdf` by 90 degrees (clockwise) and write the resulting pdf to `output.pdf`.\n\n```\npdfly rotate --output output.pdf input.pdf 90\n```\n\n### Rotate first three pages by 90 degrees (clockwise)\n\nRotate first three pages from `input.pdf` by 90 degrees (clockwise) and write the resulting pdf to `output.pdf`.\n\n```\npdfly rotate --output output.pdf input.pdf 90 :3\n```\n\n### Rotate last page by 90 degrees (clockwise)\n\nRotate last page from `input.pdf` by 90 degrees (clockwise) and write the resulting pdf to `output.pdf`.\n\n```\npdfly rotate --output output.pdf input.pdf 90 -- -1\n```\n"
  },
  {
    "path": "docs/user/subcommand-sign.md",
    "content": "# sign\n\nCreates a digitally-signed PDF from an existing PDF file and a given certificate.\n\n## Usage\n\n```\nUsage: pdfly sign [OPTIONS] FILENAME\n\nCreates a signed PDF.\n\nExamples\npdfly sign input.pdf --p12 certs.p12 -o signed.pdf\n\n    Signs the input.pdf with a PKCS12 certificate archive. Writes the resulting signed pdf into signed.pdf.\n\npdfly sign document.pdf --p12 certs.p12 --in-place\n\n    Signs the document.pdf with a PKCS12 certificate archive. Modifies the input file in-place.\n\n╭─ Arguments ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮\n│ *    filename      FILE  [required]                                                                                              │\n╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯\n╭─ Options ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮\n│ *  --p12                   FILE  PKCS12 certificate container [required]                                                         │\n│    --output        -o      PATH                                                                                                  │\n│    --in-place      -i                                                                                                            │\n│    --p12-password  -p      TEXT  The password to use to decrypt the PKCS12 file.                                                 │\n│    --help                        Show this message and exit.                                                                     │\n╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯\n```\n\n## Examples\n\n### Sign a PDF with PKCS12\n\nSigns the input.pdf with a PKCS12 certificate archive. Writes the resulting signed pdf into signed.pdf.\n\n```\npdfly sign input.pdf --p12 certs.p12 -o signed.pdf\n```\n\n### Sign a PDF in-place\n\nSigns the document.pdf with a PKCS12 certificate archive. Modifies the input file in-place.\n\n```\npdfly sign document.pdf --p12 certs.p12 --in-place\n```\n"
  },
  {
    "path": "docs/user/subcommand-uncompress.md",
    "content": "# uncompress\n\nModule for uncompressing PDF content streams.\n## Usage\n\n```\n$ pdfly ucompress --help\n Module for uncompressing PDF content streams.\n\n ╭─ Arguments ───────────────────────────────────────────╮\n │ *    pdf         FILE  [default: None] [required]     │\n │ *    output      PATH  [default: None] [required]     │\n ╰───────────────────────────────────────────────────────╯\n ╭─ Options ─────────────────────────────────────────────╮\n │ --help          Show this message and exit.           │\n ╰───────────────────────────────────────────────────────╯\n```\n\n## Examples\n\nUncompress `document_compressed.pdf` and output `document.pdf`.\n\n```\npdfly uncompress document_compressed.pdf document.pdf\n```\n"
  },
  {
    "path": "docs/user/subcommand-update-offsets.md",
    "content": "# update-offsets\n\nUpdates offsets and lengths in a simple PDF file.\n\n## Usage\n\n```\n$ pdfly update-offsets --help\n Usage: pdfly update-offsets [OPTIONS] FILE_IN FILE_OUT\n\n Updates offsets and lengths in a simple PDF file.\n\n The PDF specification requires that the xref section at the end\n of a PDF file has the correct offsets of the PDF's objects.\n It further requires that the dictionary of a stream object\n contains a /Length-entry giving the length of the encoded stream.\n\n When editing a PDF file using a text-editor (e.g. vim) it is\n elaborate to compute or adjust these offsets and lengths.\n\n This command tries to compute /Length-entries of the stream dictionaries\n and the offsets in the xref-section automatically.\n\n It expects that the PDF file has ASCII encoding only. It may\n use ISO-8859-1 or UTF-8 in its comments.\n The current implementation incorrectly replaces CR (0x0d) by LF (0x0a) in\n binary data.\n It expects that there is one xref-section only.\n It expects that the /Length-entries have default values containing\n enough digits, e.g. /Length 000 when the stream consists of 576 bytes.\n\n Example:\n    update-offsets --verbose --encoding ISO-8859-1 issue-297.pdf\n issue-297.out.pdf\n\n╭─ Arguments ──────────────────────────────────────────────────────────────────╮\n│ *    file_in       FILE  [default: None] [required]                          │\n│ *    file_out      PATH  [default: None] [required]                          │\n╰──────────────────────────────────────────────────────────────────────────────╯\n╭─ Options ────────────────────────────────────────────────────────────────────╮\n│ --encoding                    TEXT  Encoding used to read and write the      │\n│                                     files, e.g. UTF-8.                       │\n│                                     [default: ISO-8859-1]                    │\n│ --verbose     --no-verbose          Show progress while processing.          │\n│                                     [default: no-verbose]                    │\n│ --help                              Show this message and exit.              │\n╰──────────────────────────────────────────────────────────────────────────────╯\n\n```\n\n## Examples\n\nUpdate the offsets of `document.pdf` with UTF-8 encoding and write the output to `document.out.pdf`.\n```\npdfly update-offsets document.pdf --verbose --encoding UTF-8 document.out.pdf\n```\n"
  },
  {
    "path": "docs/user/subcommand-x2pdf.md",
    "content": "# x2pdf\n\nConvert a file to PDF.\n\nCurrently supported for \"x\":\n\n* PNG\n* JPG\n\n\n## Usage\n\n```\n$ pdfly x2pdf --help\n\n Usage: pdfly x2pdf [OPTIONS] X...\n\n Convert one or more files to PDF. Each file is a page.\n\n╭─ Arguments ─────────────────────────────────────────────────────────────────╮\n│ *    x      X...  [default: None] [required]                                │\n╰─────────────────────────────────────────────────────────────────────────────╯\n╭─ Options ───────────────────────────────────────────────────────────────────╮\n│ *  --output  -o      PATH  [default: None] [required]                       │\n│    --help                  Show this message and exit.                      │\n╰─────────────────────────────────────────────────────────────────────────────╯\n```\n\n## Examples\n\n### Single file\n\n```\n$ pdfly x2pdf image.jpg -o out.pdf\n$ ls -lh\n-rw-rw-r-- 1 user user 47K Sep 17 21:49 image.jpg\n-rw-rw-r-- 1 user user 49K Sep 17 22:48 out.pdf\n```\n\n### Multiple files manually\n\n```\n$ pdfly x2pdf image1.jpg image2.jpg -o out.pdf\n$ ls -lh\n-rw-rw-r-- 1 user user 47K Sep 17 21:49 image1.jpg\n-rw-rw-r-- 1 user user 15K Sep 17 21:49 image2.jpg\n-rw-rw-r-- 1 user user 64K Sep 17 22:48 out.pdf\n```\n\n### Multiple files via *\n\n```\n$ pdfly x2pdf *.jpg -o out.pdf\n$ ls -lh\n-rw-rw-r-- 1 user user 47K Sep 17 21:49 image1.jpg\n-rw-rw-r-- 1 user user 15K Sep 17 21:49 image2.jpg\n-rw-rw-r-- 1 user user 64K Sep 17 22:48 out.pdf\n```\n"
  },
  {
    "path": "make_release.py",
    "content": "\"\"\"Internal tool to update the CHANGELOG.\"\"\"\n\nimport json\nimport subprocess\nimport urllib.request\nfrom dataclasses import dataclass\nfrom datetime import datetime, timezone\nfrom typing import Any\n\nfrom rich.prompt import Prompt\n\nGH_ORG = \"py-pdf\"\nGH_PROJECT = \"pdfly\"\nVERSION_FILE_PATH = \"pdfly/_version.py\"\nCHANGELOG_FILE_PATH = \"CHANGELOG.md\"\n\n\n@dataclass(frozen=True)\nclass Change:\n    \"\"\"Capture the data of a git commit.\"\"\"\n\n    commit_hash: str\n    prefix: str\n    message: str\n    author: str\n    author_login: str\n\n\ndef main(changelog_path: str) -> None:\n    \"\"\"\n    Create a changelog.\n\n    Args:\n        changelog_path: The location of the CHANGELOG file\n\n    \"\"\"\n    changelog = get_changelog(changelog_path)\n    git_tag = get_most_recent_git_tag()\n    changes, changes_with_author = get_formatted_changes(git_tag)\n    if changes == \"\":\n        print(\"No changes\")\n        return\n\n    new_version = version_bump(git_tag)\n    new_version = get_version_interactive(new_version, changes)\n    adjust_version_py(new_version)\n\n    today = datetime.now(tz=timezone.utc)\n    header = f\"## Version {new_version}, {today:%Y-%m-%d}\\n\"\n    url = f\"https://github.com/{GH_ORG}/{GH_PROJECT}/compare/{git_tag}...{new_version}\"\n    trailer = f\"\\n[Full Changelog]({url})\\n\\n\"\n    new_entry = header + changes + trailer\n    print(new_entry)\n    write_commit_msg_file(new_version, changes_with_author + trailer)\n    write_release_msg_file(new_version, changes_with_author + trailer, today)\n\n    # Make the script idempotent by checking if the new entry is already in the changelog\n    if new_entry in changelog:\n        print(\"Changelog is already up-to-date!\")\n        return\n\n    new_changelog = \"# CHANGELOG\\n\\n\" + new_entry + strip_header(changelog)\n    write_changelog(new_changelog, changelog_path)\n    print_instructions(new_version)\n\n\ndef print_instructions(new_version: str) -> None:\n    \"\"\"Print release instructions.\"\"\"\n    print(\"=\" * 80)\n    print(f\"☑  {VERSION_FILE_PATH} was adjusted to '{new_version}'\")\n    print(f\"☑  {CHANGELOG_FILE_PATH} was adjusted\")\n    print()\n    print(\"Now run:\")\n    print(\"  git commit -eF RELEASE_COMMIT_MSG.md\")\n    print(f\"  git tag -s {new_version} -eF RELEASE_TAG_MSG.md\")\n    print(\"  git push\")\n    print(\"  git push --tags\")\n\n\ndef adjust_version_py(version: str) -> None:\n    \"\"\"Adjust the __version__ string.\"\"\"\n    with open(VERSION_FILE_PATH, \"w\") as fp:\n        fp.write(f'__version__ = \"{version}\"\\n')\n\n\ndef get_version_interactive(new_version: str, changes: str) -> str:\n    \"\"\"Get the new __version__ interactively.\"\"\"\n    print(\"The changes are:\")\n    print(changes)\n    orig = new_version\n    new_version = Prompt.ask(\"New semantic version\", default=orig)\n    while not is_semantic_version(new_version):\n        new_version = Prompt.ask(\n            \"That was not a semantic version. Please enter a semantic version\",\n            default=orig,\n        )\n    return new_version\n\n\ndef is_semantic_version(version: str) -> bool:\n    \"\"\"Check if the given version is a semantic version.\"\"\"\n    # This doesn't cover the edge-cases like pre-releases\n    if version.count(\".\") != 2:\n        return False\n    try:\n        return bool([int(part) for part in version.split(\".\")])\n    except Exception:\n        return False\n\n\ndef write_commit_msg_file(new_version: str, commit_changes: str) -> None:\n    \"\"\"\n    Write a file that can be used as a commit message.\n\n    Like this:\n\n        git commit -eF RELEASE_COMMIT_MSG.md && git push\n    \"\"\"\n    with open(\"RELEASE_COMMIT_MSG.md\", \"w\") as fp:\n        fp.write(f\"REL: {new_version}\\n\\n\")\n        fp.write(\"## What's new\\n\")\n        fp.write(commit_changes)\n\n\ndef write_release_msg_file(\n    new_version: str, commit_changes: str, today: datetime\n) -> None:\n    \"\"\"\n    Write a file that can be used as a git tag message.\n\n    Like this:\n\n        git tag -eF RELEASE_TAG_MSG.md && git push\n    \"\"\"\n    with open(\"RELEASE_TAG_MSG.md\", \"w\") as fp:\n        fp.write(f\"Version {new_version}, {today:%Y-%m-%d}\\n\\n\")\n        fp.write(\"## What's new\\n\")\n        fp.write(commit_changes)\n\n\ndef strip_header(md: str) -> str:\n    \"\"\"Remove the 'CHANGELOG' header.\"\"\"\n    return md.lstrip(\"# CHANGELOG\").lstrip()  # noqa\n\n\ndef version_bump(git_tag: str) -> str:\n    \"\"\"\n    Increase the patch version of the git tag by one.\n\n    Args:\n        git_tag: Old version tag\n\n    Returns:\n        The new version where the patch version is bumped.\n\n    \"\"\"\n    # just assume a patch version change\n    major, minor, patch = git_tag.split(\".\")\n    return f\"{major}.{minor}.{int(patch) + 1}\"\n\n\ndef get_changelog(changelog_path: str) -> str:\n    \"\"\"\n    Read the changelog.\n\n    Args:\n        changelog_path: Path to the CHANGELOG file\n\n    Returns:\n        Data of the CHANGELOG\n\n    \"\"\"\n    with open(changelog_path) as fh:\n        changelog = fh.read()\n    return changelog\n\n\ndef write_changelog(new_changelog: str, changelog_path: str) -> None:\n    \"\"\"\n    Write the changelog.\n\n    Args:\n        new_changelog: Contents of the new CHANGELOG\n        changelog_path: Path where the CHANGELOG file is\n\n    \"\"\"\n    with open(changelog_path, \"w\") as fh:\n        fh.write(new_changelog)\n\n\ndef get_formatted_changes(git_tag: str) -> tuple[str, str]:\n    \"\"\"\n    Format the changes done since the last tag.\n\n    Args:\n        git_tag: the reference tag\n\n    Returns:\n        Changes done since git_tag\n\n    \"\"\"\n    commits = get_git_commits_since_tag(git_tag)\n\n    # Group by prefix\n    grouped: dict[str, list[dict[str, Any]]] = {}\n    for commit in commits:\n        if commit.prefix not in grouped:\n            grouped[commit.prefix] = []\n        grouped[commit.prefix].append(\n            {\"msg\": commit.message, \"author\": commit.author_login}\n        )\n\n    # Order prefixes\n    order = [\n        \"SEC\",\n        \"DEP\",\n        \"ENH\",\n        \"PI\",\n        \"BUG\",\n        \"ROB\",\n        \"DOC\",  # We ignore MRs from Dependabot prefixed with: \"Docs:\"\n        \"DEV\",\n        \"CI\",\n        \"MAINT\",\n        \"TST\",\n        \"STY\",\n    ]\n    abbrev2long = {\n        \"SEC\": \"Security\",\n        \"DEP\": \"Deprecations\",\n        \"ENH\": \"New Features\",\n        \"BUG\": \"Bug Fixes\",\n        \"ROB\": \"Robustness\",\n        \"DOC\": \"Documentation\",\n        \"DEV\": \"Developer Experience\",\n        \"CI\": \"Continuous Integration\",\n        \"MAINT\": \"Maintenance\",\n        \"TST\": \"Testing\",\n        \"STY\": \"Code Style\",\n        \"PI\": \"Performance Improvements\",\n    }\n\n    # Create output\n    output = \"\"\n    output_with_user = \"\"\n    for prefix in order:\n        if prefix not in grouped:\n            continue\n        tmp = f\"\\n### {abbrev2long[prefix]} ({prefix})\\n\"  # header\n        output += tmp\n        output_with_user += tmp\n        for commit_dict in grouped[prefix]:\n            output += f\"- {commit_dict['msg']}\\n\"\n            output_with_user += (\n                f\"- {commit_dict['msg']} by @{commit_dict['author']}\\n\"\n            )\n        del grouped[prefix]\n\n    if grouped:\n        output += \"\\n### Other\\n\"\n        output_with_user += \"\\n### Other\\n\"\n        for prefix, commit_dicts in grouped.items():\n            for commit_dict in commit_dicts:\n                output += f\"- {prefix}: {commit_dict['msg']}\\n\"\n                output_with_user += f\"- {prefix}: {commit_dict['msg']} by @{commit_dict['author']}\\n\"\n\n    return output, output_with_user\n\n\ndef get_most_recent_git_tag() -> str:\n    \"\"\"\n    Get the git tag most recently created.\n\n    Returns:\n        Most recently created git tag.\n\n    \"\"\"\n    git_tag = str(\n        subprocess.check_output(\n            [\"git\", \"describe\", \"--abbrev=0\"], stderr=subprocess.STDOUT\n        )\n    ).strip(\"'b\\\\n\")\n    return git_tag\n\n\ndef get_author_mapping(line_count: int) -> dict[str, str]:\n    \"\"\"\n    Get the authors for each commit.\n\n    Args:\n        line_count: Number of lines from Git log output. Used for determining how\n            many commits to fetch.\n\n    Returns:\n        A mapping of long commit hashes to author login handles.\n\n    \"\"\"\n    per_page = min(line_count, 100)\n    page = 1\n    mapping: dict[str, str] = {}\n    for _ in range(0, line_count, per_page):\n        with urllib.request.urlopen(\n            f\"https://api.github.com/repos/{GH_ORG}/{GH_PROJECT}/commits?per_page={per_page}&page={page}\"\n        ) as response:\n            commits = json.loads(response.read())\n        page += 1\n        for commit in commits:\n            if commit[\"author\"]:\n                gh_handle = commit[\"author\"][\"login\"]\n            else:\n                # This is not perfect, but better than the other option\n                gh_handle = commit[\"commit\"][\"author\"][\"name\"].replace(\" \", \"\")\n            mapping[commit[\"sha\"]] = gh_handle\n    return mapping\n\n\ndef get_git_commits_since_tag(git_tag: str) -> list[Change]:\n    \"\"\"\n    Get all commits since the last tag.\n\n    Args:\n        git_tag: Reference tag from which the changes to the current commit are\n            fetched.\n\n    Returns:\n        list of all changes since git_tag.\n\n    \"\"\"\n    commits = (\n        subprocess.check_output(\n            [\n                \"git\",\n                \"--no-pager\",\n                \"log\",\n                f\"{git_tag}..HEAD\",\n                '--pretty=format:\"%H:::%s:::%aN\"',\n            ],\n            stderr=subprocess.STDOUT,\n        )\n        .decode(\"UTF-8\")\n        .strip()\n    )\n    lines = commits.splitlines()\n    authors = get_author_mapping(len(lines))\n    return [parse_commit_line(line, authors) for line in lines if line != \"\"]\n\n\ndef parse_commit_line(line: str, authors: dict[str, str]) -> Change:\n    \"\"\"\n    Parse the first line of a git commit message.\n\n    Args:\n        line: The first line of a git commit message.\n\n    Returns:\n        The parsed Change object\n\n    Raises:\n        ValueError: The commit line is not well-structured\n\n    \"\"\"\n    parts = line.split(\":::\")\n    if len(parts) != 3:\n        raise ValueError(f\"Invalid commit line: '{line}'\")\n    commit_hash, rest, author = parts\n    if \":\" in rest:\n        prefix, message = rest.split(\": \", 1)\n    else:\n        prefix = \"\"\n        message = rest\n\n    # Standardize\n    message.strip()\n    commit_hash = commit_hash.strip('\"')\n\n    author = author.removesuffix('\"')\n    author_login = authors[commit_hash]\n\n    prefix = prefix.strip()\n    if prefix == \"DOCS\":\n        prefix = \"DOC\"\n\n    return Change(\n        commit_hash=commit_hash,\n        prefix=prefix,\n        message=message,\n        author=author,\n        author_login=author_login,\n    )\n\n\nif __name__ == \"__main__\":\n    main(CHANGELOG_FILE_PATH)\n"
  },
  {
    "path": "mypy.ini",
    "content": "[mypy]\nplugins = pydantic.mypy\n"
  },
  {
    "path": "pdfly/__init__.py",
    "content": "\"\"\"pdfly is a command line utility for manipulating PDFs and getting information about them.\"\"\"\n\nfrom ._version import __version__\n\n__all__ = [\n    \"__version__\",\n]\n"
  },
  {
    "path": "pdfly/__main__.py",
    "content": "\"\"\"Execute pdfly as a module.\"\"\"\n\nfrom pdfly.cli import entry_point\n\nif __name__ == \"__main__\":\n    entry_point()\n"
  },
  {
    "path": "pdfly/_utils.py",
    "content": "from enum import Enum\n\n\nclass OutputOptions(Enum):\n    json = \"json\"\n    text = \"text\"\n"
  },
  {
    "path": "pdfly/_version.py",
    "content": "__version__ = \"0.5.1\"\n"
  },
  {
    "path": "pdfly/booklet.py",
    "content": "\"\"\"\nReorder and two-up PDF pages for booklet printing.\n\nIf the number of pages is not a multiple of four, pages are\nadded until it is a multiple of four. This includes a centerfold\nin the middle of the booklet and a single page on the inside\nback cover. The content of those pages are from the\ncenterfold-file and blank-page-file files, if specified, otherwise\nthey are blank pages.\n\nExample:\n    pdfly booklet input.pdf output.pdf\n\n\"\"\"\n\n# Copyright (c) 2014, Steve Witham <switham_github@mac-guyver.com>.\n# All rights reserved. This software is available under a BSD license;\n# see https://github.com/py-pdf/pypdf/LICENSE\n\nfrom collections.abc import Generator\nfrom pathlib import Path\n\nfrom pypdf import (\n    PageObject,\n    PdfReader,\n    PdfWriter,\n)\nfrom pypdf.generic import FloatObject, RectangleObject\n\n\ndef main(\n    filename: Path,\n    output: Path,\n    inside_cover_file: Path | None,\n    centerfold_file: Path | None,\n) -> None:\n    try:\n        # Set up the streams\n        reader = PdfReader(filename)\n        pages = list(reader.pages)\n        writer = PdfWriter()\n\n        # Add blank pages to make the number of pages a multiple of 4\n        # If the user specified an inside-back-cover file, use it.\n        blank_page = PageObject.create_blank_page(\n            width=pages[0].mediabox.width, height=pages[0].mediabox.height\n        )\n        if len(pages) % 2 == 1:\n            if inside_cover_file:\n                ic_reader_page = fetch_first_page(inside_cover_file)\n                pages.insert(-1, ic_reader_page)\n            else:\n                pages.insert(-1, blank_page)\n        if len(pages) % 4 == 2:\n            pages.insert(len(pages) // 2, blank_page)\n            pages.insert(len(pages) // 2, blank_page)\n            requires_centerfold = True\n        else:\n            requires_centerfold = False\n\n        # Reorder the pages and place two pages side by side (2-up) on each sheet\n        for lhs, rhs in page_iter(len(pages)):\n            pages[lhs].merge_translated_page(\n                page2=pages[rhs],\n                tx=pages[lhs].mediabox.width,\n                ty=0,\n                expand=True,\n                over=True,\n            )\n            # Double the CropBox width:\n            pages[lhs].cropbox[2] = FloatObject(2 * pages[lhs].cropbox[2])\n            writer.add_page(pages[lhs])\n\n        # If a centerfold was required, it is already\n        # present as a pair of blank pages. If the user\n        # specified a centerfold file, use it instead.\n        if requires_centerfold and centerfold_file:\n            centerfold_page = fetch_first_page(centerfold_file)\n            last_page = writer.pages[-1]\n            if centerfold_page.rotation != 0:\n                centerfold_page.transfer_rotation_to_content()\n            if requires_rotate(centerfold_page.mediabox, last_page.mediabox):\n                centerfold_page = centerfold_page.rotate(270)\n            if centerfold_page.rotation != 0:\n                centerfold_page.transfer_rotation_to_content()\n            last_page.merge_page(centerfold_page)\n\n        # Everything looks good! Write the output file.\n        with open(output, \"wb\") as output_fh:\n            writer.write(output_fh)\n\n    except Exception as error:\n        raise RuntimeError(f\"Error while processing {filename}\") from error\n\n\ndef requires_rotate(a: RectangleObject, b: RectangleObject) -> bool:\n    \"\"\"\n    Return True if a and b are rotated relative to each other.\n\n    Args:\n        a (RectangleObject): The first rectangle.\n        b (RectangleObject): The second rectangle.\n\n    \"\"\"\n    a_portrait = a.height > a.width\n    b_portrait = b.height > b.width\n    return a_portrait != b_portrait\n\n\ndef fetch_first_page(filename: Path) -> PageObject:\n    \"\"\"\n    Fetch the first page of a PDF file.\n\n    Args:\n        filename (Path): The path to the PDF file.\n\n    Returns:\n        PageObject: The first page of the PDF file.\n\n    \"\"\"\n    return PdfReader(filename).pages[0]\n\n\n# This function written with inspiration, assistance, and code\n# from claude.ai & Github Copilot\ndef page_iter(num_pages: int) -> Generator[tuple[int, int], None, None]:\n    \"\"\"\n    Generate pairs of page numbers for printing a booklet.\n    This function assumes that the total number of pages is divisible by 4.\n    It yields tuples of page numbers that should be printed on the same sheet\n    of paper to create a booklet.\n\n    Args:\n        num_pages (int): The total number of pages in the document. Must be divisible by 4.\n\n    Yields:\n        Generator[tuple[int, int], None, None]: tuples containing pairs of page numbers.\n            Each tuple represents the page numbers to be printed on one side of a sheet.\n\n    Raises:\n        ValueError: If the number of pages is not divisible by 4.\n\n    \"\"\"\n    if num_pages % 4 != 0:\n        raise ValueError(\"Number of pages must be divisible by 4\")\n\n    for sheet in range(num_pages // 4):\n        # Outside the fold\n        last_page = num_pages - sheet * 2 - 1\n        first_page = sheet * 2\n\n        # Inside the fold\n        second_page = sheet * 2 + 1\n        second_to_last_page = num_pages - sheet * 2 - 2\n\n        yield last_page, first_page\n        yield second_page, second_to_last_page\n"
  },
  {
    "path": "pdfly/cat.py",
    "content": "\"\"\"\nConcatenate pages from PDF files into a single PDF file.\n\nPage ranges refer to the previously-named file.\nA file not followed by a page range means all the pages of the file.\n\nPAGE RANGES are like Python slices.\n\n        Remember, page indices start with zero.\n\n        When using page ranges that start with a negative value a\n        two-hyphen symbol -- must be used to separate them from\n        the command line options.\n\n        Page range expression examples:\n\n            :     all pages.                   -1    last page.\n            22    just the 23rd page.          :-1   all but the last page.\n            0:3   the first three pages.       -2    second-to-last page.\n            :3    the first three pages.       -2:   last two pages.\n            5:    from the sixth page onward.  -3:-1 third & second to last.\n\n        The third, \"stride\" or \"step\" number is also recognized.\n\n            ::2       0 2 4 ... to the end.    3:0:-1    3 2 1 but not 0.\n            1:10:2    1 3 5 7 9                2::-1     2 1 0.\n            ::-1      all pages in reverse order.\n\nExamples\n    pdfly cat -o output.pdf head.pdf -- content.pdf :6 7: tail.pdf -1\n\n        Concatenate all of head.pdf, all but page seven of content.pdf,\n        and the last page of tail.pdf, producing output.pdf.\n\n    pdfly cat chapter*.pdf >book.pdf\n\n        You can specify the output file by redirection.\n\n    pdfly cat chapter?.pdf chapter10.pdf >book.pdf\n\n        In case you don't want chapter 10 before chapter 2.\n\n\"\"\"\n\n# Copyright (c) 2014, Steve Witham <switham_github@mac-guyver.com>.\n# All rights reserved. This software is available under a BSD license;\n# see https://github.com/py-pdf/pypdf/LICENSE\n\nimport os\nimport sys\nfrom pathlib import Path\n\nfrom pypdf import (\n    PageRange,\n    PasswordType,\n    PdfReader,\n    PdfWriter,\n    parse_filename_page_ranges,\n)\nfrom rich.console import Console\n\n\ndef main(\n    filename: Path,\n    fn_pgrgs: list[str] | None,\n    output: Path,\n    verbose: bool,\n    inverted_page_selection: bool = False,\n    password: str | None = None,\n) -> None:\n    console = Console()\n    filename_page_ranges = parse_filepaths_and_pagerange_args(\n        console, filename, fn_pgrgs\n    )\n    if output:\n        output_fh = open(output, \"wb\")\n    else:\n        sys.stdout.flush()\n        output_fh = os.fdopen(sys.stdout.fileno(), \"wb\")\n\n    writer = PdfWriter()\n    in_fs = {}\n    try:\n        for filepath, page_range in filename_page_ranges:  # type: ignore\n            if verbose:\n                print(filepath, page_range, file=sys.stderr)\n            if filepath not in in_fs:\n                in_fs[filepath] = open(filepath, \"rb\")\n\n            reader = PdfReader(in_fs[filepath])\n            if (\n                password is not None\n                and reader.decrypt(password) == PasswordType.NOT_DECRYPTED\n            ):\n                console.print(\n                    \"[red]Error: the decrypting password provided is invalid\"\n                )\n                sys.exit(1)\n            num_pages = len(reader.pages)\n            start, end, _step = page_range.indices(num_pages)\n            if (\n                start < 0\n                or end < 0\n                or start >= num_pages\n                or end > num_pages\n                or start > end\n            ):\n                print(\n                    f\"WARNING: Page range {page_range} is out of bounds\",\n                    file=sys.stderr,\n                )\n            if inverted_page_selection:\n                all_page_nums = set(range(len(reader.pages)))\n                page_nums = set(range(*page_range.indices(len(reader.pages))))\n                inverted_page_nums = all_page_nums - page_nums\n                for page_num in inverted_page_nums:\n                    writer.add_page(reader.pages[page_num])\n            else:\n                for page_num in range(*page_range.indices(len(reader.pages))):\n                    writer.add_page(reader.pages[page_num])\n        writer.write(output_fh)\n    except Exception as error:\n        raise RuntimeError(f\"Error while reading {filename}\") from error\n    finally:\n        output_fh.close()\n    # In 3.0, input files must stay open until output is written.\n    # Not closing the in_fs because this script exits now.\n\n\ndef parse_filepaths_and_pagerange_args(\n    console: Console, filename: Path, fn_pgrgs: list[str] | None\n) -> list[tuple[Path, PageRange]]:\n    fn_pgrgs_l = list(fn_pgrgs) if fn_pgrgs else []\n    fn_pgrgs_l.insert(0, str(filename))\n    filename_page_ranges, invalid_filepaths = [], []\n    for filepath, page_range in parse_filename_page_ranges(fn_pgrgs_l):  # type: ignore\n        if Path(filepath).is_file():\n            filename_page_ranges.append((Path(filepath), page_range))\n        else:\n            invalid_filepaths.append(str(filepath))\n    if invalid_filepaths:\n        console.print(\n            f\"[red]Error: invalid file path or page range provided: {' '.join(invalid_filepaths)}\"\n        )\n        sys.exit(2)\n    return filename_page_ranges\n"
  },
  {
    "path": "pdfly/check_sign.py",
    "content": "\"\"\"\nVerifies the signature of a signed PDF.\n\nExamples\n    pdfly verify input.pdf --pem certs.pem\n\n        Verifies the input.pdf with a PEM certificate bundle.\n\n\n\"\"\"\n\nimport sys\nfrom pathlib import Path\n\nimport typer\nfrom endesive import pdf\n\n\ndef main(filename: Path, pem: Path, verbose: bool | None) -> None:\n    x509_certificates = [pem.read_bytes()]\n    results = pdf.verify(filename.read_bytes(), x509_certificates)\n\n    if len(results) == 0:\n        raise typer.BadParameter(\"Signature missing\")\n\n    details: list[str] = []\n    for hash_ok, signature_ok, cert_ok in results:\n        if not signature_ok:\n            details.append(\"Signature not ok\")\n        elif verbose:\n            details.append(\"Signature ok\")\n        if not hash_ok:\n            details.append(\"Content hash not ok\")\n        elif verbose:\n            details.append(\"Content hash ok\")\n        if not cert_ok:\n            details.append(\"Certificate not ok\")\n        elif verbose:\n            details.append(\"Certificate ok\")\n\n    details_str = \"\" if len(details) == 0 else \" (\" + \", \".join(details) + \")\"\n    for hash_ok, signature_ok, cert_ok in results:\n        if not signature_ok or not hash_ok or not cert_ok:\n            print(f\"Check failed{details_str}.\", file=sys.stderr)\n            raise typer.Exit(code=1)\n\n    print(f\"Check succeeded{details_str}.\")\n"
  },
  {
    "path": "pdfly/cli.py",
    "content": "\"\"\"\nDefine how the CLI should behave.\n\nSubcommands are added here.\n\"\"\"\n\nfrom pathlib import Path\nfrom typing import Annotated\n\nimport typer\n\nimport pdfly.booklet\nimport pdfly.cat\nimport pdfly.check_sign\nimport pdfly.compress\nimport pdfly.extract_annotated_pages\nimport pdfly.extract_images\nimport pdfly.metadata\nimport pdfly.pagemeta\nimport pdfly.rm\nimport pdfly.rotate\nimport pdfly.sign\nimport pdfly.uncompress\nimport pdfly.up2\nimport pdfly.update_offsets\nimport pdfly.x2pdf\n\n\ndef version_callback(value: bool) -> None:\n    import pypdf\n\n    if value:\n        typer.echo(f\"pdfly {pdfly.__version__}\")\n        typer.echo(f\"  using pypdf=={pypdf.__version__}\")\n        raise typer.Exit\n\n\nentry_point = typer.Typer(\n    add_completion=False,\n    help=(\n        \"pdfly is a pure-python cli application for manipulating PDF files.\"\n    ),\n    rich_markup_mode=\"rich\",  # Allows to pretty-print commands documentation\n)\n\n\n@entry_point.callback()  # type: ignore[misc]\ndef common(\n    ctx: typer.Context,\n    version: bool = typer.Option(None, \"--version\", callback=version_callback),\n) -> None:\n    pass\n\n\n@entry_point.command(name=\"2-up\", help=pdfly.up2.__doc__)  # type: ignore[misc]\ndef up2(\n    pdf: Annotated[\n        Path,\n        typer.Argument(\n            dir_okay=False,\n            exists=True,\n            resolve_path=True,\n        ),\n    ],\n    out: Path,\n) -> None:\n    pdfly.up2.main(pdf, out)\n\n\n@entry_point.command(name=\"booklet\", help=pdfly.booklet.__doc__)  # type: ignore[misc]\ndef booklet(\n    filename: Annotated[\n        Path,\n        typer.Argument(\n            dir_okay=False,\n            exists=True,\n            resolve_path=True,\n        ),\n    ],\n    output: Annotated[\n        Path,\n        typer.Argument(\n            dir_okay=False,\n            exists=False,\n            resolve_path=False,\n        ),\n    ],\n    blank_page: Annotated[\n        Path | None,\n        typer.Option(\n            \"-b\",\n            \"--blank-page-file\",\n            help=\"page added if input is odd number of pages\",\n            dir_okay=False,\n            exists=True,\n            resolve_path=True,\n        ),\n    ] = None,\n    centerfold: Annotated[\n        Path | None,\n        typer.Option(\n            \"-c\",\n            \"--centerfold-file\",\n            help=\"double-page added if input is missing >= 2 pages\",\n            dir_okay=False,\n            exists=True,\n            resolve_path=True,\n        ),\n    ] = None,\n) -> None:\n    pdfly.booklet.main(filename, output, blank_page, centerfold)\n\n\n@entry_point.command(name=\"cat\", help=pdfly.cat.__doc__)  # type: ignore[misc]\ndef cat(\n    filename: Annotated[\n        Path,\n        typer.Argument(\n            dir_okay=False,\n            exists=True,\n            resolve_path=True,\n        ),\n    ],\n    fn_pgrgs: list[str] | None = typer.Argument(  # noqa: B008\n        None, allow_dash=True, help=\"filenames and/or page ranges\"\n    ),\n    output: Path = typer.Option(..., \"-o\", \"--output\"),  # noqa\n    password: str = typer.Option(\n        None, help=\"Document's user or owner password.\"\n    ),\n    verbose: bool = typer.Option(\n        False, help=\"show page ranges as they are being read\"\n    ),\n) -> None:\n    pdfly.cat.main(\n        filename, fn_pgrgs, output=output, verbose=verbose, password=password\n    )\n\n\n@entry_point.command(name=\"check-sign\", help=pdfly.check_sign.__doc__)\ndef check_sign(\n    filename: Annotated[\n        Path,\n        typer.Argument(dir_okay=False, exists=True, resolve_path=True),\n    ],\n    pem: Annotated[\n        Path,\n        typer.Option(\n            ...,\n            dir_okay=False,\n            exists=True,\n            resolve_path=True,\n            help=\"PEM certificate file\",\n        ),\n    ],\n    verbose: bool = typer.Option(\n        False, help=\"Show signature verification details.\"\n    ),\n) -> None:\n    pdfly.check_sign.main(filename, pem, verbose)\n\n\n@entry_point.command(name=\"compress\", help=pdfly.compress.__doc__)  # type: ignore[misc]\ndef compress(\n    pdf: Annotated[\n        Path,\n        typer.Argument(\n            dir_okay=False,\n            exists=True,\n            resolve_path=True,\n        ),\n    ],\n    output: Annotated[\n        Path,\n        typer.Argument(\n            writable=True,\n        ),\n    ],\n) -> None:\n    pdfly.compress.main(pdf, output)\n\n\n@entry_point.command(name=\"extract-annotated-pages\", help=pdfly.extract_annotated_pages.__doc__)  # type: ignore[misc]\ndef extract_annotated_pages(\n    input_pdf: Annotated[\n        Path,\n        typer.Argument(\n            dir_okay=False,\n            exists=True,\n            resolve_path=True,\n            help=\"Input PDF file.\",\n        ),\n    ],\n    output_pdf: Annotated[\n        Path | None,\n        typer.Option(\n            \"--output\",\n            \"-o\",\n            writable=True,\n            help=\"Output PDF file. Defaults to 'input_pdf_annotated'.\",\n        ),\n    ] = None,\n) -> None:\n    pdfly.extract_annotated_pages.main(input_pdf, output_pdf)\n\n\n@entry_point.command(name=\"extract-images\", help=pdfly.extract_images.__doc__)  # type: ignore[misc]\ndef extract_images(\n    pdf: Annotated[\n        Path,\n        typer.Argument(\n            dir_okay=False,\n            exists=True,\n            resolve_path=True,\n        ),\n    ],\n) -> None:\n    pdfly.extract_images.main(pdf)\n\n\n@entry_point.command(name=\"extract-text\")  # type: ignore[misc]\ndef extract_text(\n    pdf: Annotated[\n        Path,\n        typer.Argument(\n            dir_okay=False,\n            exists=True,\n            resolve_path=True,\n        ),\n    ],\n) -> None:\n    \"\"\"Extract text from a PDF file.\"\"\"\n    from pypdf import PdfReader\n\n    reader = PdfReader(str(pdf))\n    for page in reader.pages:\n        typer.echo(page.extract_text())\n\n\n@entry_point.command(name=\"meta\", help=pdfly.metadata.__doc__)  # type: ignore[misc]\ndef metadata(\n    pdf: Annotated[\n        Path,\n        typer.Argument(\n            dir_okay=False,\n            exists=True,\n            resolve_path=True,\n        ),\n    ],\n    output: pdfly.metadata.OutputOptions = typer.Option(  # noqa\n        pdfly.metadata.OutputOptions.text.value,\n        \"--output\",\n        \"-o\",\n        help=\"output format\",\n        show_default=True,\n    ),\n) -> None:\n    pdfly.metadata.main(pdf, output)\n\n\n@entry_point.command(name=\"pagemeta\", help=pdfly.pagemeta.__doc__)  # type: ignore[misc]\ndef pagemeta(\n    pdf: Annotated[\n        Path,\n        typer.Argument(\n            dir_okay=False,\n            exists=True,\n            resolve_path=True,\n        ),\n    ],\n    page_index: int,\n    output: pdfly.metadata.OutputOptions = typer.Option(  # noqa\n        pdfly.metadata.OutputOptions.text.value,\n        \"--output\",\n        \"-o\",\n        help=\"output format\",\n        show_default=True,\n    ),\n) -> None:\n    pdfly.pagemeta.main(\n        pdf,\n        page_index,\n        output,\n    )\n\n\n@entry_point.command(name=\"rm\", help=pdfly.rm.__doc__)\ndef rm(\n    filename: Annotated[\n        Path,\n        typer.Argument(\n            dir_okay=False,\n            exists=True,\n            resolve_path=True,\n        ),\n    ],\n    output: Path = typer.Option(..., \"-o\", \"--output\"),  # noqa\n    fn_pgrgs: list[str] = typer.Argument(  # noqa\n        ..., help=\"filenames and/or page ranges\"\n    ),\n    verbose: bool = typer.Option(\n        False, help=\"show page ranges as they are being read\"\n    ),\n) -> None:\n    pdfly.rm.main(filename, fn_pgrgs, output, verbose)\n\n\n@entry_point.command(name=\"rotate\", help=pdfly.rotate.__doc__)  # type: ignore[misc]\ndef rotate(\n    filename: Annotated[\n        Path,\n        typer.Argument(\n            dir_okay=False,\n            exists=True,\n            resolve_path=True,\n        ),\n    ],\n    degrees: Annotated[int, typer.Argument(..., help=\"degrees to rotate\")],\n    pgrgs: Annotated[str, typer.Argument(..., help=\"page range\")] = \":\",\n    output: Path = typer.Option(..., \"-o\", \"--output\"),  # noqa\n) -> None:\n    pdfly.rotate.main(filename, output, degrees, pgrgs)\n\n\n@entry_point.command(name=\"sign\", help=pdfly.sign.__doc__)\ndef sign(\n    filename: Annotated[\n        Path,\n        typer.Argument(dir_okay=False, exists=True, resolve_path=True),\n    ],\n    p12: Annotated[\n        Path,\n        typer.Option(\n            ...,\n            dir_okay=False,\n            exists=True,\n            resolve_path=True,\n            help=\"PKCS12 certificate container\",\n        ),\n    ],\n    output: Annotated[Path | None, typer.Option(\"--output\", \"-o\")] = None,\n    in_place: bool = typer.Option(False, \"--in-place\", \"-i\"),\n    p12_password: Annotated[\n        str | None,\n        typer.Option(\n            \"--p12-password\",\n            \"-p\",\n            help=\"The password to use to decrypt the PKCS12 file.\",\n        ),\n    ] = None,\n) -> None:\n    pdfly.sign.main(filename, output, in_place, p12, p12_password)\n\n\n@entry_point.command(name=\"uncompress\", help=pdfly.uncompress.__doc__)  # type: ignore[misc]\ndef uncompress(\n    pdf: Annotated[\n        Path,\n        typer.Argument(\n            dir_okay=False,\n            exists=True,\n            resolve_path=True,\n        ),\n    ],\n    output: Annotated[\n        Path,\n        typer.Argument(\n            writable=True,\n        ),\n    ],\n) -> None:\n    pdfly.uncompress.main(pdf, output)\n\n\n@entry_point.command(name=\"update-offsets\", help=pdfly.update_offsets.__doc__)  # type: ignore[misc]\ndef update_offsets(\n    file_in: Annotated[\n        Path,\n        typer.Argument(\n            dir_okay=False,\n            exists=True,\n            resolve_path=True,\n        ),\n    ],\n    file_out: Annotated[\n        Path, typer.Option(\"-o\", \"--output\")  # noqa\n    ] = None,  # type: ignore[assignment]\n    encoding: str = typer.Option(\n        \"ISO-8859-1\",\n        help=\"Encoding used to read and write the files, e.g. UTF-8.\",\n    ),\n    verbose: bool = typer.Option(\n        False, help=\"Show progress while processing.\"\n    ),\n) -> None:\n    pdfly.update_offsets.main(file_in, file_out, encoding, verbose)\n\n\n@entry_point.command(name=\"x2pdf\", help=pdfly.x2pdf.__doc__)  # type: ignore[misc]\ndef x2pdf(\n    x: list[\n        Annotated[\n            Path,\n            typer.Argument(\n                dir_okay=False,\n                exists=True,\n                resolve_path=True,\n            ),\n        ]\n    ],\n    output: Annotated[\n        Path,\n        typer.Option(\n            \"-o\",\n            \"--output\",\n            writable=True,\n        ),\n    ],\n) -> None:\n    exit_code = pdfly.x2pdf.main(x, output)\n    if exit_code:\n        raise typer.Exit(code=exit_code)\n"
  },
  {
    "path": "pdfly/compress.py",
    "content": "\"\"\"Compress a PDF.\"\"\"\n\nimport shutil\nfrom io import BytesIO\nfrom pathlib import Path\n\nfrom pypdf import PdfReader, PdfWriter\n\n\ndef main(pdf: Path, output: Path) -> None:\n    reader = PdfReader(pdf)\n    writer = PdfWriter()\n    for page in reader.pages:\n        writer.add_page(page)\n\n    if reader.metadata:\n        writer.add_metadata(reader.metadata)\n\n    for page in writer.pages:\n        page.compress_content_streams()\n\n    # PDF to memory buffer first\n    compressed_buffer = BytesIO()\n    writer.write(compressed_buffer)\n    compressed_data = compressed_buffer.getvalue()\n    comp_size = len(compressed_data)\n\n    orig_size = pdf.stat().st_size\n\n    # If compressed size is larger than original, use original file\n    if comp_size >= orig_size:\n        print(\n            f\"Compression resulted in larger file ({comp_size:,} >= {orig_size:,} bytes)\"\n        )\n        print(\"Keeping original file as compressed version would be larger\")\n        shutil.copy2(pdf, output)\n        final_size = orig_size\n        ratio = 100.0\n        status = \"No compression applied (would increase size)\"\n    else:\n        with open(output, \"wb\") as fp:\n            fp.write(compressed_data)\n        final_size = comp_size\n        ratio = (comp_size / orig_size) * 100\n        status = f\"Compressed ({ratio:.1f}% of original)\"\n\n    print(f\"Original Size  : {orig_size:,}\")\n    print(f\"Final Size     : {final_size:,} ({status})\")\n"
  },
  {
    "path": "pdfly/extract_annotated_pages.py",
    "content": "\"\"\"\nExtract only the annotated pages from a PDF.\n\nQ: Why does this help?\nA: https://github.com/py-pdf/pdfly/issues/97\n\"\"\"\n\nfrom pathlib import Path\nfrom typing import TYPE_CHECKING\n\nfrom pypdf import PdfReader, PdfWriter\nfrom pypdf.annotations import AnnotationDictionary\n\nif TYPE_CHECKING:\n    from pypdf.generic import ArrayObject\n\n\n# Check if an annotation is manipulable.\ndef is_manipulable(annot: AnnotationDictionary) -> bool:\n    return annot.get(\"/Subtype\") != \"/Link\"\n\n\n# Main function.\ndef main(input_pdf: Path, output_pdf: Path | None) -> None:\n    if not output_pdf:\n        output_pdf = input_pdf.with_name(input_pdf.stem + \"_annotated.pdf\")\n    input = PdfReader(input_pdf)\n    output = PdfWriter()\n    output_pages = 0\n    # Copy only the pages with annotations\n    for page in input.pages:\n        if \"/Annots\" not in page:\n            continue\n        page_annots: ArrayObject = page[\"/Annots\"]  # type: ignore[assignment]\n        if not any(is_manipulable(annot) for annot in page_annots):\n            continue\n        output.add_page(page)\n        output_pages += 1\n    # Save the output PDF\n    output.write(output_pdf)\n    print(f\"Extracted {output_pages} pages with annotations to {output_pdf}\")\n"
  },
  {
    "path": "pdfly/extract_images.py",
    "content": "\"\"\"\nExtract images from PDF without resampling or altering.\n\nAdapted from work by Sylvain Pelissier\nhttp://stackoverflow.com/questions/2693820/extract-images-from-pdf-without-resampling-in-python\n\"\"\"\n\nfrom pathlib import Path\n\nfrom pypdf import PdfReader\n\n\ndef main(pdf: Path) -> None:\n    reader = PdfReader(str(pdf))\n    extracted_images = []\n    for page_index, page0 in enumerate(reader.pages):\n        for image_file_object in page0.images:\n            path = f\"{page_index:04d}-{image_file_object.name}\"\n            with open(path, \"wb\") as fp:\n                fp.write(image_file_object.data)\n            extracted_images.append(path)\n\n    if len(extracted_images) == 0:\n        print(\"No image found.\")\n    else:\n        print(f\"Extracted {len(extracted_images)} images:\")\n        for path in extracted_images:\n            print(f\"- {path}\")\n"
  },
  {
    "path": "pdfly/metadata.py",
    "content": "\"\"\"Show metadata of a PDF file\"\"\"\n\nimport stat\nfrom datetime import datetime\nfrom pathlib import Path\n\nfrom pydantic import BaseModel\nfrom pypdf import PdfReader\n\nfrom ._utils import OutputOptions\n\n\nclass EncryptionData(BaseModel):\n    revision: int\n    v_value: int\n\n\nclass MetaInfo(BaseModel):\n    encryption: EncryptionData | None = None\n    pdf_file_version: str\n    pages: int | None = None\n    page_mode: str | None = None\n    page_layout: str | None = None\n    attachments: str = \"unknown\"\n    id1: bytes | None = None\n    id2: bytes | None = None\n    images: list[int] = []\n\n    # PDF /Info dictionary\n    author: str | None = None\n    creation_date: datetime | None = None\n    creator: str | None = None\n    keywords: str | None = None\n    producer: str | None = None\n    subject: str | None = None\n    title: str | None = None\n\n    # OS Information\n    file_permissions: str\n    file_size: int  # in bytes\n    creation_time: datetime\n    modification_time: datetime\n    access_time: datetime\n\n\ndef main(pdf: Path, output: OutputOptions) -> None:\n    reader = PdfReader(str(pdf))\n    if reader.is_encrypted:\n        pdf_stat = pdf.stat()\n        meta = MetaInfo(\n            encryption=(\n                EncryptionData(\n                    v_value=reader._encryption.V,\n                    revision=reader._encryption.R,\n                )\n                if reader._encryption\n                else None\n            ),\n            pdf_file_version=reader.stream.read(8).decode(\"utf-8\"),\n            # OS Info\n            file_permissions=f\"{stat.filemode(pdf_stat.st_mode)}\",\n            file_size=pdf_stat.st_size,\n            creation_time=datetime.fromtimestamp(pdf_stat.st_ctime),\n            modification_time=datetime.fromtimestamp(pdf_stat.st_mtime),\n            access_time=datetime.fromtimestamp(pdf_stat.st_atime),\n        )\n    else:\n        info = reader.metadata\n        reader.stream.seek(0)\n        pdf_file_version = reader.stream.read(8).decode(\"utf-8\")\n        pdf_stat = pdf.stat()\n        pdf_id = reader.trailer.get(\"/ID\")\n        meta = MetaInfo(\n            pages=len(reader.pages),\n            page_mode=reader.page_mode,\n            pdf_file_version=pdf_file_version,\n            page_layout=reader.page_layout,\n            attachments=str(list(reader.attachments.keys())),\n            id1=pdf_id[0] if pdf_id is not None else None,\n            id2=pdf_id[1] if pdf_id is not None and len(pdf_id) >= 2 else None,\n            # OS Info\n            file_permissions=f\"{stat.filemode(pdf_stat.st_mode)}\",\n            file_size=pdf_stat.st_size,\n            creation_time=datetime.fromtimestamp(pdf_stat.st_ctime),\n            modification_time=datetime.fromtimestamp(pdf_stat.st_mtime),\n            access_time=datetime.fromtimestamp(pdf_stat.st_atime),\n            images=[\n                len(image.data)\n                for page in reader.pages\n                for image in page.images\n            ],\n        )\n        if info is not None:\n            meta.author = info.author\n            meta.creation_date = info.creation_date\n            meta.creator = info.creator\n            # Pending https://github.com/py-pdf/pypdf/pull/2939 to be able to access .keywords:\n            meta.keywords = info.get(\"/Keywords\")\n            meta.producer = info.producer\n            meta.subject = info.subject\n            meta.title = info.title\n\n    if output == OutputOptions.json:\n        print(meta.json())\n    else:\n        from rich.console import Console\n        from rich.table import Table\n\n        table = Table(title=\"PDF Data\")\n        table.add_column(\n            \"Attribute\", justify=\"right\", style=\"cyan\", no_wrap=True\n        )\n        table.add_column(\"Value\", style=\"white\")\n\n        if meta.title:\n            table.add_row(\"Title\", meta.title)\n        if meta.author:\n            table.add_row(\"Author\", meta.author)\n        if meta.creation_date:\n            table.add_row(\"CreationDate\", str(meta.creation_date))\n        if meta.creator:\n            table.add_row(\"Creator\", meta.creator)\n        if meta.producer:\n            table.add_row(\"Producer\", meta.producer)\n        if meta.subject:\n            table.add_row(\"Subject\", meta.subject)\n        if meta.keywords:\n            table.add_row(\"Keywords\", meta.keywords)\n        table.add_row(\"Pages\", f\"{meta.pages:,}\" if meta.pages else \"unknown\")\n        table.add_row(\"Encrypted\", f\"{meta.encryption}\")\n        table.add_row(\"PDF File Version\", meta.pdf_file_version)\n        table.add_row(\"Page Layout\", meta.page_layout)\n        table.add_row(\"Page Mode\", meta.page_mode)\n        table.add_row(\"PDF ID\", f\"ID1={meta.id1!r} ID2={meta.id2!r}\")\n        embedded_fonts: set[str] = set()\n        unemedded_fonts: set[str] = set()\n        if not reader.is_encrypted:\n            for page in reader.pages:\n                emb, unemb = page._get_fonts()\n                embedded_fonts = embedded_fonts.union(set(emb))\n                unemedded_fonts = unemedded_fonts.union(set(unemb))\n            table.add_row(\n                \"Fonts (unembedded)\", \", \".join(sorted(unemedded_fonts))\n            )\n            table.add_row(\n                \"Fonts (embedded)\", \", \".join(sorted(embedded_fonts))\n            )\n        table.add_row(\"Attachments\", meta.attachments)\n        table.add_row(\n            \"Images\", f\"{len(meta.images)} images ({sum(meta.images):,} bytes)\"\n        )\n\n        enc_table = Table(title=\"Encryption information\")\n        enc_table.add_column(\n            \"Attribute\", justify=\"right\", style=\"cyan\", no_wrap=True\n        )\n        enc_table.add_column(\"Value\", style=\"white\")\n        if meta.encryption:\n            enc_table.add_row(\n                \"Security Handler Revision Number\",\n                str(meta.encryption.revision),\n            )\n            enc_table.add_row(\"V value\", str(meta.encryption.v_value))\n\n        os_table = Table(title=\"Operating System Data\")\n        os_table.add_column(\n            \"Attribute\", justify=\"right\", style=\"cyan\", no_wrap=True\n        )\n        os_table.add_column(\"Value\", style=\"white\")\n        os_table.add_row(\"File Name\", f\"{pdf}\")\n        os_table.add_row(\"File Permissions\", f\"{meta.file_permissions}\")\n        os_table.add_row(\"File Size\", f\"{meta.file_size:,} bytes\")\n        os_table.add_row(\n            \"Creation Time\", f\"{meta.creation_time:%Y-%m-%d %H:%M:%S}\"\n        )\n        os_table.add_row(\n            \"Modification Time\", f\"{meta.modification_time:%Y-%m-%d %H:%M:%S}\"\n        )\n        os_table.add_row(\n            \"Access Time\", f\"{meta.access_time:%Y-%m-%d %H:%M:%S}\"\n        )\n        console = Console()\n        console.print(os_table)\n        console.print(table)\n        if meta.encryption:\n            console.print(enc_table)\n        console.print(\n            \"Use the 'pagemeta' subcommand to get details about a single page\"\n        )\n"
  },
  {
    "path": "pdfly/pagemeta.py",
    "content": "\"\"\"Give details about a single page.\"\"\"\n\nfrom pathlib import Path\n\nfrom pydantic import BaseModel\nfrom pypdf import PdfReader\nfrom rich.console import Console\nfrom rich.markdown import Markdown\nfrom rich.table import Table\n\nfrom ._utils import OutputOptions\n\nKNOWN_PAGE_FORMATS = {\n    (841.89, 1190.55): \"A3\",  # 297mm x 420mm\n    (595.28, 841.89): \"A4\",  # 210mm x 297mm\n    (420.94, 595.28): \"A5\",  # 148mm x 210mm\n    (297.66, 420.94): \"A6\",  # 105mm x 148mm\n    (612, 792): \"Letter\",\n    (612, 1008): \"Legal\",\n}\n\n\nclass PageMeta(BaseModel):\n    mediabox: tuple[float, float, float, float]\n    cropbox: tuple[float, float, float, float]\n    artbox: tuple[float, float, float, float]\n    bleedbox: tuple[float, float, float, float]\n    annotations: int\n    rotation: int\n\n\ndef main(pdf: Path, page_index: int, output: OutputOptions) -> None:\n    reader = PdfReader(pdf)\n    page = reader.pages[page_index]\n    meta = PageMeta(\n        mediabox=page.mediabox,\n        cropbox=page.cropbox,\n        artbox=page.artbox,\n        bleedbox=page.bleedbox,\n        annotations=len(page.annotations) if page.annotations else 0,\n        rotation=page.rotation,\n    )\n\n    if output == OutputOptions.json:\n        print(meta.json())\n    else:\n        console = Console()\n        table = Table(title=f\"{pdf}, page index {page_index}\")\n        table.add_column(\n            \"Attribute\", justify=\"right\", style=\"cyan\", no_wrap=True\n        )\n        table.add_column(\"Value\", style=\"white\")\n\n        def add_box_attr(\n            name: str, box: tuple[float, float, float, float]\n        ) -> None:\n            width = box[2] - box[0]\n            height = box[3] - box[1]\n            known_format = find_known_format(width, height)\n            extra = f\" ({known_format})\" if known_format else \"\"\n            table.add_row(\n                name,\n                f\"({box[0]:.2f}, {box[1]:.2f}, {box[2]:.2f}, {box[3]:.2f}):\"\n                f\" {width=:.2f} x {height=:.2f}{extra}\",\n            )\n\n        add_box_attr(\"mediabox\", meta.mediabox)\n        add_box_attr(\"cropbox\", meta.cropbox)\n        add_box_attr(\"artbox\", meta.artbox)\n        add_box_attr(\"bleedbox\", meta.bleedbox)\n\n        if meta.annotations:\n            table.add_row(\"annotations\", str(meta.annotations))\n        if meta.rotation:\n            table.add_row(\"rotation\", str(meta.rotation))\n\n        console.print(table)\n\n        if page.annotations:\n            console.print(Markdown(\"**All annotations:**\"))\n            for i, annot in enumerate(page.annotations, start=1):\n                obj = annot.get_object()\n                console.print(f\"{i}. {obj['/Subtype']} at {obj['/Rect']}\")\n\n\ndef find_known_format(width: float, height: float) -> str:\n    known_format = KNOWN_PAGE_FORMATS.get((width, height))\n    if known_format:\n        return known_format\n    for (w, h), name in KNOWN_PAGE_FORMATS.items():\n        if ((w - width) * (w - width) + (h - height) * (h - height)) < 4:\n            return f\"close to format: {name}\"\n    return \"\"\n"
  },
  {
    "path": "pdfly/rm.py",
    "content": "\"\"\"\nRemove pages from PDF files.\n\nPage ranges refer to the previously-named file.\nA file not followed by a page range means all the pages of the file.\n\nPAGE RANGES are like Python slices.\n\n        Remember, page indices start with zero.\n\n        When using page ranges that start with a negative value a\n        two-hyphen symbol -- must be used to separate them from\n        the command line options.\n\n        Page range expression examples:\n\n            :     all pages.                   -1    last page.\n            22    just the 23rd page.          :-1   all but the last page.\n            0:3   the first three pages.       -2    second-to-last page.\n            :3    the first three pages.       -2:   last two pages.\n            5:    from the sixth page onward.  -3:-1 third & second to last.\n\n        The third, \"stride\" or \"step\" number is also recognized.\n\n            ::2       0 2 4 ... to the end.    3:0:-1    3 2 1 but not 0.\n            1:10:2    1 3 5 7 9                2::-1     2 1 0.\n            ::-1      all pages in reverse order.\n\nExamples\n    pdfly rm -o output.pdf document.pdf 2:5\n\n        Remove pages 2 to 4 from document.pdf, producing output.pdf.\n\n    pdfly rm document.pdf -- -1\n\n        Removes the last page from document.pdf, modifying the original file.\n\n    pdfly rm document.pdf :-1\n\n        Removes all pages except the last one from document.pdf, modifying the original file.\n\n    pdfly rm report.pdf :6 7:\n\n        Remove all pages except page seven from report.pdf,\n        producing a single-page report.pdf.\n\n\"\"\"\n\nfrom pathlib import Path\n\nfrom pdfly.cat import main as cat_main\n\n\ndef main(\n    filename: Path, fn_pgrgs: list[str], output: Path, verbose: bool\n) -> None:\n    cat_main(filename, fn_pgrgs, output, verbose, inverted_page_selection=True)\n"
  },
  {
    "path": "pdfly/rotate.py",
    "content": "\"\"\"\nRotate specified pages by the specified amount\n\nExample:\n    pdfly rotate --output output.pdf input.pdf 90\n        Rotate all pages by 90 degrees (clockwise)\n\n    pdfly rotate --output output.pdf input.pdf 90 :3\n        Rotate first three pages by 90 degrees (clockwise)\n\n    pdfly rotate --output output.pdf input.pdf 90 -- -1\n        Rotate last page by 90 degrees (clockwise)\n\nA file not followed by a page range (PGRGS) means all the pages of the file.\n\nPAGE RANGES are like Python slices.\n\n        Remember, page indices start with zero.\n\n        When using page ranges that start with a negative value a\n        two-hyphen symbol -- must be used to separate them from\n        the command line options.\n\n        Page range expression examples:\n\n            :     all pages.                   -1    last page.\n            22    just the 23rd page.          :-1   all but the last page.\n            0:3   the first three pages.       -2    second-to-last page.\n            :3    the first three pages.       -2:   last two pages.\n            5:    from the sixth page onward.  -3:-1 third & second to last.\n\n        The third, \"stride\" or \"step\" number is also recognized.\n\n            ::2       0 2 4 ... to the end.    3:0:-1    3 2 1 but not 0.\n            1:10:2    1 3 5 7 9                2::-1     2 1 0.\n            ::-1      all pages in reverse order.\n\n\n\"\"\"\n\nfrom pathlib import Path\n\nfrom pypdf import (\n    PageRange,\n    PdfReader,\n    PdfWriter,\n)\nfrom rich.console import Console\n\n\ndef main(\n    filename: Path,\n    output: Path,\n    degrees: int,\n    page_range: str,\n) -> None:\n    try:\n        # set up the streams\n        reader = PdfReader(filename)\n        pages = list(reader.pages)\n        writer = PdfWriter()\n\n        # Convert the page range into a set of page numbers\n        pages_to_rotate = convert_range_to_pages(page_range, len(pages))\n\n        for page_index, page in enumerate(pages):\n            if page_index in pages_to_rotate:\n                page = page.rotate(degrees)\n            writer.add_page(page)\n\n        # Everything looks good! Write the output file.\n        with open(output, \"wb\") as output_fh:\n            writer.write(output_fh)\n\n    except Exception as error:\n        console = Console()\n        console.print(f\"Error while rotating {filename}\")\n        raise error\n\n\ndef convert_range_to_pages(page_range: str, num_pages: int) -> set[int]:\n    pages_to_rotate = {*range(*PageRange(page_range).indices(num_pages))}\n    return pages_to_rotate\n"
  },
  {
    "path": "pdfly/sign.py",
    "content": "\"\"\"\nCreates a signed PDF from an existing PDF file.\n\nExamples\n    pdfly sign input.pdf --p12 certs.p12 -o signed.pdf\n\n        Signs the input.pdf with a PKCS12 certificate archive. Writes the resulting signed pdf into signed.pdf.\n\n    pdfly sign document.pdf --p12 certs.p12 --in-place\n\n        Signs the document.pdf with a PKCS12 certificate archive. Modifies the input file in-place.\n\n\"\"\"\n\nimport io\nimport tempfile\nfrom collections.abc import Generator\nfrom contextlib import contextmanager\nfrom pathlib import Path\nfrom typing import Union\n\nimport fpdf.sign\nimport typer\nfrom cryptography.hazmat.primitives.serialization import pkcs12\nfrom endesive import signer\nfrom fpdf import FPDF, get_scale_factor\nfrom pypdf import PageObject, PdfReader, PdfWriter\nfrom pypdf.generic import DictionaryObject, PdfObject\n\n\ndef main(\n    filename: Path,\n    output: Path | None,\n    in_place: bool,\n    p12: Path,\n    p12_password: str | None,\n) -> None:\n    validate_output_args_or_raise(output, in_place)\n\n    pdf_reader = PdfReader(filename)\n    pdf_is_unsigned_or_raise(pdf_reader)\n\n    output_file: Union[io.BufferedWriter, tempfile._TemporaryFileWrapper]\n    if output:\n        output_file = open(output, \"wb\")\n    else:\n        output_file = tempfile.NamedTemporaryFile(\n            delete=False\n        )  # will be deleted by output.unlink() later on\n        output = Path(output_file.name)\n\n    try:\n        _sign_pdf_contents(pdf_reader, output_file, p12, p12_password)\n    finally:\n        output_file.close()\n\n    if in_place:\n        filename.write_bytes(output.read_bytes())\n        output.unlink()\n\n\ndef pdf_is_unsigned_or_raise(pdf_reader: PdfReader) -> None:\n    for page in pdf_reader.pages:\n        if page.annotations is None:\n            continue\n\n        if any(is_signature(annotation) for annotation in page.annotations):\n            raise typer.BadParameter(\"PDF is already signed.\")\n\n\ndef is_signature(annotation: PdfObject) -> bool:\n    resolved_annotation_object = annotation.get_object()\n    if resolved_annotation_object is None:\n        return False\n\n    if type(resolved_annotation_object) is not DictionaryObject:\n        return False\n\n    subtype = resolved_annotation_object[\"/Subtype\"]\n    if subtype != \"/Widget\":\n        return False\n\n    fieldtype = resolved_annotation_object[\"/FT\"]\n    return fieldtype == \"/Sig\"\n\n\ndef _sign_pdf_contents(\n    pdf_reader: PdfReader,\n    output_file: Union[io.BufferedWriter, tempfile._TemporaryFileWrapper],\n    p12: Path,\n    p12_password: str | None,\n) -> None:\n    unsigned_output_buffer = io.BytesIO()\n\n    with add_to_page(pdf_reader.pages[-1]) as pdf:\n        with p12.open(\"rb\") as pkcs_file:\n            hashalgo = \"sha256\"\n            sign_time = pdf.creation_date\n\n            key, cert, extra_certs = pkcs12.load_key_and_certificates(\n                pkcs_file.read(),\n                (p12_password.encode() if p12_password is not None else None),\n            )\n        pdf.sign(\n            key=key,\n            cert=cert,  # type: ignore\n            extra_certs=extra_certs,\n            hashalgo=hashalgo,\n            signing_time=sign_time,\n        )\n\n        # defer actual signing until after the input pdfs contents are merged\n        # _sign_key = None prevents FDPF.output() from calculating the signature hash too early\n        pdf._sign_key = None\n\n    writer = PdfWriter()\n    writer.append_pages_from_reader(pdf_reader)\n    writer.write(unsigned_output_buffer)\n\n    # Now that output_buffer contains the contents to be signed\n    # we can generate the cryptographic signature using fpdf2.sign.sign_content\n\n    # patch placeholder values to match how fpdf.sign.sign_content() expects them\n    content_to_sign = bytearray(unsigned_output_buffer.getbuffer())\n    content_to_sign = content_to_sign.replace(\n        _SIGNATURE_BYTERANGE_PLACEHOLDER.encode(),\n        fpdf.sign._SIGNATURE_BYTERANGE_PLACEHOLDER.encode(),\n    )\n    content_to_sign = content_to_sign.replace(\n        b\"(\" + _SIGNATURE_CONTENTS_PLACEHOLDER.encode() + b\")\",\n        b\"<\" + fpdf.sign._SIGNATURE_CONTENTS_PLACEHOLDER.encode() + b\">\",\n    )\n\n    signed_output_buffer = fpdf.sign.sign_content(\n        signer,\n        content_to_sign,\n        key,\n        cert,  # type: ignore\n        extra_certs,\n        hashalgo,\n        sign_time,\n    )\n    output_file.write(signed_output_buffer)\n\n\n@contextmanager\ndef add_to_page(reader_page: PageObject, unit: str = \"mm\") -> Generator[FPDF]:\n    k = get_scale_factor(unit)\n    format = (reader_page.mediabox[2] / k, reader_page.mediabox[3] / k)\n    pdf = FPDF(format=format, unit=unit)\n    pdf.add_page()\n    yield pdf\n    page_overlay = PdfReader(io.BytesIO(pdf.output())).pages[0]\n    reader_page.merge_page(page2=page_overlay)\n\n\ndef validate_output_args_or_raise(output: Path | None, in_place: bool) -> None:\n    if not in_place and output is None:\n        raise typer.BadParameter(\n            \"One of the options --output or --in-place is required.\"\n        )\n\n\n# fpdf.sign placeholder values - in the form after PdfWriter serialized them\n_SIGNATURE_BYTERANGE_PLACEHOLDER = \"[ 0 0 0 0 ]\"\n_SIGNATURE_CONTENTS_PLACEHOLDER = \"\\\\000\" * 0x2000\n"
  },
  {
    "path": "pdfly/uncompress.py",
    "content": "\"\"\"Module for uncompressing PDF content streams.\"\"\"\n\nimport zlib\nfrom pathlib import Path\n\nfrom pypdf import PdfReader, PdfWriter\nfrom pypdf.generic import IndirectObject, PdfObject\n\n\ndef main(pdf: Path, output: Path) -> None:\n    reader = PdfReader(pdf)\n    writer = PdfWriter()\n\n    for page in reader.pages:\n        if \"/Contents\" in page:\n            contents: PdfObject | None = page[\"/Contents\"]\n            if isinstance(contents, IndirectObject):\n                contents = contents.get_object()\n            if contents is not None:\n                if isinstance(contents, list):\n                    for content in contents:\n                        if isinstance(content, IndirectObject):\n                            decompress_content_stream(content)\n                elif isinstance(contents, IndirectObject):\n                    decompress_content_stream(contents)\n        writer.add_page(page)\n\n    with open(output, \"wb\") as fp:\n        writer.write(fp)\n\n    orig_size = pdf.stat().st_size\n    uncomp_size = output.stat().st_size\n\n    print(f\"Original Size  : {orig_size:,}\")\n    print(\n        f\"Uncompressed Size: {uncomp_size:,} ({(uncomp_size / orig_size) * 100:.1f}% of original)\"\n    )\n\n\ndef decompress_content_stream(content: IndirectObject) -> None:\n    \"\"\"Decompress a content stream if it uses FlateDecode.\"\"\"\n    if content.get(\"/Filter\") == \"/FlateDecode\":\n        try:\n            compressed_data = content.get_data()\n            uncompressed_data = zlib.decompress(compressed_data)\n            content.set_data(uncompressed_data)\n            del content[\"/Filter\"]\n        except zlib.error as error:\n            print(\n                f\"Some content stream with /FlateDecode failed to be decompressed: {error}\"\n            )\n"
  },
  {
    "path": "pdfly/up2.py",
    "content": "\"\"\"\nCreate a booklet-style PDF from a single input.\n\nPairs of two pages will be put on one page (left and right)\n\nusage: python 2-up.py input_file output_file\n\"\"\"\n\nfrom pathlib import Path\n\nfrom pypdf import PdfReader, PdfWriter\nfrom pypdf.generic import FloatObject\n\n\ndef main(pdf: Path, output: Path) -> None:\n    reader = PdfReader(str(pdf))\n    writer = PdfWriter()\n    for i in range(0, len(reader.pages), 2):\n        lhs = reader.pages[i]\n        if i + 1 < len(reader.pages):\n            rhs = reader.pages[i + 1]\n            lhs.merge_translated_page(\n                rhs, tx=float(lhs.mediabox.width), ty=0, expand=True\n            )\n        else:\n            # Double the MediaBox width:\n            lhs.mediabox[2] = FloatObject(2 * lhs.mediabox[2])\n        # Double the CropBox width:\n        lhs.cropbox[2] = FloatObject(2 * lhs.cropbox[2])\n        writer.add_page(lhs)\n    with open(output, \"wb\") as fp:\n        writer.write(fp)\n    print(f\"{output} was created\")\n"
  },
  {
    "path": "pdfly/update_offsets.py",
    "content": "\"\"\"\nUpdates offsets and lengths in a simple PDF file.\n\nThe PDF specification requires that the xref section at the end\nof a PDF file has the correct offsets of the PDF's objects.\nIt further requires that the dictionary of a stream object\ncontains a /Length-entry giving the length of the encoded stream.\n\nWhen editing a PDF file using a text-editor (e.g. vim) it is\nelaborate to compute or adjust these offsets and lengths.\n\nThis command tries to compute /Length-entries of the stream dictionaries\nand the offsets in the xref-section automatically.\n\nIt expects that the PDF file has ASCII encoding only. It may\nuse ISO-8859-1 or UTF-8 in its comments.\nThe current implementation incorrectly replaces CR (0x0d) by LF (0x0a) in binary data.\nIt expects that there is one xref-section only.\nIt expects that the /Length-entries have default values containing\nenough digits, e.g. /Length 000 when the stream consists of 576 bytes.\n\nExample:\n   update-offsets --verbose --encoding ISO-8859-1 issue-297.pdf issue-297.out.pdf\n\n\"\"\"\n\nimport re\nfrom pathlib import Path\n\nfrom rich.console import Console\n\n# Here, only simple regular expressions are used.\n# Beyond a certain level of complexity, switching to a proper PDF dictionary parser would be better.\nRE_OBJ = re.compile(r\"^([0-9]+) ([0-9]+) obj *\")\nRE_CONTENT = re.compile(r\"^([^\\r\\n]*)\", re.DOTALL)\nRE_LENGTH_REF = re.compile(r\"^(.*/Length )([0-9]+) ([0-9]+) R(.*)\", re.DOTALL)\nRE_LENGTH = re.compile(\n    r\"^(.*/Length )([0-9]+)([ />\\x00\\t\\f\\r\\n].*)\", re.DOTALL\n)\n\n\ndef update_lines(\n    lines_in: list[str], encoding: str, console: Console, verbose: bool\n) -> list[str]:\n    \"\"\"\n    Iterates over the lines of a pdf-files and updates offsets.\n\n    The input is expected to be a pdf without binary-sections.\n\n    :param lines_in: A list over the lines including line-breaks.\n    :param encoding: The encoding, e.g. \"iso-8859-1\" or \"UTF-8\".\n    :param console: Console used to print messages.\n    :param verbose: True to activate logging of info-messages.\n    :return The output is a list of lines to be written\n            in the given encoding.\n    \"\"\"\n    lines_out = []  # lines to be written\n    map_line_offset = {}  # map from line-number to offset\n    map_obj_offset = {}  # map from object-number to offset\n    map_obj_line = {}  # map from object-number to line-number\n    line_no = 0  # current line-number (starting at 0)\n    offset_out = 0  # current offset in output-file\n    line_xref = None  # line-number of xref-line (in xref-section only)\n    line_startxref = None  # line-number of startxref-line\n    curr_obj = None  # number of current object\n    len_stream = None  # length of stream (in stream only)\n    offset_xref = None  # offset of xref-section\n    map_stream_len = {}  # map from object-number to /Length of stream\n    map_obj_length_line = {}  # map from object-number to /Length-line\n    map_obj_length_ref = (\n        {}\n    )  # map from object-number to /Length-reference (e.g. \"3\")\n    map_obj_length_line_no = {}  # map from object-number to line_no of length\n    # of /Length-line\n    for idx, line in enumerate(lines_in):\n        line_no = idx + 1\n        m_content = RE_CONTENT.match(line)\n        if m_content is None:\n            raise RuntimeError(\n                f\"Invalid PDF file: line {line_no} without line-break.\"\n            )\n        content = m_content.group(1)\n        map_line_offset[line_no] = offset_out\n        m_obj = RE_OBJ.match(line)\n        if m_obj is not None:\n            curr_obj = m_obj.group(1)\n            curr_gen = m_obj.group(2)\n            if verbose:\n                console.print(f\"line {line_no}: object {curr_obj}\")\n            if curr_gen != \"0\":\n                raise RuntimeError(\n                    f\"Invalid PDF file: generation {curr_gen} of object {curr_obj} in line {line_no} is not supported.\"\n                )\n            map_obj_offset[curr_obj] = int(offset_out)\n            map_obj_line[curr_obj] = line_no\n            len_stream = None\n\n        if content == \"xref\":\n            offset_xref = offset_out\n            line_xref = line_no\n        elif content == \"startxref\":\n            line_startxref = line_no\n            line_xref = None\n        elif content == \"stream\":\n            if verbose:\n                console.print(f\"line {line_no}: start stream\")\n            len_stream = 0\n        elif content == \"endstream\":\n            if verbose:\n                console.print(f\"line {line_no}: end stream\")\n            if curr_obj is None:\n                raise RuntimeError(\n                    f\"Invalid PDF file: line {line_no}: endstream without object-start.\"\n                )\n            if len_stream is None:\n                raise RuntimeError(\n                    f\"Invalid PDF file: line {line_no}: endstream without stream.\"\n                )\n            if len_stream > 0:\n                # Ignore the last EOL\n                len_stream = (\n                    len_stream - 2\n                    if lines_in[idx - 1][-2:] == \"\\r\\n\"\n                    else len_stream - 1\n                )\n            if verbose:\n                console.print(\n                    f\"line {line_no}: Computed /Length {len_stream} of obj {curr_obj}\"\n                )\n            map_stream_len[curr_obj] = len_stream\n        elif content == \"endobj\":\n            curr_obj = None\n        elif curr_obj is not None and len_stream is None:\n            m_length_ref = RE_LENGTH_REF.match(line)\n            if m_length_ref is not None:\n                len_obj = m_length_ref.group(2)\n                len_obj_gen = m_length_ref.group(3)\n                if verbose:\n                    console.print(\n                        f\"line {line_no}, /Length-reference {len_obj} {len_obj_gen} R: {content}\"\n                    )\n                map_obj_length_ref[curr_obj] = len_obj\n            else:\n                m_length = RE_LENGTH.match(line)\n                if m_length is not None:\n                    if verbose:\n                        console.print(f\"line {line_no}, /Length: {content}\")\n                    map_obj_length_line[curr_obj] = line\n                    map_obj_length_line_no[curr_obj] = line_no\n        elif curr_obj is not None and len_stream is not None:\n            len_stream += len(line.encode(encoding))\n        elif line_xref is not None and line_no > line_xref + 2:\n            object_number = line_no - line_xref - 2\n            if (\n                object_number <= len(map_obj_offset)\n                and str(object_number) in map_obj_offset\n            ):\n                eol = line[-2:]\n                xref_updated = (\n                    \"%010d\" % map_obj_offset[str(object_number)]\n                ) + \" 00000 n\"\n                if verbose:\n                    console.print(f\"{content} -> {xref_updated}\")\n                line = xref_updated + eol\n        elif line_startxref is not None and line_no == line_startxref + 1:\n            if offset_xref is None:\n                raise NotImplementedError(\n                    \"Unsupported file: startxref without preceding xref-section (probable cross-reference stream)\"\n                )\n            line = \"%d\\n\" % offset_xref\n        lines_out.append(line)\n\n        offset_out += len(line.encode(encoding))\n\n    # Some checks\n    if len(map_obj_offset) == 0:\n        raise RuntimeError(\n            \"Invalid PDF file: the command didn't find any PDF objects.\"\n        )\n    if offset_xref is None:\n        raise RuntimeError(\n            \"Invalid PDF file: the command didn't find a xref-section\"\n        )\n    if line_startxref is None:\n        raise RuntimeError(\n            \"Invalid PDF file: the command didn't find a startxref-section\"\n        )\n\n    for curr_obj, stream_len in map_stream_len.items():\n        if curr_obj in map_obj_length_line:\n            line = map_obj_length_line[curr_obj]\n            m_length = RE_LENGTH.match(line)\n            if m_length is None:\n                raise RuntimeError(\n                    f\"Invalid PDF file: line '{line}' does not contain a valid /Length.\"\n                )\n            prev_length = m_length.group(2)\n            len_digits = len(prev_length)\n            len_format = \"%%0%dd\" % len_digits\n            updated_length = len_format % stream_len\n            if len(updated_length) > len_digits:\n                raise RuntimeError(\n                    f\"Not enough digits in /Length-entry {prev_length}\"\n                    f\" of object {curr_obj}:\"\n                    f\" too short to take /Length {updated_length}\"\n                )\n            line = m_length.group(1) + updated_length + m_length.group(3)\n            lines_out[map_obj_length_line_no[curr_obj] - 1] = line\n        elif curr_obj in map_obj_length_ref:\n            len_obj = map_obj_length_ref[curr_obj]\n            if len_obj not in map_obj_line:\n                raise RuntimeError(\n                    f\"obj {curr_obj} has unknown length-obj {len_obj}\"\n                )\n            len_obj_line = map_obj_line[len_obj]\n            prev_length = lines_out[len_obj_line][:-1]\n            len_digits = len(prev_length)\n            len_format = \"%%0%dd\" % len_digits\n            updated_length = len_format % stream_len\n            if len(updated_length) > len_digits:\n                raise RuntimeError(\n                    f\"Not enough digits in /Length-ref-entry {prev_length}\"\n                    f\" of object {curr_obj} and len-object {len_obj}:\"\n                    f\" too short to take /Length {updated_length}\"\n                )\n            if prev_length != updated_length:\n                if verbose:\n                    console.print(\n                        f\"line {line_no}, ref-len {len_obj} of {curr_obj}: {prev_length} -> {updated_length}\"\n                    )\n                lines_out[len_obj_line] = updated_length + \"\\n\"\n        else:\n            raise RuntimeError(\n                f\"obj {curr_obj} with stream-len {stream_len} has no object-length-line: {map_obj_length_line}\"\n            )\n\n    return lines_out\n\n\ndef read_binary_file(file_path: Path, encoding: str) -> list[str]:\n    \"\"\"\n    Reads a binary file line by line and returns these lines as a list of strings in the given encoding.\n    Encoding utf-8 can't be used to read random binary data.\n\n    :param file_path: file to be read line by line\n    :param encoding: encoding to be used (e.g. \"iso-8859-1\")\n    :return lines including line-breaks\n    \"\"\"\n    chunks: list[str] = []\n    with file_path.open(\"rb\") as file:\n        buffer = bytearray()\n        while True:\n            chunk = file.read(4096)  # Read in chunks of 4096 bytes\n            if not chunk:\n                break  # End of file\n\n            buffer += chunk\n\n            # Split buffer into chunks based on LF, CR, or CRLF\n            while True:\n                match = re.search(b\"(\\x0d\\x0a|\\x0a|\\x0d)\", buffer)\n                if not match:\n                    break  # No more line breaks found, process the remaining buffer\n\n                end = match.end()\n                chunk_str = buffer[:end].decode(encoding, errors=\"strict\")\n                buffer = buffer[end:]\n\n                chunks.append(chunk_str)\n\n        # Handle the last chunk\n        if buffer:\n            chunks.append(buffer.decode(encoding, errors=\"strict\"))\n\n    return chunks\n\n\ndef main(file_in: Path, file_out: Path, encoding: str, verbose: bool) -> None:\n    if not file_out:\n        file_out = file_in\n    console = Console()\n    console.print(f\"Read {file_in}\")\n\n    lines_in = read_binary_file(file_in, encoding)\n    lines_out = update_lines(lines_in, encoding, console, verbose)\n\n    with open(file_out, \"wb\") as f:\n        f.writelines(line.encode(encoding) for line in lines_out)\n\n    console.print(f\"Wrote {file_out}\", soft_wrap=True)\n"
  },
  {
    "path": "pdfly/x2pdf.py",
    "content": "\"\"\"Convert one or more files to PDF. Each file is a page.\"\"\"\n\nfrom io import BytesIO\nfrom pathlib import Path\n\nfrom fpdf import FPDF\nfrom PIL import Image\nfrom pypdf import PdfReader, PdfWriter\nfrom rich.console import Console\n\n\ndef px_to_mm(px: float) -> float:\n    px_in_inch = 72\n    mm_in_inch = 25.4\n    inch = px / px_in_inch\n    mm = inch * mm_in_inch\n    return mm\n\n\ndef image_to_pdf(filepath: Path) -> BytesIO:\n    with Image.open(filepath) as cover:\n        w, h = cover.size\n    width, height = px_to_mm(w), px_to_mm(h)\n    pdf = FPDF(unit=\"mm\")\n    pdf.add_page(format=(width, height))  # type: ignore\n    pdf.image(filepath, x=0, y=0)\n    return BytesIO(pdf.output())\n\n\ndef main(in_filepaths: list[Path], out_filepath: Path) -> int:\n    console = Console()\n    exit_code = 0\n    writer = PdfWriter()\n    for filepath in in_filepaths:\n        if filepath.name.endswith(\".pdf\"):\n            for page in PdfReader(filepath).pages:\n                writer.insert_page(page)\n            continue\n        try:\n            pdf_bytes = image_to_pdf(filepath)\n            new_page = PdfReader(pdf_bytes).pages[0]\n            writer.insert_page(new_page)\n        except Exception:\n            console.print(\n                f\"[red]Error: Could not convert '{filepath}' to a PDF.\"\n            )\n            console.print_exception(extra_lines=1, max_frames=1)\n            exit_code += 1\n    writer.write(out_filepath)\n    return exit_code\n"
  },
  {
    "path": "pylock.toml",
    "content": "lock-version = \"1.0\"\ncreated-by = \"pip\"\n\n[[packages]]\nname = \"alabaster\"\nversion = \"1.0.0\"\n\n[[packages.wheels]]\nname = \"alabaster-1.0.0-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b\"\n\n[[packages]]\nname = \"annotated-doc\"\nversion = \"0.0.4\"\n\n[[packages.wheels]]\nname = \"annotated_doc-0.0.4-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320\"\n\n[[packages]]\nname = \"annotated-types\"\nversion = \"0.7.0\"\n\n[[packages.wheels]]\nname = \"annotated_types-0.7.0-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53\"\n\n[[packages]]\nname = \"anyio\"\nversion = \"4.12.1\"\n\n[[packages.wheels]]\nname = \"anyio-4.12.1-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c\"\n\n[[packages]]\nname = \"asn1crypto\"\nversion = \"1.5.1\"\n\n[[packages.wheels]]\nname = \"asn1crypto-1.5.1-py2.py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/c9/7f/09065fd9e27da0eda08b4d6897f1c13535066174cc023af248fc2a8d5e5a/asn1crypto-1.5.1-py2.py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"db4e40728b728508912cbb3d44f19ce188f218e9eba635821bb4b68564f8fd67\"\n\n[[packages]]\nname = \"attrs\"\nversion = \"25.4.0\"\n\n[[packages.wheels]]\nname = \"attrs-25.4.0-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373\"\n\n[[packages]]\nname = \"babel\"\nversion = \"2.18.0\"\n\n[[packages.wheels]]\nname = \"babel-2.18.0-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35\"\n\n[[packages]]\nname = \"bcrypt\"\nversion = \"5.0.0\"\n\n[[packages.wheels]]\nname = \"bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl\"\nurl = \"https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a\"\n\n[[packages]]\nname = \"black\"\nversion = \"26.3.1\"\n\n[[packages.wheels]]\nname = \"black-26.3.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl\"\nurl = \"https://files.pythonhosted.org/packages/7f/0a/8d17d1a9c06f88d3d030d0b1d4373c1551146e252afe4547ed601c0e697f/black-26.3.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"6c54a4a82e291a1fee5137371ab488866b7c86a3305af4026bdd4dc78642e1ac\"\n\n[[packages]]\nname = \"certifi\"\nversion = \"2026.2.25\"\n\n[[packages.wheels]]\nname = \"certifi-2026.2.25-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa\"\n\n[[packages]]\nname = \"cffi\"\nversion = \"2.0.0\"\n\n[[packages.wheels]]\nname = \"cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl\"\nurl = \"https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453\"\n\n[[packages]]\nname = \"cfgv\"\nversion = \"3.5.0\"\n\n[[packages.wheels]]\nname = \"cfgv-3.5.0-py2.py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0\"\n\n[[packages]]\nname = \"charset-normalizer\"\nversion = \"3.4.6\"\n\n[[packages.wheels]]\nname = \"charset_normalizer-3.4.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl\"\nurl = \"https://files.pythonhosted.org/packages/fd/ce/865e4e09b041bad659d682bbd98b47fb490b8e124f9398c9448065f64fee/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"51fb3c322c81d20567019778cb5a4a6f2dc1c200b886bc0d636238e364848c89\"\n\n[[packages]]\nname = \"check-wheel-contents\"\nversion = \"0.6.3\"\n\n[[packages.wheels]]\nname = \"check_wheel_contents-0.6.3-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/be/05/f39fde9f31ef80b285ef5822fad4ddabf73fec62a1f02c5beb4b2f328972/check_wheel_contents-0.6.3-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"5ae39c8c434b972f0740d04610759168590713175aab584b012b1b84f6771874\"\n\n[[packages]]\nname = \"click\"\nversion = \"8.3.1\"\n\n[[packages.wheels]]\nname = \"click-8.3.1-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6\"\n\n[[packages]]\nname = \"colorama\"\nversion = \"0.4.6\"\n\n[[packages.wheels]]\nname = \"colorama-0.4.6-py2.py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6\"\n\n[[packages]]\nname = \"coverage\"\nversion = \"7.13.4\"\n\n[[packages.wheels]]\nname = \"coverage-7.13.4-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl\"\nurl = \"https://files.pythonhosted.org/packages/f8/02/aa7ec01d1a5023c4b680ab7257f9bfde9defe8fdddfe40be096ac19e8177/coverage-7.13.4-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"8041b6c5bfdc03257666e9881d33b1abc88daccaf73f7b6340fb7946655cd10f\"\n\n[[packages]]\nname = \"cryptography\"\nversion = \"46.0.5\"\n\n[[packages.wheels]]\nname = \"cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl\"\nurl = \"https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c\"\n\n[[packages]]\nname = \"defusedxml\"\nversion = \"0.7.1\"\n\n[[packages.wheels]]\nname = \"defusedxml-0.7.1-py2.py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61\"\n\n[[packages]]\nname = \"distlib\"\nversion = \"0.4.0\"\n\n[[packages.wheels]]\nname = \"distlib-0.4.0-py2.py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16\"\n\n[[packages]]\nname = \"docutils\"\nversion = \"0.21.2\"\n\n[[packages.wheels]]\nname = \"docutils-0.21.2-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2\"\n\n[[packages]]\nname = \"endesive\"\nversion = \"2.19.3\"\n\n[[packages.wheels]]\nname = \"endesive-2.19.3-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/a0/c3/a0dcae019de40816352462371c473b22639cd8e68f33a5f23f07faf330fd/endesive-2.19.3-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"e5e09c1011b1977fbb9d563d672de7f17f5638304ce57a35bf7d00f3b7a3972e\"\n\n[[packages]]\nname = \"exceptiongroup\"\nversion = \"1.3.1\"\n\n[[packages.wheels]]\nname = \"exceptiongroup-1.3.1-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598\"\n\n[[packages]]\nname = \"filelock\"\nversion = \"3.25.2\"\n\n[[packages.wheels]]\nname = \"filelock-3.25.2-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70\"\n\n[[packages]]\nname = \"flake8\"\nversion = \"7.3.0\"\n\n[[packages.wheels]]\nname = \"flake8-7.3.0-py2.py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e\"\n\n[[packages]]\nname = \"flake8-bugbear\"\nversion = \"25.11.29\"\n\n[[packages.wheels]]\nname = \"flake8_bugbear-25.11.29-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/0d/42/c18f199780d99a6f6a64c4a36f4ad28a445d9e11968a6025b21d0c8b6802/flake8_bugbear-25.11.29-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"9bf15e2970e736d2340da4c0a70493db964061c9c38f708cfe1f7b2d87392298\"\n\n[[packages]]\nname = \"flake8-comprehensions\"\nversion = \"3.17.0\"\n\n[[packages.wheels]]\nname = \"flake8_comprehensions-3.17.0-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/39/bd/d6739d685fdd79349aa51c37bdedc0d8eab6ae9c6e6ed2ca935b3f88210d/flake8_comprehensions-3.17.0-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"3943a9c6f2593c3bc5cc64106c2f89d63c6ecd49c8343597f8257b8fcfc8b0a2\"\n\n[[packages]]\nname = \"flake8-isort\"\nversion = \"7.0.0\"\n\n[[packages.wheels]]\nname = \"flake8_isort-7.0.0-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/17/7d/907ef4135f6ede5187930d9ddd1f36564e07c6cdcd15ae8fb9849c9517e0/flake8_isort-7.0.0-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"c301a0e55fc77582348e636194b84b1a0baf0dfdaa6eddf3b0eeea75f8be7f36\"\n\n[[packages]]\nname = \"flake8-simplify\"\nversion = \"0.30.0\"\n\n[[packages.wheels]]\nname = \"flake8_simplify-0.30.0-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/9b/d5/18a89f40c1a145a44d1fad825553be8131bcb727f5f2783d3727a2f4b2d0/flake8_simplify-0.30.0-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"c9f54a50d24780832a3f2bb7a687ef465b91f10d7cb4ea0845dff4b65d9c91f4\"\n\n[[packages]]\nname = \"flit\"\nversion = \"3.12.0\"\n\n[[packages.wheels]]\nname = \"flit-3.12.0-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/f5/82/ce1d3bb380b227e26e517655d1de7b32a72aad61fa21ff9bd91a2e2db6ee/flit-3.12.0-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"2b4e7171dc22881fa6adc2dbf083e5ecc72520be3cd7587d2a803da94d6ef431\"\n\n[[packages]]\nname = \"flit-core\"\nversion = \"3.12.0\"\n\n[[packages.wheels]]\nname = \"flit_core-3.12.0-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/f2/65/b6ba90634c984a4fcc02c7e3afe523fef500c4980fec67cc27536ee50acf/flit_core-3.12.0-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"e7a0304069ea895172e3c7bb703292e992c5d1555dd1233ab7b5621b5b69e62c\"\n\n[[packages]]\nname = \"fonttools\"\nversion = \"4.62.1\"\n\n[[packages.wheels]]\nname = \"fonttools-4.62.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl\"\nurl = \"https://files.pythonhosted.org/packages/42/09/7dbe3d7023f57d9b580cfa832109d521988112fd59dddfda3fddda8218f9/fonttools-4.62.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"7bca7a1c1faf235ffe25d4f2e555246b4750220b38de8261d94ebc5ce8a23c23\"\n\n[[packages]]\nname = \"fpdf2\"\nversion = \"2.8.7\"\n\n[[packages.wheels]]\nname = \"fpdf2-2.8.7-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/66/0a/cf50ecffa1e3747ed9380a3adfc829259f1f86b3fdbd9e505af789003141/fpdf2-2.8.7-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"d391fc508a3ce02fc43a577c830cda4fe6f37646f2d143d489839940932fbc19\"\n\n[[packages]]\nname = \"h11\"\nversion = \"0.16.0\"\n\n[[packages.wheels]]\nname = \"h11-0.16.0-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86\"\n\n[[packages]]\nname = \"identify\"\nversion = \"2.6.18\"\n\n[[packages.wheels]]\nname = \"identify-2.6.18-py2.py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/46/33/92ef41c6fad0233e41d3d84ba8e8ad18d1780f1e5d99b3c683e6d7f98b63/identify-2.6.18-py2.py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"8db9d3c8ea9079db92cafb0ebf97abdc09d52e97f4dcf773a2e694048b7cd737\"\n\n[[packages]]\nname = \"idna\"\nversion = \"3.11\"\n\n[[packages.wheels]]\nname = \"idna-3.11-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea\"\n\n[[packages]]\nname = \"imagesize\"\nversion = \"2.0.0\"\n\n[[packages.wheels]]\nname = \"imagesize-2.0.0-py2.py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96\"\n\n[[packages]]\nname = \"iniconfig\"\nversion = \"2.3.0\"\n\n[[packages.wheels]]\nname = \"iniconfig-2.3.0-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12\"\n\n[[packages]]\nname = \"invoke\"\nversion = \"2.2.1\"\n\n[[packages.wheels]]\nname = \"invoke-2.2.1-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/32/4b/b99e37f88336009971405cbb7630610322ed6fbfa31e1d7ab3fbf3049a2d/invoke-2.2.1-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"2413bc441b376e5cd3f55bb5d364f973ad8bdd7bf87e53c79de3c11bf3feecc8\"\n\n[[packages]]\nname = \"isort\"\nversion = \"8.0.1\"\n\n[[packages.wheels]]\nname = \"isort-8.0.1-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75\"\n\n[[packages]]\nname = \"jinja2\"\nversion = \"3.1.6\"\n\n[[packages.wheels]]\nname = \"jinja2-3.1.6-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67\"\n\n[[packages]]\nname = \"librt\"\nversion = \"0.8.1\"\n\n[[packages.wheels]]\nname = \"librt-0.8.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl\"\nurl = \"https://files.pythonhosted.org/packages/01/99/f85130582f05dcf0c8902f3d629270231d2f4afdfc567f8305a952ac7f14/librt-0.8.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"97c2b54ff6717a7a563b72627990bec60d8029df17df423f0ed37d56a17a176b\"\n\n[[packages]]\nname = \"lxml\"\nversion = \"6.0.2\"\n\n[[packages.wheels]]\nname = \"lxml-6.0.2-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl\"\nurl = \"https://files.pythonhosted.org/packages/20/cf/cab09478699b003857ed6ebfe95e9fb9fa3d3c25f1353b905c9b73cfb624/lxml-6.0.2-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"a8ffaeec5dfea5881d4c9d8913a32d10cfe3923495386106e4a24d45300ef79c\"\n\n[[packages]]\nname = \"markdown-it-py\"\nversion = \"3.0.0\"\n\n[[packages.wheels]]\nname = \"markdown_it_py-3.0.0-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1\"\n\n[[packages]]\nname = \"markupsafe\"\nversion = \"3.0.3\"\n\n[[packages.wheels]]\nname = \"markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl\"\nurl = \"https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591\"\n\n[[packages]]\nname = \"mccabe\"\nversion = \"0.7.0\"\n\n[[packages.wheels]]\nname = \"mccabe-0.7.0-py2.py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e\"\n\n[[packages]]\nname = \"mdit-py-plugins\"\nversion = \"0.5.0\"\n\n[[packages.wheels]]\nname = \"mdit_py_plugins-0.5.0-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f\"\n\n[[packages]]\nname = \"mdurl\"\nversion = \"0.1.2\"\n\n[[packages.wheels]]\nname = \"mdurl-0.1.2-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8\"\n\n[[packages]]\nname = \"mypy\"\nversion = \"1.19.1\"\n\n[[packages.wheels]]\nname = \"mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl\"\nurl = \"https://files.pythonhosted.org/packages/2a/0d/93c2e4a287f74ef11a66fb6d49c7a9f05e47b0a4399040e6719b57f500d2/mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74\"\n\n[[packages]]\nname = \"mypy-extensions\"\nversion = \"1.1.0\"\n\n[[packages.wheels]]\nname = \"mypy_extensions-1.1.0-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505\"\n\n[[packages]]\nname = \"myst-parser\"\nversion = \"4.0.1\"\n\n[[packages.wheels]]\nname = \"myst_parser-4.0.1-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/5f/df/76d0321c3797b54b60fef9ec3bd6f4cfd124b9e422182156a1dd418722cf/myst_parser-4.0.1-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"9134e88959ec3b5780aedf8a99680ea242869d012e8821db3126d427edc9c95d\"\n\n[[packages]]\nname = \"nodeenv\"\nversion = \"1.10.0\"\n\n[[packages.wheels]]\nname = \"nodeenv-1.10.0-py2.py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827\"\n\n[[packages]]\nname = \"packaging\"\nversion = \"26.0\"\n\n[[packages.wheels]]\nname = \"packaging-26.0-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529\"\n\n[[packages]]\nname = \"paramiko\"\nversion = \"4.0.0\"\n\n[[packages.wheels]]\nname = \"paramiko-4.0.0-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/a9/90/a744336f5af32c433bd09af7854599682a383b37cfd78f7de263de6ad6cb/paramiko-4.0.0-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"0e20e00ac666503bf0b4eda3b6d833465a2b7aff2e2b3d79a8bba5ef144ee3b9\"\n\n[[packages]]\nname = \"pathspec\"\nversion = \"1.0.4\"\n\n[[packages.wheels]]\nname = \"pathspec-1.0.4-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723\"\n\n[[packages]]\nname = \"pdfly\"\n\n[packages.directory]\npath = \".\"\n\n[[packages]]\nname = \"pillow\"\nversion = \"12.1.1\"\n\n[[packages.wheels]]\nname = \"pillow-12.1.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl\"\nurl = \"https://files.pythonhosted.org/packages/0c/7b/f9b09a7804ec7336effb96c26d37c29d27225783dc1501b7d62dcef6ae25/pillow-12.1.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"9f51079765661884a486727f0729d29054242f74b46186026582b4e4769918e4\"\n\n[[packages]]\nname = \"pip\"\nversion = \"26.0.1\"\n\n[[packages.wheels]]\nname = \"pip-26.0.1-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/de/f0/c81e05b613866b76d2d1066490adf1a3dbc4ee9d9c839961c3fc8a6997af/pip-26.0.1-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"bdb1b08f4274833d62c1aa29e20907365a2ceb950410df15fc9521bad440122b\"\n\n[[packages]]\nname = \"platformdirs\"\nversion = \"4.9.4\"\n\n[[packages.wheels]]\nname = \"platformdirs-4.9.4-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868\"\n\n[[packages]]\nname = \"pluggy\"\nversion = \"1.6.0\"\n\n[[packages.wheels]]\nname = \"pluggy-1.6.0-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746\"\n\n[[packages]]\nname = \"pre-commit\"\nversion = \"4.5.1\"\n\n[[packages.wheels]]\nname = \"pre_commit-4.5.1-py2.py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77\"\n\n[[packages]]\nname = \"pycodestyle\"\nversion = \"2.14.0\"\n\n[[packages.wheels]]\nname = \"pycodestyle-2.14.0-py2.py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d\"\n\n[[packages]]\nname = \"pycparser\"\nversion = \"3.0\"\n\n[[packages.wheels]]\nname = \"pycparser-3.0-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992\"\n\n[[packages]]\nname = \"pydantic\"\nversion = \"2.12.5\"\n\n[[packages.wheels]]\nname = \"pydantic-2.12.5-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d\"\n\n[[packages]]\nname = \"pydantic-core\"\nversion = \"2.41.5\"\n\n[[packages.wheels]]\nname = \"pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl\"\nurl = \"https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a\"\n\n[[packages]]\nname = \"pyflakes\"\nversion = \"3.4.0\"\n\n[[packages.wheels]]\nname = \"pyflakes-3.4.0-py2.py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f\"\n\n[[packages]]\nname = \"pygments\"\nversion = \"2.19.2\"\n\n[[packages.wheels]]\nname = \"pygments-2.19.2-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b\"\n\n[[packages]]\nname = \"pykcs11\"\nversion = \"1.5.18\"\n\n[packages.sdist]\nname = \"pykcs11-1.5.18.tar.gz\"\nurl = \"https://files.pythonhosted.org/packages/22/07/0c2215cb6ef70c213892571eb015e670f4d6adbecedc5eb2369f82c1c7f2/pykcs11-1.5.18.tar.gz\"\n\n[packages.sdist.hashes]\nsha256 = \"12fd878b369821d80c1be8a140c85e8a0fb1358fcaaba66ca66869213692f227\"\n\n[[packages]]\nname = \"pynacl\"\nversion = \"1.6.2\"\n\n[[packages.wheels]]\nname = \"pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl\"\nurl = \"https://files.pythonhosted.org/packages/c9/a8/b917096b1accc9acd878819a49d3d84875731a41eb665f6ebc826b1af99e/pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6\"\n\n[[packages]]\nname = \"pypdf\"\nversion = \"6.9.0\"\n\n[[packages.wheels]]\nname = \"pypdf-6.9.0-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/00/64/ac6159cfbeabab3cf54873bbf7314b29183c7ff547c9776596d63170d7c0/pypdf-6.9.0-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"85805ad7457ca878c4cfd1bc026c4b3dcae359b4a80f889fa7e8c5a1c1a83e51\"\n\n[[packages]]\nname = \"pytest\"\nversion = \"9.0.2\"\n\n[[packages.wheels]]\nname = \"pytest-9.0.2-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b\"\n\n[[packages]]\nname = \"pytest-cov\"\nversion = \"7.0.0\"\n\n[[packages.wheels]]\nname = \"pytest_cov-7.0.0-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861\"\n\n[[packages]]\nname = \"pytest-socket\"\nversion = \"0.7.0\"\n\n[[packages.wheels]]\nname = \"pytest_socket-0.7.0-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/19/58/5d14cb5cb59409e491ebe816c47bf81423cd03098ea92281336320ae5681/pytest_socket-0.7.0-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"7e0f4642177d55d317bbd58fc68c6bd9048d6eadb2d46a89307fa9221336ce45\"\n\n[[packages]]\nname = \"pytest-timeout\"\nversion = \"2.4.0\"\n\n[[packages.wheels]]\nname = \"pytest_timeout-2.4.0-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2\"\n\n[[packages]]\nname = \"python-discovery\"\nversion = \"1.1.3\"\n\n[[packages.wheels]]\nname = \"python_discovery-1.1.3-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/e7/80/73211fc5bfbfc562369b4aa61dc1e4bf07dc7b34df7b317e4539316b809c/python_discovery-1.1.3-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"90e795f0121bc84572e737c9aa9966311b9fde44ffb88a5953b3ec9b31c6945e\"\n\n[[packages]]\nname = \"pytokens\"\nversion = \"0.4.1\"\n\n[[packages.wheels]]\nname = \"pytokens-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl\"\nurl = \"https://files.pythonhosted.org/packages/69/66/f6fb1007a4c3d8b682d5d65b7c1fb33257587a5f782647091e3408abe0b8/pytokens-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"670d286910b531c7b7e3c0b453fd8156f250adb140146d234a82219459b9640c\"\n\n[[packages]]\nname = \"pyyaml\"\nversion = \"6.0.3\"\n\n[[packages.wheels]]\nname = \"pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl\"\nurl = \"https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b\"\n\n[[packages]]\nname = \"requests\"\nversion = \"2.32.5\"\n\n[[packages.wheels]]\nname = \"requests-2.32.5-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6\"\n\n[[packages]]\nname = \"rich\"\nversion = \"14.3.3\"\n\n[[packages.wheels]]\nname = \"rich-14.3.3-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d\"\n\n[[packages]]\nname = \"ruff\"\nversion = \"0.15.6\"\n\n[[packages.wheels]]\nname = \"ruff-0.15.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl\"\nurl = \"https://files.pythonhosted.org/packages/f1/9f/f85ef5fd01a52e0b472b26dc1b4bd228b8f6f0435975442ffa4741278703/ruff-0.15.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"98893c4c0aadc8e448cfa315bd0cc343a5323d740fe5f28ef8a3f9e21b381f7e\"\n\n[[packages]]\nname = \"shellingham\"\nversion = \"1.5.4\"\n\n[[packages.wheels]]\nname = \"shellingham-1.5.4-py2.py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686\"\n\n[[packages]]\nname = \"snowballstemmer\"\nversion = \"3.0.1\"\n\n[[packages.wheels]]\nname = \"snowballstemmer-3.0.1-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064\"\n\n[[packages]]\nname = \"sphinx\"\nversion = \"8.1.3\"\n\n[[packages.wheels]]\nname = \"sphinx-8.1.3-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/26/60/1ddff83a56d33aaf6f10ec8ce84b4c007d9368b21008876fceda7e7381ef/sphinx-8.1.3-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2\"\n\n[[packages]]\nname = \"sphinx-autobuild\"\nversion = \"2024.10.3\"\n\n[[packages.wheels]]\nname = \"sphinx_autobuild-2024.10.3-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/18/c0/eba125db38c84d3c74717008fd3cb5000b68cd7e2cbafd1349c6a38c3d3b/sphinx_autobuild-2024.10.3-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"158e16c36f9d633e613c9aaf81c19b0fc458ca78b112533b20dafcda430d60fa\"\n\n[[packages]]\nname = \"sphinx-rtd-theme\"\nversion = \"3.1.0\"\n\n[[packages.wheels]]\nname = \"sphinx_rtd_theme-3.1.0-py2.py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/87/c7/b5c8015d823bfda1a346adb2c634a2101d50bb75d421eb6dcb31acd25ebc/sphinx_rtd_theme-3.1.0-py2.py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"1785824ae8e6632060490f67cf3a72d404a85d2d9fc26bce3619944de5682b89\"\n\n[[packages]]\nname = \"sphinxcontrib-applehelp\"\nversion = \"2.0.0\"\n\n[[packages.wheels]]\nname = \"sphinxcontrib_applehelp-2.0.0-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5\"\n\n[[packages]]\nname = \"sphinxcontrib-devhelp\"\nversion = \"2.0.0\"\n\n[[packages.wheels]]\nname = \"sphinxcontrib_devhelp-2.0.0-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2\"\n\n[[packages]]\nname = \"sphinxcontrib-htmlhelp\"\nversion = \"2.1.0\"\n\n[[packages.wheels]]\nname = \"sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8\"\n\n[[packages]]\nname = \"sphinxcontrib-jquery\"\nversion = \"4.1\"\n\n[[packages.wheels]]\nname = \"sphinxcontrib_jquery-4.1-py2.py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/76/85/749bd22d1a68db7291c89e2ebca53f4306c3f205853cf31e9de279034c3c/sphinxcontrib_jquery-4.1-py2.py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae\"\n\n[[packages]]\nname = \"sphinxcontrib-jsmath\"\nversion = \"1.0.1\"\n\n[[packages.wheels]]\nname = \"sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178\"\n\n[[packages]]\nname = \"sphinxcontrib-qthelp\"\nversion = \"2.0.0\"\n\n[[packages.wheels]]\nname = \"sphinxcontrib_qthelp-2.0.0-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb\"\n\n[[packages]]\nname = \"sphinxcontrib-serializinghtml\"\nversion = \"2.0.0\"\n\n[[packages.wheels]]\nname = \"sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331\"\n\n[[packages]]\nname = \"starlette\"\nversion = \"0.52.1\"\n\n[[packages.wheels]]\nname = \"starlette-0.52.1-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74\"\n\n[[packages]]\nname = \"tomli\"\nversion = \"2.4.0\"\n\n[[packages.wheels]]\nname = \"tomli-2.4.0-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a\"\n\n[[packages]]\nname = \"tomli-w\"\nversion = \"1.2.0\"\n\n[[packages.wheels]]\nname = \"tomli_w-1.2.0-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90\"\n\n[[packages]]\nname = \"typer\"\nversion = \"0.24.1\"\n\n[[packages.wheels]]\nname = \"typer-0.24.1-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e\"\n\n[[packages]]\nname = \"typing-extensions\"\nversion = \"4.15.0\"\n\n[[packages.wheels]]\nname = \"typing_extensions-4.15.0-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548\"\n\n[[packages]]\nname = \"typing-inspection\"\nversion = \"0.4.2\"\n\n[[packages.wheels]]\nname = \"typing_inspection-0.4.2-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7\"\n\n[[packages]]\nname = \"urllib3\"\nversion = \"2.6.3\"\n\n[[packages.wheels]]\nname = \"urllib3-2.6.3-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4\"\n\n[[packages]]\nname = \"uvicorn\"\nversion = \"0.42.0\"\n\n[[packages.wheels]]\nname = \"uvicorn-0.42.0-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359\"\n\n[[packages]]\nname = \"virtualenv\"\nversion = \"21.2.0\"\n\n[[packages.wheels]]\nname = \"virtualenv-21.2.0-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/c6/59/7d02447a55b2e55755011a647479041bc92a82e143f96a8195cb33bd0a1c/virtualenv-21.2.0-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f\"\n\n[[packages]]\nname = \"watchfiles\"\nversion = \"1.1.1\"\n\n[[packages.wheels]]\nname = \"watchfiles-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl\"\nurl = \"https://files.pythonhosted.org/packages/d5/dc/1a680b7458ffa3b14bb64878112aefc8f2e4f73c5af763cbf0bd43100658/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"544364b2b51a9b0c7000a4b4b02f90e9423d97fbbf7e06689236443ebcad81ab\"\n\n[[packages]]\nname = \"websockets\"\nversion = \"16.0\"\n\n[[packages.wheels]]\nname = \"websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl\"\nurl = \"https://files.pythonhosted.org/packages/9d/2f/4b3ca7e106bc608744b1cdae041e005e446124bebb037b18799c2d356864/websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72\"\n\n[[packages]]\nname = \"wheel-filename\"\nversion = \"1.4.2\"\n\n[[packages.wheels]]\nname = \"wheel_filename-1.4.2-py3-none-any.whl\"\nurl = \"https://files.pythonhosted.org/packages/b4/0f/6e97a3bc38cdde32e3ec49f8c0903fe3559ec9ec9db181782f0bb4417717/wheel_filename-1.4.2-py3-none-any.whl\"\n\n[packages.wheels.hashes]\nsha256 = \"3fa599046443d4ca830d06e3d180cd0a675d5871af0a68daa5623318bb4d17e3\"\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[build-system]\nrequires = [\"flit_core >=3.2,<4\"]\nbuild-backend = \"flit_core.buildapi\"\n\n[project]\nname = \"pdfly\"\nauthors = [\n  { name = \"Martin Thoma\", email = \"info@martin-thoma.de\" },\n  { name = \"Lucas Cimon (@Lucas-C)\" },\n]\nmaintainers = [\n  { name = \"Martin Thoma\", email = \"info@martin-thoma.de\" },\n  { name = \"Lucas Cimon (@Lucas-C)\" },\n]\ndescription = \"A pure-python CLI application to manipulate PDF files\"\nreadme = \"README.md\"\ndynamic = [\"version\"]\nlicense = \"BSD-3-Clause\"\nlicense-files = [\"LICENSE\"]\nrequires-python = \">=3.10.0\"\n\nkeywords = [\"pdf\", \"cli\", \"tools\", \"compression\", \"metadata\", \"signature\", \"booklet\"]\n\n# https://pypi.org/pypi?%3Aaction=list_classifiers\nclassifiers = [\n    \"Development Status :: 1 - Planning\",\n    \"Environment :: Console\",\n    \"Intended Audience :: Developers\",\n    \"Natural Language :: English\",\n    \"Operating System :: OS Independent\",\n    \"Programming Language :: Python :: 3\",\n    \"Programming Language :: Python :: 3 :: Only\",\n    \"Programming Language :: Python :: 3.10\",\n    \"Programming Language :: Python :: 3.11\",\n    \"Programming Language :: Python :: 3.12\",\n    \"Programming Language :: Python :: 3.13\",\n    \"Programming Language :: Python :: 3.14\",\n]\n\ndependencies = [\n    \"pypdf[full]>=5.1.0\",\n    \"typer>=0.12.4\",\n    \"pillow\",\n    \"pydantic\",\n    \"rich\",\n    \"fpdf2>=2.8.1\",\n    \"asn1crypto\",\n    \"cryptography\",\n    \"endesive\",\n    \"requests>=2.32.5\",  # required by endesive.signer\n]\n\n[dependency-groups]\ndev = [\"black\", \"check-wheel-contents\", \"flake8\", \"flake8-bugbear\", \"flake8-comprehensions\", \"flake8-isort\", \"flake8-simplify\", \"flit\", \"mypy\", \"pre-commit>=3.2.0\", \"pydantic\", \"pytest\", \"pytest-cov\", \"pytest-socket\", \"pytest-timeout\", \"rich\", \"ruff\"]\ndocs = [\"attrs\", \"sphinx\", \"sphinx_rtd_theme\", \"sphinx-autobuild\", \"myst_parser\"]  # attrs is required for myst, but not automatically installed by myst\n\n[project.urls]\nSource = \"https://github.com/py-pdf/pdfly\"\n\n[project.scripts]\npdfly = \"pdfly.cli:entry_point\"\n\n[tool.pytest.ini_options]\naddopts = \"--disable-socket --doctest-modules --cov=. --cov-report html:tests/reports/coverage-html --cov-report term-missing --ignore=docs/ --durations=3 --timeout=30\"\ndoctest_encoding = \"utf-8\"\ntestpaths = [\"tests\"]\n\n[tool.black]\nline-length = 79\n\n[tool.isort]\nline_length = 79\nindent = '    '\nmulti_line_output = 3\ninclude_trailing_comma = true\nknown_third_party = [\"pytest\", \"setuptools\"]\n\n[tool.ruff]\nline-length = 120\n\n[tool.ruff.lint]\nselect = [\"ALL\"]\nignore = [\n    \"D401\",  # First line of docstring should be in imperative mood - false positives\n    \"UP031\",  # Use format specifiers instead of percent format\n    \"D205\",  # 1 blank line required between summary line and description\n    \"D400\",  # First line should end with a period\n    \"D415\",  # First line should end with a period\n    # Introduces bugs\n    \"RUF005\",\n    \"DTZ001\", # The use of `datetime.datetime()` without `tzinfo` is necessary\n    # Personal preference\n    \"D212\",  # I want multiline-docstrings to start at the second line\n    \"D407\",  # google-style docstrings don't have dashses\n    \"BLE\",  # we want to capture Exception sometimes\n    \"COM812\",  # yes, they make the diff smaller\n    \"D100\",  # Missing docstring in public module\n    \"D105\",  # Missing docstring in magic method\n    \"D106\",  # Missing docstring in public nested class\n    \"D107\",  # Missing docstring in `__init__`\n    \"D203\", # one-blank-line-before-class\n    \"EM\",  # exception messages\n    \"G004\",  # f-string in logging statement\n    \"RET\",\n    \"S110\", # `try`-`except`-`pass` detected, consider logging the exception\n    \"SIM105\",  # contextlib.suppress\n    \"SIM108\",  # don't enforce ternary operators\n    \"SIM300\",  # yoda conditions\n    \"TID252\",  # we want relative imports\n    \"TRY\", # I don't know what this is about\n    # As long as we are not on Python 3.11+\n    \"UP006\", \"UP007\",\n    # for the moment, fix it later:\n    \"T201\",  # print\n    \"DTZ006\",  # datetime without timezone\n    \"SIM115\",  # context handler for opening files\n    \"A\",  # Variable is shadowing a built-in\n    \"B904\", # Within an `except` clause, raise exceptions with\n    \"B905\",  # `zip()` without an explicit `strict=` parameter\n    \"C901\",\n    \"D101\",  # Missing docstring in public class\n    \"D102\", # Missing docstring in public method\n    \"D103\",  # Missing docstring in public function\n    \"D417\",  # Missing argument descriptions in the docstring\n    \"FBT001\", # Boolean positional arg in function definition\n    \"FBT002\", # Boolean default value in function definition\n    \"FBT003\", # Boolean positional value in function call\n    \"PLC0415\", # `import` should be at the top-level of a file\n    \"PGH\", # Use specific error messages\n    \"PLR0912\", # Too many branches\n    \"PLR0913\",  # Too many arguments to function call\n    \"PLR0915\", # Too many statements\n    \"PLR2004\", # Magic value\n    \"PLW\",  # global variables\n    \"PTH110\",  # `os.path.exists()` should be replaced by `Path.exists()`\n    \"PTH123\", # `open()` should be replaced by `Path.open()`\n    \"S101\",  # Use of `assert` detected\n    \"SLF001\",  # Private member accessed\n    \"INP001\",  # File `docs/conf.py` is part of an implicit namespace package. Add an `__init__.py`.\n]\n\n[tool.ruff.lint.mccabe]\nmax-complexity = 20  # Recommended: 10\n\n[tool.ruff.lint.per-file-ignores]\n\"sample-files/*\" = [\"D100\", \"INP001\", \"FA102\", \"I001\"]\n\"make_release.py\" = [\"T201\", \"S603\", \"S607\"]\n"
  },
  {
    "path": "renovate.json",
    "content": "{\n  \"commitMessagePrefix\": \"MAINT:\",\n  \"extends\": [\"config:best-practices\"],\n  \"labels\": [\"dependencies\"],\n  \"osvVulnerabilityAlerts\": true,\n  \"vulnerabilityAlerts\": {\"enabled\": true}\n}\n"
  },
  {
    "path": "resources/demo2_ca.root.crt.pem",
    "content": "-----BEGIN CERTIFICATE-----\nMIIDLTCCAhWgAwIBAgIUHeQXwdDU4jyXtdItkEjDOw/SigAwDQYJKoZIhvcNAQEL\nBQAwHTEbMBkGA1UEAwwSQUEgVHJpU29mdCBSb290IENBMCAXDTI1MDYxMTE4Mjgw\nMloYDzIwNjUwNjAxMTgyODAyWjAdMRswGQYDVQQDDBJBQSBUcmlTb2Z0IFJvb3Qg\nQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCGHskGb4Gd364QhbS6\ni2NmHbJf4N5LhDJPwRjDACuRqRu42fEB+MwKvAIYoS2wVihYubf/dRZFc0/4yyCH\n7I1Mkh1YoQRjl3q51pKWjUjm5Ua611NDLHvkDU8ecQWj2qjHcJtV39ay3L/TIyvS\ntesIR+o2oOkfxzaLjkhrH08DOy5L3gvETexV7GBbmSQTaI9jvNuD9oKZs6ba1S5O\n65pPEC/u3/udZgRBKd+lB/qlLk7HNuN0trwEfZLvdBC4pS9Fc0DbUcHnsNBwWFc9\nVjrzzJDYHdWmZtYGg5rc7efx5+zVw26wm58caJv5ihi0An4J/I8i5I4TKoLMgcJP\n2r7VAgMBAAGjYzBhMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUPkWmCmbq\nvZJeJaiLKy8j/la8iHEwHQYDVR0OBBYEFD5Fpgpm6r2SXiWoiysvI/5WvIhxMA4G\nA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsFAAOCAQEAPc3cf1CHKSaF4BDM8UHT\n4B5VMdj7uZSxsQ+IerrOi6QfMIUuesVc/h9oN9eBLoTCCQsFB7nrizwmyd2xIK9d\njOuPQZexu9VhBIeJE8Fh86gG0U6IQxXw9NXW10yaW9w5RAYQqH3w+VPsaPDXnceX\nb0yjM1vtmV9WrMNoXWPil7vYuea0HAar80IyUKwrzEOZa8zqDz1HElC0rukVh0Yl\n5PHkVptl11d81ukyKeXGP6PFt1JI31vgAEZHdykz8w7SjAu0g+QrM2LCZV915wLu\nOAS3ptxRmdNymk1zYHEyPt7CRdgUV1NWhE1N0RQMuf1CnXRPWZ6+Ls83xVzoO1i7\nWA==\n-----END CERTIFICATE-----\n"
  },
  {
    "path": "resources/signing-certificate.crt",
    "content": "Bag Attributes\n    friendlyName: fpdf2\n    localKeyID: C2 58 91 78 7F 3E 01 57 6E 39 AE AD CA 28 99 06 3B 55 2D F1\nsubject=CN = fpdf2, O = fpdf2, OU = signing testing\nissuer=CN = fpdf2, O = fpdf2, OU = signing testing\n-----BEGIN CERTIFICATE-----\nMIIEFzCCAv+gAwIBAgIBfzANBgkqhkiG9w0BAQsFADA6MQ4wDAYDVQQDDAVmcGRm\nMjEOMAwGA1UECgwFZnBkZjIxGDAWBgNVBAsMD3NpZ25pbmcgdGVzdGluZzAeFw0y\nNTA3MjMwNDI0NTBaFw0zNTA3MjEwNDI0NTBaMDoxDjAMBgNVBAMMBWZwZGYyMQ4w\nDAYDVQQKDAVmcGRmMjEYMBYGA1UECwwPc2lnbmluZyB0ZXN0aW5nMIIBIjANBgkq\nhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAn1/C38InT9bJPE/R5yDhLSUS6KKR2xir\nPYQF8Blb9LYLf3jF/2Dupl9OG5FUFHQZL2Lw2PJvrIvXi4LKfi3wM93lumvNpVl8\nBFuuQKZbvV3aGXsjfLL96i4rgRd9TrnOUvYHUiyhY1Q/1f3eW7+y4+6KUTUDgXf6\nawKXC9qpmv/L0BlKNl3CaSnQcc3KRSTlxNkupOiuLC0gC+Xhf5qjUZDKPjkIQZ3R\nfUTaVsCIUYqwzKsRkfhiizcXj3L5b/XeBDTNT6qI1xz2XN7UQ2w8Z0PExxcth3Hb\nTeR6KZOPPo2dIeXPB3kljoraWAxJosxr9lDhFO2t4HP8Hbj1LwXk0wIDAQABo4IB\nJjCCASIwHQYDVR0OBBYEFFtMIYXyJ7jtFAz3bU7d4fCPlqkJMEwGA1UdIwRFMEOh\nPqQ8MDoxDjAMBgNVBAMMBWZwZGYyMQ4wDAYDVQQKDAVmcGRmMjEYMBYGA1UECwwP\nc2lnbmluZyB0ZXN0aW5nggF/MAkGA1UdEwQCMAAwCwYDVR0PBAQDAgeAMB0GA1Ud\nJQQWMBQGCCsGAQUFBwMEBggrBgEFBQcDAjAdBgNVHREEFjAUgRJzaWduZXJAZnBk\nZjIubG9jYWwwXQYIKwYBBQUHAQEEUTBPMCgGCCsGAQUFBzAChhxodHRwOi8vY2Eu\nZXhhbXBsZS5jb20vY2EucGVtMCMGCCsGAQUFBzABhhdodHRwOi8vb2NzcC5leGFt\ncGxlLmNvbTANBgkqhkiG9w0BAQsFAAOCAQEAUFuZAJ7bzp1+drypANTk1QBS476n\n2ggKfDzsxNPmF5DO8anyBS6k6rMT0Ziq7Y9TzuUe6xOtJSgXswupn7AAn81p3V/q\nslaHsIzaNo+1wg6b7EtP3/udtDKBOwQTdz3PwA3ihLdDC4IcnGLPmwPDfBX3H2tc\nR3Xw64gudbinRTdrwh8nHDxsNWZ0G56Gbwm2J+Pt6l6RS+mXrWrO/PcjvVJAigBe\n7u9laSU7LLQSUoWn5Yv99DYdAvVZQqUG0BgUeKXxFDEiIqNWtHUNzv3Ce8KdASlG\nTxFCEB+Y1Ag2S1Y1AmpKsP3RUt9SOiGjmqhHfXBIgghz2b3hoLYEAbWxSw==\n-----END CERTIFICATE-----"
  },
  {
    "path": "setup.cfg",
    "content": "[mutmut]\nbackup = False\nrunner = ./mutmut-test.sh\ntests_dir = tests/\n\n[mypy]\nignore_missing_imports = true\nstrict = true\ncheck_untyped_defs = true\ndisallow_any_generics = true\ndisallow_incomplete_defs = true\ndisallow_untyped_defs = true\nno_implicit_optional = true\nwarn_unused_ignores = false\nshow_error_codes = true\n\n[mypy-testing.*]\ndisallow_untyped_defs = false\n\n[mypy-tests.*]\ndisallow_untyped_defs = false\n\n[flake8]\nignore = E501, E203, W503, PT007, SIM115\nexclude = build/*\nper-file-ignores =\n    tests/*: ASS001\n"
  },
  {
    "path": "setup.py",
    "content": "\"\"\"Package pdfly with setuptools.\"\"\"\n\nimport re\n\nfrom setuptools import find_packages, setup\n\nVERSIONFILE = \"pdfly/_version.py\"\nwith open(VERSIONFILE) as fp:\n    verstrline = fp.read()\nVSRE = r\"^__version__ = ['\\\"]([^'\\\"]*)['\\\"]\"\nmo = re.search(VSRE, verstrline, re.MULTILINE)\nif mo:\n    verstr = mo.group(1)\nelse:\n    raise RuntimeError(\"Unable to find version string in %s.\" % (VERSIONFILE))\n\nsetup(\n    version=verstr,\n    packages=find_packages(exclude=(\"tests\",)),\n)\n"
  },
  {
    "path": "tests/__init__.py",
    "content": "\"\"\"Shared test code\"\"\"\n"
  },
  {
    "path": "tests/conftest.py",
    "content": "\"\"\"Utilities and fixtures that are available automatically for all tests.\"\"\"\n\nimport os\nfrom collections.abc import Iterator\nfrom pathlib import Path\nfrom typing import Union\n\nimport pytest\nfrom fpdf import FPDF\n\nfrom pdfly.cli import entry_point\n\ntry:\n    from contextlib import chdir  # type: ignore\nexcept ImportError:  # Fallback when not available (< Python 3.11):\n    from contextlib import contextmanager\n\n    @contextmanager  # type: ignore\n    def chdir(dir_path: Union[str, Path]) -> Iterator[None]:\n        \"\"\"Non thread-safe context manager to change the current working directory.\"\"\"\n        cwd = Path.cwd()\n        os.chdir(dir_path)\n        try:\n            yield\n        finally:\n            os.chdir(cwd)\n\n\nTESTS_ROOT = Path(__file__).parent.resolve()\nPROJECT_ROOT = TESTS_ROOT.parent\nRESOURCES_ROOT = PROJECT_ROOT / \"resources\"\n\n\ndef run_cli(args: list[str]) -> Union[None, int, str]:\n    try:\n        entry_point(args)\n        return None\n    except SystemExit as error:\n        return error.code\n\n\n@pytest.fixture\ndef two_pages_pdf_filepath(tmp_path: Path) -> Path:\n    \"\"\"A PDF with 2 pages, and a different image on each page\"\"\"\n    # Note: prior to v2.7.9, fpdf2 produced incorrect /Resources dicts for each page (cf. fpdf2 PR #1133),\n    # leading to an \"abnormal\" two_pages.pdf generated there, and for test_cat_subset_ensure_reduced_size() to fail.\n    pdf = FPDF()\n    pdf.add_page()\n    pdf.image(RESOURCES_ROOT / \"baleines.jpg\")\n    pdf.add_page()\n    pdf.image(RESOURCES_ROOT / \"pythonknight.png\")\n    pdf_filepath = tmp_path / \"two_pages.pdf\"\n    pdf.output(pdf_filepath)\n    return pdf_filepath\n\n\n@pytest.fixture\ndef pdf_file_100(tmp_path: Path) -> Path:\n    \"\"\"A PDF with 100 pages; each has only the page index on it.\"\"\"\n    pdf = FPDF()\n\n    for i in range(100):\n        pdf.add_page()\n        pdf.set_font(\"helvetica\", size=12)\n        pdf.cell(\n            200, 10, text=f\"{i}\", new_x=\"LMARGIN\", new_y=\"NEXT\", align=\"C\"\n        )\n\n    pdf_filepath = tmp_path / \"pdf_file_100.pdf\"\n    pdf.output(pdf_filepath)\n    return pdf_filepath\n\n\n@pytest.fixture\ndef pdf_file_abc(tmp_path: Path) -> Path:\n    \"\"\"A PDF with 100 pages; each has only the page index on it.\"\"\"\n    pdf = FPDF()\n\n    for char in [chr(i) for i in range(ord(\"a\"), ord(\"z\") + 1)]:\n        pdf.add_page()\n        pdf.set_font(\"helvetica\", size=12)\n        pdf.cell(\n            200, 10, text=f\"{char}\", new_x=\"LMARGIN\", new_y=\"NEXT\", align=\"C\"\n        )\n\n    pdf_filepath = tmp_path / \"abc.pdf\"\n    pdf.output(pdf_filepath)\n    return pdf_filepath\n"
  },
  {
    "path": "tests/test_booklet.py",
    "content": "from pathlib import Path\n\nimport pytest\nfrom pypdf import PdfReader\n\nfrom .conftest import RESOURCES_ROOT, chdir, run_cli\n\n\ndef test_booklet_fewer_args(\n    capsys: pytest.CaptureFixture, tmp_path: Path\n) -> None:\n    with chdir(tmp_path):\n        exit_code = run_cli([\"cat\", str(RESOURCES_ROOT / \"box.pdf\")])\n    assert exit_code == 2\n    captured = capsys.readouterr()\n    assert \"Missing\" in captured.err\n\n\ndef test_booklet_extra_args(\n    capsys: pytest.CaptureFixture, tmp_path: Path\n) -> None:\n    with chdir(tmp_path):\n        exit_code = run_cli(\n            [\"booklet\", str(RESOURCES_ROOT / \"box.pdf\"), \"a.pdf\", \"b.pdf\"]\n        )\n    assert exit_code == 2\n    captured = capsys.readouterr()\n    assert \"unexpected extra argument\" in captured.err\n\n\ndef test_booklet_page_size(tmp_path: Path) -> None:\n    in_fname = str(RESOURCES_ROOT / \"input8.pdf\")\n\n    with chdir(tmp_path):\n        exit_code = run_cli(\n            [\n                \"booklet\",\n                in_fname,\n                \"output8.pdf\",\n            ]\n        )\n        in_reader = PdfReader(in_fname)\n        out_reader = PdfReader(\"output8.pdf\")\n\n    assert exit_code == 0\n\n    assert len(in_reader.pages) == 8\n    assert len(out_reader.pages) == 4\n\n    in_height = in_reader.pages[0].mediabox.height\n    in_width = in_reader.pages[0].mediabox.width\n    out_height = out_reader.pages[0].mediabox.height\n    out_width = out_reader.pages[0].mediabox.width\n\n    assert out_width == in_width * 2\n    assert in_height == out_height\n\n\n@pytest.mark.parametrize(\n    (\"page_count\", \"expected\", \"expected_bc\"),\n    [\n        (\"8\", \"8 1\\n2 7\\n6 3\\n4 5\\n\", \"8 1\\n2 7\\n6 3\\n4 5\\n\"),\n        (\"7\", \"7 1\\n2\\n6 3\\n4 5\\n\", \"7 1\\n2 b\\n6 3\\n4 5\\n\"),\n        (\"6\", \"6 1\\n2 5\\n4 3\\n\\n\", \"6 1\\n2 5\\n4 3\\nc\\n\"),\n        (\"5\", \"5 1\\n2\\n4 3\\n\\n\", \"5 1\\n2 b\\n4 3\\nc\\n\"),\n        (\"4\", \"4 1\\n2 3\\n\", \"4 1\\n2 3\\n\"),\n        (\"3\", \"3 1\\n2\\n\", \"3 1\\n2 b\\n\"),\n        (\"2\", \"2 1\\n\\n\", \"2 1\\nc\\n\"),\n        (\"1\", \"1\\n\\n\", \"1 b\\nc\\n\"),\n    ],\n)\ndef test_booklet_order(\n    capsys: pytest.CaptureFixture,\n    tmp_path: Path,\n    page_count: str,\n    expected: str,\n    expected_bc: str,\n) -> None:\n    with chdir(tmp_path):\n        exit_code = run_cli(\n            [\n                \"cat\",\n                \"-o\",\n                f\"input{page_count}.pdf\",\n                str(RESOURCES_ROOT / \"input8.pdf\"),\n                f\":{page_count}\",\n            ]\n        )\n        assert exit_code == 0\n\n        exit_code = run_cli(\n            [\n                \"booklet\",\n                f\"input{page_count}.pdf\",\n                f\"output{page_count}.pdf\",\n            ]\n        )\n        captured = capsys.readouterr()\n        assert exit_code == 0, captured.err\n\n        exit_code = run_cli(\n            [\n                \"extract-text\",\n                f\"output{page_count}.pdf\",\n            ]\n        )\n        captured = capsys.readouterr()\n        assert exit_code == 0, captured.err\n        assert captured.out == expected\n\n        exit_code = run_cli(\n            [\n                \"booklet\",\n                \"--centerfold-file\",\n                str(RESOURCES_ROOT / \"c.pdf\"),\n                \"--blank-page-file\",\n                str(RESOURCES_ROOT / \"b.pdf\"),\n                f\"input{page_count}.pdf\",\n                f\"outputbc{page_count}.pdf\",\n            ]\n        )\n        captured = capsys.readouterr()\n        assert exit_code == 0, captured.err\n\n        exit_code = run_cli(\n            [\n                \"extract-text\",\n                f\"outputbc{page_count}.pdf\",\n            ]\n        )\n        captured = capsys.readouterr()\n        assert exit_code == 0, captured.err\n        assert captured.out == expected_bc\n"
  },
  {
    "path": "tests/test_cat.py",
    "content": "from pathlib import Path\nfrom typing import Any\n\nimport pytest\nfrom pypdf import PdfReader\n\nfrom .conftest import RESOURCES_ROOT, chdir, run_cli\n\n\ndef extract_embedded_images(pdf_filepath: Path) -> list[Any]:\n    reader = PdfReader(pdf_filepath)\n    return [page.images for page in reader.pages]\n\n\ndef extract_text_pages(pdf_filepath: Path) -> list[str]:\n    reader = PdfReader(pdf_filepath)\n    return [page.extract_text() for page in reader.pages]\n\n\ndef test_cat_incorrect_number_of_args(\n    capsys: pytest.CaptureFixture, tmp_path: Path\n) -> None:\n    with chdir(tmp_path):\n        exit_code = run_cli([\"cat\", str(RESOURCES_ROOT / \"box.pdf\")])\n    assert exit_code == 2\n    captured = capsys.readouterr()\n    assert \"Missing\" in captured.err\n\n\ndef test_cat_two_files_ok(\n    capsys: pytest.CaptureFixture, tmp_path: Path\n) -> None:\n    # Act\n    with chdir(tmp_path):\n        exit_code = run_cli(\n            [\n                \"cat\",\n                str(RESOURCES_ROOT / \"box.pdf\"),\n                str(RESOURCES_ROOT / \"jpeg.pdf\"),\n                \"--output\",\n                \"./out.pdf\",\n            ]\n        )\n    captured = capsys.readouterr()\n\n    # Assert\n    assert exit_code == 0, captured\n    assert not captured.err\n    reader = PdfReader(tmp_path / \"out.pdf\")\n    assert len(reader.pages) == 2\n\n\ndef test_cat_subset_ok(capsys: pytest.CaptureFixture, tmp_path: Path) -> None:\n    with chdir(tmp_path):\n        exit_code = run_cli(\n            [\n                \"cat\",\n                str(RESOURCES_ROOT / \"GeoBase_NHNC1_Data_Model_UML_EN.pdf\"),\n                \"13:15\",\n                \"--output\",\n                \"./out.pdf\",\n            ]\n        )\n    captured = capsys.readouterr()\n    assert exit_code == 0, captured\n    assert not captured.err\n    reader = PdfReader(tmp_path / \"out.pdf\")\n    assert len(reader.pages) == 2\n\n\n@pytest.mark.parametrize(\n    \"page_range\",\n    [\"a\", \"-\", \"1-\", \"1-1-1\", \"1:1:1:1\"],\n)\ndef test_cat_subset_invalid_args(\n    capsys: pytest.CaptureFixture, tmp_path: Path, page_range: str\n) -> None:\n    with chdir(tmp_path):\n        exit_code = run_cli(\n            [\n                \"cat\",\n                str(RESOURCES_ROOT / \"jpeg.pdf\"),\n                page_range,\n                \"--output\",\n                \"./out.pdf\",\n            ]\n        )\n    captured = capsys.readouterr()\n    assert exit_code == 2, captured\n    assert \"Error: invalid file path or page range provided\" in captured.out\n\n\ndef test_cat_subset_warn_on_missing_pages(\n    capsys: pytest.CaptureFixture, tmp_path: Path\n) -> None:\n    with chdir(tmp_path):\n        exit_code = run_cli(\n            [\n                \"cat\",\n                str(RESOURCES_ROOT / \"jpeg.pdf\"),\n                \"2\",\n                \"--output\",\n                \"./out.pdf\",\n            ]\n        )\n    captured = capsys.readouterr()\n    assert exit_code == 0, captured\n    assert \"WARN\" in captured.err\n\n\ndef test_cat_subset_ensure_reduced_size(\n    tmp_path: Path, two_pages_pdf_filepath: Path\n) -> None:\n    exit_code = run_cli(\n        [\n            \"cat\",\n            str(two_pages_pdf_filepath),\n            \"0\",\n            \"--output\",\n            str(tmp_path / \"page1.pdf\"),\n        ]\n    )\n    assert exit_code == 0\n    # The extracted PDF should only contain ONE image:\n    embedded_images = extract_embedded_images(tmp_path / \"page1.pdf\")\n    assert len(embedded_images) == 1\n\n    exit_code = run_cli(\n        [\n            \"cat\",\n            str(two_pages_pdf_filepath),\n            \"1\",\n            \"--output\",\n            str(tmp_path / \"page2.pdf\"),\n        ]\n    )\n    assert exit_code == 0\n    # The extracted PDF should only contain ONE image:\n    embedded_images = extract_embedded_images(tmp_path / \"page2.pdf\")\n    assert len(embedded_images) == 1\n\n\ndef test_cat_combine_files(\n    pdf_file_100: Path,\n    pdf_file_abc: Path,\n    tmp_path: Path,\n    capsys: pytest.CaptureFixture,\n) -> None:\n    with chdir(tmp_path):\n        output_pdf_path = tmp_path / \"out.pdf\"\n\n        # Run pdfly cat command\n        exit_code = run_cli(\n            [\n                \"cat\",\n                str(pdf_file_100),\n                \"1:10:2\",\n                str(pdf_file_abc),\n                \"::2\",\n                str(pdf_file_abc),\n                \"1::2\",\n                \"--output\",\n                str(output_pdf_path),\n            ]\n        )\n        captured = capsys.readouterr()\n\n        # Check if the command was successful\n        assert exit_code == 0, captured.out\n\n        # Extract text from the original and modified PDFs\n        extracted_pages = extract_text_pages(output_pdf_path)\n\n        # Compare the extracted text\n        assert extracted_pages == [\n            \"1\",\n            \"3\",\n            \"5\",\n            \"7\",\n            \"9\",\n            \"a\",\n            \"c\",\n            \"e\",\n            \"g\",\n            \"i\",\n            \"k\",\n            \"m\",\n            \"o\",\n            \"q\",\n            \"s\",\n            \"u\",\n            \"w\",\n            \"y\",\n            \"b\",\n            \"d\",\n            \"f\",\n            \"h\",\n            \"j\",\n            \"l\",\n            \"n\",\n            \"p\",\n            \"r\",\n            \"t\",\n            \"v\",\n            \"x\",\n            \"z\",\n        ]\n\n\n@pytest.mark.parametrize(\n    (\"page_range\", \"expected\"),\n    [\n        (\"22\", [\"22\"]),\n        (\"0:3\", [\"0\", \"1\", \"2\"]),\n        (\":3\", [\"0\", \"1\", \"2\"]),\n        (\":\", [str(el) for el in range(100)]),\n        (\"5:\", [str(el) for el in list(range(100))[5:]]),\n        (\"::2\", [str(el) for el in list(range(100))[::2]]),\n        (\"1:10:2\", [str(el) for el in list(range(100))[1:10:2]]),\n        (\"::1\", [str(el) for el in list(range(100))[::1]]),\n        (\"::-1\", [str(el) for el in list(range(100))[::-1]]),\n    ],\n)\ndef test_cat_commands(\n    pdf_file_100: Path,\n    tmp_path: Path,\n    page_range: str,\n    expected: list[str],\n) -> None:\n    with chdir(tmp_path):\n        output_pdf_path = tmp_path / \"out.pdf\"\n\n        # Run pdfly cat command\n        exit_code = run_cli(\n            [\n                \"cat\",\n                str(pdf_file_100),\n                page_range,\n                \"--output\",\n                str(output_pdf_path),\n            ]\n        )\n\n        # Check if the command was successful\n        assert exit_code == 0\n\n        # Extract text from the original and modified PDFs\n        extracted_pages = extract_text_pages(output_pdf_path)\n\n        # Compare the extracted text\n        assert extracted_pages == expected\n\n\ndef test_cat_decrypt_with_password_ok(\n    capsys: pytest.CaptureFixture, tmp_path: Path\n) -> None:\n    exit_code = run_cli(\n        [\n            \"cat\",\n            \"--password=openpassword\",\n            \"sample-files/005-libreoffice-writer-password/libreoffice-writer-password.pdf\",\n            \"--output\",\n            str(tmp_path / \"out.pdf\"),\n        ]\n    )\n    captured = capsys.readouterr()\n    assert exit_code == 0, captured\n    assert not captured.err\n    reader = PdfReader(tmp_path / \"out.pdf\")\n    assert len(reader.pages) == 1\n\n\ndef test_cat_decrypt_with_password_ko(\n    capsys: pytest.CaptureFixture, tmp_path: Path\n) -> None:\n    exit_code = run_cli(\n        [\n            \"cat\",\n            \"--password=INCORRECT\",\n            \"sample-files/005-libreoffice-writer-password/libreoffice-writer-password.pdf\",\n            \"--output\",\n            str(tmp_path / \"out.pdf\"),\n        ]\n    )\n    captured = capsys.readouterr()\n    assert exit_code == 1, captured\n    assert \"Error: the decrypting password provided is invalid\" in captured.out\n"
  },
  {
    "path": "tests/test_check_sign.py",
    "content": "from pathlib import Path\n\nimport pytest\nfrom fpdf import FPDF\n\nfrom .conftest import RESOURCES_ROOT, chdir, run_cli\n\n\ndef test_check_sign_manipulated_content(\n    capsys: pytest.CaptureFixture, tmp_path: Path\n) -> None:\n    # Arrange\n    pdf = FPDF()\n    pdf.add_page()\n    pdf.set_font(\"helvetica\", style=\"B\", size=16)\n    pdf.add_text_markup_annotation(\n        \"Underline\", \"Hello World!\", [0, 0, 0, 0, 0, 0, 0, 0]\n    )\n    pdf.sign_pkcs12(str(RESOURCES_ROOT / \"signing-certificate.p12\"), b\"fpdf2\")\n\n    input_pdf_bytes = pdf.output()\n\n    # manipulate signed pdf - leaving length intact\n    input_pdf_bytes = input_pdf_bytes.replace(b\"Hello World!\", b\"aaaaa aaaaa!\")\n\n    input_pdf_manipulated = tmp_path / \"signed_manipulated.pdf\"\n    input_pdf_manipulated.write_bytes(input_pdf_bytes)\n\n    # Act\n    with chdir(tmp_path):\n        exit_code = run_cli(\n            [\n                \"check-sign\",\n                input_pdf_manipulated.name,\n                \"--pem\",\n                str(RESOURCES_ROOT / \"signing-certificate.crt\"),\n            ]\n        )\n    captured = capsys.readouterr()\n\n    # Assert\n    assert exit_code == 1\n    assert \"Check failed\" in captured.err\n    assert \"Content hash not ok\" in captured.err\n\n\ndef test_check_sign_missing_signature(\n    capsys: pytest.CaptureFixture, tmp_path: Path\n) -> None:\n    # Act\n    with chdir(tmp_path):\n        exit_code = run_cli(\n            [\n                \"check-sign\",\n                str(RESOURCES_ROOT / \"input8.pdf\"),\n                \"--pem\",\n                str(RESOURCES_ROOT / \"signing-certificate.crt\"),\n            ]\n        )\n    captured = capsys.readouterr()\n\n    # Assert\n    assert exit_code == 2\n    assert \"Signature missing\" in captured.err\n\n\ndef test_check_sign_signature_not_matching_to_certificate(\n    capsys: pytest.CaptureFixture, tmp_path: Path\n) -> None:\n    # Act\n    with chdir(tmp_path):\n        exit_code = run_cli(\n            [\n                \"check-sign\",\n                str(RESOURCES_ROOT / \"sign_pkcs12.pdf\"),\n                \"--pem\",\n                str(\n                    RESOURCES_ROOT / \"demo2_ca.root.crt.pem\"\n                ),  # sign_pkcs12.pdf signature matched to signing-certificate.crt\n            ]\n        )\n    captured = capsys.readouterr()\n\n    # Assert\n    assert exit_code == 1\n    assert \"Check failed\" in captured.err\n    assert \"Certificate not ok\" in captured.err\n\n\ndef test_check_sign_pem(capsys: pytest.CaptureFixture, tmp_path: Path) -> None:\n    # Act\n    with chdir(tmp_path):\n        exit_code = run_cli(\n            [\n                \"check-sign\",\n                str(RESOURCES_ROOT / \"sign_pkcs12.pdf\"),\n                \"--pem\",\n                str(RESOURCES_ROOT / \"signing-certificate.crt\"),\n            ]\n        )\n    captured = capsys.readouterr()\n\n    # Assert\n    assert exit_code == 0\n    assert not captured.err\n\n\ndef test_check_sign_pdfly_signed_pdf(\n    capsys: pytest.CaptureFixture, tmp_path: Path\n) -> None:\n    # Arrange\n    with chdir(tmp_path):\n        exit_code = run_cli(\n            [\n                \"sign\",\n                str(RESOURCES_ROOT / \"input8.pdf\"),\n                \"-o\",\n                str(tmp_path / \"input8_signed.pdf\"),\n                \"--p12\",\n                str(RESOURCES_ROOT / \"signing-certificate.p12\"),\n                \"--p12-password\",\n                \"fpdf2\",\n            ]\n        )\n    captured = capsys.readouterr()\n\n    # Act\n    with chdir(tmp_path):\n        exit_code = run_cli(\n            [\n                \"check-sign\",\n                str(tmp_path / \"input8_signed.pdf\"),\n                \"--pem\",\n                str(RESOURCES_ROOT / \"signing-certificate.crt\"),\n            ]\n        )\n    captured = capsys.readouterr()\n\n    # Assert\n    assert exit_code == 0\n    assert not captured.err\n"
  },
  {
    "path": "tests/test_cli.py",
    "content": "import sys\nfrom subprocess import check_output\n\nimport pytest\nfrom pypdf import __version__ as pypdf_version\n\nfrom .conftest import run_cli\n\n\ndef test_pypdf_cli_can_be_invoked_as_a_module() -> None:\n    stdout = check_output(  # noqa: S603\n        [sys.executable, \"-m\", \"pdfly\", \"--help\"]\n    ).decode()\n    assert \"pdfly [OPTIONS] COMMAND [ARGS]...\" in stdout\n    assert (\n        \"pdfly is a pure-python cli application for manipulating PDF files.\"\n        in stdout\n    )\n\n\ndef test_pypdf_cli_version(capsys: pytest.CaptureFixture) -> None:\n    exit_code = run_cli([\"--version\"])\n    captured = capsys.readouterr()\n    assert not captured.err\n    assert pypdf_version in captured.out\n    assert exit_code == 0\n"
  },
  {
    "path": "tests/test_compress.py",
    "content": "\"\"\"Tests for the `compress` command.\"\"\"\n\nfrom pathlib import Path\n\nimport pytest\nfrom typer.testing import CliRunner\n\nfrom pdfly.cli import entry_point\n\nrunner = CliRunner()\n\n\n@pytest.mark.parametrize(\"input_pdf_filepath\", Path(\"resources\").glob(\"*.pdf\"))\ndef test_compress_sample_files(\n    input_pdf_filepath: Path, tmp_path: Path\n) -> None:\n    \"\"\"Test compression on all sample PDF files.\"\"\"\n    output_pdf_filepath = tmp_path / \"compressed_output.pdf\"\n\n    result = runner.invoke(\n        entry_point,\n        [\"compress\", str(input_pdf_filepath), str(output_pdf_filepath)],\n    )\n\n    assert (\n        result.exit_code == 0\n    ), f\"Compression failed for {input_pdf_filepath}: {result.output}\"\n\n    assert (\n        output_pdf_filepath.exists()\n    ), f\"Output PDF {output_pdf_filepath} does not exist.\"\n\n    # Verify output file is a valid PDF\n    with open(output_pdf_filepath, \"rb\") as f:\n        content = f.read()\n        assert content.startswith(\n            b\"%PDF-\"\n        ), f\"Output is not a valid PDF file: {output_pdf_filepath}\"\n\n    assert \"Original Size\" in result.output\n    assert \"Final Size\" in result.output\n\n\ndef test_compress_no_compression_when_larger(tmp_path: Path) -> None:\n    \"\"\"Test that compression doesn't apply when result would be larger.\"\"\"\n    # Create a small PDF that might not compress well\n    from fpdf import FPDF\n\n    pdf = FPDF()\n    pdf.add_page()\n    pdf.set_font(\"helvetica\", size=12)\n    pdf.cell(\n        200, 10, text=\"Short text\", new_x=\"LMARGIN\", new_y=\"NEXT\", align=\"C\"\n    )\n\n    input_pdf = tmp_path / \"small.pdf\"\n    pdf.output(input_pdf)\n\n    output_pdf = tmp_path / \"compressed.pdf\"\n\n    result = runner.invoke(\n        entry_point,\n        [\"compress\", str(input_pdf), str(output_pdf)],\n    )\n\n    assert result.exit_code == 0\n\n    if \"No compression applied\" in result.output:\n        # If compression would make file larger, ensure original is copied\n        assert input_pdf.stat().st_size == output_pdf.stat().st_size\n        assert \"would increase size\" in result.output\n    else:\n        # If compression worked, ensure it's actually smaller or same size\n        assert output_pdf.stat().st_size <= input_pdf.stat().st_size\n\n\ndef test_compress_file_integrity(tmp_path: Path) -> None:\n    \"\"\"Test that compressed files maintain PDF integrity.\"\"\"\n    from fpdf import FPDF\n\n    pdf = FPDF()\n    pdf.add_page()\n    pdf.set_font(\"helvetica\", size=12)\n    pdf.cell(\n        200,\n        10,\n        text=\"Test PDF for compression\",\n        new_x=\"LMARGIN\",\n        new_y=\"NEXT\",\n        align=\"C\",\n    )\n    pdf.cell(\n        200,\n        10,\n        text=\"This is a test document.\",\n        new_x=\"LMARGIN\",\n        new_y=\"NEXT\",\n        align=\"L\",\n    )\n    pdf.add_page()\n    pdf.cell(\n        200,\n        10,\n        text=\"Second page content\",\n        new_x=\"LMARGIN\",\n        new_y=\"NEXT\",\n        align=\"C\",\n    )\n\n    input_pdf = tmp_path / \"test.pdf\"\n    pdf.output(input_pdf)\n\n    output_pdf = tmp_path / \"compressed.pdf\"\n\n    result = runner.invoke(\n        entry_point,\n        [\"compress\", str(input_pdf), str(output_pdf)],\n    )\n\n    assert result.exit_code == 0\n\n    from pypdf import PdfReader\n\n    reader = PdfReader(str(output_pdf))\n    assert len(reader.pages) == 2\n\n    page1_text = reader.pages[0].extract_text()\n    page2_text = reader.pages[1].extract_text()\n\n    assert \"Test PDF for compression\" in page1_text\n    assert \"Second page content\" in page2_text\n\n\ndef test_compress_output_metrics(tmp_path: Path) -> None:\n    \"\"\"Test that compression metrics are properly displayed.\"\"\"\n    from fpdf import FPDF\n\n    pdf = FPDF()\n    for _i in range(10):\n        pdf.add_page()\n        pdf.set_font(\"helvetica\", size=12)\n        pdf.cell(\n            200,\n            10,\n            text=\"This is repeated text on every page \" * 5,\n            new_x=\"LMARGIN\",\n            new_y=\"NEXT\",\n            align=\"L\",\n        )\n\n    input_pdf = tmp_path / \"repeat.pdf\"\n    pdf.output(input_pdf)\n\n    output_pdf = tmp_path / \"compressed.pdf\"\n\n    result = runner.invoke(\n        entry_point,\n        [\"compress\", str(input_pdf), str(output_pdf)],\n    )\n\n    assert result.exit_code == 0\n\n    output_lines = result.output.strip().split(\"\\n\")\n    assert any(\"Original Size\" in line for line in output_lines)\n    assert any(\"Final Size\" in line for line in output_lines)\n\n    # Extract sizes from output\n    orig_size_line = next(\n        line for line in output_lines if \"Original Size\" in line\n    )\n    final_size_line = next(\n        line for line in output_lines if \"Final Size\" in line\n    )\n\n    assert \":\" in orig_size_line\n    assert \":\" in final_size_line\n\n\ndef test_compress_same_input_output_not_allowed(tmp_path: Path) -> None:\n    \"\"\"Test that input and output files cannot be the same.\"\"\"\n    input_pdf = tmp_path / \"test.pdf\"\n\n    # Create a simple PDF\n    from fpdf import FPDF\n\n    pdf = FPDF()\n    pdf.add_page()\n    pdf.set_font(\"helvetica\", size=12)\n    pdf.cell(200, 10, text=\"Test\", new_x=\"LMARGIN\", new_y=\"NEXT\", align=\"C\")\n    pdf.output(input_pdf)\n\n    # Try to compress to the same file (should work but might not compress)\n    result = runner.invoke(\n        entry_point,\n        [\"compress\", str(input_pdf), str(input_pdf)],\n    )\n\n    assert result.exit_code in [0, 1]  # 0 for success, 1 for error\n\n\ndef test_compress_preserves_metadata(tmp_path: Path) -> None:\n    \"\"\"Test that compression preserves PDF metadata.\"\"\"\n    from fpdf import FPDF\n\n    pdf = FPDF()\n    pdf.add_page()\n    pdf.set_font(\"helvetica\", size=12)\n    pdf.cell(\n        200, 10, text=\"Test document\", new_x=\"LMARGIN\", new_y=\"NEXT\", align=\"C\"\n    )\n\n    # Set some metadata\n    pdf.set_title(\"Test Title\")\n    pdf.set_author(\"Test Author\")\n    pdf.set_subject(\"Test Subject\")\n\n    input_pdf = tmp_path / \"metadata.pdf\"\n    pdf.output(input_pdf)\n\n    output_pdf = tmp_path / \"compressed.pdf\"\n\n    result = runner.invoke(\n        entry_point,\n        [\"compress\", str(input_pdf), str(output_pdf)],\n    )\n\n    assert result.exit_code == 0\n\n    from pypdf import PdfReader\n\n    reader = PdfReader(str(output_pdf))\n    metadata = reader.metadata\n\n    assert metadata is not None\n    assert metadata.get(\"/Title\") == \"Test Title\"\n    assert metadata.get(\"/Author\") == \"Test Author\"\n    assert metadata.get(\"/Subject\") == \"Test Subject\"\n"
  },
  {
    "path": "tests/test_extract_annotated_pages.py",
    "content": "from pathlib import Path\n\nimport pytest\n\nfrom .conftest import RESOURCES_ROOT, chdir, run_cli\n\n\ndef test_extract_annotated_pages_input8(\n    capsys: pytest.CaptureFixture, tmp_path: Path\n) -> None:\n    with chdir(tmp_path):\n        run_cli(\n            [\n                \"extract-annotated-pages\",\n                str(RESOURCES_ROOT / \"input8.pdf\"),\n            ]\n        )\n    captured = capsys.readouterr()\n    assert not captured.err\n    assert \"Extracted 1 pages with annotations\" in captured.out\n"
  },
  {
    "path": "tests/test_extract_images.py",
    "content": "from pathlib import Path\n\nimport pytest\n\nfrom .conftest import RESOURCES_ROOT, chdir, run_cli\n\n\ndef test_extract_images_jpg_png(\n    capsys: pytest.CaptureFixture, tmp_path: Path\n) -> None:\n    with chdir(tmp_path):\n        run_cli(\n            [\n                \"extract-images\",\n                str(RESOURCES_ROOT / \"GeoBase_NHNC1_Data_Model_UML_EN.pdf\"),\n            ]\n        )\n    captured = capsys.readouterr()\n    assert not captured.err\n    assert \"Extracted 3 images\" in captured.out\n\n\ndef test_extract_images_monochrome(\n    capsys: pytest.CaptureFixture, tmp_path: Path\n) -> None:\n    # There used to be a bug for this case: https://github.com/py-pdf/pypdf/issues/2176\n    with chdir(tmp_path):\n        run_cli([\"extract-images\", str(RESOURCES_ROOT / \"box.pdf\")])\n    captured = capsys.readouterr()\n    assert not captured.err\n    assert \"Extracted 1 images\" in captured.out\n"
  },
  {
    "path": "tests/test_pagemeta.py",
    "content": "import json\nfrom pathlib import Path\n\nimport pytest\n\nfrom .conftest import RESOURCES_ROOT, chdir, run_cli\n\n\ndef test_pagemeta_json(capsys: pytest.CaptureFixture, tmp_path: Path) -> None:\n    with chdir(tmp_path):\n        run_cli(\n            [\"pagemeta\", str(RESOURCES_ROOT / \"box.pdf\"), \"0\", \"-o\", \"json\"]\n        )\n    captured = capsys.readouterr()\n    assert not captured.err\n    page_metadata = json.loads(captured.out)\n    assert page_metadata[\"mediabox\"] == [0.0, 0.0, 60.0, 60.0]\n    assert page_metadata[\"cropbox\"] == [0.0, 0.0, 60.0, 60.0]\n    assert page_metadata[\"artbox\"] == [0.0, 0.0, 60.0, 60.0]\n    assert page_metadata[\"bleedbox\"] == [0.0, 0.0, 60.0, 60.0]\n\n\ndef test_pagemeta_text_with_known_format(\n    capsys: pytest.CaptureFixture, tmp_path: Path\n) -> None:\n    with chdir(tmp_path):\n        run_cli([\"pagemeta\", str(RESOURCES_ROOT / \"c.pdf\"), \"0\"])\n    captured = capsys.readouterr()\n    assert not captured.err\n    assert \"(Letter)\" in captured.out\n\n\ndef test_pagemeta_text_with_close_format(\n    capsys: pytest.CaptureFixture, tmp_path: Path\n) -> None:\n    with chdir(tmp_path):\n        run_cli([\"pagemeta\", str(RESOURCES_ROOT / \"jpeg.pdf\"), \"0\"])\n    captured = capsys.readouterr()\n    assert not captured.err\n    assert \"close to format: A4\" in captured.out\n"
  },
  {
    "path": "tests/test_rm.py",
    "content": "\"\"\"Tests for the `rm` command.\"\"\"\n\nfrom pathlib import Path\n\nimport pytest\nfrom _pytest.capture import CaptureFixture\nfrom pypdf import PdfReader\n\nfrom .conftest import RESOURCES_ROOT, chdir, run_cli\nfrom .test_cat import extract_embedded_images\n\n\ndef test_rm_incorrect_number_of_args(\n    capsys: CaptureFixture, tmp_path: Path\n) -> None:\n    with chdir(tmp_path):\n        exit_code = run_cli([\"rm\", str(RESOURCES_ROOT / \"box.pdf\")])\n    assert exit_code == 2\n    captured = capsys.readouterr()\n    assert \"Missing\" in captured.err\n\n\ndef test_rm_subset_ok(capsys: CaptureFixture, tmp_path: Path) -> None:\n    with chdir(tmp_path):\n        exit_code = run_cli(\n            [\n                \"rm\",\n                str(RESOURCES_ROOT / \"GeoBase_NHNC1_Data_Model_UML_EN.pdf\"),\n                \"13:15\",\n                \"--output\",\n                \"./out.pdf\",\n            ]\n        )\n    captured = capsys.readouterr()\n    assert exit_code == 0, captured\n    assert not captured.err\n    inp_reader = PdfReader(\n        RESOURCES_ROOT / \"GeoBase_NHNC1_Data_Model_UML_EN.pdf\"\n    )\n    out_reader = PdfReader(tmp_path / \"out.pdf\")\n    assert len(out_reader.pages) == len(inp_reader.pages) - 2\n\n\n@pytest.mark.parametrize(\n    \"page_range\",\n    [\"a\", \"-\", \"1-\", \"1-1-1\", \"1:1:1:1\"],\n)\ndef test_rm_subset_invalid_args(\n    capsys: CaptureFixture, tmp_path: Path, page_range: str\n) -> None:\n    with chdir(tmp_path):\n        exit_code = run_cli(\n            [\n                \"rm\",\n                str(RESOURCES_ROOT / \"jpeg.pdf\"),\n                page_range,\n                \"--output\",\n                \"./out.pdf\",\n            ]\n        )\n    captured = capsys.readouterr()\n    assert exit_code == 2, captured\n    assert \"Error: invalid file path or page range provided\" in captured.out\n\n\ndef test_rm_subset_warn_on_missing_pages(\n    capsys: CaptureFixture, tmp_path: Path\n) -> None:\n    with chdir(tmp_path):\n        exit_code = run_cli(\n            [\n                \"rm\",\n                str(RESOURCES_ROOT / \"jpeg.pdf\"),\n                \"2\",\n                \"--output\",\n                \"./out.pdf\",\n            ]\n        )\n    captured = capsys.readouterr()\n    assert exit_code == 0, captured\n    assert \"WARN\" in captured.err\n\n\ndef test_rm_subset_ensure_reduced_size(\n    tmp_path: Path, two_pages_pdf_filepath: Path\n) -> None:\n    exit_code = run_cli(\n        [\n            \"rm\",\n            str(two_pages_pdf_filepath),\n            \"0\",\n            \"--output\",\n            str(tmp_path / \"page1.pdf\"),\n        ]\n    )\n    assert exit_code == 0\n    # The extracted PDF should only contain ONE image:\n    embedded_images = extract_embedded_images(tmp_path / \"page1.pdf\")\n    assert len(embedded_images) == 1\n\n    exit_code = run_cli(\n        [\n            \"rm\",\n            str(two_pages_pdf_filepath),\n            \"1\",\n            \"--output\",\n            str(tmp_path / \"page2.pdf\"),\n        ]\n    )\n    assert exit_code == 0\n    # The extracted PDF should only contain ONE image:\n    embedded_images = extract_embedded_images(tmp_path / \"page2.pdf\")\n    assert len(embedded_images) == 1\n\n\ndef test_rm_combine_files(\n    pdf_file_100: Path,\n    pdf_file_abc: Path,\n    tmp_path: Path,\n    capsys: CaptureFixture,\n) -> None:\n    with chdir(tmp_path):\n        output_pdf_path = tmp_path / \"out.pdf\"\n\n        # Run pdfly rm command\n        exit_code = run_cli(\n            [\n                \"rm\",\n                str(pdf_file_100),\n                \"1:10:2\",\n                str(pdf_file_abc),\n                \"::2\",\n                str(pdf_file_abc),\n                \"1::2\",\n                \"--output\",\n                str(output_pdf_path),\n            ]\n        )\n        captured = capsys.readouterr()\n\n        # Check if the command was successful\n        assert exit_code == 0, captured.out\n\n        # Extract text from the original and modified PDFs\n        extracted_pages = []\n        reader = PdfReader(output_pdf_path)\n        extracted_pages = [page.extract_text() for page in reader.pages]\n\n        # Compare the extracted text\n        l1 = [str(el) for el in list(range(0, 10, 2)) + list(range(10, 100))]\n        assert extracted_pages == l1 + [\n            \"b\",\n            \"d\",\n            \"f\",\n            \"h\",\n            \"j\",\n            \"l\",\n            \"n\",\n            \"p\",\n            \"r\",\n            \"t\",\n            \"v\",\n            \"x\",\n            \"z\",\n            \"a\",\n            \"c\",\n            \"e\",\n            \"g\",\n            \"i\",\n            \"k\",\n            \"m\",\n            \"o\",\n            \"q\",\n            \"s\",\n            \"u\",\n            \"w\",\n            \"y\",\n        ]\n\n\n@pytest.mark.parametrize(\n    (\"page_range\", \"expected\"),\n    [\n        (\"22\", [str(el) for el in range(100) if el != 22]),\n        (\"0:3\", [str(el) for el in range(3, 100)]),\n        (\":3\", [str(el) for el in range(3, 100)]),\n        (\":\", []),\n        (\"5:\", [\"0\", \"1\", \"2\", \"3\", \"4\"]),\n        (\"::2\", [str(el) for el in list(range(100))[1::2]]),\n        (\n            \"1:10:2\",\n            [str(el) for el in list(range(0, 10, 2)) + list(range(10, 100))],\n        ),\n        (\"::1\", []),\n        (\"::-1\", []),\n    ],\n)\ndef test_rm_commands(\n    pdf_file_100: Path,\n    tmp_path: Path,\n    page_range: str,\n    expected: list[str],\n) -> None:\n    with chdir(tmp_path):\n        output_pdf_path = tmp_path / \"out.pdf\"\n\n        # Run pdfly rm command\n        exit_code = run_cli(\n            [\n                \"rm\",\n                str(pdf_file_100),\n                page_range,\n                \"--output\",\n                str(output_pdf_path),\n            ]\n        )\n\n        # Check if the command was successful\n        assert exit_code == 0\n\n        # Extract text from the original and modified PDFs\n        extracted_pages = []\n        reader = PdfReader(output_pdf_path)\n        extracted_pages = [page.extract_text() for page in reader.pages]\n\n        # Compare the extracted text\n        assert extracted_pages == expected\n"
  },
  {
    "path": "tests/test_rotate.py",
    "content": "from pathlib import Path\n\nimport pytest\nfrom pypdf import PdfReader\n\nfrom .conftest import RESOURCES_ROOT, chdir, run_cli\n\n\ndef test_rotate_fewer_args(\n    capsys: pytest.CaptureFixture, tmp_path: Path\n) -> None:\n    with chdir(tmp_path):\n        exit_code = run_cli(\n            [\n                \"rotate\",\n            ]\n        )\n    assert exit_code == 2\n    captured = capsys.readouterr()\n    assert \"Missing argument\" in captured.err\n\n\ndef test_rotate_extra_args(\n    capsys: pytest.CaptureFixture, tmp_path: Path\n) -> None:\n    with chdir(tmp_path):\n        exit_code = run_cli(\n            [\n                \"rotate\",\n                \"-o\",\n                \"/dev/null\",\n                str(RESOURCES_ROOT / \"box.pdf\"),\n                \"37\",\n                \"extra 1\",\n                \"extra 2\",\n            ]\n        )\n    assert exit_code == 2\n    captured = capsys.readouterr()\n    assert \"unexpected extra argument\" in captured.err\n\n\ndef get_page_rotations(fname: str) -> list[int]:\n    reader = PdfReader(fname)\n    return [page.rotation for page in reader.pages]\n\n\ndef diff_rotations(\n    in_: list[int], out: list[int], degrees: int = 0\n) -> list[int]:\n    diffs = []\n    for orig, rotated in zip(in_, out):\n        diffs.append(rotated - (orig + degrees))\n    return diffs\n\n\ndef test_rotate_default(tmp_path: Path) -> None:\n    in_fname = str(RESOURCES_ROOT / \"input8.pdf\")\n    out_fname = \"output8.pdf\"\n    degrees = 90\n\n    with chdir(tmp_path):\n        print(f\"{tmp_path=}\")\n        exit_code = run_cli(\n            [\n                \"rotate\",\n                \"-o\",\n                out_fname,\n                in_fname,\n                str(degrees),\n            ]\n        )\n        in_rotations = get_page_rotations(in_fname)\n        out_rotations = get_page_rotations(out_fname)\n\n    assert exit_code == 0\n\n    assert not any(diff_rotations(in_rotations, out_rotations, degrees))\n\n\n@pytest.mark.parametrize(\n    # NB \"slice\" can not be specified as the empty string\n    (\"degrees\", \"slice\", \"expected_diff\"),\n    [\n        (90, \":\", [90, 90, 90, 90, 90, 90, 90, 90]),  # every page\n        (90, \"::2\", [90, 0, 90, 0, 90, 0, 90, 0]),  # every other, even index\n        (90, \"1::2\", [0, 90, 0, 90, 0, 90, 0, 90]),  # every other, odd index\n        (90, \":2\", [90, 90, 0, 0, 0, 0, 0, 0]),  # first 2\n        (\n            -90,\n            \":\",\n            [-90, -90, -90, -90, -90, -90, -90, -90],\n        ),  # negative degrees works\n        (\n            -720,\n            \":\",\n            [-720, -720, -720, -720, -720, -720, -720, -720],\n        ),  # |degrees| > 360 is also supported\n    ],\n)\ndef test_rotate_slices(\n    capsys: pytest.CaptureFixture,\n    tmp_path: Path,\n    degrees: int,\n    slice: str,\n    expected_diff: list[int],\n) -> None:\n    in_fname = str(RESOURCES_ROOT / \"input8.pdf\")\n    out_fname = \"output.pdf\"\n    with chdir(tmp_path):\n        args = [\n            \"rotate\",\n            \"-o\",\n            f\"{out_fname}\",\n            f\"{in_fname}\",\n            \"--\",  # end options, so negative degree values work\n            f\"{degrees}\",\n            f\"{slice}\",\n        ]\n        exit_code = run_cli(args)\n        captured = capsys.readouterr()\n        assert exit_code == 0, captured.err\n\n        in_rotations = get_page_rotations(in_fname)\n        out_rotations = get_page_rotations(out_fname)\n        actual_diff = diff_rotations(in_rotations, out_rotations)\n\n    assert not any(diff_rotations(actual_diff, expected_diff))\n"
  },
  {
    "path": "tests/test_sign.py",
    "content": "from pathlib import Path\n\nimport pytest\nfrom endesive import pdf\n\nfrom .conftest import RESOURCES_ROOT, chdir, run_cli\n\n\ndef test_sign_missing_certificate_key_option(\n    capsys: pytest.CaptureFixture, tmp_path: Path\n) -> None:\n    # Act\n    with chdir(tmp_path):\n        exit_code = run_cli(\n            [\"sign\", str(RESOURCES_ROOT / \"input8.pdf\"), \"-o\", \"out.pdf\"]\n        )\n    captured = capsys.readouterr()\n\n    # Assert\n    assert exit_code == 2\n    assert \"Missing option\" in captured.err\n\n\ndef test_sign_already_signed_pdf(\n    capsys: pytest.CaptureFixture, tmp_path: Path\n) -> None:\n    # Act\n    with chdir(tmp_path):\n        exit_code = run_cli(\n            [\n                \"sign\",\n                str(RESOURCES_ROOT / \"sign_pkcs12.pdf\"),\n                \"-o\",\n                \"out.pdf\",\n                \"--p12\",\n                str(RESOURCES_ROOT / \"signing-certificate.p12\"),\n                \"--p12-password\",\n                \"fpdf2\",\n            ]\n        )\n    captured = capsys.readouterr()\n\n    # Assert\n    assert exit_code == 2\n    assert \"already signed\" in captured.err\n\n\ndef test_sign_pkcs12(capsys: pytest.CaptureFixture, tmp_path: Path) -> None:\n    # Act\n    with chdir(tmp_path):\n        exit_code = run_cli(\n            [\n                \"sign\",\n                str(RESOURCES_ROOT / \"input8.pdf\"),\n                \"-o\",\n                \"out.pdf\",\n                \"--p12\",\n                str(RESOURCES_ROOT / \"signing-certificate.p12\"),\n                \"--p12-password\",\n                \"fpdf2\",\n            ]\n        )\n    captured = capsys.readouterr()\n\n    # Assert\n    assert exit_code == 0\n    assert not captured.err\n\n    outpdf = tmp_path / \"out.pdf\"\n    certificate = RESOURCES_ROOT / \"signing-certificate.crt\"\n    results = pdf.verify(outpdf.read_bytes(), [certificate.read_bytes()])\n    for hash_ok, signature_ok, cert_ok in results:\n        assert signature_ok\n        assert hash_ok\n        assert cert_ok\n\n\ndef test_sign_pkcs12_in_place(\n    capsys: pytest.CaptureFixture, tmp_path: Path\n) -> None:\n    # Arrange\n    input8pdf = RESOURCES_ROOT / \"input8.pdf\"\n    outpdf = tmp_path / \"out.pdf\"\n\n    outpdf.write_bytes(input8pdf.read_bytes())\n\n    # Act\n    with chdir(tmp_path):\n        exit_code = run_cli(\n            [\n                \"sign\",\n                \"out.pdf\",\n                \"--in-place\",\n                \"--p12\",\n                str(RESOURCES_ROOT / \"signing-certificate.p12\"),\n                \"--p12-password\",\n                \"fpdf2\",\n            ]\n        )\n    captured = capsys.readouterr()\n\n    # Assert\n    assert exit_code == 0\n    assert not captured.err\n\n    certificate = RESOURCES_ROOT / \"signing-certificate.crt\"\n    results = pdf.verify(outpdf.read_bytes(), [certificate.read_bytes()])\n    for hash_ok, signature_ok, cert_ok in results:\n        assert signature_ok\n        assert hash_ok\n        assert cert_ok\n"
  },
  {
    "path": "tests/test_uncompress.py",
    "content": "\"\"\"Tests for the `uncompress` command.\"\"\"\n\nfrom pathlib import Path\n\nimport pytest\nfrom pypdf import PdfReader\nfrom typer.testing import CliRunner\n\nfrom pdfly.cli import entry_point\n\nrunner = CliRunner()\n\n\n@pytest.mark.parametrize(\n    \"input_pdf_filepath\", Path(\"sample-files\").glob(\"*.pdf\")\n)\ndef test_uncompress_all_sample_files(\n    input_pdf_filepath: Path, tmp_path: Path\n) -> None:\n    output_pdf_filepath = tmp_path / \"uncompressed_output.pdf\"\n\n    result = runner.invoke(\n        entry_point,\n        [\"uncompress\", str(input_pdf_filepath), str(output_pdf_filepath)],\n    )\n\n    assert (\n        result.exit_code == 0\n    ), f\"Error in uncompressing {input_pdf_filepath}: {result.output}\"\n    assert (\n        output_pdf_filepath.exists()\n    ), f\"Output PDF {output_pdf_filepath} does not exist.\"\n\n    reader = PdfReader(str(output_pdf_filepath))\n    for page in reader.pages:\n        contents = page.get(\"/Contents\")\n        if contents:\n            assert (\n                \"/Filter\" not in contents\n            ), \"Content stream is still compressed\"\n"
  },
  {
    "path": "tests/test_up2.py",
    "content": "import os.path\nfrom pathlib import Path\n\nimport pytest\nfrom pypdf import PdfReader\n\nfrom .conftest import RESOURCES_ROOT, chdir, run_cli\n\n\ndef test_up2_fewer_args(capsys: pytest.CaptureFixture, tmp_path: Path) -> None:\n    with chdir(tmp_path):\n        exit_code = run_cli([\"2-up\", str(RESOURCES_ROOT / \"box.pdf\")])\n    assert exit_code == 2\n    captured = capsys.readouterr()\n    assert \"Missing argument\" in captured.err\n\n\ndef test_up2_extra_args(capsys: pytest.CaptureFixture, tmp_path: Path) -> None:\n    with chdir(tmp_path):\n        exit_code = run_cli(\n            [\n                \"2-up\",\n                str(RESOURCES_ROOT / \"box.pdf\"),\n                \"./out.pdf\",\n                \"./out2.pdf\",\n            ]\n        )\n\n    assert exit_code == 2\n    captured = capsys.readouterr()\n    assert \"unexpected extra argument\" in captured.err\n\n    with chdir(tmp_path):\n        assert not os.path.exists(\"out.pdf\"), \"'out.pdf' should not exist.\"\n        assert not os.path.exists(\"out2.pdf\"), \"'out2.pdf' should not exist.\"\n\n\ndef test_up2_8page_file(capsys: pytest.CaptureFixture, tmp_path: Path) -> None:\n    pdf_file = str(RESOURCES_ROOT / \"input8.pdf\")\n    out_file_name = \"out.pdf\"\n\n    in_reader = PdfReader(pdf_file)\n    assert len(in_reader.pages) == 8\n    in_height = in_reader.pages[0].mediabox.height\n    in_width = in_reader.pages[0].mediabox.width\n\n    # Act\n    with chdir(tmp_path):\n        exit_code = run_cli(\n            [\n                \"2-up\",\n                pdf_file,\n                out_file_name,\n            ]\n        )\n    captured = capsys.readouterr()\n\n    # Assert\n    assert exit_code == 0, captured\n    assert not captured.err\n\n    out_reader = PdfReader(tmp_path / out_file_name)\n    assert len(out_reader.pages) == 4\n\n    out_width = out_reader.pages[0].mediabox.width\n    out_height = out_reader.pages[0].mediabox.height\n\n    assert out_width == 2 * in_width  # PR #78\n    assert out_height == in_height\n\n\n# Fix issue #218\ndef test_up2_odd_page_number(\n    capsys: pytest.CaptureFixture, tmp_path: Path\n) -> None:\n    pdf_file = \"sample-files/026-latex-multicolumn/multicolumn.pdf\"\n    out_file_path = tmp_path / \"out.pdf\"\n\n    # Ensure original page number is odd:\n    in_reader = PdfReader(pdf_file)\n    assert len(in_reader.pages) % 2 == 1\n\n    # Act\n    exit_code = run_cli(\n        [\n            \"2-up\",\n            pdf_file,\n            str(out_file_path),\n        ]\n    )\n    captured = capsys.readouterr()\n\n    # Assert\n    assert exit_code == 0, captured\n    assert not captured.err\n\n    out_reader = PdfReader(out_file_path)\n    assert len(out_reader.pages) == (len(in_reader.pages) + 1) / 2\n"
  },
  {
    "path": "tests/test_update_offsets.py",
    "content": "\"\"\"\nEvery CLI command is called here with a typer CliRunner.\n\nHere should only be end-to-end tests.\n\"\"\"\n\nimport re\nimport sys\nfrom pathlib import Path\n\nimport pytest\n\nfrom .conftest import RESOURCES_ROOT, run_cli\n\n\n@pytest.mark.skipif(sys.platform == \"win32\", reason=\"Does not run on windows\")\ndef test_update_offsets(capsys: pytest.CaptureFixture) -> None:\n    # Arrange\n    input = RESOURCES_ROOT / \"file-with-invalid-offsets.pdf\"\n    file_expected = str(RESOURCES_ROOT / \"file-with-fixed-offsets.pdf\")\n\n    # Act\n    exit_code = run_cli(\n        [\n            \"update-offsets\",\n            str(input),\n        ]\n    )\n\n    # Assert\n    captured = capsys.readouterr()\n    assert exit_code == 0, captured\n    assert not captured.err\n    assert re.search(r\"Wrote\\s+\" + re.escape(str(input)), captured.out)\n    with open(file_expected, encoding=\"iso-8859-1\") as file_exp:\n        lines_exp = file_exp.readlines()\n    with input.open(encoding=\"iso-8859-1\") as file_act:\n        lines_act = file_act.readlines()\n    assert len(lines_exp) == len(\n        lines_act\n    ), f\"lines_exp=f{lines_exp}, lines_act=f{lines_act}\"\n    for line_no, (line_exp, line_act) in enumerate(\n        zip(lines_exp, lines_act), start=1\n    ):\n        assert line_exp == line_act, f\"Lines differ in line {line_no}\"\n\n\n# The current implementation doesn't support valid PDF lines as \"/Length 5470>> stream\".\n\n\n@pytest.mark.parametrize(\n    \"input_pdf_filepath\",\n    [\n        \"sample-files/002-trivial-libre-office-writer/002-trivial-libre-office-writer.pdf\",\n        \"sample-files/005-libreoffice-writer-password/libreoffice-writer-password.pdf\",\n        \"sample-files/007-imagemagick-images/imagemagick-ASCII85Decode.pdf\",\n        \"sample-files/007-imagemagick-images/imagemagick-CCITTFaxDecode.pdf\",\n        \"sample-files/007-imagemagick-images/imagemagick-images.pdf\",\n        \"sample-files/007-imagemagick-images/imagemagick-lzw.pdf\",\n        \"sample-files/008-reportlab-inline-image/inline-image.pdf\",\n        \"sample-files/009-pdflatex-geotopo/GeoTopo-komprimiert.pdf\",\n        # \"sample-files/011-google-doc-document/google-doc-document.pdf\", # stream token in line after /Length\n        \"sample-files/012-libreoffice-form/libreoffice-form.pdf\",\n        \"sample-files/013-reportlab-overlay/reportlab-overlay.pdf\",\n        \"sample-files/015-arabic/habibi-oneline-cmap.pdf\",\n        \"sample-files/015-arabic/habibi-rotated.pdf\",\n        \"sample-files/015-arabic/habibi.pdf\",\n        \"sample-files/016-libre-office-link/libre-office-link.pdf\",\n        # \"sample-files/017-unreadable-meta-data/unreadablemetadata.pdf\", # stream in line after object\n        \"sample-files/018-base64-image/base64image.pdf\",\n        # \"sample-files/019-grayscale-image/grayscale-image.pdf\", # stream in line after object\n        \"sample-files/020-xmp/output_with_metadata_pymupdf.pdf\",\n        # \"sample-files/021-pdfa/crazyones-pdfa.pdf\", # stream in line is after dictionary\n        \"sample-files/022-pdfkit/pdfkit.pdf\",\n        \"sample-files/023-cmyk-image/cmyk-image.pdf\",\n        \"sample-files/024-annotations/annotated_pdf.pdf\",\n        \"sample-files/025-attachment/with-attachment.pdf\",\n    ],\n)\ndef test_update_offsets_on_all_reference_files(\n    capsys: pytest.CaptureFixture, tmp_path: Path, input_pdf_filepath: Path\n) -> None:\n    # Arrange\n    output_pdf_filepath = tmp_path / \"out.pdf\"\n\n    # Act\n    exit_code = run_cli(\n        [\n            \"update-offsets\",\n            \"--encoding\",\n            \"iso-8859-1\",\n            str(input_pdf_filepath),\n            \"-o\",\n            str(output_pdf_filepath),\n        ]\n    )\n\n    # Assert\n    captured = capsys.readouterr()\n    assert exit_code == 0, captured\n    assert not captured.err\n    assert f\"Wrote {output_pdf_filepath}\" in captured.out\n    assert output_pdf_filepath.exists()\n"
  },
  {
    "path": "tests/test_x2pdf.py",
    "content": "\"\"\"\nEvery CLI command is called here with a typer CliRunner.\n\nHere should only be end-to-end tests.\n\"\"\"\n\nfrom pathlib import Path\n\nimport pytest\n\nfrom .conftest import run_cli\n\n\ndef test_x2pdf_succeed_to_convert_jpg(\n    capsys: pytest.CaptureFixture, tmp_path: Path\n) -> None:\n    # Arrange\n    output = tmp_path / \"out.pdf\"\n\n    # Act\n    exit_code = run_cli(\n        [\n            \"x2pdf\",\n            \"sample-files/003-pdflatex-image/page-0-Im1.jpg\",\n            \"--output\",\n            str(output),\n        ]\n    )\n\n    # Assert\n    captured = capsys.readouterr()\n    assert exit_code == 0, captured\n    assert captured.out == \"\"\n    assert output.exists()\n\n\ndef test_x2pdf_succeed_to_embed_pdfs(\n    capsys: pytest.CaptureFixture, tmp_path: Path\n) -> None:\n    # Arrange\n    output = tmp_path / \"out.pdf\"\n\n    # Act\n    exit_code = run_cli(\n        [\n            \"x2pdf\",\n            \"sample-files/001-trivial/minimal-document.pdf\",\n            \"sample-files/002-trivial-libre-office-writer/002-trivial-libre-office-writer.pdf\",\n            \"--output\",\n            str(output),\n        ]\n    )\n\n    # Assert\n    captured = capsys.readouterr()\n    assert exit_code == 0, captured\n    assert captured.out == \"\"\n    assert output.exists()\n\n\ndef test_x2pdf_fail_to_open_file(\n    capsys: pytest.CaptureFixture, tmp_path: Path\n) -> None:\n    # Arrange & Act\n    exit_code = run_cli(\n        [\n            \"x2pdf\",\n            \"NonExistingFile\",\n            \"--output\",\n            str(tmp_path / \"out.pdf\"),\n        ]\n    )\n\n    # Assert\n    captured = capsys.readouterr()\n    assert exit_code == 1, captured\n    assert \"No such file or directory\" in captured.out\n\n\ndef test_x2pdf_fail_to_convert(\n    capsys: pytest.CaptureFixture, tmp_path: Path\n) -> None:\n    # Arrange & Act\n    exit_code = run_cli(\n        [\n            \"x2pdf\",\n            \"README.md\",\n            \"--output\",\n            str(tmp_path / \"out.pdf\"),\n        ]\n    )\n\n    # Assert\n    captured = capsys.readouterr()\n    assert exit_code == 1, captured\n    assert \"Error: Could not convert 'README.md' to a PDF\" in captured.out\n"
  }
]