diff --git a/.docker/Dockerfile b/.docker/Dockerfile index 3262616c..6c35625d 100644 --- a/.docker/Dockerfile +++ b/.docker/Dockerfile @@ -55,7 +55,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \ COPY . /app RUN --mount=type=cache,target=/root/.cache/uv \ uv sync --frozen \ - --extra test --extra webservice --extra watcher --no-dev \ + --extra webservice --extra watcher --no-dev \ --no-install-package pyarrow FROM base @@ -67,6 +67,8 @@ RUN add-apt-repository -y ppa:alex-p/tesseract-ocr5 RUN apt-get update && apt-get install -y --no-install-recommends \ ghostscript \ fonts-droid-fallback \ + fonts-noto-core \ + fonts-noto-cjk \ jbig2dec \ pngquant \ tesseract-ocr \ diff --git a/.docker/Dockerfile.alpine b/.docker/Dockerfile.alpine index 297f0295..3d3681eb 100644 --- a/.docker/Dockerfile.alpine +++ b/.docker/Dockerfile.alpine @@ -39,7 +39,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \ COPY . /app RUN --mount=type=cache,target=/root/.cache/uv \ uv sync --frozen \ - --extra test --extra webservice --extra watcher --no-dev \ + --extra webservice --extra watcher --no-dev \ --no-install-package pyarrow FROM base @@ -57,6 +57,7 @@ RUN apk add --no-cache \ tesseract-ocr-data-osd \ tesseract-ocr-data-por \ tesseract-ocr-data-spa \ + font-noto \ ttf-droid \ unpaper \ && rm -rf /var/cache/apk/* diff --git a/.gitattributes b/.gitattributes index 9e64aba4..be2a4386 100644 --- a/.gitattributes +++ b/.gitattributes @@ -13,5 +13,6 @@ *.jpg binary *.bin binary *.afdesign binary +*.ttf binary .git_archival.txt export-subst diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 518cebad..58739755 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,11 +22,11 @@ jobs: strategy: matrix: os: [ubuntu-22.04, ubuntu-24.04] - python: ["3.10", "3.11", "3.12", "3.13", "3.14"] + python: ["3.11", "3.12", "3.13", "3.14"] include: - os: ubuntu-22.04 tesseract_ppa: "ppa" - python: "3.10" + python: "3.11" env: OS: ${{ matrix.os }} @@ -57,6 +57,8 @@ jobs: sudo apt-get update sudo apt-get install -y --no-install-recommends \ curl \ + fonts-noto-core \ + fonts-noto-cjk \ ghostscript \ jbig2dec \ img2pdf \ @@ -74,7 +76,7 @@ jobs: - name: Install Python packages run: | - uv sync --extra test --no-dev + uv sync --group test - name: Report versions run: | @@ -102,7 +104,7 @@ jobs: strategy: matrix: os: [macos-latest] - python: ["3.10", "3.11", "3.12", "3.13", "3.14"] + python: ["3.11", "3.12", "3.13", "3.14"] env: OS: ${{ matrix.os }} @@ -123,7 +125,9 @@ jobs: jbig2enc \ openjpeg \ pngquant \ - tesseract + poppler \ + tesseract \ + verapdf - name: Install uv uses: astral-sh/setup-uv@v7 @@ -137,7 +141,7 @@ jobs: - name: Install Python packages run: | - uv sync --extra test --no-dev + uv sync --group test - name: Report versions run: | @@ -164,7 +168,7 @@ jobs: strategy: matrix: os: [windows-latest] - python: ["3.10", "3.11", "3.12", "3.13", "3.14"] + python: ["3.11", "3.12", "3.13", "3.14"] env: OS: ${{ matrix.os }} @@ -187,12 +191,13 @@ jobs: - name: Install system packages run: | - choco install --yes --no-progress --pre tesseract + choco install --yes --no-progress tesseract choco install --yes --no-progress --ignore-checksums ghostscript --version 9.56.1 + choco install --yes --no-progress poppler --version=25.11.0 - name: Install Python packages run: | - uv sync --extra test --no-dev + uv sync --group test - name: Test run: | diff --git a/.gitignore b/.gitignore index c4b31d00..99fe5ab7 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,4 @@ src/ocrmypdf/_version.py .idea/ .aider* +CLAUDE.md diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6145a772..5150ebe3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,17 +10,12 @@ repos: - id: check-toml - id: check-yaml - id: debug-statements - - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: "v0.0.261" + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: "v0.14.11" hooks: - - id: ruff - files: "src/.*\\.pyi?$" - args: [--fix, --exit-non-zero-on-fix] - - repo: https://github.com/psf/black - rev: 23.3.0 - hooks: - - id: black - language_version: python + - id: ruff-check + args: [--fix] + - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.2.0 hooks: diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 43b85b58..67e8d311 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -15,7 +15,7 @@ sphinx: build: os: ubuntu-22.04 tools: - python: "3.10" + python: "3.11" python: install: diff --git a/README.md b/README.md index 4dc30623..e617c965 100644 --- a/README.md +++ b/README.md @@ -126,8 +126,8 @@ Please report issues on our [GitHub issues](https://github.com/ocrmypdf/OCRmyPDF ## Feature demo ```bash -# Add an OCR layer and convert to PDF/A -ocrmypdf input.pdf output.pdf +# Add an OCR layer and require PDF/A +ocrmypdf --output-type pdfa input.pdf output.pdf # Convert an image to single page PDF ocrmypdf input.jpg output.pdf @@ -151,6 +151,16 @@ For more features, see the [documentation](https://ocrmypdf.readthedocs.io/en/la In addition to the required Python version, OCRmyPDF requires external program installations of Ghostscript and Tesseract OCR. OCRmyPDF is pure Python, and runs on pretty much everything: Linux, macOS, Windows and FreeBSD. +## Plugins + +OCRmyPDF provides a plugin interface allowing its capabilities to be extended or replaced. Here are some plugins we are aware of: + +- [OCRmyPDF-AppleOCR](https://github.com/mkyt/ocrmypdf-AppleOCR): replaces the standard Tesseract OCR engine with Apple Vision Framework. Requires macOS. +- [OCRmyPDF-EasyOCR](https://github.com/ocrmypdf/OCRmyPDF-EasyOCR): replaces the standard Tesseract OCR engine with EasyOCR, a newer OCR engine based on PyTorch. GPU strongly recommended. +- [OCRmyPDF-PaddleOCR](https://github.com/clefru/ocrmypdf-paddleocr): replaces the standard Tesseract OCR engine with PaddleOCR, a powerful GPU accelerated OCR engine. + +[paperless-ngx](https://docs.paperless-ngx.com/) provides integration of OCRmyPDF into a searchable document management system. + ## Press & Media - [Going paperless with OCRmyPDF](https://medium.com/@ikirichenko/going-paperless-with-ocrmypdf-e2f36143f46a) diff --git a/REUSE.toml b/REUSE.toml index 5f21ab3f..dbee971d 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -167,15 +167,9 @@ SPDX-FileCopyrightText = [ SPDX-License-Identifier = "Zlib" [[annotations]] -path = "src/ocrmypdf/data/pdf.ttf" +path = "src/ocrmypdf/data/Occulta.ttf" precedence = "aggregate" -SPDX-FileCopyrightText = [ - "(C) 2014 Ray Smith", - "(C) 2015 Ken Sharp", - "(C) 2016 James R. Barlow", - "(C) 2016 Jeff Breidenbach", - "(C) 2017 Zdenko Podobný", -] +SPDX-FileCopyrightText = ["(C) 2026 James R. Barlow"] SPDX-License-Identifier = "Apache-2.0" [[annotations]] diff --git a/docs/advanced.md b/docs/advanced.md index 65aacfc2..f8061f2d 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -58,6 +58,38 @@ disk space. OCRmyPDF provides many features to control the behavior of the OCR engine, Tesseract. +### OCR processing mode + +:::{versionadded} 17.0.0 +The `--mode` (`-m`) argument consolidates OCR processing options. +::: + +OCRmyPDF provides a unified `--mode` argument to control how pages with +existing text are handled: + +| Mode | Behavior | Legacy equivalent | +|------|----------|-------------------| +| `default` | Error if text is found | (no flag) | +| `force` | Rasterize all content and run OCR | `--force-ocr` | +| `skip` | Skip pages with existing text | `--skip-text` | +| `redo` | Re-OCR pages, stripping old OCR layer | `--redo-ocr` | + +```bash +# Skip pages that already have text +ocrmypdf --mode skip input.pdf output.pdf +# or equivalently: +ocrmypdf -m skip input.pdf output.pdf + +# Force OCR on all pages (rasterizes everything) +ocrmypdf --mode force input.pdf output.pdf + +# Re-do OCR, replacing old invisible text +ocrmypdf --mode redo input.pdf output.pdf +``` + +The legacy flags (`--force-ocr`, `--skip-text`, `--redo-ocr`) remain as +silent aliases for backward compatibility. + ### When OCR is skipped If a page in a PDF seems to have text, by default OCRmyPDF will exit @@ -65,13 +97,13 @@ without modifying the PDF. This is to ensure that PDFs that were previously OCRed or were "born digital" rather than scanned are not processed. -If `--skip-text` is issued, then no image processing or OCR will be +If `--mode skip` (or `--skip-text`) is issued, then no image processing or OCR will be performed on pages that already have text. The page will be copied to the output. This may be useful for documents that contain both "born digital" and scanned content, or to use OCRmyPDF to normalize and convert to PDF/A regardless of their contents. -If `--redo-ocr` is issued, then a detailed text analysis is performed. +If `--mode redo` (or `--redo-ocr`) is issued, then a detailed text analysis is performed. Text is categorized as either visible or invisible. Invisible text (OCR) is stripped out. Then an image of each page is created with visible text masked out. The page image is sent for OCR, and any additional text is @@ -82,7 +114,7 @@ technically printable or visible in some way, perhaps by drawing it and then painting over it. OCRmyPDF cannot distinguish this type of OCR text from real text, so it will not be "redone". -If `--force-ocr` is issued, then all pages will be rasterized to +If `--mode force` (or `--force-ocr`) is issued, then all pages will be rasterized to images, discarding any hidden OCR text, rasterizing any printable text, and flattening form fields or interactive objects into their visual representation. This is useful for redoing OCR, for fixing OCR text @@ -257,44 +289,85 @@ Their use may interfere with `--rotate-pages` and other features. It is currently not possible to use advanced Tesseract OCR features, such as creating OCR information, when using Tesseract through OCRmyPDF. -## Changing the PDF renderer +## Choosing a PDF rasterizer + +:::{versionadded} 17.0.0 +::: rasterizing -: Converting a PDF to an image for display. +: Converting a PDF page to an image for OCR processing. + +OCRmyPDF supports two PDF rasterizers: + +| Rasterizer | Package | Advantages | Disadvantages | +|------------|---------|------------|---------------| +| pypdfium2 | Python package | Faster, fewer version issues | Requires pypdfium2 package | +| Ghostscript | System binary | More widely packaged | Version consistency issues, restrictive AGPLv3 | + +The `--rasterizer` argument controls which rasterizer is used: + +```bash +# Automatic selection (default) - prefers pypdfium when available +ocrmypdf --rasterizer auto input.pdf output.pdf + +# Force pypdfium2 +ocrmypdf --rasterizer pypdfium input.pdf output.pdf + +# Force Ghostscript +ocrmypdf --rasterizer ghostscript input.pdf output.pdf +``` + +pypdfium2 is a Python binding for pdfium, the PDF rendering library used +by Google Chrome and Chromium. It generally produces output identical to +Ghostscript but with better performance. + +:::{note} +If pypdfium2 is not installed and `--rasterizer pypdfium` is requested, +OCRmyPDF will exit with an error. Install it with: `pip install pypdfium2` +::: + +## Changing the PDF renderer rendering : Creating a new PDF from other data (such as an existing PDF). -OCRmyPDF has these PDF renderers: `sandwich` and `hocr`. The +:::{versionchanged} 17.0.0 +The fpdf2 renderer is now the default, replacing the legacy hOCR renderer. +::: + +OCRmyPDF uses PDF renderers to create the invisible text layer. The renderer may be selected using `--pdf-renderer`. The default is -`auto` which lets OCRmyPDF select the renderer to use. Currently, -`auto` always selects `hocr`. +`auto` which selects `fpdf2`. -### The `hocr` renderer +### The `fpdf2` renderer (default) -:::{versionchanged} 16.0.0 +:::{versionadded} 17.0.0 +::: + +The fpdf2 renderer creates text layers using the fpdf2 library. It provides: + +- Full multilingual support including RTL languages (Arabic, Hebrew, Persian) +- Accurate text positioning aligned with OCR bounding boxes +- Improved "Occulta" glyphless font handling: + - Zero-width markers are properly handled + - Double-width CJK characters are properly sized +- Direct OcrElement tree input (no hOCR intermediate format required) + +The fpdf2 renderer is the recommended choice for all installations. + +:::{note} +The fpdf2 renderer may be slightly slower than the legacy hocrtransform +renderer for some workloads. This is an area of ongoing optimization. ::: In both renderers, a text-only layer is rendered and sandwiched (overlaid) on to either the original PDF page, or newly rasterized version of the -original PDF page (when `--force-ocr` is used). In this way, loss +original PDF page (when `--mode force` is used). In this way, loss of PDF information is generally avoided. (You may need to disable PDF/A conversion and optimization to eliminate all lossy transformations.) -The current approach used by the new hOCR renderer is a re-implementation -of Tesseract's PDF renderer, using the same Glyphless font and general -ideas, but fixing many technical issues that impeded it. The new hocr -provides better text placement accuracy, avoids issues with word -segmentation, and provides better positioning of skewed text. - -Using the experimental API, it is also possible to edit the OCR output -from Tesseract, using any tool that is capable of editing hOCR files. - -Older versions of this renderer did not support non-Latin languages, but -it is now universal. - ### The `sandwich` renderer The `sandwich` renderer uses Tesseract's text-only PDF feature, @@ -310,6 +383,11 @@ When image preprocessing features like `--deskew` are used, the original PDF will be rendered as a full page and the OCR layer will be placed on top. +### Legacy renderer options + +The `hocr` and `hocrdebug` renderer options are deprecated and +automatically redirect to `fpdf2`. They will be removed in a future version. + ## Rendering and rasterizing options :::{versionadded} 14.3.0 @@ -341,6 +419,81 @@ curves. In this case, you may want to use a different color conversion strategy. The `--color-conversion-strategy` option allows you to select a different strategy, such as `RGB`. +## PDF/A output modes + +:::{versionchanged} 17.0.0 +The default `--output-type` is now `auto` instead of `pdfa`. +::: + +OCRmyPDF can produce PDF/A compliant output for long-term archival. The +`--output-type` argument controls PDF/A conversion: + +| Output type | Behavior | +|-------------|----------| +| `auto` | Best-effort PDF/A without requiring Ghostscript (default) | +| `pdfa` | PDF/A-2b via Ghostscript | +| `pdfa-1` | PDF/A-1b via Ghostscript | +| `pdfa-2` | PDF/A-2b via Ghostscript (same as `pdfa`) | +| `pdfa-3` | PDF/A-3b via Ghostscript | +| `pdf` | Standard PDF, no PDF/A conversion | +| `none` | No output file (useful with `--sidecar`) | + +### Speculative PDF/A conversion + +:::{versionadded} 17.0.0 +::: + +When `--output-type auto` is used (the default), OCRmyPDF attempts a +fast "speculative" PDF/A conversion that avoids Ghostscript when possible: + +1. OCRmyPDF adds an sRGB ICC profile and PDF/A XMP metadata using pikepdf +2. If verapdf is available, it validates the result +3. If validation passes, Ghostscript is skipped entirely +4. If validation fails or verapdf is unavailable, falls back to Ghostscript + +This approach is faster and avoids some Ghostscript limitations (such as +image transcoding), but only works for PDFs that are already "mostly" +PDF/A compliant. + +### PDF/A conversion flow + +The following diagram illustrates the PDF/A conversion decision tree: + +```{mermaid} +flowchart TD + A[Start] --> B{--output-type?} + B -->|pdf| C[Output standard PDF] + B -->|pdfa/pdfa-N| D[Use Ghostscript] + B -->|auto| E[Attempt speculative conversion] + + E --> F["Add sRGB ICC + XMP metadata (pikepdf)"] + F --> G{verapdf available?} + + G -->|No| H{Ghostscript available?} + G -->|Yes| I[Validate with verapdf] + + I --> J{Validation passed?} + J -->|Yes| K[Output PDF/A - Ghostscript skipped] + J -->|No| H + + H -->|Yes| D + H -->|No| L[Output standard PDF + WARNING] + + D --> M[Ghostscript PDF/A conversion] + M --> N[Output PDF/A] + + style K fill:#90EE90 + style N fill:#90EE90 + style L fill:#FFB6C1 +``` + +:::{warning} +**Breaking change:** If neither Ghostscript nor verapdf is installed, +`--output-type auto` will produce a standard PDF instead of PDF/A. +This is a change from previous versions where Ghostscript was required +and PDF/A was always produced. +::: + ## Return code policy OCRmyPDF writes all messages to `stderr`. `stdout` is reserved for diff --git a/docs/api.md b/docs/api.md index b38567c8..93e30313 100644 --- a/docs/api.md +++ b/docs/api.md @@ -13,8 +13,49 @@ subprocess call anyway, as this provides isolation of its activities. ## Example OCRmyPDF provides one high-level function to run its main engine from an -application. The parameters are symmetric to the command line arguments -and largely have the same functions. +application. + +```{versionchanged} 17.0 +The {func}`ocrmypdf.ocr` function now accepts an {class}`~ocrmypdf.OcrOptions` +object as its first argument, providing a cleaner API with full type hints +and validation. The previous positional argument style remains supported. +``` + +### Modern API (recommended) + +The recommended way to call {func}`ocrmypdf.ocr` is to construct an +{class}`~ocrmypdf.OcrOptions` object with all settings, then pass it +as the sole argument: + +```python +import ocrmypdf +from ocrmypdf import OcrOptions + +if __name__ == '__main__': # To ensure correct behavior on Windows and macOS + options = OcrOptions( + input_file='input.pdf', + output_file='output.pdf', + deskew=True, + languages=['eng'], + ) + ocrmypdf.ocr(options) +``` + +{class}`~ocrmypdf.OcrOptions` is a Pydantic model that provides: + +- Full type hints and IDE autocompletion +- Validation of option values at construction time +- Clear documentation of all available options + +```{versionadded} 17.0 +The {class}`~ocrmypdf.OcrOptions` class is now exported from the top-level +`ocrmypdf` module. +``` + +### Legacy API + +For compatibility with OCRmyPDF < v17, the traditional calling style +with positional arguments is still fully supported: ```python import ocrmypdf @@ -23,7 +64,7 @@ if __name__ == '__main__': # To ensure correct behavior on Windows and macOS ocrmypdf.ocr('input.pdf', 'output.pdf', deskew=True) ``` -With some exceptions, all of the command line arguments are available +With this style, all of the command line arguments are available and may be passed as equivalent keywords. A few differences are that `verbose` and `quiet` are not available. @@ -51,9 +92,12 @@ OCRmyPDF fails for any reason. For example: ```python from multiprocessing import Process +import ocrmypdf +from ocrmypdf import OcrOptions def ocrmypdf_process(): - ocrmypdf.ocr('input.pdf', 'output.pdf') + options = OcrOptions(input_file='input.pdf', output_file='output.pdf') + ocrmypdf.ocr(options) def call_ocrmypdf_from_my_app(): p = Process(target=ocrmypdf_process) @@ -117,3 +161,17 @@ handler. OCRmyPDF will clean up its temporary files and worker processes automatically when an exception occurs. When OCRmyPDF succeeds conditionally, it returns an integer exit code. + +### Plugin Development Changes + +```{versionchanged} 16.13 +Plugin hooks now receive {class}`~ocrmypdf.OcrOptions` objects instead of +`argparse.Namespace`. +``` + +- {class}`~ocrmypdf.OcrOptions` provides the same attribute access as `Namespace` (duck-typing compatible) +- Plugin developers should update type hints: `from ocrmypdf import OcrOptions` +- Built-in plugins no longer modify options in-place for better immutability + +Most existing plugins will continue working without modification due to the +duck-typing compatibility between {class}`~ocrmypdf.OcrOptions` and `Namespace`. diff --git a/docs/apiref.md b/docs/apiref.md index ae4d0299..239b7d75 100644 --- a/docs/apiref.md +++ b/docs/apiref.md @@ -13,6 +13,13 @@ should be mainly of interest to plugin developers. :members: ``` +## ocrmypdf._options + +```{eval-rst} +.. automodule:: ocrmypdf._options + :members: OcrOptions +``` + ## ocrmypdf.exceptions ```{eval-rst} diff --git a/docs/batch.md b/docs/batch.md index a113a0dc..53bc6f15 100644 --- a/docs/batch.md +++ b/docs/batch.md @@ -117,6 +117,10 @@ tend to give better performance. watcher.py works on all platforms. Users may need to customize the script to meet their requirements. :::{code} bash +# Using uv (recommended) +uv sync --extra watcher + +# Or using pip pip3 install ocrmypdf[watcher] env OCR_INPUT_DIRECTORY=/mnt/input-pdfs \ diff --git a/docs/conf.py b/docs/conf.py index be811169..a4a8f636 100755 --- a/docs/conf.py +++ b/docs/conf.py @@ -25,10 +25,11 @@ # sys.path.insert(0, os.path.abspath('.')) # -- General configuration ------------------------------------------------ +from __future__ import annotations needs_sphinx = '8' -import datetime +import datetime as dt # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom @@ -41,6 +42,7 @@ extensions = [ 'sphinx.ext.napoleon', 'sphinx.ext.imgconverter', # PDF docs needs this for SVG to PNG conversion 'sphinx_issues', + 'sphinxcontrib.mermaid', ] myst_enable_extensions = ['colon_fence', 'attrs_block', 'attrs_inline', 'substitution'] @@ -62,7 +64,7 @@ master_doc = 'index' # General information about the project. project = 'ocrmypdf' -year = str(datetime.date.today().year) +year = str(dt.date.today().year) copyright = ( f'{year}, James R. Barlow. ' + 'Licensed under Creative Commons Attribution-ShareAlike 4.0' diff --git a/docs/cookbook.md b/docs/cookbook.md index 4fc8a514..434df25a 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -215,15 +215,22 @@ ocrmypdf --deskew --clean --rotate-pages input.pdf output.pdf Don\'t actually OCR my PDF -------------------------- -If you set `--tesseract-timeout 0` OCRmyPDF will apply its image -processing without performing OCR (by causing OCR to time out). This -works if all you want to is to apply image processing or PDF/A +If you set `--ocr-engine none` OCRmyPDF will apply its image processing without +performing OCR. This works if all you want to is to apply image processing or PDF/A conversion. ```bash -ocrmypdf --tesseract-timeout 0 --remove-background input.pdf output.pdf +ocrmypdf --ocr-engine none --deskew --output-type pdfa input.pdf output.pdf ``` +:::{versionchanged} v17.0.0 + +Prior to this version, `--tesseract-timeout 0` was recommended as an idiom +to turn off OCR. This is not longer recommended, as we move away from +Tesseract OCR as the primary OCR engine. + +::: + :::{versionchanged} v14.1.0 Prior to this version, `--tesseract-timeout 0` would prevent other uses @@ -238,7 +245,7 @@ This is getting ridiculous, but OCRmyPDF can complete strip all textual information from a PDF and reconstruct it as a \"bag of images\" PDF. ```bash -ocrmypdf --tesseract-timeout 0 --force-ocr input.pdf output.pdf +ocrmypdf --ocr-engine none --force-ocr input.pdf output.pdf ``` Why would you want to do this? Perhaps you have a PDF where OCR fails to @@ -250,9 +257,69 @@ This command also removes OCR generated by third party tools. You can also optimize all images without performing any OCR: ```bash -ocrmypdf --tesseract-timeout 0 --optimize 3 --skip-text input.pdf output.pdf +ocrmypdf --ocr-engine none --optimize 3 --skip-text input.pdf output.pdf ``` +## Using v17 features + +### Select a rasterizer + +:::{versionadded} 17.0.0 +::: + +OCRmyPDF can use pypdfium2 or Ghostscript to rasterize PDF pages. pypdfium2 +is generally faster and is preferred when available. + +```bash +# Automatic selection (default) - prefers pypdfium when available +ocrmypdf --rasterizer auto input.pdf output.pdf + +# Explicitly use pypdfium2 (requires pip install pypdfium2) +ocrmypdf --rasterizer pypdfium input.pdf output.pdf + +# Explicitly use Ghostscript +ocrmypdf --rasterizer ghostscript input.pdf output.pdf +``` + +### PDF/A without Ghostscript + +:::{versionadded} 17.0.0 +::: + +With verapdf installed, OCRmyPDF can produce PDF/A without using Ghostscript +for conversion. This is faster and avoids some Ghostscript limitations. + +```bash +# Uses speculative conversion with verapdf validation (default) +ocrmypdf --output-type auto input.pdf output.pdf + +# Explicitly request Ghostscript-based PDF/A conversion +ocrmypdf --output-type pdfa input.pdf output.pdf +``` + +### Using --mode instead of legacy flags + +:::{versionadded} 17.0.0 +::: + +The `--mode` (`-m`) flag consolidates OCR behavior options: + +```bash +# Instead of --skip-text +ocrmypdf --mode skip input.pdf output.pdf + +# Instead of --force-ocr +ocrmypdf --mode force input.pdf output.pdf + +# Instead of --redo-ocr +ocrmypdf --mode redo input.pdf output.pdf + +# Short form +ocrmypdf -m skip input.pdf output.pdf +``` + +The legacy flags continue to work as aliases. + ### Process only certain pages You can ask OCRmyPDF to only apply [image processing](#image-processing) diff --git a/docs/installation.md b/docs/installation.md index 16ed5167..0185d346 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -487,7 +487,7 @@ You can then run OCRmyPDF in the Windows command prompt or Powershell, prefixing First install the the following prerequisite Cygwin packages using `setup-x86_64.exe`: ``` -python310 (or later) +python311 (or later) python3?-devel python3?-pip python3?-lxml @@ -599,21 +599,45 @@ OCRmyPDF currently requires these external programs and libraries to be installed, and must be satisfied using the operating system package manager. `pip` cannot provide them. +:::{versionchanged} 17.0.0 +Ghostscript is now optional. pypdfium2 can be used for PDF rasterization, +and verapdf can validate speculative PDF/A conversion. +::: + The following versions are required: -- Python 3.10 or newer -- Ghostscript 9.54 or newer +- Python 3.11 or newer - Tesseract 4.1.1 or newer -- jbig2enc 0.29 or newer -- pngquant 2.5 or newer -- unpaper 6.1 +- One of: Ghostscript 9.54+ **or** pypdfium2 (Python package) +- One of: Ghostscript 9.54+ **or** verapdf (for PDF/A output) +- fpdf2 2.8 or newer (Python package) +- jbig2enc 0.29 or newer (optional) +- pngquant 2.5 or newer (optional) +- unpaper 6.1 (optional) + +:::{note} +For the best user experience, install both Ghostscript and pypdfium2. +pypdfium2 is faster for rasterization, while Ghostscript provides +broader compatibility and is required for certain PDF/A conversions. +::: We recommend 64-bit versions of all software. (32-bit versions are not supported, although on Linux, they may still work.) -jbig2enc, pngquant, and unpaper are optional. If missing certain -features are disabled. OCRmyPDF will discover them as soon as they are -available. +**fpdf2** is a required dependency that provides the text layer +rendering engine. It replaces the legacy hOCR-based renderer with improved +multilingual support. Install with: `pip install fpdf2` + +**pypdfium2**, if present, provides fast PDF page rasterization using +the pdfium library (the same library used by Google Chrome). It is +preferred over Ghostscript when available due to better performance. +Install with: `pip install pypdfium2` + +**verapdf**, if present, enables fast speculative PDF/A conversion. +OCRmyPDF attempts to create PDF/A by adding metadata and ICC profiles +using pikepdf, then validates with verapdf. If validation passes, +Ghostscript is skipped entirely. See your distribution's package manager +or visit [verapdf.org](https://verapdf.org/). **jbig2enc**, if present, will be used to optimize the encoding of monochrome images. This can significantly reduce the file size of the @@ -623,6 +647,12 @@ available for Ubuntu or Debian due to lingering concerns about patent issues, but can easily be built from source. To add JBIG2 encoding, see {ref}`jbig2`. +:::{warning} +Lossy JBIG2 encoding (`--jbig2-lossy`) has been removed in v17.0.0 due to +well-documented risks of character substitution errors. Only lossless +JBIG2 compression is now supported. +::: + **pngquant**, if present, is optionally used to optimize the encoding of PNG-style images in PDFs (actually, any that are that losslessly encoded) by lossily quantizing to a smaller color palette. It is only @@ -638,7 +668,7 @@ unfortunately, the `pip install` command cannot satisfy all of them. ## Installing HEAD revision from sources -If you have `git` and Python 3.10 or newer installed, you can install +If you have `git` and Python 3.11 or newer installed, you can install from source. When the `pip` installer runs, it will alert you if dependencies are missing. @@ -686,18 +716,71 @@ need to be installed. The script requires specific versions of the dependencies. Older version than the ones mentioned in the release notes are likely not to be compatible to OCRmyPDF. +## Optional Features + +OCRmyPDF provides optional features and development tools. We recommend using `uv` as your package manager. + +### Installing User Features + +User features are available as optional dependencies. Install them with `uv` (recommended) or `pip`: + +```bash +# Using uv (recommended) +uv sync --extra watcher # File watching service +uv sync --extra webservice # Streamlit web UI +uv sync --extra watcher --extra webservice # Multiple features + +# Using pip (also works) +pip install ocrmypdf[watcher] +pip install ocrmypdf[webservice] +pip install ocrmypdf[watcher,webservice] +``` + +### Development Tools (uv only) + +Development tools use dependency groups and require `uv`: + +```bash +# Testing infrastructure +uv sync --group test + +# Documentation building +uv sync --group docs + +# Enhanced Streamlit development +uv sync --group streamlit-dev + +# All development groups +uv sync +``` + +:::{note} +**User features** (`watcher`, `webservice`) work with both `uv` and `pip`. +**Developer tools** (`test`, `docs`, `streamlit-dev`) require `uv` and use dependency groups (PEP 735). +::: + +**Why use uv?** + +- Modern, fast Python package manager +- Required for development (testing, docs) +- Better dependency resolution +- Consistent across all platforms + +Install uv: `pip install uv` or visit https://docs.astral.sh/uv/ + ### For development To install all of the development and test requirements: ```bash git clone -b main https://github.com/ocrmypdf/OCRmyPDF.git -python -m venv .venv -source .venv/bin/activate cd OCRmyPDF -pip install -e .[test] +pip install uv # Install uv if not already installed +uv sync --group test ``` +Note: Development requires `uv`. The old `pip install -e .[test]` method is no longer supported. + To add JBIG2 encoding, see {ref}`jbig2`. ## Shell completions diff --git a/docs/introduction.md b/docs/introduction.md index acc5c9d1..70811218 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -77,10 +77,17 @@ straightforward, and any PDF viewer can handle PDF/A files. OCRmyPDF analyzes each page of a PDF to determine the required colorspace and resolution (DPI) for capturing all the information on that page without -losing content. It uses -[Ghostscript](http://ghostscript.com/) to rasterize each page and subsequently -performs OCR on the rasterized image to generate an OCR "layer." This layer -is then integrated back into the original PDF. +losing content. It uses a PDF rasterizer (pypdfium2 or +[Ghostscript](http://ghostscript.com/)) to convert each page to an image and +subsequently performs OCR on the rasterized image to generate an OCR "layer." +This layer is then integrated back into the original PDF. + +:::{versionchanged} 17.0.0 +OCRmyPDF now supports pypdfium2 as an alternative rasterizer to Ghostscript. +pypdfium2 is a Python binding for pdfium, the PDF rendering library used by +Google Chrome. The `--rasterizer auto` setting (default) prefers pypdfium2 +when available. +::: While it is possible to use a program like Ghostscript or ImageMagick to obtain an image and then run that image through Tesseract OCR, this process @@ -156,7 +163,16 @@ These limitations are inherent to any software relying on Tesseract: the text and its bounding box. As such, the generated PDF does not contain any information about the document's structure. -Ghostscript also imposes some limitations: +### Ghostscript considerations + +:::{versionchanged} 17.0.0 +Ghostscript is no longer strictly required. OCRmyPDF can use pypdfium2 +for rasterization and verapdf for PDF/A validation. +::: + +While Ghostscript remains a capable and feature-rich tool with a long history, +recent releases have introduced some compatibility challenges that OCRmyPDF +v17 addresses through alternative codepaths. When Ghostscript is used: - PDFs containing JPEG 2000-encoded content may be converted to JPEG encoding, which may introduce compression artifacts, if Ghostscript @@ -173,6 +189,10 @@ Ghostscript also imposes some limitations: - Ghostscript's PDF/A conversion may remove or deactivate hyperlinks and other active content. +When pypdfium2 and verapdf are available, many of these limitations can be +avoided by using the speculative PDF/A conversion path (enabled by default +with `--output-type auto`). + You can use `--output-type pdf` to disable PDF/A conversion and produce a standard, non-archival PDF. diff --git a/docs/jbig2.md b/docs/jbig2.md index 9271339c..5b1d8e09 100644 --- a/docs/jbig2.md +++ b/docs/jbig2.md @@ -43,33 +43,21 @@ be required depending on your system. [sudo] apt install autotools-dev automake libtool libleptonica-dev pkg-config ::: -{#jbig2-lossy} +## JBIG2 Compression -## Lossy mode JBIG2 +OCRmyPDF uses JBIG2 lossless compression for bitonal (black and white) +images. This provides excellent compression ratios compared to the older +CCITT G4 standard, while preserving the exact pixel content of the +original image. -OCRmyPDF provides lossy mode JBIG2 as an advanced and potentially -dangerous feature. Users should [review the technical concerns with -JBIG2 in lossy mode](https://en.wikipedia.org/wiki/JBIG2#Disadvantages) -and decide if this feature is acceptable for their use case. In general, -this mode should not be used for archival purposes, should not be used -when the original document is not available or will be destroyed, and -should not be used when numbers present in the document are important, -because there is a risk of 6/8 and 8/6 substitution errors. +You can adjust the threshold for JBIG2 compression with +`--jbig2-threshold`. The default is 0.85. -JBIG2 lossy mode does achieve higher compression ratios than any other -monochrome (bitonal) compression technology; for large text documents -the savings are considerable. JBIG2 lossless still gives great -compression ratios and is a major improvement over the older CCITT G4 -standard. - -To turn on JBIG2 lossy mode, add the argument `--jbig2-lossy`. -`--optimize {1,2,3}` are necessary for the argument to take effect also -required. Also, a JBIG2 encoder must be installed as described in the -previous section. - -You can adjust the threshold for JBIG2 compression with the -`--jbig2-threshold`. The default is 0.85, meaning that if two symbols -are 85% similar, they will be compressed together. - -*Due to an oversight, ocrmypdf v7.0 and v7.1 used lossy mode by -default.* +:::{note} +Previous versions of OCRmyPDF supported a lossy JBIG2 mode +(`--jbig2-lossy`). This feature has been removed due to the well-known +risk of character substitution errors (e.g., 6/8 confusion). See +[JBIG2 disadvantages](https://en.wikipedia.org/wiki/JBIG2#Disadvantages) +for more information on why lossy JBIG2 is problematic. The `--jbig2-lossy` +and `--jbig2-page-group-size` arguments are now ignored with a warning. +::: diff --git a/docs/maintainers.md b/docs/maintainers.md index 0a2b3fd4..f8a61000 100644 --- a/docs/maintainers.md +++ b/docs/maintainers.md @@ -20,12 +20,48 @@ with much stiffer build requirements. If you want to use OCRmyPDF on some novel platform or distribution, first make sure you can package pikepdf. -### Non-Python dependencies +### Core dependencies -Note that we have non-Python dependencies. In particular, OCRmyPDF -requires Ghostscript and Tesseract OCR to be installed and needs to be -able to locate their binaries on the system PATH. On Windows, OCRmyPDF -will also check the registry for their locations. +:::{versionchanged} 17.0.0 +Ghostscript is no longer strictly required. OCRmyPDF now supports alternative +codepaths for both PDF rasterization and PDF/A conversion. +::: + +OCRmyPDF has the following runtime dependencies: + +**For PDF rasterization** (converting PDF pages to images for OCR): + +- `pypdfium2` (Python package) - OR - +- `ghostscript` (system binary) +- Recommendation: Install both for best compatibility + +**For PDF/A conversion**: + +- `verapdf` (system binary) with pikepdf's speculative conversion - OR - +- `ghostscript` (system binary) +- Recommendation: Install both for best compatibility + +**For OCR**: +- `tesseract-ocr` (system binary) - Required for MVP + +**For text rendering** (expressing OCR results in PDF): +- `fpdf2` (Python package) - Required for text layer rendering +- `uharfbuzz` (Python package) - Required for text layer rendering +- `font-noto` (system package) - Recommended for text layer rendering + +**Other dependencies**: +- `unpaper` (system binary) - Optional, enables `--clean` and `--clean-final` +- `pngquant` (system binary) - Optional, enables `--optimize 2` and `--optimize 3` +- `jbig2enc` (system binary) - Optional, improves compression of monochrome images + +While Ghostscript remains a capable and feature-rich tool with a long history, +recent releases have introduced some compatibility challenges that OCRmyPDF v17 +addresses through alternative codepaths. For the best user experience, packagers +should install both Ghostscript and the alternative tools (pypdfium2, verapdf) +when available. + +On Windows, OCRmyPDF will also check the registry for Tesseract and Ghostscript +locations. Tesseract OCR relies on SIMD for performance and only has proper support for this on ARM and x86\_64. Performance may be poor on other processor @@ -48,7 +84,79 @@ override versioning for some reason. OCRmyPDF will use jbig2enc, a JBIG2 encoder, if one can be found. Some distributions have shied away from packaging JBIG2 because it contains patented algorithms, but all patents have expired since 2017. If -possible, consider packaging it too to improve OCRmyPDF\'s compression. +possible, consider packaging it too to improve OCRmyPDF's compression. + +:::{note} +Lossy JBIG2 encoding has been removed in v17.0.0 due to well-documented +risks of character substitution errors. Previously we provided this feature +on a "caveat emptor" basis but in the interest of focusing and eliminating +risks, we decided to remove this option. Now, only lossless JBIG2 compression +is supported. +::: + +### Dependency matrix for packagers + +:::{versionadded} 17.0.0 +::: + +The following table summarizes the dependency options introduced in v17.0.0: + +| Feature | Option 1 | Option 2 | Notes | +|---------|----------|----------|-------| +| PDF rasterization | pypdfium2 (Python) | ghostscript (binary) | pypdfium2 preferred when available | +| PDF/A conversion | verapdf + pikepdf | ghostscript | verapdf validates speculative conversion | +| Text rendering | fpdf2 (Python) | - | Required, replaces legacy hOCR renderer | +| OCR | tesseract-ocr | `--ocr-engine none` | Can be skipped entirely | + +**Minimum viable installation:** + +- tesseract-ocr + (pypdfium2 OR ghostscript) + fpdf2 + +**Recommended installation:** + +- tesseract-ocr + pypdfium2 + ghostscript + verapdf + fpdf2 + unpaper + pngquant + jbig2enc + +:::{warning} +If Ghostscript is not installed and verapdf is not available, PDF/A output +cannot be produced. The output will be a standard PDF instead. This is a +breaking change for rare configurations that previously relied on PDF/A +output without Ghostscript alternatives. +::: + +**Sample debian/control dependency specification** + +``` +Depends: + fonts-noto, + fpdf2 (>= 2.8), + ghostscript (>= 9.55), # Not strictly required, but best user experience + icc-profiles-free, + img2pdf, + python3-coloredlogs, + python3-deprecation, + python3-pdfminer (>= 20181108+dfsg-3), + python3-pikepdf (>= 8.14.0), + python3-pil, + python3-pluggy, + python3-reportlab, + python3-rich, + python3-uharfbuzz, # Not currently in Debian + tesseract-ocr (>= 5.0.0), + zlib1g, + ${misc:Depends}, + ${python3:Depends}, +Recommends: + cyclopts, # Not currently in Debian + jbig2 + paddleocr, # Not currently in Debian + pngquant, + pypdfium2, # Not currently in Debian + unpaper, + verapdf, # Not currently in Debian +Suggests: + ocrmypdf-doc, + python-watchdog, +``` ### Command line completions diff --git a/docs/optimizer.md b/docs/optimizer.md index 5c7b28cd..6eba6cc8 100644 --- a/docs/optimizer.md +++ b/docs/optimizer.md @@ -28,9 +28,6 @@ header-rows: 1 - Enables lossless optimizations, such as transcoding images to more efficient formats. Also compress other uncompressed objects in the PDF and enables the more efficient "object streams" within the PDF. - (If ``--jbig2-lossy`` is issued, then lossy JBIG2 optimization is used. - The decision to use lossy JBIG2 is separate from standard optimization - settings.) * - ``--optimize 2`` - ``-O2`` - All of the above, and enables lossy optimizations and color quantization. @@ -105,7 +102,3 @@ quality image may be suitable for storage after OCR. It is not possible to optimize all image types. Uncommon image types may be skipped by the optimizer. - -OCRmyPDF provides `lossy mode JBIG2 `{.interpreted-text -role="ref"} as an advanced feature that additional requires the argument -`--jbig2-lossy`. diff --git a/docs/plugins.md b/docs/plugins.md index 19d684d9..94321c68 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -164,6 +164,77 @@ chaining operations. .. autofunction:: ocrmypdf.pluginspec.check_options ``` +### Plugin option models + +Plugins can define their own option models using Pydantic. This allows plugins to: + +- Define type-safe option structures with validation +- Add CLI arguments that map to their option model fields +- Access options via nested namespaces (e.g., `options.tesseract.timeout`) + +```{eval-rst} +.. autofunction:: ocrmypdf.pluginspec.register_options +``` + +Plugin options can be accessed in two ways: + +1. **Flat access** (backward compatible): `options.tesseract_timeout` +2. **Nested access**: `options.tesseract.timeout` + +Both access patterns are equivalent and return the same values. + +:::{note} +**Plugin Interface Change**: Starting in OCRmyPDF v17.0.0, plugin hooks receive +`OcrOptions` objects instead of `argparse.Namespace` objects. Most plugins will +continue working due to duck-typing compatibility, but plugin developers should +update their type hints accordingly. +::: + +### Migration guide for plugin developers + +:::{versionadded} 17.0.0 +::: + +**Update imports:** + +```python +from ocrmypdf._options import OcrOptions +``` + +**Update type hints:** + +```python +# Before (v16 and earlier) +def check_options(options: argparse.Namespace) -> None: + ... + +# After (v17+) +def check_options(options: OcrOptions) -> None: + ... +``` + +**Attribute access unchanged:** + +```python +# These work exactly as before +options.languages +options.output_type +options.tesseract_timeout +``` + +**Remove in-place modifications:** + +```python +# Before (v16 pattern - no longer recommended) +def check_options(options): + options.some_computed_value = compute_value(options) + +# After (v17 pattern - compute at point of use) +def some_function(options): + computed = compute_value(options) + use_computed(computed) +``` + ### Execution and progress reporting ```{eval-rst} @@ -248,3 +319,98 @@ chaining operations. ```{eval-rst} .. autofunction:: ocrmypdf.pluginspec.is_optimization_enabled ``` + +### Working with OcrElement trees + +:::{versionadded} 17.0.0 +::: + +OCRmyPDF v17 introduces the `OcrElement` dataclass for representing OCR +output in an engine-agnostic format. This enables plugins to work with +OCR results without parsing hOCR XML. + +**Key classes:** + +```python +from ocrmypdf import OcrElement, OcrClass, BoundingBox + +# OcrElement - represents any OCR structural unit +page = OcrElement( + ocr_class=OcrClass.PAGE, + bbox=BoundingBox(0, 0, 612, 792), + children=[...] +) + +# BoundingBox - axis-aligned bounding box (left, top, right, bottom) +bbox = BoundingBox(left=100, top=50, right=300, bottom=80) + +# OcrClass - constants for element types +OcrClass.PAGE # "ocr_page" +OcrClass.LINE # "ocr_line" +OcrClass.WORD # "ocrx_word" +OcrClass.PARAGRAPH # "ocr_par" +``` + +**Navigating the tree:** + +```python +# Get all words in a page +words = page.words # Returns list[OcrElement] + +# Get all lines +lines = page.lines + +# Get combined text +text = page.get_text_recursive() + +# Iterate by class +for para in page.paragraphs: + print(para.get_text_recursive()) +``` + +**OCR engine plugins:** + +Plugins implementing custom OCR engines can now output `OcrElement` trees +directly via the `generate_ocr()` method, bypassing hOCR entirely: + +```python +from pathlib import Path +from ocrmypdf.pluginspec import OcrEngine +from ocrmypdf import OcrElement, OcrClass, BoundingBox + +class MyOcrEngine(OcrEngine): + def generate_ocr( + self, + input_file: Path, + options, + context, + ) -> OcrElement: + # Perform OCR and return OcrElement tree directly + # No need to generate hOCR XML + return OcrElement( + ocr_class=OcrClass.PAGE, + bbox=BoundingBox(0, 0, width, height), + dpi=300, + children=[ + OcrElement( + ocr_class=OcrClass.LINE, + bbox=BoundingBox(100, 50, 500, 80), + children=[ + OcrElement( + ocr_class=OcrClass.WORD, + bbox=BoundingBox(100, 50, 200, 80), + text="Hello", + ), + # ... more words + ] + ), + # ... more lines + ] + ) + + def supports_generate_ocr(self) -> bool: + return True # Indicate this engine uses generate_ocr() +``` + +This approach is simpler than generating hOCR and allows modern OCR +engines to integrate more naturally with OCRmyPDF. diff --git a/docs/release_notes.md b/docs/release_notes.md index 5539af6f..101ce2a1 100644 --- a/docs/release_notes.md +++ b/docs/release_notes.md @@ -25,6 +25,102 @@ about a forthcoming release that has not been tagged yet. A release is only official when it's tagged and posted to PyPI. ::: +## v17.0.0 (unreleased) + +**Breaking changes** + +- **Plugin interface migration**: Plugin hooks now receive `OcrOptions` objects instead of + `argparse.Namespace` objects. Most plugins will continue working due to duck-typing + compatibility, but plugin developers should update their type hints from `Namespace` + to `OcrOptions`. +- Built-in plugins no longer modify options in-place, improving immutability and + code clarity. +- **Lossy JBIG2 removed**: The `--jbig2-lossy` and `--jbig2-page-group-size` options have been + removed due to well-documented risks of character substitution errors. These options are now + deprecated and will emit warnings if used. Only lossless JBIG2 compression is supported. +- **PDF/A output behavior change**: If neither Ghostscript nor verapdf is installed, + `--output-type auto` (the new default) will produce a standard PDF instead of PDF/A. This is + a change from previous versions where Ghostscript was required and PDF/A was always produced. + This configuration is rare but users should be aware of the change. + +**New features** + +- **pypdfium2 rasterizer**: Added optional pypdfium2-based PDF rasterization plugin as an + alternative to Ghostscript for page rendering. Use `--rasterizer pypdfium` to enable + (requires `pip install pypdfium2`). The default `--rasterizer auto` prefers pypdfium when + available and falls back to Ghostscript. +- **Pluggable OCR engines**: New `--ocr-engine` option allows selecting OCR engines: + - `auto` (default): Uses Tesseract + - `tesseract`: Explicit Tesseract selection + - `none`: Skip OCR entirely for PDF processing-only workflows + + This prepares the foundation for future third-party OCR engine plugins. +- **Smart PDF/A conversion**: New `--output-type auto` (now the default) produces best-effort + PDF/A output without requiring Ghostscript when the verapdf validator is available. Falls back + to traditional Ghostscript conversion when needed. +- **verapdf integration**: Added optional verapdf validation for fast PDF/A conversion. When + available, OCRmyPDF attempts speculative PDF/A conversion using pikepdf, validates with verapdf, + and skips Ghostscript if validation passes. +- **Optional Ghostscript**: As a consequence of the changes above, Ghostscript is no longer a required dependency. It is optional. +- **fpdf2 text renderer**: Replaced legacy hOCR text renderer with new fpdf2-based implementation, + providing better multilingual support and more accurate text positioning. +- **Improved Occulta glyphless font**: The new Occulta font provides better handling of + zero-width markers and double-width CJK characters for accurate text layer positioning. +- **Expanded multilingual font support**: Added FontProvider infrastructure with language-aware + font selection for Devanagari (Hindi, Sanskrit, Marathi, Nepali), CJK (Chinese, Japanese, + Korean), Arabic script, and many other scripts. System font discovery reduces package size. +- **Simplified mode selection**: New `--mode` (`-m`) argument consolidates processing options: + - `default`: Error if text is found (standard behavior) + - `force`: Rasterize all content and run OCR (replaces `--force-ocr`) + - `skip`: Skip pages with existing text (replaces `--skip-text`) + - `redo`: Re-OCR pages, stripping old text layer (replaces `--redo-ocr`) + + Legacy flags remain as silent aliases for backward compatibility. + +**API improvements** + +- Centralized validation logic in the `OcrOptions` Pydantic model +- Removed scattered option mutation throughout the codebase +- Better type safety for plugin development +- Simplified plugin option handling +- New `OcrElement`, `OcrClass`, and `BoundingBox` exports for OCR engine plugin developers +- Extended `OcrEngine` ABC with `generate_ocr()` method for direct OCR tree output, eliding the need to translate a modern engine's output to hOCR or directly write to PDF. + +**Bug fixes** + +- Fixed double-compression of already-deflated JPEGs. +- Fixed tesseract_cache plugin to properly handle cache misses. +- Fixed handling of PDF page boxes (ArtBox, BleedBox) which were not being processed correctly. +- Added thread safety lock to pypdfium plugin for concurrent operations. +- Improved pdfminer.six compatibility with explicit word spacing. + +**Documentation** + +- Updated cookbook to replace deprecated `--tesseract-timeout 0` with `--ocr-engine none`. +- Added comprehensive plugin documentation for new OCR engine framework. + +**Dependency changes** + +- Requires: one of `pypdfium2` or `ghostscript` for PDF rasterization (PDF to image) + - Preferred: both +- Requires: one of `verapdf` or `ghostscript` for PDF/A generation + - Preferred: both +- Recommended: `pypdfium2` for PDF rasterization (new dependency) +- Recommended: `ghostscript` (used to be Required) +- Recommended: Noto fonts for improved OCR text positioning +- Optional: `verapdf` for fast PDF/A validation (new dependency) +- Requires: `fpdf2` for text layer rendering (new dependency) +- Recommended: replace `typer` with `cyclopts` in misc scripts (new dependency) +- See docs/maintainers.md for details. + +**Migration guide for plugin developers** + +- Update imports: `from ocrmypdf._options import OcrOptions` +- Update type hints: `def check_options(options: OcrOptions)` instead of `options: Namespace` +- Attribute access remains unchanged: `options.languages`, `options.output_type`, etc. +- Remove any in-place option modifications - compute values at point of use instead +- Most existing plugins will continue working without changes due to duck-typing + ## v16.13.0 - Added detection and repair for Ghostscript 10.6 JPEG corruption. When GS 10.6 diff --git a/misc/_webservice.py b/misc/_webservice.py index 8ddcacd5..df7752f0 100644 --- a/misc/_webservice.py +++ b/misc/_webservice.py @@ -96,8 +96,9 @@ with st.expander("Optimization after OCR"): png_quality = st.slider( "PNG quality", min_value=0, max_value=100, value=75, key="png_quality" ) - jbig2_lossy = st.checkbox("JBIG2 lossy (dangerous)", value=False, key="jbig2_lossy") - jbig2_threshold = st.number_input("JBIG2 threshold", value=0, key="jbig2_threshold") + jbig2_threshold = st.number_input( + "JBIG2 threshold", value=0.85, key="jbig2_threshold" + ) with st.expander("Advanced options"): jobs = st.slider( @@ -189,51 +190,51 @@ if uploaded: args.append(f"--jpeg-quality={jpeg_quality}") if optimize > '0' and png_quality: args.append(f"--png-quality={png_quality}") - if jbig2_lossy: - args.append("--jbig2-lossy") if jbig2_threshold: args.append(f"--jbig2-threshold={jbig2_threshold}") if jobs: args.append(f"--jobs={jobs}") - input_file = NamedTemporaryFile(delete=True, suffix=f"_{uploaded.name}") - input_file.write(uploaded.getvalue()) - input_file.flush() - input_file.seek(0) - args.append(str(input_file.name)) - output_file = NamedTemporaryFile(delete=True, suffix=".pdf") - args.append(str(output_file.name)) + with NamedTemporaryFile(delete=True, suffix=f"_{uploaded.name}") as input_file: + input_file.write(uploaded.getvalue()) + input_file.flush() + input_file.seek(0) + args.append(str(input_file.name)) + with NamedTemporaryFile(delete=True, suffix=".pdf") as output_file: + args.append(str(output_file.name)) - st.session_state['running'] = ( - 'run_button' in st.session_state and st.session_state.run_button - ) - if st.button( - "Run OCRmyPDF", - disabled=st.session_state.get("running", False), - key='run_button', - ): - st.session_state['running'] = True - args = [sys.executable, '-u', '-m', "ocrmypdf"] + args + st.session_state['running'] = ( + 'run_button' in st.session_state and st.session_state.run_button + ) + if st.button( + "Run OCRmyPDF", + disabled=st.session_state.get("running", False), + key='run_button', + ): + st.session_state['running'] = True + args = [sys.executable, '-u', '-m', "ocrmypdf"] + args - proc = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - with st.container(border=True): - while proc.poll() is None: - line = proc.stderr.readline() - if line: - st.html("" + line.decode().strip() + "") + proc = subprocess.Popen( + args, stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) + with st.container(border=True): + while proc.poll() is None: + line = proc.stderr.readline() + if line: + st.html("" + line.decode().strip() + "") - if proc.returncode != 0: - st.error(f"ocrmypdf failed with exit code {proc.returncode}") - st.session_state['running'] = False - st.stop() + if proc.returncode != 0: + st.error(f"ocrmypdf failed with exit code {proc.returncode}") + st.session_state['running'] = False + st.stop() - if Path(output_file.name).stat().st_size == 0: - st.error("No output PDF file was generated") - st.stop() + if Path(output_file.name).stat().st_size == 0: + st.error("No output PDF file was generated") + st.stop() - st.download_button( - label="Download output PDF", - data=output_file.read(), - file_name=uploaded.name, - mime="application/pdf", - ) - st.session_state['running'] = False + st.download_button( + label="Download output PDF", + data=output_file.read(), + file_name=uploaded.name, + mime="application/pdf", + ) + st.session_state['running'] = False diff --git a/misc/batch.py b/misc/batch.py index a9e45ac2..f45e8919 100644 --- a/misc/batch.py +++ b/misc/batch.py @@ -39,10 +39,7 @@ script_dir = Path(__file__).parent # set archive_dir to a path for backup original documents. Leave empty if not required. archive_dir = "/pdfbak" -if len(sys.argv) > 1: - start_dir = Path(sys.argv[1]) -else: - start_dir = Path(".") +start_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(".") if len(sys.argv) > 2: log_file = Path(sys.argv[2]) diff --git a/misc/bisect_pdf.py b/misc/bisect_pdf.py index 79cd8f98..b50aec07 100644 --- a/misc/bisect_pdf.py +++ b/misc/bisect_pdf.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: MIT """Helper script for bisecting PDFs to find a page with an issue.""" +from __future__ import annotations import sys diff --git a/misc/completion/ocrmypdf.bash b/misc/completion/ocrmypdf.bash index a4cb6801..a80d6cc2 100644 --- a/misc/completion/ocrmypdf.bash +++ b/misc/completion/ocrmypdf.bash @@ -21,14 +21,13 @@ __ocrmypdf_arguments() --subject (set metadata) --keywords (set metadata) --rotate-pages (rotate pages to correct orientation) ---remove-background (attempt to remove background from pages) --deskew (fix small horizontal alignment skew) --clean (clean document images before OCR) --clean-final (clean document images and keep result) --unpaper-args (a quoted string of arguments to pass to unpaper) --oversample (oversample images to this DPI) --remove-vectors (don\'t send vector objects to OCR) ---threshold (threshold images before OCR) +--mode (processing mode for pages with existing text) --force-ocr (OCR documents that already have printable text) --skip-text (skip OCR on any pages that already contain text) --redo-ocr (redo OCR on any pages that seem to have OCR already) @@ -42,9 +41,12 @@ __ocrmypdf_arguments() --pages (apply OCR to only the specified pages) --max-image-mpixels (image decompression bomb threshold) --pdf-renderer (select PDF renderer options) +--ocr-engine (OCR engine to use) +--rasterizer (PDF page rasterizer) --rotate-pages-threshold (page rotation confidence) --pdfa-image-compression (set PDF/A image compression options) --fast-web-view (if file size if above this amount in MB linearize PDF) +--continue-on-soft-render-error (continue after recoverable render errors) --plugin (name of plugin to import) --keep-temporary-files (keep temporary files (debug) --tesseract-config (set custom tesseract config file) @@ -52,6 +54,10 @@ __ocrmypdf_arguments() --tesseract-oem (set tesseract --oem) --tesseract-thresholding (set tesseract image thresholding) --tesseract-timeout (maximum number of seconds to wait for OCR) +--tesseract-non-ocr-timeout (maximum seconds for non-OCR operations) +--tesseract-downsample-large-images (downsample large images before OCR) +--no-tesseract-downsample-large-images (do not downsample large images) +--tesseract-downsample-above (downsample images larger than this pixel size) --user-words (specify location of user words file) --user-patterns (specify location of user patterns file) --no-progress-bar (disable the progress bar) @@ -68,7 +74,8 @@ __ocrmypdf_arguments() __ocrmypdf_output-type() { - local choices="pdfa (output a PDF/A (default)) + local choices="auto (best-effort PDF/A without Ghostscript (default)) +pdfa (output a PDF/A-2b) pdf (output a standard PDF) pdfa-1 (output a PDF/A-1b) pdfa-2 (output a PDF/A-2b) @@ -114,10 +121,11 @@ __ocrmypdf_optimize() __ocrmypdf_pdf-renderer() { - local choices="auto (auto select PDF renderer) -hocr (use hOCR renderer) -hocrdebug (uses hOCR renderer in debug mode, showing recognized text) -sandwich (use sandwich renderer)" + local choices="auto (auto select PDF renderer, uses fpdf2) +fpdf2 (use fpdf2 renderer with full language support) +sandwich (use sandwich renderer) +hocr (use hOCR renderer - deprecated) +hocrdebug (uses hOCR renderer in debug mode - deprecated)" COMPREPLY=( $( compgen -W "$choices" -- "$cur") ) @@ -210,6 +218,46 @@ UseDeviceIndependentColor (convert with device independent color)" fi } +__ocrmypdf_mode() +{ + local choices="default (error if text is found) +force (rasterize all content and run OCR) +skip (skip pages with existing text) +redo (re-OCR pages, replacing old invisible text)" + + COMPREPLY=( $( compgen -W "$choices" -- "$cur") ) + # Remove description if only one completion exists + if [[ ${#COMPREPLY[*]} -eq 1 ]]; then + COMPREPLY=( ${COMPREPLY[0]%% *} ) + fi +} + +__ocrmypdf_ocr-engine() +{ + local choices="auto (select best available engine) +tesseract (use Tesseract OCR) +none (skip OCR entirely)" + + COMPREPLY=( $( compgen -W "$choices" -- "$cur") ) + # Remove description if only one completion exists + if [[ ${#COMPREPLY[*]} -eq 1 ]]; then + COMPREPLY=( ${COMPREPLY[0]%% *} ) + fi +} + +__ocrmypdf_rasterizer() +{ + local choices="auto (prefer pypdfium, fall back to Ghostscript) +ghostscript (use Ghostscript rasterizer) +pypdfium (use pypdfium rasterizer - faster)" + + COMPREPLY=( $( compgen -W "$choices" -- "$cur") ) + # Remove description if only one completion exists + if [[ ${#COMPREPLY[*]} -eq 1 ]]; then + COMPREPLY=( ${COMPREPLY[0]%% *} ) + fi +} + __ocrmypdf_check_previous() { case $prev in @@ -241,6 +289,18 @@ __ocrmypdf_check_previous() __ocrmypdf_pdf-renderer return 0 ;; + -m|--mode) + __ocrmypdf_mode + return 0 + ;; + --ocr-engine) + __ocrmypdf_ocr-engine + return 0 + ;; + --rasterizer) + __ocrmypdf_rasterizer + return 0 + ;; --pdfa-image-compression) __ocrmypdf_pdfa-image-compression return 0 @@ -260,7 +320,8 @@ __ocrmypdf_check_previous() --title|--author|--subject|--keywords|--unpaper-args|--pages|--plugin|\ --jpeg-quality|--png-quality|--image-dpi|--oversample|--skip-big|--max-image-mpixels|\ - --tesseract-timeout|--rotate-pages-threshold|--fast-web-view) + --tesseract-timeout|--tesseract-non-ocr-timeout|--tesseract-downsample-above|\ + --rotate-pages-threshold|--fast-web-view) # argument required but no completions available return 0 ;; diff --git a/misc/completion/ocrmypdf.fish b/misc/completion/ocrmypdf.fish index 9d1ca073..a831c439 100644 --- a/misc/completion/ocrmypdf.fish +++ b/misc/completion/ocrmypdf.fish @@ -11,8 +11,16 @@ complete -c ocrmypdf -s r -l rotate-pages -d "rotate pages to correct orientatio complete -c ocrmypdf -s d -l deskew -d "fix small horizontal alignment skew" complete -c ocrmypdf -s c -l clean -d "clean document images before OCR" complete -c ocrmypdf -s i -l clean-final -d "clean document images and keep result" +complete -c ocrmypdf -x -l unpaper-args -d "quoted string of arguments to pass to unpaper" complete -c ocrmypdf -l remove-vectors -d "don't send vector objects to OCR" +function __fish_ocrmypdf_mode + echo -e "default\t"(_ "error if text is found") + echo -e "force\t"(_ "rasterize all content and run OCR") + echo -e "skip\t"(_ "skip pages with existing text") + echo -e "redo\t"(_ "re-OCR pages, replacing old invisible text") +end +complete -c ocrmypdf -x -s m -l mode -a '(__fish_ocrmypdf_mode)' -d "processing mode for pages with existing text" complete -c ocrmypdf -s f -l force-ocr -d "OCR documents that already have printable text" complete -c ocrmypdf -s s -l skip-text -d "skip OCR on any pages that already contain text" complete -c ocrmypdf -l redo-ocr -d "redo OCR on any pages that seem to have OCR already" @@ -32,7 +40,8 @@ complete -c ocrmypdf -x -s l -l language -a '(__fish_ocrmypdf_languages)' -d lan complete -c ocrmypdf -x -l image-dpi -d "assume this DPI if input image DPI is unknown" function __fish_ocrmypdf_output_type - echo -e "pdfa\t"(_ "output a PDF/A (default)") + echo -e "auto\t"(_ "best-effort PDF/A without requiring Ghostscript (default)") + echo -e "pdfa\t"(_ "output a PDF/A-2b") echo -e "pdf\t"(_ "output a standard PDF") echo -e "pdfa-1\t"(_ "output a PDF/A-1b") echo -e "pdfa-2\t"(_ "output a PDF/A-2b") @@ -42,13 +51,28 @@ end complete -c ocrmypdf -x -l output-type -a '(__fish_ocrmypdf_output_type)' -d "select PDF output options" function __fish_ocrmypdf_pdf_renderer - echo -e "auto\t"(_ "auto select PDF renderer") - echo -e "hocr\t"(_ "use hOCR renderer") - echo -e "hocrdebug\t"(_ "uses hOCR renderer in debug mode, showing recognized text") + echo -e "auto\t"(_ "auto select PDF renderer (default, uses fpdf2)") + echo -e "fpdf2\t"(_ "use fpdf2 renderer with full language support") echo -e "sandwich\t"(_ "use sandwich renderer") + echo -e "hocr\t"(_ "use hOCR renderer (deprecated)") + echo -e "hocrdebug\t"(_ "uses hOCR renderer in debug mode (deprecated)") end complete -c ocrmypdf -x -l pdf-renderer -a '(__fish_ocrmypdf_pdf_renderer)' -d "select PDF renderer options" +function __fish_ocrmypdf_ocr_engine + echo -e "auto\t"(_ "select best available engine (default)") + echo -e "tesseract\t"(_ "use Tesseract OCR") + echo -e "none\t"(_ "skip OCR entirely") +end +complete -c ocrmypdf -x -l ocr-engine -a '(__fish_ocrmypdf_ocr_engine)' -d "OCR engine to use" + +function __fish_ocrmypdf_rasterizer + echo -e "auto\t"(_ "prefer pypdfium, fall back to Ghostscript (default)") + echo -e "ghostscript\t"(_ "use Ghostscript rasterizer") + echo -e "pypdfium\t"(_ "use pypdfium rasterizer (faster)") +end +complete -c ocrmypdf -x -l rasterizer -a '(__fish_ocrmypdf_rasterizer)' -d "PDF page rasterizer" + function __fish_ocrmypdf_optimize echo -e "0\t"(_ "do not optimize") echo -e "1\t"(_ "do safe, lossless optimizations (default)") @@ -124,11 +148,17 @@ end complete -c ocrmypdf -x -l tesseract-thresholding -a '(__fish_ocrmypdf_tesseract_thresholding)' -d "set tesseract thresholding method (needs Tesseract 5.x)" complete -c ocrmypdf -x -l tesseract-timeout -d "maximum number of seconds to wait for OCR" +complete -c ocrmypdf -x -l tesseract-non-ocr-timeout -d "maximum seconds to wait for non-OCR operations" +complete -c ocrmypdf -l tesseract-downsample-large-images -d "downsample large images before OCR" +complete -c ocrmypdf -l no-tesseract-downsample-large-images -d "do not downsample large images" +complete -c ocrmypdf -x -l tesseract-downsample-above -d "downsample images larger than this pixel size" complete -c ocrmypdf -x -l rotate-pages-threshold -d "page rotation confidence" complete -c ocrmypdf -r -l user-words -d "specify location of user words file" complete -c ocrmypdf -r -l user-patterns -d "specify location of user patterns file" complete -c ocrmypdf -x -l fast-web-view -d "if file size if above this amount in MB, linearize PDF" +complete -c ocrmypdf -l continue-on-soft-render-error -d "continue processing after recoverable render errors" +complete -c ocrmypdf -r -l plugin -d "name of plugin to import" function __fish_ocrmypdf_color_conversion_strategy echo -e "LeaveColorUnchanged\t"(_ "do not convert color spaces (default)") diff --git a/misc/ocrmypdf_compare.py b/misc/ocrmypdf_compare.py index 9e4d5678..a292456a 100644 --- a/misc/ocrmypdf_compare.py +++ b/misc/ocrmypdf_compare.py @@ -108,7 +108,7 @@ def main(): doc1 = pymupdf.open(os.path.join(d, "output1.pdf")) doc2 = pymupdf.open(os.path.join(d, "output2.pdf")) - for i, page1_2 in enumerate(zip(doc1, doc2)): + for i, page1_2 in enumerate(zip(doc1, doc2, strict=False)): st.write(f"Page {i+1}") page1, page2 = page1_2 col1, col2 = st.columns(2) diff --git a/misc/pdf_compare.py b/misc/pdf_compare.py index d2807bbb..a49ed6b8 100644 --- a/misc/pdf_compare.py +++ b/misc/pdf_compare.py @@ -62,7 +62,7 @@ def main(): with st.expander("Text"): doc1 = pymupdf.open(os.path.join(d, "1.pdf")) doc2 = pymupdf.open(os.path.join(d, "2.pdf")) - for i, page1_2 in enumerate(zip(doc1, doc2)): + for i, page1_2 in enumerate(zip(doc1, doc2, strict=False)): st.write(f"Page {i+1}") page1, page2 = page1_2 col1, col2 = st.columns(2) diff --git a/misc/pdf_text_diff.py b/misc/pdf_text_diff.py index 5b93ec23..b99af9c1 100644 --- a/misc/pdf_text_diff.py +++ b/misc/pdf_text_diff.py @@ -5,34 +5,45 @@ from __future__ import annotations +from pathlib import Path from subprocess import run from tempfile import NamedTemporaryFile from typing import Annotated -import typer +import cyclopts + +app = cyclopts.App() +@app.default def main( - pdf1: Annotated[typer.FileBinaryRead, typer.Argument()], - pdf2: Annotated[typer.FileBinaryRead, typer.Argument()], - engine: Annotated[str, typer.Option()] = 'pdftotext', + pdf1: Annotated[Path, cyclopts.Parameter()], + pdf2: Annotated[Path, cyclopts.Parameter()], + *, + engine: Annotated[str, cyclopts.Parameter()] = 'pdftotext', ): """Compare text in PDFs.""" + with open(pdf1, 'rb') as f1, open(pdf2, 'rb') as f2: + text1 = run( + ['pdftotext', '-layout', '-', '-'], + stdin=f1, + capture_output=True, + check=True, + ) + text2 = run( + ['pdftotext', '-layout', '-', '-'], + stdin=f2, + capture_output=True, + check=True, + ) - text1 = run( - ['pdftotext', '-layout', '-', '-'], stdin=pdf1, capture_output=True, check=True - ) - text2 = run( - ['pdftotext', '-layout', '-', '-'], stdin=pdf2, capture_output=True, check=True - ) - - with NamedTemporaryFile() as f1, NamedTemporaryFile() as f2: - f1.write(text1.stdout) - f1.flush() - f2.write(text2.stdout) - f2.flush() + with NamedTemporaryFile() as t1, NamedTemporaryFile() as t2: + t1.write(text1.stdout) + t1.flush() + t2.write(text2.stdout) + t2.flush() diff = run( - ['diff', '--color=always', '--side-by-side', f1.name, f2.name], + ['diff', '--color=always', '--side-by-side', t1.name, t2.name], capture_output=True, ) run(['less', '-R'], input=diff.stdout, check=True) @@ -43,4 +54,4 @@ def main( if __name__ == '__main__': - typer.run(main) + app() diff --git a/misc/watcher.py b/misc/watcher.py index 65f8df72..7d1f421f 100644 --- a/misc/watcher.py +++ b/misc/watcher.py @@ -7,18 +7,18 @@ from __future__ import annotations +import datetime as dt import json import logging import shutil import sys import time -from datetime import datetime from enum import Enum from pathlib import Path from typing import Annotated, Any +import cyclopts import pikepdf -import typer from dotenv import load_dotenv from watchdog.events import PatternMatchingEventHandler from watchdog.observers import Observer @@ -30,7 +30,7 @@ load_dotenv() # pylint: disable=logging-format-interpolation -app = typer.Typer(name="ocrmypdf-watcher") +app = cyclopts.App(name="ocrmypdf-watcher") log = logging.getLogger('ocrmypdf-watcher') @@ -48,7 +48,7 @@ class LoggingLevelEnum(str, Enum): def get_output_path(root: Path, basename: str, output_dir_year_month: bool) -> Path: assert '/' not in basename, "basename must not contain '/'" if output_dir_year_month: - today = datetime.today() + today = dt.datetime.today() output_directory_year_month = root / str(today.year) / f'{today.month:02d}' if not output_directory_year_month.exists(): output_directory_year_month.mkdir(parents=True, exist_ok=True) @@ -114,9 +114,11 @@ def execute_ocrmypdf( f'kwargs: {ocrmypdf_kwargs}' ) exit_code = ocrmypdf.ocr( - input_file=file_path, - output_file=output_path, - **ocrmypdf_kwargs, + ocrmypdf.OcrOptions( + input_file=file_path, + output_file=output_path, + **ocrmypdf_kwargs, + ) ) if exit_code == 0: if on_success_delete: @@ -138,7 +140,7 @@ class HandleObserverEvent(PatternMatchingEventHandler): ignore_patterns=None, ignore_directories=False, case_sensitive=False, - settings={}, + settings=None, ): super().__init__( patterns=patterns, @@ -146,117 +148,101 @@ class HandleObserverEvent(PatternMatchingEventHandler): ignore_directories=ignore_directories, case_sensitive=case_sensitive, ) - self._settings = settings + self._settings = settings if settings else {} def on_any_event(self, event): if event.event_type in ['created']: execute_ocrmypdf(file_path=Path(event.src_path), **self._settings) -@app.command() +@app.default def main( input_dir: Annotated[ Path, - typer.Argument( - envvar='OCR_INPUT_DIRECTORY', - exists=True, - file_okay=False, - dir_okay=True, - readable=True, - resolve_path=True, + cyclopts.Parameter( + env_var='OCR_INPUT_DIRECTORY', ), - ] = '/input', + ] = Path('/input'), output_dir: Annotated[ Path, - typer.Argument( - envvar='OCR_OUTPUT_DIRECTORY', - exists=True, - file_okay=False, - dir_okay=True, - writable=True, - resolve_path=True, + cyclopts.Parameter( + env_var='OCR_OUTPUT_DIRECTORY', ), - ] = '/output', + ] = Path('/output'), archive_dir: Annotated[ Path, - typer.Argument( - envvar='OCR_ARCHIVE_DIRECTORY', - exists=True, - file_okay=False, - dir_okay=True, - writable=True, - resolve_path=True, + cyclopts.Parameter( + env_var='OCR_ARCHIVE_DIRECTORY', ), - ] = '/processed', + ] = Path('/processed'), + *, output_dir_year_month: Annotated[ bool, - typer.Option( - envvar='OCR_OUTPUT_DIRECTORY_YEAR_MONTH', + cyclopts.Parameter( + env_var='OCR_OUTPUT_DIRECTORY_YEAR_MONTH', help='Create a subdirectory in the output directory for each year/month', ), ] = False, on_success_delete: Annotated[ bool, - typer.Option( - envvar='OCR_ON_SUCCESS_DELETE', + cyclopts.Parameter( + env_var='OCR_ON_SUCCESS_DELETE', help='Delete the input file after successful OCR', ), ] = False, on_success_archive: Annotated[ bool, - typer.Option( - envvar='OCR_ON_SUCCESS_ARCHIVE', + cyclopts.Parameter( + env_var='OCR_ON_SUCCESS_ARCHIVE', help='Archive the input file after successful OCR', ), ] = False, deskew: Annotated[ bool, - typer.Option( - envvar='OCR_DESKEW', + cyclopts.Parameter( + env_var='OCR_DESKEW', help='Deskew the input file before OCR', ), ] = False, ocr_json_settings: Annotated[ - str, - typer.Option( - envvar='OCR_JSON_SETTINGS', + str | None, + cyclopts.Parameter( + env_var='OCR_JSON_SETTINGS', help='JSON settings to pass to OCRmyPDF (JSON string or file path)', ), ] = None, poll_new_file_seconds: Annotated[ int, - typer.Option( - envvar='OCR_POLL_NEW_FILE_SECONDS', + cyclopts.Parameter( + env_var='OCR_POLL_NEW_FILE_SECONDS', help='Seconds to wait before polling a new file', - min=0, ), ] = 1, use_polling: Annotated[ bool, - typer.Option( - envvar='OCR_USE_POLLING', + cyclopts.Parameter( + env_var='OCR_USE_POLLING', help='Use polling instead of filesystem events', ), ] = False, retries_loading_file: Annotated[ int, - typer.Option( - envvar='OCR_RETRIES_LOADING_FILE', + cyclopts.Parameter( + env_var='OCR_RETRIES_LOADING_FILE', help='Number of times to retry loading a file before giving up', - min=0, ), ] = 5, loglevel: Annotated[ LoggingLevelEnum, - typer.Option( - envvar='OCR_LOGLEVEL', + cyclopts.Parameter( + env_var='OCR_LOGLEVEL', help='Logging level', ), ] = LoggingLevelEnum.INFO, patterns: Annotated[ str, - typer.Option( - envvar='OCR_PATTERNS', + cyclopts.Parameter( + env_var='OCR_PATTERNS', help='File patterns to watch', ), ] = '*.pdf,*.PDF', @@ -316,13 +302,10 @@ def main( 'output_dir_year_month': output_dir_year_month, }, ) - if use_polling: - observer = PollingObserver() - else: - observer = Observer() + observer = PollingObserver() if use_polling else Observer() observer.schedule(handler, input_dir, recursive=True) observer.start() - typer.echo(f"Watching {input_dir} for new PDFs. Press Ctrl+C to exit.") + print(f"Watching {input_dir} for new PDFs. Press Ctrl+C to exit.") try: while True: time.sleep(30) diff --git a/misc/webservice.py b/misc/webservice.py index a432f50e..d3584414 100755 --- a/misc/webservice.py +++ b/misc/webservice.py @@ -4,6 +4,8 @@ """Run the OCRmyPDF web service.""" +from __future__ import annotations + import os import sys @@ -13,7 +15,7 @@ except ImportError: raise ImportError( 'You need to install streamlit in the Python environment ' 'to run the web service.\n' - ) + ) from None if __name__ == '__main__': os.execvp( diff --git a/pyproject.toml b/pyproject.toml index 8029e8e1..01d95e9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,9 +10,10 @@ dynamic = ["version"] description = "OCRmyPDF adds an OCR text layer to scanned PDF files, allowing them to be searched" readme = "README.md" license = "MPL-2.0" -requires-python = ">=3.10" +requires-python = ">=3.11" dependencies = [ "deprecation>=2.1.0", + "fpdf2>=2.8.0", "img2pdf>=0.5", "packaging>=20", "pdfminer.six>=20220319", @@ -20,7 +21,10 @@ dependencies = [ "pikepdf>=10", "Pillow>=10.0.1", "pluggy>=1", + "pydantic>=2.12.5", + "pypdfium2>=5.0.0", "rich>=13", + "uharfbuzz>=0.53.2", ] authors = [{ name = "James R. Barlow", email = "james@purplerock.ca" }] classifiers = [ @@ -48,20 +52,8 @@ Tracker = "https://github.com/ocrmypdf/OCRmyPDF/issues" Changelog = "https://github.com/ocrmypdf/OCRmyPDF/docs/release_notes.md" [project.optional-dependencies] -docs = ["myst-parser>=4.0.1", "sphinx", "sphinx-issues", "sphinx-rtd-theme"] -extended_test = ["PyMuPDF>=1.19.1"] -test = [ - "coverage[toml]>=6.2", - "hypothesis>=6.36.0", - "pytest>=6.2.5", - "pytest-cov>=3.0.0", - "pytest-xdist>=2.5.0", - "python-xmp-toolkit==2.0.1", # also requires apt-get install libexempi3 - "reportlab>=3.6.8", - "types-Pillow", - "types-humanfriendly", -] -watcher = ["watchdog>=1.0.2", "typer-slim[standard]", "python-dotenv"] +# User-installable features - use `uv sync --extra ` or `pip install ocrmypdf[name]` +watcher = ["watchdog>=1.0.2", "cyclopts>=3", "python-dotenv"] webservice = ["streamlit>=1.41.0"] [project.scripts] @@ -74,7 +66,7 @@ source = "vcs" version-file = "src/ocrmypdf/_version.py" [tool.distutils.bdist_wheel] -python-tag = "py310" +python-tag = "py311" [tool.coverage.run] branch = true @@ -125,27 +117,43 @@ module = [ ignore_missing_imports = true [tool.ruff] -target-version = "py310" +target-version = "py311" +exclude = ["src/ocrmypdf/_version.py"] # Autogenerated [tool.ruff.lint] "select" = [ - "D", # pydocstyle - "E", # pycodestyle - "W", # pycodestyle - "F", # pyflakes - "I001", # isort - "UP", # pyupgrade + "D", # pydocstyle + "E", # pycodestyle + "W", # pycodestyle + "F", # pyflakes + "I", # isort + "UP", # pyupgrade + "SIM", # simplify + "B", # flake8-bugbear + "ICN", # flake8-import-conventions +] +ignore = [ + "B028", # warning with no explicit stacklevel + # rule is key in dict instead of key in dict.keys(); but pikepdf semantics differ + "SIM118", ] [tool.ruff.lint.isort] known-first-party = ["ocrmypdf"] +required-imports = ["from __future__ import annotations"] + +[tool.ruff.lint.flake8-import-conventions] +# Prohibit explicit imports from the 'datetime' module +banned-from = ["datetime"] +# Optionally, suggest an alias for 'import datetime' (e.g., as dt) +extend-aliases = { "datetime" = "dt" } [tool.ruff.lint.pydocstyle] convention = "google" [tool.ruff.lint.per-file-ignores] "docs/conf.py" = ["D100", "D101", "D105"] -"tests/*.py" = ["D100", "D101", "D102", "D103", "D105"] +"tests/*.py" = ["D100", "D101", "D102", "D103", "D105", "E501"] "misc/*.py" = ["D103", "D101", "D102"] "src/ocrmypdf/builtin_plugins/*.py" = ["D103", "D102", "D105"] @@ -153,10 +161,29 @@ convention = "google" quote-style = "preserve" [dependency-groups] -dev = [ - "mypy>=1.13.0", +# Developer-only tools - use `uv sync --group ` +dev = ["mypy>=1.13.0", "ipykernel>=6.29.5", "reportlab>=4.4.4"] +test = [ + # Core testing framework + "coverage[toml]>=6.2", + "hypothesis>=6.36.0", + "pytest>=6.2.5", + "pytest-cov>=3.0.0", + "pytest-xdist>=2.5.0", + # Test dependencies + "python-xmp-toolkit==2.0.1", # also requires apt-get install libexempi3 + "reportlab>=3.6.8", + # Type stubs for testing + "types-Pillow", + "types-humanfriendly", + # Extended test capabilities (merged from extended_test) "pymupdf>=1.24.14", - "streamlit-pdf-viewer>=0.0.19", - "streamlit>=1.40.2", - "ipykernel>=6.29.5", ] +docs = [ + "myst-parser>=4.0.1", + "sphinx", + "sphinx-issues", + "sphinx-rtd-theme", + "sphinxcontrib-mermaid", +] +streamlit-dev = ["streamlit>=1.40.2", "streamlit-pdf-viewer>=0.0.19"] diff --git a/scripts/generate_glyphless_font.py b/scripts/generate_glyphless_font.py new file mode 100644 index 00000000..988863f5 --- /dev/null +++ b/scripts/generate_glyphless_font.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2024 James R. Barlow +# SPDX-License-Identifier: MPL-2.0 + +"""Generate the Occulta glyphless font for OCRmyPDF. + +Occulta (Latin for "hidden") is a glyphless font designed for invisible text layers +in searchable PDFs. It has proper Unicode cmap coverage using format 13 (many-to-one) +for efficient mapping of all BMP codepoints to a small set of width-specific glyphs. + +Features: +- Full BMP coverage (U+0000 to U+FFFF) +- Width-aware glyphs for proper text selection: + - Zero-width for combining marks and invisible characters + - Regular width (500 units) for Latin, Greek, Cyrillic, Arabic, Hebrew, etc. + - Double width (1000 units) for CJK and fullwidth characters +- Uses cmap format 13 (many-to-one) for ~12KB size vs ~780KB with format 12 +- Compatible with fpdf2 and other modern PDF libraries + +Usage: + python scripts/generate_glyphless_font.py + +Output: + src/ocrmypdf/data/Occulta.ttf +""" + +from __future__ import annotations + +import unicodedata +from pathlib import Path + +from fontTools.fontBuilder import FontBuilder +from fontTools.ttLib import TTFont +from fontTools.ttLib.tables._c_m_a_p import CmapSubtable +from fontTools.ttLib.tables._g_l_y_f import Glyph + +# Output path relative to this script +OUTPUT_PATH = Path(__file__).parent.parent / "src" / "ocrmypdf" / "data" / "Occulta.ttf" + +# Font metrics (units per em = 1000) +UNITS_PER_EM = 1000 +ASCENT = 800 +DESCENT = -200 + +# Glyph definitions: (name, advance_width, left_side_bearing) +GLYPHS = [ + (".notdef", 500, 0), # Required, used for unmapped characters + ("space", 500, 0), # U+0020 SPACE + ("nbspace", 500, 0), # U+00A0 NO-BREAK SPACE + ("blank0", 0, 0), # Zero-width (combining marks, ZWNJ, ZWJ, BOM) + ("blank1", 500, 0), # Regular width (most scripts) + ("blank2", 1000, 0), # Double width (CJK, fullwidth) +] + +# Explicit zero-width character codepoints +ZERO_WIDTH_CHARS = frozenset( + [ + 0x200B, # ZERO WIDTH SPACE + 0x200C, # ZERO WIDTH NON-JOINER + 0x200D, # ZERO WIDTH JOINER + 0xFEFF, # ZERO WIDTH NO-BREAK SPACE (BOM) + 0x200E, # LEFT-TO-RIGHT MARK + 0x200F, # RIGHT-TO-LEFT MARK + 0x202A, # LEFT-TO-RIGHT EMBEDDING + 0x202B, # RIGHT-TO-LEFT EMBEDDING + 0x202C, # POP DIRECTIONAL FORMATTING + 0x202D, # LEFT-TO-RIGHT OVERRIDE + 0x202E, # RIGHT-TO-LEFT OVERRIDE + 0x2060, # WORD JOINER + 0x2061, # FUNCTION APPLICATION + 0x2062, # INVISIBLE TIMES + 0x2063, # INVISIBLE SEPARATOR + 0x2064, # INVISIBLE PLUS + ] +) + + +def classify_codepoint(codepoint: int) -> str: + """Classify a Unicode codepoint into one of our glyph categories. + + Args: + codepoint: Unicode codepoint (0x0000 to 0xFFFF) + + Returns: + Glyph name to map this codepoint to + """ + # Special cases first + if codepoint == 0x0020: + return "space" + if codepoint == 0x00A0: + return "nbspace" + if codepoint in ZERO_WIDTH_CHARS: + return "blank0" + + # Use Unicode properties for the rest + char = chr(codepoint) + try: + category = unicodedata.category(char) + east_asian_width = unicodedata.east_asian_width(char) + + # Combining marks are zero-width + if category.startswith("M"): + return "blank0" + + # Wide and Fullwidth characters are double-width + if east_asian_width in ("W", "F"): + return "blank2" + + # Everything else is regular width + return "blank1" + + except (ValueError, TypeError): + # Fallback for any edge cases + return "blank1" + + +def build_cmap() -> dict[int, str]: + """Build the Unicode to glyph name mapping for the entire BMP. + + Returns: + Dictionary mapping codepoints to glyph names + """ + return {cp: classify_codepoint(cp) for cp in range(0x10000)} + + +def create_font() -> TTFont: + """Create the Occulta glyphless font. + + Returns: + TTFont object ready to be saved + """ + glyph_names = [g[0] for g in GLYPHS] + + # Start building the font + fb = FontBuilder(UNITS_PER_EM, isTTF=True) + fb.setupGlyphOrder(glyph_names) + + # Create empty (invisible) glyphs + glyphs = {} + for name, _, _ in GLYPHS: + glyph = Glyph() + glyph.numberOfContours = 0 + glyphs[name] = glyph + fb.setupGlyf(glyphs) + + # Set up horizontal metrics + metrics = {name: (width, lsb) for name, width, lsb in GLYPHS} + fb.setupHorizontalMetrics(metrics) + + # Minimal cmap to satisfy FontBuilder (we'll replace it later) + fb.setupCharacterMap({0x0020: "space", 0x00A0: "nbspace"}) + + # Set up other required tables + fb.setupHorizontalHeader(ascent=ASCENT, descent=DESCENT) + fb.setupOS2( + sTypoAscender=ASCENT, + sTypoDescender=DESCENT, + sTypoLineGap=0, + usWinAscent=UNITS_PER_EM, + usWinDescent=abs(DESCENT), + sxHeight=500, + sCapHeight=700, + ) + import time + + # Use current time for font timestamps + now = int(time.time()) + fb.setupHead(unitsPerEm=UNITS_PER_EM, created=now, modified=now) + fb.setupPost() + fb.setupNameTable( + { + "familyName": "Occulta", + "styleName": "Regular", + "uniqueFontIdentifier": "OCRmyPDF;Occulta-Regular;2026", + "fullName": "Occulta Regular", + "version": "Version 2.0", + "psName": "Occulta-Regular", + } + ) + + # Build the font + font = fb.font + + # Now replace the cmap with format 13 for efficient many-to-one mapping + char_to_glyph = build_cmap() + + cmap13 = CmapSubtable.newSubtable(13) + cmap13.platformID = 3 # Windows + cmap13.platEncID = 10 # Unicode full repertoire + cmap13.language = 0 + cmap13.cmap = char_to_glyph + + font["cmap"].tables = [cmap13] + + return font + + +def main() -> None: + """Generate the Occulta font and save it.""" + print("Generating Occulta glyphless font...") + + font = create_font() + + # Create output directory if needed + OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True) + + # Save the font + font.save(str(OUTPUT_PATH)) + font.close() + + # Report statistics + size = OUTPUT_PATH.stat().st_size + print(f"Saved to: {OUTPUT_PATH}") + print(f"Size: {size:,} bytes") + + # Verify cmap + font = TTFont(str(OUTPUT_PATH)) + for table in font["cmap"].tables: + print( + f"cmap: Platform {table.platformID}, " + f"Encoding {table.platEncID}, " + f"Format {table.format}, " + f"{len(table.cmap)} mappings" + ) + font.close() + + print("Done!") + + +if __name__ == "__main__": + main() diff --git a/snapcraft.yaml b/snapcraft.yaml index b238c765..de9e7589 100644 --- a/snapcraft.yaml +++ b/snapcraft.yaml @@ -5,7 +5,7 @@ name: ocrmypdf title: OCRmyPDF -base: core22 +base: core24 version: git summary: OCRmyPDF adds a searchable text layer to scanned PDF files description: OCRmyPDF packaged for snap @@ -14,12 +14,13 @@ confinement: strict icon: docs/images/logo-square-256.svg license: MPL-2.0 -architectures: [amd64] +platforms: + amd64: environment: - TESSDATA_PREFIX: $SNAP/usr/share/tesseract-ocr/4.00/tessdata - GS_LIB: $SNAP/usr/share/ghostscript/9.55.0/Resource/Init - GS_FONTPATH: $SNAP/usr/share/ghostscript/9.55.0/Resource/Font + TESSDATA_PREFIX: $SNAP/usr/share/tesseract-ocr/5/tessdata + GS_LIB: $SNAP/usr/share/ghostscript/10.02.1/Resource/Init + GS_FONTPATH: $SNAP/usr/share/ghostscript/10.02.1/Resource/Font LD_LIBRARY_PATH: $SNAP/usr/lib/x86_64-linux-gnu apps: @@ -84,11 +85,5 @@ parts: - wheel override-build: | - pip3 install --user dephell[full] - $HOME/.local/bin/dephell deps convert \ - --from-path pyproject.toml \ - --from-format pyproject \ - --to-path setup.py \ - --to-format setuppy - snapcraftctl build - ln -sf ../usr/lib/libsnapcraft-preload.so $SNAPCRAFT_PART_INSTALL/lib/libsnapcraft-preload.so + craftctl default + ln -sf ../usr/lib/libsnapcraft-preload.so $CRAFT_PART_INSTALL/lib/libsnapcraft-preload.so diff --git a/src/ocrmypdf/__init__.py b/src/ocrmypdf/__init__.py index 2ca4aff9..fab78d98 100644 --- a/src/ocrmypdf/__init__.py +++ b/src/ocrmypdf/__init__.py @@ -11,6 +11,7 @@ from ocrmypdf import helpers, hocrtransform, pdfa, pdfinfo from ocrmypdf._concurrent import Executor from ocrmypdf._defaults import PROGRAM_NAME from ocrmypdf._jobcontext import PageContext, PdfContext +from ocrmypdf._options import OcrOptions from ocrmypdf._pipelines._common import ( configure_debug_logging, ) @@ -34,6 +35,13 @@ from ocrmypdf.exceptions import ( TesseractConfigError, UnsupportedImageFormatError, ) +from ocrmypdf.models.ocr_element import ( + Baseline, + BoundingBox, + FontInfo, + OcrClass, + OcrElement, +) from ocrmypdf.pluginspec import OcrEngine, OrientationConfidence hookimpl = _HookimplMarker('ocrmypdf') @@ -41,6 +49,8 @@ hookimpl = _HookimplMarker('ocrmypdf') __all__ = [ '__version__', 'BadArgsError', + 'Baseline', + 'BoundingBox', 'configure_debug_logging', 'configure_logging', 'DpiError', @@ -48,13 +58,17 @@ __all__ = [ 'Executor', 'ExitCode', 'ExitCodeException', + 'FontInfo', 'helpers', 'hocrtransform', 'hookimpl', 'InputFileError', 'MissingDependencyError', 'ocr', + 'OcrClass', + 'OcrElement', 'OcrEngine', + 'OcrOptions', 'OrientationConfidence', 'OutputFileAccessError', 'PageContext', diff --git a/src/ocrmypdf/__main__.py b/src/ocrmypdf/__main__.py index 74a035ef..74ffb3e6 100755 --- a/src/ocrmypdf/__main__.py +++ b/src/ocrmypdf/__main__.py @@ -15,9 +15,9 @@ from contextlib import suppress from ocrmypdf import __version__ from ocrmypdf._pipelines.ocr import run_pipeline_cli -from ocrmypdf._plugin_manager import get_parser_options_plugins from ocrmypdf._validation import check_options from ocrmypdf.api import Verbosity, configure_logging +from ocrmypdf.cli import get_options_and_plugins from ocrmypdf.exceptions import ( BadArgsError, ExitCode, @@ -39,7 +39,7 @@ def sigbus(*args): def run(args=None): """Run the ocrmypdf command line interface.""" - _parser, options, plugin_manager = get_parser_options_plugins(args=args) + options, plugin_manager = get_options_and_plugins(args=args) with suppress(AttributeError, PermissionError): os.nice(5) diff --git a/src/ocrmypdf/_concurrent.py b/src/ocrmypdf/_concurrent.py index b8664c65..ab337e4d 100644 --- a/src/ocrmypdf/_concurrent.py +++ b/src/ocrmypdf/_concurrent.py @@ -15,7 +15,7 @@ from ocrmypdf._progressbar import NullProgressBar, ProgressBar T = TypeVar('T') -def _task_noop(*_args, **_kwargs): +def _task_noop(*_args, **_kwargs) -> None: return @@ -101,8 +101,8 @@ class Executor(ABC): def setup_executor(plugin_manager) -> Executor: - pbar_class = plugin_manager.hook.get_progressbar_class() - return plugin_manager.hook.get_executor(progressbar_class=pbar_class) + pbar_class = plugin_manager.get_progressbar_class() + return plugin_manager.get_executor(progressbar_class=pbar_class) class SerialExecutor(Executor): diff --git a/src/ocrmypdf/_defaults.py b/src/ocrmypdf/_defaults.py index 39674286..c979b8eb 100644 --- a/src/ocrmypdf/_defaults.py +++ b/src/ocrmypdf/_defaults.py @@ -2,6 +2,8 @@ # SPDX-License-Identifier: MPL-2.0 # Enforce English hegemony +from __future__ import annotations + DEFAULT_LANGUAGE = 'eng' # Default rotation threshold diff --git a/src/ocrmypdf/_exec/ghostscript.py b/src/ocrmypdf/_exec/ghostscript.py index 35e058e2..b48f2080 100644 --- a/src/ocrmypdf/_exec/ghostscript.py +++ b/src/ocrmypdf/_exec/ghostscript.py @@ -9,7 +9,6 @@ import logging import os import re from collections import deque -from io import BytesIO from os import fspath from pathlib import Path from subprocess import PIPE, CalledProcessError @@ -23,6 +22,7 @@ from ocrmypdf.exceptions import ( SubprocessOutputError, ) from ocrmypdf.helpers import Resolution +from ocrmypdf.pluginspec import GhostscriptRasterDevice from ocrmypdf.subprocess import get_version, run, run_polling_stderr COLOR_CONVERSION_STRATEGIES = frozenset( @@ -99,15 +99,30 @@ def rasterize_pdf( input_file: os.PathLike, output_file: os.PathLike, *, - raster_device: str, + raster_device: GhostscriptRasterDevice, raster_dpi: Resolution, pageno: int = 1, page_dpi: Resolution | None = None, rotation: int | None = None, filter_vector: bool = False, stop_on_error: bool = False, + use_cropbox: bool = False, ): - """Rasterize one page of a PDF at resolution raster_dpi in canvas units.""" + """Rasterize one page of a PDF at resolution raster_dpi in canvas units. + + Args: + input_file: The PDF file to rasterize. + output_file: The file to write the rasterized PDF to. + raster_device: The Ghostscript raster device to use to rasterize the PDF. + raster_dpi: Resolution in dots per inch at which to rasterize page. + pageno: Page number to rasterize (beginning at page 1). + page_dpi: Resolution, overriding output image DPI. + rotation: Cardinal angle, clockwise, to rotate page. + filter_vector: If True, remove vector graphics objects. + stop_on_error: If True, stop rasterizing on the first error. + use_cropbox: If True, rasterize the CropBox instead of MediaBox. + Default is False (use MediaBox). + """ raster_dpi = raster_dpi.round(6) if not page_dpi: page_dpi = raster_dpi @@ -124,6 +139,7 @@ def rasterize_pdf( f'-dLastPage={pageno}', f'-r{raster_dpi.x:f}x{raster_dpi.y:f}', ] + + (['-dUseCropBox'] if use_cropbox else []) + (['-dFILTERVECTOR'] if filter_vector else []) + (['-dPDFSTOPONERROR'] if stop_on_error else []) + [ diff --git a/src/ocrmypdf/_exec/jbig2enc.py b/src/ocrmypdf/_exec/jbig2enc.py index 1c6dd5fe..736de67e 100644 --- a/src/ocrmypdf/_exec/jbig2enc.py +++ b/src/ocrmypdf/_exec/jbig2enc.py @@ -31,24 +31,6 @@ def available(): return True -def convert_group(cwd, infiles, out_prefix, threshold): - args = [ - 'jbig2', - '-b', - out_prefix, - '--symbol-mode', # symbol mode (lossy) - '-t', - str(threshold), # threshold - # '-r', # refinement mode (lossless symbol mode, currently disabled in - # jbig2) - '--pdf', - ] - args.extend(infiles) - proc = run(args, cwd=cwd, stdout=PIPE, stderr=PIPE) - proc.check_returncode() - return proc - - def convert_single(cwd, infile, outfile, threshold): args = ['jbig2', '--pdf', '-t', str(threshold), infile] with open(outfile, 'wb') as fstdout: diff --git a/src/ocrmypdf/_exec/tesseract.py b/src/ocrmypdf/_exec/tesseract.py index c27a677c..d41a0af7 100644 --- a/src/ocrmypdf/_exec/tesseract.py +++ b/src/ocrmypdf/_exec/tesseract.py @@ -6,8 +6,10 @@ from __future__ import annotations import logging +import os import re from contextlib import suppress +from enum import IntEnum from math import pi from os import fspath from pathlib import Path @@ -26,11 +28,30 @@ from ocrmypdf.subprocess import get_version, run log = logging.getLogger(__name__) +def _tesseract_env(omp_thread_limit: int | None) -> dict[str, str] | None: + """Create environment dict with OMP_THREAD_LIMIT set for Tesseract subprocesses.""" + if omp_thread_limit is None: + return None + env = os.environ.copy() + env['OMP_THREAD_LIMIT'] = str(omp_thread_limit) + return env + + +class ThresholdingMethod(IntEnum): + """Tesseract thresholding methods for image binarization.""" + + AUTO = 0 + OTSU = 0 # Alias for AUTO - uses Tesseract's default (legacy Otsu) + ADAPTIVE_OTSU = 1 + SAUVOLA = 2 + + +# Legacy dictionary for backward compatibility TESSERACT_THRESHOLDING_METHODS: dict[str, int] = { - 'auto': 0, - 'otsu': 0, - 'adaptive-otsu': 1, - 'sauvola': 2, + 'auto': ThresholdingMethod.AUTO, + 'otsu': ThresholdingMethod.OTSU, + 'adaptive-otsu': ThresholdingMethod.ADAPTIVE_OTSU, + 'sauvola': ThresholdingMethod.SAUVOLA, } @@ -155,7 +176,10 @@ def _parse_tesseract_output(binary_output: bytes) -> dict[str, str]: def get_orientation( - input_file: Path, engine_mode: int | None, timeout: float + input_file: Path, + engine_mode: int | None, + timeout: float, + omp_thread_limit: int | None = None, ) -> OrientationConfidence: args_tesseract = tess_base_args(['osd'], engine_mode) + [ '--psm', @@ -165,15 +189,24 @@ def get_orientation( ] try: - p = run(args_tesseract, stdout=PIPE, stderr=STDOUT, timeout=timeout, check=True) + p = run( + args_tesseract, + stdout=PIPE, + stderr=STDOUT, + timeout=timeout, + check=True, + env=_tesseract_env(omp_thread_limit), + ) except TimeoutExpired: return OrientationConfidence(angle=0, confidence=0.0) except CalledProcessError as e: tesseract_log_output(e.stdout) tesseract_log_output(e.stderr) + # Check both stdout (e.output) and stderr for known non-fatal messages + all_output = (e.output or b'') + (e.stderr or b'') if ( - b'Too few characters. Skipping this page' in e.output - or b'Image too large' in e.output + b'Too few characters. Skipping this page' in all_output + or b'Image too large' in all_output ): return OrientationConfidence(0, 0) raise SubprocessOutputError() from e @@ -199,7 +232,11 @@ def _is_empty_page_error(exc): def get_deskew( - input_file: Path, languages: list[str], engine_mode: int | None, timeout: float + input_file: Path, + languages: list[str], + engine_mode: int | None, + timeout: float, + omp_thread_limit: int | None = None, ) -> float: """Gets angle to deskew this page, in degrees.""" args_tesseract = tess_base_args(languages, engine_mode) + [ @@ -210,7 +247,14 @@ def get_deskew( ] try: - p = run(args_tesseract, stdout=PIPE, stderr=STDOUT, timeout=timeout, check=True) + p = run( + args_tesseract, + stdout=PIPE, + stderr=STDOUT, + timeout=timeout, + check=True, + env=_tesseract_env(omp_thread_limit), + ) except TimeoutExpired: return 0.0 except CalledProcessError as e: @@ -243,9 +287,9 @@ def tesseract_log_output(stream: bytes) -> None: lines = text.splitlines() for line in lines: - if line.startswith("Tesseract Open Source"): - continue - elif line.startswith("Warning in pixReadMem"): + if line.startswith( + ("Tesseract Open Source", "Warning in pixReadMem") + ): continue elif 'diacritics' in line: tlog.warning("lots of diacritics - possibly poor OCR") @@ -294,9 +338,10 @@ def generate_hocr( tessconfig: list[str], timeout: float, pagesegmode: int, - thresholding: int, + thresholding: ThresholdingMethod, user_words, user_patterns, + omp_thread_limit: int | None = None, ) -> None: """Generate a hOCR file, which must be converted to PDF.""" prefix = output_hocr.with_suffix('') @@ -306,7 +351,7 @@ def generate_hocr( if pagesegmode is not None: args_tesseract.extend(['--psm', str(pagesegmode)]) - if thresholding != 0 and has_thresholding(): + if thresholding != ThresholdingMethod.AUTO and has_thresholding(): args_tesseract.extend(['-c', f'thresholding_method={thresholding}']) if user_words: @@ -320,7 +365,14 @@ def generate_hocr( args_tesseract.extend([fspath(input_file), fspath(prefix), 'hocr', 'txt']) args_tesseract.extend(tessconfig) try: - p = run(args_tesseract, stdout=PIPE, stderr=STDOUT, timeout=timeout, check=True) + p = run( + args_tesseract, + stdout=PIPE, + stderr=STDOUT, + timeout=timeout, + check=True, + env=_tesseract_env(omp_thread_limit), + ) stdout = p.stdout except TimeoutExpired: # Generate a HOCR file with no recognized text if tesseract times out @@ -360,9 +412,10 @@ def generate_pdf( tessconfig: list[str], timeout: float, pagesegmode: int, - thresholding: int, + thresholding: ThresholdingMethod, user_words, user_patterns, + omp_thread_limit: int | None = None, ) -> None: """Generate a PDF using Tesseract's internal PDF generator. @@ -376,7 +429,7 @@ def generate_pdf( args_tesseract.extend(['-c', 'textonly_pdf=1']) - if thresholding != 0 and has_thresholding(): + if thresholding != ThresholdingMethod.AUTO and has_thresholding(): args_tesseract.extend(['-c', f'thresholding_method={thresholding}']) if user_words: @@ -393,7 +446,14 @@ def generate_pdf( args_tesseract.extend([fspath(input_file), fspath(prefix), 'pdf', 'txt']) args_tesseract.extend(tessconfig) try: - p = run(args_tesseract, stdout=PIPE, stderr=STDOUT, timeout=timeout, check=True) + p = run( + args_tesseract, + stdout=PIPE, + stderr=STDOUT, + timeout=timeout, + check=True, + env=_tesseract_env(omp_thread_limit), + ) stdout = p.stdout with suppress(FileNotFoundError): prefix.with_suffix('.txt').replace(output_text) diff --git a/src/ocrmypdf/_exec/verapdf.py b/src/ocrmypdf/_exec/verapdf.py new file mode 100644 index 00000000..e087b440 --- /dev/null +++ b/src/ocrmypdf/_exec/verapdf.py @@ -0,0 +1,108 @@ +# SPDX-FileCopyrightText: 2024 James R. Barlow +# SPDX-License-Identifier: MPL-2.0 + +"""Interface to verapdf executable.""" + +from __future__ import annotations + +import json +import logging +from pathlib import Path +from subprocess import PIPE +from typing import NamedTuple + +from packaging.version import Version + +from ocrmypdf.exceptions import MissingDependencyError +from ocrmypdf.subprocess import get_version, run + +log = logging.getLogger(__name__) + + +class ValidationResult(NamedTuple): + """Result of PDF/A validation.""" + + valid: bool + failed_rules: int + message: str + + +def version() -> Version: + """Get verapdf version.""" + return Version(get_version('verapdf', regex=r'veraPDF (\d+(\.\d+)*)')) + + +def available() -> bool: + """Check if verapdf is available.""" + try: + version() + except MissingDependencyError: + return False + return True + + +def output_type_to_flavour(output_type: str) -> str: + """Map OCRmyPDF output_type to verapdf flavour. + + Args: + output_type: One of 'pdfa', 'pdfa-1', 'pdfa-2', 'pdfa-3' + + Returns: + verapdf flavour string like '1b', '2b', '3b' + """ + mapping = { + 'pdfa': '2b', + 'pdfa-1': '1b', + 'pdfa-2': '2b', + 'pdfa-3': '3b', + } + return mapping.get(output_type, '2b') + + +def validate(input_file: Path, flavour: str) -> ValidationResult: + """Validate a PDF against a PDF/A profile. + + Args: + input_file: Path to PDF file to validate + flavour: verapdf flavour (1a, 1b, 2a, 2b, 2u, 3a, 3b, 3u) + + Returns: + ValidationResult with validation status + """ + args = [ + 'verapdf', + '--format', + 'json', + '--flavour', + flavour, + str(input_file), + ] + + try: + proc = run(args, stdout=PIPE, stderr=PIPE, check=False) + except FileNotFoundError as e: + raise MissingDependencyError('verapdf') from e + + try: + result = json.loads(proc.stdout) + jobs = result.get('report', {}).get('jobs', []) + if not jobs: + return ValidationResult(False, -1, 'No validation jobs in result') + validation_results = jobs[0].get('validationResult', []) + if not validation_results: + return ValidationResult(False, -1, 'No validation result in output') + validation_result = validation_results[0] + details = validation_result.get('details', {}) + failed_rules = details.get('failedRules', 0) + + if failed_rules == 0: + return ValidationResult(True, 0, 'PDF/A validation passed') + else: + return ValidationResult( + False, + failed_rules, + f'PDF/A validation failed with {failed_rules} rule violations', + ) + except (json.JSONDecodeError, KeyError, TypeError) as e: + log.debug('Failed to parse verapdf output: %s', e) + return ValidationResult(False, -1, f'Failed to parse verapdf output: {e}') diff --git a/src/ocrmypdf/_graft.py b/src/ocrmypdf/_graft.py index a740e053..53098b54 100644 --- a/src/ocrmypdf/_graft.py +++ b/src/ocrmypdf/_graft.py @@ -7,30 +7,155 @@ from __future__ import annotations import logging from contextlib import suppress +from dataclasses import dataclass from enum import Enum from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ocrmypdf.hocrtransform import OcrElement from pikepdf import ( Dictionary, - Matrix, Name, Operator, Page, Pdf, - PdfError, Stream, parse_content_stream, unparse_content_stream, ) from ocrmypdf._jobcontext import PdfContext +from ocrmypdf._options import ProcessingMode +from ocrmypdf._pipeline import VECTOR_PAGE_DPI class RenderMode(Enum): + """Controls where the OCR text layer is placed relative to page content. + + ON_TOP: Text layer renders above page content (reserved for future use). + UNDERNEATH: Text layer renders below page content (current default behavior). + """ + ON_TOP = 0 UNDERNEATH = 1 +@dataclass +class Fpdf2PageInfo: + """Information needed to render and graft an fpdf2 page.""" + + pageno: int + hocr_path: Path + dpi: float + autorotate_correction: int + emplaced_page: bool + + +@dataclass +class Fpdf2ParsedPage: + """Parsed page data ready for fpdf2 rendering.""" + + pageno: int + ocr_tree: OcrElement + dpi: float + autorotate_correction: int + emplaced_page: bool + + +# Alias for backward compatibility with plan documentation +Fpdf2DirectPage = Fpdf2ParsedPage + + +def _compute_text_misalignment( + content_rotation: int, autorotate_correction: int, emplaced_page: bool +) -> int: + """Compute rotation needed to align text layer with page content. + + Args: + content_rotation: Original page /Rotate value (degrees). + autorotate_correction: Rotation applied during rasterization (degrees). + emplaced_page: Whether the page content was replaced with rasterized image. + + Returns: + Rotation in degrees to apply to text layer to align with content. + """ + if emplaced_page: + # New image is upright after autorotation was applied + content_rotation = autorotate_correction + text_rotation = autorotate_correction + return (text_rotation - content_rotation) % 360 + + +def _compute_page_rotation( + content_rotation: int, autorotate_correction: int, emplaced_page: bool +) -> int: + """Compute final page /Rotate value after grafting. + + Args: + content_rotation: Original page /Rotate value (degrees). + autorotate_correction: Rotation applied during rasterization (degrees). + emplaced_page: Whether the page content was replaced with rasterized image. + + Returns: + Final /Rotate value for the page. + """ + if emplaced_page: + content_rotation = autorotate_correction + return (content_rotation - autorotate_correction) % 360 + + +def _build_text_layer_ctm( + text_width: float, + text_height: float, + page_width: float, + page_height: float, + page_origin_x: float, + page_origin_y: float, + text_rotation: int, +): + """Build transformation matrix to align text layer with page content. + + Args: + text_width: Width of text layer mediabox. + text_height: Height of text layer mediabox. + page_width: Width of target page mediabox. + page_height: Height of target page mediabox. + page_origin_x: X origin of target page mediabox. + page_origin_y: Y origin of target page mediabox. + text_rotation: Rotation in degrees (clockwise) to apply to text layer. + + Returns: + pikepdf.Matrix transformation matrix, or None if no rotation needed. + """ + if text_rotation == 0: + return None + + from pikepdf import Matrix + + wt, ht = text_width, text_height + + # Center text, rotate, scale to fit page, then position at page origin + translate = Matrix().translated(-wt / 2, -ht / 2) + untranslate = Matrix().translated(page_width / 2, page_height / 2) + corner = Matrix().translated(page_origin_x, page_origin_y) + + # Negate rotation because input is clockwise angle + rotate = Matrix().rotated(-text_rotation % 360) + + # Swap dimensions if 90 or 270 degree rotation + if text_rotation in (90, 270): + wt, ht = ht, wt + + # Scale to fit page dimensions + scale_x = page_width / wt if wt else 1.0 + scale_y = page_height / ht if ht else 1.0 + scale = Matrix().scaled(scale_x, scale_y) + + return translate @ rotate @ scale @ untranslate @ corner + + log = logging.getLogger(__name__) MAX_REPLACE_PAGES = 100 @@ -41,22 +166,6 @@ def _ensure_dictionary(obj: Dictionary | Stream, name: Name): return obj[name] -def _update_resources( - *, - obj: Dictionary | Stream, - font: Dictionary | None, - font_key: Name | None, -): - """Update this obj's fonts with a reference to the Glyphless font. - - obj can be a page or Form XObject. - """ - resources = _ensure_dictionary(obj, Name.Resources) - fonts = _ensure_dictionary(resources, Name.Font) - if font_key is not None and font_key not in fonts: - fonts[font_key] = font - - def strip_invisible_text(pdf: Pdf, page: Page): stream = [] in_text_obj = False @@ -72,12 +181,9 @@ def strip_invisible_text(pdf: Pdf, page: Page): render_mode_stack.append(render_mode) if operator == Operator('Q'): - try: + # IndexError is raised if stack is empty; try to carry on + with suppress(IndexError): render_mode = render_mode_stack.pop() - except IndexError: - # Stack underflow: content stream is malformed - # but try to carry on - pass if not in_text_obj: if operator == Operator('BT'): @@ -105,27 +211,45 @@ class OcrGrafter: self.path_base = context.origin self.pdf_base = Pdf.open(self.path_base) - self.font: Dictionary | None = None - self.font_key: Name | None = None self.pdfinfo = context.pdfinfo self.output_file = context.get_path('graft_layers.pdf') self.emplacements = 1 - self.interim_count = 0 self.render_mode = RenderMode.UNDERNEATH + # Check renderer type + pdf_renderer = context.options.pdf_renderer + self.use_sandwich_renderer = pdf_renderer == 'sandwich' + + # For fpdf2: accumulate pages before rendering + self.fpdf2_hocr_pages: list[Fpdf2PageInfo] = [] + self.fpdf2_parsed_pages: list[Fpdf2ParsedPage] = [] + def graft_page( self, *, pageno: int, image: Path | None, - textpdf: Path | None, + ocr_output: Path | None, + ocr_tree: OcrElement | None, autorotate_correction: int, ): - if textpdf and not self.font: - self.font, self.font_key = self._find_font(textpdf) + """Graft OCR output onto a page of the base PDF. + Args: + pageno: Zero-based page number. + image: Path to the visible page image PDF, or None if not replacing. + ocr_output: Path to OCR output file. For fpdf2 renderer this is an + hOCR file; for sandwich renderer this is a text-only PDF. + ocr_tree: OCR tree for fpdf2 renderer. + autorotate_correction: Orientation correction in degrees (0, 90, 180, 270). + """ + if ocr_output and ocr_tree: + raise ValueError( + 'Cannot specify both ocr_output and ocr_tree for fpdf2 renderer' + ) + # Handle image emplacement first emplaced_page = False content_rotation = self.pdfinfo[pageno].rotation path_image = Path(image).resolve() if image else None @@ -144,195 +268,339 @@ class OcrGrafter: del self.pdf_base.pages[-1] emplaced_page = True - # Calculate if the text is misaligned compared to the content - if emplaced_page: - content_rotation = autorotate_correction - text_rotation = autorotate_correction - text_misaligned = (text_rotation - content_rotation) % 360 - log.debug( - f"Text rotation: (text, autorotate, content) -> text misalignment = " - f"({text_rotation}, {autorotate_correction}, {content_rotation}) -> " - f"{text_misaligned}" - ) - - if textpdf and self.font: - if self.font_key is None: - raise ValueError("Font key is not set") - # Graft the text layer onto this page, whether new or old, possibly - # rotating the text layer by the amount is misaligned. - strip_old = self.context.options.redo_ocr - self._graft_text_layer( - page_num=pageno + 1, - textpdf=textpdf, - font=self.font, - font_key=self.font_key, - text_rotation=text_misaligned, - strip_old_text=strip_old, - ) - - # Correct the overall page rotation if needed, now that the text and content - # are aligned - page_rotation = (content_rotation - autorotate_correction) % 360 - self.pdf_base.pages[pageno].Rotate = page_rotation - log.debug( - f"Page rotation: (content, auto) -> page = " - f"({content_rotation}, {autorotate_correction}) -> {page_rotation}" - ) - if self.emplacements % MAX_REPLACE_PAGES == 0: - self.save_and_reload() - - def save_and_reload(self) -> None: - """Save and reload the Pdf. - - This will keep a lid on our memory usage for very large files. Attach - the font to page 1 even if page 1 doesn't use it, so we have a way to get it - back. - """ - page0 = self.pdf_base.pages[0] - _update_resources(obj=page0.obj, font=self.font, font_key=self.font_key) - - # We cannot read and write the same file, that will corrupt it - # but we don't to keep more copies than we need to. Delete intermediates. - # {interim_count} is the opened file we were updating - # {interim_count - 1} can be deleted - # {interim_count + 1} is the new file will produce and open - old_file = self.output_file.with_suffix(f'.working{self.interim_count - 1}.pdf') - if not self.context.options.keep_temporary_files: - with suppress(FileNotFoundError): - old_file.unlink() - - next_file = self.output_file.with_suffix( - f'.working{self.interim_count + 1}.pdf' - ) - self.pdf_base.save(next_file) - self.pdf_base.close() - - self.pdf_base = Pdf.open(next_file) - self.font, self.font_key = None, None # Ensure we reacquire this information - self.interim_count += 1 + if self.use_sandwich_renderer: + # Sandwich renderer: graft pre-rendered PDF immediately + if ocr_output: + text_misaligned = _compute_text_misalignment( + content_rotation, autorotate_correction, emplaced_page + ) + self._graft_sandwich_text_layer( + pageno=pageno, + textpdf=ocr_output, + text_rotation=text_misaligned, + ) + page_rotation = _compute_page_rotation( + content_rotation, autorotate_correction, emplaced_page + ) + self.pdf_base.pages[pageno].Rotate = page_rotation + else: + # fpdf2 renderer: accumulate page info for batch rendering. + # The hOCR coordinates are in the corrected (upright) coordinate system. + # We store autorotate_correction and emplaced_page to set the final + # page /Rotate tag after grafting. + if ocr_tree: + self.fpdf2_parsed_pages.append( + Fpdf2ParsedPage( + ocr_tree=ocr_tree, + pageno=pageno, + autorotate_correction=autorotate_correction, + emplaced_page=emplaced_page, + dpi=self.pdfinfo[pageno].dpi.to_scalar(), + ) + ) + if ocr_output: + self.fpdf2_hocr_pages.append( + Fpdf2PageInfo( + hocr_path=ocr_output, + pageno=pageno, + autorotate_correction=autorotate_correction, + emplaced_page=emplaced_page, + dpi=self.pdfinfo[pageno].dpi.to_scalar(), + ) + ) def finalize(self): + # Can have hocr OR parsed pages OR neither (no OCR), but not both + assert not ( + self.fpdf2_hocr_pages and self.fpdf2_parsed_pages + ), "Can't have both hocr and ocrtree pages" + + if self.fpdf2_hocr_pages: + # Render all pages with fpdf2, then graft + parsed_pages = self._parse_hocr_pages() + self.fpdf2_parsed_pages = parsed_pages + + if self.fpdf2_parsed_pages: + self._render_and_graft_fpdf2_pages() + self.pdf_base.save(self.output_file) self.pdf_base.close() return self.output_file - def _find_font(self, text: Path) -> tuple[Dictionary | None, Name | None]: - """Copy a font from the filename text into pdf_base.""" - font, font_key = None, None - possible_font_names = ('/f-0-0', '/F1') - try: - with Pdf.open(text) as pdf_text: - try: - pdf_text_fonts = pdf_text.pages[0].Resources.get( - Name.Font, Dictionary() - ) - except (AttributeError, IndexError, KeyError): - return None, None - if not isinstance(pdf_text_fonts, Dictionary): - log.warning("Page fonts are not stored in a dictionary") - return None, None - pdf_text_font = None - for f in possible_font_names: - pdf_text_font = pdf_text_fonts.get(f, None) - if pdf_text_font is not None: - font_key = Name(f) - break - if pdf_text_font: - font = self.pdf_base.copy_foreign(pdf_text_font) - if not isinstance(font, Dictionary): - log.warning("Font is not a dictionary") - font, font_key = None, None - return font, font_key - except (FileNotFoundError, PdfError): - # PdfError occurs if a 0-length file is written e.g. due to OCR timeout - return None, None + def _parse_hocr_pages(self): + """Render all pages to multi-page PDF with shared fonts, then graft.""" + from ocrmypdf.hocrtransform.hocr_parser import HocrParser - def _graft_text_layer( + log.info( + "Parsing %d pages with HocrParser", + len(self.fpdf2_hocr_pages), + ) + + # Parse all hOCR files and collect OcrElements + pages_data: list[Fpdf2ParsedPage] = [] + for page_info in self.fpdf2_hocr_pages: + if page_info.hocr_path.stat().st_size == 0: + continue # Skip empty pages + + # Parse hOCR to OcrElement + parser = HocrParser(page_info.hocr_path) + ocr_tree = parser.parse() + + # Use DPI from hOCR (scan_res) which reflects actual rasterization DPI. + # Fall back to pdfinfo DPI or VECTOR_PAGE_DPI for vector-only pages. + effective_dpi = ocr_tree.dpi or page_info.dpi or float(VECTOR_PAGE_DPI) + pages_data.append( + Fpdf2ParsedPage( + pageno=page_info.pageno, + ocr_tree=ocr_tree, + dpi=effective_dpi, + autorotate_correction=page_info.autorotate_correction, + emplaced_page=page_info.emplaced_page, + ) + ) + + return pages_data + + def _render_and_graft_fpdf2_pages(self): + font_dir = Path(__file__).parent / "data" + + # Render all pages to single PDF + multi_page_pdf_path = self.context.get_path('fpdf2_multipage.pdf') + + from ocrmypdf.font import MultiFontManager + from ocrmypdf.fpdf_renderer import Fpdf2MultiPageRenderer + + multi_font_manager = MultiFontManager(font_dir) + # Build renderer input as (pageno, ocr_tree, dpi) tuples + renderer_pages_data = [ + (parsed.pageno, parsed.ocr_tree, parsed.dpi) + for parsed in self.fpdf2_parsed_pages + ] + renderer = Fpdf2MultiPageRenderer( + pages_data=renderer_pages_data, + multi_font_manager=multi_font_manager, + invisible_text=True, + ) + + renderer.render(multi_page_pdf_path) + + # Now graft each page from the multi-page PDF + with Pdf.open(multi_page_pdf_path) as pdf_text: + for idx, parsed in enumerate(self.fpdf2_parsed_pages): + # Copy page from multi-page PDF + text_page = pdf_text.pages[idx] + + content_rotation = self.pdfinfo[parsed.pageno].rotation + text_misaligned = _compute_text_misalignment( + content_rotation, + parsed.autorotate_correction, + parsed.emplaced_page, + ) + self._graft_fpdf2_text_layer(parsed.pageno, text_page, text_misaligned) + + page_rotation = _compute_page_rotation( + content_rotation, + parsed.autorotate_correction, + parsed.emplaced_page, + ) + self.pdf_base.pages[parsed.pageno].Rotate = page_rotation + + # Clean up multi-page PDF if not keeping temp files + if not self.context.options.keep_temporary_files: + with suppress(FileNotFoundError): + multi_page_pdf_path.unlink() + + def _graft_fpdf2_text_layer(self, pageno: int, text_page: Page, text_rotation: int): + """Graft a single text page onto the base PDF. + + Similar to existing _graft_text_layer but works with + already-rendered pikepdf Page instead of file path. + + Args: + pageno: Zero-based page number. + text_page: The text-only PDF page to graft. + text_rotation: Rotation to apply to align text with content (degrees). + """ + from pikepdf import Array + + base_page = self.pdf_base.pages[pageno] + + # Extract content stream from text_page + text_contents = text_page.Contents.read_bytes() + + # Get the mediabox from the text page + mediabox = Array([float(x) for x in text_page.mediabox]) # type: ignore[misc] + wt = float(mediabox[2]) - float(mediabox[0]) + ht = float(mediabox[3]) - float(mediabox[1]) + + # Get base page mediabox + base_mediabox = base_page.mediabox + wp = float(base_mediabox[2]) - float(base_mediabox[0]) + hp = float(base_mediabox[3]) - float(base_mediabox[1]) + + # Create Form XObject from text page content + base_resources = _ensure_dictionary(base_page.obj, Name.Resources) + base_xobjs = _ensure_dictionary(base_resources, Name.XObject) + text_xobj_name = Name.random(prefix="OCR-") + xobj = self.pdf_base.make_stream(text_contents) + base_xobjs[text_xobj_name] = xobj + xobj.Type = Name.XObject + xobj.Subtype = Name.Form + xobj.FormType = 1 + xobj.BBox = mediabox + + # Copy resources from text page's Resources to xobj + # We need to handle this carefully since text_page is from a foreign PDF + if hasattr(text_page, 'Resources') and text_page.Resources: + # Create empty Resources dictionary for xobj + xobj_resources = _ensure_dictionary(xobj, Name.Resources) + + # Copy fonts if they exist + if Name.Font in text_page.Resources: + xobj_fonts = _ensure_dictionary(xobj_resources, Name.Font) + text_fonts = text_page.Resources[Name.Font] + # Copy each font from the foreign PDF + for font_name, font_obj in text_fonts.items(): + xobj_fonts[font_name] = self.pdf_base.copy_foreign(font_obj) + + # Copy ExtGState (graphics state) if it exists - needed for transparency + if Name.ExtGState in text_page.Resources: + xobj_extstates = _ensure_dictionary(xobj_resources, Name.ExtGState) + text_extstates = text_page.Resources[Name.ExtGState] + # Copy each graphics state from the foreign PDF + for gs_name, gs_obj in text_extstates.items(): + xobj_extstates[gs_name] = self.pdf_base.copy_foreign(gs_obj) + + # Build transformation matrix for rotation and scaling + ctm = _build_text_layer_ctm( + wt, + ht, + wp, + hp, + float(base_mediabox[0]), + float(base_mediabox[1]), + text_rotation, + ) + if ctm is not None: + pdf_draw_xobj = ( + (b'q %s cm\n' % ctm.encode()) + (b'%s Do\n' % text_xobj_name) + b'Q\n' + ) + else: + pdf_draw_xobj = b'q\n' + (b'%s Do\n' % text_xobj_name) + b'\nQ\n' + + new_text_layer = Stream(self.pdf_base, pdf_draw_xobj) + + # Strip old invisible text if redo mode is enabled + if self.context.options.mode == ProcessingMode.redo: + strip_invisible_text(self.pdf_base, base_page) + + # Add text layer to base page + base_page.contents_coalesce() + base_page.contents_add( + new_text_layer, prepend=self.render_mode == RenderMode.UNDERNEATH + ) + base_page.contents_coalesce() + + def _graft_sandwich_text_layer( self, *, - page_num: int, + pageno: int, textpdf: Path, - font: Dictionary, - font_key: Name, text_rotation: int, - strip_old_text: bool, ): - """Insert the text layer from text page 0 on to pdf_base at page_num.""" - # pylint: disable=invalid-name + """Graft a pre-rendered text-only PDF onto the base PDF. - log.debug("Grafting") + This is used by the sandwich renderer which generates PDFs directly + from Tesseract rather than going through hOCR. + """ + from pikepdf import PdfError + + log.debug("Grafting sandwich text layer") if Path(textpdf).stat().st_size == 0: return - # This is a pointer indicating a specific page in the base file - with Pdf.open(textpdf) as pdf_text: - pdf_text_contents = pdf_text.pages[0].Contents.read_bytes() + try: + with Pdf.open(textpdf) as pdf_text: + pdf_text_contents = pdf_text.pages[0].Contents.read_bytes() - base_page = self.pdf_base.pages.p(page_num) + base_page = self.pdf_base.pages[pageno] - # The text page always will be oriented up by this stage but the original - # content may have a rotation applied. Wrap the text stream with a rotation - # so it will be oriented the same way as the rest of the page content. - # (Previous versions OCRmyPDF rotated the content layer to match the text.) - mediabox = pdf_text.pages[0].mediabox - wt, ht = mediabox[2] - mediabox[0], mediabox[3] - mediabox[1] + # Get font from the text PDF + pdf_text_fonts = pdf_text.pages[0].Resources.get( + Name.Font, Dictionary() + ) + font = None + font_key = None + for f in ('/f-0-0', '/F1'): + pdf_text_font = pdf_text_fonts.get(f, None) + if pdf_text_font is not None: + font_key = Name(f) + font = self.pdf_base.copy_foreign(pdf_text_font) + break - mediabox = base_page.mediabox - wp, hp = mediabox[2] - mediabox[0], mediabox[3] - mediabox[1] + # Get mediabox dimensions for rotation calculations + mediabox = pdf_text.pages[0].mediabox + wt = float(mediabox[2]) - float(mediabox[0]) + ht = float(mediabox[3]) - float(mediabox[1]) - translate = Matrix().translated(-wt / 2, -ht / 2) - untranslate = Matrix().translated(wp / 2, hp / 2) - corner = Matrix().translated(mediabox[0], mediabox[1]) - # -rotation because the input is a clockwise angle and this formula - # uses CCW - text_rotation = -text_rotation % 360 - rotate = Matrix().rotated(text_rotation) + base_mediabox = base_page.mediabox + wp = float(base_mediabox[2]) - float(base_mediabox[0]) + hp = float(base_mediabox[3]) - float(base_mediabox[1]) - # Because of rounding of DPI, we might get a text layer that is not - # identically sized to the target page. Scale to adjust. Normally this - # is within 0.998. - if text_rotation in (90, 270): - wt, ht = ht, wt - scale_x = wp / wt - scale_y = hp / ht + # Build transformation matrix for rotation and scaling + ctm = _build_text_layer_ctm( + wt, + ht, + wp, + hp, + float(base_mediabox[0]), + float(base_mediabox[1]), + text_rotation, + ) + log.debug("Grafting with ctm %r", ctm) - # log.debug('%r', scale_x, scale_y) - scale = Matrix().scaled(scale_x, scale_y) + # Create Form XObject + base_resources = _ensure_dictionary(base_page.obj, Name.Resources) + base_xobjs = _ensure_dictionary(base_resources, Name.XObject) + text_xobj_name = Name.random(prefix="OCR-") + xobj = self.pdf_base.make_stream(pdf_text_contents) + base_xobjs[text_xobj_name] = xobj + xobj.Type = Name.XObject + xobj.Subtype = Name.Form + xobj.FormType = 1 + xobj.BBox = base_mediabox - # Translate the text so it is centered at (0, 0), rotate it there, adjust - # for a size different between initial and text PDF, then untranslate, and - # finally move the lower left corner to match the mediabox. - ctm = translate @ rotate @ scale @ untranslate @ corner - log.debug("Grafting with ctm %r", ctm) + # Add font to xobj resources + if font_key is not None and font is not None: + xobj_resources = _ensure_dictionary(xobj, Name.Resources) + xobj_fonts = _ensure_dictionary(xobj_resources, Name.Font) + if font_key not in xobj_fonts: + xobj_fonts[font_key] = font - base_resources = _ensure_dictionary(base_page.obj, Name.Resources) - base_xobjs = _ensure_dictionary(base_resources, Name.XObject) - text_xobj_name = Name.random(prefix="OCR-") - xobj = self.pdf_base.make_stream(pdf_text_contents) - base_xobjs[text_xobj_name] = xobj - xobj.Type = Name.XObject - xobj.Subtype = Name.Form - xobj.FormType = 1 - xobj.BBox = mediabox - _update_resources(obj=xobj, font=font, font_key=font_key) + if ctm is not None: + pdf_draw_xobj = ( + (b'q %s cm\n' % ctm.encode()) + + (b'%s Do\n' % text_xobj_name) + + b'\nQ\n' + ) + else: + pdf_draw_xobj = b'q\n' + (b'%s Do\n' % text_xobj_name) + b'\nQ\n' + new_text_layer = Stream(self.pdf_base, pdf_draw_xobj) - pdf_draw_xobj = ( - (b'q %s cm\n' % ctm.encode()) + (b'%s Do\n' % text_xobj_name) + b'\nQ\n' - ) - new_text_layer = Stream(self.pdf_base, pdf_draw_xobj) + if self.context.options.mode == ProcessingMode.redo: + strip_invisible_text(self.pdf_base, base_page) + base_page.contents_coalesce() + base_page.contents_add( + new_text_layer, prepend=self.render_mode == RenderMode.UNDERNEATH + ) + base_page.contents_coalesce() - if strip_old_text: - strip_invisible_text(self.pdf_base, base_page) - base_page.contents_coalesce() - if self.render_mode == RenderMode.ON_TOP: - # Add q/Q to ensure content we append is drawn correctly - # Strictly speaking this needs to trace the whole q/Q stack in case - # stack is not balanced. - original = base_page.Contents.read_bytes() - base_page.Contents.write(b'q\n' + original + b'\nQ\n') - base_page.contents_add( - new_text_layer, prepend=self.render_mode == RenderMode.UNDERNEATH - ) - base_page.contents_coalesce() - - _update_resources(obj=base_page.obj, font=font, font_key=font_key) + # Add font to page resources + if font_key is not None and font is not None: + page_resources = _ensure_dictionary(base_page.obj, Name.Resources) + page_fonts = _ensure_dictionary(page_resources, Name.Font) + if font_key not in page_fonts: + page_fonts[font_key] = font + except (FileNotFoundError, PdfError): + # PdfError occurs if a 0-length file is written e.g. due to OCR timeout + pass diff --git a/src/ocrmypdf/_jobcontext.py b/src/ocrmypdf/_jobcontext.py index b1c775d9..15ebd5e6 100644 --- a/src/ocrmypdf/_jobcontext.py +++ b/src/ocrmypdf/_jobcontext.py @@ -5,29 +5,31 @@ from __future__ import annotations -import os -from argparse import Namespace from collections.abc import Iterator -from copy import copy from pathlib import Path +from typing import TYPE_CHECKING -from pluggy import PluginManager - +from ocrmypdf._options import OcrOptions from ocrmypdf.pdfinfo import PdfInfo from ocrmypdf.pdfinfo.info import PageInfo +if TYPE_CHECKING: + from ocrmypdf._plugin_manager import OcrmypdfPluginManager + class PdfContext: """Holds the context for a particular run of the pipeline.""" - options: Namespace #: The specified options for processing this PDF. + options: OcrOptions #: The specified options for processing this PDF. origin: Path #: The filename of the original input file. pdfinfo: PdfInfo #: Detailed data for this PDF. - plugin_manager: PluginManager #: PluginManager for processing the current PDF. + plugin_manager: ( + OcrmypdfPluginManager #: PluginManager for processing the current PDF. + ) def __init__( self, - options: Namespace, + options: OcrOptions, work_folder: Path, origin: Path, pdfinfo: PdfInfo, @@ -65,21 +67,27 @@ class PageContext: Must be pickle-able, so stores only intrinsic/simple data elements or those capable of their serializing themselves via ``__getstate__``. + + Note: Uses OcrOptions with JSON serialization for multiprocessing compatibility. """ - options: Namespace #: The specified options for processing this PDF. origin: Path #: The filename of the original input file. pageno: int #: This page number (zero-based). pageinfo: PageInfo #: Information on this page. - plugin_manager: PluginManager #: PluginManager for processing the current PDF. + plugin_manager: ( + OcrmypdfPluginManager #: PluginManager for processing the current PDF. + ) def __init__(self, pdf_context: PdfContext, pageno): self.work_folder = pdf_context.work_folder self.origin = pdf_context.origin + # Store OcrOptions directly instead of Namespace self.options = pdf_context.options self.pageno = pageno self.pageinfo = pdf_context.pdfinfo[pageno] self.plugin_manager = pdf_context.plugin_manager + # Ensure no reference to PdfContext which contains OcrOptions + self._pdf_context = None def get_path(self, name: str) -> Path: """Generate a ``Path`` for a file that is part of processing this page. @@ -92,9 +100,22 @@ class PageContext: def __getstate__(self): state = self.__dict__.copy() - state['options'] = copy(self.options) - if not isinstance(state['options'].input_file, str | bytes | os.PathLike): - state['options'].input_file = 'stream' - if not isinstance(state['options'].output_file, str | bytes | os.PathLike): - state['options'].output_file = 'stream' + options_json = self.options.model_dump_json_safe() + state['options_json'] = options_json + # Remove the OcrOptions object to avoid pickle issues + del state['options'] + + # Remove any potential references to Pydantic objects + state.pop('_pdf_context', None) return state + + def __setstate__(self, state): + self.__dict__.update(state) + + # Reconstruct OcrOptions from JSON if available + if 'options_json' in state: + from ocrmypdf._options import OcrOptions + + self.options = OcrOptions.model_validate_json_safe(state['options_json']) + # Otherwise, we have a fallback Namespace (shouldn't happen in normal operation) + # Leave it as-is for compatibility diff --git a/src/ocrmypdf/_metadata.py b/src/ocrmypdf/_metadata.py index 5b9e03e4..f973a2a0 100644 --- a/src/ocrmypdf/_metadata.py +++ b/src/ocrmypdf/_metadata.py @@ -5,9 +5,9 @@ from __future__ import annotations +import datetime as dt import logging import os -from datetime import datetime, timezone from pathlib import Path from typing import Any @@ -15,7 +15,6 @@ from pikepdf import Dictionary, Name, Pdf from pikepdf import __version__ as PIKEPDF_VERSION from pikepdf.models.metadata import PdfMetadata, encode_pdf_date -from ocrmypdf._annots import remove_broken_goto_annotations from ocrmypdf._defaults import PROGRAM_NAME from ocrmypdf._jobcontext import PdfContext from ocrmypdf._version import __version__ as OCRMYPF_VERSION @@ -48,11 +47,13 @@ def get_docinfo(base_pdf: Pdf, context: PdfContext) -> dict[str, str]: if options.subject: pdfmark['/Subject'] = options.subject - creator_tag = context.plugin_manager.hook.get_ocr_engine().creator_tag(options) + creator_tag = context.plugin_manager.get_ocr_engine(options=options).creator_tag( + options + ) pdfmark['/Creator'] = f'{PROGRAM_NAME} {OCRMYPF_VERSION} / {creator_tag}' pdfmark['/Producer'] = f'pikepdf {PIKEPDF_VERSION}' - pdfmark['/ModDate'] = encode_pdf_date(datetime.now(timezone.utc)) + pdfmark['/ModDate'] = encode_pdf_date(dt.datetime.now(dt.UTC)) return pdfmark @@ -99,9 +100,7 @@ def should_linearize(working_file: Path, context: PdfContext) -> bool: For smaller files, linearization is not worth the effort. """ filesize = os.stat(working_file).st_size - if filesize > (context.options.fast_web_view * 1_000_000): - return True - return False + return filesize > (context.options.fast_web_view * 1_000_000) def _fix_metadata(meta_original: PdfMetadata, meta_pdf: PdfMetadata): @@ -109,12 +108,11 @@ def _fix_metadata(meta_original: PdfMetadata, meta_pdf: PdfMetadata): # ensure consistency with Ghostscript. if 'xmp:CreateDate' not in meta_pdf: meta_pdf['xmp:CreateDate'] = meta_pdf.get('xmp:ModifyDate', '') - if meta_pdf.get('dc:title') == 'Untitled': + if meta_pdf.get('dc:title') == 'Untitled' and ('dc:title' not in meta_original): # Ghostscript likes to set title to Untitled if omitted from input. # Reverse this, because PDF/A TechNote 0003:Metadata in PDF/A-1 # and the XMP Spec do not make this recommendation. - if 'dc:title' not in meta_original: - del meta_pdf['dc:title'] + del meta_pdf['dc:title'] def _unset_empty_metadata(meta: PdfMetadata, options): @@ -187,7 +185,7 @@ def metadata_fixup( output_file = context.get_path('metafix.pdf') options = context.options - pbar_class = context.plugin_manager.hook.get_progressbar_class() + pbar_class = context.plugin_manager.get_progressbar_class() with ( Pdf.open(context.origin) as original, Pdf.open(working_file) as pdf, diff --git a/src/ocrmypdf/_options.py b/src/ocrmypdf/_options.py new file mode 100644 index 00000000..2d1493fb --- /dev/null +++ b/src/ocrmypdf/_options.py @@ -0,0 +1,613 @@ +# SPDX-FileCopyrightText: 2024 James R. Barlow +# SPDX-License-Identifier: MPL-2.0 + +"""Internal options model for OCRmyPDF.""" + +from __future__ import annotations + +import json +import logging +import os +import unicodedata +from collections.abc import Sequence +from enum import StrEnum +from io import IOBase +from pathlib import Path +from typing import Any, BinaryIO + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +from ocrmypdf._defaults import DEFAULT_LANGUAGE, DEFAULT_ROTATE_PAGES_THRESHOLD +from ocrmypdf.exceptions import BadArgsError +from ocrmypdf.helpers import monotonic + +# Import plugin option models - these will be available after plugins are loaded +# We'll use forward references and handle imports dynamically + +log = logging.getLogger(__name__) + +# Module-level registry for plugin option models +# This is populated by setup_plugin_infrastructure() after plugins are loaded +_plugin_option_models: dict[str, type] = {} + +PathOrIO = BinaryIO | IOBase | Path | str | bytes + + +class ProcessingMode(StrEnum): + """OCR processing mode for handling pages with existing text. + + This enum controls how OCRmyPDF handles pages that already contain text: + + - ``default``: Error if text is found (standard OCR behavior) + - ``force``: Rasterize all content and run OCR regardless of existing text + - ``skip``: Skip OCR on pages that already have text + - ``redo``: Re-OCR pages, stripping old invisible text layer + """ + + default = 'default' + force = 'force' + skip = 'skip' + redo = 'redo' + + +def _pages_from_ranges(ranges: str) -> set[int]: + """Convert page range string to set of page numbers.""" + pages: list[int] = [] + page_groups = ranges.replace(' ', '').split(',') + for group in page_groups: + if not group: + continue + try: + start, end = group.split('-') + except ValueError: + pages.append(int(group) - 1) + else: + try: + new_pages = list(range(int(start) - 1, int(end))) + if not new_pages: + raise BadArgsError( + f"invalid page subrange '{start}-{end}'" + ) from None + pages.extend(new_pages) + except ValueError: + raise BadArgsError(f"invalid page subrange '{group}'") from None + + if not pages: + raise BadArgsError( + f"The string of page ranges '{ranges}' did not contain any recognizable " + f"page ranges." + ) + + if not monotonic(pages): + log.warning( + "List of pages to process contains duplicate pages, or pages that are " + "out of order" + ) + if any(page < 0 for page in pages): + raise BadArgsError("pages refers to a page number less than 1") + + log.debug("OCRing only these pages: %s", pages) + return set(pages) + + +class OcrOptions(BaseModel): + """Internal options model that can masquerade as argparse.Namespace. + + This model provides proper typing and validation while maintaining + compatibility with existing code that expects argparse.Namespace behavior. + """ + + # I/O options + input_file: PathOrIO + output_file: PathOrIO + sidecar: PathOrIO | None = None + output_folder: Path | None = None + work_folder: Path | None = None + + # Core OCR options + languages: list[str] = Field(default_factory=lambda: [DEFAULT_LANGUAGE]) + output_type: str = 'auto' + mode: ProcessingMode = ProcessingMode.default + + # Backward compatibility properties for force_ocr, skip_text, redo_ocr + @property + def force_ocr(self) -> bool: + """Backward compatibility alias for mode == ProcessingMode.force.""" + return self.mode == ProcessingMode.force + + @property + def skip_text(self) -> bool: + """Backward compatibility alias for mode == ProcessingMode.skip.""" + return self.mode == ProcessingMode.skip + + @property + def redo_ocr(self) -> bool: + """Backward compatibility alias for mode == ProcessingMode.redo.""" + return self.mode == ProcessingMode.redo + + # Job control + jobs: int | None = None + use_threads: bool = True + progress_bar: bool = True + quiet: bool = False + verbose: int = 0 + keep_temporary_files: bool = False + + # Image processing + image_dpi: int | None = None + deskew: bool = False + clean: bool = False + clean_final: bool = False + rotate_pages: bool = False + remove_background: bool = False + remove_vectors: bool = False + oversample: int = 0 + unpaper_args: str | list[str] | None = ( + None # Can be string or list after validation + ) + + # OCR behavior + skip_big: float | None = None + pages: str | set[int] | None = None # Can be string or set after validation + invalidate_digital_signatures: bool = False + + # Metadata + title: str | None = None + author: str | None = None + subject: str | None = None + keywords: str | None = None + + # Optimization + optimize: int = 1 + jpg_quality: int | None = None + png_quality: int | None = None + jbig2_threshold: float = 0.85 + + # Compatibility alias for plugins that expect jpeg_quality + @property + def jpeg_quality(self): + """Compatibility alias for jpg_quality.""" + return self.jpg_quality + + @jpeg_quality.setter + def jpeg_quality(self, value): + """Compatibility alias for jpg_quality.""" + self.jpg_quality = value + + # Advanced options + max_image_mpixels: float = 250.0 + pdf_renderer: str = 'auto' + ocr_engine: str = 'auto' + rasterizer: str = 'auto' + rotate_pages_threshold: float = DEFAULT_ROTATE_PAGES_THRESHOLD + user_words: os.PathLike | None = None + user_patterns: os.PathLike | None = None + fast_web_view: float = 1.0 + continue_on_soft_render_error: bool | None = None + + # Tesseract options - also accessible via options.tesseract. + tesseract_config: list[str] = [] + tesseract_pagesegmode: int | None = None + tesseract_oem: int | None = None + tesseract_thresholding: int | None = None + tesseract_timeout: float = 0.0 + tesseract_non_ocr_timeout: float | None = None + tesseract_downsample_above: int = 32767 + tesseract_downsample_large_images: bool | None = None + + # Ghostscript options - also accessible via options.ghostscript. + pdfa_image_compression: str | None = None + color_conversion_strategy: str = "LeaveColorUnchanged" + + # Optimize/JBIG2 options - also accessible via options.optimize. + jbig2_threshold: float = 0.85 + + # Plugin system + plugins: Sequence[Path | str] | None = None + + # Store any extra attributes (for plugins and dynamic options) + extra_attrs: dict[str, Any] = Field( + default_factory=dict, exclude=True, alias='_extra_attrs' + ) + + @field_validator('languages') + @classmethod + def validate_languages(cls, v): + """Ensure languages list is not empty.""" + if not v: + return [DEFAULT_LANGUAGE] + return v + + @field_validator('output_type') + @classmethod + def validate_output_type(cls, v): + """Validate output type is one of the allowed values.""" + valid_types = {'auto', 'pdfa', 'pdf', 'pdfa-1', 'pdfa-2', 'pdfa-3', 'none'} + if v not in valid_types: + raise ValueError(f"output_type must be one of {valid_types}") + return v + + @field_validator('pdf_renderer') + @classmethod + def validate_pdf_renderer(cls, v): + """Validate PDF renderer is one of the allowed values.""" + valid_renderers = {'auto', 'sandwich', 'fpdf2'} + # Legacy hocr/hocrdebug are accepted but redirected to fpdf2 + legacy_renderers = {'hocr', 'hocrdebug'} + all_accepted = valid_renderers | legacy_renderers + if v not in all_accepted: + raise ValueError(f"pdf_renderer must be one of {all_accepted}") + return v + + @field_validator('rasterizer') + @classmethod + def validate_rasterizer(cls, v): + """Validate rasterizer is one of the allowed values.""" + valid_rasterizers = {'auto', 'ghostscript', 'pypdfium'} + if v not in valid_rasterizers: + raise ValueError(f"rasterizer must be one of {valid_rasterizers}") + return v + + @field_validator('clean_final') + @classmethod + def validate_clean_final(cls, v, info): + """If clean_final is True, also set clean to True.""" + if v and hasattr(info, 'data') and 'clean' in info.data: + info.data['clean'] = True + return v + + @field_validator('jobs') + @classmethod + def validate_jobs(cls, v): + """Validate jobs is a reasonable number.""" + if v is not None and (v < 0 or v > 256): + raise ValueError("jobs must be between 0 and 256") + return v + + @field_validator('verbose') + @classmethod + def validate_verbose(cls, v): + """Validate verbose level.""" + if v < 0 or v > 2: + raise ValueError("verbose must be between 0 and 2") + return v + + @field_validator('oversample') + @classmethod + def validate_oversample(cls, v): + """Validate oversample DPI.""" + if v < 0 or v > 5000: + raise ValueError("oversample must be between 0 and 5000") + return v + + @field_validator('max_image_mpixels') + @classmethod + def validate_max_image_mpixels(cls, v): + """Validate max image megapixels.""" + if v < 0: + raise ValueError("max_image_mpixels must be non-negative") + return v + + @field_validator('rotate_pages_threshold') + @classmethod + def validate_rotate_pages_threshold(cls, v): + """Validate rotate pages threshold.""" + if v < 0 or v > 1000: + raise ValueError("rotate_pages_threshold must be between 0 and 1000") + return v + + @field_validator('title', 'author', 'keywords', 'subject') + @classmethod + def validate_metadata_unicode(cls, v): + """Validate metadata strings don't contain unsupported Unicode characters.""" + if v is None: + return v + + for char in v: + if unicodedata.category(char) == 'Co' or ord(char) >= 0x10000: + hexchar = hex(ord(char))[2:].upper() + raise ValueError( + f"Metadata string contains unsupported Unicode character: " + f"{char} (U+{hexchar})" + ) + return v + + @field_validator('pages') + @classmethod + def validate_pages_format(cls, v): + """Convert page ranges string to set of page numbers.""" + if v is None: + return v + if isinstance(v, set): + return v # Already processed + + # Convert string ranges to set of page numbers + return _pages_from_ranges(v) + + @model_validator(mode='before') + @classmethod + def handle_special_cases(cls, data): + """Handle special cases for API compatibility and legacy options.""" + if isinstance(data, dict): + # For hOCR API, output_file might not be present + if 'output_folder' in data and 'output_file' not in data: + data['output_file'] = '/dev/null' # Placeholder + + # Convert legacy boolean options (force_ocr, skip_text, redo_ocr) to mode + force = data.pop('force_ocr', None) + skip = data.pop('skip_text', None) + redo = data.pop('redo_ocr', None) + + # Count how many legacy options are set to True + legacy_set = [ + (force, ProcessingMode.force), + (skip, ProcessingMode.skip), + (redo, ProcessingMode.redo), + ] + legacy_true = [(val, mode) for val, mode in legacy_set if val] + legacy_count = len(legacy_true) + + # Get current mode value (may be string or enum) + current_mode = data.get('mode', ProcessingMode.default) + if isinstance(current_mode, str): + current_mode = ProcessingMode(current_mode) + mode_is_set = current_mode != ProcessingMode.default + + if legacy_count > 1: + raise ValueError( + "Choose only one of --force-ocr, --skip-text, --redo-ocr." + ) + + if legacy_count == 1: + expected_mode = legacy_true[0][1] + if mode_is_set and current_mode != expected_mode: + legacy_flag = f"--{expected_mode.value.replace('_', '-')}-ocr" + raise ValueError( + f"Conflicting options: --mode {current_mode.value} " + f"cannot be used with {legacy_flag} or similar legacy flag." + ) + # Set mode from legacy option + data['mode'] = expected_mode + + return data + + @model_validator(mode='after') + def validate_redo_ocr_options(self): + """Validate options compatible with redo mode.""" + if self.mode == ProcessingMode.redo and ( + self.deskew or self.clean_final or self.remove_background + ): + raise ValueError( + "--redo-ocr (or --mode redo) is not currently compatible with " + "--deskew, --clean-final, and --remove-background" + ) + return self + + @model_validator(mode='after') + def validate_output_type_compatibility(self): + """Validate output type is compatible with output file.""" + if self.output_type == 'none' and str(self.output_file) not in ( + os.devnull, + '-', + ): + raise ValueError( + "Since you specified `--output-type none`, the output file " + f"{self.output_file} cannot be produced. Set the output file to " + f"`-` to suppress this message." + ) + return self + + @property + def lossless_reconstruction(self): + """Determine lossless_reconstruction based on other options.""" + lossless = not any( + [ + self.deskew, + self.clean_final, + self.mode == ProcessingMode.force, + self.remove_background, + ] + ) + return lossless + + def model_dump_json_safe(self) -> str: + """Serialize to JSON with special handling for non-serializable types.""" + # Create a copy of the model data for serialization + data = self.model_dump() + + # Handle special types that don't serialize to JSON directly + def _serialize_value(value): + if isinstance(value, Path): + return {'__type__': 'Path', 'value': str(value)} + elif ( + isinstance(value, BinaryIO | IOBase) + or hasattr(value, 'read') + or hasattr(value, 'write') + ): + # Stream object - replace with placeholder + return {'__type__': 'Stream', 'value': 'stream'} + elif hasattr(value, '__class__') and 'Iterator' in value.__class__.__name__: + # Handle Pydantic serialization iterators + return {'__type__': 'Stream', 'value': 'stream'} + elif isinstance(value, property): + # Handle property objects that shouldn't be serialized + return None + elif isinstance(value, list | tuple): + return [_serialize_value(item) for item in value] + elif isinstance(value, dict): + return {k: _serialize_value(v) for k, v in value.items()} + else: + return value + + # Process all fields + serializable_data = {} + for key, value in data.items(): + serialized_value = _serialize_value(value) + if serialized_value is not None: # Skip None values from properties + serializable_data[key] = serialized_value + + # Add extra_attrs, excluding plugin cache entries (they'll be recreated lazily) + if self.extra_attrs: + filtered_extra = { + k: v + for k, v in self.extra_attrs.items() + if not k.startswith('_plugin_cache_') + } + if filtered_extra: + serializable_data['_extra_attrs'] = _serialize_value(filtered_extra) + + return json.dumps(serializable_data) + + @classmethod + def model_validate_json_safe(cls, json_str: str) -> OcrOptions: + """Reconstruct from JSON with special handling for non-serializable types.""" + data = json.loads(json_str) + + # Handle special types during deserialization + def _deserialize_value(value): + if isinstance(value, dict) and '__type__' in value: + if value['__type__'] == 'Path': + return Path(value['value']) + elif value['__type__'] == 'Stream': + # For streams, we'll use a placeholder string + return value['value'] + else: + return value['value'] + elif isinstance(value, list): + return [_deserialize_value(item) for item in value] + elif isinstance(value, dict): + return {k: _deserialize_value(v) for k, v in value.items()} + else: + return value + + # Process all fields + deserialized_data = {} + extra_attrs = {} + + for key, value in data.items(): + if key == '_extra_attrs': + extra_attrs = _deserialize_value(value) + else: + deserialized_data[key] = _deserialize_value(value) + + # Create instance + instance = cls(**deserialized_data) + instance.extra_attrs = extra_attrs + + return instance + + model_config = ConfigDict( + extra="forbid", # Force use of extra_attrs for unknown fields + arbitrary_types_allowed=True, # Allow BinaryIO, Path, etc. + validate_assignment=True, # Validate on attribute assignment + ) + + @classmethod + def register_plugin_models(cls, models: dict[str, type]) -> None: + """Register plugin option model classes for nested access. + + Args: + models: Dictionary mapping namespace to model class + """ + global _plugin_option_models + _plugin_option_models.update(models) + + def _get_plugin_options(self, namespace: str) -> Any: + """Get or create a plugin options instance for the given namespace. + + This method creates plugin option instances lazily from flat field values. + + Args: + namespace: The plugin namespace (e.g., 'tesseract', 'optimize') + + Returns: + An instance of the plugin's option model, or None if not registered + """ + # Use extra_attrs to cache plugin option instances + cache_key = f'_plugin_cache_{namespace}' + if cache_key in self.extra_attrs: + return self.extra_attrs[cache_key] + + if namespace not in _plugin_option_models: + raise AttributeError( + f"Plugin namespace '{namespace}' is not registered. " + f"Ensure setup_plugin_infrastructure() was called." + ) + + model_class = _plugin_option_models[namespace] + + def _convert_value(value): + """Convert value to be compatible with plugin model fields.""" + if isinstance(value, os.PathLike): + return os.fspath(value) + return value + + # Build kwargs from flat fields + kwargs = {} + for field_name in model_class.model_fields: + # Try namespace_field pattern first (e.g., tesseract_timeout) + flat_name = f"{namespace}_{field_name}" + if flat_name in OcrOptions.model_fields: + value = getattr(self, flat_name) + if value is not None: + kwargs[field_name] = _convert_value(value) + # Also check direct field name (for fields like jbig2_lossy) + elif field_name in OcrOptions.model_fields: + value = getattr(self, field_name) + if value is not None: + kwargs[field_name] = _convert_value(value) + # Check for special mappings + elif namespace == 'optimize' and field_name == 'level': + # 'optimize' field maps to 'level' in OptimizeOptions + if 'optimize' in OcrOptions.model_fields: + value = self.optimize + if value is not None: + kwargs[field_name] = _convert_value(value) + elif namespace == 'optimize' and field_name == 'jpeg_quality': + # jpg_quality maps to jpeg_quality + if 'jpg_quality' in OcrOptions.model_fields: + value = self.jpg_quality + if value is not None: + kwargs[field_name] = _convert_value(value) + + # Create and cache the plugin options instance + instance = model_class(**kwargs) + self.extra_attrs[cache_key] = instance + return instance + + def __getattr__(self, name: str) -> Any: + """Support dynamic access to plugin option namespaces. + + This allows accessing plugin options like: + options.tesseract.timeout + options.optimize.level + + Plugin models must be registered via register_plugin_models() for + namespace access to work. Built-in plugins register their models + during initialization. + + Args: + name: Attribute name + + Returns: + Plugin options instance if name is a registered namespace, + otherwise raises AttributeError + """ + # Check if this is a plugin namespace + if name.startswith('_'): + # Private attributes should not trigger plugin lookup + raise AttributeError( + f"'{type(self).__name__}' object has no attribute '{name}'" + ) + + # Try to get plugin options for this namespace + if name in _plugin_option_models: + return self._get_plugin_options(name) + + # Check extra_attrs + if 'extra_attrs' in self.__dict__ and name in self.extra_attrs: + return self.extra_attrs[name] + + raise AttributeError( + f"'{type(self).__name__}' object has no attribute '{name}'" + ) diff --git a/src/ocrmypdf/_pipeline.py b/src/ocrmypdf/_pipeline.py index 89eb8382..af9e1c61 100644 --- a/src/ocrmypdf/_pipeline.py +++ b/src/ocrmypdf/_pipeline.py @@ -15,7 +15,10 @@ from contextlib import suppress from io import BytesIO from pathlib import Path from shutil import copyfileobj -from typing import Any, BinaryIO, TypeVar, cast +from typing import TYPE_CHECKING, Any, BinaryIO, TypeVar, cast + +if TYPE_CHECKING: + from ocrmypdf.hocrtransform import OcrElement import img2pdf import pikepdf @@ -25,6 +28,7 @@ from ocrmypdf._concurrent import Executor from ocrmypdf._exec import unpaper from ocrmypdf._jobcontext import PageContext, PdfContext from ocrmypdf._metadata import repair_docinfo_nuls +from ocrmypdf._options import OcrOptions, ProcessingMode from ocrmypdf.exceptions import ( DigitalSignatureError, DpiError, @@ -35,12 +39,13 @@ from ocrmypdf.exceptions import ( UnsupportedImageFormatError, ) from ocrmypdf.helpers import IMG2PDF_KWARGS, Resolution, safe_symlink -from ocrmypdf.hocrtransform import DebugRenderOptions, HocrTransform -from ocrmypdf.hocrtransform._font import Courier -from ocrmypdf.pdfa import generate_pdfa_ps -from ocrmypdf.pdfinfo import Colorspace, Encoding, PageInfo, PdfInfo -from ocrmypdf.pdfinfo.info import FloatRect -from ocrmypdf.pluginspec import OrientationConfidence +from ocrmypdf.pdfa import ( + file_claims_pdfa, + generate_pdfa_ps, + speculative_pdfa_conversion, +) +from ocrmypdf.pdfinfo import Colorspace, Encoding, FloatRect, PageInfo, PdfInfo +from ocrmypdf.pluginspec import GhostscriptRasterDevice, OrientationConfidence try: from pi_heif import register_heif_opener @@ -59,7 +64,7 @@ VECTOR_PAGE_DPI = 400 register_heif_opener() -def triage_image_file(input_file: Path, output_file: Path, options) -> None: +def triage_image_file(input_file: Path, output_file: Path, options: OcrOptions) -> None: """Triage the input image file. If the input file is an image, check its resolution and convert it to PDF. @@ -158,7 +163,7 @@ def _pdf_guess_version(input_file: Path, search_window=1024) -> str: def triage( - original_filename: str, input_file: Path, output_file: Path, options + original_filename: str, input_file: Path, output_file: Path, options: OcrOptions ) -> Path: """Triage the input file. We can handle PDFs and images.""" try: @@ -228,10 +233,10 @@ def validate_pdfinfo_options(context: PdfContext) -> None: else: raise DigitalSignatureError() if pdfinfo.has_acroform: - if options.redo_ocr: + if options.mode == ProcessingMode.redo: raise InputFileError( - "This PDF has a user fillable form. --redo-ocr is not " - "currently possible on such files." + "This PDF has a user fillable form. --redo-ocr (or --mode redo) " + "is not currently possible on such files." ) else: log.warning( @@ -239,14 +244,14 @@ def validate_pdfinfo_options(context: PdfContext) -> None: "Chances are it is a pure digital " "document that does not need OCR." ) - if not options.force_ocr: + if options.mode != ProcessingMode.force: log.info( - "Use the option --force-ocr to produce an image of the " - "form and all filled form fields. The output PDF will be " - "'flattened' and will no longer be fillable." + "Use the option --force-ocr (or --mode force) to produce an " + "image of the form and all filled form fields. The output PDF " + "will be 'flattened' and will no longer be fillable." ) if pdfinfo.is_tagged: - if options.force_ocr or options.skip_text or options.redo_ocr: + if options.mode != ProcessingMode.default: log.warning( "This PDF is marked as a Tagged PDF. This often indicates " "that the PDF was generated from an office document and does " @@ -255,7 +260,7 @@ def validate_pdfinfo_options(context: PdfContext) -> None: ) else: raise TaggedPDFError() - context.plugin_manager.hook.validate(pdfinfo=pdfinfo, options=options) + context.plugin_manager.validate(pdfinfo=pdfinfo, options=options) def _vector_page_dpi(pageinfo: PageInfo) -> int: @@ -323,24 +328,24 @@ def is_ocr_required(page_context: PageContext) -> bool: log.debug(f"skipped {pageinfo.pageno} as requested by --pages {options.pages}") ocr_required = False elif pageinfo.has_text: - if not options.force_ocr and not (options.skip_text or options.redo_ocr): + if options.mode == ProcessingMode.default: raise PriorOcrFoundError( - "page already has text! - aborting (use --force-ocr to force OCR; " - " see also help for the arguments --skip-text and --redo-ocr" + "page already has text! - aborting (use --force-ocr or --mode force " + "to force OCR; see also help for --skip-text, --redo-ocr, and --mode)" ) - elif options.force_ocr: + elif options.mode == ProcessingMode.force: log.info("page already has text! - rasterizing text and running OCR anyway") ocr_required = True - elif options.redo_ocr: + elif options.mode == ProcessingMode.redo: if pageinfo.has_corrupt_text: log.warning( "some text on this page cannot be mapped to characters: " - "consider using --force-ocr instead" + "consider using --force-ocr (or --mode force) instead" ) else: log.info("redoing OCR") ocr_required = True - elif options.skip_text: + elif options.mode == ProcessingMode.skip: log.info("skipping all processing on this page") ocr_required = False elif not pageinfo.images and not options.lossless_reconstruction: @@ -351,14 +356,14 @@ def is_ocr_required(page_context: PageContext) -> bool: # ahead and rasterize. If not forced, then pretend there's no text # on the page at all so we don't lose anything. # This could be made smarter by explicitly searching for vector art. - if options.force_ocr and options.oversample: + if options.mode == ProcessingMode.force and options.oversample: # The user really wants to reprocess this file log.info( "page has no images - " f"rasterizing at {options.oversample} DPI because " - "--force-ocr --oversample was specified" + "--force-ocr --oversample (or --mode force --oversample) was specified" ) - elif options.force_ocr: + elif options.mode == ProcessingMode.force: # Warn the user they might not want to do this log.warning( "page has no images - " @@ -371,8 +376,8 @@ def is_ocr_required(page_context: PageContext) -> bool: log.info( "page has no images - " "skipping all processing on this page to avoid losing detail. " - "Use --force-ocr if you wish to perform OCR on pages that " - "have vector content." + "Use --force-ocr (or --mode force) if you wish to perform OCR on " + "pages that have vector content." ) ocr_required = False @@ -395,16 +400,18 @@ def rasterize_preview(input_file: Path, page_context: PageContext) -> Path: [get_canvas_square_dpi(page_context)] ) page_dpi = Resolution(300.0, 300.0).take_min([get_page_square_dpi(page_context)]) - page_context.plugin_manager.hook.rasterize_pdf_page( + page_context.plugin_manager.rasterize_pdf_page( input_file=input_file, output_file=output_file, - raster_device='jpeggray', + raster_device=GhostscriptRasterDevice.JPEGGRAY, raster_dpi=canvas_dpi, pageno=page_context.pageinfo.pageno + 1, page_dpi=page_dpi, rotation=0, filter_vector=False, stop_on_soft_error=not page_context.options.continue_on_soft_render_error, + options=page_context.options, + use_cropbox=False, ) return output_file @@ -424,10 +431,7 @@ def describe_rotation( else: action = 'rotation appears correct' else: - if correction != 0: - action = 'confidence too low to rotate' - else: - action = 'no change' + action = "confidence too low to rotate" if correction != 0 else "no change" facing = '' @@ -453,9 +457,10 @@ def get_orientation_correction(preview: Path, page_context: PageContext) -> int: which points it (hopefully) upright. _graft.py takes care of the orienting the image and text layers. """ - orient_conf = page_context.plugin_manager.hook.get_ocr_engine().get_orientation( - preview, page_context.options + ocr_engine = page_context.plugin_manager.get_ocr_engine( + options=page_context.options ) + orient_conf = ocr_engine.get_orientation(preview, page_context.options) correction = orient_conf.angle % 360 log.info(describe_rotation(page_context, orient_conf, correction)) @@ -521,7 +526,12 @@ def rasterize( Returns: Path: The output PNG file path. """ - colorspaces = ['pngmono', 'pnggray', 'png256', 'png16m'] + colorspaces = [ + GhostscriptRasterDevice.PNGMONO, + GhostscriptRasterDevice.PNGGRAY, + GhostscriptRasterDevice.PNG256, + GhostscriptRasterDevice.PNG16M, + ] device_idx = 0 if remove_vectors is None: @@ -538,15 +548,15 @@ def rasterize( continue # ignore masks if image.bpc > 1: if image.color == Colorspace.index: - device_idx = at_least('png256') + device_idx = at_least(GhostscriptRasterDevice.PNG256) elif image.color == Colorspace.gray: - device_idx = at_least('pnggray') + device_idx = at_least(GhostscriptRasterDevice.PNGGRAY) else: - device_idx = at_least('png16m') + device_idx = at_least(GhostscriptRasterDevice.PNG16M) if pageinfo.has_vector: - log.debug("Page has vector content, using png16m") - device_idx = at_least('png16m') + log.debug(f"Page has vector content, using {GhostscriptRasterDevice.PNG16M}") + device_idx = at_least(GhostscriptRasterDevice.PNG16M) device = colorspaces[device_idx] @@ -556,7 +566,7 @@ def rasterize( canvas_dpi, page_dpi = calculate_raster_dpi(page_context) - page_context.plugin_manager.hook.rasterize_pdf_page( + page_context.plugin_manager.rasterize_pdf_page( input_file=input_file, output_file=output_file, raster_device=device, @@ -566,6 +576,8 @@ def rasterize( rotation=correction, filter_vector=remove_vectors, stop_on_soft_error=not page_context.options.continue_on_soft_render_error, + options=page_context.options, + use_cropbox=False, ) return output_file @@ -594,7 +606,9 @@ def preprocess_deskew(input_file: Path, page_context: PageContext) -> Path: output_file = page_context.get_path('pp_deskew.png') dpi = get_page_square_dpi(page_context, calculate_image_dpi(page_context)) - ocr_engine = page_context.plugin_manager.hook.get_ocr_engine() + ocr_engine = page_context.plugin_manager.get_ocr_engine( + options=page_context.options + ) deskew_angle_degrees = ocr_engine.get_deskew(input_file, page_context.options) with Image.open(input_file) as im: @@ -633,11 +647,11 @@ def create_ocr_image(image: Path, page_context: PageContext) -> Path: with Image.open(image) as im: log.debug('resolution %r', im.info['dpi']) - if not options.force_ocr: + if options.mode != ProcessingMode.force: # Do not mask text areas when forcing OCR, because we need to OCR # all text areas mask = None # Exclude both visible and invisible text from OCR - if options.redo_ocr: + if options.mode == ProcessingMode.redo: mask = True # Mask visible text, but not invisible text draw = ImageDraw.ImageDraw(im) @@ -659,7 +673,7 @@ def create_ocr_image(image: Path, page_context: PageContext) -> Path: draw.rectangle(pixcoords, fill='white') # draw.rectangle(pixcoords, outline='pink') - filter_im = page_context.plugin_manager.hook.filter_ocr_image( + filter_im = page_context.plugin_manager.filter_ocr_image( page=page_context, image=im ) if filter_im is not None: @@ -677,7 +691,7 @@ def ocr_engine_hocr(input_file: Path, page_context: PageContext) -> tuple[Path, hocr_text_out = page_context.get_path('ocr_hocr.txt') options = page_context.options - ocr_engine = page_context.plugin_manager.hook.get_ocr_engine() + ocr_engine = page_context.plugin_manager.get_ocr_engine(options=options) ocr_engine.generate_hocr( input_file=input_file, output_hocr=hocr_out, @@ -687,6 +701,37 @@ def ocr_engine_hocr(input_file: Path, page_context: PageContext) -> tuple[Path, return hocr_out, hocr_text_out +def ocr_engine_direct( + input_file: Path, page_context: PageContext +) -> tuple[OcrElement, Path]: + """Run the OCR engine and return OcrElement tree directly. + + This is the modern path for OCR engines that support the generate_ocr() API. + It bypasses hOCR file generation for better performance and richer data. + + Args: + input_file: The image file to OCR. + page_context: The page context with options and path utilities. + + Returns: + A tuple of (OcrElement tree, path to text sidecar file). + """ + text_out = page_context.get_path('ocr_direct.txt') + options = page_context.options + + ocr_engine = page_context.plugin_manager.get_ocr_engine(options=options) + ocr_tree, text_content = ocr_engine.generate_ocr( + input_file=input_file, + options=options, + page_number=page_context.pageno, + ) + + # Write text sidecar file + text_out.write_text(text_content, encoding='utf-8') + + return ocr_tree, text_out + + def should_visible_page_image_use_jpg(pageinfo: PageInfo) -> bool: """Determines whether the visible page image should be saved as a JPEG. @@ -764,47 +809,12 @@ def create_pdf_page_from_image( bio.seek(0) fix_pagepdf_boxes(bio, output_file, page_context, swap_axis=swap_axis) - output_file = page_context.plugin_manager.hook.filter_pdf_page( + output_file = page_context.plugin_manager.filter_pdf_page( page=page_context, image_filename=image, output_pdf=output_file ) return output_file -def render_hocr_page(hocr: Path, page_context: PageContext) -> Path: - """Render the hOCR page to a PDF.""" - options = page_context.options - output_file = page_context.get_path('ocr_hocr.pdf') - if hocr.stat().st_size == 0: - # If hOCR file is empty (skipped page marker), create an empty PDF file - output_file.touch() - return output_file - - dpi = get_page_square_dpi(page_context, calculate_image_dpi(page_context)) - debug_kwargs = {} - if options.pdf_renderer == 'hocrdebug': - debug_kwargs = dict( - debug_render_options=DebugRenderOptions( - render_baseline=True, - render_triangle=True, - render_line_bbox=False, - render_word_bbox=True, - render_paragraph_bbox=False, - render_space_bbox=False, - ), - font=Courier(), - ) - HocrTransform( - hocr_filename=hocr, - dpi=dpi.to_scalar(), - **debug_kwargs, # square - ).to_pdf( - out_filename=output_file, - image_filename=None, - invisible_text=True if not debug_kwargs else False, - ) - return output_file - - def ocr_engine_textonly_pdf( input_image: Path, page_context: PageContext ) -> tuple[Path, Path]: @@ -813,7 +823,7 @@ def ocr_engine_textonly_pdf( output_text = page_context.get_path('ocr_tess.txt') options = page_context.options - ocr_engine = page_context.plugin_manager.hook.get_ocr_engine() + ocr_engine = page_context.plugin_manager.get_ocr_engine(options=options) ocr_engine.generate_pdf( input_file=input_image, output_pdf=output_pdf, @@ -936,15 +946,26 @@ def convert_to_pdfa(input_pdf: Path, input_ps_stub: Path, context: PdfContext) - else: safe_symlink(input_pdf, fix_docinfo_file) - context.plugin_manager.hook.generate_pdfa( + # Extract PDF/A part correctly + if options.output_type.startswith('pdfa'): + if options.output_type == 'pdfa': + pdfa_part = '2' # Default to PDF/A-2 + else: + pdfa_part = options.output_type.split('-')[ + -1 + ] # Extract number from pdfa-1, pdfa-2, etc. + else: + pdfa_part = '2' # Fallback + + context.plugin_manager.generate_pdfa( pdf_version=input_pdfinfo.min_version, pdf_pages=[fix_docinfo_file], pdfmark=input_ps_stub, output_file=output_file, context=context, - pdfa_part=options.output_type[-1], # is pdfa-1, pdfa-2, or pdfa-3 + pdfa_part=pdfa_part, progressbar_class=( - context.plugin_manager.hook.get_progressbar_class() + context.plugin_manager.get_progressbar_class() if options.progress_bar else None ), @@ -954,15 +975,136 @@ def convert_to_pdfa(input_pdf: Path, input_ps_stub: Path, context: PdfContext) - return output_file +def try_speculative_pdfa(input_pdf: Path, context: PdfContext) -> Path | None: + """Try speculative PDF/A conversion with verapdf validation. + + This attempts a fast PDF/A conversion by adding PDF/A structures + directly with pikepdf, then validating with verapdf. If validation + passes, returns the converted file. If it fails or verapdf is not + available, returns None to signal that Ghostscript should be used. + + Args: + input_pdf: Path to the PDF to convert + context: The PDF context + + Returns: + Path to valid PDF/A file, or None if speculative conversion failed + """ + from ocrmypdf._exec import verapdf + + options = context.options + + # Skip speculative conversion if user requested specific image compression, + # since that requires Ghostscript to apply + gs_opts = getattr(options, 'ghostscript', None) + if gs_opts is not None: + compression = getattr(gs_opts, 'pdfa_image_compression', 'auto') + if compression != 'auto': + log.debug( + 'Skipping speculative PDF/A: --pdfa-image-compression=%s requires ' + 'Ghostscript', + compression, + ) + return None + + if not verapdf.available(): + log.debug('verapdf not available, skipping speculative PDF/A conversion') + return None + output_file = context.get_path('speculative_pdfa.pdf') + + try: + speculative_pdfa_conversion(input_pdf, output_file, options.output_type) + + flavour = verapdf.output_type_to_flavour(options.output_type) + result = verapdf.validate(output_file, flavour) + + if result.valid: + log.info('Speculative PDF/A conversion succeeded - skipping Ghostscript') + return output_file + else: + log.debug( + 'Speculative PDF/A validation failed (%d rule violations), ' + 'falling back to Ghostscript', + result.failed_rules, + ) + return None + + except Exception as e: + log.debug('Speculative PDF/A conversion failed: %s', e) + return None + + +def try_auto_pdfa(input_pdf: Path, context: PdfContext) -> tuple[Path, str]: + """Best-effort PDF/A for 'auto' output type. + + This function attempts to produce PDF/A without requiring Ghostscript: + 1. If verapdf is available, tries speculative conversion with validation + 2. Without verapdf, passes through as PDF/A if safe (input already PDF/A + or force-ocr was used) + 3. Falls back to regular PDF if neither condition is met + + Args: + input_pdf: Path to the PDF to convert + context: The PDF context + + Returns: + Tuple of (output_path, actual_output_type) where actual_output_type + is 'pdfa' if PDF/A was achieved, 'pdf' otherwise + """ + from ocrmypdf._exec import verapdf + + # If verapdf available, try speculative conversion with validation + if verapdf.available(): + result = try_speculative_pdfa(input_pdf, context) + if result is not None: + return (result, 'pdfa') + # verapdf validation failed - fall through to regular PDF + log.info( + 'Auto mode: speculative PDF/A validation failed, outputting regular PDF' + ) + return (input_pdf, 'pdf') + + # Without verapdf, check if we can pass through as PDF/A + if _is_safe_pdfa(input_pdf, context.options): + # Pass through as-is (no modifications needed) + log.info('Auto mode: passing through as PDF/A (input already compliant)') + return (input_pdf, 'pdfa') + + # Fall through to regular PDF + log.info('Auto mode: no verapdf available and input is not PDF/A, outputting PDF') + return (input_pdf, 'pdf') + + +def _is_safe_pdfa(input_pdf: Path, options) -> bool: + """Check if file can be considered PDF/A without validation. + + These are cases where our modifications don't break PDF/A compliance: + 1. Input already claims PDF/A (we just grafted OCR text onto it) + 2. We used force-ocr (we rewrote the entire PDF from scratch) + + Args: + input_pdf: Path to the PDF to check + options: OCR options + + Returns: + True if file can safely be considered PDF/A + """ + # Safe if input already claims PDF/A + pdfa_status = file_claims_pdfa(input_pdf) + if pdfa_status['pass']: + return True + + # Safe if we rewrote the PDF with force mode + return options.mode == ProcessingMode.force + + def should_linearize(working_file: Path, context: PdfContext) -> bool: """Determine whether the PDF should be linearized. For smaller files, linearization is not worth the effort. """ filesize = os.stat(working_file).st_size - if filesize > (context.options.fast_web_view * 1_000_000): - return True - return False + return filesize > (context.options.fast_web_view * 1_000_000) def get_pdf_save_settings(output_type: str) -> dict[str, Any]: @@ -1016,7 +1158,7 @@ def optimize_pdf( ) -> tuple[Path, Sequence[str]]: """Optimize the given PDF file.""" output_file = context.get_path('optimize.pdf') - output_pdf, messages = context.plugin_manager.hook.optimize_pdf( + output_pdf, messages = context.plugin_manager.optimize_pdf( input_pdf=input_file, output_pdf=output_file, context=context, @@ -1080,10 +1222,7 @@ def merge_sidecars(txt_files: Iterable[Path | None], context: PdfContext) -> Pat # others don't. Remove it if it exists, since we add one manually. stream.write(txt.removesuffix('\f')) else: - if from_ != to_: - pages = f'{from_}-{to_}' - else: - pages = f'{from_}' + pages = f"{from_}-{to_}" if from_ != to_ else f"{from_}" stream.write(f'[OCR skipped on page(s) {pages}]') return output_file diff --git a/src/ocrmypdf/_pipelines/_common.py b/src/ocrmypdf/_pipelines/_common.py index e5ba9725..492c39cc 100644 --- a/src/ocrmypdf/_pipelines/_common.py +++ b/src/ocrmypdf/_pipelines/_common.py @@ -3,7 +3,6 @@ from __future__ import annotations -import argparse import json import logging import logging.handlers @@ -17,9 +16,13 @@ from concurrent.futures.thread import BrokenThreadPool from contextlib import contextmanager from dataclasses import dataclass from pathlib import Path -from typing import NamedTuple, cast +from typing import TYPE_CHECKING, NamedTuple, cast + +if TYPE_CHECKING: + from ocrmypdf.hocrtransform import OcrElement import PIL +import PIL.Image from pikepdf import Pdf from ocrmypdf._annots import remove_broken_goto_annotations @@ -27,6 +30,7 @@ from ocrmypdf._concurrent import Executor, setup_executor from ocrmypdf._jobcontext import PageContext, PdfContext from ocrmypdf._logging import PageNumberFilter from ocrmypdf._metadata import metadata_fixup +from ocrmypdf._options import OcrOptions from ocrmypdf._pipeline import ( convert_to_pdfa, create_ocr_image, @@ -44,6 +48,8 @@ from ocrmypdf._pipeline import ( rasterize_preview, should_linearize, should_visible_page_image_use_jpg, + try_auto_pdfa, + try_speculative_pdfa, ) from ocrmypdf._plugin_manager import OcrmypdfPluginManager from ocrmypdf._validation import ( @@ -51,7 +57,6 @@ from ocrmypdf._validation import ( ) from ocrmypdf.exceptions import ExitCode, ExitCodeException from ocrmypdf.helpers import ( - available_cpu_count, check_pdf, pikepdf_enable_mmap, running_in_docker, @@ -105,6 +110,9 @@ class PageResult(NamedTuple): orientation_correction: int = 0 """Orientation correction in degrees.""" + ocr_tree: OcrElement | None = None + """Direct OcrElement tree (when using generate_ocr() API).""" + class HOCRResultEncoder(json.JSONEncoder): def default(self, obj): @@ -115,7 +123,8 @@ class HOCRResultEncoder(json.JSONEncoder): class HOCRResultDecoder(json.JSONDecoder): def __init__(self, *args, **kwargs): - super().__init__(object_hook=self.dict_to_object, *args, **kwargs) + kwargs['object_hook'] = self.dict_to_object + super().__init__(*args, **kwargs) def dict_to_object(self, d): if 'Path' in d: @@ -142,6 +151,9 @@ class HOCRResult: orientation_correction: int = 0 """Orientation correction in degrees.""" + ocr_tree: OcrElement | None = None + """Direct OcrElement tree (when using generate_ocr() API).""" + @classmethod def from_json(cls, json_str: str) -> HOCRResult: """Create an instance from a dict.""" @@ -194,7 +206,7 @@ def worker_init(max_pixels: int | None) -> None: @contextmanager def manage_debug_log_handler( *, - options: argparse.Namespace, + options: OcrOptions, work_folder: Path, ): remover = None @@ -243,8 +255,8 @@ def manage_work_folder(*, work_folder: Path, retain: bool, print_location: bool) def cli_exception_handler( - fn: Callable[[argparse.Namespace, OcrmypdfPluginManager], ExitCode], - options: argparse.Namespace, + fn: Callable[[OcrOptions, OcrmypdfPluginManager], ExitCode], + options: OcrOptions, plugin_manager: OcrmypdfPluginManager, ) -> ExitCode: """Convert exceptions into command line error messages and exit codes. @@ -274,6 +286,16 @@ def cli_exception_handler( else: log.error(type(e).__name__) return e.exit_code + except ValueError as e: + # Convert Pydantic validation errors to BadArgsError for proper exit code + if "validation error" in str(e).lower() or "value error" in str(e).lower(): + if options.verbose >= 1: + log.exception("Validation error") + else: + log.error("Invalid argument: %s", str(e)) + return ExitCode.bad_args + # Re-raise other ValueErrors to be caught by the general exception handler + raise except PIL.Image.DecompressionBombError: log.exception( "A decompression bomb error was encountered while executing the " @@ -298,23 +320,33 @@ def cli_exception_handler( def setup_pipeline( - options: argparse.Namespace, + options: OcrOptions, plugin_manager: OcrmypdfPluginManager, ) -> Executor: # Any changes to options will not take effect for options that are already # bound to function parameters in the pipeline. (For example # options.input_file, options.pdf_renderer are already bound.) - if not options.jobs: - options.jobs = available_cpu_count() + # Note: OcrOptions is immutable, so we can't modify options.jobs directly + # The jobs field should already be set correctly during OcrOptions creation + + # Apply PIL max image pixels side effect + PIL.Image.MAX_IMAGE_PIXELS = int(options.max_image_mpixels * 1_000_000) + if PIL.Image.MAX_IMAGE_PIXELS == 0: + PIL.Image.MAX_IMAGE_PIXELS = None # type: ignore pikepdf_enable_mmap() executor = setup_executor(plugin_manager) return executor -def do_get_pdfinfo( - pdf_path: Path, executor: Executor, options: argparse.Namespace -) -> PdfInfo: +def do_get_pdfinfo(pdf_path: Path, executor: Executor, options) -> PdfInfo: + # Handle pages field - it might be a string that needs conversion + check_pages = options.pages + if isinstance(check_pages, str): + from ocrmypdf._options import _pages_from_ranges + + check_pages = _pages_from_ranges(check_pages) + return get_pdfinfo( pdf_path, executor=executor, @@ -322,7 +354,7 @@ def do_get_pdfinfo( progbar=options.progress_bar, max_workers=options.jobs, use_threads=options.use_threads, - check_pages=options.pages, + check_pages=check_pages, ) @@ -425,7 +457,7 @@ def process_page(page_context: PageContext) -> tuple[Path, Path | None, int]: visible_image_out = preprocess_out if should_visible_page_image_use_jpg(page_context.pageinfo): visible_image_out = create_visible_page_jpg(visible_image_out, page_context) - filtered_image = page_context.plugin_manager.hook.filter_page_image( + filtered_image = page_context.plugin_manager.filter_page_image( page=page_context, image_filename=visible_image_out ) if filtered_image is not None: # None if no hook is present @@ -448,11 +480,22 @@ def postprocess( pdf_out = fix_annots else: pdf_out = pdf_file - if context.options.output_type.startswith('pdfa'): - ps_stub_out = generate_postscript_stub(context) - pdf_out = convert_to_pdfa(pdf_out, ps_stub_out, context) + if context.options.output_type == 'auto': + # Best effort PDF/A - never uses Ghostscript + pdf_out, actual_type = try_auto_pdfa(pdf_out, context) + # Store actual output type for reporting + context.options.extra_attrs['_actual_output_type'] = actual_type + elif context.options.output_type.startswith('pdfa'): + # Required PDF/A - uses Ghostscript as fallback + speculative_result = try_speculative_pdfa(pdf_out, context) + if speculative_result is not None: + pdf_out = speculative_result + else: + # Fall back to Ghostscript conversion + ps_stub_out = generate_postscript_stub(context) + pdf_out = convert_to_pdfa(pdf_out, ps_stub_out, context) - optimizing = context.plugin_manager.hook.is_optimization_enabled(context=context) + optimizing = context.plugin_manager.is_optimization_enabled(context=context) save_settings = get_pdf_save_settings(context.options.output_type) save_settings['linearize'] = not optimizing and should_linearize(pdf_out, context) @@ -468,7 +511,22 @@ def report_output_pdf(options, start_input_file, optimize_messages) -> ExitCode: elif samefile(options.output_file, Path(os.devnull)): pass # Say nothing when sending to dev null else: - if options.output_type.startswith('pdfa'): + if options.output_type == 'auto': + # For 'auto' mode, check what we actually produced + actual_type = options.extra_attrs.get('_actual_output_type', 'pdf') + pdfa_info = file_claims_pdfa(options.output_file) + if actual_type == 'pdfa' and pdfa_info['pass']: + log.info( + "Output file is a %s (auto mode achieved PDF/A)", + pdfa_info['conformance'], + ) + elif pdfa_info['pass']: + # Unexpectedly got PDF/A + log.info("Output file is a %s", pdfa_info['conformance']) + else: + # Regular PDF - this is expected for auto mode fallback + log.info("Output file is a PDF (auto mode)") + elif options.output_type.startswith('pdfa'): pdfa_info = file_claims_pdfa(options.output_file) if pdfa_info['pass']: log.info("Output file is a %s (as expected)", pdfa_info['conformance']) diff --git a/src/ocrmypdf/_pipelines/hocr_to_ocr_pdf.py b/src/ocrmypdf/_pipelines/hocr_to_ocr_pdf.py index c6fb5e38..750901f3 100644 --- a/src/ocrmypdf/_pipelines/hocr_to_ocr_pdf.py +++ b/src/ocrmypdf/_pipelines/hocr_to_ocr_pdf.py @@ -6,7 +6,6 @@ from __future__ import annotations -import argparse import logging import logging.handlers from collections.abc import Sequence @@ -17,10 +16,8 @@ import PIL from ocrmypdf._concurrent import Executor from ocrmypdf._graft import OcrGrafter from ocrmypdf._jobcontext import PageContext, PdfContext -from ocrmypdf._pipeline import ( - copy_final, - render_hocr_page, -) +from ocrmypdf._options import OcrOptions +from ocrmypdf._pipeline import copy_final from ocrmypdf._pipelines._common import ( HOCRResult, do_get_pdfinfo, @@ -34,6 +31,7 @@ from ocrmypdf._pipelines._common import ( from ocrmypdf._plugin_manager import OcrmypdfPluginManager from ocrmypdf._progressbar import ProgressBar from ocrmypdf.exceptions import ExitCode +from ocrmypdf.helpers import available_cpu_count log = logging.getLogger(__name__) @@ -45,9 +43,8 @@ def _exec_hocrtransform_sync(page_context: PageContext) -> HOCRResult: # No hOCR file, so no OCR was performed on this page. return HOCRResult(pageno=page_context.pageno) hocr_result = HOCRResult.from_json(hocr_json.read_text()) - hocr_result.textpdf = render_hocr_page( - page_context.get_path('ocr_hocr.hocr'), page_context - ) + # hOCR path is passed directly to the grafting phase where fpdf2 renders it + hocr_result.textpdf = page_context.get_path('ocr_hocr.hocr') return hocr_result @@ -55,7 +52,8 @@ def exec_hocr_to_ocr_pdf(context: PdfContext, executor: Executor) -> Sequence[st """Convert hOCR files to OCR PDF.""" # Run exec_page_sync on every page options = context.options - max_workers = min(len(context.pdfinfo), options.jobs) + jobs = options.jobs or available_cpu_count() + max_workers = min(len(context.pdfinfo), jobs) if max_workers > 1: log.info("Continue processing %d pages concurrently", max_workers) @@ -69,7 +67,8 @@ def exec_hocr_to_ocr_pdf(context: PdfContext, executor: Executor) -> Sequence[st ocrgraft.graft_page( pageno=result.pageno, image=result.pdf_page_from_image, - textpdf=result.textpdf, + ocr_output=result.textpdf, + ocr_tree=result.ocr_tree, autorotate_correction=result.orientation_correction, ) pbar.update() @@ -105,7 +104,7 @@ def exec_hocr_to_ocr_pdf(context: PdfContext, executor: Executor) -> Sequence[st def run_hocr_to_ocr_pdf_pipeline( - options: argparse.Namespace, + options: OcrOptions, *, plugin_manager: OcrmypdfPluginManager, ) -> ExitCode: @@ -119,7 +118,7 @@ def run_hocr_to_ocr_pdf_pipeline( # Gather pdfinfo and create context pdfinfo = do_get_pdfinfo(origin_pdf, executor, options) context = PdfContext(options, work_folder, origin_pdf, pdfinfo, plugin_manager) - plugin_manager.hook.check_options(options=options) + plugin_manager.check_options(options=options) optimize_messages = exec_hocr_to_ocr_pdf(context, executor) return report_output_pdf(options, origin_pdf, optimize_messages) diff --git a/src/ocrmypdf/_pipelines/ocr.py b/src/ocrmypdf/_pipelines/ocr.py index e742dce0..dd0288e7 100644 --- a/src/ocrmypdf/_pipelines/ocr.py +++ b/src/ocrmypdf/_pipelines/ocr.py @@ -6,7 +6,6 @@ from __future__ import annotations -import argparse import logging import logging.handlers from collections.abc import Sequence @@ -19,13 +18,14 @@ import PIL from ocrmypdf._concurrent import Executor from ocrmypdf._graft import OcrGrafter from ocrmypdf._jobcontext import PageContext, PdfContext +from ocrmypdf._options import OcrOptions from ocrmypdf._pipeline import ( copy_final, is_ocr_required, merge_sidecars, + ocr_engine_direct, ocr_engine_hocr, ocr_engine_textonly_pdf, - render_hocr_page, triage, validate_pdfinfo_options, ) @@ -49,23 +49,32 @@ from ocrmypdf._validation import ( create_input_file, ) from ocrmypdf.exceptions import ExitCode +from ocrmypdf.helpers import available_cpu_count +from ocrmypdf.models.ocr_element import OcrElement log = logging.getLogger(__name__) def _image_to_ocr_text( page_context: PageContext, ocr_image_out: Path -) -> tuple[Path, Path]: +) -> tuple[Path | None, Path, OcrElement | None]: """Run OCR engine on image to create OCR PDF and text file.""" options = page_context.options - if options.pdf_renderer.startswith('hocr'): - hocr_out, text_out = ocr_engine_hocr(ocr_image_out, page_context) - ocr_out = render_hocr_page(hocr_out, page_context) - elif options.pdf_renderer == 'sandwich': + pdf_renderer = options.pdf_renderer + + # fpdf2 is the default renderer (auto resolves to fpdf2) + if pdf_renderer in ('auto', 'fpdf2'): + # Use generate_ocr() if the engine supports it, otherwise use hOCR path + ocr_engine = page_context.plugin_manager.get_ocr_engine(options=options) + if ocr_engine and ocr_engine.supports_generate_ocr(): + ocr_tree, text_out = ocr_engine_direct(ocr_image_out, page_context) + return None, text_out, ocr_tree + ocr_out, text_out = ocr_engine_hocr(ocr_image_out, page_context) + elif pdf_renderer == 'sandwich': ocr_out, text_out = ocr_engine_textonly_pdf(ocr_image_out, page_context) else: - raise NotImplementedError(f"pdf_renderer {options.pdf_renderer}") - return ocr_out, text_out + raise NotImplementedError(f"pdf_renderer {pdf_renderer}") + return ocr_out, text_out, None def _exec_page_sync(page_context: PageContext) -> PageResult: @@ -78,22 +87,24 @@ def _exec_page_sync(page_context: PageContext) -> PageResult: ocr_image_out, pdf_page_from_image_out, orientation_correction = process_page( page_context ) - ocr_out, text_out = _image_to_ocr_text(page_context, ocr_image_out) + ocr_out, text_out, ocr_tree = _image_to_ocr_text(page_context, ocr_image_out) return PageResult( pageno=page_context.pageno, pdf_page_from_image=pdf_page_from_image_out, ocr=ocr_out, text=text_out, orientation_correction=orientation_correction, + ocr_tree=ocr_tree, ) def exec_concurrent(context: PdfContext, executor: Executor) -> Sequence[str]: """Execute the OCR pipeline concurrently.""" options = context.options - max_workers = min(len(context.pdfinfo), options.jobs) + jobs = options.jobs or available_cpu_count() + max_workers = min(len(context.pdfinfo), jobs) if max_workers > 1: - log.info("Start processing %d pages concurrently", max_workers) + log.info("Starting processing with %d workers concurrently", max_workers) sidecars: list[Path | None] = [None] * len(context.pdfinfo) ocrgraft = OcrGrafter(context) @@ -107,7 +118,8 @@ def exec_concurrent(context: PdfContext, executor: Executor) -> Sequence[str]: ocrgraft.graft_page( pageno=result.pageno, image=result.pdf_page_from_image, - textpdf=result.ocr, + ocr_output=result.ocr, + ocr_tree=result.ocr_tree, autorotate_correction=result.orientation_correction, ) pbar.update(0.5) @@ -119,7 +131,7 @@ def exec_concurrent(context: PdfContext, executor: Executor) -> Sequence[str]: max_workers=max_workers, progress_kwargs=dict( total=len(context.pdfinfo), - desc='OCR' if options.tesseract_timeout > 0 else 'Image processing', + desc='OCR' if options.ocr_engine != 'none' else 'Image processing', unit='page', disable=not options.progress_bar, ), @@ -150,7 +162,7 @@ def exec_concurrent(context: PdfContext, executor: Executor) -> Sequence[str]: def _run_pipeline( - options: argparse.Namespace, + options: OcrOptions, plugin_manager: OcrmypdfPluginManager, ) -> ExitCode: with ( @@ -185,14 +197,14 @@ def _run_pipeline( def run_pipeline_cli( - options: argparse.Namespace, + options: OcrOptions, *, plugin_manager: OcrmypdfPluginManager, ) -> ExitCode: """Run the OCR pipeline with command line exception handling. Args: - options: The parsed command line options. + options: The parsed OCR options. plugin_manager: The plugin manager to use. If not provided, one will be created. """ @@ -200,14 +212,14 @@ def run_pipeline_cli( def run_pipeline( - options: argparse.Namespace, + options: OcrOptions, *, plugin_manager: OcrmypdfPluginManager, ) -> ExitCode: """Run the OCR pipeline without command line exception handling. Args: - options: The parsed command line options. + options: The parsed OCR options. plugin_manager: The plugin manager to use. If not provided, one will be created. """ diff --git a/src/ocrmypdf/_pipelines/pdf_to_hocr.py b/src/ocrmypdf/_pipelines/pdf_to_hocr.py index 457d144c..b63f67d9 100644 --- a/src/ocrmypdf/_pipelines/pdf_to_hocr.py +++ b/src/ocrmypdf/_pipelines/pdf_to_hocr.py @@ -6,7 +6,6 @@ from __future__ import annotations -import argparse import logging import logging.handlers import shutil @@ -16,6 +15,7 @@ import PIL from ocrmypdf._concurrent import Executor from ocrmypdf._jobcontext import PageContext, PdfContext +from ocrmypdf._options import OcrOptions from ocrmypdf._pipeline import ( is_ocr_required, ocr_engine_hocr, @@ -31,9 +31,7 @@ from ocrmypdf._pipelines._common import ( worker_init, ) from ocrmypdf._plugin_manager import OcrmypdfPluginManager -from ocrmypdf._validation import ( - set_lossless_reconstruction, -) +from ocrmypdf.helpers import available_cpu_count log = logging.getLogger(__name__) @@ -64,9 +62,10 @@ def exec_pdf_to_hocr(context: PdfContext, executor: Executor) -> None: """Execute the OCR pipeline concurrently and output hOCR.""" # Run exec_page_sync on every page options = context.options - max_workers = min(len(context.pdfinfo), options.jobs) + jobs = options.jobs or available_cpu_count() + max_workers = min(len(context.pdfinfo), jobs) if max_workers > 1: - log.info("Start processing %d pages concurrently", max_workers) + log.info("Starting processing with %d workers concurrently", max_workers) executor( use_threads=options.use_threads, @@ -85,11 +84,13 @@ def exec_pdf_to_hocr(context: PdfContext, executor: Executor) -> None: def run_hocr_pipeline( - options: argparse.Namespace, + options: OcrOptions, *, plugin_manager: OcrmypdfPluginManager, ) -> None: """Run pipeline to output hOCR.""" + if options.output_folder is None: + raise ValueError("output_folder must be specified for hOCR pipeline") with manage_work_folder( work_folder=options.output_folder, retain=True, print_location=False ) as work_folder: @@ -103,6 +104,5 @@ def run_hocr_pipeline( options, work_folder, options.input_file, pdfinfo, plugin_manager ) # Validate options are okay for this pdf - set_lossless_reconstruction(options) validate_pdfinfo_options(context) exec_pdf_to_hocr(context, executor) diff --git a/src/ocrmypdf/_plugin_manager.py b/src/ocrmypdf/_plugin_manager.py index 090aaf27..02999aa2 100644 --- a/src/ocrmypdf/_plugin_manager.py +++ b/src/ocrmypdf/_plugin_manager.py @@ -1,33 +1,44 @@ # SPDX-FileCopyrightText: 2022 James R. Barlow # SPDX-License-Identifier: MPL-2.0 -"""Plugin manager using pluggy.""" +"""Plugin manager using pluggy with type-safe interface.""" from __future__ import annotations -import argparse import importlib import importlib.util import pkgutil import sys +from argparse import ArgumentParser from collections.abc import Sequence +from logging import Handler from pathlib import Path +from typing import TYPE_CHECKING import pluggy +from pydantic import BaseModel import ocrmypdf.builtin_plugins -from ocrmypdf import pluginspec -from ocrmypdf.cli import get_parser, plugins_only_parser +from ocrmypdf import Executor, PdfContext, pluginspec +from ocrmypdf._options import OcrOptions +from ocrmypdf._progressbar import ProgressBar +from ocrmypdf.helpers import Resolution +from ocrmypdf.pluginspec import OcrEngine + +if TYPE_CHECKING: + from PIL import Image + + from ocrmypdf._jobcontext import PageContext + from ocrmypdf.pdfinfo import PdfInfo -class OcrmypdfPluginManager(pluggy.PluginManager): - """pluggy.PluginManager that can fork. +class OcrmypdfPluginManager: + """Type-safe wrapper around pluggy.PluginManager. - Capable of reconstructing itself in child workers. + Capable of reconstructing itself in child workers via pickle. - Arguments: - setup_func: callback that initializes the plugin manager with all - standard plugins + This class provides type-safe methods for all hooks defined in pluginspec.py, + removing the need for unsafe `hook.method_name()` calls. """ def __init__( @@ -37,19 +48,28 @@ class OcrmypdfPluginManager(pluggy.PluginManager): builtins: bool = True, **kwargs, ): - self.__init_args = args - self.__init_kwargs = kwargs - self.__plugins = plugins - self.__builtins = builtins - super().__init__(*args, **kwargs) - self.setup_plugins() + self._init_args = args + self._init_kwargs = kwargs + self._plugins = plugins + self._builtins = builtins + self._pm = pluggy.PluginManager(*args, **kwargs) + self._setup_plugins() + + @property + def pluggy(self) -> pluggy.PluginManager: + """Access the underlying pluggy.PluginManager for advanced use cases. + + This is useful for plugins that need to call methods like set_blocked() + in their initialize hook. + """ + return self._pm def __getstate__(self): state = dict( - init_args=self.__init_args, - plugins=self.__plugins, - builtins=self.__builtins, - init_kwargs=self.__init_kwargs, + init_args=self._init_args, + plugins=self._plugins, + builtins=self._builtins, + init_kwargs=self._init_kwargs, ) return state @@ -61,23 +81,23 @@ class OcrmypdfPluginManager(pluggy.PluginManager): **state['init_kwargs'], ) - def setup_plugins(self): - self.add_hookspecs(pluginspec) + def _setup_plugins(self): + self._pm.add_hookspecs(pluginspec) # 1. Register builtins - if self.__builtins: + if self._builtins: for module in sorted( pkgutil.iter_modules(ocrmypdf.builtin_plugins.__path__) ): name = f'ocrmypdf.builtin_plugins.{module.name}' module = importlib.import_module(name) - self.register(module) + self._pm.register(module) # 2. Register setuptools plugins - self.load_setuptools_entrypoints('ocrmypdf') + self._pm.load_setuptools_entrypoints('ocrmypdf') # 3. Register plugins specified on command line - for name in self.__plugins: + for name in self._plugins: if isinstance(name, Path) or name.endswith('.py'): # Import by filename module_name = Path(name).stem @@ -88,7 +108,156 @@ class OcrmypdfPluginManager(pluggy.PluginManager): else: # Import by dotted module name module = importlib.import_module(name) - self.register(module) + self._pm.register(module) + + # ========================================================================= + # Type-safe hook methods + # ========================================================================= + + # --- firstresult hooks --- + + def get_logging_console(self) -> Handler | None: + """Returns a custom logging handler for progress bar compatibility.""" + return self._pm.hook.get_logging_console() + + def get_executor(self, *, progressbar_class: type[ProgressBar]) -> Executor | None: + """Returns an executor for parallel processing.""" + return self._pm.hook.get_executor(progressbar_class=progressbar_class) + + def get_progressbar_class(self) -> type[ProgressBar] | None: + """Returns a progress bar class.""" + return self._pm.hook.get_progressbar_class() + + def rasterize_pdf_page( + self, + *, + input_file: Path, + output_file: Path, + raster_device: str, + raster_dpi: Resolution, + pageno: int, + page_dpi: Resolution | None, + rotation: int | None, + filter_vector: bool, + stop_on_soft_error: bool, + options: OcrOptions | None, + use_cropbox: bool, + ) -> Path | None: + """Rasterize one page of a PDF at specified resolution.""" + return self._pm.hook.rasterize_pdf_page( + input_file=input_file, + output_file=output_file, + raster_device=raster_device, + raster_dpi=raster_dpi, + pageno=pageno, + page_dpi=page_dpi, + rotation=rotation, + filter_vector=filter_vector, + stop_on_soft_error=stop_on_soft_error, + options=options, + use_cropbox=use_cropbox, + ) + + def filter_ocr_image( + self, *, page: PageContext, image: Image.Image + ) -> Image.Image | None: + """Filter the image before it is sent to OCR.""" + return self._pm.hook.filter_ocr_image(page=page, image=image) + + def filter_page_image( + self, *, page: PageContext, image_filename: Path + ) -> Path | None: + """Filter the whole page image before it is inserted into the PDF.""" + return self._pm.hook.filter_page_image(page=page, image_filename=image_filename) + + def filter_pdf_page( + self, *, page: PageContext, image_filename: Path, output_pdf: Path + ) -> Path | None: + """Convert a filtered whole page image into a PDF.""" + return self._pm.hook.filter_pdf_page( + page=page, image_filename=image_filename, output_pdf=output_pdf + ) + + def get_ocr_engine(self, *, options: OcrOptions | None = None) -> OcrEngine | None: + """Returns an OcrEngine to use for processing. + + Args: + options: OcrOptions to pass to the hook for engine selection. + """ + return self._pm.hook.get_ocr_engine(options=options) + + def generate_pdfa( + self, + *, + pdf_pages: list[Path], + pdfmark: Path, + output_file: Path, + context: PdfContext, + pdf_version: str, + pdfa_part: str, + progressbar_class: type[ProgressBar] | None, + stop_on_soft_error: bool, + ) -> Path | None: + """Generate a PDF/A file.""" + return self._pm.hook.generate_pdfa( + pdf_pages=pdf_pages, + pdfmark=pdfmark, + output_file=output_file, + context=context, + pdf_version=pdf_version, + pdfa_part=pdfa_part, + progressbar_class=progressbar_class, + stop_on_soft_error=stop_on_soft_error, + ) + + def optimize_pdf( + self, + *, + input_pdf: Path, + output_pdf: Path, + context: PdfContext, + executor: Executor, + linearize: bool, + ) -> tuple[Path, Sequence[str]] | None: + """Optimize a PDF after OCR processing.""" + return self._pm.hook.optimize_pdf( + input_pdf=input_pdf, + output_pdf=output_pdf, + context=context, + executor=executor, + linearize=linearize, + ) + + def is_optimization_enabled(self, *, context: PdfContext) -> bool | None: + """Returns whether optimization is enabled for given context.""" + return self._pm.hook.is_optimization_enabled(context=context) + + # --- non-firstresult hooks --- + + def initialize(self, *, plugin_manager: pluggy.PluginManager) -> list[None]: + """Called when plugins are first loaded. + + Args: + plugin_manager: The underlying pluggy.PluginManager, allowing + plugins to call methods like set_blocked(). + """ + return self._pm.hook.initialize(plugin_manager=plugin_manager) + + def add_options(self, *, parser: ArgumentParser) -> list[None]: + """Allows plugins to add command line and API arguments.""" + return self._pm.hook.add_options(parser=parser) + + def register_options(self) -> list[dict[str, type[BaseModel]]]: + """Returns plugin option models keyed by namespace.""" + return self._pm.hook.register_options() + + def check_options(self, *, options: OcrOptions) -> list[None]: + """Called to validate options after parsing.""" + return self._pm.hook.check_options(options=options) + + def validate(self, *, pdfinfo: PdfInfo, options: OcrOptions) -> list[None]: + """Called to validate options and pdfinfo after PDF is loaded.""" + return self._pm.hook.validate(pdfinfo=pdfinfo, options=options) def get_plugin_manager( @@ -101,20 +270,4 @@ def get_plugin_manager( ) -def get_parser_options_plugins( - args: Sequence[str], -) -> tuple[argparse.ArgumentParser, argparse.Namespace, pluggy.PluginManager]: - pre_options, _unused = plugins_only_parser.parse_known_args(args=args) - plugin_manager = get_plugin_manager(pre_options.plugins) - - parser = get_parser() - plugin_manager.hook.initialize( # pylint: disable=no-member - plugin_manager=plugin_manager - ) - plugin_manager.hook.add_options(parser=parser) # pylint: disable=no-member - - options = parser.parse_args(args=args) - return parser, options, plugin_manager - - -__all__ = ['OcrmypdfPluginManager', 'get_plugin_manager', 'get_parser_options_plugins'] +__all__ = ['OcrmypdfPluginManager', 'get_plugin_manager'] diff --git a/src/ocrmypdf/_plugin_registry.py b/src/ocrmypdf/_plugin_registry.py new file mode 100644 index 00000000..26829824 --- /dev/null +++ b/src/ocrmypdf/_plugin_registry.py @@ -0,0 +1,50 @@ +# SPDX-FileCopyrightText: 2024 James R. Barlow +# SPDX-License-Identifier: MPL-2.0 + +"""Plugin option registry for dynamic model composition.""" + +from __future__ import annotations + +import logging + +from pydantic import BaseModel + +log = logging.getLogger(__name__) + + +class PluginOptionRegistry: + """Registry for plugin option models. + + This registry collects option models from plugins during initialization. + Plugin options can be accessed via nested namespaces on OcrOptions + (e.g., options.tesseract.timeout) or via flat field names for backward + compatibility (e.g., options.tesseract_timeout). + """ + + def __init__(self): + self._option_models: dict[str, type[BaseModel]] = {} + + def register_option_model( + self, namespace: str, model_class: type[BaseModel] + ) -> None: + """Register a plugin's option model. + + Args: + namespace: The namespace for the plugin options (e.g., 'tesseract') + model_class: The Pydantic model class for the plugin options + """ + if namespace in self._option_models: + log.warning( + f"Plugin option namespace '{namespace}' already registered, overriding" + ) + + self._option_models[namespace] = model_class + + log.debug( + f"Registered plugin option model for namespace '{namespace}': " + f"{model_class.__name__}" + ) + + def get_registered_models(self) -> dict[str, type[BaseModel]]: + """Get all registered plugin option models.""" + return self._option_models.copy() diff --git a/src/ocrmypdf/_progressbar.py b/src/ocrmypdf/_progressbar.py index f15a171d..c5e33d56 100644 --- a/src/ocrmypdf/_progressbar.py +++ b/src/ocrmypdf/_progressbar.py @@ -48,9 +48,11 @@ class ProgressBar(Protocol): A brief description of the current step (e.g. "Scanning contents", "OCR", "PDF/A conversion"). OCRmyPDF updates this before each major step. unit (str | None): - A short label for the type of work being tracked (e.g. "page", "%", "image"). + A short label for the type of work being tracked + (e.g. "page", "%", "image"). disable (bool): - If ``True``, progress updates are suppressed (no output). Defaults to ``False``. + If ``True``, progress updates are suppressed (no output). + Defaults to ``False``. **kwargs: Future or extra parameters that OCRmyPDF might pass. Implementations should accept and ignore unrecognized keywords gracefully. @@ -64,7 +66,8 @@ class ProgressBar(Protocol): from ocrmypdf import hookimpl class ConsoleProgressBar(ProgressBar): - def __init__(self, *, total=None, desc=None, unit=None, disable=False, **kwargs): + def __init__(self, *, total=None, desc=None, unit=None, disable=False, + **kwargs): self.total = total self.desc = desc self.unit = unit @@ -73,7 +76,9 @@ class ProgressBar(Protocol): def __enter__(self): if not self.disable: - print(f"Starting {self.desc or 'an OCR task'} (total={self.total} {self.unit})") + print(f"Starting {self.desc or 'an OCR task'} " + f"(total={self.total} {self.unit})" + ) return self def __exit__(self, exc_type, exc_value, traceback): @@ -86,7 +91,7 @@ class ProgressBar(Protocol): def update(self, n=1, *, completed=None): if completed is not None: - # If 'completed' is given, you could set self.current = completed + # If 'completed' is given, set self.current # but let's just read it to show usage print(f"Absolute completion reported: {completed}") # Otherwise, we increment by 'n' @@ -94,7 +99,10 @@ class ProgressBar(Protocol): if not self.disable: if self.total: percent = (self.current / self.total) * 100 - print(f"{self.desc}: {self.current}/{self.total} ({percent:.1f}%)") + print( + f"{self.desc}: {self.current}" + f"/{self.total} ({percent:.1f}%)" + ) else: print(f"{self.desc}: {self.current} units done") diff --git a/src/ocrmypdf/_validation.py b/src/ocrmypdf/_validation.py index 76f9b7a4..e28b1a2c 100644 --- a/src/ocrmypdf/_validation.py +++ b/src/ocrmypdf/_validation.py @@ -6,22 +6,19 @@ from __future__ import annotations -import locale import logging import os import sys -import unicodedata -from argparse import Namespace from collections.abc import Sequence from pathlib import Path from shutil import copyfileobj import pikepdf -import PIL -from pluggy import PluginManager -from ocrmypdf._defaults import DEFAULT_LANGUAGE, DEFAULT_ROTATE_PAGES_THRESHOLD +from ocrmypdf._defaults import DEFAULT_ROTATE_PAGES_THRESHOLD from ocrmypdf._exec import unpaper +from ocrmypdf._options import OcrOptions +from ocrmypdf._plugin_manager import OcrmypdfPluginManager from ocrmypdf.exceptions import ( BadArgsError, InputFileError, @@ -30,7 +27,6 @@ from ocrmypdf.exceptions import ( ) from ocrmypdf.helpers import ( is_file_writable, - monotonic, running_in_docker, running_in_snap, safe_symlink, @@ -51,13 +47,19 @@ def check_platform() -> None: def check_options_languages( - options: Namespace, ocr_engine_languages: list[str] + options: OcrOptions, ocr_engine_languages: list[str] ) -> None: - if not options.languages: - options.languages = [DEFAULT_LANGUAGE] - system_lang = locale.getlocale()[0] - if system_lang and not system_lang.startswith('en'): - log.debug("No language specified; assuming --language %s", DEFAULT_LANGUAGE) + # Check for blocked languages first, before checking if they're installed + DENIED_LANGUAGES = {'equ', 'osd'} + blocked = DENIED_LANGUAGES & set(options.languages) + if blocked: + raise BadArgsError( + "The following languages are for Tesseract's internal use and " + "should not be issued explicitly: " + f"{', '.join(blocked)}\n" + "Remove them from the -l/--language argument." + ) + if not ocr_engine_languages: return @@ -81,36 +83,7 @@ def check_options_languages( raise MissingDependencyError(msg) -def check_options_output(options: Namespace) -> None: - if options.output_type == 'none' and options.output_file not in (os.devnull, '-'): - raise BadArgsError( - "Since you specified `--output-type none`, the output file " - f"{options.output_file} cannot be produced. Set the output file to " - f"`-` to suppress this message." - ) - - -def set_lossless_reconstruction(options: Namespace) -> None: - lossless_reconstruction = False - if not any( - ( - options.deskew, - options.clean_final, - options.force_ocr, - options.remove_background, - ) - ): - lossless_reconstruction = True - options.lossless_reconstruction = lossless_reconstruction - - if not options.lossless_reconstruction and options.redo_ocr: - raise BadArgsError( - "--redo-ocr is not currently compatible with --deskew, " - "--clean-final, and --remove-background" - ) - - -def check_options_sidecar(options: Namespace) -> None: +def check_options_sidecar(options: OcrOptions) -> None: if options.sidecar == '\0': if options.output_file == '-': raise BadArgsError("--sidecar filename needed when output file is stdout.") @@ -125,7 +98,7 @@ def check_options_sidecar(options: Namespace) -> None: ) -def check_options_preprocessing(options: Namespace) -> None: +def check_options_preprocessing(options: OcrOptions) -> None: if options.clean_final: options.clean = True if options.unpaper_args and not options.clean: @@ -152,98 +125,45 @@ def check_options_preprocessing(options: Namespace) -> None: raise BadArgsError("--unpaper-args: " + str(e)) from e -def _pages_from_ranges(ranges: str) -> set[int]: - pages: list[int] = [] - page_groups = ranges.replace(' ', '').split(',') - for group in page_groups: - if not group: - continue - try: - start, end = group.split('-') - except ValueError: - pages.append(int(group) - 1) - else: - try: - new_pages = list(range(int(start) - 1, int(end))) - if not new_pages: - raise BadArgsError( - f"invalid page subrange '{start}-{end}'" - ) from None - pages.extend(new_pages) - except ValueError: - raise BadArgsError(f"invalid page subrange '{group}'") from None - - if not pages: - raise BadArgsError( - f"The string of page ranges '{ranges}' did not contain any recognizable " - f"page ranges." - ) - - if not monotonic(pages): - log.warning( - "List of pages to process contains duplicate pages, or pages that are " - "out of order" - ) - if any(page < 0 for page in pages): - raise BadArgsError("pages refers to a page number less than 1") - - log.debug("OCRing only these pages: %s", pages) - return set(pages) - - -def check_options_ocr_behavior(options: Namespace) -> None: - exclusive_options = sum( - (1 if opt else 0) - for opt in (options.force_ocr, options.skip_text, options.redo_ocr) - ) - if exclusive_options >= 2: - raise BadArgsError("Choose only one of --force-ocr, --skip-text, --redo-ocr.") - if options.pages: - options.pages = _pages_from_ranges(options.pages) - - -def check_options_metadata(options: Namespace) -> None: - docinfo = [options.title, options.author, options.keywords, options.subject] - for s in (m for m in docinfo if m): - for char in s: - if unicodedata.category(char) == 'Co' or ord(char) >= 0x10000: - hexchar = hex(ord(char))[2:].upper() - raise ValueError( - "One of the metadata strings contains " - "an unsupported Unicode character: " - f"{char} (U+{hexchar})" - ) - - -def check_options_pillow(options: Namespace) -> None: - PIL.Image.MAX_IMAGE_PIXELS = int(options.max_image_mpixels * 1_000_000) - if PIL.Image.MAX_IMAGE_PIXELS == 0: - PIL.Image.MAX_IMAGE_PIXELS = None # type: ignore - - -def _check_plugin_invariant_options(options: Namespace) -> None: +def _check_plugin_invariant_options(options: OcrOptions) -> None: check_platform() - check_options_metadata(options) - check_options_output(options) - set_lossless_reconstruction(options) check_options_sidecar(options) check_options_preprocessing(options) - check_options_ocr_behavior(options) - check_options_pillow(options) -def _check_plugin_options(options: Namespace, plugin_manager: PluginManager) -> None: - plugin_manager.hook.check_options(options=options) - ocr_engine_languages = plugin_manager.hook.get_ocr_engine().languages(options) +def _check_plugin_options( + options: OcrOptions, plugin_manager: OcrmypdfPluginManager +) -> None: + # First, let plugins check their external dependencies + plugin_manager.check_options(options=options) + + # Then check OCR engine language support + ocr_engine_languages = plugin_manager.get_ocr_engine(options=options).languages( + options + ) check_options_languages(options, ocr_engine_languages) + # Finally, run comprehensive validation using the coordinator + from ocrmypdf._validation_coordinator import ValidationCoordinator -def check_options(options: Namespace, plugin_manager: PluginManager) -> None: + coordinator = ValidationCoordinator(plugin_manager) + coordinator.validate_all_options(options) + + +def check_options(options: OcrOptions, plugin_manager: OcrmypdfPluginManager) -> None: + """Check options for validity and consistency. + + This function coordinates validation across the entire system: + 1. Core validation (platform, files, preprocessing) + 2. Plugin external dependency validation + 3. Plugin-specific validation (handled by plugin models) + 4. Cross-cutting validation (handled by validation coordinator) + """ _check_plugin_invariant_options(options) _check_plugin_options(options, plugin_manager) -def create_input_file(options: Namespace, work_folder: Path) -> tuple[Path, str]: +def create_input_file(options: OcrOptions, work_folder: Path) -> tuple[Path, str]: if options.input_file == '-': # stdin log.info('reading file from standard input') @@ -288,7 +208,7 @@ def create_input_file(options: Namespace, work_folder: Path) -> tuple[Path, str] raise InputFileError(msg) from e -def check_requested_output_file(options: Namespace) -> None: +def check_requested_output_file(options: OcrOptions) -> None: if options.output_file == '-': if sys.stdout.isatty(): raise BadArgsError( @@ -306,7 +226,7 @@ def check_requested_output_file(options: Namespace) -> None: def report_output_file_size( - options: Namespace, + options: OcrOptions, input_file: Path, output_file: Path, optimize_messages: Sequence[str] | None = None, @@ -335,13 +255,15 @@ def report_output_file_size( 'clean_final', 'remove_background', 'oversample', - 'force_ocr', } for arg in image_preproc: if getattr(options, arg, False): reasons.append( f"--{arg.replace('_', '-')} was issued, causing transcoding." ) + # Check force_ocr via the backward-compatible property + if options.force_ocr: + reasons.append("--force-ocr (or --mode force) was issued, causing transcoding.") reasons.extend(optimize_messages) diff --git a/src/ocrmypdf/_validation_coordinator.py b/src/ocrmypdf/_validation_coordinator.py new file mode 100644 index 00000000..d5440475 --- /dev/null +++ b/src/ocrmypdf/_validation_coordinator.py @@ -0,0 +1,144 @@ +# SPDX-FileCopyrightText: 2024 James R. Barlow +# SPDX-License-Identifier: MPL-2.0 + +"""Validation coordinator for plugin options and cross-cutting concerns.""" + +from __future__ import annotations + +import logging +import os +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import pluggy + + from ocrmypdf._options import OcrOptions + +log = logging.getLogger(__name__) + + +class ValidationCoordinator: + """Coordinates validation across plugin models and core options.""" + + def __init__(self, plugin_manager: pluggy.PluginManager): + self.plugin_manager = plugin_manager + self.registry = getattr(plugin_manager, '_option_registry', None) + + def validate_all_options(self, options: OcrOptions) -> None: + """Run comprehensive validation on all options. + + This runs validation in the correct order: + 1. Plugin self-validation (already done by Pydantic) + 2. Plugin context validation (requires external context) + 3. Cross-cutting validation (between plugins and core) + + Args: + options: The options to validate + """ + # Step 1: Plugin context validation + self._validate_plugin_contexts(options) + + # Step 2: Cross-cutting validation + self._validate_cross_cutting_concerns(options) + + def _validate_plugin_contexts(self, options: OcrOptions) -> None: + """Validate plugin options that require external context.""" + # For now, we'll run the plugin validation directly since the models + # are still being integrated. This ensures the validation warnings + # and checks still work as expected. + + # Run Tesseract validation + self._validate_tesseract_options(options) + + # Run Optimize validation + self._validate_optimize_options(options) + + def _validate_tesseract_options(self, options: OcrOptions) -> None: + """Validate Tesseract options.""" + # Check pagesegmode warning + if options.tesseract.pagesegmode in (0, 2): + log.warning( + "The tesseract-pagesegmode you selected will disable OCR. " + "This may cause processing to fail." + ) + + # Check downsample consistency + if ( + options.tesseract.downsample_above != 32767 + and not options.tesseract.downsample_large_images + ): + log.warning( + "The --tesseract-downsample-above argument will have no effect unless " + "--tesseract-downsample-large-images is also given." + ) + + # Note: blocked languages (equ, osd) are checked earlier in + # check_options_languages() to ensure the check runs before + # the missing language check. + + def _validate_optimize_options(self, options: OcrOptions) -> None: + """Validate optimization options.""" + # Check optimization consistency + if options.optimize == 0 and any( + [ + options.png_quality and options.png_quality > 0, + options.jpeg_quality and options.jpeg_quality > 0, + ] + ): + log.warning( + "The arguments --png-quality and --jpeg-quality " + "will be ignored because --optimize=0." + ) + + def _validate_cross_cutting_concerns(self, options: OcrOptions) -> None: + """Validate cross-cutting concerns that span multiple plugins.""" + from ocrmypdf._options import ProcessingMode + + # Handle deprecated pdf_renderer values + self._handle_deprecated_pdf_renderer(options) + + # Note: Mutual exclusivity of force_ocr/skip_text/redo_ocr is now enforced + # by the ProcessingMode enum - only one mode can be active at a time. + + # Validate redo mode compatibility + if options.mode == ProcessingMode.redo and ( + options.deskew or options.clean_final or options.remove_background + ): + raise ValueError( + "--redo-ocr (or --mode redo) is not currently compatible with " + "--deskew, --clean-final, and --remove-background" + ) + + # Validate output type compatibility + if options.output_type == 'none' and str(options.output_file) not in ( + os.devnull, + '-', + ): + raise ValueError( + "Since you specified `--output-type none`, the output file " + f"{options.output_file} cannot be produced. Set the output file to " + "`-` to suppress this message." + ) + + # Validate PDF/A image compression compatibility + if ( + options.ghostscript.pdfa_image_compression + and options.ghostscript.pdfa_image_compression != 'auto' + and not options.output_type.startswith('pdfa') + ): + log.warning( + "--pdfa-image-compression argument only applies when " + "--output-type is one of 'pdfa', 'pdfa-1', or 'pdfa-2'" + ) + + def _handle_deprecated_pdf_renderer(self, options: OcrOptions) -> None: + """Handle deprecated pdf_renderer values by redirecting to fpdf2.""" + if options.pdf_renderer in ('hocr', 'hocrdebug'): + log.info( + "The '%s' PDF renderer has been removed. Using 'fpdf2' instead, " + "which provides full international language support, proper RTL " + "rendering, and improved text positioning.", + options.pdf_renderer, + ) + # Modify the options object to use fpdf2 + object.__setattr__(options, 'pdf_renderer', 'fpdf2') diff --git a/src/ocrmypdf/api.py b/src/ocrmypdf/api.py index 0d737aef..350c064d 100644 --- a/src/ocrmypdf/api.py +++ b/src/ocrmypdf/api.py @@ -43,24 +43,22 @@ import logging import os import sys import threading -from argparse import Namespace from collections.abc import Iterable, Sequence from enum import IntEnum from io import IOBase from pathlib import Path -from typing import BinaryIO +from typing import BinaryIO, overload from warnings import warn -import pluggy - from ocrmypdf._logging import PageNumberFilter +from ocrmypdf._options import OcrOptions from ocrmypdf._pipelines.hocr_to_ocr_pdf import run_hocr_to_ocr_pdf_pipeline from ocrmypdf._pipelines.ocr import run_pipeline, run_pipeline_cli from ocrmypdf._pipelines.pdf_to_hocr import run_hocr_pipeline -from ocrmypdf._plugin_manager import get_plugin_manager +from ocrmypdf._plugin_manager import OcrmypdfPluginManager, get_plugin_manager from ocrmypdf._validation import check_options from ocrmypdf.cli import ArgumentParser, get_parser -from ocrmypdf.helpers import is_iterable_notstr +from ocrmypdf.exceptions import ExitCode StrPath = Path | str | bytes PathOrIO = BinaryIO | StrPath @@ -71,6 +69,67 @@ PathOrIO = BinaryIO | StrPath _api_lock = threading.Lock() +def setup_plugin_infrastructure( + plugins: Sequence[Path | str] | None = None, + plugin_manager: OcrmypdfPluginManager | None = None, +) -> OcrmypdfPluginManager: + """Set up plugin infrastructure with proper initialization. + + This function handles: + 1. Creating or validating the plugin manager + 2. Calling plugin initialization hooks + 3. Setting up plugin option registry + + Args: + plugins: List of plugin paths/names to load + plugin_manager: Existing plugin manager (if any) + + Returns: + Properly initialized plugin manager + + Raises: + ValueError: If both plugins and plugin_manager are provided + """ + if plugins and plugin_manager: + raise ValueError("plugins= and plugin_manager are mutually exclusive") + + if not plugins: + plugins = [] + elif isinstance(plugins, str | Path): + plugins = [plugins] + else: + plugins = list(plugins) + + # Create plugin manager if not provided + if not plugin_manager: + plugin_manager = get_plugin_manager(plugins) + + # Initialize plugins (pass the underlying pluggy manager) + plugin_manager.initialize(plugin_manager=plugin_manager.pluggy) + + # Initialize plugin option registry + from ocrmypdf._plugin_registry import PluginOptionRegistry + + registry = PluginOptionRegistry() + + # Let plugins register their option models + option_models = plugin_manager.register_options() + all_plugin_models: dict[str, type] = {} + for plugin_options in option_models: + if plugin_options: # Skip None returns + for namespace, model_class in plugin_options.items(): + registry.register_option_model(namespace, model_class) + all_plugin_models[namespace] = model_class + + # Register plugin models with OcrOptions for dynamic nested access + OcrOptions.register_plugin_models(all_plugin_models) + + # Store registry in plugin manager for later access + plugin_manager._option_registry = registry + + return plugin_manager + + class Verbosity(IntEnum): """Verbosity level for configure_logging.""" @@ -86,7 +145,7 @@ def configure_logging( *, progress_bar_friendly: bool = True, manage_root_logger: bool = False, - plugin_manager: pluggy.PluginManager | None = None, + plugin_manager: OcrmypdfPluginManager | None = None, ): """Set up logging. @@ -133,7 +192,7 @@ def configure_logging( console = None if plugin_manager and progress_bar_friendly: - console = plugin_manager.hook.get_logging_console() + console = plugin_manager.get_logging_console() if not console: console = logging.StreamHandler(stream=sys.stderr) @@ -165,6 +224,8 @@ def configure_logging( pdfminer_log.setLevel(logging.ERROR) pil_log = logging.getLogger('PIL') pil_log.setLevel(logging.INFO) + fonttools_log = logging.getLogger('fontTools') + fonttools_log.setLevel(logging.ERROR) if manage_root_logger: logging.captureWarnings(True) @@ -172,98 +233,130 @@ def configure_logging( return log -def _kwargs_to_cmdline( - *, defer_kwargs: set[str], **kwargs -) -> tuple[list[str | bytes], dict[str, str | bytes]]: - """Convert kwargs to command line arguments.""" - cmdline: list[str | bytes] = [] - deferred = {} - for arg, val in kwargs.items(): - if val is None: - continue +def _check_no_conflicting_ocr_params( + locals_dict: dict, + kwargs: dict, + excluded: set[str] | None = None, +) -> None: + """Check that no individual OCR parameters conflict with OcrOptions. - # Skip arguments that are handled elsewhere - if arg in defer_kwargs: - deferred[arg] = val - continue + When a user passes an OcrOptions object, they should not also pass + individual OCR parameters (except plugins/plugin_manager which are + handled separately). - cmd_style_arg = arg.replace('_', '-') + Args: + locals_dict: The locals() dict from the calling function. + kwargs: The **kwargs dict from the calling function. + excluded: Parameter names to exclude from conflict checking. - # Booleans are special: add only if True, omit for False - if isinstance(val, bool): - if val: - cmdline.append(f"--{cmd_style_arg}") - continue + Raises: + ValueError: If conflicting parameters are found. + """ + if excluded is None: + excluded = set() - if is_iterable_notstr(val): - for elem in val: - cmdline.append(f"--{cmd_style_arg}") - cmdline.append(elem) - continue + # Parameters that are allowed alongside OcrOptions + allowed_with_options = { + 'input_file_or_options', + 'options', # The OcrOptions object itself after assignment + 'plugins', + 'plugin_manager', + 'kwargs', + } | excluded - # We have a parameter - cmdline.append(f"--{cmd_style_arg}") - if isinstance(val, int | float): - cmdline.append(str(val)) - elif isinstance(val, str): - cmdline.append(val) - elif isinstance(val, Path): - cmdline.append(str(val)) - else: - raise TypeError(f"{arg}: {val} ({type(val)})") - return cmdline, deferred + # Check all locals that are OCR parameters (not None and not allowed) + conflicts = [ + name + for name, value in locals_dict.items() + if value is not None and name not in allowed_with_options + ] + + # Check kwargs + conflicts.extend(kwargs.keys()) + + if conflicts: + raise ValueError( + f"When passing OcrOptions as the first argument, do not pass " + f"additional OCR parameters. Conflicting parameters: " + f"{', '.join(sorted(conflicts))}. " + f"Set these values in OcrOptions instead." + ) def create_options( *, input_file: PathOrIO, output_file: PathOrIO, parser: ArgumentParser, **kwargs -) -> Namespace: +) -> OcrOptions: """Construct an options object from the input/output files and keyword arguments. Args: input_file: Input file path or file object. output_file: Output file path or file object. - parser: ArgumentParser object. + parser: ArgumentParser object (kept for compatibility, + may be used for plugin validation). **kwargs: Keyword arguments. Returns: - argparse.Namespace: A Namespace object containing the parsed arguments. + OcrOptions: An options object containing the parsed arguments. Raises: TypeError: If the type of a keyword argument is not supported. """ - cmdline, deferred = _kwargs_to_cmdline( - defer_kwargs={'progress_bar', 'plugins', 'parser', 'input_file', 'output_file'}, - **kwargs, - ) - if isinstance(input_file, BinaryIO | IOBase): - cmdline.append('stream://input_file') - else: - cmdline.append(os.fspath(input_file)) - if isinstance(output_file, BinaryIO | IOBase): - cmdline.append('stream://output_file') - else: - cmdline.append(os.fspath(output_file)) - if 'sidecar' in kwargs and isinstance(kwargs['sidecar'], BinaryIO | IOBase): - cmdline.append('--sidecar') - cmdline.append('stream://sidecar') + # Prepare kwargs for direct OcrOptions construction + options_kwargs = kwargs.copy() - parser.enable_api_mode() - options = parser.parse_args(cmdline) - for keyword, val in deferred.items(): - setattr(options, keyword, val) + # Set input and output files + options_kwargs['input_file'] = input_file + options_kwargs['output_file'] = output_file - if options.input_file == 'stream://input_file': - options.input_file = input_file - if options.output_file == 'stream://output_file': - options.output_file = output_file - if options.sidecar == 'stream://sidecar': - options.sidecar = kwargs['sidecar'] + # Handle special stream cases for sidecar + if 'sidecar' in options_kwargs and isinstance( + options_kwargs['sidecar'], BinaryIO | IOBase + ): + # Keep the stream object as-is - OcrOptions can handle it + pass - return options + # Remove None values to let OcrOptions use its defaults + options_kwargs = {k: v for k, v in options_kwargs.items() if v is not None} + + # Remove any kwargs that aren't OcrOptions fields and store in extra_attrs + extra_attrs = {} + ocr_fields = set(OcrOptions.model_fields.keys()) + # Legacy mode flags are handled by OcrOptions model validator + legacy_mode_flags = {'force_ocr', 'skip_text', 'redo_ocr'} + + # Known extra attributes that should be preserved + known_extra = {'progress_bar', 'plugins'} + + for key in list(options_kwargs.keys()): + if key in ocr_fields or key in legacy_mode_flags or key in known_extra: + continue + extra_attrs[key] = options_kwargs.pop(key) + + # Create OcrOptions directly + try: + options = OcrOptions(**options_kwargs) + # Add any extra attributes + if extra_attrs: + options.extra_attrs.update(extra_attrs) + return options + except Exception as e: + # If direct construction fails, provide a helpful error message + raise TypeError(f"Failed to create OcrOptions: {e}") from e -def ocr( # noqa: D417 - input_file: PathOrIO, +@overload +def ocr( + options: OcrOptions, + /, + *, + plugins: Iterable[Path | str] | None = None, + plugin_manager: OcrmypdfPluginManager | None = None, +) -> ExitCode: ... + + +@overload +def ocr( + input_file_or_options: PathOrIO, output_file: PathOrIO, *, language: Iterable[str] | None = None, @@ -284,6 +377,7 @@ def ocr( # noqa: D417 unpaper_args: str | None = None, oversample: int | None = None, remove_vectors: bool | None = None, + mode: str | None = None, force_ocr: bool | None = None, skip_text: bool | None = None, redo_ocr: bool | None = None, @@ -301,6 +395,7 @@ def ocr( # noqa: D417 tesseract_oem: int | None = None, tesseract_thresholding: int | None = None, pdf_renderer: str | None = None, + rasterizer: str | None = None, tesseract_timeout: float | None = None, tesseract_non_ocr_timeout: float | None = None, tesseract_downsample_above: int | None = None, @@ -314,13 +409,89 @@ def ocr( # noqa: D417 continue_on_soft_render_error: bool | None = None, invalidate_digital_signatures: bool | None = None, plugins: Iterable[Path | str] | None = None, - plugin_manager=None, + plugin_manager: OcrmypdfPluginManager | None = None, keep_temporary_files: bool | None = None, progress_bar: bool | None = None, **kwargs, -): +) -> ExitCode: ... + + +def ocr( # noqa: D417 + input_file_or_options: PathOrIO | OcrOptions, + output_file: PathOrIO | None = None, + *, + language: Iterable[str] | None = None, + image_dpi: int | None = None, + output_type: str | None = None, + sidecar: PathOrIO | None = None, + jobs: int | None = None, + use_threads: bool | None = None, + title: str | None = None, + author: str | None = None, + subject: str | None = None, + keywords: str | None = None, + rotate_pages: bool | None = None, + remove_background: bool | None = None, + deskew: bool | None = None, + clean: bool | None = None, + clean_final: bool | None = None, + unpaper_args: str | None = None, + oversample: int | None = None, + remove_vectors: bool | None = None, + mode: str | None = None, + force_ocr: bool | None = None, # Legacy, use mode='force' instead + skip_text: bool | None = None, # Legacy, use mode='skip' instead + redo_ocr: bool | None = None, # Legacy, use mode='redo' instead + skip_big: float | None = None, + optimize: int | None = None, + jpg_quality: int | None = None, + png_quality: int | None = None, + jbig2_lossy: bool | None = None, # Deprecated, ignored + jbig2_page_group_size: int | None = None, # Deprecated, ignored + jbig2_threshold: float | None = None, + pages: str | None = None, + max_image_mpixels: float | None = None, + tesseract_config: Iterable[str] | None = None, + tesseract_pagesegmode: int | None = None, + tesseract_oem: int | None = None, + tesseract_thresholding: int | None = None, + pdf_renderer: str | None = None, + rasterizer: str | None = None, + tesseract_timeout: float | None = None, + tesseract_non_ocr_timeout: float | None = None, + tesseract_downsample_above: int | None = None, + tesseract_downsample_large_images: bool | None = None, + rotate_pages_threshold: float | None = None, + pdfa_image_compression: str | None = None, + color_conversion_strategy: str | None = None, + user_words: os.PathLike | None = None, + user_patterns: os.PathLike | None = None, + fast_web_view: float | None = None, + continue_on_soft_render_error: bool | None = None, + invalidate_digital_signatures: bool | None = None, + plugins: Iterable[Path | str] | None = None, + plugin_manager: OcrmypdfPluginManager | None = None, + keep_temporary_files: bool | None = None, + progress_bar: bool | None = None, + **kwargs, +) -> ExitCode: """Run OCRmyPDF on one PDF or image. + This function supports two calling conventions: + + **New style (recommended):** + >>> from ocrmypdf import ocr + >>> from ocrmypdf._options import OcrOptions + >>> options = OcrOptions( + ... input_file="input.pdf", + ... output_file="output.pdf", + ... languages=["eng"], + ... ) + >>> ocr(options) + + **Old style:** + >>> ocr("input.pdf", "output.pdf", language=["eng"]) + For most arguments, see documentation for the equivalent command line parameter. This API takes a threading lock, because OCRmyPDF uses global state in particular @@ -337,24 +508,33 @@ def ocr( # noqa: D417 A few specific arguments are discussed here: Args: + input_file_or_options: Either an OcrOptions object containing all settings, + or a path/stream for the input file (old-style API). + output_file: Output file path or stream. Required when using old-style API + with input_file as first argument. Must be None when passing OcrOptions. use_threads: Use worker threads instead of processes. This reduces performance but may make debugging easier since it is easier to set breakpoints. - input_file: If a :class:`pathlib.Path`, ``str`` or ``bytes``, this is - interpreted as file system path to the input file. If the object - appears to be a readable stream (with methods such as ``.read()`` - and ``.seek()``), the object will be read in its entirety and saved to - a temporary file. If ``input_file`` is ``"-"``, standard input will be - read. - output_file: If a :class:`pathlib.Path`, ``str`` or ``bytes``, this is - interpreted as file system path to the output file. If the object - appears to be a writable stream (with methods such as ``.write()`` and - ``.seek()``), the output will be written to this stream. If - ``output_file`` is ``"-"``, the output will be written to ``sys.stdout`` - (provided that standard output does not seem to be a terminal device). - When a stream is used as output, whether via a writable object or - ``"-"``, some final validation steps are not performed (we do not read - back the stream after it is written). + plugins: List of plugin paths to load. Can be passed alongside OcrOptions. + plugin_manager: Pre-configured plugin manager. Can be passed alongside + OcrOptions. + + For input_file (old-style API): If a :class:`pathlib.Path`, ``str`` or + ``bytes``, this is interpreted as file system path to the input file. + If the object appears to be a readable stream (with methods such as + ``.read()`` and ``.seek()``), the object will be read in its entirety + and saved to a temporary file. If ``input_file`` is ``"-"``, standard + input will be read. + + For output_file (old-style API): If a :class:`pathlib.Path`, ``str`` or + ``bytes``, this is interpreted as file system path to the output file. + If the object appears to be a writable stream (with methods such as + ``.write()`` and ``.seek()``), the output will be written to this + stream. If ``output_file`` is ``"-"``, the output will be written to + ``sys.stdout`` (provided that standard output does not seem to be a + terminal device). When a stream is used as output, whether via a + writable object or ``"-"``, some final validation steps are not + performed (we do not read back the stream after it is written). Raises: ocrmypdf.MissingDependencyError: If a required dependency program is missing or @@ -373,45 +553,119 @@ def ocr( # noqa: D417 OCRmyPDF does not remove passwords. ocrmypdf.TesseractConfigError: If Tesseract reported its configuration was not valid. + ValueError: If OcrOptions is passed along with other OCR parameters, or if + both plugins and plugin_manager are provided. + TypeError: If output_file is missing when using the old-style API. Returns: :class:`ocrmypdf.ExitCode` """ - if plugins and plugin_manager: - raise ValueError("plugins= and plugin_manager are mutually exclusive") + # Detect calling convention: OcrOptions object vs individual parameters + if isinstance(input_file_or_options, OcrOptions): + # New-style API: OcrOptions passed directly + options = input_file_or_options + + # Check for conflicting parameters + # (all should be None except plugins/plugin_manager) + _check_no_conflicting_ocr_params(locals(), kwargs) + + # plugins and plugin_manager can still be passed alongside OcrOptions + if plugins and plugin_manager: + raise ValueError("plugins= and plugin_manager are mutually exclusive") + + # Use plugins from OcrOptions if not explicitly passed + if plugins is None: + plugins = options.plugins or [] + + if isinstance(plugins, str | Path): + plugins = [plugins] + else: + plugins = list(plugins) if plugins else [] + + # Run the pipeline with the OcrOptions + with _api_lock: + plugin_manager = setup_plugin_infrastructure( + plugins=plugins, plugin_manager=plugin_manager + ) + + parser = get_parser() + plugin_manager.add_options(parser=parser) + + check_options(options, plugin_manager) + return run_pipeline(options=options, plugin_manager=plugin_manager) - if not plugins: - plugins = [] - elif isinstance(plugins, str | Path): - plugins = [plugins] else: - plugins = list(plugins) + # Old-style API: positional arguments + input_file = input_file_or_options - # No new variable names should be assigned until these two steps are run - create_options_kwargs = { - k: v - for k, v in locals().items() - if k not in {'input_file', 'output_file', 'kwargs', 'plugin_manager'} - } - create_options_kwargs.update(kwargs) + if output_file is None: + raise TypeError( + "ocr() missing required argument: 'output_file'. " + "Either pass output_file as the second argument, or pass " + "an OcrOptions object as the first argument." + ) - parser = get_parser() - with _api_lock: - if not plugin_manager: - plugin_manager = get_plugin_manager(plugins) - plugin_manager.hook.add_options(parser=parser) # pylint: disable=no-member + if plugins and plugin_manager: + raise ValueError("plugins= and plugin_manager are mutually exclusive") - if 'verbose' in kwargs: - warn("ocrmypdf.ocr(verbose=) is ignored. Use ocrmypdf.configure_logging().") + if not plugins: + plugins = [] + elif isinstance(plugins, str | Path): + plugins = [plugins] + else: + plugins = list(plugins) - options = create_options( - input_file=input_file, - output_file=output_file, - parser=parser, - **create_options_kwargs, - ) - check_options(options, plugin_manager) - return run_pipeline(options=options, plugin_manager=plugin_manager) + # No new variable names should be assigned until these two steps are run + create_options_kwargs = { + k: v + for k, v in locals().items() + if k + not in { + 'input_file_or_options', + 'input_file', + 'output_file', + 'kwargs', + 'plugin_manager', + } + } + create_options_kwargs.update(kwargs) + + parser = get_parser() + with _api_lock: + # Set up plugin infrastructure with proper initialization + plugin_manager = setup_plugin_infrastructure( + plugins=plugins, plugin_manager=plugin_manager + ) + + # Get parser and let plugins add their options + parser = get_parser() + plugin_manager.add_options(parser=parser) + + if 'verbose' in kwargs: + warn( + "ocrmypdf.ocr(verbose=) is ignored. " + "Use ocrmypdf.configure_logging()." + ) + + # Warn about deprecated jbig2 options and remove from kwargs + if jbig2_lossy: + warn( + "jbig2_lossy is deprecated and will be ignored. " + "Lossy JBIG2 has been removed due to character substitution risks." + ) + create_options_kwargs.pop('jbig2_lossy', None) + if jbig2_page_group_size: + warn("jbig2_page_group_size is deprecated and will be ignored.") + create_options_kwargs.pop('jbig2_page_group_size', None) + + options = create_options( + input_file=input_file, + output_file=output_file, + parser=parser, + **create_options_kwargs, + ) + check_options(options, plugin_manager) + return run_pipeline(options=options, plugin_manager=plugin_manager) def _pdf_to_hocr( # noqa: D417 @@ -434,9 +688,10 @@ def _pdf_to_hocr( # noqa: D417 unpaper_args: str | None = None, oversample: int | None = None, remove_vectors: bool | None = None, - force_ocr: bool | None = None, - skip_text: bool | None = None, - redo_ocr: bool | None = None, + mode: str | None = None, + force_ocr: bool | None = None, # Legacy, use mode='force' instead + skip_text: bool | None = None, # Legacy, use mode='skip' instead + redo_ocr: bool | None = None, # Legacy, use mode='redo' instead skip_big: float | None = None, pages: str | None = None, max_image_mpixels: float | None = None, @@ -449,6 +704,7 @@ def _pdf_to_hocr( # noqa: D417 tesseract_downsample_above: int | None = None, tesseract_downsample_large_images: bool | None = None, rotate_pages_threshold: float | None = None, + rasterizer: str | None = None, user_words: os.PathLike | None = None, user_patterns: os.PathLike | None = None, continue_on_soft_render_error: bool | None = None, @@ -478,33 +734,72 @@ def _pdf_to_hocr( # noqa: D417 output_folder: Output folder path. **kwargs: Keyword arguments. """ - # No new variable names should be assigned until these two steps are run - create_options_kwargs = { - k: v - for k, v in locals().items() - if k not in {'input_pdf', 'output_folder', 'kwargs'} - } - create_options_kwargs.update(kwargs) + if plugins and plugin_manager: + raise ValueError("plugins= and plugin_manager are mutually exclusive") - parser = get_parser() + if not plugins: + plugins = [] + elif isinstance(plugins, str | Path): + plugins = [plugins] + else: + plugins = list(plugins) + + # Prepare kwargs for direct OcrOptions construction + options_kwargs = kwargs.copy() + + # Set input file and handle special output_folder case + options_kwargs['input_file'] = input_pdf + options_kwargs['output_file'] = '/dev/null' # Placeholder for hOCR pipeline + + # Add all the function parameters + for param_name, param_value in locals().items(): + if ( + param_name + not in {'input_pdf', 'output_folder', 'kwargs', 'plugin_manager', 'plugins'} + and param_value is not None + ): + options_kwargs[param_name] = param_value + + # Handle plugins + if plugins: + options_kwargs['plugins'] = plugins + + # Remove None values to let OcrOptions use its defaults + options_kwargs = {k: v for k, v in options_kwargs.items() if v is not None} + + # Add output_folder to options_kwargs since it's now a proper field + options_kwargs['output_folder'] = output_folder + + # Remove any kwargs that aren't OcrOptions fields and store in extra_attrs + extra_attrs = {} + ocr_fields = set(OcrOptions.model_fields.keys()) + # Legacy mode flags are handled by OcrOptions model validator + legacy_mode_flags = {'force_ocr', 'skip_text', 'redo_ocr'} + known_extra = {'progress_bar', 'plugins'} + + for key in list(options_kwargs.keys()): + if key in ocr_fields or key in legacy_mode_flags or key in known_extra: + continue + extra_attrs[key] = options_kwargs.pop(key) with _api_lock: - if not plugin_manager: - plugin_manager = get_plugin_manager(plugins) - plugin_manager.hook.add_options(parser=parser) # pylint: disable=no-member - - cmdline, deferred = _kwargs_to_cmdline( - defer_kwargs={'input_pdf', 'output_folder', 'plugins'}, - **create_options_kwargs, + # Set up plugin infrastructure with proper initialization + plugin_manager = setup_plugin_infrastructure( + plugins=plugins, plugin_manager=plugin_manager ) - cmdline.append(str(input_pdf)) - cmdline.append(str(output_folder)) - parser.enable_api_mode() - options = parser.parse_args(cmdline) - for keyword, val in deferred.items(): - setattr(options, keyword, val) - delattr(options, 'output_file') - setattr(options, 'output_folder', output_folder) + + plugin_manager.add_options(parser=get_parser()) + + # Create OcrOptions directly + try: + options = OcrOptions(**options_kwargs) + # Add any extra attributes + if extra_attrs: + options.extra_attrs.update(extra_attrs) + except Exception as e: + raise TypeError( + f"Failed to create OcrOptions for hOCR pipeline: {e}" + ) from e return run_hocr_pipeline(options=options, plugin_manager=plugin_manager) @@ -518,8 +813,8 @@ def _hocr_to_ocr_pdf( # noqa: D417 optimize: int | None = None, jpg_quality: int | None = None, png_quality: int | None = None, - jbig2_lossy: bool | None = None, - jbig2_page_group_size: int | None = None, + jbig2_lossy: bool | None = None, # Deprecated, ignored + jbig2_page_group_size: int | None = None, # Deprecated, ignored jbig2_threshold: float | None = None, pdfa_image_compression: str | None = None, color_conversion_strategy: str | None = None, @@ -544,33 +839,83 @@ def _hocr_to_ocr_pdf( # noqa: D417 output_file: Output PDF file path. **kwargs: Keyword arguments. """ - # No new variable names should be assigned until these two steps are run - create_options_kwargs = { - k: v - for k, v in locals().items() - if k not in {'work_folder', 'output_pdf', 'kwargs'} - } - create_options_kwargs.update(kwargs) + if plugins and plugin_manager: + raise ValueError("plugins= and plugin_manager are mutually exclusive") - parser = get_parser() + if not plugins: + plugins = [] + elif isinstance(plugins, str | Path): + plugins = [plugins] + else: + plugins = list(plugins) + + # Prepare kwargs for direct OcrOptions construction + options_kwargs = kwargs.copy() + + # Set output file and handle special work_folder case + options_kwargs['input_file'] = '/dev/null' # Placeholder for hOCR to PDF pipeline + options_kwargs['output_file'] = output_file + + # Add all the function parameters + for param_name, param_value in locals().items(): + if ( + param_name + not in {'work_folder', 'output_file', 'kwargs', 'plugin_manager', 'plugins'} + and param_value is not None + ): + options_kwargs[param_name] = param_value + + # Handle plugins + if plugins: + options_kwargs['plugins'] = plugins + + # Remove None values to let OcrOptions use its defaults + options_kwargs = {k: v for k, v in options_kwargs.items() if v is not None} + + # Warn about deprecated jbig2 options and remove from kwargs + if jbig2_lossy: + warn( + "jbig2_lossy is deprecated and will be ignored. " + "Lossy JBIG2 has been removed due to character substitution risks." + ) + options_kwargs.pop('jbig2_lossy', None) + if jbig2_page_group_size: + warn("jbig2_page_group_size is deprecated and will be ignored.") + options_kwargs.pop('jbig2_page_group_size', None) + + # Add work_folder to options_kwargs since it's now a proper field + options_kwargs['work_folder'] = work_folder + + # Remove any kwargs that aren't OcrOptions fields and store in extra_attrs + extra_attrs = {} + ocr_fields = set(OcrOptions.model_fields.keys()) + # Legacy mode flags are handled by OcrOptions model validator + legacy_mode_flags = {'force_ocr', 'skip_text', 'redo_ocr'} + known_extra = {'progress_bar', 'plugins'} + + for key in list(options_kwargs.keys()): + if key in ocr_fields or key in legacy_mode_flags or key in known_extra: + continue + extra_attrs[key] = options_kwargs.pop(key) with _api_lock: - if not plugin_manager: - plugin_manager = get_plugin_manager(plugins) - plugin_manager.hook.add_options(parser=parser) # pylint: disable=no-member - - cmdline, deferred = _kwargs_to_cmdline( - defer_kwargs={'work_folder', 'output_file', 'plugins'}, - **create_options_kwargs, + # Set up plugin infrastructure with proper initialization + plugin_manager = setup_plugin_infrastructure( + plugins=plugins, plugin_manager=plugin_manager ) - cmdline.append(str(work_folder)) - cmdline.append(str(output_file)) - parser.enable_api_mode() - options = parser.parse_args(cmdline) - for keyword, val in deferred.items(): - setattr(options, keyword, val) - delattr(options, 'input_file') - setattr(options, 'work_folder', work_folder) + + plugin_manager.add_options(parser=get_parser()) + + # Create OcrOptions directly + try: + options = OcrOptions(**options_kwargs) + # Add any extra attributes + if extra_attrs: + options.extra_attrs.update(extra_attrs) + except Exception as e: + raise TypeError( + f"Failed to create OcrOptions for hOCR to PDF pipeline: {e}" + ) from e return run_hocr_to_ocr_pdf_pipeline( options=options, plugin_manager=plugin_manager @@ -588,4 +933,5 @@ __all__ = [ 'ocr', 'run_pipeline', 'run_pipeline_cli', + 'setup_plugin_infrastructure', ] diff --git a/src/ocrmypdf/builtin_plugins/concurrency.py b/src/ocrmypdf/builtin_plugins/concurrency.py index 86603a9d..b09213b4 100644 --- a/src/ocrmypdf/builtin_plugins/concurrency.py +++ b/src/ocrmypdf/builtin_plugins/concurrency.py @@ -7,6 +7,7 @@ from __future__ import annotations import logging import logging.handlers import multiprocessing +import multiprocessing.queues import os import queue import signal @@ -15,7 +16,7 @@ import threading from collections.abc import Callable, Iterable from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor, as_completed from contextlib import suppress -from typing import Union +from typing import TYPE_CHECKING from rich.console import Console as RichConsole @@ -25,12 +26,14 @@ from ocrmypdf._progressbar import RichProgressBar from ocrmypdf.exceptions import InputFileError from ocrmypdf.helpers import remove_all_log_handlers -FuturesExecutorClass = Union[ # noqa: UP007 - type[ThreadPoolExecutor], type[ProcessPoolExecutor] -] -Queue = Union[multiprocessing.Queue, queue.Queue] # noqa: UP007 -UserInit = Callable[[], None] -WorkerInit = Callable[[Queue, UserInit, int], None] +if TYPE_CHECKING: + from typing import TypeAlias + + Queue: TypeAlias = multiprocessing.queues.Queue | queue.Queue + UserInit: TypeAlias = Callable[[], None] + WorkerInit: TypeAlias = Callable[[Queue, UserInit, int], None] + +FuturesExecutorClass = type[ThreadPoolExecutor] | type[ProcessPoolExecutor] def log_listener(q: Queue): diff --git a/src/ocrmypdf/builtin_plugins/ghostscript.py b/src/ocrmypdf/builtin_plugins/ghostscript.py index 0934e850..110e6983 100644 --- a/src/ocrmypdf/builtin_plugins/ghostscript.py +++ b/src/ocrmypdf/builtin_plugins/ghostscript.py @@ -5,13 +5,17 @@ from __future__ import annotations import logging +from enum import StrEnum from pathlib import Path +from typing import Annotated from packaging.version import Version from pikepdf import Name, Pdf, Stream +from pydantic import BaseModel, Field from ocrmypdf import hookimpl from ocrmypdf._exec import ghostscript +from ocrmypdf._options import ProcessingMode from ocrmypdf.exceptions import MissingDependencyError from ocrmypdf.subprocess import check_external_program @@ -22,79 +26,134 @@ log = logging.getLogger(__name__) BLACKLISTED_GS_VERSIONS: frozenset[Version] = frozenset() +class ColorConversionStrategy(StrEnum): + """Ghostscript color conversion strategies.""" + + CMYK = 'CMYK' + GRAY = 'Gray' + LEAVE_COLOR_UNCHANGED = 'LeaveColorUnchanged' + RGB = 'RGB' + USE_DEVICE_INDEPENDENT_COLOR = 'UseDeviceIndependentColor' + + +class PdfaImageCompression(StrEnum): + """PDF/A image compression methods.""" + + AUTO = 'auto' + JPEG = 'jpeg' + LOSSLESS = 'lossless' + + +class GhostscriptOptions(BaseModel): + """Options specific to Ghostscript operations.""" + + color_conversion_strategy: Annotated[ + ColorConversionStrategy, + Field(description="Ghostscript color conversion strategy"), + ] = ColorConversionStrategy.LEAVE_COLOR_UNCHANGED + pdfa_image_compression: Annotated[ + PdfaImageCompression, Field(description="PDF/A image compression method") + ] = PdfaImageCompression.AUTO + + @classmethod + def add_arguments_to_parser(cls, parser, namespace: str = 'ghostscript'): + """Add Ghostscript-specific arguments to the argument parser. + + Args: + parser: The argument parser to add arguments to + namespace: The namespace prefix for argument names (not used for ghostscript + for backward compatibility) + """ + gs = parser.add_argument_group("Ghostscript", "Advanced control of Ghostscript") + gs.add_argument( + '--color-conversion-strategy', + action='store', + type=str, + choices=[ccs.value for ccs in ColorConversionStrategy], + default=ColorConversionStrategy.LEAVE_COLOR_UNCHANGED.value, + help="Set Ghostscript color conversion strategy", + ) + gs.add_argument( + '--pdfa-image-compression', + choices=[pc.value for pc in PdfaImageCompression], + default=PdfaImageCompression.AUTO.value, + help="Specify how to compress images in the output PDF/A. 'auto' lets " + "OCRmyPDF decide. 'jpeg' changes all grayscale and color images to " + "JPEG compression. 'lossless' uses PNG-style lossless compression " + "for all images. Monochrome images are always compressed using a " + "lossless codec. Compression settings " + "are applied to all pages, including those for which OCR was " + "skipped. Not supported for --output-type=pdf ; that setting " + "preserves the original compression of all images.", + ) + + +@hookimpl +def register_options(): + """Register Ghostscript option model.""" + return {'ghostscript': GhostscriptOptions} + + @hookimpl def add_options(parser): - gs = parser.add_argument_group("Ghostscript", "Advanced control of Ghostscript") - gs.add_argument( - '--color-conversion-strategy', - action='store', - type=str, - metavar='STRATEGY', - choices=ghostscript.COLOR_CONVERSION_STRATEGIES, - default='LeaveColorUnchanged', - help="Set Ghostscript color conversion strategy", - ) - gs.add_argument( - '--pdfa-image-compression', - choices=['auto', 'jpeg', 'lossless'], - default='auto', - help="Specify how to compress images in the output PDF/A. 'auto' lets " - "OCRmyPDF decide. 'jpeg' changes all grayscale and color images to " - "JPEG compression. 'lossless' uses PNG-style lossless compression " - "for all images. Monochrome images are always compressed using a " - "lossless codec. Compression settings " - "are applied to all pages, including those for which OCR was " - "skipped. Not supported for --output-type=pdf ; that setting " - "preserves the original compression of all images.", - ) + # Use the model's CLI generation method + GhostscriptOptions.add_arguments_to_parser(parser) @hookimpl def check_options(options): """Check that the options are valid for this plugin.""" - check_external_program( - program='gs', - package='ghostscript', - version_checker=ghostscript.version, - need_version='9.54', # RHEL 9's version; Ubuntu 22.04 has 9.55 - ) - gs_version = ghostscript.version() - if gs_version in BLACKLISTED_GS_VERSIONS: - raise MissingDependencyError( - f"Ghostscript {gs_version} contains serious regressions and is not " - "supported. Please upgrade to a newer version." - ) - if Version('10.0.0') <= gs_version < Version('10.02.1') and ( - options.skip_text or options.redo_ocr - ): - raise MissingDependencyError( - f"Ghostscript 10.0.0 through 10.02.0 (your version: {gs_version}) " - "contain serious regressions that corrupt PDFs with existing text, " - "such as those processed using --skip-text or --redo-ocr. " - "Please upgrade to a " - "newer version, or use --output-type pdf to avoid Ghostscript, or " - "use --force-ocr to discard existing text." + # Only require Ghostscript for pdfa* output types (not 'auto' or 'pdf') + # 'auto' mode uses best-effort PDF/A without Ghostscript fallback + if options.output_type.startswith('pdfa'): + check_external_program( + program='gs', + package='ghostscript', + version_checker=ghostscript.version, + need_version='9.54', # RHEL 9's version; Ubuntu 22.04 has 9.55 ) + gs_version = ghostscript.version() + if gs_version in BLACKLISTED_GS_VERSIONS: + raise MissingDependencyError( + f"Ghostscript {gs_version} contains serious regressions and is not " + "supported. Please upgrade to a newer version." + ) + if Version('10.0.0') <= gs_version < Version('10.02.1') and ( + options.mode in (ProcessingMode.skip, ProcessingMode.redo) + ): + raise MissingDependencyError( + f"Ghostscript 10.0.0 through 10.02.0 (your version: {gs_version}) " + "contain serious regressions that corrupt PDFs with existing text, " + "such as those processed using --skip-text or --redo-ocr " + "(or --mode skip/redo). Please upgrade to a newer version, or use " + "--output-type pdf to avoid Ghostscript, or use --force-ocr " + "(or --mode force) to discard existing text." + ) + if gs_version >= Version('10.6.0'): + log.warning( + "Ghostscript 10.6.x contains JPEG encoding errors that may corrupt " + "images. OCRmyPDF will attempt to mitigate, but this version is " + "strongly not recommended. Please upgrade to a newer version. " + "As of 2025-12, 10.6.0 is the latest version of Ghostscript." + ) + if options.output_type == 'pdfa': + options.output_type = 'pdfa-2' - if gs_version >= Version('10.6.0') and options.output_type.startswith('pdfa'): - log.warning( - "Ghostscript 10.6.x contains JPEG encoding errors that may corrupt " - "images. OCRmyPDF will attempt to mitigate, but this version is " - "strongly not recommended. Please upgrade to a newer version. " - "As of 2025-12, 10.6.0 is the latest version of Ghostscript." - ) - if options.output_type == 'pdfa': - options.output_type = 'pdfa-2' - if options.color_conversion_strategy not in ghostscript.COLOR_CONVERSION_STRATEGIES: + if ( + options.ghostscript.color_conversion_strategy + not in ghostscript.COLOR_CONVERSION_STRATEGIES + ): raise ValueError( - f"Invalid color conversion strategy: {options.color_conversion_strategy}" + f"Invalid color conversion strategy: " + f"{options.ghostscript.color_conversion_strategy}" ) - if options.pdfa_image_compression != 'auto' and not options.output_type.startswith( - 'pdfa' + if ( + options.ghostscript.pdfa_image_compression != 'auto' + and options.output_type not in ('auto', 'pdfa', 'pdfa-1', 'pdfa-2', 'pdfa-3') ): log.warning( "--pdfa-image-compression argument only applies when " - "--output-type is one of 'pdfa', 'pdfa-1', or 'pdfa-2'" + "--output-type is 'auto' or one of 'pdfa', 'pdfa-1', 'pdfa-2', 'pdfa-3'" ) @@ -109,8 +168,15 @@ def rasterize_pdf_page( rotation, filter_vector, stop_on_soft_error, + options, + use_cropbox, ): """Rasterize a single page of a PDF file using Ghostscript.""" + # Check if user explicitly requested a different rasterizer + if options is not None and options.rasterizer == 'pypdfium': + # Let pypdfium handle it (it will error in check_options if unavailable) + return None + ghostscript.rasterize_pdf( input_file, output_file, @@ -121,6 +187,7 @@ def rasterize_pdf_page( rotation=rotation, filter_vector=filter_vector, stop_on_error=stop_on_soft_error, + use_cropbox=use_cropbox, ) return output_file @@ -275,11 +342,16 @@ def generate_pdfa( stop_on_soft_error, ): """Generate a PDF/A from the list of PDF pages and PDF/A metadata.""" + # Normalize output_type at point of use + output_type = context.options.output_type + if output_type == 'pdfa': + output_type = 'pdfa-2' + ghostscript.generate_pdfa( pdf_pages=[pdfmark, *pdf_pages], output_file=output_file, - compression=context.options.pdfa_image_compression, - color_conversion_strategy=context.options.color_conversion_strategy, + compression=context.options.ghostscript.pdfa_image_compression, + color_conversion_strategy=context.options.ghostscript.color_conversion_strategy, pdf_version=pdf_version, pdfa_part=pdfa_part, progressbar_class=progressbar_class, diff --git a/src/ocrmypdf/builtin_plugins/null_ocr.py b/src/ocrmypdf/builtin_plugins/null_ocr.py new file mode 100644 index 00000000..3017a9ae --- /dev/null +++ b/src/ocrmypdf/builtin_plugins/null_ocr.py @@ -0,0 +1,159 @@ +# SPDX-FileCopyrightText: 2025 James R. Barlow +# SPDX-License-Identifier: MPL-2.0 + +"""Built-in plugin implementing a null OCR engine (no OCR). + +This plugin provides an OCR engine that produces no text output. It is useful +when users want OCRmyPDF's image processing, PDF/A conversion, or optimization +features without performing actual OCR. + +Usage: + ocrmypdf --ocr-engine none input.pdf output.pdf +""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +from PIL import Image + +from ocrmypdf import hookimpl +from ocrmypdf.hocrtransform import BoundingBox, OcrClass, OcrElement +from ocrmypdf.pluginspec import OcrEngine, OrientationConfidence + +if TYPE_CHECKING: + from ocrmypdf._options import OcrOptions + + +class NullOcrEngine(OcrEngine): + """A no-op OCR engine that produces no text output. + + Use this when you want OCRmyPDF's image processing, PDF/A conversion, + or optimization features without performing actual OCR. + """ + + @staticmethod + def version() -> str: + """Return version string.""" + return "none" + + @staticmethod + def creator_tag(options: OcrOptions) -> str: + """Return creator tag for PDF metadata.""" + return "OCRmyPDF (no OCR)" + + def __str__(self) -> str: + """Return human-readable engine name.""" + return "No OCR engine" + + @staticmethod + def languages(options: OcrOptions) -> set[str]: + """Return supported languages (empty set for null engine).""" + return set() + + @staticmethod + def get_orientation(input_file: Path, options: OcrOptions) -> OrientationConfidence: + """Return neutral orientation (no rotation detected).""" + return OrientationConfidence(angle=0, confidence=0.0) + + @staticmethod + def get_deskew(input_file: Path, options: OcrOptions) -> float: + """Return zero deskew angle.""" + return 0.0 + + @staticmethod + def supports_generate_ocr() -> bool: + """Return True - this engine supports the generate_ocr() API.""" + return True + + @staticmethod + def generate_ocr( + input_file: Path, + options: OcrOptions, + page_number: int = 0, + ) -> tuple[OcrElement, str]: + """Generate empty OCR results. + + Args: + input_file: The image file (used to get dimensions). + options: OCR options (ignored). + page_number: Page number (stored in result). + + Returns: + A tuple of (empty OcrElement page, empty string). + """ + # Get image dimensions + with Image.open(input_file) as img: + width, height = img.size + dpi_info = img.info.get('dpi', (72, 72)) + dpi = dpi_info[0] if isinstance(dpi_info, tuple) else dpi_info + + # Create empty page element with correct dimensions + page = OcrElement( + ocr_class=OcrClass.PAGE, + bbox=BoundingBox(left=0, top=0, right=width, bottom=height), + dpi=float(dpi), + page_number=page_number, + ) + + return page, "" + + @staticmethod + def generate_hocr( + input_file: Path, + output_hocr: Path, + output_text: Path, + options: OcrOptions, + ) -> None: + """Generate empty hOCR file. + + Creates minimal valid hOCR output with no text content. + """ + # Get image dimensions for hOCR bbox + with Image.open(input_file) as img: + width, height = img.size + + hocr_content = f''' + + + + OCRmyPDF - No OCR + + + + +
+
+ + +''' + output_hocr.write_text(hocr_content, encoding='utf-8') + output_text.write_text('', encoding='utf-8') + + @staticmethod + def generate_pdf( + input_file: Path, + output_pdf: Path, + output_text: Path, + options: OcrOptions, + ) -> None: + """NullOcrEngine cannot generate PDFs directly. + + Use pdf_renderer='fpdf2' instead of 'sandwich'. + """ + raise NotImplementedError( + "NullOcrEngine cannot generate PDFs directly. " + "Use --pdf-renderer fpdf2 instead of sandwich mode." + ) + + +@hookimpl +def get_ocr_engine(options): + """Return NullOcrEngine when --ocr-engine none is selected.""" + if options is not None: + ocr_engine = getattr(options, 'ocr_engine', 'auto') + if ocr_engine != 'none': + return None + return NullOcrEngine() diff --git a/src/ocrmypdf/builtin_plugins/optimize.py b/src/ocrmypdf/builtin_plugins/optimize.py index ff841f93..f9941b94 100644 --- a/src/ocrmypdf/builtin_plugins/optimize.py +++ b/src/ocrmypdf/builtin_plugins/optimize.py @@ -8,6 +8,9 @@ import argparse import logging from collections.abc import Sequence from pathlib import Path +from typing import Annotated + +from pydantic import BaseModel, Field, model_validator from ocrmypdf import Executor, PdfContext, hookimpl from ocrmypdf._exec import jbig2enc, pngquant @@ -19,87 +22,165 @@ from ocrmypdf.subprocess import check_external_program log = logging.getLogger(__name__) +class OptimizeOptions(BaseModel): + """Options specific to PDF optimization.""" + + level: Annotated[ + int, + Field( + ge=0, + le=3, + description="Optimization level (0=none, 1=safe, 2=lossy, 3=aggressive)", + ), + ] = 1 + jpeg_quality: Annotated[ + int, Field(ge=0, le=100, description="JPEG quality level for optimization") + ] = 0 + png_quality: Annotated[ + int, Field(ge=0, le=100, description="PNG quality level for optimization") + ] = 0 + jbig2_threshold: Annotated[ + float, + Field(ge=0.4, le=0.9, description="JBIG2 symbol classification threshold"), + ] = 0.85 + + @classmethod + def add_arguments_to_parser(cls, parser, namespace: str = 'optimize'): + """Add optimization-specific arguments to the argument parser. + + Args: + parser: The argument parser to add arguments to + namespace: The namespace prefix for argument names + (not used for optimize for backward compatibility) + """ + optimizing = parser.add_argument_group( + "Optimization options", "Control how the PDF is optimized after OCR" + ) + optimizing.add_argument( + '-O', + '--optimize', + type=int, + choices=range(0, 4), + default=1, + help=( + "Control how PDF is optimized after processing:" + "0 - do not optimize; " + "1 - do safe, lossless optimizations (default); " + "2 - do lossy JPEG and JPEG2000 optimizations; " + "3 - do more aggressive lossy JPEG and JPEG2000 optimizations. " + "To enable lossy JBIG2, see --jbig2-lossy." + ), + ) + optimizing.add_argument( + '--jpeg-quality', + type=numeric(int, 0, 100), + default=0, + metavar='Q', + help=( + "Adjust JPEG quality level for JPEG optimization. " + "100 is best quality and largest output size; " + "1 is lowest quality and smallest output; " + "0 uses the default." + ), + ) + optimizing.add_argument( + '--jpg-quality', + type=numeric(int, 0, 100), + default=0, + metavar='Q', + dest='jpeg_quality', + help=argparse.SUPPRESS, # Alias for --jpeg-quality + ) + optimizing.add_argument( + '--png-quality', + type=numeric(int, 0, 100), + default=0, + metavar='Q', + help=( + "Adjust PNG quality level to use when quantizing PNGs. " + "Values have same meaning as with --jpeg-quality" + ), + ) + # Deprecated arguments - kept for backward compatibility, emit warnings + optimizing.add_argument( + '--jbig2-lossy', + action='store_true', + help=argparse.SUPPRESS, # Deprecated, hidden from help + ) + optimizing.add_argument( + '--jbig2-page-group-size', + type=numeric(int, 1, 10000), + default=0, + metavar='N', + help=argparse.SUPPRESS, # Deprecated, hidden from help + ) + optimizing.add_argument( + '--jbig2-threshold', + type=numeric(float, 0.4, 0.9), + default=0.85, + metavar='T', + help=( + "Adjust JBIG2 symbol code classification threshold " + "(default 0.85), range 0.4 to 0.9." + ), + ) + + @model_validator(mode='after') + def validate_optimization_consistency(self): + """Validate optimization options are consistent.""" + if self.level == 0 and any([self.png_quality > 0, self.jpeg_quality > 0]): + log.warning( + "The arguments --png-quality and --jpeg-quality " + "will be ignored because --optimize=0." + ) + return self + + def validate_with_context( + self, external_programs_available: dict[str, bool] + ) -> None: + """Validate options that require external context. + + Args: + external_programs_available: Dict of program name -> availability + """ + if self.level >= 2: + if not external_programs_available.get('pngquant', False): + log.warning( + "pngquant is not available, so PNG optimization will be limited" + ) + if not external_programs_available.get('jbig2enc', False): + log.warning( + "jbig2enc is not available, so JBIG2 optimization will be limited" + ) + + +@hookimpl +def register_options(): + """Register optimization option model.""" + return {'optimize': OptimizeOptions} + + @hookimpl def add_options(parser): - optimizing = parser.add_argument_group( - "Optimization options", "Control how the PDF is optimized after OCR" - ) - optimizing.add_argument( - '-O', - '--optimize', - type=int, - choices=range(0, 4), - default=1, - help=( - "Control how PDF is optimized after processing:" - "0 - do not optimize; " - "1 - do safe, lossless optimizations (default); " - "2 - do lossy JPEG and JPEG2000 optimizations; " - "3 - do more aggressive lossy JPEG and JPEG2000 optimizations. " - "To enable lossy JBIG2, see --jbig2-lossy." - ), - ) - optimizing.add_argument( - '--jpeg-quality', - type=numeric(int, 0, 100), - default=0, - metavar='Q', - help=( - "Adjust JPEG quality level for JPEG optimization. " - "100 is best quality and largest output size; " - "1 is lowest quality and smallest output; " - "0 uses the default." - ), - ) - optimizing.add_argument( - '--jpg-quality', - type=numeric(int, 0, 100), - default=0, - metavar='Q', - dest='jpeg_quality', - help=argparse.SUPPRESS, # Alias for --jpeg-quality - ) - optimizing.add_argument( - '--png-quality', - type=numeric(int, 0, 100), - default=0, - metavar='Q', - help=( - "Adjust PNG quality level to use when quantizing PNGs. " - "Values have same meaning as with --jpeg-quality" - ), - ) - optimizing.add_argument( - '--jbig2-lossy', - action='store_true', - help=( - "Enable JBIG2 lossy mode (better compression, not suitable for some " - "use cases - see documentation). Only takes effect if --optimize 1 or " - "higher is also enabled." - ), - ) - optimizing.add_argument( - '--jbig2-page-group-size', - type=numeric(int, 1, 10000), - default=0, - metavar='N', - # Adjust number of pages to consider at once for JBIG2 compression - help=argparse.SUPPRESS, - ) - optimizing.add_argument( - '--jbig2-threshold', - type=numeric(float, 0.4, 0.9), - default=0.85, - metavar='T', - help=( - "Adjust JBIG2 symbol code classification threshold " - "(default 0.85), range 0.4 to 0.9." - ), - ) + # Use the model's CLI generation method + OptimizeOptions.add_arguments_to_parser(parser) @hookimpl def check_options(options): + """Check external dependencies for optimization.""" + # Warn about deprecated options + if getattr(options, 'jbig2_lossy', False): + log.warning( + "The --jbig2-lossy option is deprecated and will be ignored. " + "Lossy JBIG2 compression has been removed due to risks of " + "character substitution errors." + ) + if getattr(options, 'jbig2_page_group_size', 0) not in (0, None): + log.warning( + "The --jbig2-page-group-size option is deprecated and will be ignored." + ) + if options.optimize >= 2: check_external_program( program='pngquant', @@ -117,16 +198,8 @@ def check_options(options): package='jbig2enc', version_checker=jbig2enc.version, need_version='0.28', - required_for='--optimize {2,3} | --jbig2-lossy', - recommended=True if not options.jbig2_lossy else False, - ) - - if options.optimize == 0 and any( - [options.jbig2_lossy, options.png_quality, options.jpeg_quality] - ): - log.warning( - "The arguments --jbig2-lossy, --png-quality, and --jpeg-quality " - "will be ignored because --optimize=0." + required_for='--optimize {2,3}', + recommended=True, ) diff --git a/src/ocrmypdf/builtin_plugins/pypdfium.py b/src/ocrmypdf/builtin_plugins/pypdfium.py new file mode 100644 index 00000000..1c52d5fc --- /dev/null +++ b/src/ocrmypdf/builtin_plugins/pypdfium.py @@ -0,0 +1,256 @@ +# SPDX-FileCopyrightText: 2025 James R. Barlow +# SPDX-License-Identifier: MPL-2.0 +"""Built-in plugin to implement PDF page rasterization using pypdfium2.""" + +from __future__ import annotations + +import logging +import threading +from contextlib import closing +from pathlib import Path + +try: + import pypdfium2 as pdfium +except ImportError: + pdfium = None + +from PIL import Image + +from ocrmypdf import hookimpl +from ocrmypdf.exceptions import MissingDependencyError +from ocrmypdf.helpers import Resolution + +log = logging.getLogger(__name__) + +# pypdfium2/PDFium is not thread-safe. All calls to the library must be serialized. +# See: https://pypdfium2.readthedocs.io/en/stable/python_api.html#incompatibility-with-threading +# When using process-based parallelism (use_threads=False), each process has its own +# pdfium instance, so locking is not needed across processes. +_pdfium_lock = threading.Lock() + + +@hookimpl +def check_options(options): + """Check that pypdfium2 is available if explicitly requested.""" + if options.rasterizer == 'pypdfium' and pdfium is None: + raise MissingDependencyError( + "The --rasterizer pypdfium option requires the pypdfium2 package. " + "Install it with: pip install pypdfium2" + ) + + +def _open_pdf_document(input_file: Path): + """Open a PDF document using pypdfium2.""" + assert pdfium is not None, "pypdfium2 must be available to call this function" + return pdfium.PdfDocument(input_file) + + +def _calculate_mediabox_crop(page) -> tuple[float, float, float, float]: + """Calculate crop values to expand rendering from CropBox to MediaBox. + + By default pypdfium2 renders to the CropBox. To render the full MediaBox, + we need negative crop values to expand the rendering area. + + Returns: + Tuple of (left, bottom, right, top) crop values. Negative values + expand the rendering area beyond the CropBox to the MediaBox. + """ + mediabox = page.get_mediabox() # (left, bottom, right, top) + cropbox = page.get_cropbox() # (left, bottom, right, top), defaults to mediabox + + # Calculate how much to expand from cropbox to mediabox + # Negative values = expand, positive = shrink + return ( + mediabox[0] - cropbox[0], # Expand left + mediabox[1] - cropbox[1], # Expand bottom + cropbox[2] - mediabox[2], # Expand right + cropbox[3] - mediabox[3], # Expand top + ) + + +def _render_page_to_bitmap( + page, + raster_device: str, + raster_dpi: Resolution, + rotation: int | None, + use_cropbox: bool, +): + """Render a PDF page to a bitmap.""" + # Round DPI to match Ghostscript's precision + raster_dpi = raster_dpi.round(6) + + # Get page dimensions BEFORE applying rotation + page_width_pts, page_height_pts = page.get_size() + + # Calculate expected output dimensions using separate x/y DPI + expected_width = int(round(page_width_pts * raster_dpi.x / 72.0)) + expected_height = int(round(page_height_pts * raster_dpi.y / 72.0)) + + # Calculate the scale factor based on DPI + # pypdfium2 uses points (72 DPI) as base unit + scale = raster_dpi.to_scalar() / 72.0 + + # Apply rotation if specified + if rotation: + # pypdfium2 rotation is in degrees, same as our input + # we track rotation in CCW, and pypdfium2 expects CW, so negate + page.set_rotation(-rotation % 360) + # When rotation is 90 or 270, dimensions are swapped in output + if rotation % 180 == 90: + expected_width, expected_height = expected_height, expected_width + + # Render the page to a bitmap + # The scale parameter controls the resolution + grayscale = raster_device.lower() in ('pnggray', 'jpeggray') + + # Calculate crop to render the appropriate box + # Default (use_cropbox=False) renders MediaBox for consistency with Ghostscript + crop = (0, 0, 0, 0) if use_cropbox else _calculate_mediabox_crop(page) + + bitmap = page.render( + scale=scale, + rotation=0, # We already set rotation on the page + crop=crop, + may_draw_forms=True, + draw_annots=True, + grayscale=grayscale, + # Note: pypdfium2 doesn't have a direct equivalent to filter_vector + # This would require more complex implementation if needed + ) + return bitmap, expected_width, expected_height + + +def _process_image_for_output( + pil_image, + raster_device: str, + raster_dpi: Resolution, + page_dpi: Resolution | None, + stop_on_soft_error: bool, + expected_width: int | None = None, + expected_height: int | None = None, +): + """Process PIL image for output format and set DPI metadata.""" + # Correct dimensions if slightly off (within 2 pixels tolerance) + if expected_width and expected_height: + actual_width, actual_height = pil_image.width, pil_image.height + width_diff = abs(actual_width - expected_width) + height_diff = abs(actual_height - expected_height) + + # Only resize if off by small amount (1-2 pixels) + if (width_diff <= 2 or height_diff <= 2) and ( + width_diff > 0 or height_diff > 0 + ): + log.debug( + f"Adjusting rendered dimensions from " + f"{actual_width}x{actual_height} to expected " + f"{expected_width}x{expected_height}" + ) + pil_image = pil_image.resize( + (expected_width, expected_height), + Image.Resampling.LANCZOS + ) + + # Set the DPI metadata if page_dpi is specified + if page_dpi: + # PIL expects DPI as a tuple + dpi_tuple = (float(page_dpi.x), float(page_dpi.y)) + pil_image.info['dpi'] = dpi_tuple + else: + # Use the raster DPI + dpi_tuple = (float(raster_dpi.x), float(raster_dpi.y)) + pil_image.info['dpi'] = dpi_tuple + + # Determine output format based on raster_device + if raster_device.lower() in ('png', 'pngmono', 'pnggray', 'png16m', 'pngalpha'): + format_name = 'PNG' + elif raster_device.lower() in ('jpeg', 'jpeggray', 'jpg'): + format_name = 'JPEG' + # Convert RGBA to RGB for JPEG + if pil_image.mode == 'RGBA': + # Create white background + background = pil_image.new('RGB', pil_image.size, (255, 255, 255)) + background.paste( + pil_image, mask=pil_image.split()[-1] + ) # Use alpha channel as mask + pil_image = background + elif raster_device.lower() in ('tiff', 'tif'): + format_name = 'TIFF' + else: + # Default to PNG for unknown formats + format_name = 'PNG' + if stop_on_soft_error: + raise ValueError(f"Unsupported raster device: {raster_device}") + else: + log.warning(f"Unsupported raster device {raster_device}, using PNG") + + return pil_image, format_name + + +def _save_image(pil_image, output_file: Path, format_name: str): + """Save PIL image to file with appropriate DPI metadata.""" + save_kwargs = {} + if ( + format_name in ('PNG', 'TIFF') + and 'dpi' in pil_image.info + or format_name == 'JPEG' + and 'dpi' in pil_image.info + ): + save_kwargs['dpi'] = pil_image.info['dpi'] + + pil_image.save(output_file, format=format_name, **save_kwargs) + + +@hookimpl +def rasterize_pdf_page( + input_file: Path, + output_file: Path, + raster_device: str, + raster_dpi: Resolution, + pageno: int, + page_dpi: Resolution | None, + rotation: int | None, + filter_vector: bool, + stop_on_soft_error: bool, + options, + use_cropbox: bool, +) -> Path | None: + """Rasterize a single page of a PDF file using pypdfium2. + + Returns None if pypdfium2 is not available or if the user has selected + a different rasterizer, allowing Ghostscript to be used. + """ + # Check if user explicitly requested a different rasterizer + if options is not None and options.rasterizer == 'ghostscript': + return None # Let Ghostscript handle it + + if pdfium is None: + return None # Fall back to Ghostscript + + # Acquire lock to ensure thread-safe access to pypdfium2 + with ( + _pdfium_lock, + closing(_open_pdf_document(input_file)) as pdf, + closing(pdf[pageno - 1]) as page, + ): + # Render the page to a bitmap + bitmap, expected_width, expected_height = _render_page_to_bitmap( + page, raster_device, raster_dpi, rotation, use_cropbox + ) + with closing(bitmap): + # Convert to PIL Image + pil_image = bitmap.to_pil() + + # Process and save image outside the lock (PIL operations are thread-safe) + pil_image, format_name = _process_image_for_output( + pil_image, + raster_device, + raster_dpi, + page_dpi, + stop_on_soft_error, + expected_width, + expected_height, + ) + + _save_image(pil_image, output_file, format_name) + + return output_file diff --git a/src/ocrmypdf/builtin_plugins/tesseract_ocr.py b/src/ocrmypdf/builtin_plugins/tesseract_ocr.py index a54052c5..0df11606 100644 --- a/src/ocrmypdf/builtin_plugins/tesseract_ocr.py +++ b/src/ocrmypdf/builtin_plugins/tesseract_ocr.py @@ -7,15 +7,18 @@ from __future__ import annotations import argparse import logging import os +from typing import Annotated from PIL import Image +from pydantic import BaseModel, Field, field_validator, model_validator from ocrmypdf import hookimpl from ocrmypdf._exec import tesseract +from ocrmypdf._exec.tesseract import ThresholdingMethod from ocrmypdf._jobcontext import PageContext -from ocrmypdf.cli import numeric, str_to_int +from ocrmypdf.cli import numeric from ocrmypdf.exceptions import BadArgsError, MissingDependencyError -from ocrmypdf.helpers import clamp +from ocrmypdf.helpers import available_cpu_count, clamp from ocrmypdf.imageops import calculate_downsample, downsample_image from ocrmypdf.pluginspec import OcrEngine from ocrmypdf.subprocess import check_external_program @@ -23,121 +26,286 @@ from ocrmypdf.subprocess import check_external_program log = logging.getLogger(__name__) +def _thresholding_method_converter(value: str) -> ThresholdingMethod: + """Convert string argument to ThresholdingMethod enum. + + Args: + value: String name of thresholding method (auto, otsu, adaptive-otsu, sauvola) + + Returns: + ThresholdingMethod enum value + + Raises: + argparse.ArgumentTypeError: If value is not a valid thresholding method + """ + method_map = { + 'auto': ThresholdingMethod.AUTO, + 'otsu': ThresholdingMethod.OTSU, + 'adaptive-otsu': ThresholdingMethod.ADAPTIVE_OTSU, + 'sauvola': ThresholdingMethod.SAUVOLA, + } + if value.lower() not in method_map: + import argparse + + valid = ', '.join(method_map.keys()) + raise argparse.ArgumentTypeError( + f"Invalid thresholding method '{value}'. Must be one of: {valid}" + ) + return method_map[value.lower()] + + +class TesseractOptions(BaseModel): + """Options specific to Tesseract OCR engine.""" + + config: Annotated[ + list[str], Field(description="Additional Tesseract configuration files") + ] = [] + pagesegmode: Annotated[ + int | None, + Field(ge=0, le=13, description="Set Tesseract page segmentation mode"), + ] = None + oem: Annotated[ + int | None, Field(ge=0, le=3, description="Set Tesseract OCR engine mode") + ] = None + thresholding: Annotated[ + ThresholdingMethod, + Field(description="Set Tesseract input image thresholding mode"), + ] = ThresholdingMethod.AUTO + timeout: Annotated[ + float, Field(ge=0, description="Timeout for OCR operations in seconds") + ] = 180.0 + non_ocr_timeout: Annotated[ + float, Field(ge=0, description="Timeout for non-OCR operations in seconds") + ] = 180.0 + downsample_large_images: Annotated[ + bool, Field(description="Downsample large images before OCR") + ] = True + downsample_above: Annotated[ + int, + Field( + ge=100, + le=32767, + description="Downsample images larger than this pixel size", + ), + ] = 32767 + user_words: Annotated[ + str | None, Field(description="Path to Tesseract user words file") + ] = None + user_patterns: Annotated[ + str | None, Field(description="Path to Tesseract user patterns file") + ] = None + omp_thread_limit: Annotated[ + int | None, + Field( + description="Calculated OMP_THREAD_LIMIT for Tesseract subprocesses", + exclude=True, + ), + ] = None + + @classmethod + def add_arguments_to_parser(cls, parser, namespace: str = 'tesseract'): + """Add Tesseract-specific arguments to the argument parser. + + Args: + parser: The argument parser to add arguments to + namespace: The namespace prefix for argument names + """ + tess = parser.add_argument_group( + "Tesseract", "Advanced control of Tesseract OCR" + ) + + tess.add_argument( + f'--{namespace}-config', + action='append', + metavar='CFG', + default=[], + dest=f'{namespace}_config', + help="Additional Tesseract configuration files -- see documentation.", + ) + + tess.add_argument( + f'--{namespace}-pagesegmode', + action='store', + type=int, + metavar='PSM', + choices=range(0, 14), + dest=f'{namespace}_pagesegmode', + help="Set Tesseract page segmentation mode (see tesseract --help).", + ) + + tess.add_argument( + f'--{namespace}-oem', + action='store', + type=int, + metavar='MODE', + choices=range(0, 4), + dest=f'{namespace}_oem', + help=( + "Set Tesseract 4+ OCR engine mode: " + "0 - original Tesseract only; " + "1 - neural nets LSTM only; " + "2 - Tesseract + LSTM; " + "3 - default." + ), + ) + + tess.add_argument( + f'--{namespace}-thresholding', + action='store', + type=_thresholding_method_converter, + default='auto', + dest=f'{namespace}_thresholding', + help=( + "Set Tesseract 5.0+ input image thresholding mode. This may improve " + "OCR results on low quality images or those that contain high " + "contrast color. Options: auto, otsu, adaptive-otsu, sauvola. " + "auto/otsu is the Tesseract default (legacy Otsu); adaptive-otsu " + "is an improved Otsu algorithm with improved sort for background " + "color changes; sauvola is based on local standard deviation." + ), + ) + + tess.add_argument( + f'--{namespace}-timeout', + default=180.0, + type=numeric(float, 0), + metavar='SECONDS', + dest=f'{namespace}_timeout', + help=( + "Give up on OCR after the timeout, but copy the preprocessed page " + "into the final output. This timeout is only used when using Tesseract " + "for OCR. When Tesseract is used for other operations such as " + "deskewing and orientation, the timeout is controlled by " + f"--{namespace}-non-ocr-timeout." + ), + ) + + tess.add_argument( + f'--{namespace}-non-ocr-timeout', + default=180.0, + type=numeric(float, 0), + metavar='SECONDS', + dest=f'{namespace}_non_ocr_timeout', + help=( + "Give up on non-OCR operations such as deskewing and orientation " + f"after timeout. This is a separate timeout from --{namespace}-timeout " + "because these operations are not as expensive as OCR." + ), + ) + + tess.add_argument( + f'--{namespace}-downsample-large-images', + action=argparse.BooleanOptionalAction, + default=True, + dest=f'{namespace}_downsample_large_images', + help=( + "Downsample large images before OCR. Tesseract has " + "an upper limit on the size images it will support." + " If this argument is given, OCRmyPDF will " + "downsample large images to fit Tesseract. This " + "may reduce OCR quality, on large images the most" + " desirable text is usually larger. If this " + "parameter is not supplied, Tesseract will error " + "out and produce no OCR on the page in question. " + "This argument should be used with a high value " + f"of --{namespace}-timeout to ensure Tesseract " + "has enough to time." + ), + ) + + tess.add_argument( + f'--{namespace}-downsample-above', + action='store', + type=numeric(int, 100, 32767), + default=32767, + dest=f'{namespace}_downsample_above', + help=( + "Downsample images larger than this size pixel size (either dimension) " + f"before OCR. --{namespace}-downsample-large-images downsamples when " + "an image exceeds Tesseract's internal limits. This argument causes " + "downsampling to occur when an image exceeds the given size. This may " + "reduce OCR quality, but on large images the most desirable text is " + "usually larger." + ), + ) + + tess.add_argument( + '--user-words', + metavar='FILE', + dest='user_words', + help="Specify the location of the Tesseract user words file. This is a " + "list of words Tesseract should consider while performing OCR in " + "addition to its standard language dictionaries. This can improve " + "OCR quality especially for specialized and technical documents.", + ) + tess.add_argument( + '--user-patterns', + metavar='FILE', + dest='user_patterns', + help="Specify the location of the Tesseract user patterns file.", + ) + + @field_validator('timeout', 'non_ocr_timeout') + @classmethod + def validate_timeout_reasonable(cls, v): + """Validate timeout values are reasonable.""" + if v > 3600: # 1 hour + log.warning(f"Timeout of {v} seconds is very long and may cause issues") + return v + + @field_validator('pagesegmode') + @classmethod + def validate_pagesegmode_warning(cls, v): + """Validate page segmentation mode and warn about problematic values.""" + if v in (0, 2): + log.warning( + "The tesseract-pagesegmode you selected will disable OCR. " + "This may cause processing to fail." + ) + return v + + @model_validator(mode='after') + def validate_downsample_consistency(self): + """Validate downsample options are consistent.""" + if self.downsample_above != 32767 and not self.downsample_large_images: + log.warning( + "The --tesseract-downsample-above argument will have no effect unless " + "--tesseract-downsample-large-images is also given." + ) + return self + + def validate_with_context(self, languages: list[str]) -> None: + """Validate options that require external context. + + Args: + languages: List of languages being used for OCR + """ + # Validate languages are not internal Tesseract languages + DENIED_LANGUAGES = {'equ', 'osd'} + if DENIED_LANGUAGES & set(languages): + raise BadArgsError( + "The following languages are for Tesseract's internal use " + "and should not be issued explicitly: " + f"{', '.join(DENIED_LANGUAGES & set(languages))}\n" + "Remove them from the -l/--language argument." + ) + + +@hookimpl +def register_options(): + """Register Tesseract option model.""" + return {'tesseract': TesseractOptions} + + @hookimpl def add_options(parser): - tess = parser.add_argument_group("Tesseract", "Advanced control of Tesseract OCR") - tess.add_argument( - '--tesseract-config', - action='append', - metavar='CFG', - default=[], - help="Additional Tesseract configuration files -- see documentation.", - ) - tess.add_argument( - '--tesseract-pagesegmode', - action='store', - type=int, - metavar='PSM', - choices=range(0, 14), - help="Set Tesseract page segmentation mode (see tesseract --help).", - ) - tess.add_argument( - '--tesseract-oem', - action='store', - type=int, - metavar='MODE', - choices=range(0, 4), - help=( - "Set Tesseract 4+ OCR engine mode: " - "0 - original Tesseract only; " - "1 - neural nets LSTM only; " - "2 - Tesseract + LSTM; " - "3 - default." - ), - ) - tess.add_argument( - '--tesseract-thresholding', - action='store', - type=str_to_int(tesseract.TESSERACT_THRESHOLDING_METHODS), - default='auto', - metavar='METHOD', - help=( - "Set Tesseract 5.0+ input image thresholding mode. This may improve OCR " - "results on low quality images or those that contain high contrast color. " - "legacy-otsu is the Tesseract default; adaptive-otsu is an improved Otsu " - "algorithm with improved sort for background color changes; sauvola is " - "based on local standard deviation." - ), - ) - tess.add_argument( - '--tesseract-timeout', - default=180.0, - type=numeric(float, 0), - metavar='SECONDS', - help=( - "Give up on OCR after the timeout, but copy the preprocessed page " - "into the final output. This timeout is only used when using Tesseract " - "for OCR. When Tesseract is used for other operations such as " - "deskewing and orientation, the timeout is controlled by " - "--tesseract-non-ocr-timeout." - ), - ) - tess.add_argument( - '--tesseract-non-ocr-timeout', - default=180.0, - type=numeric(float, 0), - metavar='SECONDS', - help=( - "Give up on non-OCR operations such as deskewing and orientation " - "after timeout. This is a separate timeout from --tesseract-timeout " - "because these operations are not as expensive as OCR." - ), - ) - tess.add_argument( - '--tesseract-downsample-large-images', - action=argparse.BooleanOptionalAction, - default=True, - help=( - "Downsample large images before OCR. Tesseract has an upper limit on the " - "size images it will support. If this argument is given, OCRmyPDF will " - "downsample large images to fit Tesseract. This may reduce OCR quality, " - "on large images the most desirable text is usually larger. If this " - "parameter is not supplied, Tesseract will error out and produce no OCR " - "on the page in question. This argument should be used with a high value " - "of --tesseract-timeout to ensure Tesseract has enough to time." - ), - ) - tess.add_argument( - '--tesseract-downsample-above', - action='store', - type=numeric(int, 100, 32767), - default=32767, - help=( - "Downsample images larger than this size pixel size in either dimension " - "before OCR. --tesseract-downsample-large-images downsamples only when " - "an image exceeds Tesseract's internal limits. This argument causes " - "downsampling to occur when an image exceeds the given size. This may " - "reduce OCR quality, but on large images the most desirable text is " - "usually larger." - ), - ) - tess.add_argument( - '--user-words', - metavar='FILE', - help="Specify the location of the Tesseract user words file. This is a " - "list of words Tesseract should consider while performing OCR in " - "addition to its standard language dictionaries. This can improve " - "OCR quality especially for specialized and technical documents.", - ) - tess.add_argument( - '--user-patterns', - metavar='FILE', - help="Specify the location of the Tesseract user patterns file.", - ) + # Use the model's CLI generation method - it now handles all Tesseract options + TesseractOptions.add_arguments_to_parser(parser) @hookimpl def check_options(options): + """Check external dependencies and version compatibility for Tesseract.""" check_external_program( program='tesseract', package={'linux': 'tesseract-ocr'}, @@ -152,33 +320,16 @@ def check_options(options): "Please upgrade to a newer or supported older version." ) - # Decide on what renderer to use - if options.pdf_renderer == 'auto': - if {'ara', 'heb', 'fas', 'per'} & set(options.languages): - log.info("Using sandwich renderer since there is an RTL language") - options.pdf_renderer = 'sandwich' - else: - options.pdf_renderer = 'hocr' - - if not tesseract.has_thresholding() and options.tesseract_thresholding != 0: + # Check version-specific feature compatibility + if ( + not tesseract.has_thresholding() + and options.tesseract.thresholding != ThresholdingMethod.AUTO + ): log.warning( "The installed version of Tesseract does not support changes to its " "thresholding method. The --tesseract-threshold argument will be " "ignored." ) - if options.tesseract_pagesegmode in (0, 2): - log.warning( - "The --tesseract-pagesegmode argument you select will disable OCR. " - "This may cause processing to fail." - ) - DENIED_LANGUAGES = {'equ', 'osd'} - if DENIED_LANGUAGES & set(options.languages): - raise BadArgsError( - "The following languages for Tesseract's internal use and should not " - "be issued explicitly: " - f"{', '.join(DENIED_LANGUAGES & set(options.languages))}\n" - "Remove them from the -l/--language argument." - ) @hookimpl @@ -192,15 +343,17 @@ def validate(pdfinfo, options): # constraint: (ocrmypdf workers) * (tesseract threads) <= max_workers. # As of Tesseract 4.1, 3 threads is the most effective on a 4 core/8 thread system. if not os.environ.get('OMP_THREAD_LIMIT', '').isnumeric(): - tess_threads = clamp(options.jobs // len(pdfinfo), 1, 3) - os.environ['OMP_THREAD_LIMIT'] = str(tess_threads) + jobs = options.jobs or available_cpu_count() + tess_threads = clamp(jobs // len(pdfinfo), 1, 3) else: tess_threads = int(os.environ['OMP_THREAD_LIMIT']) + # Store the thread limit in options - it will be passed to subprocess env + options.tesseract.omp_thread_limit = tess_threads log.debug("Using Tesseract OpenMP thread limit %d", tess_threads) if ( - options.tesseract_downsample_above != 32767 - and not options.tesseract_downsample_large_images + options.tesseract.downsample_above != 32767 + and not options.tesseract.downsample_large_images ): log.warning( "The --tesseract-downsample-above argument will have no effect unless " @@ -216,10 +369,12 @@ def filter_ocr_image(page: PageContext, image: Image.Image) -> Image.Image: or more than 2**31 bytes. This function resizes the image to fit within those limits. """ - threshold = min(page.options.tesseract_downsample_above, 32767) - options = page.options - if options.tesseract_downsample_large_images: + if getattr(options, 'tesseract', None) is None: + return image + threshold = min(options.tesseract.downsample_above, 32767) + + if options.tesseract.downsample_large_images: size = calculate_downsample( image, max_size=(threshold, threshold), max_bytes=(2**31) - 1 ) @@ -234,10 +389,25 @@ class TesseractOcrEngine(OcrEngine): def version(): return str(tesseract.version()) + @staticmethod + def _determine_renderer(options): + """Determine the PDF renderer to use based on options and languages.""" + if options.pdf_renderer == 'auto': + return 'fpdf2' + return options.pdf_renderer + @staticmethod def creator_tag(options): - tag = '-PDF' if options.pdf_renderer == 'sandwich' else '-hOCR' - return f"Tesseract OCR{tag} {TesseractOcrEngine.version()}" + renderer = TesseractOcrEngine._determine_renderer(options) + match renderer: + case 'hocr': + return f"OCRmyPDF hOCR + Tesseract OCR {TesseractOcrEngine.version()}" + case 'fpdf2': + return f"OCRmyPDF fpdf2 + Tesseract OCR {TesseractOcrEngine.version()}" + case "sandwich": + return f"Tesseract OCR + PDF {TesseractOcrEngine.version()}" + case _: + return f"Tesseract OCR {TesseractOcrEngine.version()}" def __str__(self): return f"Tesseract OCR {TesseractOcrEngine.version()}" @@ -250,8 +420,9 @@ class TesseractOcrEngine(OcrEngine): def get_orientation(input_file, options): return tesseract.get_orientation( input_file, - engine_mode=options.tesseract_oem, - timeout=options.tesseract_non_ocr_timeout, + engine_mode=options.tesseract.oem, + timeout=options.tesseract.non_ocr_timeout, + omp_thread_limit=options.tesseract.omp_thread_limit, ) @staticmethod @@ -259,8 +430,9 @@ class TesseractOcrEngine(OcrEngine): return tesseract.get_deskew( input_file, languages=options.languages, - engine_mode=options.tesseract_oem, - timeout=options.tesseract_non_ocr_timeout, + engine_mode=options.tesseract.oem, + timeout=options.tesseract.non_ocr_timeout, + omp_thread_limit=options.tesseract.omp_thread_limit, ) @staticmethod @@ -270,13 +442,14 @@ class TesseractOcrEngine(OcrEngine): output_hocr=output_hocr, output_text=output_text, languages=options.languages, - engine_mode=options.tesseract_oem, - tessconfig=options.tesseract_config, - timeout=options.tesseract_timeout, - pagesegmode=options.tesseract_pagesegmode, - thresholding=options.tesseract_thresholding, - user_words=options.user_words, - user_patterns=options.user_patterns, + engine_mode=options.tesseract.oem, + tessconfig=options.tesseract.config, + timeout=options.tesseract.timeout, + pagesegmode=options.tesseract.pagesegmode, + thresholding=options.tesseract.thresholding, + user_words=options.tesseract.user_words, + user_patterns=options.tesseract.user_patterns, + omp_thread_limit=options.tesseract.omp_thread_limit, ) @staticmethod @@ -286,16 +459,23 @@ class TesseractOcrEngine(OcrEngine): output_pdf=output_pdf, output_text=output_text, languages=options.languages, - engine_mode=options.tesseract_oem, - tessconfig=options.tesseract_config, - timeout=options.tesseract_timeout, - pagesegmode=options.tesseract_pagesegmode, - thresholding=options.tesseract_thresholding, - user_words=options.user_words, - user_patterns=options.user_patterns, + engine_mode=options.tesseract.oem, + tessconfig=options.tesseract.config, + timeout=options.tesseract.timeout, + pagesegmode=options.tesseract.pagesegmode, + thresholding=options.tesseract.thresholding, + user_words=options.tesseract.user_words, + user_patterns=options.tesseract.user_patterns, + omp_thread_limit=options.tesseract.omp_thread_limit, ) @hookimpl -def get_ocr_engine(): +def get_ocr_engine(options): + """Return TesseractOcrEngine when selected or as default.""" + if options is not None: + ocr_engine = getattr(options, 'ocr_engine', 'auto') + # Tesseract is selected if explicitly requested or if 'auto' + if ocr_engine not in ('auto', 'tesseract'): + return None return TesseractOcrEngine() diff --git a/src/ocrmypdf/cli.py b/src/ocrmypdf/cli.py index f3bd50cd..f23696dd 100644 --- a/src/ocrmypdf/cli.py +++ b/src/ocrmypdf/cli.py @@ -6,11 +6,14 @@ from __future__ import annotations import argparse +from argparse import ArgumentParser from collections.abc import Callable, Mapping from typing import Any, TypeVar from ocrmypdf._defaults import DEFAULT_ROTATE_PAGES_THRESHOLD from ocrmypdf._defaults import PROGRAM_NAME as _PROGRAM_NAME +from ocrmypdf._options import OcrOptions, ProcessingMode +from ocrmypdf._plugin_manager import OcrmypdfPluginManager from ocrmypdf._version import __version__ as _VERSION T = TypeVar('T', int, float) @@ -51,39 +54,6 @@ def str_to_int(mapping: Mapping[str, int]): return _str_to_int -class ArgumentParser(argparse.ArgumentParser): - """Override parser's default behavior of calling sys.exit(). - - https://stackoverflow.com/questions/5943249/python-argparse-and-controlling-overriding-the-exit-status-code - - OCRmyPDF began as a CLI but eventually acquired an API. The API works inside out, - by synthesizing a command line argument. So we subclass the standard parser with - one that doesn't call sys.exit(). Obviously this is not the ideal way to do things - but it works for us. - """ - - def __init__(self, *args, **kwargs): - """Initialize the parser.""" - super().__init__(*args, **kwargs) - self._api_mode = False - - def enable_api_mode(self): - """Enable API mode. - - When set, the parser will not call sys.exit() on error. OCRmyPDF was originally - a command line program, but now it has an API. The API works by synthesizing - command line arguments. - """ - self._api_mode = True - - def error(self, message): - """Override the default argparse error behavior.""" - if not self._api_mode: - super().error(message) - return - raise ValueError(message) - - class LanguageSetAction(argparse.Action): """Manages a list of languages.""" @@ -96,7 +66,7 @@ class LanguageSetAction(argparse.Action): def __call__(self, parser, namespace, values, option_string=None): """Add a language to the set.""" dest = getattr(namespace, self.dest) - if '+' in values: + if isinstance(values, str) and '+' in values: [dest.append(lang) for lang in values.split('+')] else: dest.append(values) @@ -189,16 +159,17 @@ Online documentation is located at: ) parser.add_argument( '--output-type', - choices=['pdfa', 'pdf', 'pdfa-1', 'pdfa-2', 'pdfa-3', 'none'], - default='pdfa', - help="Choose output type. 'pdfa' creates a PDF/A-2b compliant file for " - "long term archiving (default, recommended) but may not suitable " - "for users who want their file altered as little as possible. 'pdfa' " - "also has problems with full Unicode text. 'pdf' minimizes changes " - "to the input file. 'pdf-a1' creates a " - "PDF/A-1b file. 'pdf-a2' is equivalent to 'pdfa'. 'pdf-a3' creates a " - "PDF/A-3b file. 'none' will produce no output, which may be helpful if " - "only the --sidecar is desired.", + choices=['auto', 'pdfa', 'pdf', 'pdfa-1', 'pdfa-2', 'pdfa-3', 'none'], + default='auto', + help="Choose output type. 'auto' (default) produces best-effort PDF/A " + "without requiring Ghostscript - uses verapdf validation when available, " + "otherwise passes through as PDF/A if safe (input already PDF/A or " + "force-ocr was used), or falls back to regular PDF. 'pdfa' creates a " + "PDF/A-2b compliant file for long term archiving (requires Ghostscript " + "as fallback). 'pdf' minimizes changes to the input file. 'pdfa-1' " + "creates a PDF/A-1b file. 'pdfa-2' is equivalent to 'pdfa'. 'pdfa-3' " + "creates a PDF/A-3b file. 'none' will produce no output, which may be " + "helpful if only the --sidecar is desired.", ) # Use null string '\0' as sentinel to indicate the user supplied no argument, @@ -337,12 +308,25 @@ Online documentation is located at: ) ocrsettings = parser.add_argument_group("OCR options", "Control how OCR is applied") + ocrsettings.add_argument( + '-m', + '--mode', + choices=[mode.value for mode in ProcessingMode], + default=ProcessingMode.default.value, + help="Processing mode for pages with existing text. " + "'default' errors if text is found. " + "'force' rasterizes all content and runs OCR (same as --force-ocr). " + "'skip' skips pages with existing text (same as --skip-text). " + "'redo' re-OCRs pages, replacing old invisible text (same as --redo-ocr).", + ) + # Legacy flags for backward compatibility - these set the mode internally ocrsettings.add_argument( '-f', '--force-ocr', action='store_true', help="Rasterize any text or vector objects on each page, apply OCR, and " - "save the rastered output (this rewrites the PDF)", + "save the rastered output (this rewrites the PDF). " + "Equivalent to --mode force.", ) ocrsettings.add_argument( '-s', @@ -350,7 +334,8 @@ Online documentation is located at: action='store_true', help="Skip OCR on any pages that already contain text, but include the " "page in final output; useful for PDFs that contain a mix of " - "images, text pages, and/or previously OCRed pages", + "images, text pages, and/or previously OCRed pages. " + "Equivalent to --mode skip.", ) ocrsettings.add_argument( '--redo-ocr', @@ -358,7 +343,8 @@ Online documentation is located at: help="Attempt to detect and remove the hidden OCR layer from files that " "were previously OCRed with OCRmyPDF or another program. Apply OCR " "to text found in raster images. Existing visible text objects will " - "not be changed. If there is no existing OCR, OCR will be added.", + "not be changed. If there is no existing OCR, OCR will be added. " + "Equivalent to --mode redo.", ) ocrsettings.add_argument( '--skip-big', @@ -397,10 +383,30 @@ Online documentation is located at: ) advanced.add_argument( '--pdf-renderer', - choices=['auto', 'hocr', 'sandwich', 'hocrdebug'], + choices=['auto', 'hocr', 'sandwich', 'hocrdebug', 'fpdf2'], default='auto', - help="Choose OCR PDF renderer - the default option is to let OCRmyPDF " - "choose. See documentation for discussion.", + help="Choose OCR PDF renderer. 'auto' (recommended) uses fpdf2, which " + "provides full international language support including RTL scripts, " + "proper text positioning, and invisible text that becomes visible when " + "selected. 'sandwich' renders text as a background layer. Legacy 'hocr' " + "and 'hocrdebug' options are deprecated and will use fpdf2.", + ) + advanced.add_argument( + '--ocr-engine', + choices=['auto', 'tesseract', 'none'], + default='auto', + help="OCR engine to use. 'auto' (default) selects the best available engine. " + "'tesseract' uses Tesseract OCR. " + "'none' skips OCR entirely, useful for PDF/A conversion or image processing " + "without text recognition.", + ) + advanced.add_argument( + '--rasterizer', + choices=['auto', 'ghostscript', 'pypdfium'], + default='auto', + help="Choose PDF page rasterizer. 'auto' prefers pypdfium when available, " + "falling back to Ghostscript. 'pypdfium' is faster but requires the " + "pypdfium2 package. 'ghostscript' uses the traditional Ghostscript rasterizer.", ) advanced.add_argument( '--rotate-pages-threshold', @@ -465,3 +471,75 @@ plugins_only_parser.add_argument( default=[], help="Name of plugin to import.", ) + + +def namespace_to_options(ns) -> OcrOptions: + """Convert argparse.Namespace to OcrOptions. + + This function encapsulates CLI-specific knowledge of how command line + arguments map to our internal options model. + """ + # Extract known fields + known_fields = {} + extra_attrs = {} + + # Legacy boolean flags that map to mode - handled by OcrOptions model validator + legacy_mode_flags = {'force_ocr', 'skip_text', 'redo_ocr'} + + for key, value in vars(ns).items(): + if key in OcrOptions.model_fields: + known_fields[key] = value + elif key in legacy_mode_flags: + # Pass legacy flags to OcrOptions for conversion to mode + known_fields[key] = value + else: + extra_attrs[key] = value + + # Handle special cases for hOCR API + if 'output_folder' in extra_attrs and 'output_file' not in known_fields: + known_fields['output_file'] = '/dev/null' # Placeholder + + # Handle case where input_file is missing (e.g., in _hocr_to_ocr_pdf) + if 'work_folder' in extra_attrs and 'input_file' not in known_fields: + known_fields['input_file'] = '/dev/null' # Placeholder + + instance = OcrOptions(**known_fields) + instance.extra_attrs = extra_attrs + return instance + + +def get_options_and_plugins( + args=None, +) -> tuple[OcrOptions, OcrmypdfPluginManager]: + """Parse command line arguments and return OcrOptions and plugin manager. + + This is the main entry point for CLI argument processing. It handles + plugin discovery, argument parsing, and conversion to our internal + options model. + + Args: + args: Command line arguments. If None, uses sys.argv. + + Returns: + Tuple of (OcrOptions, PluginManager) + """ + # Import here to avoid circular imports + from ocrmypdf.api import setup_plugin_infrastructure + + # First pass: get plugins so we can register their options + pre_options, _unused = plugins_only_parser.parse_known_args(args=args) + + # Set up plugin infrastructure with proper initialization + plugin_manager = setup_plugin_infrastructure(plugins=pre_options.plugins) + + # Get parser and let plugins add their options + parser = get_parser() + plugin_manager.add_options(parser=parser) + + # Parse all arguments + namespace = parser.parse_args(args=args) + + # Convert to OcrOptions + options = namespace_to_options(namespace) + + return options, plugin_manager diff --git a/src/ocrmypdf/data/NotoSans-Regular.ttf b/src/ocrmypdf/data/NotoSans-Regular.ttf new file mode 100644 index 00000000..7552fbe8 Binary files /dev/null and b/src/ocrmypdf/data/NotoSans-Regular.ttf differ diff --git a/src/ocrmypdf/data/Occulta.ttf b/src/ocrmypdf/data/Occulta.ttf new file mode 100644 index 00000000..e80c8850 Binary files /dev/null and b/src/ocrmypdf/data/Occulta.ttf differ diff --git a/src/ocrmypdf/data/pdf.ttf b/src/ocrmypdf/data/pdf.ttf deleted file mode 100644 index d1472b20..00000000 Binary files a/src/ocrmypdf/data/pdf.ttf and /dev/null differ diff --git a/src/ocrmypdf/font/__init__.py b/src/ocrmypdf/font/__init__.py new file mode 100644 index 00000000..f94cc3cb --- /dev/null +++ b/src/ocrmypdf/font/__init__.py @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: 2025 James R. Barlow +# SPDX-License-Identifier: MPL-2.0 + +"""Font management for OCRmyPDF PDF rendering. + +This module provides font infrastructure for the fpdf2 PDF renderer. It includes: + +- FontManager: Base class for font loading and glyph checking +- FontProvider: Protocol and implementations for font discovery +- MultiFontManager: Automatic font selection for multilingual documents +- SystemFontProvider: System font discovery +""" +from __future__ import annotations + +from ocrmypdf.font.font_manager import FontManager +from ocrmypdf.font.font_provider import ( + BuiltinFontProvider, + ChainedFontProvider, + FontProvider, +) +from ocrmypdf.font.multi_font_manager import MultiFontManager +from ocrmypdf.font.system_font_provider import SystemFontProvider + +__all__ = [ + "FontManager", + "FontProvider", + "BuiltinFontProvider", + "ChainedFontProvider", + "MultiFontManager", + "SystemFontProvider", +] diff --git a/src/ocrmypdf/font/font_manager.py b/src/ocrmypdf/font/font_manager.py new file mode 100644 index 00000000..97fb6c34 --- /dev/null +++ b/src/ocrmypdf/font/font_manager.py @@ -0,0 +1,115 @@ +# SPDX-FileCopyrightText: 2025 James R. Barlow +# SPDX-License-Identifier: MPL-2.0 + +"""Base font management for PDF rendering. + +This module provides the base FontManager class that handles font loading +and glyph checking using uharfbuzz. +""" + +from __future__ import annotations + +from pathlib import Path + +import uharfbuzz as hb + + +class FontManager: + """Manages font loading and glyph checking for PDF rendering. + + This base class handles loading fonts with uharfbuzz for glyph checking + and text shaping. Renderer-specific subclasses should extend this to + add their own font objects. + + Attributes: + font_path: Path to the font file + font_data: Raw font file bytes + font_index: Index within TTC collection (0 for single-font files) + hb_face: uharfbuzz Face object + hb_font: uharfbuzz Font object + """ + + def __init__(self, font_path: Path, font_index: int = 0): + """Initialize font manager. + + Args: + font_path: Path to TrueType/OpenType font file + font_index: Index of font within a TTC collection (default 0). + For single-font files (.ttf, .otf), use 0. + """ + self.font_path = font_path + self.font_index = font_index + + # Load font data + self.font_data = font_path.read_bytes() + + # Load font with uharfbuzz for glyph checking and text measurement + # Note: uharfbuzz Face also supports font_index for TTC files + self.hb_face = hb.Face(self.font_data, font_index) + self.hb_font = hb.Font(self.hb_face) + + def get_hb_font(self) -> hb.Font: + """Get uharfbuzz Font object for text measurement. + + Returns: + UHarfBuzz Font instance + """ + return self.hb_font + + def has_glyph(self, codepoint: int) -> bool: + """Check if font has a glyph for given codepoint. + + Args: + codepoint: Unicode codepoint + + Returns: + True if font has a real glyph (not .notdef) + """ + glyph_id = self.hb_font.get_nominal_glyph(codepoint) + return glyph_id is not None and glyph_id != 0 + + def get_font_metrics(self) -> tuple[float, float, float]: + """Get normalized font metrics (ascent, descent, units_per_em). + + Returns: + Tuple of (ascent, descent, units_per_em) where ascent and descent + are in font units. Ascent is positive (above baseline), descent + is typically negative (below baseline). + """ + extents = self.hb_font.get_font_extents('ltr') + units_per_em = self.hb_face.upem + return (extents.ascender, extents.descender, units_per_em) + + def get_left_side_bearing(self, char: str, font_size: float) -> float: + """Get the left side bearing of a character at a given font size. + + The left side bearing (lsb) is the horizontal distance from the glyph + origin (x=0) to the leftmost pixel of the glyph. A positive lsb means + there's whitespace before the glyph starts. + + Args: + char: Single character to get lsb for + font_size: Font size in points + + Returns: + Left side bearing in points. Returns 0 if character not found. + """ + if not char: + return 0.0 + + codepoint = ord(char) + glyph_id = self.hb_font.get_nominal_glyph(codepoint) + if glyph_id is None or glyph_id == 0: + return 0.0 + + # Get glyph extents which include left/right bearing info + extents = self.hb_font.get_glyph_extents(glyph_id) + if extents is None: + return 0.0 + + # x_bearing is the left side bearing in font units + units_per_em = self.hb_face.upem + lsb_units = extents.x_bearing + lsb_pt = lsb_units * font_size / units_per_em + + return lsb_pt diff --git a/src/ocrmypdf/font/font_provider.py b/src/ocrmypdf/font/font_provider.py new file mode 100644 index 00000000..90c5035e --- /dev/null +++ b/src/ocrmypdf/font/font_provider.py @@ -0,0 +1,189 @@ +# SPDX-FileCopyrightText: 2025 James R. Barlow +# SPDX-License-Identifier: MPL-2.0 + +"""Font provider protocol and implementations for PDF rendering.""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Protocol + +from ocrmypdf.font.font_manager import FontManager + +log = logging.getLogger(__name__) + + +class FontProvider(Protocol): + """Protocol for providing fonts to MultiFontManager. + + Implementations are responsible for knowing where fonts are located + and loading them. MultiFontManager asks for fonts by name and uses + them for glyph coverage checking. + """ + + def get_font(self, font_name: str) -> FontManager | None: + """Get a FontManager for the named font. + + Args: + font_name: Logical font name (e.g., 'NotoSans-Regular') + + Returns: + FontManager if font is available, None otherwise + """ + ... + + def get_available_fonts(self) -> list[str]: + """Get list of available font names. + + Returns: + List of font names that can be retrieved with get_font() + """ + ... + + def get_fallback_font(self) -> FontManager: + """Get the glyphless fallback font. + + This font must always be available and handles any codepoint. + + Returns: + FontManager for the glyphless fallback font (Occulta.ttf) + """ + ... + + +class BuiltinFontProvider: + """Font provider using builtin fonts from ocrmypdf/data directory.""" + + # Mapping of logical font names to filenames + # Only Latin (NotoSans) and the glyphless fallback (Occulta.ttf) are bundled. + # All other scripts (Arabic, Devanagari, CJK, etc.) are discovered from + # system fonts by SystemFontProvider to reduce package size. + FONT_FILES = { + 'NotoSans-Regular': 'NotoSans-Regular.ttf', + 'Occulta': 'Occulta.ttf', + } + + def __init__(self, font_dir: Path | None = None): + """Initialize builtin font provider. + + Args: + font_dir: Directory containing font files. If None, uses + the default ocrmypdf/data directory. + """ + if font_dir is None: + font_dir = Path(__file__).parent.parent / "data" + self.font_dir = font_dir + self._fonts: dict[str, FontManager] = {} + self._load_fonts() + + def _load_fonts(self) -> None: + """Load available fonts, logging warnings for missing ones.""" + for font_name, font_file in self.FONT_FILES.items(): + font_path = self.font_dir / font_file + if not font_path.exists(): + if font_name == 'Occulta': + raise FileNotFoundError( + f"Required fallback font not found: {font_path}" + ) + log.warning( + "Font %s not found at %s - OCR output quality for some " + "scripts may be affected", + font_name, + font_path, + ) + continue + + try: + self._fonts[font_name] = FontManager(font_path) + except Exception as e: + if font_name == 'Occulta': + raise ValueError( + f"Failed to load required fallback font {font_file}: {e}" + ) from e + log.warning( + "Failed to load font %s: %s - OCR output quality may be affected", + font_name, + e, + ) + + def get_font(self, font_name: str) -> FontManager | None: + """Get a FontManager for the named font.""" + return self._fonts.get(font_name) + + def get_available_fonts(self) -> list[str]: + """Get list of available font names.""" + return list(self._fonts.keys()) + + def get_fallback_font(self) -> FontManager: + """Get the glyphless fallback font.""" + return self._fonts['Occulta'] + + +class ChainedFontProvider: + """Font provider that tries multiple providers in order. + + This allows combining builtin fonts with system fonts, trying + the builtin provider first and falling back to system fonts + for fonts not bundled with the package. + """ + + def __init__(self, providers: list[FontProvider]): + """Initialize chained font provider. + + Args: + providers: List of font providers to try in order. + The first provider that returns a font wins. + """ + if not providers: + raise ValueError("At least one provider is required") + self.providers = providers + + def get_font(self, font_name: str) -> FontManager | None: + """Get a FontManager for the named font. + + Tries each provider in order until one returns a font. + + Args: + font_name: Logical font name (e.g., 'NotoSans-Regular') + + Returns: + FontManager if any provider has the font, None otherwise + """ + for provider in self.providers: + if font := provider.get_font(font_name): + return font + return None + + def get_available_fonts(self) -> list[str]: + """Get list of available font names from all providers. + + Returns: + Combined list of font names (deduplicated, order preserved) + """ + seen: set[str] = set() + result: list[str] = [] + for provider in self.providers: + for name in provider.get_available_fonts(): + if name not in seen: + seen.add(name) + result.append(name) + return result + + def get_fallback_font(self) -> FontManager: + """Get the glyphless fallback font. + + Tries each provider until one provides a fallback font. + + Returns: + FontManager for the fallback font + + Raises: + RuntimeError: If no provider can provide a fallback font + """ + for provider in self.providers: + try: + return provider.get_fallback_font() + except (NotImplementedError, AttributeError, KeyError): + continue + raise RuntimeError("No fallback font available from any provider") diff --git a/src/ocrmypdf/font/multi_font_manager.py b/src/ocrmypdf/font/multi_font_manager.py new file mode 100644 index 00000000..96283fde --- /dev/null +++ b/src/ocrmypdf/font/multi_font_manager.py @@ -0,0 +1,334 @@ +# SPDX-FileCopyrightText: 2025 James R. Barlow +# SPDX-License-Identifier: MPL-2.0 + +"""Multi-font management for PDF rendering. + +Provides automatic font selection for multilingual documents based on +language hints and glyph coverage analysis. +""" + +from __future__ import annotations + +import logging +from pathlib import Path + +from ocrmypdf.font.font_manager import FontManager +from ocrmypdf.font.font_provider import ( + BuiltinFontProvider, + ChainedFontProvider, + FontProvider, +) +from ocrmypdf.font.system_font_provider import SystemFontProvider + +log = logging.getLogger(__name__) + + +class MultiFontManager: + """Manages multiple fonts with automatic selection and fallback. + + This class orchestrates multiple FontManager instances to provide + word-level font selection for multilingual documents. It uses a hybrid + approach combining language hints from hOCR with glyph coverage analysis. + + Font selection strategy: + 1. Try language-preferred font (if language hint available) + 2. Try fallback fonts in order by glyph coverage + 3. Fall back to Occulta.ttf (glyphless fallback) + """ + + # Language to font mapping + # Keys are ISO 639-2/3 codes or Tesseract language codes + LANGUAGE_FONT_MAP = { + # Arabic script + 'ara': 'NotoSansArabic-Regular', # Arabic + 'per': 'NotoSansArabic-Regular', # Persian (uses Arabic script) + 'fas': 'NotoSansArabic-Regular', # Farsi (alternative code for Persian) + 'urd': 'NotoSansArabic-Regular', # Urdu (uses Arabic script) + 'pus': 'NotoSansArabic-Regular', # Pashto + 'kur': 'NotoSansArabic-Regular', # Kurdish (Arabic script variant) + # Devanagari script + 'hin': 'NotoSansDevanagari-Regular', # Hindi + 'san': 'NotoSansDevanagari-Regular', # Sanskrit + 'mar': 'NotoSansDevanagari-Regular', # Marathi + 'nep': 'NotoSansDevanagari-Regular', # Nepali + 'kok': 'NotoSansDevanagari-Regular', # Konkani + 'bho': 'NotoSansDevanagari-Regular', # Bhojpuri + 'mai': 'NotoSansDevanagari-Regular', # Maithili + # CJK + 'chi': 'NotoSansCJK-Regular', # Chinese (generic) + 'zho': 'NotoSansCJK-Regular', # Chinese (ISO 639-3) + 'chi_sim': 'NotoSansCJK-Regular', # Chinese Simplified (Tesseract) + 'chi_tra': 'NotoSansCJK-Regular', # Chinese Traditional (Tesseract) + 'jpn': 'NotoSansCJK-Regular', # Japanese + 'kor': 'NotoSansCJK-Regular', # Korean + # Thai + 'tha': 'NotoSansThai-Regular', # Thai + # Hebrew + 'heb': 'NotoSansHebrew-Regular', # Hebrew + 'yid': 'NotoSansHebrew-Regular', # Yiddish (uses Hebrew script) + # Bengali script + 'ben': 'NotoSansBengali-Regular', # Bengali + 'asm': 'NotoSansBengali-Regular', # Assamese (uses Bengali script) + # Tamil + 'tam': 'NotoSansTamil-Regular', # Tamil + # Gujarati + 'guj': 'NotoSansGujarati-Regular', # Gujarati + # Telugu + 'tel': 'NotoSansTelugu-Regular', # Telugu + # Kannada + 'kan': 'NotoSansKannada-Regular', # Kannada + # Malayalam + 'mal': 'NotoSansMalayalam-Regular', # Malayalam + # Myanmar (Burmese) + 'mya': 'NotoSansMyanmar-Regular', # Myanmar + # Khmer (Cambodian) + 'khm': 'NotoSansKhmer-Regular', # Khmer + # Lao + 'lao': 'NotoSansLao-Regular', # Lao + # Georgian + 'kat': 'NotoSansGeorgian-Regular', # Georgian + 'geo': 'NotoSansGeorgian-Regular', # Georgian (alternative) + # Armenian + 'hye': 'NotoSansArmenian-Regular', # Armenian + 'arm': 'NotoSansArmenian-Regular', # Armenian (alternative) + # Ethiopic + 'amh': 'NotoSansEthiopic-Regular', # Amharic + 'tir': 'NotoSansEthiopic-Regular', # Tigrinya + # Sinhala + 'sin': 'NotoSansSinhala-Regular', # Sinhala + # Gurmukhi (Punjabi) + 'pan': 'NotoSansGurmukhi-Regular', # Punjabi + 'pnb': 'NotoSansGurmukhi-Regular', # Western Punjabi + # Oriya + 'ori': 'NotoSansOriya-Regular', # Oriya + 'ory': 'NotoSansOriya-Regular', # Oriya (alternative) + # Tibetan + 'bod': 'NotoSansTibetan-Regular', # Tibetan + 'tib': 'NotoSansTibetan-Regular', # Tibetan (alternative) + } + + # Ordered fallback chain for fonts (after language-preferred font) + # Order matters: most common scripts first for faster matching + FALLBACK_FONTS = [ + 'NotoSans-Regular', # Latin, Greek, Cyrillic + 'NotoSansArabic-Regular', + 'NotoSansDevanagari-Regular', + 'NotoSansCJK-Regular', + 'NotoSansThai-Regular', + 'NotoSansHebrew-Regular', + 'NotoSansBengali-Regular', + 'NotoSansTamil-Regular', + 'NotoSansGujarati-Regular', + 'NotoSansTelugu-Regular', + 'NotoSansKannada-Regular', + 'NotoSansMalayalam-Regular', + 'NotoSansMyanmar-Regular', + 'NotoSansKhmer-Regular', + 'NotoSansLao-Regular', + 'NotoSansGeorgian-Regular', + 'NotoSansArmenian-Regular', + 'NotoSansEthiopic-Regular', + 'NotoSansSinhala-Regular', + 'NotoSansGurmukhi-Regular', + 'NotoSansOriya-Regular', + 'NotoSansTibetan-Regular', + ] + + def __init__( + self, + font_dir: Path | None = None, + *, + font_provider: FontProvider | None = None, + ): + """Initialize multi-font manager. + + Args: + font_dir: Directory containing font files. If font_provider is + not specified, this is passed to BuiltinFontProvider. + font_provider: Provider for loading fonts. If None, uses a + ChainedFontProvider that tries builtin fonts first, + then searches system fonts. + """ + if font_provider is not None: + self.font_provider = font_provider + else: + # Use chained provider: try builtin fonts first, then system fonts + self.font_provider = ChainedFontProvider( + [ + BuiltinFontProvider(font_dir), + SystemFontProvider(), + ] + ) + + # Font selection cache: (word_text, language) -> font_name + self._selection_cache: dict[tuple[str, str | None], str] = {} + # Track whether we've warned about missing fonts (warn once per script) + self._warned_scripts: set[str] = set() + + @property + def fonts(self) -> dict[str, FontManager]: + """Get all loaded fonts (backward compatibility).""" + return self.get_all_fonts() + + def _try_font( + self, font_name: str, word_text: str, cache_key: tuple[str, str | None] + ) -> FontManager | None: + """Try to use a font for the given word. + + Args: + font_name: Name of font to try + word_text: Text content to check + cache_key: Cache key for storing successful result + + Returns: + FontManager if font exists and has all glyphs, None otherwise + """ + font = self.font_provider.get_font(font_name) + if font is None: + return None + if self._has_all_glyphs(font, word_text): + self._selection_cache[cache_key] = font_name + return font + return None + + def select_font_for_word( + self, word_text: str, line_language: str | None + ) -> FontManager: + """Select appropriate font for a word. + + Uses a hybrid approach: + 1. Language-based selection (if language hint available) + 2. Ordered fallback through available fonts by glyph coverage + 3. Final fallback to Occulta.ttf (glyphless) + + Args: + word_text: The text content of the word + line_language: Language code from hOCR (e.g., 'ara', 'eng') + + Returns: + FontManager instance to use for rendering this word + """ + cache_key = (word_text, line_language) + if cache_key in self._selection_cache: + cached_name = self._selection_cache[cache_key] + font = self.font_provider.get_font(cached_name) + if font: + return font + + tried_fonts: set[str] = set() + + # Phase 1: Try language-preferred font + if line_language and line_language in self.LANGUAGE_FONT_MAP: + preferred = self.LANGUAGE_FONT_MAP[line_language] + tried_fonts.add(preferred) + if result := self._try_font(preferred, word_text, cache_key): + return result + + # Phase 2: Try fallback fonts in order + for font_name in self.FALLBACK_FONTS: + if font_name in tried_fonts: + continue + if result := self._try_font(font_name, word_text, cache_key): + return result + + # Phase 3: Glyphless fallback (always succeeds) + # Warn if we're falling back for non-ASCII text (likely missing font) + self._warn_missing_font(word_text, line_language) + self._selection_cache[cache_key] = 'Occulta' + return self.font_provider.get_fallback_font() + + def _warn_missing_font(self, word_text: str, line_language: str | None) -> None: + """Warn user about missing font for non-Latin text. + + Only warns once per language/script to avoid log spam. + """ + # Determine a key for deduplication (language or 'non-ascii') + warn_key = line_language if line_language else 'unknown' + + # Only warn for non-ASCII text and only once per key + if warn_key in self._warned_scripts: + return + + # Check if text contains non-ASCII characters + if not any(ord(c) > 127 for c in word_text): + return + + self._warned_scripts.add(warn_key) + + if line_language and line_language in self.LANGUAGE_FONT_MAP: + font_name = self.LANGUAGE_FONT_MAP[line_language] + log.warning( + "No font found with glyphs for '%s' text. " + "Install %s for better rendering. " + "See https://fonts.google.com/noto", + line_language, + font_name, + ) + else: + log.warning( + "No font found with glyphs for some text. " + "Install Noto fonts for better rendering. " + "See https://fonts.google.com/noto" + ) + + def _has_all_glyphs(self, font: FontManager, text: str) -> bool: + """Check if a font has glyphs for all characters in text. + + Args: + font: FontManager instance to check + text: Text to verify coverage for + + Returns: + True if font has real glyphs for all characters (not .notdef) + """ + if not text: + return True + + hb_font = font.get_hb_font() + + for char in text: + codepoint = ord(char) + glyph_id = hb_font.get_nominal_glyph(codepoint) + if glyph_id is None or glyph_id == 0: # 0 = .notdef glyph + return False + + return True + + def has_font(self, font_name: str) -> bool: + """Check if a named font is available. + + Args: + font_name: Name of font to check + + Returns: + True if font is available + """ + return self.font_provider.get_font(font_name) is not None + + def has_all_glyphs(self, font_name: str, text: str) -> bool: + """Check if a named font has glyphs for all characters in text. + + Args: + font_name: Name of font to check + text: Text to verify coverage for + + Returns: + True if font has real glyphs for all characters (not .notdef) + """ + font = self.font_provider.get_font(font_name) + if font is None: + return False + return self._has_all_glyphs(font, text) + + def get_all_fonts(self) -> dict[str, FontManager]: + """Get all loaded font managers. + + Returns: + Dictionary mapping font names to FontManager instances + """ + result = {} + for name in self.font_provider.get_available_fonts(): + font = self.font_provider.get_font(name) + if font is not None: + result[name] = font + return result diff --git a/src/ocrmypdf/font/system_font_provider.py b/src/ocrmypdf/font/system_font_provider.py new file mode 100644 index 00000000..d0d17bc3 --- /dev/null +++ b/src/ocrmypdf/font/system_font_provider.py @@ -0,0 +1,297 @@ +# SPDX-FileCopyrightText: 2025 James R. Barlow +# SPDX-License-Identifier: MPL-2.0 + +"""System font discovery for PDF rendering. + +Provides lazy discovery of Noto fonts installed on the system across +Linux, macOS, and Windows platforms. +""" + +from __future__ import annotations + +import logging +import os +import sys +from pathlib import Path + +from ocrmypdf.font.font_manager import FontManager + +log = logging.getLogger(__name__) + + +class SystemFontProvider: + """Discovers and provides system-installed Noto fonts with lazy scanning. + + This provider searches standard system font directories for Noto fonts. + Scanning is performed lazily - only when a font is actually requested + and not found in the builtin fonts. Results are cached for the lifetime + of the provider instance. + """ + + # System font directories by platform + SYSTEM_FONT_DIRS: dict[str, list[Path]] = { + 'linux': [ + Path('/usr/share/fonts'), + Path('/usr/local/share/fonts'), + Path.home() / '.fonts', + Path.home() / '.local/share/fonts', + ], + 'freebsd': [ + Path('/usr/local/share/fonts'), + Path.home() / '.fonts', + ], + 'darwin': [ + Path('/Library/Fonts'), + Path('/System/Library/Fonts'), + Path.home() / 'Library/Fonts', + ], + # Windows is handled dynamically in _get_font_dirs() + } + + # Noto font logical names → possible filenames (priority order) + # The first match found will be used + NOTO_FONT_PATTERNS: dict[str, list[str]] = { + 'NotoSans-Regular': [ + 'NotoSans-Regular.ttf', + 'NotoSans-Regular.otf', + ], + 'NotoSansArabic-Regular': [ + 'NotoSansArabic-Regular.ttf', + 'NotoSansArabic-Regular.otf', + ], + 'NotoSansDevanagari-Regular': [ + 'NotoSansDevanagari-Regular.ttf', + 'NotoSansDevanagari-Regular.otf', + ], + 'NotoSansCJK-Regular': [ + # Language-specific variants (any will work for CJK) + 'NotoSansCJKsc-Regular.otf', # Simplified Chinese + 'NotoSansCJKtc-Regular.otf', # Traditional Chinese + 'NotoSansCJKjp-Regular.otf', # Japanese + 'NotoSansCJKkr-Regular.otf', # Korean + # TTC collections (common on Linux distros) + 'NotoSansCJK-Regular.ttc', + 'NotoSansCJKsc-Regular.ttc', + # Variable fonts + 'NotoSansCJKsc-VF.otf', + ], + 'NotoSansThai-Regular': [ + 'NotoSansThai-Regular.ttf', + 'NotoSansThai-Regular.otf', + ], + 'NotoSansHebrew-Regular': [ + 'NotoSansHebrew-Regular.ttf', + 'NotoSansHebrew-Regular.otf', + ], + 'NotoSansBengali-Regular': [ + 'NotoSansBengali-Regular.ttf', + 'NotoSansBengali-Regular.otf', + ], + 'NotoSansTamil-Regular': [ + 'NotoSansTamil-Regular.ttf', + 'NotoSansTamil-Regular.otf', + ], + 'NotoSansGujarati-Regular': [ + 'NotoSansGujarati-Regular.ttf', + 'NotoSansGujarati-Regular.otf', + ], + 'NotoSansTelugu-Regular': [ + 'NotoSansTelugu-Regular.ttf', + 'NotoSansTelugu-Regular.otf', + ], + 'NotoSansKannada-Regular': [ + 'NotoSansKannada-Regular.ttf', + 'NotoSansKannada-Regular.otf', + ], + 'NotoSansMalayalam-Regular': [ + 'NotoSansMalayalam-Regular.ttf', + 'NotoSansMalayalam-Regular.otf', + ], + 'NotoSansMyanmar-Regular': [ + 'NotoSansMyanmar-Regular.ttf', + 'NotoSansMyanmar-Regular.otf', + ], + 'NotoSansKhmer-Regular': [ + 'NotoSansKhmer-Regular.ttf', + 'NotoSansKhmer-Regular.otf', + ], + 'NotoSansLao-Regular': [ + 'NotoSansLao-Regular.ttf', + 'NotoSansLao-Regular.otf', + ], + 'NotoSansGeorgian-Regular': [ + 'NotoSansGeorgian-Regular.ttf', + 'NotoSansGeorgian-Regular.otf', + ], + 'NotoSansArmenian-Regular': [ + 'NotoSansArmenian-Regular.ttf', + 'NotoSansArmenian-Regular.otf', + ], + 'NotoSansEthiopic-Regular': [ + 'NotoSansEthiopic-Regular.ttf', + 'NotoSansEthiopic-Regular.otf', + ], + 'NotoSansSinhala-Regular': [ + 'NotoSansSinhala-Regular.ttf', + 'NotoSansSinhala-Regular.otf', + ], + 'NotoSansGurmukhi-Regular': [ + 'NotoSansGurmukhi-Regular.ttf', + 'NotoSansGurmukhi-Regular.otf', + ], + 'NotoSansOriya-Regular': [ + 'NotoSansOriya-Regular.ttf', + 'NotoSansOriya-Regular.otf', + ], + 'NotoSansTibetan-Regular': [ + 'NotoSansTibetan-Regular.ttf', + 'NotoSansTibetan-Regular.otf', + ], + } + + def __init__(self) -> None: + """Initialize system font provider with empty caches.""" + # Cache: font_name -> FontManager (successfully loaded fonts) + self._font_cache: dict[str, FontManager] = {} + # Negative cache: font names we've searched for but not found + self._not_found: set[str] = set() + # Cached font directories (computed lazily) + self._font_dirs: list[Path] | None = None + + def _get_platform(self) -> str: + """Get the current platform identifier. + + Returns: + Platform string: 'linux', 'darwin', 'windows', or 'freebsd' + """ + if sys.platform == 'win32': + return 'windows' + elif sys.platform == 'darwin': + return 'darwin' + elif 'freebsd' in sys.platform: + return 'freebsd' + else: + return 'linux' + + def _get_font_dirs(self) -> list[Path]: + """Get font directories for the current platform. + + Returns: + List of paths to search for fonts (may include non-existent paths) + """ + if self._font_dirs is not None: + return self._font_dirs + + platform = self._get_platform() + + if platform == 'windows': + # Get Windows font directories from environment + windir = os.environ.get('WINDIR', r'C:\Windows') + self._font_dirs = [Path(windir) / 'Fonts'] + # User-installed fonts (Windows 10+) + localappdata = os.environ.get('LOCALAPPDATA') + if localappdata: + self._font_dirs.append( + Path(localappdata) / 'Microsoft' / 'Windows' / 'Fonts' + ) + else: + self._font_dirs = list(self.SYSTEM_FONT_DIRS.get(platform, [])) + + return self._font_dirs + + def _find_font_file(self, font_name: str) -> Path | None: + """Search system directories for a font file. + + Args: + font_name: Logical font name (e.g., 'NotoSansCJK-Regular') + + Returns: + Path to font file if found, None otherwise + """ + if font_name not in self.NOTO_FONT_PATTERNS: + return None + + patterns = self.NOTO_FONT_PATTERNS[font_name] + + for font_dir in self._get_font_dirs(): + if not font_dir.exists(): + continue + + for pattern in patterns: + # Search recursively for the font file + try: + matches = list(font_dir.rglob(pattern)) + if matches: + log.debug( + "Found system font %s at %s", font_name, matches[0] + ) + return matches[0] + except PermissionError: + # Skip directories we can't read + continue + + return None + + def get_font(self, font_name: str) -> FontManager | None: + """Get a FontManager for the named font (lazy loading). + + This method implements lazy scanning: fonts are only searched for + when first requested. Results (both positive and negative) are + cached for subsequent calls. + + Args: + font_name: Logical font name (e.g., 'NotoSansCJK-Regular') + + Returns: + FontManager if font is found and loadable, None otherwise + """ + # Check positive cache first + if font_name in self._font_cache: + return self._font_cache[font_name] + + # Check negative cache (already searched, not found) + if font_name in self._not_found: + return None + + # Lazy scan for this specific font + font_path = self._find_font_file(font_name) + if font_path is not None: + try: + fm = FontManager(font_path) + self._font_cache[font_name] = fm + return fm + except Exception as e: + log.warning( + "Found font %s at %s but failed to load: %s", + font_name, + font_path, + e, + ) + + # Cache negative result + self._not_found.add(font_name) + return None + + def get_available_fonts(self) -> list[str]: + """Get list of font names this provider can potentially find. + + Note: This returns all font names we know patterns for, not + necessarily fonts that are actually installed. Use get_font() + to check if a specific font is available. + + Returns: + List of logical font names + """ + return list(self.NOTO_FONT_PATTERNS.keys()) + + def get_fallback_font(self) -> FontManager: + """Get the glyphless fallback font. + + Raises: + NotImplementedError: System provider doesn't provide fallback. + Use BuiltinFontProvider for the fallback font. + """ + raise NotImplementedError( + "SystemFontProvider does not provide a fallback font. " + "Use BuiltinFontProvider for Occulta.ttf fallback." + ) diff --git a/src/ocrmypdf/fpdf_renderer/__init__.py b/src/ocrmypdf/fpdf_renderer/__init__.py new file mode 100644 index 00000000..db039f9e --- /dev/null +++ b/src/ocrmypdf/fpdf_renderer/__init__.py @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: 2025 James R. Barlow +# SPDX-License-Identifier: MPL-2.0 + +"""fpdf2-based PDF renderer for OCR text layers. + +This module provides the PDF renderer using fpdf2 for creating +searchable OCR text layers. +""" +from __future__ import annotations + +from ocrmypdf.fpdf_renderer.renderer import ( + DebugRenderOptions, + Fpdf2MultiPageRenderer, + Fpdf2PdfRenderer, +) + +__all__ = [ + "DebugRenderOptions", + "Fpdf2PdfRenderer", + "Fpdf2MultiPageRenderer", +] diff --git a/src/ocrmypdf/fpdf_renderer/renderer.py b/src/ocrmypdf/fpdf_renderer/renderer.py new file mode 100644 index 00000000..8032b544 --- /dev/null +++ b/src/ocrmypdf/fpdf_renderer/renderer.py @@ -0,0 +1,833 @@ +# SPDX-FileCopyrightText: 2025 James R. Barlow +# SPDX-License-Identifier: MPL-2.0 + +"""fpdf2-based PDF renderer for OCR text layers. + +This module provides PDF rendering using fpdf2 for creating searchable +OCR text layers. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from itertools import pairwise +from math import atan, degrees +from pathlib import Path + +from fpdf import FPDF +from fpdf.enums import TextMode +from pikepdf import Matrix, Rectangle + +from ocrmypdf.font import FontManager, MultiFontManager +from ocrmypdf.models.ocr_element import OcrClass, OcrElement + +log = logging.getLogger(__name__) + + +def transform_point(matrix: Matrix, x: float, y: float) -> tuple[float, float]: + """Transform a point (x, y) by a matrix. + + Args: + matrix: pikepdf Matrix to apply + x: X coordinate + y: Y coordinate + + Returns: + Tuple of (transformed_x, transformed_y) + """ + # Use a degenerate rectangle to transform a single point + rect = Rectangle(x, y, x, y) + transformed = matrix.transform(rect) + return (transformed.llx, transformed.lly) + + +def transform_box( + matrix: Matrix, left: float, top: float, right: float, bottom: float +) -> tuple[float, float, float, float]: + """Transform a bounding box by a matrix. + + Args: + matrix: pikepdf Matrix to apply + left: Left edge of box + top: Top edge of box + right: Right edge of box + bottom: Bottom edge of box + + Returns: + Tuple of (llx, lly, width, height) of the transformed box + """ + rect = Rectangle(left, top, right, bottom) + transformed = matrix.transform(rect) + return ( + transformed.llx, + transformed.lly, + transformed.width, + transformed.height, + ) + + +@dataclass +class DebugRenderOptions: + """Options for debug visualization during rendering. + + When enabled, draws colored lines/shapes to visualize OCR structure. + """ + + render_baseline: bool = False # Magenta lines along baselines + render_line_bbox: bool = False # Blue rectangles around lines + render_word_bbox: bool = False # Green rectangles around words + + +class CoordinateTransform: + """Manages coordinate transformations for fpdf2 rendering. + + Handles conversion from OCR pixel coordinates (top-left origin) to + PDF points. fpdf2 uses top-left origin like hOCR, so no Y-flip needed. + """ + + def __init__(self, dpi: float, page_width_px: float, page_height_px: float): + """Initialize coordinate transform.""" + self.dpi = dpi + self.page_width_px = page_width_px + self.page_height_px = page_height_px + + @property + def page_width_pt(self) -> float: + """Page width in PDF points.""" + return self.page_width_px * 72.0 / self.dpi + + @property + def page_height_pt(self) -> float: + """Page height in PDF points.""" + return self.page_height_px * 72.0 / self.dpi + + def px_to_pt(self, value: float) -> float: + """Convert pixels to PDF points.""" + return value * 72.0 / self.dpi + + def bbox_to_pt(self, bbox) -> tuple[float, float, float, float]: + """Convert BoundingBox from pixels to points.""" + return ( + self.px_to_pt(bbox.left), + self.px_to_pt(bbox.top), + self.px_to_pt(bbox.right), + self.px_to_pt(bbox.bottom), + ) + + +class Fpdf2PdfRenderer: + """Renders OcrElement trees to PDF using fpdf2. + + This class provides the core rendering logic for converting OCR output + into PDF text layers using fpdf2's text drawing capabilities. + """ + + def __init__( + self, + page: OcrElement, + dpi: float, + multi_font_manager: MultiFontManager, + invisible_text: bool = True, + debug_render_options: DebugRenderOptions | None = None, + ): + """Initialize renderer. + + Args: + page: Root OcrElement (must be ocr_page) + dpi: Source image DPI + multi_font_manager: MultiFontManager instance + invisible_text: If True, render text as invisible (text mode 3) + debug_render_options: Options for debug visualization + + Raises: + ValueError: If page is not an ocr_page or lacks a bounding box + """ + if page.ocr_class != OcrClass.PAGE: + raise ValueError("Root element must be ocr_page") + if page.bbox is None: + raise ValueError("Page must have bounding box") + + self.page = page + self.dpi = dpi + self.multi_font_manager = multi_font_manager + self.invisible_text = invisible_text + self.debug_options = debug_render_options or DebugRenderOptions() + + # Setup coordinate transform + self.coord_transform = CoordinateTransform( + dpi=dpi, + page_width_px=page.bbox.width, + page_height_px=page.bbox.height, + ) + + # Registered fonts: font_path -> fpdf_family_name + self._registered_fonts: dict[str, str] = {} + + def render(self, output_path: Path) -> None: + """Render page to PDF file. + + Args: + output_path: Output PDF file path + """ + # Create PDF with custom page size + pdf = FPDF( + unit="pt", + format=( + self.coord_transform.page_width_pt, + self.coord_transform.page_height_pt, + ), + ) + pdf.set_auto_page_break(auto=False) + + # Enable text shaping for complex scripts + pdf.set_text_shaping(True) + + # Disable cell margin to ensure precise text positioning + # fpdf2's cell() adds c_margin padding by default, which shifts text + pdf.c_margin = 0 + + # Set text mode for invisible text + if self.invisible_text: + pdf.text_rendering_mode = TextMode.INVISIBLE + else: + pdf.text_rendering_mode = TextMode.FILL + + # Render content to PDF + self.render_to_pdf(pdf) + + # Write PDF + pdf.output(str(output_path)) + + def render_to_pdf(self, pdf: FPDF) -> None: + """Render page content to an existing FPDF instance. + + This method adds a page and renders all content. Used by both + single-page rendering and multi-page rendering. + + Args: + pdf: FPDF instance to render into + """ + # Add page with correct dimensions + pdf.add_page( + format=( + self.coord_transform.page_width_pt, + self.coord_transform.page_height_pt, + ) + ) + + # Render all paragraphs + for para in self.page.paragraphs: + self._render_paragraph(pdf, para) + + # If no paragraphs, render lines directly + if not self.page.paragraphs: + for line in self.page.lines: + self._render_line(pdf, line) + + def _register_font(self, pdf: FPDF, font_manager: FontManager) -> str: + """Register font with fpdf2 if not already registered. + + Args: + pdf: FPDF instance + font_manager: FontManager containing the font + + Returns: + Font family name to use with pdf.set_font() + """ + font_path_str = str(font_manager.font_path) + + if font_path_str not in self._registered_fonts: + # Use the font filename stem as the family name + family_name = font_manager.font_path.stem + pdf.add_font(family=family_name, fname=font_path_str) + self._registered_fonts[font_path_str] = family_name + + return self._registered_fonts[font_path_str] + + def _render_paragraph(self, pdf: FPDF, para: OcrElement) -> None: + """Render a paragraph element. + + Args: + pdf: FPDF instance + para: Paragraph OCR element + """ + for line in para.children: + if line.ocr_class in OcrClass.LINE_TYPES: + self._render_line(pdf, line) + + def _render_line(self, pdf: FPDF, line: OcrElement) -> None: + """Render a line element with baseline support. + + Strategy (following pikepdf reference implementation): + 1. Create a baseline_matrix that transforms from hOCR coordinates to + a coordinate system aligned with the text baseline + 2. For each word, transform its hOCR bbox using baseline_matrix.inverse() + to get its position in the baseline coordinate system + 3. Render words along the baseline with horizontal scaling + + Args: + pdf: FPDF instance + line: Line OCR element + """ + if line.bbox is None: + return + + # Validate line bbox + if line.bbox.height <= 0: + log.error( + "line box is invalid so we cannot render it: box=%s text=%s", + line.bbox, + line.text if hasattr(line, 'text') else '', + ) + return + + # Convert line bbox to PDF points + line_left_pt = self.coord_transform.px_to_pt(line.bbox.left) + line_top_pt = self.coord_transform.px_to_pt(line.bbox.top) + line_right_pt = self.coord_transform.px_to_pt(line.bbox.right) + line_bottom_pt = self.coord_transform.px_to_pt(line.bbox.bottom) + # Note: line_width_pt and line_height_pt not needed since we compute + # dimensions in the un-rotated coordinate system via matrix transform + + # Debug rendering: draw line bbox (in page coordinates) + if self.debug_options.render_line_bbox: + self._render_debug_line_bbox( + pdf, line_left_pt, line_top_pt, line_right_pt, line_bottom_pt + ) + + # Get textangle (rotation of the entire line) + textangle = line.textangle or 0.0 + + # Build line_size_aabb_matrix: transforms from page coords to un-rotated + # line coords. The hOCR bbox is the minimum axis-aligned bounding box + # enclosing the rotated text. + # Start at top-left corner of line bbox, then rotate by -textangle + line_size_aabb_matrix = ( + Matrix() + .translated(line_left_pt, line_top_pt) + .rotated(-textangle) # textangle is counter-clockwise per hOCR spec + ) + + # Get the line dimensions in the un-rotated coordinate system + # Transform line bbox corners to get the un-rotated dimensions + inv_line_matrix = line_size_aabb_matrix.inverse() + # Transform bottom-right corner to get line dimensions in rotated space + _, _, line_size_width, line_size_height = transform_box( + inv_line_matrix, line_left_pt, line_top_pt, line_right_pt, line_bottom_pt + ) + + # Get baseline information (slope and intercept) + slope = 0.0 + intercept_pt = 0.0 + if line.baseline is not None: + slope = line.baseline.slope + intercept_pt = self.coord_transform.px_to_pt(line.baseline.intercept) + if abs(slope) < 0.005: + slope = 0.0 + else: + # No baseline provided: calculate from font metrics + default_font_manager = self.multi_font_manager.fonts['NotoSans-Regular'] + ascent, descent, units_per_em = default_font_manager.get_font_metrics() + ascent_norm = ascent / units_per_em + descent_norm = descent / units_per_em + # Baseline intercept based on font metrics + intercept_pt = ( + -abs(descent_norm) + * line_size_height + / (ascent_norm + abs(descent_norm)) + ) + + slope_angle_deg = degrees(atan(slope)) if slope != 0.0 else 0.0 + + # Build baseline_matrix: transforms from page coords to baseline coords + # 1. Start with line_size_aabb_matrix (translates to line corner, rotates) + # 2. Translate down to bottom of un-rotated line (line_size_height) + # 3. Apply baseline intercept offset + # 4. Rotate by baseline slope + baseline_matrix = ( + line_size_aabb_matrix.translated( + 0, line_size_height + ) # Move to bottom of line + .translated(0, intercept_pt) # Apply baseline intercept + .rotated(slope_angle_deg) # Rotate by baseline slope + ) + + # Calculate font size: height from baseline to top of line + font_size = line_size_height + intercept_pt + if font_size < 1.0: + font_size = line_size_height * 0.8 + + # Total rotation for rendering (textangle + slope) + total_rotation_deg = -textangle + slope_angle_deg + + # Debug rendering: draw baseline + if self.debug_options.render_baseline: + # Baseline starts at origin in baseline coords, extends line width + baseline_start = transform_point(baseline_matrix, 0, 0) + baseline_end = transform_point(baseline_matrix, line_size_width, 0) + pdf.set_draw_color(255, 0, 255) # Magenta + pdf.set_line_width(0.75) + pdf.line( + baseline_start[0], baseline_start[1], baseline_end[0], baseline_end[1] + ) + + # Extract line language for font selection + line_language = line.language + + # Get inverse of baseline_matrix for transforming word bboxes + inv_baseline_matrix = baseline_matrix.inverse() + + # Collect words to render + words: list[OcrElement | None] = [ + w for w in line.children if w.ocr_class == OcrClass.WORD and w.text + ] + + # Render each word followed by space (except last) + # Use pairwise to iterate over consecutive word pairs, pairing the last + # word with a None to signal the end of the line. + for current_word, next_word in pairwise(words + [None]): + if current_word: # Don't render EOL sentinel + # Render the current word + self._render_word( + pdf, + current_word, + baseline_matrix, + inv_baseline_matrix, + font_size, + total_rotation_deg, + line_language, + ) + if next_word: # Don't render EOL sentinel + self._maybe_render_space( + pdf, + current_word, + next_word, + baseline_matrix, + inv_baseline_matrix, + font_size, + total_rotation_deg, + line_language, + line.direction, + ) + + def _render_word( + self, + pdf: FPDF, + word: OcrElement, + baseline_matrix: Matrix, + inv_baseline_matrix: Matrix, + font_size: float, + rotation_deg: float, + line_language: str | None, + ) -> None: + """Render a word using word bbox positioning. + + Position text so its visual bounding box matches the hOCR word bbox. + This provides more accurate placement than baseline-relative positioning + because we match the actual glyph bounds rather than relying on font + metrics which may not exactly match the OCR'd text appearance. + + Args: + pdf: FPDF instance + word: Word OCR element + baseline_matrix: Transform from baseline coords to page coords + inv_baseline_matrix: Transform from page coords to baseline coords + font_size: Font size in points (from line calculation) + rotation_deg: Total rotation angle for text + line_language: Language code from line for font selection + """ + if not word.text or word.bbox is None: + return + + # Select appropriate font for this word + font_manager = self.multi_font_manager.select_font_for_word( + word.text, line_language + ) + + # Register font with fpdf2 + font_family = self._register_font(pdf, font_manager) + + # Convert word bbox to PDF points + word_left_pt = self.coord_transform.px_to_pt(word.bbox.left) + word_top_pt = self.coord_transform.px_to_pt(word.bbox.top) + word_right_pt = self.coord_transform.px_to_pt(word.bbox.right) + word_bottom_pt = self.coord_transform.px_to_pt(word.bbox.bottom) + word_width_pt = word_right_pt - word_left_pt + + # Transform word bbox into baseline coordinate system to get x position + box_llx, _, _, _ = transform_box( + inv_baseline_matrix, + word_left_pt, + word_top_pt, + word_right_pt, + word_bottom_pt, + ) + + # Debug rendering: draw word bbox (in page coordinates) + if self.debug_options.render_word_bbox: + self._render_debug_word_bbox( + pdf, word_left_pt, word_top_pt, word_right_pt, word_bottom_pt + ) + + # Use line-based font_size for consistent vertical sizing + word_font_size = font_size + + # Set font + pdf.set_font(font_family, size=word_font_size) + + # Calculate natural text width at this font size + natural_width = pdf.get_string_width(word.text) + + # Calculate horizontal scale to fit word bbox width + if natural_width > 0 and word_width_pt > 0: + scale_x = (word_width_pt / natural_width) * 100 + else: + scale_x = 100 + + # Apply horizontal stretching + pdf.set_stretching(scale_x) + + # Get left side bearing of first character to compensate for glyph offset + lsb_pt = font_manager.get_left_side_bearing(word.text[0], word_font_size) + + # Transform the baseline-relative x position back to page coordinates + # The word sits at (box_llx, 0) in baseline coords (on the baseline) + page_x, page_y = transform_point(baseline_matrix, box_llx, 0) + + # Adjust x position to account for lsb (scaled by horizontal stretch) + adjusted_x = page_x - lsb_pt * (scale_x / 100) + + # Calculate y position based on baseline + # In fpdf2, set_xy(x, y) positions text such that the baseline is at: + # baseline_y = set_y + font_size * (ascent / (ascent + |descent|)) + # We want baseline at page_y, so: + # page_y = set_y + font_size * (ascent / (ascent + |descent|)) + # set_y = page_y - font_size * (ascent / (ascent + |descent|)) + ascent, descent, _ = font_manager.get_font_metrics() + total_height = ascent + abs(descent) + baseline_offset_ratio = ascent / total_height + adjusted_y = page_y - word_font_size * baseline_offset_ratio + + # Position and draw text with rotation + if abs(rotation_deg) > 0.1: + with pdf.rotation(-rotation_deg, x=page_x, y=page_y): + pdf.set_xy(adjusted_x, adjusted_y) + pdf.cell(text=word.text) + else: + pdf.set_xy(adjusted_x, adjusted_y) + pdf.cell(text=word.text) + + # Reset stretching + pdf.set_stretching(100) + + def _is_cjk_only(self, text: str) -> bool: + """Check if text contains only CJK characters. + + CJK scripts don't use spaces between words, so we should not insert + spaces between adjacent CJK words. + + Args: + text: Text to check + + Returns: + True if text contains only CJK characters + """ + for char in text: + cp = ord(char) + # Check if character is in CJK ranges + if not ( + 0x4E00 <= cp <= 0x9FFF # CJK Unified Ideographs + or 0x3400 <= cp <= 0x4DBF # CJK Extension A + or 0x20000 <= cp <= 0x2A6DF # CJK Extension B + or 0x2A700 <= cp <= 0x2B73F # CJK Extension C + or 0x2B740 <= cp <= 0x2B81F # CJK Extension D + or 0x2B820 <= cp <= 0x2CEAF # CJK Extension E + or 0x2CEB0 <= cp <= 0x2EBEF # CJK Extension F + or 0x30000 <= cp <= 0x3134F # CJK Extension G + or 0x3040 <= cp <= 0x309F # Hiragana + or 0x30A0 <= cp <= 0x30FF # Katakana + or 0x31F0 <= cp <= 0x31FF # Katakana Phonetic Extensions + or 0xAC00 <= cp <= 0xD7AF # Hangul Syllables + or 0x1100 <= cp <= 0x11FF # Hangul Jamo + or 0x3130 <= cp <= 0x318F # Hangul Compatibility Jamo + or 0xA960 <= cp <= 0xA97F # Hangul Jamo Extended-A + or 0xD7B0 <= cp <= 0xD7FF # Hangul Jamo Extended-B + or 0x3000 <= cp <= 0x303F # CJK Symbols and Punctuation + or 0xFF00 <= cp <= 0xFFEF # Halfwidth and Fullwidth Forms + ): + return False + return True + + def _maybe_render_space( + self, + pdf: FPDF, + current_word: OcrElement, + next_word: OcrElement, + baseline_matrix: Matrix, + inv_baseline_matrix: Matrix, + font_size: float, + rotation_deg: float, + line_language: str | None, + direction: str | None, + ) -> None: + """Render a space character between two words if a gap exists. + + This ensures that PDF readers like pdfminer.six can properly segment + words during text extraction. Some PDF readers rely on explicit space + characters rather than inferring word boundaries from positioning. + + Args: + pdf: FPDF instance + current_word: The word that was just rendered + next_word: The next word to be rendered + baseline_matrix: Transform from baseline coords to page coords + inv_baseline_matrix: Transform from page coords to baseline coords + font_size: Font size in points + rotation_deg: Total rotation angle for text + line_language: Language code from line for font selection + direction: Text direction ("ltr" or "rtl") + """ + if current_word.bbox is None or next_word.bbox is None: + return + + # Skip if both words are CJK-only (no spaces in CJK text) + if self._is_cjk_only(current_word.text) and self._is_cjk_only(next_word.text): + return + + # Calculate gap between words + if direction == "rtl": + gap_left = next_word.bbox.right + gap_right = current_word.bbox.left + else: + gap_left = current_word.bbox.right + gap_right = next_word.bbox.left + + gap_width_px = gap_right - gap_left + + # Use word height as proxy for line height + line_height_px = current_word.bbox.height + + # Skip if gap is too small (noise) or words are overlapping + if gap_width_px <= line_height_px * 0.05: + return + + # Render space in the gap + self._render_space( + pdf, + gap_left, + gap_right, + current_word.bbox.top, + current_word.bbox.bottom, + baseline_matrix, + inv_baseline_matrix, + font_size, + rotation_deg, + line_language, + ) + + def _render_space( + self, + pdf: FPDF, + gap_left_px: float, + gap_right_px: float, + gap_top_px: float, + gap_bottom_px: float, + baseline_matrix: Matrix, + inv_baseline_matrix: Matrix, + font_size: float, + rotation_deg: float, + line_language: str | None, + ) -> None: + """Render a space character in a gap between words. + + Uses the same baseline transformation logic as word rendering to ensure + proper alignment on rotated or sloped baselines. + + Args: + pdf: FPDF instance + gap_left_px: Left edge of gap in pixels + gap_right_px: Right edge of gap in pixels + gap_top_px: Top edge of gap in pixels + gap_bottom_px: Bottom edge of gap in pixels + baseline_matrix: Transform from baseline coords to page coords + inv_baseline_matrix: Transform from page coords to baseline coords + font_size: Font size in points + rotation_deg: Total rotation angle for text + line_language: Language code from line for font selection + """ + # Convert gap to PDF points + gap_left_pt = self.coord_transform.px_to_pt(gap_left_px) + gap_top_pt = self.coord_transform.px_to_pt(gap_top_px) + gap_right_pt = self.coord_transform.px_to_pt(gap_right_px) + gap_bottom_pt = self.coord_transform.px_to_pt(gap_bottom_px) + gap_width_pt = gap_right_pt - gap_left_pt + + # Transform gap bbox into baseline coordinate system to get x position + box_llx, _, _, _ = transform_box( + inv_baseline_matrix, + gap_left_pt, + gap_top_pt, + gap_right_pt, + gap_bottom_pt, + ) + + # Select font (use default font for space) + font_manager = self.multi_font_manager.select_font_for_word(" ", line_language) + font_family = self._register_font(pdf, font_manager) + + # Set font + pdf.set_font(font_family, size=font_size) + + # Calculate natural space width and scaling + natural_width = pdf.get_string_width(" ") + if natural_width > 0 and gap_width_pt > 0: + scale_x = (gap_width_pt / natural_width) * 100 + else: + scale_x = 100 + + # Apply horizontal stretching + pdf.set_stretching(scale_x) + + # Transform the baseline-relative x position back to page coordinates + page_x, page_y = transform_point(baseline_matrix, box_llx, 0) + + # Calculate y position based on baseline (same as _render_word) + ascent, descent, _ = font_manager.get_font_metrics() + total_height = ascent + abs(descent) + baseline_offset_ratio = ascent / total_height + adjusted_y = page_y - font_size * baseline_offset_ratio + + # Position and draw space with rotation + if abs(rotation_deg) > 0.1: + with pdf.rotation(-rotation_deg, x=page_x, y=page_y): + pdf.set_xy(page_x, adjusted_y) + pdf.cell(text=" ") + else: + pdf.set_xy(page_x, adjusted_y) + pdf.cell(text=" ") + + # Reset stretching + pdf.set_stretching(100) + + def _render_debug_line_bbox( + self, + pdf: FPDF, + left: float, + top: float, + right: float, + bottom: float, + ) -> None: + """Draw a blue box around the line bbox.""" + pdf.set_draw_color(0, 0, 255) # Blue + pdf.set_line_width(0.5) + pdf.rect(left, top, right - left, bottom - top) + + def _render_debug_baseline( + self, + pdf: FPDF, + x: float, + y: float, + width: float, + rotation_deg: float, + ) -> None: + """Draw a magenta line along the baseline.""" + pdf.set_draw_color(255, 0, 255) # Magenta + pdf.set_line_width(0.75) + + if abs(rotation_deg) > 0.1: + with pdf.rotation(rotation_deg, x=x, y=y): + pdf.line(x, y, x + width, y) + else: + pdf.line(x, y, x + width, y) + + def _render_debug_word_bbox( + self, + pdf: FPDF, + left: float, + top: float, + right: float, + bottom: float, + ) -> None: + """Draw a green box around the word bbox.""" + pdf.set_draw_color(0, 255, 0) # Green + pdf.set_line_width(0.3) + pdf.rect(left, top, right - left, bottom - top) + + +class Fpdf2MultiPageRenderer: + """Renders multiple OcrElement pages into a single PDF. + + This class handles multi-page documents by delegating to Fpdf2PdfRenderer + for each page while sharing a single FPDF instance and font registration. + """ + + def __init__( + self, + pages_data: list[tuple[int, OcrElement, float]], + multi_font_manager: MultiFontManager, + invisible_text: bool = True, + debug_render_options: DebugRenderOptions | None = None, + ): + """Initialize multi-page renderer. + + Args: + pages_data: List of (pageno, ocr_tree, dpi) tuples + multi_font_manager: Shared multi-font manager for all pages + invisible_text: Whether to render invisible text + debug_render_options: Options for debug visualization + """ + self.pages_data = pages_data + self.multi_font_manager = multi_font_manager + self.invisible_text = invisible_text + self.debug_options = debug_render_options or DebugRenderOptions() + + def render(self, output_path: Path) -> None: + """Render all pages to a single multi-page PDF. + + Args: + output_path: Output PDF file path + """ + if not self.pages_data: + raise ValueError("No pages to render") + + # Create PDF (page size will be set per-page) + pdf = FPDF(unit="pt") + pdf.set_auto_page_break(auto=False) + pdf.set_text_shaping(True) + + # Disable cell margin to ensure precise text positioning + # fpdf2's cell() adds c_margin padding by default, which shifts text + pdf.c_margin = 0 + + # Set text mode for invisible text + if self.invisible_text: + pdf.text_rendering_mode = TextMode.INVISIBLE + else: + pdf.text_rendering_mode = TextMode.FILL + + # Shared font registration across all pages + shared_registered_fonts: dict[str, str] = {} + + # Render each page using Fpdf2PdfRenderer + for _pageno, page, dpi in self.pages_data: + if page.bbox is None: + continue + + # Create a renderer for this page + page_renderer = Fpdf2PdfRenderer( + page=page, + dpi=dpi, + multi_font_manager=self.multi_font_manager, + invisible_text=self.invisible_text, + debug_render_options=self.debug_options, + ) + + # Share font registration to avoid re-registering fonts + page_renderer._registered_fonts = shared_registered_fonts + + # Render page content to the shared PDF + page_renderer.render_to_pdf(pdf) + + # Write PDF + pdf.output(str(output_path)) diff --git a/src/ocrmypdf/helpers.py b/src/ocrmypdf/helpers.py index 354faffc..9884e245 100644 --- a/src/ocrmypdf/helpers.py +++ b/src/ocrmypdf/helpers.py @@ -25,7 +25,6 @@ from typing import ( import img2pdf import pikepdf -from deprecation import deprecated log = logging.getLogger(__name__) @@ -136,11 +135,6 @@ class Resolution(Generic[T]): return self._isclose(self.x, other.x) and self._isclose(self.y, other.y) -@deprecated(deprecated_in='15.4.0') -class NeverRaise(Exception): - """An exception that is never raised.""" - - def safe_symlink(input_file: os.PathLike, soft_link_name: os.PathLike) -> None: """Create a symbolic link at ``soft_link_name``, which references ``input_file``. @@ -200,7 +194,7 @@ def is_iterable_notstr(thing: Any) -> bool: def monotonic(seq: Sequence) -> bool: """Does this sequence increase monotonically?""" - return all(b > a for a, b in zip(seq, seq[1:])) + return all(b > a for a, b in zip(seq, seq[1:], strict=False)) def page_number(input_file: os.PathLike) -> int: @@ -298,9 +292,7 @@ def check_pdf(input_file: Path) -> bool: if linearize_msgs: log.warning(linearize_msgs) - if success and not linearize_msgs: - return True - return False + return bool(success and not linearize_msgs) def clamp(n: T, smallest: T, largest: T) -> T: diff --git a/src/ocrmypdf/hocrtransform/__init__.py b/src/ocrmypdf/hocrtransform/__init__.py index 7dce171a..b0d294eb 100755 --- a/src/ocrmypdf/hocrtransform/__init__.py +++ b/src/ocrmypdf/hocrtransform/__init__.py @@ -1,18 +1,48 @@ -# SPDX-FileCopyrightText: 2023 James R. Barlow +# SPDX-FileCopyrightText: 2023-2025 James R. Barlow # SPDX-License-Identifier: MIT -"""Transform .hocr and page image to text PDF.""" +"""Transform OCR output to text-only PDFs. + +This package provides tools for: +1. Parsing OCR output (hOCR format) into generic OcrElement structures +2. Rendering OcrElement structures to searchable PDF text layers + +The architecture separates parsing from rendering, allowing: +- Support for multiple OCR input formats (hOCR, ALTO, custom engines) +- Independent improvements to text rendering +- Reuse of the OcrElement data model for other purposes + +Main components: +- OcrElement: Generic dataclass representing OCR output structure +- HocrParser: Parses hOCR files into OcrElement trees +- Fpdf2PdfRenderer: Renders OcrElement trees to PDF text layers (via fpdf2) + +For PDF rendering, use the fpdf2_renderer module: + from ocrmypdf.fpdf_renderer import Fpdf2PdfRenderer, DebugRenderOptions +""" from __future__ import annotations -from ocrmypdf.hocrtransform._hocr import ( - DebugRenderOptions, - HocrTransform, - HocrTransformError, +from ocrmypdf.hocrtransform.hocr_parser import ( + HocrParseError, + HocrParser, +) +from ocrmypdf.models.ocr_element import ( + Baseline, + BoundingBox, + FontInfo, + OcrClass, + OcrElement, ) __all__ = ( - 'HocrTransform', - 'HocrTransformError', - 'DebugRenderOptions', + # hOCR parsing + 'HocrParser', + 'HocrParseError', + # OCR element data model + 'OcrElement', + 'OcrClass', + 'BoundingBox', + 'Baseline', + 'FontInfo', ) diff --git a/src/ocrmypdf/hocrtransform/__main__.py b/src/ocrmypdf/hocrtransform/__main__.py index 3877916a..76375293 100644 --- a/src/ocrmypdf/hocrtransform/__main__.py +++ b/src/ocrmypdf/hocrtransform/__main__.py @@ -1,11 +1,15 @@ -# SPDX-FileCopyrightText: 2023 James R. Barlow +# SPDX-FileCopyrightText: 2023-2025 James R. Barlow # SPDX-License-Identifier: MIT -"""Simple CLI for testing HOCR.""" +"""Simple CLI for testing HOCR to PDF conversion using fpdf2 renderer.""" +from __future__ import annotations import argparse +from pathlib import Path -from ocrmypdf.hocrtransform import HocrTransform +from ocrmypdf.font import MultiFontManager +from ocrmypdf.fpdf_renderer import DebugRenderOptions, Fpdf2PdfRenderer +from ocrmypdf.hocrtransform.hocr_parser import HocrParser if __name__ == "__main__": parser = argparse.ArgumentParser(description='Convert hocr file to PDF') @@ -14,7 +18,7 @@ if __name__ == "__main__": '--boundingboxes', action="store_true", default=False, - help='Show bounding boxes borders', + help='Show bounding boxes borders (debug mode)', ) parser.add_argument( '-r', @@ -27,14 +31,44 @@ if __name__ == "__main__": '-i', '--image', default=None, - help='Path to the image to be placed above the text', + help='Path to the image to be placed above the text (not yet supported)', ) parser.add_argument('hocrfile', help='Path to the hocr file to be parsed') parser.add_argument('outputfile', help='Path to the PDF file to be generated') args = parser.parse_args() - hocr = HocrTransform(hocr_filename=args.hocrfile, dpi=args.resolution) - hocr.to_pdf( - out_filename=args.outputfile, - image_filename=args.image, + # Parse hOCR file + hocr_parser = HocrParser(args.hocrfile) + ocr_page = hocr_parser.parse() + + # Use DPI from hOCR if available, otherwise use command-line resolution + dpi = ocr_page.dpi or args.resolution + + # Setup debug render options if requested + debug_options = None + if args.boundingboxes: + debug_options = DebugRenderOptions( + render_line_bbox=True, + render_word_bbox=True, + render_baseline=True, + ) + + # Create multi-font manager with default font directory + font_dir = Path(__file__).parent.parent / "data" + multi_font_manager = MultiFontManager(font_dir) + + # Render to PDF using fpdf2 + renderer = Fpdf2PdfRenderer( + page=ocr_page, + dpi=dpi, + multi_font_manager=multi_font_manager, + invisible_text=not args.boundingboxes, # Visible text in debug mode + debug_render_options=debug_options, ) + renderer.render(Path(args.outputfile)) + + if args.image: + print( + f"Warning: Image overlay (--image {args.image}) is not yet supported " + "with the fpdf2 renderer." + ) diff --git a/src/ocrmypdf/hocrtransform/_font.py b/src/ocrmypdf/hocrtransform/_font.py deleted file mode 100644 index 9324b71e..00000000 --- a/src/ocrmypdf/hocrtransform/_font.py +++ /dev/null @@ -1,141 +0,0 @@ -# SPDX-FileCopyrightText: 2023 James R. Barlow -# SPDX-License-Identifier: MPL-2.0 - -from __future__ import annotations - -import logging -import unicodedata -import zlib -from importlib.resources import files as package_files - -from pikepdf import ( - Dictionary, - Name, - Pdf, -) -from pikepdf.canvas import Font - -log = logging.getLogger(__name__) - - -class EncodableFont(Font): - def text_encode(self, text: str) -> bytes: - raise NotImplementedError() - - -class GlyphlessFont(EncodableFont): - CID_TO_GID_DATA = zlib.compress(b"\x00\x01" * 65536) - GLYPHLESS_FONT_NAME = 'pdf.ttf' - GLYPHLESS_FONT = (package_files('ocrmypdf.data') / GLYPHLESS_FONT_NAME).read_bytes() - CHAR_ASPECT = 2 - - def __init__(self): - pass - - def text_width(self, text: str, fontsize: float) -> float: - """Estimate the width of a text string when rendered with the given font.""" - # NFKC: split ligatures, combine diacritics - return len(unicodedata.normalize("NFKC", text)) * (fontsize / self.CHAR_ASPECT) - - def text_encode(self, text: str) -> bytes: - return text.encode('utf-16be') - - def register(self, pdf: Pdf): - """Register the glyphless font. - - Create several data structures in the Pdf to describe the font. While it create - the data, a reference should be set in at least one page's /Resources dictionary - to retain the font in the output PDF and ensure it is usable on that page. - """ - PLACEHOLDER = Name.Placeholder - - basefont = pdf.make_indirect( - Dictionary( - BaseFont=Name.GlyphLessFont, - DescendantFonts=[PLACEHOLDER], - Encoding=Name("/Identity-H"), - Subtype=Name.Type0, - ToUnicode=PLACEHOLDER, - Type=Name.Font, - ) - ) - cid_font_type2 = pdf.make_indirect( - Dictionary( - BaseFont=Name.GlyphLessFont, - CIDToGIDMap=PLACEHOLDER, - CIDSystemInfo=Dictionary( - Ordering="Identity", - Registry="Adobe", - Supplement=0, - ), - FontDescriptor=PLACEHOLDER, - Subtype=Name.CIDFontType2, - Type=Name.Font, - DW=1000 // self.CHAR_ASPECT, - ) - ) - basefont.DescendantFonts = [cid_font_type2] - cid_font_type2.CIDToGIDMap = pdf.make_stream( - self.CID_TO_GID_DATA, Filter=Name.FlateDecode - ) - basefont.ToUnicode = pdf.make_stream( - b"/CIDInit /ProcSet findresource begin\n" - b"12 dict begin\n" - b"begincmap\n" - b"/CIDSystemInfo\n" - b"<<\n" - b" /Registry (Adobe)\n" - b" /Ordering (UCS)\n" - b" /Supplement 0\n" - b">> def\n" - b"/CMapName /Adobe-Identify-UCS def\n" - b"/CMapType 2 def\n" - b"1 begincodespacerange\n" - b"<0000> \n" - b"endcodespacerange\n" - b"1 beginbfrange\n" - b"<0000> <0000>\n" - b"endbfrange\n" - b"endcmap\n" - b"CMapName currentdict /CMap defineresource pop\n" - b"end\n" - b"end\n" - ) - font_descriptor = pdf.make_indirect( - Dictionary( - Ascent=1000, - CapHeight=1000, - Descent=-1, - Flags=5, # Fixed pitch and symbolic - FontBBox=[0, 0, 1000 // self.CHAR_ASPECT, 1000], - FontFile2=PLACEHOLDER, - FontName=Name.GlyphLessFont, - ItalicAngle=0, - StemV=80, - Type=Name.FontDescriptor, - ) - ) - font_descriptor.FontFile2 = pdf.make_stream(self.GLYPHLESS_FONT) - cid_font_type2.FontDescriptor = font_descriptor - return basefont - - -class Courier(EncodableFont): - """Courier font.""" - - def text_width(self, text: str, fontsize: float) -> float: - """Estimate the width of a text string when rendered with the given font.""" - return len(text) * fontsize - - def text_encode(self, text: str) -> bytes: - return text.encode('pdfdoc', errors='ignore') - - def register(self, pdf: Pdf) -> Dictionary: - """Register the font.""" - return pdf.make_indirect( - Dictionary( - BaseFont=Name.Courier, - Type=Name.Font, - Subtype=Name.Type1, - ) - ) diff --git a/src/ocrmypdf/hocrtransform/_hocr.py b/src/ocrmypdf/hocrtransform/_hocr.py deleted file mode 100644 index 9245a598..00000000 --- a/src/ocrmypdf/hocrtransform/_hocr.py +++ /dev/null @@ -1,540 +0,0 @@ -# SPDX-FileCopyrightText: 2010 Jonathan Brinley -# SPDX-FileCopyrightText: 2013-2014 Julien Pfefferkorn -# SPDX-FileCopyrightText: 2023 James R. Barlow -# SPDX-FileCopyrightText: 2025 Odin Dahlström -# SPDX-License-Identifier: MIT - -"""hOCR transform implementation.""" - -from __future__ import annotations - -import logging -import os -import re -import unicodedata -from dataclasses import dataclass -from itertools import pairwise -from math import atan, pi -from pathlib import Path -from xml.etree import ElementTree - -from pikepdf import Matrix, Name, Rectangle -from pikepdf.canvas import ( - BLACK, - BLUE, - CYAN, - DARKGREEN, - GREEN, - MAGENTA, - RED, - Canvas, - Text, - TextDirection, -) - -from ocrmypdf.hocrtransform._font import EncodableFont as Font -from ocrmypdf.hocrtransform._font import GlyphlessFont - -log = logging.getLogger(__name__) - -INCH = 72.0 - -Element = ElementTree.Element - - -@dataclass -class DebugRenderOptions: - """A class for managing rendering options.""" - - render_paragraph_bbox: bool = False - render_baseline: bool = False - render_triangle: bool = False - render_line_bbox: bool = False - render_word_bbox: bool = False - render_space_bbox: bool = False - - -class HocrTransformError(Exception): - """Error while applying hOCR transform.""" - - -class HocrTransform: - """A class for converting documents from the hOCR format. - - For details of the hOCR format, see: - http://kba.github.io/hocr-spec/1.2/. - """ - - box_pattern = re.compile( - r''' - bbox \s+ - (\d+) \s+ # left: uint - (\d+) \s+ # top: uint - (\d+) \s+ # right: uint - (\d+) # bottom: uint - ''', - re.VERBOSE, - ) - baseline_pattern = re.compile( - r''' - baseline \s+ - ([\-\+]?\d*\.?\d*) \s+ # +/- decimal float - ([\-\+]?\d+) # +/- int - ''', - re.VERBOSE, - ) - textangle_pattern = re.compile( - r''' - textangle \s+ - ([\-\+]?\d*\.?\d*) # +/- decimal float - ''', - re.VERBOSE, - ) - - def __init__( - self, - *, - hocr_filename: str | Path, - dpi: float, - debug: bool = False, - fontname: Name = Name("/f-0-0"), - font: Font = GlyphlessFont(), - debug_render_options: DebugRenderOptions | None = None, - ): - """Initialize the HocrTransform object.""" - if debug: - log.warning("Use debug_render_options instead", DeprecationWarning) - self.render_options = DebugRenderOptions( - render_baseline=debug, - render_triangle=debug, - render_line_bbox=False, - render_word_bbox=debug, - render_paragraph_bbox=False, - render_space_bbox=False, - ) - else: - self.render_options = debug_render_options or DebugRenderOptions() - self.dpi = dpi - self.hocr = ElementTree.parse(os.fspath(hocr_filename)) - self._fontname = fontname - self._font = font - - # if the hOCR file has a namespace, ElementTree requires its use to - # find elements - matches = re.match(r'({.*})html', self.hocr.getroot().tag) - self.xmlns = '' - if matches: - self.xmlns = matches.group(1) - - for div in self.hocr.findall(self._child_xpath('div', 'ocr_page')): - coords = self.element_coordinates(div) - if not coords: - raise HocrTransformError("hocr file is missing page dimensions") - self.width = (coords.urx - coords.llx) / (self.dpi / INCH) - self.height = (coords.ury - coords.lly) / (self.dpi / INCH) - # Stop after first div that has page coordinates - break - - def _get_element_text(self, element: Element) -> str: - """Return the textual content of the element and its children.""" - text = element.text if element.text is not None else '' - for child in element: - text += self._get_element_text(child) - text += element.tail if element.tail is not None else '' - return text - - @classmethod - def element_coordinates(cls, element: Element) -> Rectangle | None: - """Get coordinates of the bounding box around an element.""" - matches = cls.box_pattern.search(element.attrib.get('title', '')) - if not matches: - return None - return Rectangle( - float(matches.group(1)), # llx = left - float(matches.group(2)), # lly = top - float(matches.group(3)), # urx = right - float(matches.group(4)), # ury = bottom - ) - - @classmethod - def baseline(cls, element: Element) -> tuple[float, float]: - """Get baseline's slope and intercept.""" - matches = cls.baseline_pattern.search(element.attrib.get('title', '')) - if not matches: - return (0.0, 0.0) - return float(matches.group(1)), int(matches.group(2)) - - @classmethod - def textangle(cls, element: Element) -> float: - """Get text angle of an element.""" - matches = cls.textangle_pattern.search(element.attrib.get('title', '')) - if not matches: - return 0.0 - return float(matches.group(1)) - - def _child_xpath(self, html_tag: str, html_class: str | None = None) -> str: - xpath = f".//{self.xmlns}{html_tag}" - if html_class: - xpath += f"[@class='{html_class}']" - return xpath - - @classmethod - def normalize_text(cls, s: str) -> str: - """Normalize the given text using the NFKC normalization form.""" - return unicodedata.normalize("NFKC", s) - - def to_pdf( - self, - *, - out_filename: Path, - image_filename: Path | None = None, - invisible_text: bool = True, - ) -> None: - """Creates a PDF file with an image superimposed on top of the text. - - Text is positioned according to the bounding box of the lines in - the hOCR file. - The image need not be identical to the image used to create the hOCR - file. - It can have a lower resolution, different color mode, etc. - - Arguments: - out_filename: Path of PDF to write. - image_filename: Image to use for this file. If omitted, the OCR text - is shown. - invisible_text: If True, text is rendered invisible so that is - selectable but never drawn. If False, text is visible and may - be seen if the image is skipped or deleted in Acrobat. - """ - # create the PDF file - # page size in points (1/72 in.) - canvas = Canvas(page_size=(self.width, self.height)) - canvas.add_font(self._fontname, self._font) - page_matrix = ( - Matrix() - .translated(0, self.height) - .scaled(1, -1) - .scaled(INCH / self.dpi, INCH / self.dpi) - ) - log.debug(page_matrix) - with canvas.do.save_state(cm=page_matrix): - self._debug_draw_paragraph_boxes(canvas) - found_lines = False - for par in self.hocr.iterfind(self._child_xpath('p', 'ocr_par')): - for line in ( - element - for element in par.iterfind(self._child_xpath('span')) - if 'class' in element.attrib - and element.attrib['class'] - in {'ocr_header', 'ocr_line', 'ocr_textfloat', 'ocr_caption'} - ): - found_lines = True - direction = self._get_text_direction(par) - inject_word_breaks = self._get_inject_word_breaks(par) - self._do_line( - canvas, - line, - "ocrx_word", - invisible_text, - direction, - inject_word_breaks, - ) - - if not found_lines: - # Tesseract did not report any lines (just words) - root = self.hocr.find(self._child_xpath('div', 'ocr_page')) - direction = self._get_text_direction(root) - self._do_line( - canvas, - root, - "ocrx_word", - invisible_text, - direction, - True, - ) - # put the image on the page, scaled to fill the page - if image_filename is not None: - canvas.do.draw_image( - image_filename, 0, 0, width=self.width, height=self.height - ) - - # finish up the page and save it - canvas.to_pdf().save(out_filename) - - def _get_text_direction(self, par): - """Get the text direction of the paragraph. - - Arabic, Hebrew, Persian, are right-to-left languages. - When the paragraph element is None, defaults to left-to-right. - """ - if par is None: - return TextDirection.LTR - - return ( - TextDirection.RTL - if par.attrib.get('dir', 'ltr') == 'rtl' - else TextDirection.LTR - ) - - def _get_inject_word_breaks(self, par): - """Determine whether word breaks should be injected. - - In Chinese, Japanese, and Korean, word breaks are not injected, because - words are usually one or two characters and separators are usually explicit. - In all other languages, we inject word breaks to help word segmentation. - """ - lang = par.attrib.get('lang', '') - log.debug(lang) - if lang in {'chi_sim', 'chi_tra', 'jpn', 'kor'}: - return False - return True - - @classmethod - def polyval(cls, poly, x): # pragma: no cover - """Calculate the value of a polynomial at a point.""" - return x * poly[0] + poly[1] - - def _do_line( - self, - canvas: Canvas, - line: Element | None, - elemclass: str, - invisible_text: bool, - text_direction: TextDirection, - inject_word_breaks: bool, - ): - """Render the text for a given line. - - The canvas's coordinate system must be configured so that hOCR pixel - coordinates are mapped to PDF coordinates. - """ - if line is None: - return - # line_min_aabb (which is created from the "bbox" hOCR property) is so named - # because a Rectangle instance is always an AABB (it has no orientation). - # However, this means that for non-zero values of the "textangle" hOCR - # property, line_min_aabb is not the true bounding box of the hOCR line, - # but rather the minimum AABB that encloses the bounding box of the line. - # The true bounding box of the line must be seen as an OBB, due to the - # existance of the "textangle" hOCR property. - line_min_aabb = self.element_coordinates(line) - if not line_min_aabb: - return - if line_min_aabb.ury <= line_min_aabb.lly: - log.error( - "line box is invalid so we cannot render it: box=%s text=%s", - line_min_aabb, - self._get_element_text(line), - ) - return - self._debug_draw_line_bbox(canvas, line_min_aabb) - - # Even though line_min_aabb is not the true bounding box of the line, - # it is still possible to derive an AABB (Rectangle) from it that is - # the same size as the true bounding box of the line, - # if we use a coordinate system that is axis-aligned with respect to - # the rotation of the OBB (textangle). - # line_size_aabb_matrix is a transform matrix for such a coordinate - # system, and line_size_aabb is thus an AABB with the same - # size as the true bounding box of the line. - top_left_corner = (line_min_aabb.llx, line_min_aabb.lly) - line_size_aabb_matrix = ( - Matrix() - .translated(*top_left_corner) - # Note: negative sign (textangle is counter-clockwise, see hOCR spec) - .rotated(-self.textangle(line)) - ) - line_size_aabb = line_size_aabb_matrix.inverse().transform(line_min_aabb) - - slope, intercept = self.baseline(line) - if abs(slope) < 0.005: - slope = 0.0 - slope_angle = atan(slope) - - # Final PDF-perspective (bottom-left corner) transform matrix for the - # text baseline, which has an intercept and slope relative to the OBB. - # See "bbox", "textangle" and "baseline" in the hOCR spec for more details. - baseline_matrix = ( - line_size_aabb_matrix - # Translate from hOCR perspective (top-left corner) to PDF perspective - # (bottom-left corner). - # Note: it would be incorrect to use line_min_aabb.height here because - # it is not the true height of the OBB of the line, if textangle != 0. - .translated(0, line_size_aabb.height) - .translated(0, intercept) - .rotated(slope_angle / pi * 180) - ) - - with canvas.do.save_state(cm=baseline_matrix): - text = Text(direction=text_direction) - fontsize = line_size_aabb.height + intercept - text.font(self._fontname, fontsize) - text.render_mode(3 if invisible_text else 0) - - self._debug_draw_baseline( - canvas, baseline_matrix.inverse().transform(line_min_aabb), 0 - ) - - canvas.do.fill_color(BLACK) # text in black - elements = line.findall(self._child_xpath('span', elemclass)) - for elem, next_elem in pairwise(elements + [None]): - self._do_line_word( - canvas, - baseline_matrix, - text, - fontsize, - elem, - next_elem, - text_direction, - inject_word_breaks, - ) - canvas.do.draw_text(text) - - def _do_line_word( - self, - canvas: Canvas, - line_matrix: Matrix, - text: Text, - fontsize: float, - elem: Element | None, - next_elem: Element | None, - text_direction: TextDirection, - inject_word_breaks: bool, - ): - """Render the text for a single word.""" - if elem is None: - return - elemtxt = self.normalize_text(self._get_element_text(elem).strip()) - if elemtxt == '': - return - - hocr_box = self.element_coordinates(elem) - if hocr_box is None: - return - box = line_matrix.inverse().transform(hocr_box) - font_width = self._font.text_width(elemtxt, fontsize) - - # Debug sketches - self._debug_draw_word_triangle(canvas, box) - self._debug_draw_word_bbox(canvas, box) - - # If this word is 0 units wide, our best bet seems to be to suppress this text - if text_direction == TextDirection.RTL: - log.info("RTL: %s", elemtxt) - if font_width > 0: - if text_direction == TextDirection.LTR: - text.text_transform(Matrix(1, 0, 0, -1, box.llx, 0)) - elif text_direction == TextDirection.RTL: - text.text_transform(Matrix(-1, 0, 0, -1, box.llx + box.width, 0)) - text.horiz_scale(100 * box.width / font_width) - text.show(self._font.text_encode(elemtxt)) - - # Get coordinates of the next word (if there is one) - hocr_next_box = ( - self.element_coordinates(next_elem) if next_elem is not None else None - ) - if hocr_next_box is None: - return - # Render a space between this word and the next word. The explicit space helps - # PDF viewers identify the word break, and horizontally scaling it to - # occupy the space the between the words helps the PDF viewer - # avoid combiningthewordstogether. - if not inject_word_breaks: - return - next_box = line_matrix.inverse().transform(hocr_next_box) - if text_direction == TextDirection.LTR: - space_box = Rectangle(box.urx, box.lly, next_box.llx, next_box.ury) - elif text_direction == TextDirection.RTL: - space_box = Rectangle(next_box.urx, box.lly, box.llx, next_box.ury) - self._debug_draw_space_bbox(canvas, space_box) - space_width = self._font.text_width(' ', fontsize) - if space_width > 0 and space_box.width > 0: - if text_direction == TextDirection.LTR: - text.text_transform(Matrix(1, 0, 0, -1, space_box.llx, 0)) - elif text_direction == TextDirection.RTL: - text.text_transform( - Matrix(-1, 0, 0, -1, space_box.llx + space_box.width, 0) - ) - text.horiz_scale(100 * space_box.width / space_width) - text.show(self._font.text_encode(' ')) - - def _debug_draw_paragraph_boxes(self, canvas: Canvas, color=CYAN): - """Draw boxes around paragraphs in the document.""" - if not self.render_options.render_paragraph_bbox: # pragma: no cover - return - with canvas.do.save_state(): - # draw box around paragraph - canvas.do.stroke_color(color).line_width(0.1) - for elem in self.hocr.iterfind(self._child_xpath('p', 'ocr_par')): - elemtxt = self._get_element_text(elem).strip() - if len(elemtxt) == 0: - continue - ocr_par = self.element_coordinates(elem) - if ocr_par is None: - continue - canvas.do.rect( - ocr_par.llx, ocr_par.lly, ocr_par.width, ocr_par.height, fill=False - ) - - def _debug_draw_line_bbox(self, canvas: Canvas, line_box: Rectangle, color=BLUE): - """Render the bounding box of a text line.""" - if not self.render_options.render_line_bbox: # pragma: no cover - return - with canvas.do.save_state(): - canvas.do.stroke_color(color).line_width(0.15).rect( - line_box.llx, line_box.lly, line_box.width, line_box.height, fill=False - ) - - def _debug_draw_word_triangle( - self, canvas: Canvas, box: Rectangle, color=RED, line_width=0.1 - ): - """Render a triangle that conveys word height and drawing direction.""" - if not self.render_options.render_triangle: # pragma: no cover - return - with canvas.do.save_state(): - canvas.do.stroke_color(color).line_width(line_width).line( - box.llx, box.lly, box.urx, box.lly - ).line(box.urx, box.lly, box.llx, box.ury).line( - box.llx, box.lly, box.llx, box.ury - ) - - def _debug_draw_word_bbox( - self, canvas: Canvas, box: Rectangle, color=GREEN, line_width=0.1 - ): - """Render a box depicting the word.""" - if not self.render_options.render_word_bbox: # pragma: no cover - return - with canvas.do.save_state(): - canvas.do.stroke_color(color).line_width(line_width).rect( - box.llx, box.lly, box.width, box.height, fill=False - ) - - def _debug_draw_space_bbox( - self, canvas: Canvas, box: Rectangle, color=DARKGREEN, line_width=0.1 - ): - """Render a box depicting the space between two words.""" - if not self.render_options.render_space_bbox: # pragma: no cover - return - with canvas.do.save_state(): - canvas.do.fill_color(color).line_width(line_width).rect( - box.llx, box.lly, box.width, box.height, fill=True - ) - - def _debug_draw_baseline( - self, - canvas: Canvas, - line_box: Rectangle, - baseline_lly, - color=MAGENTA, - line_width=0.25, - ): - """Render the text baseline.""" - if not self.render_options.render_baseline: - return - with canvas.do.save_state(): - canvas.do.stroke_color(color).line_width(line_width).line( - line_box.llx, - baseline_lly, - line_box.urx, - baseline_lly, - ) diff --git a/src/ocrmypdf/hocrtransform/hocr_parser.py b/src/ocrmypdf/hocrtransform/hocr_parser.py new file mode 100644 index 00000000..efc188f9 --- /dev/null +++ b/src/ocrmypdf/hocrtransform/hocr_parser.py @@ -0,0 +1,521 @@ +# SPDX-FileCopyrightText: 2010 Jonathan Brinley +# SPDX-FileCopyrightText: 2013-2014 Julien Pfefferkorn +# SPDX-FileCopyrightText: 2023-2025 James R. Barlow +# SPDX-License-Identifier: MIT + +"""Parser for hOCR format files. + +This module provides functionality to parse hOCR files (HTML-based OCR format) +and convert them to the engine-agnostic OcrElement tree structure. + +For details of the hOCR format, see: +http://kba.github.io/hocr-spec/1.2/ +""" + +from __future__ import annotations + +import logging +import os +import re +import unicodedata +from pathlib import Path +from typing import Literal, cast +from xml.etree import ElementTree as ET + +from ocrmypdf.models.ocr_element import ( + Baseline, + BoundingBox, + FontInfo, + OcrClass, + OcrElement, +) + +TextDirection = Literal["ltr", "rtl"] + +log = logging.getLogger(__name__) + +Element = ET.Element + + +class HocrParseError(Exception): + """Error while parsing hOCR file.""" + + +class HocrParser: + """Parser for hOCR format files. + + Converts hOCR XML/HTML files into OcrElement trees. + + The hOCR format uses HTML with special class attributes (ocr_page, ocr_line, + ocrx_word, etc.) and a title attribute containing properties like bbox, + baseline, and confidence scores. + """ + + # Regex patterns for parsing hOCR title attributes + _bbox_pattern = re.compile( + r''' + bbox \s+ + (\d+) \s+ # left: uint + (\d+) \s+ # top: uint + (\d+) \s+ # right: uint + (\d+) # bottom: uint + ''', + re.VERBOSE, + ) + + _baseline_pattern = re.compile( + r''' + baseline \s+ + ([\-\+]?\d*\.?\d*) \s+ # slope: +/- decimal float + ([\-\+]?\d+) # intercept: +/- int + ''', + re.VERBOSE, + ) + + _textangle_pattern = re.compile( + r''' + textangle \s+ + ([\-\+]?\d*\.?\d*) # angle: +/- decimal float + ''', + re.VERBOSE, + ) + + _x_wconf_pattern = re.compile( + r''' + x_wconf \s+ + (\d+) # confidence: uint (0-100) + ''', + re.VERBOSE, + ) + + _x_fsize_pattern = re.compile( + r''' + x_fsize \s+ + (\d*\.?\d+) # font size: float + ''', + re.VERBOSE, + ) + + _x_font_pattern = re.compile( + r''' + x_font \s+ + ([^\s;]+) # font name: non-whitespace, non-semicolon string + ''', + re.VERBOSE, + ) + + _ppageno_pattern = re.compile( + r''' + ppageno \s+ + (\d+) # page number: uint + ''', + re.VERBOSE, + ) + + _scan_res_pattern = re.compile( + r''' + scan_res \s+ + (\d+) \s+ # x resolution + (\d+) # y resolution + ''', + re.VERBOSE, + ) + + def __init__(self, hocr_file: str | Path): + """Initialize the parser with an hOCR file. + + Args: + hocr_file: Path to the hOCR file to parse + + Raises: + HocrParseError: If the file cannot be parsed + """ + self._hocr_path = Path(hocr_file) + try: + self._tree = ET.parse(os.fspath(hocr_file)) + except ET.ParseError as e: + raise HocrParseError(f"Failed to parse hOCR file: {e}") from e + + # Detect XML namespace + root_tag = self._tree.getroot().tag + matches = re.match(r'({.*})html', root_tag) + self._xmlns = matches.group(1) if matches else '' + + def parse(self) -> OcrElement: + """Parse the hOCR file and return an OcrElement tree. + + Returns: + The root OcrElement (ocr_page) containing the document structure + + Raises: + HocrParseError: If no ocr_page element is found + """ + # Find the first ocr_page element + page_div = self._tree.find(self._xpath('div', 'ocr_page')) + if page_div is None: + raise HocrParseError("No ocr_page element found in hOCR file") + + return self._parse_page(page_div) + + def _xpath(self, html_tag: str, html_class: str | None = None) -> str: + """Build an XPath expression for finding elements. + + Args: + html_tag: HTML tag name (e.g., 'div', 'span', 'p') + html_class: Optional class attribute to match + + Returns: + XPath expression string + """ + xpath = f".//{self._xmlns}{html_tag}" + if html_class: + xpath += f"[@class='{html_class}']" + return xpath + + def _parse_page(self, page_elem: Element) -> OcrElement: + """Parse an ocr_page element. + + Args: + page_elem: The XML element with class="ocr_page" + + Returns: + OcrElement representing the page + """ + title = page_elem.attrib.get('title', '') + + bbox = self._parse_bbox(title) + if bbox is None: + raise HocrParseError("ocr_page missing bbox") + + # Parse page-level properties + page_number = self._parse_ppageno(title) + dpi = self._parse_scan_res(title) + + page = OcrElement( + ocr_class=OcrClass.PAGE, + bbox=bbox, + page_number=page_number, + dpi=dpi, + ) + + # Parse child paragraphs + for par_elem in page_elem.iterfind(self._xpath('p', 'ocr_par')): + paragraph = self._parse_paragraph(par_elem) + if paragraph is not None: + page.children.append(paragraph) + + # If no paragraphs found, check for words directly under page + # (some Tesseract output structures) + if not page.children: + for word_elem in page_elem.iterfind(self._xpath('span', 'ocrx_word')): + word = self._parse_word(word_elem) + if word is not None: + page.children.append(word) + + return page + + def _parse_paragraph(self, par_elem: Element) -> OcrElement | None: + """Parse an ocr_par element. + + Args: + par_elem: The XML element with class="ocr_par" + + Returns: + OcrElement representing the paragraph, or None if empty + """ + title = par_elem.attrib.get('title', '') + bbox = self._parse_bbox(title) + + # Get direction and language from attributes + dir_attr = par_elem.attrib.get('dir') + direction: TextDirection | None = ( + cast(TextDirection, dir_attr) if dir_attr in ('ltr', 'rtl') else None + ) + + language = par_elem.attrib.get('lang') + + paragraph = OcrElement( + ocr_class=OcrClass.PARAGRAPH, + bbox=bbox, + direction=direction, + language=language, + ) + + # Parse child lines + line_classes = { + 'ocr_line', + 'ocr_header', + 'ocr_footer', + 'ocr_caption', + 'ocr_textfloat', + } + for span_elem in par_elem.iterfind(self._xpath('span')): + elem_class = span_elem.attrib.get('class', '') + if elem_class in line_classes: + line = self._parse_line(span_elem, elem_class, direction, language) + if line is not None: + paragraph.children.append(line) + + # Return None if paragraph is empty + if not paragraph.children: + return None + + return paragraph + + def _parse_line( + self, + line_elem: Element, + ocr_class: str, + parent_direction: TextDirection | None, + parent_language: str | None, + ) -> OcrElement | None: + """Parse a line element (ocr_line, ocr_header, etc.). + + Args: + line_elem: The XML element representing the line + ocr_class: The hOCR class of the line + parent_direction: Text direction inherited from parent + parent_language: Language inherited from parent + + Returns: + OcrElement representing the line, or None if empty + """ + title = line_elem.attrib.get('title', '') + bbox = self._parse_bbox(title) + + if bbox is None: + return None + + baseline = self._parse_baseline(title) + textangle = self._parse_textangle(title) + + # Inherit direction and language from parent if not specified + dir_attr = line_elem.attrib.get('dir') + if dir_attr in ('ltr', 'rtl'): + direction: TextDirection | None = cast(TextDirection, dir_attr) + else: + direction = parent_direction + + language = line_elem.attrib.get('lang') or parent_language + + line = OcrElement( + ocr_class=ocr_class, + bbox=bbox, + baseline=baseline, + textangle=textangle, + direction=direction, + language=language, + ) + + # Parse child words + for word_elem in line_elem.iterfind(self._xpath('span', 'ocrx_word')): + word = self._parse_word(word_elem) + if word is not None: + line.children.append(word) + + # Return None if line has no words + if not line.children: + return None + + return line + + def _parse_word(self, word_elem: Element) -> OcrElement | None: + """Parse an ocrx_word element. + + Args: + word_elem: The XML element with class="ocrx_word" + + Returns: + OcrElement representing the word, or None if empty + """ + title = word_elem.attrib.get('title', '') + bbox = self._parse_bbox(title) + + # Get the text content + text = self._get_element_text(word_elem) + text = self._normalize_text(text) + + if not text: + return None + + # Parse confidence (x_wconf is 0-100, convert to 0.0-1.0) + confidence = self._parse_x_wconf(title) + if confidence is not None: + confidence = confidence / 100.0 + + # Parse font info + font = self._parse_font_info(title) + + return OcrElement( + ocr_class=OcrClass.WORD, + bbox=bbox, + text=text, + confidence=confidence, + font=font, + ) + + def _get_element_text(self, element: Element) -> str: + """Get the full text content of an element including children. + + Args: + element: XML element + + Returns: + Combined text content + """ + text = element.text if element.text is not None else '' + for child in element: + text += self._get_element_text(child) + text += element.tail if element.tail is not None else '' + return text + + @staticmethod + def _normalize_text(text: str) -> str: + """Normalize text using NFKC normalization. + + This splits ligatures and combines diacritics. + + Args: + text: Raw text + + Returns: + Normalized text, stripped of leading/trailing whitespace + """ + return unicodedata.normalize("NFKC", text).strip() + + def _parse_bbox(self, title: str) -> BoundingBox | None: + """Parse a bbox from an hOCR title attribute. + + Args: + title: The title attribute value + + Returns: + BoundingBox or None if not found + """ + match = self._bbox_pattern.search(title) + if not match: + return None + + try: + return BoundingBox( + left=float(match.group(1)), + top=float(match.group(2)), + right=float(match.group(3)), + bottom=float(match.group(4)), + ) + except ValueError: + return None + + def _parse_baseline(self, title: str) -> Baseline | None: + """Parse baseline from an hOCR title attribute. + + Args: + title: The title attribute value + + Returns: + Baseline or None if not found + """ + match = self._baseline_pattern.search(title) + if not match: + return None + + try: + return Baseline( + slope=float(match.group(1)) if match.group(1) else 0.0, + intercept=float(match.group(2)), + ) + except ValueError: + return None + + def _parse_textangle(self, title: str) -> float | None: + """Parse textangle from an hOCR title attribute. + + Args: + title: The title attribute value + + Returns: + Angle in degrees or None if not found + """ + match = self._textangle_pattern.search(title) + if not match: + return None + + try: + return float(match.group(1)) + except ValueError: + return None + + def _parse_x_wconf(self, title: str) -> float | None: + """Parse word confidence from an hOCR title attribute. + + Args: + title: The title attribute value + + Returns: + Confidence (0-100) or None if not found + """ + match = self._x_wconf_pattern.search(title) + if not match: + return None + + try: + return float(match.group(1)) + except ValueError: + return None + + def _parse_ppageno(self, title: str) -> int | None: + """Parse physical page number from an hOCR title attribute. + + Args: + title: The title attribute value + + Returns: + Page number or None if not found + """ + match = self._ppageno_pattern.search(title) + if not match: + return None + + try: + return int(match.group(1)) + except ValueError: + return None + + def _parse_scan_res(self, title: str) -> float | None: + """Parse scan resolution (DPI) from an hOCR title attribute. + + Args: + title: The title attribute value + + Returns: + DPI (using first value if x and y differ) or None if not found + """ + match = self._scan_res_pattern.search(title) + if not match: + return None + + try: + # Use the first (x) resolution value + return float(match.group(1)) + except ValueError: + return None + + def _parse_font_info(self, title: str) -> FontInfo | None: + """Parse font information from an hOCR title attribute. + + Args: + title: The title attribute value + + Returns: + FontInfo or None if no font info found + """ + font_match = self._x_font_pattern.search(title) + size_match = self._x_fsize_pattern.search(title) + + if not font_match and not size_match: + return None + + return FontInfo( + name=font_match.group(1) if font_match else None, + size=float(size_match.group(1)) if size_match else None, + ) diff --git a/src/ocrmypdf/imageops.py b/src/ocrmypdf/imageops.py index faa913bf..61e055a2 100644 --- a/src/ocrmypdf/imageops.py +++ b/src/ocrmypdf/imageops.py @@ -60,11 +60,10 @@ def _calculate_downsample( elif size[1] == 0: size = min(size[0], max_size[0]), 1 - if max_pixels is not None: - if size[0] * size[1] > max_pixels: - log.debug("Resizing image to fit image pixel limit") - pixels_factor = sqrt(max_pixels / (size[0] * size[1])) - size = floor(size[0] * pixels_factor), floor(size[1] * pixels_factor) + if max_pixels is not None and size[0] * size[1] > max_pixels: + log.debug("Resizing image to fit image pixel limit") + pixels_factor = sqrt(max_pixels / (size[0] * size[1])) + size = floor(size[0] * pixels_factor), floor(size[1] * pixels_factor) if max_bytes is not None: bpp = bytes_per_pixel diff --git a/src/ocrmypdf/languages.py b/src/ocrmypdf/languages.py index 90e36775..0702fe53 100644 --- a/src/ocrmypdf/languages.py +++ b/src/ocrmypdf/languages.py @@ -6,6 +6,7 @@ Derived from https://www.loc.gov/standards/iso639-2/ascii_8bits.html """ +from __future__ import annotations from typing import NamedTuple diff --git a/src/ocrmypdf/models/__init__.py b/src/ocrmypdf/models/__init__.py new file mode 100644 index 00000000..5a1314f4 --- /dev/null +++ b/src/ocrmypdf/models/__init__.py @@ -0,0 +1,6 @@ +# SPDX-FileCopyrightText: 2025 James R. Barlow +# SPDX-License-Identifier: MPL-2.0 + +"""OCRmyPDF models for plugin options and cross-cutting concerns.""" + +from __future__ import annotations diff --git a/src/ocrmypdf/models/ocr_element.py b/src/ocrmypdf/models/ocr_element.py new file mode 100644 index 00000000..fc825919 --- /dev/null +++ b/src/ocrmypdf/models/ocr_element.py @@ -0,0 +1,267 @@ +# SPDX-FileCopyrightText: 2025 James R. Barlow +# SPDX-License-Identifier: MPL-2.0 + +"""OCR element dataclasses for representing OCR output structure. + +This module provides a generic, engine-agnostic representation of OCR output. +The OcrElement dataclass can represent structural units from any OCR source +(hOCR, ALTO, custom engines, etc.) in a unified format suitable for rendering. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Literal + + +@dataclass +class BoundingBox: + """An axis-aligned bounding box in pixel coordinates. + + Coordinates use top-left origin (standard for images and hOCR). + + Attributes: + left: Left edge x-coordinate + top: Top edge y-coordinate + right: Right edge x-coordinate + bottom: Bottom edge y-coordinate + """ + + left: float + top: float + right: float + bottom: float + + @property + def width(self) -> float: + """Width of the bounding box.""" + return self.right - self.left + + @property + def height(self) -> float: + """Height of the bounding box.""" + return self.bottom - self.top + + def __post_init__(self): + """Validate bounding box coordinates.""" + if self.right < self.left: + raise ValueError( + f"Invalid bounding box: right ({self.right}) < left ({self.left})" + ) + if self.bottom < self.top: + raise ValueError( + f"Invalid bounding box: bottom ({self.bottom}) < top ({self.top})" + ) + + +@dataclass +class Baseline: + """Text baseline information. + + The baseline is represented as a linear equation: y = slope * x + intercept. + This describes the line along which text characters sit, relative to the + bottom-left corner of the line's bounding box. + + In hOCR, the baseline is specified relative to the bottom of the line's bbox, + with the intercept being the vertical offset from the bottom and the slope + representing rotation (positive = ascending left-to-right). + + Attributes: + slope: Slope of the baseline (rise over run) + intercept: Y-intercept of the baseline (vertical offset from bbox bottom) + """ + + slope: float = 0.0 + intercept: float = 0.0 + + +@dataclass +class FontInfo: + """Font information for text rendering. + + Attributes: + name: Font family name (e.g., "Times New Roman") + size: Font size in points + bold: Whether the font is bold + italic: Whether the font is italic + monospace: Whether the font is monospace + serif: Whether the font is serif (vs sans-serif) + smallcaps: Whether the font uses small caps + underline: Whether the text is underlined + """ + + name: str | None = None + size: float | None = None + bold: bool = False + italic: bool = False + monospace: bool = False + serif: bool = False + smallcaps: bool = False + underline: bool = False + + +@dataclass +class OcrElement: + """A generic OCR element representing any structural unit of OCR output. + + OcrElements form a tree structure where pages contain paragraphs, paragraphs + contain lines, lines contain words, etc. The specific hierarchy depends on + the OCR engine, but this dataclass can represent any of these levels. + + The ocr_class field uses hOCR naming conventions (ocr_page, ocr_par, ocr_line, + ocrx_word, etc.) as a common vocabulary, but elements from other sources can + map to these classes. + + Common hOCR classes: + - ocr_page: The root element for a page + - ocr_carea: A content/column area + - ocr_par: A paragraph + - ocr_line: A line of text + - ocr_header: A header line + - ocr_footer: A footer line + - ocr_caption: A caption line + - ocr_textfloat: A floating text element + - ocrx_word: A single word + + Attributes: + ocr_class: The element type (e.g., "ocr_page", "ocr_line", "ocrx_word") + bbox: Axis-aligned bounding box in source pixel coordinates (top-left origin) + poly: Polygon vertices for oriented/non-rectangular bounds + text: Text content (primarily for leaf nodes like words) + confidence: OCR confidence score (0.0-1.0) + children: Child elements (hierarchical structure) + direction: Text direction ("ltr" or "rtl") + language: Language code (e.g., "eng", "deu", "chi_sim") + baseline: Text baseline information (slope and intercept) + textangle: Text rotation angle in degrees (counter-clockwise from horizontal) + font: Font information (name, size, style) + dpi: Image resolution in dots per inch (typically for page-level) + page_number: Physical page number (0-indexed) + logical_page_number: Logical page number (as printed on the page) + """ + + ocr_class: str + + # Bounding boxes + bbox: BoundingBox | None = None + poly: list[tuple[float, float]] | None = None + + # Text content + text: str = "" + + # Confidence (0.0-1.0) + confidence: float | None = None + + # Children (hierarchical structure) + children: list[OcrElement] = field(default_factory=list) + + # Text direction and language + direction: Literal["ltr", "rtl"] | None = None + language: str | None = None + + # Baseline (for lines) + baseline: Baseline | None = None + + # Rotation angle in degrees (counter-clockwise) + textangle: float | None = None + + # Font information + font: FontInfo | None = None + + # Page-level properties + dpi: float | None = None + page_number: int | None = None + logical_page_number: int | None = None + + def iter_by_class(self, *ocr_classes: str) -> list[OcrElement]: + """Iterate over all descendants matching the given class(es). + + Args: + *ocr_classes: One or more ocr_class values to match + + Returns: + List of all matching descendant elements (depth-first order) + """ + result = [] + if self.ocr_class in ocr_classes: + result.append(self) + for child in self.children: + result.extend(child.iter_by_class(*ocr_classes)) + return result + + def find_by_class(self, *ocr_classes: str) -> OcrElement | None: + """Find the first descendant matching the given class(es). + + Args: + *ocr_classes: One or more ocr_class values to match + + Returns: + The first matching element, or None if not found + """ + if self.ocr_class in ocr_classes: + return self + for child in self.children: + result = child.find_by_class(*ocr_classes) + if result is not None: + return result + return None + + def get_text_recursive(self) -> str: + """Get the combined text of this element and all descendants. + + Returns: + Combined text content, with words separated by spaces + """ + if self.text: + return self.text + texts = [child.get_text_recursive() for child in self.children] + return " ".join(t for t in texts if t) + + @property + def words(self) -> list[OcrElement]: + """Get all word elements (ocrx_word) in this element's subtree.""" + return self.iter_by_class("ocrx_word") + + @property + def lines(self) -> list[OcrElement]: + """Get all line elements in this element's subtree.""" + return self.iter_by_class( + "ocr_line", "ocr_header", "ocr_footer", "ocr_caption", "ocr_textfloat" + ) + + @property + def paragraphs(self) -> list[OcrElement]: + """Get all paragraph elements (ocr_par) in this element's subtree.""" + return self.iter_by_class("ocr_par") + + +# Type alias for text direction +TextDirection = Literal["ltr", "rtl"] + + +# hOCR class constants for convenience +class OcrClass: + """Constants for common OCR element classes.""" + + # Page-level + PAGE = "ocr_page" + CAREA = "ocr_carea" + + # Block-level + PARAGRAPH = "ocr_par" + + # Line-level + LINE = "ocr_line" + HEADER = "ocr_header" + FOOTER = "ocr_footer" + CAPTION = "ocr_caption" + TEXTFLOAT = "ocr_textfloat" + + # Word-level + WORD = "ocrx_word" + + # Character-level + CHAR = "ocrx_cinfo" + + # Line types (for convenience) + LINE_TYPES = frozenset({LINE, HEADER, FOOTER, CAPTION, TEXTFLOAT}) diff --git a/src/ocrmypdf/optimize.py b/src/ocrmypdf/optimize.py index 41a1a385..69e1e844 100644 --- a/src/ocrmypdf/optimize.py +++ b/src/ocrmypdf/optimize.py @@ -9,7 +9,6 @@ import logging import sys import tempfile import threading -from collections import defaultdict from collections.abc import Callable, Iterator, MutableSet, Sequence from os import fspath from pathlib import Path @@ -19,6 +18,7 @@ from zlib import compress import img2pdf from packaging.version import Version from pikepdf import ( + Array, Dictionary, Name, Object, @@ -194,11 +194,9 @@ def extract_image_jbig2( def _should_optimize_jpeg(options, filtdp): if options.optimize >= 2: return True - if options.optimize < 2 and ghostscript.version() >= Version('10.6.0'): - # Ghostscript 10.6.0+ introduced some sort of JPEG encoding issue. - # To resolve this, re-optimize the JPEG anyway. - return True - return False + # Ghostscript 10.6.0+ introduced some sort of JPEG encoding issue. + # To resolve this, re-optimize the JPEG anyway. + return options.optimize < 2 and ghostscript.version() >= Version('10.6.0') def extract_image_generic( @@ -245,11 +243,9 @@ def extract_image_generic( not pim.indexed and pim.colorspace == Name.ICCBased and pim.bits_per_component == 1 - and not options.jbig2_lossy ): # We can losslessly optimize 1-bit images to CCITT or JBIG2 without - # paying any attention to the ICC profile, provided we're not doing - # lossy JBIG2 + # paying any attention to the ICC profile pim.as_pil_image().save(png_name(root, xref)) return XrefExt(xref, '.png') @@ -372,116 +368,75 @@ def extract_images_generic( return jpegs, pngs -def extract_images_jbig2(pdf: Pdf, root: Path, options) -> dict[int, list[XrefExt]]: +def extract_images_jbig2(pdf: Pdf, root: Path, options) -> list[XrefExt]: """Extract any bitonal image that we think we can improve as JBIG2.""" - jbig2_groups = defaultdict(list) - for pageno, xref_ext in extract_images(pdf, root, options, extract_image_jbig2): - group = pageno // options.jbig2_page_group_size - jbig2_groups[group].append(xref_ext) + jbig2_images = [] + for _pageno, xref_ext in extract_images(pdf, root, options, extract_image_jbig2): + jbig2_images.append(xref_ext) - log.debug(f"Optimizable images: JBIG2 groups: {len(jbig2_groups)}") - return jbig2_groups + log.debug(f"Optimizable images: JBIG2: {len(jbig2_images)}") + return jbig2_images def _produce_jbig2_images( - jbig2_groups: dict[int, list[XrefExt]], root: Path, options, executor: Executor + jbig2_images: list[XrefExt], root: Path, options, executor: Executor ) -> None: - """Produce JBIG2 images from their groups.""" + """Produce JBIG2 images using lossless single-image encoding.""" - def jbig2_group_args(root: Path, groups: dict[int, list[XrefExt]]): - for group, xref_exts in groups.items(): - prefix = f'group{group:08d}' + def jbig2_args(): + for xref_ext in jbig2_images: + xref, ext = xref_ext yield ( - fspath(root), # =cwd - (img_name(root, xref, ext) for xref, ext in xref_exts), # =infiles - prefix, # =out_prefix + fspath(root), + img_name(root, xref, ext), + root / f'{xref:08d}.jbig2', options.jbig2_threshold, ) - def jbig2_single_args(root: Path, groups: dict[int, list[XrefExt]]): - for group, xref_exts in groups.items(): - prefix = f'group{group:08d}' - # Second loop is to ensure multiple images per page are unpacked - for n, xref_ext in enumerate(xref_exts): - xref, ext = xref_ext - yield ( - fspath(root), - img_name(root, xref, ext), - root / f'{prefix}.{n:04d}', - options.jbig2_threshold, - ) - - if options.jbig2_page_group_size > 1: - jbig2_args = jbig2_group_args - jbig2_convert = jbig2enc.convert_group - else: - jbig2_args = jbig2_single_args - jbig2_convert = jbig2enc.convert_single - executor( use_threads=True, max_workers=options.jobs, progress_kwargs=dict( - total=len(jbig2_groups), + total=len(jbig2_images), desc="JBIG2", - unit='item', + unit='image', disable=not options.progress_bar, ), - task=jbig2_convert, - task_arguments=jbig2_args(root, jbig2_groups), + task=jbig2enc.convert_single, + task_arguments=jbig2_args(), ) def convert_to_jbig2( pdf: Pdf, - jbig2_groups: dict[int, list[XrefExt]], + jbig2_images: list[XrefExt], root: Path, options, executor: Executor, ) -> None: """Convert images to JBIG2 and insert into PDF. - When the JBIG2 page group size is > 1 we do several JBIG2 images at once - and build a symbol dictionary that will span several pages. Each JBIG2 - image must reference to its symbol dictionary. If too many pages shared the - same dictionary JBIG2 encoding becomes more expensive and less efficient. - The default value of 10 was determined through testing. Currently this - must be lossy encoding since jbig2enc does not support refinement coding. - - When the JBIG2 symbolic coder is not used, each JBIG2 stands on its own - and needs no dictionary. Currently this must be lossless JBIG2. + Each JBIG2 image is encoded independently using lossless compression. + No symbol dictionary (JBIG2Globals) is used. """ - jbig2_globals_dict: Dictionary | None + _produce_jbig2_images(jbig2_images, root, options, executor) - _produce_jbig2_images(jbig2_groups, root, options, executor) - - for group, xref_exts in jbig2_groups.items(): - prefix = f'group{group:08d}' - jbig2_symfile = root / (prefix + '.sym') - if jbig2_symfile.exists(): - jbig2_globals_data = jbig2_symfile.read_bytes() - jbig2_globals = Stream(pdf, jbig2_globals_data) - jbig2_globals_dict = Dictionary(JBIG2Globals=jbig2_globals) - elif options.jbig2_page_group_size == 1: - jbig2_globals_dict = None - else: - raise FileNotFoundError(jbig2_symfile) - - for n, xref_ext in enumerate(xref_exts): - xref, _ = xref_ext - jbig2_im_file = root / (prefix + f'.{n:04d}') - jbig2_im_data = jbig2_im_file.read_bytes() - im_obj = pdf.get_object(xref, 0) - im_obj.write( - jbig2_im_data, filter=Name.JBIG2Decode, decode_parms=jbig2_globals_dict - ) + for xref_ext in jbig2_images: + xref, _ = xref_ext + jbig2_im_file = root / f'{xref:08d}.jbig2' + jbig2_im_data = jbig2_im_file.read_bytes() + im_obj = pdf.get_object(xref, 0) + im_obj.write(jbig2_im_data, filter=Name.JBIG2Decode, decode_parms=None) def _optimize_jpeg( - xref: Xref, in_jpg: Path, opt_jpg: Path, jpeg_quality: int + xref: Xref, in_jpg: Path, opt_jpg: Path, jpg_quality: int ) -> tuple[Xref, Path | None]: with Image.open(in_jpg) as im: - im.save(opt_jpg, optimize=True, quality=jpeg_quality) + save_kwargs: dict[str, Any] = {'optimize': True} + if isinstance(jpg_quality, int) and 0 < jpg_quality <= 100: + save_kwargs['quality'] = jpg_quality + im.save(opt_jpg, **save_kwargs) if opt_jpg.stat().st_size > in_jpg.stat().st_size: log.debug(f"xref {xref}, jpeg, made larger - skip") @@ -499,7 +454,7 @@ def transcode_jpegs( for xref in jpegs: in_jpg = jpg_name(root, xref) opt_jpg = in_jpg.with_suffix('.opt.jpg') - yield xref, in_jpg, opt_jpg, options.jpeg_quality + yield xref, in_jpg, opt_jpg, options.jpg_quality def finish_jpeg(result: tuple[Xref, Path | None], pbar: ProgressBar): xref, opt_jpg = result @@ -524,6 +479,16 @@ def transcode_jpegs( ) +def _already_flate_encoded(image: Stream) -> bool: + """Check if the image already has FlateDecode in its filter chain.""" + filt = image.get(Name.Filter) + if filt is None: + return False + if isinstance(filt, Array): + return Name.FlateDecode in list(filt) + return filt == Name.FlateDecode + + def _find_deflatable_jpeg( *, pdf: Pdf, root: Path, image: Stream, xref: Xref, options ) -> XrefExt | None: @@ -532,6 +497,10 @@ def _find_deflatable_jpeg( return None _pim, filtdp = result + # Skip if already FlateDecode compressed - would double-compress + if _already_flate_encoded(image): + return None + if ( filtdp[0] == Name.DCTDecode and not filtdp[1] @@ -712,12 +681,10 @@ def optimize( safe_symlink(input_file, output_file) return output_file - if options.jpeg_quality == 0: - options.jpeg_quality = DEFAULT_JPEG_QUALITY if options.optimize < 3 else 40 + if options.jpg_quality == 0: + options.jpg_quality = DEFAULT_JPEG_QUALITY if options.optimize < 3 else 40 if options.png_quality == 0: options.png_quality = DEFAULT_PNG_QUALITY if options.optimize < 3 else 30 - if options.jbig2_page_group_size == 0: - options.jbig2_page_group_size = 10 if options.jbig2_lossy else 1 with Pdf.open(input_file) as pdf: root = output_file.parent / 'images' @@ -731,8 +698,8 @@ def optimize( # transcode_pngs(pdf, jpegs, jpg_name, root, options) transcode_pngs(pdf, pngs, png_name, root, options, executor) - jbig2_groups = extract_images_jbig2(pdf, root, options) - convert_to_jbig2(pdf, jbig2_groups, root, options, executor) + jbig2_images = extract_images_jbig2(pdf, root, options) + convert_to_jbig2(pdf, jbig2_images, root, options, executor) target_file = output_file.with_suffix('.opt.pdf') pdf.remove_unreferenced_resources() @@ -767,35 +734,25 @@ def main(infile, outfile, level, jobs=1): from shutil import copy # pylint: disable=import-outside-toplevel from tempfile import TemporaryDirectory # pylint: disable=import-outside-toplevel - class OptimizeOptions: - """Emulate ocrmypdf's options.""" - - def __init__( - self, input_file, jobs, optimize_, jpeg_quality, png_quality, jb2lossy - ): - self.input_file = input_file - self.jobs = jobs - self.optimize = optimize_ - self.jpeg_quality = jpeg_quality - self.png_quality = png_quality - self.jbig2_page_group_size = 0 - self.jbig2_lossy = jb2lossy - self.jbig2_threshold = 0.85 - self.quiet = True - self.progress_bar = False + from ocrmypdf._options import OcrOptions # pylint: disable=import-outside-toplevel infile = Path(infile) - options = OptimizeOptions( + + # Create OcrOptions with optimization-specific settings + options = OcrOptions( input_file=infile, + output_file=outfile, # Required field jobs=jobs, - optimize_=int(level), - jpeg_quality=0, # Use default + optimize=int(level), + jpg_quality=0, # Use default png_quality=0, - jb2lossy=False, + jbig2_threshold=0.85, + quiet=True, + progress_bar=False, ) with TemporaryDirectory() as tmpdir: - context = PdfContext(options, tmpdir, infile, None, None) + context = PdfContext(options, Path(tmpdir), infile, None, None) tmpout = Path(tmpdir) / 'out.pdf' optimize( infile, diff --git a/src/ocrmypdf/pdfa.py b/src/ocrmypdf/pdfa.py index b44765d1..3e73c394 100644 --- a/src/ocrmypdf/pdfa.py +++ b/src/ocrmypdf/pdfa.py @@ -1,16 +1,20 @@ # SPDX-FileCopyrightText: 2022 James R. Barlow # SPDX-License-Identifier: MPL-2.0 -"""Utilities for PDF/A production and confirmation with Ghostspcript.""" +"""Utilities for PDF/A production and confirmation with Ghostscript.""" from __future__ import annotations import base64 +import logging from collections.abc import Iterator from importlib.resources import files as package_files from pathlib import Path import pikepdf +from pikepdf import Array, Dictionary, Name, Pdf, Stream + +log = logging.getLogger(__name__) SRGB_ICC_PROFILE_NAME = 'sRGB.icc' @@ -131,3 +135,115 @@ def file_claims_pdfa(filename: Path): pdfa_dict['output'] = 'pdfa' pdfa_dict['conformance'] = conformance return pdfa_dict + + +def _load_srgb_icc_profile() -> bytes: + """Load the sRGB ICC profile from package data.""" + return (package_files('ocrmypdf.data') / SRGB_ICC_PROFILE_NAME).read_bytes() + + +def _pdfa_part_conformance(output_type: str) -> tuple[str, str]: + """Extract PDF/A part and conformance from output_type. + + Args: + output_type: One of 'pdfa', 'pdfa-1', 'pdfa-2', 'pdfa-3' + + Returns: + Tuple of (part, conformance) e.g., ('2', 'B') + """ + mapping = { + 'pdfa': ('2', 'B'), + 'pdfa-1': ('1', 'B'), + 'pdfa-2': ('2', 'B'), + 'pdfa-3': ('3', 'B'), + } + return mapping.get(output_type, ('2', 'B')) + + +def add_pdfa_metadata(pdf: Pdf, part: str, conformance: str) -> None: + """Add PDF/A XMP metadata declaration to a PDF. + + Args: + pdf: An open pikepdf.Pdf object + part: PDF/A part number ('1', '2', or '3') + conformance: Conformance level ('A', 'B', or 'U') + """ + with pdf.open_metadata() as meta: + meta['pdfaid:part'] = part + meta['pdfaid:conformance'] = conformance + + +def add_srgb_output_intent(pdf: Pdf) -> None: + """Add sRGB ICC profile as OutputIntent to PDF catalog. + + This creates the required PDF/A OutputIntent structure with: + - An ICC profile stream containing sRGB profile + - An OutputIntent dictionary pointing to that profile + - Updates the Catalog's OutputIntents array + + Args: + pdf: An open pikepdf.Pdf object + """ + icc_data = _load_srgb_icc_profile() + + # Create ICC profile stream + icc_stream = Stream(pdf, icc_data) + icc_stream[Name.N] = 3 # RGB has 3 components + + # Create OutputIntent dictionary + output_intent = Dictionary({ + '/Type': Name.OutputIntent, + '/S': Name('/GTS_PDFA1'), + '/OutputConditionIdentifier': 'sRGB', + '/DestOutputProfile': icc_stream, + }) + + # Add to catalog's OutputIntents array + if Name.OutputIntents not in pdf.Root: + pdf.Root[Name.OutputIntents] = Array([]) + + # Check if sRGB OutputIntent already exists + for intent in pdf.Root.OutputIntents: # type: ignore[attr-defined] + if str(intent.get(Name.OutputConditionIdentifier)) == 'sRGB': + log.debug('sRGB OutputIntent already exists, skipping') + return + + pdf.Root.OutputIntents.append(output_intent) + + +def speculative_pdfa_conversion( + input_file: Path, + output_file: Path, + output_type: str, +) -> Path: + """Attempt to convert a PDF to PDF/A by adding required structures. + + This function creates a copy of the input PDF and adds: + 1. sRGB ICC profile as OutputIntent + 2. XMP metadata declaring PDF/A conformance + + This approach works for PDFs that are already mostly PDF/A compliant + but lack the formal declarations. It does NOT perform color conversion, + font embedding, or other transformations that Ghostscript does. + + Args: + input_file: Path to input PDF + output_file: Path where output PDF should be written + output_type: One of 'pdfa', 'pdfa-1', 'pdfa-2', 'pdfa-3' + + Returns: + Path to the output file + + Raises: + pikepdf.PdfError: If the PDF cannot be opened or modified + """ + part, conformance = _pdfa_part_conformance(output_type) + + with Pdf.open(input_file) as pdf: + add_srgb_output_intent(pdf) + add_pdfa_metadata(pdf, part, conformance) + + pdf.save(output_file) + + log.debug('Speculative PDF/A conversion complete: %s', output_file) + return output_file diff --git a/src/ocrmypdf/pdfinfo/__init__.py b/src/ocrmypdf/pdfinfo/__init__.py index fe896337..3257ea4e 100644 --- a/src/ocrmypdf/pdfinfo/__init__.py +++ b/src/ocrmypdf/pdfinfo/__init__.py @@ -6,6 +6,7 @@ from __future__ import annotations -from ocrmypdf.pdfinfo.info import Colorspace, Encoding, PageInfo, PdfInfo +from ocrmypdf.pdfinfo._types import Colorspace, Encoding, FloatRect +from ocrmypdf.pdfinfo.info import PageInfo, PdfInfo -__all__ = ["Colorspace", "Encoding", "PageInfo", "PdfInfo"] +__all__ = ["Colorspace", "Encoding", "FloatRect", "PageInfo", "PdfInfo"] diff --git a/src/ocrmypdf/pdfinfo/_contentstream.py b/src/ocrmypdf/pdfinfo/_contentstream.py new file mode 100644 index 00000000..cde28733 --- /dev/null +++ b/src/ocrmypdf/pdfinfo/_contentstream.py @@ -0,0 +1,231 @@ +# SPDX-FileCopyrightText: 2022 James R. Barlow +# SPDX-License-Identifier: MPL-2.0 +"""PDF content stream interpretation.""" + +from __future__ import annotations + +import re +from collections import defaultdict +from collections.abc import Mapping +from math import hypot, inf, isclose +from typing import NamedTuple +from warnings import warn + +from pikepdf import Matrix, Object, PdfInlineImage, parse_content_stream + +from ocrmypdf.exceptions import InputFileError +from ocrmypdf.helpers import Resolution +from ocrmypdf.pdfinfo._types import UNIT_SQUARE + + +class XobjectSettings(NamedTuple): + """Info about an XObject found in a PDF.""" + + name: str + shorthand: tuple[float, float, float, float, float, float] + stack_depth: int + + +class InlineSettings(NamedTuple): + """Info about an inline image found in a PDF.""" + + iimage: PdfInlineImage + shorthand: tuple[float, float, float, float, float, float] + stack_depth: int + + +class ContentsInfo(NamedTuple): + """Info about various objects found in a PDF.""" + + xobject_settings: list[XobjectSettings] + inline_images: list[InlineSettings] + found_vector: bool + found_text: bool + name_index: Mapping[str, list[XobjectSettings]] + + +class TextboxInfo(NamedTuple): + """Info about a text box found in a PDF.""" + + bbox: tuple[float, float, float, float] + is_visible: bool + is_corrupt: bool + + +class VectorMarker: + """Sentinel indicating vector drawing operations were found on a page.""" + + +class TextMarker: + """Sentinel indicating text drawing operations were found on a page.""" + + +def _is_unit_square(shorthand): + """Check if the shorthand represents a unit square transformation.""" + values = map(float, shorthand) + pairwise = zip(values, UNIT_SQUARE, strict=False) + return all(isclose(a, b, rel_tol=1e-3) for a, b in pairwise) + + +def _normalize_stack(graphobjs): + """Convert runs of qQ's in the stack into single graphobjs.""" + for operands, operator in graphobjs: + operator = str(operator) + if re.match(r'Q*q+$', operator): # Zero or more Q, one or more q + for char in operator: # Split into individual + yield ([], char) # Yield individual + else: + yield (operands, operator) + + +def _interpret_contents(contentstream: Object, initial_shorthand=UNIT_SQUARE): + """Interpret the PDF content stream. + + The stack represents the state of the PDF graphics stack. We are only + interested in the current transformation matrix (CTM) so we only track + this object; a full implementation would need to track many other items. + + The CTM is initialized to the mapping from user space to device space. + PDF units are 1/72". In a PDF viewer or printer this matrix is initialized + to the transformation to device space. For example if set to + (1/72, 0, 0, 1/72, 0, 0) then all units would be calculated in inches. + + Images are always considered to be (0, 0) -> (1, 1). Before drawing an + image there should be a 'cm' that sets up an image coordinate system + where drawing from (0, 0) -> (1, 1) will draw on the desired area of the + page. + + PDF units suit our needs so we initialize ctm to the identity matrix. + + According to the PDF specification, the maximum stack depth is 32. Other + viewers tolerate some amount beyond this. We issue a warning if the + stack depth exceeds the spec limit and set a hard limit beyond this to + bound our memory requirements. If the stack underflows behavior is + undefined in the spec, but we just pretend nothing happened and leave the + CTM unchanged. + """ + stack = [] + ctm = Matrix(initial_shorthand) + xobject_settings: list[XobjectSettings] = [] + inline_images: list[InlineSettings] = [] + name_index = defaultdict(lambda: []) + found_vector = False + found_text = False + vector_ops = set('S s f F f* B B* b b*'.split()) + text_showing_ops = set("""TJ Tj " '""".split()) + image_ops = set('BI ID EI q Q Do cm'.split()) + operator_whitelist = ' '.join(vector_ops | text_showing_ops | image_ops) + + for n, graphobj in enumerate( + _normalize_stack(parse_content_stream(contentstream, operator_whitelist)) + ): + operands, operator = graphobj + if operator == 'q': + stack.append(ctm) + if len(stack) > 32: # See docstring + if len(stack) > 128: + raise RuntimeError( + f"PDF graphics stack overflowed hard limit at operator {n}" + ) + warn("PDF graphics stack overflowed spec limit") + elif operator == 'Q': + try: + ctm = stack.pop() + except IndexError: + # Keeping the ctm the same seems to be the only sensible thing + # to do. Just pretend nothing happened, keep calm and carry on. + warn("PDF graphics stack underflowed - PDF may be malformed") + elif operator == 'cm': + try: + ctm = Matrix(operands) @ ctm + except ValueError as e: + raise InputFileError( + "PDF content stream is corrupt - this PDF is malformed. " + "Use a PDF editor that is capable of visually inspecting the PDF." + ) from e + elif operator == 'Do': + image_name = operands[0] + settings = XobjectSettings( + name=image_name, shorthand=ctm.shorthand, stack_depth=len(stack) + ) + xobject_settings.append(settings) + name_index[str(image_name)].append(settings) + elif operator == 'INLINE IMAGE': # BI/ID/EI are grouped into this + iimage = operands[0] + inline = InlineSettings( + iimage=iimage, shorthand=ctm.shorthand, stack_depth=len(stack) + ) + inline_images.append(inline) + elif operator in vector_ops: + found_vector = True + elif operator in text_showing_ops: + found_text = True + + return ContentsInfo( + xobject_settings=xobject_settings, + inline_images=inline_images, + found_vector=found_vector, + found_text=found_text, + name_index=name_index, + ) + + +def _get_dpi(ctm_shorthand, image_size) -> Resolution: + """Given the transformation matrix and image size, find the image DPI. + + PDFs do not include image resolution information within image data. + Instead, the PDF page content stream describes the location where the + image will be rasterized, and the effective resolution is the ratio of the + pixel size to raster target size. + + Normally a scanned PDF has the paper size set appropriately but this is + not guaranteed. The most common case is a cropped image will change the + page size (/CropBox) without altering the page content stream. That means + it is not sufficient to assume that the image fills the page, even though + that is the most common case. + + A PDF image may be scaled (always), cropped, translated, rotated in place + to an arbitrary angle (rarely) and skewed. Only equal area mappings can + be expressed, that is, it is not necessary to consider distortions where + the effective DPI varies with position. + + To determine the image scale, transform an offset axis vector v0 (0, 0), + width-axis vector v0 (1, 0), height-axis vector vh (0, 1) with the matrix, + which gives the dimensions of the image in PDF units. From there we can + compare to actual image dimensions. PDF uses + row vector * matrix_transposed unlike the traditional + matrix * column vector. + + The offset, width and height vectors can be combined in a matrix and + multiplied by the transform matrix. Then we want to calculated + magnitude(width_vector - offset_vector) + and + magnitude(height_vector - offset_vector) + + When the above is worked out algebraically, the effect of translation + cancels out, and the vector magnitudes become functions of the nonzero + transformation matrix indices. The results of the derivation are used + in this code. + + pdfimages -list does calculate the DPI in some way that is not completely + naive, but it does not get the DPI of rotated images right, so cannot be + used anymore to validate this. Photoshop works, or using Acrobat to + rotate the image back to normal. + + It does not matter if the image is partially cropped, or even out of the + /MediaBox. + + """ + a, b, c, d, _, _ = ctm_shorthand # pylint: disable=invalid-name + + # Calculate the width and height of the image in PDF units + image_drawn = hypot(a, b), hypot(c, d) + + def calc(drawn, pixels, inches_per_pt=72.0): + # The scale of the image is pixels per unit of default user space (1/72") + scale = pixels / drawn if drawn != 0 else inf + dpi = scale * inches_per_pt + return dpi + + dpi_w, dpi_h = (calc(image_drawn[n], image_size[n]) for n in range(2)) + return Resolution(dpi_w, dpi_h) diff --git a/src/ocrmypdf/pdfinfo/_image.py b/src/ocrmypdf/pdfinfo/_image.py new file mode 100644 index 00000000..0686de48 --- /dev/null +++ b/src/ocrmypdf/pdfinfo/_image.py @@ -0,0 +1,380 @@ +# SPDX-FileCopyrightText: 2022 James R. Barlow +# SPDX-License-Identifier: MPL-2.0 +"""PDF image analysis.""" + +from __future__ import annotations + +import logging +from collections.abc import Iterator +from decimal import Decimal + +from pikepdf import ( + Dictionary, + Matrix, + Name, + Object, + Pdf, + PdfImage, + PdfInlineImage, + Stream, + UnsupportedImageTypeError, +) + +from ocrmypdf.helpers import Resolution +from ocrmypdf.pdfinfo._contentstream import ( + ContentsInfo, + TextMarker, + VectorMarker, + _get_dpi, + _interpret_contents, + _is_unit_square, +) +from ocrmypdf.pdfinfo._types import ( + FRIENDLY_COLORSPACE, + FRIENDLY_COMP, + FRIENDLY_ENCODING, + UNIT_SQUARE, + Colorspace, + Encoding, +) + +logger = logging.getLogger() + + +class ImageInfo: + """Information about an image found in a PDF. + + This gathers information from pikepdf and pdfminer.six, and is pickle-able + so that it can be passed to a worker process, unlike objects from those + libraries. + """ + + DPI_PREC = Decimal('1.000') + + _comp: int | None + _name: str + + def __init__( + self, + *, + name='', + pdfimage: Object | None = None, + inline: PdfInlineImage | None = None, + shorthand=None, + ): + """Initialize an ImageInfo.""" + self._name = str(name) + self._shorthand = shorthand + + pim: PdfInlineImage | PdfImage + + if inline is not None: + self._origin = 'inline' + pim = inline + elif pdfimage is not None and isinstance(pdfimage, Stream): + self._origin = 'xobject' + pim = PdfImage(pdfimage) + else: + raise ValueError("Either pdfimage or inline must be set") + + self._width = pim.width + self._height = pim.height + if (smask := pim.obj.get(Name.SMask, None)) is not None and isinstance( + smask, Stream | Dictionary + ): + # SMask is pretty much an alpha channel, but in PDF it's possible + # for channel to have different dimensions than the image + # itself. Some PDF writers use this to create a grayscale stencil + # mask. For our purposes, the effective size is the size of the + # larger component (image or smask). + self._width = max(smask.get(Name.Width, 0), self._width) + self._height = max(smask.get(Name.Height, 0), self._height) + if (mask := pim.obj.get(Name.Mask, None)) is not None and isinstance( + mask, Stream | Dictionary + ): + # If the image has a /Mask entry, it has an explicit mask. + # /Mask can be a Stream or an Array. If it's a Stream, + # use its /Width and /Height if they are larger than the main + # image's. + self._width = max(mask.get(Name.Width, 0), self._width) + self._height = max(mask.get(Name.Height, 0), self._height) + + # If /ImageMask is true, then this image is a stencil mask + # (Images that draw with this stencil mask will have a reference to + # it in their /Mask, but we don't actually need that information) + if pim.image_mask: + self._type = 'stencil' + else: + self._type = 'image' + + self._bpc = int(pim.bits_per_component) + try: + self._enc = FRIENDLY_ENCODING.get(pim.filters[0]) + except IndexError: + self._enc = None + + try: + self._color = FRIENDLY_COLORSPACE.get(pim.colorspace or '') + except NotImplementedError: + self._color = None + if self._enc == Encoding.jpeg2000: + self._color = Colorspace.jpeg2000 + + self._comp = None + if self._color == Colorspace.icc and isinstance(pim, PdfImage): + self._comp = self._init_icc(pim) + else: + if isinstance(self._color, Colorspace): + self._comp = FRIENDLY_COMP.get(self._color) + # Bit of a hack... infer grayscale if component count is uncertain + # but encoding only supports monochrome. + if self._comp is None and self._enc in (Encoding.ccitt, Encoding.jbig2): + self._comp = FRIENDLY_COMP[Colorspace.gray] + + def _init_icc(self, pim: PdfImage): + try: + icc = pim.icc + except UnsupportedImageTypeError as e: + logger.warning( + f"An image with a corrupt or unreadable ICC profile was found. " + f"Output PDF may not match the input PDF visually: {e}. {self}" + ) + return None + # Check the ICC profile to determine actual colorspace + if icc is None or not hasattr(icc, 'profile'): + logger.warning( + f"An image with an ICC profile but no ICC profile data was found. " + f"The output PDF may not match the input PDF visually. {self}" + ) + return None + try: + if icc.profile.xcolor_space == 'GRAY': + return 1 + elif icc.profile.xcolor_space == 'CMYK': + return 4 + else: + return 3 + except AttributeError: + return None + + @property + def name(self): + """Name of the image as it appears in the PDF.""" + return self._name + + @property + def type_(self): + """Type of image, either 'image' or 'stencil'.""" + return self._type + + @property + def width(self) -> int: + """Width of the image in pixels.""" + return self._width + + @property + def height(self) -> int: + """Height of the image in pixels.""" + return self._height + + @property + def bpc(self): + """Bits per component.""" + return self._bpc + + @property + def color(self): + """Colorspace of the image.""" + return self._color if self._color is not None else '?' + + @property + def comp(self): + """Number of components/channels in the image.""" + return self._comp if self._comp is not None else '?' + + @property + def enc(self): + """Encoding of the image.""" + return self._enc if self._enc is not None else 'image' + + @property + def renderable(self) -> bool: + """Whether the image is renderable. + + Some PDFs in the wild have invalid images that are not renderable, + due to unusual dimensions. + + Stencil masks are not also not renderable, since they are not + drawn, but rather they control how rendering happens. + """ + return ( + self.dpi.is_finite + and self.width >= 0 + and self.height >= 0 + and self.type_ != 'stencil' + ) + + @property + def dpi(self) -> Resolution: + """Dots per inch of the image. + + Calculated based on where and how the image is drawn in the PDF. + """ + return _get_dpi(self._shorthand, (self._width, self._height)) + + @property + def printed_area(self) -> float: + """Physical area of the image in square inches.""" + if not self.renderable: + return 0.0 + return float((self.width / self.dpi.x) * (self.height / self.dpi.y)) + + def __repr__(self): + """Return a string representation of the image.""" + return ( + f"" + ) + + +def _find_inline_images(contentsinfo: ContentsInfo) -> Iterator[ImageInfo]: + """Find inline images in the contentstream.""" + for n, inline in enumerate(contentsinfo.inline_images): + yield ImageInfo( + name=f'inline-{n:02d}', shorthand=inline.shorthand, inline=inline.iimage + ) + + +def _image_xobjects(container) -> Iterator[tuple[Object, str]]: + """Search for all XObject-based images in the container. + + Usually the container is a page, but it could also be a Form XObject + that contains images. Filter out the Form XObjects which are dealt with + elsewhere. + + Generate a sequence of tuples (image, xobj container), where container, + where xobj is the name of the object and image is the object itself, + since the object does not know its own name. + + """ + if Name.Resources not in container: + return + resources = container[Name.Resources] + if Name.XObject not in resources: + return + for key, candidate in resources[Name.XObject].items(): + if candidate is None or Name.Subtype not in candidate: + continue + if candidate[Name.Subtype] == Name.Image: + pdfimage = candidate + yield (pdfimage, key) + + +def _find_regular_images( + container: Object, contentsinfo: ContentsInfo +) -> Iterator[ImageInfo]: + """Find images stored in the container's /Resources /XObject. + + Usually the container is a page, but it could also be a Form XObject + that contains images. + + Generates images with their DPI at time of drawing. + """ + for pdfimage, xobj in _image_xobjects(container): + if xobj not in contentsinfo.name_index: + continue + for draw in contentsinfo.name_index[xobj]: + if draw.stack_depth == 0 and _is_unit_square(draw.shorthand): + # At least one PDF in the wild (and test suite) draws an image + # when the graphics stack depth is 0, meaning that the image + # gets drawn into a square of 1x1 PDF units (or 1/72", + # or 0.35 mm). The equivalent DPI will be >100,000. Exclude + # these from our DPI calculation for the page. + continue + + yield ImageInfo(name=draw.name, pdfimage=pdfimage, shorthand=draw.shorthand) + + +def _find_form_xobject_images(pdf: Pdf, container: Object, contentsinfo: ContentsInfo): + """Find any images that are in Form XObjects in the container. + + The container may be a page, or a parent Form XObject. + + """ + if Name.Resources not in container: + return + resources = container[Name.Resources] + if Name.XObject not in resources: + return + xobjs = resources[Name.XObject].as_dict() + for xobj in xobjs: + candidate = xobjs[xobj] + if candidate is None or candidate.get(Name.Subtype) != Name.Form: + continue + + form_xobject = candidate + for settings in contentsinfo.xobject_settings: + if settings.name != xobj: + continue + + # Find images once for each time this Form XObject is drawn. + # This could be optimized to cache the multiple drawing events + # but in practice both Form XObjects and multiple drawing of the + # same object are both very rare. + ctm_shorthand = settings.shorthand + yield from _process_content_streams( + pdf=pdf, container=form_xobject, shorthand=ctm_shorthand + ) + + +def _process_content_streams( + *, pdf: Pdf, container: Object, shorthand=None +) -> Iterator[VectorMarker | TextMarker | ImageInfo]: + """Find all individual instances of images drawn in the container. + + Usually the container is a page, but it may also be a Form XObject. + + On a typical page images are stored inline or as regular images + in an XObject. + + Form XObjects may include inline images, XObject images, + and recursively, other Form XObjects; and also vector graphic objects. + + Every instance of an image being drawn somewhere is flattened and + treated as a unique image, since if the same image is drawn multiple times + on one page it may be drawn at differing resolutions, and our objective + is to find the resolution at which the page can be rastered without + downsampling. + + """ + if container.get(Name.Type) == Name.Page and Name.Contents in container: + initial_shorthand = shorthand or UNIT_SQUARE + elif ( + container.get(Name.Type) == Name.XObject + and container[Name.Subtype] == Name.Form + ): + # Set the CTM to the state it was when the "Do" operator was + # encountered that is drawing this instance of the Form XObject + ctm = Matrix(shorthand) if shorthand else Matrix() + + # A Form XObject may provide its own matrix to map form space into + # user space. Get this if one exists + form_shorthand = container.get(Name.Matrix, Matrix()) + form_matrix = Matrix(form_shorthand) + + # Concatenate form matrix with CTM to ensure CTM is correct for + # drawing this instance of the XObject + ctm = form_matrix @ ctm + initial_shorthand = ctm.shorthand + else: + return + + contentsinfo = _interpret_contents(container, initial_shorthand) + + if contentsinfo.found_vector: + yield VectorMarker() + if contentsinfo.found_text: + yield TextMarker() + yield from _find_inline_images(contentsinfo) + yield from _find_regular_images(container, contentsinfo) + yield from _find_form_xobject_images(pdf, container, contentsinfo) diff --git a/src/ocrmypdf/pdfinfo/_types.py b/src/ocrmypdf/pdfinfo/_types.py new file mode 100644 index 00000000..2bee4d88 --- /dev/null +++ b/src/ocrmypdf/pdfinfo/_types.py @@ -0,0 +1,83 @@ +# SPDX-FileCopyrightText: 2022 James R. Barlow +# SPDX-License-Identifier: MPL-2.0 +"""PDF type definitions and constants.""" + +from __future__ import annotations + +from enum import Enum, auto + + +class Colorspace(Enum): + """Description of common image colorspaces in a PDF.""" + + # pylint: disable=invalid-name + gray = auto() + rgb = auto() + cmyk = auto() + lab = auto() + icc = auto() + index = auto() + sep = auto() + devn = auto() + pattern = auto() + jpeg2000 = auto() + + +class Encoding(Enum): + """Description of common image encodings in a PDF.""" + + # pylint: disable=invalid-name + ccitt = auto() + jpeg = auto() + jpeg2000 = auto() + jbig2 = auto() + asciihex = auto() + ascii85 = auto() + lzw = auto() + flate = auto() + runlength = auto() + + +FloatRect = tuple[float, float, float, float] + +FRIENDLY_COLORSPACE: dict[str, Colorspace] = { + '/DeviceGray': Colorspace.gray, + '/CalGray': Colorspace.gray, + '/DeviceRGB': Colorspace.rgb, + '/CalRGB': Colorspace.rgb, + '/DeviceCMYK': Colorspace.cmyk, + '/Lab': Colorspace.lab, + '/ICCBased': Colorspace.icc, + '/Indexed': Colorspace.index, + '/Separation': Colorspace.sep, + '/DeviceN': Colorspace.devn, + '/Pattern': Colorspace.pattern, + '/G': Colorspace.gray, # Abbreviations permitted in inline images + '/RGB': Colorspace.rgb, + '/CMYK': Colorspace.cmyk, + '/I': Colorspace.index, +} + +FRIENDLY_ENCODING: dict[str, Encoding] = { + '/CCITTFaxDecode': Encoding.ccitt, + '/DCTDecode': Encoding.jpeg, + '/JPXDecode': Encoding.jpeg2000, + '/JBIG2Decode': Encoding.jbig2, + '/CCF': Encoding.ccitt, # Abbreviations permitted in inline images + '/DCT': Encoding.jpeg, + '/AHx': Encoding.asciihex, + '/A85': Encoding.ascii85, + '/LZW': Encoding.lzw, + '/Fl': Encoding.flate, + '/RL': Encoding.runlength, +} + +FRIENDLY_COMP: dict[Colorspace, int] = { + Colorspace.gray: 1, + Colorspace.rgb: 3, + Colorspace.cmyk: 4, + Colorspace.lab: 3, + Colorspace.index: 1, +} + +UNIT_SQUARE = (1.0, 0.0, 0.0, 1.0, 0.0, 0.0) diff --git a/src/ocrmypdf/pdfinfo/_worker.py b/src/ocrmypdf/pdfinfo/_worker.py new file mode 100644 index 00000000..f0da8926 --- /dev/null +++ b/src/ocrmypdf/pdfinfo/_worker.py @@ -0,0 +1,143 @@ +# SPDX-FileCopyrightText: 2022 James R. Barlow +# SPDX-License-Identifier: MPL-2.0 +"""PDF page info worker process handling.""" + +from __future__ import annotations + +import atexit +import logging +from collections.abc import Container, Sequence +from contextlib import contextmanager +from functools import partial +from pathlib import Path +from typing import TYPE_CHECKING + +from pikepdf import Pdf + +from ocrmypdf._concurrent import Executor +from ocrmypdf._progressbar import ProgressBar +from ocrmypdf.exceptions import InputFileError +from ocrmypdf.helpers import available_cpu_count, pikepdf_enable_mmap + +if TYPE_CHECKING: + from ocrmypdf.pdfinfo.info import PageInfo + from ocrmypdf.pdfinfo.layout import PdfMinerState + +logger = logging.getLogger() + +worker_pdf = None # pylint: disable=invalid-name + + +def _pdf_pageinfo_sync_init(pdf: Pdf, infile: Path, pdfminer_loglevel): + global worker_pdf # pylint: disable=global-statement,invalid-name + pikepdf_enable_mmap() + + logging.getLogger('pdfminer').setLevel(pdfminer_loglevel) + + # If the pdf is not opened, open a copy for our worker process to use + if pdf is None: + worker_pdf = Pdf.open(infile) + + def on_process_close(): + worker_pdf.close() + + # Close when this process exits + atexit.register(on_process_close) + + +@contextmanager +def _pdf_pageinfo_sync_pdf(thread_pdf: Pdf | None, infile: Path): + if thread_pdf is not None: + yield thread_pdf + elif worker_pdf is not None: + yield worker_pdf + else: + with Pdf.open(infile) as pdf: + yield pdf + + +def _pdf_pageinfo_sync( + pageno: int, + thread_pdf: Pdf | None, + infile: Path, + check_pages: Container[int], + detailed_analysis: bool, + miner_state: PdfMinerState | None, +) -> PageInfo: + # Import here to avoid circular import - info.py imports this module, + # but PageInfo is defined in info.py + from ocrmypdf.pdfinfo.info import PageInfo + + with _pdf_pageinfo_sync_pdf(thread_pdf, infile) as pdf: + return PageInfo( + pdf, pageno, infile, check_pages, detailed_analysis, miner_state + ) + + +def _pdf_pageinfo_concurrent( + pdf, + executor: Executor, + max_workers: int, + use_threads: bool, + infile, + progbar, + check_pages, + detailed_analysis: bool = False, + miner_state: PdfMinerState | None = None, +) -> Sequence[PageInfo | None]: + pages: list[PageInfo | None] = [None] * len(pdf.pages) + + def update_pageinfo(page: PageInfo, pbar: ProgressBar): + if not page: + raise InputFileError("Could read a page in the PDF") + pages[page.pageno] = page + pbar.update() + + if max_workers is None: + max_workers = available_cpu_count() + + total = len(pdf.pages) + + n_workers = min(1 + len(pages) // 4, max_workers) + if n_workers == 1: + # If we decided on only one worker, there is no point in using + # a separate process. + use_threads = True + + if use_threads and n_workers > 1: + # If we are using threads, there is no point in using more than one + # worker thread - they will just fight over the GIL. + n_workers = 1 + + # If we use a thread, we can pass the already-open Pdf for them to use + # If we use processes, we pass a None which tells the init function to open its + # own + initial_pdf = pdf if use_threads else None + + contexts = ( + (n, initial_pdf, infile, check_pages, detailed_analysis, miner_state) + for n in range(total) + ) + assert n_workers == 1 if use_threads else n_workers >= 1, "Not multithreadable" + logger.debug( + f"Gathering info with {n_workers} " + + ('thread' if use_threads else 'process') + + " workers" + ) + executor( + use_threads=use_threads, + max_workers=n_workers, + progress_kwargs=dict( + total=total, desc="Scanning contents", unit='page', disable=not progbar + ), + worker_initializer=partial( + _pdf_pageinfo_sync_init, + initial_pdf, + infile, + logging.getLogger('pdfminer').level, + ), + task=_pdf_pageinfo_sync, + task_arguments=contexts, + task_finished=update_pageinfo, + ) + return pages diff --git a/src/ocrmypdf/pdfinfo/info.py b/src/ocrmypdf/pdfinfo/info.py index 31a77a22..c10c295a 100644 --- a/src/ocrmypdf/pdfinfo/info.py +++ b/src/ocrmypdf/pdfinfo/info.py @@ -6,677 +6,34 @@ from __future__ import annotations -import atexit import logging -import re import statistics -from collections import defaultdict -from collections.abc import Callable, Container, Iterable, Iterator, Mapping, Sequence -from contextlib import contextmanager, nullcontext +from collections.abc import Callable, Container, Iterable, Iterator +from contextlib import nullcontext from decimal import Decimal -from enum import Enum, auto -from functools import partial -from math import hypot, inf, isclose from os import PathLike from pathlib import Path from typing import NamedTuple -from warnings import warn from pdfminer.layout import LTPage, LTTextBox -from pikepdf import ( - Dictionary, - Matrix, - Name, - Object, - Page, - Pdf, - PdfImage, - PdfInlineImage, - Stream, - UnsupportedImageTypeError, - parse_content_stream, -) +from pikepdf import Name, Page, Pdf from ocrmypdf._concurrent import Executor, SerialExecutor -from ocrmypdf._progressbar import ProgressBar -from ocrmypdf.exceptions import EncryptedPdfError, InputFileError -from ocrmypdf.helpers import Resolution, available_cpu_count, pikepdf_enable_mmap +from ocrmypdf.exceptions import EncryptedPdfError +from ocrmypdf.helpers import Resolution +from ocrmypdf.pdfinfo._contentstream import TextboxInfo, TextMarker, VectorMarker +from ocrmypdf.pdfinfo._image import ImageInfo, _process_content_streams +from ocrmypdf.pdfinfo._types import FloatRect +from ocrmypdf.pdfinfo._worker import _pdf_pageinfo_concurrent from ocrmypdf.pdfinfo.layout import ( LTStateAwareChar, PdfMinerState, - get_page_analysis, get_text_boxes, ) logger = logging.getLogger() -class Colorspace(Enum): - """Description of common image colorspaces in a PDF.""" - - # pylint: disable=invalid-name - gray = auto() - rgb = auto() - cmyk = auto() - lab = auto() - icc = auto() - index = auto() - sep = auto() - devn = auto() - pattern = auto() - jpeg2000 = auto() - - -class Encoding(Enum): - """Description of common image encodings in a PDF.""" - - # pylint: disable=invalid-name - ccitt = auto() - jpeg = auto() - jpeg2000 = auto() - jbig2 = auto() - asciihex = auto() - ascii85 = auto() - lzw = auto() - flate = auto() - runlength = auto() - - -FloatRect = tuple[float, float, float, float] - -FRIENDLY_COLORSPACE: dict[str, Colorspace] = { - '/DeviceGray': Colorspace.gray, - '/CalGray': Colorspace.gray, - '/DeviceRGB': Colorspace.rgb, - '/CalRGB': Colorspace.rgb, - '/DeviceCMYK': Colorspace.cmyk, - '/Lab': Colorspace.lab, - '/ICCBased': Colorspace.icc, - '/Indexed': Colorspace.index, - '/Separation': Colorspace.sep, - '/DeviceN': Colorspace.devn, - '/Pattern': Colorspace.pattern, - '/G': Colorspace.gray, # Abbreviations permitted in inline images - '/RGB': Colorspace.rgb, - '/CMYK': Colorspace.cmyk, - '/I': Colorspace.index, -} - -FRIENDLY_ENCODING: dict[str, Encoding] = { - '/CCITTFaxDecode': Encoding.ccitt, - '/DCTDecode': Encoding.jpeg, - '/JPXDecode': Encoding.jpeg2000, - '/JBIG2Decode': Encoding.jbig2, - '/CCF': Encoding.ccitt, # Abbreviations permitted in inline images - '/DCT': Encoding.jpeg, - '/AHx': Encoding.asciihex, - '/A85': Encoding.ascii85, - '/LZW': Encoding.lzw, - '/Fl': Encoding.flate, - '/RL': Encoding.runlength, -} - -FRIENDLY_COMP: dict[Colorspace, int] = { - Colorspace.gray: 1, - Colorspace.rgb: 3, - Colorspace.cmyk: 4, - Colorspace.lab: 3, - Colorspace.index: 1, -} - - -UNIT_SQUARE = (1.0, 0.0, 0.0, 1.0, 0.0, 0.0) - - -def _is_unit_square(shorthand): - values = map(float, shorthand) - pairwise = zip(values, UNIT_SQUARE) - return all(isclose(a, b, rel_tol=1e-3) for a, b in pairwise) - - -class XobjectSettings(NamedTuple): - """Info about an XObject found in a PDF.""" - - name: str - shorthand: tuple[float, float, float, float, float, float] - stack_depth: int - - -class InlineSettings(NamedTuple): - """Info about an inline image found in a PDF.""" - - iimage: PdfInlineImage - shorthand: tuple[float, float, float, float, float, float] - stack_depth: int - - -class ContentsInfo(NamedTuple): - """Info about various objects found in a PDF.""" - - xobject_settings: list[XobjectSettings] - inline_images: list[InlineSettings] - found_vector: bool - found_text: bool - name_index: Mapping[str, list[XobjectSettings]] - - -class TextboxInfo(NamedTuple): - """Info about a text box found in a PDF.""" - - bbox: tuple[float, float, float, float] - is_visible: bool - is_corrupt: bool - - -class VectorMarker: - """Sentinel indicating vector drawing operations were found on a page.""" - - -class TextMarker: - """Sentinel indicating text drawing operations were found on a page.""" - - -def _normalize_stack(graphobjs): - """Convert runs of qQ's in the stack into single graphobjs.""" - for operands, operator in graphobjs: - operator = str(operator) - if re.match(r'Q*q+$', operator): # Zero or more Q, one or more q - for char in operator: # Split into individual - yield ([], char) # Yield individual - else: - yield (operands, operator) - - -def _interpret_contents(contentstream: Object, initial_shorthand=UNIT_SQUARE): - """Interpret the PDF content stream. - - The stack represents the state of the PDF graphics stack. We are only - interested in the current transformation matrix (CTM) so we only track - this object; a full implementation would need to track many other items. - - The CTM is initialized to the mapping from user space to device space. - PDF units are 1/72". In a PDF viewer or printer this matrix is initialized - to the transformation to device space. For example if set to - (1/72, 0, 0, 1/72, 0, 0) then all units would be calculated in inches. - - Images are always considered to be (0, 0) -> (1, 1). Before drawing an - image there should be a 'cm' that sets up an image coordinate system - where drawing from (0, 0) -> (1, 1) will draw on the desired area of the - page. - - PDF units suit our needs so we initialize ctm to the identity matrix. - - According to the PDF specification, the maximum stack depth is 32. Other - viewers tolerate some amount beyond this. We issue a warning if the - stack depth exceeds the spec limit and set a hard limit beyond this to - bound our memory requirements. If the stack underflows behavior is - undefined in the spec, but we just pretend nothing happened and leave the - CTM unchanged. - """ - stack = [] - ctm = Matrix(initial_shorthand) - xobject_settings: list[XobjectSettings] = [] - inline_images: list[InlineSettings] = [] - name_index = defaultdict(lambda: []) - found_vector = False - found_text = False - vector_ops = set('S s f F f* B B* b b*'.split()) - text_showing_ops = set("""TJ Tj " '""".split()) - image_ops = set('BI ID EI q Q Do cm'.split()) - operator_whitelist = ' '.join(vector_ops | text_showing_ops | image_ops) - - for n, graphobj in enumerate( - _normalize_stack(parse_content_stream(contentstream, operator_whitelist)) - ): - operands, operator = graphobj - if operator == 'q': - stack.append(ctm) - if len(stack) > 32: # See docstring - if len(stack) > 128: - raise RuntimeError( - f"PDF graphics stack overflowed hard limit at operator {n}" - ) - warn("PDF graphics stack overflowed spec limit") - elif operator == 'Q': - try: - ctm = stack.pop() - except IndexError: - # Keeping the ctm the same seems to be the only sensible thing - # to do. Just pretend nothing happened, keep calm and carry on. - warn("PDF graphics stack underflowed - PDF may be malformed") - elif operator == 'cm': - try: - ctm = Matrix(operands) @ ctm - except ValueError: - raise InputFileError( - "PDF content stream is corrupt - this PDF is malformed. " - "Use a PDF editor that is capable of visually inspecting the PDF." - ) - elif operator == 'Do': - image_name = operands[0] - settings = XobjectSettings( - name=image_name, shorthand=ctm.shorthand, stack_depth=len(stack) - ) - xobject_settings.append(settings) - name_index[str(image_name)].append(settings) - elif operator == 'INLINE IMAGE': # BI/ID/EI are grouped into this - iimage = operands[0] - inline = InlineSettings( - iimage=iimage, shorthand=ctm.shorthand, stack_depth=len(stack) - ) - inline_images.append(inline) - elif operator in vector_ops: - found_vector = True - elif operator in text_showing_ops: - found_text = True - - return ContentsInfo( - xobject_settings=xobject_settings, - inline_images=inline_images, - found_vector=found_vector, - found_text=found_text, - name_index=name_index, - ) - - -def _get_dpi(ctm_shorthand, image_size) -> Resolution: - """Given the transformation matrix and image size, find the image DPI. - - PDFs do not include image resolution information within image data. - Instead, the PDF page content stream describes the location where the - image will be rasterized, and the effective resolution is the ratio of the - pixel size to raster target size. - - Normally a scanned PDF has the paper size set appropriately but this is - not guaranteed. The most common case is a cropped image will change the - page size (/CropBox) without altering the page content stream. That means - it is not sufficient to assume that the image fills the page, even though - that is the most common case. - - A PDF image may be scaled (always), cropped, translated, rotated in place - to an arbitrary angle (rarely) and skewed. Only equal area mappings can - be expressed, that is, it is not necessary to consider distortions where - the effective DPI varies with position. - - To determine the image scale, transform an offset axis vector v0 (0, 0), - width-axis vector v0 (1, 0), height-axis vector vh (0, 1) with the matrix, - which gives the dimensions of the image in PDF units. From there we can - compare to actual image dimensions. PDF uses - row vector * matrix_transposed unlike the traditional - matrix * column vector. - - The offset, width and height vectors can be combined in a matrix and - multiplied by the transform matrix. Then we want to calculated - magnitude(width_vector - offset_vector) - and - magnitude(height_vector - offset_vector) - - When the above is worked out algebraically, the effect of translation - cancels out, and the vector magnitudes become functions of the nonzero - transformation matrix indices. The results of the derivation are used - in this code. - - pdfimages -list does calculate the DPI in some way that is not completely - naive, but it does not get the DPI of rotated images right, so cannot be - used anymore to validate this. Photoshop works, or using Acrobat to - rotate the image back to normal. - - It does not matter if the image is partially cropped, or even out of the - /MediaBox. - - """ - a, b, c, d, _, _ = ctm_shorthand # pylint: disable=invalid-name - - # Calculate the width and height of the image in PDF units - image_drawn = hypot(a, b), hypot(c, d) - - def calc(drawn, pixels, inches_per_pt=72.0): - # The scale of the image is pixels per unit of default user space (1/72") - scale = pixels / drawn if drawn != 0 else inf - dpi = scale * inches_per_pt - return dpi - - dpi_w, dpi_h = (calc(image_drawn[n], image_size[n]) for n in range(2)) - return Resolution(dpi_w, dpi_h) - - -class ImageInfo: - """Information about an image found in a PDF. - - This gathers information from pikepdf and pdfminer.six, and is pickle-able - so that it can be passed to a worker process, unlike objects from those - libraries. - """ - - DPI_PREC = Decimal('1.000') - - _comp: int | None - _name: str - - def __init__( - self, - *, - name='', - pdfimage: Object | None = None, - inline: PdfInlineImage | None = None, - shorthand=None, - ): - """Initialize an ImageInfo.""" - self._name = str(name) - self._shorthand = shorthand - - pim: PdfInlineImage | PdfImage - - if inline is not None: - self._origin = 'inline' - pim = inline - elif pdfimage is not None and isinstance(pdfimage, Stream): - self._origin = 'xobject' - pim = PdfImage(pdfimage) - else: - raise ValueError("Either pdfimage or inline must be set") - - self._width = pim.width - self._height = pim.height - if (smask := pim.obj.get(Name.SMask, None)) is not None: - # SMask is pretty much an alpha channel, but in PDF it's possible - # for channel to have different dimensions than the image - # itself. Some PDF writers use this to create a grayscale stencil - # mask. For our purposes, the effective size is the size of the - # larger component (image or smask). - if isinstance(smask, Stream | Dictionary): - self._width = max(smask.get(Name.Width, 0), self._width) - self._height = max(smask.get(Name.Height, 0), self._height) - if (mask := pim.obj.get(Name.Mask, None)) is not None: - # If the image has a /Mask entry, it has an explicit mask. - # /Mask can be a Stream or an Array. If it's a Stream, - # use its /Width and /Height if they are larger than the main - # image's. - if isinstance(mask, Stream | Dictionary): - self._width = max(mask.get(Name.Width, 0), self._width) - self._height = max(mask.get(Name.Height, 0), self._height) - - # If /ImageMask is true, then this image is a stencil mask - # (Images that draw with this stencil mask will have a reference to - # it in their /Mask, but we don't actually need that information) - if pim.image_mask: - self._type = 'stencil' - else: - self._type = 'image' - - self._bpc = int(pim.bits_per_component) - try: - self._enc = FRIENDLY_ENCODING.get(pim.filters[0]) - except IndexError: - self._enc = None - - try: - self._color = FRIENDLY_COLORSPACE.get(pim.colorspace or '') - except NotImplementedError: - self._color = None - if self._enc == Encoding.jpeg2000: - self._color = Colorspace.jpeg2000 - - self._comp = None - if self._color == Colorspace.icc and isinstance(pim, PdfImage): - self._comp = self._init_icc(pim) - else: - if isinstance(self._color, Colorspace): - self._comp = FRIENDLY_COMP.get(self._color) - # Bit of a hack... infer grayscale if component count is uncertain - # but encoding only supports monochrome. - if self._comp is None and self._enc in (Encoding.ccitt, Encoding.jbig2): - self._comp = FRIENDLY_COMP[Colorspace.gray] - - def _init_icc(self, pim: PdfImage): - try: - icc = pim.icc - except UnsupportedImageTypeError as e: - logger.warning( - f"An image with a corrupt or unreadable ICC profile was found. " - f"Output PDF may not match the input PDF visually: {e}. {self}" - ) - return None - # Check the ICC profile to determine actual colorspace - if icc is None or not hasattr(icc, 'profile'): - logger.warning( - f"An image with an ICC profile but no ICC profile data was found. " - f"The output PDF may not match the input PDF visually. {self}" - ) - return None - try: - if icc.profile.xcolor_space == 'GRAY': - return 1 - elif icc.profile.xcolor_space == 'CMYK': - return 4 - else: - return 3 - except AttributeError: - return None - - @property - def name(self): - """Name of the image as it appears in the PDF.""" - return self._name - - @property - def type_(self): - """Type of image, either 'image' or 'stencil'.""" - return self._type - - @property - def width(self) -> int: - """Width of the image in pixels.""" - return self._width - - @property - def height(self) -> int: - """Height of the image in pixels.""" - return self._height - - @property - def bpc(self): - """Bits per component.""" - return self._bpc - - @property - def color(self): - """Colorspace of the image.""" - return self._color if self._color is not None else '?' - - @property - def comp(self): - """Number of components/channels in the image.""" - return self._comp if self._comp is not None else '?' - - @property - def enc(self): - """Encoding of the image.""" - return self._enc if self._enc is not None else 'image' - - @property - def renderable(self) -> bool: - """Whether the image is renderable. - - Some PDFs in the wild have invalid images that are not renderable, - due to unusual dimensions. - - Stencil masks are not also not renderable, since they are not - drawn, but rather they control how rendering happens. - """ - return ( - self.dpi.is_finite - and self.width >= 0 - and self.height >= 0 - and self.type_ != 'stencil' - ) - - @property - def dpi(self) -> Resolution: - """Dots per inch of the image. - - Calculated based on where and how the image is drawn in the PDF. - """ - return _get_dpi(self._shorthand, (self._width, self._height)) - - @property - def printed_area(self) -> float: - """Physical area of the image in square inches.""" - if not self.renderable: - return 0.0 - return float((self.width / self.dpi.x) * (self.height / self.dpi.y)) - - def __repr__(self): - """Return a string representation of the image.""" - return ( - f"" - ) - - -def _find_inline_images(contentsinfo: ContentsInfo) -> Iterator[ImageInfo]: - """Find inline images in the contentstream.""" - for n, inline in enumerate(contentsinfo.inline_images): - yield ImageInfo( - name=f'inline-{n:02d}', shorthand=inline.shorthand, inline=inline.iimage - ) - - -def _image_xobjects(container) -> Iterator[tuple[Object, str]]: - """Search for all XObject-based images in the container. - - Usually the container is a page, but it could also be a Form XObject - that contains images. Filter out the Form XObjects which are dealt with - elsewhere. - - Generate a sequence of tuples (image, xobj container), where container, - where xobj is the name of the object and image is the object itself, - since the object does not know its own name. - - """ - if Name.Resources not in container: - return - resources = container[Name.Resources] - if Name.XObject not in resources: - return - for key, candidate in resources[Name.XObject].items(): - if candidate is None or Name.Subtype not in candidate: - continue - if candidate[Name.Subtype] == Name.Image: - pdfimage = candidate - yield (pdfimage, key) - - -def _find_regular_images( - container: Object, contentsinfo: ContentsInfo -) -> Iterator[ImageInfo]: - """Find images stored in the container's /Resources /XObject. - - Usually the container is a page, but it could also be a Form XObject - that contains images. - - Generates images with their DPI at time of drawing. - """ - for pdfimage, xobj in _image_xobjects(container): - if xobj not in contentsinfo.name_index: - continue - for draw in contentsinfo.name_index[xobj]: - if draw.stack_depth == 0 and _is_unit_square(draw.shorthand): - # At least one PDF in the wild (and test suite) draws an image - # when the graphics stack depth is 0, meaning that the image - # gets drawn into a square of 1x1 PDF units (or 1/72", - # or 0.35 mm). The equivalent DPI will be >100,000. Exclude - # these from our DPI calculation for the page. - continue - - yield ImageInfo(name=draw.name, pdfimage=pdfimage, shorthand=draw.shorthand) - - -def _find_form_xobject_images(pdf: Pdf, container: Object, contentsinfo: ContentsInfo): - """Find any images that are in Form XObjects in the container. - - The container may be a page, or a parent Form XObject. - - """ - if Name.Resources not in container: - return - resources = container[Name.Resources] - if Name.XObject not in resources: - return - xobjs = resources[Name.XObject].as_dict() - for xobj in xobjs: - candidate = xobjs[xobj] - if candidate is None or candidate.get(Name.Subtype) != Name.Form: - continue - - form_xobject = candidate - for settings in contentsinfo.xobject_settings: - if settings.name != xobj: - continue - - # Find images once for each time this Form XObject is drawn. - # This could be optimized to cache the multiple drawing events - # but in practice both Form XObjects and multiple drawing of the - # same object are both very rare. - ctm_shorthand = settings.shorthand - yield from _process_content_streams( - pdf=pdf, container=form_xobject, shorthand=ctm_shorthand - ) - - -def _process_content_streams( - *, pdf: Pdf, container: Object, shorthand=None -) -> Iterator[VectorMarker | TextMarker | ImageInfo]: - """Find all individual instances of images drawn in the container. - - Usually the container is a page, but it may also be a Form XObject. - - On a typical page images are stored inline or as regular images - in an XObject. - - Form XObjects may include inline images, XObject images, - and recursively, other Form XObjects; and also vector graphic objects. - - Every instance of an image being drawn somewhere is flattened and - treated as a unique image, since if the same image is drawn multiple times - on one page it may be drawn at differing resolutions, and our objective - is to find the resolution at which the page can be rastered without - downsampling. - - """ - if container.get(Name.Type) == Name.Page and Name.Contents in container: - initial_shorthand = shorthand or UNIT_SQUARE - elif ( - container.get(Name.Type) == Name.XObject - and container[Name.Subtype] == Name.Form - ): - # Set the CTM to the state it was when the "Do" operator was - # encountered that is drawing this instance of the Form XObject - ctm = Matrix(shorthand) if shorthand else Matrix() - - # A Form XObject may provide its own matrix to map form space into - # user space. Get this if one exists - form_shorthand = container.get(Name.Matrix, Matrix()) - form_matrix = Matrix(form_shorthand) - - # Concatenate form matrix with CTM to ensure CTM is correct for - # drawing this instance of the XObject - ctm = form_matrix @ ctm - initial_shorthand = ctm.shorthand - else: - return - - contentsinfo = _interpret_contents(container, initial_shorthand) - - if contentsinfo.found_vector: - yield VectorMarker() - if contentsinfo.found_text: - yield TextMarker() - yield from _find_inline_images(contentsinfo) - yield from _find_regular_images(container, contentsinfo) - yield from _find_form_xobject_images(pdf, container, contentsinfo) - - def _page_has_text(text_blocks: Iterable[FloatRect], page_width, page_height) -> bool: """Smarter text detection that ignores text in margins.""" pw, ph = float(page_width), float(page_height) # pylint: disable=invalid-name @@ -723,120 +80,6 @@ def simplify_textboxes( yield TextboxInfo(box.bbox, visible, corrupt) -worker_pdf = None # pylint: disable=invalid-name - - -def _pdf_pageinfo_sync_init(pdf: Pdf, infile: Path, pdfminer_loglevel): - global worker_pdf # pylint: disable=global-statement,invalid-name - pikepdf_enable_mmap() - - logging.getLogger('pdfminer').setLevel(pdfminer_loglevel) - - # If the pdf is not opened, open a copy for our worker process to use - if pdf is None: - worker_pdf = Pdf.open(infile) - - def on_process_close(): - worker_pdf.close() - - # Close when this process exits - atexit.register(on_process_close) - - -@contextmanager -def _pdf_pageinfo_sync_pdf(thread_pdf: Pdf | None, infile: Path): - if thread_pdf is not None: - yield thread_pdf - elif worker_pdf is not None: - yield worker_pdf - else: - with Pdf.open(infile) as pdf: - yield pdf - - -def _pdf_pageinfo_sync( - pageno: int, - thread_pdf: Pdf | None, - infile: Path, - check_pages: Container[int], - detailed_analysis: bool, - miner_state: PdfMinerState | None, -) -> PageInfo: - with _pdf_pageinfo_sync_pdf(thread_pdf, infile) as pdf: - return PageInfo( - pdf, pageno, infile, check_pages, detailed_analysis, miner_state - ) - - -def _pdf_pageinfo_concurrent( - pdf, - executor: Executor, - max_workers: int, - use_threads: bool, - infile, - progbar, - check_pages, - detailed_analysis: bool = False, - miner_state: PdfMinerState | None = None, -) -> Sequence[PageInfo | None]: - pages: list[PageInfo | None] = [None] * len(pdf.pages) - - def update_pageinfo(page: PageInfo, pbar: ProgressBar): - if not page: - raise InputFileError("Could read a page in the PDF") - pages[page.pageno] = page - pbar.update() - - if max_workers is None: - max_workers = available_cpu_count() - - total = len(pdf.pages) - - n_workers = min(1 + len(pages) // 4, max_workers) - if n_workers == 1: - # If we decided on only one worker, there is no point in using - # a separate process. - use_threads = True - - if use_threads and n_workers > 1: - # If we are using threads, there is no point in using more than one - # worker thread - they will just fight over the GIL. - n_workers = 1 - - # If we use a thread, we can pass the already-open Pdf for them to use - # If we use processes, we pass a None which tells the init function to open its - # own - initial_pdf = pdf if use_threads else None - - contexts = ( - (n, initial_pdf, infile, check_pages, detailed_analysis, miner_state) - for n in range(total) - ) - assert n_workers == 1 if use_threads else n_workers >= 1, "Not multithreadable" - logger.debug( - f"Gathering info with {n_workers} " - + ('thread' if use_threads else 'process') - + " workers" - ) - executor( - use_threads=use_threads, - max_workers=n_workers, - progress_kwargs=dict( - total=total, desc="Scanning contents", unit='page', disable=not progbar - ), - worker_initializer=partial( - _pdf_pageinfo_sync_init, - initial_pdf, - infile, - logging.getLogger('pdfminer').level, - ), - task=_pdf_pageinfo_sync, - task_arguments=contexts, - task_finished=update_pageinfo, - ) - return pages - - class PageResolutionProfile(NamedTuple): """Information about the resolutions of a page.""" @@ -1061,12 +304,10 @@ class PageInfo: obj: TextboxInfo, want_visible: bool | None, want_corrupt: bool | None ) -> bool: result = True - if want_visible is not None: - if obj.is_visible != want_visible: - result = False - if want_corrupt is not None: - if obj.is_corrupt != want_corrupt: - result = False + if want_visible is not None and obj.is_visible != want_visible: + result = False + if want_corrupt is not None and obj.is_corrupt != want_corrupt: + result = False return result if not self._textboxes: @@ -1199,9 +440,10 @@ class PdfInfo: ) self._needs_rendering = pdf.Root.get(Name.NeedsRendering, False) if Name.AcroForm in pdf.Root: - if len(pdf.Root.AcroForm.get(Name.Fields, [])) > 0: - self._has_acroform = True - elif Name.XFA in pdf.Root.AcroForm: + if ( + len(pdf.Root.AcroForm.get(Name.Fields, [])) > 0 + or Name.XFA in pdf.Root.AcroForm + ): self._has_acroform = True self._has_signature = bool(pdf.Root.AcroForm.get(Name.SigFlags, 0) & 1) self._is_tagged = bool( @@ -1209,7 +451,7 @@ class PdfInfo: ) @property - def pages(self) -> Sequence[PageInfo | None]: + def pages(self) -> list[PageInfo | None]: """Return list of PageInfo objects, one per page in the PDF.""" return self._pages diff --git a/src/ocrmypdf/pdfinfo/layout.py b/src/ocrmypdf/pdfinfo/layout.py index 6c78f0f4..47e0e676 100644 --- a/src/ocrmypdf/pdfinfo/layout.py +++ b/src/ocrmypdf/pdfinfo/layout.py @@ -58,7 +58,7 @@ def pdfsimplefont__init__( return -setattr(PDFSimpleFont, '__init__', pdfsimplefont__init__) +PDFSimpleFont.__init__ = pdfsimplefont__init__ # Patch pdfminer.six buffer size # The parser doesn't properly handle keyword tokens are split across the end of the @@ -363,7 +363,7 @@ class PdfMinerState: except StopIteration: raise InputFileError( f"pdfminer did not find page {pageno} in the input file." - ) + ) from None page = self.page_cache[pageno] if not page: raise InputFileError( diff --git a/src/ocrmypdf/pluginspec.py b/src/ocrmypdf/pluginspec.py index 05d5a648..d50e03ca 100644 --- a/src/ocrmypdf/pluginspec.py +++ b/src/ocrmypdf/pluginspec.py @@ -6,15 +6,18 @@ from __future__ import annotations from abc import ABC, abstractmethod -from argparse import ArgumentParser, Namespace +from argparse import ArgumentParser from collections.abc import Sequence, Set +from enum import StrEnum from logging import Handler from pathlib import Path from typing import TYPE_CHECKING, NamedTuple import pluggy +from pydantic import BaseModel from ocrmypdf import Executor, PdfContext +from ocrmypdf._options import OcrOptions from ocrmypdf._progressbar import ProgressBar from ocrmypdf.helpers import Resolution @@ -23,10 +26,23 @@ if TYPE_CHECKING: # pylint: disable=ungrouped-imports from ocrmypdf._jobcontext import PageContext + from ocrmypdf.hocrtransform import OcrElement from ocrmypdf.pdfinfo import PdfInfo # pylint: enable=ungrouped-imports + +class GhostscriptRasterDevice(StrEnum): + """Possible raster devices for Ghostscript.""" + + JPEGGRAY = 'jpeggray' + JPEGCOLOR = 'jpeg' + PNGMONO = 'pngmono' + PNGGRAY = 'pnggray' + PNG256 = 'png256' + PNG16M = 'png16m' + + hookspec = pluggy.HookspecMarker('ocrmypdf') # pylint: disable=unused-argument @@ -34,7 +50,7 @@ hookspec = pluggy.HookspecMarker('ocrmypdf') @hookspec(firstresult=True) -def get_logging_console() -> Handler: +def get_logging_console() -> Handler: # type: ignore[return-value] """Returns a custom logging handler. Generally this is necessary when both logging output and a progress bar are both @@ -87,7 +103,29 @@ def add_options(parser: ArgumentParser) -> None: @hookspec -def check_options(options: Namespace) -> None: +def register_options() -> dict[str, type[BaseModel]]: + """Return plugin's option models keyed by namespace. + + This hook allows plugins to register their option models with the + plugin option registry. The returned dictionary should map namespace + strings to Pydantic model classes. + + Returns: + Dictionary mapping namespace strings to BaseModel classes + + Example: + @hookimpl + def register_options(): + return {'tesseract': TesseractOptions} + + Note: + This hook will be called from the main process during plugin + infrastructure setup, before child worker processes are forked. + """ + + +@hookspec +def check_options(options: OcrOptions) -> None: """Called to ask the plugin to check all of the options. The plugin may check if options that it added are valid. @@ -110,7 +148,7 @@ def check_options(options: Namespace) -> None: @hookspec(firstresult=True) -def get_executor(progressbar_class: type[ProgressBar]) -> Executor: +def get_executor(progressbar_class: type[ProgressBar]) -> Executor: # type: ignore[return-value] """Called to obtain an object that manages parallel execution. This may be used to replace OCRmyPDF's default parallel execution system @@ -138,7 +176,7 @@ def get_executor(progressbar_class: type[ProgressBar]) -> Executor: @hookspec(firstresult=True) -def get_progressbar_class() -> type[ProgressBar]: +def get_progressbar_class() -> type[ProgressBar]: # type: ignore[return-value] """Called to obtain a class that can be used to monitor progress. OCRmyPDF will call this function when it wants to display a progress bar. @@ -158,7 +196,7 @@ def get_progressbar_class() -> type[ProgressBar]: @hookspec -def validate(pdfinfo: PdfInfo, options: Namespace) -> None: +def validate(pdfinfo: PdfInfo, options: OcrOptions) -> None: """Called to give a plugin an opportunity to review *options* and *pdfinfo*. *options* contains the "work order" to process a particular file. *pdfinfo* @@ -182,14 +220,16 @@ def validate(pdfinfo: PdfInfo, options: Namespace) -> None: def rasterize_pdf_page( input_file: Path, output_file: Path, - raster_device: str, + raster_device: GhostscriptRasterDevice, raster_dpi: Resolution, pageno: int, page_dpi: Resolution | None, rotation: int | None, filter_vector: bool, stop_on_soft_error: bool, -) -> Path: + options: OcrOptions | None, + use_cropbox: bool, +) -> Path: # type: ignore[return-value] """Rasterize one page of a PDF at resolution raster_dpi in canvas units. The image is sized to match the integer pixels dimensions implied by @@ -212,6 +252,12 @@ def rasterize_pdf_page( cannot proceed, it should always raise an exception, regardless of this setting. One "soft error" would be a missing font that is required to properly rasterize the PDF. + options: OCRmyPDF options. Plugins may use this to check settings like + ``options.rasterizer`` to determine whether they should handle the + request or defer to another plugin. Introduced in version 17.0. + use_cropbox: If True, rasterize the page's CropBox instead of the + MediaBox. Default is False (use MediaBox) for consistency with + Ghostscript's default behavior. Returns: Path: output_file if successful @@ -226,7 +272,7 @@ def rasterize_pdf_page( @hookspec(firstresult=True) -def filter_ocr_image(page: PageContext, image: Image.Image) -> Image.Image: +def filter_ocr_image(page: PageContext, image: Image.Image) -> Image.Image: # type: ignore[return-value] """Called to filter the image before it is sent to OCR. This is the image that OCR sees, not what the user sees when they view the @@ -261,7 +307,7 @@ def filter_ocr_image(page: PageContext, image: Image.Image) -> Image.Image: @hookspec(firstresult=True) -def filter_page_image(page: PageContext, image_filename: Path) -> Path: +def filter_page_image(page: PageContext, image_filename: Path) -> Path: # type: ignore[return-value] """Called to filter the whole page before it is inserted into the PDF. A whole page image is only produced when preprocessing command line arguments @@ -298,7 +344,7 @@ def filter_page_image(page: PageContext, image_filename: Path) -> Path: @hookspec(firstresult=True) -def filter_pdf_page(page: PageContext, image_filename: Path, output_pdf: Path) -> Path: +def filter_pdf_page(page: PageContext, image_filename: Path, output_pdf: Path) -> Path: # type: ignore[return-value] """Called to convert a filtered whole page image into a PDF. A whole page image is only produced when preprocessing command line arguments @@ -368,7 +414,7 @@ class OcrEngine(ABC): @staticmethod @abstractmethod - def creator_tag(options: Namespace) -> str: + def creator_tag(options: OcrOptions) -> str: """Returns the creator tag to identify this software's role in creating the PDF. This tag will be inserted in the XMP metadata and DocumentInfo dictionary @@ -380,7 +426,7 @@ class OcrEngine(ABC): """ @abstractmethod - def __str__(self): + def __str__(self) -> str: """Returns name of OCR engine and version. This is used when OCRmyPDF wants to mention the name of the OCR engine @@ -389,7 +435,7 @@ class OcrEngine(ABC): @staticmethod @abstractmethod - def languages(options: Namespace) -> Set[str]: + def languages(options: OcrOptions) -> Set[str]: """Returns the set of all languages that are supported by the engine. Languages are typically given in 3-letter ISO 3166-1 codes, but actually @@ -398,18 +444,18 @@ class OcrEngine(ABC): @staticmethod @abstractmethod - def get_orientation(input_file: Path, options: Namespace) -> OrientationConfidence: + def get_orientation(input_file: Path, options: OcrOptions) -> OrientationConfidence: """Returns the orientation of the image.""" @staticmethod - def get_deskew(input_file: Path, options: Namespace) -> float: + def get_deskew(input_file: Path, options: OcrOptions) -> float: """Returns the deskew angle of the image, in degrees.""" return 0.0 @staticmethod @abstractmethod def generate_hocr( - input_file: Path, output_hocr: Path, output_text: Path, options: Namespace + input_file: Path, output_hocr: Path, output_text: Path, options: OcrOptions ) -> None: """Called to produce a hOCR file from a page image and sidecar text file. @@ -432,7 +478,7 @@ class OcrEngine(ABC): @staticmethod @abstractmethod def generate_pdf( - input_file: Path, output_pdf: Path, output_text: Path, options: Namespace + input_file: Path, output_pdf: Path, output_text: Path, options: OcrOptions ) -> None: """Called to produce a text only PDF from a page image. @@ -452,14 +498,67 @@ class OcrEngine(ABC): options: The command line options. """ + @staticmethod + def supports_generate_ocr() -> bool: + """Return True if this engine supports the generate_ocr() API. + + The pipeline uses this to determine whether to call generate_ocr() + or fall back to generate_hocr(). + + Returns: + False by default. Engines implementing generate_ocr() should + override this to return True. + """ + return False + + @staticmethod + def generate_ocr( + input_file: Path, + options: OcrOptions, + page_number: int = 0, + ) -> tuple[OcrElement, str]: + """Generate OCR results as an OcrElement tree. + + This is the modern API for OCR engines. Engines implementing this method + can return structured OCR results directly without intermediate file formats. + + This function executes in a worker thread or worker process. OCRmyPDF + automatically parallelizes OCR over pages. The OCR engine should not + introduce more parallelism. + + Args: + input_file: A page image on which to perform OCR. + options: The command line options. + page_number: Zero-indexed page number (for multi-page context). + + Returns: + A tuple of (OcrElement tree for the page, plain text content). + The OcrElement should have ocr_class=OcrClass.PAGE as its root. + + Note: + This method is optional. Engines that don't implement it should + leave the default implementation, and the pipeline will fall back to + generate_hocr() or generate_pdf(). + """ + raise NotImplementedError("This OcrEngine does not implement generate_ocr()") + @hookspec(firstresult=True) -def get_ocr_engine() -> OcrEngine: +def get_ocr_engine(options: OcrOptions | None) -> OcrEngine: # type: ignore[return-value] """Returns an OcrEngine to use for processing this file. The OcrEngine may be instantiated multiple times, by both the main process and child process. + When multiple OCR engine plugins are installed, plugins should check + ``options.ocr_engine`` and return ``None`` if they are not the selected + engine. The hook caller will then try the next plugin. + + Args: + options: The current OcrOptions, used to determine which engine + to select. May be None for backward compatibility with external + plugins. + Note: This is a :ref:`firstresult hook`. """ @@ -475,7 +574,7 @@ def generate_pdfa( pdfa_part: str, progressbar_class: type[ProgressBar] | None, stop_on_soft_error: bool, -) -> Path: +) -> Path: # type: ignore[return-value] """Generate a PDF/A. This API strongly assumes a PDF/A generator with Ghostscript's semantics. @@ -522,7 +621,7 @@ def optimize_pdf( context: PdfContext, executor: Executor, linearize: bool, -) -> tuple[Path, Sequence[str]]: +) -> tuple[Path, Sequence[str]]: # type: ignore[return-value] """Optimize a PDF after image, OCR and metadata processing. If the input_pdf is a PDF/A, the plugin should modify input_pdf in a way @@ -559,7 +658,7 @@ def optimize_pdf( @hookspec(firstresult=True) -def is_optimization_enabled(context: PdfContext) -> bool: +def is_optimization_enabled(context: PdfContext) -> bool: # type: ignore[return-value] """For a given PdfContext, OCRmyPDF asks the plugin if optimization is enabled. An optimization plugin might be installed and active but could be disabled by diff --git a/src/ocrmypdf/quality.py b/src/ocrmypdf/quality.py index 6c54b380..56abbf57 100644 --- a/src/ocrmypdf/quality.py +++ b/src/ocrmypdf/quality.py @@ -40,8 +40,5 @@ class OcrQualityDictionary: w != w.lower() and w.lower() in self.dictionary ): matches += 1 - if matches > 0: - hit_ratio = matches / len(text_words) - else: - hit_ratio = 0.0 + hit_ratio = matches / len(text_words) if matches > 0 else 0.0 return hit_ratio diff --git a/src/ocrmypdf/subprocess/__init__.py b/src/ocrmypdf/subprocess/__init__.py index c9a0700f..23aa1612 100644 --- a/src/ocrmypdf/subprocess/__init__.py +++ b/src/ocrmypdf/subprocess/__init__.py @@ -23,13 +23,13 @@ from ocrmypdf.exceptions import MissingDependencyError log = logging.getLogger(__name__) Args = Sequence[Path | str] -OsEnviron = os._Environ # pylint: disable=protected-access +Environ = Mapping[str, str] | os._Environ # pylint: disable=protected-access def run( args: Args, *, - env: OsEnviron | None = None, + env: Environ | None = None, logs_errors_to_stdout: bool = False, check: bool = False, **kwargs, @@ -81,7 +81,7 @@ def run_polling_stderr( *, callback: Callable[[str], None], check: bool = False, - env: OsEnviron | None = None, + env: Environ | None = None, **kwargs, ) -> CompletedProcess: """Run a process like ``ocrmypdf.subprocess.run``, and poll stderr. @@ -116,8 +116,8 @@ def run_polling_stderr( def _fix_process_args( - args: Args, env: OsEnviron | None, kwargs -) -> tuple[Args, OsEnviron, logging.Logger, bool]: + args: Args, env: Environ | None, kwargs +) -> tuple[Args, Environ, logging.Logger, bool]: if not env: env = os.environ @@ -142,7 +142,7 @@ def get_version( *, version_arg: str = '--version', regex=r'(\d+(\.\d+)*)', - env: OsEnviron | None = None, + env: Environ | None = None, ) -> str: """Get the version of the specified program. diff --git a/tests/cache/2400dpi/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin b/tests/cache/2400dpi/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin index 5c249da3..e39da0d6 100644 --- a/tests/cache/2400dpi/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin +++ b/tests/cache/2400dpi/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin @@ -5,170 +5,171 @@ - - + + -
-
-

- - i - a - la - Waterman +

+
+

+ + Linzensoep + a + la + Waterman

-
-
-

- - 4 - ons - linzen +

+
+

+ + 4 + ons + linzen

-

- - 3 - liter - water +

+ + 3 + liter + water

-

- - 3 - uien +

+ + 3 + uien

-

- - bloem, - boter +

+ + bloem, + boter

-

- - 2 - kopjes - melk +

+ + 2 + kopjes + melk

-

- - laurier, - kruidnagel, - kerrie, - zout +

+ + laurier, + kruidnagel, + kerrie, + zout

-
-

- - De - linzgen - wassen - en - in-l - liter - kokend - wa- +

+

+ + De + linzen + wassen + en + in + 1 + liter + kokend + wa- - - ter - 1 - dag - laten - weken, - 2 - liter - water - bij + + ter + 1 + dag + laten + weken, + 2 + liter + water + bij - - de - linzen - voegen, - zonder - het - water - waarin + + de + linzen + voegen, + zonder + het + water + waarin - - ze - geweekt - zijn - af - te - gieten, - De - helft - van + + ze + geweekt + zijn + af + te + gieten, + De + helft + van - - de - uien - bakken - met - laurier - en - Kruidnagel. + + de + uien + bakken + met + laurier + en + kruidnagel. - - Alle - uien, - kerrie - en - zgout - bij - de - linzen + + Alle + uien, + kerrie + en + gout + bijg + de + linzen - - voegen, - Alles - aan - de - kook - brengen, - Van - de + + voegen, + Alles + aan + de + kook + brengen,. + Van + de - - bloem - met - boter - en - melk - een - papje - maken - en + + bloem + met + boter + en + melk + een + papje + maken + en - - verder - afmaken - met - de - soep, - Als - de - linzen + + verder + afmaken + met + de + soep, + Als + de + linzen - - gfgaar - Zijn - is - de - soep - klaar. + + gaar + Zijn + is + de + soep + klaar.

diff --git a/tests/cache/2400dpi/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/txt.bin b/tests/cache/2400dpi/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/txt.bin index dee575a9..eea8bbb4 100644 --- a/tests/cache/2400dpi/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/txt.bin +++ b/tests/cache/2400dpi/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/txt.bin @@ -1,4 +1,4 @@ -i a la Waterman +Linzensoep a la Waterman 4 ons linzen @@ -12,13 +12,13 @@ bloem, boter laurier, kruidnagel, kerrie, zout -De linzgen wassen en in-l liter kokend wa- +De linzen wassen en in 1 liter kokend wa- ter 1 dag laten weken, 2 liter water bij de linzen voegen, zonder het water waarin ze geweekt zijn af te gieten, De helft van -de uien bakken met laurier en Kruidnagel. -Alle uien, kerrie en zgout bij de linzen -voegen, Alles aan de kook brengen, Van de +de uien bakken met laurier en kruidnagel. +Alle uien, kerrie en gout bijg de linzen +voegen, Alles aan de kook brengen,. Van de bloem met boter en melk een papje maken en verder afmaken met de soep, Als de linzen -gfgaar Zijn is de soep klaar. +gaar Zijn is de soep klaar. diff --git a/tests/cache/2400dpi/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin b/tests/cache/2400dpi/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin deleted file mode 100644 index 6e2f2616..00000000 Binary files a/tests/cache/2400dpi/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin and /dev/null differ diff --git a/tests/cache/2400dpi/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/stderr.bin b/tests/cache/2400dpi/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/stderr.bin deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/cache/2400dpi/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/stdout.bin b/tests/cache/2400dpi/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/stdout.bin deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/cache/2400dpi/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/txt.bin b/tests/cache/2400dpi/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/txt.bin deleted file mode 100644 index dee575a9..00000000 --- a/tests/cache/2400dpi/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/txt.bin +++ /dev/null @@ -1,24 +0,0 @@ -i a la Waterman - -4 ons linzen - -3 liter water - -3 uien - -bloem, boter - -2 kopjes melk - -laurier, kruidnagel, kerrie, zout - -De linzgen wassen en in-l liter kokend wa- -ter 1 dag laten weken, 2 liter water bij -de linzen voegen, zonder het water waarin -ze geweekt zijn af te gieten, De helft van -de uien bakken met laurier en Kruidnagel. -Alle uien, kerrie en zgout bij de linzen -voegen, Alles aan de kook brengen, Van de -bloem met boter en melk een papje maken en -verder afmaken met de soep, Als de linzen -gfgaar Zijn is de soep klaar. diff --git a/tests/cache/3small/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin b/tests/cache/3small/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin index d5066fcb..623b575c 100644 --- a/tests/cache/3small/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin +++ b/tests/cache/3small/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin @@ -5,11 +5,11 @@ - - + + -
+

@@ -22,8 +22,8 @@

- Bokale - oa + Bokale + oa

@@ -55,32 +55,34 @@

- BAIONA - zeiteninsiie - + BAIONA + i + zeettnansise +

- 7 - Trenbideak - ----- + 1 + Trenbideak + -- + ~~~

- t\ - Basusarri - - spmeans:20141004 - ae: - . - _ - ~ + t\ + Basusarri + + spmsans20141004 + se: + . + a + ~

diff --git a/tests/cache/3small/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/txt.bin b/tests/cache/3small/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/txt.bin index 561bae0b..d4caaa8d 100644 --- a/tests/cache/3small/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/txt.bin +++ b/tests/cache/3small/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/txt.bin @@ -8,8 +8,8 @@ Mugerre Milafranga Komunikabideak -BAIONA zeiteninsiie — +BAIONA i zeettnansise — -7 Trenbideak ----- +1 Trenbideak -- ~~~ -t\ Basusarri — spmeans:20141004 ae: . _ ~ +t\ Basusarri — spmsans20141004 se: . a ~ diff --git a/tests/cache/3small/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin b/tests/cache/3small/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin deleted file mode 100644 index e75334e2..00000000 Binary files a/tests/cache/3small/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin and /dev/null differ diff --git a/tests/cache/3small/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/stderr.bin b/tests/cache/3small/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/stderr.bin deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/cache/3small/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/stdout.bin b/tests/cache/3small/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/stdout.bin deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/cache/3small/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/txt.bin b/tests/cache/3small/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/txt.bin deleted file mode 100644 index 561bae0b..00000000 --- a/tests/cache/3small/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/txt.bin +++ /dev/null @@ -1,15 +0,0 @@ -Tarnose - -Bokale oa - -Lehuntze - -Mugerre - -Milafranga Komunikabideak - -BAIONA zeiteninsiie — - -7 Trenbideak ----- - -t\ Basusarri — spmeans:20141004 ae: . _ ~ diff --git a/tests/cache/3small/__-l__eng__000002_ocr.png__000002_ocr_hocr__hocr__txt/hocr.bin b/tests/cache/3small/__-l__eng__000002_ocr.png__000002_ocr_hocr__hocr__txt/hocr.bin index 42cff052..ad0fd8e1 100644 --- a/tests/cache/3small/__-l__eng__000002_ocr.png__000002_ocr_hocr__hocr__txt/hocr.bin +++ b/tests/cache/3small/__-l__eng__000002_ocr.png__000002_ocr_hocr__hocr__txt/hocr.bin @@ -5,20 +5,20 @@ - - + + -
+

- Covfefe + Covfefe is a - perfectly - cromulent - word. + perfectly + cromulent + word.

diff --git a/tests/cache/3small/__-l__eng__000002_ocr.png__000002_ocr_tess__pdf__txt/pdf.bin b/tests/cache/3small/__-l__eng__000002_ocr.png__000002_ocr_tess__pdf__txt/pdf.bin deleted file mode 100644 index 7a13dc03..00000000 Binary files a/tests/cache/3small/__-l__eng__000002_ocr.png__000002_ocr_tess__pdf__txt/pdf.bin and /dev/null differ diff --git a/tests/cache/3small/__-l__eng__000002_ocr.png__000002_ocr_tess__pdf__txt/stderr.bin b/tests/cache/3small/__-l__eng__000002_ocr.png__000002_ocr_tess__pdf__txt/stderr.bin deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/cache/3small/__-l__eng__000002_ocr.png__000002_ocr_tess__pdf__txt/stdout.bin b/tests/cache/3small/__-l__eng__000002_ocr.png__000002_ocr_tess__pdf__txt/stdout.bin deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/cache/3small/__-l__eng__000002_ocr.png__000002_ocr_tess__pdf__txt/txt.bin b/tests/cache/3small/__-l__eng__000002_ocr.png__000002_ocr_tess__pdf__txt/txt.bin deleted file mode 100644 index 60e0a81a..00000000 --- a/tests/cache/3small/__-l__eng__000002_ocr.png__000002_ocr_tess__pdf__txt/txt.bin +++ /dev/null @@ -1 +0,0 @@ -Covfefe is a perfectly cromulent word. diff --git a/tests/cache/3small/__-l__eng__000003_ocr.png__000003_ocr_hocr__hocr__txt/hocr.bin b/tests/cache/3small/__-l__eng__000003_ocr.png__000003_ocr_hocr__hocr__txt/hocr.bin index 72e43d14..f0b91ea2 100644 --- a/tests/cache/3small/__-l__eng__000003_ocr.png__000003_ocr_hocr__hocr__txt/hocr.bin +++ b/tests/cache/3small/__-l__eng__000003_ocr.png__000003_ocr_hocr__hocr__txt/hocr.bin @@ -5,11 +5,11 @@ - - + + -
+

@@ -24,7 +24,7 @@

- 4 + 4 ons linzen @@ -32,16 +32,16 @@

- 3 - liter - water + 3 + liter + water

- 3 - uien + 3 + uien

@@ -54,9 +54,9 @@

- 2 + 2 kopjes - melk + melk

@@ -64,111 +64,112 @@ laurier, kruidnagel, - kerrie, - zout + kerrie, + zout

- - De - linzgen + + De + linzgen wassen - en - in-l - liter - kokend - wa- + en + in + -l + liter + kokend + wa- - ter - 1 - dag - laten - weken, - 2 - liter - water - bij + ter + 1 + dag + laten + weken, + 2 + liter + water + bij - - de - linzen - voegen, - zonder - het - water - waarin + + de + linzen + voegen, + zonder + het + water + waarin - ze - geweekt - zijn - af - te - gieten, - De - helft - van + ze + geweekt + zijn + af + te + gieten, + De + helft + van - de - uien - bakken - met - laurier - en - Kruidnagel. + de + uien + bakken + met + laurier + en + kKruicdnagel. - Alle - uien, - kerrie - en - zgout - bij - de - linzen + Alle + uien, + kerrie + en + gout + bij + de + linzen - voegen, - Alles - aan - de - kook - brengen, - Van - de + voegen, + Alles + aan + de + kook + brengen,. + Van + de - bloem - met - boter - en - melk - een - papje - maken - en + bloem + met + boter + en + melk + een + papje + maken + en - - verder - afmaken - met - de - soep, - Als - de - linzen + + verder + afmaken + met + de + soep,. + Als + de + linzen - - gfgaar - Zijn - is - de - soep - klaar. + + gaar + Zijn + is + de + soep + klaar.

diff --git a/tests/cache/3small/__-l__eng__000003_ocr.png__000003_ocr_hocr__hocr__txt/txt.bin b/tests/cache/3small/__-l__eng__000003_ocr.png__000003_ocr_hocr__hocr__txt/txt.bin index d7c5e058..857b059e 100644 --- a/tests/cache/3small/__-l__eng__000003_ocr.png__000003_ocr_hocr__hocr__txt/txt.bin +++ b/tests/cache/3small/__-l__eng__000003_ocr.png__000003_ocr_hocr__hocr__txt/txt.bin @@ -12,13 +12,13 @@ bloem, boter laurier, kruidnagel, kerrie, zout -De linzgen wassen en in-l liter kokend wa- +De linzgen wassen en in -l liter kokend wa- ter 1 dag laten weken, 2 liter water bij de linzen voegen, zonder het water waarin ze geweekt zijn af te gieten, De helft van -de uien bakken met laurier en Kruidnagel. -Alle uien, kerrie en zgout bij de linzen -voegen, Alles aan de kook brengen, Van de +de uien bakken met laurier en kKruicdnagel. +Alle uien, kerrie en gout bij de linzen +voegen, Alles aan de kook brengen,. Van de bloem met boter en melk een papje maken en -verder afmaken met de soep, Als de linzen -gfgaar Zijn is de soep klaar. +verder afmaken met de soep,. Als de linzen +gaar Zijn is de soep klaar. diff --git a/tests/cache/3small/__-l__eng__000003_ocr.png__000003_ocr_tess__pdf__txt/pdf.bin b/tests/cache/3small/__-l__eng__000003_ocr.png__000003_ocr_tess__pdf__txt/pdf.bin deleted file mode 100644 index f138b41f..00000000 Binary files a/tests/cache/3small/__-l__eng__000003_ocr.png__000003_ocr_tess__pdf__txt/pdf.bin and /dev/null differ diff --git a/tests/cache/3small/__-l__eng__000003_ocr.png__000003_ocr_tess__pdf__txt/stderr.bin b/tests/cache/3small/__-l__eng__000003_ocr.png__000003_ocr_tess__pdf__txt/stderr.bin deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/cache/3small/__-l__eng__000003_ocr.png__000003_ocr_tess__pdf__txt/stdout.bin b/tests/cache/3small/__-l__eng__000003_ocr.png__000003_ocr_tess__pdf__txt/stdout.bin deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/cache/3small/__-l__eng__000003_ocr.png__000003_ocr_tess__pdf__txt/txt.bin b/tests/cache/3small/__-l__eng__000003_ocr.png__000003_ocr_tess__pdf__txt/txt.bin deleted file mode 100644 index d7c5e058..00000000 --- a/tests/cache/3small/__-l__eng__000003_ocr.png__000003_ocr_tess__pdf__txt/txt.bin +++ /dev/null @@ -1,24 +0,0 @@ -Linzensoep a la Waterman - -4 ons linzen - -3 liter water - -3 uien - -bloem, boter - -2 kopjes melk - -laurier, kruidnagel, kerrie, zout - -De linzgen wassen en in-l liter kokend wa- -ter 1 dag laten weken, 2 liter water bij -de linzen voegen, zonder het water waarin -ze geweekt zijn af te gieten, De helft van -de uien bakken met laurier en Kruidnagel. -Alle uien, kerrie en zgout bij de linzen -voegen, Alles aan de kook brengen, Van de -bloem met boter en melk een papje maken en -verder afmaken met de soep, Als de linzen -gfgaar Zijn is de soep klaar. diff --git a/tests/cache/aspect/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin b/tests/cache/aspect/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin index c53d0bfd..7a55ec72 100644 --- a/tests/cache/aspect/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin +++ b/tests/cache/aspect/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin @@ -5,11 +5,11 @@ - - + + -
+

@@ -17,9 +17,9 @@ This should be - a - perfect - circle. + a + perfect + circle.

diff --git a/tests/cache/aspect/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin b/tests/cache/aspect/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin index bc89ff94..5239215d 100644 Binary files a/tests/cache/aspect/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin and b/tests/cache/aspect/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin differ diff --git a/tests/cache/cardinal/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin b/tests/cache/cardinal/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin index ae8ef762..f7e8f0d0 100644 --- a/tests/cache/cardinal/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin +++ b/tests/cache/cardinal/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin @@ -5,11 +5,11 @@ - - + + -
+

@@ -17,8 +17,8 @@ LinnSequencer - 32 - Track + 32 + Track MIDI Sequence Recorder @@ -29,7 +29,7 @@

The - LinnSequencer + LinnSequencer is a state-of-the-art @@ -39,16 +39,16 @@ tool for the - professional + professional musician. It - is + is

- extremely + extremely powerful, yet amazingly @@ -67,24 +67,24 @@

- ¢ - Operation + ¢ + Operation is - similar + similar to multi-track tape recorder - with + with PLAY, STOP, RECORD, - FAST + FAST FORWARD, - REWIND, - and + REWIND, + and LOCATE controls. @@ -93,7 +93,7 @@

- ¢ + e Each of the @@ -109,12 +109,12 @@ may - be + be assigned to one - of - 16 + of + 16 MIDI channels. Simultaneously @@ -136,9 +136,9 @@

- © + ¢ Ultra-fast - 32” + 3%" disk drive stores @@ -149,7 +149,7 @@ and holds over - 110,000 + 110,000 notes

@@ -165,13 +165,13 @@

- ¢ - One - or + ¢ + One + or all - tracks + tracks may - be + be TRANSPOSED at the @@ -181,21 +181,21 @@ key. - ¢ + e Exclusive real-time ERASE function makes editing - FAST. + FAST. - © + ¢ Exclusive REPEAT function - automatically + automatically repeats any held @@ -217,25 +217,25 @@

- ¢ - TIMING + ¢ + TIMING CORRECTION - works - during + works + during playback and operates without - ‘chopping’ - notes. + ‘chopping’ + notes.

- ¢ - Optional + ¢ + Optional SMPTE time code @@ -246,49 +246,49 @@

- © + © Optional remote - control. + control.

- Recording - a + Recording + a Sequence

- To - record - a + To + record + a sequence, simply press RECORD and - PLAY, + PLAY, then play your - MIDI - keyboard - in + MIDI + keyboard + in time to the - Sequencer’s + Sequencer’s click - track. + track. When the sequence @@ -297,766 +297,764 @@ around to bar - 1, + 1, - you’ - ll - hear - what - you - played—only - all - timing - errors - will - be + you'll + hear + what + you + played—only + all + timing + errors + will + be

- corrected! - (Timing - correction - may - be - adjusted - or - defeated). + corrected! + (Timing + correction + may + be + adjusted + or + defeated).

-
-

- - Any - additional - notes - played - will - be - added - into - the - track +

+

+ + Any + additional + notes + played + will + be + added + into + the + track - - - existing - notes - are - not - erased - while - recording! + + —existing + notes + are + not + erased + while + recording!

-

- - FAST - FORWARD, - REWIND, - and - LOCATE - controls +

+ + FAST + FORWARD, + REWIND, + and + LOCATE + controls - - may - be - used - at - any - time - to - quickly - access - any - location - in + + may + be + used + at + any + time + to + quickly + access + any + location + in - - your - sequence - for - spot-recording. - To - overdub - a - new - part, + + your + sequence + for + spot-recording. + To + overdub + a + new + part, - - select - a - different - track - and - start - recording—while - you + + select + a + different + track + and + start + recording—while + you - - record, - the - first - track - will - play - in - perfect - sync - (unless - you + + record, + the + first + track + will + play + in + perfect + sync + (unless + you - - MUTE - it, - or - SOLO - another - track). - In - this - way, - up - to - 32 + + MUTE + it, + or + SOLO + another + track). + In + this + way, + up + to + 32 - - tracks - may - be - overdubbed! - All - MIDI - effects - are - recorded + + tracks + may + be + overdubbed! + All + MIDI + effects + are + recorded - - including - pitch - bend, - modulation, - velocity, - aftertouch, + + including + pitch + bend, + modulation, + velocity, + aftertouch, - - sustain - pedal, - and - program - changes! + + sustain + pedal, + and + program + changes!

-
-

- - Editing +

+

+ + Editing

-

- - To - erase - a - wrong - note, - simply - hold - ERASE - and - press +

+ + To + erase + a + wrong + note, + simply + hold + ERASE + and + press - - the - note - to - be - erased - just - before - it - plays - in - the - sequence— + + the + note + to + be + erased + just + before + it + plays + in + the + sequence— - - when - played - back, - it - will - be - gone. - Notes - may - also - be + + when + played + back, + it + will + be + gone. + Notes + may + also + be

-
-

- - added, - erased, - or - changed - using - the - SINGLE - STEP - func- +

+

+ + added, + erased, + or + changed + using + the + SINGLE + STEP + func- - - tion. - To - overdub - notes - at - specific - points - within - a - sequence, + + tion. + To + overdub + notes + at + specific + points + within + a + sequence,

-
-

- - Additional - Features +

+

+ + Additional + Features

-
+

- - simply - use - LOCATE, - FAST - FORWARD, - or - REWIND - to + + simply + use + LOCATE, + FAST + FORWARD, + or + REWIND + to - find - the - desired - bar - number, - then - start - recording. + find + the + desired + bar + number, + then + start + recording.

-

+

- The - INSERT/COPY - function - allows - you - to - move - bars + The + INSERT/COPY + function + allows + you + to + move + bars - from - one - location - to - another—in - the - same - sequence - or - a + from + one + location + to + another—in + the + same + sequence + or + a - different - one. - For - example, - you - might - insert - a - copy - of - the + different + one. + For + example, + you + might + insert + a + copy + of + the - first - verse - between - the - second - chorus - and - the - bridge. + first + verse + between + the + second + chorus + and + the + bridge. - - DELETE - BARS - operates - the - same - way - to - remove + + DELETE + BARS + operates + the + same + way + to + remove - - unwanted - sections, + + unwanted + sections.

-
-

- - Creating - a - Song +

+

+ + Creating + a + Song

-

- - One - way - to - create - a - song - is - to - record - each - track - all - the +

+ + One + way + to + create + a + song + is + to + record + each + track + all + the - - way - through - (up - to - 999 - bars). - Another - way - is - to - record + + way + through + (up + to + 999 + bars). + Another + way + is + to + record - - each - basic - section - (verse, - chorus, - etc.) - in - individual + + each + basic + section + (verse, + chorus, + etc.) + in + individual - - sequences, - then - use - the - CREATE - SONG - function - to - “chain” + + sequences, + then + use + the + CREATE + SONG + function + to + “chain” - - them - together. - CREATE - SONG - will - then - automatically + + them + together. + CREATE + SONG + will + then + automatically - - copy - all - the - parts - into - a - new - sequence. - If - desired, - you - can + + copy + all + the + parts + into + a + new + sequence. + If + desired, + you + can - - even - set - the - last - few - bars - to - repeat - infinitely, - for - a - fadeout. + + even + set + the + last + few + bars + to + repeat + infinitely, + for + a + fadeout.

-
-

- - Composition - Without - Compromise +

+

+ + Composition + Without + Compromise

-

- - The - technology - you - use - should - never - be - so - complex - that +

+ + The + technology + you + use + should + never + be + so + complex + that - - it - interferes - with - the - creative - process. - That’s - precisely - why + + it + interferes + with + the + creative + process. + That’s + precisely + why - - the - LinnSequencer - is - designed - to - let - you - compose, - record + + the + LinnSequencer + is + designed + to + let + you + compose, + record - - and - edit - while - devoting - your - undivided - attention - to - your + + and + edit + while + devoting + your + undivided + attention + to + your - - music. - See - your - Linn - dealer - today - for - a - demonstration! + + music. + See + your + Linn + dealer + today + for + a + demonstration!

-
-

- - * - Simple, - easy - to - learn - operation—the - 32 - character - LCD - display - clearly - guides - you - through - all - operations. - If - needed, - the +

+

+ + * + Simple, + easy + to + learn + operation—the + 32 + character + LCD + display + clearly + guides + you + through + all + operations. + If + needed, + the

-
-

- - HELP - button - displays - additional - explanations. +

+

+ + HELP + button + displays + additional + explanations.

-
-

- - * - Non-destructive - recording—existing - notes - are - not - erased - while - recording. +

+

+ + * + Non-destructive + recording—existing + notes + are + not + erased + while + recording. - - ¢ - Two - FOOTSWITCH - INPUTS - may - be - assigned - to - remotely - control - many - of - the - commonly - used - functions, - including + + ¢ + Two + FOOTSWITCH + INPUTS + may + be + assigned + to + remotely + control + many + of + the + commonly + used + functions, + including

-
-

- - ERASE, - REPEAT, - PLAY/STOP, - or - LOCATE. +

+

+ + ERASE, + REPEAT, + PLAY/STOP, + or + LOCATE.

-
-

- - ¢ - Two - TRIGGER - OUTPUTS - may - be - programmed - to - output - pulses - at - any - selected - note - value. +

+

+ + ¢ + Two + TRIGGER + OUTPUTS + may + be + programmed + to + output + pulses + at + any + selected + note + value.

-
-

- - © - Will - sync - to - standard - LinnDrum - or - Linn - 9000 - sync - tone. +

+

+ + © + Will + sync + to + standard + LinnDrum + or + Linn + 9000 + sync + tone.

-
-

- - ® - Utilizes - ultra - high-speed, - 8 - MHz - 80186 - 16 - bit - computer - internally - for - FAST - operation. +

+

+ + © + Utilizes + ultra + high-speed, + 8 + MHz + 80186 + 16 + bit + computer + internally + for + FAST + operation. - - * - TEMPO - may - be - specified - in - BEATS-PER-MINUTE - or - FRAMES-PER-BEAT - at - 24, - 25, - or - 30 - frames - per - second, + + ¢ + TEMPO + may + be + specified + in + BEATS-PER-MINUTE + or + FRAMES-PER-BEAT + at + 24, + 25, + or + 30 + frames + per + second,

-
-

- - (even - drop - frame!) +

+

+ + (even + drop + frame!)

-
-

- - ¢ - TEMPO - may - be - entered - numerically, - adjustable - in - tenths - of - a - Beat-Per-Minute - increments, - or - by - tapping - quarter - notes +

+

+ + ¢ + TEMPO + may + be + entered + numerically, + adjustable + in + tenths + of + a + Beat-Per-Minute + increments, + or + by + tapping + quarter + notes

-
-

- - on - the - TAP - TEMPO - button. +

+

+ + on + the + TAP + TEMPO + button.

-
-

- - ¢ - TEMPO - CHANGES - may - be - programmed - into - a - sequence, - with - smooth - transitions - if - desired. +

+

+ + ¢ + TEMPO + CHANGES + may + be + programmed + into + a + sequence, + with + smooth + transitions + if + desired. - - ¢ - Any - TIME - SIGNATURE - may - be - used, - and - may - be - changed - within - a - song. + + ¢ + Any + TIME + SIGNATURE + may + be + used, + and + may + be + changed + within + a + song.

-
-

- - linn +

+

+ + linn - - Linn - Electronics, - Inc. + + Linn + Electronics, + Inc.

-
-

- - 18720 - Oxnard - Street, - Tarzana, - CA - 91356 +

+

+ + 18720 + Oxnard + Street, + Tarzana, + CA + 91356 - - (818) - 708-8131 - TELEX - #298949 - LINN - UR + + (818) + 708-8131 + TELEX + #298949 + LINN + UR

diff --git a/tests/cache/cardinal/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/txt.bin b/tests/cache/cardinal/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/txt.bin index 9e1c5257..7ce15cb2 100644 --- a/tests/cache/cardinal/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/txt.bin +++ b/tests/cache/cardinal/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/txt.bin @@ -8,18 +8,18 @@ extremely powerful, yet amazingly simple to learn and use. It’s many remarkabl ¢ Operation is similar to multi-track tape recorder with PLAY, STOP, RECORD, FAST FORWARD, REWIND, and LOCATE controls. -¢ Each of the 100 sequences contains 32 simultaneous, polyphonic tracks. Each track may +e Each of the 100 sequences contains 32 simultaneous, polyphonic tracks. Each track may be assigned to one of 16 MIDI channels. Simultaneously plays up to 16 polyphonic synthesizers! -© Ultra-fast 32” disk drive stores complex songs in seconds and holds over 110,000 notes +¢ Ultra-fast 3%" disk drive stores complex songs in seconds and holds over 110,000 notes per disk! ¢ One or all tracks may be TRANSPOSED at the touch of a key. -¢ Exclusive real-time ERASE function makes editing FAST. -© Exclusive REPEAT function automatically repeats any held notes at a pre-selected +e Exclusive real-time ERASE function makes editing FAST. +¢ Exclusive REPEAT function automatically repeats any held notes at a pre-selected rhythmic value. @@ -34,12 +34,12 @@ Recording a Sequence To record a sequence, simply press RECORD and PLAY, then play your MIDI keyboard in time to the Sequencer’s click track. When the sequence loops back around to bar 1, -you’ ll hear what you played—only all timing errors will be +you'll hear what you played—only all timing errors will be corrected! (Timing correction may be adjusted or defeated). Any additional notes played will be added into the track -— existing notes are not erased while recording! +—existing notes are not erased while recording! FAST FORWARD, REWIND, and LOCATE controls may be used at any time to quickly access any location in @@ -70,7 +70,7 @@ from one location to another—in the same sequence or a different one. For example, you might insert a copy of the first verse between the second chorus and the bridge. DELETE BARS operates the same way to remove -unwanted sections, +unwanted sections. Creating a Song @@ -103,8 +103,8 @@ ERASE, REPEAT, PLAY/STOP, or LOCATE. © Will sync to standard LinnDrum or Linn 9000 sync tone. -® Utilizes ultra high-speed, 8 MHz 80186 16 bit computer internally for FAST operation. -* TEMPO may be specified in BEATS-PER-MINUTE or FRAMES-PER-BEAT at 24, 25, or 30 frames per second, +© Utilizes ultra high-speed, 8 MHz 80186 16 bit computer internally for FAST operation. +¢ TEMPO may be specified in BEATS-PER-MINUTE or FRAMES-PER-BEAT at 24, 25, or 30 frames per second, (even drop frame!) diff --git a/tests/cache/cardinal/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin b/tests/cache/cardinal/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin index f68abaf6..91e80b5c 100644 Binary files a/tests/cache/cardinal/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin and b/tests/cache/cardinal/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin differ diff --git a/tests/cache/cardinal/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/txt.bin b/tests/cache/cardinal/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/txt.bin index 9e1c5257..7ce15cb2 100644 --- a/tests/cache/cardinal/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/txt.bin +++ b/tests/cache/cardinal/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/txt.bin @@ -8,18 +8,18 @@ extremely powerful, yet amazingly simple to learn and use. It’s many remarkabl ¢ Operation is similar to multi-track tape recorder with PLAY, STOP, RECORD, FAST FORWARD, REWIND, and LOCATE controls. -¢ Each of the 100 sequences contains 32 simultaneous, polyphonic tracks. Each track may +e Each of the 100 sequences contains 32 simultaneous, polyphonic tracks. Each track may be assigned to one of 16 MIDI channels. Simultaneously plays up to 16 polyphonic synthesizers! -© Ultra-fast 32” disk drive stores complex songs in seconds and holds over 110,000 notes +¢ Ultra-fast 3%" disk drive stores complex songs in seconds and holds over 110,000 notes per disk! ¢ One or all tracks may be TRANSPOSED at the touch of a key. -¢ Exclusive real-time ERASE function makes editing FAST. -© Exclusive REPEAT function automatically repeats any held notes at a pre-selected +e Exclusive real-time ERASE function makes editing FAST. +¢ Exclusive REPEAT function automatically repeats any held notes at a pre-selected rhythmic value. @@ -34,12 +34,12 @@ Recording a Sequence To record a sequence, simply press RECORD and PLAY, then play your MIDI keyboard in time to the Sequencer’s click track. When the sequence loops back around to bar 1, -you’ ll hear what you played—only all timing errors will be +you'll hear what you played—only all timing errors will be corrected! (Timing correction may be adjusted or defeated). Any additional notes played will be added into the track -— existing notes are not erased while recording! +—existing notes are not erased while recording! FAST FORWARD, REWIND, and LOCATE controls may be used at any time to quickly access any location in @@ -70,7 +70,7 @@ from one location to another—in the same sequence or a different one. For example, you might insert a copy of the first verse between the second chorus and the bridge. DELETE BARS operates the same way to remove -unwanted sections, +unwanted sections. Creating a Song @@ -103,8 +103,8 @@ ERASE, REPEAT, PLAY/STOP, or LOCATE. © Will sync to standard LinnDrum or Linn 9000 sync tone. -® Utilizes ultra high-speed, 8 MHz 80186 16 bit computer internally for FAST operation. -* TEMPO may be specified in BEATS-PER-MINUTE or FRAMES-PER-BEAT at 24, 25, or 30 frames per second, +© Utilizes ultra high-speed, 8 MHz 80186 16 bit computer internally for FAST operation. +¢ TEMPO may be specified in BEATS-PER-MINUTE or FRAMES-PER-BEAT at 24, 25, or 30 frames per second, (even drop frame!) diff --git a/tests/cache/cardinal/__-l__eng__000002_ocr.png__000002_ocr_hocr__hocr__txt/hocr.bin b/tests/cache/cardinal/__-l__eng__000002_ocr.png__000002_ocr_hocr__hocr__txt/hocr.bin index 58eab038..509142b4 100644 --- a/tests/cache/cardinal/__-l__eng__000002_ocr.png__000002_ocr_hocr__hocr__txt/hocr.bin +++ b/tests/cache/cardinal/__-l__eng__000002_ocr.png__000002_ocr_hocr__hocr__txt/hocr.bin @@ -5,1058 +5,1084 @@ - - + + -
-
-

- - The - LinnSequencer - - - 32 - Track - MIDI - Sequence - Recorder +

+
+

+ + 2A + NNI'l + 6F6867# + XATALL + IE18-80L + (818)

-
-

- - The - LinnSequencer - is - a - state-of-the-art - composition - and - performance - tool - for - the - professional - musician. - It - is +

+

+ + 9SEI6 + WO + “BUBZIRL, + “1981S + PIPUXO + OZL8I + + + “UY + ‘soTUOMON[q + UULT + +

+
+
+

+ + uut] + +

+
+
+

+ + “SUOS + & + UIJIM + pasueyo + oq + ABU + pue + ‘posn + oq + AWW + AYN + IVNOIS + AWLL + AUV + + + “parlsop + Jr + SUOTIISUBI} + YIOOUIS + YIM + “ousNbas + B + OJUI + pourtueISOId + 9q + ABU + SHONWHO + OdIWAL + e + +

+
+
+

+ + ‘uoting + OdWAL + dV + 94) + uo + +

+
+
+

+ + sojou + Jayienb + Suidde} + Aq + 10 + ‘syUdIOIOUT + oINUTIAI-JOg-Jesg_ + & + JO + SIS} + UT + ofquisn(pe + ‘ATTeoLIAUINU + patajua + oq + ABU + OdIWALL + +

+
+
+

+ + (jewel + doip + uaaa) + +

+
+
+

+ + ‘puooes + Jod + sowely + O€ + 10 + “SZ + “PZ + 18 + [VAG + MAd-SHNVU + 10 + ALNANIWAAd-SLVA + UT + parsioods + oq + Ae + OdWALL + « + + + ‘uoT}e1odo + [SV + JO} + AT[eUIOJUT + JoINdUIOS + 114 + 9] + 98108 + ZH + 8 + ‘poeds-ysry + Baan + soz] + e + +

+
+
+

+ + ‘9U0} + OUAS + (006 + UU] + JO + tuniqUUr] + prepurys + 0} + OUAS + [ITAA + e + +

+
+
+

+ + ‘ONYBA + 9}OU + pojoapes + Aue + Je + sas—nd + indyno + 07 + powureisoid + 9q + ACU + SLAG + LNO + YADOIALL + OML + +

+
+
+

+ + "ALVOOT + 10 + GOLS/AV + 1d + ‘IVadae + “ASVud + +

+
+
+

+ + BuIpNpoUur + ‘suoTJOUNJ + pasn + ATUOUIWWOS + 94} + JO + AUBUT + [OIUOS + A]OJOWI + 0} + Pousisse + oq + APU + SLAdNI + HOLIMS.LOOA + OM + + + “SUIPIONAL + S[IYA + POSKsa + JOU + BI + $3]OU + BUNSTXO—BUIPIOIS! + SATION.YSOP-UON + +

+
+
+

+ + ‘suoneurldxa + yeuoryippe + sdeydsip + uowng + q1qH + +

+
+
+

+ + ay) + ‘papsau + JT + ‘suoneiodo + [je + Ysnosy} + NOA + sapins + ApIwapo + Avdsip + QO] + Jopereyo + + 9yj—uoeIodo + Urea] + 0} + Aseo + ‘aus + e + +

+
+
+

+ + jUorelsuOWap + B + JO} + Aepol + Jayeap + UUI’] + INOA + aag + ‘oISNU + + + INOA + 0} + UOTUS}]¥ + PepIAIpUN + INOA + SUTJOASP + ITY + ps + pue + + + p1ooai + ‘asodulod + nok + Ja] + 0} + pausisap + st + J=0uenbesuur’] + oy) + + + AUM + ApOsiooid + Jey], + ‘SSIDOId + SATIBSIO + SY} + YIM + SOIOJIOWUT + 41 + + + yey) + Xo]CWIOd + Os + 9q + JOAN + P[NoYs + osn + NOK + AZOpOuYdII} + ou, + +

+
+
+

+ + ISTUOIGUIO?) + INOYIAA + UOHISOdWIO;D + +

+
+
+

+ + "NOSpr] + B + Oy + ‘AaNUTsUT + yeadal + 0} + seq + Maz + Se] + BY] + Jas + UdAd + + + UBS + NOA + ‘palisap + JJ + ‘souanbes + Mou + ¥v + OVUT + syed + at] + [Te + Adoo + + + ATeonewo + ne + Way) + [IM + ONOS + ALVA + JeyIes0} + wey} + + + ,deyd,, + 0} + WOTOUNJ + ONOS + ALVA + su] + asn + usy] + ‘saouanbes + + + JENPIAIpUt + UI + ("949 + ‘snOYD + ‘aS1oA) + UOTIDIS + JIseq + YOR + + + Plodel + OF + ST + ABM + JouIOUY + “(Seq + 666 + 0] + dn) + ysnory) + ABM

-

- - extremely - powerful, - yet - amazingly - simple - to - learn - and - use. - It’s - many - remarkable - features - include: +

+ + dU} + [fe + YORI] + YOeS + p10991 + 0} + ST + SUOS + B + 978919 + 0] + ABM + SUG, + +

+
+
+

+ + SUOS + & + SUTVIID + +

+
+
+

+ + *suoT]es + po]UeMUN

-

- - ¢ - Operation - is - similar - to - multi-track - tape - recorder - with - PLAY, - STOP, - RECORD, - FAST - - - FORWARD, - REWIND, - and - LOCATE - controls. - -

-
-
-

- - ¢ - Each - of - the - 100 - sequences - contains - 32 - simultaneous, - polyphonic - tracks. - Each - track - may - - - be - assigned - to - one - of - 16 - MIDI - channels. - Simultaneously - plays - up - to - 16 - polyphonic - -

-
-
-

- - synthesizers! - -

-
-
-

- - © - Ultra-fast - 32” - disk - drive - stores - complex - songs - in - seconds - and - holds - over - 110,000 - notes - -

-
-
-

- - per - disk! - -

-
-
-

- - ¢ - One - or - all - tracks - may - be - TRANSPOSED - at - the - touch - of - a - key. - - - ¢ - Exclusive - real-time - ERASE - function - makes - editing - FAST. - - - © - Exclusive - REPEAT - function - automatically - repeats - any - held - notes - at - a - pre-selected - -

-
-
-

- - rhythmic - value. - -

-
-
-

- - ¢ - TIMING - CORRECTION - works - during - playback - and - operates - without - ‘chopping’ - notes. - -

-
-
-

- - ¢ - Optional - SMPTE - time - code - synchronization. - -

-
-
-

- - © - Optional - remote - control. - -

-
-
-

- - Recording - a - Sequence +

+ + SAOUIAI + 0} + ABM + Wes + dU} + Sa}eIOdo + SUV + ALATAaG

-

- - To - record - a - sequence, - simply - press - RECORD - and - PLAY, - - - then - play - your - MIDI - keyboard - in - time - to - the - Sequencer’s - - - click - track. - When - the - sequence - loops - back - around - to - bar - 1, - - - you’ - ll - hear - what - you - played—only - all - timing - errors - will - be - -

-
-
-

- - corrected! - (Timing - correction - may - be - adjusted - or - defeated). - -

-
-
-

- - Any - additional - notes - played - will - be - added - into - the - track - - - - existing - notes - are - not - erased - while - recording! +

+ + “OBPLIq + VY} + PUB + SNIOYD + PUOdAS + dT] + U99MIAQ + 9SIOA + ISI

-

- - FAST - FORWARD, - REWIND, - and - LOCATE - controls - - - may - be - used - at - any - time - to - quickly - access - any - location - in - - - your - sequence - for - spot-recording. - To - overdub - a - new - part, - - - select - a - different - track - and - start - recording—while - you - - - record, - the - first - track - will - play - in - perfect - sync - (unless - you - - - MUTE - it, - or - SOLO - another - track). - In - this - way, - up - to - 32 - - - tracks - may - be - overdubbed! - All - MIDI - effects - are - recorded - - - including - pitch - bend, - modulation, - velocity, - aftertouch, - - - sustain - pedal, - and - program - changes! - -

-
-
-

- - Editing +

+ + ay) + Jo + Adoo + & + JasUI + WYSTU + NOAA + ‘afdwexs + 10.f + ‘duo + JUdIOIJIP

-

- - To - erase - a - wrong - note, - simply - hold - ERASE - and - press +

+ + B + IO + aouaNbas + sues + OY} + UI—JOYIOUP + 0} + UOTIEIO] + BUO + WOT] - - the - note - to - be - erased - just - before - it - plays - in - the - sequence— - - - when - played - back, - it - will - be - gone. - Notes - may - also - be - -

-
-
-

- - added, - erased, - or - changed - using - the - SINGLE - STEP - func- - - - tion. - To - overdub - notes - at - specific - points - within - a - sequence, - -

-
-
-

- - Additional - Features - -

-
-
-

- - simply - use - LOCATE, - FAST - FORWARD, - or - REWIND - to - - - find - the - desired - bar - number, - then - start - recording. + + s1eq + JAOUI + OF + NOA + sMOT[e + WOTIOUNS + KOO/IMASNI + PULL

-

- - The - INSERT/COPY - function - allows - you - to - move - bars - - - from - one - location - to - another—in - the - same - sequence - or - a - - - different - one. - For - example, - you - might - insert - a - copy - of - the - - - first - verse - between - the - second - chorus - and - the - bridge. - - - DELETE - BARS - operates - the - same - way - to - remove - - - unwanted - sections, - -

-
-
-

- - Creating - a - Song +

+ + ‘SUTPIONAI + JIV]S + Ud) + “OQuINU + eq + Polisop + ay} + puy

-

- - One - way - to - create - a - song - is - to - record - each - track - all - the - - - way - through - (up - to - 999 - bars). - Another - way - is - to - record - - - each - basic - section - (verse, - chorus, - etc.) - in - individual - - - sequences, - then - use - the - CREATE - SONG - function - to - “chain” - - - them - together. - CREATE - SONG - will - then - automatically - - - copy - all - the - parts - into - a - new - sequence. - If - desired, - you - can - - - even - set - the - last - few - bars - to - repeat - infinitely, - for - a - fadeout. +

+ + 0} + GNIMAY + 40 + ‘CYWM + AO + LSVA + “ALVOOT + osn + Apduns

-
-

- - Composition - Without - Compromise +

+

+ + sainjeay + [VUOHIPPY + +

+
+
+

+ + ‘gouanbas + 8 + UTYIIM + SJUTOd + oy1aeds + + $9]0U + QnpIOAO + OL + ‘UOT + + + -ounj + dALLS + ATONIS + 24) + Suisn + pasueyo + 10 + ‘pasesa + ‘poppe + + + aq + Osye + ABUT + Sd]ON + ‘UO + 9q + ]IIM + II + “Yoeq + podeyd + usyM + + + —aouanbas + oy] + ul + skeqd + 71 + a10Jaq + Isnf + posesa + aq + 0} + 9]0U + ayy + + + ssaid + pue + ASvuUq + ploy + Aydurts + ‘oj0u + Suomm + & + aseio + OL + +

+
+
+

+ + sunipa + +

+
+
+

+ + jsedueyo + ureisgoid + pue + ‘fepoed + ureysns + + + ‘yonoparye + ‘AWOOTOA + ‘UOTyeTNpow + ‘pusg + youd + Surpnyout + + + pop10del + are + $199JJ2 + TCI + WV + iPeqqnpseao + aq + Aeur + syoen + + + Ze + 07 + dn + ‘Kem + sie + Uy + *(foeI} + JopOUR + OJOS + 10 + ALLAN + + + NOA + ssapun) + dUAS + yOaysod + ul + Avy + [[IM + Yow] + ISIJ + 93 + “prooar + + + NOA + 3[IYM—SUIPIOOA + LIBIS + PU + YORI) + JUdIOTJIP + B + JOaTVs + + + *y1ed + MOU + B + QNPI9A0 + OL, + “SuIps0daI-jOds + 10} + aouenbes + nok + + + Ul + UONBIO] + Aue + ssad0e + ATYOIND + 0} + owt} + Aue + ye + pasn + aq + AvUE + + + SJONUOD + FLIVOOT + Pur + ‘ANIMA + ‘CYVMAOd + LSVA + + + {SUIPIOSAI + aI + posesa + JOU + se + So10U + BuTsTXO— + + + yous} + 3U} + OJUT + poppe + aq + ][IM + poteyd + sajou + yeuonippe + Auy + +

+
+
+

+ + *(polesjop + 10 + paysn{pe + oq + ABW + UOTI99II09 + BUTUTLT) + j{paqoeLI09 + +

+
+
+

+ + 2q + [JIM + S1OLIa + Sur + [fe + A(UO—padeyd + nod + Jey + sedy + ]],NOA

-

- - The - technology - you - use - should - never - be - so - complex - that +

+ + ‘] + req + 0] + punose + yoeq + sdoo] + sduanbas + ay] + Us + AA + “YORI + YTS - - it - interferes - with - the - creative - process. - That’s - precisely - why +

+ +

+ + §,sa0uUaNbag + at} + O} + SUIT] + UT + prevOgAay + [QI] + INO + Avy + uay}y - - the - LinnSequencer - is - designed - to - let - you - compose, - record - - - and - edit - while - devoting - your - undivided - attention - to - your - - - music. - See - your - Linn - dealer - today - for - a - demonstration! + + AV'1d + pue + (YOON + ssoid + A[duus + ‘aousnbes + ve + p109es + OF,

-
-

- - * - Simple, - easy - to - learn - operation—the - 32 - character - LCD - display - clearly - guides - you - through - all - operations. - If - needed, - the +

+

+ + gouaNbIs + & + SUIP1090y]

-
-

- - HELP - button - displays - additional - explanations. +

+

+ + ‘JOWOS + dJoOWNNI + TeUONdO + e

-
-

- - * - Non-destructive - recording—existing - notes - are - not - erased - while - recording. - - - ¢ - Two - FOOTSWITCH - INPUTS - may - be - assigned - to - remotely - control - many - of - the - commonly - used - functions, - including +

+

+ + "UOTEZTUOIYOUAS + APOS + UI} + FLAWS + [euondo + e

-
-

- - ERASE, - REPEAT, - PLAY/STOP, - or - LOCATE. +

+

+ + ‘sou + sulddoys, + noyyM + sayerodo + pue + yoegdvyd + BuLmNp + Sy¥IOM + NOL + LOANYOOD + ONIWILL + e

-
-

- - ¢ - Two - TRIGGER - OUTPUTS - may - be - programmed - to - output - pulses - at - any - selected - note - value. +

+

+ + ‘onqea + OryAYI

-
-

- - © - Will - sync - to - standard - LinnDrum - or - Linn - 9000 - sync - tone. +

+

+ + pojoojas-aid + vB + ye + sajou + pyoy + Aue + syeodar + AyTTeoNewo + Ne + UOTOUNS + [WAdAY + OAISNOXy + e + + + “LS + VA + BulIps + soyeul + UOTOUNS + ASVAA + SUlN}-[eol + SAISNOX” + e + + + ‘Ay + B + JO + YONO} + 9] + 18 + CASOdSNVALL + 94 + ACUI + SyxdeI) + [Te + 10 + 9UO + e

-
-

- - ® - Utilizes - ultra - high-speed, - 8 - MHz - 80186 - 16 - bit - computer - internally - for - FAST - operation. - - - * - TEMPO - may - be - specified - in - BEATS-PER-MINUTE - or - FRAMES-PER-BEAT - at - 24, - 25, - or - 30 - frames - per - second, +

+

+ + iSIP + Jod

-
-

- - (even - drop - frame!) +

+

+ + S9}OU + 000 + ‘OI + J0A0 + Spfoy + pue + spUOdeS + UI + SBUOS + XaTdUIOD + So10}S + OALIP + YSIP + , + 74 + + ISCJ-CIN + ©

-
-

- - ¢ - TEMPO - may - be - entered - numerically, - adjustable - in - tenths - of - a - Beat-Per-Minute - increments, - or - by - tapping - quarter - notes +

+

+ + jSJOZISoyJUAS

-
-

- - on - the - TAP - TEMPO - button. +

+

+ + dtuoyddjod + 9f + 0} + dn + skeyd + AJsnoouejnurrs + ‘spouueys + [QI + 9T + JO + duo + 0} + pousisse + oq + + + ABUL + YORI] + YOR + ‘syous) + oruoydAjod + ‘snoouelnuns + + suTeJUOD + ssdUaNbas + QO] + OY} + JO + HORA + e

-
-

- - ¢ - TEMPO - CHANGES - may - be - programmed - into - a - sequence, - with - smooth - transitions - if - desired. +

+

+ + ‘sJONUOS + ATWOOT + pure + ‘GNIMAY + ‘GYVM + OA - - ¢ - Any - TIME - SIGNATURE - may - be - used, - and - may - be - changed - within - a - song. + + LSVd + ‘GYOOde + AOLS + ‘AV + Td + YM + Jopsoses + ode} + Yows}-N[NU + O} + TepMUIS + st + UOTLIOdOE + e + + + LOPNOUI + SaINjeoy + s[quyIeulsl + AUBUL + $,J] + ‘OSN + puke + UIes] + O} + o[duNs + A[suIzeUe + 194 + ‘[NJsomod + ApOUIIITXO + + + St + 1] + ‘UeroIsNUL + feUOTssajoid + oY} + 10J + JOO} + soUBULIOJIJAd + pue + UOT + IsOduIOS + 11e-9Y1-JO-}e)s + B + SI + JONUANbDaguUT’] + Oy],

-
-

- - linn +

+

+ + JIps1odadyesUINbIS + [GTI + WAL + ZE - - Linn - Electronics, - Inc. - -

-
-
-

- - 18720 - Oxnard - Street, - Tarzana, - CA - 91356 - - - (818) - 708-8131 - TELEX - #298949 - LINN - UR + + Jgouanbaguury + ayy

diff --git a/tests/cache/cardinal/__-l__eng__000002_ocr.png__000002_ocr_hocr__hocr__txt/txt.bin b/tests/cache/cardinal/__-l__eng__000002_ocr.png__000002_ocr_hocr__hocr__txt/txt.bin index 9e1c5257..530e8018 100644 --- a/tests/cache/cardinal/__-l__eng__000002_ocr.png__000002_ocr_hocr__hocr__txt/txt.bin +++ b/tests/cache/cardinal/__-l__eng__000002_ocr.png__000002_ocr_hocr__hocr__txt/txt.bin @@ -1,122 +1,127 @@ -The LinnSequencer -32 Track MIDI Sequence Recorder +2A NNI'l 6F6867# XATALL IE18-80L (818) -The LinnSequencer is a state-of-the-art composition and performance tool for the professional musician. It is +9SEI6 WO “BUBZIRL, “1981S PIPUXO OZL8I +“UY ‘soTUOMON[q UULT -extremely powerful, yet amazingly simple to learn and use. It’s many remarkable features include: +uut] -¢ Operation is similar to multi-track tape recorder with PLAY, STOP, RECORD, FAST -FORWARD, REWIND, and LOCATE controls. +“SUOS & UIJIM pasueyo oq ABU pue ‘posn oq AWW AYN IVNOIS AWLL AUV +“parlsop Jr SUOTIISUBI} YIOOUIS YIM “ousNbas B OJUI pourtueISOId 9q ABU SHONWHO OdIWAL e -¢ Each of the 100 sequences contains 32 simultaneous, polyphonic tracks. Each track may -be assigned to one of 16 MIDI channels. Simultaneously plays up to 16 polyphonic +‘uoting OdWAL dV 94) uo -synthesizers! +sojou Jayienb Suidde} Aq 10 ‘syUdIOIOUT oINUTIAI-JOg-Jesg_ & JO SIS} UT ofquisn(pe ‘ATTeoLIAUINU patajua oq ABU OdIWALL -© Ultra-fast 32” disk drive stores complex songs in seconds and holds over 110,000 notes +(jewel doip uaaa) -per disk! +‘puooes Jod sowely O€ 10 “SZ “PZ 18 [VAG MAd-SHNVU 10 ALNANIWAAd-SLVA UT parsioods oq Ae OdWALL « +‘uoT}e1odo [SV JO} AT[eUIOJUT JoINdUIOS 114 9] 98108 ZH 8 ‘poeds-ysry Baan soz] e -¢ One or all tracks may be TRANSPOSED at the touch of a key. -¢ Exclusive real-time ERASE function makes editing FAST. -© Exclusive REPEAT function automatically repeats any held notes at a pre-selected +‘9U0} OUAS (006 UU] JO tuniqUUr] prepurys 0} OUAS [ITAA e -rhythmic value. +‘ONYBA 9}OU pojoapes Aue Je sas—nd indyno 07 powureisoid 9q ACU SLAG LNO YADOIALL OML -¢ TIMING CORRECTION works during playback and operates without ‘chopping’ notes. +"ALVOOT 10 GOLS/AV 1d ‘IVadae “ASVud -¢ Optional SMPTE time code synchronization. +BuIpNpoUur ‘suoTJOUNJ pasn ATUOUIWWOS 94} JO AUBUT [OIUOS A]OJOWI 0} Pousisse oq APU SLAdNI HOLIMS.LOOA OM +“SUIPIONAL S[IYA POSKsa JOU BI $3]OU BUNSTXO—BUIPIOIS! SATION.YSOP-UON -© Optional remote control. +‘suoneurldxa yeuoryippe sdeydsip uowng q1qH -Recording a Sequence +ay) ‘papsau JT ‘suoneiodo [je Ysnosy} NOA sapins ApIwapo Avdsip QO] Jopereyo 7¢ 9yj—uoeIodo Urea] 0} Aseo ‘aus e -To record a sequence, simply press RECORD and PLAY, -then play your MIDI keyboard in time to the Sequencer’s -click track. When the sequence loops back around to bar 1, -you’ ll hear what you played—only all timing errors will be +jUorelsuOWap B JO} Aepol Jayeap UUI’] INOA aag ‘oISNU +INOA 0} UOTUS}]¥ PepIAIpUN INOA SUTJOASP ITY ps pue +p1ooai ‘asodulod nok Ja] 0} pausisap st J=0uenbesuur’] oy) +AUM ApOsiooid Jey], ‘SSIDOId SATIBSIO SY} YIM SOIOJIOWUT 41 +yey) Xo]CWIOd Os 9q JOAN P[NoYs osn NOK AZOpOuYdII} ou, -corrected! (Timing correction may be adjusted or defeated). +ISTUOIGUIO?) INOYIAA UOHISOdWIO;D -Any additional notes played will be added into the track -— existing notes are not erased while recording! +"NOSpr] B Oy ‘AaNUTsUT yeadal 0} seq Maz Se] BY] Jas UdAd +UBS NOA ‘palisap JJ ‘souanbes Mou ¥v OVUT syed at] [Te Adoo +ATeonewo ne Way) [IM ONOS ALVA JeyIes0} wey} +,deyd,, 0} WOTOUNJ ONOS ALVA su] asn usy] ‘saouanbes +JENPIAIpUt UI ("949 ‘snOYD ‘aS1oA) UOTIDIS JIseq YOR +Plodel OF ST ABM JouIOUY “(Seq 666 0] dn) ysnory) ABM -FAST FORWARD, REWIND, and LOCATE controls -may be used at any time to quickly access any location in -your sequence for spot-recording. To overdub a new part, -select a different track and start recording—while you -record, the first track will play in perfect sync (unless you -MUTE it, or SOLO another track). In this way, up to 32 -tracks may be overdubbed! All MIDI effects are recorded -including pitch bend, modulation, velocity, aftertouch, -sustain pedal, and program changes! +dU} [fe YORI] YOeS p10991 0} ST SUOS B 978919 0] ABM SUG, -Editing +SUOS & SUTVIID -To erase a wrong note, simply hold ERASE and press -the note to be erased just before it plays in the sequence— -when played back, it will be gone. Notes may also be +*suoT]es po]UeMUN -added, erased, or changed using the SINGLE STEP func- -tion. To overdub notes at specific points within a sequence, +SAOUIAI 0} ABM Wes dU} Sa}eIOdo SUV ALATAaG -Additional Features +“OBPLIq VY} PUB SNIOYD PUOdAS dT] U99MIAQ 9SIOA ISI -simply use LOCATE, FAST FORWARD, or REWIND to -find the desired bar number, then start recording. +ay) Jo Adoo & JasUI WYSTU NOAA ‘afdwexs 10.f ‘duo JUdIOIJIP -The INSERT/COPY function allows you to move bars -from one location to another—in the same sequence or a -different one. For example, you might insert a copy of the -first verse between the second chorus and the bridge. -DELETE BARS operates the same way to remove -unwanted sections, +B IO aouaNbas sues OY} UI—JOYIOUP 0} UOTIEIO] BUO WOT] +s1eq JAOUI OF NOA sMOT[e WOTIOUNS KOO/IMASNI PULL -Creating a Song +‘SUTPIONAI JIV]S Ud) “OQuINU eq Polisop ay} puy -One way to create a song is to record each track all the -way through (up to 999 bars). Another way is to record -each basic section (verse, chorus, etc.) in individual -sequences, then use the CREATE SONG function to “chain” -them together. CREATE SONG will then automatically -copy all the parts into a new sequence. If desired, you can -even set the last few bars to repeat infinitely, for a fadeout. +0} GNIMAY 40 ‘CYWM AO LSVA “ALVOOT osn Apduns -Composition Without Compromise +sainjeay [VUOHIPPY -The technology you use should never be so complex that -it interferes with the creative process. That’s precisely why -the LinnSequencer is designed to let you compose, record -and edit while devoting your undivided attention to your -music. See your Linn dealer today for a demonstration! +‘gouanbas 8 UTYIIM SJUTOd oy1aeds 3¥ $9]0U QnpIOAO OL ‘UOT +-ounj dALLS ATONIS 24) Suisn pasueyo 10 ‘pasesa ‘poppe +aq Osye ABUT Sd]ON ‘UO 9q ]IIM II “Yoeq podeyd usyM +—aouanbas oy] ul skeqd 71 a10Jaq Isnf posesa aq 0} 9]0U ayy +ssaid pue ASvuUq ploy Aydurts ‘oj0u Suomm & aseio OL -* Simple, easy to learn operation—the 32 character LCD display clearly guides you through all operations. If needed, the +sunipa -HELP button displays additional explanations. +jsedueyo ureisgoid pue ‘fepoed ureysns +‘yonoparye ‘AWOOTOA ‘UOTyeTNpow ‘pusg youd Surpnyout +pop10del are $199JJ2 TCI WV iPeqqnpseao aq Aeur syoen +Ze 07 dn ‘Kem sie Uy *(foeI} JopOUR OJOS 10 ALLAN +NOA ssapun) dUAS yOaysod ul Avy [[IM Yow] ISIJ 93 “prooar +NOA 3[IYM—SUIPIOOA LIBIS PU YORI) JUdIOTJIP B JOaTVs +*y1ed MOU B QNPI9A0 OL, “SuIps0daI-jOds 10} aouenbes nok +Ul UONBIO] Aue ssad0e ATYOIND 0} owt} Aue ye pasn aq AvUE +SJONUOD FLIVOOT Pur ‘ANIMA ‘CYVMAOd LSVA +{SUIPIOSAI aI posesa JOU se So10U BuTsTXO— +yous} 3U} OJUT poppe aq ][IM poteyd sajou yeuonippe Auy -* Non-destructive recording—existing notes are not erased while recording. -¢ Two FOOTSWITCH INPUTS may be assigned to remotely control many of the commonly used functions, including +*(polesjop 10 paysn{pe oq ABW UOTI99II09 BUTUTLT) j{paqoeLI09 -ERASE, REPEAT, PLAY/STOP, or LOCATE. +2q [JIM S1OLIa Sur [fe A(UO—padeyd nod Jey sedy ]],NOA -¢ Two TRIGGER OUTPUTS may be programmed to output pulses at any selected note value. +‘] req 0] punose yoeq sdoo] sduanbas ay] Us AA “YORI YTS -© Will sync to standard LinnDrum or Linn 9000 sync tone. +§,sa0uUaNbag at} O} SUIT] UT prevOgAay [QI] INO Avy uay}y +AV'1d pue (YOON ssoid A[duus ‘aousnbes ve p109es OF, -® Utilizes ultra high-speed, 8 MHz 80186 16 bit computer internally for FAST operation. -* TEMPO may be specified in BEATS-PER-MINUTE or FRAMES-PER-BEAT at 24, 25, or 30 frames per second, +gouaNbIs & SUIP1090y] -(even drop frame!) +‘JOWOS dJoOWNNI TeUONdO e -¢ TEMPO may be entered numerically, adjustable in tenths of a Beat-Per-Minute increments, or by tapping quarter notes +"UOTEZTUOIYOUAS APOS UI} FLAWS [euondo e -on the TAP TEMPO button. +‘sou sulddoys, noyyM sayerodo pue yoegdvyd BuLmNp Sy¥IOM NOL LOANYOOD ONIWILL e -¢ TEMPO CHANGES may be programmed into a sequence, with smooth transitions if desired. -¢ Any TIME SIGNATURE may be used, and may be changed within a song. +‘onqea OryAYI -linn -Linn Electronics, Inc. +pojoojas-aid vB ye sajou pyoy Aue syeodar AyTTeoNewo Ne UOTOUNS [WAdAY OAISNOXy e +“LS VA BulIps soyeul UOTOUNS ASVAA SUlN}-[eol SAISNOX” e +‘Ay B JO YONO} 9] 18 CASOdSNVALL 94 ACUI SyxdeI) [Te 10 9UO e -18720 Oxnard Street, Tarzana, CA 91356 -(818) 708-8131 TELEX #298949 LINN UR +iSIP Jod + +S9}OU 000 ‘OI J0A0 Spfoy pue spUOdeS UI SBUOS XaTdUIOD So10}S OALIP YSIP , 74 € ISCJ-CIN © + +jSJOZISoyJUAS + +dtuoyddjod 9f 0} dn skeyd AJsnoouejnurrs ‘spouueys [QI 9T JO duo 0} pousisse oq +ABUL YORI] YOR ‘syous) oruoydAjod ‘snoouelnuns 7¢ suTeJUOD ssdUaNbas QO] OY} JO HORA e + +‘sJONUOS ATWOOT pure ‘GNIMAY ‘GYVM OA +LSVd ‘GYOOde AOLS ‘AV Td YM Jopsoses ode} Yows}-N[NU O} TepMUIS st UOTLIOdOE e +LOPNOUI SaINjeoy s[quyIeulsl AUBUL $,J] ‘OSN puke UIes] O} o[duNs A[suIzeUe 194 ‘[NJsomod ApOUIIITXO +St 1] ‘UeroIsNUL feUOTssajoid oY} 10J JOO} soUBULIOJIJAd pue UOT IsOduIOS 11e-9Y1-JO-}e)s B SI JONUANbDaguUT’] Oy], + +JIps1odadyesUINbIS [GTI WAL ZE +Jgouanbaguury ayy diff --git a/tests/cache/cardinal/__-l__eng__000002_ocr.png__000002_ocr_tess__pdf__txt/pdf.bin b/tests/cache/cardinal/__-l__eng__000002_ocr.png__000002_ocr_tess__pdf__txt/pdf.bin index 7048ee01..8ddc67e5 100644 Binary files a/tests/cache/cardinal/__-l__eng__000002_ocr.png__000002_ocr_tess__pdf__txt/pdf.bin and b/tests/cache/cardinal/__-l__eng__000002_ocr.png__000002_ocr_tess__pdf__txt/pdf.bin differ diff --git a/tests/cache/cardinal/__-l__eng__000002_ocr.png__000002_ocr_tess__pdf__txt/txt.bin b/tests/cache/cardinal/__-l__eng__000002_ocr.png__000002_ocr_tess__pdf__txt/txt.bin index 9e1c5257..7ce15cb2 100644 --- a/tests/cache/cardinal/__-l__eng__000002_ocr.png__000002_ocr_tess__pdf__txt/txt.bin +++ b/tests/cache/cardinal/__-l__eng__000002_ocr.png__000002_ocr_tess__pdf__txt/txt.bin @@ -8,18 +8,18 @@ extremely powerful, yet amazingly simple to learn and use. It’s many remarkabl ¢ Operation is similar to multi-track tape recorder with PLAY, STOP, RECORD, FAST FORWARD, REWIND, and LOCATE controls. -¢ Each of the 100 sequences contains 32 simultaneous, polyphonic tracks. Each track may +e Each of the 100 sequences contains 32 simultaneous, polyphonic tracks. Each track may be assigned to one of 16 MIDI channels. Simultaneously plays up to 16 polyphonic synthesizers! -© Ultra-fast 32” disk drive stores complex songs in seconds and holds over 110,000 notes +¢ Ultra-fast 3%" disk drive stores complex songs in seconds and holds over 110,000 notes per disk! ¢ One or all tracks may be TRANSPOSED at the touch of a key. -¢ Exclusive real-time ERASE function makes editing FAST. -© Exclusive REPEAT function automatically repeats any held notes at a pre-selected +e Exclusive real-time ERASE function makes editing FAST. +¢ Exclusive REPEAT function automatically repeats any held notes at a pre-selected rhythmic value. @@ -34,12 +34,12 @@ Recording a Sequence To record a sequence, simply press RECORD and PLAY, then play your MIDI keyboard in time to the Sequencer’s click track. When the sequence loops back around to bar 1, -you’ ll hear what you played—only all timing errors will be +you'll hear what you played—only all timing errors will be corrected! (Timing correction may be adjusted or defeated). Any additional notes played will be added into the track -— existing notes are not erased while recording! +—existing notes are not erased while recording! FAST FORWARD, REWIND, and LOCATE controls may be used at any time to quickly access any location in @@ -70,7 +70,7 @@ from one location to another—in the same sequence or a different one. For example, you might insert a copy of the first verse between the second chorus and the bridge. DELETE BARS operates the same way to remove -unwanted sections, +unwanted sections. Creating a Song @@ -103,8 +103,8 @@ ERASE, REPEAT, PLAY/STOP, or LOCATE. © Will sync to standard LinnDrum or Linn 9000 sync tone. -® Utilizes ultra high-speed, 8 MHz 80186 16 bit computer internally for FAST operation. -* TEMPO may be specified in BEATS-PER-MINUTE or FRAMES-PER-BEAT at 24, 25, or 30 frames per second, +© Utilizes ultra high-speed, 8 MHz 80186 16 bit computer internally for FAST operation. +¢ TEMPO may be specified in BEATS-PER-MINUTE or FRAMES-PER-BEAT at 24, 25, or 30 frames per second, (even drop frame!) diff --git a/tests/cache/cardinal/__-l__eng__000003_ocr.png__000003_ocr_hocr__hocr__txt/hocr.bin b/tests/cache/cardinal/__-l__eng__000003_ocr.png__000003_ocr_hocr__hocr__txt/hocr.bin index 7c8fd82d..8e824a3c 100644 --- a/tests/cache/cardinal/__-l__eng__000003_ocr.png__000003_ocr_hocr__hocr__txt/hocr.bin +++ b/tests/cache/cardinal/__-l__eng__000003_ocr.png__000003_ocr_hocr__hocr__txt/hocr.bin @@ -5,11 +5,11 @@ - - + + -
+

@@ -18,8 +18,8 @@ 32 - Track - MIDI + Track + MIDI Sequence Recorder @@ -29,7 +29,7 @@

The - LinnSequencer + LinnSequencer is a state-of-the-art @@ -37,7 +37,7 @@ and performance tool - for + for the professional musician. @@ -67,25 +67,25 @@

- ¢ + ¢ Operation is - similar + similar to multi-track tape recorder - with - PLAY, + with + PLAY, STOP, - RECORD, + RECORD, FAST FORWARD, - REWIND, - and - LOCATE + REWIND, + and + LOCATE controls.

@@ -93,7 +93,7 @@

- ¢ + e Each of the @@ -110,13 +110,13 @@ be - assigned + assigned to one of 16 MIDI - channels. + channels. Simultaneously plays up @@ -136,9 +136,9 @@

- © + ¢ Ultra-fast - 32” + 3%” disk drive stores @@ -165,13 +165,13 @@

- ¢ - One - or + ¢ + One + or all tracks - may - be + may + be TRANSPOSED at the @@ -181,7 +181,7 @@ key. - ¢ + e Exclusive real-time ERASE @@ -191,11 +191,11 @@ FAST. - © + © Exclusive REPEAT function - automatically + automatically repeats any held @@ -209,7 +209,7 @@

- rhythmic + rhythmic value.

@@ -217,7 +217,7 @@

- ¢ + ¢ TIMING CORRECTION works @@ -226,7 +226,7 @@ and operates without - ‘chopping’ + ‘chopping’ notes.

@@ -238,7 +238,7 @@ Optional SMPTE time - code + code synchronization.

@@ -246,7 +246,7 @@

- © + © Optional remote control. @@ -256,23 +256,23 @@

- Recording - a + Recording + a Sequence

- To + To record a sequence, simply - press + press RECORD and - PLAY, + PLAY, then @@ -280,15 +280,15 @@ your MIDI keyboard - in - time + in + time to the Sequencer’s click - track. + track. When the sequence @@ -297,11 +297,11 @@ around to bar - 1, + 1, - you’ - ll + you’ + ll hear what you @@ -323,7 +323,7 @@ may be adjusted - or + or defeated).

@@ -356,7 +356,7 @@

- FAST + FAST FORWARD, REWIND, and @@ -364,9 +364,9 @@ controls - may + may be - used + used at any time @@ -380,10 +380,10 @@ your sequence - for - spot-recording. + for + spot-recording. To - overdub + overdub a new part, @@ -400,13 +400,13 @@ record, - the - first - track + the + first + track will play in - perfect + perfect sync (unless you @@ -442,7 +442,7 @@ bend, modulation, velocity, - aftertouch, + aftertouch, sustain @@ -469,15 +469,15 @@ note, simply hold - ERASE - and + ERASE + and press the note to - be + be erased just before @@ -485,7 +485,7 @@ plays in the - sequence— + sequence— when @@ -510,21 +510,21 @@ or changed using - the - SINGLE - STEP - func- + the + SINGLE + STEP + func- tion. To - overdub + overdub notes at specific points - within - a + within + a sequence,

@@ -542,16 +542,16 @@ simply use - LOCATE, + LOCATE, FAST - FORWARD, + FORWARD, or - REWIND - to + REWIND + to find - the + the desired bar number, @@ -570,13 +570,13 @@ you to move - bars + bars - from + from one location - to + to another—in the same @@ -615,12 +615,12 @@ the same way - to + to remove unwanted - sections, + sections,

@@ -640,8 +640,8 @@ to create a - song - is + song + is to record each @@ -652,14 +652,14 @@ way through - (up - to + (up + to 999 bars). Another way is - to + to record @@ -677,7 +677,7 @@ then use the - CREATE + CREATE SONG function to @@ -686,7 +686,7 @@ them together. - CREATE + CREATE SONG will then @@ -697,7 +697,7 @@ all the parts - into + into a new sequence. @@ -716,8 +716,8 @@ to repeat infinitely, - for - a + for + a fadeout.

@@ -725,7 +725,7 @@

- Composition + Composition Without Compromise @@ -741,24 +741,24 @@ never be so - complex + complex that it interferes with - the + the creative process. - That’s + That’s precisely why the LinnSequencer - is + is designed to let @@ -782,18 +782,18 @@ See your Linn - dealer - today + dealer + today for a - demonstration! + demonstration!

- * + * Simple, easy to @@ -805,11 +805,11 @@ display clearly guides - you + you through all operations. - If + If needed, the @@ -821,7 +821,7 @@ HELP button displays - additional + additional explanations.

@@ -829,20 +829,20 @@

- * + * Non-destructive - recording—existing + recording—existing notes are not erased while - recording. + recording. - ¢ + ¢ Two - FOOTSWITCH + FOOTSWITCH INPUTS may be @@ -864,8 +864,8 @@

ERASE, - REPEAT, - PLAY/STOP, + REPEAT, + PLAY/STOP, or LOCATE. @@ -874,9 +874,9 @@

- ¢ - Two - TRIGGER + ¢ + Iwo + TRIGGER OUTPUTS may be @@ -887,7 +887,7 @@ at any selected - note + note value.

@@ -895,12 +895,12 @@

- © + © Will - sync + sync to standard - LinnDrum + LinnDrum or Linn 9000 @@ -912,13 +912,13 @@

- ® - Utilizes + © + Utilizes ultra high-speed, 8 - MHz - 80186 + MHz + 80186 16 bit computer @@ -928,7 +928,7 @@ operation. - * + * TEMPO may be @@ -936,14 +936,14 @@ in BEATS-PER-MINUTE or - FRAMES-PER-BEAT + FRAMES-PER-BEAT at 24, 25, or 30 - frames - per + frames + per second,

@@ -960,18 +960,18 @@

- ¢ - TEMPO + ¢ + TEMPO may - be + be entered numerically, - adjustable + adjustable in - tenths + tenths of - a - Beat-Per-Minute + a + Beat-Per-Minute increments, or by @@ -995,23 +995,23 @@

- ¢ + ¢ TEMPO CHANGES may be - programmed + programmed into a sequence, with - smooth + smooth transitions if - desired. + desired. - ¢ + ¢ Any TIME SIGNATURE @@ -1035,8 +1035,8 @@ Linn - Electronics, - Inc. + Electronics, + Inc.

@@ -1047,15 +1047,15 @@ Oxnard Street, Tarzana, - CA + CA 91356 (818) - 708-8131 + 708-8131 TELEX - #298949 - LINN + #298949 + LINN UR

diff --git a/tests/cache/cardinal/__-l__eng__000003_ocr.png__000003_ocr_hocr__hocr__txt/txt.bin b/tests/cache/cardinal/__-l__eng__000003_ocr.png__000003_ocr_hocr__hocr__txt/txt.bin index 9e1c5257..8147020a 100644 --- a/tests/cache/cardinal/__-l__eng__000003_ocr.png__000003_ocr_hocr__hocr__txt/txt.bin +++ b/tests/cache/cardinal/__-l__eng__000003_ocr.png__000003_ocr_hocr__hocr__txt/txt.bin @@ -8,17 +8,17 @@ extremely powerful, yet amazingly simple to learn and use. It’s many remarkabl ¢ Operation is similar to multi-track tape recorder with PLAY, STOP, RECORD, FAST FORWARD, REWIND, and LOCATE controls. -¢ Each of the 100 sequences contains 32 simultaneous, polyphonic tracks. Each track may +e Each of the 100 sequences contains 32 simultaneous, polyphonic tracks. Each track may be assigned to one of 16 MIDI channels. Simultaneously plays up to 16 polyphonic synthesizers! -© Ultra-fast 32” disk drive stores complex songs in seconds and holds over 110,000 notes +¢ Ultra-fast 3%” disk drive stores complex songs in seconds and holds over 110,000 notes per disk! ¢ One or all tracks may be TRANSPOSED at the touch of a key. -¢ Exclusive real-time ERASE function makes editing FAST. +e Exclusive real-time ERASE function makes editing FAST. © Exclusive REPEAT function automatically repeats any held notes at a pre-selected rhythmic value. @@ -99,11 +99,11 @@ HELP button displays additional explanations. ERASE, REPEAT, PLAY/STOP, or LOCATE. -¢ Two TRIGGER OUTPUTS may be programmed to output pulses at any selected note value. +¢ Iwo TRIGGER OUTPUTS may be programmed to output pulses at any selected note value. © Will sync to standard LinnDrum or Linn 9000 sync tone. -® Utilizes ultra high-speed, 8 MHz 80186 16 bit computer internally for FAST operation. +© Utilizes ultra high-speed, 8 MHz 80186 16 bit computer internally for FAST operation. * TEMPO may be specified in BEATS-PER-MINUTE or FRAMES-PER-BEAT at 24, 25, or 30 frames per second, (even drop frame!) diff --git a/tests/cache/cardinal/__-l__eng__000003_ocr.png__000003_ocr_tess__pdf__txt/pdf.bin b/tests/cache/cardinal/__-l__eng__000003_ocr.png__000003_ocr_tess__pdf__txt/pdf.bin index 97934a26..66ceaf08 100644 Binary files a/tests/cache/cardinal/__-l__eng__000003_ocr.png__000003_ocr_tess__pdf__txt/pdf.bin and b/tests/cache/cardinal/__-l__eng__000003_ocr.png__000003_ocr_tess__pdf__txt/pdf.bin differ diff --git a/tests/cache/cardinal/__-l__eng__000003_ocr.png__000003_ocr_tess__pdf__txt/txt.bin b/tests/cache/cardinal/__-l__eng__000003_ocr.png__000003_ocr_tess__pdf__txt/txt.bin index 9e1c5257..7ce15cb2 100644 --- a/tests/cache/cardinal/__-l__eng__000003_ocr.png__000003_ocr_tess__pdf__txt/txt.bin +++ b/tests/cache/cardinal/__-l__eng__000003_ocr.png__000003_ocr_tess__pdf__txt/txt.bin @@ -8,18 +8,18 @@ extremely powerful, yet amazingly simple to learn and use. It’s many remarkabl ¢ Operation is similar to multi-track tape recorder with PLAY, STOP, RECORD, FAST FORWARD, REWIND, and LOCATE controls. -¢ Each of the 100 sequences contains 32 simultaneous, polyphonic tracks. Each track may +e Each of the 100 sequences contains 32 simultaneous, polyphonic tracks. Each track may be assigned to one of 16 MIDI channels. Simultaneously plays up to 16 polyphonic synthesizers! -© Ultra-fast 32” disk drive stores complex songs in seconds and holds over 110,000 notes +¢ Ultra-fast 3%" disk drive stores complex songs in seconds and holds over 110,000 notes per disk! ¢ One or all tracks may be TRANSPOSED at the touch of a key. -¢ Exclusive real-time ERASE function makes editing FAST. -© Exclusive REPEAT function automatically repeats any held notes at a pre-selected +e Exclusive real-time ERASE function makes editing FAST. +¢ Exclusive REPEAT function automatically repeats any held notes at a pre-selected rhythmic value. @@ -34,12 +34,12 @@ Recording a Sequence To record a sequence, simply press RECORD and PLAY, then play your MIDI keyboard in time to the Sequencer’s click track. When the sequence loops back around to bar 1, -you’ ll hear what you played—only all timing errors will be +you'll hear what you played—only all timing errors will be corrected! (Timing correction may be adjusted or defeated). Any additional notes played will be added into the track -— existing notes are not erased while recording! +—existing notes are not erased while recording! FAST FORWARD, REWIND, and LOCATE controls may be used at any time to quickly access any location in @@ -70,7 +70,7 @@ from one location to another—in the same sequence or a different one. For example, you might insert a copy of the first verse between the second chorus and the bridge. DELETE BARS operates the same way to remove -unwanted sections, +unwanted sections. Creating a Song @@ -103,8 +103,8 @@ ERASE, REPEAT, PLAY/STOP, or LOCATE. © Will sync to standard LinnDrum or Linn 9000 sync tone. -® Utilizes ultra high-speed, 8 MHz 80186 16 bit computer internally for FAST operation. -* TEMPO may be specified in BEATS-PER-MINUTE or FRAMES-PER-BEAT at 24, 25, or 30 frames per second, +© Utilizes ultra high-speed, 8 MHz 80186 16 bit computer internally for FAST operation. +¢ TEMPO may be specified in BEATS-PER-MINUTE or FRAMES-PER-BEAT at 24, 25, or 30 frames per second, (even drop frame!) diff --git a/tests/cache/cardinal/__-l__eng__000004_ocr.png__000004_ocr_hocr__hocr__txt/hocr.bin b/tests/cache/cardinal/__-l__eng__000004_ocr.png__000004_ocr_hocr__hocr__txt/hocr.bin index dff5cf96..e941d6e8 100644 --- a/tests/cache/cardinal/__-l__eng__000004_ocr.png__000004_ocr_hocr__hocr__txt/hocr.bin +++ b/tests/cache/cardinal/__-l__eng__000004_ocr.png__000004_ocr_hocr__hocr__txt/hocr.bin @@ -5,1058 +5,1086 @@ - - + + -
-
-

- - The - LinnSequencer - - - 32 - Track - MIDI - Sequence - Recorder +

+
+

+ + 2 + NNI‘l + 6F6867# + XATALL + IE18-80L + (818)

-
-

- - The - LinnSequencer - is - a - state-of-the-art - composition - and - performance - tool - for - the - professional - musician. - It - is +

+

+ + 9SEI6 + VO + “BUBZIRL, + “1081S + PIPUXO + OZL8I + + + “Uy + ‘soTUOMOI,q + UUrT + +

+
+
+

+ + uut] + +

+
+
+

+ + “‘SUOS + B + UIJIM + pasueyo + oq + ABU + pue + ‘posn + oq + ACW + AYN + IVNOIS + AWLL + AUV + + + ‘parlsop + Jr + SUOTIISUBI} + YIOOUIS + YIM + ‘“BousNbas + B + OJUI + pourtueIZOId + 9q + ABUL + SFONWHO + OdINAL + e + +

+
+
+

+ + ‘uonng + OdNAL + d¥L + 24) + UO + +

+
+
+

+ + sajou + Jayienb + Suiddy} + Aq + 10 + ‘syUdtIOIOUI + oINUTIAI-JOg-Jesg_ + & + JO + sys} + Ul + sfqeisn(pe + ‘ATTeoLAUINU + paiajua + oq + ABU + OdIWAL + +

+
+
+

+ + (jourery + doup + uaa9) + +

+
+
+

+ + “puooes + Jed + sowely + O€ + 10 + “SZ + “pz + 18 + [VAG + MAd-SHNVU + 10 + ALOANIWAAd-SLVAd + U! + patyoeds + aq + Aew + OdWAL + © + + + ‘uoT}e1odo + [SV + OJ + A][eUIOJUT + JoyndUIOd + 11q + 9] + 98108 + ZHI + 8g + ‘poeds-ysry + By[N + sozyN] + e + +

+
+
+

+ + "9U0} + OUAS + 0006 + UUL] + JO + tuniquuUry] + prepueys + 0} + OUAS + [ITAA + e + +

+
+
+

+ + “ANYBA + 9}OU + pojoapes + Aue + Je + sas—nd + jndyno + 07 + powureisoid + 9q + ACU + SL + Ad + LNO + YADOIMAL + OML + +

+
+
+

+ + "ALVOOT + 10 + AOLS/AV + 1d + ‘LWadad + “ASV + +

+
+
+

+ + SUIPNpOUr + ‘sUOTIOUN] + pasn + A[UOUILUOS + IY} + JO + AUBUL + [O1]UOD + AJ9JOWIAI + 0} + PoUsIsse + oq + APU + ST + AANI + HOLIMS + LOO? + OME + « + + + “BUIPIONSI + JIYM + PIseld + JOU + a1v + $3}OU + ZUTISIXO—ZUIPIOIAI + SATON.ASOP-UON] + @ + +

+
+
+

+ + ‘suoneur[dxa + peuorippe + sdeydsip + uong + q1qH + +

+
+
+

+ + oy) + ‘pepsou + JI + ‘suonesodo + [ye + YBnosY] + NOA + sapins + ApIeapo + Avfdsip + QO] + Jopesleyo + + 9yj—uOTeIOdo + UIEa] + 0} + Ased + ‘aTCUIS + « + +

+
+
+

+ + juoreljsuoWwap + & + IO} + Aepod + Jayeap + uUI’] + INOA + dag + ‘dISNUL + + + INOA + 0} + UOTUS}}Y + PeplAIpuN + INOA + SUTOASp + ITY + ps + pue + + + piosai + ‘asodurod + noX + Jay + 0} + pausisap + st + 1<0uenbaguur’] + oy} + + + AYM + ApOstooid + $, + JEU, + ‘SSddOId + BATIBIID + OY} + YIM + SOIOJIOIUT + 7 + + + yey) + xa{dwi0d + Os + aq + J9A9U + pPrNoys + osn + Nok + AZopOuYIE} + oy, + +

+
+
+

+ + ISTUMOIGUIO?) + INOYAA + UOHISOdwo) + +

+
+
+

+ + "NOpr] + B + IO} + ‘AJONUTJUT + yeadal + OF + seq + Maz + ISP] + BY] + JOS + UaAD + + + ued + NOA + ‘palisap + J] + ‘souanbes + Mou + v + OVUT + syed + au] + [Te + Adoo + + + ATeonewone + wsy) + [TIM + ONOS + ALVA + JeyIeso} + way} + + + ,Uleyd,, + 0} + UOTOUNJ + ONOS + ALVA + oY] + asn + usyy + ‘saouanbes + + + JENPIAIpUt + UI + (“919 + ‘snJOYD + ‘dS19A) + UOT}DIS + JISeq + YO" + + + PlodeI + OF + st + ABM + JAYIOUY + ‘(S1eq + 666 + O} + dn) + Ysnory] + ACM

-

- - extremely - powerful, - yet - amazingly - simple - to - learn - and - use. - It’s - many - remarkable - features - include: +

+ + 9} + [fe + YI] + YORI + P1OIOI + OJ + ST + SUOS + B + 9}B9IO + 0} + ABM + SUC, + +

+
+
+

+ + SUOS + & + SUT}LAID + +

+
+
+

+ + ‘suoT}oes + po]ueMUN

-

- - ¢ - Operation - is - similar - to - multi-track - tape - recorder - with - PLAY, - STOP, - RECORD, - FAST - - - FORWARD, - REWIND, - and - LOCATE - controls. - -

-
-
-

- - ¢ - Each - of - the - 100 - sequences - contains - 32 - simultaneous, - polyphonic - tracks. - Each - track - may - - - be - assigned - to - one - of - 16 - MIDI - channels. - Simultaneously - plays - up - to - 16 - polyphonic - -

-
-
-

- - synthesizers! - -

-
-
-

- - © - Ultra-fast - 32” - disk - drive - stores - complex - songs - in - seconds - and - holds - over - 110,000 - notes - -

-
-
-

- - per - disk! - -

-
-
-

- - ¢ - One - or - all - tracks - may - be - TRANSPOSED - at - the - touch - of - a - key. - - - ¢ - Exclusive - real-time - ERASE - function - makes - editing - FAST. - - - © - Exclusive - REPEAT - function - automatically - repeats - any - held - notes - at - a - pre-selected - -

-
-
-

- - rhythmic - value. - -

-
-
-

- - ¢ - TIMING - CORRECTION - works - during - playback - and - operates - without - ‘chopping’ - notes. - -

-
-
-

- - ¢ - Optional - SMPTE - time - code - synchronization. - -

-
-
-

- - © - Optional - remote - control. - -

-
-
-

- - Recording - a - Sequence +

+ + dAOWAT + 0} + ABM + SUVS + dU} + SoVIDdO + SUVE + ALATA

-

- - To - record - a - sequence, - simply - press - RECORD - and - PLAY, - - - then - play - your - MIDI - keyboard - in - time - to - the - Sequencer’s - - - click - track. - When - the - sequence - loops - back - around - to - bar - 1, - - - you’ - ll - hear - what - you - played—only - all - timing - errors - will - be - -

-
-
-

- - corrected! - (Timing - correction - may - be - adjusted - or - defeated). - -

-
-
-

- - Any - additional - notes - played - will - be - added - into - the - track - - - - existing - notes - are - not - erased - while - recording! +

+ + ‘ASPLIq + BY] + PUL + SNIOYD + PUODAS + dT]] + USOMIAQ + 9SIOA + ISAT

-

- - FAST - FORWARD, - REWIND, - and - LOCATE - controls - - - may - be - used - at - any - time - to - quickly - access - any - location - in - - - your - sequence - for - spot-recording. - To - overdub - a - new - part, - - - select - a - different - track - and - start - recording—while - you - - - record, - the - first - track - will - play - in - perfect - sync - (unless - you - - - MUTE - it, - or - SOLO - another - track). - In - this - way, - up - to - 32 - - - tracks - may - be - overdubbed! - All - MIDI - effects - are - recorded - - - including - pitch - bend, - modulation, - velocity, - aftertouch, - - - sustain - pedal, - and - program - changes! - -

-
-
-

- - Editing +

+ + ay + Jo + Adoo + & + YaST + JYSTU + NOA + ‘afdwexe + 10.f + ‘UO + JUSIOIJIP

-

- - To - erase - a - wrong - note, - simply - hold - ERASE - and - press +

+ + B + IO + aduaNbas + sues + dy} + UI—JYJOUB + 0} + UOTIBIO] + BU + WO - - the - note - to - be - erased - just - before - it - plays - in - the - sequence— - - - when - played - back, - it - will - be - gone. - Notes - may - also - be - -

-
-
-

- - added, - erased, - or - changed - using - the - SINGLE - STEP - func- - - - tion. - To - overdub - notes - at - specific - points - within - a - sequence, - -

-
-
-

- - Additional - Features - -

-
-
-

- - simply - use - LOCATE, - FAST - FORWARD, - or - REWIND - to - - - find - the - desired - bar - number, - then - start - recording. + + $IBq + DAOUL + OF + NOA + sMOTIe + UOTIOUNS + AdOO/LAASNI + OULL

-

- - The - INSERT/COPY - function - allows - you - to - move - bars - - - from - one - location - to - another—in - the - same - sequence - or - a - - - different - one. - For - example, - you - might - insert - a - copy - of - the - - - first - verse - between - the - second - chorus - and - the - bridge. - - - DELETE - BARS - operates - the - same - way - to - remove - - - unwanted - sections, - -

-
-
-

- - Creating - a - Song +

+ + ‘SUTPIONAI + J1V]S + Ud} + ‘IOquINU + Ieq + polIsop + ay} + pul

-

- - One - way - to - create - a - song - is - to - record - each - track - all - the - - - way - through - (up - to - 999 - bars). - Another - way - is - to - record - - - each - basic - section - (verse, - chorus, - etc.) - in - individual - - - sequences, - then - use - the - CREATE - SONG - function - to - “chain” - - - them - together. - CREATE - SONG - will - then - automatically - - - copy - all - the - parts - into - a - new - sequence. - If - desired, - you - can - - - even - set - the - last - few - bars - to - repeat - infinitely, - for - a - fadeout. +

+ + 0} + CNIMAY + 40 + ‘CUYVMAOd + LSVA + “ALVOOT + 2sn + Apduns

-
-

- - Composition - Without - Compromise +

+

+ + soinjeay + [BUOHIPPY + +

+
+
+

+ + ‘gouanbas + & + UTYIIM + sJUTOd + a1y1Dads + + sa10U + GnpPIDAO + QL + ‘uO + + + -Ouny + dALLS + AIONIS + 94} + Suisn + pasueyo + 10 + ‘pasvsa + ‘pappe + + + aq + ose + ABUT + SdION + ‘UB + 9q + [TIM + 1 + “yoRq + podeyd + uayM + + + —aouanbas + oy] + ul + skeqd + 31 + a10J0q + Isnf + pasess + aq + 0} + d]0U + ayy + + + ssaid + pue + ASvwUq + proy + Ayduuts + ‘jou + 3uom + & + aseso + OF + +

+
+
+

+ + sunipa + +

+
+
+

+ + jsosueyo + weisold + pue + ‘yepod + ureysns + + + ‘yOnoPaIJe + ‘AWOOTOA + ‘UOTETNpow + ‘pusq + youd + Surpnyour + + + Pep1OIEI + AIB + $199]J2 + ICN + IV + iPeqqnpseao + aq + Aew + syoen + + + Ze + 0) + dn + ‘Kem + sry + Uy + *(YoeI} + JOyJOUR + OOS + 10 + “I + ALAIN + + + NOA + ssafun) + duAS + yooysod + ul + Avy + [TIM + Yow) + ISI + ay} + “prooas + + + NOK + d[TYM—SUIPIOOII + JABS + PUB + YIVI} + JUDIOJJIP + & + JOoJOs + + + *yaed + Mau + B + QnpIdAO + OF, + “‘BUIps0deI-jods + 10J + aduanbes + nok + + + UT + UOTIBdO] + Aue + ssodoe + ATYIND + 0} + owt) + Aue + ye + posn + aq + Aur + + + S]ONUOD + ALVOOT + pur + ‘GNIMAY + ‘AAVMAOA + LSVA + + + {SUIpIOal + aIYM + posesd + jou + se + $9}OU + BUTsTxO— + + + youd) + UY} + OUT + poppe + aq + [IM + poterd + sojou + yeuonippe + Auy + +

+
+
+

+ + ‘(poyeayop + 10 + paysn{pe + aq + ABW + UOTI991109 + BUTUITL) + j{po}oe1109 + +

+
+
+

+ + 9q + [IM + S1O11a + Surtun + [fe + A[UO—pateyd + nod + JeyM + sJesy + ]],NOA

-

- - The - technology - you - use - should - never - be - so - complex - that +

+ + ‘] + tq + 0] + punose + yorq + sdooy] + sduanbas + ay] + us + AA + “YOu + YOIO - - it - interferes - with - the - creative - process. - That’s - precisely - why +

+ +

+ + §,Ja0uaNbag + at{} + O] + SUIT] + UT + preOgAay + [CIP + INOA + Avyd + usyy - - the - LinnSequencer - is - designed - to - let - you - compose, - record - - - and - edit - while - devoting - your - undivided - attention - to - your - - - music. - See - your - Linn - dealer - today - for - a - demonstration! + + AV'1d + Puke + CYOOAY + ssoid + Ayduuts + ‘aouenbes + v + p1099e4 + OL,

-
-

- - * - Simple, - easy - to - learn - operation—the - 32 - character - LCD - display - clearly - guides - you - through - all - operations. - If - needed, - the +

+

+ + goUaNbIs + & + SUIP10I0Y

-
-

- - HELP - button - displays - additional - explanations. +

+

+ + ‘JONWOD + sJoOuIaI + TeUONdGO + e

-
-

- - * - Non-destructive - recording—existing - notes - are - not - erased - while - recording. - - - ¢ - Two - FOOTSWITCH - INPUTS - may - be - assigned - to - remotely - control - many - of - the - commonly - used - functions, - including +

+

+ + "UOTEZIUOIYIUAS + OPOS + dU} + FLAWS + [euondo + e

-
-

- - ERASE, - REPEAT, - PLAY/STOP, - or - LOCATE. +

+

+ + ‘sou + suiddoys, + jnoyyM + soyelodo + pue + yoegdeyd + SuLinp + Sy¥IOM + NOL + LOANYOO + ONIWILL + e

-
-

- - ¢ - Two - TRIGGER - OUTPUTS - may - be - programmed - to - output - pulses - at - any - selected - note - value. +

+

+ + ‘anyea + ory + AYI

-
-

- - © - Will - sync - to - standard - LinnDrum - or - Linn - 9000 - sync - tone. +

+

+ + pojoojes-oid + & + ye + sojoU + pyoy + Aue + syeadar + ATTeonewOjNe + UOTOUN] + [TWAdAY + OAISNOX + e + + + “LSVJ + SUTIpS + soyeU + UOTOUN + ASVAA + OUll-[eal + SAISNIOXY + + + ‘Ady + B + JO + YONO} + 941 + 1 + CASOdSNVALL + 9 + ABU! + SyoeI] + [Te + IO + SUC + e

-
-

- - ® - Utilizes - ultra - high-speed, - 8 - MHz - 80186 - 16 - bit - computer - internally - for - FAST - operation. - - - * - TEMPO - may - be - specified - in - BEATS-PER-MINUTE - or - FRAMES-PER-BEAT - at - 24, - 25, - or - 30 - frames - per - second, +

+

+ + i + ASIP + od

-
-

- - (even - drop - frame!) +

+

+ + $9}0U + QOO‘OIT + 120 + Spfoy + puk + SpUOdeS + UT + SBUOS + Xa[AUIOD + So10}S + DALIP + YSIP + , + 4 + ISCJ-CNIN

-
-

- - ¢ - TEMPO - may - be - entered - numerically, - adjustable - in - tenths - of - a - Beat-Per-Minute - increments, - or - by - tapping - quarter - notes +

+

+ + jSIOZISOyJUAS

-
-

- - on - the - TAP - TEMPO - button. +

+

+ + dtuoyddjod + gf + 0} + dn + skeyd + A[snooueynus + ‘spouueys + [QI + 9T + JO + duo + 0} + pousisse + oq + + + ABUL + YORI] + YOR + ‘syous) + oruoydAjod + ‘snooue}nuns + + suTeJUOS + ssoUaNbeas + YO] + OY} + JO + HORA + e

-
-

- - ¢ - TEMPO - CHANGES - may - be - programmed - into - a - sequence, - with - smooth - transitions - if - desired. +

+

+ + ‘sJOUOS + ALWOOT + pure + ‘GNIMAY + ‘UVM + OA - - ¢ - Any - TIME - SIGNATURE - may - be - used, - and - may - be - changed - within - a - song. + + LSVd + ‘GYOOde + AOLS + ‘AV + Td + YIM + Jopsosas + ade} + Yows}-N[NU + O} + eps + st + UOTeIOdO + e + + + SOPNOUT + Sanjeoy + o[quyIeUlsl + AUBUL + S.J + ‘OSN + puke + UIee] + O} + o[duNs + A[SuIzeUe + JOA + ‘PNJsomod + ApOUIITXO + + + St + 1] + ‘UeIOIsNUL + FeUOTssajoid + oY} + 10 + JOO} + soUBULIOJIJAd + pue + UOTIsOduION + 11e-9Y1-JO-}e)s + B + SI + IONUaNDaguUT] + ay + L

-
-

- - linn +

+

+ + JOps1odady + soUINbIS + [GTI + YAL + ZE - - Linn - Electronics, - Inc. - -

-
-
-

- - 18720 - Oxnard - Street, - Tarzana, - CA - 91356 - - - (818) - 708-8131 - TELEX - #298949 - LINN - UR + + Jg0uenbaCguury + sy

diff --git a/tests/cache/cardinal/__-l__eng__000004_ocr.png__000004_ocr_hocr__hocr__txt/txt.bin b/tests/cache/cardinal/__-l__eng__000004_ocr.png__000004_ocr_hocr__hocr__txt/txt.bin index 9e1c5257..4213e03b 100644 --- a/tests/cache/cardinal/__-l__eng__000004_ocr.png__000004_ocr_hocr__hocr__txt/txt.bin +++ b/tests/cache/cardinal/__-l__eng__000004_ocr.png__000004_ocr_hocr__hocr__txt/txt.bin @@ -1,122 +1,127 @@ -The LinnSequencer -32 Track MIDI Sequence Recorder +2 NNI‘l 6F6867# XATALL IE18-80L (818) -The LinnSequencer is a state-of-the-art composition and performance tool for the professional musician. It is +9SEI6 VO “BUBZIRL, “1081S PIPUXO OZL8I +“Uy ‘soTUOMOI,q UUrT -extremely powerful, yet amazingly simple to learn and use. It’s many remarkable features include: +uut] -¢ Operation is similar to multi-track tape recorder with PLAY, STOP, RECORD, FAST -FORWARD, REWIND, and LOCATE controls. +“‘SUOS B UIJIM pasueyo oq ABU pue ‘posn oq ACW AYN IVNOIS AWLL AUV +‘parlsop Jr SUOTIISUBI} YIOOUIS YIM ‘“BousNbas B OJUI pourtueIZOId 9q ABUL SFONWHO OdINAL e -¢ Each of the 100 sequences contains 32 simultaneous, polyphonic tracks. Each track may -be assigned to one of 16 MIDI channels. Simultaneously plays up to 16 polyphonic +‘uonng OdNAL d¥L 24) UO -synthesizers! +sajou Jayienb Suiddy} Aq 10 ‘syUdtIOIOUI oINUTIAI-JOg-Jesg_ & JO sys} Ul sfqeisn(pe ‘ATTeoLAUINU paiajua oq ABU OdIWAL -© Ultra-fast 32” disk drive stores complex songs in seconds and holds over 110,000 notes +(jourery doup uaa9) -per disk! +“puooes Jed sowely O€ 10 “SZ “pz 18 [VAG MAd-SHNVU 10 ALOANIWAAd-SLVAd U! patyoeds aq Aew OdWAL © +‘uoT}e1odo [SV OJ A][eUIOJUT JoyndUIOd 11q 9] 98108 ZHI 8g ‘poeds-ysry By[N sozyN] e -¢ One or all tracks may be TRANSPOSED at the touch of a key. -¢ Exclusive real-time ERASE function makes editing FAST. -© Exclusive REPEAT function automatically repeats any held notes at a pre-selected +"9U0} OUAS 0006 UUL] JO tuniquuUry] prepueys 0} OUAS [ITAA e -rhythmic value. +“ANYBA 9}OU pojoapes Aue Je sas—nd jndyno 07 powureisoid 9q ACU SL Ad LNO YADOIMAL OML -¢ TIMING CORRECTION works during playback and operates without ‘chopping’ notes. +"ALVOOT 10 AOLS/AV 1d ‘LWadad “ASV -¢ Optional SMPTE time code synchronization. +SUIPNpOUr ‘sUOTIOUN] pasn A[UOUILUOS IY} JO AUBUL [O1]UOD AJ9JOWIAI 0} PoUsIsse oq APU ST AANI HOLIMS LOO? OME « +“BUIPIONSI JIYM PIseld JOU a1v $3}OU ZUTISIXO—ZUIPIOIAI SATON.ASOP-UON] @ -© Optional remote control. +‘suoneur[dxa peuorippe sdeydsip uong q1qH -Recording a Sequence +oy) ‘pepsou JI ‘suonesodo [ye YBnosY] NOA sapins ApIeapo Avfdsip QO] Jopesleyo 7¢ 9yj—uOTeIOdo UIEa] 0} Ased ‘aTCUIS « -To record a sequence, simply press RECORD and PLAY, -then play your MIDI keyboard in time to the Sequencer’s -click track. When the sequence loops back around to bar 1, -you’ ll hear what you played—only all timing errors will be +juoreljsuoWwap & IO} Aepod Jayeap uUI’] INOA dag ‘dISNUL +INOA 0} UOTUS}}Y PeplAIpuN INOA SUTOASp ITY ps pue +piosai ‘asodurod noX Jay 0} pausisap st 1<0uenbaguur’] oy} +AYM ApOstooid $, JEU, ‘SSddOId BATIBIID OY} YIM SOIOJIOIUT 7 +yey) xa{dwi0d Os aq J9A9U pPrNoys osn Nok AZopOuYIE} oy, -corrected! (Timing correction may be adjusted or defeated). +ISTUMOIGUIO?) INOYAA UOHISOdwo) -Any additional notes played will be added into the track -— existing notes are not erased while recording! +"NOpr] B IO} ‘AJONUTJUT yeadal OF seq Maz ISP] BY] JOS UaAD +ued NOA ‘palisap J] ‘souanbes Mou v OVUT syed au] [Te Adoo +ATeonewone wsy) [TIM ONOS ALVA JeyIeso} way} +,Uleyd,, 0} UOTOUNJ ONOS ALVA oY] asn usyy ‘saouanbes +JENPIAIpUt UI (“919 ‘snJOYD ‘dS19A) UOT}DIS JISeq YO" +PlodeI OF st ABM JAYIOUY ‘(S1eq 666 O} dn) Ysnory] ACM -FAST FORWARD, REWIND, and LOCATE controls -may be used at any time to quickly access any location in -your sequence for spot-recording. To overdub a new part, -select a different track and start recording—while you -record, the first track will play in perfect sync (unless you -MUTE it, or SOLO another track). In this way, up to 32 -tracks may be overdubbed! All MIDI effects are recorded -including pitch bend, modulation, velocity, aftertouch, -sustain pedal, and program changes! +9} [fe YI] YORI P1OIOI OJ ST SUOS B 9}B9IO 0} ABM SUC, -Editing +SUOS & SUT}LAID -To erase a wrong note, simply hold ERASE and press -the note to be erased just before it plays in the sequence— -when played back, it will be gone. Notes may also be +‘suoT}oes po]ueMUN -added, erased, or changed using the SINGLE STEP func- -tion. To overdub notes at specific points within a sequence, +dAOWAT 0} ABM SUVS dU} SoVIDdO SUVE ALATA -Additional Features +‘ASPLIq BY] PUL SNIOYD PUODAS dT]] USOMIAQ 9SIOA ISAT -simply use LOCATE, FAST FORWARD, or REWIND to -find the desired bar number, then start recording. +ay Jo Adoo & YaST JYSTU NOA ‘afdwexe 10.f ‘UO JUSIOIJIP -The INSERT/COPY function allows you to move bars -from one location to another—in the same sequence or a -different one. For example, you might insert a copy of the -first verse between the second chorus and the bridge. -DELETE BARS operates the same way to remove -unwanted sections, +B IO aduaNbas sues dy} UI—JYJOUB 0} UOTIBIO] BU WO +$IBq DAOUL OF NOA sMOTIe UOTIOUNS AdOO/LAASNI OULL -Creating a Song +‘SUTPIONAI J1V]S Ud} ‘IOquINU Ieq polIsop ay} pul -One way to create a song is to record each track all the -way through (up to 999 bars). Another way is to record -each basic section (verse, chorus, etc.) in individual -sequences, then use the CREATE SONG function to “chain” -them together. CREATE SONG will then automatically -copy all the parts into a new sequence. If desired, you can -even set the last few bars to repeat infinitely, for a fadeout. +0} CNIMAY 40 ‘CUYVMAOd LSVA “ALVOOT 2sn Apduns -Composition Without Compromise +soinjeay [BUOHIPPY -The technology you use should never be so complex that -it interferes with the creative process. That’s precisely why -the LinnSequencer is designed to let you compose, record -and edit while devoting your undivided attention to your -music. See your Linn dealer today for a demonstration! +‘gouanbas & UTYIIM sJUTOd a1y1Dads 3¥ sa10U GnpPIDAO QL ‘uO +-Ouny dALLS AIONIS 94} Suisn pasueyo 10 ‘pasvsa ‘pappe +aq ose ABUT SdION ‘UB 9q [TIM 1 “yoRq podeyd uayM +—aouanbas oy] ul skeqd 31 a10J0q Isnf pasess aq 0} d]0U ayy +ssaid pue ASvwUq proy Ayduuts ‘jou 3uom & aseso OF -* Simple, easy to learn operation—the 32 character LCD display clearly guides you through all operations. If needed, the +sunipa -HELP button displays additional explanations. +jsosueyo weisold pue ‘yepod ureysns +‘yOnoPaIJe ‘AWOOTOA ‘UOTETNpow ‘pusq youd Surpnyour +Pep1OIEI AIB $199]J2 ICN IV iPeqqnpseao aq Aew syoen +Ze 0) dn ‘Kem sry Uy *(YoeI} JOyJOUR OOS 10 “I ALAIN +NOA ssafun) duAS yooysod ul Avy [TIM Yow) ISI ay} “prooas +NOK d[TYM—SUIPIOOII JABS PUB YIVI} JUDIOJJIP & JOoJOs +*yaed Mau B QnpIdAO OF, “‘BUIps0deI-jods 10J aduanbes nok +UT UOTIBdO] Aue ssodoe ATYIND 0} owt) Aue ye posn aq Aur +S]ONUOD ALVOOT pur ‘GNIMAY ‘AAVMAOA LSVA +{SUIpIOal aIYM posesd jou se $9}OU BUTsTxO— +youd) UY} OUT poppe aq [IM poterd sojou yeuonippe Auy -* Non-destructive recording—existing notes are not erased while recording. -¢ Two FOOTSWITCH INPUTS may be assigned to remotely control many of the commonly used functions, including +‘(poyeayop 10 paysn{pe aq ABW UOTI991109 BUTUITL) j{po}oe1109 -ERASE, REPEAT, PLAY/STOP, or LOCATE. +9q [IM S1O11a Surtun [fe A[UO—pateyd nod JeyM sJesy ]],NOA -¢ Two TRIGGER OUTPUTS may be programmed to output pulses at any selected note value. +‘] tq 0] punose yorq sdooy] sduanbas ay] us AA “YOu YOIO -© Will sync to standard LinnDrum or Linn 9000 sync tone. +§,Ja0uaNbag at{} O] SUIT] UT preOgAay [CIP INOA Avyd usyy +AV'1d Puke CYOOAY ssoid Ayduuts ‘aouenbes v p1099e4 OL, -® Utilizes ultra high-speed, 8 MHz 80186 16 bit computer internally for FAST operation. -* TEMPO may be specified in BEATS-PER-MINUTE or FRAMES-PER-BEAT at 24, 25, or 30 frames per second, +goUaNbIs & SUIP10I0Y -(even drop frame!) +‘JONWOD sJoOuIaI TeUONdGO e -¢ TEMPO may be entered numerically, adjustable in tenths of a Beat-Per-Minute increments, or by tapping quarter notes +"UOTEZIUOIYIUAS OPOS dU} FLAWS [euondo e -on the TAP TEMPO button. +‘sou suiddoys, jnoyyM soyelodo pue yoegdeyd SuLinp Sy¥IOM NOL LOANYOO ONIWILL e -¢ TEMPO CHANGES may be programmed into a sequence, with smooth transitions if desired. -¢ Any TIME SIGNATURE may be used, and may be changed within a song. +‘anyea ory AYI -linn -Linn Electronics, Inc. +pojoojes-oid & ye sojoU pyoy Aue syeadar ATTeonewOjNe UOTOUN] [TWAdAY OAISNOX e +“LSVJ SUTIpS soyeU UOTOUN ASVAA OUll-[eal SAISNIOXY +‘Ady B JO YONO} 941 1 CASOdSNVALL 9 ABU! SyoeI] [Te IO SUC e -18720 Oxnard Street, Tarzana, CA 91356 -(818) 708-8131 TELEX #298949 LINN UR +i ASIP od + +$9}0U QOO‘OIT 120 Spfoy puk SpUOdeS UT SBUOS Xa[AUIOD So10}S DALIP YSIP , 4 ISCJ-CNIN + +jSIOZISOyJUAS + +dtuoyddjod gf 0} dn skeyd A[snooueynus ‘spouueys [QI 9T JO duo 0} pousisse oq +ABUL YORI] YOR ‘syous) oruoydAjod ‘snooue}nuns 7¢ suTeJUOS ssoUaNbeas YO] OY} JO HORA e + +‘sJOUOS ALWOOT pure ‘GNIMAY ‘UVM OA +LSVd ‘GYOOde AOLS ‘AV Td YIM Jopsosas ade} Yows}-N[NU O} eps st UOTeIOdO e +SOPNOUT Sanjeoy o[quyIeUlsl AUBUL S.J ‘OSN puke UIee] O} o[duNs A[SuIzeUe JOA ‘PNJsomod ApOUIITXO +St 1] ‘UeIOIsNUL FeUOTssajoid oY} 10 JOO} soUBULIOJIJAd pue UOTIsOduION 11e-9Y1-JO-}e)s B SI IONUaNDaguUT] ay L + +JOps1odady soUINbIS [GTI YAL ZE +Jg0uenbaCguury sy diff --git a/tests/cache/cardinal/__-l__eng__000004_ocr.png__000004_ocr_tess__pdf__txt/pdf.bin b/tests/cache/cardinal/__-l__eng__000004_ocr.png__000004_ocr_tess__pdf__txt/pdf.bin index 3e9f904e..5b30d5ef 100644 Binary files a/tests/cache/cardinal/__-l__eng__000004_ocr.png__000004_ocr_tess__pdf__txt/pdf.bin and b/tests/cache/cardinal/__-l__eng__000004_ocr.png__000004_ocr_tess__pdf__txt/pdf.bin differ diff --git a/tests/cache/cardinal/__-l__eng__000004_ocr.png__000004_ocr_tess__pdf__txt/txt.bin b/tests/cache/cardinal/__-l__eng__000004_ocr.png__000004_ocr_tess__pdf__txt/txt.bin index 9e1c5257..7ce15cb2 100644 --- a/tests/cache/cardinal/__-l__eng__000004_ocr.png__000004_ocr_tess__pdf__txt/txt.bin +++ b/tests/cache/cardinal/__-l__eng__000004_ocr.png__000004_ocr_tess__pdf__txt/txt.bin @@ -8,18 +8,18 @@ extremely powerful, yet amazingly simple to learn and use. It’s many remarkabl ¢ Operation is similar to multi-track tape recorder with PLAY, STOP, RECORD, FAST FORWARD, REWIND, and LOCATE controls. -¢ Each of the 100 sequences contains 32 simultaneous, polyphonic tracks. Each track may +e Each of the 100 sequences contains 32 simultaneous, polyphonic tracks. Each track may be assigned to one of 16 MIDI channels. Simultaneously plays up to 16 polyphonic synthesizers! -© Ultra-fast 32” disk drive stores complex songs in seconds and holds over 110,000 notes +¢ Ultra-fast 3%" disk drive stores complex songs in seconds and holds over 110,000 notes per disk! ¢ One or all tracks may be TRANSPOSED at the touch of a key. -¢ Exclusive real-time ERASE function makes editing FAST. -© Exclusive REPEAT function automatically repeats any held notes at a pre-selected +e Exclusive real-time ERASE function makes editing FAST. +¢ Exclusive REPEAT function automatically repeats any held notes at a pre-selected rhythmic value. @@ -34,12 +34,12 @@ Recording a Sequence To record a sequence, simply press RECORD and PLAY, then play your MIDI keyboard in time to the Sequencer’s click track. When the sequence loops back around to bar 1, -you’ ll hear what you played—only all timing errors will be +you'll hear what you played—only all timing errors will be corrected! (Timing correction may be adjusted or defeated). Any additional notes played will be added into the track -— existing notes are not erased while recording! +—existing notes are not erased while recording! FAST FORWARD, REWIND, and LOCATE controls may be used at any time to quickly access any location in @@ -70,7 +70,7 @@ from one location to another—in the same sequence or a different one. For example, you might insert a copy of the first verse between the second chorus and the bridge. DELETE BARS operates the same way to remove -unwanted sections, +unwanted sections. Creating a Song @@ -103,8 +103,8 @@ ERASE, REPEAT, PLAY/STOP, or LOCATE. © Will sync to standard LinnDrum or Linn 9000 sync tone. -® Utilizes ultra high-speed, 8 MHz 80186 16 bit computer internally for FAST operation. -* TEMPO may be specified in BEATS-PER-MINUTE or FRAMES-PER-BEAT at 24, 25, or 30 frames per second, +© Utilizes ultra high-speed, 8 MHz 80186 16 bit computer internally for FAST operation. +¢ TEMPO may be specified in BEATS-PER-MINUTE or FRAMES-PER-BEAT at 24, 25, or 30 frames per second, (even drop frame!) diff --git a/tests/cache/cardinal/__-l__osd__--psm__0__000001_rasterize_preview.jpg__stdout/stdout.bin b/tests/cache/cardinal/__-l__osd__--psm__0__000001_rasterize_preview.jpg__stdout/stdout.bin index ed33923b..22ac2ecb 100644 --- a/tests/cache/cardinal/__-l__osd__--psm__0__000001_rasterize_preview.jpg__stdout/stdout.bin +++ b/tests/cache/cardinal/__-l__osd__--psm__0__000001_rasterize_preview.jpg__stdout/stdout.bin @@ -1,6 +1,6 @@ Page number: 0 Orientation in degrees: 0 Rotate: 0 -Orientation confidence: 34.40 +Orientation confidence: 33.98 Script: Latin -Script confidence: 2.90 +Script confidence: 3.86 diff --git a/tests/cache/cardinal/__-l__osd__--psm__0__000002_rasterize_preview.jpg__stdout/stdout.bin b/tests/cache/cardinal/__-l__osd__--psm__0__000002_rasterize_preview.jpg__stdout/stdout.bin index 518b8289..dfa9c226 100644 --- a/tests/cache/cardinal/__-l__osd__--psm__0__000002_rasterize_preview.jpg__stdout/stdout.bin +++ b/tests/cache/cardinal/__-l__osd__--psm__0__000002_rasterize_preview.jpg__stdout/stdout.bin @@ -1,6 +1,6 @@ Page number: 0 Orientation in degrees: 90 Rotate: 270 -Orientation confidence: 34.53 +Orientation confidence: 29.83 Script: Latin -Script confidence: 3.83 +Script confidence: 3.42 diff --git a/tests/cache/cardinal/__-l__osd__--psm__0__000003_rasterize_preview.jpg__stdout/stdout.bin b/tests/cache/cardinal/__-l__osd__--psm__0__000003_rasterize_preview.jpg__stdout/stdout.bin index 1f2bb985..92cb169f 100644 --- a/tests/cache/cardinal/__-l__osd__--psm__0__000003_rasterize_preview.jpg__stdout/stdout.bin +++ b/tests/cache/cardinal/__-l__osd__--psm__0__000003_rasterize_preview.jpg__stdout/stdout.bin @@ -1,6 +1,6 @@ Page number: 0 Orientation in degrees: 180 Rotate: 180 -Orientation confidence: 31.79 +Orientation confidence: 32.06 Script: Latin -Script confidence: 3.18 +Script confidence: 3.93 diff --git a/tests/cache/cardinal/__-l__osd__--psm__0__000004_rasterize_preview.jpg__stdout/stdout.bin b/tests/cache/cardinal/__-l__osd__--psm__0__000004_rasterize_preview.jpg__stdout/stdout.bin index f2bf5667..2e8dcf3e 100644 --- a/tests/cache/cardinal/__-l__osd__--psm__0__000004_rasterize_preview.jpg__stdout/stdout.bin +++ b/tests/cache/cardinal/__-l__osd__--psm__0__000004_rasterize_preview.jpg__stdout/stdout.bin @@ -1,6 +1,6 @@ Page number: 0 Orientation in degrees: 270 Rotate: 90 -Orientation confidence: 32.59 +Orientation confidence: 32.46 Script: Latin -Script confidence: 2.85 +Script confidence: 2.40 diff --git a/tests/cache/ccitt/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin b/tests/cache/ccitt/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin index 19f93857..8e3e885d 100644 --- a/tests/cache/ccitt/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin +++ b/tests/cache/ccitt/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin @@ -5,11 +5,11 @@ - - + + -
+

@@ -17,8 +17,8 @@ LinnSequencer - 32 - Track + 32 + Track MIDI Sequence Recorder @@ -29,7 +29,7 @@

The - LinnSequencer + LinnSequencer is a state-of-the-art @@ -39,16 +39,16 @@ tool for the - professional + professional musician. It - is + is

- extremely + extremely powerful, yet amazingly @@ -67,24 +67,24 @@

- ¢ - Operation + ¢ + Operation is - similar + similar to multi-track tape recorder - with + with PLAY, STOP, RECORD, - FAST + FAST FORWARD, - REWIND, - and + REWIND, + and LOCATE controls. @@ -93,7 +93,7 @@

- ¢ + e Each of the @@ -109,12 +109,12 @@ may - be + be assigned to one - of - 16 + of + 16 MIDI channels. Simultaneously @@ -136,9 +136,9 @@

- © + ¢ Ultra-fast - 32” + 3%" disk drive stores @@ -149,7 +149,7 @@ and holds over - 110,000 + 110,000 notes

@@ -165,13 +165,13 @@

- ¢ - One - or + ¢ + One + or all - tracks + tracks may - be + be TRANSPOSED at the @@ -181,21 +181,21 @@ key. - ¢ + e Exclusive real-time ERASE function makes editing - FAST. + FAST. - © + ¢ Exclusive REPEAT function - automatically + automatically repeats any held @@ -217,25 +217,25 @@

- ¢ - TIMING + ¢ + TIMING CORRECTION - works - during + works + during playback and operates without - ‘chopping’ - notes. + ‘chopping’ + notes.

- ¢ - Optional + ¢ + Optional SMPTE time code @@ -246,49 +246,49 @@

- © + © Optional remote - control. + control.

- Recording - a + Recording + a Sequence

- To - record - a + To + record + a sequence, simply press RECORD and - PLAY, + PLAY, then play your - MIDI - keyboard - in + MIDI + keyboard + in time to the - Sequencer’s + Sequencer’s click - track. + track. When the sequence @@ -297,766 +297,764 @@ around to bar - 1, + 1, - you’ - ll - hear - what - you - played—only - all - timing - errors - will - be + you'll + hear + what + you + played—only + all + timing + errors + will + be

- corrected! - (Timing - correction - may - be - adjusted - or - defeated). + corrected! + (Timing + correction + may + be + adjusted + or + defeated).

-
-

- - Any - additional - notes - played - will - be - added - into - the - track +

+

+ + Any + additional + notes + played + will + be + added + into + the + track - - - existing - notes - are - not - erased - while - recording! + + —existing + notes + are + not + erased + while + recording!

-

- - FAST - FORWARD, - REWIND, - and - LOCATE - controls +

+ + FAST + FORWARD, + REWIND, + and + LOCATE + controls - - may - be - used - at - any - time - to - quickly - access - any - location - in + + may + be + used + at + any + time + to + quickly + access + any + location + in - - your - sequence - for - spot-recording. - To - overdub - a - new - part, + + your + sequence + for + spot-recording. + To + overdub + a + new + part, - - select - a - different - track - and - start - recording—while - you + + select + a + different + track + and + start + recording—while + you - - record, - the - first - track - will - play - in - perfect - sync - (unless - you + + record, + the + first + track + will + play + in + perfect + sync + (unless + you - - MUTE - it, - or - SOLO - another - track). - In - this - way, - up - to - 32 + + MUTE + it, + or + SOLO + another + track). + In + this + way, + up + to + 32 - - tracks - may - be - overdubbed! - All - MIDI - effects - are - recorded + + tracks + may + be + overdubbed! + All + MIDI + effects + are + recorded - - including - pitch - bend, - modulation, - velocity, - aftertouch, + + including + pitch + bend, + modulation, + velocity, + aftertouch, - - sustain - pedal, - and - program - changes! + + sustain + pedal, + and + program + changes!

-
-

- - Editing +

+

+ + Editing

-

- - To - erase - a - wrong - note, - simply - hold - ERASE - and - press +

+ + To + erase + a + wrong + note, + simply + hold + ERASE + and + press - - the - note - to - be - erased - just - before - it - plays - in - the - sequence— + + the + note + to + be + erased + just + before + it + plays + in + the + sequence— - - when - played - back, - it - will - be - gone. - Notes - may - also - be + + when + played + back, + it + will + be + gone. + Notes + may + also + be

-
-

- - added, - erased, - or - changed - using - the - SINGLE - STEP - func- +

+

+ + added, + erased, + or + changed + using + the + SINGLE + STEP + func- - - tion. - To - overdub - notes - at - specific - points - within - a - sequence, + + tion. + To + overdub + notes + at + specific + points + within + a + sequence,

-
-

- - Additional - Features +

+

+ + Additional + Features

-
+

- - simply - use - LOCATE, - FAST - FORWARD, - or - REWIND - to + + simply + use + LOCATE, + FAST + FORWARD, + or + REWIND + to - find - the - desired - bar - number, - then - start - recording. + find + the + desired + bar + number, + then + start + recording.

-

+

- The - INSERT/COPY - function - allows - you - to - move - bars + The + INSERT/COPY + function + allows + you + to + move + bars - from - one - location - to - another—in - the - same - sequence - or - a + from + one + location + to + another—in + the + same + sequence + or + a - different - one. - For - example, - you - might - insert - a - copy - of - the + different + one. + For + example, + you + might + insert + a + copy + of + the - first - verse - between - the - second - chorus - and - the - bridge. + first + verse + between + the + second + chorus + and + the + bridge. - - DELETE - BARS - operates - the - same - way - to - remove + + DELETE + BARS + operates + the + same + way + to + remove - - unwanted - sections, + + unwanted + sections.

-
-

- - Creating - a - Song +

+

+ + Creating + a + Song

-

- - One - way - to - create - a - song - is - to - record - each - track - all - the +

+ + One + way + to + create + a + song + is + to + record + each + track + all + the - - way - through - (up - to - 999 - bars). - Another - way - is - to - record + + way + through + (up + to + 999 + bars). + Another + way + is + to + record - - each - basic - section - (verse, - chorus, - etc.) - in - individual + + each + basic + section + (verse, + chorus, + etc.) + in + individual - - sequences, - then - use - the - CREATE - SONG - function - to - “chain” + + sequences, + then + use + the + CREATE + SONG + function + to + “chain” - - them - together. - CREATE - SONG - will - then - automatically + + them + together. + CREATE + SONG + will + then + automatically - - copy - all - the - parts - into - a - new - sequence. - If - desired, - you - can + + copy + all + the + parts + into + a + new + sequence. + If + desired, + you + can - - even - set - the - last - few - bars - to - repeat - infinitely, - for - a - fadeout. + + even + set + the + last + few + bars + to + repeat + infinitely, + for + a + fadeout.

-
-

- - Composition - Without - Compromise +

+

+ + Composition + Without + Compromise

-

- - The - technology - you - use - should - never - be - so - complex - that +

+ + The + technology + you + use + should + never + be + so + complex + that - - it - interferes - with - the - creative - process. - That’s - precisely - why + + it + interferes + with + the + creative + process. + That’s + precisely + why - - the - LinnSequencer - is - designed - to - let - you - compose, - record + + the + LinnSequencer + is + designed + to + let + you + compose, + record - - and - edit - while - devoting - your - undivided - attention - to - your + + and + edit + while + devoting + your + undivided + attention + to + your - - music. - See - your - Linn - dealer - today - for - a - demonstration! + + music. + See + your + Linn + dealer + today + for + a + demonstration!

-
-

- - * - Simple, - easy - to - learn - operation—the - 32 - character - LCD - display - clearly - guides - you - through - all - operations. - If - needed, - the +

+

+ + * + Simple, + easy + to + learn + operation—the + 32 + character + LCD + display + clearly + guides + you + through + all + operations. + If + needed, + the

-
-

- - HELP - button - displays - additional - explanations. +

+

+ + HELP + button + displays + additional + explanations.

-
-

- - * - Non-destructive - recording—existing - notes - are - not - erased - while - recording. +

+

+ + * + Non-destructive + recording—existing + notes + are + not + erased + while + recording. - - ¢ - Two - FOOTSWITCH - INPUTS - may - be - assigned - to - remotely - control - many - of - the - commonly - used - functions, - including + + ¢ + Two + FOOTSWITCH + INPUTS + may + be + assigned + to + remotely + control + many + of + the + commonly + used + functions, + including

-
-

- - ERASE, - REPEAT, - PLAY/STOP, - or - LOCATE. +

+

+ + ERASE, + REPEAT, + PLAY/STOP, + or + LOCATE.

-
-

- - ¢ - Two - TRIGGER - OUTPUTS - may - be - programmed - to - output - pulses - at - any - selected - note - value. +

+

+ + ¢ + Two + TRIGGER + OUTPUTS + may + be + programmed + to + output + pulses + at + any + selected + note + value.

-
-

- - © - Will - sync - to - standard - LinnDrum - or - Linn - 9000 - sync - tone. +

+

+ + © + Will + sync + to + standard + LinnDrum + or + Linn + 9000 + sync + tone.

-
-

- - ® - Utilizes - ultra - high-speed, - 8 - MHz - 80186 - 16 - bit - computer - internally - for - FAST - operation. +

+

+ + © + Utilizes + ultra + high-speed, + 8 + MHz + 80186 + 16 + bit + computer + internally + for + FAST + operation. - - * - TEMPO - may - be - specified - in - BEATS-PER-MINUTE - or - FRAMES-PER-BEAT - at - 24, - 25, - or - 30 - frames - per - second, + + ¢ + TEMPO + may + be + specified + in + BEATS-PER-MINUTE + or + FRAMES-PER-BEAT + at + 24, + 25, + or + 30 + frames + per + second,

-
-

- - (even - drop - frame!) +

+

+ + (even + drop + frame!)

-
-

- - ¢ - TEMPO - may - be - entered - numerically, - adjustable - in - tenths - of - a - Beat-Per-Minute - increments, - or - by - tapping - quarter - notes +

+

+ + ¢ + TEMPO + may + be + entered + numerically, + adjustable + in + tenths + of + a + Beat-Per-Minute + increments, + or + by + tapping + quarter + notes

-
-

- - on - the - TAP - TEMPO - button. +

+

+ + on + the + TAP + TEMPO + button.

-
-

- - ¢ - TEMPO - CHANGES - may - be - programmed - into - a - sequence, - with - smooth - transitions - if - desired. +

+

+ + ¢ + TEMPO + CHANGES + may + be + programmed + into + a + sequence, + with + smooth + transitions + if + desired. - - ¢ - Any - TIME - SIGNATURE - may - be - used, - and - may - be - changed - within - a - song. + + ¢ + Any + TIME + SIGNATURE + may + be + used, + and + may + be + changed + within + a + song.

-
-

- - linn +

+

+ + linn - - Linn - Electronics, - Inc. + + Linn + Electronics, + Inc.

-
-

- - 18720 - Oxnard - Street, - Tarzana, - CA - 91356 +

+

+ + 18720 + Oxnard + Street, + Tarzana, + CA + 91356 - - (818) - 708-8131 - TELEX - #298949 - LINN - UR + + (818) + 708-8131 + TELEX + #298949 + LINN + UR

diff --git a/tests/cache/ccitt/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/txt.bin b/tests/cache/ccitt/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/txt.bin index 9e1c5257..7ce15cb2 100644 --- a/tests/cache/ccitt/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/txt.bin +++ b/tests/cache/ccitt/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/txt.bin @@ -8,18 +8,18 @@ extremely powerful, yet amazingly simple to learn and use. It’s many remarkabl ¢ Operation is similar to multi-track tape recorder with PLAY, STOP, RECORD, FAST FORWARD, REWIND, and LOCATE controls. -¢ Each of the 100 sequences contains 32 simultaneous, polyphonic tracks. Each track may +e Each of the 100 sequences contains 32 simultaneous, polyphonic tracks. Each track may be assigned to one of 16 MIDI channels. Simultaneously plays up to 16 polyphonic synthesizers! -© Ultra-fast 32” disk drive stores complex songs in seconds and holds over 110,000 notes +¢ Ultra-fast 3%" disk drive stores complex songs in seconds and holds over 110,000 notes per disk! ¢ One or all tracks may be TRANSPOSED at the touch of a key. -¢ Exclusive real-time ERASE function makes editing FAST. -© Exclusive REPEAT function automatically repeats any held notes at a pre-selected +e Exclusive real-time ERASE function makes editing FAST. +¢ Exclusive REPEAT function automatically repeats any held notes at a pre-selected rhythmic value. @@ -34,12 +34,12 @@ Recording a Sequence To record a sequence, simply press RECORD and PLAY, then play your MIDI keyboard in time to the Sequencer’s click track. When the sequence loops back around to bar 1, -you’ ll hear what you played—only all timing errors will be +you'll hear what you played—only all timing errors will be corrected! (Timing correction may be adjusted or defeated). Any additional notes played will be added into the track -— existing notes are not erased while recording! +—existing notes are not erased while recording! FAST FORWARD, REWIND, and LOCATE controls may be used at any time to quickly access any location in @@ -70,7 +70,7 @@ from one location to another—in the same sequence or a different one. For example, you might insert a copy of the first verse between the second chorus and the bridge. DELETE BARS operates the same way to remove -unwanted sections, +unwanted sections. Creating a Song @@ -103,8 +103,8 @@ ERASE, REPEAT, PLAY/STOP, or LOCATE. © Will sync to standard LinnDrum or Linn 9000 sync tone. -® Utilizes ultra high-speed, 8 MHz 80186 16 bit computer internally for FAST operation. -* TEMPO may be specified in BEATS-PER-MINUTE or FRAMES-PER-BEAT at 24, 25, or 30 frames per second, +© Utilizes ultra high-speed, 8 MHz 80186 16 bit computer internally for FAST operation. +¢ TEMPO may be specified in BEATS-PER-MINUTE or FRAMES-PER-BEAT at 24, 25, or 30 frames per second, (even drop frame!) diff --git a/tests/cache/ccitt/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin b/tests/cache/ccitt/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin index ba6a2fd3..90c648e2 100644 Binary files a/tests/cache/ccitt/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin and b/tests/cache/ccitt/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin differ diff --git a/tests/cache/ccitt/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/txt.bin b/tests/cache/ccitt/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/txt.bin index 9e1c5257..7ce15cb2 100644 --- a/tests/cache/ccitt/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/txt.bin +++ b/tests/cache/ccitt/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/txt.bin @@ -8,18 +8,18 @@ extremely powerful, yet amazingly simple to learn and use. It’s many remarkabl ¢ Operation is similar to multi-track tape recorder with PLAY, STOP, RECORD, FAST FORWARD, REWIND, and LOCATE controls. -¢ Each of the 100 sequences contains 32 simultaneous, polyphonic tracks. Each track may +e Each of the 100 sequences contains 32 simultaneous, polyphonic tracks. Each track may be assigned to one of 16 MIDI channels. Simultaneously plays up to 16 polyphonic synthesizers! -© Ultra-fast 32” disk drive stores complex songs in seconds and holds over 110,000 notes +¢ Ultra-fast 3%" disk drive stores complex songs in seconds and holds over 110,000 notes per disk! ¢ One or all tracks may be TRANSPOSED at the touch of a key. -¢ Exclusive real-time ERASE function makes editing FAST. -© Exclusive REPEAT function automatically repeats any held notes at a pre-selected +e Exclusive real-time ERASE function makes editing FAST. +¢ Exclusive REPEAT function automatically repeats any held notes at a pre-selected rhythmic value. @@ -34,12 +34,12 @@ Recording a Sequence To record a sequence, simply press RECORD and PLAY, then play your MIDI keyboard in time to the Sequencer’s click track. When the sequence loops back around to bar 1, -you’ ll hear what you played—only all timing errors will be +you'll hear what you played—only all timing errors will be corrected! (Timing correction may be adjusted or defeated). Any additional notes played will be added into the track -— existing notes are not erased while recording! +—existing notes are not erased while recording! FAST FORWARD, REWIND, and LOCATE controls may be used at any time to quickly access any location in @@ -70,7 +70,7 @@ from one location to another—in the same sequence or a different one. For example, you might insert a copy of the first verse between the second chorus and the bridge. DELETE BARS operates the same way to remove -unwanted sections, +unwanted sections. Creating a Song @@ -103,8 +103,8 @@ ERASE, REPEAT, PLAY/STOP, or LOCATE. © Will sync to standard LinnDrum or Linn 9000 sync tone. -® Utilizes ultra high-speed, 8 MHz 80186 16 bit computer internally for FAST operation. -* TEMPO may be specified in BEATS-PER-MINUTE or FRAMES-PER-BEAT at 24, 25, or 30 frames per second, +© Utilizes ultra high-speed, 8 MHz 80186 16 bit computer internally for FAST operation. +¢ TEMPO may be specified in BEATS-PER-MINUTE or FRAMES-PER-BEAT at 24, 25, or 30 frames per second, (even drop frame!) diff --git a/tests/cache/francais/__-l__deu__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin b/tests/cache/francais/__-l__deu__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin index d678997c..591577ca 100644 --- a/tests/cache/francais/__-l__deu__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin +++ b/tests/cache/francais/__-l__deu__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin @@ -5,97 +5,97 @@ - - + + -
-
-

- +

+
+

+ Portez ce - vieux - whisky - au - juge + vieux + whisky + au + juge - - blond - qui - fume + + blond + qui + fume sur - son - Ile + son + Ile - - interieure, - a - cöte - de - l'alcöve + + interieure, + a + cöte + de + l'alcöve - - ovoide, - oU - les - büches - se + + ovoide, + + les + büches + se - - consument - dans - l'ätre, - ce - qui + + consument + dans + l'ätre, + ce + qui - - ui - permet - de - penser - & - la + + lui + permet + de + penser + & + la - - caenogenese - de - |'etre - dont - il + + caenogenese + de + l'&tre + dont + il - - est - question - dans - la - cause + + est + question + dans + la + cause - - ambigu& - entendue - a - MoY, - dans + + ambigu& + entendue + a + Moy, + dans - - un - capharnaüm - qui, - pense-t-il, + + un + capharnaüm + qui, + pense-t-il, - - diminue - ca - et - la - la - qualit& - de - son + + diminue + ca + et + la + la + qualit€ + de + son - - ceuvre. + + ceuvre.

diff --git a/tests/cache/francais/__-l__deu__000001_ocr.png__000001_ocr_hocr__hocr__txt/txt.bin b/tests/cache/francais/__-l__deu__000001_ocr.png__000001_ocr_hocr__hocr__txt/txt.bin index 6506feea..61572c2d 100644 --- a/tests/cache/francais/__-l__deu__000001_ocr.png__000001_ocr_hocr__hocr__txt/txt.bin +++ b/tests/cache/francais/__-l__deu__000001_ocr.png__000001_ocr_hocr__hocr__txt/txt.bin @@ -1,12 +1,12 @@ Portez ce vieux whisky au juge blond qui fume sur son Ile interieure, a cöte de l'alcöve -ovoide, oU les büches se +ovoide, oü les büches se consument dans l'ätre, ce qui -ui permet de penser & la -caenogenese de |'etre dont il +lui permet de penser & la +caenogenese de l'&tre dont il est question dans la cause -ambigu& entendue a MoY, dans +ambigu& entendue a Moy, dans un capharnaüm qui, pense-t-il, -diminue ca et la la qualit& de son +diminue ca et la la qualit€ de son ceuvre. diff --git a/tests/cache/francais/__-l__deu__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin b/tests/cache/francais/__-l__deu__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin deleted file mode 100644 index 10ac07ab..00000000 Binary files a/tests/cache/francais/__-l__deu__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin and /dev/null differ diff --git a/tests/cache/francais/__-l__deu__000001_ocr.png__000001_ocr_tess__pdf__txt/stderr.bin b/tests/cache/francais/__-l__deu__000001_ocr.png__000001_ocr_tess__pdf__txt/stderr.bin deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/cache/francais/__-l__deu__000001_ocr.png__000001_ocr_tess__pdf__txt/stdout.bin b/tests/cache/francais/__-l__deu__000001_ocr.png__000001_ocr_tess__pdf__txt/stdout.bin deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/cache/francais/__-l__deu__000001_ocr.png__000001_ocr_tess__pdf__txt/txt.bin b/tests/cache/francais/__-l__deu__000001_ocr.png__000001_ocr_tess__pdf__txt/txt.bin deleted file mode 100644 index 6506feea..00000000 --- a/tests/cache/francais/__-l__deu__000001_ocr.png__000001_ocr_tess__pdf__txt/txt.bin +++ /dev/null @@ -1,12 +0,0 @@ -Portez ce vieux whisky au juge -blond qui fume sur son Ile -interieure, a cöte de l'alcöve -ovoide, oU les büches se -consument dans l'ätre, ce qui -ui permet de penser & la -caenogenese de |'etre dont il -est question dans la cause -ambigu& entendue a MoY, dans -un capharnaüm qui, pense-t-il, -diminue ca et la la qualit& de son -ceuvre. diff --git a/tests/cache/graph_ocred/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin b/tests/cache/graph_ocred/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin index 4a0ce480..58c06a35 100644 --- a/tests/cache/graph_ocred/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin +++ b/tests/cache/graph_ocred/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin @@ -5,186 +5,86 @@ - - + + -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-

- - 600 - - - 500 - . - - - 400 - _ - EB - fp - ys - - - 300 - —f— - EN - / - ~/ - Y - - - Tg - ANA - a3 - - - 100 - - - nee - -eemiae - - - 0-+—-* - a - ee - ee - ee - eee - - - S - @ - s - > - oO - © - ve - S - ) - @ - + - & - & - “A - 5 - oo - - - Se - ° - Ps - as - ge - eS - - x - ro - NS - Po - e? - & - s - AS - - - a - a - © - FF - SF - HY - HK - SK - BM - ee - sO - - - e - < - Na - - : - > - cy - xs - eS - @ - Ww - a) - Oo - - - ee - S&S - FF - SF - HK - S - e - © - 4 - - - ~ - & - e - - - 3 - x +

+
+
+

+ + 600

-
-

- - —¢—Support - -

- -

- - —H— - No - vote[note - 1] - - - ir - Oppose - - - —=—Net[note - 2] - -

- -

- - re - Percentage - [note - 3] +

+

+ + oN + la

+
+

+ + | + Sos + ee + + + » + SS + WW + AV + _ + + + pn + a3 + =™— + No + vote[note + 1] + + + 200 + s + ; + =i + Oppose + + + i + eg + ae + ¥ + aae=Net[note + 2] + + + 100 + - + = + es + Percentage + [note + 3] + +

+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/cache/graph_ocred/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/txt.bin b/tests/cache/graph_ocred/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/txt.bin index 099eefcd..417f8a21 100644 --- a/tests/cache/graph_ocred/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/txt.bin +++ b/tests/cache/graph_ocred/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/txt.bin @@ -1,22 +1,11 @@ 600 -500 . -400 _ EB fp ys -300 —f— EN / ~/ Y -Tg ANA a3 -100 - nee -eemiae -0-+—-* a ee ee ee eee -S @ s > oO © ve S ) @ + & & “A 5 oo -Se ° Ps as ge eS € x ro NS Po e? & s AS -a a © FF SF HY HK SK BM ee sO -e < Na ‘ : > cy xs eS @ Ww a) Oo -ee S&S FF SF HK S e © 4 -~ & e -3 x -—¢—Support +oN la -—H— No vote[note 1] -ir Oppose -—=—Net[note 2] +| Sos ee +» SS WW AV _ +pn a3 =™— No vote[note 1] +200 s ; =i Oppose +i eg ae ¥ aae=Net[note 2] +100 - = es Percentage [note 3] -re Percentage [note 3] diff --git a/tests/cache/graph_ocred/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin b/tests/cache/graph_ocred/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin deleted file mode 100644 index dbe823cd..00000000 Binary files a/tests/cache/graph_ocred/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin and /dev/null differ diff --git a/tests/cache/graph_ocred/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/stderr.bin b/tests/cache/graph_ocred/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/stderr.bin deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/cache/graph_ocred/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/stdout.bin b/tests/cache/graph_ocred/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/stdout.bin deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/cache/graph_ocred/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/txt.bin b/tests/cache/graph_ocred/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/txt.bin deleted file mode 100644 index 099eefcd..00000000 --- a/tests/cache/graph_ocred/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/txt.bin +++ /dev/null @@ -1,22 +0,0 @@ -600 -500 . -400 _ EB fp ys -300 —f— EN / ~/ Y -Tg ANA a3 -100 - nee -eemiae -0-+—-* a ee ee ee eee -S @ s > oO © ve S ) @ + & & “A 5 oo -Se ° Ps as ge eS € x ro NS Po e? & s AS -a a © FF SF HY HK SK BM ee sO -e < Na ‘ : > cy xs eS @ Ww a) Oo -ee S&S FF SF HK S e © 4 -~ & e -3 x - -—¢—Support - -—H— No vote[note 1] -ir Oppose -—=—Net[note 2] - -re Percentage [note 3] diff --git a/tests/cache/jbig2/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin b/tests/cache/jbig2/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin index cbfda34d..77e548d7 100644 --- a/tests/cache/jbig2/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin +++ b/tests/cache/jbig2/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin @@ -5,20 +5,20 @@ - - + + -
+

The - LinnSequencer + LinnSequencer - 32 - Track + 32 + Track MIDI Sequence Recorder @@ -29,7 +29,7 @@

The - LinnSequencer + LinnSequencer is a state-of-the-art @@ -42,50 +42,50 @@ professional musician. It - is + is

- extremely + extremely powerful, yet amazingly simple to learn - and + and use. It’s many remarkable - features - include: + features + include:

- ¢ - Operation + ¢ + Operation is - similar + similar to multi-track tape - recorder + recorder with - PLAY, - STOP, + PLAY, + STOP, RECORD, FAST FORWARD, - REWIND, + REWIND, and - LOCATE + LOCATE controls.

@@ -93,7 +93,7 @@

- e + e Each of the @@ -110,7 +110,7 @@ be - assigned + assigned to one of @@ -122,7 +122,7 @@ up to 16 - polyphonic + polyphonic

@@ -136,9 +136,9 @@

- ¢ - Ultra-fast - 312” + ¢ + Ultra-fast + 312” disk drive stores @@ -165,33 +165,33 @@

- ® - One - or - all + ¢ + One + or + all tracks may - be + be TRANSPOSED at the touch of a - key. + key. - ¢ - Exclusive + e + Exclusive real-time ERASE function makes editing - FAST. + FAST. - ¢ + ¢ Exclusive REPEAT function @@ -210,33 +210,33 @@

rhythmic - value. + value.

- ¢ + ¢ TIMING CORRECTION - works + works during playback and operates without ‘chopping’ - notes. + notes.

- ¢ - Optional - SMPTE + ¢ + Optional + SMPTE time code synchronization. @@ -246,7 +246,7 @@

- ¢ + * Optional remote control. @@ -268,7 +268,7 @@ record a sequence, - simply + simply press RECORD and @@ -280,33 +280,33 @@ your MIDI keyboard - in - time + in + time to - the + the Sequencer’s click - track. + track. When the sequence loops back around - to - bar - 1, + to + bar + 1, - you'll + you'll hear what you played—only all - timing + timing errors will be @@ -317,8 +317,8 @@

corrected! - (Timing - correction + (Timing + correction may be adjusted @@ -327,735 +327,734 @@

-
-

- - Any - additional - notes - played - will - be - added - into - the - track +

+

+ + Any + additional + notes + played + will + be + added + into + the + track - - - existing - notes - are - not - erased - while - recording! + + —existing + notes + are + not + erased + while + recording!

-

- - FAST - FORWARD, - REWIND, - and - LOCATE - controls +

+ + FAST + FORWARD, + REWIND, + and + LOCATE + controls - - may - be - used - at - any - time - to - quickly - access - any - location - in + + may + be + used + at + any + time + to + quickly + access + any + location + in - - your - sequence - for - spot-recording. - To - overdub - a - new - part, + + your + sequence + for + spot-recording. + To + overdub + a + new + part, - - select - a - different - track - and - start - recording—while - you + + select + a + different + track + and + start + recording—while + you - - record, - the - first - track - will - play - in - perfect - sync - (unless - you + + record, + the + first + track + will + play + in + perfect + sync + (unless + you - - MUTE - it, - or - SOLO - another - track). - In - this - way, - up - to - 32 + + MUTE + it, + or + SOLO + another + track). + In + this + way, + up + to + 32 - - tracks - may - be - overdubbed! - All - MIDI - effects - are - recorded + + tracks + may + be + overdubbed! + All + MIDI + effects + are + recorded - - including - pitch - bend, - modulation, - velocity, - aftertouch, + + including + pitch + bend, + modulation, + velocity, + aftertouch, - - sustain - pedal, - and - program - changes! + + sustain + pedal, + and + program + changes!

-
-

- - Editing +

+

+ + Editing

-

- - To - erase - a - wrong - note, - simply - hold - ERASE - and - press +

+ + To + erase + a + wrong + note, + simply + hold + ERASE + and + press - - the - note - to - be - erased - just - before - it - plays - in - the - sequence— + + the + note + to + be + erased + just + before + it + plays + in + the + sequence— - - when - played - back, - it - will - be - gone. - Notes - may - also - be + + when + played + back, + it + will + be + gone. + Notes + may + also + be

-
-

- - added, - erased, - or - changed - using - the - SINGLE - STEP - func- +

+

+ + added, + erased, + or + changed + using + the + SINGLE + STEP + func- - - tion. - To - overdub - notes - at - specific - points - within - a - sequence, + + tion. + To + overdub + notes + at + specific + points + within + a + sequence,

-
-

- - Additional - Features +

+

+ + Additional + Features

-
+

- - simply - use - LOCATE, - FAST - FORWARD, - or - REWIND - to + + simply + use + LOCATE, + FAST + FORWARD, + or + REWIND + to - find - the - desired - bar - number, - then - start - recording. + find + the + desired + bar + number, + then + start + recording.

-

+

- The - INSERT/COPY - function - allows - you - to - move - bars + The + INSERT/COPY + function + allows + you + to + move + bars - from - one - location - to - another—in - the - same - sequence - or - a + from + one + location + to + another—in + the + same + sequence + or + a - different - one. - For - example, - you - might - insert - a - copy - of - the + different + one. + For + example, + you + might + insert + a + copy + of + the - first - verse - between - the - second - chorus - and - the - bridge. + first + verse + between + the + second + chorus + and + the + bridge. - - DELETE - BARS - operates - the - same - way - to - remove + + DELETE + BARS + operates + the + same + way + to + remove - - unwanted - sections, + + unwanted + sections.

-
-

- - Creating - a - Song +

+

+ + Creating + a + Song

-

- - One - way - to - create - a - song - is - to - record - each - track - all - the +

+ + One + way + to + create + a + song + is + to + record + each + track + all + the - - way - through - (up - to - 999 - bars). - Another - way - is - to - record + + way + through + (up + to + 999 + bars). + Another + way + is + to + record - - each - basic - section - (verse, - chorus, - etc.) - in - individual + + each + basic + section + (verse, + chorus, + etc.) + in + individual - - sequences, - then - use - the - CREATE - SONG - function - to - “chain” + + sequences, + then + use + the + CREATE + SONG + function + to + “chain” - - them - together. - CREATE - SONG - will - then - automatically + + them + together. + CREATE + SONG + will + then + automatically - - copy - all - the - parts - into - a - new - sequence. - If - desired, - you - can + + copy + all + the + parts + into + a + new + sequence. + If + desired, + you + can - - even - set - the - last - few - bars - to - repeat - infinitely, - for - a - fadeout. + + even + set + the + last + few + bars + to + repeat + infinitely, + for + a + fadeout.

-
-

- - Composition - Without - Compromise +

+

+ + Composition + Without + Compromise

-

- - The - technology - you - use - should - never - be - so - complex - that +

+ + The + technology + you + use + should + never + be + so + complex + that - - it - interferes - with - the - creative - process. - That’s - precisely - why + + it + interferes + with + the + creative + process. + That’s + precisely + why - - the - LinnSequencer - is - designed - to - let - you - compose, - record + + the + LinnSequencer + is + designed + to + let + you + compose, + record - - and - edit - while - devoting - your - undivided - attention - to - your + + and + edit + while + devoting + your + undivided + attention + to + your - - music. - See - your - Linn - dealer - today - for - a - demonstration! + + music. + See + your + Linn + dealer + today + for + a + demonstration!

-
-

- - * - Simple, - easy - to - learn - operation—the - 32 - character - LCD - display - clearly - guides - you - through - all - operations. - If - needed, - the +

+

+ + * + Simple, + easy + to + learn + operation—the + 32 + character + LCD + display + clearly + guides + you + through + all + operations. + If + needed, + the

-
-

- - HELP - button - displays - additional - explanations. +

+

+ + HELP + button + displays + additional + explanations.

-
-

- - * - Non-destructive - recording—existing - notes - are - not - erased - while - recording. +

+

+ + * + Non-destructive + recording—existing + notes + are + not + erased + while + recording. - - * - Two - FOOTSWITCH - INPUTS - may - be - assigned - to - remotely - control - many - of - the - commonly - used - functions, - including + + * + Two + FOOTSWITCH + INPUTS + may + be + assigned + to + remotely + control + many + of + the + commonly + used + functions, + including

-
-

- - ERASE, - REPEAT, - PLAY/STOP, - or - LOCATE. +

+

+ + ERASE, + REPEAT, + PLAY/STOP, + or + LOCATE.

-
-

- - * - Two - TRIGGER - OUTPUTS - may - be - programmed - to - output - pulses - at - any - selected - note - value. +

+

+ + * + Two + TRIGGER + OUTPUTS + may + be + programmed + to + output + pulses + at + any + selected + note + value.

-
-

- - ® - Will - sync - to - standard - LinnDrum - or - Linn - 9000 - sync - tone. +

+

+ + © + Will + sync + to + standard + LinnDrum + or + Linn + 9000 + sync + tone.

-
-

- - © - Utilizes - ultra - high-speed, - 8 - MHz - 80186 - 16 - bit - computer - internally - for - FAST - operation. +

+

+ + © + Utilizes + ultra + high-speed, + 8 + MHz + 80186 + 16 + bit + computer + internally + for + FAST + operation. - - * - TEMPO - may - be - specified - in - BEATS-PER-MINUTE - or - FRAMES-PER-BEAT - at - 24, - 25, - or - 30 - frames - per - second, + + * + TEMPO + may + be + specified + in + BEATS-PER-MINUTE + or + FRAMES-PER-BEAT + at + 24, + 25, + or + 30 + frames + per + second,

-
-

- - (even - drop - frame!) +

+

+ + (even + drop + frame!)

-
-

- - * - TEMPO - may - be - entered - numerically, - adjustable - in - tenths - of - a - Beat-Per-Minute - increments, - or - by - tapping - quarter - notes +

+

+ + ¢ + TEMPO + may + be + entered + numerically, + adjustable + in + tenths + of + a + Beat-Per-Minute + increments, + or + by + tapping + quarter + notes

-
-

- - on - the - TAP - TEMPO - button. +

+

+ + on + the + TAP + TEMPO + button.

-
-

- - * - TEMPO - CHANGES - may - be - programmed - into - a - sequence, - with - smooth - transitions - if - desired. +

+

+ + * + TEMPO + CHANGES + may + be + programmed + into + a + sequence, + with + smooth + transitions + if + desired. - - ¢ - Any - TIME - SIGNATURE - may - be - used, - and - may - be - changed - within - a - song. + + ¢ + Any + TIME + SIGNATURE + may + be + used, + and + may + be + changed + within + a + song.

-
-

- - linn +

+

+ + linn - - Linn - Electronics, - Inc. + + Linn + Electronics, + Inc.

-
-

- - 18720 - Oxnard - Street, - Tarzana, - CA - 91356 +

+

+ + 18720 + Oxnard + Street, + Tarzana, + CA + 91356 - - (818) - 708-8131 - TELEX - #298949 - LINN - UR + + (818) + 708-8131 + TELEX + #298949 + LINN + UR

diff --git a/tests/cache/jbig2/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/txt.bin b/tests/cache/jbig2/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/txt.bin index f6e811b7..857c444e 100644 --- a/tests/cache/jbig2/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/txt.bin +++ b/tests/cache/jbig2/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/txt.bin @@ -17,8 +17,8 @@ synthesizers! per disk! -® One or all tracks may be TRANSPOSED at the touch of a key. -¢ Exclusive real-time ERASE function makes editing FAST. +¢ One or all tracks may be TRANSPOSED at the touch of a key. +e Exclusive real-time ERASE function makes editing FAST. ¢ Exclusive REPEAT function automatically repeats any held notes at a pre-selected rhythmic value. @@ -27,7 +27,7 @@ rhythmic value. ¢ Optional SMPTE time code synchronization. -¢ Optional remote control. +* Optional remote control. Recording a Sequence @@ -39,7 +39,7 @@ you'll hear what you played—only all timing errors will be corrected! (Timing correction may be adjusted or defeated). Any additional notes played will be added into the track -— existing notes are not erased while recording! +—existing notes are not erased while recording! FAST FORWARD, REWIND, and LOCATE controls may be used at any time to quickly access any location in @@ -70,7 +70,7 @@ from one location to another—in the same sequence or a different one. For example, you might insert a copy of the first verse between the second chorus and the bridge. DELETE BARS operates the same way to remove -unwanted sections, +unwanted sections. Creating a Song @@ -101,14 +101,14 @@ ERASE, REPEAT, PLAY/STOP, or LOCATE. * Two TRIGGER OUTPUTS may be programmed to output pulses at any selected note value. -® Will sync to standard LinnDrum or Linn 9000 sync tone. +© Will sync to standard LinnDrum or Linn 9000 sync tone. © Utilizes ultra high-speed, 8 MHz 80186 16 bit computer internally for FAST operation. * TEMPO may be specified in BEATS-PER-MINUTE or FRAMES-PER-BEAT at 24, 25, or 30 frames per second, (even drop frame!) -* TEMPO may be entered numerically, adjustable in tenths of a Beat-Per-Minute increments, or by tapping quarter notes +¢ TEMPO may be entered numerically, adjustable in tenths of a Beat-Per-Minute increments, or by tapping quarter notes on the TAP TEMPO button. diff --git a/tests/cache/jbig2/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin b/tests/cache/jbig2/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin index cb72730c..69f82a47 100644 Binary files a/tests/cache/jbig2/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin and b/tests/cache/jbig2/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin differ diff --git a/tests/cache/jbig2/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/txt.bin b/tests/cache/jbig2/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/txt.bin index f6e811b7..0918c805 100644 --- a/tests/cache/jbig2/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/txt.bin +++ b/tests/cache/jbig2/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/txt.bin @@ -17,8 +17,8 @@ synthesizers! per disk! -® One or all tracks may be TRANSPOSED at the touch of a key. -¢ Exclusive real-time ERASE function makes editing FAST. +¢ One or all tracks may be TRANSPOSED at the touch of a key. +e Exclusive real-time ERASE function makes editing FAST. ¢ Exclusive REPEAT function automatically repeats any held notes at a pre-selected rhythmic value. @@ -27,7 +27,7 @@ rhythmic value. ¢ Optional SMPTE time code synchronization. -¢ Optional remote control. +® Optional remote control. Recording a Sequence @@ -39,7 +39,7 @@ you'll hear what you played—only all timing errors will be corrected! (Timing correction may be adjusted or defeated). Any additional notes played will be added into the track -— existing notes are not erased while recording! +—existing notes are not erased while recording! FAST FORWARD, REWIND, and LOCATE controls may be used at any time to quickly access any location in @@ -70,7 +70,7 @@ from one location to another—in the same sequence or a different one. For example, you might insert a copy of the first verse between the second chorus and the bridge. DELETE BARS operates the same way to remove -unwanted sections, +unwanted sections. Creating a Song @@ -101,14 +101,14 @@ ERASE, REPEAT, PLAY/STOP, or LOCATE. * Two TRIGGER OUTPUTS may be programmed to output pulses at any selected note value. -® Will sync to standard LinnDrum or Linn 9000 sync tone. +© Will sync to standard LinnDrum or Linn 9000 sync tone. © Utilizes ultra high-speed, 8 MHz 80186 16 bit computer internally for FAST operation. * TEMPO may be specified in BEATS-PER-MINUTE or FRAMES-PER-BEAT at 24, 25, or 30 frames per second, (even drop frame!) -* TEMPO may be entered numerically, adjustable in tenths of a Beat-Per-Minute increments, or by tapping quarter notes +¢ TEMPO may be entered numerically, adjustable in tenths of a Beat-Per-Minute increments, or by tapping quarter notes on the TAP TEMPO button. diff --git a/tests/cache/lichtenstein/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin b/tests/cache/lichtenstein/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin index 87bdb5ea..40b7e9ee 100644 --- a/tests/cache/lichtenstein/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin +++ b/tests/cache/lichtenstein/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin @@ -5,11 +5,11 @@ - - + + -
+
diff --git a/tests/cache/lichtenstein/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin b/tests/cache/lichtenstein/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin index 105e5d74..bb1e37d0 100644 Binary files a/tests/cache/lichtenstein/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin and b/tests/cache/lichtenstein/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin differ diff --git a/tests/cache/manifest.jsonl b/tests/cache/manifest.jsonl index a910f04a..fd27f850 100644 --- a/tests/cache/manifest.jsonl +++ b/tests/cache/manifest.jsonl @@ -1,85 +1,80 @@ -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__thresholding_method=1__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/trivial.pdf", "args": ["-l", "eng", "-c", "thresholding_method=1", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_hocr", "hocr", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__000003_ocr.png__000003_ocr_hocr__hocr__txt", "sourcefile": "resources/multipage.pdf", "args": ["-l", "eng", "/tmp/pytest-of-jb/pytest-42/popen-gw1/test_hocr_api_multipage0/000003_ocr.png", "/tmp/pytest-of-jb/pytest-42/popen-gw1/test_hocr_api_multipage0/000003_ocr_hocr", "hocr", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__thresholding_method=2__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/trivial.pdf", "args": ["-l", "eng", "-c", "thresholding_method=2", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_hocr", "hocr", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__--psm__7__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/skew.pdf", "args": ["-l", "eng", "--psm", "7", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_hocr", "hocr", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__--psm__2__000003_rasterize.png__stdout", "sourcefile": "resources/multipage.pdf", "args": ["-l", "eng", "--psm", "2", "$TMPDIR/000003_rasterize.png", "stdout"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__--psm__2__000005_rasterize.png__stdout", "sourcefile": "resources/multipage.pdf", "args": ["-l", "eng", "--psm", "2", "$TMPDIR/000005_rasterize.png", "stdout"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__000005_ocr.png__000005_ocr_hocr__hocr__txt", "sourcefile": "resources/multipage.pdf", "args": ["-l", "eng", "/tmp/pytest-of-jb/pytest-42/popen-gw1/test_hocr_api_multipage0/000005_ocr.png", "/tmp/pytest-of-jb/pytest-42/popen-gw1/test_hocr_api_multipage0/000005_ocr_hocr", "hocr", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__deu__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/francais.pdf", "args": ["-l", "deu", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_hocr", "hocr", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__--psm__7__000001_ocr.png__000001_ocr_tess__pdf__txt", "sourcefile": "resources/skew.pdf", "args": ["-l", "eng", "--psm", "7", "-c", "textonly_pdf=1", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_tess", "pdf", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__--psm__2__000004_rasterize.png__stdout", "sourcefile": "resources/multipage.pdf", "args": ["-l", "eng", "--psm", "2", "$TMPDIR/000004_rasterize.png", "stdout"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__--oem__1__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/trivial.pdf", "args": ["-l", "eng", "--oem", "1", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_hocr", "hocr", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__--psm__2__000006_rasterize.png__stdout", "sourcefile": "resources/multipage.pdf", "args": ["-l", "eng", "--psm", "2", "$TMPDIR/000006_rasterize.png", "stdout"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__000002_ocr.png__000002_ocr_hocr__hocr__txt", "sourcefile": "resources/3small.pdf", "args": ["-l", "eng", "$TMPDIR/000002_ocr.png", "$TMPDIR/000002_ocr_hocr", "hocr", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/ccitt.pdf", "args": ["-l", "eng", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_hocr", "hocr", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/jbig2.pdf", "args": ["-l", "eng", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_hocr", "hocr", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/3small.pdf", "args": ["-l", "eng", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_hocr", "hocr", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/trivial.pdf", "args": ["-l", "eng", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_hocr", "hocr", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/skew.pdf", "args": ["-l", "eng", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_hocr", "hocr", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__--psm__2__000001_rasterize.png__stdout", "sourcefile": "resources/multipage.pdf", "args": ["-l", "eng", "--psm", "2", "$TMPDIR/000001_rasterize.png", "stdout"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__000006_ocr.png__000006_ocr_hocr__hocr__txt", "sourcefile": "resources/multipage.pdf", "args": ["-l", "eng", "/tmp/pytest-of-jb/pytest-42/popen-gw1/test_hocr_api_multipage0/000006_ocr.png", "/tmp/pytest-of-jb/pytest-42/popen-gw1/test_hocr_api_multipage0/000006_ocr_hocr", "hocr", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/ccitt.pdf", "args": ["-l", "eng", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_hocr", "hocr", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/multipage.pdf", "args": ["-l", "eng", "/tmp/pytest-of-jb/pytest-42/popen-gw1/test_hocr_api_multipage0/000001_ocr.png", "/tmp/pytest-of-jb/pytest-42/popen-gw1/test_hocr_api_multipage0/000001_ocr_hocr", "hocr", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__000003_ocr.png__000003_ocr_hocr__hocr__txt", "sourcefile": "resources/3small.pdf", "args": ["-l", "eng", "$TMPDIR/000003_ocr.png", "$TMPDIR/000003_ocr_hocr", "hocr", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/2400dpi.pdf", "args": ["-l", "eng", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_hocr", "hocr", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__000004_ocr.png__000004_ocr_hocr__hocr__txt", "sourcefile": "resources/multipage.pdf", "args": ["-l", "eng", "$TMPDIR/000004_ocr.png", "$TMPDIR/000004_ocr_hocr", "hocr", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__--psm__2__000001_rasterize.png__stdout", "sourcefile": "resources/palette.pdf", "args": ["-l", "eng", "--psm", "2", "$TMPDIR/000001_rasterize.png", "stdout"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__--psm__2__000001_rasterize.png__stdout", "sourcefile": "resources/palette.pdf", "args": ["-l", "eng", "--psm", "2", "$TMPDIR/000001_rasterize.png", "stdout"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt", "sourcefile": "resources/skew.pdf", "args": ["-l", "eng", "-c", "textonly_pdf=1", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_tess", "pdf", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt", "sourcefile": "resources/palette.pdf", "args": ["-l", "eng", "-c", "textonly_pdf=1", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_tess", "pdf", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/palette.pdf", "args": ["-l", "eng", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_hocr", "hocr", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__--psm__2__000001_rasterize.png__stdout", "sourcefile": "resources/lichtenstein.pdf", "args": ["-l", "eng", "--psm", "2", "$TMPDIR/000001_rasterize.png", "stdout"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__--psm__2__000001_rasterize.png__stdout", "sourcefile": "resources/ccitt.pdf", "args": ["-l", "eng", "--psm", "2", "$TMPDIR/000001_rasterize.png", "stdout"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/lichtenstein.pdf", "args": ["-l", "eng", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_hocr", "hocr", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__--psm__2__000001_rasterize.png__stdout", "sourcefile": "resources/jbig2.pdf", "args": ["-l", "eng", "--psm", "2", "$TMPDIR/000001_rasterize.png", "stdout"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__--psm__2__000001_rasterize.png__stdout", "sourcefile": "resources/jbig2.pdf", "args": ["-l", "eng", "--psm", "2", "$TMPDIR/000001_rasterize.png", "stdout"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/aspect.pdf", "args": ["-l", "eng", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_hocr", "hocr", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt", "sourcefile": "resources/aspect.pdf", "args": ["-l", "eng", "-c", "textonly_pdf=1", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_tess", "pdf", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt", "sourcefile": "resources/ccitt.pdf", "args": ["-l", "eng", "-c", "textonly_pdf=1", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_tess", "pdf", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/graph_ocred.pdf", "args": ["-l", "eng", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_hocr", "hocr", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt", "sourcefile": "resources/ccitt.pdf", "args": ["-l", "eng", "-c", "textonly_pdf=1", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_tess", "pdf", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__osd__--psm__0__000001_rasterize_preview.jpg__stdout", "sourcefile": "resources/cardinal.pdf", "args": ["-l", "osd", "--psm", "0", "$TMPDIR/000001_rasterize_preview.jpg", "stdout"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__osd__--psm__0__000001_rasterize_preview.jpg__stdout", "sourcefile": "resources/cardinal.pdf", "args": ["-l", "osd", "--psm", "0", "$TMPDIR/000001_rasterize_preview.jpg", "stdout"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__000002_ocr.png__000002_ocr_hocr__hocr__txt", "sourcefile": "resources/multipage.pdf", "args": ["-l", "eng", "/tmp/pytest-of-jb/pytest-42/popen-gw1/test_hocr_api_multipage0/000002_ocr.png", "/tmp/pytest-of-jb/pytest-42/popen-gw1/test_hocr_api_multipage0/000002_ocr_hocr", "hocr", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt", "sourcefile": "resources/jbig2.pdf", "args": ["-l", "eng", "-c", "textonly_pdf=1", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_tess", "pdf", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt", "sourcefile": "resources/jbig2.pdf", "args": ["-l", "eng", "-c", "textonly_pdf=1", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_tess", "pdf", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__osd__--psm__0__000001_rasterize_preview.jpg__stdout", "sourcefile": "resources/cardinal.pdf", "args": ["-l", "osd", "--psm", "0", "$TMPDIR/000001_rasterize_preview.jpg", "stdout"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt", "sourcefile": "resources/lichtenstein.pdf", "args": ["-l", "eng", "-c", "textonly_pdf=1", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_tess", "pdf", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__osd__--psm__0__000002_rasterize_preview.jpg__stdout", "sourcefile": "resources/cardinal.pdf", "args": ["-l", "osd", "--psm", "0", "$TMPDIR/000002_rasterize_preview.jpg", "stdout"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__osd__--psm__0__000002_rasterize_preview.jpg__stdout", "sourcefile": "resources/cardinal.pdf", "args": ["-l", "osd", "--psm", "0", "$TMPDIR/000002_rasterize_preview.jpg", "stdout"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__osd__--psm__0__000003_rasterize_preview.jpg__stdout", "sourcefile": "resources/cardinal.pdf", "args": ["-l", "osd", "--psm", "0", "$TMPDIR/000003_rasterize_preview.jpg", "stdout"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__osd__--psm__0__000003_rasterize_preview.jpg__stdout", "sourcefile": "resources/cardinal.pdf", "args": ["-l", "osd", "--psm", "0", "$TMPDIR/000003_rasterize_preview.jpg", "stdout"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__osd__--psm__0__000004_rasterize_preview.jpg__stdout", "sourcefile": "resources/cardinal.pdf", "args": ["-l", "osd", "--psm", "0", "$TMPDIR/000004_rasterize_preview.jpg", "stdout"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__osd__--psm__0__000004_rasterize_preview.jpg__stdout", "sourcefile": "resources/cardinal.pdf", "args": ["-l", "osd", "--psm", "0", "$TMPDIR/000004_rasterize_preview.jpg", "stdout"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__osd__--psm__0__000004_rasterize_preview.jpg__stdout", "sourcefile": "resources/cardinal.pdf", "args": ["-l", "osd", "--psm", "0", "$TMPDIR/000004_rasterize_preview.jpg", "stdout"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/cardinal.pdf", "args": ["-l", "eng", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_hocr", "hocr", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/cardinal.pdf", "args": ["-l", "eng", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_hocr", "hocr", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt", "sourcefile": "resources/cardinal.pdf", "args": ["-l", "eng", "-c", "textonly_pdf=1", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_tess", "pdf", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__000003_ocr.png__000003_ocr_tess__pdf__txt", "sourcefile": "resources/multipage.pdf", "args": ["-l", "eng", "-c", "textonly_pdf=1", "$TMPDIR/000003_ocr.png", "$TMPDIR/000003_ocr_tess", "pdf", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__000002_ocr.png__000002_ocr_hocr__hocr__txt", "sourcefile": "resources/cardinal.pdf", "args": ["-l", "eng", "$TMPDIR/000002_ocr.png", "$TMPDIR/000002_ocr_hocr", "hocr", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__000003_ocr.png__000003_ocr_hocr__hocr__txt", "sourcefile": "resources/cardinal.pdf", "args": ["-l", "eng", "$TMPDIR/000003_ocr.png", "$TMPDIR/000003_ocr_hocr", "hocr", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__000002_ocr.png__000002_ocr_tess__pdf__txt", "sourcefile": "resources/cardinal.pdf", "args": ["-l", "eng", "-c", "textonly_pdf=1", "$TMPDIR/000002_ocr.png", "$TMPDIR/000002_ocr_tess", "pdf", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__000005_ocr.png__000005_ocr_tess__pdf__txt", "sourcefile": "resources/multipage.pdf", "args": ["-l", "eng", "-c", "textonly_pdf=1", "$TMPDIR/000005_ocr.png", "$TMPDIR/000005_ocr_tess", "pdf", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__osd__--psm__0__000001_rasterize_preview.jpg__stdout", "sourcefile": "resources/poster.pdf", "args": ["-l", "osd", "--psm", "0", "$TMPDIR/000001_rasterize_preview.jpg", "stdout"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__000004_ocr.png__000004_ocr_tess__pdf__txt", "sourcefile": "resources/multipage.pdf", "args": ["-l", "eng", "-c", "textonly_pdf=1", "$TMPDIR/000004_ocr.png", "$TMPDIR/000004_ocr_tess", "pdf", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__000003_ocr.png__000003_ocr_hocr__hocr__txt", "sourcefile": "resources/cardinal.pdf", "args": ["-l", "eng", "$TMPDIR/000003_ocr.png", "$TMPDIR/000003_ocr_hocr", "hocr", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__000004_ocr.png__000004_ocr_hocr__hocr__txt", "sourcefile": "resources/cardinal.pdf", "args": ["-l", "eng", "$TMPDIR/000004_ocr.png", "$TMPDIR/000004_ocr_hocr", "hocr", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__000003_ocr.png__000003_ocr_tess__pdf__txt", "sourcefile": "resources/cardinal.pdf", "args": ["-l", "eng", "-c", "textonly_pdf=1", "$TMPDIR/000003_ocr.png", "$TMPDIR/000003_ocr_tess", "pdf", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__000006_ocr.png__000006_ocr_tess__pdf__txt", "sourcefile": "resources/multipage.pdf", "args": ["-l", "eng", "-c", "textonly_pdf=1", "$TMPDIR/000006_ocr.png", "$TMPDIR/000006_ocr_tess", "pdf", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__000004_ocr.png__000004_ocr_hocr__hocr__txt", "sourcefile": "resources/cardinal.pdf", "args": ["-l", "eng", "$TMPDIR/000004_ocr.png", "$TMPDIR/000004_ocr_hocr", "hocr", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__000004_ocr.png__000004_ocr_tess__pdf__txt", "sourcefile": "resources/cardinal.pdf", "args": ["-l", "eng", "-c", "textonly_pdf=1", "$TMPDIR/000004_ocr.png", "$TMPDIR/000004_ocr_tess", "pdf", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/poster.pdf", "args": ["-l", "eng", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_hocr", "hocr", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt", "sourcefile": "resources/multipage.pdf", "args": ["-l", "eng", "-c", "textonly_pdf=1", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_tess", "pdf", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.6", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/poster.pdf", "args": ["-l", "eng", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_hocr", "hocr", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.7", "argv_slug": "__-l__deu__000001_ocr.png__000001_ocr_tess__pdf__txt", "sourcefile": "resources/francais.pdf", "args": ["-l", "deu", "-c", "textonly_pdf=1", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_tess", "pdf", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.7", "argv_slug": "__-l__eng__--oem__1__000001_ocr.png__000001_ocr_tess__pdf__txt", "sourcefile": "resources/trivial.pdf", "args": ["-l", "eng", "--oem", "1", "-c", "textonly_pdf=1", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_tess", "pdf", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.7", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt", "sourcefile": "resources/trivial.pdf", "args": ["-l", "eng", "-c", "textonly_pdf=1", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_tess", "pdf", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.7", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt", "sourcefile": "resources/2400dpi.pdf", "args": ["-l", "eng", "-c", "textonly_pdf=1", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_tess", "pdf", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.7", "argv_slug": "__-l__eng__thresholding_method=1__000001_ocr.png__000001_ocr_tess__pdf__txt", "sourcefile": "resources/trivial.pdf", "args": ["-l", "eng", "-c", "textonly_pdf=1", "-c", "thresholding_method=1", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_tess", "pdf", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.7", "argv_slug": "__-l__eng__thresholding_method=2__000001_ocr.png__000001_ocr_tess__pdf__txt", "sourcefile": "resources/trivial.pdf", "args": ["-l", "eng", "-c", "textonly_pdf=1", "-c", "thresholding_method=2", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_tess", "pdf", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.7", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt", "sourcefile": "resources/graph_ocred.pdf", "args": ["-l", "eng", "-c", "textonly_pdf=1", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_tess", "pdf", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.7", "argv_slug": "__-l__eng__000002_ocr.png__000002_ocr_tess__pdf__txt", "sourcefile": "resources/3small.pdf", "args": ["-l", "eng", "-c", "textonly_pdf=1", "$TMPDIR/000002_ocr.png", "$TMPDIR/000002_ocr_tess", "pdf", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.7", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt", "sourcefile": "resources/3small.pdf", "args": ["-l", "eng", "-c", "textonly_pdf=1", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_tess", "pdf", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.7", "argv_slug": "__-l__eng__000003_ocr.png__000003_ocr_tess__pdf__txt", "sourcefile": "resources/3small.pdf", "args": ["-l", "eng", "-c", "textonly_pdf=1", "$TMPDIR/000003_ocr.png", "$TMPDIR/000003_ocr_tess", "pdf", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.7", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt", "sourcefile": "resources/poster.pdf", "args": ["-l", "eng", "-c", "textonly_pdf=1", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_tess", "pdf", "txt"]} -{"tesseract_version": "5.3.2", "system": "Linux", "python": "3.11.7", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt", "sourcefile": "resources/poster.pdf", "args": ["-l", "eng", "-c", "textonly_pdf=1", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_tess", "pdf", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/trivial.pdf", "args": ["-l", "eng", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_hocr", "hocr", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__--psm__2__000003_rasterize.png__stdout", "sourcefile": "resources/multipage.pdf", "args": ["-l", "eng", "--psm", "2", "$TMPDIR/000003_rasterize.png", "stdout"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__--psm__2__000004_rasterize.png__stdout", "sourcefile": "resources/multipage.pdf", "args": ["-l", "eng", "--psm", "2", "$TMPDIR/000004_rasterize.png", "stdout"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__--psm__2__000005_rasterize.png__stdout", "sourcefile": "resources/multipage.pdf", "args": ["-l", "eng", "--psm", "2", "$TMPDIR/000005_rasterize.png", "stdout"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__--psm__2__000001_rasterize.png__stdout", "sourcefile": "resources/multipage.pdf", "args": ["-l", "eng", "--psm", "2", "$TMPDIR/000001_rasterize.png", "stdout"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/2400dpi.pdf", "args": ["-l", "eng", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_hocr", "hocr", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000002_ocr.png__000002_ocr_hocr__hocr__txt", "sourcefile": "resources/3small.pdf", "args": ["-l", "eng", "$TMPDIR/000002_ocr.png", "$TMPDIR/000002_ocr_hocr", "hocr", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__--psm__2__000006_rasterize.png__stdout", "sourcefile": "resources/multipage.pdf", "args": ["-l", "eng", "--psm", "2", "$TMPDIR/000006_rasterize.png", "stdout"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/3small.pdf", "args": ["-l", "eng", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_hocr", "hocr", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000003_ocr.png__000003_ocr_hocr__hocr__txt", "sourcefile": "resources/multipage.pdf", "args": ["-l", "eng", "$TMPDIR/000003_ocr.png", "$TMPDIR/000003_ocr_hocr", "hocr", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000004_ocr.png__000004_ocr_hocr__hocr__txt", "sourcefile": "resources/multipage.pdf", "args": ["-l", "eng", "$TMPDIR/000004_ocr.png", "$TMPDIR/000004_ocr_hocr", "hocr", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__thresholding_method=1__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/trivial.pdf", "args": ["-l", "eng", "-c", "thresholding_method=1", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_hocr", "hocr", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/ccitt.pdf", "args": ["-l", "eng", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_hocr", "hocr", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/jbig2.pdf", "args": ["-l", "eng", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_hocr", "hocr", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000003_ocr.png__000003_ocr_hocr__hocr__txt", "sourcefile": "resources/3small.pdf", "args": ["-l", "eng", "$TMPDIR/000003_ocr.png", "$TMPDIR/000003_ocr_hocr", "hocr", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt", "sourcefile": "resources/skew.pdf", "args": ["-l", "eng", "-c", "textonly_pdf=1", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_tess", "pdf", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000005_ocr.png__000005_ocr_hocr__hocr__txt", "sourcefile": "resources/multipage.pdf", "args": ["-l", "eng", "$TMPDIR/000005_ocr.png", "$TMPDIR/000005_ocr_hocr", "hocr", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/multipage.pdf", "args": ["-l", "eng", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_hocr", "hocr", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__thresholding_method=2__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/trivial.pdf", "args": ["-l", "eng", "-c", "thresholding_method=2", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_hocr", "hocr", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__--psm__7__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/skew.pdf", "args": ["-l", "eng", "--psm", "7", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_hocr", "hocr", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000006_ocr.png__000006_ocr_hocr__hocr__txt", "sourcefile": "resources/multipage.pdf", "args": ["-l", "eng", "$TMPDIR/000006_ocr.png", "$TMPDIR/000006_ocr_hocr", "hocr", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/graph_ocred.pdf", "args": ["-l", "eng", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_hocr", "hocr", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/skew.pdf", "args": ["-l", "eng", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_hocr", "hocr", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__--psm__7__000001_ocr.png__000001_ocr_tess__pdf__txt", "sourcefile": "resources/skew.pdf", "args": ["-l", "eng", "--psm", "7", "-c", "textonly_pdf=1", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_tess", "pdf", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000003_ocr.png__000003_ocr_tess__pdf__txt", "sourcefile": "resources/multipage.pdf", "args": ["-l", "eng", "-c", "textonly_pdf=1", "$TMPDIR/000003_ocr.png", "$TMPDIR/000003_ocr_tess", "pdf", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000004_ocr.png__000004_ocr_tess__pdf__txt", "sourcefile": "resources/multipage.pdf", "args": ["-l", "eng", "-c", "textonly_pdf=1", "$TMPDIR/000004_ocr.png", "$TMPDIR/000004_ocr_tess", "pdf", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__--oem__1__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/trivial.pdf", "args": ["-l", "eng", "--oem", "1", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_hocr", "hocr", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000005_ocr.png__000005_ocr_tess__pdf__txt", "sourcefile": "resources/multipage.pdf", "args": ["-l", "eng", "-c", "textonly_pdf=1", "$TMPDIR/000005_ocr.png", "$TMPDIR/000005_ocr_tess", "pdf", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__--psm__2__000001_rasterize.png__stdout", "sourcefile": "resources/ccitt.pdf", "args": ["-l", "eng", "--psm", "2", "$TMPDIR/000001_rasterize.png", "stdout"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__--psm__2__000001_rasterize.png__stdout", "sourcefile": "resources/jbig2.pdf", "args": ["-l", "eng", "--psm", "2", "$TMPDIR/000001_rasterize.png", "stdout"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000006_ocr.png__000006_ocr_tess__pdf__txt", "sourcefile": "resources/multipage.pdf", "args": ["-l", "eng", "-c", "textonly_pdf=1", "$TMPDIR/000006_ocr.png", "$TMPDIR/000006_ocr_tess", "pdf", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__--psm__2__000001_rasterize.png__stdout", "sourcefile": "resources/lichtenstein.pdf", "args": ["-l", "eng", "--psm", "2", "$TMPDIR/000001_rasterize.png", "stdout"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/lichtenstein.pdf", "args": ["-l", "eng", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_hocr", "hocr", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__--psm__2__000001_rasterize.png__stdout", "sourcefile": "resources/palette.pdf", "args": ["-l", "eng", "--psm", "2", "$TMPDIR/000001_rasterize.png", "stdout"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt", "sourcefile": "resources/aspect.pdf", "args": ["-l", "eng", "-c", "textonly_pdf=1", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_tess", "pdf", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt", "sourcefile": "resources/multipage.pdf", "args": ["-l", "eng", "-c", "textonly_pdf=1", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_tess", "pdf", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/palette.pdf", "args": ["-l", "eng", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_hocr", "hocr", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt", "sourcefile": "resources/palette.pdf", "args": ["-l", "eng", "-c", "textonly_pdf=1", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_tess", "pdf", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000005_ocr.png__000005_ocr_hocr__hocr__txt", "sourcefile": "resources/multipage.pdf", "args": ["-l", "eng", "$TMPDIR/000005_ocr.png", "$TMPDIR/000005_ocr_hocr", "hocr", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000006_ocr.png__000006_ocr_hocr__hocr__txt", "sourcefile": "resources/multipage.pdf", "args": ["-l", "eng", "$TMPDIR/000006_ocr.png", "$TMPDIR/000006_ocr_hocr", "hocr", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/multipage.pdf", "args": ["-l", "eng", "/tmp/pytest-of-jb/pytest-73/popen-gw3/test_hocr_api_multipage0/000001_ocr.png", "/tmp/pytest-of-jb/pytest-73/popen-gw3/test_hocr_api_multipage0/000001_ocr_hocr", "hocr", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000003_ocr.png__000003_ocr_hocr__hocr__txt", "sourcefile": "resources/multipage.pdf", "args": ["-l", "eng", "/tmp/pytest-of-jb/pytest-73/popen-gw3/test_hocr_api_multipage0/000003_ocr.png", "/tmp/pytest-of-jb/pytest-73/popen-gw3/test_hocr_api_multipage0/000003_ocr_hocr", "hocr", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/ccitt.pdf", "args": ["-l", "eng", "/tmp/pytest-of-jb/pytest-73/popen-gw4/test_hocr_to_pdf_api0/000001_ocr.png", "/tmp/pytest-of-jb/pytest-73/popen-gw4/test_hocr_to_pdf_api0/000001_ocr_hocr", "hocr", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/ccitt.pdf", "args": ["-l", "eng", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_hocr", "hocr", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000002_ocr.png__000002_ocr_hocr__hocr__txt", "sourcefile": "resources/multipage.pdf", "args": ["-l", "eng", "/tmp/pytest-of-jb/pytest-73/popen-gw3/test_hocr_api_multipage0/000002_ocr.png", "/tmp/pytest-of-jb/pytest-73/popen-gw3/test_hocr_api_multipage0/000002_ocr_hocr", "hocr", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/trivial.pdf", "args": ["-l", "eng", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_hocr", "hocr", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000004_ocr.png__000004_ocr_hocr__hocr__txt", "sourcefile": "resources/multipage.pdf", "args": ["-l", "eng", "$TMPDIR/000004_ocr.png", "$TMPDIR/000004_ocr_hocr", "hocr", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000002_ocr.png__000002_ocr_hocr__hocr__txt", "sourcefile": "resources/3small.pdf", "args": ["-l", "eng", "$TMPDIR/000002_ocr.png", "$TMPDIR/000002_ocr_hocr", "hocr", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/2400dpi.pdf", "args": ["-l", "eng", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_hocr", "hocr", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/3small.pdf", "args": ["-l", "eng", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_hocr", "hocr", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/jbig2.pdf", "args": ["-l", "eng", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_hocr", "hocr", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000003_ocr.png__000003_ocr_hocr__hocr__txt", "sourcefile": "resources/3small.pdf", "args": ["-l", "eng", "$TMPDIR/000003_ocr.png", "$TMPDIR/000003_ocr_hocr", "hocr", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__thresholding_method=1__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/trivial.pdf", "args": ["-l", "eng", "-c", "thresholding_method=1", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_hocr", "hocr", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/graph_ocred.pdf", "args": ["-l", "eng", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_hocr", "hocr", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__--psm__7__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/skew.pdf", "args": ["-l", "eng", "--psm", "7", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_hocr", "hocr", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__thresholding_method=2__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/trivial.pdf", "args": ["-l", "eng", "-c", "thresholding_method=2", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_hocr", "hocr", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/skew.pdf", "args": ["-l", "eng", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_hocr", "hocr", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__--oem__1__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/trivial.pdf", "args": ["-l", "eng", "--oem", "1", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_hocr", "hocr", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/palette.pdf", "args": ["-l", "eng", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_hocr", "hocr", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt", "sourcefile": "resources/jbig2.pdf", "args": ["-l", "eng", "-c", "textonly_pdf=1", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_tess", "pdf", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt", "sourcefile": "resources/ccitt.pdf", "args": ["-l", "eng", "-c", "textonly_pdf=1", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_tess", "pdf", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt", "sourcefile": "resources/lichtenstein.pdf", "args": ["-l", "eng", "-c", "textonly_pdf=1", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_tess", "pdf", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/lichtenstein.pdf", "args": ["-l", "eng", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_hocr", "hocr", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__osd__--psm__0__000001_rasterize_preview.jpg__stdout", "sourcefile": "resources/cardinal.pdf", "args": ["-l", "osd", "--psm", "0", "$TMPDIR/000001_rasterize_preview.jpg", "stdout"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__osd__--psm__0__000002_rasterize_preview.jpg__stdout", "sourcefile": "resources/cardinal.pdf", "args": ["-l", "osd", "--psm", "0", "$TMPDIR/000002_rasterize_preview.jpg", "stdout"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__osd__--psm__0__000003_rasterize_preview.jpg__stdout", "sourcefile": "resources/cardinal.pdf", "args": ["-l", "osd", "--psm", "0", "$TMPDIR/000003_rasterize_preview.jpg", "stdout"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/aspect.pdf", "args": ["-l", "eng", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_hocr", "hocr", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__osd__--psm__0__000004_rasterize_preview.jpg__stdout", "sourcefile": "resources/cardinal.pdf", "args": ["-l", "osd", "--psm", "0", "$TMPDIR/000004_rasterize_preview.jpg", "stdout"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/cardinal.pdf", "args": ["-l", "eng", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_hocr", "hocr", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000003_ocr.png__000003_ocr_hocr__hocr__txt", "sourcefile": "resources/cardinal.pdf", "args": ["-l", "eng", "$TMPDIR/000003_ocr.png", "$TMPDIR/000003_ocr_hocr", "hocr", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000002_ocr.png__000002_ocr_hocr__hocr__txt", "sourcefile": "resources/cardinal.pdf", "args": ["-l", "eng", "$TMPDIR/000002_ocr.png", "$TMPDIR/000002_ocr_hocr", "hocr", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000004_ocr.png__000004_ocr_hocr__hocr__txt", "sourcefile": "resources/cardinal.pdf", "args": ["-l", "eng", "$TMPDIR/000004_ocr.png", "$TMPDIR/000004_ocr_hocr", "hocr", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000002_ocr.png__000002_ocr_hocr__hocr__txt", "sourcefile": "resources/cardinal.pdf", "args": ["-l", "eng", "$TMPDIR/000002_ocr.png", "$TMPDIR/000002_ocr_hocr", "hocr", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000004_ocr.png__000004_ocr_hocr__hocr__txt", "sourcefile": "resources/cardinal.pdf", "args": ["-l", "eng", "$TMPDIR/000004_ocr.png", "$TMPDIR/000004_ocr_hocr", "hocr", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/poster.pdf", "args": ["-l", "eng", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_hocr", "hocr", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__deu__000001_ocr.png__000001_ocr_hocr__hocr__txt", "sourcefile": "resources/francais.pdf", "args": ["-l", "deu", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_hocr", "hocr", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt", "sourcefile": "resources/cardinal.pdf", "args": ["-l", "eng", "-c", "textonly_pdf=1", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_tess", "pdf", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000002_ocr.png__000002_ocr_tess__pdf__txt", "sourcefile": "resources/cardinal.pdf", "args": ["-l", "eng", "-c", "textonly_pdf=1", "$TMPDIR/000002_ocr.png", "$TMPDIR/000002_ocr_tess", "pdf", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000003_ocr.png__000003_ocr_tess__pdf__txt", "sourcefile": "resources/cardinal.pdf", "args": ["-l", "eng", "-c", "textonly_pdf=1", "$TMPDIR/000003_ocr.png", "$TMPDIR/000003_ocr_tess", "pdf", "txt"]} +{"tesseract_version": "5.5.1", "system": "Linux", "python": "3.11.14", "argv_slug": "__-l__eng__000004_ocr.png__000004_ocr_tess__pdf__txt", "sourcefile": "resources/cardinal.pdf", "args": ["-l", "eng", "-c", "textonly_pdf=1", "$TMPDIR/000004_ocr.png", "$TMPDIR/000004_ocr_tess", "pdf", "txt"]} diff --git a/tests/cache/multipage/__-l__eng__--psm__2__000001_rasterize.png__stdout/stderr.bin b/tests/cache/multipage/__-l__eng__--psm__2__000001_rasterize.png__stdout/stderr.bin index f5bb79e9..f44c6464 100644 --- a/tests/cache/multipage/__-l__eng__--psm__2__000001_rasterize.png__stdout/stderr.bin +++ b/tests/cache/multipage/__-l__eng__--psm__2__000001_rasterize.png__stdout/stderr.bin @@ -1,4 +1,4 @@ Orientation: 0 WritingDirection: 0 TextlineOrder: 2 -Deskew angle: -0.0091 +Deskew angle: -0.0081 diff --git a/tests/cache/multipage/__-l__eng__--psm__2__000004_rasterize.png__stdout/stderr.bin b/tests/cache/multipage/__-l__eng__--psm__2__000004_rasterize.png__stdout/stderr.bin index b3216ce4..116a8cfe 100644 --- a/tests/cache/multipage/__-l__eng__--psm__2__000004_rasterize.png__stdout/stderr.bin +++ b/tests/cache/multipage/__-l__eng__--psm__2__000004_rasterize.png__stdout/stderr.bin @@ -1,4 +1,4 @@ Orientation: 0 WritingDirection: 0 TextlineOrder: 2 -Deskew angle: 0.0001 +Deskew angle: 0.0000 diff --git a/tests/cache/multipage/__-l__eng__--psm__2__000005_rasterize.png__stdout/stderr.bin b/tests/cache/multipage/__-l__eng__--psm__2__000005_rasterize.png__stdout/stderr.bin index 04414b66..10179a2c 100644 --- a/tests/cache/multipage/__-l__eng__--psm__2__000005_rasterize.png__stdout/stderr.bin +++ b/tests/cache/multipage/__-l__eng__--psm__2__000005_rasterize.png__stdout/stderr.bin @@ -1,4 +1,4 @@ Orientation: 0 WritingDirection: 0 TextlineOrder: 2 -Deskew angle: 0.0343 +Deskew angle: 0.0347 diff --git a/tests/cache/multipage/__-l__eng__--psm__2__000006_rasterize.png__stdout/stderr.bin b/tests/cache/multipage/__-l__eng__--psm__2__000006_rasterize.png__stdout/stderr.bin index 33810042..72fde58a 100644 --- a/tests/cache/multipage/__-l__eng__--psm__2__000006_rasterize.png__stdout/stderr.bin +++ b/tests/cache/multipage/__-l__eng__--psm__2__000006_rasterize.png__stdout/stderr.bin @@ -1,4 +1,4 @@ Orientation: 0 WritingDirection: 0 TextlineOrder: 2 -Deskew angle: 0.0005 +Deskew angle: 0.0004 diff --git a/tests/cache/multipage/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin b/tests/cache/multipage/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin index b6591e3a..45a9d616 100644 --- a/tests/cache/multipage/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin +++ b/tests/cache/multipage/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin @@ -5,304 +5,298 @@ - - + + -
-
-

- - Chapter - i +

+
+

+ + the + trees + back + towards + the + end + of + the + + + widow’s + garden, + stooping + down + so + as + + + the + branches + wouldn’t + scrape + our + heads. + + + When + we + was + passing + by + the + kitchen + + + I + fell + over + a + root + and + made + a + noise. + + + We + scrouched + down + and + laid + still. + + + Miss + Watson’s + big + nigger, + named + + + Jim, + was + setting + in + the + kitchen + door; + + + we + could + see + him + pretty + clear, + because + + + there + was + a + light + behind + him. + He + + + got + up + and + stretched + his + neck + out + + + about + a + minute, + listening. + Then + he + + + says,

-
-

- - lad +

+

+ + Le + Weg! + j

-

- - - We - went - tip-toeing - along - a - path - amongst - - - the - trees - back - towards - the - end - of - the - - - widow’s - garden, - stooping - down - so - as - -

-
-
-
-

- - the - branches - wouldn’t - scrape - our - heads. - - - When - we - was - passing - by - the - kitchen - - - I - fell - over - a - root - and - made - a - noise. - - - We - scrouched - down - and - laid - still. - - - Miss - Watson’s - big - nigger, - named - - - Jim, - was - setting - in - the - kitchen - door; - - - we - could - see - him - pretty - clear, - because - - - there - was - a - light - behind - him. - He - - - got - up - and - stretched - his - neck - out - - - about - a - minute, - listening. - Then - he - - - says, +

+ + j + Vy + ; + “Who + dah?”

-

- - «Who - dah?” +

+ + Wf + h + He + listened + some + more; + then + he -

- -

- - He - listened - some - more; - then - he - - - come - tip-toeing + + i + if + f + come + tip-toeing down - and. - stood + and + stood - - right - between - us; - we - could - a - touched + + | + right + between + us; + we + could + a + touched - - him, - nearly. - Well, - likely - it - was - min- + + him, + nearly. + Well, + likely + it + was + min- - - utes - and - minutes - that - there - warn’t - a + + utes + and + minutes + that + there + warn’t + a - - sound, - and - we - all - there - so - close + + sound, + and + we + all + there + so + close - - together. - ‘There - was - a - place - on - my + + together. + ‘There + was + a + place + on + my - - ankle - that - got - to - itching; - but - I + + ankle + that + got + to + itching; + but + I - - dasn’t - scratch - it; - and - then - my - ear - begun - to - itch; - and - next - my - back, - right - be- + + dasn’t + scratch + it; + and + then + my + ear + begun + to + itch; + and + next + my + back, + right + be- - - tween - my - shoulders. - Seemed - like - I’d - die - if - I - couldn’t - scratch. - Well, - I’ve + + tween + my + shoulders. + Seemed + like + I’d + die + if + I + couldn’t + scratch. + Well, + I’ve - - noticed - that - thing - plenty - of - times - since. - If - you - are - with - the - quality, - or - at - a - - - funeral, - or - trying - to - go - to - sleep - when - you - ain’t - sleepy—if - you - are - anywheres + + noticed + that + thing + plenty + of + times + since. + If + you + are + with + the + quality, + or + at + a

-
-

- - ‘qumY - TIP-TOED - ALONG. +

+

+ + funeral, + or + trying + to + go + to + sleep + when + you + ain’t + sleepy—if + you + are + anywheres + +

+
+
+
+

+ + ‘quHY + TIP-TOED + ALONG,

diff --git a/tests/cache/multipage/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/txt.bin b/tests/cache/multipage/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/txt.bin index 5ba8673f..7f4390aa 100644 --- a/tests/cache/multipage/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/txt.bin +++ b/tests/cache/multipage/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/txt.bin @@ -1,11 +1,5 @@ -Chapter i - -lad - -‘ We went tip-toeing along a path amongst the trees back towards the end of the widow’s garden, stooping down so as - the branches wouldn’t scrape our heads. When we was passing by the kitchen I fell over a root and made a noise. @@ -18,11 +12,13 @@ got up and stretched his neck out about a minute, listening. Then he says, -«Who dah?” +Le Weg! j -He listened some more; then he -come tip-toeing down and. stood -right between us; we could a touched +j Vy ; “Who dah?” + +Wf h He listened some more; then he +i if f come tip-toeing down and stood +| right between us; we could a touched him, nearly. Well, likely it was min- utes and minutes that there warn’t a sound, and we all there so close @@ -31,6 +27,7 @@ ankle that got to itching; but I dasn’t scratch it; and then my ear begun to itch; and next my back, right be- tween my shoulders. Seemed like I’d die if I couldn’t scratch. Well, I’ve noticed that thing plenty of times since. If you are with the quality, or at a + funeral, or trying to go to sleep when you ain’t sleepy—if you are anywheres -‘qumY TIP-TOED ALONG. +‘quHY TIP-TOED ALONG, diff --git a/tests/cache/multipage/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin b/tests/cache/multipage/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin index 51feee05..907a48ad 100644 Binary files a/tests/cache/multipage/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin and b/tests/cache/multipage/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin differ diff --git a/tests/cache/multipage/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/txt.bin b/tests/cache/multipage/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/txt.bin index 927addf5..7ec543b1 100644 --- a/tests/cache/multipage/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/txt.bin +++ b/tests/cache/multipage/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/txt.bin @@ -1,15 +1,9 @@ -ee if /) J: -( H} : cha -iy ‘ H +whapter UH -THEY TIP-TOED ALONG. - -N - -: chapter J - -E went tip-toeing along a path amongst +DS aa +ae +4 Wer went tip-toeing along a path amongst the trees back towards the end of the widow’s garden, stooping down so as the branches wouldn’t scrape our heads. @@ -20,22 +14,33 @@ Miss Watson’s big nigger, named Jim, was setting in the kitchen door ; we could see him pretty clear, because there was a light behind him. He -got up and stretched his neck out -about a minute, listening. Then he -says, +| got up and stretched his neck out +ANY) 47 about a minute, listening. Then he -** Who dah?” +Y Mngt e.. says, -He listened some more; then he -come tip-toeing down and- stood -right between us; we could a touched -him, nearly. Well, likely it was min- +Y, if ** Who dah?” + +Vis + +YY Yy He listened some more; then he +uh mf come tip-toeing down and- stood +iM Oh right between us; we could a touched +Wa him, nearly. Well, likely it was min- utes and minutes that there warn’t a sound, and we all there so close together. ‘There was a place on my ankle that got to itching; but I - dasn’t scratch it; and then my ear begun to itch; and next my back, right be- tween my shoulders. Seemed like I’d die if I couldn’t scratch. Well, I’ve noticed that thing plenty of times since. If you are with the quality, or at a + funeral, or trying to go to sleep when you ain’t sleepy—if you are anywheres + +i) +f + +Nall 4 ‘ +‘ AINA £4 Fat 4 + +THEY TIP-TOED ALONG. diff --git a/tests/cache/multipage/__-l__eng__000002_ocr.png__000002_ocr_hocr__hocr__txt/hocr.bin b/tests/cache/multipage/__-l__eng__000002_ocr.png__000002_ocr_hocr__hocr__txt/hocr.bin index e712ef19..03e0fb76 100644 --- a/tests/cache/multipage/__-l__eng__000002_ocr.png__000002_ocr_hocr__hocr__txt/hocr.bin +++ b/tests/cache/multipage/__-l__eng__000002_ocr.png__000002_ocr_hocr__hocr__txt/hocr.bin @@ -5,23 +5,23 @@ - - + + -
+

- 9OO0Ox9000 - pixels - at - GOO - DPI + Q9OO0Ox9O000 + pixels + at + GOO + DPI - S|] - megapixels + S|] + megapixels

diff --git a/tests/cache/multipage/__-l__eng__000002_ocr.png__000002_ocr_hocr__hocr__txt/txt.bin b/tests/cache/multipage/__-l__eng__000002_ocr.png__000002_ocr_hocr__hocr__txt/txt.bin index 4b9f106b..f2d253c6 100644 --- a/tests/cache/multipage/__-l__eng__000002_ocr.png__000002_ocr_hocr__hocr__txt/txt.bin +++ b/tests/cache/multipage/__-l__eng__000002_ocr.png__000002_ocr_hocr__hocr__txt/txt.bin @@ -1,2 +1,2 @@ -9OO0Ox9000 pixels at GOO DPI +Q9OO0Ox9O000 pixels at GOO DPI S|] megapixels diff --git a/tests/cache/multipage/__-l__eng__000003_ocr.png__000003_ocr_hocr__hocr__txt/hocr.bin b/tests/cache/multipage/__-l__eng__000003_ocr.png__000003_ocr_hocr__hocr__txt/hocr.bin index d77c385d..85b9f859 100644 --- a/tests/cache/multipage/__-l__eng__000003_ocr.png__000003_ocr_hocr__hocr__txt/hocr.bin +++ b/tests/cache/multipage/__-l__eng__000003_ocr.png__000003_ocr_hocr__hocr__txt/hocr.bin @@ -5,32 +5,32 @@ - - + + -
+

- - —esupport + + —esupport - 300 + 300

- BE - No - votefnote - 1] + BH + No + vote{note + 1]

@@ -38,13 +38,12 @@

- + - ay - > - Net[note - 2] + | + —Net[note + 2]

@@ -53,9 +52,9 @@

- Percentage - [note - 3] + Percentage + [note + 3]

diff --git a/tests/cache/multipage/__-l__eng__000003_ocr.png__000003_ocr_hocr__hocr__txt/txt.bin b/tests/cache/multipage/__-l__eng__000003_ocr.png__000003_ocr_hocr__hocr__txt/txt.bin index 52ecff30..7781a42e 100644 --- a/tests/cache/multipage/__-l__eng__000003_ocr.png__000003_ocr_hocr__hocr__txt/txt.bin +++ b/tests/cache/multipage/__-l__eng__000003_ocr.png__000003_ocr_hocr__hocr__txt/txt.bin @@ -1,10 +1,10 @@ — —esupport 300 -BE No votefnote 1] +BH No vote{note 1] — -ay > Net[note 2] +| —Net[note 2] Percentage [note 3] diff --git a/tests/cache/multipage/__-l__eng__000003_ocr.png__000003_ocr_tess__pdf__txt/pdf.bin b/tests/cache/multipage/__-l__eng__000003_ocr.png__000003_ocr_tess__pdf__txt/pdf.bin index 53a56b7a..5a3edaa7 100644 Binary files a/tests/cache/multipage/__-l__eng__000003_ocr.png__000003_ocr_tess__pdf__txt/pdf.bin and b/tests/cache/multipage/__-l__eng__000003_ocr.png__000003_ocr_tess__pdf__txt/pdf.bin differ diff --git a/tests/cache/multipage/__-l__eng__000003_ocr.png__000003_ocr_tess__pdf__txt/txt.bin b/tests/cache/multipage/__-l__eng__000003_ocr.png__000003_ocr_tess__pdf__txt/txt.bin index d687659d..fe9151a7 100644 --- a/tests/cache/multipage/__-l__eng__000003_ocr.png__000003_ocr_tess__pdf__txt/txt.bin +++ b/tests/cache/multipage/__-l__eng__000003_ocr.png__000003_ocr_tess__pdf__txt/txt.bin @@ -1,7 +1,8 @@ -—¢—Support += §— Support == No vote[note 1] -ie Oppose -——Net[note 2] +ir Oppose -=e Percentage [note 3] += Net[note 2] + +== Percentage [note 3] diff --git a/tests/cache/multipage/__-l__eng__000004_ocr.png__000004_ocr_hocr__hocr__txt/hocr.bin b/tests/cache/multipage/__-l__eng__000004_ocr.png__000004_ocr_hocr__hocr__txt/hocr.bin index 4f91eff6..a2ad88b1 100644 --- a/tests/cache/multipage/__-l__eng__000004_ocr.png__000004_ocr_hocr__hocr__txt/hocr.bin +++ b/tests/cache/multipage/__-l__eng__000004_ocr.png__000004_ocr_hocr__hocr__txt/hocr.bin @@ -5,186 +5,86 @@ - - + + -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-

- - 600 - - - 500 - . - - - 400 - _ - EB - fp - ys - - - 300 - —f— - EN - / - ~/ - Y - - - Tg - ANA - a3 - - - 100 - - - nee - -eemiae - - - 0-+—-* - a - ee - ee - ee - eee - - - S - @ - s - > - oO - © - ve - S - te) - @ - + - & - & - “A - 5 - oo - - - Se - ° - Fs - as - ge - eS - F - x - ro - NS - Pe - e? - & - s - AS - - - aa - © - FF - SF - HY - HK - SK - HM - ee - sO - - - e - < - Na - - : - > - cy - xs - eS - @ - Ww - No - a) - Oo - - - ee - S&S - FF - SF - LK - S - e - © - 4 - - - ~ - & - e& - - - s - x +

+
+
+

+ + 600

-
-

- - —¢—Support - -

- -

- - —H— - No - vote[note - 1] - - - ir - Oppose - - - ——Net[note - 2] - -

- -

- - re - Percentage - [note - 3] +

+

+ + oN + la

+
+

+ + | + Sos + ee + + + » + SS + WW + AV + _ + + + pn + a3 + =™— + No + vote[note + 1] + + + 200 + s + ; + =i + Oppose + + + i + eg + ae + ¥ + aae=Net[note + 2] + + + 100 + - + = + es + Percentage + [note + 3] + +

+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/cache/multipage/__-l__eng__000004_ocr.png__000004_ocr_hocr__hocr__txt/txt.bin b/tests/cache/multipage/__-l__eng__000004_ocr.png__000004_ocr_hocr__hocr__txt/txt.bin index 764581b6..417f8a21 100644 --- a/tests/cache/multipage/__-l__eng__000004_ocr.png__000004_ocr_hocr__hocr__txt/txt.bin +++ b/tests/cache/multipage/__-l__eng__000004_ocr.png__000004_ocr_hocr__hocr__txt/txt.bin @@ -1,22 +1,11 @@ 600 -500 . -400 _ EB fp ys -300 —f— EN / ~/ Y -Tg ANA a3 -100 - nee -eemiae -0-+—-* a ee ee ee eee -S @ s > oO © ve S te) @ + & & “A 5 oo -Se ° Fs as ge eS F x ro NS Pe e? & s AS -aa © FF SF HY HK SK HM ee sO -e < Na ‘ : > cy xs eS @ Ww No a) Oo -ee S&S FF SF LK S e © 4 -~ & e& -s x -—¢—Support +oN la -—H— No vote[note 1] -ir Oppose -——Net[note 2] +| Sos ee +» SS WW AV _ +pn a3 =™— No vote[note 1] +200 s ; =i Oppose +i eg ae ¥ aae=Net[note 2] +100 - = es Percentage [note 3] -re Percentage [note 3] diff --git a/tests/cache/multipage/__-l__eng__000004_ocr.png__000004_ocr_tess__pdf__txt/pdf.bin b/tests/cache/multipage/__-l__eng__000004_ocr.png__000004_ocr_tess__pdf__txt/pdf.bin index 04f4bc1d..309af7bb 100644 Binary files a/tests/cache/multipage/__-l__eng__000004_ocr.png__000004_ocr_tess__pdf__txt/pdf.bin and b/tests/cache/multipage/__-l__eng__000004_ocr.png__000004_ocr_tess__pdf__txt/pdf.bin differ diff --git a/tests/cache/multipage/__-l__eng__000004_ocr.png__000004_ocr_tess__pdf__txt/txt.bin b/tests/cache/multipage/__-l__eng__000004_ocr.png__000004_ocr_tess__pdf__txt/txt.bin index 764581b6..417f8a21 100644 --- a/tests/cache/multipage/__-l__eng__000004_ocr.png__000004_ocr_tess__pdf__txt/txt.bin +++ b/tests/cache/multipage/__-l__eng__000004_ocr.png__000004_ocr_tess__pdf__txt/txt.bin @@ -1,22 +1,11 @@ 600 -500 . -400 _ EB fp ys -300 —f— EN / ~/ Y -Tg ANA a3 -100 - nee -eemiae -0-+—-* a ee ee ee eee -S @ s > oO © ve S te) @ + & & “A 5 oo -Se ° Fs as ge eS F x ro NS Pe e? & s AS -aa © FF SF HY HK SK HM ee sO -e < Na ‘ : > cy xs eS @ Ww No a) Oo -ee S&S FF SF LK S e © 4 -~ & e& -s x -—¢—Support +oN la -—H— No vote[note 1] -ir Oppose -——Net[note 2] +| Sos ee +» SS WW AV _ +pn a3 =™— No vote[note 1] +200 s ; =i Oppose +i eg ae ¥ aae=Net[note 2] +100 - = es Percentage [note 3] -re Percentage [note 3] diff --git a/tests/cache/multipage/__-l__eng__000005_ocr.png__000005_ocr_hocr__hocr__txt/hocr.bin b/tests/cache/multipage/__-l__eng__000005_ocr.png__000005_ocr_hocr__hocr__txt/hocr.bin index 29e2cb1d..a3f2dfc2 100644 --- a/tests/cache/multipage/__-l__eng__000005_ocr.png__000005_ocr_hocr__hocr__txt/hocr.bin +++ b/tests/cache/multipage/__-l__eng__000005_ocr.png__000005_ocr_hocr__hocr__txt/hocr.bin @@ -5,41 +5,41 @@ - - + + -
+

- with - a - plain + with + a + plain face, on - the - throne - of - England; + the + throne + of + England; there were - a - king - with - a + a + king + with + a large jaw and - a + a queen - with - a - fair + with + a + fair face, on the @@ -50,18 +50,18 @@ both - countries - it - was + countries + it + was clearer - than - crystal + than + crystal to the - lords + lords - of + Of the State preserves @@ -72,22 +72,22 @@ that - things + things in general were - settled + settled for - ever. + ever.

- It - was - the - year + It + was + the + year of Our Lord @@ -95,10 +95,10 @@ thousand - seven - hundred + seven + hundred and - seventy-five. + seventy-five. Spiritual reve- @@ -107,9 +107,9 @@ were conceded to - England - at - that + England + at + that favoured @@ -118,14 +118,14 @@ at this. Mrs. - Southcott + Southcott had - recently + Tecently attained her - five-and-twentieth + five-and-twentieth blessed @@ -140,17 +140,17 @@ Life - Guards - had + Guards + had heralded the sublime - appearance + appearance by - ‘announcing - that + ‘announcing + that arrangements were made @@ -158,138 +158,138 @@ the - swallowing + swallowing up - of - London - and - Westminster. + of + London + and + Westminster. - Even + Even the - Cock-lane + Cock-lane ghost - had - been + had + been laid - only + only a - ound - dozen + round + dozen of years, - after - rapping - out - its - mes- + after + rapping + out + its + mes- - sages, - as + sages, + as the spirits of - this - very + this + very year last past - (Supematurally + (Supematurally deficient in originality) rapped - out + ‘out theirs. Mere messages - in + in the earthly order - of + of - events + events had lately come - to - the - English + to + the + English Crown and - People, + People, from - a - congress + a + congress of British - subjects - in + subjects + in - America: + America: which, strange to - relate, - have - proved + relate, + have + proved - ‘more + ‘more important - to + to the - human - race - than - any - com- + human + race + than + any + com- - munications - yet - received + munications + yet + received through - any - of - the + any + of + the - chickens - of - the + chickens + of + the Cock-lane - brood, + brood,

France, - less + less favoured - on + on the - whole + whole as - to - mat- + to + mat- - ‘ers + ‘ers spiritual than her @@ -298,39 +298,39 @@ the shield and - tri- + tri- dent, - rolled + rolled with exceeding - smoothness - down + smoothness + down - hill, - making + hill, + making paper money and - spending + spending it. Under the - guidance - of - her + guidance + of + her Christian pastors, she - enter- + enter- - tained - herself, + tained + herself, besides, with such @@ -343,7 +343,7 @@ a youth to - have + have his

@@ -351,141 +351,141 @@

- ‘hands - cut + ‘hands + cut off, his tongue - tom + tom out - with - pincers, + with + pincers, - and + and his body - burned - alive, - because + burned + alive, + because he had - not + not - {kneeled - down - in + ‘kneeled + down + in the rain - to - do - honour - to - a - dirty + to + do + honour + to + a + dirty Procession of - monks - which + monks + which passed - within - his + within + his - view, - ata + view, + ata distance of some - fifty - or + fifty + or sixty - yards. + yards. It - is - likely + is + likely enough that, - rooted - in + rooted + in the woods - of + of - France + France and Norway, there - were - growing + were + growing trees, - when + when that sufferer was put to death, - already + already marked by the Woodman, - Fate, - to + Fate, + to come - down + down - and + ‘and be sawn into boards, to make - a + a certain mov- able - framework - with + framework + with a - sack - and + sack + and a - knife - in - it, - ter- + knife + in + it, + ter- - rible + rible in history. - It - is + It + is likely enough that in - the + the - rough - outhouses + rough + outhouses of some tillers @@ -497,193 +497,193 @@ lands adjacent to - Paris, - there - were + Paris, + there + were sheltered from the - weather - that + weather + that very - day, - rude - carts, + day, + rude + carts, - bespattered - with - rustic - mire, - snuffed + bespattered + with + rustic + mire, + snuffed about - by + by - Pigs, - and + Pigs, + and roosted in by - poultry, + poultry, which the - Farmer, - Death, + Farmer, + Death, had - already + already set apart to - be - his + be + his - ‘umbrils + ‘tumbrils of the - Revolution. + Revolution. But that - Woodman + Woodman - and + and that - Farmer, + Farmer, though they work - unceasingly, + unceasingly, - work - silently, + work + silently, and no one heard - them - as - they + them + as + they - ‘went + ‘went about with - muffled + muffled tread: the rather, - foras- + foras- - much + uch as to - entertain + entertain any - suspicion - that - they + suspicion + that + they - were + were awake, was to be atheistical and - traitorous, + traitorous,

- In - England, + In + England, there was scarcely an - amount - of + amount + of - order + order and protection to justify much - national + national - boasting. - Daring - burglaries + boasting. + Daring + burglaries by - armed - men, - and + armed + men, + and highway - robberies, - took + robberies, + took place - in - the + in + the capital - itself - every - night; - families + itself + every + night; + families were publicly cau- - tioned + tioned not to go out - of - town + of + town without removing - their - furniture - to - upholsterers' - warehouses - for + their + furniture + to + upholsterers' + warehouses + for - security; + security; the - highwayman - in + highwayman + in the dark was - a - City + a + City - ‘tradesman - in - the + ‘tradesman + in + the light, and, being diff --git a/tests/cache/multipage/__-l__eng__000005_ocr.png__000005_ocr_hocr__hocr__txt/txt.bin b/tests/cache/multipage/__-l__eng__000005_ocr.png__000005_ocr_hocr__hocr__txt/txt.bin index 0e3db33a..938d5882 100644 --- a/tests/cache/multipage/__-l__eng__000005_ocr.png__000005_ocr_hocr__hocr__txt/txt.bin +++ b/tests/cache/multipage/__-l__eng__000005_ocr.png__000005_ocr_hocr__hocr__txt/txt.bin @@ -2,23 +2,23 @@ with a plain face, on the throne of England; there were a king with a large jaw and a queen with a fair face, on the throne of France. In both countries it was clearer than crystal to the lords -of the State preserves of loaves and fishes, that +Of the State preserves of loaves and fishes, that things in general were settled for ever. It was the year of Our Lord one thousand seven hundred and seventy-five. Spiritual reve- lations were conceded to England at that favoured period, as at this. Mrs. Southcott had -recently attained her five-and-twentieth blessed +Tecently attained her five-and-twentieth blessed birthday, of whom a prophetic private in the Life Guards had heralded the sublime appearance by ‘announcing that arrangements were made for the swallowing up of London and Westminster. Even the Cock-lane ghost had been laid only a -ound dozen of years, after rapping out its mes- +round dozen of years, after rapping out its mes- sages, as the spirits of this very year last past (Supematurally deficient in originality) rapped -out theirs. Mere messages in the earthly order of +‘out theirs. Mere messages in the earthly order of events had lately come to the English Crown and People, from a congress of British subjects in America: which, strange to relate, have proved @@ -36,14 +36,14 @@ achievements as sentencing a youth to have his ‘hands cut off, his tongue tom out with pincers, and his body burned alive, because he had not -{kneeled down in the rain to do honour to a dirty +‘kneeled down in the rain to do honour to a dirty Procession of monks which passed within his view, ata distance of some fifty or sixty yards. It is likely enough that, rooted in the woods of France and Norway, there were growing trees, when that sufferer was put to death, already marked by the Woodman, Fate, to come down -and be sawn into boards, to make a certain mov- +‘and be sawn into boards, to make a certain mov- able framework with a sack and a knife in it, ter- rible in history. It is likely enough that in the rough outhouses of some tillers of the heavy @@ -52,11 +52,11 @@ from the weather that very day, rude carts, bespattered with rustic mire, snuffed about by Pigs, and roosted in by poultry, which the Farmer, Death, had already set apart to be his -‘umbrils of the Revolution. But that Woodman +‘tumbrils of the Revolution. But that Woodman and that Farmer, though they work unceasingly, work silently, and no one heard them as they ‘went about with muffled tread: the rather, foras- -much as to entertain any suspicion that they +uch as to entertain any suspicion that they were awake, was to be atheistical and traitorous, In England, there was scarcely an amount of diff --git a/tests/cache/multipage/__-l__eng__000005_ocr.png__000005_ocr_tess__pdf__txt/pdf.bin b/tests/cache/multipage/__-l__eng__000005_ocr.png__000005_ocr_tess__pdf__txt/pdf.bin index d1ce8af7..62de774a 100644 Binary files a/tests/cache/multipage/__-l__eng__000005_ocr.png__000005_ocr_tess__pdf__txt/pdf.bin and b/tests/cache/multipage/__-l__eng__000005_ocr.png__000005_ocr_tess__pdf__txt/pdf.bin differ diff --git a/tests/cache/multipage/__-l__eng__000005_ocr.png__000005_ocr_tess__pdf__txt/txt.bin b/tests/cache/multipage/__-l__eng__000005_ocr.png__000005_ocr_tess__pdf__txt/txt.bin index 17ecb68b..84e93a94 100644 --- a/tests/cache/multipage/__-l__eng__000005_ocr.png__000005_ocr_tess__pdf__txt/txt.bin +++ b/tests/cache/multipage/__-l__eng__000005_ocr.png__000005_ocr_tess__pdf__txt/txt.bin @@ -3,12 +3,12 @@ there were a king with a large jaw and a queen with a fair face, on the throne of France. In both countries it was clearer than crystal to the lords of the State preserves of loaves and fishes, that -things in general were settled for ever. +things in general were settled for ever It was the year of Our Lord one thousand seven hundred and seventy-five. Spiritual reve- lations were conceded to England at that -favoured period, as at this, Mrs. Southcott had +favoured period, as at this. Mrs. Southeott had recently attained her five-and-twentieth blessed birthday, of whom a prophetic private in the Life Guards had heralded the sublime appearance by @@ -31,14 +31,14 @@ ters spiritual than her sister of the shield and tri- dent, rolled with exceeding smoothness down hill, making paper money and spending it. Under the guidance of her Christian pastors, she enter- -tained herself, besides, with such humane +tamed herself. besides, with such humane achievements as sentencing a youth to have his hands cut off, his tongue torn out with pincers, and his body burned alive, because he had not kneeled down in the rain to do honour to a dirty -Procession of monks which passed within his -view, at a distance of some fifty or sixty yards. It +Procession of monks which Passed within his +view, at a distance of some fifty or sixty yards, It is likely enough that, rooted in the woods of France and Norway, there were growing trees, when that sufferer was put to death, already @@ -56,7 +56,7 @@ tumbrils of the Revolution. But that Woodman and that Farmer, though they work unceasingly, work silently, and no one heard them as they went about with muffled tread: the rather, foras- -much as to entertain any Suspicion that they +much as to entertain any suspicion that they were awake, was to be atheistical and traitorous. In England, there was scarcely an amount of @@ -65,6 +65,6 @@ boasting. Daring burglaries by armed men, and highway robberies, took place in the capital itself every night; families were publicly cau- tioned not to go out of town without removing -their furniture to upholsterers' warehouses for +their furniture to upholsterers’ warehouses for security; the highwayman in the dark was a City tradesman in the light, and, being recognised and diff --git a/tests/cache/multipage/__-l__eng__000006_ocr.png__000006_ocr_hocr__hocr__txt/hocr.bin b/tests/cache/multipage/__-l__eng__000006_ocr.png__000006_ocr_hocr__hocr__txt/hocr.bin index f58715d1..9d59668a 100644 --- a/tests/cache/multipage/__-l__eng__000006_ocr.png__000006_ocr_hocr__hocr__txt/hocr.bin +++ b/tests/cache/multipage/__-l__eng__000006_ocr.png__000006_ocr_hocr__hocr__txt/hocr.bin @@ -5,694 +5,694 @@ - - + + -

+

with a - plain - face, + plain + face, on the - throne - of - England; + throne + of + England; there were a - king + king with - a - large - jaw - and - a - queen + a + large + jaw + and + a + queen with a fair face, - on + on the throne of - France. - In - both + France. + In + both - + countries it - was - clearer - than + was + clearer + than crystal - to + to the - lords + lords of the State preserves - of - loaves - and - fishes, + of + loaves + and + fishes, that things in general - were + were settled - for - ever. + for + ever.

-
-

+

+

- It + It was the year - of + of Our - Lord - one - thousand + Lord + one + thousand seven hundred and - seventy-five. + seventy-five. Spiritual reve- - lations - were - conceded - to - England - at - that + lations + were + conceded + to + England + at + that - - favoured + + favoured period, - as + as at - this. - Mrs. - Southcott - had + this. + Mrs. + Southcott + had - - recently - attained - her - five-and-twentieth - blessed + + recently + attained + her + five-and-twentieth + blessed - + birthday, - of - whom + of + whom a - prophetic - private - in - the - Life + prophetic + private + in + the + Life - - Guards - had - heralded - the - sublime + + Guards + had + heralded + the + sublime appearance - by + by - - announcing - that - arrangements - were - made - for - the + + announcing + that + arrangements + were + made + for + the - - swallowing - up - of - London - and - Westminster. + + swallowing + up + of + London + and + Westminster. - - Even - the - Cock-lane - ghost - had - been - laid - only - a + + Even + the + Cock-lane + ghost + had + been + laid + only + a - - round - dozen - of - years, - after - rapping - out - its - mes- + + round + dozen + of + years, + after + rapping + out + its + mes- - - sages, - as - the - spirits - of - this - very - year - last - past + + sages, + as + the + spirits + of + this + very + year + last + past - - (supernaturally - deficient - in - originality) - rapped + + (supernaturally + deficient + in + originality) + rapped - - out - theirs. - Mere - messages - in - the - earthly - order - of + + out + theirs. + Mere + messages + in + the + earthly + order + of - - events - had - lately - come - to - the - English - Crown - and + + events + had + lately + come + to + the + English + Crown + and - - People, - from - a - congress - of - British - subjects - in + + People, + from + a + congress + of + British + subjects + in - - America: - which, - strange - to - relate, - have - proved + + America: + which, + strange + to + relate, + have + proved - - more - important - to - the - human - race - than - any - com- + + more + important + to + the + human + race + than + any + com- - - munications - yet - received - through - any - of - the + + munications + yet + received + through + any + of + the - - chickens - of - the - Cock-lane - brood. + + chickens + of + the + Cock-lane + brood.

-
-

- - France, - less - favoured - on - the - whole - as - to - mat- +

+

+ + France, + less + favoured + on + the + whole + as + to + mat- - - ters - spiritual - than - her - sister - of - the - shield - and - tri- + + ters + spiritual + than + her + sister + of + the + shield + and + tri- - - dent, - rolled - with - exceeding - smoothness - down + + dent, + rolled + with + exceeding + smoothness + down - - hill, - making - paper - money - and - spending - it. - Under + + hill, + making + paper + money + and + spending + it. + Under - - the - guidance - of - her - Christian - pastors, - she - enter- + + the + guidance + of + her + Christian + pastors, + she + enter- - - tained - herself, - besides, - with - such - humane + + tained + herself, + besides, + with + such + humane - - achievements - as - sentencing - a - youth - to - have - his + + achievements + as + sentencing + a + youth + to + have + his

-
-

- - hands - cut - off, - his - tongue - torn - out - with - pincers, +

+

+ + hands + cut + off, + his + tongue + torn + out + with + pincers, - - and - his + + and + his body - burned - alive, - because - he - had - not + burned + alive, + because + he + had + not - - kneeled - down - in - the + + kneeled + down + in + the rain - to - do - honour - to - a - dirty + to + do + honour + to + a + dirty - - procession + + procession of monks - which - passed - within - his + which + passed + within + his - - view, + + view, at - a - distance - of - some - fifty - or - sixty - yards. - It + a + distance + of + some + fifty + or + sixty + yards. + It - - is + + is likely - enough - that, - rooted - in - the - woods - of + enough + that, + rooted + in + the + woods + of - - France + + France and - Norway, - there - were - growing - trees, + Norway, + there + were + growing + trees, - - when - that - sufferer - was - put - to - death, - already + + when + that + sufferer + was + put + to + death, + already - - marked - by + + marked + by the - Woodman, - Fate, - to - come - down + Woodman, + Fate, + to + come + down - - and - be - sawn - into - boards, - to - make - a - certain - mov- + + and + be + sawn + into + boards, + to + make + a + certain + mov- - - able - framework + + able + framework with - a - sack - and - a - knife - in - it, - ter- + a + sack + and + a + knife + in + it, + ter- - - tible - in - history. - It - is + + rible + in + history. + It + is likely - enough - that - in - the + enough + that + in + the - - rough - outhouses - of - some - tillers - of - the - heavy + + rough + outhouses + of + some + tillers + of + the + heavy - - lands - adjacent - to - Paris, - there - were - sheltered + + lands + adjacent + to + Paris, + there + were + sheltered - - from - the - weather - that - very - day, - rude - carts, + + from + the + weather + that + very + day, + rude + carts, - - bespattered - with - rustic - mire, - snuffed - about - by + + bespattered + with + rustic + mire, + snuffed + about + by - - pigs, - and - roosted - in - by - poultry, - which - the + + pigs, + and + roosted + in + by + poultry, + which + the - - Farmer, - Death, - had - already - set - apart - to - be - his + + Farmer, + Death, + had + already + set + apart + to + be + his - - tumbrils - of - the - Revolution. - But - that - Woodman + + tumbrils + of + the + Revolution. + But + that + Woodman - - and - that - Farmer, - though - they - work - unceasingly, + + and + that + Farmer, + though + they + work + unceasingly, - - work - silently, - and - no - one - heard - them - as - they + + work + silently, + and + no + one + heard + them + as + they - - went - about - with - muffled - tread: - the - rather, - foras- + + went + about + with + muffled + tread: + the + rather, + foras- - - much - as - to - entertain - any - suspicion - that - they + + much + as + to + entertain + any + suspicion + that + they - - were - awake, - was - to - be - atheistical - and - traitorous. + + were + awake, + was + to + be + atheistical + and + traitorous.

-
-

- - In - England, - there - was - scarcely - an - amount - of +

+

+ + In + England, + there + was + scarcely + an + amount + of - - order - and - protection - to - justify - much - national + + order + and + protection + to + justify + much + national - - boasting. - Daring - burglaries - by - armed - men, - and + + boasting. + Daring + burglaries + by + armed + men, + and - - highway - robberies, - took - place - in - the - capital + + highway + robberies, + took + place + in + the + capital - - itself - every - night; - families - were - publicly - cau- + + itself + every + night; + families + were + publicly + cau- - - tioned - not - to - go - out - of - town - without - removing + + tioned + not + to + go + out + of + town + without + removing - - their - furniture - to - upholsterers' - warehouses - for + + their + furniture + to + upholsterers' + warehouses + for - - security; - the - highwayman - in - the - dark - was - a - City + + security; + the + highwayman + in + the + dark + was + a + City - - tradesman - in - the - light, - and, - being - recognised - and + + tradesman + in + the + light, + and, + being + recognised + and

diff --git a/tests/cache/multipage/__-l__eng__000006_ocr.png__000006_ocr_hocr__hocr__txt/txt.bin b/tests/cache/multipage/__-l__eng__000006_ocr.png__000006_ocr_hocr__hocr__txt/txt.bin index 2f108ca4..bb94a4fa 100644 --- a/tests/cache/multipage/__-l__eng__000006_ocr.png__000006_ocr_hocr__hocr__txt/txt.bin +++ b/tests/cache/multipage/__-l__eng__000006_ocr.png__000006_ocr_hocr__hocr__txt/txt.bin @@ -45,7 +45,7 @@ when that sufferer was put to death, already marked by the Woodman, Fate, to come down and be sawn into boards, to make a certain mov- able framework with a sack and a knife in it, ter- -tible in history. It is likely enough that in the +rible in history. It is likely enough that in the rough outhouses of some tillers of the heavy lands adjacent to Paris, there were sheltered from the weather that very day, rude carts, diff --git a/tests/cache/multipage/__-l__eng__000006_ocr.png__000006_ocr_tess__pdf__txt/pdf.bin b/tests/cache/multipage/__-l__eng__000006_ocr.png__000006_ocr_tess__pdf__txt/pdf.bin index 23944daa..897fd6c9 100644 Binary files a/tests/cache/multipage/__-l__eng__000006_ocr.png__000006_ocr_tess__pdf__txt/pdf.bin and b/tests/cache/multipage/__-l__eng__000006_ocr.png__000006_ocr_tess__pdf__txt/pdf.bin differ diff --git a/tests/cache/palette/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin b/tests/cache/palette/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin index f8f4d374..82356ef8 100644 --- a/tests/cache/palette/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin +++ b/tests/cache/palette/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin @@ -5,11 +5,11 @@ - - + + -
+

@@ -21,7 +21,7 @@

- Mugerre + Mugerre

@@ -29,38 +29,43 @@

Angelu - | + |

- Milafranga - Komunikabideak + Milafranga + Komunikabideak

-
-

- +

+

+ BAIONA - ek - + testes +

-
-

- - Basusatri - - egueans:201e1008 - se: +

+

+ + . + Trenbideak + - + + + Basusarri + + tgmsate:20141004 + se:

-
+
diff --git a/tests/cache/palette/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/txt.bin b/tests/cache/palette/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/txt.bin index 44fae8dc..1bbdd4f3 100644 --- a/tests/cache/palette/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/txt.bin +++ b/tests/cache/palette/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/txt.bin @@ -6,7 +6,8 @@ Angelu | Milafranga Komunikabideak -BAIONA ek — +BAIONA testes — -Basusatri — egueans:201e1008 se: +. Trenbideak - +Basusarri — tgmsate:20141004 se: diff --git a/tests/cache/palette/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin b/tests/cache/palette/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin index d54a1673..a1da75b4 100644 Binary files a/tests/cache/palette/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin and b/tests/cache/palette/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin differ diff --git a/tests/cache/palette/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/txt.bin b/tests/cache/palette/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/txt.bin index 44fae8dc..1bbdd4f3 100644 --- a/tests/cache/palette/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/txt.bin +++ b/tests/cache/palette/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/txt.bin @@ -6,7 +6,8 @@ Angelu | Milafranga Komunikabideak -BAIONA ek — +BAIONA testes — -Basusatri — egueans:201e1008 se: +. Trenbideak - +Basusarri — tgmsate:20141004 se: diff --git a/tests/cache/poster/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin b/tests/cache/poster/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin index e56e81c8..ea418674 100644 --- a/tests/cache/poster/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin +++ b/tests/cache/poster/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin @@ -5,1049 +5,12 @@ - - + + -
-
-

- - The - LinnSequencer - - - 32 - Track - MIDI - Sequence - Recorder - -

-
-
-

- - The - LinnSequencer - is - a - state-of-the-art - composition - and - performance - tool - for - the - professional - musician. - It - is - - - extremely - powerful, - yet - amazingly - simple - to - learn - and - use. - It’s - many - remarkable - features - include: - -

-
-
-

- - © - Operation - is - similar - to - multi-track - tape - recorder - with - PLAY, - STOP, - RECORD, - FAST - - - FORWARD, - REWIND, - and - LOCATE - controls. - -

-
-
-

- - e - Each - of - the - 100 - sequences - contains - 32 - simultaneous, - polyphonic - tracks. - Each - track - may - - - be - assigned - to - one - of - 16 - MIDI - channels. - Simultaneously - plays - up - to - 16 - polyphonic - -

-
-
-

- - synthesizers! - -

-
-
-

- - © - Ultra-fast - 3!” - disk - drive - stores - complex - songs - in - seconds - and - holds - over - 110,000 - notes - -

-
-
-

- - per - disk! - -

-
-
-

- - ¢ - One - or - all - tracks - may - be - TRANSPOSED - at - the - touch - of - a - key. - - - e - Exclusive - real-time - ERASE - function - makes - editing - FAST. - - - e - Exclusive - REPEAT - function - automatically - repeats - any - held - notes - at - a - pre-selected - -

-
-
-

- - rhythmic - value. - -

-
-
-

- - ¢ - TIMING - CORRECTION - works - during - playback - and - operates - without - ‘chopping’ - notes. - -

-
-
-

- - ¢ - Optional - SMPTE - time - code - synchronization. - -

-
-
-

- - ¢ - Optional - remote - control. - -

-
-
-

- - Recording - a - Sequence - -

-
-
-

- - To - record - a - sequence, - simply - press - RECORD - and - PLAY, - - - then - play - your - MIDI - keyboard - in - time - to - the - Sequencer’s - - - click - track. - When - the - sequence - loops - back - around - to - bar - 1, - - - you’ - ll - hear - what - you - played—only - all - timing - errors - will - be - - - corrected! - (Timing - correction - may - be - adjusted - or - defeated). - - - Any - additional - notes - played - will - be - added - into - the - track - - - —existing - notes - are - not - erased - while - recording! - -

- -

- - FAST - FORWARD, - REWIND, - and - LOCATE - controls - - - may - be - used - at - any - time - to - quickly - access - any - location - in - - - your - sequence - for - spot-recording. - To - overdub - a - new - part, - - - select - a - different - track - and - start - recording—while - you - - - record, - the - first - track - will - play - in - perfect - sync - (unless - you - - - MUTE - it, - or - SOLO - another - track). - In - this - way, - up - to - 32 - - - tracks - may - be - overdubbed! - All - MIDI - effects - are - recorded - - - including - pitch - bend, - modulation, - velocity, - aftertouch, - - - sustain - pedal, - and - program - changes! - -

-
-
-

- - Editing - -

- -

- - To - erase - a - wrong - note, - simply - hold - ERASE - and - press - - - the - note - to - be - erased - just - before - it - plays - in - the - sequence— - - - when - played - back, - it - will - be - gone. - Notes - may - also - be - - - added, - erased, - or - changed - using - the - SINGLE - STEP - func- - - - tion. - To - overdub - notes - at - specific - points - within - a - sequence, - -

-
-
-
-

- - Additional - Features - -

-
-
-

- - simply - use - LOCATE, - FAST - FORWARD, - or - REWIND - to - - - find - the - desired - bar - number, - then - start - recording. - -

- -

- - The - INSERT/COPY - function - allows - you - to - move - bars - - - from - one - location - to - another—in - the - same - sequence - or - a - - - different - one. - For - example, - you - might - insert - a - copy - of - the - - - first - verse - between - the - second - chorus - and - the - bridge. - - - DELETE - BARS - operates - the - same - way - to - remove - - - unwanted - sections. - -

-
-
-

- - Creating - a - Song - -

-
-
-

- - One - way - to - create - a - song - is - to - record - each - track - all - the - - - way - through - (up - to - 999 - bars). - Another - way - is - to - record - - - each - basic - section - (verse, - chorus, - etc.) - in - individual - - - sequences, - then - use - the - CREATE - SONG - function - to - “chain” - - - them - together. - CREATE - SONG - will - then - automatically - - - copy - all - the - parts - into - a - new - sequence. - If - desired, - you - can - - - even - set - the - last - few - bars - to - repeat - infinitely, - for - a - fadeout. - -

-
-
-

- - Composition - Without - Compromise - -

- -

- - The - technology - you - use - should - never - be - so - complex - that - - - it - interferes - with - the - creative - process. - That’s - precisely - why - - - the - LinnSequencer - is - designed - to - let - you - compose, - record - - - and - edit - while - devoting - your - undivided - attention - to - your - - - music. - See - your - Linn - dealer - today - for - a - demonstration! - -

-
-
-

- - ¢ - Simple, - easy - to - learn - operation—the - 32 - character - LCD - display - clearly - guides - you - through - all - operations. - If - needed, - the - -

-
-
-

- - HELP - button - displays - additional - explanations. - -

-
-
-

- - ® - Non-destructive - recording—existing - notes - are - not - erased - while - recording. - - - © - Two - FOOTSWITCH - INPUTS - may - be - assigned - to - remotely - control - many - of - the - commonly - used - functions, - including - -

-
-
-

- - ERASE, - REPEAT, - PLAY/STOP, - or - LOCATE. - -

-
-
-

- - ¢ - Two - TRIGGER - OUTPUTS - may - be - programmed - to - output - pulses - at - any - selected - note - value. - -

-
-
-

- - @ - Will - sync - to - standard - LinnDrum - or - Linn - 9000 - sync - tone. - -

-
-
-

- - © - Utilizes - ultra - high-speed, - 8 - MHz - 80186 - 16 - bit - computer - internally - for - FAST - operation. - - - ¢ - TEMPO - may - be - specified - in - BEATS-PER-MINUTE - or - FRAMES-PER-BEAT - at - 24, - 25, - or - 30 - frames - per - second, - -

-
-
-

- - (even - drop - frame!) - -

-
-
-

- - ¢ - TEMPO - may - be - entered - numerically, - adjustable - in - tenths - of - a - Beat-Per-Minute - increments, - or - by - tapping - quarter - notes - -

-
-
-

- - on - the - TAP - TEMPO - button. - -

-
-
-

- - ¢ - TEMPO - CHANGES - may - be - programmed - into - a - sequence, - with - smooth - transitions - if - desired. - - - e - Any - TIME - SIGNATURE - may - be - used, - and - may - be - changed - within - a - song. - -

-
-
-

- - linn - - - Linn - Electronics, - Inc. - -

-
-
-

- - 18720 - Oxnard - Street, - Tarzana, - CA - 91356 - - - (818) - 708-8131 - TELEX - #298949 - LINN - UR - -

-
+
+
diff --git a/tests/cache/poster/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/txt.bin b/tests/cache/poster/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/txt.bin index 9289b566..e69de29b 100644 --- a/tests/cache/poster/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/txt.bin +++ b/tests/cache/poster/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/txt.bin @@ -1,118 +0,0 @@ -The LinnSequencer -32 Track MIDI Sequence Recorder - -The LinnSequencer is a state-of-the-art composition and performance tool for the professional musician. It is -extremely powerful, yet amazingly simple to learn and use. It’s many remarkable features include: - -© Operation is similar to multi-track tape recorder with PLAY, STOP, RECORD, FAST -FORWARD, REWIND, and LOCATE controls. - -e Each of the 100 sequences contains 32 simultaneous, polyphonic tracks. Each track may -be assigned to one of 16 MIDI channels. Simultaneously plays up to 16 polyphonic - -synthesizers! - -© Ultra-fast 3!” disk drive stores complex songs in seconds and holds over 110,000 notes - -per disk! - -¢ One or all tracks may be TRANSPOSED at the touch of a key. -e Exclusive real-time ERASE function makes editing FAST. -e Exclusive REPEAT function automatically repeats any held notes at a pre-selected - -rhythmic value. - -¢ TIMING CORRECTION works during playback and operates without ‘chopping’ notes. - -¢ Optional SMPTE time code synchronization. - -¢ Optional remote control. - -Recording a Sequence - -To record a sequence, simply press RECORD and PLAY, -then play your MIDI keyboard in time to the Sequencer’s -click track. When the sequence loops back around to bar 1, -you’ ll hear what you played—only all timing errors will be -corrected! (Timing correction may be adjusted or defeated). -Any additional notes played will be added into the track -—existing notes are not erased while recording! - -FAST FORWARD, REWIND, and LOCATE controls -may be used at any time to quickly access any location in -your sequence for spot-recording. To overdub a new part, -select a different track and start recording—while you -record, the first track will play in perfect sync (unless you -MUTE it, or SOLO another track). In this way, up to 32 -tracks may be overdubbed! All MIDI effects are recorded -including pitch bend, modulation, velocity, aftertouch, -sustain pedal, and program changes! - -Editing - -To erase a wrong note, simply hold ERASE and press -the note to be erased just before it plays in the sequence— -when played back, it will be gone. Notes may also be -added, erased, or changed using the SINGLE STEP func- -tion. To overdub notes at specific points within a sequence, - -Additional Features - -simply use LOCATE, FAST FORWARD, or REWIND to -find the desired bar number, then start recording. - -The INSERT/COPY function allows you to move bars -from one location to another—in the same sequence or a -different one. For example, you might insert a copy of the -first verse between the second chorus and the bridge. -DELETE BARS operates the same way to remove -unwanted sections. - -Creating a Song - -One way to create a song is to record each track all the -way through (up to 999 bars). Another way is to record -each basic section (verse, chorus, etc.) in individual -sequences, then use the CREATE SONG function to “chain” -them together. CREATE SONG will then automatically -copy all the parts into a new sequence. If desired, you can -even set the last few bars to repeat infinitely, for a fadeout. - -Composition Without Compromise - -The technology you use should never be so complex that -it interferes with the creative process. That’s precisely why -the LinnSequencer is designed to let you compose, record -and edit while devoting your undivided attention to your -music. See your Linn dealer today for a demonstration! - -¢ Simple, easy to learn operation—the 32 character LCD display clearly guides you through all operations. If needed, the - -HELP button displays additional explanations. - -® Non-destructive recording—existing notes are not erased while recording. -© Two FOOTSWITCH INPUTS may be assigned to remotely control many of the commonly used functions, including - -ERASE, REPEAT, PLAY/STOP, or LOCATE. - -¢ Two TRIGGER OUTPUTS may be programmed to output pulses at any selected note value. - -@ Will sync to standard LinnDrum or Linn 9000 sync tone. - -© Utilizes ultra high-speed, 8 MHz 80186 16 bit computer internally for FAST operation. -¢ TEMPO may be specified in BEATS-PER-MINUTE or FRAMES-PER-BEAT at 24, 25, or 30 frames per second, - -(even drop frame!) - -¢ TEMPO may be entered numerically, adjustable in tenths of a Beat-Per-Minute increments, or by tapping quarter notes - -on the TAP TEMPO button. - -¢ TEMPO CHANGES may be programmed into a sequence, with smooth transitions if desired. -e Any TIME SIGNATURE may be used, and may be changed within a song. - -linn -Linn Electronics, Inc. - -18720 Oxnard Street, Tarzana, CA 91356 -(818) 708-8131 TELEX #298949 LINN UR diff --git a/tests/cache/poster/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin b/tests/cache/poster/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin deleted file mode 100644 index 9c8246f7..00000000 Binary files a/tests/cache/poster/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin and /dev/null differ diff --git a/tests/cache/poster/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/stderr.bin b/tests/cache/poster/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/stderr.bin deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/cache/poster/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/stdout.bin b/tests/cache/poster/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/stdout.bin deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/cache/poster/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/txt.bin b/tests/cache/poster/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/txt.bin deleted file mode 100644 index 9289b566..00000000 --- a/tests/cache/poster/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/txt.bin +++ /dev/null @@ -1,118 +0,0 @@ -The LinnSequencer -32 Track MIDI Sequence Recorder - -The LinnSequencer is a state-of-the-art composition and performance tool for the professional musician. It is -extremely powerful, yet amazingly simple to learn and use. It’s many remarkable features include: - -© Operation is similar to multi-track tape recorder with PLAY, STOP, RECORD, FAST -FORWARD, REWIND, and LOCATE controls. - -e Each of the 100 sequences contains 32 simultaneous, polyphonic tracks. Each track may -be assigned to one of 16 MIDI channels. Simultaneously plays up to 16 polyphonic - -synthesizers! - -© Ultra-fast 3!” disk drive stores complex songs in seconds and holds over 110,000 notes - -per disk! - -¢ One or all tracks may be TRANSPOSED at the touch of a key. -e Exclusive real-time ERASE function makes editing FAST. -e Exclusive REPEAT function automatically repeats any held notes at a pre-selected - -rhythmic value. - -¢ TIMING CORRECTION works during playback and operates without ‘chopping’ notes. - -¢ Optional SMPTE time code synchronization. - -¢ Optional remote control. - -Recording a Sequence - -To record a sequence, simply press RECORD and PLAY, -then play your MIDI keyboard in time to the Sequencer’s -click track. When the sequence loops back around to bar 1, -you’ ll hear what you played—only all timing errors will be -corrected! (Timing correction may be adjusted or defeated). -Any additional notes played will be added into the track -—existing notes are not erased while recording! - -FAST FORWARD, REWIND, and LOCATE controls -may be used at any time to quickly access any location in -your sequence for spot-recording. To overdub a new part, -select a different track and start recording—while you -record, the first track will play in perfect sync (unless you -MUTE it, or SOLO another track). In this way, up to 32 -tracks may be overdubbed! All MIDI effects are recorded -including pitch bend, modulation, velocity, aftertouch, -sustain pedal, and program changes! - -Editing - -To erase a wrong note, simply hold ERASE and press -the note to be erased just before it plays in the sequence— -when played back, it will be gone. Notes may also be -added, erased, or changed using the SINGLE STEP func- -tion. To overdub notes at specific points within a sequence, - -Additional Features - -simply use LOCATE, FAST FORWARD, or REWIND to -find the desired bar number, then start recording. - -The INSERT/COPY function allows you to move bars -from one location to another—in the same sequence or a -different one. For example, you might insert a copy of the -first verse between the second chorus and the bridge. -DELETE BARS operates the same way to remove -unwanted sections. - -Creating a Song - -One way to create a song is to record each track all the -way through (up to 999 bars). Another way is to record -each basic section (verse, chorus, etc.) in individual -sequences, then use the CREATE SONG function to “chain” -them together. CREATE SONG will then automatically -copy all the parts into a new sequence. If desired, you can -even set the last few bars to repeat infinitely, for a fadeout. - -Composition Without Compromise - -The technology you use should never be so complex that -it interferes with the creative process. That’s precisely why -the LinnSequencer is designed to let you compose, record -and edit while devoting your undivided attention to your -music. See your Linn dealer today for a demonstration! - -¢ Simple, easy to learn operation—the 32 character LCD display clearly guides you through all operations. If needed, the - -HELP button displays additional explanations. - -® Non-destructive recording—existing notes are not erased while recording. -© Two FOOTSWITCH INPUTS may be assigned to remotely control many of the commonly used functions, including - -ERASE, REPEAT, PLAY/STOP, or LOCATE. - -¢ Two TRIGGER OUTPUTS may be programmed to output pulses at any selected note value. - -@ Will sync to standard LinnDrum or Linn 9000 sync tone. - -© Utilizes ultra high-speed, 8 MHz 80186 16 bit computer internally for FAST operation. -¢ TEMPO may be specified in BEATS-PER-MINUTE or FRAMES-PER-BEAT at 24, 25, or 30 frames per second, - -(even drop frame!) - -¢ TEMPO may be entered numerically, adjustable in tenths of a Beat-Per-Minute increments, or by tapping quarter notes - -on the TAP TEMPO button. - -¢ TEMPO CHANGES may be programmed into a sequence, with smooth transitions if desired. -e Any TIME SIGNATURE may be used, and may be changed within a song. - -linn -Linn Electronics, Inc. - -18720 Oxnard Street, Tarzana, CA 91356 -(818) 708-8131 TELEX #298949 LINN UR diff --git a/tests/cache/poster/__-l__osd__--psm__0__000001_rasterize_preview.jpg__stdout/stderr.bin b/tests/cache/poster/__-l__osd__--psm__0__000001_rasterize_preview.jpg__stdout/stderr.bin deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/cache/poster/__-l__osd__--psm__0__000001_rasterize_preview.jpg__stdout/stdout.bin b/tests/cache/poster/__-l__osd__--psm__0__000001_rasterize_preview.jpg__stdout/stdout.bin deleted file mode 100644 index 9e15c7c3..00000000 --- a/tests/cache/poster/__-l__osd__--psm__0__000001_rasterize_preview.jpg__stdout/stdout.bin +++ /dev/null @@ -1,6 +0,0 @@ -Page number: 0 -Orientation in degrees: 0 -Rotate: 0 -Orientation confidence: 28.48 -Script: Latin -Script confidence: 5.24 diff --git a/tests/cache/skew/__-l__eng__--psm__7__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin b/tests/cache/skew/__-l__eng__--psm__7__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin index c113bc8f..db63a7f7 100644 --- a/tests/cache/skew/__-l__eng__--psm__7__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin +++ b/tests/cache/skew/__-l__eng__--psm__7__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin @@ -5,11 +5,11 @@ - - + + -
+
diff --git a/tests/cache/skew/__-l__eng__--psm__7__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin b/tests/cache/skew/__-l__eng__--psm__7__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin index 6b05fa95..81699447 100644 Binary files a/tests/cache/skew/__-l__eng__--psm__7__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin and b/tests/cache/skew/__-l__eng__--psm__7__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin differ diff --git a/tests/cache/skew/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin b/tests/cache/skew/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin index 441145bb..1cb372ee 100644 --- a/tests/cache/skew/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin +++ b/tests/cache/skew/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin @@ -5,964 +5,1069 @@ - - + + -
-
-

- - The - LinnSequencer - - - 32 - Track - MIDI - Sequence - Recorder +

+
+

+ + The + LinnSequencer

-
-

- - The - LinnSequencer - is - a - state-of-the-art - composition - and - performance - tool - for - the - professional - musician. - It - is - - - extremely - powerful, - yet - amazingly - simple - to - learn - and - use. - It - ’s - many - remarkable - features - include: +

+

+ + 32 + Track + MIDIS

-
-

- - * - Operation - is - similar - to - multi-track - tape - recorder - with - PLAY, - STOP, - RECORD, - FAST - - - FORWARD, - REWIND, - and - LOCATE - controls, +

+

+ + equence + Recorder

-
-

- - © - Each - of - the - 100 - sequences - contains - 32 - simultaneous, - polyphonic - tracks. - Each - track - may - - - be - assigned - to - one - of - 16 - MIDI - channels. - Simultaneously - plays - up - to - 16 - polyphonic - - - synthesizers! - -

-
-
-

- - * - Ultra-fast - 314" - disk - drive - stores - complex - songs - in - seconds - and - holds - over - 110,000 - notes - - - per - disk! - -

-
-
-

- - * - One - or - all - tracks - may - be - TRANSPOSED - at - the - touch - of - a - key. - - - Exclusive - real-time - ERASE - function - makes - editing - FAST, - -

-
-
-

- - * - Exclusive - REPEAT - function - automatically - repeats - any - held - notes - at - a - pre-selected - - - rhythmic - value. - -

-
-
-

- - * - TIMING - CORRECTION - works - during - playback - and - operates - without - ‘chopping’ - notes, - - - ¢ - Optional - SMPTE - time - code - synchronization. - - - * - Optional - remote - control. - -

-
-
-

- - Recording - a - Sequence - simply - use - LOCATE, - FAST - FORWARD, - or - REWIND - to - - - To - record - a - sequence, - simply - press - RECORD - and - PL - AY, - _ - find - the - desired - bar - number, - then - Start - recording. - - - then - play - your - MIDI - keyboard - in - time - to - the - Sequencer’s - The - INSERT/COPY - function - allows - you - to - move - bars - -

-
-
-

- - click - track, - When - the - sequence - loops - back - around - to - bar - 1, - from - one - location - to - another—in - the - same - sequence - or - a - - - you'll - hear - what - you - played—only - all - timing - errors - will - be - _different - one. - For - example, - you - might - insert - a - copy - of - the - - - corrected! - (Timing - correction - may - be - adjusted - or - defeated), - _ - first - verse - between - the - second - chorus - and - the - bridge. - - - Any - additional - notes - played - will - be - added - into - the - track - DELETE - BARS - operates - the - same - way - to - remove - - - —existing - notes - are - not - erased - while - recording! - unwanted - sections, +

+

+ + The + LinnSequencer + is + a + state-of-the-art + composition + and + performance + tool + for + the + professional + musician. + It + is

-

- - FAST - FORWARD, - REWIND, - and - LOCATE - controls - . - - - may - be - used - at - any - time - to - quickly - access - any - location - in - Creating - a - Song - -

-
-
-

- - your - sequence - for - spot-recording. - To - overdub - a - new - part, - One - way - to - create - a - song - is - to - record - each - track - all - the - - - select - a - different - track - and - start - recording—while - you - way - through - (up - to - 999 - bars), - Another - way - is - to - record - - - record, - the - first - track - will - play - in - perfect - sync - (unless - you - each - basic - section - (verse, - chorus, - etc.) - in - individual - -

-
-
-

- - MUTE - it, - or - SOLO - another - track). - In - this - way, - up - to - 32 - sequences, - then - use - the - CREATE - SONG - function - to - “chain” - - - tracks - may - be - overdubbed! - All - MIDI - effects - are - recorded - them - together. - CREATE - SONG - will - then - automatically - -

-
-
-

- - including - pitch - bend, - modulation, - velocity, - aftertouch, - copy - all - the - parts - into - a - new - sequence. - If - desir - ed, - you - can - - - sustain - pedal, - and - program - changes! - even - set - the - last - few - bars - to - repeat - infinitely, - for - a - fadeout. - - - Editing - Composition - Without - Compromise +

+ + extremely + powerful, + yet + amazingly + simple + to + learn + and + use. + It + ’s + many + remarkable + features + include:

-

- - To - erase - a - wrong - note, - simply - hold - ERASE - and - press - The - technology - you - use - should - never - be - so - complex - that +

+ + * + Operation + is + similar + to + multi-track + tape + recorder + with + PLAY, + STOP, + RECORD, + FAST - - the - note - to - be - erased - just - before - it - plays - in - the - sequence— - it - interferes - with - the - creative - process. - That’s - precisely - why - - - when - played - back, - it - will - be - gone. - Notes - may - also - be - the - LinnSequencer - is - designed - to - let - you - compose, - record + + FORWARD, + REWIND, + and + LOCATE + controls.

-
-

- - added, - erased, - or - changed - using - the - SINGLE - STEP - func- - and - edit - while - devoting - your - undivided - attention - to - your +

+

+ + ¢ + Each + of + the + 100 + sequences + contains + 32 + simultaneous, + polyphonic + tracks. + Each + track + may - - tion. - To - overdub - notes - at - specific - points - within - a - Sequence, - music. - See - your - Linn - dealer - today - for - a - demonstration! + + be + assigned + to + one + of + 16 + MIDI + channels. + Simultaneously + plays + up + to + 16 + polyphonic

-
-

- - Additional - Features +

+

+ + synthesizers!

-
-

- - * - Simple, - easy - to - learn - operation—the - 32 - character - LCD - display - clearly - guides - you - through - all - operations. - If - needed, - the - - - HELP - button - displays - additional - explanations, +

+

+ + * + Ultra-fast + 314” + disk + drive + stores + complex + songs + in + seconds + and + holds + over + 110,000 + notes

-
-

- - * - Non-destructive - recording—existing - notes - are - not - erased - while - recording. +

+

+ + per + disk!

-
-

- - * - Two - FOOTSWITCH - INPUTS - may - be - assigned - to - remotely - control - many - of - the - commonly - used - functions, - including +

+

+ + ® + One + or + all + tracks + may + be + TRANSPOSED + at + the + touch + of + a + key. - - ERASE, - REPEAT, - PLAY/ - STOP, - or - LOCATE, + + ¢ + Exclusive + real-time + ERASE + function + makes + editing + FAST. + + + ¢ + Exclusive + REPEAT + function + automatically + repeats + any + held + notes + at + a + pre-selected

-
-

- - * - Two - TRIGGER - OUTPUTS - may - be - Programmed - to - output - pulses - at - any - selected - note - value. - - - © - Will - sync - to - standard - LinnDrum - or - Linn - 9000 - sync - tone, - - - * - Utilizes - ultra - high-speed, - 8 - MHz - 80186 - 16 - bit - computer - internally - for - FAST - operation. +

+

+ + rhythmic + value.

-
-

- - * - TEMPO - may - be - specified - in - BEATS-PER-MINUTE - or - FRAMES-PER-BEAT - at - 24, - 25, - or - 30 - frames - per - second, - - - (even - drop - frame!) +

+

+ + ¢ + TIMING + CORRECTION + works + during + playback + and + Operates + without + ‘chopping’ + notes.

-
-

- - * - TEMPO - may - be - entered - numerically, - adjustable - in - tenths - of - a - Beat-Per-Minute - increments, - or - by - tapping - quarter - notes - - - on - the - TAP - TEMPO - button. +

+

+ + ¢ + Optional + SMPTE + time + code + synchronization.

-
-

- - * - TEMPO - CHANGES - may - be - programmed - into - a - sequence, - with - smooth - transitions - if - desired. +

+

+ + * + Optional + remote + control.

-
-

- - « - Any - TIME - SIGNATURE - may - be - used, - and - may - be - changed - within - a - song, +

+

+ + Recording + a + Sequence - - linn +

+ +

+ + To + record + a + sequence, + simply + press + RECORD + and + PLAY, + + + then + play + your + MIDI + keyboard + in + time + to + the + Sequencer’s + + + click + track. + When + the + sequence + loops + back + around + to + bar + 1, + + + you'll + hear + what + you + played—only + all + timing + errors + will + be + + + corrected! + (Timing + correction + may + be + adjusted + or + defeated). + + + Any + additional + notes + played + will + be + added + into + the + track + + + —existing + notes + are + not + erased + while + recording! + +

+ +

+ + FAST + FORWARD, + REWIND, + and + LOCATE + controls + + + may + be + used + at + any + time + to + quickly + access + any + location + in + + + your + sequence + for + spot-recording. + To + overdub + a + new + part, + + + select + a + different + track + and + start + recording—while + you + + + record, + the + first + track + will + play + in + perfect + sync + (unless + you + + + MUTE + it, + or + SOLO + another + track). + In + this + way, + up + to + 32 + + + tracks + may + be + overdubbed! + All + MIDI + effects + are + recorded + + + including + pitch + bend, + modulation, + velocity, + aftertouch, + + + sustain + pedal, + and + program + changes!

-
-

- - Linn - Electronics, - Inc. +

+

+ + Editing - - 18720 - Oxnard - Street, - Tarzana, - CA - 91356 +

+ +

+ + To + erase + a + wrong + note, + simply + hold + ERASE + and + press - - (818) - 708-8131 - TELEX - #298949 - LINN - UR + + the + note + to + be + erased + just + before + it + plays + in + the + sequence— + + + when + played + back, + it + will + be + gone. + Notes + may + also + be + +

+
+
+

+ + added, + erased, + or + changed + using + the + SINGLE + STEP + func- + + + tion. + To + overdub + notes + at + specific + points + within + a + sequence, + +

+
+
+

+ + Additional + Features + +

+
+
+

+ + simply + use + LOCATE, + FAST + FORWARD, + or + REWIND + to + + + find + the + desired + bar + number, + then + start + recording, + +

+ +

+ + The + INSERT/COPY + function + allows + you + to + move + bars + + + from + one + location + to + another—in + the + same + sequence + or + a + + + different + one. + For + example, + you + might + insert + a + copy + of + the + + + first + verse + between + the + second + chorus + and + the + bridge. + + + DELETE + BARS + Operates + the + same + way + to + remove + + + unwanted + sections, + +

+
+
+

+ + Creating + a + Song + +

+ +

+ + One + way + to + create + a + song + is + to + record + each + track + all + the + + + way + through + (up + to + 999 + bars). + Another + way + is + to + record + + + each + basic + section + (verse, + chorus, + etc.) + in + individual + + + sequences, + then + use + the + CREATE + SONG + function + to + “chain” + + + them + together. + CREATE + SONG + will + then + automatically + + + copy + all + the + parts + into + a + new + sequence. + If + desired, + you + can + + + even + set + the + last + few + bars + to + repeat + infinitely, + for + a + fadeout. + +

+
+
+

+ + Composition + Without + Compromise + +

+ +

+ + The + technology + you + use + should + never + be + so + complex + that + + + it + interferes + with + the + creative + process. + That’s + precisely + why + + + the + LinnSequencer + is + designed + to + let + you + compose, + record + + + and + edit + while + devoting + your + undivided + attention + to + your + + + music. + See + your + Linn + dealer + today + for + a + demonstration! + +

+
+
+

+ + * + Simple, + easy + to + learn + operation—the + 32 + character + LCD + display + clearly + guides + you + through + all + operations. + If + needed, + the + +

+
+
+

+ + HELP + button + displays + additional + explanations. + +

+
+
+

+ + * + Non-destructive + recording—existing + notes + are + not + erased + while + recording. + + + * + Two + FOOTSWITCH + INPUTS + may + be + assigned + to + remotely + control + many + of + the + commonly + used + functions, + including + +

+
+
+

+ + ERASE, + REPEAT, + PLAY/ + STOP, + or + LOCATE. + +

+
+
+

+ + * + Two + TRIGGER + OUTPUTS + may + be + programmed + to + Output + pulses + at + any + selected + note + value. + +

+
+
+

+ + © + Will + sync + to + standard + LinnDrum + or + Linn + 9000 + sync + tone. + +

+
+
+

+ + * + Utilizes + ultra + high-speed, + 8 + MHz + 80186 + 16 + bit + computer + internally + for + EAST + Operation. + + + ¢ + TEMPO + may + be + specified + in + BEATS-PER-MINUTE + or + FRAMES-PER-BEAT + at + 24, + 25, + or + 30 + frames + per + second, + +

+
+
+

+ + (even + drop + frame!) + +

+
+
+

+ + * + TEMPO + may + be + entered + numerically, + adjustable + in + tenths + of + a + Beat-Per-Minute + increments, + or + by + tapping + quarter + notes + +

+
+
+

+ + on + the + TAP + TEMPO + button. + +

+
+
+

+ + ¢ + TEMPO + CHANGES + may + be + programmed + into + a + sequence, + + + ¢ + Any + TIME + SIGNATURE + may + be + used, + and + may + be + change + +

+
+
+

+ + with + smooth + transitions + if + desired. + + + d + within + a + song. + +

+
+
+

+ + linn + + + Linn + Electronics, + Inc. + +

+
+
+

+ + 18720 + Oxnard + Street, + Tarzana, + CA + 91356 + + + (818) + 708-8131 + TELEX + #298949 + LINN + UR

diff --git a/tests/cache/skew/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/txt.bin b/tests/cache/skew/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/txt.bin index bb5d0c7c..1f368cec 100644 --- a/tests/cache/skew/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/txt.bin +++ b/tests/cache/skew/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/txt.bin @@ -1,85 +1,126 @@ The LinnSequencer -32 Track MIDI Sequence Recorder + +32 Track MIDIS + +equence Recorder The LinnSequencer is a state-of-the-art composition and performance tool for the professional musician. It is + extremely powerful, yet amazingly simple to learn and use. It ’s many remarkable features include: * Operation is similar to multi-track tape recorder with PLAY, STOP, RECORD, FAST -FORWARD, REWIND, and LOCATE controls, +FORWARD, REWIND, and LOCATE controls. -© Each of the 100 sequences contains 32 simultaneous, polyphonic tracks. Each track may +¢ Each of the 100 sequences contains 32 simultaneous, polyphonic tracks. Each track may be assigned to one of 16 MIDI channels. Simultaneously plays up to 16 polyphonic + synthesizers! -* Ultra-fast 314" disk drive stores complex songs in seconds and holds over 110,000 notes +* Ultra-fast 314” disk drive stores complex songs in seconds and holds over 110,000 notes + per disk! -* One or all tracks may be TRANSPOSED at the touch of a key. -Exclusive real-time ERASE function makes editing FAST, +® One or all tracks may be TRANSPOSED at the touch of a key. +¢ Exclusive real-time ERASE function makes editing FAST. +¢ Exclusive REPEAT function automatically repeats any held notes at a pre-selected -* Exclusive REPEAT function automatically repeats any held notes at a pre-selected rhythmic value. -* TIMING CORRECTION works during playback and operates without ‘chopping’ notes, +¢ TIMING CORRECTION works during playback and Operates without ‘chopping’ notes. + ¢ Optional SMPTE time code synchronization. + * Optional remote control. -Recording a Sequence simply use LOCATE, FAST FORWARD, or REWIND to -To record a sequence, simply press RECORD and PL AY, _ find the desired bar number, then Start recording. -then play your MIDI keyboard in time to the Sequencer’s The INSERT/COPY function allows you to move bars +Recording a Sequence -click track, When the sequence loops back around to bar 1, from one location to another—in the same sequence or a -you'll hear what you played—only all timing errors will be _different one. For example, you might insert a copy of the -corrected! (Timing correction may be adjusted or defeated), _ first verse between the second chorus and the bridge. -Any additional notes played will be added into the track DELETE BARS operates the same way to remove -—existing notes are not erased while recording! unwanted sections, +To record a sequence, simply press RECORD and PLAY, +then play your MIDI keyboard in time to the Sequencer’s +click track. When the sequence loops back around to bar 1, +you'll hear what you played—only all timing errors will be +corrected! (Timing correction may be adjusted or defeated). +Any additional notes played will be added into the track +—existing notes are not erased while recording! -FAST FORWARD, REWIND, and LOCATE controls . -may be used at any time to quickly access any location in Creating a Song +FAST FORWARD, REWIND, and LOCATE controls +may be used at any time to quickly access any location in +your sequence for spot-recording. To overdub a new part, +select a different track and start recording—while you +record, the first track will play in perfect sync (unless you +MUTE it, or SOLO another track). In this way, up to 32 +tracks may be overdubbed! All MIDI effects are recorded +including pitch bend, modulation, velocity, aftertouch, +sustain pedal, and program changes! -your sequence for spot-recording. To overdub a new part, One way to create a song is to record each track all the -select a different track and start recording—while you way through (up to 999 bars), Another way is to record -record, the first track will play in perfect sync (unless you each basic section (verse, chorus, etc.) in individual +Editing -MUTE it, or SOLO another track). In this way, up to 32 sequences, then use the CREATE SONG function to “chain” -tracks may be overdubbed! All MIDI effects are recorded them together. CREATE SONG will then automatically +To erase a wrong note, simply hold ERASE and press +the note to be erased just before it plays in the sequence— +when played back, it will be gone. Notes may also be -including pitch bend, modulation, velocity, aftertouch, copy all the parts into a new sequence. If desir ed, you can -sustain pedal, and program changes! even set the last few bars to repeat infinitely, for a fadeout. -Editing Composition Without Compromise - -To erase a wrong note, simply hold ERASE and press The technology you use should never be so complex that -the note to be erased just before it plays in the sequence— it interferes with the creative process. That’s precisely why -when played back, it will be gone. Notes may also be the LinnSequencer is designed to let you compose, record - -added, erased, or changed using the SINGLE STEP func- and edit while devoting your undivided attention to your -tion. To overdub notes at specific points within a Sequence, music. See your Linn dealer today for a demonstration! +added, erased, or changed using the SINGLE STEP func- +tion. To overdub notes at specific points within a sequence, Additional Features +simply use LOCATE, FAST FORWARD, or REWIND to +find the desired bar number, then start recording, + +The INSERT/COPY function allows you to move bars +from one location to another—in the same sequence or a +different one. For example, you might insert a copy of the +first verse between the second chorus and the bridge. +DELETE BARS Operates the same way to remove +unwanted sections, + +Creating a Song + +One way to create a song is to record each track all the +way through (up to 999 bars). Another way is to record +each basic section (verse, chorus, etc.) in individual +sequences, then use the CREATE SONG function to “chain” +them together. CREATE SONG will then automatically +copy all the parts into a new sequence. If desired, you can +even set the last few bars to repeat infinitely, for a fadeout. + +Composition Without Compromise + +The technology you use should never be so complex that +it interferes with the creative process. That’s precisely why +the LinnSequencer is designed to let you compose, record +and edit while devoting your undivided attention to your +music. See your Linn dealer today for a demonstration! + * Simple, easy to learn operation—the 32 character LCD display clearly guides you through all operations. If needed, the -HELP button displays additional explanations, + +HELP button displays additional explanations. * Non-destructive recording—existing notes are not erased while recording. - * Two FOOTSWITCH INPUTS may be assigned to remotely control many of the commonly used functions, including -ERASE, REPEAT, PLAY/ STOP, or LOCATE, -* Two TRIGGER OUTPUTS may be Programmed to output pulses at any selected note value. -© Will sync to standard LinnDrum or Linn 9000 sync tone, -* Utilizes ultra high-speed, 8 MHz 80186 16 bit computer internally for FAST operation. +ERASE, REPEAT, PLAY/ STOP, or LOCATE. + +* Two TRIGGER OUTPUTS may be programmed to Output pulses at any selected note value. + +© Will sync to standard LinnDrum or Linn 9000 sync tone. + +* Utilizes ultra high-speed, 8 MHz 80186 16 bit computer internally for EAST Operation. +¢ TEMPO may be specified in BEATS-PER-MINUTE or FRAMES-PER-BEAT at 24, 25, or 30 frames per second, -* TEMPO may be specified in BEATS-PER-MINUTE or FRAMES-PER-BEAT at 24, 25, or 30 frames per second, (even drop frame!) * TEMPO may be entered numerically, adjustable in tenths of a Beat-Per-Minute increments, or by tapping quarter notes + on the TAP TEMPO button. -* TEMPO CHANGES may be programmed into a sequence, with smooth transitions if desired. +¢ TEMPO CHANGES may be programmed into a sequence, +¢ Any TIME SIGNATURE may be used, and may be change + +with smooth transitions if desired. +d within a song. -« Any TIME SIGNATURE may be used, and may be changed within a song, linn - Linn Electronics, Inc. + 18720 Oxnard Street, Tarzana, CA 91356 (818) 708-8131 TELEX #298949 LINN UR diff --git a/tests/cache/skew/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin b/tests/cache/skew/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin index d5bf4172..12155aeb 100644 Binary files a/tests/cache/skew/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin and b/tests/cache/skew/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin differ diff --git a/tests/cache/skew/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/txt.bin b/tests/cache/skew/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/txt.bin index bb5d0c7c..1f368cec 100644 --- a/tests/cache/skew/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/txt.bin +++ b/tests/cache/skew/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/txt.bin @@ -1,85 +1,126 @@ The LinnSequencer -32 Track MIDI Sequence Recorder + +32 Track MIDIS + +equence Recorder The LinnSequencer is a state-of-the-art composition and performance tool for the professional musician. It is + extremely powerful, yet amazingly simple to learn and use. It ’s many remarkable features include: * Operation is similar to multi-track tape recorder with PLAY, STOP, RECORD, FAST -FORWARD, REWIND, and LOCATE controls, +FORWARD, REWIND, and LOCATE controls. -© Each of the 100 sequences contains 32 simultaneous, polyphonic tracks. Each track may +¢ Each of the 100 sequences contains 32 simultaneous, polyphonic tracks. Each track may be assigned to one of 16 MIDI channels. Simultaneously plays up to 16 polyphonic + synthesizers! -* Ultra-fast 314" disk drive stores complex songs in seconds and holds over 110,000 notes +* Ultra-fast 314” disk drive stores complex songs in seconds and holds over 110,000 notes + per disk! -* One or all tracks may be TRANSPOSED at the touch of a key. -Exclusive real-time ERASE function makes editing FAST, +® One or all tracks may be TRANSPOSED at the touch of a key. +¢ Exclusive real-time ERASE function makes editing FAST. +¢ Exclusive REPEAT function automatically repeats any held notes at a pre-selected -* Exclusive REPEAT function automatically repeats any held notes at a pre-selected rhythmic value. -* TIMING CORRECTION works during playback and operates without ‘chopping’ notes, +¢ TIMING CORRECTION works during playback and Operates without ‘chopping’ notes. + ¢ Optional SMPTE time code synchronization. + * Optional remote control. -Recording a Sequence simply use LOCATE, FAST FORWARD, or REWIND to -To record a sequence, simply press RECORD and PL AY, _ find the desired bar number, then Start recording. -then play your MIDI keyboard in time to the Sequencer’s The INSERT/COPY function allows you to move bars +Recording a Sequence -click track, When the sequence loops back around to bar 1, from one location to another—in the same sequence or a -you'll hear what you played—only all timing errors will be _different one. For example, you might insert a copy of the -corrected! (Timing correction may be adjusted or defeated), _ first verse between the second chorus and the bridge. -Any additional notes played will be added into the track DELETE BARS operates the same way to remove -—existing notes are not erased while recording! unwanted sections, +To record a sequence, simply press RECORD and PLAY, +then play your MIDI keyboard in time to the Sequencer’s +click track. When the sequence loops back around to bar 1, +you'll hear what you played—only all timing errors will be +corrected! (Timing correction may be adjusted or defeated). +Any additional notes played will be added into the track +—existing notes are not erased while recording! -FAST FORWARD, REWIND, and LOCATE controls . -may be used at any time to quickly access any location in Creating a Song +FAST FORWARD, REWIND, and LOCATE controls +may be used at any time to quickly access any location in +your sequence for spot-recording. To overdub a new part, +select a different track and start recording—while you +record, the first track will play in perfect sync (unless you +MUTE it, or SOLO another track). In this way, up to 32 +tracks may be overdubbed! All MIDI effects are recorded +including pitch bend, modulation, velocity, aftertouch, +sustain pedal, and program changes! -your sequence for spot-recording. To overdub a new part, One way to create a song is to record each track all the -select a different track and start recording—while you way through (up to 999 bars), Another way is to record -record, the first track will play in perfect sync (unless you each basic section (verse, chorus, etc.) in individual +Editing -MUTE it, or SOLO another track). In this way, up to 32 sequences, then use the CREATE SONG function to “chain” -tracks may be overdubbed! All MIDI effects are recorded them together. CREATE SONG will then automatically +To erase a wrong note, simply hold ERASE and press +the note to be erased just before it plays in the sequence— +when played back, it will be gone. Notes may also be -including pitch bend, modulation, velocity, aftertouch, copy all the parts into a new sequence. If desir ed, you can -sustain pedal, and program changes! even set the last few bars to repeat infinitely, for a fadeout. -Editing Composition Without Compromise - -To erase a wrong note, simply hold ERASE and press The technology you use should never be so complex that -the note to be erased just before it plays in the sequence— it interferes with the creative process. That’s precisely why -when played back, it will be gone. Notes may also be the LinnSequencer is designed to let you compose, record - -added, erased, or changed using the SINGLE STEP func- and edit while devoting your undivided attention to your -tion. To overdub notes at specific points within a Sequence, music. See your Linn dealer today for a demonstration! +added, erased, or changed using the SINGLE STEP func- +tion. To overdub notes at specific points within a sequence, Additional Features +simply use LOCATE, FAST FORWARD, or REWIND to +find the desired bar number, then start recording, + +The INSERT/COPY function allows you to move bars +from one location to another—in the same sequence or a +different one. For example, you might insert a copy of the +first verse between the second chorus and the bridge. +DELETE BARS Operates the same way to remove +unwanted sections, + +Creating a Song + +One way to create a song is to record each track all the +way through (up to 999 bars). Another way is to record +each basic section (verse, chorus, etc.) in individual +sequences, then use the CREATE SONG function to “chain” +them together. CREATE SONG will then automatically +copy all the parts into a new sequence. If desired, you can +even set the last few bars to repeat infinitely, for a fadeout. + +Composition Without Compromise + +The technology you use should never be so complex that +it interferes with the creative process. That’s precisely why +the LinnSequencer is designed to let you compose, record +and edit while devoting your undivided attention to your +music. See your Linn dealer today for a demonstration! + * Simple, easy to learn operation—the 32 character LCD display clearly guides you through all operations. If needed, the -HELP button displays additional explanations, + +HELP button displays additional explanations. * Non-destructive recording—existing notes are not erased while recording. - * Two FOOTSWITCH INPUTS may be assigned to remotely control many of the commonly used functions, including -ERASE, REPEAT, PLAY/ STOP, or LOCATE, -* Two TRIGGER OUTPUTS may be Programmed to output pulses at any selected note value. -© Will sync to standard LinnDrum or Linn 9000 sync tone, -* Utilizes ultra high-speed, 8 MHz 80186 16 bit computer internally for FAST operation. +ERASE, REPEAT, PLAY/ STOP, or LOCATE. + +* Two TRIGGER OUTPUTS may be programmed to Output pulses at any selected note value. + +© Will sync to standard LinnDrum or Linn 9000 sync tone. + +* Utilizes ultra high-speed, 8 MHz 80186 16 bit computer internally for EAST Operation. +¢ TEMPO may be specified in BEATS-PER-MINUTE or FRAMES-PER-BEAT at 24, 25, or 30 frames per second, -* TEMPO may be specified in BEATS-PER-MINUTE or FRAMES-PER-BEAT at 24, 25, or 30 frames per second, (even drop frame!) * TEMPO may be entered numerically, adjustable in tenths of a Beat-Per-Minute increments, or by tapping quarter notes + on the TAP TEMPO button. -* TEMPO CHANGES may be programmed into a sequence, with smooth transitions if desired. +¢ TEMPO CHANGES may be programmed into a sequence, +¢ Any TIME SIGNATURE may be used, and may be change + +with smooth transitions if desired. +d within a song. -« Any TIME SIGNATURE may be used, and may be changed within a song, linn - Linn Electronics, Inc. + 18720 Oxnard Street, Tarzana, CA 91356 (818) 708-8131 TELEX #298949 LINN UR diff --git a/tests/cache/trivial/__-l__eng__--oem__1__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin b/tests/cache/trivial/__-l__eng__--oem__1__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin index 31793c77..66783bf2 100644 --- a/tests/cache/trivial/__-l__eng__--oem__1__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin +++ b/tests/cache/trivial/__-l__eng__--oem__1__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin @@ -5,11 +5,11 @@ - - + + -
+
diff --git a/tests/cache/trivial/__-l__eng__--oem__1__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin b/tests/cache/trivial/__-l__eng__--oem__1__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin deleted file mode 100644 index 53d84381..00000000 Binary files a/tests/cache/trivial/__-l__eng__--oem__1__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin and /dev/null differ diff --git a/tests/cache/trivial/__-l__eng__--oem__1__000001_ocr.png__000001_ocr_tess__pdf__txt/stderr.bin b/tests/cache/trivial/__-l__eng__--oem__1__000001_ocr.png__000001_ocr_tess__pdf__txt/stderr.bin deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/cache/trivial/__-l__eng__--oem__1__000001_ocr.png__000001_ocr_tess__pdf__txt/stdout.bin b/tests/cache/trivial/__-l__eng__--oem__1__000001_ocr.png__000001_ocr_tess__pdf__txt/stdout.bin deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/cache/trivial/__-l__eng__--oem__1__000001_ocr.png__000001_ocr_tess__pdf__txt/txt.bin b/tests/cache/trivial/__-l__eng__--oem__1__000001_ocr.png__000001_ocr_tess__pdf__txt/txt.bin deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/cache/trivial/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin b/tests/cache/trivial/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin index a0b45859..8287da7e 100644 --- a/tests/cache/trivial/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin +++ b/tests/cache/trivial/__-l__eng__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin @@ -5,11 +5,11 @@ - - + + -
+
diff --git a/tests/cache/trivial/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin b/tests/cache/trivial/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin deleted file mode 100644 index 53d84381..00000000 Binary files a/tests/cache/trivial/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin and /dev/null differ diff --git a/tests/cache/trivial/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/stderr.bin b/tests/cache/trivial/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/stderr.bin deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/cache/trivial/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/stdout.bin b/tests/cache/trivial/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/stdout.bin deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/cache/trivial/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/txt.bin b/tests/cache/trivial/__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt/txt.bin deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/cache/trivial/__-l__eng__thresholding_method=1__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin b/tests/cache/trivial/__-l__eng__thresholding_method=1__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin index d337f38d..d16a6a99 100644 --- a/tests/cache/trivial/__-l__eng__thresholding_method=1__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin +++ b/tests/cache/trivial/__-l__eng__thresholding_method=1__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin @@ -5,11 +5,11 @@ - - + + -
+
diff --git a/tests/cache/trivial/__-l__eng__thresholding_method=1__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin b/tests/cache/trivial/__-l__eng__thresholding_method=1__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin deleted file mode 100644 index 9de7f4a7..00000000 Binary files a/tests/cache/trivial/__-l__eng__thresholding_method=1__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin and /dev/null differ diff --git a/tests/cache/trivial/__-l__eng__thresholding_method=1__000001_ocr.png__000001_ocr_tess__pdf__txt/stderr.bin b/tests/cache/trivial/__-l__eng__thresholding_method=1__000001_ocr.png__000001_ocr_tess__pdf__txt/stderr.bin deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/cache/trivial/__-l__eng__thresholding_method=1__000001_ocr.png__000001_ocr_tess__pdf__txt/stdout.bin b/tests/cache/trivial/__-l__eng__thresholding_method=1__000001_ocr.png__000001_ocr_tess__pdf__txt/stdout.bin deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/cache/trivial/__-l__eng__thresholding_method=1__000001_ocr.png__000001_ocr_tess__pdf__txt/txt.bin b/tests/cache/trivial/__-l__eng__thresholding_method=1__000001_ocr.png__000001_ocr_tess__pdf__txt/txt.bin deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/cache/trivial/__-l__eng__thresholding_method=2__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin b/tests/cache/trivial/__-l__eng__thresholding_method=2__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin index 6b2c3166..b3aaa191 100644 --- a/tests/cache/trivial/__-l__eng__thresholding_method=2__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin +++ b/tests/cache/trivial/__-l__eng__thresholding_method=2__000001_ocr.png__000001_ocr_hocr__hocr__txt/hocr.bin @@ -5,11 +5,11 @@ - - + + -
+
diff --git a/tests/cache/trivial/__-l__eng__thresholding_method=2__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin b/tests/cache/trivial/__-l__eng__thresholding_method=2__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin deleted file mode 100644 index deac0923..00000000 Binary files a/tests/cache/trivial/__-l__eng__thresholding_method=2__000001_ocr.png__000001_ocr_tess__pdf__txt/pdf.bin and /dev/null differ diff --git a/tests/cache/trivial/__-l__eng__thresholding_method=2__000001_ocr.png__000001_ocr_tess__pdf__txt/stderr.bin b/tests/cache/trivial/__-l__eng__thresholding_method=2__000001_ocr.png__000001_ocr_tess__pdf__txt/stderr.bin deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/cache/trivial/__-l__eng__thresholding_method=2__000001_ocr.png__000001_ocr_tess__pdf__txt/stdout.bin b/tests/cache/trivial/__-l__eng__thresholding_method=2__000001_ocr.png__000001_ocr_tess__pdf__txt/stdout.bin deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/cache/trivial/__-l__eng__thresholding_method=2__000001_ocr.png__000001_ocr_tess__pdf__txt/txt.bin b/tests/cache/trivial/__-l__eng__thresholding_method=2__000001_ocr.png__000001_ocr_tess__pdf__txt/txt.bin deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/conftest.py b/tests/conftest.py index 004d91df..472aaee0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,7 @@ from __future__ import annotations +import logging import platform import sys from pathlib import Path @@ -12,10 +13,33 @@ import pytest from ocrmypdf import api, pdfinfo from ocrmypdf._exec import unpaper -from ocrmypdf._plugin_manager import get_parser_options_plugins +from ocrmypdf.api import setup_plugin_infrastructure +from ocrmypdf.cli import get_options_and_plugins from ocrmypdf.exceptions import ExitCode +class Gs106WarningFilter(logging.Filter): + """Filter out expected Ghostscript 10.6.x warning from test logs.""" + + def filter(self, record: logging.LogRecord) -> bool: + # Allow all records except the expected Ghostscript 10.6.x warning + return ( + "Ghostscript 10.6.x contains JPEG encoding errors" + not in record.getMessage() + ) + + +@pytest.fixture(autouse=True) +def suppress_gs106_warning(): + """Suppress the expected Ghostscript 10.6.x JPEG encoding warning in tests.""" + # Add filter to root logger to suppress expected warnings + root_logger = logging.getLogger() + warning_filter = Gs106WarningFilter() + root_logger.addFilter(warning_filter) + yield + root_logger.removeFilter(warning_filter) + + def is_linux(): return platform.system() == 'Linux' @@ -84,7 +108,7 @@ def check_ocrmypdf(input_file: Path, output_file: Path, *args) -> Path: str(arg) for arg in args if arg is not None ] - _parser, options, plugin_manager = get_parser_options_plugins(args=api_args) + options, plugin_manager = get_options_and_plugins(args=api_args) api.check_options(options, plugin_manager) result = api.run_pipeline(options, plugin_manager=plugin_manager) @@ -108,7 +132,7 @@ def run_ocrmypdf_api(input_file: Path, output_file: Path, *args) -> ExitCode: api_args = [str(input_file), str(output_file)] + [ str(arg) for arg in args if arg is not None ] - _parser, options, plugin_manager = get_parser_options_plugins(args=api_args) + options, plugin_manager = get_options_and_plugins(args=api_args) api.check_options(options, plugin_manager) return api.run_pipeline_cli(options, plugin_manager=plugin_manager) @@ -164,3 +188,8 @@ def pytest_collection_modifyitems(config, items): for item in items: if "slow" in item.keywords: item.add_marker(skip_slow) + + +def get_test_plugin_manager(plugins=None): + """Get a properly initialized plugin manager for testing.""" + return setup_plugin_infrastructure(plugins=plugins or []) diff --git a/tests/plugins/gs_raster_failure.py b/tests/plugins/gs_raster_failure.py index bdb11c85..fb7b5ecf 100644 --- a/tests/plugins/gs_raster_failure.py +++ b/tests/plugins/gs_raster_failure.py @@ -24,9 +24,12 @@ def rasterize_pdf_page( raster_device, raster_dpi, pageno, - page_dpi=None, - rotation=None, - filter_vector=False, + page_dpi, + rotation, + filter_vector, + stop_on_soft_error, + options, + use_cropbox, ) -> Path: with patch('ocrmypdf._exec.ghostscript.run') as mock: mock.side_effect = raise_gs_fail @@ -39,7 +42,9 @@ def rasterize_pdf_page( page_dpi=page_dpi, rotation=rotation, filter_vector=filter_vector, - stop_on_soft_error=True, + stop_on_soft_error=stop_on_soft_error, + options=options, + use_cropbox=use_cropbox, ) mock.assert_called() return output_file diff --git a/tests/plugins/gs_raster_soft_error.py b/tests/plugins/gs_raster_soft_error.py index 57a740b6..8e3c81c8 100644 --- a/tests/plugins/gs_raster_soft_error.py +++ b/tests/plugins/gs_raster_soft_error.py @@ -29,6 +29,8 @@ def rasterize_pdf_page( rotation, filter_vector, stop_on_soft_error, + options, + use_cropbox, ) -> Path: with patch('ocrmypdf._exec.ghostscript.run') as mock: mock.side_effect = fail_if_stoponerror @@ -42,6 +44,8 @@ def rasterize_pdf_page( rotation=rotation, filter_vector=filter_vector, stop_on_soft_error=stop_on_soft_error, + options=options, + use_cropbox=use_cropbox, ) mock.assert_called() return output_file diff --git a/tests/plugins/tesseract_cache.py b/tests/plugins/tesseract_cache.py index c417ccd2..bc3214ac 100644 --- a/tests/plugins/tesseract_cache.py +++ b/tests/plugins/tesseract_cache.py @@ -95,14 +95,27 @@ def cached_run(options, run_args, **run_kwargs): log.debug(f"Using Tesseract cache {cache_folder}") - if (cache_folder / 'stderr.bin').exists(): + # Determine what configfiles we need + configfiles = args.configfiles if args.configfiles else ['txt'] + + # Check if cache has all required files + def cache_complete(): + if not (cache_folder / 'stderr.bin').exists(): + return False + if not (cache_folder / 'stdout.bin').exists(): + return False + if args.outputbase != 'stdout': + for configfile in configfiles: + if not (cache_folder / f'{configfile}.bin').exists(): + return False + return True + + if cache_complete(): log.debug("Cache HIT") # Replicate stdout/err if args.outputbase != 'stdout': - if not args.configfiles: - args.configfiles.append('txt') - for configfile in args.configfiles: + for configfile in configfiles: # cp cache -> output tessfile = args.outputbase + '.' + configfile shutil.copy(str(cache_folder / configfile) + '.bin', tessfile) @@ -118,7 +131,12 @@ def cached_run(options, run_args, **run_kwargs): cache_kwargs = { k: v for k, v in run_kwargs.items() if k not in ('stdout', 'stderr') } - assert cache_kwargs['check'] + # Don't pass timeout=0 to the actual run call - it would timeout immediately + # A timeout of 0 means "use default/no timeout" in the caching context + if cache_kwargs.get('timeout') == 0.0: + cache_kwargs['timeout'] = None + if 'check' not in cache_kwargs: + cache_kwargs['check'] = True try: p = run(run_args, stdout=PIPE, stderr=PIPE, **cache_kwargs) except CalledProcessError as e: @@ -130,11 +148,8 @@ def cached_run(options, run_args, **run_kwargs): (cache_folder / 'stderr.bin').write_bytes(p.stderr) if args.outputbase != 'stdout': - if not args.configfiles: - args.configfiles.append('txt') - - for configfile in args.configfiles: - if configfile not in ('hocr', 'pdf', 'txt'): + for configfile in configfiles: + if configfile not in ('fpdf2', 'hocr', 'pdf', 'txt'): continue # cp pwd/{outputbase}.{configfile} -> {cache}/{configfile} tessfile = args.outputbase + '.' + configfile diff --git a/tests/plugins/tesseract_debug_rotate.py b/tests/plugins/tesseract_debug_rotate.py index 6bd8ba3c..771bbd28 100644 --- a/tests/plugins/tesseract_debug_rotate.py +++ b/tests/plugins/tesseract_debug_rotate.py @@ -5,7 +5,7 @@ To quickly run tests where getting OCR output is not necessary and we want to test the rotation pipeline. -In 'hocr' mode, create a .hocr file that specifies no text found. +In generate_hocr mode, create a .hocr file that specifies no text found. In 'pdf' mode, convert the image to PDF using another program. diff --git a/tests/plugins/tesseract_noop.py b/tests/plugins/tesseract_noop.py index 7f55a821..b8e109df 100644 --- a/tests/plugins/tesseract_noop.py +++ b/tests/plugins/tesseract_noop.py @@ -4,7 +4,7 @@ To quickly run tests where getting OCR output is not necessary. -In 'hocr' mode, create a .hocr file that specifies no text found. +In generate_hocr mode, create a .hocr file that specifies no text found. In 'pdf' mode, convert the image to PDF using another program. diff --git a/tests/resources/arabic.hocr b/tests/resources/arabic.hocr new file mode 100644 index 00000000..af24e8ba --- /dev/null +++ b/tests/resources/arabic.hocr @@ -0,0 +1,36 @@ + + + + + + + + + + +
+
+

+ +مرحبا +بالعالم + +

+

+ +هذا +نص +عربي + +

+

+ +سلام +فارسی + +

+
+
+ + diff --git a/tests/resources/cjk.hocr b/tests/resources/cjk.hocr new file mode 100644 index 00000000..21e92217 --- /dev/null +++ b/tests/resources/cjk.hocr @@ -0,0 +1,41 @@ + + + + + + + + + + +
+
+

+ +你好 +世界 + +

+

+ +繁體 +中文 + +

+

+ +こんにちは +世界 + +

+

+ +안녕하세요 +세계 + +

+
+
+ + diff --git a/tests/resources/devanagari.hocr b/tests/resources/devanagari.hocr new file mode 100644 index 00000000..9755470e --- /dev/null +++ b/tests/resources/devanagari.hocr @@ -0,0 +1,37 @@ + + + + + + + + + + +
+
+

+ +नमस्ते +दुनिया + +

+

+ +यह +हिंदी +पाठ +है + +

+

+ +संस्कृत +भाषा + +

+
+
+ + diff --git a/tests/resources/hello_world_scripts.hocr b/tests/resources/hello_world_scripts.hocr new file mode 100644 index 00000000..c7ff54ad --- /dev/null +++ b/tests/resources/hello_world_scripts.hocr @@ -0,0 +1,192 @@ + + + + +Multilingual Hello World Script Test + + + + + + +
+ + +
+

+ +Hello! + +

+
+ +
+

+ +¡Hola! + +

+
+ + +
+

+ +Bonjour! + +

+
+ +
+

+ +Grüß Gott! + +

+
+ + +
+

+ +Привет! + +

+
+ +
+

+ +Γειά σου! + +

+
+ + +
+

+ +你好! + +

+
+ +
+

+ +こんにちは! + +

+
+ + +
+

+ +안녕하세요! + +

+
+ +
+

+ +Merhaba! + +

+
+ + +
+

+ +नमस्ते! + +

+
+ +
+

+ +!مرحبا + +

+
+ + +
+

+ +שלום + +

+
+ +
+

+ +Olá! + +

+
+ + + +
+

+ +Ciao! + +

+
+ + +
+

+ +Cześć! + +

+
+ + +
+

+ +您好! + +

+
+ + + +
+

+ +Здравствуй! + +

+
+ + +
+

+ +Χαίρετε! + +

+
+ + +
+

+ +!أهلاً + +

+
+ +
+ + diff --git a/tests/resources/latin.hocr b/tests/resources/latin.hocr new file mode 100644 index 00000000..d5edd2ed --- /dev/null +++ b/tests/resources/latin.hocr @@ -0,0 +1,40 @@ + + + + + + + + + + +
+
+

+ +The +quick +brown +fox +jumps + +

+

+ +Café +résumé +naïve + +

+

+ +Größe +Zürich +Ärger + +

+
+
+ + diff --git a/tests/resources/multilingual.hocr b/tests/resources/multilingual.hocr new file mode 100644 index 00000000..64254a92 --- /dev/null +++ b/tests/resources/multilingual.hocr @@ -0,0 +1,30 @@ + + + + + + + + + + +
+
+

+ +English +Text +Here + +

+

+ +مرحبا +بك + +

+
+
+ + diff --git a/tests/test_acroform.py b/tests/test_acroform.py index 34a4a2d6..30ed24e8 100644 --- a/tests/test_acroform.py +++ b/tests/test_acroform.py @@ -22,7 +22,8 @@ def acroform(resources): def test_acroform_and_redo(acroform, no_outpdf): with pytest.raises( - ocrmypdf.exceptions.InputFileError, match='--redo-ocr is not currently possible' + ocrmypdf.exceptions.InputFileError, + match=r'.*--redo-ocr.*is not currently possible.*', ): check_ocrmypdf(acroform, no_outpdf, '--redo-ocr') diff --git a/tests/test_annots.py b/tests/test_annots.py index 24287e96..900d0047 100644 --- a/tests/test_annots.py +++ b/tests/test_annots.py @@ -3,7 +3,6 @@ from __future__ import annotations -import pytest from pikepdf import Array, Dictionary, Name, NameTree, Pdf from ocrmypdf._annots import remove_broken_goto_annotations diff --git a/tests/test_api.py b/tests/test_api.py index d6f34fcd..9eebc0f2 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -89,7 +89,7 @@ def test_hocr_result_json(): assert ( result.to_json() == '{"pageno": 1, "pdf_page_from_image": {"Path": "a"}, "hocr": {"Path": "b"}, ' - '"textpdf": {"Path": "c"}, "orientation_correction": 180}' + '"textpdf": {"Path": "c"}, "orientation_correction": 180, "ocr_tree": null}' ) assert ocrmypdf._pipelines._common.HOCRResult.from_json(result.to_json()) == result @@ -103,3 +103,40 @@ def test_hocr_result_pickle(): orientation_correction=180, ) assert result == pickle.loads(pickle.dumps(result)) + + +def test_nested_plugin_option_access(): + """Test that plugin options can be accessed via nested namespaces.""" + from ocrmypdf._options import OcrOptions + from ocrmypdf.api import setup_plugin_infrastructure + + # Set up plugin infrastructure to register plugin models + setup_plugin_infrastructure() + + # Create options with tesseract settings + options = OcrOptions( + input_file='test.pdf', + output_file='output.pdf', + tesseract_timeout=120.0, + tesseract_oem=1, + optimize=2, + ) + + # Test flat access still works + assert options.tesseract_timeout == 120.0 + assert options.tesseract_oem == 1 + assert options.optimize == 2 + + # Test nested access for tesseract + tesseract = options.tesseract + assert tesseract is not None + assert tesseract.timeout == 120.0 + assert tesseract.oem == 1 + + # Test nested access for ghostscript + ghostscript = options.ghostscript + assert ghostscript is not None + assert ghostscript.color_conversion_strategy == "LeaveColorUnchanged" + + # Test that cached instances are returned + assert options.tesseract is tesseract diff --git a/tests/test_fpdf_renderer.py b/tests/test_fpdf_renderer.py new file mode 100644 index 00000000..351e3f7b --- /dev/null +++ b/tests/test_fpdf_renderer.py @@ -0,0 +1,539 @@ +# SPDX-FileCopyrightText: 2025 James R. Barlow +# SPDX-License-Identifier: MPL-2.0 + +"""Tests for fpdf2-based PDF renderer.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from ocrmypdf.font import MultiFontManager +from ocrmypdf.fpdf_renderer import ( + DebugRenderOptions, + Fpdf2MultiPageRenderer, + Fpdf2PdfRenderer, +) +from ocrmypdf.hocrtransform.hocr_parser import HocrParser +from ocrmypdf.models.ocr_element import OcrClass + + +@pytest.fixture +def font_dir(): + """Return path to font directory.""" + return Path(__file__).parent.parent / "src" / "ocrmypdf" / "data" + + +@pytest.fixture +def multi_font_manager(font_dir): + """Create MultiFontManager instance for testing.""" + return MultiFontManager(font_dir) + + +@pytest.fixture +def resources(): + """Return path to test resources directory.""" + return Path(__file__).parent / "resources" + + +class TestFpdf2RendererImports: + """Test that all fpdf2 renderer modules can be imported.""" + + def test_imports(self): + """Test that all fpdf_renderer modules can be imported.""" + from ocrmypdf.fpdf_renderer import ( + DebugRenderOptions, + Fpdf2MultiPageRenderer, + Fpdf2PdfRenderer, + ) + + assert DebugRenderOptions is not None + assert Fpdf2PdfRenderer is not None + assert Fpdf2MultiPageRenderer is not None + + +class TestDebugRenderOptions: + """Test DebugRenderOptions dataclass.""" + + def test_defaults(self): + """Test default values.""" + opts = DebugRenderOptions() + assert opts.render_baseline is False + assert opts.render_line_bbox is False + assert opts.render_word_bbox is False + + def test_custom_values(self): + """Test custom values.""" + opts = DebugRenderOptions( + render_baseline=True, + render_line_bbox=True, + render_word_bbox=True, + ) + assert opts.render_baseline is True + assert opts.render_line_bbox is True + assert opts.render_word_bbox is True + + +class TestFpdf2PdfRenderer: + """Test Fpdf2PdfRenderer.""" + + def test_requires_page_element(self, multi_font_manager): + """Test that renderer requires ocr_page element.""" + from ocrmypdf.models.ocr_element import BoundingBox, OcrElement + + # Create a non-page element + word = OcrElement( + ocr_class=OcrClass.WORD, + text="test", + bbox=BoundingBox(left=0, top=0, right=100, bottom=20), + ) + + with pytest.raises(ValueError, match="Root element must be ocr_page"): + Fpdf2PdfRenderer( + page=word, + dpi=300, + multi_font_manager=multi_font_manager, + ) + + def test_requires_bbox(self, multi_font_manager): + """Test that renderer requires page with bounding box.""" + from ocrmypdf.models.ocr_element import OcrElement + + page = OcrElement(ocr_class=OcrClass.PAGE) + + with pytest.raises(ValueError, match="Page must have bounding box"): + Fpdf2PdfRenderer( + page=page, + dpi=300, + multi_font_manager=multi_font_manager, + ) + + def test_render_simple_page(self, multi_font_manager, tmp_path): + """Test rendering a simple page with one word.""" + from ocrmypdf.models.ocr_element import BoundingBox, OcrElement + + # Create a simple page with one word + word = OcrElement( + ocr_class=OcrClass.WORD, + text="Hello", + bbox=BoundingBox(left=100, top=100, right=200, bottom=130), + ) + line = OcrElement( + ocr_class=OcrClass.LINE, + bbox=BoundingBox(left=100, top=100, right=200, bottom=130), + children=[word], + ) + page = OcrElement( + ocr_class=OcrClass.PAGE, + bbox=BoundingBox(left=0, top=0, right=612, bottom=792), + children=[line], + ) + + renderer = Fpdf2PdfRenderer( + page=page, + dpi=72, # 1:1 mapping to PDF points + multi_font_manager=multi_font_manager, + invisible_text=False, + ) + + output_path = tmp_path / "test_simple.pdf" + renderer.render(output_path) + + assert output_path.exists() + assert output_path.stat().st_size > 0 + + def test_render_invisible_text(self, multi_font_manager, tmp_path): + """Test rendering invisible text (OCR layer).""" + from ocrmypdf.models.ocr_element import BoundingBox, OcrElement + + word = OcrElement( + ocr_class=OcrClass.WORD, + text="Invisible", + bbox=BoundingBox(left=100, top=100, right=250, bottom=130), + ) + line = OcrElement( + ocr_class=OcrClass.LINE, + bbox=BoundingBox(left=100, top=100, right=250, bottom=130), + children=[word], + ) + page = OcrElement( + ocr_class=OcrClass.PAGE, + bbox=BoundingBox(left=0, top=0, right=612, bottom=792), + children=[line], + ) + + renderer = Fpdf2PdfRenderer( + page=page, + dpi=72, + multi_font_manager=multi_font_manager, + invisible_text=True, # This is the default + ) + + output_path = tmp_path / "test_invisible.pdf" + renderer.render(output_path) + + assert output_path.exists() + assert output_path.stat().st_size > 0 + + +class TestFpdf2MultiPageRenderer: + """Test Fpdf2MultiPageRenderer.""" + + def test_requires_pages(self, multi_font_manager): + """Test that renderer requires at least one page.""" + with pytest.raises(ValueError, match="No pages to render"): + renderer = Fpdf2MultiPageRenderer( + pages_data=[], + multi_font_manager=multi_font_manager, + ) + renderer.render(Path("/tmp/test.pdf")) + + def test_render_multiple_pages(self, multi_font_manager, tmp_path): + """Test rendering multiple pages.""" + from ocrmypdf.models.ocr_element import BoundingBox, OcrElement + + pages_data = [] + for i in range(3): + word = OcrElement( + ocr_class=OcrClass.WORD, + text=f"Page{i+1}", + bbox=BoundingBox(left=100, top=100, right=200, bottom=130), + ) + line = OcrElement( + ocr_class=OcrClass.LINE, + bbox=BoundingBox(left=100, top=100, right=200, bottom=130), + children=[word], + ) + page = OcrElement( + ocr_class=OcrClass.PAGE, + bbox=BoundingBox(left=0, top=0, right=612, bottom=792), + children=[line], + ) + pages_data.append((i + 1, page, 72)) + + renderer = Fpdf2MultiPageRenderer( + pages_data=pages_data, + multi_font_manager=multi_font_manager, + invisible_text=False, + ) + + output_path = tmp_path / "test_multipage.pdf" + renderer.render(output_path) + + assert output_path.exists() + assert output_path.stat().st_size > 0 + + +class TestFpdf2RendererWithHocr: + """Test fpdf2 renderer with actual hOCR files.""" + + def test_render_latin_hocr(self, resources, multi_font_manager, tmp_path): + """Test rendering Latin text from hOCR.""" + hocr_path = resources / "latin.hocr" + if not hocr_path.exists(): + pytest.skip("latin.hocr not found") + + parser = HocrParser(hocr_path) + page = parser.parse() + + # Ensure we got a page + assert page.ocr_class == OcrClass.PAGE + assert page.bbox is not None + + renderer = Fpdf2PdfRenderer( + page=page, + dpi=300, + multi_font_manager=multi_font_manager, + invisible_text=False, + ) + + output_path = tmp_path / "latin_fpdf2.pdf" + renderer.render(output_path) + + assert output_path.exists() + assert output_path.stat().st_size > 0 + + def test_render_cjk_hocr(self, resources, multi_font_manager, tmp_path): + """Test rendering CJK text from hOCR.""" + hocr_path = resources / "cjk.hocr" + if not hocr_path.exists(): + pytest.skip("cjk.hocr not found") + + parser = HocrParser(hocr_path) + page = parser.parse() + + renderer = Fpdf2PdfRenderer( + page=page, + dpi=300, + multi_font_manager=multi_font_manager, + invisible_text=False, + ) + + output_path = tmp_path / "cjk_fpdf2.pdf" + renderer.render(output_path) + + assert output_path.exists() + assert output_path.stat().st_size > 0 + + def test_render_arabic_hocr(self, resources, multi_font_manager, tmp_path): + """Test rendering Arabic text from hOCR.""" + hocr_path = resources / "arabic.hocr" + if not hocr_path.exists(): + pytest.skip("arabic.hocr not found") + + parser = HocrParser(hocr_path) + page = parser.parse() + + renderer = Fpdf2PdfRenderer( + page=page, + dpi=300, + multi_font_manager=multi_font_manager, + invisible_text=False, + ) + + output_path = tmp_path / "arabic_fpdf2.pdf" + renderer.render(output_path) + + assert output_path.exists() + assert output_path.stat().st_size > 0 + + def test_render_hello_world_scripts_hocr( + self, resources, multi_font_manager, tmp_path + ): + """Test rendering comprehensive multilingual 'Hello!' hOCR file. + + This tests all major scripts including: + - Latin (English, Spanish, French, German, Italian, Polish, Portuguese, Turkish) + - Cyrillic (Russian) + - Greek + - CJK (Chinese Simplified, Chinese Traditional, Japanese, Korean) + - Devanagari (Hindi) + - Arabic (RTL) + - Hebrew (RTL) + + Also includes rotated baselines to exercise skew handling. + """ + hocr_path = resources / "hello_world_scripts.hocr" + if not hocr_path.exists(): + pytest.skip("hello_world_scripts.hocr not found") + + parser = HocrParser(hocr_path) + page = parser.parse() + + # Verify we parsed the page correctly + assert page.ocr_class == OcrClass.PAGE + assert page.bbox is not None + # Should have 2550x3300 at 300 DPI + assert page.bbox.right == 2550 + assert page.bbox.bottom == 3300 + + # Test with visible text for visual inspection + renderer = Fpdf2PdfRenderer( + page=page, + dpi=300, + multi_font_manager=multi_font_manager, + invisible_text=False, + ) + + output_path = tmp_path / "hello_world_scripts_fpdf2.pdf" + renderer.render(output_path) + + assert output_path.exists() + assert output_path.stat().st_size > 0 + + def test_render_hello_world_scripts_multipage( + self, resources, multi_font_manager, tmp_path + ): + """Test rendering hello_world_scripts.hocr using MultiPageRenderer. + + Uses Fpdf2MultiPageRenderer to render the multilingual test file, + demonstrating font handling across all major writing systems. + """ + hocr_path = resources / "hello_world_scripts.hocr" + if not hocr_path.exists(): + pytest.skip("hello_world_scripts.hocr not found") + + parser = HocrParser(hocr_path) + page = parser.parse() + + # Build pages_data list as expected by MultiPageRenderer + pages_data = [(1, page, 300)] # (page_number, page_element, dpi) + + renderer = Fpdf2MultiPageRenderer( + pages_data=pages_data, + multi_font_manager=multi_font_manager, + invisible_text=False, + ) + + output_path = tmp_path / "hello_world_scripts_multipage.pdf" + renderer.render(output_path) + + assert output_path.exists() + assert output_path.stat().st_size > 0 + + +class TestWordSegmentation: + """Test that rendered PDFs have proper word segmentation for pdfminer.six.""" + + def test_word_segmentation_with_pdfminer(self, multi_font_manager, tmp_path): + """Test that pdfminer.six can extract words with proper spacing. + + This test verifies that explicit space characters are inserted between + words so that pdfminer.six (and similar PDF readers) can properly + segment words during text extraction. + """ + from pdfminer.high_level import extract_text + + from ocrmypdf.models.ocr_element import BoundingBox, OcrElement + + # Create a page with multiple words on one line + word1 = OcrElement( + ocr_class=OcrClass.WORD, + text="Hello", + bbox=BoundingBox(left=100, top=100, right=200, bottom=130), + ) + word2 = OcrElement( + ocr_class=OcrClass.WORD, + text="World", + bbox=BoundingBox(left=220, top=100, right=320, bottom=130), + ) + word3 = OcrElement( + ocr_class=OcrClass.WORD, + text="Test", + bbox=BoundingBox(left=340, top=100, right=420, bottom=130), + ) + line = OcrElement( + ocr_class=OcrClass.LINE, + bbox=BoundingBox(left=100, top=100, right=420, bottom=130), + children=[word1, word2, word3], + ) + page = OcrElement( + ocr_class=OcrClass.PAGE, + bbox=BoundingBox(left=0, top=0, right=612, bottom=792), + children=[line], + ) + + renderer = Fpdf2PdfRenderer( + page=page, + dpi=72, # 1:1 mapping to PDF points + multi_font_manager=multi_font_manager, + invisible_text=False, + ) + + output_path = tmp_path / "test_word_segmentation.pdf" + renderer.render(output_path) + + # Extract text using pdfminer.six + extracted_text = extract_text(str(output_path)) + + # Verify words are separated by spaces + assert "Hello" in extracted_text + assert "World" in extracted_text + assert "Test" in extracted_text + + # The text should NOT be run together like "HelloWorldTest" + assert "HelloWorld" not in extracted_text + assert "WorldTest" not in extracted_text + + # Verify proper word segmentation - words should be separated + # (allowing for whitespace variations) + words_found = extracted_text.split() + assert "Hello" in words_found + assert "World" in words_found + assert "Test" in words_found + + def test_cjk_no_spurious_spaces(self, multi_font_manager, tmp_path): + """Test that CJK text does not get spurious spaces inserted. + + CJK scripts don't use spaces between characters/words, so we should + not insert spaces between adjacent CJK words. + """ + from pdfminer.high_level import extract_text + + from ocrmypdf.models.ocr_element import BoundingBox, OcrElement + + # Create a page with CJK words (Chinese characters) + # 你好 = "Hello" in Chinese + # 世界 = "World" in Chinese + word1 = OcrElement( + ocr_class=OcrClass.WORD, + text="你好", + bbox=BoundingBox(left=100, top=100, right=160, bottom=130), + ) + word2 = OcrElement( + ocr_class=OcrClass.WORD, + text="世界", + bbox=BoundingBox(left=170, top=100, right=230, bottom=130), + ) + line = OcrElement( + ocr_class=OcrClass.LINE, + bbox=BoundingBox(left=100, top=100, right=230, bottom=130), + children=[word1, word2], + ) + page = OcrElement( + ocr_class=OcrClass.PAGE, + bbox=BoundingBox(left=0, top=0, right=612, bottom=792), + children=[line], + ) + + renderer = Fpdf2PdfRenderer( + page=page, + dpi=72, + multi_font_manager=multi_font_manager, + invisible_text=False, + ) + + output_path = tmp_path / "test_cjk_segmentation.pdf" + renderer.render(output_path) + + # Extract text using pdfminer.six + extracted_text = extract_text(str(output_path)) + + # CJK text should be present + assert "你好" in extracted_text + assert "世界" in extracted_text + + # There should NOT be spaces between CJK characters + # (but pdfminer may add some whitespace, so we check the raw chars) + extracted_chars = extracted_text.replace(" ", "").replace("\n", "") + assert "你好世界" in extracted_chars or ( + "你好" in extracted_chars and "世界" in extracted_chars + ) + + def test_latin_hocr_word_segmentation( + self, resources, multi_font_manager, tmp_path + ): + """Test word segmentation with real Latin hOCR file.""" + from pdfminer.high_level import extract_text + + hocr_path = resources / "latin.hocr" + if not hocr_path.exists(): + pytest.skip("latin.hocr not found") + + parser = HocrParser(hocr_path) + page = parser.parse() + + renderer = Fpdf2PdfRenderer( + page=page, + dpi=300, + multi_font_manager=multi_font_manager, + invisible_text=False, + ) + + output_path = tmp_path / "latin_segmentation.pdf" + renderer.render(output_path) + + # Extract text using pdfminer.six + extracted_text = extract_text(str(output_path)) + + # The Latin text should have proper word segmentation + # Words should be separable + words = extracted_text.split() + assert len(words) > 0 + + # Check that common English words are properly segmented + # (not stuck together) + text_no_newlines = extracted_text.replace("\n", " ") + # There should be spaces in the extracted text + assert " " in text_no_newlines diff --git a/tests/test_ghostscript.py b/tests/test_ghostscript.py index 79250d3b..7d971216 100644 --- a/tests/test_ghostscript.py +++ b/tests/test_ghostscript.py @@ -20,6 +20,7 @@ from ocrmypdf._exec.ghostscript import DuplicateFilter, rasterize_pdf from ocrmypdf.builtin_plugins.ghostscript import _repair_gs106_jpeg_corruption from ocrmypdf.exceptions import ColorConversionNeededError, ExitCode, InputFileError from ocrmypdf.helpers import Resolution +from ocrmypdf.pluginspec import GhostscriptRasterDevice from .conftest import check_ocrmypdf, run_ocrmypdf_api @@ -43,7 +44,7 @@ def test_rasterize_size(francais, outdir): rasterize_pdf( path, outdir / 'out.png', - raster_device='pngmono', + raster_device=GhostscriptRasterDevice.PNGMONO, raster_dpi=Resolution( target_size[0] / page_size[0], target_size[1] / page_size[1] ), @@ -67,7 +68,7 @@ def test_rasterize_rotated(francais, outdir, caplog): rasterize_pdf( path, outdir / 'out.png', - raster_device='pngmono', + raster_device=GhostscriptRasterDevice.PNGMONO, raster_dpi=Resolution( target_size[0] / page_size[0], target_size[1] / page_size[1] ), @@ -84,6 +85,8 @@ def test_gs_render_failure(resources, outpdf, caplog): exitcode = run_ocrmypdf_api( resources / 'blank.pdf', outpdf, + '--output-type', + 'pdfa', # Required to trigger Ghostscript PDF/A generation '--plugin', 'tests/plugins/tesseract_noop.py', '--plugin', @@ -110,6 +113,8 @@ def test_ghostscript_pdfa_failure(resources, outpdf, caplog): exitcode = run_ocrmypdf_api( resources / 'francais.pdf', outpdf, + '--output-type', + 'pdfa', # Required to trigger Ghostscript PDF/A generation '--plugin', 'tests/plugins/tesseract_noop.py', '--plugin', @@ -136,6 +141,8 @@ def test_ghostscript_mandatory_color_conversion(resources, outpdf): check_ocrmypdf( resources / 'jbig2_baddevicen.pdf', outpdf, + '--output-type', + 'pdfa', # Required to trigger Ghostscript PDF/A generation '--plugin', 'tests/plugins/tesseract_noop.py', ) @@ -151,7 +158,7 @@ def test_rasterize_pdf_errors(resources, no_outpdf, caplog): rasterize_pdf( resources / 'francais.pdf', no_outpdf, - raster_device='pngmono', + raster_device=GhostscriptRasterDevice.PNGMONO, raster_dpi=Resolution(100, 100), ) assert "this is an error" in caplog.text @@ -261,7 +268,7 @@ def test_recoverable_image_error(pdf_with_invalid_image, outdir, caplog): rasterize_pdf( outdir / 'invalid_image.pdf', outdir / 'out.png', - raster_device='pngmono', + raster_device=GhostscriptRasterDevice.PNGMONO, raster_dpi=Resolution(10, 10), stop_on_error=False, ) @@ -283,7 +290,7 @@ def test_recoverable_image_error_with_stop(pdf_with_invalid_image, outdir, caplo rasterize_pdf( outdir / 'invalid_image.pdf', outdir / 'out.png', - raster_device='pngmono', + raster_device=GhostscriptRasterDevice.PNGMONO, raster_dpi=Resolution(100, 100), stop_on_error=True, ) @@ -376,7 +383,7 @@ class TestGs106JpegCorruptionRepair: repaired_bytes_list.append(obj.read_raw_bytes()) assert len(repaired_bytes_list) == len(original_bytes_list) - for orig, repaired_bytes in zip(original_bytes_list, repaired_bytes_list): + for orig, repaired_bytes in zip(original_bytes_list, repaired_bytes_list, strict=False): assert orig == repaired_bytes, "Repaired bytes should match original" # Check that error/warning was logged diff --git a/tests/test_hocr_parser.py b/tests/test_hocr_parser.py new file mode 100644 index 00000000..c7f02374 --- /dev/null +++ b/tests/test_hocr_parser.py @@ -0,0 +1,533 @@ +# SPDX-FileCopyrightText: 2025 James R. Barlow +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for HocrParser class.""" + +from __future__ import annotations + +from pathlib import Path +from textwrap import dedent + +import pytest + +from ocrmypdf.hocrtransform import ( + HocrParseError, + HocrParser, + OcrClass, +) + + +@pytest.fixture +def simple_hocr(tmp_path) -> Path: + """Create a simple valid hOCR file.""" + content = dedent("""\ + + + + + Test + + +
+

+ + Hello + World + +

+
+ + + """) + hocr_file = tmp_path / "simple.hocr" + hocr_file.write_text(content, encoding='utf-8') + return hocr_file + + +@pytest.fixture +def multiline_hocr(tmp_path) -> Path: + """Create an hOCR file with multiple lines and paragraphs.""" + content = dedent("""\ + + + +
+

+ + Line + one + + + Line + two + +

+

+ + German + text + +

+
+ + + """) + hocr_file = tmp_path / "multiline.hocr" + hocr_file.write_text(content, encoding='utf-8') + return hocr_file + + +@pytest.fixture +def rtl_hocr(tmp_path) -> Path: + """Create an hOCR file with RTL text.""" + content = dedent("""\ + + + +
+

+ + مرحبا + +

+
+ + + """) + hocr_file = tmp_path / "rtl.hocr" + hocr_file.write_text(content, encoding='utf-8') + return hocr_file + + +@pytest.fixture +def rotated_hocr(tmp_path) -> Path: + """Create an hOCR file with rotated text (textangle).""" + content = dedent("""\ + + + +
+

+ + Rotated + +

+
+ + + """) + hocr_file = tmp_path / "rotated.hocr" + hocr_file.write_text(content, encoding='utf-8') + return hocr_file + + +@pytest.fixture +def header_hocr(tmp_path) -> Path: + """Create an hOCR file with different line types.""" + content = dedent("""\ + + + +
+

+ + Chapter + One + + + Body + text + + + Figure + 1 + +

+
+ + + """) + hocr_file = tmp_path / "header.hocr" + hocr_file.write_text(content, encoding='utf-8') + return hocr_file + + +@pytest.fixture +def font_info_hocr(tmp_path) -> Path: + """Create an hOCR file with font information.""" + content = dedent("""\ + + + +
+

+ + Styled + +

+
+ + + """) + hocr_file = tmp_path / "font_info.hocr" + hocr_file.write_text(content, encoding='utf-8') + return hocr_file + + +class TestHocrParserBasic: + """Basic HocrParser functionality tests.""" + + def test_parse_simple_hocr(self, simple_hocr): + parser = HocrParser(simple_hocr) + page = parser.parse() + + assert page.ocr_class == OcrClass.PAGE + assert page.bbox is not None + assert page.bbox.width == 1000 + assert page.bbox.height == 500 + + def test_parse_page_number(self, simple_hocr): + parser = HocrParser(simple_hocr) + page = parser.parse() + + assert page.page_number == 0 + + def test_parse_paragraphs(self, simple_hocr): + parser = HocrParser(simple_hocr) + page = parser.parse() + + assert len(page.paragraphs) == 1 + paragraph = page.paragraphs[0] + assert paragraph.ocr_class == OcrClass.PARAGRAPH + assert paragraph.language == "eng" + assert paragraph.direction == "ltr" + + def test_parse_lines(self, simple_hocr): + parser = HocrParser(simple_hocr) + page = parser.parse() + + lines = page.lines + assert len(lines) == 1 + line = lines[0] + assert line.ocr_class == OcrClass.LINE + assert line.bbox is not None + assert line.baseline is not None + assert line.baseline.slope == pytest.approx(0.01) + assert line.baseline.intercept == -5 + + def test_parse_words(self, simple_hocr): + parser = HocrParser(simple_hocr) + page = parser.parse() + + words = page.words + assert len(words) == 2 + assert words[0].text == "Hello" + assert words[1].text == "World" + + def test_parse_word_confidence(self, simple_hocr): + parser = HocrParser(simple_hocr) + page = parser.parse() + + words = page.words + assert words[0].confidence == pytest.approx(0.95) + assert words[1].confidence == pytest.approx(0.90) + + def test_parse_word_bbox(self, simple_hocr): + parser = HocrParser(simple_hocr) + page = parser.parse() + + word = page.words[0] + assert word.bbox is not None + assert word.bbox.left == 100 + assert word.bbox.top == 100 + assert word.bbox.right == 200 + assert word.bbox.bottom == 150 + + +class TestHocrParserMultiline: + """Test parsing of multi-line/multi-paragraph hOCR.""" + + def test_multiple_lines(self, multiline_hocr): + parser = HocrParser(multiline_hocr) + page = parser.parse() + + assert len(page.paragraphs) == 2 + assert len(page.lines) == 3 # 2 in first par, 1 in second + + def test_multiple_paragraphs_languages(self, multiline_hocr): + parser = HocrParser(multiline_hocr) + page = parser.parse() + + paragraphs = page.paragraphs + assert paragraphs[0].language == "eng" + assert paragraphs[1].language == "deu" + + def test_word_count(self, multiline_hocr): + parser = HocrParser(multiline_hocr) + page = parser.parse() + + assert len(page.words) == 6 # 2 + 2 + 2 + + +class TestHocrParserRTL: + """Test parsing of RTL text.""" + + def test_rtl_direction(self, rtl_hocr): + parser = HocrParser(rtl_hocr) + page = parser.parse() + + paragraph = page.paragraphs[0] + assert paragraph.direction == "rtl" + assert paragraph.language == "ara" + + def test_rtl_line_inherits_direction(self, rtl_hocr): + parser = HocrParser(rtl_hocr) + page = parser.parse() + + line = page.lines[0] + assert line.direction == "rtl" + + +class TestHocrParserRotation: + """Test parsing of rotated text.""" + + def test_textangle(self, rotated_hocr): + parser = HocrParser(rotated_hocr) + page = parser.parse() + + line = page.lines[0] + assert line.textangle == pytest.approx(5.5) + + +class TestHocrParserLineTypes: + """Test parsing of different line types.""" + + def test_header_line(self, header_hocr): + parser = HocrParser(header_hocr) + page = parser.parse() + + lines = page.lines + assert len(lines) == 3 + + # Check line types + line_classes = [line.ocr_class for line in lines] + assert OcrClass.HEADER in line_classes + assert OcrClass.LINE in line_classes + assert OcrClass.CAPTION in line_classes + + def test_all_line_types_have_words(self, header_hocr): + parser = HocrParser(header_hocr) + page = parser.parse() + + for line in page.lines: + assert len(line.children) > 0 + + +class TestHocrParserFontInfo: + """Test parsing of font information.""" + + def test_font_name_and_size(self, font_info_hocr): + parser = HocrParser(font_info_hocr) + page = parser.parse() + + word = page.words[0] + assert word.font is not None + assert word.font.name == "Arial" + assert word.font.size == pytest.approx(12.5) + + +class TestHocrParserErrors: + """Test error handling in HocrParser.""" + + def test_missing_file(self, tmp_path): + with pytest.raises(FileNotFoundError): + HocrParser(tmp_path / "nonexistent.hocr") + + def test_invalid_xml(self, tmp_path): + hocr_file = tmp_path / "invalid.hocr" + hocr_file.write_text("not closed", encoding='utf-8') + + with pytest.raises(HocrParseError): + HocrParser(hocr_file) + + def test_missing_ocr_page(self, tmp_path): + hocr_file = tmp_path / "no_page.hocr" + hocr_file.write_text( + "

No ocr_page

", encoding='utf-8' + ) + + parser = HocrParser(hocr_file) + with pytest.raises(HocrParseError, match="No ocr_page"): + parser.parse() + + def test_missing_page_bbox(self, tmp_path): + hocr_file = tmp_path / "no_bbox.hocr" + hocr_file.write_text( + "
No bbox
", + encoding='utf-8', + ) + + parser = HocrParser(hocr_file) + with pytest.raises(HocrParseError, match="bbox"): + parser.parse() + + +class TestHocrParserEdgeCases: + """Test edge cases in HocrParser.""" + + def test_empty_word_text(self, tmp_path): + """Words with empty text should be skipped.""" + content = dedent("""\ + + + +
+

+ + + Valid + +

+
+ + + """) + hocr_file = tmp_path / "empty_word.hocr" + hocr_file.write_text(content, encoding='utf-8') + + parser = HocrParser(hocr_file) + page = parser.parse() + + # Only the non-empty word should be parsed + assert len(page.words) == 1 + assert page.words[0].text == "Valid" + + def test_whitespace_only_word(self, tmp_path): + """Words with only whitespace should be skipped.""" + content = dedent("""\ + + + +
+

+ + + Valid + +

+
+ + + """) + hocr_file = tmp_path / "whitespace_word.hocr" + hocr_file.write_text(content, encoding='utf-8') + + parser = HocrParser(hocr_file) + page = parser.parse() + + assert len(page.words) == 1 + assert page.words[0].text == "Valid" + + def test_line_without_bbox(self, tmp_path): + """Lines without bbox should be skipped.""" + content = dedent("""\ + + + +
+

+ + Word + + + Valid + +

+
+ + + """) + hocr_file = tmp_path / "no_line_bbox.hocr" + hocr_file.write_text(content, encoding='utf-8') + + parser = HocrParser(hocr_file) + page = parser.parse() + + # Only line with bbox should be parsed + assert len(page.lines) == 1 + assert page.words[0].text == "Valid" + + def test_unicode_normalization(self, tmp_path): + """Text should be NFKC normalized.""" + # Use a string with combining characters + content = dedent("""\ + + + +
+

+ + + +

+
+ + + """) + hocr_file = tmp_path / "unicode.hocr" + hocr_file.write_text(content, encoding='utf-8') + + parser = HocrParser(hocr_file) + page = parser.parse() + + # fi ligature should be normalized to "fi" + assert page.words[0].text == "fi" + + def test_words_directly_under_page(self, tmp_path): + """Test fallback for words directly under page (no paragraph structure).""" + content = dedent("""\ + + + +
+ Direct + Word +
+ + + """) + hocr_file = tmp_path / "direct_words.hocr" + hocr_file.write_text(content, encoding='utf-8') + + parser = HocrParser(hocr_file) + page = parser.parse() + + # Words should be parsed as direct children + assert len(page.children) == 2 + assert page.children[0].text == "Direct" + assert page.children[1].text == "Word" + + def test_no_namespace(self, tmp_path): + """Test parsing hOCR without XHTML namespace.""" + content = dedent("""\ + + +
+

+ + NoNS + +

+
+ + + """) + hocr_file = tmp_path / "no_namespace.hocr" + hocr_file.write_text(content, encoding='utf-8') + + parser = HocrParser(hocr_file) + page = parser.parse() + + assert len(page.words) == 1 + assert page.words[0].text == "NoNS" diff --git a/tests/test_hocrtransform.py b/tests/test_hocrtransform.py index 0d5d7fe1..1da66543 100644 --- a/tests/test_hocrtransform.py +++ b/tests/test_hocrtransform.py @@ -5,6 +5,7 @@ from __future__ import annotations import re from io import StringIO +from pathlib import Path import pytest from pdfminer.converter import TextConverter @@ -15,9 +16,11 @@ from pdfminer.pdfpage import PDFPage from pdfminer.pdfparser import PDFParser from PIL import Image -from ocrmypdf import hocrtransform from ocrmypdf._exec.tesseract import generate_hocr +from ocrmypdf.font import MultiFontManager +from ocrmypdf.fpdf_renderer import Fpdf2PdfRenderer from ocrmypdf.helpers import check_pdf +from ocrmypdf.hocrtransform import HocrParser from .conftest import check_ocrmypdf @@ -38,6 +41,18 @@ def text_from_pdf(filename): # pylint: disable=redefined-outer-name +@pytest.fixture +def font_dir(): + """Get the font directory.""" + return Path(__file__).parent.parent / "src" / "ocrmypdf" / "data" + + +@pytest.fixture +def multi_font_manager(font_dir): + """Create a MultiFontManager for tests.""" + return MultiFontManager(font_dir) + + @pytest.fixture def blank_hocr(tmp_path): im = Image.new('1', (8, 8), 0) @@ -58,23 +73,38 @@ def blank_hocr(tmp_path): return tmp_path / 'blank.hocr' -def test_mono_image(blank_hocr, outdir): +def test_mono_image(blank_hocr, outdir, multi_font_manager): im = Image.new('1', (8, 8), 0) for n in range(8): im.putpixel((n, n), 1) im.save(outdir / 'mono.tif', format='TIFF') - hocr = hocrtransform.HocrTransform(hocr_filename=str(blank_hocr), dpi=8) - hocr.to_pdf( - out_filename=str(outdir / 'mono.pdf'), image_filename=str(outdir / 'mono.tif') + # Parse hOCR file + parser = HocrParser(str(blank_hocr)) + ocr_page = parser.parse() + + # Use DPI from hOCR or default + dpi = ocr_page.dpi or 8 + + # Render to PDF using fpdf2 + renderer = Fpdf2PdfRenderer( + page=ocr_page, + dpi=dpi, + multi_font_manager=multi_font_manager, + invisible_text=True, ) - # shutil.copy(outdir / 'mono.pdf', 'mono.pdf') - check_pdf(str(outdir / 'mono.pdf')) + renderer.render(outdir / 'mono.pdf') + + check_pdf(outdir / 'mono.pdf') @pytest.mark.slow -def test_hocrtransform_matches_sandwich(resources, outdir): - check_ocrmypdf(resources / 'ccitt.pdf', outdir / 'hocr.pdf', '--pdf-renderer=hocr') +def test_fpdf2_matches_sandwich(resources, outdir): + """Test that fpdf2 renderer produces similar output to sandwich renderer.""" + # Note: hocr renderer now redirects to fpdf2 + check_ocrmypdf( + resources / 'ccitt.pdf', outdir / 'fpdf2.pdf', '--pdf-renderer=fpdf2' + ) check_ocrmypdf( resources / 'ccitt.pdf', outdir / 'tess.pdf', '--pdf-renderer=sandwich' ) @@ -86,17 +116,9 @@ def test_hocrtransform_matches_sandwich(resources, outdir): words = s.split(' ') return set(words) - hocr_words = clean(text_from_pdf(outdir / 'hocr.pdf')) + fpdf2_words = clean(text_from_pdf(outdir / 'fpdf2.pdf')) tess_words = clean(text_from_pdf(outdir / 'tess.pdf')) - similarity = len(hocr_words & tess_words) / len(hocr_words | tess_words) - - # from pathlib import Path - - # Path('hocr.txt').write_text(sorted('\n'.join(hocr_words))) - # Path('tess.txt').write_text(sorted('\n'.join(tess_words))) - # Path('mismatch.txt').write_text( - # '\n'.join(sorted(hocr_words ^ tess_words)), encoding='utf8' - # ) + similarity = len(fpdf2_words & tess_words) / len(fpdf2_words | tess_words) assert similarity > 0.99 diff --git a/tests/test_json_serialization.py b/tests/test_json_serialization.py new file mode 100644 index 00000000..e08a8bfe --- /dev/null +++ b/tests/test_json_serialization.py @@ -0,0 +1,162 @@ +"""Test JSON serialization of OcrOptions for multiprocessing compatibility.""" +from __future__ import annotations + +import multiprocessing +from io import BytesIO +from pathlib import Path, PurePath + +import pytest + +from ocrmypdf._options import OcrOptions +from ocrmypdf.builtin_plugins.tesseract_ocr import TesseractOptions + + +@pytest.fixture(autouse=True) +def register_plugin_models(): + """Register plugin models for tests.""" + OcrOptions.register_plugin_models({'tesseract': TesseractOptions}) + yield + # Clean up after test (optional, but good practice) + + +def worker_function(options_json: str) -> str: + """Worker function that deserializes OcrOptions from JSON and returns a result.""" + # Register plugin models in worker process + from ocrmypdf._options import OcrOptions + from ocrmypdf.builtin_plugins.tesseract_ocr import TesseractOptions + + OcrOptions.register_plugin_models({'tesseract': TesseractOptions}) + + # Reconstruct OcrOptions from JSON in worker process + options = OcrOptions.model_validate_json_safe(options_json) + + # Verify we can access various option types + # Count only user-added extra_attrs (exclude plugin cache keys starting with '_') + user_attrs_count = len( + [k for k in options.extra_attrs.keys() if not k.startswith('_')] + ) + result = { + 'input_file': str(options.input_file), + 'output_file': str(options.output_file), + 'languages': options.languages, + 'optimize': options.optimize, + 'tesseract_timeout': options.tesseract.timeout, + 'fast_web_view': options.fast_web_view, + 'extra_attrs_count': user_attrs_count, + } + + # Return as JSON string + import json + + return json.dumps(result) + + +def test_json_serialization_multiprocessing(): + """Test that OcrOptions can be JSON serialized and used in multiprocessing.""" + # Create OcrOptions with various field types + options = OcrOptions( + input_file=Path('/test/input.pdf'), + output_file=Path('/test/output.pdf'), + languages=['eng', 'deu'], + optimize=2, + tesseract_timeout=120.0, + fast_web_view=2.5, + deskew=True, + clean=False, + ) + + # Add some extra attributes + options.extra_attrs['custom_field'] = 'test_value' + options.extra_attrs['numeric_field'] = 42 + + # Serialize to JSON + options_json = options.model_dump_json_safe() + + # Test that we can deserialize in the main process + reconstructed = OcrOptions.model_validate_json_safe(options_json) + assert reconstructed.input_file == options.input_file + assert reconstructed.output_file == options.output_file + assert reconstructed.languages == options.languages + assert reconstructed.optimize == options.optimize + assert reconstructed.tesseract_timeout == options.tesseract.timeout + assert reconstructed.fast_web_view == options.fast_web_view + assert reconstructed.deskew == options.deskew + assert reconstructed.clean == options.clean + # Compare user-added extra_attrs (excluding plugin cache keys) + user_attrs = {k: v for k, v in options.extra_attrs.items() if not k.startswith('_')} + reconstructed_attrs = { + k: v for k, v in reconstructed.extra_attrs.items() if not k.startswith('_') + } + assert reconstructed_attrs == user_attrs + + # Test multiprocessing with JSON serialization + with multiprocessing.Pool(processes=2) as pool: + # Send the JSON string to worker processes + results = pool.map(worker_function, [options_json, options_json]) + + # Verify results from worker processes + import json + + for result_json in results: + result = json.loads(result_json) + assert PurePath(result['input_file']) == PurePath('/test/input.pdf') + assert PurePath(result['output_file']) == PurePath('/test/output.pdf') + assert result['languages'] == ['eng', 'deu'] + assert result['optimize'] == 2 + assert result['tesseract_timeout'] == 120.0 + assert result['fast_web_view'] == 2.5 + assert result['extra_attrs_count'] == 2 # custom_field and numeric_field + + +def test_json_serialization_with_streams(): + """Test JSON serialization with stream objects.""" + input_stream = BytesIO(b'fake pdf data') + output_stream = BytesIO() + + options = OcrOptions( + input_file=input_stream, + output_file=output_stream, + languages=['eng'], + optimize=1, + ) + + # Serialize to JSON (streams should be converted to placeholders) + options_json = options.model_dump_json_safe() + + # Deserialize (streams will be placeholder strings) + reconstructed = OcrOptions.model_validate_json_safe(options_json) + + # Streams should be converted to placeholder strings + assert reconstructed.input_file == 'stream' + assert reconstructed.output_file == 'stream' + assert reconstructed.languages == ['eng'] + assert reconstructed.optimize == 1 + + +def test_json_serialization_with_none_values(): + """Test JSON serialization handles None values correctly.""" + options = OcrOptions( + input_file=Path('/test/input.pdf'), + output_file=Path('/test/output.pdf'), + languages=['eng'], + # Many fields will be None by default + ) + + # Serialize to JSON + options_json = options.model_dump_json_safe() + + # Deserialize + reconstructed = OcrOptions.model_validate_json_safe(options_json) + + # Verify None values are preserved (check actual defaults from model) + assert reconstructed.tesseract_timeout == 0.0 # Default value, not None + assert reconstructed.fast_web_view == 1.0 # Default value, not None + assert ( + reconstructed.color_conversion_strategy == "LeaveColorUnchanged" + ) # Default value + assert reconstructed.pdfa_image_compression is None # This one is actually None + + # Verify non-None values are preserved + assert reconstructed.input_file == options.input_file + assert reconstructed.output_file == options.output_file + assert reconstructed.languages == options.languages diff --git a/tests/test_main.py b/tests/test_main.py index 9ad5f8ff..8428d801 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -35,7 +35,7 @@ from .conftest import ( # pylint: disable=redefined-outer-name -RENDERERS = ['hocr', 'sandwich'] +RENDERERS = ['fpdf2', 'sandwich'] def test_quick(resources, outpdf): @@ -435,7 +435,7 @@ def test_jbig2_passthrough(resources, outpdf): '--output-type', 'pdf', '--pdf-renderer', - 'hocr', + 'fpdf2', '--plugin', 'tests/plugins/tesseract_cache.py', ) diff --git a/tests/test_metadata.py b/tests/test_metadata.py index 2383773e..be8826bb 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -3,21 +3,19 @@ from __future__ import annotations -import datetime +import datetime as dt import warnings -from datetime import timezone from shutil import copyfile import pikepdf import pytest -from packaging.version import Version from pikepdf.models.metadata import decode_pdf_date -from ocrmypdf._exec import ghostscript from ocrmypdf._jobcontext import PdfContext from ocrmypdf._metadata import metadata_fixup from ocrmypdf._pipeline import convert_to_pdfa -from ocrmypdf._plugin_manager import get_parser_options_plugins, get_plugin_manager +from ocrmypdf.api import setup_plugin_infrastructure +from ocrmypdf.cli import get_options_and_plugins from ocrmypdf.exceptions import ExitCode from ocrmypdf.pdfa import file_claims_pdfa, generate_pdfa_ps from ocrmypdf.pdfinfo import PdfInfo @@ -199,10 +197,7 @@ def test_creation_date_preserved(output_type, resources, infile, outpdf): # We expect that the modified date is quite recent date_after = decode_pdf_date(str(after['/ModDate'])) - assert ( - seconds_between_dates(date_after, datetime.datetime.now(timezone.utc)) - < 1000 - ) + assert seconds_between_dates(date_after, dt.datetime.now(dt.UTC)) < 1000 @pytest.fixture @@ -325,15 +320,15 @@ def test_kodak_toc(resources, outpdf): def test_metadata_fixup_warning(resources, outdir, caplog): - _parser, options, _pm = get_parser_options_plugins( + options, _pm = get_options_and_plugins( ['--output-type', 'pdfa-2', 'graph.pdf', 'out.pdf'] ) copyfile(resources / 'graph.pdf', outdir / 'graph.pdf') - context = PdfContext( - options, outdir, outdir / 'graph.pdf', None, get_plugin_manager([]) - ) + # Use the new setup function instead of get_plugin_manager directly + plugin_manager = setup_plugin_infrastructure([]) + context = PdfContext(options, outdir, outdir / 'graph.pdf', None, plugin_manager) metadata_fixup( working_file=outdir / 'graph.pdf', context=context, pdf_save_settings={} ) @@ -347,7 +342,7 @@ def test_metadata_fixup_warning(resources, outdir, caplog): graph.save(outdir / 'graph_mod.pdf') context = PdfContext( - options, outdir, outdir / 'graph_mod.pdf', None, get_plugin_manager([]) + options, outdir, outdir / 'graph_mod.pdf', None, plugin_manager ) metadata_fixup( working_file=outdir / 'graph.pdf', context=context, pdf_save_settings={} @@ -369,7 +364,7 @@ def test_prevent_gs_invalid_xml(resources, outdir): ) pdf.save(outdir / 'layers.rendered.pdf', fix_metadata_version=False) - _, options, _ = get_parser_options_plugins( + options, _ = get_options_and_plugins( args=[ '-j', '1', @@ -380,8 +375,11 @@ def test_prevent_gs_invalid_xml(resources, outdir): ] ) pdfinfo = PdfInfo(outdir / 'layers.rendered.pdf') + + # Use the new setup function + plugin_manager = setup_plugin_infrastructure([]) context = PdfContext( - options, outdir, outdir / 'layers.rendered.pdf', pdfinfo, get_plugin_manager([]) + options, outdir, outdir / 'layers.rendered.pdf', pdfinfo, plugin_manager ) convert_to_pdfa( diff --git a/tests/test_multi_font_manager.py b/tests/test_multi_font_manager.py new file mode 100644 index 00000000..0a69c0e9 --- /dev/null +++ b/tests/test_multi_font_manager.py @@ -0,0 +1,446 @@ +# SPDX-FileCopyrightText: 2025 James R. Barlow +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for MultiFontManager and FontProvider.""" + +from __future__ import annotations + +import logging +from pathlib import Path + +import pytest + +from ocrmypdf.font import BuiltinFontProvider, FontManager, MultiFontManager + + +@pytest.fixture +def font_dir(): + """Return path to font directory.""" + return Path(__file__).parent.parent / "src" / "ocrmypdf" / "data" + + +@pytest.fixture +def multi_font_manager(font_dir): + """Create MultiFontManager instance for testing.""" + return MultiFontManager(font_dir) + + +def has_cjk_font(manager: MultiFontManager) -> bool: + """Check if CJK font is available (from system).""" + return 'NotoSansCJK-Regular' in manager.fonts + + +def has_arabic_font(manager: MultiFontManager) -> bool: + """Check if Arabic font is available (from system).""" + return 'NotoSansArabic-Regular' in manager.fonts + + +def has_devanagari_font(manager: MultiFontManager) -> bool: + """Check if Devanagari font is available (from system).""" + return 'NotoSansDevanagari-Regular' in manager.fonts + + +# Marker for tests that require CJK fonts +requires_cjk = pytest.mark.skipif( + "not has_cjk_font(MultiFontManager())", + reason="CJK font not available (not installed on system)" +) + + +# --- MultiFontManager Initialization Tests --- + + +def test_init_loads_builtin_fonts(multi_font_manager): + """Test that initialization loads all expected builtin fonts.""" + # Only NotoSans-Regular and Occulta are bundled + assert 'NotoSans-Regular' in multi_font_manager.fonts + assert 'Occulta' in multi_font_manager.fonts + + # At least 2 builtin fonts should be loaded + assert len(multi_font_manager.fonts) >= 2 + + # Arabic, Devanagari, CJK are optional (system fonts) + + +def test_missing_font_directory(): + """Test that missing font directory raises error for fallback font.""" + with pytest.raises(FileNotFoundError): + MultiFontManager(Path("/nonexistent/path")) + + +# --- Arabic Script Language Tests --- +# These tests require Arabic fonts to be installed on the system + + +def test_select_font_for_arabic_language(multi_font_manager): + """Test font selection with Arabic language hint.""" + if not has_arabic_font(multi_font_manager): + pytest.skip("Arabic font not available") + font_manager = multi_font_manager.select_font_for_word("مرحبا", "ara") + assert font_manager == multi_font_manager.fonts['NotoSansArabic-Regular'] + + +def test_select_font_for_persian_language(multi_font_manager): + """Test font selection with Persian language hint.""" + if not has_arabic_font(multi_font_manager): + pytest.skip("Arabic font not available") + font_manager = multi_font_manager.select_font_for_word("سلام", "per") + assert font_manager == multi_font_manager.fonts['NotoSansArabic-Regular'] + + +def test_select_font_for_urdu_language(multi_font_manager): + """Test font selection with Urdu language hint.""" + if not has_arabic_font(multi_font_manager): + pytest.skip("Arabic font not available") + font_manager = multi_font_manager.select_font_for_word("ہیلو", "urd") + assert font_manager == multi_font_manager.fonts['NotoSansArabic-Regular'] + + +def test_farsi_language_code(multi_font_manager): + """Test that 'fas' (Farsi alternative code) maps to Arabic font.""" + if not has_arabic_font(multi_font_manager): + pytest.skip("Arabic font not available") + font_manager = multi_font_manager.select_font_for_word("سلام", "fas") + assert font_manager == multi_font_manager.fonts['NotoSansArabic-Regular'] + + +# --- Devanagari Script Language Tests --- +# These tests require Devanagari fonts to be installed on the system + + +def test_select_font_for_hindi_language(multi_font_manager): + """Test font selection with Hindi language hint.""" + if not has_devanagari_font(multi_font_manager): + pytest.skip("Devanagari font not available") + font_manager = multi_font_manager.select_font_for_word("नमस्ते", "hin") + assert font_manager == multi_font_manager.fonts['NotoSansDevanagari-Regular'] + + +def test_select_font_for_sanskrit_language(multi_font_manager): + """Test font selection with Sanskrit language hint.""" + if not has_devanagari_font(multi_font_manager): + pytest.skip("Devanagari font not available") + font_manager = multi_font_manager.select_font_for_word("संस्कृतम्", "san") + assert font_manager == multi_font_manager.fonts['NotoSansDevanagari-Regular'] + + +def test_select_font_for_marathi_language(multi_font_manager): + """Test font selection with Marathi language hint.""" + if not has_devanagari_font(multi_font_manager): + pytest.skip("Devanagari font not available") + font_manager = multi_font_manager.select_font_for_word("मराठी", "mar") + assert font_manager == multi_font_manager.fonts['NotoSansDevanagari-Regular'] + + +def test_select_font_for_nepali_language(multi_font_manager): + """Test font selection with Nepali language hint.""" + if not has_devanagari_font(multi_font_manager): + pytest.skip("Devanagari font not available") + font_manager = multi_font_manager.select_font_for_word("नेपाली", "nep") + assert font_manager == multi_font_manager.fonts['NotoSansDevanagari-Regular'] + + +# --- CJK Language Tests --- +# These tests require CJK fonts to be installed on the system + + +def test_select_font_for_chinese_language(multi_font_manager): + """Test font selection with Chinese language hint (ISO 639-3).""" + if not has_cjk_font(multi_font_manager): + pytest.skip("CJK font not available") + font_manager = multi_font_manager.select_font_for_word("你好", "zho") + assert font_manager == multi_font_manager.fonts['NotoSansCJK-Regular'] + + +def test_select_font_for_chinese_generic(multi_font_manager): + """Test font selection with generic Chinese language code.""" + if not has_cjk_font(multi_font_manager): + pytest.skip("CJK font not available") + font_manager = multi_font_manager.select_font_for_word("中文", "chi") + assert font_manager == multi_font_manager.fonts['NotoSansCJK-Regular'] + + +def test_select_font_for_chinese_simplified(multi_font_manager): + """Test font selection with Tesseract's chi_sim language code.""" + if not has_cjk_font(multi_font_manager): + pytest.skip("CJK font not available") + font_manager = multi_font_manager.select_font_for_word("简体字", "chi_sim") + assert font_manager == multi_font_manager.fonts['NotoSansCJK-Regular'] + + +def test_select_font_for_chinese_traditional(multi_font_manager): + """Test font selection with Tesseract's chi_tra language code.""" + if not has_cjk_font(multi_font_manager): + pytest.skip("CJK font not available") + font_manager = multi_font_manager.select_font_for_word("漢字", "chi_tra") + assert font_manager == multi_font_manager.fonts['NotoSansCJK-Regular'] + + +def test_select_font_for_japanese_language(multi_font_manager): + """Test font selection with Japanese language hint.""" + if not has_cjk_font(multi_font_manager): + pytest.skip("CJK font not available") + font_manager = multi_font_manager.select_font_for_word("こんにちは", "jpn") + assert font_manager == multi_font_manager.fonts['NotoSansCJK-Regular'] + + +def test_select_font_for_korean_language(multi_font_manager): + """Test font selection with Korean language hint.""" + if not has_cjk_font(multi_font_manager): + pytest.skip("CJK font not available") + font_manager = multi_font_manager.select_font_for_word("안녕하세요", "kor") + assert font_manager == multi_font_manager.fonts['NotoSansCJK-Regular'] + + +# --- Latin/English Tests --- + + +def test_select_font_for_english_text(multi_font_manager): + """Test font selection for English text.""" + font_manager = multi_font_manager.select_font_for_word("Hello World", "eng") + assert font_manager == multi_font_manager.fonts['NotoSans-Regular'] + + +def test_select_font_without_language_hint(multi_font_manager): + """Test font selection without language hint falls back to glyph checking.""" + font_manager = multi_font_manager.select_font_for_word("Hello", None) + assert font_manager == multi_font_manager.fonts['NotoSans-Regular'] + + +# --- Fallback Behavior Tests --- + + +def test_select_font_arabic_text_without_language_hint(multi_font_manager): + """Test that Arabic text is handled via fallback without language hint.""" + if not has_arabic_font(multi_font_manager): + pytest.skip("Arabic font not available") + font_manager = multi_font_manager.select_font_for_word("مرحبا", None) + # Should get NotoSansArabic-Regular via fallback chain glyph checking + assert font_manager == multi_font_manager.fonts['NotoSansArabic-Regular'] + + +def test_devanagari_text_without_language_hint(multi_font_manager): + """Test that Devanagari text is handled via fallback without language hint.""" + # NotoSans-Regular includes Devanagari glyphs, so it's selected first in fallback + font_manager = multi_font_manager.select_font_for_word("नमस्ते", None) + # Could be NotoSans-Regular or NotoSansDevanagari-Regular depending on availability + assert font_manager is not None + + +def test_cjk_text_without_language_hint(multi_font_manager): + """Test that CJK text is handled via fallback without language hint.""" + if not has_cjk_font(multi_font_manager): + pytest.skip("CJK font not available") + font_manager = multi_font_manager.select_font_for_word("你好", None) + assert font_manager == multi_font_manager.fonts['NotoSansCJK-Regular'] + + +def test_fallback_to_occulta_font(multi_font_manager): + """Test that unsupported characters fall back to Occulta.ttf.""" + # Use a character unlikely to be in any standard font + font_manager = multi_font_manager.select_font_for_word("test", "xyz") + # Should return some valid font + assert font_manager in multi_font_manager.fonts.values() + + +def test_fallback_fonts_constant(multi_font_manager): + """Test that FALLBACK_FONTS contains expected fonts.""" + # Check that core fonts are in fallback list + assert 'NotoSans-Regular' in MultiFontManager.FALLBACK_FONTS + assert 'NotoSansArabic-Regular' in MultiFontManager.FALLBACK_FONTS + assert 'NotoSansDevanagari-Regular' in MultiFontManager.FALLBACK_FONTS + assert 'NotoSansCJK-Regular' in MultiFontManager.FALLBACK_FONTS + + # Only NotoSans-Regular is bundled; other scripts are system fonts + assert 'NotoSans-Regular' in multi_font_manager.fonts + + +# --- Glyph Coverage Tests --- + + +def test_has_all_glyphs_for_english(multi_font_manager): + """Test glyph coverage checking for English text.""" + assert multi_font_manager.has_all_glyphs('NotoSans-Regular', "Hello World") + assert multi_font_manager.has_all_glyphs('NotoSans-Regular', "café") + + +def test_has_all_glyphs_for_arabic(multi_font_manager): + """Test glyph coverage checking for Arabic text.""" + if not has_arabic_font(multi_font_manager): + pytest.skip("Arabic font not available") + assert multi_font_manager.has_all_glyphs('NotoSansArabic-Regular', "مرحبا") + + +def test_has_all_glyphs_for_devanagari(multi_font_manager): + """Test glyph coverage checking for Devanagari text.""" + if not has_devanagari_font(multi_font_manager): + pytest.skip("Devanagari font not available") + assert multi_font_manager.has_all_glyphs('NotoSansDevanagari-Regular', "नमस्ते") + + +def test_has_all_glyphs_for_cjk(multi_font_manager): + """Test glyph coverage checking for CJK text.""" + if not has_cjk_font(multi_font_manager): + pytest.skip("CJK font not available") + assert multi_font_manager.has_all_glyphs('NotoSansCJK-Regular', "你好") + + +def test_empty_text_has_all_glyphs(multi_font_manager): + """Test that empty text returns True for glyph coverage.""" + assert multi_font_manager.has_all_glyphs('NotoSans-Regular', "") + + +def test_has_all_glyphs_missing_font(multi_font_manager): + """Test that has_all_glyphs returns False for non-existent font.""" + assert not multi_font_manager.has_all_glyphs('NonExistentFont', "test") + + +# --- Caching Tests --- + + +def test_font_selection_caching(multi_font_manager): + """Test that font selection results are cached.""" + font1 = multi_font_manager.select_font_for_word("Hello", "eng") + + cache_key = ("Hello", "eng") + assert cache_key in multi_font_manager._selection_cache + + font2 = multi_font_manager.select_font_for_word("Hello", "eng") + assert font1 == font2 + + +# --- Language Font Map Tests --- + + +def test_language_font_map_coverage(): + """Test that LANGUAGE_FONT_MAP has valid structure.""" + # Only NotoSans-Regular is bundled now + # This test just verifies the structure is valid + for font_name in MultiFontManager.LANGUAGE_FONT_MAP.values(): + # All font names should be valid strings + assert isinstance(font_name, str) + assert font_name.startswith('NotoSans') + + +# --- get_all_fonts Tests --- + + +def test_get_all_fonts(multi_font_manager): + """Test get_all_fonts returns all loaded fonts.""" + all_fonts = multi_font_manager.get_all_fonts() + assert isinstance(all_fonts, dict) + # At least 2 builtin fonts should be loaded (NotoSans-Regular and Occulta) + assert len(all_fonts) >= 2 + assert 'NotoSans-Regular' in all_fonts + assert 'Occulta' in all_fonts + # Arabic, Devanagari, CJK are optional (system fonts) + + +# --- FontProvider Tests --- + + +class MockFontProvider: + """Mock FontProvider for testing missing fonts.""" + + def __init__( + self, available_fonts: dict[str, FontManager], fallback: FontManager + ): + """Initialize mock font provider with given fonts.""" + self._fonts = available_fonts + self._fallback = fallback + + def get_font(self, font_name: str) -> FontManager | None: + return self._fonts.get(font_name) + + def get_available_fonts(self) -> list[str]: + return list(self._fonts.keys()) + + def get_fallback_font(self) -> FontManager: + return self._fallback + + +def test_custom_font_provider(font_dir): + """Test that custom FontProvider can be injected.""" + fonts = { + 'NotoSans-Regular': FontManager(font_dir / 'NotoSans-Regular.ttf'), + 'Occulta': FontManager(font_dir / 'Occulta.ttf'), + } + provider = MockFontProvider(fonts, fonts['Occulta']) + + manager = MultiFontManager(font_provider=provider) + + # Should only have the fonts we provided + assert len(manager.fonts) == 2 + assert 'NotoSans-Regular' in manager.fonts + assert 'Occulta' in manager.fonts + + +def test_missing_font_uses_fallback(font_dir): + """Test that missing fonts gracefully fall back.""" + fonts = { + 'NotoSans-Regular': FontManager(font_dir / 'NotoSans-Regular.ttf'), + 'Occulta': FontManager(font_dir / 'Occulta.ttf'), + } + provider = MockFontProvider(fonts, fonts['Occulta']) + + manager = MultiFontManager(font_provider=provider) + + # Arabic text should fall back to Occulta since NotoSansArabic is missing + font = manager.select_font_for_word("مرحبا", "ara") + assert font == fonts['Occulta'] + + +def test_builtin_font_provider_loads_expected_fonts(font_dir): + """Test BuiltinFontProvider loads all expected builtin fonts.""" + provider = BuiltinFontProvider(font_dir) + + available = provider.get_available_fonts() + assert 'NotoSans-Regular' in available + assert 'Occulta' in available + # Only Latin (NotoSans) and glyphless fallback (Occulta) are bundled. + # All other scripts (Arabic, Devanagari, CJK, etc.) are discovered + # from system fonts by SystemFontProvider to reduce package size. + assert len(available) == 2 + + +def test_builtin_font_provider_get_font(font_dir): + """Test BuiltinFontProvider.get_font returns correct fonts.""" + provider = BuiltinFontProvider(font_dir) + + font = provider.get_font('NotoSans-Regular') + assert font is not None + assert isinstance(font, FontManager) + + missing = provider.get_font('NonExistent') + assert missing is None + + +def test_builtin_font_provider_get_fallback(font_dir): + """Test BuiltinFontProvider.get_fallback_font returns Occulta font.""" + provider = BuiltinFontProvider(font_dir) + + fallback = provider.get_fallback_font() + assert fallback is not None + assert fallback == provider.get_font('Occulta') + + +def test_builtin_font_provider_missing_font_logs_warning(tmp_path, font_dir, caplog): + """Test that missing expected fonts log a warning.""" + # Create minimal font directory with only Occulta.ttf + (tmp_path / 'Occulta.ttf').write_bytes((font_dir / 'Occulta.ttf').read_bytes()) + + with caplog.at_level(logging.WARNING): + provider = BuiltinFontProvider(tmp_path) + + # Should have logged warnings for missing fonts + assert 'NotoSans-Regular' in caplog.text + assert 'not found' in caplog.text + + # But Occulta should be loaded + assert provider.get_fallback_font() is not None + + +def test_builtin_font_provider_missing_occulta_raises(tmp_path): + """Test that missing Occulta.ttf raises FileNotFoundError.""" + with pytest.raises(FileNotFoundError, match="Required fallback font"): + BuiltinFontProvider(tmp_path) diff --git a/tests/test_multilingual_direct.py b/tests/test_multilingual_direct.py new file mode 100644 index 00000000..113e29d2 --- /dev/null +++ b/tests/test_multilingual_direct.py @@ -0,0 +1,614 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2025 James R. Barlow +# SPDX-License-Identifier: MPL-2.0 + +"""Direct tests for multilingual text rendering with fpdf2 renderer. + +This tests the fpdf2 renderer with various language groups: +- Latin (English, French, German with diacritics) +- Arabic (Arabic, Persian - RTL scripts) +- CJK (Chinese Simplified/Traditional, Japanese, Korean) +- Devanagari (Hindi, Sanskrit) +""" +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + +import pytest + +from ocrmypdf.font import MultiFontManager +from ocrmypdf.fpdf_renderer import DebugRenderOptions, Fpdf2PdfRenderer +from ocrmypdf.hocrtransform.hocr_parser import HocrParser + +RESOURCES = Path(__file__).parent / "resources" + + +@pytest.fixture +def pdftotext(): + """Return a function to extract text from PDF using pdftotext. + + Skips the test if pdftotext is not available. + """ + pdftotext_path = shutil.which('pdftotext') + if pdftotext_path is None: + pytest.skip("pdftotext not available") + + def extract_text(pdf_path: Path) -> str: + return subprocess.check_output( + ['pdftotext', '-enc', 'UTF-8', str(pdf_path), '-'], + text=True, + encoding='utf-8', + ) + + return extract_text + + +@pytest.fixture +def font_dir(): + """Return path to font directory.""" + return Path(__file__).parent.parent / "src" / "ocrmypdf" / "data" + + +@pytest.fixture +def multi_font_manager(font_dir): + """Create MultiFontManager instance for testing.""" + return MultiFontManager(font_dir) + + +@pytest.fixture +def multi_font_manager_arabic(font_dir): + """Create MultiFontManager instance for testing, with Arabic.""" + mfm = MultiFontManager(font_dir) + if not mfm.has_font("NotoSansArabic-Regular"): + pytest.skip("NotoSansArabic font not available") + return mfm + + +# ============================================================================= +# Latin Script Tests +# ============================================================================= + + +class TestLatinScript: + """Tests for Latin script (English, French, German, etc.).""" + + @pytest.fixture + def latin_hocr(self): + """Return path to Latin HOCR test file.""" + return RESOURCES / "latin.hocr" + + def test_render_latin_basic( + self, latin_hocr, multi_font_manager, tmp_path, pdftotext + ): + """Test rendering Latin script with various diacritics.""" + parser = HocrParser(latin_hocr) + page = parser.parse() + + assert page is not None + paras = list(page.paragraphs) + assert len(paras) == 3 # English, French, German + + # Check languages + assert paras[0].language == 'eng' + assert paras[1].language == 'fra' + assert paras[2].language == 'deu' + + # Render to PDF + output_pdf = tmp_path / "latin_output.pdf" + renderer = Fpdf2PdfRenderer( + page=page, + dpi=300.0, + multi_font_manager=multi_font_manager, + invisible_text=False, + ) + renderer.render(output_pdf) + + assert output_pdf.exists() + assert output_pdf.stat().st_size > 0 + + # Extract text and verify + text = pdftotext(output_pdf) + + # English words + assert 'quick' in text or 'brown' in text or 'fox' in text + + # French with diacritics + assert 'Café' in text or 'résumé' in text or 'naïve' in text + + # German with umlauts and eszett + assert 'Größe' in text or 'Zürich' in text or 'Ärger' in text + + def test_latin_font_selection(self, latin_hocr, multi_font_manager): + """Test that NotoSans is selected for Latin text.""" + parser = HocrParser(latin_hocr) + page = parser.parse() + + for line in page.lines: + for word in line.children: + if word.text: + font = multi_font_manager.select_font_for_word( + word.text, line.language + ) + assert font is not None + # Latin text should use NotoSans-Regular + assert multi_font_manager.has_all_glyphs( + 'NotoSans-Regular', word.text + ) + + +# ============================================================================= +# Arabic Script Tests +# ============================================================================= + + +class TestArabicScript: + """Tests for Arabic script (Arabic, Persian, etc.).""" + + @pytest.fixture + def arabic_hocr(self): + """Return path to Arabic HOCR test file.""" + return RESOURCES / "arabic.hocr" + + def test_render_arabic_basic( + self, arabic_hocr, multi_font_manager_arabic, tmp_path, pdftotext + ): + """Test rendering Arabic script text.""" + parser = HocrParser(arabic_hocr) + page = parser.parse() + + assert page is not None + paras = list(page.paragraphs) + assert len(paras) == 3 # Arabic paragraphs and Persian + + # Render to PDF + output_pdf = tmp_path / "arabic_output.pdf" + renderer = Fpdf2PdfRenderer( + page=page, + dpi=300.0, + multi_font_manager=multi_font_manager_arabic, + invisible_text=False, + ) + renderer.render(output_pdf) + + assert output_pdf.exists() + assert output_pdf.stat().st_size > 0 + + # Extract text and verify Arabic content + text = pdftotext(output_pdf) + + # Arabic words: مرحبا بالعالم (Hello world) + assert 'مرحبا' in text or 'بالعالم' in text + # هذا نص عربي (This is Arabic text) + assert 'عربي' in text or 'نص' in text + + def test_arabic_font_selection(self, arabic_hocr, multi_font_manager_arabic): + """Test that NotoSansArabic is selected for Arabic text.""" + parser = HocrParser(arabic_hocr) + page = parser.parse() + + for line in page.lines: + for word in line.children: + if word.text and line.language in ('ara', 'per'): + font = multi_font_manager_arabic.select_font_for_word( + word.text, line.language + ) + assert font is not None + # Arabic text should use NotoSansArabic + assert multi_font_manager_arabic.has_all_glyphs( + 'NotoSansArabic-Regular', word.text + ), f"NotoSansArabic cannot render '{word.text}'" + + def test_arabic_rtl_handling(self, arabic_hocr): + """Test that RTL direction is correctly parsed from hOCR.""" + parser = HocrParser(arabic_hocr) + page = parser.parse() + + for para in page.paragraphs: + if para.language in ('ara', 'per'): + # Arabic paragraphs should have RTL direction + assert ( + para.direction == 'rtl' + ), "Arabic paragraph should have RTL direction" + + +# ============================================================================= +# CJK Script Tests +# ============================================================================= + + +def _latin_font_works(multi_font_manager) -> bool: + """Check if Latin font is available.""" + return multi_font_manager.has_font('NotoSans-Regular') + + +def _arabic_font_works(multi_font_manager) -> bool: + """Check if Arabic font is available.""" + return multi_font_manager.has_font('NotoSansArabic-Regular') + + +def _devanagari_font_works(multi_font_manager) -> bool: + """Check if Devanagari font is available.""" + return multi_font_manager.has_font('NotoSansDevanagari-Regular') + + +def _cjk_font_works(multi_font_manager) -> bool: + """Check if CJK font is working (not corrupted).""" + return multi_font_manager.has_font('NotoSansCJK-Regular') + + +class TestCJKScript: + """Tests for CJK scripts (Chinese, Japanese, Korean).""" + + @pytest.fixture + def cjk_hocr(self): + """Return path to CJK HOCR test file.""" + return RESOURCES / "cjk.hocr" + + def test_render_cjk_basic(self, cjk_hocr, multi_font_manager, tmp_path, pdftotext): + """Test rendering CJK script text.""" + if not _cjk_font_works(multi_font_manager): + pytest.skip("CJK font not available or corrupted") + + parser = HocrParser(cjk_hocr) + page = parser.parse() + + assert page is not None + paras = list(page.paragraphs) + assert len(paras) == 4 # Chinese Simplified, Traditional, Japanese, Korean + + # Check languages + languages = [p.language for p in paras] + assert 'chi_sim' in languages + assert 'chi_tra' in languages + assert 'jpn' in languages + assert 'kor' in languages + + # Render to PDF + output_pdf = tmp_path / "cjk_output.pdf" + renderer = Fpdf2PdfRenderer( + page=page, + dpi=300.0, + multi_font_manager=multi_font_manager, + invisible_text=False, + ) + renderer.render(output_pdf) + + assert output_pdf.exists() + assert output_pdf.stat().st_size > 0 + + # Extract text and verify CJK content + text = pdftotext(output_pdf) + + # Chinese: 你好 世界 (Hello world) + assert '你好' in text or '世界' in text + # Japanese: こんにちは (Hello) + assert 'こんにちは' in text or '世界' in text + # Korean: 안녕하세요 (Hello) + assert '안녕하세요' in text or '세계' in text + + def test_cjk_font_selection(self, cjk_hocr, multi_font_manager): + """Test that NotoSansCJK is selected for CJK text.""" + if not _cjk_font_works(multi_font_manager): + pytest.skip("CJK font not available or corrupted") + + parser = HocrParser(cjk_hocr) + page = parser.parse() + + cjk_languages = {'chi_sim', 'chi_tra', 'jpn', 'kor', 'zho', 'chi'} + + for line in page.lines: + for word in line.children: + if word.text and line.language in cjk_languages: + font = multi_font_manager.select_font_for_word( + word.text, line.language + ) + assert font is not None + # CJK text should use NotoSansCJK + assert multi_font_manager.has_all_glyphs( + 'NotoSansCJK-Regular', word.text + ), f"NotoSansCJK cannot render '{word.text}'" + + +# ============================================================================= +# Devanagari Script Tests +# ============================================================================= + + +class TestDevanagariScript: + """Tests for Devanagari script (Hindi, Sanskrit, etc.).""" + + @pytest.fixture + def devanagari_hocr(self): + """Return path to Devanagari HOCR test file.""" + return RESOURCES / "devanagari.hocr" + + def test_render_devanagari_basic( + self, devanagari_hocr, multi_font_manager, tmp_path, pdftotext + ): + """Test rendering Devanagari script text.""" + parser = HocrParser(devanagari_hocr) + page = parser.parse() + + assert page is not None + paras = list(page.paragraphs) + assert len(paras) == 3 # Hindi paragraphs and Sanskrit + + # Render to PDF + output_pdf = tmp_path / "devanagari_output.pdf" + renderer = Fpdf2PdfRenderer( + page=page, + dpi=300.0, + multi_font_manager=multi_font_manager, + invisible_text=False, + ) + renderer.render(output_pdf) + + assert output_pdf.exists() + assert output_pdf.stat().st_size > 0 + + # Extract text and verify Devanagari content + text = pdftotext(output_pdf) + + # Hindi: नमस्ते दुनिया (Hello world) + assert 'नमस्ते' in text or 'दुनिया' in text + # यह हिंदी पाठ है (This is Hindi text) + assert 'हिंदी' in text or 'पाठ' in text + + def test_devanagari_font_selection(self, devanagari_hocr, multi_font_manager): + """Test that NotoSansDevanagari is selected for Devanagari text.""" + if not multi_font_manager.has_font('NotoSansDevanagari-Regular'): + pytest.skip("Devanagari font not available") + parser = HocrParser(devanagari_hocr) + page = parser.parse() + + devanagari_languages = {'hin', 'san', 'mar', 'nep'} + + for line in page.lines: + for word in line.children: + if word.text and line.language in devanagari_languages: + font = multi_font_manager.select_font_for_word( + word.text, line.language + ) + assert font is not None + # Devanagari text should use NotoSansDevanagari + assert multi_font_manager.has_all_glyphs( + 'NotoSansDevanagari-Regular', word.text + ), f"NotoSansDevanagari cannot render '{word.text}'" + + +# ============================================================================= +# Mixed Language / Multilingual Tests +# ============================================================================= + + +class TestMultilingual: + """Tests for mixed-language documents.""" + + @pytest.fixture + def multilingual_hocr(self): + """Return path to multilingual HOCR test file.""" + return RESOURCES / "multilingual.hocr" + + def test_render_multilingual_hocr_basic( + self, multilingual_hocr, multi_font_manager_arabic, tmp_path, pdftotext + ): + """Test rendering multilingual HOCR file with English and Arabic text.""" + parser = HocrParser(multilingual_hocr) + page = parser.parse() + + assert page is not None + assert len(list(page.paragraphs)) == 2 # English and Arabic paragraphs + + # Check languages + paras = list(page.paragraphs) + assert paras[0].language == 'eng' + assert paras[1].language == 'ara' + + # Render to PDF + output_pdf = tmp_path / "multilingual_output.pdf" + renderer = Fpdf2PdfRenderer( + page=page, + dpi=300.0, + multi_font_manager=multi_font_manager_arabic, + invisible_text=False, + ) + renderer.render(output_pdf) + + assert output_pdf.exists() + assert output_pdf.stat().st_size > 0 + + # Extract text from PDF + text = pdftotext(output_pdf) + + # Verify both English and Arabic text are present + assert 'English' in text or 'Text' in text or 'Here' in text + # Arabic text: مرحبا بك + assert 'مرحبا' in text or 'بك' in text + + def test_render_multilingual_with_debug_options( + self, multilingual_hocr, multi_font_manager, tmp_path + ): + """Test rendering with debug visualization enabled.""" + parser = HocrParser(multilingual_hocr) + page = parser.parse() + + # Render with debug options + output_pdf = tmp_path / "multilingual_debug.pdf" + debug_options = DebugRenderOptions( + render_baseline=True, + render_line_bbox=True, + render_word_bbox=True, + ) + renderer = Fpdf2PdfRenderer( + page=page, + dpi=300.0, + multi_font_manager=multi_font_manager, + invisible_text=False, + debug_render_options=debug_options, + ) + renderer.render(output_pdf) + + assert output_pdf.exists() + assert output_pdf.stat().st_size > 0 + + def test_multilingual_invisible_text( + self, multilingual_hocr, multi_font_manager, tmp_path, pdftotext + ): + """Test rendering with invisible text (default OCR mode).""" + parser = HocrParser(multilingual_hocr) + page = parser.parse() + + # Render with invisible text (standard for OCR layer) + output_pdf = tmp_path / "multilingual_invisible.pdf" + renderer = Fpdf2PdfRenderer( + page=page, + dpi=300.0, + multi_font_manager=multi_font_manager, + invisible_text=True, + ) + renderer.render(output_pdf) + + assert output_pdf.exists() + + # Text should still be extractable even though invisible + text = pdftotext(output_pdf) + assert len(text.strip()) > 0 + + def test_multilingual_font_selection( + self, multilingual_hocr, multi_font_manager_arabic + ): + """Test that correct fonts are selected for each language.""" + parser = HocrParser(multilingual_hocr) + page = parser.parse() + + # Get all words + words = [] + for line in page.lines: + for word in line.children: + if word.text: + words.append((word.text, line.language)) + + # Verify we have both English and Arabic words + eng_words = [w for w, lang in words if lang == 'eng'] + ara_words = [w for w, lang in words if lang == 'ara'] + + assert len(eng_words) > 0, "Should have English words" + assert len(ara_words) > 0, "Should have Arabic words" + + # Test font selection + for text, lang in words: + font_mgr = multi_font_manager_arabic.select_font_for_word(text, lang) + assert font_mgr is not None, f"No font selected for '{text}' ({lang})" + + if lang == 'ara': + assert multi_font_manager_arabic.has_all_glyphs( + 'NotoSansArabic-Regular', text + ), f"NotoSansArabic cannot render '{text}'" + + +# ============================================================================= +# Baseline and Structure Tests +# ============================================================================= + + +class TestBaselineHandling: + """Tests for baseline and hOCR structure handling.""" + + @pytest.fixture + def multilingual_hocr(self): + """Return path to multilingual HOCR test file.""" + return RESOURCES / "multilingual.hocr" + + def test_multilingual_baseline_handling(self, multilingual_hocr): + """Test that baseline information is correctly parsed from hOCR.""" + parser = HocrParser(multilingual_hocr) + page = parser.parse() + + for line in page.lines: + if line.baseline: + # Baseline should be reasonable + assert ( + -1.0 <= line.baseline.slope <= 1.0 + ), "Baseline slope should be reasonable" + + +# ============================================================================= +# Font Coverage Tests +# ============================================================================= + + +class TestFontCoverage: + """Tests verifying font coverage for various scripts.""" + + def test_noto_sans_latin_coverage(self, multi_font_manager): + """Test NotoSans covers common Latin characters and diacritics.""" + if not _latin_font_works(multi_font_manager): + pytest.skip("NotoSans font not available") + + latin_samples = [ + "Hello World", + "Café résumé naïve", + "Größe Zürich Ärger", + "ÀÁÂÃÄÅÆÇÈÉÊË", + "àáâãäåæçèéêë", + ] + + for sample in latin_samples: + assert multi_font_manager.has_all_glyphs( + 'NotoSans-Regular', sample + ), f"NotoSans should cover: {sample}" + + def test_noto_sans_arabic_coverage(self, multi_font_manager_arabic): + """Test NotoSansArabic covers Arabic characters.""" + arabic_samples = [ + "مرحبا", # Hello + "بالعالم", # World + "العربية", # Arabic + ] + + for sample in arabic_samples: + assert multi_font_manager_arabic.has_all_glyphs( + 'NotoSansArabic-Regular', sample + ), f"NotoSansArabic should cover: {sample}" + + def test_noto_sans_devanagari_coverage(self, multi_font_manager): + """Test NotoSansDevanagari covers Devanagari characters.""" + if not _devanagari_font_works(multi_font_manager): + pytest.skip("NotoSansDevanagari font not available") + + devanagari_samples = [ + "नमस्ते", # Hello + "हिंदी", # Hindi + "संस्कृत", # Sanskrit + ] + + for sample in devanagari_samples: + assert multi_font_manager.has_all_glyphs( + 'NotoSansDevanagari-Regular', sample + ), f"NotoSansDevanagari should cover: {sample}" + + def test_noto_sans_cjk_coverage(self, multi_font_manager): + """Test NotoSansCJK covers CJK characters.""" + if not _cjk_font_works(multi_font_manager): + pytest.skip("CJK font not available or corrupted") + + cjk_samples = [ + "你好", # Chinese: Hello + "世界", # Chinese: World + "こんにちは", # Japanese: Hello + "안녕하세요", # Korean: Hello + ] + + for sample in cjk_samples: + assert multi_font_manager.has_all_glyphs( + 'NotoSansCJK-Regular', sample + ), f"NotoSansCJK should cover: {sample}" + + +if __name__ == "__main__": + # Allow running this test directly for quick iteration + import sys + + sys.exit(pytest.main([__file__, "-v", "-s"])) diff --git a/tests/test_null_ocr_engine.py b/tests/test_null_ocr_engine.py new file mode 100644 index 00000000..7798ccce --- /dev/null +++ b/tests/test_null_ocr_engine.py @@ -0,0 +1,169 @@ +# SPDX-FileCopyrightText: 2025 James R. Barlow +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for NullOcrEngine (--ocr-engine none). + +Tests verify that the Null OCR engine exists and functions correctly +for scenarios where users want PDF processing without OCR. +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + + +class TestNullOcrEngineExists: + """Test that NullOcrEngine plugin exists and is loadable.""" + + def test_null_ocr_module_importable(self): + """null_ocr module should be importable.""" + from ocrmypdf.builtin_plugins import null_ocr + + assert null_ocr is not None + + def test_null_ocr_engine_class_exists(self): + """NullOcrEngine class should exist.""" + from ocrmypdf.builtin_plugins.null_ocr import NullOcrEngine + + assert NullOcrEngine is not None + + +class TestNullOcrEngineInterface: + """Test NullOcrEngine implements OcrEngine interface.""" + + def test_version_returns_none(self): + """NullOcrEngine.version() should return 'none'.""" + from ocrmypdf.builtin_plugins.null_ocr import NullOcrEngine + + assert NullOcrEngine.version() == "none" + + def test_creator_tag(self): + """NullOcrEngine.creator_tag() should indicate no OCR.""" + from ocrmypdf.builtin_plugins.null_ocr import NullOcrEngine + + tag = NullOcrEngine.creator_tag(MagicMock()) + tag_lower = tag.lower() + assert "no ocr" in tag_lower or "null" in tag_lower or "none" in tag_lower + + def test_languages_returns_empty_set(self): + """NullOcrEngine.languages() should return empty set.""" + from ocrmypdf.builtin_plugins.null_ocr import NullOcrEngine + + langs = NullOcrEngine.languages(MagicMock()) + assert langs == set() + + def test_supports_generate_ocr_returns_true(self): + """NullOcrEngine should support generate_ocr().""" + from ocrmypdf.builtin_plugins.null_ocr import NullOcrEngine + + assert NullOcrEngine.supports_generate_ocr() is True + + def test_get_orientation_returns_zero(self): + """NullOcrEngine.get_orientation() should return angle=0.""" + from ocrmypdf.builtin_plugins.null_ocr import NullOcrEngine + + result = NullOcrEngine.get_orientation(Path("test.png"), MagicMock()) + assert result.angle == 0 + + def test_get_deskew_returns_zero(self): + """NullOcrEngine.get_deskew() should return 0.0.""" + from ocrmypdf.builtin_plugins.null_ocr import NullOcrEngine + + result = NullOcrEngine.get_deskew(Path("test.png"), MagicMock()) + assert result == 0.0 + + +class TestNullOcrEngineGenerateOcr: + """Test NullOcrEngine.generate_ocr() output.""" + + @pytest.fixture + def sample_image(self, tmp_path): + """Create a simple test image.""" + from PIL import Image + + img_path = tmp_path / "test.png" + img = Image.new('RGB', (100, 100), color='white') + img.save(img_path, dpi=(300, 300)) + return img_path + + def test_generate_ocr_returns_tuple(self, sample_image): + """generate_ocr() should return (OcrElement, str) tuple.""" + from ocrmypdf import OcrElement + from ocrmypdf.builtin_plugins.null_ocr import NullOcrEngine + + result = NullOcrEngine.generate_ocr(sample_image, MagicMock(), 0) + + assert isinstance(result, tuple) + assert len(result) == 2 + assert isinstance(result[0], OcrElement) + assert isinstance(result[1], str) + + def test_generate_ocr_returns_empty_text(self, sample_image): + """generate_ocr() should return empty text string.""" + from ocrmypdf.builtin_plugins.null_ocr import NullOcrEngine + + _, text = NullOcrEngine.generate_ocr(sample_image, MagicMock(), 0) + + assert text == "" + + def test_generate_ocr_returns_page_element(self, sample_image): + """generate_ocr() should return OcrElement with ocr_class PAGE.""" + from ocrmypdf import OcrClass + from ocrmypdf.builtin_plugins.null_ocr import NullOcrEngine + + ocr_tree, _ = NullOcrEngine.generate_ocr(sample_image, MagicMock(), 0) + + assert ocr_tree.ocr_class == OcrClass.PAGE + + def test_generate_ocr_page_has_correct_dimensions(self, sample_image): + """generate_ocr() page element should have image dimensions.""" + from ocrmypdf.builtin_plugins.null_ocr import NullOcrEngine + + ocr_tree, _ = NullOcrEngine.generate_ocr(sample_image, MagicMock(), 0) + + # Image is 100x100 + assert ocr_tree.bbox.right == 100 + assert ocr_tree.bbox.bottom == 100 + + +class TestOcrEngineOption: + """Test --ocr-engine CLI option.""" + + def test_ocr_engine_option_accepted(self): + """CLI should accept --ocr-engine option.""" + from ocrmypdf.cli import get_parser + + parser = get_parser() + + # Should not raise + args = parser.parse_args(['--ocr-engine', 'none', 'in.pdf', 'out.pdf']) + assert args.ocr_engine == 'none' + + def test_ocr_engine_choices_include_none(self): + """--ocr-engine should include 'none' as a choice.""" + from ocrmypdf.cli import get_parser + + parser = get_parser() + + # Find the --ocr-engine action + for action in parser._actions: + if '--ocr-engine' in action.option_strings: + assert 'none' in action.choices + break + else: + pytest.fail("--ocr-engine option not found") + + def test_ocr_engine_choices_include_auto(self): + """--ocr-engine should include 'auto' as default.""" + from ocrmypdf.cli import get_parser + + parser = get_parser() + + for action in parser._actions: + if '--ocr-engine' in action.option_strings: + assert 'auto' in action.choices + assert action.default == 'auto' + break diff --git a/tests/test_ocr_element.py b/tests/test_ocr_element.py new file mode 100644 index 00000000..d5785fd7 --- /dev/null +++ b/tests/test_ocr_element.py @@ -0,0 +1,244 @@ +# SPDX-FileCopyrightText: 2025 James R. Barlow +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for OcrElement dataclass and related classes.""" + +from __future__ import annotations + +import pytest + +from ocrmypdf.hocrtransform import ( + Baseline, + BoundingBox, + FontInfo, + OcrClass, + OcrElement, +) + + +class TestBoundingBox: + """Tests for BoundingBox dataclass.""" + + def test_basic_creation(self): + bbox = BoundingBox(left=10, top=20, right=100, bottom=50) + assert bbox.left == 10 + assert bbox.top == 20 + assert bbox.right == 100 + assert bbox.bottom == 50 + + def test_width_height(self): + bbox = BoundingBox(left=10, top=20, right=110, bottom=70) + assert bbox.width == 100 + assert bbox.height == 50 + + def test_zero_size_box(self): + bbox = BoundingBox(left=10, top=20, right=10, bottom=20) + assert bbox.width == 0 + assert bbox.height == 0 + + def test_invalid_left_right(self): + with pytest.raises(ValueError, match="right.*left"): + BoundingBox(left=100, top=20, right=10, bottom=50) + + def test_invalid_top_bottom(self): + with pytest.raises(ValueError, match="bottom.*top"): + BoundingBox(left=10, top=50, right=100, bottom=20) + + +class TestBaseline: + """Tests for Baseline dataclass.""" + + def test_defaults(self): + baseline = Baseline() + assert baseline.slope == 0.0 + assert baseline.intercept == 0.0 + + def test_with_values(self): + baseline = Baseline(slope=0.01, intercept=-5) + assert baseline.slope == 0.01 + assert baseline.intercept == -5 + + +class TestFontInfo: + """Tests for FontInfo dataclass.""" + + def test_defaults(self): + font = FontInfo() + assert font.name is None + assert font.size is None + assert font.bold is False + assert font.italic is False + + def test_with_values(self): + font = FontInfo(name="Arial", size=12.0, bold=True) + assert font.name == "Arial" + assert font.size == 12.0 + assert font.bold is True + assert font.italic is False + + +class TestOcrElement: + """Tests for OcrElement dataclass.""" + + def test_minimal_element(self): + elem = OcrElement(ocr_class=OcrClass.WORD, text="hello") + assert elem.ocr_class == "ocrx_word" + assert elem.text == "hello" + assert elem.bbox is None + assert elem.children == [] + + def test_element_with_bbox(self): + bbox = BoundingBox(left=0, top=0, right=100, bottom=50) + elem = OcrElement(ocr_class=OcrClass.LINE, bbox=bbox) + assert elem.bbox == bbox + assert elem.bbox.width == 100 + + def test_element_hierarchy(self): + word1 = OcrElement(ocr_class=OcrClass.WORD, text="Hello") + word2 = OcrElement(ocr_class=OcrClass.WORD, text="World") + line = OcrElement(ocr_class=OcrClass.LINE, children=[word1, word2]) + paragraph = OcrElement(ocr_class=OcrClass.PARAGRAPH, children=[line]) + page = OcrElement(ocr_class=OcrClass.PAGE, children=[paragraph]) + + assert len(page.children) == 1 + assert len(page.children[0].children) == 1 + assert len(page.children[0].children[0].children) == 2 + + def test_iter_by_class_single(self): + word = OcrElement(ocr_class=OcrClass.WORD, text="test") + line = OcrElement(ocr_class=OcrClass.LINE, children=[word]) + page = OcrElement(ocr_class=OcrClass.PAGE, children=[line]) + + words = page.iter_by_class(OcrClass.WORD) + assert len(words) == 1 + assert words[0].text == "test" + + def test_iter_by_class_multiple(self): + words = [ + OcrElement(ocr_class=OcrClass.WORD, text="one"), + OcrElement(ocr_class=OcrClass.WORD, text="two"), + OcrElement(ocr_class=OcrClass.WORD, text="three"), + ] + line = OcrElement(ocr_class=OcrClass.LINE, children=words) + page = OcrElement(ocr_class=OcrClass.PAGE, children=[line]) + + result = page.iter_by_class(OcrClass.WORD) + assert len(result) == 3 + assert [w.text for w in result] == ["one", "two", "three"] + + def test_iter_by_class_multiple_types(self): + line = OcrElement(ocr_class=OcrClass.LINE) + header = OcrElement(ocr_class=OcrClass.HEADER) + caption = OcrElement(ocr_class=OcrClass.CAPTION) + page = OcrElement(ocr_class=OcrClass.PAGE, children=[line, header, caption]) + + result = page.iter_by_class(OcrClass.LINE, OcrClass.HEADER) + assert len(result) == 2 + + def test_find_by_class(self): + word = OcrElement(ocr_class=OcrClass.WORD, text="found") + line = OcrElement(ocr_class=OcrClass.LINE, children=[word]) + page = OcrElement(ocr_class=OcrClass.PAGE, children=[line]) + + result = page.find_by_class(OcrClass.WORD) + assert result is not None + assert result.text == "found" + + def test_find_by_class_not_found(self): + line = OcrElement(ocr_class=OcrClass.LINE) + page = OcrElement(ocr_class=OcrClass.PAGE, children=[line]) + + result = page.find_by_class(OcrClass.WORD) + assert result is None + + def test_get_text_recursive_leaf(self): + word = OcrElement(ocr_class=OcrClass.WORD, text="hello") + assert word.get_text_recursive() == "hello" + + def test_get_text_recursive_nested(self): + word1 = OcrElement(ocr_class=OcrClass.WORD, text="Hello") + word2 = OcrElement(ocr_class=OcrClass.WORD, text="World") + line = OcrElement(ocr_class=OcrClass.LINE, children=[word1, word2]) + + assert line.get_text_recursive() == "Hello World" + + def test_words_property(self): + words = [ + OcrElement(ocr_class=OcrClass.WORD, text="a"), + OcrElement(ocr_class=OcrClass.WORD, text="b"), + ] + line = OcrElement(ocr_class=OcrClass.LINE, children=words) + page = OcrElement(ocr_class=OcrClass.PAGE, children=[line]) + + assert len(page.words) == 2 + assert page.words[0].text == "a" + + def test_lines_property(self): + line1 = OcrElement(ocr_class=OcrClass.LINE) + line2 = OcrElement(ocr_class=OcrClass.HEADER) # Also a line type + par = OcrElement(ocr_class=OcrClass.PARAGRAPH, children=[line1, line2]) + page = OcrElement(ocr_class=OcrClass.PAGE, children=[par]) + + assert len(page.lines) == 2 + + def test_paragraphs_property(self): + par1 = OcrElement(ocr_class=OcrClass.PARAGRAPH) + par2 = OcrElement(ocr_class=OcrClass.PARAGRAPH) + page = OcrElement(ocr_class=OcrClass.PAGE, children=[par1, par2]) + + assert len(page.paragraphs) == 2 + + def test_direction_ltr(self): + elem = OcrElement(ocr_class=OcrClass.PARAGRAPH, direction="ltr") + assert elem.direction == "ltr" + + def test_direction_rtl(self): + elem = OcrElement(ocr_class=OcrClass.PARAGRAPH, direction="rtl") + assert elem.direction == "rtl" + + def test_language(self): + elem = OcrElement(ocr_class=OcrClass.PARAGRAPH, language="eng") + assert elem.language == "eng" + + def test_baseline(self): + baseline = Baseline(slope=0.01, intercept=-3) + elem = OcrElement(ocr_class=OcrClass.LINE, baseline=baseline) + assert elem.baseline.slope == 0.01 + assert elem.baseline.intercept == -3 + + def test_textangle(self): + elem = OcrElement(ocr_class=OcrClass.LINE, textangle=5.0) + assert elem.textangle == 5.0 + + def test_confidence(self): + elem = OcrElement(ocr_class=OcrClass.WORD, confidence=0.95) + assert elem.confidence == 0.95 + + def test_page_properties(self): + elem = OcrElement( + ocr_class=OcrClass.PAGE, + dpi=300.0, + page_number=0, + logical_page_number=1, + ) + assert elem.dpi == 300.0 + assert elem.page_number == 0 + assert elem.logical_page_number == 1 + + +class TestOcrClass: + """Tests for OcrClass constants.""" + + def test_class_values(self): + assert OcrClass.PAGE == "ocr_page" + assert OcrClass.PARAGRAPH == "ocr_par" + assert OcrClass.LINE == "ocr_line" + assert OcrClass.WORD == "ocrx_word" + assert OcrClass.HEADER == "ocr_header" + assert OcrClass.CAPTION == "ocr_caption" + + def test_line_types_frozenset(self): + assert OcrClass.LINE in OcrClass.LINE_TYPES + assert OcrClass.HEADER in OcrClass.LINE_TYPES + assert OcrClass.CAPTION in OcrClass.LINE_TYPES + assert OcrClass.WORD not in OcrClass.LINE_TYPES diff --git a/tests/test_ocr_engine_interface.py b/tests/test_ocr_engine_interface.py new file mode 100644 index 00000000..7ec66d53 --- /dev/null +++ b/tests/test_ocr_engine_interface.py @@ -0,0 +1,131 @@ +# SPDX-FileCopyrightText: 2025 James R. Barlow +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for OcrEngine interface extensions. + +These tests verify that the OcrEngine ABC has the new generate_ocr() method +and that OcrElement classes are exported from the public API. +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from ocrmypdf.pluginspec import OcrEngine + + +class TestOcrEngineInterface: + """Test that OcrEngine ABC has required methods.""" + + def test_generate_ocr_method_exists(self): + """OcrEngine must have generate_ocr() method signature.""" + assert hasattr(OcrEngine, 'generate_ocr') + + def test_supports_generate_ocr_method_exists(self): + """OcrEngine must have supports_generate_ocr() method.""" + assert hasattr(OcrEngine, 'supports_generate_ocr') + + def test_supports_generate_ocr_default_false(self): + """Default supports_generate_ocr() should return False.""" + from ocrmypdf.pluginspec import OrientationConfidence + + # Create a minimal concrete implementation + class MinimalEngine(OcrEngine): + @staticmethod + def version(): + return "1.0" + + @staticmethod + def creator_tag(options): + return "test" + + def __str__(self): + return "test" + + @staticmethod + def languages(options): + return set() + + @staticmethod + def get_orientation(input_file, options): + return OrientationConfidence(0, 0.0) + + @staticmethod + def get_deskew(input_file, options): + return 0.0 + + @staticmethod + def generate_hocr(input_file, output_hocr, output_text, options): + pass + + @staticmethod + def generate_pdf(input_file, output_pdf, output_text, options): + pass + + engine = MinimalEngine() + assert engine.supports_generate_ocr() is False + + def test_generate_ocr_raises_not_implemented_by_default(self): + """Default generate_ocr() should raise NotImplementedError.""" + from ocrmypdf.pluginspec import OrientationConfidence + + class MinimalEngine(OcrEngine): + @staticmethod + def version(): + return "1.0" + + @staticmethod + def creator_tag(options): + return "test" + + def __str__(self): + return "test" + + @staticmethod + def languages(options): + return set() + + @staticmethod + def get_orientation(input_file, options): + return OrientationConfidence(0, 0.0) + + @staticmethod + def get_deskew(input_file, options): + return 0.0 + + @staticmethod + def generate_hocr(input_file, output_hocr, output_text, options): + pass + + @staticmethod + def generate_pdf(input_file, output_pdf, output_text, options): + pass + + engine = MinimalEngine() + with pytest.raises(NotImplementedError): + engine.generate_ocr(Path("test.png"), MagicMock(), 0) + + +class TestOcrElementExport: + """Test that OcrElement is exported from public API.""" + + def test_ocrelement_importable_from_ocrmypdf(self): + """OcrElement should be importable from ocrmypdf package.""" + from ocrmypdf import OcrElement + + assert OcrElement is not None + + def test_ocrclass_importable_from_ocrmypdf(self): + """OcrClass should be importable from ocrmypdf package.""" + from ocrmypdf import OcrClass + + assert OcrClass is not None + + def test_boundingbox_importable_from_ocrmypdf(self): + """BoundingBox should be importable from ocrmypdf package.""" + from ocrmypdf import BoundingBox + + assert BoundingBox is not None diff --git a/tests/test_ocr_engine_selection.py b/tests/test_ocr_engine_selection.py new file mode 100644 index 00000000..265f74d1 --- /dev/null +++ b/tests/test_ocr_engine_selection.py @@ -0,0 +1,139 @@ +# SPDX-FileCopyrightText: 2025 James R. Barlow +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for OCR engine selection mechanism. + +Tests verify that the --ocr-engine option works correctly and that +engine-specific options are available. +""" + +from __future__ import annotations + +import pytest + + +class TestOcrEngineCliOption: + """Test --ocr-engine CLI option.""" + + def test_ocr_engine_option_exists(self): + """CLI should have --ocr-engine option.""" + from ocrmypdf.cli import get_parser + + parser = get_parser() + + option_strings = [] + for action in parser._actions: + option_strings.extend(action.option_strings) + + assert '--ocr-engine' in option_strings + + def test_ocr_engine_accepts_tesseract(self): + """--ocr-engine should accept 'tesseract'.""" + from ocrmypdf.cli import get_parser + + parser = get_parser() + + args = parser.parse_args(['--ocr-engine', 'tesseract', 'in.pdf', 'out.pdf']) + assert args.ocr_engine == 'tesseract' + + def test_ocr_engine_accepts_auto(self): + """--ocr-engine should accept 'auto'.""" + from ocrmypdf.cli import get_parser + + parser = get_parser() + + args = parser.parse_args(['--ocr-engine', 'auto', 'in.pdf', 'out.pdf']) + assert args.ocr_engine == 'auto' + + def test_ocr_engine_accepts_none(self): + """--ocr-engine should accept 'none'.""" + from ocrmypdf.cli import get_parser + + parser = get_parser() + + args = parser.parse_args(['--ocr-engine', 'none', 'in.pdf', 'out.pdf']) + assert args.ocr_engine == 'none' + + def test_ocr_engine_default_is_auto(self): + """--ocr-engine should default to 'auto'.""" + from ocrmypdf.cli import get_parser + + parser = get_parser() + + args = parser.parse_args(['in.pdf', 'out.pdf']) + assert args.ocr_engine == 'auto' + + def test_ocr_engine_rejects_invalid(self): + """--ocr-engine should reject invalid values.""" + from ocrmypdf.cli import get_parser + + parser = get_parser() + + with pytest.raises(SystemExit): + parser.parse_args(['--ocr-engine', 'invalid_engine', 'in.pdf', 'out.pdf']) + + +class TestOcrEngineOptionsModel: + """Test OcrOptions has ocr_engine field.""" + + def test_ocr_options_has_ocr_engine_field(self): + """OcrOptions should have ocr_engine field.""" + from ocrmypdf._options import OcrOptions + + # Check field exists in model + assert 'ocr_engine' in OcrOptions.model_fields + + +class TestOcrEnginePluginSelection: + """Test that get_ocr_engine() hook selects correct engine based on options.""" + + def test_tesseract_selected_when_auto(self): + """TesseractOcrEngine should be returned when ocr_engine='auto'.""" + from unittest.mock import MagicMock + + from ocrmypdf.builtin_plugins import tesseract_ocr + from ocrmypdf.builtin_plugins.tesseract_ocr import TesseractOcrEngine + + options = MagicMock() + options.ocr_engine = 'auto' + + engine = tesseract_ocr.get_ocr_engine(options=options) + assert isinstance(engine, TesseractOcrEngine) + + def test_tesseract_selected_when_tesseract(self): + """TesseractOcrEngine should be returned when ocr_engine='tesseract'.""" + from unittest.mock import MagicMock + + from ocrmypdf.builtin_plugins import tesseract_ocr + from ocrmypdf.builtin_plugins.tesseract_ocr import TesseractOcrEngine + + options = MagicMock() + options.ocr_engine = 'tesseract' + + engine = tesseract_ocr.get_ocr_engine(options=options) + assert isinstance(engine, TesseractOcrEngine) + + def test_null_selected_when_none(self): + """NullOcrEngine should be returned when ocr_engine='none'.""" + from unittest.mock import MagicMock + + from ocrmypdf.builtin_plugins import null_ocr + from ocrmypdf.builtin_plugins.null_ocr import NullOcrEngine + + options = MagicMock() + options.ocr_engine = 'none' + + engine = null_ocr.get_ocr_engine(options=options) + assert isinstance(engine, NullOcrEngine) + + def test_null_returns_none_when_auto(self): + """null_ocr.get_ocr_engine() should return None when ocr_engine='auto'.""" + from unittest.mock import MagicMock + + from ocrmypdf.builtin_plugins import null_ocr + + options = MagicMock() + options.ocr_engine = 'auto' + + engine = null_ocr.get_ocr_engine(options=options) + assert engine is None diff --git a/tests/test_optimize.py b/tests/test_optimize.py index afdf4a0e..710ffb6a 100644 --- a/tests/test_optimize.py +++ b/tests/test_optimize.py @@ -19,6 +19,7 @@ from ocrmypdf._exec import jbig2enc, pngquant from ocrmypdf._exec.ghostscript import rasterize_pdf from ocrmypdf.helpers import IMG2PDF_KWARGS, Resolution from ocrmypdf.optimize import PdfImage, extract_image_filter +from ocrmypdf.pluginspec import GhostscriptRasterDevice from tests.conftest import check_ocrmypdf needs_pngquant = pytest.mark.skipif( @@ -54,7 +55,7 @@ def test_mono_not_inverted(resources, outdir): rasterize_pdf( outdir / 'out.pdf', outdir / 'im.png', - raster_device='pnggray', + raster_device=GhostscriptRasterDevice.PNGGRAY, raster_dpi=Resolution(10, 10), ) @@ -81,8 +82,8 @@ def test_jpg_png_params(resources, outpdf): @needs_jbig2enc -@pytest.mark.parametrize('lossy', [False, True]) -def test_jbig2_lossy(lossy, resources, outpdf): +def test_jbig2_lossless(resources, outpdf): + """Test that JBIG2 lossless encoding works without JBIG2Globals.""" args = [ resources / 'ccitt.pdf', outpdf, @@ -99,19 +100,14 @@ def test_jbig2_lossy(lossy, resources, outpdf): '--jbig2-threshold', '0.7', ] - if lossy: - args.append('--jbig2-lossy') check_ocrmypdf(*args) with pikepdf.open(outpdf) as pdf: pim = pikepdf.PdfImage(next(iter(pdf.pages[0].images.values()))) assert pim.filters[0] == '/JBIG2Decode' - - if lossy: - assert '/JBIG2Globals' in pim.decode_parms[0] - else: - assert len(pim.decode_parms) == 0 + # Lossless JBIG2 has no JBIG2Globals (no shared symbol dictionary) + assert len(pim.decode_parms) == 0 @needs_pngquant diff --git a/tests/test_page_boxes.py b/tests/test_page_boxes.py index 9251a04d..c033609e 100644 --- a/tests/test_page_boxes.py +++ b/tests/test_page_boxes.py @@ -6,6 +6,8 @@ from __future__ import annotations import pikepdf import pytest +from ocrmypdf._exec import verapdf + from .conftest import check_ocrmypdf page_rect = [0, 0, 612, 792] @@ -14,13 +16,18 @@ wh_rect = [0, 0, 412, 592] neg_rect = [-100, -100, 512, 692] +# When speculative PDF/A succeeds (verapdf available), MediaBox is preserved. +# Ghostscript would normalize MediaBox to start at origin, but speculative +# conversion bypasses Ghostscript. +_pdfa_inset_expected = inset_rect if verapdf.available() else wh_rect + mediabox_testdata = [ - ('hocr', 'pdfa', 'ccitt.pdf', None, inset_rect, wh_rect), - ('sandwich', 'pdfa', 'ccitt.pdf', None, inset_rect, wh_rect), - ('hocr', 'pdf', 'ccitt.pdf', None, inset_rect, inset_rect), + ('fpdf2', 'pdfa', 'ccitt.pdf', None, inset_rect, _pdfa_inset_expected), + ('sandwich', 'pdfa', 'ccitt.pdf', None, inset_rect, _pdfa_inset_expected), + ('fpdf2', 'pdf', 'ccitt.pdf', None, inset_rect, inset_rect), ('sandwich', 'pdf', 'ccitt.pdf', None, inset_rect, inset_rect), ( - 'hocr', + 'fpdf2', 'pdfa', 'ccitt.pdf', '--force-ocr', @@ -28,15 +35,15 @@ mediabox_testdata = [ wh_rect, ), ( - 'hocr', + 'fpdf2', 'pdf', 'ccitt.pdf', '--force-ocr', inset_rect, wh_rect, ), - ('hocr', 'pdfa', 'ccitt.pdf', '--force-ocr', neg_rect, page_rect), - ('hocr', 'pdf', 'ccitt.pdf', '--force-ocr', neg_rect, page_rect), + ('fpdf2', 'pdfa', 'ccitt.pdf', '--force-ocr', neg_rect, page_rect), + ('fpdf2', 'pdf', 'ccitt.pdf', '--force-ocr', neg_rect, page_rect), ] @@ -65,16 +72,16 @@ def test_media_box( with pikepdf.open(outdir / 'processed.pdf') as pdf: page = pdf.pages[0] - assert page.MediaBox == crop_expected + assert [float(x) for x in page.mediabox] == crop_expected cropbox_testdata = [ - ('hocr', 'pdfa', 'ccitt.pdf', None, inset_rect, inset_rect), + ('fpdf2', 'pdfa', 'ccitt.pdf', None, inset_rect, inset_rect), ('sandwich', 'pdfa', 'ccitt.pdf', None, inset_rect, inset_rect), - ('hocr', 'pdf', 'ccitt.pdf', None, inset_rect, inset_rect), + ('fpdf2', 'pdf', 'ccitt.pdf', None, inset_rect, inset_rect), ('sandwich', 'pdf', 'ccitt.pdf', None, inset_rect, inset_rect), ( - 'hocr', + 'fpdf2', 'pdfa', 'ccitt.pdf', '--force-ocr', @@ -82,7 +89,7 @@ cropbox_testdata = [ inset_rect, ), ( - 'hocr', + 'fpdf2', 'pdf', 'ccitt.pdf', '--force-ocr', @@ -119,4 +126,4 @@ def test_crop_box( with pikepdf.open(outdir / 'processed.pdf') as pdf: page = pdf.pages[0] - assert page.CropBox == crop_expected + assert [float(x) for x in page.cropbox] == crop_expected diff --git a/tests/test_page_numbers.py b/tests/test_page_numbers.py index aac80c0d..79b1acf0 100644 --- a/tests/test_page_numbers.py +++ b/tests/test_page_numbers.py @@ -6,7 +6,7 @@ from __future__ import annotations import pytest import ocrmypdf -from ocrmypdf._validation import _pages_from_ranges +from ocrmypdf._options import _pages_from_ranges from ocrmypdf.exceptions import BadArgsError from ocrmypdf.pdfinfo import PdfInfo diff --git a/tests/test_pdf_renderer.py b/tests/test_pdf_renderer.py new file mode 100644 index 00000000..1fc7c183 --- /dev/null +++ b/tests/test_pdf_renderer.py @@ -0,0 +1,599 @@ +# SPDX-FileCopyrightText: 2025 James R. Barlow +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for Fpdf2PdfRenderer class.""" + +from __future__ import annotations + +from io import StringIO +from pathlib import Path + +import pytest +from pdfminer.converter import TextConverter +from pdfminer.layout import LAParams +from pdfminer.pdfdocument import PDFDocument +from pdfminer.pdfinterp import PDFPageInterpreter, PDFResourceManager +from pdfminer.pdfpage import PDFPage +from pdfminer.pdfparser import PDFParser + +from ocrmypdf.font import MultiFontManager +from ocrmypdf.fpdf_renderer import DebugRenderOptions, Fpdf2PdfRenderer +from ocrmypdf.helpers import check_pdf +from ocrmypdf.hocrtransform import ( + Baseline, + BoundingBox, + OcrClass, + OcrElement, +) + + +def text_from_pdf(filename: Path) -> str: + """Extract text from a PDF file using pdfminer.""" + output_string = StringIO() + with open(filename, 'rb') as in_file: + parser = PDFParser(in_file) + doc = PDFDocument(parser) + rsrcmgr = PDFResourceManager() + device = TextConverter(rsrcmgr, output_string, laparams=LAParams()) + interpreter = PDFPageInterpreter(rsrcmgr, device) + for page in PDFPage.create_pages(doc): + interpreter.process_page(page) + return output_string.getvalue() + + +@pytest.fixture +def font_dir(): + """Get the font directory.""" + return Path(__file__).parent.parent / "src" / "ocrmypdf" / "data" + + +@pytest.fixture +def multi_font_manager(font_dir): + """Create a MultiFontManager for tests.""" + return MultiFontManager(font_dir) + + +def create_simple_page( + width: float = 1000, + height: float = 500, + words: list[tuple[str, tuple[float, float, float, float]]] | None = None, +) -> OcrElement: + """Create a simple OcrElement page for testing. + + Args: + width: Page width in pixels + height: Page height in pixels + words: List of (text, (left, top, right, bottom)) tuples + + Returns: + OcrElement representing the page + """ + if words is None: + words = [("Hello", (100, 100, 200, 150)), ("World", (250, 100, 350, 150))] + + word_elements = [ + OcrElement( + ocr_class=OcrClass.WORD, + text=text, + bbox=BoundingBox(left=bbox[0], top=bbox[1], right=bbox[2], bottom=bbox[3]), + ) + for text, bbox in words + ] + + line = OcrElement( + ocr_class=OcrClass.LINE, + bbox=BoundingBox(left=100, top=100, right=900, bottom=150), + baseline=Baseline(slope=0.0, intercept=0), + children=word_elements, + ) + + paragraph = OcrElement( + ocr_class=OcrClass.PARAGRAPH, + bbox=BoundingBox(left=100, top=100, right=900, bottom=150), + direction="ltr", + language="eng", + children=[line], + ) + + page = OcrElement( + ocr_class=OcrClass.PAGE, + bbox=BoundingBox(left=0, top=0, right=width, bottom=height), + children=[paragraph], + ) + + return page + + +class TestFpdf2PdfRendererBasic: + """Basic Fpdf2PdfRenderer functionality tests.""" + + def test_render_simple_page(self, tmp_path, multi_font_manager): + """Test rendering a simple page with two words.""" + page = create_simple_page() + output_pdf = tmp_path / "simple.pdf" + + renderer = Fpdf2PdfRenderer( + page=page, dpi=72.0, multi_font_manager=multi_font_manager + ) + renderer.render(output_pdf) + + assert output_pdf.exists() + check_pdf(str(output_pdf)) + + def test_rendered_text_extractable(self, tmp_path, multi_font_manager): + """Test that rendered text can be extracted from the PDF.""" + page = create_simple_page() + output_pdf = tmp_path / "extractable.pdf" + + renderer = Fpdf2PdfRenderer( + page=page, dpi=72.0, multi_font_manager=multi_font_manager + ) + renderer.render(output_pdf) + + extracted_text = text_from_pdf(output_pdf) + assert "Hello" in extracted_text + assert "World" in extracted_text + + def test_invisible_text_mode(self, tmp_path, multi_font_manager): + """Test that invisible_text=True creates a valid PDF.""" + page = create_simple_page() + output_pdf = tmp_path / "invisible.pdf" + + renderer = Fpdf2PdfRenderer( + page=page, + dpi=72.0, + multi_font_manager=multi_font_manager, + invisible_text=True, + ) + renderer.render(output_pdf) + + # Text should still be extractable even when invisible + extracted_text = text_from_pdf(output_pdf) + assert "Hello" in extracted_text + + def test_visible_text_mode(self, tmp_path, multi_font_manager): + """Test that invisible_text=False creates a valid PDF with visible text.""" + page = create_simple_page() + output_pdf = tmp_path / "visible.pdf" + + renderer = Fpdf2PdfRenderer( + page=page, + dpi=72.0, + multi_font_manager=multi_font_manager, + invisible_text=False, + ) + renderer.render(output_pdf) + + # Text should be extractable + extracted_text = text_from_pdf(output_pdf) + assert "Hello" in extracted_text + + +class TestFpdf2PdfRendererPageSize: + """Test page size calculations.""" + + def test_page_dimensions(self, tmp_path, multi_font_manager): + """Test that page dimensions are calculated correctly.""" + # 1000x500 pixels at 72 dpi = 1000x500 points + page = create_simple_page(width=1000, height=500) + output_pdf = tmp_path / "dimensions.pdf" + + renderer = Fpdf2PdfRenderer( + page=page, dpi=72.0, multi_font_manager=multi_font_manager + ) + assert renderer.coord_transform.page_width_pt == pytest.approx(1000.0) + assert renderer.coord_transform.page_height_pt == pytest.approx(500.0) + + renderer.render(output_pdf) + + def test_high_dpi_page(self, tmp_path, multi_font_manager): + """Test page dimensions at higher DPI.""" + # 720x360 pixels at 144 dpi = 360x180 points + page = create_simple_page(width=720, height=360) + output_pdf = tmp_path / "high_dpi.pdf" + + renderer = Fpdf2PdfRenderer( + page=page, dpi=144.0, multi_font_manager=multi_font_manager + ) + assert renderer.coord_transform.page_width_pt == pytest.approx(360.0) + assert renderer.coord_transform.page_height_pt == pytest.approx(180.0) + + renderer.render(output_pdf) + check_pdf(str(output_pdf)) + + +class TestFpdf2PdfRendererMultiLine: + """Test rendering of multi-line content.""" + + def test_multiple_lines(self, tmp_path, multi_font_manager): + """Test rendering multiple lines of text.""" + line1_words = [ + OcrElement( + ocr_class=OcrClass.WORD, + text="Line", + bbox=BoundingBox(left=100, top=100, right=180, bottom=150), + ), + OcrElement( + ocr_class=OcrClass.WORD, + text="one", + bbox=BoundingBox(left=190, top=100, right=250, bottom=150), + ), + ] + line1 = OcrElement( + ocr_class=OcrClass.LINE, + bbox=BoundingBox(left=100, top=100, right=900, bottom=150), + baseline=Baseline(slope=0.0, intercept=0), + children=line1_words, + ) + + line2_words = [ + OcrElement( + ocr_class=OcrClass.WORD, + text="Line", + bbox=BoundingBox(left=100, top=200, right=180, bottom=250), + ), + OcrElement( + ocr_class=OcrClass.WORD, + text="two", + bbox=BoundingBox(left=190, top=200, right=250, bottom=250), + ), + ] + line2 = OcrElement( + ocr_class=OcrClass.LINE, + bbox=BoundingBox(left=100, top=200, right=900, bottom=250), + baseline=Baseline(slope=0.0, intercept=0), + children=line2_words, + ) + + paragraph = OcrElement( + ocr_class=OcrClass.PARAGRAPH, + bbox=BoundingBox(left=100, top=100, right=900, bottom=250), + direction="ltr", + language="eng", + children=[line1, line2], + ) + + page = OcrElement( + ocr_class=OcrClass.PAGE, + bbox=BoundingBox(left=0, top=0, right=1000, bottom=500), + children=[paragraph], + ) + + output_pdf = tmp_path / "multiline.pdf" + renderer = Fpdf2PdfRenderer( + page=page, dpi=72.0, multi_font_manager=multi_font_manager + ) + renderer.render(output_pdf) + + extracted_text = text_from_pdf(output_pdf) + assert "Line" in extracted_text + assert "one" in extracted_text + assert "two" in extracted_text + + +class TestFpdf2PdfRendererTextDirection: + """Test rendering of different text directions.""" + + def test_ltr_text(self, tmp_path, multi_font_manager): + """Test rendering LTR text.""" + page = create_simple_page() + output_pdf = tmp_path / "ltr.pdf" + + renderer = Fpdf2PdfRenderer( + page=page, dpi=72.0, multi_font_manager=multi_font_manager + ) + renderer.render(output_pdf) + + check_pdf(str(output_pdf)) + + def test_rtl_text(self, tmp_path, multi_font_manager): + """Test rendering RTL text.""" + word = OcrElement( + ocr_class=OcrClass.WORD, + text="مرحبا", + bbox=BoundingBox(left=100, top=100, right=200, bottom=150), + ) + line = OcrElement( + ocr_class=OcrClass.LINE, + bbox=BoundingBox(left=100, top=100, right=900, bottom=150), + baseline=Baseline(slope=0.0, intercept=0), + direction="rtl", + children=[word], + ) + paragraph = OcrElement( + ocr_class=OcrClass.PARAGRAPH, + bbox=BoundingBox(left=100, top=100, right=900, bottom=150), + direction="rtl", + language="ara", + children=[line], + ) + page = OcrElement( + ocr_class=OcrClass.PAGE, + bbox=BoundingBox(left=0, top=0, right=1000, bottom=500), + children=[paragraph], + ) + + output_pdf = tmp_path / "rtl.pdf" + renderer = Fpdf2PdfRenderer( + page=page, dpi=72.0, multi_font_manager=multi_font_manager + ) + renderer.render(output_pdf) + + check_pdf(str(output_pdf)) + + +class TestFpdf2PdfRendererBaseline: + """Test baseline handling in rendering.""" + + def test_sloped_baseline(self, tmp_path, multi_font_manager): + """Test rendering with a sloped baseline.""" + word = OcrElement( + ocr_class=OcrClass.WORD, + text="Sloped", + bbox=BoundingBox(left=100, top=100, right=200, bottom=150), + ) + line = OcrElement( + ocr_class=OcrClass.LINE, + bbox=BoundingBox(left=100, top=100, right=900, bottom=150), + baseline=Baseline(slope=0.02, intercept=-5), + children=[word], + ) + paragraph = OcrElement( + ocr_class=OcrClass.PARAGRAPH, + bbox=BoundingBox(left=100, top=100, right=900, bottom=150), + direction="ltr", + language="eng", + children=[line], + ) + page = OcrElement( + ocr_class=OcrClass.PAGE, + bbox=BoundingBox(left=0, top=0, right=1000, bottom=500), + children=[paragraph], + ) + + output_pdf = tmp_path / "sloped.pdf" + renderer = Fpdf2PdfRenderer( + page=page, dpi=72.0, multi_font_manager=multi_font_manager + ) + renderer.render(output_pdf) + + check_pdf(str(output_pdf)) + extracted_text = text_from_pdf(output_pdf) + assert "Sloped" in extracted_text + + +class TestFpdf2PdfRendererTextangle: + """Test textangle (rotation) handling in rendering.""" + + def test_rotated_text(self, tmp_path, multi_font_manager): + """Test rendering rotated text.""" + word = OcrElement( + ocr_class=OcrClass.WORD, + text="Rotated", + bbox=BoundingBox(left=100, top=100, right=200, bottom=150), + ) + line = OcrElement( + ocr_class=OcrClass.LINE, + bbox=BoundingBox(left=100, top=100, right=900, bottom=150), + baseline=Baseline(slope=0.0, intercept=0), + textangle=5.0, + children=[word], + ) + paragraph = OcrElement( + ocr_class=OcrClass.PARAGRAPH, + bbox=BoundingBox(left=100, top=100, right=900, bottom=150), + direction="ltr", + language="eng", + children=[line], + ) + page = OcrElement( + ocr_class=OcrClass.PAGE, + bbox=BoundingBox(left=0, top=0, right=1000, bottom=500), + children=[paragraph], + ) + + output_pdf = tmp_path / "rotated.pdf" + renderer = Fpdf2PdfRenderer( + page=page, dpi=72.0, multi_font_manager=multi_font_manager + ) + renderer.render(output_pdf) + + check_pdf(str(output_pdf)) + extracted_text = text_from_pdf(output_pdf) + assert "Rotated" in extracted_text + + +class TestFpdf2PdfRendererWordBreaks: + """Test word rendering.""" + + def test_word_breaks_english(self, tmp_path, multi_font_manager): + """Test that words are rendered for English text.""" + page = create_simple_page() + output_pdf = tmp_path / "english.pdf" + + renderer = Fpdf2PdfRenderer( + page=page, dpi=72.0, multi_font_manager=multi_font_manager + ) + renderer.render(output_pdf) + + extracted_text = text_from_pdf(output_pdf) + # Words should be present + assert "Hello" in extracted_text + assert "World" in extracted_text + + def test_cjk_text(self, tmp_path, multi_font_manager): + """Test rendering CJK text.""" + words = [ + OcrElement( + ocr_class=OcrClass.WORD, + text="你好", + bbox=BoundingBox(left=100, top=100, right=150, bottom=150), + ), + OcrElement( + ocr_class=OcrClass.WORD, + text="世界", + bbox=BoundingBox(left=160, top=100, right=210, bottom=150), + ), + ] + line = OcrElement( + ocr_class=OcrClass.LINE, + bbox=BoundingBox(left=100, top=100, right=900, bottom=150), + baseline=Baseline(slope=0.0, intercept=0), + children=words, + ) + paragraph = OcrElement( + ocr_class=OcrClass.PARAGRAPH, + bbox=BoundingBox(left=100, top=100, right=900, bottom=150), + direction="ltr", + language="chi_sim", # Simplified Chinese + children=[line], + ) + page = OcrElement( + ocr_class=OcrClass.PAGE, + bbox=BoundingBox(left=0, top=0, right=1000, bottom=500), + children=[paragraph], + ) + + output_pdf = tmp_path / "chinese.pdf" + renderer = Fpdf2PdfRenderer( + page=page, dpi=72.0, multi_font_manager=multi_font_manager + ) + renderer.render(output_pdf) + + check_pdf(str(output_pdf)) + + +class TestFpdf2PdfRendererDebugOptions: + """Test debug rendering options.""" + + def test_debug_render_options_default(self, multi_font_manager): + """Test that debug options are disabled by default.""" + page = create_simple_page() + renderer = Fpdf2PdfRenderer( + page=page, dpi=72.0, multi_font_manager=multi_font_manager + ) + + assert renderer.debug_options.render_baseline is False + assert renderer.debug_options.render_word_bbox is False + assert renderer.debug_options.render_line_bbox is False + + def test_debug_render_options_enabled(self, tmp_path, multi_font_manager): + """Test rendering with debug options enabled.""" + page = create_simple_page() + output_pdf = tmp_path / "debug.pdf" + + debug_opts = DebugRenderOptions( + render_baseline=True, + render_word_bbox=True, + render_line_bbox=True, + ) + + renderer = Fpdf2PdfRenderer( + page=page, + dpi=72.0, + multi_font_manager=multi_font_manager, + invisible_text=False, + debug_render_options=debug_opts, + ) + renderer.render(output_pdf) + + check_pdf(str(output_pdf)) + # Text should still be extractable + extracted_text = text_from_pdf(output_pdf) + assert "Hello" in extracted_text + + +class TestFpdf2PdfRendererErrors: + """Test error handling in Fpdf2PdfRenderer.""" + + def test_invalid_ocr_class(self, multi_font_manager): + """Test that non-page elements are rejected.""" + line = OcrElement( + ocr_class=OcrClass.LINE, bbox=BoundingBox(left=0, top=0, right=100, bottom=50) + ) + + with pytest.raises(ValueError, match="ocr_page"): + Fpdf2PdfRenderer(page=line, dpi=72.0, multi_font_manager=multi_font_manager) + + def test_page_without_bbox(self, multi_font_manager): + """Test that pages without bbox are rejected.""" + page = OcrElement(ocr_class=OcrClass.PAGE) + + with pytest.raises(ValueError, match="bounding box"): + Fpdf2PdfRenderer(page=page, dpi=72.0, multi_font_manager=multi_font_manager) + + +class TestFpdf2PdfRendererLineTypes: + """Test rendering of different line types.""" + + def test_header_line(self, tmp_path, multi_font_manager): + """Test rendering header lines.""" + word = OcrElement( + ocr_class=OcrClass.WORD, + text="Header", + bbox=BoundingBox(left=100, top=50, right=200, bottom=100), + ) + header = OcrElement( + ocr_class=OcrClass.HEADER, + bbox=BoundingBox(left=100, top=50, right=900, bottom=100), + baseline=Baseline(slope=0.0, intercept=0), + children=[word], + ) + paragraph = OcrElement( + ocr_class=OcrClass.PARAGRAPH, + bbox=BoundingBox(left=100, top=50, right=900, bottom=100), + direction="ltr", + language="eng", + children=[header], + ) + page = OcrElement( + ocr_class=OcrClass.PAGE, + bbox=BoundingBox(left=0, top=0, right=1000, bottom=500), + children=[paragraph], + ) + + output_pdf = tmp_path / "header.pdf" + renderer = Fpdf2PdfRenderer( + page=page, dpi=72.0, multi_font_manager=multi_font_manager + ) + renderer.render(output_pdf) + + check_pdf(str(output_pdf)) + extracted_text = text_from_pdf(output_pdf) + assert "Header" in extracted_text + + def test_caption_line(self, tmp_path, multi_font_manager): + """Test rendering caption lines.""" + word = OcrElement( + ocr_class=OcrClass.WORD, + text="Caption", + bbox=BoundingBox(left=100, top=300, right=200, bottom=350), + ) + caption = OcrElement( + ocr_class=OcrClass.CAPTION, + bbox=BoundingBox(left=100, top=300, right=900, bottom=350), + baseline=Baseline(slope=0.0, intercept=0), + children=[word], + ) + paragraph = OcrElement( + ocr_class=OcrClass.PARAGRAPH, + bbox=BoundingBox(left=100, top=300, right=900, bottom=350), + direction="ltr", + language="eng", + children=[caption], + ) + page = OcrElement( + ocr_class=OcrClass.PAGE, + bbox=BoundingBox(left=0, top=0, right=1000, bottom=500), + children=[paragraph], + ) + + output_pdf = tmp_path / "caption.pdf" + renderer = Fpdf2PdfRenderer( + page=page, dpi=72.0, multi_font_manager=multi_font_manager + ) + renderer.render(output_pdf) + + check_pdf(str(output_pdf)) + extracted_text = text_from_pdf(output_pdf) + assert "Caption" in extracted_text diff --git a/tests/test_pdfa.py b/tests/test_pdfa.py index 0e33678c..9edd0654 100644 --- a/tests/test_pdfa.py +++ b/tests/test_pdfa.py @@ -36,6 +36,5 @@ def test_pdfa(resources, outpdf, optimize, pdfa_level): # we don't use it assert b'/ObjStm' not in outpdf.read_bytes() - with pikepdf.open(outpdf) as pdf: - with pdf.open_metadata() as m: - assert m.pdfa_status == f'{pdfa_level}B' + with pikepdf.open(outpdf) as pdf, pdf.open_metadata() as m: + assert m.pdfa_status == f'{pdfa_level}B' diff --git a/tests/test_pdfinfo.py b/tests/test_pdfinfo.py index 7091488a..1bb7a160 100644 --- a/tests/test_pdfinfo.py +++ b/tests/test_pdfinfo.py @@ -19,6 +19,7 @@ from ocrmypdf import pdfinfo from ocrmypdf.exceptions import InputFileError from ocrmypdf.helpers import IMG2PDF_KWARGS, Resolution from ocrmypdf.pdfinfo import Colorspace, Encoding +from ocrmypdf.pdfinfo._contentstream import _interpret_contents from ocrmypdf.pdfinfo.layout import PDFPage warnings.filterwarnings( @@ -189,16 +190,15 @@ def test_stack_abuse(): stream = pikepdf.Stream(p, b'q ' * 35) with pytest.warns(UserWarning, match="overflowed"): - pdfinfo.info._interpret_contents(stream) + _interpret_contents(stream) stream = pikepdf.Stream(p, b'q Q Q Q Q') with pytest.warns(UserWarning, match="underflowed"): - pdfinfo.info._interpret_contents(stream) + _interpret_contents(stream) stream = pikepdf.Stream(p, b'q ' * 135) - with pytest.warns(UserWarning): - with pytest.raises(RuntimeError): - pdfinfo.info._interpret_contents(stream) + with pytest.warns(UserWarning), pytest.raises(RuntimeError): + _interpret_contents(stream) def test_pages_issue700(monkeypatch, resources): diff --git a/tests/test_pipeline_generate_ocr.py b/tests/test_pipeline_generate_ocr.py new file mode 100644 index 00000000..f2b03eda --- /dev/null +++ b/tests/test_pipeline_generate_ocr.py @@ -0,0 +1,101 @@ +# SPDX-FileCopyrightText: 2025 James R. Barlow +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for pipeline support of generate_ocr(). + +These tests verify that the pipeline supports the new generate_ocr() API +alongside the existing hOCR path. +""" + +from __future__ import annotations + +import dataclasses +from pathlib import Path +from unittest.mock import MagicMock, patch + +from ocrmypdf import BoundingBox, OcrElement + + +class TestOcrEngineDirect: + """Test the ocr_engine_direct() pipeline function.""" + + def test_ocr_engine_direct_function_exists(self): + """ocr_engine_direct function should exist in _pipeline module.""" + from ocrmypdf import _pipeline + + assert hasattr(_pipeline, 'ocr_engine_direct') + + def test_ocr_engine_direct_returns_tuple(self, tmp_path): + """ocr_engine_direct should return (OcrElement, Path) tuple.""" + from ocrmypdf._pipeline import ocr_engine_direct + + # Mock page context with an engine that supports generate_ocr + mock_context = MagicMock() + mock_engine = MagicMock() + mock_engine.supports_generate_ocr.return_value = True + mock_engine.generate_ocr.return_value = ( + OcrElement(ocr_class='ocr_page', bbox=BoundingBox(0, 0, 100, 100)), + "test text", + ) + mock_context.plugin_manager.get_ocr_engine.return_value = mock_engine + mock_context.get_path.return_value = tmp_path / Path("test.txt") + mock_context.pageno = 0 + + with patch('builtins.open', MagicMock()): + result = ocr_engine_direct(Path("test.png"), mock_context) + + assert isinstance(result, tuple) + assert len(result) == 2 + + +class TestPageResultExtension: + """Test PageResult NamedTuple extension.""" + + def test_page_result_has_ocr_tree_field(self): + """PageResult should have ocr_tree field.""" + from ocrmypdf._pipelines._common import PageResult + + # PageResult is a NamedTuple, use _fields + assert 'ocr_tree' in PageResult._fields + + def test_page_result_ocr_tree_default_none(self): + """PageResult.ocr_tree should default to None.""" + from ocrmypdf._pipelines._common import PageResult + + result = PageResult(pageno=0) + assert result.ocr_tree is None + + +class TestFpdf2DirectPage: + """Test Fpdf2DirectPage dataclass for direct OcrElement input.""" + + def test_fpdf2_direct_page_exists(self): + """Fpdf2DirectPage dataclass should exist.""" + from ocrmypdf._graft import Fpdf2DirectPage + + assert Fpdf2DirectPage is not None + + def test_fpdf2_direct_page_has_ocr_tree(self): + """Fpdf2DirectPage should have ocr_tree field.""" + from ocrmypdf._graft import Fpdf2DirectPage + + fields = {f.name for f in dataclasses.fields(Fpdf2DirectPage)} + assert 'ocr_tree' in fields + + +class TestHOCRResultExtension: + """Test HOCRResult dataclass extension.""" + + def test_hocr_result_has_ocr_tree_field(self): + """HOCRResult should have ocr_tree field.""" + from ocrmypdf._pipelines._common import HOCRResult + + fields = {f.name for f in dataclasses.fields(HOCRResult)} + assert 'ocr_tree' in fields + + def test_hocr_result_ocr_tree_default_none(self): + """HOCRResult.ocr_tree should default to None.""" + from ocrmypdf._pipelines._common import HOCRResult + + result = HOCRResult(pageno=0) + assert result.ocr_tree is None diff --git a/tests/test_preprocessing.py b/tests/test_preprocessing.py index 62678160..9e42c888 100644 --- a/tests/test_preprocessing.py +++ b/tests/test_preprocessing.py @@ -12,10 +12,11 @@ from ocrmypdf._exec import ghostscript, tesseract from ocrmypdf.exceptions import ExitCode from ocrmypdf.helpers import Resolution from ocrmypdf.pdfinfo import PdfInfo +from ocrmypdf.pluginspec import GhostscriptRasterDevice from .conftest import check_ocrmypdf, have_unpaper, run_ocrmypdf -RENDERERS = ['hocr', 'sandwich'] +RENDERERS = ['fpdf2', 'sandwich'] def test_deskew(resources, outdir): @@ -28,7 +29,7 @@ def test_deskew(resources, outdir): ghostscript.rasterize_pdf( deskewed_pdf, deskewed_png, - raster_device='pngmono', + raster_device=GhostscriptRasterDevice.PNGMONO, raster_dpi=Resolution(150, 150), pageno=1, ) @@ -65,7 +66,7 @@ def test_remove_background(resources, outdir): ghostscript.rasterize_pdf( output_pdf, output_png, - raster_device='png16m', + raster_device=GhostscriptRasterDevice.PNG16M, raster_dpi=Resolution(100, 100), pageno=1, ) @@ -79,7 +80,7 @@ def test_remove_background(resources, outdir): @pytest.mark.parametrize( "pdf", ['palette.pdf', 'cmyk.pdf', 'ccitt.pdf', 'jbig2.pdf', 'lichtenstein.pdf'] ) -@pytest.mark.parametrize("renderer", ['sandwich', 'hocr']) +@pytest.mark.parametrize("renderer", ['sandwich', 'fpdf2']) @pytest.mark.parametrize("output_type", ['pdf', 'pdfa']) def test_exotic_image(pdf, renderer, output_type, resources, outdir): outfile = outdir / f'test_{pdf}_{renderer}.pdf' diff --git a/tests/test_rasterizer.py b/tests/test_rasterizer.py new file mode 100644 index 00000000..dec805c1 --- /dev/null +++ b/tests/test_rasterizer.py @@ -0,0 +1,599 @@ +# SPDX-FileCopyrightText: 2025 James R. Barlow +# SPDX-License-Identifier: MPL-2.0 + +"""Tests for the --rasterizer CLI option.""" + +from __future__ import annotations + +from io import BytesIO + +import img2pdf +import pikepdf +import pytest +from PIL import Image + +from ocrmypdf._options import OcrOptions +from ocrmypdf._plugin_manager import get_plugin_manager +from ocrmypdf.helpers import IMG2PDF_KWARGS, Resolution + +from .conftest import check_ocrmypdf + +# Check if pypdfium2 is available +try: + import pypdfium2 # noqa: F401 + + PYPDFIUM_AVAILABLE = True +except ImportError: + PYPDFIUM_AVAILABLE = False + + +class TestRasterizerOption: + """Test the --rasterizer CLI option.""" + + def test_rasterizer_auto_default(self, resources, outpdf): + """Test that --rasterizer auto (default) works.""" + check_ocrmypdf( + resources / 'graph.pdf', + outpdf, + '--rasterizer', + 'auto', + '--plugin', + 'tests/plugins/tesseract_noop.py', + ) + + def test_rasterizer_ghostscript(self, resources, outpdf): + """Test that --rasterizer ghostscript works.""" + check_ocrmypdf( + resources / 'graph.pdf', + outpdf, + '--rasterizer', + 'ghostscript', + '--plugin', + 'tests/plugins/tesseract_noop.py', + ) + + @pytest.mark.skipif(not PYPDFIUM_AVAILABLE, reason="pypdfium2 not installed") + def test_rasterizer_pypdfium(self, resources, outpdf): + """Test that --rasterizer pypdfium works when pypdfium2 is installed.""" + check_ocrmypdf( + resources / 'graph.pdf', + outpdf, + '--rasterizer', + 'pypdfium', + '--plugin', + 'tests/plugins/tesseract_noop.py', + ) + + def test_rasterizer_invalid(self): + """Test that an invalid rasterizer value is rejected.""" + with pytest.raises(ValueError, match="rasterizer must be one of"): + OcrOptions( + input_file='test.pdf', output_file='out.pdf', rasterizer='invalid' + ) + + +class TestRasterizerWithRotation: + """Test --rasterizer interaction with --rotate-pages.""" + + def test_ghostscript_with_rotation(self, resources, outpdf): + """Test Ghostscript rasterizer with page rotation.""" + check_ocrmypdf( + resources / 'cardinal.pdf', + outpdf, + '--rasterizer', + 'ghostscript', + '--rotate-pages', + '--rotate-pages-threshold', + '0.1', + '--plugin', + 'tests/plugins/tesseract_cache.py', + ) + + @pytest.mark.skipif(not PYPDFIUM_AVAILABLE, reason="pypdfium2 not installed") + def test_pypdfium_with_rotation(self, resources, outpdf): + """Test pypdfium rasterizer with page rotation.""" + check_ocrmypdf( + resources / 'cardinal.pdf', + outpdf, + '--rasterizer', + 'pypdfium', + '--rotate-pages', + '--rotate-pages-threshold', + '0.1', + '--plugin', + 'tests/plugins/tesseract_cache.py', + ) + + def test_auto_with_rotation(self, resources, outpdf): + """Test auto rasterizer with page rotation.""" + check_ocrmypdf( + resources / 'cardinal.pdf', + outpdf, + '--rasterizer', + 'auto', + '--rotate-pages', + '--rotate-pages-threshold', + '0.1', + '--plugin', + 'tests/plugins/tesseract_cache.py', + ) + + +class TestRasterizerHookDirect: + """Test rasterize_pdf_page hook directly with different rasterizer options.""" + + def test_ghostscript_hook_respects_option(self, resources, tmp_path): + """Test that Ghostscript hook returns None when pypdfium is requested.""" + pm = get_plugin_manager([]) + + # Create options requesting pypdfium + options = OcrOptions( + input_file=resources / 'graph.pdf', + output_file=tmp_path / 'out.pdf', + rasterizer='pypdfium', + ) + + img = tmp_path / 'ghostscript_test.png' + result = pm.rasterize_pdf_page( + input_file=resources / 'graph.pdf', + output_file=img, + raster_device='pngmono', + raster_dpi=Resolution(50, 50), + page_dpi=Resolution(50, 50), + pageno=1, + rotation=0, + filter_vector=False, + stop_on_soft_error=True, + options=options, + use_cropbox=False, + ) + # When pypdfium is requested: + # - If pypdfium IS available, pypdfium handles it and returns the path + # - If pypdfium is NOT available, both plugins return None + # (ghostscript returns None because pypdfium was requested, + # pypdfium returns None because it's not installed) + if PYPDFIUM_AVAILABLE: + assert result == img + else: + assert result is None + + def test_pypdfium_hook_respects_option(self, resources, tmp_path): + """Test that pypdfium hook returns None when ghostscript is requested.""" + pm = get_plugin_manager([]) + + # Create options requesting ghostscript + options = OcrOptions( + input_file=resources / 'graph.pdf', + output_file=tmp_path / 'out.pdf', + rasterizer='ghostscript', + ) + + img = tmp_path / 'pypdfium_test.png' + result = pm.rasterize_pdf_page( + input_file=resources / 'graph.pdf', + output_file=img, + raster_device='pngmono', + raster_dpi=Resolution(50, 50), + page_dpi=Resolution(50, 50), + pageno=1, + rotation=0, + filter_vector=False, + stop_on_soft_error=True, + options=options, + use_cropbox=False, + ) + # Ghostscript should handle it + assert result == img + assert img.exists() + + def test_auto_uses_pypdfium_when_available(self, resources, tmp_path): + """Test that auto mode uses pypdfium when available.""" + pm = get_plugin_manager([]) + + options = OcrOptions( + input_file=resources / 'graph.pdf', + output_file=tmp_path / 'out.pdf', + rasterizer='auto', + ) + + img = tmp_path / 'auto_test.png' + result = pm.rasterize_pdf_page( + input_file=resources / 'graph.pdf', + output_file=img, + raster_device='pngmono', + raster_dpi=Resolution(50, 50), + page_dpi=Resolution(50, 50), + pageno=1, + rotation=0, + filter_vector=False, + stop_on_soft_error=True, + options=options, + use_cropbox=False, + ) + assert result == img + assert img.exists() + + +def _create_gradient_image(width: int, height: int) -> Image.Image: + """Create an image with multiple gradients to detect rasterization errors. + + The image contains: + - Horizontal gradient from red to blue + - Vertical gradient overlay from green to transparent + - Diagonal bands for edge detection + """ + img = Image.new('RGB', (width, height)) + pixels = img.load() + + for y in range(height): + for x in range(width): + # Horizontal gradient: red to blue + r = int(255 * (1 - x / width)) + b = int(255 * (x / width)) + + # Vertical gradient: add green component + g = int(255 * (y / height)) + + # Add diagonal bands for edge detection + band = ((x + y) // 20) % 2 + if band: + r = min(255, r + 40) + g = min(255, g + 40) + b = min(255, b + 40) + + pixels[x, y] = (r, g, b) + + return img + + +@pytest.fixture +def pdf_with_nonstandard_boxes(tmp_path): + """Create a PDF with nonstandard MediaBox, TrimBox and CropBox.""" + # Create an image with gradients to detect rasterization errors + img = _create_gradient_image(200, 300) + img_bytes = BytesIO() + img.save(img_bytes, format='PNG') + img_bytes.seek(0) + + # Convert to PDF + pdf_bytes = BytesIO() + img2pdf.convert( + img_bytes.read(), + layout_fun=img2pdf.get_fixed_dpi_layout_fun((72, 72)), + outputstream=pdf_bytes, + **IMG2PDF_KWARGS, + ) + pdf_bytes.seek(0) + + # Modify the PDF to have nonstandard boxes + pdf_path = tmp_path / 'nonstandard_boxes.pdf' + with pikepdf.open(pdf_bytes) as pdf: + page = pdf.pages[0] + # Set MediaBox larger than content + page.MediaBox = pikepdf.Array([0, 0, 400, 500]) + # Set CropBox smaller - this is what viewers typically show + page.CropBox = pikepdf.Array([50, 50, 350, 450]) + # Set TrimBox even smaller - indicates intended trim area + page.TrimBox = pikepdf.Array([75, 75, 325, 425]) + pdf.save(pdf_path) + + return pdf_path + + +@pytest.fixture +def pdf_with_negative_mediabox(tmp_path): + """Create a PDF with MediaBox that has negative origin coordinates.""" + # Create an image with gradients to detect rasterization errors + img = _create_gradient_image(200, 300) + img_bytes = BytesIO() + img.save(img_bytes, format='PNG') + img_bytes.seek(0) + + pdf_bytes = BytesIO() + img2pdf.convert( + img_bytes.read(), + layout_fun=img2pdf.get_fixed_dpi_layout_fun((72, 72)), + outputstream=pdf_bytes, + **IMG2PDF_KWARGS, + ) + pdf_bytes.seek(0) + + pdf_path = tmp_path / 'negative_mediabox.pdf' + with pikepdf.open(pdf_bytes) as pdf: + page = pdf.pages[0] + # MediaBox with negative origin (valid PDF but unusual) + page.MediaBox = pikepdf.Array([-100, -100, 300, 400]) + pdf.save(pdf_path) + + return pdf_path + + +class TestRasterizerWithNonStandardBoxes: + """Test rasterizers with PDFs having nonstandard MediaBox/TrimBox/CropBox.""" + + def test_ghostscript_nonstandard_boxes(self, pdf_with_nonstandard_boxes, outpdf): + """Test Ghostscript handles nonstandard page boxes correctly.""" + check_ocrmypdf( + pdf_with_nonstandard_boxes, + outpdf, + '--rasterizer', + 'ghostscript', + '--plugin', + 'tests/plugins/tesseract_noop.py', + ) + + @pytest.mark.skipif(not PYPDFIUM_AVAILABLE, reason="pypdfium2 not installed") + def test_pypdfium_nonstandard_boxes(self, pdf_with_nonstandard_boxes, outpdf): + """Test pypdfium handles nonstandard page boxes correctly.""" + check_ocrmypdf( + pdf_with_nonstandard_boxes, + outpdf, + '--rasterizer', + 'pypdfium', + '--plugin', + 'tests/plugins/tesseract_noop.py', + ) + + def test_ghostscript_negative_mediabox(self, pdf_with_negative_mediabox, outpdf): + """Test Ghostscript handles negative MediaBox origin.""" + check_ocrmypdf( + pdf_with_negative_mediabox, + outpdf, + '--rasterizer', + 'ghostscript', + '--plugin', + 'tests/plugins/tesseract_noop.py', + ) + + @pytest.mark.skipif(not PYPDFIUM_AVAILABLE, reason="pypdfium2 not installed") + def test_pypdfium_negative_mediabox(self, pdf_with_negative_mediabox, outpdf): + """Test pypdfium handles negative MediaBox origin.""" + check_ocrmypdf( + pdf_with_negative_mediabox, + outpdf, + '--rasterizer', + 'pypdfium', + '--plugin', + 'tests/plugins/tesseract_noop.py', + ) + + def test_compare_rasterizers_nonstandard_boxes( + self, pdf_with_nonstandard_boxes, tmp_path + ): + """Compare output dimensions between rasterizers for nonstandard boxes.""" + pm = get_plugin_manager([]) + + options_gs = OcrOptions( + input_file=pdf_with_nonstandard_boxes, + output_file=tmp_path / 'out_gs.pdf', + rasterizer='ghostscript', + ) + + img_gs = tmp_path / 'gs.png' + pm.rasterize_pdf_page( + input_file=pdf_with_nonstandard_boxes, + output_file=img_gs, + raster_device='png16m', + raster_dpi=Resolution(72, 72), + page_dpi=Resolution(72, 72), + pageno=1, + rotation=0, + filter_vector=False, + stop_on_soft_error=True, + options=options_gs, + use_cropbox=False, + ) + + with Image.open(img_gs) as im_gs: + gs_size = im_gs.size + + if PYPDFIUM_AVAILABLE: + options_pdfium = OcrOptions( + input_file=pdf_with_nonstandard_boxes, + output_file=tmp_path / 'out_pdfium.pdf', + rasterizer='pypdfium', + ) + + img_pdfium = tmp_path / 'pdfium.png' + pm.rasterize_pdf_page( + input_file=pdf_with_nonstandard_boxes, + output_file=img_pdfium, + raster_device='png16m', + raster_dpi=Resolution(72, 72), + page_dpi=Resolution(72, 72), + pageno=1, + rotation=0, + filter_vector=False, + stop_on_soft_error=True, + options=options_pdfium, + use_cropbox=False, + ) + + with Image.open(img_pdfium) as im_pdfium: + pdfium_size = im_pdfium.size + + # Both rasterizers should now produce MediaBox dimensions (400x500) + # when use_cropbox=False (the default) + assert gs_size == (400, 500), f"Ghostscript size: {gs_size}" + assert pdfium_size == (400, 500), f"pypdfium size: {pdfium_size}" + + +class TestRasterizerWithRotationAndBoxes: + """Test rasterizer + rotation + nonstandard boxes combinations.""" + + # The pdf_with_nonstandard_boxes fixture creates a PDF with: + # - MediaBox: [0, 0, 400, 500] → 400x500 points + # - CropBox: [50, 50, 350, 450] → 300x400 points + # - TrimBox: [75, 75, 325, 425] → 250x350 points + # + # With use_cropbox=False (default), both rasterizers use MediaBox + MEDIABOX_WIDTH = 400 + MEDIABOX_HEIGHT = 500 + + def _get_expected_size(self, rotation: int) -> tuple[int, int]: + """Get expected image dimensions after rotation.""" + width, height = self.MEDIABOX_WIDTH, self.MEDIABOX_HEIGHT + + if rotation in (0, 180): + return (width, height) + else: # 90, 270 + return (height, width) + + def test_ghostscript_rotation_dimensions( + self, pdf_with_nonstandard_boxes, tmp_path + ): + """Test Ghostscript produces correct dimensions with rotation.""" + pm = get_plugin_manager([]) + + options = OcrOptions( + input_file=pdf_with_nonstandard_boxes, + output_file=tmp_path / 'out.pdf', + rasterizer='ghostscript', + ) + + for rotation in [0, 90, 180, 270]: + img_path = tmp_path / f'gs_rot{rotation}.png' + pm.rasterize_pdf_page( + input_file=pdf_with_nonstandard_boxes, + output_file=img_path, + raster_device='png16m', + raster_dpi=Resolution(72, 72), + page_dpi=Resolution(72, 72), + pageno=1, + rotation=rotation, + filter_vector=False, + stop_on_soft_error=True, + options=options, + use_cropbox=False, + ) + assert img_path.exists(), f"Failed to rasterize with rotation {rotation}" + + with Image.open(img_path) as img: + expected = self._get_expected_size(rotation) + # Allow small tolerance for rounding + assert abs(img.size[0] - expected[0]) <= 2, ( + f"Width mismatch at {rotation}°: got {img.size[0]}, " + f"expected {expected[0]}" + ) + assert abs(img.size[1] - expected[1]) <= 2, ( + f"Height mismatch at {rotation}°: got {img.size[1]}, " + f"expected {expected[1]}" + ) + + @pytest.mark.skipif(not PYPDFIUM_AVAILABLE, reason="pypdfium2 not installed") + def test_pypdfium_rotation_dimensions(self, pdf_with_nonstandard_boxes, tmp_path): + """Test pypdfium produces correct dimensions with rotation.""" + pm = get_plugin_manager([]) + + options = OcrOptions( + input_file=pdf_with_nonstandard_boxes, + output_file=tmp_path / 'out.pdf', + rasterizer='pypdfium', + ) + + for rotation in [0, 90, 180, 270]: + img_path = tmp_path / f'pdfium_rot{rotation}.png' + pm.rasterize_pdf_page( + input_file=pdf_with_nonstandard_boxes, + output_file=img_path, + raster_device='png16m', + raster_dpi=Resolution(72, 72), + page_dpi=Resolution(72, 72), + pageno=1, + rotation=rotation, + filter_vector=False, + stop_on_soft_error=True, + options=options, + use_cropbox=False, + ) + assert img_path.exists(), f"Failed to rasterize with rotation {rotation}" + + with Image.open(img_path) as img: + expected = self._get_expected_size(rotation) + # Allow small tolerance for rounding + assert abs(img.size[0] - expected[0]) <= 2, ( + f"Width mismatch at {rotation}°: got {img.size[0]}, " + f"expected {expected[0]}" + ) + assert abs(img.size[1] - expected[1]) <= 2, ( + f"Height mismatch at {rotation}°: got {img.size[1]}, " + f"expected {expected[1]}" + ) + + @pytest.mark.skipif(not PYPDFIUM_AVAILABLE, reason="pypdfium2 not installed") + def test_rasterizers_produce_same_dimensions( + self, pdf_with_nonstandard_boxes, tmp_path + ): + """Verify ghostscript and pypdfium produce the same MediaBox dimensions. + + With use_cropbox=False (the default), both rasterizers should render + to the MediaBox and produce identical dimensions. + """ + pm = get_plugin_manager([]) + + for rotation in [0, 90, 180, 270]: + # Rasterize with Ghostscript + gs_options = OcrOptions( + input_file=pdf_with_nonstandard_boxes, + output_file=tmp_path / 'out.pdf', + rasterizer='ghostscript', + ) + gs_img_path = tmp_path / f'gs_cmp_rot{rotation}.png' + pm.rasterize_pdf_page( + input_file=pdf_with_nonstandard_boxes, + output_file=gs_img_path, + raster_device='png16m', + raster_dpi=Resolution(72, 72), + page_dpi=Resolution(72, 72), + pageno=1, + rotation=rotation, + filter_vector=False, + stop_on_soft_error=True, + options=gs_options, + use_cropbox=False, + ) + + # Rasterize with pypdfium + pdfium_options = OcrOptions( + input_file=pdf_with_nonstandard_boxes, + output_file=tmp_path / 'out.pdf', + rasterizer='pypdfium', + ) + pdfium_img_path = tmp_path / f'pdfium_cmp_rot{rotation}.png' + pm.rasterize_pdf_page( + input_file=pdf_with_nonstandard_boxes, + output_file=pdfium_img_path, + raster_device='png16m', + raster_dpi=Resolution(72, 72), + page_dpi=Resolution(72, 72), + pageno=1, + rotation=rotation, + filter_vector=False, + stop_on_soft_error=True, + options=pdfium_options, + use_cropbox=False, + ) + + # Verify both produce the same MediaBox dimensions + with ( + Image.open(gs_img_path) as gs_img, + Image.open(pdfium_img_path) as pdfium_img, + ): + expected = self._get_expected_size(rotation) + + assert abs(gs_img.size[0] - expected[0]) <= 2, ( + f"GS width at {rotation}°: {gs_img.size[0]}, " + f"expected {expected[0]}" + ) + assert abs(gs_img.size[1] - expected[1]) <= 2, ( + f"GS height at {rotation}°: {gs_img.size[1]}, " + f"expected {expected[1]}" + ) + assert abs(pdfium_img.size[0] - expected[0]) <= 2, ( + f"pdfium width at {rotation}°: {pdfium_img.size[0]}, " + f"expected {expected[0]}" + ) + assert abs(pdfium_img.size[1] - expected[1]) <= 2, ( + f"pdfium height at {rotation}°: {pdfium_img.size[1]}, " + f"expected {expected[1]}" + ) diff --git a/tests/test_rotation.py b/tests/test_rotation.py index fa769125..b23f6d0f 100644 --- a/tests/test_rotation.py +++ b/tests/test_rotation.py @@ -19,12 +19,13 @@ from ocrmypdf._exec import ghostscript from ocrmypdf._plugin_manager import get_plugin_manager from ocrmypdf.helpers import IMG2PDF_KWARGS, Resolution from ocrmypdf.pdfinfo import PdfInfo +from ocrmypdf.pluginspec import GhostscriptRasterDevice from .conftest import check_ocrmypdf, run_ocrmypdf_api # pylintx: disable=unused-variable -RENDERERS = ['hocr', 'sandwich'] +RENDERERS = ['fpdf2', 'sandwich'] def compare_images_monochrome( @@ -40,7 +41,7 @@ def compare_images_monochrome( ghostscript.rasterize_pdf( pdf, png, - raster_device='pngmono', + raster_device=GhostscriptRasterDevice.PNGMONO, raster_dpi=Resolution(100, 100), pageno=pageno, rotation=0, @@ -146,7 +147,8 @@ def test_autorotate_threshold(threshold, op, comparison_threshold, resources, ou assert op(cmp, comparison_threshold) -def test_rotated_skew_timeout(resources, outpdf): +@pytest.mark.parametrize('rasterizer', ['pypdfium', 'ghostscript']) +def test_rotated_skew_timeout(resources, outpdf, rasterizer): """Check rotated skew timeout. This document contains an image that is rotated 90 into place with a @@ -167,10 +169,12 @@ def test_rotated_skew_timeout(resources, outpdf): input_file, outpdf, '--pdf-renderer', - 'hocr', + 'fpdf2', '--deskew', '--tesseract-timeout', '0', + '--rasterizer', + rasterizer, ) out_pageinfo = PdfInfo(out)[0] @@ -185,7 +189,8 @@ def test_rotated_skew_timeout(resources, outpdf): ), "Expected page rotation to be baked in" -def test_rotate_deskew_ocr_timeout(resources, outdir): +@pytest.mark.parametrize('rasterizer', ['pypdfium', 'ghostscript']) +def test_rotate_deskew_ocr_timeout(resources, outdir, rasterizer): check_ocrmypdf( resources / 'rotated_skew.pdf', outdir / 'deskewed.pdf', @@ -196,7 +201,9 @@ def test_rotate_deskew_ocr_timeout(resources, outdir): '--tesseract-timeout', '0', '--pdf-renderer', - 'hocr', + 'fpdf2', + '--rasterizer', + rasterizer, ) cmp = compare_images_monochrome( @@ -208,7 +215,9 @@ def test_rotate_deskew_ocr_timeout(resources, outdir): ) # Confirm that the page still got deskewed - assert cmp > 0.95 + # pypdfium anti-aliases so gets better visual quality, but lower score (0.88) + # on monochrome comparison; ghostscript looks ugly but gets > 0.95 + assert cmp > 0.85 def make_rotate_test(imagefile, outdir, prefix, image_angle, page_angle, cropbox=None): @@ -287,7 +296,7 @@ def test_page_rotate_tag(page_rotate_angle, resources, outdir, caplog): @pytest.mark.parametrize('page_rotate_angle', (0, 90, 180, 270)) -@pytest.mark.parametrize('renderer', ['sandwich', 'hocr']) +@pytest.mark.parametrize('renderer', ['sandwich', 'fpdf2']) @pytest.mark.parametrize('output_type', ['pdf', 'pdfa']) def test_rotate_and_crop( resources, outdir, page_rotate_angle, renderer, output_type, caplog @@ -324,35 +333,48 @@ def test_rotate_and_crop( assert compare_images_monochrome(outdir, reference, 1, out, 1) > 0.9 -def test_rasterize_rotates(resources, tmp_path): +@pytest.mark.parametrize('rasterizer', ['pypdfium', 'ghostscript']) +def test_rasterize_rotates(resources, tmp_path, rasterizer): + from ocrmypdf._options import OcrOptions + pm = get_plugin_manager([]) + options = OcrOptions( + input_file=resources / 'graph.pdf', + output_file=tmp_path / 'out.pdf', + rasterizer=rasterizer, + ) + img = tmp_path / 'img90.png' - pm.hook.rasterize_pdf_page( + pm.rasterize_pdf_page( input_file=resources / 'graph.pdf', output_file=img, - raster_device='pngmono', + raster_device=GhostscriptRasterDevice.PNGMONO, raster_dpi=Resolution(20, 20), page_dpi=Resolution(20, 20), pageno=1, rotation=90, filter_vector=False, stop_on_soft_error=True, + options=options, + use_cropbox=False, ) with Image.open(img) as im: assert im.size == (83, 200), "Image not rotated" img = tmp_path / 'img180.png' - pm.hook.rasterize_pdf_page( + pm.rasterize_pdf_page( input_file=resources / 'graph.pdf', output_file=img, - raster_device='pngmono', + raster_device=GhostscriptRasterDevice.PNGMONO, raster_dpi=Resolution(20, 20), page_dpi=Resolution(20, 20), pageno=1, rotation=180, filter_vector=False, stop_on_soft_error=True, + options=options, + use_cropbox=False, ) assert Image.open(img).size == (200, 83), "Image not rotated" diff --git a/tests/test_soft_error.py b/tests/test_soft_error.py index 01138c8c..5f5291f3 100644 --- a/tests/test_soft_error.py +++ b/tests/test_soft_error.py @@ -41,6 +41,8 @@ def test_render_continue_on_soft_error(resources, outpdf): exitcode = run_ocrmypdf_api( resources / 'francais.pdf', outpdf, + '--output-type', + 'pdfa', # Required to trigger Ghostscript PDF/A generation '--continue-on-soft-render-error', '--plugin', 'tests/plugins/tesseract_noop.py', @@ -55,6 +57,8 @@ def test_render_stop_on_soft_error(resources, outpdf): exitcode = run_ocrmypdf_api( resources / 'francais.pdf', outpdf, + '--output-type', + 'pdfa', # Required to trigger Ghostscript PDF/A generation '--plugin', 'tests/plugins/tesseract_noop.py', '--plugin', diff --git a/tests/test_system_font_provider.py b/tests/test_system_font_provider.py new file mode 100644 index 00000000..31f35767 --- /dev/null +++ b/tests/test_system_font_provider.py @@ -0,0 +1,344 @@ +# SPDX-FileCopyrightText: 2025 James R. Barlow +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for SystemFontProvider and ChainedFontProvider.""" + +from __future__ import annotations + +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from ocrmypdf.font import ( + BuiltinFontProvider, + ChainedFontProvider, + SystemFontProvider, +) + +# --- SystemFontProvider Platform Detection Tests --- + + +class TestSystemFontProviderPlatform: + """Test platform detection in SystemFontProvider.""" + + def test_get_platform_linux(self): + """Test Linux platform detection.""" + provider = SystemFontProvider() + with patch.object(sys, 'platform', 'linux'): + assert provider._get_platform() == 'linux' + + def test_get_platform_darwin(self): + """Test macOS platform detection.""" + provider = SystemFontProvider() + with patch.object(sys, 'platform', 'darwin'): + assert provider._get_platform() == 'darwin' + + def test_get_platform_windows(self): + """Test Windows platform detection.""" + provider = SystemFontProvider() + with patch.object(sys, 'platform', 'win32'): + assert provider._get_platform() == 'windows' + + def test_get_platform_freebsd(self): + """Test FreeBSD platform detection.""" + provider = SystemFontProvider() + with patch.object(sys, 'platform', 'freebsd13'): + assert provider._get_platform() == 'freebsd' + + +class TestSystemFontProviderDirectories: + """Test font directory resolution.""" + + def test_linux_font_dirs(self): + """Test Linux font directories.""" + provider = SystemFontProvider() + with patch.object(sys, 'platform', 'linux'): + provider._font_dirs = None # Reset cache + dirs = provider._get_font_dirs() + assert Path('/usr/share/fonts') in dirs + assert Path('/usr/local/share/fonts') in dirs + + def test_darwin_font_dirs(self): + """Test macOS font directories.""" + provider = SystemFontProvider() + with patch.object(sys, 'platform', 'darwin'): + provider._font_dirs = None # Reset cache + dirs = provider._get_font_dirs() + assert Path('/Library/Fonts') in dirs + assert Path('/System/Library/Fonts') in dirs + + def test_windows_font_dirs_with_windir(self): + """Test Windows font directory from WINDIR env var.""" + provider = SystemFontProvider() + with ( + patch.object(sys, 'platform', 'win32'), + patch.dict('os.environ', {'WINDIR': r'D:\Windows'}), + ): + provider._font_dirs = None # Reset cache + dirs = provider._get_font_dirs() + # Check that Fonts subdir of WINDIR is included + # Use str comparison to avoid Path normalization issues across platforms + dir_strs = [str(d) for d in dirs] + assert any('Fonts' in d for d in dir_strs) + + def test_windows_font_dirs_default(self): + """Test Windows font directory with default path.""" + provider = SystemFontProvider() + with ( + patch.object(sys, 'platform', 'win32'), + patch.dict('os.environ', {}, clear=True), + ): + provider._font_dirs = None # Reset cache + dirs = provider._get_font_dirs() + # Check that Windows\Fonts is included (default fallback) + dir_strs = [str(d) for d in dirs] + assert any('Windows' in d and 'Fonts' in d for d in dir_strs) + + def test_windows_font_dirs_with_localappdata(self): + """Test Windows user fonts directory from LOCALAPPDATA env var.""" + provider = SystemFontProvider() + with ( + patch.object(sys, 'platform', 'win32'), + patch.dict( + 'os.environ', + {'WINDIR': r'C:\Windows', 'LOCALAPPDATA': r'C:\Users\Test\AppData\Local'}, + ), + ): + provider._font_dirs = None # Reset cache + dirs = provider._get_font_dirs() + dir_strs = [str(d) for d in dirs] + # Should have both system and user font directories + assert len(dirs) == 2 + assert any('Windows' in d and 'Fonts' in d for d in dir_strs) + assert any( + 'AppData' in d and 'Local' in d and 'Fonts' in d + for d in dir_strs + ) + + def test_font_dirs_cached(self): + """Test that font directories are cached.""" + provider = SystemFontProvider() + dirs1 = provider._get_font_dirs() + dirs2 = provider._get_font_dirs() + assert dirs1 is dirs2 # Same object, not recomputed + + +class TestSystemFontProviderLazyLoading: + """Test lazy loading behavior.""" + + def test_no_scanning_on_init(self): + """Test that no directory scanning happens during initialization.""" + provider = SystemFontProvider() + # Caches should be empty + assert len(provider._font_cache) == 0 + assert len(provider._not_found) == 0 + + def test_get_font_unknown_name_returns_none(self): + """Test that unknown font names return None.""" + provider = SystemFontProvider() + result = provider.get_font('UnknownFont-Regular') + assert result is None + # Unknown fonts are added to not_found to cache the negative result + assert 'UnknownFont-Regular' in provider._not_found + + def test_negative_cache(self): + """Test that not-found results are cached.""" + provider = SystemFontProvider() + # Mock _find_font_file to return None + with patch.object(provider, '_find_font_file', return_value=None): + result1 = provider.get_font('NotoSansCJK-Regular') + assert result1 is None + assert 'NotoSansCJK-Regular' in provider._not_found + + # Second call should not call _find_font_file again + provider._find_font_file = MagicMock(return_value=None) + result2 = provider.get_font('NotoSansCJK-Regular') + assert result2 is None + provider._find_font_file.assert_not_called() + + def test_positive_cache(self): + """Test that found fonts are cached.""" + provider = SystemFontProvider() + font_dir = Path(__file__).parent.parent / "src" / "ocrmypdf" / "data" + font_path = font_dir / "NotoSans-Regular.ttf" + + if not font_path.exists(): + pytest.skip("Test font not available") + + with patch.object(provider, '_find_font_file', return_value=font_path): + result1 = provider.get_font('NotoSans-Regular') + assert result1 is not None + assert 'NotoSans-Regular' in provider._font_cache + + # Second call should use cache + provider._find_font_file = MagicMock() + result2 = provider.get_font('NotoSans-Regular') + assert result2 is result1 + provider._find_font_file.assert_not_called() + + +class TestSystemFontProviderAvailableFonts: + """Test get_available_fonts method.""" + + def test_returns_all_patterns(self): + """Test that get_available_fonts returns all known font patterns.""" + provider = SystemFontProvider() + fonts = provider.get_available_fonts() + assert 'NotoSans-Regular' in fonts + assert 'NotoSansCJK-Regular' in fonts + assert 'NotoSansArabic-Regular' in fonts + assert 'NotoSansThai-Regular' in fonts + + def test_fallback_font_raises(self): + """Test that get_fallback_font raises NotImplementedError.""" + provider = SystemFontProvider() + with pytest.raises(NotImplementedError): + provider.get_fallback_font() + + +# --- ChainedFontProvider Tests --- + + +class TestChainedFontProvider: + """Test ChainedFontProvider.""" + + def test_requires_at_least_one_provider(self): + """Test that empty provider list raises error.""" + with pytest.raises(ValueError, match="At least one provider"): + ChainedFontProvider([]) + + def test_get_font_tries_providers_in_order(self): + """Test that get_font tries providers in order.""" + provider1 = MagicMock() + provider1.get_font.return_value = None + + provider2 = MagicMock() + mock_font = MagicMock() + provider2.get_font.return_value = mock_font + + chain = ChainedFontProvider([provider1, provider2]) + result = chain.get_font('TestFont') + + provider1.get_font.assert_called_once_with('TestFont') + provider2.get_font.assert_called_once_with('TestFont') + assert result is mock_font + + def test_get_font_stops_on_first_match(self): + """Test that get_font stops after first successful match.""" + mock_font = MagicMock() + provider1 = MagicMock() + provider1.get_font.return_value = mock_font + + provider2 = MagicMock() + + chain = ChainedFontProvider([provider1, provider2]) + result = chain.get_font('TestFont') + + provider1.get_font.assert_called_once() + provider2.get_font.assert_not_called() + assert result is mock_font + + def test_get_font_returns_none_if_all_fail(self): + """Test that get_font returns None if all providers fail.""" + provider1 = MagicMock() + provider1.get_font.return_value = None + + provider2 = MagicMock() + provider2.get_font.return_value = None + + chain = ChainedFontProvider([provider1, provider2]) + result = chain.get_font('TestFont') + + assert result is None + + def test_get_available_fonts_combines_providers(self): + """Test that get_available_fonts combines all providers.""" + provider1 = MagicMock() + provider1.get_available_fonts.return_value = ['Font1', 'Font2'] + + provider2 = MagicMock() + provider2.get_available_fonts.return_value = ['Font2', 'Font3'] + + chain = ChainedFontProvider([provider1, provider2]) + fonts = chain.get_available_fonts() + + assert fonts == ['Font1', 'Font2', 'Font3'] # Deduplicated, order preserved + + def test_get_fallback_font_from_first_provider(self): + """Test that get_fallback_font uses first available fallback.""" + mock_font = MagicMock() + provider1 = MagicMock() + provider1.get_fallback_font.return_value = mock_font + + provider2 = MagicMock() + + chain = ChainedFontProvider([provider1, provider2]) + result = chain.get_fallback_font() + + assert result is mock_font + provider2.get_fallback_font.assert_not_called() + + def test_get_fallback_font_skips_not_implemented(self): + """Test that get_fallback_font skips providers that raise.""" + provider1 = MagicMock() + provider1.get_fallback_font.side_effect = NotImplementedError() + + mock_font = MagicMock() + provider2 = MagicMock() + provider2.get_fallback_font.return_value = mock_font + + chain = ChainedFontProvider([provider1, provider2]) + result = chain.get_fallback_font() + + assert result is mock_font + + def test_get_fallback_font_raises_if_none_available(self): + """Test that get_fallback_font raises if no provider has fallback.""" + provider1 = MagicMock() + provider1.get_fallback_font.side_effect = NotImplementedError() + + provider2 = MagicMock() + provider2.get_fallback_font.side_effect = KeyError() + + chain = ChainedFontProvider([provider1, provider2]) + with pytest.raises(RuntimeError, match="No fallback font available"): + chain.get_fallback_font() + + +class TestChainedFontProviderIntegration: + """Integration tests with real providers.""" + + @pytest.fixture + def font_dir(self): + """Return path to font directory.""" + return Path(__file__).parent.parent / "src" / "ocrmypdf" / "data" + + def test_builtin_then_system_chain(self, font_dir): + """Test chaining BuiltinFontProvider with SystemFontProvider.""" + builtin = BuiltinFontProvider(font_dir) + system = SystemFontProvider() + + chain = ChainedFontProvider([builtin, system]) + + # Should find NotoSans from builtin + font = chain.get_font('NotoSans-Regular') + assert font is not None + + # Should get fallback from builtin + fallback = chain.get_fallback_font() + assert fallback is not None + + def test_system_fonts_extend_builtin(self, font_dir): + """Test that system fonts add to builtin fonts.""" + builtin = BuiltinFontProvider(font_dir) + system = SystemFontProvider() + + chain = ChainedFontProvider([builtin, system]) + + builtin_fonts = set(builtin.get_available_fonts()) + chain_fonts = set(chain.get_available_fonts()) + + # Chain should have at least as many fonts as builtin + assert chain_fonts >= builtin_fonts diff --git a/tests/test_tesseract.py b/tests/test_tesseract.py index 0132795f..81c42c3f 100644 --- a/tests/test_tesseract.py +++ b/tests/test_tesseract.py @@ -13,7 +13,7 @@ import pytest from ocrmypdf import pdfinfo from ocrmypdf._exec import tesseract -from ocrmypdf.exceptions import BadArgsError, ExitCode, MissingDependencyError +from ocrmypdf.exceptions import BadArgsError, MissingDependencyError from .conftest import check_ocrmypdf, run_ocrmypdf_api @@ -49,7 +49,9 @@ def test_skip_pages_does_not_replicate(resources, basename, outdir): def test_content_preservation(resources, outpdf): infile = resources / 'masks.pdf' - check_ocrmypdf(infile, outpdf, '--pdf-renderer', 'hocr', '--tesseract-timeout', '0') + check_ocrmypdf( + infile, outpdf, '--pdf-renderer', 'fpdf2', '--tesseract-timeout', '0' + ) info = pdfinfo.PdfInfo(outpdf) page = info[0] diff --git a/tests/test_unpaper.py b/tests/test_unpaper.py index 952cc33a..319748da 100644 --- a/tests/test_unpaper.py +++ b/tests/test_unpaper.py @@ -11,8 +11,8 @@ import pytest from packaging.version import Version from ocrmypdf._exec import unpaper -from ocrmypdf._plugin_manager import get_parser_options_plugins from ocrmypdf._validation import check_options +from ocrmypdf.cli import get_options_and_plugins from ocrmypdf.exceptions import BadArgsError, ExitCode, MissingDependencyError from .conftest import check_ocrmypdf, have_unpaper, run_ocrmypdf_api @@ -26,7 +26,7 @@ def test_no_unpaper(resources, no_outpdf): input_ = fspath(resources / "c02-22.pdf") output = fspath(no_outpdf) - _parser, options, pm = get_parser_options_plugins(["--clean", input_, output]) + options, pm = get_options_and_plugins(["--clean", input_, output]) with patch("ocrmypdf._exec.unpaper.version") as mock: mock.side_effect = FileNotFoundError("unpaper") @@ -39,7 +39,7 @@ def test_old_unpaper(resources, no_outpdf): input_ = fspath(resources / "c02-22.pdf") output = fspath(no_outpdf) - _parser, options, pm = get_parser_options_plugins(["--clean", input_, output]) + options, pm = get_options_and_plugins(["--clean", input_, output]) with patch("ocrmypdf._exec.unpaper.version") as mock: mock.return_value = Version('0.5') @@ -52,7 +52,7 @@ def test_unpaper_version_chatter(resources, no_outpdf): input_ = fspath(resources / "c02-22.pdf") output = fspath(no_outpdf) - _parser, options, pm = get_parser_options_plugins(["--clean", input_, output]) + options, pm = get_options_and_plugins(["--clean", input_, output]) with patch("ocrmypdf.subprocess.run") as mock: mock.return_value = Mock(stdout='Warning: using insecure memory!\n7.0.0\n') @@ -119,7 +119,7 @@ def test_unpaper_args_invalid(resources, outpdf): def test_unpaper_image_too_big(resources, outdir, caplog): with patch('ocrmypdf._exec.unpaper.UNPAPER_IMAGE_PIXEL_LIMIT', 42): infile = resources / 'crom.png' - unpaper.clean(infile, outdir / 'out.png', dpi=300) == infile + assert unpaper.clean(infile, outdir / 'out.png', dpi=300) == infile assert any( 'too large for cleaning' in rec.message diff --git a/tests/test_validation.py b/tests/test_validation.py index 1cc614b6..4bd44f4b 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -13,8 +13,8 @@ import pytest from ocrmypdf import _validation as vd from ocrmypdf._concurrent import NullProgressBar, SerialExecutor from ocrmypdf._exec.tesseract import TesseractVersion -from ocrmypdf._plugin_manager import get_plugin_manager -from ocrmypdf.api import create_options +from ocrmypdf._options import OcrOptions +from ocrmypdf.api import create_options, setup_plugin_infrastructure from ocrmypdf.cli import get_parser from ocrmypdf.exceptions import BadArgsError, MissingDependencyError from ocrmypdf.pdfinfo import PdfInfo @@ -26,8 +26,8 @@ def make_opts_pm(input_file='a.pdf', output_file='b.pdf', language='eng', **kwar if language is not None: kwargs['language'] = language parser = get_parser() - pm = get_plugin_manager(kwargs.get('plugins', [])) - pm.hook.add_options(parser=parser) # pylint: disable=no-member + pm = setup_plugin_infrastructure(plugins=kwargs.get('plugins', [])) + pm.add_options(parser=parser) return ( create_options( input_file=input_file, output_file=output_file, parser=parser, **kwargs @@ -41,13 +41,17 @@ def make_opts(*args, **kwargs): return opts +def make_ocr_opts(input_file='a.pdf', output_file='b.pdf', **kwargs): + """Create OcrOptions directly for testing Pydantic validation.""" + return OcrOptions(input_file=input_file, output_file=output_file, **kwargs) + + def test_old_tesseract_error(): with patch( 'ocrmypdf._exec.tesseract.version', return_value=TesseractVersion('4.00.00alpha'), - ): - with pytest.raises(MissingDependencyError): - vd.check_options(*make_opts_pm(pdf_renderer='sandwich', language='eng')) + ), pytest.raises(MissingDependencyError): + vd.check_options(*make_opts_pm(pdf_renderer='sandwich', language='eng')) def test_tesseract_not_installed(caplog): @@ -63,36 +67,45 @@ def test_tesseract_not_installed(caplog): def test_lossless_redo(): - with pytest.raises(BadArgsError): - options = make_opts(redo_ocr=True, deskew=True) - vd.check_options_output(options) - vd.set_lossless_reconstruction(options) + with pytest.raises(ValueError, match="--redo-ocr.*is not currently compatible"): + make_ocr_opts(redo_ocr=True, deskew=True) def test_mutex_options(): - with pytest.raises(BadArgsError): - vd.check_options_ocr_behavior(make_opts(force_ocr=True, skip_text=True)) - with pytest.raises(BadArgsError): - vd.check_options_ocr_behavior(make_opts(redo_ocr=True, skip_text=True)) - with pytest.raises(BadArgsError): - vd.check_options_ocr_behavior(make_opts(redo_ocr=True, force_ocr=True)) + with pytest.raises( + ValueError, match="Choose only one of --force-ocr, --skip-text, --redo-ocr" + ): + make_ocr_opts(force_ocr=True, skip_text=True) + with pytest.raises( + ValueError, match="Choose only one of --force-ocr, --skip-text, --redo-ocr" + ): + make_ocr_opts(redo_ocr=True, skip_text=True) + with pytest.raises( + ValueError, match="Choose only one of --force-ocr, --skip-text, --redo-ocr" + ): + make_ocr_opts(redo_ocr=True, force_ocr=True) def test_optimizing(caplog): vd.check_options( - *make_opts_pm(optimize=0, jbig2_lossy=True, png_quality=18, jpeg_quality=10) + *make_opts_pm(optimize=0, png_quality=18, jpeg_quality=10) ) assert 'will be ignored because' in caplog.text def test_pillow_options(): - vd.check_options_pillow(make_opts(max_image_mpixels=0)) + # Test that max_image_mpixels=0 is valid (validation now in OcrOptions) + opts = make_ocr_opts(max_image_mpixels=0) + assert opts.max_image_mpixels == 0 + + # Test that negative values are rejected + with pytest.raises(ValueError, match="max_image_mpixels must be non-negative"): + make_ocr_opts(max_image_mpixels=-1) def test_output_tty(): - with patch('sys.stdout.isatty', return_value=True): - with pytest.raises(BadArgsError): - vd.check_requested_output_file(make_opts(output_file='-')) + with patch('sys.stdout.isatty', return_value=True), pytest.raises(BadArgsError): + vd.check_requested_output_file(make_opts(output_file='-')) def test_report_file_size(tmp_path, caplog): @@ -157,28 +170,6 @@ def test_no_progress_bar(progress_bar, resources): assert pbar_disabled is not None and pbar_disabled != progress_bar -def test_language_warning(caplog): - opts = make_opts(language=None) - _plugin_manager = get_plugin_manager(opts.plugins) - caplog.set_level(logging.DEBUG) - with patch( - 'ocrmypdf._validation.locale.getlocale', return_value=('en_US', 'UTF-8') - ) as mock: - vd.check_options_languages(opts, ['eng']) - assert opts.languages == ['eng'] - assert '' in caplog.text - mock.assert_called_once() - - opts = make_opts(language=None) - with patch( - 'ocrmypdf._validation.locale.getlocale', return_value=('fr_FR', 'UTF-8') - ) as mock: - vd.check_options_languages(opts, ['eng']) - assert opts.languages == ['eng'] - assert 'assuming --language' in caplog.text - mock.assert_called_once() - - def make_version(version): def _make_version(): return TesseractVersion(version) @@ -274,7 +265,7 @@ def test_optional_program_recommended(caplog): def test_pagesegmode_warning(caplog): opts = make_opts(tesseract_pagesegmode='0') - plugin_manager = get_plugin_manager(opts.plugins) + plugin_manager = setup_plugin_infrastructure(plugins=opts.plugins or []) vd.check_options(opts, plugin_manager) assert 'disable OCR' in caplog.text @@ -285,7 +276,7 @@ def test_two_languages(): input_file='a.pdf', output_file='b.pdf', parser=get_parser(), - language='fakelang1+fakelang2', + languages=['fakelang1', 'fakelang2'], ), ['fakelang1', 'fakelang2'], ) diff --git a/tests/test_verapdf.py b/tests/test_verapdf.py new file mode 100644 index 00000000..e7562256 --- /dev/null +++ b/tests/test_verapdf.py @@ -0,0 +1,166 @@ +# SPDX-FileCopyrightText: 2024 James R. Barlow +# SPDX-License-Identifier: CC-BY-SA-4.0 + +"""Tests for verapdf wrapper and speculative PDF/A conversion.""" + +from __future__ import annotations + +import pikepdf +import pytest +from pikepdf import Name + +from ocrmypdf._exec import verapdf +from ocrmypdf.pdfa import ( + _pdfa_part_conformance, + add_pdfa_metadata, + add_srgb_output_intent, + speculative_pdfa_conversion, +) + + +class TestVerapdfModule: + """Tests for verapdf wrapper module.""" + + def test_output_type_to_flavour(self): + assert verapdf.output_type_to_flavour('pdfa') == '2b' + assert verapdf.output_type_to_flavour('pdfa-1') == '1b' + assert verapdf.output_type_to_flavour('pdfa-2') == '2b' + assert verapdf.output_type_to_flavour('pdfa-3') == '3b' + # Unknown should default to 2b + assert verapdf.output_type_to_flavour('unknown') == '2b' + + @pytest.mark.skipif(not verapdf.available(), reason='verapdf not installed') + def test_version(self): + ver = verapdf.version() + assert ver.major >= 1 + + @pytest.mark.skipif(not verapdf.available(), reason='verapdf not installed') + def test_validate_non_pdfa(self, tmp_path): + """Test validation of a non-PDF/A file returns invalid.""" + test_pdf = tmp_path / 'test.pdf' + with pikepdf.new() as pdf: + pdf.add_blank_page() + pdf.save(test_pdf) + + result = verapdf.validate(test_pdf, '2b') + assert not result.valid + assert result.failed_rules > 0 + + +class TestPdfaPartConformance: + """Tests for _pdfa_part_conformance helper.""" + + def test_pdfa_part_conformance(self): + assert _pdfa_part_conformance('pdfa') == ('2', 'B') + assert _pdfa_part_conformance('pdfa-1') == ('1', 'B') + assert _pdfa_part_conformance('pdfa-2') == ('2', 'B') + assert _pdfa_part_conformance('pdfa-3') == ('3', 'B') + # Unknown should default to 2B + assert _pdfa_part_conformance('unknown') == ('2', 'B') + + +class TestAddPdfaMetadata: + """Tests for add_pdfa_metadata function.""" + + def test_add_pdfa_metadata(self, tmp_path): + """Test adding PDF/A XMP metadata.""" + test_pdf = tmp_path / 'test.pdf' + with pikepdf.new() as pdf: + pdf.add_blank_page() + pdf.save(test_pdf) + + with pikepdf.open(test_pdf, allow_overwriting_input=True) as pdf: + add_pdfa_metadata(pdf, '2', 'B') + with pdf.open_metadata() as meta: + assert meta.pdfa_status == '2B' + pdf.save(test_pdf) + + # Verify it persists after save + with pikepdf.open(test_pdf) as pdf, pdf.open_metadata() as meta: + assert meta.pdfa_status == '2B' + + +class TestAddSrgbOutputIntent: + """Tests for add_srgb_output_intent function.""" + + def test_add_srgb_output_intent(self, tmp_path): + """Test adding sRGB OutputIntent to a PDF.""" + test_pdf = tmp_path / 'test.pdf' + with pikepdf.new() as pdf: + pdf.add_blank_page() + pdf.save(test_pdf) + + with pikepdf.open(test_pdf, allow_overwriting_input=True) as pdf: + add_srgb_output_intent(pdf) + assert Name.OutputIntents in pdf.Root + assert len(pdf.Root.OutputIntents) == 1 + intent = pdf.Root.OutputIntents[0] + assert str(intent.get(Name.OutputConditionIdentifier)) == 'sRGB' + pdf.save(test_pdf) + + def test_add_srgb_output_intent_idempotent(self, tmp_path): + """Test that adding OutputIntent twice doesn't duplicate.""" + test_pdf = tmp_path / 'test.pdf' + with pikepdf.new() as pdf: + pdf.add_blank_page() + pdf.save(test_pdf) + + with pikepdf.open(test_pdf, allow_overwriting_input=True) as pdf: + add_srgb_output_intent(pdf) + add_srgb_output_intent(pdf) # Second call should be a no-op + assert len(pdf.Root.OutputIntents) == 1 + pdf.save(test_pdf) + + +class TestSpeculativePdfaConversion: + """Tests for speculative PDF/A conversion.""" + + def test_speculative_conversion_creates_pdfa_structures(self, tmp_path, resources): + """Test that speculative conversion adds PDF/A structures.""" + input_pdf = resources / 'graph.pdf' + output_pdf = tmp_path / 'output.pdf' + + result = speculative_pdfa_conversion(input_pdf, output_pdf, 'pdfa-2') + + assert result.exists() + with pikepdf.open(result) as pdf: + assert Name.OutputIntents in pdf.Root + with pdf.open_metadata() as meta: + assert meta.pdfa_status == '2B' + + def test_speculative_conversion_different_parts(self, tmp_path, resources): + """Test speculative conversion with different PDF/A parts.""" + input_pdf = resources / 'graph.pdf' + + for output_type, expected_status in [ + ('pdfa-1', '1B'), + ('pdfa-2', '2B'), + ('pdfa-3', '3B'), + ]: + output_pdf = tmp_path / f'output_{output_type}.pdf' + speculative_pdfa_conversion(input_pdf, output_pdf, output_type) + + with pikepdf.open(output_pdf) as pdf, pdf.open_metadata() as meta: + assert meta.pdfa_status == expected_status + + +@pytest.mark.skipif(not verapdf.available(), reason='verapdf not installed') +class TestVerapdfIntegration: + """Integration tests requiring verapdf.""" + + def test_speculative_conversion_validation(self, tmp_path, resources): + """Test that speculative conversion can be validated by verapdf. + + Note: Most test PDFs will fail validation because they have issues + that require Ghostscript to fix (fonts, colorspaces, etc.). This test + verifies the validation pipeline works, not that all PDFs pass. + """ + input_pdf = resources / 'graph.pdf' + output_pdf = tmp_path / 'output.pdf' + + speculative_pdfa_conversion(input_pdf, output_pdf, 'pdfa-2') + + # The converted file can be validated (even if it fails) + result = verapdf.validate(output_pdf, '2b') + assert isinstance(result.valid, bool) + assert isinstance(result.failed_rules, int) diff --git a/tests/test_watcher.py b/tests/test_watcher.py index 40b01089..321cf19f 100644 --- a/tests/test_watcher.py +++ b/tests/test_watcher.py @@ -1,4 +1,6 @@ -import datetime +from __future__ import annotations + +import datetime as dt import os import shutil import subprocess @@ -20,10 +22,7 @@ def test_watcher(tmp_path, resources, year_month): processed_dir = tmp_path / 'processed' processed_dir.mkdir() - if year_month: - env_extra = {'OCR_OUTPUT_DIRECTORY_YEAR_MONTH': '1'} - else: - env_extra = {} + env_extra = {'OCR_OUTPUT_DIRECTORY_YEAR_MONTH': '1'} if year_month else {} proc = subprocess.Popen( [ sys.executable, @@ -43,8 +42,8 @@ def test_watcher(tmp_path, resources, year_month): if year_month: assert ( output_dir - / f'{datetime.date.today().year}' - / f'{datetime.date.today().month:02d}' + / f'{dt.date.today().year}' + / f'{dt.date.today().month:02d}' / 'trivial.pdf' ).exists() else: diff --git a/uv.lock b/uv.lock index 17b5d186..6188ac50 100644 --- a/uv.lock +++ b/uv.lock @@ -1,10 +1,9 @@ version = 1 revision = 3 -requires-python = ">=3.10" +requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", - "python_full_version < '3.11'", + "python_full_version < '3.12'", ] [[package]] @@ -18,18 +17,27 @@ wheels = [ [[package]] name = "altair" -version = "5.5.0" +version = "6.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jinja2" }, { name = "jsonschema" }, { name = "narwhals" }, { name = "packaging" }, - { name = "typing-extensions", marker = "python_full_version < '3.14'" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/16/b1/f2969c7bdb8ad8bbdda031687defdce2c19afba2aa2c8e1d2a17f78376d8/altair-5.5.0.tar.gz", hash = "sha256:d960ebe6178c56de3855a68c47b516be38640b73fb3b5111c2a9ca90546dd73d", size = 705305, upload-time = "2024-11-23T23:39:58.542Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/c0/184a89bd5feba14ff3c41cfaf1dd8a82c05f5ceedbc92145e17042eb08a4/altair-6.0.0.tar.gz", hash = "sha256:614bf5ecbe2337347b590afb111929aa9c16c9527c4887d96c9bc7f6640756b4", size = 763834, upload-time = "2025-11-12T08:59:11.519Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/f3/0b6ced594e51cc95d8c1fc1640d3623770d01e4969d29c0bd09945fafefa/altair-5.5.0-py3-none-any.whl", hash = "sha256:91a310b926508d560fe0148d02a194f38b824122641ef528113d029fcd129f8c", size = 731200, upload-time = "2024-11-23T23:39:56.4Z" }, + { url = "https://files.pythonhosted.org/packages/db/33/ef2f2409450ef6daa61459d5de5c08128e7d3edb773fefd0a324d1310238/altair-6.0.0-py3-none-any.whl", hash = "sha256:09ae95b53d5fe5b16987dccc785a7af8588f2dca50de1e7a156efa8a461515f8", size = 795410, upload-time = "2025-11-12T08:59:09.804Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] [[package]] @@ -43,11 +51,11 @@ wheels = [ [[package]] name = "asttokens" -version = "3.0.0" +version = "3.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4a/e7/82da0a03e7ba5141f05cce0d302e6eed121ae055e0456ca228bf693984bc/asttokens-3.0.0.tar.gz", hash = "sha256:0dcd8baa8d62b0c1d118b399b2ddba3c4aff271d0d7a9e0d4c1681c79035bbc7", size = 61978, upload-time = "2024-11-30T04:30:14.439Z" } +sdist = { url = "https://files.pythonhosted.org/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308, upload-time = "2025-11-15T16:43:48.578Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2", size = 26918, upload-time = "2024-11-30T04:30:10.946Z" }, + { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" }, ] [[package]] @@ -79,20 +87,20 @@ wheels = [ [[package]] name = "cachetools" -version = "6.2.1" +version = "6.2.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cc/7e/b975b5814bd36faf009faebe22c1072a1fa1168db34d285ef0ba071ad78c/cachetools-6.2.1.tar.gz", hash = "sha256:3f391e4bd8f8bf0931169baf7456cc822705f4e2a31f840d218f445b9a854201", size = 31325, upload-time = "2025-10-12T14:55:30.139Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/1d/ede8680603f6016887c062a2cf4fc8fdba905866a3ab8831aa8aa651320c/cachetools-6.2.4.tar.gz", hash = "sha256:82c5c05585e70b6ba2d3ae09ea60b79548872185d2f24ae1f2709d37299fd607", size = 31731, upload-time = "2025-12-15T18:24:53.744Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/c5/1e741d26306c42e2bf6ab740b2202872727e0f606033c9dd713f8b93f5a8/cachetools-6.2.1-py3-none-any.whl", hash = "sha256:09868944b6dde876dfd44e1d47e18484541eaf12f26f29b7af91b26cc892d701", size = 11280, upload-time = "2025-10-12T14:55:28.382Z" }, + { url = "https://files.pythonhosted.org/packages/2c/fc/1d7b80d0eb7b714984ce40efc78859c022cd930e402f599d8ca9e39c78a4/cachetools-6.2.4-py3-none-any.whl", hash = "sha256:69a7a52634fed8b8bf6e24a050fb60bff1c9bd8f6d24572b99c32d4e71e62a51", size = 11551, upload-time = "2025-12-15T18:24:52.332Z" }, ] [[package]] name = "certifi" -version = "2025.10.5" +version = "2026.1.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload-time = "2025-10-05T04:12:15.808Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, ] [[package]] @@ -104,18 +112,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, - { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, - { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, - { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, - { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, - { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, - { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, - { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, - { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, - { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, - { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, - { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, @@ -183,22 +179,6 @@ version = "3.4.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" }, - { url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" }, - { url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" }, - { url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" }, - { url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" }, - { url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" }, - { url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" }, - { url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" }, - { url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" }, - { url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" }, - { url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" }, - { url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" }, - { url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" }, - { url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" }, - { url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" }, { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, @@ -268,14 +248,14 @@ wheels = [ [[package]] name = "click" -version = "8.3.0" +version = "8.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" }, + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, ] [[package]] @@ -298,101 +278,89 @@ wheels = [ [[package]] name = "coverage" -version = "7.11.3" +version = "7.13.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d2/59/9698d57a3b11704c7b89b21d69e9d23ecf80d538cabb536c8b63f4a12322/coverage-7.11.3.tar.gz", hash = "sha256:0f59387f5e6edbbffec2281affb71cdc85e0776c1745150a3ab9b6c1d016106b", size = 815210, upload-time = "2025-11-10T00:13:17.18Z" } +sdist = { url = "https://files.pythonhosted.org/packages/23/f9/e92df5e07f3fc8d4c7f9a0f146ef75446bf870351cd37b788cf5897f8079/coverage-7.13.1.tar.gz", hash = "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd", size = 825862, upload-time = "2025-12-28T15:42:56.969Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/68/b53157115ef76d50d1d916d6240e5cd5b3c14dba8ba1b984632b8221fc2e/coverage-7.11.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0c986537abca9b064510f3fd104ba33e98d3036608c7f2f5537f869bc10e1ee5", size = 216377, upload-time = "2025-11-10T00:10:27.317Z" }, - { url = "https://files.pythonhosted.org/packages/14/c1/d2f9d8e37123fe6e7ab8afcaab8195f13bc84a8b2f449a533fd4812ac724/coverage-7.11.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:28c5251b3ab1d23e66f1130ca0c419747edfbcb4690de19467cd616861507af7", size = 216892, upload-time = "2025-11-10T00:10:30.624Z" }, - { url = "https://files.pythonhosted.org/packages/83/73/18f05d8010149b650ed97ee5c9f7e4ae68c05c7d913391523281e41c2495/coverage-7.11.3-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4f2bb4ee8dd40f9b2a80bb4adb2aecece9480ba1fa60d9382e8c8e0bd558e2eb", size = 243650, upload-time = "2025-11-10T00:10:32.392Z" }, - { url = "https://files.pythonhosted.org/packages/63/3c/c0cbb296c0ecc6dcbd70f4b473fcd7fe4517bbef8b09f4326d78f38adb87/coverage-7.11.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e5f4bfac975a2138215a38bda599ef00162e4143541cf7dd186da10a7f8e69f1", size = 245478, upload-time = "2025-11-10T00:10:34.157Z" }, - { url = "https://files.pythonhosted.org/packages/b9/9a/dad288cf9faa142a14e75e39dc646d968b93d74e15c83e9b13fd628f2cb3/coverage-7.11.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f4cbfff5cf01fa07464439a8510affc9df281535f41a1f5312fbd2b59b4ab5c", size = 247337, upload-time = "2025-11-10T00:10:35.655Z" }, - { url = "https://files.pythonhosted.org/packages/e3/ba/f6148ebf5547b3502013175e41bf3107a4e34b7dd19f9793a6ce0e1cd61f/coverage-7.11.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:31663572f20bf3406d7ac00d6981c7bbbcec302539d26b5ac596ca499664de31", size = 244328, upload-time = "2025-11-10T00:10:37.459Z" }, - { url = "https://files.pythonhosted.org/packages/e6/4d/b93784d0b593c5df89a0d48cbbd2d0963e0ca089eaf877405849792e46d3/coverage-7.11.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9799bd6a910961cb666196b8583ed0ee125fa225c6fdee2cbf00232b861f29d2", size = 245381, upload-time = "2025-11-10T00:10:39.229Z" }, - { url = "https://files.pythonhosted.org/packages/3a/8d/6735bfd4f0f736d457642ee056a570d704c9d57fdcd5c91ea5d6b15c944e/coverage-7.11.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:097acc18bedf2c6e3144eaf09b5f6034926c3c9bb9e10574ffd0942717232507", size = 243390, upload-time = "2025-11-10T00:10:40.984Z" }, - { url = "https://files.pythonhosted.org/packages/db/3d/7ba68ed52d1873d450aefd8d2f5a353e67b421915cb6c174e4222c7b918c/coverage-7.11.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:6f033dec603eea88204589175782290a038b436105a8f3637a81c4359df27832", size = 243654, upload-time = "2025-11-10T00:10:42.496Z" }, - { url = "https://files.pythonhosted.org/packages/14/26/be2720c4c7bf73c6591ae4ab503a7b5a31c7a60ced6dba855cfcb4a5af7e/coverage-7.11.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dd9ca2d44ed8018c90efb72f237a2a140325a4c3339971364d758e78b175f58e", size = 244272, upload-time = "2025-11-10T00:10:44.39Z" }, - { url = "https://files.pythonhosted.org/packages/90/20/086f5697780df146dbc0df4ae9b6db2b23ddf5aa550f977b2825137728e9/coverage-7.11.3-cp310-cp310-win32.whl", hash = "sha256:900580bc99c145e2561ea91a2d207e639171870d8a18756eb57db944a017d4bb", size = 218969, upload-time = "2025-11-10T00:10:45.863Z" }, - { url = "https://files.pythonhosted.org/packages/98/5c/cc6faba945ede5088156da7770e30d06c38b8591785ac99bcfb2074f9ef6/coverage-7.11.3-cp310-cp310-win_amd64.whl", hash = "sha256:c8be5bfcdc7832011b2652db29ed7672ce9d353dd19bce5272ca33dbcf60aaa8", size = 219903, upload-time = "2025-11-10T00:10:47.676Z" }, - { url = "https://files.pythonhosted.org/packages/92/92/43a961c0f57b666d01c92bcd960c7f93677de5e4ee7ca722564ad6dee0fa/coverage-7.11.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:200bb89fd2a8a07780eafcdff6463104dec459f3c838d980455cfa84f5e5e6e1", size = 216504, upload-time = "2025-11-10T00:10:49.524Z" }, - { url = "https://files.pythonhosted.org/packages/5d/5c/dbfc73329726aef26dbf7fefef81b8a2afd1789343a579ea6d99bf15d26e/coverage-7.11.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8d264402fc179776d43e557e1ca4a7d953020d3ee95f7ec19cc2c9d769277f06", size = 217006, upload-time = "2025-11-10T00:10:51.32Z" }, - { url = "https://files.pythonhosted.org/packages/a5/e0/878c84fb6661964bc435beb1e28c050650aa30e4c1cdc12341e298700bda/coverage-7.11.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:385977d94fc155f8731c895accdfcc3dd0d9dd9ef90d102969df95d3c637ab80", size = 247415, upload-time = "2025-11-10T00:10:52.805Z" }, - { url = "https://files.pythonhosted.org/packages/56/9e/0677e78b1e6a13527f39c4b39c767b351e256b333050539861c63f98bd61/coverage-7.11.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0542ddf6107adbd2592f29da9f59f5d9cff7947b5bb4f734805085c327dcffaa", size = 249332, upload-time = "2025-11-10T00:10:54.35Z" }, - { url = "https://files.pythonhosted.org/packages/54/90/25fc343e4ce35514262451456de0953bcae5b37dda248aed50ee51234cee/coverage-7.11.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d60bf4d7f886989ddf80e121a7f4d140d9eac91f1d2385ce8eb6bda93d563297", size = 251443, upload-time = "2025-11-10T00:10:55.832Z" }, - { url = "https://files.pythonhosted.org/packages/13/56/bc02bbc890fd8b155a64285c93e2ab38647486701ac9c980d457cdae857a/coverage-7.11.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0a3b6e32457535df0d41d2d895da46434706dd85dbaf53fbc0d3bd7d914b362", size = 247554, upload-time = "2025-11-10T00:10:57.829Z" }, - { url = "https://files.pythonhosted.org/packages/0f/ab/0318888d091d799a82d788c1e8d8bd280f1d5c41662bbb6e11187efe33e8/coverage-7.11.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:876a3ee7fd2613eb79602e4cdb39deb6b28c186e76124c3f29e580099ec21a87", size = 249139, upload-time = "2025-11-10T00:10:59.465Z" }, - { url = "https://files.pythonhosted.org/packages/79/d8/3ee50929c4cd36fcfcc0f45d753337001001116c8a5b8dd18d27ea645737/coverage-7.11.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a730cd0824e8083989f304e97b3f884189efb48e2151e07f57e9e138ab104200", size = 247209, upload-time = "2025-11-10T00:11:01.432Z" }, - { url = "https://files.pythonhosted.org/packages/94/7c/3cf06e327401c293e60c962b4b8a2ceb7167c1a428a02be3adbd1d7c7e4c/coverage-7.11.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b5cd111d3ab7390be0c07ad839235d5ad54d2ca497b5f5db86896098a77180a4", size = 246936, upload-time = "2025-11-10T00:11:02.964Z" }, - { url = "https://files.pythonhosted.org/packages/99/0b/ffc03dc8f4083817900fd367110015ef4dd227b37284104a5eb5edc9c106/coverage-7.11.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:074e6a5cd38e06671580b4d872c1a67955d4e69639e4b04e87fc03b494c1f060", size = 247835, upload-time = "2025-11-10T00:11:04.405Z" }, - { url = "https://files.pythonhosted.org/packages/17/4d/dbe54609ee066553d0bcdcdf108b177c78dab836292bee43f96d6a5674d1/coverage-7.11.3-cp311-cp311-win32.whl", hash = "sha256:86d27d2dd7c7c5a44710565933c7dc9cd70e65ef97142e260d16d555667deef7", size = 218994, upload-time = "2025-11-10T00:11:05.966Z" }, - { url = "https://files.pythonhosted.org/packages/94/11/8e7155df53f99553ad8114054806c01a2c0b08f303ea7e38b9831652d83d/coverage-7.11.3-cp311-cp311-win_amd64.whl", hash = "sha256:ca90ef33a152205fb6f2f0c1f3e55c50df4ef049bb0940ebba666edd4cdebc55", size = 219926, upload-time = "2025-11-10T00:11:07.936Z" }, - { url = "https://files.pythonhosted.org/packages/1f/93/bea91b6a9e35d89c89a1cd5824bc72e45151a9c2a9ca0b50d9e9a85e3ae3/coverage-7.11.3-cp311-cp311-win_arm64.whl", hash = "sha256:56f909a40d68947ef726ce6a34eb38f0ed241ffbe55c5007c64e616663bcbafc", size = 218599, upload-time = "2025-11-10T00:11:09.578Z" }, - { url = "https://files.pythonhosted.org/packages/c2/39/af056ec7a27c487e25c7f6b6e51d2ee9821dba1863173ddf4dc2eebef4f7/coverage-7.11.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5b771b59ac0dfb7f139f70c85b42717ef400a6790abb6475ebac1ecee8de782f", size = 216676, upload-time = "2025-11-10T00:11:11.566Z" }, - { url = "https://files.pythonhosted.org/packages/3c/f8/21126d34b174d037b5d01bea39077725cbb9a0da94a95c5f96929c695433/coverage-7.11.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:603c4414125fc9ae9000f17912dcfd3d3eb677d4e360b85206539240c96ea76e", size = 217034, upload-time = "2025-11-10T00:11:13.12Z" }, - { url = "https://files.pythonhosted.org/packages/d5/3f/0fd35f35658cdd11f7686303214bd5908225838f374db47f9e457c8d6df8/coverage-7.11.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:77ffb3b7704eb7b9b3298a01fe4509cef70117a52d50bcba29cffc5f53dd326a", size = 248531, upload-time = "2025-11-10T00:11:15.023Z" }, - { url = "https://files.pythonhosted.org/packages/8f/59/0bfc5900fc15ce4fd186e092451de776bef244565c840c9c026fd50857e1/coverage-7.11.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4d4ca49f5ba432b0755ebb0fc3a56be944a19a16bb33802264bbc7311622c0d1", size = 251290, upload-time = "2025-11-10T00:11:16.628Z" }, - { url = "https://files.pythonhosted.org/packages/71/88/d5c184001fa2ac82edf1b8f2cd91894d2230d7c309e937c54c796176e35b/coverage-7.11.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:05fd3fb6edff0c98874d752013588836f458261e5eba587afe4c547bba544afd", size = 252375, upload-time = "2025-11-10T00:11:18.249Z" }, - { url = "https://files.pythonhosted.org/packages/5c/29/f60af9f823bf62c7a00ce1ac88441b9a9a467e499493e5cc65028c8b8dd2/coverage-7.11.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0e920567f8c3a3ce68ae5a42cf7c2dc4bb6cc389f18bff2235dd8c03fa405de5", size = 248946, upload-time = "2025-11-10T00:11:20.202Z" }, - { url = "https://files.pythonhosted.org/packages/67/16/4662790f3b1e03fce5280cad93fd18711c35980beb3c6f28dca41b5230c6/coverage-7.11.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4bec8c7160688bd5a34e65c82984b25409563134d63285d8943d0599efbc448e", size = 250310, upload-time = "2025-11-10T00:11:21.689Z" }, - { url = "https://files.pythonhosted.org/packages/8f/75/dd6c2e28308a83e5fc1ee602f8204bd3aa5af685c104cb54499230cf56db/coverage-7.11.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:adb9b7b42c802bd8cb3927de8c1c26368ce50c8fdaa83a9d8551384d77537044", size = 248461, upload-time = "2025-11-10T00:11:23.384Z" }, - { url = "https://files.pythonhosted.org/packages/16/fe/b71af12be9f59dc9eb060688fa19a95bf3223f56c5af1e9861dfa2275d2c/coverage-7.11.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c8f563b245b4ddb591e99f28e3cd140b85f114b38b7f95b2e42542f0603eb7d7", size = 248039, upload-time = "2025-11-10T00:11:25.07Z" }, - { url = "https://files.pythonhosted.org/packages/11/b8/023b2003a2cd96bdf607afe03d9b96c763cab6d76e024abe4473707c4eb8/coverage-7.11.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e2a96fdc7643c9517a317553aca13b5cae9bad9a5f32f4654ce247ae4d321405", size = 249903, upload-time = "2025-11-10T00:11:26.992Z" }, - { url = "https://files.pythonhosted.org/packages/d6/ee/5f1076311aa67b1fa4687a724cc044346380e90ce7d94fec09fd384aa5fd/coverage-7.11.3-cp312-cp312-win32.whl", hash = "sha256:e8feeb5e8705835f0622af0fe7ff8d5cb388948454647086494d6c41ec142c2e", size = 219201, upload-time = "2025-11-10T00:11:28.619Z" }, - { url = "https://files.pythonhosted.org/packages/4f/24/d21688f48fe9fcc778956680fd5aaf69f4e23b245b7c7a4755cbd421d25b/coverage-7.11.3-cp312-cp312-win_amd64.whl", hash = "sha256:abb903ffe46bd319d99979cdba350ae7016759bb69f47882242f7b93f3356055", size = 220012, upload-time = "2025-11-10T00:11:30.234Z" }, - { url = "https://files.pythonhosted.org/packages/4f/9e/d5eb508065f291456378aa9b16698b8417d87cb084c2b597f3beb00a8084/coverage-7.11.3-cp312-cp312-win_arm64.whl", hash = "sha256:1451464fd855d9bd000c19b71bb7dafea9ab815741fb0bd9e813d9b671462d6f", size = 218652, upload-time = "2025-11-10T00:11:32.165Z" }, - { url = "https://files.pythonhosted.org/packages/6d/f6/d8572c058211c7d976f24dab71999a565501fb5b3cdcb59cf782f19c4acb/coverage-7.11.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84b892e968164b7a0498ddc5746cdf4e985700b902128421bb5cec1080a6ee36", size = 216694, upload-time = "2025-11-10T00:11:34.296Z" }, - { url = "https://files.pythonhosted.org/packages/4a/f6/b6f9764d90c0ce1bce8d995649fa307fff21f4727b8d950fa2843b7b0de5/coverage-7.11.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f761dbcf45e9416ec4698e1a7649248005f0064ce3523a47402d1bff4af2779e", size = 217065, upload-time = "2025-11-10T00:11:36.281Z" }, - { url = "https://files.pythonhosted.org/packages/a5/8d/a12cb424063019fd077b5be474258a0ed8369b92b6d0058e673f0a945982/coverage-7.11.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1410bac9e98afd9623f53876fae7d8a5db9f5a0ac1c9e7c5188463cb4b3212e2", size = 248062, upload-time = "2025-11-10T00:11:37.903Z" }, - { url = "https://files.pythonhosted.org/packages/7f/9c/dab1a4e8e75ce053d14259d3d7485d68528a662e286e184685ea49e71156/coverage-7.11.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:004cdcea3457c0ea3233622cd3464c1e32ebba9b41578421097402bee6461b63", size = 250657, upload-time = "2025-11-10T00:11:39.509Z" }, - { url = "https://files.pythonhosted.org/packages/3f/89/a14f256438324f33bae36f9a1a7137729bf26b0a43f5eda60b147ec7c8c7/coverage-7.11.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f067ada2c333609b52835ca4d4868645d3b63ac04fb2b9a658c55bba7f667d3", size = 251900, upload-time = "2025-11-10T00:11:41.372Z" }, - { url = "https://files.pythonhosted.org/packages/04/07/75b0d476eb349f1296486b1418b44f2d8780cc8db47493de3755e5340076/coverage-7.11.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:07bc7745c945a6d95676953e86ba7cebb9f11de7773951c387f4c07dc76d03f5", size = 248254, upload-time = "2025-11-10T00:11:43.27Z" }, - { url = "https://files.pythonhosted.org/packages/5a/4b/0c486581fa72873489ca092c52792d008a17954aa352809a7cbe6cf0bf07/coverage-7.11.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8bba7e4743e37484ae17d5c3b8eb1ce78b564cb91b7ace2e2182b25f0f764cb5", size = 250041, upload-time = "2025-11-10T00:11:45.274Z" }, - { url = "https://files.pythonhosted.org/packages/af/a3/0059dafb240ae3e3291f81b8de00e9c511d3dd41d687a227dd4b529be591/coverage-7.11.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:fbffc22d80d86fbe456af9abb17f7a7766e7b2101f7edaacc3535501691563f7", size = 248004, upload-time = "2025-11-10T00:11:46.93Z" }, - { url = "https://files.pythonhosted.org/packages/83/93/967d9662b1eb8c7c46917dcc7e4c1875724ac3e73c3cb78e86d7a0ac719d/coverage-7.11.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:0dba4da36730e384669e05b765a2c49f39514dd3012fcc0398dd66fba8d746d5", size = 247828, upload-time = "2025-11-10T00:11:48.563Z" }, - { url = "https://files.pythonhosted.org/packages/4c/1c/5077493c03215701e212767e470b794548d817dfc6247a4718832cc71fac/coverage-7.11.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ae12fe90b00b71a71b69f513773310782ce01d5f58d2ceb2b7c595ab9d222094", size = 249588, upload-time = "2025-11-10T00:11:50.581Z" }, - { url = "https://files.pythonhosted.org/packages/7f/a5/77f64de461016e7da3e05d7d07975c89756fe672753e4cf74417fc9b9052/coverage-7.11.3-cp313-cp313-win32.whl", hash = "sha256:12d821de7408292530b0d241468b698bce18dd12ecaf45316149f53877885f8c", size = 219223, upload-time = "2025-11-10T00:11:52.184Z" }, - { url = "https://files.pythonhosted.org/packages/ed/1c/ec51a3c1a59d225b44bdd3a4d463135b3159a535c2686fac965b698524f4/coverage-7.11.3-cp313-cp313-win_amd64.whl", hash = "sha256:6bb599052a974bb6cedfa114f9778fedfad66854107cf81397ec87cb9b8fbcf2", size = 220033, upload-time = "2025-11-10T00:11:53.871Z" }, - { url = "https://files.pythonhosted.org/packages/01/ec/e0ce39746ed558564c16f2cc25fa95ce6fc9fa8bfb3b9e62855d4386b886/coverage-7.11.3-cp313-cp313-win_arm64.whl", hash = "sha256:bb9d7efdb063903b3fdf77caec7b77c3066885068bdc0d44bc1b0c171033f944", size = 218661, upload-time = "2025-11-10T00:11:55.597Z" }, - { url = "https://files.pythonhosted.org/packages/46/cb/483f130bc56cbbad2638248915d97b185374d58b19e3cc3107359715949f/coverage-7.11.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:fb58da65e3339b3dbe266b607bb936efb983d86b00b03eb04c4ad5b442c58428", size = 217389, upload-time = "2025-11-10T00:11:57.59Z" }, - { url = "https://files.pythonhosted.org/packages/cb/ae/81f89bae3afef75553cf10e62feb57551535d16fd5859b9ee5a2a97ddd27/coverage-7.11.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8d16bbe566e16a71d123cd66382c1315fcd520c7573652a8074a8fe281b38c6a", size = 217742, upload-time = "2025-11-10T00:11:59.519Z" }, - { url = "https://files.pythonhosted.org/packages/db/6e/a0fb897041949888191a49c36afd5c6f5d9f5fd757e0b0cd99ec198a324b/coverage-7.11.3-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8258f10059b5ac837232c589a350a2df4a96406d6d5f2a09ec587cbdd539655", size = 259049, upload-time = "2025-11-10T00:12:01.592Z" }, - { url = "https://files.pythonhosted.org/packages/d9/b6/d13acc67eb402d91eb94b9bd60593411799aed09ce176ee8d8c0e39c94ca/coverage-7.11.3-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4c5627429f7fbff4f4131cfdd6abd530734ef7761116811a707b88b7e205afd7", size = 261113, upload-time = "2025-11-10T00:12:03.639Z" }, - { url = "https://files.pythonhosted.org/packages/ea/07/a6868893c48191d60406df4356aa7f0f74e6de34ef1f03af0d49183e0fa1/coverage-7.11.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:465695268414e149bab754c54b0c45c8ceda73dd4a5c3ba255500da13984b16d", size = 263546, upload-time = "2025-11-10T00:12:05.485Z" }, - { url = "https://files.pythonhosted.org/packages/24/e5/28598f70b2c1098332bac47925806353b3313511d984841111e6e760c016/coverage-7.11.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4ebcddfcdfb4c614233cff6e9a3967a09484114a8b2e4f2c7a62dc83676ba13f", size = 258260, upload-time = "2025-11-10T00:12:07.137Z" }, - { url = "https://files.pythonhosted.org/packages/0e/58/58e2d9e6455a4ed746a480c4b9cf96dc3cb2a6b8f3efbee5efd33ae24b06/coverage-7.11.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:13b2066303a1c1833c654d2af0455bb009b6e1727b3883c9964bc5c2f643c1d0", size = 261121, upload-time = "2025-11-10T00:12:09.138Z" }, - { url = "https://files.pythonhosted.org/packages/17/57/38803eefb9b0409934cbc5a14e3978f0c85cb251d2b6f6a369067a7105a0/coverage-7.11.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d8750dd20362a1b80e3cf84f58013d4672f89663aee457ea59336df50fab6739", size = 258736, upload-time = "2025-11-10T00:12:11.195Z" }, - { url = "https://files.pythonhosted.org/packages/a8/f3/f94683167156e93677b3442be1d4ca70cb33718df32a2eea44a5898f04f6/coverage-7.11.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ab6212e62ea0e1006531a2234e209607f360d98d18d532c2fa8e403c1afbdd71", size = 257625, upload-time = "2025-11-10T00:12:12.843Z" }, - { url = "https://files.pythonhosted.org/packages/87/ed/42d0bf1bc6bfa7d65f52299a31daaa866b4c11000855d753857fe78260ac/coverage-7.11.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a6b17c2b5e0b9bb7702449200f93e2d04cb04b1414c41424c08aa1e5d352da76", size = 259827, upload-time = "2025-11-10T00:12:15.128Z" }, - { url = "https://files.pythonhosted.org/packages/d3/76/5682719f5d5fbedb0c624c9851ef847407cae23362deb941f185f489c54e/coverage-7.11.3-cp313-cp313t-win32.whl", hash = "sha256:426559f105f644b69290ea414e154a0d320c3ad8a2bb75e62884731f69cf8e2c", size = 219897, upload-time = "2025-11-10T00:12:17.274Z" }, - { url = "https://files.pythonhosted.org/packages/10/e0/1da511d0ac3d39e6676fa6cc5ec35320bbf1cebb9b24e9ee7548ee4e931a/coverage-7.11.3-cp313-cp313t-win_amd64.whl", hash = "sha256:90a96fcd824564eae6137ec2563bd061d49a32944858d4bdbae5c00fb10e76ac", size = 220959, upload-time = "2025-11-10T00:12:19.292Z" }, - { url = "https://files.pythonhosted.org/packages/e5/9d/e255da6a04e9ec5f7b633c54c0fdfa221a9e03550b67a9c83217de12e96c/coverage-7.11.3-cp313-cp313t-win_arm64.whl", hash = "sha256:1e33d0bebf895c7a0905fcfaff2b07ab900885fc78bba2a12291a2cfbab014cc", size = 219234, upload-time = "2025-11-10T00:12:21.251Z" }, - { url = "https://files.pythonhosted.org/packages/84/d6/634ec396e45aded1772dccf6c236e3e7c9604bc47b816e928f32ce7987d1/coverage-7.11.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fdc5255eb4815babcdf236fa1a806ccb546724c8a9b129fd1ea4a5448a0bf07c", size = 216746, upload-time = "2025-11-10T00:12:23.089Z" }, - { url = "https://files.pythonhosted.org/packages/28/76/1079547f9d46f9c7c7d0dad35b6873c98bc5aa721eeabceafabd722cd5e7/coverage-7.11.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fe3425dc6021f906c6325d3c415e048e7cdb955505a94f1eb774dafc779ba203", size = 217077, upload-time = "2025-11-10T00:12:24.863Z" }, - { url = "https://files.pythonhosted.org/packages/2d/71/6ad80d6ae0d7cb743b9a98df8bb88b1ff3dc54491508a4a97549c2b83400/coverage-7.11.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4ca5f876bf41b24378ee67c41d688155f0e54cdc720de8ef9ad6544005899240", size = 248122, upload-time = "2025-11-10T00:12:26.553Z" }, - { url = "https://files.pythonhosted.org/packages/20/1d/784b87270784b0b88e4beec9d028e8d58f73ae248032579c63ad2ac6f69a/coverage-7.11.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9061a3e3c92b27fd8036dafa26f25d95695b6aa2e4514ab16a254f297e664f83", size = 250638, upload-time = "2025-11-10T00:12:28.555Z" }, - { url = "https://files.pythonhosted.org/packages/f5/26/b6dd31e23e004e9de84d1a8672cd3d73e50f5dae65dbd0f03fa2cdde6100/coverage-7.11.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abcea3b5f0dc44e1d01c27090bc32ce6ffb7aa665f884f1890710454113ea902", size = 251972, upload-time = "2025-11-10T00:12:30.246Z" }, - { url = "https://files.pythonhosted.org/packages/c9/ef/f9c64d76faac56b82daa036b34d4fe9ab55eb37f22062e68e9470583e688/coverage-7.11.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:68c4eb92997dbaaf839ea13527be463178ac0ddd37a7ac636b8bc11a51af2428", size = 248147, upload-time = "2025-11-10T00:12:32.195Z" }, - { url = "https://files.pythonhosted.org/packages/b6/eb/5b666f90a8f8053bd264a1ce693d2edef2368e518afe70680070fca13ecd/coverage-7.11.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:149eccc85d48c8f06547534068c41d69a1a35322deaa4d69ba1561e2e9127e75", size = 249995, upload-time = "2025-11-10T00:12:33.969Z" }, - { url = "https://files.pythonhosted.org/packages/eb/7b/871e991ffb5d067f8e67ffb635dabba65b231d6e0eb724a4a558f4a702a5/coverage-7.11.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:08c0bcf932e47795c49f0406054824b9d45671362dfc4269e0bc6e4bff010704", size = 247948, upload-time = "2025-11-10T00:12:36.341Z" }, - { url = "https://files.pythonhosted.org/packages/0a/8b/ce454f0af9609431b06dbe5485fc9d1c35ddc387e32ae8e374f49005748b/coverage-7.11.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:39764c6167c82d68a2d8c97c33dba45ec0ad9172570860e12191416f4f8e6e1b", size = 247770, upload-time = "2025-11-10T00:12:38.167Z" }, - { url = "https://files.pythonhosted.org/packages/61/8f/79002cb58a61dfbd2085de7d0a46311ef2476823e7938db80284cedd2428/coverage-7.11.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3224c7baf34e923ffc78cb45e793925539d640d42c96646db62dbd61bbcfa131", size = 249431, upload-time = "2025-11-10T00:12:40.354Z" }, - { url = "https://files.pythonhosted.org/packages/58/cc/d06685dae97468ed22999440f2f2f5060940ab0e7952a7295f236d98cce7/coverage-7.11.3-cp314-cp314-win32.whl", hash = "sha256:c713c1c528284d636cd37723b0b4c35c11190da6f932794e145fc40f8210a14a", size = 219508, upload-time = "2025-11-10T00:12:42.231Z" }, - { url = "https://files.pythonhosted.org/packages/5f/ed/770cd07706a3598c545f62d75adf2e5bd3791bffccdcf708ec383ad42559/coverage-7.11.3-cp314-cp314-win_amd64.whl", hash = "sha256:c381a252317f63ca0179d2c7918e83b99a4ff3101e1b24849b999a00f9cd4f86", size = 220325, upload-time = "2025-11-10T00:12:44.065Z" }, - { url = "https://files.pythonhosted.org/packages/ee/ac/6a1c507899b6fb1b9a56069954365f655956bcc648e150ce64c2b0ecbed8/coverage-7.11.3-cp314-cp314-win_arm64.whl", hash = "sha256:3e33a968672be1394eded257ec10d4acbb9af2ae263ba05a99ff901bb863557e", size = 218899, upload-time = "2025-11-10T00:12:46.18Z" }, - { url = "https://files.pythonhosted.org/packages/9a/58/142cd838d960cd740654d094f7b0300d7b81534bb7304437d2439fb685fb/coverage-7.11.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f9c96a29c6d65bd36a91f5634fef800212dff69dacdb44345c4c9783943ab0df", size = 217471, upload-time = "2025-11-10T00:12:48.392Z" }, - { url = "https://files.pythonhosted.org/packages/bc/2c/2f44d39eb33e41ab3aba80571daad32e0f67076afcf27cb443f9e5b5a3ee/coverage-7.11.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2ec27a7a991d229213c8070d31e3ecf44d005d96a9edc30c78eaeafaa421c001", size = 217742, upload-time = "2025-11-10T00:12:50.182Z" }, - { url = "https://files.pythonhosted.org/packages/32/76/8ebc66c3c699f4de3174a43424c34c086323cd93c4930ab0f835731c443a/coverage-7.11.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:72c8b494bd20ae1c58528b97c4a67d5cfeafcb3845c73542875ecd43924296de", size = 259120, upload-time = "2025-11-10T00:12:52.451Z" }, - { url = "https://files.pythonhosted.org/packages/19/89/78a3302b9595f331b86e4f12dfbd9252c8e93d97b8631500888f9a3a2af7/coverage-7.11.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:60ca149a446da255d56c2a7a813b51a80d9497a62250532598d249b3cdb1a926", size = 261229, upload-time = "2025-11-10T00:12:54.667Z" }, - { url = "https://files.pythonhosted.org/packages/07/59/1a9c0844dadef2a6efac07316d9781e6c5a3f3ea7e5e701411e99d619bfd/coverage-7.11.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb5069074db19a534de3859c43eec78e962d6d119f637c41c8e028c5ab3f59dd", size = 263642, upload-time = "2025-11-10T00:12:56.841Z" }, - { url = "https://files.pythonhosted.org/packages/37/86/66c15d190a8e82eee777793cabde730640f555db3c020a179625a2ad5320/coverage-7.11.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac5d5329c9c942bbe6295f4251b135d860ed9f86acd912d418dce186de7c19ac", size = 258193, upload-time = "2025-11-10T00:12:58.687Z" }, - { url = "https://files.pythonhosted.org/packages/c7/c7/4a4aeb25cb6f83c3ec4763e5f7cc78da1c6d4ef9e22128562204b7f39390/coverage-7.11.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e22539b676fafba17f0a90ac725f029a309eb6e483f364c86dcadee060429d46", size = 261107, upload-time = "2025-11-10T00:13:00.502Z" }, - { url = "https://files.pythonhosted.org/packages/ed/91/b986b5035f23cf0272446298967ecdd2c3c0105ee31f66f7e6b6948fd7f8/coverage-7.11.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2376e8a9c889016f25472c452389e98bc6e54a19570b107e27cde9d47f387b64", size = 258717, upload-time = "2025-11-10T00:13:02.747Z" }, - { url = "https://files.pythonhosted.org/packages/f0/c7/6c084997f5a04d050c513545d3344bfa17bd3b67f143f388b5757d762b0b/coverage-7.11.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4234914b8c67238a3c4af2bba648dc716aa029ca44d01f3d51536d44ac16854f", size = 257541, upload-time = "2025-11-10T00:13:04.689Z" }, - { url = "https://files.pythonhosted.org/packages/3b/c5/38e642917e406930cb67941210a366ccffa767365c8f8d9ec0f465a8b218/coverage-7.11.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f0b4101e2b3c6c352ff1f70b3a6fcc7c17c1ab1a91ccb7a33013cb0782af9820", size = 259872, upload-time = "2025-11-10T00:13:06.559Z" }, - { url = "https://files.pythonhosted.org/packages/b7/67/5e812979d20c167f81dbf9374048e0193ebe64c59a3d93d7d947b07865fa/coverage-7.11.3-cp314-cp314t-win32.whl", hash = "sha256:305716afb19133762e8cf62745c46c4853ad6f9eeba54a593e373289e24ea237", size = 220289, upload-time = "2025-11-10T00:13:08.635Z" }, - { url = "https://files.pythonhosted.org/packages/24/3a/b72573802672b680703e0df071faadfab7dcd4d659aaaffc4626bc8bbde8/coverage-7.11.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9245bd392572b9f799261c4c9e7216bafc9405537d0f4ce3ad93afe081a12dc9", size = 221398, upload-time = "2025-11-10T00:13:10.734Z" }, - { url = "https://files.pythonhosted.org/packages/f8/4e/649628f28d38bad81e4e8eb3f78759d20ac173e3c456ac629123815feb40/coverage-7.11.3-cp314-cp314t-win_arm64.whl", hash = "sha256:9a1d577c20b4334e5e814c3d5fe07fa4a8c3ae42a601945e8d7940bab811d0bd", size = 219435, upload-time = "2025-11-10T00:13:12.712Z" }, - { url = "https://files.pythonhosted.org/packages/19/8f/92bdd27b067204b99f396a1414d6342122f3e2663459baf787108a6b8b84/coverage-7.11.3-py3-none-any.whl", hash = "sha256:351511ae28e2509c8d8cae5311577ea7dd511ab8e746ffc8814a0896c3d33fbe", size = 208478, upload-time = "2025-11-10T00:13:14.908Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9b/77baf488516e9ced25fc215a6f75d803493fc3f6a1a1227ac35697910c2a/coverage-7.13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a55d509a1dc5a5b708b5dad3b5334e07a16ad4c2185e27b40e4dba796ab7f88", size = 218755, upload-time = "2025-12-28T15:40:30.812Z" }, + { url = "https://files.pythonhosted.org/packages/d7/cd/7ab01154e6eb79ee2fab76bf4d89e94c6648116557307ee4ebbb85e5c1bf/coverage-7.13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d010d080c4888371033baab27e47c9df7d6fb28d0b7b7adf85a4a49be9298b3", size = 219257, upload-time = "2025-12-28T15:40:32.333Z" }, + { url = "https://files.pythonhosted.org/packages/01/d5/b11ef7863ffbbdb509da0023fad1e9eda1c0eaea61a6d2ea5b17d4ac706e/coverage-7.13.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d938b4a840fb1523b9dfbbb454f652967f18e197569c32266d4d13f37244c3d9", size = 249657, upload-time = "2025-12-28T15:40:34.1Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7c/347280982982383621d29b8c544cf497ae07ac41e44b1ca4903024131f55/coverage-7.13.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bf100a3288f9bb7f919b87eb84f87101e197535b9bd0e2c2b5b3179633324fee", size = 251581, upload-time = "2025-12-28T15:40:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/82/f6/ebcfed11036ade4c0d75fa4453a6282bdd225bc073862766eec184a4c643/coverage-7.13.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef6688db9bf91ba111ae734ba6ef1a063304a881749726e0d3575f5c10a9facf", size = 253691, upload-time = "2025-12-28T15:40:37.626Z" }, + { url = "https://files.pythonhosted.org/packages/02/92/af8f5582787f5d1a8b130b2dcba785fa5e9a7a8e121a0bb2220a6fdbdb8a/coverage-7.13.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b609fc9cdbd1f02e51f67f51e5aee60a841ef58a68d00d5ee2c0faf357481a3", size = 249799, upload-time = "2025-12-28T15:40:39.47Z" }, + { url = "https://files.pythonhosted.org/packages/24/aa/0e39a2a3b16eebf7f193863323edbff38b6daba711abaaf807d4290cf61a/coverage-7.13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c43257717611ff5e9a1d79dce8e47566235ebda63328718d9b65dd640bc832ef", size = 251389, upload-time = "2025-12-28T15:40:40.954Z" }, + { url = "https://files.pythonhosted.org/packages/73/46/7f0c13111154dc5b978900c0ccee2e2ca239b910890e674a77f1363d483e/coverage-7.13.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e09fbecc007f7b6afdfb3b07ce5bd9f8494b6856dd4f577d26c66c391b829851", size = 249450, upload-time = "2025-12-28T15:40:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ca/e80da6769e8b669ec3695598c58eef7ad98b0e26e66333996aee6316db23/coverage-7.13.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a03a4f3a19a189919c7055098790285cc5c5b0b3976f8d227aea39dbf9f8bfdb", size = 249170, upload-time = "2025-12-28T15:40:44.279Z" }, + { url = "https://files.pythonhosted.org/packages/af/18/9e29baabdec1a8644157f572541079b4658199cfd372a578f84228e860de/coverage-7.13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3820778ea1387c2b6a818caec01c63adc5b3750211af6447e8dcfb9b6f08dbba", size = 250081, upload-time = "2025-12-28T15:40:45.748Z" }, + { url = "https://files.pythonhosted.org/packages/00/f8/c3021625a71c3b2f516464d322e41636aea381018319050a8114105872ee/coverage-7.13.1-cp311-cp311-win32.whl", hash = "sha256:ff10896fa55167371960c5908150b434b71c876dfab97b69478f22c8b445ea19", size = 221281, upload-time = "2025-12-28T15:40:47.232Z" }, + { url = "https://files.pythonhosted.org/packages/27/56/c216625f453df6e0559ed666d246fcbaaa93f3aa99eaa5080cea1229aa3d/coverage-7.13.1-cp311-cp311-win_amd64.whl", hash = "sha256:a998cc0aeeea4c6d5622a3754da5a493055d2d95186bad877b0a34ea6e6dbe0a", size = 222215, upload-time = "2025-12-28T15:40:49.19Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/be342e76f6e531cae6406dc46af0d350586f24d9b67fdfa6daee02df71af/coverage-7.13.1-cp311-cp311-win_arm64.whl", hash = "sha256:fea07c1a39a22614acb762e3fbbb4011f65eedafcb2948feeef641ac78b4ee5c", size = 220886, upload-time = "2025-12-28T15:40:51.067Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8a/87af46cccdfa78f53db747b09f5f9a21d5fc38d796834adac09b30a8ce74/coverage-7.13.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6f34591000f06e62085b1865c9bc5f7858df748834662a51edadfd2c3bfe0dd3", size = 218927, upload-time = "2025-12-28T15:40:52.814Z" }, + { url = "https://files.pythonhosted.org/packages/82/a8/6e22fdc67242a4a5a153f9438d05944553121c8f4ba70cb072af4c41362e/coverage-7.13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b67e47c5595b9224599016e333f5ec25392597a89d5744658f837d204e16c63e", size = 219288, upload-time = "2025-12-28T15:40:54.262Z" }, + { url = "https://files.pythonhosted.org/packages/d0/0a/853a76e03b0f7c4375e2ca025df45c918beb367f3e20a0a8e91967f6e96c/coverage-7.13.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e7b8bd70c48ffb28461ebe092c2345536fb18bbbf19d287c8913699735f505c", size = 250786, upload-time = "2025-12-28T15:40:56.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b4/694159c15c52b9f7ec7adf49d50e5f8ee71d3e9ef38adb4445d13dd56c20/coverage-7.13.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c223d078112e90dc0e5c4e35b98b9584164bea9fbbd221c0b21c5241f6d51b62", size = 253543, upload-time = "2025-12-28T15:40:57.585Z" }, + { url = "https://files.pythonhosted.org/packages/96/b2/7f1f0437a5c855f87e17cf5d0dc35920b6440ff2b58b1ba9788c059c26c8/coverage-7.13.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:794f7c05af0763b1bbd1b9e6eff0e52ad068be3b12cd96c87de037b01390c968", size = 254635, upload-time = "2025-12-28T15:40:59.443Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d1/73c3fdb8d7d3bddd9473c9c6a2e0682f09fc3dfbcb9c3f36412a7368bcab/coverage-7.13.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0642eae483cc8c2902e4af7298bf886d605e80f26382124cddc3967c2a3df09e", size = 251202, upload-time = "2025-12-28T15:41:01.328Z" }, + { url = "https://files.pythonhosted.org/packages/66/3c/f0edf75dcc152f145d5598329e864bbbe04ab78660fe3e8e395f9fff010f/coverage-7.13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f5e772ed5fef25b3de9f2008fe67b92d46831bd2bc5bdc5dd6bfd06b83b316f", size = 252566, upload-time = "2025-12-28T15:41:03.319Z" }, + { url = "https://files.pythonhosted.org/packages/17/b3/e64206d3c5f7dcbceafd14941345a754d3dbc78a823a6ed526e23b9cdaab/coverage-7.13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:45980ea19277dc0a579e432aef6a504fe098ef3a9032ead15e446eb0f1191aee", size = 250711, upload-time = "2025-12-28T15:41:06.411Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ad/28a3eb970a8ef5b479ee7f0c484a19c34e277479a5b70269dc652b730733/coverage-7.13.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f18eca6028ffa62adbd185a8f1e1dd242f2e68164dba5c2b74a5204850b4cf", size = 250278, upload-time = "2025-12-28T15:41:08.285Z" }, + { url = "https://files.pythonhosted.org/packages/54/e3/c8f0f1a93133e3e1291ca76cbb63565bd4b5c5df63b141f539d747fff348/coverage-7.13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8dca5590fec7a89ed6826fce625595279e586ead52e9e958d3237821fbc750c", size = 252154, upload-time = "2025-12-28T15:41:09.969Z" }, + { url = "https://files.pythonhosted.org/packages/d0/bf/9939c5d6859c380e405b19e736321f1c7d402728792f4c752ad1adcce005/coverage-7.13.1-cp312-cp312-win32.whl", hash = "sha256:ff86d4e85188bba72cfb876df3e11fa243439882c55957184af44a35bd5880b7", size = 221487, upload-time = "2025-12-28T15:41:11.468Z" }, + { url = "https://files.pythonhosted.org/packages/fa/dc/7282856a407c621c2aad74021680a01b23010bb8ebf427cf5eacda2e876f/coverage-7.13.1-cp312-cp312-win_amd64.whl", hash = "sha256:16cc1da46c04fb0fb128b4dc430b78fa2aba8a6c0c9f8eb391fd5103409a6ac6", size = 222299, upload-time = "2025-12-28T15:41:13.386Z" }, + { url = "https://files.pythonhosted.org/packages/10/79/176a11203412c350b3e9578620013af35bcdb79b651eb976f4a4b32044fa/coverage-7.13.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d9bc218650022a768f3775dd7fdac1886437325d8d295d923ebcfef4892ad5c", size = 220941, upload-time = "2025-12-28T15:41:14.975Z" }, + { url = "https://files.pythonhosted.org/packages/a3/a4/e98e689347a1ff1a7f67932ab535cef82eb5e78f32a9e4132e114bbb3a0a/coverage-7.13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cb237bfd0ef4d5eb6a19e29f9e528ac67ac3be932ea6b44fb6cc09b9f3ecff78", size = 218951, upload-time = "2025-12-28T15:41:16.653Z" }, + { url = "https://files.pythonhosted.org/packages/32/33/7cbfe2bdc6e2f03d6b240d23dc45fdaf3fd270aaf2d640be77b7f16989ab/coverage-7.13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1dcb645d7e34dcbcc96cd7c132b1fc55c39263ca62eb961c064eb3928997363b", size = 219325, upload-time = "2025-12-28T15:41:18.609Z" }, + { url = "https://files.pythonhosted.org/packages/59/f6/efdabdb4929487baeb7cb2a9f7dac457d9356f6ad1b255be283d58b16316/coverage-7.13.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3d42df8201e00384736f0df9be2ced39324c3907607d17d50d50116c989d84cd", size = 250309, upload-time = "2025-12-28T15:41:20.629Z" }, + { url = "https://files.pythonhosted.org/packages/12/da/91a52516e9d5aea87d32d1523f9cdcf7a35a3b298e6be05d6509ba3cfab2/coverage-7.13.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa3edde1aa8807de1d05934982416cb3ec46d1d4d91e280bcce7cca01c507992", size = 252907, upload-time = "2025-12-28T15:41:22.257Z" }, + { url = "https://files.pythonhosted.org/packages/75/38/f1ea837e3dc1231e086db1638947e00d264e7e8c41aa8ecacf6e1e0c05f4/coverage-7.13.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9edd0e01a343766add6817bc448408858ba6b489039eaaa2018474e4001651a4", size = 254148, upload-time = "2025-12-28T15:41:23.87Z" }, + { url = "https://files.pythonhosted.org/packages/7f/43/f4f16b881aaa34954ba446318dea6b9ed5405dd725dd8daac2358eda869a/coverage-7.13.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:985b7836931d033570b94c94713c6dba5f9d3ff26045f72c3e5dbc5fe3361e5a", size = 250515, upload-time = "2025-12-28T15:41:25.437Z" }, + { url = "https://files.pythonhosted.org/packages/84/34/8cba7f00078bd468ea914134e0144263194ce849ec3baad187ffb6203d1c/coverage-7.13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ffed1e4980889765c84a5d1a566159e363b71d6b6fbaf0bebc9d3c30bc016766", size = 252292, upload-time = "2025-12-28T15:41:28.459Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a4/cffac66c7652d84ee4ac52d3ccb94c015687d3b513f9db04bfcac2ac800d/coverage-7.13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8842af7f175078456b8b17f1b73a0d16a65dcbdc653ecefeb00a56b3c8c298c4", size = 250242, upload-time = "2025-12-28T15:41:30.02Z" }, + { url = "https://files.pythonhosted.org/packages/f4/78/9a64d462263dde416f3c0067efade7b52b52796f489b1037a95b0dc389c9/coverage-7.13.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ccd7a6fca48ca9c131d9b0a2972a581e28b13416fc313fb98b6d24a03ce9a398", size = 250068, upload-time = "2025-12-28T15:41:32.007Z" }, + { url = "https://files.pythonhosted.org/packages/69/c8/a8994f5fece06db7c4a97c8fc1973684e178599b42e66280dded0524ef00/coverage-7.13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0403f647055de2609be776965108447deb8e384fe4a553c119e3ff6bfbab4784", size = 251846, upload-time = "2025-12-28T15:41:33.946Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f7/91fa73c4b80305c86598a2d4e54ba22df6bf7d0d97500944af7ef155d9f7/coverage-7.13.1-cp313-cp313-win32.whl", hash = "sha256:549d195116a1ba1e1ae2f5ca143f9777800f6636eab917d4f02b5310d6d73461", size = 221512, upload-time = "2025-12-28T15:41:35.519Z" }, + { url = "https://files.pythonhosted.org/packages/45/0b/0768b4231d5a044da8f75e097a8714ae1041246bb765d6b5563bab456735/coverage-7.13.1-cp313-cp313-win_amd64.whl", hash = "sha256:5899d28b5276f536fcf840b18b61a9fce23cc3aec1d114c44c07fe94ebeaa500", size = 222321, upload-time = "2025-12-28T15:41:37.371Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b8/bdcb7253b7e85157282450262008f1366aa04663f3e3e4c30436f596c3e2/coverage-7.13.1-cp313-cp313-win_arm64.whl", hash = "sha256:868a2fae76dfb06e87291bcbd4dcbcc778a8500510b618d50496e520bd94d9b9", size = 220949, upload-time = "2025-12-28T15:41:39.553Z" }, + { url = "https://files.pythonhosted.org/packages/70/52/f2be52cc445ff75ea8397948c96c1b4ee14f7f9086ea62fc929c5ae7b717/coverage-7.13.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67170979de0dacac3f3097d02b0ad188d8edcea44ccc44aaa0550af49150c7dc", size = 219643, upload-time = "2025-12-28T15:41:41.567Z" }, + { url = "https://files.pythonhosted.org/packages/47/79/c85e378eaa239e2edec0c5523f71542c7793fe3340954eafb0bc3904d32d/coverage-7.13.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f80e2bb21bfab56ed7405c2d79d34b5dc0bc96c2c1d2a067b643a09fb756c43a", size = 219997, upload-time = "2025-12-28T15:41:43.418Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9b/b1ade8bfb653c0bbce2d6d6e90cc6c254cbb99b7248531cc76253cb4da6d/coverage-7.13.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f83351e0f7dcdb14d7326c3d8d8c4e915fa685cbfdc6281f9470d97a04e9dfe4", size = 261296, upload-time = "2025-12-28T15:41:45.207Z" }, + { url = "https://files.pythonhosted.org/packages/1f/af/ebf91e3e1a2473d523e87e87fd8581e0aa08741b96265730e2d79ce78d8d/coverage-7.13.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb3f6562e89bad0110afbe64e485aac2462efdce6232cdec7862a095dc3412f6", size = 263363, upload-time = "2025-12-28T15:41:47.163Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8b/fb2423526d446596624ac7fde12ea4262e66f86f5120114c3cfd0bb2befa/coverage-7.13.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77545b5dcda13b70f872c3b5974ac64c21d05e65b1590b441c8560115dc3a0d1", size = 265783, upload-time = "2025-12-28T15:41:49.03Z" }, + { url = "https://files.pythonhosted.org/packages/9b/26/ef2adb1e22674913b89f0fe7490ecadcef4a71fa96f5ced90c60ec358789/coverage-7.13.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4d240d260a1aed814790bbe1f10a5ff31ce6c21bc78f0da4a1e8268d6c80dbd", size = 260508, upload-time = "2025-12-28T15:41:51.035Z" }, + { url = "https://files.pythonhosted.org/packages/ce/7d/f0f59b3404caf662e7b5346247883887687c074ce67ba453ea08c612b1d5/coverage-7.13.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d2287ac9360dec3837bfdad969963a5d073a09a85d898bd86bea82aa8876ef3c", size = 263357, upload-time = "2025-12-28T15:41:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b1/29896492b0b1a047604d35d6fa804f12818fa30cdad660763a5f3159e158/coverage-7.13.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0d2c11f3ea4db66b5cbded23b20185c35066892c67d80ec4be4bab257b9ad1e0", size = 260978, upload-time = "2025-12-28T15:41:54.589Z" }, + { url = "https://files.pythonhosted.org/packages/48/f2/971de1238a62e6f0a4128d37adadc8bb882ee96afbe03ff1570291754629/coverage-7.13.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:3fc6a169517ca0d7ca6846c3c5392ef2b9e38896f61d615cb75b9e7134d4ee1e", size = 259877, upload-time = "2025-12-28T15:41:56.263Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fc/0474efcbb590ff8628830e9aaec5f1831594874360e3251f1fdec31d07a3/coverage-7.13.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d10a2ed46386e850bb3de503a54f9fe8192e5917fcbb143bfef653a9355e9a53", size = 262069, upload-time = "2025-12-28T15:41:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/88/4f/3c159b7953db37a7b44c0eab8a95c37d1aa4257c47b4602c04022d5cb975/coverage-7.13.1-cp313-cp313t-win32.whl", hash = "sha256:75a6f4aa904301dab8022397a22c0039edc1f51e90b83dbd4464b8a38dc87842", size = 222184, upload-time = "2025-12-28T15:41:59.763Z" }, + { url = "https://files.pythonhosted.org/packages/58/a5/6b57d28f81417f9335774f20679d9d13b9a8fb90cd6160957aa3b54a2379/coverage-7.13.1-cp313-cp313t-win_amd64.whl", hash = "sha256:309ef5706e95e62578cda256b97f5e097916a2c26247c287bbe74794e7150df2", size = 223250, upload-time = "2025-12-28T15:42:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/81/7c/160796f3b035acfbb58be80e02e484548595aa67e16a6345e7910ace0a38/coverage-7.13.1-cp313-cp313t-win_arm64.whl", hash = "sha256:92f980729e79b5d16d221038dbf2e8f9a9136afa072f9d5d6ed4cb984b126a09", size = 221521, upload-time = "2025-12-28T15:42:03.275Z" }, + { url = "https://files.pythonhosted.org/packages/aa/8e/ba0e597560c6563fc0adb902fda6526df5d4aa73bb10adf0574d03bd2206/coverage-7.13.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:97ab3647280d458a1f9adb85244e81587505a43c0c7cff851f5116cd2814b894", size = 218996, upload-time = "2025-12-28T15:42:04.978Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8e/764c6e116f4221dc7aa26c4061181ff92edb9c799adae6433d18eeba7a14/coverage-7.13.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8f572d989142e0908e6acf57ad1b9b86989ff057c006d13b76c146ec6a20216a", size = 219326, upload-time = "2025-12-28T15:42:06.691Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a6/6130dc6d8da28cdcbb0f2bf8865aeca9b157622f7c0031e48c6cf9a0e591/coverage-7.13.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d72140ccf8a147e94274024ff6fd8fb7811354cf7ef88b1f0a988ebaa5bc774f", size = 250374, upload-time = "2025-12-28T15:42:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/82/2b/783ded568f7cd6b677762f780ad338bf4b4750205860c17c25f7c708995e/coverage-7.13.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3c9f051b028810f5a87c88e5d6e9af3c0ff32ef62763bf15d29f740453ca909", size = 252882, upload-time = "2025-12-28T15:42:10.515Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b2/9808766d082e6a4d59eb0cc881a57fc1600eb2c5882813eefff8254f71b5/coverage-7.13.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f398ba4df52d30b1763f62eed9de5620dcde96e6f491f4c62686736b155aa6e4", size = 254218, upload-time = "2025-12-28T15:42:12.208Z" }, + { url = "https://files.pythonhosted.org/packages/44/ea/52a985bb447c871cb4d2e376e401116520991b597c85afdde1ea9ef54f2c/coverage-7.13.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:132718176cc723026d201e347f800cd1a9e4b62ccd3f82476950834dad501c75", size = 250391, upload-time = "2025-12-28T15:42:14.21Z" }, + { url = "https://files.pythonhosted.org/packages/7f/1d/125b36cc12310718873cfc8209ecfbc1008f14f4f5fa0662aa608e579353/coverage-7.13.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e549d642426e3579b3f4b92d0431543b012dcb6e825c91619d4e93b7363c3f9", size = 252239, upload-time = "2025-12-28T15:42:16.292Z" }, + { url = "https://files.pythonhosted.org/packages/6a/16/10c1c164950cade470107f9f14bbac8485f8fb8515f515fca53d337e4a7f/coverage-7.13.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:90480b2134999301eea795b3a9dbf606c6fbab1b489150c501da84a959442465", size = 250196, upload-time = "2025-12-28T15:42:18.54Z" }, + { url = "https://files.pythonhosted.org/packages/2a/c6/cd860fac08780c6fd659732f6ced1b40b79c35977c1356344e44d72ba6c4/coverage-7.13.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e825dbb7f84dfa24663dd75835e7257f8882629fc11f03ecf77d84a75134b864", size = 250008, upload-time = "2025-12-28T15:42:20.365Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/a8c58d3d38f82a5711e1e0a67268362af48e1a03df27c03072ac30feefcf/coverage-7.13.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:623dcc6d7a7ba450bbdbeedbaa0c42b329bdae16491af2282f12a7e809be7eb9", size = 251671, upload-time = "2025-12-28T15:42:22.114Z" }, + { url = "https://files.pythonhosted.org/packages/f0/bc/fd4c1da651d037a1e3d53e8cb3f8182f4b53271ffa9a95a2e211bacc0349/coverage-7.13.1-cp314-cp314-win32.whl", hash = "sha256:6e73ebb44dca5f708dc871fe0b90cf4cff1a13f9956f747cc87b535a840386f5", size = 221777, upload-time = "2025-12-28T15:42:23.919Z" }, + { url = "https://files.pythonhosted.org/packages/4b/50/71acabdc8948464c17e90b5ffd92358579bd0910732c2a1c9537d7536aa6/coverage-7.13.1-cp314-cp314-win_amd64.whl", hash = "sha256:be753b225d159feb397bd0bf91ae86f689bad0da09d3b301478cd39b878ab31a", size = 222592, upload-time = "2025-12-28T15:42:25.619Z" }, + { url = "https://files.pythonhosted.org/packages/f7/c8/a6fb943081bb0cc926499c7907731a6dc9efc2cbdc76d738c0ab752f1a32/coverage-7.13.1-cp314-cp314-win_arm64.whl", hash = "sha256:228b90f613b25ba0019361e4ab81520b343b622fc657daf7e501c4ed6a2366c0", size = 221169, upload-time = "2025-12-28T15:42:27.629Z" }, + { url = "https://files.pythonhosted.org/packages/16/61/d5b7a0a0e0e40d62e59bc8c7aa1afbd86280d82728ba97f0673b746b78e2/coverage-7.13.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:60cfb538fe9ef86e5b2ab0ca8fc8d62524777f6c611dcaf76dc16fbe9b8e698a", size = 219730, upload-time = "2025-12-28T15:42:29.306Z" }, + { url = "https://files.pythonhosted.org/packages/a3/2c/8881326445fd071bb49514d1ce97d18a46a980712b51fee84f9ab42845b4/coverage-7.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:57dfc8048c72ba48a8c45e188d811e5efd7e49b387effc8fb17e97936dde5bf6", size = 220001, upload-time = "2025-12-28T15:42:31.319Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d7/50de63af51dfa3a7f91cc37ad8fcc1e244b734232fbc8b9ab0f3c834a5cd/coverage-7.13.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3f2f725aa3e909b3c5fdb8192490bdd8e1495e85906af74fe6e34a2a77ba0673", size = 261370, upload-time = "2025-12-28T15:42:32.992Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2c/d31722f0ec918fd7453b2758312729f645978d212b410cd0f7c2aed88a94/coverage-7.13.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ee68b21909686eeb21dfcba2c3b81fee70dcf38b140dcd5aa70680995fa3aa5", size = 263485, upload-time = "2025-12-28T15:42:34.759Z" }, + { url = "https://files.pythonhosted.org/packages/fa/7a/2c114fa5c5fc08ba0777e4aec4c97e0b4a1afcb69c75f1f54cff78b073ab/coverage-7.13.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724b1b270cb13ea2e6503476e34541a0b1f62280bc997eab443f87790202033d", size = 265890, upload-time = "2025-12-28T15:42:36.517Z" }, + { url = "https://files.pythonhosted.org/packages/65/d9/f0794aa1c74ceabc780fe17f6c338456bbc4e96bd950f2e969f48ac6fb20/coverage-7.13.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916abf1ac5cf7eb16bc540a5bf75c71c43a676f5c52fcb9fe75a2bd75fb944e8", size = 260445, upload-time = "2025-12-28T15:42:38.646Z" }, + { url = "https://files.pythonhosted.org/packages/49/23/184b22a00d9bb97488863ced9454068c79e413cb23f472da6cbddc6cfc52/coverage-7.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:776483fd35b58d8afe3acbd9988d5de592ab6da2d2a865edfdbc9fdb43e7c486", size = 263357, upload-time = "2025-12-28T15:42:40.788Z" }, + { url = "https://files.pythonhosted.org/packages/7d/bd/58af54c0c9199ea4190284f389005779d7daf7bf3ce40dcd2d2b2f96da69/coverage-7.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b6f3b96617e9852703f5b633ea01315ca45c77e879584f283c44127f0f1ec564", size = 260959, upload-time = "2025-12-28T15:42:42.808Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2a/6839294e8f78a4891bf1df79d69c536880ba2f970d0ff09e7513d6e352e9/coverage-7.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd63e7b74661fed317212fab774e2a648bc4bb09b35f25474f8e3325d2945cd7", size = 259792, upload-time = "2025-12-28T15:42:44.818Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c3/528674d4623283310ad676c5af7414b9850ab6d55c2300e8aa4b945ec554/coverage-7.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:933082f161bbb3e9f90d00990dc956120f608cdbcaeea15c4d897f56ef4fe416", size = 262123, upload-time = "2025-12-28T15:42:47.108Z" }, + { url = "https://files.pythonhosted.org/packages/06/c5/8c0515692fb4c73ac379d8dc09b18eaf0214ecb76ea6e62467ba7a1556ff/coverage-7.13.1-cp314-cp314t-win32.whl", hash = "sha256:18be793c4c87de2965e1c0f060f03d9e5aff66cfeae8e1dbe6e5b88056ec153f", size = 222562, upload-time = "2025-12-28T15:42:49.144Z" }, + { url = "https://files.pythonhosted.org/packages/05/0e/c0a0c4678cb30dac735811db529b321d7e1c9120b79bd728d4f4d6b010e9/coverage-7.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:0e42e0ec0cd3e0d851cb3c91f770c9301f48647cb2877cb78f74bdaa07639a79", size = 223670, upload-time = "2025-12-28T15:42:51.218Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/b177aa0011f354abf03a8f30a85032686d290fdeed4222b27d36b4372a50/coverage-7.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eaecf47ef10c72ece9a2a92118257da87e460e113b83cc0d2905cbbe931792b4", size = 221707, upload-time = "2025-12-28T15:42:53.034Z" }, + { url = "https://files.pythonhosted.org/packages/cc/48/d9f421cb8da5afaa1a64570d9989e00fb7955e6acddc5a12979f7666ef60/coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573", size = 210722, upload-time = "2025-12-28T15:42:54.901Z" }, ] [package.optional-dependencies] @@ -406,7 +374,6 @@ version = "46.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" } wheels = [ @@ -455,8 +422,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" }, { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" }, { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" }, - { url = "https://files.pythonhosted.org/packages/d9/cd/1a8633802d766a0fa46f382a77e096d7e209e0817892929655fe0586ae32/cryptography-46.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a23582810fedb8c0bc47524558fb6c56aac3fc252cb306072fd2815da2a47c32", size = 3689163, upload-time = "2025-10-15T23:18:13.821Z" }, - { url = "https://files.pythonhosted.org/packages/4c/59/6b26512964ace6480c3e54681a9859c974172fb141c38df11eadd8416947/cryptography-46.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e7aec276d68421f9574040c26e2a7c3771060bc0cff408bae1dcb19d3ab1e63c", size = 3429474, upload-time = "2025-10-15T23:18:15.477Z" }, { url = "https://files.pythonhosted.org/packages/06/8a/e60e46adab4362a682cf142c7dcb5bf79b782ab2199b0dcb81f55970807f/cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea", size = 3698132, upload-time = "2025-10-15T23:18:17.056Z" }, { url = "https://files.pythonhosted.org/packages/da/38/f59940ec4ee91e93d3311f7532671a5cef5570eb04a144bf203b58552d11/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b", size = 4243992, upload-time = "2025-10-15T23:18:18.695Z" }, { url = "https://files.pythonhosted.org/packages/b0/0c/35b3d92ddebfdfda76bb485738306545817253d0a3ded0bfe80ef8e67aa5/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb", size = 4409944, upload-time = "2025-10-15T23:18:20.597Z" }, @@ -466,32 +431,43 @@ wheels = [ ] [[package]] -name = "debugpy" -version = "1.8.17" +name = "cyclopts" +version = "4.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/15/ad/71e708ff4ca377c4230530d6a7aa7992592648c122a2cd2b321cf8b35a76/debugpy-1.8.17.tar.gz", hash = "sha256:fd723b47a8c08892b1a16b2c6239a8b96637c62a59b94bb5dab4bac592a58a8e", size = 1644129, upload-time = "2025-09-17T16:33:20.633Z" } +dependencies = [ + { name = "attrs" }, + { name = "docstring-parser" }, + { name = "rich" }, + { name = "rich-rst" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/7b/663f3285c1ac0e5d0854bd9db2c87caa6fa3d1a063185e3394a6cdca9151/cyclopts-4.5.0.tar.gz", hash = "sha256:717ac4235548b58d500baf7e688aa4d024caf0ee68f61a012ffd5e29db3099f9", size = 161980, upload-time = "2026-01-16T02:07:16.171Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/36/b57c6e818d909f6e59c0182252921cf435e0951126a97e11de37e72ab5e1/debugpy-1.8.17-cp310-cp310-macosx_15_0_x86_64.whl", hash = "sha256:c41d2ce8bbaddcc0009cc73f65318eedfa3dbc88a8298081deb05389f1ab5542", size = 2098021, upload-time = "2025-09-17T16:33:22.556Z" }, - { url = "https://files.pythonhosted.org/packages/be/01/0363c7efdd1e9febd090bb13cee4fb1057215b157b2979a4ca5ccb678217/debugpy-1.8.17-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:1440fd514e1b815edd5861ca394786f90eb24960eb26d6f7200994333b1d79e3", size = 3087399, upload-time = "2025-09-17T16:33:24.292Z" }, - { url = "https://files.pythonhosted.org/packages/79/bc/4a984729674aa9a84856650438b9665f9a1d5a748804ac6f37932ce0d4aa/debugpy-1.8.17-cp310-cp310-win32.whl", hash = "sha256:3a32c0af575749083d7492dc79f6ab69f21b2d2ad4cd977a958a07d5865316e4", size = 5230292, upload-time = "2025-09-17T16:33:26.137Z" }, - { url = "https://files.pythonhosted.org/packages/5d/19/2b9b3092d0cf81a5aa10c86271999453030af354d1a5a7d6e34c574515d7/debugpy-1.8.17-cp310-cp310-win_amd64.whl", hash = "sha256:a3aad0537cf4d9c1996434be68c6c9a6d233ac6f76c2a482c7803295b4e4f99a", size = 5261885, upload-time = "2025-09-17T16:33:27.592Z" }, - { url = "https://files.pythonhosted.org/packages/d8/53/3af72b5c159278c4a0cf4cffa518675a0e73bdb7d1cac0239b815502d2ce/debugpy-1.8.17-cp311-cp311-macosx_15_0_universal2.whl", hash = "sha256:d3fce3f0e3de262a3b67e69916d001f3e767661c6e1ee42553009d445d1cd840", size = 2207154, upload-time = "2025-09-17T16:33:29.457Z" }, - { url = "https://files.pythonhosted.org/packages/8f/6d/204f407df45600e2245b4a39860ed4ba32552330a0b3f5f160ae4cc30072/debugpy-1.8.17-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:c6bdf134457ae0cac6fb68205776be635d31174eeac9541e1d0c062165c6461f", size = 3170322, upload-time = "2025-09-17T16:33:30.837Z" }, - { url = "https://files.pythonhosted.org/packages/f2/13/1b8f87d39cf83c6b713de2620c31205299e6065622e7dd37aff4808dd410/debugpy-1.8.17-cp311-cp311-win32.whl", hash = "sha256:e79a195f9e059edfe5d8bf6f3749b2599452d3e9380484cd261f6b7cd2c7c4da", size = 5155078, upload-time = "2025-09-17T16:33:33.331Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c5/c012c60a2922cc91caa9675d0ddfbb14ba59e1e36228355f41cab6483469/debugpy-1.8.17-cp311-cp311-win_amd64.whl", hash = "sha256:b532282ad4eca958b1b2d7dbcb2b7218e02cb934165859b918e3b6ba7772d3f4", size = 5179011, upload-time = "2025-09-17T16:33:35.711Z" }, - { url = "https://files.pythonhosted.org/packages/08/2b/9d8e65beb2751876c82e1aceb32f328c43ec872711fa80257c7674f45650/debugpy-1.8.17-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:f14467edef672195c6f6b8e27ce5005313cb5d03c9239059bc7182b60c176e2d", size = 2549522, upload-time = "2025-09-17T16:33:38.466Z" }, - { url = "https://files.pythonhosted.org/packages/b4/78/eb0d77f02971c05fca0eb7465b18058ba84bd957062f5eec82f941ac792a/debugpy-1.8.17-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:24693179ef9dfa20dca8605905a42b392be56d410c333af82f1c5dff807a64cc", size = 4309417, upload-time = "2025-09-17T16:33:41.299Z" }, - { url = "https://files.pythonhosted.org/packages/37/42/c40f1d8cc1fed1e75ea54298a382395b8b937d923fcf41ab0797a554f555/debugpy-1.8.17-cp312-cp312-win32.whl", hash = "sha256:6a4e9dacf2cbb60d2514ff7b04b4534b0139facbf2abdffe0639ddb6088e59cf", size = 5277130, upload-time = "2025-09-17T16:33:43.554Z" }, - { url = "https://files.pythonhosted.org/packages/72/22/84263b205baad32b81b36eac076de0cdbe09fe2d0637f5b32243dc7c925b/debugpy-1.8.17-cp312-cp312-win_amd64.whl", hash = "sha256:e8f8f61c518952fb15f74a302e068b48d9c4691768ade433e4adeea961993464", size = 5319053, upload-time = "2025-09-17T16:33:53.033Z" }, - { url = "https://files.pythonhosted.org/packages/50/76/597e5cb97d026274ba297af8d89138dfd9e695767ba0e0895edb20963f40/debugpy-1.8.17-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:857c1dd5d70042502aef1c6d1c2801211f3ea7e56f75e9c335f434afb403e464", size = 2538386, upload-time = "2025-09-17T16:33:54.594Z" }, - { url = "https://files.pythonhosted.org/packages/5f/60/ce5c34fcdfec493701f9d1532dba95b21b2f6394147234dce21160bd923f/debugpy-1.8.17-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:3bea3b0b12f3946e098cce9b43c3c46e317b567f79570c3f43f0b96d00788088", size = 4292100, upload-time = "2025-09-17T16:33:56.353Z" }, - { url = "https://files.pythonhosted.org/packages/e8/95/7873cf2146577ef71d2a20bf553f12df865922a6f87b9e8ee1df04f01785/debugpy-1.8.17-cp313-cp313-win32.whl", hash = "sha256:e34ee844c2f17b18556b5bbe59e1e2ff4e86a00282d2a46edab73fd7f18f4a83", size = 5277002, upload-time = "2025-09-17T16:33:58.231Z" }, - { url = "https://files.pythonhosted.org/packages/46/11/18c79a1cee5ff539a94ec4aa290c1c069a5580fd5cfd2fb2e282f8e905da/debugpy-1.8.17-cp313-cp313-win_amd64.whl", hash = "sha256:6c5cd6f009ad4fca8e33e5238210dc1e5f42db07d4b6ab21ac7ffa904a196420", size = 5319047, upload-time = "2025-09-17T16:34:00.586Z" }, - { url = "https://files.pythonhosted.org/packages/de/45/115d55b2a9da6de812696064ceb505c31e952c5d89c4ed1d9bb983deec34/debugpy-1.8.17-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:045290c010bcd2d82bc97aa2daf6837443cd52f6328592698809b4549babcee1", size = 2536899, upload-time = "2025-09-17T16:34:02.657Z" }, - { url = "https://files.pythonhosted.org/packages/5a/73/2aa00c7f1f06e997ef57dc9b23d61a92120bec1437a012afb6d176585197/debugpy-1.8.17-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:b69b6bd9dba6a03632534cdf67c760625760a215ae289f7489a452af1031fe1f", size = 4268254, upload-time = "2025-09-17T16:34:04.486Z" }, - { url = "https://files.pythonhosted.org/packages/86/b5/ed3e65c63c68a6634e3ba04bd10255c8e46ec16ebed7d1c79e4816d8a760/debugpy-1.8.17-cp314-cp314-win32.whl", hash = "sha256:5c59b74aa5630f3a5194467100c3b3d1c77898f9ab27e3f7dc5d40fc2f122670", size = 5277203, upload-time = "2025-09-17T16:34:06.65Z" }, - { url = "https://files.pythonhosted.org/packages/b0/26/394276b71c7538445f29e792f589ab7379ae70fd26ff5577dfde71158e96/debugpy-1.8.17-cp314-cp314-win_amd64.whl", hash = "sha256:893cba7bb0f55161de4365584b025f7064e1f88913551bcd23be3260b231429c", size = 5318493, upload-time = "2025-09-17T16:34:08.483Z" }, - { url = "https://files.pythonhosted.org/packages/b0/d0/89247ec250369fc76db477720a26b2fce7ba079ff1380e4ab4529d2fe233/debugpy-1.8.17-py2.py3-none-any.whl", hash = "sha256:60c7dca6571efe660ccb7a9508d73ca14b8796c4ed484c2002abba714226cfef", size = 5283210, upload-time = "2025-09-17T16:34:25.835Z" }, + { url = "https://files.pythonhosted.org/packages/12/a3/2e00fececc34a99ae3a5d5702a5dd29c5371e4ed016647301a2b9bcc1976/cyclopts-4.5.0-py3-none-any.whl", hash = "sha256:305b9aa90a9cd0916f0a450b43e50ad5df9c252680731a0719edfb9b20381bf5", size = 199772, upload-time = "2026-01-16T02:07:14.707Z" }, +] + +[[package]] +name = "debugpy" +version = "1.8.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/73/75/9e12d4d42349b817cd545b89247696c67917aab907012ae5b64bbfea3199/debugpy-1.8.19.tar.gz", hash = "sha256:eea7e5987445ab0b5ed258093722d5ecb8bb72217c5c9b1e21f64efe23ddebdb", size = 1644590, upload-time = "2025-12-15T21:53:28.044Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/e2/48531a609b5a2aa94c6b6853afdfec8da05630ab9aaa96f1349e772119e9/debugpy-1.8.19-cp311-cp311-macosx_15_0_universal2.whl", hash = "sha256:c5dcfa21de1f735a4f7ced4556339a109aa0f618d366ede9da0a3600f2516d8b", size = 2207620, upload-time = "2025-12-15T21:53:37.1Z" }, + { url = "https://files.pythonhosted.org/packages/1b/d4/97775c01d56071969f57d93928899e5616a4cfbbf4c8cc75390d3a51c4a4/debugpy-1.8.19-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:806d6800246244004625d5222d7765874ab2d22f3ba5f615416cf1342d61c488", size = 3170796, upload-time = "2025-12-15T21:53:38.513Z" }, + { url = "https://files.pythonhosted.org/packages/8d/7e/8c7681bdb05be9ec972bbb1245eb7c4c7b0679bb6a9e6408d808bc876d3d/debugpy-1.8.19-cp311-cp311-win32.whl", hash = "sha256:783a519e6dfb1f3cd773a9bda592f4887a65040cb0c7bd38dde410f4e53c40d4", size = 5164287, upload-time = "2025-12-15T21:53:40.857Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a8/aaac7ff12ddf5d68a39e13a423a8490426f5f661384f5ad8d9062761bd8e/debugpy-1.8.19-cp311-cp311-win_amd64.whl", hash = "sha256:14035cbdbb1fe4b642babcdcb5935c2da3b1067ac211c5c5a8fdc0bb31adbcaa", size = 5188269, upload-time = "2025-12-15T21:53:42.359Z" }, + { url = "https://files.pythonhosted.org/packages/4a/15/d762e5263d9e25b763b78be72dc084c7a32113a0bac119e2f7acae7700ed/debugpy-1.8.19-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:bccb1540a49cde77edc7ce7d9d075c1dbeb2414751bc0048c7a11e1b597a4c2e", size = 2549995, upload-time = "2025-12-15T21:53:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/a7/88/f7d25c68b18873b7c53d7c156ca7a7ffd8e77073aa0eac170a9b679cf786/debugpy-1.8.19-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:e9c68d9a382ec754dc05ed1d1b4ed5bd824b9f7c1a8cd1083adb84b3c93501de", size = 4309891, upload-time = "2025-12-15T21:53:45.26Z" }, + { url = "https://files.pythonhosted.org/packages/c5/4f/a65e973aba3865794da65f71971dca01ae66666132c7b2647182d5be0c5f/debugpy-1.8.19-cp312-cp312-win32.whl", hash = "sha256:6599cab8a783d1496ae9984c52cb13b7c4a3bd06a8e6c33446832a5d97ce0bee", size = 5286355, upload-time = "2025-12-15T21:53:46.763Z" }, + { url = "https://files.pythonhosted.org/packages/d8/3a/d3d8b48fec96e3d824e404bf428276fb8419dfa766f78f10b08da1cb2986/debugpy-1.8.19-cp312-cp312-win_amd64.whl", hash = "sha256:66e3d2fd8f2035a8f111eb127fa508469dfa40928a89b460b41fd988684dc83d", size = 5328239, upload-time = "2025-12-15T21:53:48.868Z" }, + { url = "https://files.pythonhosted.org/packages/71/3d/388035a31a59c26f1ecc8d86af607d0c42e20ef80074147cd07b180c4349/debugpy-1.8.19-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:91e35db2672a0abaf325f4868fcac9c1674a0d9ad9bb8a8c849c03a5ebba3e6d", size = 2538859, upload-time = "2025-12-15T21:53:50.478Z" }, + { url = "https://files.pythonhosted.org/packages/4a/19/c93a0772d0962294f083dbdb113af1a7427bb632d36e5314297068f55db7/debugpy-1.8.19-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:85016a73ab84dea1c1f1dcd88ec692993bcbe4532d1b49ecb5f3c688ae50c606", size = 4292575, upload-time = "2025-12-15T21:53:51.821Z" }, + { url = "https://files.pythonhosted.org/packages/5c/56/09e48ab796b0a77e3d7dc250f95251832b8bf6838c9632f6100c98bdf426/debugpy-1.8.19-cp313-cp313-win32.whl", hash = "sha256:b605f17e89ba0ecee994391194285fada89cee111cfcd29d6f2ee11cbdc40976", size = 5286209, upload-time = "2025-12-15T21:53:53.602Z" }, + { url = "https://files.pythonhosted.org/packages/fb/4e/931480b9552c7d0feebe40c73725dd7703dcc578ba9efc14fe0e6d31cfd1/debugpy-1.8.19-cp313-cp313-win_amd64.whl", hash = "sha256:c30639998a9f9cd9699b4b621942c0179a6527f083c72351f95c6ab1728d5b73", size = 5328206, upload-time = "2025-12-15T21:53:55.433Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b9/cbec520c3a00508327476c7fce26fbafef98f412707e511eb9d19a2ef467/debugpy-1.8.19-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:1e8c4d1bd230067bf1bbcdbd6032e5a57068638eb28b9153d008ecde288152af", size = 2537372, upload-time = "2025-12-15T21:53:57.318Z" }, + { url = "https://files.pythonhosted.org/packages/88/5e/cf4e4dc712a141e10d58405c58c8268554aec3c35c09cdcda7535ff13f76/debugpy-1.8.19-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:d40c016c1f538dbf1762936e3aeb43a89b965069d9f60f9e39d35d9d25e6b809", size = 4268729, upload-time = "2025-12-15T21:53:58.712Z" }, + { url = "https://files.pythonhosted.org/packages/82/a3/c91a087ab21f1047db328c1d3eb5d1ff0e52de9e74f9f6f6fa14cdd93d58/debugpy-1.8.19-cp314-cp314-win32.whl", hash = "sha256:0601708223fe1cd0e27c6cce67a899d92c7d68e73690211e6788a4b0e1903f5b", size = 5286388, upload-time = "2025-12-15T21:54:00.687Z" }, + { url = "https://files.pythonhosted.org/packages/17/b8/bfdc30b6e94f1eff09f2dc9cc1f9cd1c6cde3d996bcbd36ce2d9a4956e99/debugpy-1.8.19-cp314-cp314-win_amd64.whl", hash = "sha256:8e19a725f5d486f20e53a1dde2ab8bb2c9607c40c00a42ab646def962b41125f", size = 5327741, upload-time = "2025-12-15T21:54:02.148Z" }, + { url = "https://files.pythonhosted.org/packages/25/3e/e27078370414ef35fafad2c06d182110073daaeb5d3bf734b0b1eeefe452/debugpy-1.8.19-py2.py3-none-any.whl", hash = "sha256:360ffd231a780abbc414ba0f005dad409e71c78637efe8f2bd75837132a41d38", size = 5292321, upload-time = "2025-12-15T21:54:16.024Z" }, ] [[package]] @@ -503,6 +479,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, ] +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +] + [[package]] name = "deprecated" version = "1.3.1" @@ -528,33 +513,30 @@ wheels = [ ] [[package]] -name = "docutils" -version = "0.21.2" +name = "docstring-parser" +version = "0.17.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload-time = "2024-04-23T18:57:18.24Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" }, + { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, ] [[package]] -name = "exceptiongroup" -version = "1.3.0" +name = "docutils" +version = "0.22.4" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, ] [[package]] name = "execnet" -version = "2.1.1" +version = "2.1.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bb/ff/b4c0dc78fbe20c3e59c0c7334de0c27eb4001a2b2017999af398bf730817/execnet-2.1.1.tar.gz", hash = "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3", size = 166524, upload-time = "2024-04-08T09:04:19.245Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/43/09/2aea36ff60d16dd8879bdb2f5b3ee0ba8d08cbbdcdfe870e695ce3784385/execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc", size = 40612, upload-time = "2024-04-08T09:04:17.414Z" }, + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, ] [[package]] @@ -566,6 +548,69 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, ] +[[package]] +name = "fonttools" +version = "4.61.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/ca/cf17b88a8df95691275a3d77dc0a5ad9907f328ae53acbe6795da1b2f5ed/fonttools-4.61.1.tar.gz", hash = "sha256:6675329885c44657f826ef01d9e4fb33b9158e9d93c537d84ad8399539bc6f69", size = 3565756, upload-time = "2025-12-12T17:31:24.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/12/bf9f4eaa2fad039356cc627587e30ed008c03f1cebd3034376b5ee8d1d44/fonttools-4.61.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c6604b735bb12fef8e0efd5578c9fb5d3d8532d5001ea13a19cddf295673ee09", size = 2852213, upload-time = "2025-12-12T17:29:46.675Z" }, + { url = "https://files.pythonhosted.org/packages/ac/49/4138d1acb6261499bedde1c07f8c2605d1d8f9d77a151e5507fd3ef084b6/fonttools-4.61.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5ce02f38a754f207f2f06557523cd39a06438ba3aafc0639c477ac409fc64e37", size = 2401689, upload-time = "2025-12-12T17:29:48.769Z" }, + { url = "https://files.pythonhosted.org/packages/e5/fe/e6ce0fe20a40e03aef906af60aa87668696f9e4802fa283627d0b5ed777f/fonttools-4.61.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77efb033d8d7ff233385f30c62c7c79271c8885d5c9657d967ede124671bbdfb", size = 5058809, upload-time = "2025-12-12T17:29:51.701Z" }, + { url = "https://files.pythonhosted.org/packages/79/61/1ca198af22f7dd22c17ab86e9024ed3c06299cfdb08170640e9996d501a0/fonttools-4.61.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:75c1a6dfac6abd407634420c93864a1e274ebc1c7531346d9254c0d8f6ca00f9", size = 5036039, upload-time = "2025-12-12T17:29:53.659Z" }, + { url = "https://files.pythonhosted.org/packages/99/cc/fa1801e408586b5fce4da9f5455af8d770f4fc57391cd5da7256bb364d38/fonttools-4.61.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0de30bfe7745c0d1ffa2b0b7048fb7123ad0d71107e10ee090fa0b16b9452e87", size = 5034714, upload-time = "2025-12-12T17:29:55.592Z" }, + { url = "https://files.pythonhosted.org/packages/bf/aa/b7aeafe65adb1b0a925f8f25725e09f078c635bc22754f3fecb7456955b0/fonttools-4.61.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:58b0ee0ab5b1fc9921eccfe11d1435added19d6494dde14e323f25ad2bc30c56", size = 5158648, upload-time = "2025-12-12T17:29:57.861Z" }, + { url = "https://files.pythonhosted.org/packages/99/f9/08ea7a38663328881384c6e7777bbefc46fd7d282adfd87a7d2b84ec9d50/fonttools-4.61.1-cp311-cp311-win32.whl", hash = "sha256:f79b168428351d11e10c5aeb61a74e1851ec221081299f4cf56036a95431c43a", size = 2280681, upload-time = "2025-12-12T17:29:59.943Z" }, + { url = "https://files.pythonhosted.org/packages/07/ad/37dd1ae5fa6e01612a1fbb954f0927681f282925a86e86198ccd7b15d515/fonttools-4.61.1-cp311-cp311-win_amd64.whl", hash = "sha256:fe2efccb324948a11dd09d22136fe2ac8a97d6c1347cf0b58a911dcd529f66b7", size = 2331951, upload-time = "2025-12-12T17:30:02.254Z" }, + { url = "https://files.pythonhosted.org/packages/6f/16/7decaa24a1bd3a70c607b2e29f0adc6159f36a7e40eaba59846414765fd4/fonttools-4.61.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f3cb4a569029b9f291f88aafc927dd53683757e640081ca8c412781ea144565e", size = 2851593, upload-time = "2025-12-12T17:30:04.225Z" }, + { url = "https://files.pythonhosted.org/packages/94/98/3c4cb97c64713a8cf499b3245c3bf9a2b8fd16a3e375feff2aed78f96259/fonttools-4.61.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41a7170d042e8c0024703ed13b71893519a1a6d6e18e933e3ec7507a2c26a4b2", size = 2400231, upload-time = "2025-12-12T17:30:06.47Z" }, + { url = "https://files.pythonhosted.org/packages/b7/37/82dbef0f6342eb01f54bca073ac1498433d6ce71e50c3c3282b655733b31/fonttools-4.61.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10d88e55330e092940584774ee5e8a6971b01fc2f4d3466a1d6c158230880796", size = 4954103, upload-time = "2025-12-12T17:30:08.432Z" }, + { url = "https://files.pythonhosted.org/packages/6c/44/f3aeac0fa98e7ad527f479e161aca6c3a1e47bb6996b053d45226fe37bf2/fonttools-4.61.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15acc09befd16a0fb8a8f62bc147e1a82817542d72184acca9ce6e0aeda9fa6d", size = 5004295, upload-time = "2025-12-12T17:30:10.56Z" }, + { url = "https://files.pythonhosted.org/packages/14/e8/7424ced75473983b964d09f6747fa09f054a6d656f60e9ac9324cf40c743/fonttools-4.61.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e6bcdf33aec38d16508ce61fd81838f24c83c90a1d1b8c68982857038673d6b8", size = 4944109, upload-time = "2025-12-12T17:30:12.874Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8b/6391b257fa3d0b553d73e778f953a2f0154292a7a7a085e2374b111e5410/fonttools-4.61.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5fade934607a523614726119164ff621e8c30e8fa1ffffbbd358662056ba69f0", size = 5093598, upload-time = "2025-12-12T17:30:15.79Z" }, + { url = "https://files.pythonhosted.org/packages/d9/71/fd2ea96cdc512d92da5678a1c98c267ddd4d8c5130b76d0f7a80f9a9fde8/fonttools-4.61.1-cp312-cp312-win32.whl", hash = "sha256:75da8f28eff26defba42c52986de97b22106cb8f26515b7c22443ebc9c2d3261", size = 2269060, upload-time = "2025-12-12T17:30:18.058Z" }, + { url = "https://files.pythonhosted.org/packages/80/3b/a3e81b71aed5a688e89dfe0e2694b26b78c7d7f39a5ffd8a7d75f54a12a8/fonttools-4.61.1-cp312-cp312-win_amd64.whl", hash = "sha256:497c31ce314219888c0e2fce5ad9178ca83fe5230b01a5006726cdf3ac9f24d9", size = 2319078, upload-time = "2025-12-12T17:30:22.862Z" }, + { url = "https://files.pythonhosted.org/packages/4b/cf/00ba28b0990982530addb8dc3e9e6f2fa9cb5c20df2abdda7baa755e8fe1/fonttools-4.61.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8c56c488ab471628ff3bfa80964372fc13504ece601e0d97a78ee74126b2045c", size = 2846454, upload-time = "2025-12-12T17:30:24.938Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ca/468c9a8446a2103ae645d14fee3f610567b7042aba85031c1c65e3ef7471/fonttools-4.61.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dc492779501fa723b04d0ab1f5be046797fee17d27700476edc7ee9ae535a61e", size = 2398191, upload-time = "2025-12-12T17:30:27.343Z" }, + { url = "https://files.pythonhosted.org/packages/a3/4b/d67eedaed19def5967fade3297fed8161b25ba94699efc124b14fb68cdbc/fonttools-4.61.1-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:64102ca87e84261419c3747a0d20f396eb024bdbeb04c2bfb37e2891f5fadcb5", size = 4928410, upload-time = "2025-12-12T17:30:29.771Z" }, + { url = "https://files.pythonhosted.org/packages/b0/8d/6fb3494dfe61a46258cd93d979cf4725ded4eb46c2a4ca35e4490d84daea/fonttools-4.61.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c1b526c8d3f615a7b1867f38a9410849c8f4aef078535742198e942fba0e9bd", size = 4984460, upload-time = "2025-12-12T17:30:32.073Z" }, + { url = "https://files.pythonhosted.org/packages/f7/f1/a47f1d30b3dc00d75e7af762652d4cbc3dff5c2697a0dbd5203c81afd9c3/fonttools-4.61.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:41ed4b5ec103bd306bb68f81dc166e77409e5209443e5773cb4ed837bcc9b0d3", size = 4925800, upload-time = "2025-12-12T17:30:34.339Z" }, + { url = "https://files.pythonhosted.org/packages/a7/01/e6ae64a0981076e8a66906fab01539799546181e32a37a0257b77e4aa88b/fonttools-4.61.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b501c862d4901792adaec7c25b1ecc749e2662543f68bb194c42ba18d6eec98d", size = 5067859, upload-time = "2025-12-12T17:30:36.593Z" }, + { url = "https://files.pythonhosted.org/packages/73/aa/28e40b8d6809a9b5075350a86779163f074d2b617c15d22343fce81918db/fonttools-4.61.1-cp313-cp313-win32.whl", hash = "sha256:4d7092bb38c53bbc78e9255a59158b150bcdc115a1e3b3ce0b5f267dc35dd63c", size = 2267821, upload-time = "2025-12-12T17:30:38.478Z" }, + { url = "https://files.pythonhosted.org/packages/1a/59/453c06d1d83dc0951b69ef692d6b9f1846680342927df54e9a1ca91c6f90/fonttools-4.61.1-cp313-cp313-win_amd64.whl", hash = "sha256:21e7c8d76f62ab13c9472ccf74515ca5b9a761d1bde3265152a6dc58700d895b", size = 2318169, upload-time = "2025-12-12T17:30:40.951Z" }, + { url = "https://files.pythonhosted.org/packages/32/8f/4e7bf82c0cbb738d3c2206c920ca34ca74ef9dabde779030145d28665104/fonttools-4.61.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fff4f534200a04b4a36e7ae3cb74493afe807b517a09e99cb4faa89a34ed6ecd", size = 2846094, upload-time = "2025-12-12T17:30:43.511Z" }, + { url = "https://files.pythonhosted.org/packages/71/09/d44e45d0a4f3a651f23a1e9d42de43bc643cce2971b19e784cc67d823676/fonttools-4.61.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d9203500f7c63545b4ce3799319fe4d9feb1a1b89b28d3cb5abd11b9dd64147e", size = 2396589, upload-time = "2025-12-12T17:30:45.681Z" }, + { url = "https://files.pythonhosted.org/packages/89/18/58c64cafcf8eb677a99ef593121f719e6dcbdb7d1c594ae5a10d4997ca8a/fonttools-4.61.1-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa646ecec9528bef693415c79a86e733c70a4965dd938e9a226b0fc64c9d2e6c", size = 4877892, upload-time = "2025-12-12T17:30:47.709Z" }, + { url = "https://files.pythonhosted.org/packages/8a/ec/9e6b38c7ba1e09eb51db849d5450f4c05b7e78481f662c3b79dbde6f3d04/fonttools-4.61.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11f35ad7805edba3aac1a3710d104592df59f4b957e30108ae0ba6c10b11dd75", size = 4972884, upload-time = "2025-12-12T17:30:49.656Z" }, + { url = "https://files.pythonhosted.org/packages/5e/87/b5339da8e0256734ba0dbbf5b6cdebb1dd79b01dc8c270989b7bcd465541/fonttools-4.61.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b931ae8f62db78861b0ff1ac017851764602288575d65b8e8ff1963fed419063", size = 4924405, upload-time = "2025-12-12T17:30:51.735Z" }, + { url = "https://files.pythonhosted.org/packages/0b/47/e3409f1e1e69c073a3a6fd8cb886eb18c0bae0ee13db2c8d5e7f8495e8b7/fonttools-4.61.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b148b56f5de675ee16d45e769e69f87623a4944f7443850bf9a9376e628a89d2", size = 5035553, upload-time = "2025-12-12T17:30:54.823Z" }, + { url = "https://files.pythonhosted.org/packages/bf/b6/1f6600161b1073a984294c6c031e1a56ebf95b6164249eecf30012bb2e38/fonttools-4.61.1-cp314-cp314-win32.whl", hash = "sha256:9b666a475a65f4e839d3d10473fad6d47e0a9db14a2f4a224029c5bfde58ad2c", size = 2271915, upload-time = "2025-12-12T17:30:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/52/7b/91e7b01e37cc8eb0e1f770d08305b3655e4f002fc160fb82b3390eabacf5/fonttools-4.61.1-cp314-cp314-win_amd64.whl", hash = "sha256:4f5686e1fe5fce75d82d93c47a438a25bf0d1319d2843a926f741140b2b16e0c", size = 2323487, upload-time = "2025-12-12T17:30:59.804Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/908ad78e46c61c3e3ed70c3b58ff82ab48437faf84ec84f109592cabbd9f/fonttools-4.61.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e76ce097e3c57c4bcb67c5aa24a0ecdbd9f74ea9219997a707a4061fbe2707aa", size = 2929571, upload-time = "2025-12-12T17:31:02.574Z" }, + { url = "https://files.pythonhosted.org/packages/bd/41/975804132c6dea64cdbfbaa59f3518a21c137a10cccf962805b301ac6ab2/fonttools-4.61.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9cfef3ab326780c04d6646f68d4b4742aae222e8b8ea1d627c74e38afcbc9d91", size = 2435317, upload-time = "2025-12-12T17:31:04.974Z" }, + { url = "https://files.pythonhosted.org/packages/b0/5a/aef2a0a8daf1ebaae4cfd83f84186d4a72ee08fd6a8451289fcd03ffa8a4/fonttools-4.61.1-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a75c301f96db737e1c5ed5fd7d77d9c34466de16095a266509e13da09751bd19", size = 4882124, upload-time = "2025-12-12T17:31:07.456Z" }, + { url = "https://files.pythonhosted.org/packages/80/33/d6db3485b645b81cea538c9d1c9219d5805f0877fda18777add4671c5240/fonttools-4.61.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:91669ccac46bbc1d09e9273546181919064e8df73488ea087dcac3e2968df9ba", size = 5100391, upload-time = "2025-12-12T17:31:09.732Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d6/675ba631454043c75fcf76f0ca5463eac8eb0666ea1d7badae5fea001155/fonttools-4.61.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c33ab3ca9d3ccd581d58e989d67554e42d8d4ded94ab3ade3508455fe70e65f7", size = 4978800, upload-time = "2025-12-12T17:31:11.681Z" }, + { url = "https://files.pythonhosted.org/packages/7f/33/d3ec753d547a8d2bdaedd390d4a814e8d5b45a093d558f025c6b990b554c/fonttools-4.61.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:664c5a68ec406f6b1547946683008576ef8b38275608e1cee6c061828171c118", size = 5006426, upload-time = "2025-12-12T17:31:13.764Z" }, + { url = "https://files.pythonhosted.org/packages/b4/40/cc11f378b561a67bea850ab50063366a0d1dd3f6d0a30ce0f874b0ad5664/fonttools-4.61.1-cp314-cp314t-win32.whl", hash = "sha256:aed04cabe26f30c1647ef0e8fbb207516fd40fe9472e9439695f5c6998e60ac5", size = 2335377, upload-time = "2025-12-12T17:31:16.49Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ff/c9a2b66b39f8628531ea58b320d66d951267c98c6a38684daa8f50fb02f8/fonttools-4.61.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2180f14c141d2f0f3da43f3a81bc8aa4684860f6b0e6f9e165a4831f24e6a23b", size = 2400613, upload-time = "2025-12-12T17:31:18.769Z" }, + { url = "https://files.pythonhosted.org/packages/c7/4e/ce75a57ff3aebf6fc1f4e9d508b8e5810618a33d900ad6c19eb30b290b97/fonttools-4.61.1-py3-none-any.whl", hash = "sha256:17d2bf5d541add43822bcf0c43d7d847b160c9bb01d15d5007d84e2217aaa371", size = 1148996, upload-time = "2025-12-12T17:31:21.03Z" }, +] + +[[package]] +name = "fpdf2" +version = "2.8.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "defusedxml" }, + { name = "fonttools" }, + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/c0/784b130a28f4ed612e9aff26d1118e1f91005713dcd0a35e60b54d316b56/fpdf2-2.8.5.tar.gz", hash = "sha256:af4491ef2e0a5fe476f9d61362925658949c995f7e804438c0e81008f1550247", size = 336046, upload-time = "2025-10-29T14:17:59.569Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/a7/8532d8fffe6d1c388ad4941d678dd0da4d8da80434f2dbf4f35de0fa8029/fpdf2-2.8.5-py3-none-any.whl", hash = "sha256:2356b94e2a5fcbd1fe53ac5cbb83494e9003308860ab180050255ba50961d913", size = 301627, upload-time = "2025-10-29T14:17:57.685Z" }, +] + [[package]] name = "gitdb" version = "4.0.12" @@ -580,27 +625,26 @@ wheels = [ [[package]] name = "gitpython" -version = "3.1.45" +version = "3.1.46" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "gitdb" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9a/c8/dd58967d119baab745caec2f9d853297cec1989ec1d63f677d3880632b88/gitpython-3.1.45.tar.gz", hash = "sha256:85b0ee964ceddf211c41b9f27a49086010a190fd8132a24e21f362a4b36a791c", size = 215076, upload-time = "2025-07-24T03:45:54.871Z" } +sdist = { url = "https://files.pythonhosted.org/packages/df/b5/59d16470a1f0dfe8c793f9ef56fd3826093fc52b3bd96d6b9d6c26c7e27b/gitpython-3.1.46.tar.gz", hash = "sha256:400124c7d0ef4ea03f7310ac2fbf7151e09ff97f2a3288d64a440c584a29c37f", size = 215371, upload-time = "2026-01-01T15:37:32.073Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/01/61/d4b89fec821f72385526e1b9d9a3a0385dda4a72b206d28049e2c7cd39b8/gitpython-3.1.45-py3-none-any.whl", hash = "sha256:8908cb2e02fb3b93b7eb0f2827125cb699869470432cc885f019b8fd0fccff77", size = 208168, upload-time = "2025-07-24T03:45:52.517Z" }, + { url = "https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl", hash = "sha256:79812ed143d9d25b6d176a10bb511de0f9c67b1fa641d82097b0ab90398a2058", size = 208620, upload-time = "2026-01-01T15:37:30.574Z" }, ] [[package]] name = "hypothesis" -version = "6.147.0" +version = "6.150.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "sortedcontainers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f3/53/e19fe74671fd60db86344a4623c818fac58b813cc3efbb7ea3b3074dcb71/hypothesis-6.147.0.tar.gz", hash = "sha256:72e6004ea3bd1460bdb4640b6389df23b87ba7a4851893fd84d1375635d3e507", size = 468587, upload-time = "2025-11-06T20:27:29.682Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d2/19/a4eee0c98e2ec678854272f79646f34943f8fbbc42689cc355b530c5bc96/hypothesis-6.150.2.tar.gz", hash = "sha256:deb043c41c53eaf0955f4a08739c2a34c3d8040ee3d9a2da0aa5470122979f75", size = 475250, upload-time = "2026-01-13T17:09:22.146Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/1b/932eddc3d55c4ed6c585006cffe6c6a133b5e1797d873de0bcf5208e4fed/hypothesis-6.147.0-py3-none-any.whl", hash = "sha256:de588807b6da33550d32f47bcd42b1a86d061df85673aa73e6443680249d185e", size = 535595, upload-time = "2025-11-06T20:27:23.536Z" }, + { url = "https://files.pythonhosted.org/packages/b3/5e/21caad4acf45db7caf730cca1bc61422283e4c4e841efbc862d17ab81a21/hypothesis-6.150.2-py3-none-any.whl", hash = "sha256:648d6a2be435889e713ba3d335b0fb5e7a250f569b56e6867887c1e7a0d1f02f", size = 542712, upload-time = "2026-01-13T17:09:19.945Z" }, ] [[package]] @@ -651,8 +695,7 @@ dependencies = [ { name = "appnope", marker = "sys_platform == 'darwin'" }, { name = "comm" }, { name = "debugpy" }, - { name = "ipython", version = "8.37.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "ipython", version = "9.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "ipython" }, { name = "jupyter-client" }, { name = "jupyter-core" }, { name = "matplotlib-inline" }, @@ -670,53 +713,24 @@ wheels = [ [[package]] name = "ipython" -version = "8.37.0" +version = "9.9.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] dependencies = [ - { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, - { name = "decorator", marker = "python_full_version < '3.11'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "jedi", marker = "python_full_version < '3.11'" }, - { name = "matplotlib-inline", marker = "python_full_version < '3.11'" }, - { name = "pexpect", marker = "python_full_version < '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit", marker = "python_full_version < '3.11'" }, - { name = "pygments", marker = "python_full_version < '3.11'" }, - { name = "stack-data", marker = "python_full_version < '3.11'" }, - { name = "traitlets", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "decorator" }, + { name = "ipython-pygments-lexers" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/85/31/10ac88f3357fc276dc8a64e8880c82e80e7459326ae1d0a211b40abf6665/ipython-8.37.0.tar.gz", hash = "sha256:ca815841e1a41a1e6b73a0b08f3038af9b2252564d01fc405356d34033012216", size = 5606088, upload-time = "2025-05-31T16:39:09.613Z" } +sdist = { url = "https://files.pythonhosted.org/packages/46/dd/fb08d22ec0c27e73c8bc8f71810709870d51cadaf27b7ddd3f011236c100/ipython-9.9.0.tar.gz", hash = "sha256:48fbed1b2de5e2c7177eefa144aba7fcb82dac514f09b57e2ac9da34ddb54220", size = 4425043, upload-time = "2026-01-05T12:36:46.233Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/91/d0/274fbf7b0b12643cbbc001ce13e6a5b1607ac4929d1b11c72460152c9fc3/ipython-8.37.0-py3-none-any.whl", hash = "sha256:ed87326596b878932dbcb171e3e698845434d8c61b8d8cd474bf663041a9dcf2", size = 831864, upload-time = "2025-05-31T16:39:06.38Z" }, -] - -[[package]] -name = "ipython" -version = "9.7.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", -] -dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, - { name = "decorator", marker = "python_full_version >= '3.11'" }, - { name = "ipython-pygments-lexers", marker = "python_full_version >= '3.11'" }, - { name = "jedi", marker = "python_full_version >= '3.11'" }, - { name = "matplotlib-inline", marker = "python_full_version >= '3.11'" }, - { name = "pexpect", marker = "python_full_version >= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit", marker = "python_full_version >= '3.11'" }, - { name = "pygments", marker = "python_full_version >= '3.11'" }, - { name = "stack-data", marker = "python_full_version >= '3.11'" }, - { name = "traitlets", marker = "python_full_version >= '3.11'" }, - { name = "typing-extensions", marker = "python_full_version == '3.11.*'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/29/e6/48c74d54039241a456add616464ea28c6ebf782e4110d419411b83dae06f/ipython-9.7.0.tar.gz", hash = "sha256:5f6de88c905a566c6a9d6c400a8fed54a638e1f7543d17aae2551133216b1e4e", size = 4422115, upload-time = "2025-11-05T12:18:54.646Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/05/aa/62893d6a591d337aa59dcc4c6f6c842f1fe20cd72c8c5c1f980255243252/ipython-9.7.0-py3-none-any.whl", hash = "sha256:bce8ac85eb9521adc94e1845b4c03d88365fd6ac2f4908ec4ed1eb1b0a065f9f", size = 618911, upload-time = "2025-11-05T12:18:52.484Z" }, + { url = "https://files.pythonhosted.org/packages/86/92/162cfaee4ccf370465c5af1ce36a9eacec1becb552f2033bb3584e6f640a/ipython-9.9.0-py3-none-any.whl", hash = "sha256:b457fe9165df2b84e8ec909a97abcf2ed88f565970efba16b1f7229c283d252b", size = 621431, upload-time = "2026-01-05T12:36:44.669Z" }, ] [[package]] @@ -724,7 +738,7 @@ name = "ipython-pygments-lexers" version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pygments", marker = "python_full_version >= '3.11'" }, + { name = "pygments" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } wheels = [ @@ -757,7 +771,7 @@ wheels = [ [[package]] name = "jsonschema" -version = "4.25.1" +version = "4.26.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, @@ -765,9 +779,9 @@ dependencies = [ { name = "referencing" }, { name = "rpds-py" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342, upload-time = "2025-08-18T17:03:50.038Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040, upload-time = "2025-08-18T17:03:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, ] [[package]] @@ -784,7 +798,7 @@ wheels = [ [[package]] name = "jupyter-client" -version = "8.6.3" +version = "8.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jupyter-core" }, @@ -793,9 +807,9 @@ dependencies = [ { name = "tornado" }, { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/71/22/bf9f12fdaeae18019a468b68952a60fe6dbab5d67cd2a103cac7659b41ca/jupyter_client-8.6.3.tar.gz", hash = "sha256:35b3a0947c4a6e9d589eb97d7d4cd5e90f910ee73101611f01283732bd6d9419", size = 342019, upload-time = "2024-09-17T10:44:17.613Z" } +sdist = { url = "https://files.pythonhosted.org/packages/05/e4/ba649102a3bc3fbca54e7239fb924fd434c766f855693d86de0b1f2bec81/jupyter_client-8.8.0.tar.gz", hash = "sha256:d556811419a4f2d96c869af34e854e3f059b7cc2d6d01a9cd9c85c267691be3e", size = 348020, upload-time = "2026-01-08T13:55:47.938Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/11/85/b0394e0b6fcccd2c1eeefc230978a6f8cb0c5df1e4cd3e7625735a0d7d1e/jupyter_client-8.6.3-py3-none-any.whl", hash = "sha256:e8a19cc986cc45905ac3362915f410f3af85424b4c0905e94fa5f2cb08e8f23f", size = 106105, upload-time = "2024-09-17T10:44:15.218Z" }, + { url = "https://files.pythonhosted.org/packages/2d/0b/ceb7694d864abc0a047649aec263878acb9f792e1fec3e676f22dc9015e3/jupyter_client-8.8.0-py3-none-any.whl", hash = "sha256:f93a5b99c5e23a507b773d3a1136bd6e16c67883ccdbd9a829b0bbdb98cd7d7a", size = 107371, upload-time = "2026-01-08T13:55:45.562Z" }, ] [[package]] @@ -811,28 +825,75 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" }, ] +[[package]] +name = "librt" +version = "0.7.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/24/5f3646ff414285e0f7708fa4e946b9bf538345a41d1c375c439467721a5e/librt-0.7.8.tar.gz", hash = "sha256:1a4ede613941d9c3470b0368be851df6bb78ab218635512d0370b27a277a0862", size = 148323, upload-time = "2026-01-14T12:56:16.876Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/a3/87ea9c1049f2c781177496ebee29430e4631f439b8553a4969c88747d5d8/librt-0.7.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ff3e9c11aa260c31493d4b3197d1e28dd07768594a4f92bec4506849d736248f", size = 56507, upload-time = "2026-01-14T12:54:54.156Z" }, + { url = "https://files.pythonhosted.org/packages/5e/4a/23bcef149f37f771ad30203d561fcfd45b02bc54947b91f7a9ac34815747/librt-0.7.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ddb52499d0b3ed4aa88746aaf6f36a08314677d5c346234c3987ddc506404eac", size = 58455, upload-time = "2026-01-14T12:54:55.978Z" }, + { url = "https://files.pythonhosted.org/packages/22/6e/46eb9b85c1b9761e0f42b6e6311e1cc544843ac897457062b9d5d0b21df4/librt-0.7.8-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e9c0afebbe6ce177ae8edba0c7c4d626f2a0fc12c33bb993d163817c41a7a05c", size = 164956, upload-time = "2026-01-14T12:54:57.311Z" }, + { url = "https://files.pythonhosted.org/packages/7a/3f/aa7c7f6829fb83989feb7ba9aa11c662b34b4bd4bd5b262f2876ba3db58d/librt-0.7.8-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:631599598e2c76ded400c0a8722dec09217c89ff64dc54b060f598ed68e7d2a8", size = 174364, upload-time = "2026-01-14T12:54:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/3f/2d/d57d154b40b11f2cb851c4df0d4c4456bacd9b1ccc4ecb593ddec56c1a8b/librt-0.7.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c1ba843ae20db09b9d5c80475376168feb2640ce91cd9906414f23cc267a1ff", size = 188034, upload-time = "2026-01-14T12:55:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/59/f9/36c4dad00925c16cd69d744b87f7001792691857d3b79187e7a673e812fb/librt-0.7.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b5b007bb22ea4b255d3ee39dfd06d12534de2fcc3438567d9f48cdaf67ae1ae3", size = 186295, upload-time = "2026-01-14T12:55:01.303Z" }, + { url = "https://files.pythonhosted.org/packages/23/9b/8a9889d3df5efb67695a67785028ccd58e661c3018237b73ad081691d0cb/librt-0.7.8-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:dbd79caaf77a3f590cbe32dc2447f718772d6eea59656a7dcb9311161b10fa75", size = 181470, upload-time = "2026-01-14T12:55:02.492Z" }, + { url = "https://files.pythonhosted.org/packages/43/64/54d6ef11afca01fef8af78c230726a9394759f2addfbf7afc5e3cc032a45/librt-0.7.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:87808a8d1e0bd62a01cafc41f0fd6818b5a5d0ca0d8a55326a81643cdda8f873", size = 201713, upload-time = "2026-01-14T12:55:03.919Z" }, + { url = "https://files.pythonhosted.org/packages/2d/29/73e7ed2991330b28919387656f54109139b49e19cd72902f466bd44415fd/librt-0.7.8-cp311-cp311-win32.whl", hash = "sha256:31724b93baa91512bd0a376e7cf0b59d8b631ee17923b1218a65456fa9bda2e7", size = 43803, upload-time = "2026-01-14T12:55:04.996Z" }, + { url = "https://files.pythonhosted.org/packages/3f/de/66766ff48ed02b4d78deea30392ae200bcbd99ae61ba2418b49fd50a4831/librt-0.7.8-cp311-cp311-win_amd64.whl", hash = "sha256:978e8b5f13e52cf23a9e80f3286d7546baa70bc4ef35b51d97a709d0b28e537c", size = 50080, upload-time = "2026-01-14T12:55:06.489Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e3/33450438ff3a8c581d4ed7f798a70b07c3206d298cf0b87d3806e72e3ed8/librt-0.7.8-cp311-cp311-win_arm64.whl", hash = "sha256:20e3946863d872f7cabf7f77c6c9d370b8b3d74333d3a32471c50d3a86c0a232", size = 43383, upload-time = "2026-01-14T12:55:07.49Z" }, + { url = "https://files.pythonhosted.org/packages/56/04/79d8fcb43cae376c7adbab7b2b9f65e48432c9eced62ac96703bcc16e09b/librt-0.7.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9b6943885b2d49c48d0cff23b16be830ba46b0152d98f62de49e735c6e655a63", size = 57472, upload-time = "2026-01-14T12:55:08.528Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ba/60b96e93043d3d659da91752689023a73981336446ae82078cddf706249e/librt-0.7.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:46ef1f4b9b6cc364b11eea0ecc0897314447a66029ee1e55859acb3dd8757c93", size = 58986, upload-time = "2026-01-14T12:55:09.466Z" }, + { url = "https://files.pythonhosted.org/packages/7c/26/5215e4cdcc26e7be7eee21955a7e13cbf1f6d7d7311461a6014544596fac/librt-0.7.8-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:907ad09cfab21e3c86e8f1f87858f7049d1097f77196959c033612f532b4e592", size = 168422, upload-time = "2026-01-14T12:55:10.499Z" }, + { url = "https://files.pythonhosted.org/packages/0f/84/e8d1bc86fa0159bfc24f3d798d92cafd3897e84c7fea7fe61b3220915d76/librt-0.7.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2991b6c3775383752b3ca0204842743256f3ad3deeb1d0adc227d56b78a9a850", size = 177478, upload-time = "2026-01-14T12:55:11.577Z" }, + { url = "https://files.pythonhosted.org/packages/57/11/d0268c4b94717a18aa91df1100e767b010f87b7ae444dafaa5a2d80f33a6/librt-0.7.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03679b9856932b8c8f674e87aa3c55ea11c9274301f76ae8dc4d281bda55cf62", size = 192439, upload-time = "2026-01-14T12:55:12.7Z" }, + { url = "https://files.pythonhosted.org/packages/8d/56/1e8e833b95fe684f80f8894ae4d8b7d36acc9203e60478fcae599120a975/librt-0.7.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3968762fec1b2ad34ce57458b6de25dbb4142713e9ca6279a0d352fa4e9f452b", size = 191483, upload-time = "2026-01-14T12:55:13.838Z" }, + { url = "https://files.pythonhosted.org/packages/17/48/f11cf28a2cb6c31f282009e2208312aa84a5ee2732859f7856ee306176d5/librt-0.7.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:bb7a7807523a31f03061288cc4ffc065d684c39db7644c676b47d89553c0d714", size = 185376, upload-time = "2026-01-14T12:55:15.017Z" }, + { url = "https://files.pythonhosted.org/packages/b8/6a/d7c116c6da561b9155b184354a60a3d5cdbf08fc7f3678d09c95679d13d9/librt-0.7.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad64a14b1e56e702e19b24aae108f18ad1bf7777f3af5fcd39f87d0c5a814449", size = 206234, upload-time = "2026-01-14T12:55:16.571Z" }, + { url = "https://files.pythonhosted.org/packages/61/de/1975200bb0285fc921c5981d9978ce6ce11ae6d797df815add94a5a848a3/librt-0.7.8-cp312-cp312-win32.whl", hash = "sha256:0241a6ed65e6666236ea78203a73d800dbed896cf12ae25d026d75dc1fcd1dac", size = 44057, upload-time = "2026-01-14T12:55:18.077Z" }, + { url = "https://files.pythonhosted.org/packages/8e/cd/724f2d0b3461426730d4877754b65d39f06a41ac9d0a92d5c6840f72b9ae/librt-0.7.8-cp312-cp312-win_amd64.whl", hash = "sha256:6db5faf064b5bab9675c32a873436b31e01d66ca6984c6f7f92621656033a708", size = 50293, upload-time = "2026-01-14T12:55:19.179Z" }, + { url = "https://files.pythonhosted.org/packages/bd/cf/7e899acd9ee5727ad8160fdcc9994954e79fab371c66535c60e13b968ffc/librt-0.7.8-cp312-cp312-win_arm64.whl", hash = "sha256:57175aa93f804d2c08d2edb7213e09276bd49097611aefc37e3fa38d1fb99ad0", size = 43574, upload-time = "2026-01-14T12:55:20.185Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fe/b1f9de2829cf7fc7649c1dcd202cfd873837c5cc2fc9e526b0e7f716c3d2/librt-0.7.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4c3995abbbb60b3c129490fa985dfe6cac11d88fc3c36eeb4fb1449efbbb04fc", size = 57500, upload-time = "2026-01-14T12:55:21.219Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d4/4a60fbe2e53b825f5d9a77325071d61cd8af8506255067bf0c8527530745/librt-0.7.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:44e0c2cbc9bebd074cf2cdbe472ca185e824be4e74b1c63a8e934cea674bebf2", size = 59019, upload-time = "2026-01-14T12:55:22.256Z" }, + { url = "https://files.pythonhosted.org/packages/6a/37/61ff80341ba5159afa524445f2d984c30e2821f31f7c73cf166dcafa5564/librt-0.7.8-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d2f1e492cae964b3463a03dc77a7fe8742f7855d7258c7643f0ee32b6651dd3", size = 169015, upload-time = "2026-01-14T12:55:23.24Z" }, + { url = "https://files.pythonhosted.org/packages/1c/86/13d4f2d6a93f181ebf2fc953868826653ede494559da8268023fe567fca3/librt-0.7.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:451e7ffcef8f785831fdb791bd69211f47e95dc4c6ddff68e589058806f044c6", size = 178161, upload-time = "2026-01-14T12:55:24.826Z" }, + { url = "https://files.pythonhosted.org/packages/88/26/e24ef01305954fc4d771f1f09f3dd682f9eb610e1bec188ffb719374d26e/librt-0.7.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3469e1af9f1380e093ae06bedcbdd11e407ac0b303a56bbe9afb1d6824d4982d", size = 193015, upload-time = "2026-01-14T12:55:26.04Z" }, + { url = "https://files.pythonhosted.org/packages/88/a0/92b6bd060e720d7a31ed474d046a69bd55334ec05e9c446d228c4b806ae3/librt-0.7.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f11b300027ce19a34f6d24ebb0a25fd0e24a9d53353225a5c1e6cadbf2916b2e", size = 192038, upload-time = "2026-01-14T12:55:27.208Z" }, + { url = "https://files.pythonhosted.org/packages/06/bb/6f4c650253704279c3a214dad188101d1b5ea23be0606628bc6739456624/librt-0.7.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4adc73614f0d3c97874f02f2c7fd2a27854e7e24ad532ea6b965459c5b757eca", size = 186006, upload-time = "2026-01-14T12:55:28.594Z" }, + { url = "https://files.pythonhosted.org/packages/dc/00/1c409618248d43240cadf45f3efb866837fa77e9a12a71481912135eb481/librt-0.7.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:60c299e555f87e4c01b2eca085dfccda1dde87f5a604bb45c2906b8305819a93", size = 206888, upload-time = "2026-01-14T12:55:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/d9/83/b2cfe8e76ff5c1c77f8a53da3d5de62d04b5ebf7cf913e37f8bca43b5d07/librt-0.7.8-cp313-cp313-win32.whl", hash = "sha256:b09c52ed43a461994716082ee7d87618096851319bf695d57ec123f2ab708951", size = 44126, upload-time = "2026-01-14T12:55:31.44Z" }, + { url = "https://files.pythonhosted.org/packages/a9/0b/c59d45de56a51bd2d3a401fc63449c0ac163e4ef7f523ea8b0c0dee86ec5/librt-0.7.8-cp313-cp313-win_amd64.whl", hash = "sha256:f8f4a901a3fa28969d6e4519deceab56c55a09d691ea7b12ca830e2fa3461e34", size = 50262, upload-time = "2026-01-14T12:55:33.01Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b9/973455cec0a1ec592395250c474164c4a58ebf3e0651ee920fef1a2623f1/librt-0.7.8-cp313-cp313-win_arm64.whl", hash = "sha256:43d4e71b50763fcdcf64725ac680d8cfa1706c928b844794a7aa0fa9ac8e5f09", size = 43600, upload-time = "2026-01-14T12:55:34.054Z" }, + { url = "https://files.pythonhosted.org/packages/1a/73/fa8814c6ce2d49c3827829cadaa1589b0bf4391660bd4510899393a23ebc/librt-0.7.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:be927c3c94c74b05128089a955fba86501c3b544d1d300282cc1b4bd370cb418", size = 57049, upload-time = "2026-01-14T12:55:35.056Z" }, + { url = "https://files.pythonhosted.org/packages/53/fe/f6c70956da23ea235fd2e3cc16f4f0b4ebdfd72252b02d1164dd58b4e6c3/librt-0.7.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7b0803e9008c62a7ef79058233db7ff6f37a9933b8f2573c05b07ddafa226611", size = 58689, upload-time = "2026-01-14T12:55:36.078Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4d/7a2481444ac5fba63050d9abe823e6bc16896f575bfc9c1e5068d516cdce/librt-0.7.8-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:79feb4d00b2a4e0e05c9c56df707934f41fcb5fe53fd9efb7549068d0495b758", size = 166808, upload-time = "2026-01-14T12:55:37.595Z" }, + { url = "https://files.pythonhosted.org/packages/ac/3c/10901d9e18639f8953f57c8986796cfbf4c1c514844a41c9197cf87cb707/librt-0.7.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b9122094e3f24aa759c38f46bd8863433820654927370250f460ae75488b66ea", size = 175614, upload-time = "2026-01-14T12:55:38.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/01/5cbdde0951a5090a80e5ba44e6357d375048123c572a23eecfb9326993a7/librt-0.7.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e03bea66af33c95ce3addf87a9bf1fcad8d33e757bc479957ddbc0e4f7207ac", size = 189955, upload-time = "2026-01-14T12:55:39.939Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b4/e80528d2f4b7eaf1d437fcbd6fc6ba4cbeb3e2a0cb9ed5a79f47c7318706/librt-0.7.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1ade7f31675db00b514b98f9ab9a7698c7282dad4be7492589109471852d398", size = 189370, upload-time = "2026-01-14T12:55:41.057Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ab/938368f8ce31a9787ecd4becb1e795954782e4312095daf8fd22420227c8/librt-0.7.8-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a14229ac62adcf1b90a15992f1ab9c69ae8b99ffb23cb64a90878a6e8a2f5b81", size = 183224, upload-time = "2026-01-14T12:55:42.328Z" }, + { url = "https://files.pythonhosted.org/packages/3c/10/559c310e7a6e4014ac44867d359ef8238465fb499e7eb31b6bfe3e3f86f5/librt-0.7.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5bcaaf624fd24e6a0cb14beac37677f90793a96864c67c064a91458611446e83", size = 203541, upload-time = "2026-01-14T12:55:43.501Z" }, + { url = "https://files.pythonhosted.org/packages/f8/db/a0db7acdb6290c215f343835c6efda5b491bb05c3ddc675af558f50fdba3/librt-0.7.8-cp314-cp314-win32.whl", hash = "sha256:7aa7d5457b6c542ecaed79cec4ad98534373c9757383973e638ccced0f11f46d", size = 40657, upload-time = "2026-01-14T12:55:44.668Z" }, + { url = "https://files.pythonhosted.org/packages/72/e0/4f9bdc2a98a798511e81edcd6b54fe82767a715e05d1921115ac70717f6f/librt-0.7.8-cp314-cp314-win_amd64.whl", hash = "sha256:3d1322800771bee4a91f3b4bd4e49abc7d35e65166821086e5afd1e6c0d9be44", size = 46835, upload-time = "2026-01-14T12:55:45.655Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3d/59c6402e3dec2719655a41ad027a7371f8e2334aa794ed11533ad5f34969/librt-0.7.8-cp314-cp314-win_arm64.whl", hash = "sha256:5363427bc6a8c3b1719f8f3845ea53553d301382928a86e8fab7984426949bce", size = 39885, upload-time = "2026-01-14T12:55:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/4e/9c/2481d80950b83085fb14ba3c595db56330d21bbc7d88a19f20165f3538db/librt-0.7.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ca916919793a77e4a98d4a1701e345d337ce53be4a16620f063191f7322ac80f", size = 59161, upload-time = "2026-01-14T12:55:48.45Z" }, + { url = "https://files.pythonhosted.org/packages/96/79/108df2cfc4e672336765d54e3ff887294c1cc36ea4335c73588875775527/librt-0.7.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:54feb7b4f2f6706bb82325e836a01be805770443e2400f706e824e91f6441dde", size = 61008, upload-time = "2026-01-14T12:55:49.527Z" }, + { url = "https://files.pythonhosted.org/packages/46/f2/30179898f9994a5637459d6e169b6abdc982012c0a4b2d4c26f50c06f911/librt-0.7.8-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:39a4c76fee41007070f872b648cc2f711f9abf9a13d0c7162478043377b52c8e", size = 187199, upload-time = "2026-01-14T12:55:50.587Z" }, + { url = "https://files.pythonhosted.org/packages/b4/da/f7563db55cebdc884f518ba3791ad033becc25ff68eb70902b1747dc0d70/librt-0.7.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac9c8a458245c7de80bc1b9765b177055efff5803f08e548dd4bb9ab9a8d789b", size = 198317, upload-time = "2026-01-14T12:55:51.991Z" }, + { url = "https://files.pythonhosted.org/packages/b3/6c/4289acf076ad371471fa86718c30ae353e690d3de6167f7db36f429272f1/librt-0.7.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b67aa7eff150f075fda09d11f6bfb26edffd300f6ab1666759547581e8f666", size = 210334, upload-time = "2026-01-14T12:55:53.682Z" }, + { url = "https://files.pythonhosted.org/packages/4a/7f/377521ac25b78ac0a5ff44127a0360ee6d5ddd3ce7327949876a30533daa/librt-0.7.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:535929b6eff670c593c34ff435d5440c3096f20fa72d63444608a5aef64dd581", size = 211031, upload-time = "2026-01-14T12:55:54.827Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b1/e1e96c3e20b23d00cf90f4aad48f0deb4cdfec2f0ed8380d0d85acf98bbf/librt-0.7.8-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:63937bd0f4d1cb56653dc7ae900d6c52c41f0015e25aaf9902481ee79943b33a", size = 204581, upload-time = "2026-01-14T12:55:56.811Z" }, + { url = "https://files.pythonhosted.org/packages/43/71/0f5d010e92ed9747e14bef35e91b6580533510f1e36a8a09eb79ee70b2f0/librt-0.7.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cf243da9e42d914036fd362ac3fa77d80a41cadcd11ad789b1b5eec4daaf67ca", size = 224731, upload-time = "2026-01-14T12:55:58.175Z" }, + { url = "https://files.pythonhosted.org/packages/22/f0/07fb6ab5c39a4ca9af3e37554f9d42f25c464829254d72e4ebbd81da351c/librt-0.7.8-cp314-cp314t-win32.whl", hash = "sha256:171ca3a0a06c643bd0a2f62a8944e1902c94aa8e5da4db1ea9a8daf872685365", size = 41173, upload-time = "2026-01-14T12:55:59.315Z" }, + { url = "https://files.pythonhosted.org/packages/24/d4/7e4be20993dc6a782639625bd2f97f3c66125c7aa80c82426956811cfccf/librt-0.7.8-cp314-cp314t-win_amd64.whl", hash = "sha256:445b7304145e24c60288a2f172b5ce2ca35c0f81605f5299f3fa567e189d2e32", size = 47668, upload-time = "2026-01-14T12:56:00.261Z" }, + { url = "https://files.pythonhosted.org/packages/fc/85/69f92b2a7b3c0f88ffe107c86b952b397004b5b8ea5a81da3d9c04c04422/librt-0.7.8-cp314-cp314t-win_arm64.whl", hash = "sha256:8766ece9de08527deabcd7cb1b4f1a967a385d26e33e536d6d8913db6ef74f06", size = 40550, upload-time = "2026-01-14T12:56:01.542Z" }, +] + [[package]] name = "lxml" version = "6.0.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/db/8a/f8192a08237ef2fb1b19733f709db88a4c43bc8ab8357f01cb41a27e7f6a/lxml-6.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e77dd455b9a16bbd2a5036a63ddbd479c19572af81b624e79ef422f929eef388", size = 8590589, upload-time = "2025-09-22T04:00:10.51Z" }, - { url = "https://files.pythonhosted.org/packages/12/64/27bcd07ae17ff5e5536e8d88f4c7d581b48963817a13de11f3ac3329bfa2/lxml-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5d444858b9f07cefff6455b983aea9a67f7462ba1f6cbe4a21e8bf6791bf2153", size = 4629671, upload-time = "2025-09-22T04:00:15.411Z" }, - { url = "https://files.pythonhosted.org/packages/02/5a/a7d53b3291c324e0b6e48f3c797be63836cc52156ddf8f33cd72aac78866/lxml-6.0.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f952dacaa552f3bb8834908dddd500ba7d508e6ea6eb8c52eb2d28f48ca06a31", size = 4999961, upload-time = "2025-09-22T04:00:17.619Z" }, - { url = "https://files.pythonhosted.org/packages/f5/55/d465e9b89df1761674d8672bb3e4ae2c47033b01ec243964b6e334c6743f/lxml-6.0.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:71695772df6acea9f3c0e59e44ba8ac50c4f125217e84aab21074a1a55e7e5c9", size = 5157087, upload-time = "2025-09-22T04:00:19.868Z" }, - { url = "https://files.pythonhosted.org/packages/62/38/3073cd7e3e8dfc3ba3c3a139e33bee3a82de2bfb0925714351ad3d255c13/lxml-6.0.2-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:17f68764f35fd78d7c4cc4ef209a184c38b65440378013d24b8aecd327c3e0c8", size = 5067620, upload-time = "2025-09-22T04:00:21.877Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d3/1e001588c5e2205637b08985597827d3827dbaaece16348c8822bfe61c29/lxml-6.0.2-cp310-cp310-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:058027e261afed589eddcfe530fcc6f3402d7fd7e89bfd0532df82ebc1563dba", size = 5406664, upload-time = "2025-09-22T04:00:23.714Z" }, - { url = "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", hash = "sha256:a8ffaeec5dfea5881d4c9d8913a32d10cfe3923495386106e4a24d45300ef79c", size = 5289397, upload-time = "2025-09-22T04:00:25.544Z" }, - { url = "https://files.pythonhosted.org/packages/a3/84/02a2d0c38ac9a8b9f9e5e1bbd3f24b3f426044ad618b552e9549ee91bd63/lxml-6.0.2-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:f2e3b1a6bb38de0bc713edd4d612969dd250ca8b724be8d460001a387507021c", size = 4772178, upload-time = "2025-09-22T04:00:27.602Z" }, - { url = "https://files.pythonhosted.org/packages/56/87/e1ceadcc031ec4aa605fe95476892d0b0ba3b7f8c7dcdf88fdeff59a9c86/lxml-6.0.2-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d6690ec5ec1cce0385cb20896b16be35247ac8c2046e493d03232f1c2414d321", size = 5358148, upload-time = "2025-09-22T04:00:29.323Z" }, - { url = "https://files.pythonhosted.org/packages/fe/13/5bb6cf42bb228353fd4ac5f162c6a84fd68a4d6f67c1031c8cf97e131fc6/lxml-6.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f2a50c3c1d11cad0ebebbac357a97b26aa79d2bcaf46f256551152aa85d3a4d1", size = 5112035, upload-time = "2025-09-22T04:00:31.061Z" }, - { url = "https://files.pythonhosted.org/packages/e4/e2/ea0498552102e59834e297c5c6dff8d8ded3db72ed5e8aad77871476f073/lxml-6.0.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:3efe1b21c7801ffa29a1112fab3b0f643628c30472d507f39544fd48e9549e34", size = 4799111, upload-time = "2025-09-22T04:00:33.11Z" }, - { url = "https://files.pythonhosted.org/packages/6a/9e/8de42b52a73abb8af86c66c969b3b4c2a96567b6ac74637c037d2e3baa60/lxml-6.0.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:59c45e125140b2c4b33920d21d83681940ca29f0b83f8629ea1a2196dc8cfe6a", size = 5351662, upload-time = "2025-09-22T04:00:35.237Z" }, - { url = "https://files.pythonhosted.org/packages/28/a2/de776a573dfb15114509a37351937c367530865edb10a90189d0b4b9b70a/lxml-6.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:452b899faa64f1805943ec1c0c9ebeaece01a1af83e130b69cdefeda180bb42c", size = 5314973, upload-time = "2025-09-22T04:00:37.086Z" }, - { url = "https://files.pythonhosted.org/packages/50/a0/3ae1b1f8964c271b5eec91db2043cf8c6c0bce101ebb2a633b51b044db6c/lxml-6.0.2-cp310-cp310-win32.whl", hash = "sha256:1e786a464c191ca43b133906c6903a7e4d56bef376b75d97ccbb8ec5cf1f0a4b", size = 3611953, upload-time = "2025-09-22T04:00:39.224Z" }, - { url = "https://files.pythonhosted.org/packages/d1/70/bd42491f0634aad41bdfc1e46f5cff98825fb6185688dc82baa35d509f1a/lxml-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:dacf3c64ef3f7440e3167aa4b49aa9e0fb99e0aa4f9ff03795640bf94531bcb0", size = 4032695, upload-time = "2025-09-22T04:00:41.402Z" }, - { url = "https://files.pythonhosted.org/packages/d2/d0/05c6a72299f54c2c561a6c6cbb2f512e047fca20ea97a05e57931f194ac4/lxml-6.0.2-cp310-cp310-win_arm64.whl", hash = "sha256:45f93e6f75123f88d7f0cfd90f2d05f441b808562bf0bc01070a00f53f5028b5", size = 3680051, upload-time = "2025-09-22T04:00:43.525Z" }, { url = "https://files.pythonhosted.org/packages/77/d5/becbe1e2569b474a23f0c672ead8a29ac50b2dc1d5b9de184831bda8d14c/lxml-6.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:13e35cbc684aadf05d8711a5d1b5857c92e5e580efa9a0d2be197199c8def607", size = 8634365, upload-time = "2025-09-22T04:00:45.672Z" }, { url = "https://files.pythonhosted.org/packages/28/66/1ced58f12e804644426b85d0bb8a4478ca77bc1761455da310505f1a3526/lxml-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b1675e096e17c6fe9c0e8c81434f5736c0739ff9ac6123c87c2d452f48fc938", size = 4650793, upload-time = "2025-09-22T04:00:47.783Z" }, { url = "https://files.pythonhosted.org/packages/11/84/549098ffea39dfd167e3f174b4ce983d0eed61f9d8d25b7bf2a57c3247fc/lxml-6.0.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac6e5811ae2870953390452e3476694196f98d447573234592d30488147404d", size = 4944362, upload-time = "2025-09-22T04:00:49.845Z" }, @@ -921,12 +982,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" }, { url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" }, { url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" }, - { url = "https://files.pythonhosted.org/packages/e7/9c/780c9a8fce3f04690b374f72f41306866b0400b9d0fdf3e17aaa37887eed/lxml-6.0.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:e748d4cf8fef2526bb2a589a417eba0c8674e29ffcb570ce2ceca44f1e567bf6", size = 3939264, upload-time = "2025-09-22T04:04:32.892Z" }, - { url = "https://files.pythonhosted.org/packages/f5/5a/1ab260c00adf645d8bf7dec7f920f744b032f69130c681302821d5debea6/lxml-6.0.2-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4ddb1049fa0579d0cbd00503ad8c58b9ab34d1254c77bc6a5576d96ec7853dba", size = 4216435, upload-time = "2025-09-22T04:04:34.907Z" }, - { url = "https://files.pythonhosted.org/packages/f2/37/565f3b3d7ffede22874b6d86be1a1763d00f4ea9fc5b9b6ccb11e4ec8612/lxml-6.0.2-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cb233f9c95f83707dae461b12b720c1af9c28c2d19208e1be03387222151daf5", size = 4325913, upload-time = "2025-09-22T04:04:37.205Z" }, - { url = "https://files.pythonhosted.org/packages/22/ec/f3a1b169b2fb9d03467e2e3c0c752ea30e993be440a068b125fc7dd248b0/lxml-6.0.2-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc456d04db0515ce3320d714a1eac7a97774ff0849e7718b492d957da4631dd4", size = 4269357, upload-time = "2025-09-22T04:04:39.322Z" }, - { url = "https://files.pythonhosted.org/packages/77/a2/585a28fe3e67daa1cf2f06f34490d556d121c25d500b10082a7db96e3bcd/lxml-6.0.2-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2613e67de13d619fd283d58bda40bff0ee07739f624ffee8b13b631abf33083d", size = 4412295, upload-time = "2025-09-22T04:04:41.647Z" }, - { url = "https://files.pythonhosted.org/packages/7b/d9/a57dd8bcebd7c69386c20263830d4fa72d27e6b72a229ef7a48e88952d9a/lxml-6.0.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:24a8e756c982c001ca8d59e87c80c4d9dcd4d9b44a4cbeb8d9be4482c514d41d", size = 3516913, upload-time = "2025-09-22T04:04:43.602Z" }, { url = "https://files.pythonhosted.org/packages/0b/11/29d08bc103a62c0eba8016e7ed5aeebbf1e4312e83b0b1648dd203b0e87d/lxml-6.0.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1c06035eafa8404b5cf475bb37a9f6088b0aca288d4ccc9d69389750d5543700", size = 3949829, upload-time = "2025-09-22T04:04:45.608Z" }, { url = "https://files.pythonhosted.org/packages/12/b3/52ab9a3b31e5ab8238da241baa19eec44d2ab426532441ee607165aebb52/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c7d13103045de1bdd6fe5d61802565f1a3537d70cd3abf596aa0af62761921ee", size = 4226277, upload-time = "2025-09-22T04:04:47.754Z" }, { url = "https://files.pythonhosted.org/packages/a0/33/1eaf780c1baad88224611df13b1c2a9dfa460b526cacfe769103ff50d845/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a3c150a95fbe5ac91de323aa756219ef9cf7fde5a3f00e2281e30f33fa5fa4f", size = 4330433, upload-time = "2025-09-22T04:04:49.907Z" }, @@ -937,14 +992,14 @@ wheels = [ [[package]] name = "markdown-it-py" -version = "3.0.0" +version = "4.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mdurl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] [[package]] @@ -953,17 +1008,6 @@ version = "3.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, - { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, - { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, - { url = "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", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, - { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, - { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, - { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, - { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, - { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, - { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, @@ -1067,47 +1111,41 @@ wheels = [ [[package]] name = "mypy" -version = "1.18.2" +version = "1.19.1" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, { name = "mypy-extensions" }, { name = "pathspec" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c0/77/8f0d0001ffad290cef2f7f216f96c814866248a0b92a722365ed54648e7e/mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b", size = 3448846, upload-time = "2025-09-19T00:11:10.519Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/03/6f/657961a0743cff32e6c0611b63ff1c1970a0b482ace35b069203bf705187/mypy-1.18.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c1eab0cf6294dafe397c261a75f96dc2c31bffe3b944faa24db5def4e2b0f77c", size = 12807973, upload-time = "2025-09-19T00:10:35.282Z" }, - { url = "https://files.pythonhosted.org/packages/10/e9/420822d4f661f13ca8900f5fa239b40ee3be8b62b32f3357df9a3045a08b/mypy-1.18.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7a780ca61fc239e4865968ebc5240bb3bf610ef59ac398de9a7421b54e4a207e", size = 11896527, upload-time = "2025-09-19T00:10:55.791Z" }, - { url = "https://files.pythonhosted.org/packages/aa/73/a05b2bbaa7005f4642fcfe40fb73f2b4fb6bb44229bd585b5878e9a87ef8/mypy-1.18.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:448acd386266989ef11662ce3c8011fd2a7b632e0ec7d61a98edd8e27472225b", size = 12507004, upload-time = "2025-09-19T00:11:05.411Z" }, - { url = "https://files.pythonhosted.org/packages/4f/01/f6e4b9f0d031c11ccbd6f17da26564f3a0f3c4155af344006434b0a05a9d/mypy-1.18.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9e171c465ad3901dc652643ee4bffa8e9fef4d7d0eece23b428908c77a76a66", size = 13245947, upload-time = "2025-09-19T00:10:46.923Z" }, - { url = "https://files.pythonhosted.org/packages/d7/97/19727e7499bfa1ae0773d06afd30ac66a58ed7437d940c70548634b24185/mypy-1.18.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:592ec214750bc00741af1f80cbf96b5013d81486b7bb24cb052382c19e40b428", size = 13499217, upload-time = "2025-09-19T00:09:39.472Z" }, - { url = "https://files.pythonhosted.org/packages/9f/4f/90dc8c15c1441bf31cf0f9918bb077e452618708199e530f4cbd5cede6ff/mypy-1.18.2-cp310-cp310-win_amd64.whl", hash = "sha256:7fb95f97199ea11769ebe3638c29b550b5221e997c63b14ef93d2e971606ebed", size = 9766753, upload-time = "2025-09-19T00:10:49.161Z" }, - { url = "https://files.pythonhosted.org/packages/88/87/cafd3ae563f88f94eec33f35ff722d043e09832ea8530ef149ec1efbaf08/mypy-1.18.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:807d9315ab9d464125aa9fcf6d84fde6e1dc67da0b6f80e7405506b8ac72bc7f", size = 12731198, upload-time = "2025-09-19T00:09:44.857Z" }, - { url = "https://files.pythonhosted.org/packages/0f/e0/1e96c3d4266a06d4b0197ace5356d67d937d8358e2ee3ffac71faa843724/mypy-1.18.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:776bb00de1778caf4db739c6e83919c1d85a448f71979b6a0edd774ea8399341", size = 11817879, upload-time = "2025-09-19T00:09:47.131Z" }, - { url = "https://files.pythonhosted.org/packages/72/ef/0c9ba89eb03453e76bdac5a78b08260a848c7bfc5d6603634774d9cd9525/mypy-1.18.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1379451880512ffce14505493bd9fe469e0697543717298242574882cf8cdb8d", size = 12427292, upload-time = "2025-09-19T00:10:22.472Z" }, - { url = "https://files.pythonhosted.org/packages/1a/52/ec4a061dd599eb8179d5411d99775bec2a20542505988f40fc2fee781068/mypy-1.18.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1331eb7fd110d60c24999893320967594ff84c38ac6d19e0a76c5fd809a84c86", size = 13163750, upload-time = "2025-09-19T00:09:51.472Z" }, - { url = "https://files.pythonhosted.org/packages/c4/5f/2cf2ceb3b36372d51568f2208c021870fe7834cf3186b653ac6446511839/mypy-1.18.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ca30b50a51e7ba93b00422e486cbb124f1c56a535e20eff7b2d6ab72b3b2e37", size = 13351827, upload-time = "2025-09-19T00:09:58.311Z" }, - { url = "https://files.pythonhosted.org/packages/c8/7d/2697b930179e7277529eaaec1513f8de622818696857f689e4a5432e5e27/mypy-1.18.2-cp311-cp311-win_amd64.whl", hash = "sha256:664dc726e67fa54e14536f6e1224bcfce1d9e5ac02426d2326e2bb4e081d1ce8", size = 9757983, upload-time = "2025-09-19T00:10:09.071Z" }, - { url = "https://files.pythonhosted.org/packages/07/06/dfdd2bc60c66611dd8335f463818514733bc763e4760dee289dcc33df709/mypy-1.18.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33eca32dd124b29400c31d7cf784e795b050ace0e1f91b8dc035672725617e34", size = 12908273, upload-time = "2025-09-19T00:10:58.321Z" }, - { url = "https://files.pythonhosted.org/packages/81/14/6a9de6d13a122d5608e1a04130724caf9170333ac5a924e10f670687d3eb/mypy-1.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3c47adf30d65e89b2dcd2fa32f3aeb5e94ca970d2c15fcb25e297871c8e4764", size = 11920910, upload-time = "2025-09-19T00:10:20.043Z" }, - { url = "https://files.pythonhosted.org/packages/5f/a9/b29de53e42f18e8cc547e38daa9dfa132ffdc64f7250e353f5c8cdd44bee/mypy-1.18.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d6c838e831a062f5f29d11c9057c6009f60cb294fea33a98422688181fe2893", size = 12465585, upload-time = "2025-09-19T00:10:33.005Z" }, - { url = "https://files.pythonhosted.org/packages/77/ae/6c3d2c7c61ff21f2bee938c917616c92ebf852f015fb55917fd6e2811db2/mypy-1.18.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01199871b6110a2ce984bde85acd481232d17413868c9807e95c1b0739a58914", size = 13348562, upload-time = "2025-09-19T00:10:11.51Z" }, - { url = "https://files.pythonhosted.org/packages/4d/31/aec68ab3b4aebdf8f36d191b0685d99faa899ab990753ca0fee60fb99511/mypy-1.18.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a2afc0fa0b0e91b4599ddfe0f91e2c26c2b5a5ab263737e998d6817874c5f7c8", size = 13533296, upload-time = "2025-09-19T00:10:06.568Z" }, - { url = "https://files.pythonhosted.org/packages/9f/83/abcb3ad9478fca3ebeb6a5358bb0b22c95ea42b43b7789c7fb1297ca44f4/mypy-1.18.2-cp312-cp312-win_amd64.whl", hash = "sha256:d8068d0afe682c7c4897c0f7ce84ea77f6de953262b12d07038f4d296d547074", size = 9828828, upload-time = "2025-09-19T00:10:28.203Z" }, - { url = "https://files.pythonhosted.org/packages/5f/04/7f462e6fbba87a72bc8097b93f6842499c428a6ff0c81dd46948d175afe8/mypy-1.18.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc", size = 12898728, upload-time = "2025-09-19T00:10:01.33Z" }, - { url = "https://files.pythonhosted.org/packages/99/5b/61ed4efb64f1871b41fd0b82d29a64640f3516078f6c7905b68ab1ad8b13/mypy-1.18.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e", size = 11910758, upload-time = "2025-09-19T00:10:42.607Z" }, - { url = "https://files.pythonhosted.org/packages/3c/46/d297d4b683cc89a6e4108c4250a6a6b717f5fa96e1a30a7944a6da44da35/mypy-1.18.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986", size = 12475342, upload-time = "2025-09-19T00:11:00.371Z" }, - { url = "https://files.pythonhosted.org/packages/83/45/4798f4d00df13eae3bfdf726c9244bcb495ab5bd588c0eed93a2f2dd67f3/mypy-1.18.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d", size = 13338709, upload-time = "2025-09-19T00:11:03.358Z" }, - { url = "https://files.pythonhosted.org/packages/d7/09/479f7358d9625172521a87a9271ddd2441e1dab16a09708f056e97007207/mypy-1.18.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba", size = 13529806, upload-time = "2025-09-19T00:10:26.073Z" }, - { url = "https://files.pythonhosted.org/packages/71/cf/ac0f2c7e9d0ea3c75cd99dff7aec1c9df4a1376537cb90e4c882267ee7e9/mypy-1.18.2-cp313-cp313-win_amd64.whl", hash = "sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544", size = 9833262, upload-time = "2025-09-19T00:10:40.035Z" }, - { url = "https://files.pythonhosted.org/packages/5a/0c/7d5300883da16f0063ae53996358758b2a2df2a09c72a5061fa79a1f5006/mypy-1.18.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62f0e1e988ad41c2a110edde6c398383a889d95b36b3e60bcf155f5164c4fdce", size = 12893775, upload-time = "2025-09-19T00:10:03.814Z" }, - { url = "https://files.pythonhosted.org/packages/50/df/2cffbf25737bdb236f60c973edf62e3e7b4ee1c25b6878629e88e2cde967/mypy-1.18.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8795a039bab805ff0c1dfdb8cd3344642c2b99b8e439d057aba30850b8d3423d", size = 11936852, upload-time = "2025-09-19T00:10:51.631Z" }, - { url = "https://files.pythonhosted.org/packages/be/50/34059de13dd269227fb4a03be1faee6e2a4b04a2051c82ac0a0b5a773c9a/mypy-1.18.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ca1e64b24a700ab5ce10133f7ccd956a04715463d30498e64ea8715236f9c9c", size = 12480242, upload-time = "2025-09-19T00:11:07.955Z" }, - { url = "https://files.pythonhosted.org/packages/5b/11/040983fad5132d85914c874a2836252bbc57832065548885b5bb5b0d4359/mypy-1.18.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d924eef3795cc89fecf6bedc6ed32b33ac13e8321344f6ddbf8ee89f706c05cb", size = 13326683, upload-time = "2025-09-19T00:09:55.572Z" }, - { url = "https://files.pythonhosted.org/packages/e9/ba/89b2901dd77414dd7a8c8729985832a5735053be15b744c18e4586e506ef/mypy-1.18.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20c02215a080e3a2be3aa50506c67242df1c151eaba0dcbc1e4e557922a26075", size = 13514749, upload-time = "2025-09-19T00:10:44.827Z" }, - { url = "https://files.pythonhosted.org/packages/25/bc/cc98767cffd6b2928ba680f3e5bc969c4152bf7c2d83f92f5a504b92b0eb/mypy-1.18.2-cp314-cp314-win_amd64.whl", hash = "sha256:749b5f83198f1ca64345603118a6f01a4e99ad4bf9d103ddc5a3200cc4614adf", size = 9982959, upload-time = "2025-09-19T00:10:37.344Z" }, - { url = "https://files.pythonhosted.org/packages/87/e3/be76d87158ebafa0309946c4a73831974d4d6ab4f4ef40c3b53a385a66fd/mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e", size = 2352367, upload-time = "2025-09-19T00:10:15.489Z" }, + { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, + { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, + { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, + { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, + { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, + { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, + { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, + { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, + { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, + { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, ] [[package]] @@ -1121,7 +1159,7 @@ wheels = [ [[package]] name = "myst-parser" -version = "4.0.1" +version = "5.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "docutils" }, @@ -1129,21 +1167,21 @@ dependencies = [ { name = "markdown-it-py" }, { name = "mdit-py-plugins" }, { name = "pyyaml" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/a5/9626ba4f73555b3735ad86247a8077d4603aa8628537687c839ab08bfe44/myst_parser-4.0.1.tar.gz", hash = "sha256:5cfea715e4f3574138aecbf7d54132296bfd72bb614d31168f48c477a830a7c4", size = 93985, upload-time = "2025-02-12T10:53:03.833Z" } +sdist = { url = "https://files.pythonhosted.org/packages/33/fa/7b45eef11b7971f0beb29d27b7bfe0d747d063aa29e170d9edd004733c8a/myst_parser-5.0.0.tar.gz", hash = "sha256:f6f231452c56e8baa662cc352c548158f6a16fcbd6e3800fc594978002b94f3a", size = 98535, upload-time = "2026-01-15T09:08:18.036Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/df/76d0321c3797b54b60fef9ec3bd6f4cfd124b9e422182156a1dd418722cf/myst_parser-4.0.1-py3-none-any.whl", hash = "sha256:9134e88959ec3b5780aedf8a99680ea242869d012e8821db3126d427edc9c95d", size = 84579, upload-time = "2025-02-12T10:53:02.078Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ac/686789b9145413f1a61878c407210e41bfdb097976864e0913078b24098c/myst_parser-5.0.0-py3-none-any.whl", hash = "sha256:ab31e516024918296e169139072b81592336f2fef55b8986aa31c9f04b5f7211", size = 84533, upload-time = "2026-01-15T09:08:16.788Z" }, ] [[package]] name = "narwhals" -version = "2.11.0" +version = "2.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/a2/25208347aa4c2d82a265cf4bc0873aaf5069f525c0438146821e7fc19ef5/narwhals-2.11.0.tar.gz", hash = "sha256:d23f3ea7efc6b4d0355444a72de6b8fa3011175585246c3400c894a7583964af", size = 589233, upload-time = "2025-11-10T16:28:35.675Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/6d/b57c64e5038a8cf071bce391bb11551657a74558877ac961e7fa905ece27/narwhals-2.15.0.tar.gz", hash = "sha256:a9585975b99d95084268445a1fdd881311fa26ef1caa18020d959d5b2ff9a965", size = 603479, upload-time = "2026-01-06T08:10:13.27Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/a1/4d21933898e23b011ae0528151b57a9230a62960d0919bf2ee48c7f5c20a/narwhals-2.11.0-py3-none-any.whl", hash = "sha256:a9795e1e44aa94e5ba6406ef1c5ee4c172414ced4f1aea4a79e5894f0c7378d4", size = 423069, upload-time = "2025-11-10T16:28:33.522Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2e/cf2ffeb386ac3763526151163ad7da9f1b586aac96d2b4f7de1eaebf0c61/narwhals-2.15.0-py3-none-any.whl", hash = "sha256:cbfe21ca19d260d9fd67f995ec75c44592d1f106933b03ddd375df7ac841f9d6", size = 432856, upload-time = "2026-01-06T08:10:11.511Z" }, ] [[package]] @@ -1157,152 +1195,81 @@ wheels = [ [[package]] name = "numpy" -version = "2.2.6" +version = "2.4.1" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } +sdist = { url = "https://files.pythonhosted.org/packages/24/62/ae72ff66c0f1fd959925b4c11f8c2dea61f47f6acaea75a08512cdfe3fed/numpy-2.4.1.tar.gz", hash = "sha256:a1ceafc5042451a858231588a104093474c6a5c57dcc724841f5c888d237d690", size = 20721320, upload-time = "2026-01-10T06:44:59.619Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" }, - { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" }, - { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" }, - { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" }, - { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" }, - { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" }, - { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" }, - { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" }, - { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" }, - { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" }, - { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, - { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, - { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, - { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, - { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, - { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, - { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, - { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, - { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, - { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, - { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, - { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, - { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, - { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, - { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, - { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, - { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, - { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, - { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, - { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, - { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" }, - { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" }, - { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" }, - { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" }, - { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" }, - { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" }, - { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" }, - { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" }, - { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" }, - { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" }, - { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" }, - { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" }, - { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" }, - { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" }, - { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" }, - { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" }, - { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" }, - { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" }, - { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" }, - { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, - { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, - { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, - { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, - { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, -] - -[[package]] -name = "numpy" -version = "2.3.4" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", -] -sdist = { url = "https://files.pythonhosted.org/packages/b5/f4/098d2270d52b41f1bd7db9fc288aaa0400cb48c2a3e2af6fa365d9720947/numpy-2.3.4.tar.gz", hash = "sha256:a7d018bfedb375a8d979ac758b120ba846a7fe764911a64465fd87b8729f4a6a", size = 20582187, upload-time = "2025-10-15T16:18:11.77Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/60/e7/0e07379944aa8afb49a556a2b54587b828eb41dc9adc56fb7615b678ca53/numpy-2.3.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e78aecd2800b32e8347ce49316d3eaf04aed849cd5b38e0af39f829a4e59f5eb", size = 21259519, upload-time = "2025-10-15T16:15:19.012Z" }, - { url = "https://files.pythonhosted.org/packages/d0/cb/5a69293561e8819b09e34ed9e873b9a82b5f2ade23dce4c51dc507f6cfe1/numpy-2.3.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7fd09cc5d65bda1e79432859c40978010622112e9194e581e3415a3eccc7f43f", size = 14452796, upload-time = "2025-10-15T16:15:23.094Z" }, - { url = "https://files.pythonhosted.org/packages/e4/04/ff11611200acd602a1e5129e36cfd25bf01ad8e5cf927baf2e90236eb02e/numpy-2.3.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:1b219560ae2c1de48ead517d085bc2d05b9433f8e49d0955c82e8cd37bd7bf36", size = 5381639, upload-time = "2025-10-15T16:15:25.572Z" }, - { url = "https://files.pythonhosted.org/packages/ea/77/e95c757a6fe7a48d28a009267408e8aa382630cc1ad1db7451b3bc21dbb4/numpy-2.3.4-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:bafa7d87d4c99752d07815ed7a2c0964f8ab311eb8168f41b910bd01d15b6032", size = 6914296, upload-time = "2025-10-15T16:15:27.079Z" }, - { url = "https://files.pythonhosted.org/packages/a3/d2/137c7b6841c942124eae921279e5c41b1c34bab0e6fc60c7348e69afd165/numpy-2.3.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36dc13af226aeab72b7abad501d370d606326a0029b9f435eacb3b8c94b8a8b7", size = 14591904, upload-time = "2025-10-15T16:15:29.044Z" }, - { url = "https://files.pythonhosted.org/packages/bb/32/67e3b0f07b0aba57a078c4ab777a9e8e6bc62f24fb53a2337f75f9691699/numpy-2.3.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7b2f9a18b5ff9824a6af80de4f37f4ec3c2aab05ef08f51c77a093f5b89adda", size = 16939602, upload-time = "2025-10-15T16:15:31.106Z" }, - { url = "https://files.pythonhosted.org/packages/95/22/9639c30e32c93c4cee3ccdb4b09c2d0fbff4dcd06d36b357da06146530fb/numpy-2.3.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9984bd645a8db6ca15d850ff996856d8762c51a2239225288f08f9050ca240a0", size = 16372661, upload-time = "2025-10-15T16:15:33.546Z" }, - { url = "https://files.pythonhosted.org/packages/12/e9/a685079529be2b0156ae0c11b13d6be647743095bb51d46589e95be88086/numpy-2.3.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:64c5825affc76942973a70acf438a8ab618dbd692b84cd5ec40a0a0509edc09a", size = 18884682, upload-time = "2025-10-15T16:15:36.105Z" }, - { url = "https://files.pythonhosted.org/packages/cf/85/f6f00d019b0cc741e64b4e00ce865a57b6bed945d1bbeb1ccadbc647959b/numpy-2.3.4-cp311-cp311-win32.whl", hash = "sha256:ed759bf7a70342f7817d88376eb7142fab9fef8320d6019ef87fae05a99874e1", size = 6570076, upload-time = "2025-10-15T16:15:38.225Z" }, - { url = "https://files.pythonhosted.org/packages/7d/10/f8850982021cb90e2ec31990291f9e830ce7d94eef432b15066e7cbe0bec/numpy-2.3.4-cp311-cp311-win_amd64.whl", hash = "sha256:faba246fb30ea2a526c2e9645f61612341de1a83fb1e0c5edf4ddda5a9c10996", size = 13089358, upload-time = "2025-10-15T16:15:40.404Z" }, - { url = "https://files.pythonhosted.org/packages/d1/ad/afdd8351385edf0b3445f9e24210a9c3971ef4de8fd85155462fc4321d79/numpy-2.3.4-cp311-cp311-win_arm64.whl", hash = "sha256:4c01835e718bcebe80394fd0ac66c07cbb90147ebbdad3dcecd3f25de2ae7e2c", size = 10462292, upload-time = "2025-10-15T16:15:42.896Z" }, - { url = "https://files.pythonhosted.org/packages/96/7a/02420400b736f84317e759291b8edaeee9dc921f72b045475a9cbdb26b17/numpy-2.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ef1b5a3e808bc40827b5fa2c8196151a4c5abe110e1726949d7abddfe5c7ae11", size = 20957727, upload-time = "2025-10-15T16:15:44.9Z" }, - { url = "https://files.pythonhosted.org/packages/18/90/a014805d627aa5750f6f0e878172afb6454552da929144b3c07fcae1bb13/numpy-2.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c2f91f496a87235c6aaf6d3f3d89b17dba64996abadccb289f48456cff931ca9", size = 14187262, upload-time = "2025-10-15T16:15:47.761Z" }, - { url = "https://files.pythonhosted.org/packages/c7/e4/0a94b09abe89e500dc748e7515f21a13e30c5c3fe3396e6d4ac108c25fca/numpy-2.3.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:f77e5b3d3da652b474cc80a14084927a5e86a5eccf54ca8ca5cbd697bf7f2667", size = 5115992, upload-time = "2025-10-15T16:15:50.144Z" }, - { url = "https://files.pythonhosted.org/packages/88/dd/db77c75b055c6157cbd4f9c92c4458daef0dd9cbe6d8d2fe7f803cb64c37/numpy-2.3.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:8ab1c5f5ee40d6e01cbe96de5863e39b215a4d24e7d007cad56c7184fdf4aeef", size = 6648672, upload-time = "2025-10-15T16:15:52.442Z" }, - { url = "https://files.pythonhosted.org/packages/e1/e6/e31b0d713719610e406c0ea3ae0d90760465b086da8783e2fd835ad59027/numpy-2.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77b84453f3adcb994ddbd0d1c5d11db2d6bda1a2b7fd5ac5bd4649d6f5dc682e", size = 14284156, upload-time = "2025-10-15T16:15:54.351Z" }, - { url = "https://files.pythonhosted.org/packages/f9/58/30a85127bfee6f108282107caf8e06a1f0cc997cb6b52cdee699276fcce4/numpy-2.3.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4121c5beb58a7f9e6dfdee612cb24f4df5cd4db6e8261d7f4d7450a997a65d6a", size = 16641271, upload-time = "2025-10-15T16:15:56.67Z" }, - { url = "https://files.pythonhosted.org/packages/06/f2/2e06a0f2adf23e3ae29283ad96959267938d0efd20a2e25353b70065bfec/numpy-2.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:65611ecbb00ac9846efe04db15cbe6186f562f6bb7e5e05f077e53a599225d16", size = 16059531, upload-time = "2025-10-15T16:15:59.412Z" }, - { url = "https://files.pythonhosted.org/packages/b0/e7/b106253c7c0d5dc352b9c8fab91afd76a93950998167fa3e5afe4ef3a18f/numpy-2.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dabc42f9c6577bcc13001b8810d300fe814b4cfbe8a92c873f269484594f9786", size = 18578983, upload-time = "2025-10-15T16:16:01.804Z" }, - { url = "https://files.pythonhosted.org/packages/73/e3/04ecc41e71462276ee867ccbef26a4448638eadecf1bc56772c9ed6d0255/numpy-2.3.4-cp312-cp312-win32.whl", hash = "sha256:a49d797192a8d950ca59ee2d0337a4d804f713bb5c3c50e8db26d49666e351dc", size = 6291380, upload-time = "2025-10-15T16:16:03.938Z" }, - { url = "https://files.pythonhosted.org/packages/3d/a8/566578b10d8d0e9955b1b6cd5db4e9d4592dd0026a941ff7994cedda030a/numpy-2.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:985f1e46358f06c2a09921e8921e2c98168ed4ae12ccd6e5e87a4f1857923f32", size = 12787999, upload-time = "2025-10-15T16:16:05.801Z" }, - { url = "https://files.pythonhosted.org/packages/58/22/9c903a957d0a8071b607f5b1bff0761d6e608b9a965945411f867d515db1/numpy-2.3.4-cp312-cp312-win_arm64.whl", hash = "sha256:4635239814149e06e2cb9db3dd584b2fa64316c96f10656983b8026a82e6e4db", size = 10197412, upload-time = "2025-10-15T16:16:07.854Z" }, - { url = "https://files.pythonhosted.org/packages/57/7e/b72610cc91edf138bc588df5150957a4937221ca6058b825b4725c27be62/numpy-2.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c090d4860032b857d94144d1a9976b8e36709e40386db289aaf6672de2a81966", size = 20950335, upload-time = "2025-10-15T16:16:10.304Z" }, - { url = "https://files.pythonhosted.org/packages/3e/46/bdd3370dcea2f95ef14af79dbf81e6927102ddf1cc54adc0024d61252fd9/numpy-2.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a13fc473b6db0be619e45f11f9e81260f7302f8d180c49a22b6e6120022596b3", size = 14179878, upload-time = "2025-10-15T16:16:12.595Z" }, - { url = "https://files.pythonhosted.org/packages/ac/01/5a67cb785bda60f45415d09c2bc245433f1c68dd82eef9c9002c508b5a65/numpy-2.3.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:3634093d0b428e6c32c3a69b78e554f0cd20ee420dcad5a9f3b2a63762ce4197", size = 5108673, upload-time = "2025-10-15T16:16:14.877Z" }, - { url = "https://files.pythonhosted.org/packages/c2/cd/8428e23a9fcebd33988f4cb61208fda832800ca03781f471f3727a820704/numpy-2.3.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:043885b4f7e6e232d7df4f51ffdef8c36320ee9d5f227b380ea636722c7ed12e", size = 6641438, upload-time = "2025-10-15T16:16:16.805Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d1/913fe563820f3c6b079f992458f7331278dcd7ba8427e8e745af37ddb44f/numpy-2.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ee6a571d1e4f0ea6d5f22d6e5fbd6ed1dc2b18542848e1e7301bd190500c9d7", size = 14281290, upload-time = "2025-10-15T16:16:18.764Z" }, - { url = "https://files.pythonhosted.org/packages/9e/7e/7d306ff7cb143e6d975cfa7eb98a93e73495c4deabb7d1b5ecf09ea0fd69/numpy-2.3.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc8a63918b04b8571789688b2780ab2b4a33ab44bfe8ccea36d3eba51228c953", size = 16636543, upload-time = "2025-10-15T16:16:21.072Z" }, - { url = "https://files.pythonhosted.org/packages/47/6a/8cfc486237e56ccfb0db234945552a557ca266f022d281a2f577b98e955c/numpy-2.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:40cc556d5abbc54aabe2b1ae287042d7bdb80c08edede19f0c0afb36ae586f37", size = 16056117, upload-time = "2025-10-15T16:16:23.369Z" }, - { url = "https://files.pythonhosted.org/packages/b1/0e/42cb5e69ea901e06ce24bfcc4b5664a56f950a70efdcf221f30d9615f3f3/numpy-2.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ecb63014bb7f4ce653f8be7f1df8cbc6093a5a2811211770f6606cc92b5a78fd", size = 18577788, upload-time = "2025-10-15T16:16:27.496Z" }, - { url = "https://files.pythonhosted.org/packages/86/92/41c3d5157d3177559ef0a35da50f0cda7fa071f4ba2306dd36818591a5bc/numpy-2.3.4-cp313-cp313-win32.whl", hash = "sha256:e8370eb6925bb8c1c4264fec52b0384b44f675f191df91cbe0140ec9f0955646", size = 6282620, upload-time = "2025-10-15T16:16:29.811Z" }, - { url = "https://files.pythonhosted.org/packages/09/97/fd421e8bc50766665ad35536c2bb4ef916533ba1fdd053a62d96cc7c8b95/numpy-2.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:56209416e81a7893036eea03abcb91c130643eb14233b2515c90dcac963fe99d", size = 12784672, upload-time = "2025-10-15T16:16:31.589Z" }, - { url = "https://files.pythonhosted.org/packages/ad/df/5474fb2f74970ca8eb978093969b125a84cc3d30e47f82191f981f13a8a0/numpy-2.3.4-cp313-cp313-win_arm64.whl", hash = "sha256:a700a4031bc0fd6936e78a752eefb79092cecad2599ea9c8039c548bc097f9bc", size = 10196702, upload-time = "2025-10-15T16:16:33.902Z" }, - { url = "https://files.pythonhosted.org/packages/11/83/66ac031464ec1767ea3ed48ce40f615eb441072945e98693bec0bcd056cc/numpy-2.3.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:86966db35c4040fdca64f0816a1c1dd8dbd027d90fca5a57e00e1ca4cd41b879", size = 21049003, upload-time = "2025-10-15T16:16:36.101Z" }, - { url = "https://files.pythonhosted.org/packages/5f/99/5b14e0e686e61371659a1d5bebd04596b1d72227ce36eed121bb0aeab798/numpy-2.3.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:838f045478638b26c375ee96ea89464d38428c69170360b23a1a50fa4baa3562", size = 14302980, upload-time = "2025-10-15T16:16:39.124Z" }, - { url = "https://files.pythonhosted.org/packages/2c/44/e9486649cd087d9fc6920e3fc3ac2aba10838d10804b1e179fb7cbc4e634/numpy-2.3.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d7315ed1dab0286adca467377c8381cd748f3dc92235f22a7dfc42745644a96a", size = 5231472, upload-time = "2025-10-15T16:16:41.168Z" }, - { url = "https://files.pythonhosted.org/packages/3e/51/902b24fa8887e5fe2063fd61b1895a476d0bbf46811ab0c7fdf4bd127345/numpy-2.3.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:84f01a4d18b2cc4ade1814a08e5f3c907b079c847051d720fad15ce37aa930b6", size = 6739342, upload-time = "2025-10-15T16:16:43.777Z" }, - { url = "https://files.pythonhosted.org/packages/34/f1/4de9586d05b1962acdcdb1dc4af6646361a643f8c864cef7c852bf509740/numpy-2.3.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:817e719a868f0dacde4abdfc5c1910b301877970195db9ab6a5e2c4bd5b121f7", size = 14354338, upload-time = "2025-10-15T16:16:46.081Z" }, - { url = "https://files.pythonhosted.org/packages/1f/06/1c16103b425de7969d5a76bdf5ada0804b476fed05d5f9e17b777f1cbefd/numpy-2.3.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85e071da78d92a214212cacea81c6da557cab307f2c34b5f85b628e94803f9c0", size = 16702392, upload-time = "2025-10-15T16:16:48.455Z" }, - { url = "https://files.pythonhosted.org/packages/34/b2/65f4dc1b89b5322093572b6e55161bb42e3e0487067af73627f795cc9d47/numpy-2.3.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2ec646892819370cf3558f518797f16597b4e4669894a2ba712caccc9da53f1f", size = 16134998, upload-time = "2025-10-15T16:16:51.114Z" }, - { url = "https://files.pythonhosted.org/packages/d4/11/94ec578896cdb973aaf56425d6c7f2aff4186a5c00fac15ff2ec46998b46/numpy-2.3.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:035796aaaddfe2f9664b9a9372f089cfc88bd795a67bd1bfe15e6e770934cf64", size = 18651574, upload-time = "2025-10-15T16:16:53.429Z" }, - { url = "https://files.pythonhosted.org/packages/62/b7/7efa763ab33dbccf56dade36938a77345ce8e8192d6b39e470ca25ff3cd0/numpy-2.3.4-cp313-cp313t-win32.whl", hash = "sha256:fea80f4f4cf83b54c3a051f2f727870ee51e22f0248d3114b8e755d160b38cfb", size = 6413135, upload-time = "2025-10-15T16:16:55.992Z" }, - { url = "https://files.pythonhosted.org/packages/43/70/aba4c38e8400abcc2f345e13d972fb36c26409b3e644366db7649015f291/numpy-2.3.4-cp313-cp313t-win_amd64.whl", hash = "sha256:15eea9f306b98e0be91eb344a94c0e630689ef302e10c2ce5f7e11905c704f9c", size = 12928582, upload-time = "2025-10-15T16:16:57.943Z" }, - { url = "https://files.pythonhosted.org/packages/67/63/871fad5f0073fc00fbbdd7232962ea1ac40eeaae2bba66c76214f7954236/numpy-2.3.4-cp313-cp313t-win_arm64.whl", hash = "sha256:b6c231c9c2fadbae4011ca5e7e83e12dc4a5072f1a1d85a0a7b3ed754d145a40", size = 10266691, upload-time = "2025-10-15T16:17:00.048Z" }, - { url = "https://files.pythonhosted.org/packages/72/71/ae6170143c115732470ae3a2d01512870dd16e0953f8a6dc89525696069b/numpy-2.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:81c3e6d8c97295a7360d367f9f8553973651b76907988bb6066376bc2252f24e", size = 20955580, upload-time = "2025-10-15T16:17:02.509Z" }, - { url = "https://files.pythonhosted.org/packages/af/39/4be9222ffd6ca8a30eda033d5f753276a9c3426c397bb137d8e19dedd200/numpy-2.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7c26b0b2bf58009ed1f38a641f3db4be8d960a417ca96d14e5b06df1506d41ff", size = 14188056, upload-time = "2025-10-15T16:17:04.873Z" }, - { url = "https://files.pythonhosted.org/packages/6c/3d/d85f6700d0a4aa4f9491030e1021c2b2b7421b2b38d01acd16734a2bfdc7/numpy-2.3.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:62b2198c438058a20b6704351b35a1d7db881812d8512d67a69c9de1f18ca05f", size = 5116555, upload-time = "2025-10-15T16:17:07.499Z" }, - { url = "https://files.pythonhosted.org/packages/bf/04/82c1467d86f47eee8a19a464c92f90a9bb68ccf14a54c5224d7031241ffb/numpy-2.3.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:9d729d60f8d53a7361707f4b68a9663c968882dd4f09e0d58c044c8bf5faee7b", size = 6643581, upload-time = "2025-10-15T16:17:09.774Z" }, - { url = "https://files.pythonhosted.org/packages/0c/d3/c79841741b837e293f48bd7db89d0ac7a4f2503b382b78a790ef1dc778a5/numpy-2.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd0c630cf256b0a7fd9d0a11c9413b42fef5101219ce6ed5a09624f5a65392c7", size = 14299186, upload-time = "2025-10-15T16:17:11.937Z" }, - { url = "https://files.pythonhosted.org/packages/e8/7e/4a14a769741fbf237eec5a12a2cbc7a4c4e061852b6533bcb9e9a796c908/numpy-2.3.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5e081bc082825f8b139f9e9fe42942cb4054524598aaeb177ff476cc76d09d2", size = 16638601, upload-time = "2025-10-15T16:17:14.391Z" }, - { url = "https://files.pythonhosted.org/packages/93/87/1c1de269f002ff0a41173fe01dcc925f4ecff59264cd8f96cf3b60d12c9b/numpy-2.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15fb27364ed84114438fff8aaf998c9e19adbeba08c0b75409f8c452a8692c52", size = 16074219, upload-time = "2025-10-15T16:17:17.058Z" }, - { url = "https://files.pythonhosted.org/packages/cd/28/18f72ee77408e40a76d691001ae599e712ca2a47ddd2c4f695b16c65f077/numpy-2.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:85d9fb2d8cd998c84d13a79a09cc0c1091648e848e4e6249b0ccd7f6b487fa26", size = 18576702, upload-time = "2025-10-15T16:17:19.379Z" }, - { url = "https://files.pythonhosted.org/packages/c3/76/95650169b465ececa8cf4b2e8f6df255d4bf662775e797ade2025cc51ae6/numpy-2.3.4-cp314-cp314-win32.whl", hash = "sha256:e73d63fd04e3a9d6bc187f5455d81abfad05660b212c8804bf3b407e984cd2bc", size = 6337136, upload-time = "2025-10-15T16:17:22.886Z" }, - { url = "https://files.pythonhosted.org/packages/dc/89/a231a5c43ede5d6f77ba4a91e915a87dea4aeea76560ba4d2bf185c683f0/numpy-2.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:3da3491cee49cf16157e70f607c03a217ea6647b1cea4819c4f48e53d49139b9", size = 12920542, upload-time = "2025-10-15T16:17:24.783Z" }, - { url = "https://files.pythonhosted.org/packages/0d/0c/ae9434a888f717c5ed2ff2393b3f344f0ff6f1c793519fa0c540461dc530/numpy-2.3.4-cp314-cp314-win_arm64.whl", hash = "sha256:6d9cd732068e8288dbe2717177320723ccec4fb064123f0caf9bbd90ab5be868", size = 10480213, upload-time = "2025-10-15T16:17:26.935Z" }, - { url = "https://files.pythonhosted.org/packages/83/4b/c4a5f0841f92536f6b9592694a5b5f68c9ab37b775ff342649eadf9055d3/numpy-2.3.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:22758999b256b595cf0b1d102b133bb61866ba5ceecf15f759623b64c020c9ec", size = 21052280, upload-time = "2025-10-15T16:17:29.638Z" }, - { url = "https://files.pythonhosted.org/packages/3e/80/90308845fc93b984d2cc96d83e2324ce8ad1fd6efea81b324cba4b673854/numpy-2.3.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9cb177bc55b010b19798dc5497d540dea67fd13a8d9e882b2dae71de0cf09eb3", size = 14302930, upload-time = "2025-10-15T16:17:32.384Z" }, - { url = "https://files.pythonhosted.org/packages/3d/4e/07439f22f2a3b247cec4d63a713faae55e1141a36e77fb212881f7cda3fb/numpy-2.3.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:0f2bcc76f1e05e5ab58893407c63d90b2029908fa41f9f1cc51eecce936c3365", size = 5231504, upload-time = "2025-10-15T16:17:34.515Z" }, - { url = "https://files.pythonhosted.org/packages/ab/de/1e11f2547e2fe3d00482b19721855348b94ada8359aef5d40dd57bfae9df/numpy-2.3.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:8dc20bde86802df2ed8397a08d793da0ad7a5fd4ea3ac85d757bf5dd4ad7c252", size = 6739405, upload-time = "2025-10-15T16:17:36.128Z" }, - { url = "https://files.pythonhosted.org/packages/3b/40/8cd57393a26cebe2e923005db5134a946c62fa56a1087dc7c478f3e30837/numpy-2.3.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e199c087e2aa71c8f9ce1cb7a8e10677dc12457e7cc1be4798632da37c3e86e", size = 14354866, upload-time = "2025-10-15T16:17:38.884Z" }, - { url = "https://files.pythonhosted.org/packages/93/39/5b3510f023f96874ee6fea2e40dfa99313a00bf3ab779f3c92978f34aace/numpy-2.3.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85597b2d25ddf655495e2363fe044b0ae999b75bc4d630dc0d886484b03a5eb0", size = 16703296, upload-time = "2025-10-15T16:17:41.564Z" }, - { url = "https://files.pythonhosted.org/packages/41/0d/19bb163617c8045209c1996c4e427bccbc4bbff1e2c711f39203c8ddbb4a/numpy-2.3.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:04a69abe45b49c5955923cf2c407843d1c85013b424ae8a560bba16c92fe44a0", size = 16136046, upload-time = "2025-10-15T16:17:43.901Z" }, - { url = "https://files.pythonhosted.org/packages/e2/c1/6dba12fdf68b02a21ac411c9df19afa66bed2540f467150ca64d246b463d/numpy-2.3.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e1708fac43ef8b419c975926ce1eaf793b0c13b7356cfab6ab0dc34c0a02ac0f", size = 18652691, upload-time = "2025-10-15T16:17:46.247Z" }, - { url = "https://files.pythonhosted.org/packages/f8/73/f85056701dbbbb910c51d846c58d29fd46b30eecd2b6ba760fc8b8a1641b/numpy-2.3.4-cp314-cp314t-win32.whl", hash = "sha256:863e3b5f4d9915aaf1b8ec79ae560ad21f0b8d5e3adc31e73126491bb86dee1d", size = 6485782, upload-time = "2025-10-15T16:17:48.872Z" }, - { url = "https://files.pythonhosted.org/packages/17/90/28fa6f9865181cb817c2471ee65678afa8a7e2a1fb16141473d5fa6bacc3/numpy-2.3.4-cp314-cp314t-win_amd64.whl", hash = "sha256:962064de37b9aef801d33bc579690f8bfe6c5e70e29b61783f60bcba838a14d6", size = 13113301, upload-time = "2025-10-15T16:17:50.938Z" }, - { url = "https://files.pythonhosted.org/packages/54/23/08c002201a8e7e1f9afba93b97deceb813252d9cfd0d3351caed123dcf97/numpy-2.3.4-cp314-cp314t-win_arm64.whl", hash = "sha256:8b5a9a39c45d852b62693d9b3f3e0fe052541f804296ff401a72a1b60edafb29", size = 10547532, upload-time = "2025-10-15T16:17:53.48Z" }, - { url = "https://files.pythonhosted.org/packages/b1/b6/64898f51a86ec88ca1257a59c1d7fd077b60082a119affefcdf1dd0df8ca/numpy-2.3.4-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6e274603039f924c0fe5cb73438fa9246699c78a6df1bd3decef9ae592ae1c05", size = 21131552, upload-time = "2025-10-15T16:17:55.845Z" }, - { url = "https://files.pythonhosted.org/packages/ce/4c/f135dc6ebe2b6a3c77f4e4838fa63d350f85c99462012306ada1bd4bc460/numpy-2.3.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d149aee5c72176d9ddbc6803aef9c0f6d2ceeea7626574fc68518da5476fa346", size = 14377796, upload-time = "2025-10-15T16:17:58.308Z" }, - { url = "https://files.pythonhosted.org/packages/d0/a4/f33f9c23fcc13dd8412fc8614559b5b797e0aba9d8e01dfa8bae10c84004/numpy-2.3.4-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:6d34ed9db9e6395bb6cd33286035f73a59b058169733a9db9f85e650b88df37e", size = 5306904, upload-time = "2025-10-15T16:18:00.596Z" }, - { url = "https://files.pythonhosted.org/packages/28/af/c44097f25f834360f9fb960fa082863e0bad14a42f36527b2a121abdec56/numpy-2.3.4-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:fdebe771ca06bb8d6abce84e51dca9f7921fe6ad34a0c914541b063e9a68928b", size = 6819682, upload-time = "2025-10-15T16:18:02.32Z" }, - { url = "https://files.pythonhosted.org/packages/c5/8c/cd283b54c3c2b77e188f63e23039844f56b23bba1712318288c13fe86baf/numpy-2.3.4-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e92defe6c08211eb77902253b14fe5b480ebc5112bc741fd5e9cd0608f847", size = 14422300, upload-time = "2025-10-15T16:18:04.271Z" }, - { url = "https://files.pythonhosted.org/packages/b0/f0/8404db5098d92446b3e3695cf41c6f0ecb703d701cb0b7566ee2177f2eee/numpy-2.3.4-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13b9062e4f5c7ee5c7e5be96f29ba71bc5a37fed3d1d77c37390ae00724d296d", size = 16760806, upload-time = "2025-10-15T16:18:06.668Z" }, - { url = "https://files.pythonhosted.org/packages/95/8e/2844c3959ce9a63acc7c8e50881133d86666f0420bcde695e115ced0920f/numpy-2.3.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:81b3a59793523e552c4a96109dde028aa4448ae06ccac5a76ff6532a85558a7f", size = 12973130, upload-time = "2025-10-15T16:18:09.397Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/2b1bc18424f3ad9af577f6ce23600319968a70575bd7db31ce66731bbef9/numpy-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0cce2a669e3c8ba02ee563c7835f92c153cf02edff1ae05e1823f1dde21b16a5", size = 16944563, upload-time = "2026-01-10T06:42:14.615Z" }, + { url = "https://files.pythonhosted.org/packages/2c/57/26e5f97d075aef3794045a6ca9eada6a4ed70eb9a40e7a4a93f9ac80d704/numpy-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:899d2c18024984814ac7e83f8f49d8e8180e2fbe1b2e252f2e7f1d06bea92425", size = 12645658, upload-time = "2026-01-10T06:42:17.298Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ba/80fc0b1e3cb2fd5c6143f00f42eb67762aa043eaa05ca924ecc3222a7849/numpy-2.4.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:09aa8a87e45b55a1c2c205d42e2808849ece5c484b2aab11fecabec3841cafba", size = 5474132, upload-time = "2026-01-10T06:42:19.637Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/0a5b9a397f0e865ec171187c78d9b57e5588afc439a04ba9cab1ebb2c945/numpy-2.4.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:edee228f76ee2dab4579fad6f51f6a305de09d444280109e0f75df247ff21501", size = 6804159, upload-time = "2026-01-10T06:42:21.44Z" }, + { url = "https://files.pythonhosted.org/packages/86/9c/841c15e691c7085caa6fd162f063eff494099c8327aeccd509d1ab1e36ab/numpy-2.4.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a92f227dbcdc9e4c3e193add1a189a9909947d4f8504c576f4a732fd0b54240a", size = 14708058, upload-time = "2026-01-10T06:42:23.546Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9d/7862db06743f489e6a502a3b93136d73aea27d97b2cf91504f70a27501d6/numpy-2.4.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:538bf4ec353709c765ff75ae616c34d3c3dca1a68312727e8f2676ea644f8509", size = 16651501, upload-time = "2026-01-10T06:42:25.909Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9c/6fc34ebcbd4015c6e5f0c0ce38264010ce8a546cb6beacb457b84a75dfc8/numpy-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ac08c63cb7779b85e9d5318e6c3518b424bc1f364ac4cb2c6136f12e5ff2dccc", size = 16492627, upload-time = "2026-01-10T06:42:28.938Z" }, + { url = "https://files.pythonhosted.org/packages/aa/63/2494a8597502dacda439f61b3c0db4da59928150e62be0e99395c3ad23c5/numpy-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4f9c360ecef085e5841c539a9a12b883dff005fbd7ce46722f5e9cef52634d82", size = 18585052, upload-time = "2026-01-10T06:42:31.312Z" }, + { url = "https://files.pythonhosted.org/packages/6a/93/098e1162ae7522fc9b618d6272b77404c4656c72432ecee3abc029aa3de0/numpy-2.4.1-cp311-cp311-win32.whl", hash = "sha256:0f118ce6b972080ba0758c6087c3617b5ba243d806268623dc34216d69099ba0", size = 6236575, upload-time = "2026-01-10T06:42:33.872Z" }, + { url = "https://files.pythonhosted.org/packages/8c/de/f5e79650d23d9e12f38a7bc6b03ea0835b9575494f8ec94c11c6e773b1b1/numpy-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:18e14c4d09d55eef39a6ab5b08406e84bc6869c1e34eef45564804f90b7e0574", size = 12604479, upload-time = "2026-01-10T06:42:35.778Z" }, + { url = "https://files.pythonhosted.org/packages/dd/65/e1097a7047cff12ce3369bd003811516b20ba1078dbdec135e1cd7c16c56/numpy-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:6461de5113088b399d655d45c3897fa188766415d0f568f175ab071c8873bd73", size = 10578325, upload-time = "2026-01-10T06:42:38.518Z" }, + { url = "https://files.pythonhosted.org/packages/78/7f/ec53e32bf10c813604edf07a3682616bd931d026fcde7b6d13195dfb684a/numpy-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d3703409aac693fa82c0aee023a1ae06a6e9d065dba10f5e8e80f642f1e9d0a2", size = 16656888, upload-time = "2026-01-10T06:42:40.913Z" }, + { url = "https://files.pythonhosted.org/packages/b8/e0/1f9585d7dae8f14864e948fd7fa86c6cb72dee2676ca2748e63b1c5acfe0/numpy-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7211b95ca365519d3596a1d8688a95874cc94219d417504d9ecb2df99fa7bfa8", size = 12373956, upload-time = "2026-01-10T06:42:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/8e/43/9762e88909ff2326f5e7536fa8cb3c49fb03a7d92705f23e6e7f553d9cb3/numpy-2.4.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:5adf01965456a664fc727ed69cc71848f28d063217c63e1a0e200a118d5eec9a", size = 5202567, upload-time = "2026-01-10T06:42:45.107Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ee/34b7930eb61e79feb4478800a4b95b46566969d837546aa7c034c742ef98/numpy-2.4.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:26f0bcd9c79a00e339565b303badc74d3ea2bd6d52191eeca5f95936cad107d0", size = 6549459, upload-time = "2026-01-10T06:42:48.152Z" }, + { url = "https://files.pythonhosted.org/packages/79/e3/5f115fae982565771be994867c89bcd8d7208dbfe9469185497d70de5ddf/numpy-2.4.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0093e85df2960d7e4049664b26afc58b03236e967fb942354deef3208857a04c", size = 14404859, upload-time = "2026-01-10T06:42:49.947Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7d/9c8a781c88933725445a859cac5d01b5871588a15969ee6aeb618ba99eee/numpy-2.4.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ad270f438cbdd402c364980317fb6b117d9ec5e226fff5b4148dd9aa9fc6e02", size = 16371419, upload-time = "2026-01-10T06:42:52.409Z" }, + { url = "https://files.pythonhosted.org/packages/a6/d2/8aa084818554543f17cf4162c42f162acbd3bb42688aefdba6628a859f77/numpy-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:297c72b1b98100c2e8f873d5d35fb551fce7040ade83d67dd51d38c8d42a2162", size = 16182131, upload-time = "2026-01-10T06:42:54.694Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/0425216684297c58a8df35f3284ef56ec4a043e6d283f8a59c53562caf1b/numpy-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cf6470d91d34bf669f61d515499859fa7a4c2f7c36434afb70e82df7217933f9", size = 18295342, upload-time = "2026-01-10T06:42:56.991Z" }, + { url = "https://files.pythonhosted.org/packages/31/4c/14cb9d86240bd8c386c881bafbe43f001284b7cce3bc01623ac9475da163/numpy-2.4.1-cp312-cp312-win32.whl", hash = "sha256:b6bcf39112e956594b3331316d90c90c90fb961e39696bda97b89462f5f3943f", size = 5959015, upload-time = "2026-01-10T06:42:59.631Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/52a703dbeb0c65807540d29699fef5fda073434ff61846a564d5c296420f/numpy-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:e1a27bb1b2dee45a2a53f5ca6ff2d1a7f135287883a1689e930d44d1ff296c87", size = 12310730, upload-time = "2026-01-10T06:43:01.627Z" }, + { url = "https://files.pythonhosted.org/packages/69/80/a828b2d0ade5e74a9fe0f4e0a17c30fdc26232ad2bc8c9f8b3197cf7cf18/numpy-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:0e6e8f9d9ecf95399982019c01223dc130542960a12edfa8edd1122dfa66a8a8", size = 10312166, upload-time = "2026-01-10T06:43:03.673Z" }, + { url = "https://files.pythonhosted.org/packages/04/68/732d4b7811c00775f3bd522a21e8dd5a23f77eb11acdeb663e4a4ebf0ef4/numpy-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d797454e37570cfd61143b73b8debd623c3c0952959adb817dd310a483d58a1b", size = 16652495, upload-time = "2026-01-10T06:43:06.283Z" }, + { url = "https://files.pythonhosted.org/packages/20/ca/857722353421a27f1465652b2c66813eeeccea9d76d5f7b74b99f298e60e/numpy-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82c55962006156aeef1629b953fd359064aa47e4d82cfc8e67f0918f7da3344f", size = 12368657, upload-time = "2026-01-10T06:43:09.094Z" }, + { url = "https://files.pythonhosted.org/packages/81/0d/2377c917513449cc6240031a79d30eb9a163d32a91e79e0da47c43f2c0c8/numpy-2.4.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:71abbea030f2cfc3092a0ff9f8c8fdefdc5e0bf7d9d9c99663538bb0ecdac0b9", size = 5197256, upload-time = "2026-01-10T06:43:13.634Z" }, + { url = "https://files.pythonhosted.org/packages/17/39/569452228de3f5de9064ac75137082c6214be1f5c532016549a7923ab4b5/numpy-2.4.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:5b55aa56165b17aaf15520beb9cbd33c9039810e0d9643dd4379e44294c7303e", size = 6545212, upload-time = "2026-01-10T06:43:15.661Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a4/77333f4d1e4dac4395385482557aeecf4826e6ff517e32ca48e1dafbe42a/numpy-2.4.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0faba4a331195bfa96f93dd9dfaa10b2c7aa8cda3a02b7fd635e588fe821bf5", size = 14402871, upload-time = "2026-01-10T06:43:17.324Z" }, + { url = "https://files.pythonhosted.org/packages/ba/87/d341e519956273b39d8d47969dd1eaa1af740615394fe67d06f1efa68773/numpy-2.4.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e3087f53e2b4428766b54932644d148613c5a595150533ae7f00dab2f319a8", size = 16359305, upload-time = "2026-01-10T06:43:19.376Z" }, + { url = "https://files.pythonhosted.org/packages/32/91/789132c6666288eaa20ae8066bb99eba1939362e8f1a534949a215246e97/numpy-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:49e792ec351315e16da54b543db06ca8a86985ab682602d90c60ef4ff4db2a9c", size = 16181909, upload-time = "2026-01-10T06:43:21.808Z" }, + { url = "https://files.pythonhosted.org/packages/cf/b8/090b8bd27b82a844bb22ff8fdf7935cb1980b48d6e439ae116f53cdc2143/numpy-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:79e9e06c4c2379db47f3f6fc7a8652e7498251789bf8ff5bd43bf478ef314ca2", size = 18284380, upload-time = "2026-01-10T06:43:23.957Z" }, + { url = "https://files.pythonhosted.org/packages/67/78/722b62bd31842ff029412271556a1a27a98f45359dea78b1548a3a9996aa/numpy-2.4.1-cp313-cp313-win32.whl", hash = "sha256:3d1a100e48cb266090a031397863ff8a30050ceefd798f686ff92c67a486753d", size = 5957089, upload-time = "2026-01-10T06:43:27.535Z" }, + { url = "https://files.pythonhosted.org/packages/da/a6/cf32198b0b6e18d4fbfa9a21a992a7fca535b9bb2b0cdd217d4a3445b5ca/numpy-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:92a0e65272fd60bfa0d9278e0484c2f52fe03b97aedc02b357f33fe752c52ffb", size = 12307230, upload-time = "2026-01-10T06:43:29.298Z" }, + { url = "https://files.pythonhosted.org/packages/44/6c/534d692bfb7d0afe30611320c5fb713659dcb5104d7cc182aff2aea092f5/numpy-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:20d4649c773f66cc2fc36f663e091f57c3b7655f936a4c681b4250855d1da8f5", size = 10313125, upload-time = "2026-01-10T06:43:31.782Z" }, + { url = "https://files.pythonhosted.org/packages/da/a1/354583ac5c4caa566de6ddfbc42744409b515039e085fab6e0ff942e0df5/numpy-2.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f93bc6892fe7b0663e5ffa83b61aab510aacffd58c16e012bb9352d489d90cb7", size = 12496156, upload-time = "2026-01-10T06:43:34.237Z" }, + { url = "https://files.pythonhosted.org/packages/51/b0/42807c6e8cce58c00127b1dc24d365305189991f2a7917aa694a109c8d7d/numpy-2.4.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:178de8f87948163d98a4c9ab5bee4ce6519ca918926ec8df195af582de28544d", size = 5324663, upload-time = "2026-01-10T06:43:36.211Z" }, + { url = "https://files.pythonhosted.org/packages/fe/55/7a621694010d92375ed82f312b2f28017694ed784775269115323e37f5e2/numpy-2.4.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:98b35775e03ab7f868908b524fc0a84d38932d8daf7b7e1c3c3a1b6c7a2c9f15", size = 6645224, upload-time = "2026-01-10T06:43:37.884Z" }, + { url = "https://files.pythonhosted.org/packages/50/96/9fa8635ed9d7c847d87e30c834f7109fac5e88549d79ef3324ab5c20919f/numpy-2.4.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:941c2a93313d030f219f3a71fd3d91a728b82979a5e8034eb2e60d394a2b83f9", size = 14462352, upload-time = "2026-01-10T06:43:39.479Z" }, + { url = "https://files.pythonhosted.org/packages/03/d1/8cf62d8bb2062da4fb82dd5d49e47c923f9c0738032f054e0a75342faba7/numpy-2.4.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:529050522e983e00a6c1c6b67411083630de8b57f65e853d7b03d9281b8694d2", size = 16407279, upload-time = "2026-01-10T06:43:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/86/1c/95c86e17c6b0b31ce6ef219da00f71113b220bcb14938c8d9a05cee0ff53/numpy-2.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2302dc0224c1cbc49bb94f7064f3f923a971bfae45c33870dcbff63a2a550505", size = 16248316, upload-time = "2026-01-10T06:43:44.121Z" }, + { url = "https://files.pythonhosted.org/packages/30/b4/e7f5ff8697274c9d0fa82398b6a372a27e5cef069b37df6355ccb1f1db1a/numpy-2.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:9171a42fcad32dcf3fa86f0a4faa5e9f8facefdb276f54b8b390d90447cff4e2", size = 18329884, upload-time = "2026-01-10T06:43:46.613Z" }, + { url = "https://files.pythonhosted.org/packages/37/a4/b073f3e9d77f9aec8debe8ca7f9f6a09e888ad1ba7488f0c3b36a94c03ac/numpy-2.4.1-cp313-cp313t-win32.whl", hash = "sha256:382ad67d99ef49024f11d1ce5dcb5ad8432446e4246a4b014418ba3a1175a1f4", size = 6081138, upload-time = "2026-01-10T06:43:48.854Z" }, + { url = "https://files.pythonhosted.org/packages/16/16/af42337b53844e67752a092481ab869c0523bc95c4e5c98e4dac4e9581ac/numpy-2.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:62fea415f83ad8fdb6c20840578e5fbaf5ddd65e0ec6c3c47eda0f69da172510", size = 12447478, upload-time = "2026-01-10T06:43:50.476Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f8/fa85b2eac68ec631d0b631abc448552cb17d39afd17ec53dcbcc3537681a/numpy-2.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a7870e8c5fc11aef57d6fea4b4085e537a3a60ad2cdd14322ed531fdca68d261", size = 10382981, upload-time = "2026-01-10T06:43:52.575Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a7/ef08d25698e0e4b4efbad8d55251d20fe2a15f6d9aa7c9b30cd03c165e6f/numpy-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3869ea1ee1a1edc16c29bbe3a2f2a4e515cc3a44d43903ad41e0cacdbaf733dc", size = 16652046, upload-time = "2026-01-10T06:43:54.797Z" }, + { url = "https://files.pythonhosted.org/packages/8f/39/e378b3e3ca13477e5ac70293ec027c438d1927f18637e396fe90b1addd72/numpy-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e867df947d427cdd7a60e3e271729090b0f0df80f5f10ab7dd436f40811699c3", size = 12378858, upload-time = "2026-01-10T06:43:57.099Z" }, + { url = "https://files.pythonhosted.org/packages/c3/74/7ec6154f0006910ed1fdbb7591cf4432307033102b8a22041599935f8969/numpy-2.4.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e3bd2cb07841166420d2fa7146c96ce00cb3410664cbc1a6be028e456c4ee220", size = 5207417, upload-time = "2026-01-10T06:43:59.037Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b7/053ac11820d84e42f8feea5cb81cc4fcd1091499b45b1ed8c7415b1bf831/numpy-2.4.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:f0a90aba7d521e6954670550e561a4cb925713bd944445dbe9e729b71f6cabee", size = 6542643, upload-time = "2026-01-10T06:44:01.852Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c4/2e7908915c0e32ca636b92e4e4a3bdec4cb1e7eb0f8aedf1ed3c68a0d8cd/numpy-2.4.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d558123217a83b2d1ba316b986e9248a1ed1971ad495963d555ccd75dcb1556", size = 14418963, upload-time = "2026-01-10T06:44:04.047Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c0/3ed5083d94e7ffd7c404e54619c088e11f2e1939a9544f5397f4adb1b8ba/numpy-2.4.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f44de05659b67d20499cbc96d49f2650769afcb398b79b324bb6e297bfe3844", size = 16363811, upload-time = "2026-01-10T06:44:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/0e/68/42b66f1852bf525050a67315a4fb94586ab7e9eaa541b1bef530fab0c5dd/numpy-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:69e7419c9012c4aaf695109564e3387f1259f001b4326dfa55907b098af082d3", size = 16197643, upload-time = "2026-01-10T06:44:08.33Z" }, + { url = "https://files.pythonhosted.org/packages/d2/40/e8714fc933d85f82c6bfc7b998a0649ad9769a32f3494ba86598aaf18a48/numpy-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ffd257026eb1b34352e749d7cc1678b5eeec3e329ad8c9965a797e08ccba205", size = 18289601, upload-time = "2026-01-10T06:44:10.841Z" }, + { url = "https://files.pythonhosted.org/packages/80/9a/0d44b468cad50315127e884802351723daca7cf1c98d102929468c81d439/numpy-2.4.1-cp314-cp314-win32.whl", hash = "sha256:727c6c3275ddefa0dc078524a85e064c057b4f4e71ca5ca29a19163c607be745", size = 6005722, upload-time = "2026-01-10T06:44:13.332Z" }, + { url = "https://files.pythonhosted.org/packages/7e/bb/c6513edcce5a831810e2dddc0d3452ce84d208af92405a0c2e58fd8e7881/numpy-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:7d5d7999df434a038d75a748275cd6c0094b0ecdb0837342b332a82defc4dc4d", size = 12438590, upload-time = "2026-01-10T06:44:15.006Z" }, + { url = "https://files.pythonhosted.org/packages/e9/da/a598d5cb260780cf4d255102deba35c1d072dc028c4547832f45dd3323a8/numpy-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:ce9ce141a505053b3c7bce3216071f3bf5c182b8b28930f14cd24d43932cd2df", size = 10596180, upload-time = "2026-01-10T06:44:17.386Z" }, + { url = "https://files.pythonhosted.org/packages/de/bc/ea3f2c96fcb382311827231f911723aeff596364eb6e1b6d1d91128aa29b/numpy-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e53170557d37ae404bf8d542ca5b7c629d6efa1117dac6a83e394142ea0a43f", size = 12498774, upload-time = "2026-01-10T06:44:19.467Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ab/ef9d939fe4a812648c7a712610b2ca6140b0853c5efea361301006c02ae5/numpy-2.4.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:a73044b752f5d34d4232f25f18160a1cc418ea4507f5f11e299d8ac36875f8a0", size = 5327274, upload-time = "2026-01-10T06:44:23.189Z" }, + { url = "https://files.pythonhosted.org/packages/bd/31/d381368e2a95c3b08b8cf7faac6004849e960f4a042d920337f71cef0cae/numpy-2.4.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:fb1461c99de4d040666ca0444057b06541e5642f800b71c56e6ea92d6a853a0c", size = 6648306, upload-time = "2026-01-10T06:44:25.012Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e5/0989b44ade47430be6323d05c23207636d67d7362a1796ccbccac6773dd2/numpy-2.4.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423797bdab2eeefbe608d7c1ec7b2b4fd3c58d51460f1ee26c7500a1d9c9ee93", size = 14464653, upload-time = "2026-01-10T06:44:26.706Z" }, + { url = "https://files.pythonhosted.org/packages/10/a7/cfbe475c35371cae1358e61f20c5f075badc18c4797ab4354140e1d283cf/numpy-2.4.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:52b5f61bdb323b566b528899cc7db2ba5d1015bda7ea811a8bcf3c89c331fa42", size = 16405144, upload-time = "2026-01-10T06:44:29.378Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a3/0c63fe66b534888fa5177cc7cef061541064dbe2b4b60dcc60ffaf0d2157/numpy-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42d7dd5fa36d16d52a84f821eb96031836fd405ee6955dd732f2023724d0aa01", size = 16247425, upload-time = "2026-01-10T06:44:31.721Z" }, + { url = "https://files.pythonhosted.org/packages/6b/2b/55d980cfa2c93bd40ff4c290bf824d792bd41d2fe3487b07707559071760/numpy-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7b6b5e28bbd47b7532698e5db2fe1db693d84b58c254e4389d99a27bb9b8f6b", size = 18330053, upload-time = "2026-01-10T06:44:34.617Z" }, + { url = "https://files.pythonhosted.org/packages/23/12/8b5fc6b9c487a09a7957188e0943c9ff08432c65e34567cabc1623b03a51/numpy-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:5de60946f14ebe15e713a6f22850c2372fa72f4ff9a432ab44aa90edcadaa65a", size = 6152482, upload-time = "2026-01-10T06:44:36.798Z" }, + { url = "https://files.pythonhosted.org/packages/00/a5/9f8ca5856b8940492fc24fbe13c1bc34d65ddf4079097cf9e53164d094e1/numpy-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:8f085da926c0d491ffff3096f91078cc97ea67e7e6b65e490bc8dcda65663be2", size = 12627117, upload-time = "2026-01-10T06:44:38.828Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0d/eca3d962f9eef265f01a8e0d20085c6dd1f443cbffc11b6dede81fd82356/numpy-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6436cffb4f2bf26c974344439439c95e152c9a527013f26b3577be6c2ca64295", size = 10667121, upload-time = "2026-01-10T06:44:41.644Z" }, + { url = "https://files.pythonhosted.org/packages/1e/48/d86f97919e79314a1cdee4c832178763e6e98e623e123d0bada19e92c15a/numpy-2.4.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:8ad35f20be147a204e28b6a0575fbf3540c5e5f802634d4258d55b1ff5facce1", size = 16822202, upload-time = "2026-01-10T06:44:43.738Z" }, + { url = "https://files.pythonhosted.org/packages/51/e9/1e62a7f77e0f37dcfb0ad6a9744e65df00242b6ea37dfafb55debcbf5b55/numpy-2.4.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:8097529164c0f3e32bb89412a0905d9100bf434d9692d9fc275e18dcf53c9344", size = 12569985, upload-time = "2026-01-10T06:44:45.945Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7e/914d54f0c801342306fdcdce3e994a56476f1b818c46c47fc21ae968088c/numpy-2.4.1-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:ea66d2b41ca4a1630aae5507ee0a71647d3124d1741980138aa8f28f44dac36e", size = 5398484, upload-time = "2026-01-10T06:44:48.012Z" }, + { url = "https://files.pythonhosted.org/packages/1c/d8/9570b68584e293a33474e7b5a77ca404f1dcc655e40050a600dee81d27fb/numpy-2.4.1-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:d3f8f0df9f4b8be57b3bf74a1d087fec68f927a2fab68231fdb442bf2c12e426", size = 6713216, upload-time = "2026-01-10T06:44:49.725Z" }, + { url = "https://files.pythonhosted.org/packages/33/9b/9dd6e2db8d49eb24f86acaaa5258e5f4c8ed38209a4ee9de2d1a0ca25045/numpy-2.4.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2023ef86243690c2791fd6353e5b4848eedaa88ca8a2d129f462049f6d484696", size = 14538937, upload-time = "2026-01-10T06:44:51.498Z" }, + { url = "https://files.pythonhosted.org/packages/53/87/d5bd995b0f798a37105b876350d346eea5838bd8f77ea3d7a48392f3812b/numpy-2.4.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8361ea4220d763e54cff2fbe7d8c93526b744f7cd9ddab47afeff7e14e8503be", size = 16479830, upload-time = "2026-01-10T06:44:53.931Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c7/b801bf98514b6ae6475e941ac05c58e6411dd863ea92916bfd6d510b08c1/numpy-2.4.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:4f1b68ff47680c2925f8063402a693ede215f0257f02596b1318ecdfb1d79e33", size = 12492579, upload-time = "2026-01-10T06:44:57.094Z" }, ] [[package]] @@ -1310,6 +1277,7 @@ name = "ocrmypdf" source = { editable = "." } dependencies = [ { name = "deprecation" }, + { name = "fpdf2" }, { name = "img2pdf" }, { name = "packaging" }, { name = "pdfminer-six" }, @@ -1317,34 +1285,16 @@ dependencies = [ { name = "pikepdf" }, { name = "pillow" }, { name = "pluggy" }, + { name = "pydantic" }, + { name = "pypdfium2" }, { name = "rich" }, + { name = "uharfbuzz" }, ] [package.optional-dependencies] -docs = [ - { name = "myst-parser" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "sphinx-issues" }, - { name = "sphinx-rtd-theme" }, -] -extended-test = [ - { name = "pymupdf" }, -] -test = [ - { name = "coverage", extra = ["toml"] }, - { name = "hypothesis" }, - { name = "pytest" }, - { name = "pytest-cov" }, - { name = "pytest-xdist" }, - { name = "python-xmp-toolkit" }, - { name = "reportlab" }, - { name = "types-humanfriendly" }, - { name = "types-pillow" }, -] watcher = [ + { name = "cyclopts" }, { name = "python-dotenv" }, - { name = "typer-slim", extra = ["standard"] }, { name = "watchdog" }, ] webservice = [ @@ -1355,51 +1305,84 @@ webservice = [ dev = [ { name = "ipykernel" }, { name = "mypy" }, - { name = "pymupdf" }, + { name = "reportlab" }, +] +docs = [ + { name = "myst-parser" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "sphinx-issues" }, + { name = "sphinx-rtd-theme" }, + { name = "sphinxcontrib-mermaid" }, +] +streamlit-dev = [ { name = "streamlit" }, { name = "streamlit-pdf-viewer" }, ] +test = [ + { name = "coverage", extra = ["toml"] }, + { name = "hypothesis" }, + { name = "pymupdf" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-xdist" }, + { name = "python-xmp-toolkit" }, + { name = "reportlab" }, + { name = "types-humanfriendly" }, + { name = "types-pillow" }, +] [package.metadata] requires-dist = [ - { name = "coverage", extras = ["toml"], marker = "extra == 'test'", specifier = ">=6.2" }, + { name = "cyclopts", marker = "extra == 'watcher'", specifier = ">=3" }, { name = "deprecation", specifier = ">=2.1.0" }, - { name = "hypothesis", marker = "extra == 'test'", specifier = ">=6.36.0" }, + { name = "fpdf2", specifier = ">=2.8.0" }, { name = "img2pdf", specifier = ">=0.5" }, - { name = "myst-parser", marker = "extra == 'docs'", specifier = ">=4.0.1" }, { name = "packaging", specifier = ">=20" }, { name = "pdfminer-six", specifier = ">=20220319" }, { name = "pi-heif" }, { name = "pikepdf", specifier = ">=10" }, { name = "pillow", specifier = ">=10.0.1" }, { name = "pluggy", specifier = ">=1" }, - { name = "pymupdf", marker = "extra == 'extended-test'", specifier = ">=1.19.1" }, - { name = "pytest", marker = "extra == 'test'", specifier = ">=6.2.5" }, - { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=3.0.0" }, - { name = "pytest-xdist", marker = "extra == 'test'", specifier = ">=2.5.0" }, + { name = "pydantic", specifier = ">=2.12.5" }, + { name = "pypdfium2", specifier = ">=5.0.0" }, { name = "python-dotenv", marker = "extra == 'watcher'" }, - { name = "python-xmp-toolkit", marker = "extra == 'test'", specifier = "==2.0.1" }, - { name = "reportlab", marker = "extra == 'test'", specifier = ">=3.6.8" }, { name = "rich", specifier = ">=13" }, - { name = "sphinx", marker = "extra == 'docs'" }, - { name = "sphinx-issues", marker = "extra == 'docs'" }, - { name = "sphinx-rtd-theme", marker = "extra == 'docs'" }, { name = "streamlit", marker = "extra == 'webservice'", specifier = ">=1.41.0" }, - { name = "typer-slim", extras = ["standard"], marker = "extra == 'watcher'" }, - { name = "types-humanfriendly", marker = "extra == 'test'" }, - { name = "types-pillow", marker = "extra == 'test'" }, + { name = "uharfbuzz", specifier = ">=0.53.2" }, { name = "watchdog", marker = "extra == 'watcher'", specifier = ">=1.0.2" }, ] -provides-extras = ["docs", "extended-test", "test", "watcher", "webservice"] +provides-extras = ["watcher", "webservice"] [package.metadata.requires-dev] dev = [ { name = "ipykernel", specifier = ">=6.29.5" }, { name = "mypy", specifier = ">=1.13.0" }, - { name = "pymupdf", specifier = ">=1.24.14" }, + { name = "reportlab", specifier = ">=4.4.4" }, +] +docs = [ + { name = "myst-parser", specifier = ">=4.0.1" }, + { name = "sphinx" }, + { name = "sphinx-issues" }, + { name = "sphinx-rtd-theme" }, + { name = "sphinxcontrib-mermaid" }, +] +streamlit-dev = [ { name = "streamlit", specifier = ">=1.40.2" }, { name = "streamlit-pdf-viewer", specifier = ">=0.0.19" }, ] +test = [ + { name = "coverage", extras = ["toml"], specifier = ">=6.2" }, + { name = "hypothesis", specifier = ">=6.36.0" }, + { name = "pymupdf", specifier = ">=1.24.14" }, + { name = "pytest", specifier = ">=6.2.5" }, + { name = "pytest-cov", specifier = ">=3.0.0" }, + { name = "pytest-xdist", specifier = ">=2.5.0" }, + { name = "python-xmp-toolkit", specifier = "==2.0.1" }, + { name = "reportlab", specifier = ">=3.6.8" }, + { name = "types-humanfriendly" }, + { name = "types-pillow" }, +] [[package]] name = "packaging" @@ -1415,21 +1398,13 @@ name = "pandas" version = "2.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.3.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy" }, { name = "python-dateutil" }, { name = "pytz" }, { name = "tzdata" }, ] sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/f7/f425a00df4fcc22b292c6895c6831c0c8ae1d9fac1e024d16f98a9ce8749/pandas-2.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:376c6446ae31770764215a6c937f72d917f214b43560603cd60da6408f183b6c", size = 11555763, upload-time = "2025-09-29T23:16:53.287Z" }, - { url = "https://files.pythonhosted.org/packages/13/4f/66d99628ff8ce7857aca52fed8f0066ce209f96be2fede6cef9f84e8d04f/pandas-2.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e19d192383eab2f4ceb30b412b22ea30690c9e618f78870357ae1d682912015a", size = 10801217, upload-time = "2025-09-29T23:17:04.522Z" }, - { url = "https://files.pythonhosted.org/packages/1d/03/3fc4a529a7710f890a239cc496fc6d50ad4a0995657dccc1d64695adb9f4/pandas-2.3.3-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5caf26f64126b6c7aec964f74266f435afef1c1b13da3b0636c7518a1fa3e2b1", size = 12148791, upload-time = "2025-09-29T23:17:18.444Z" }, - { url = "https://files.pythonhosted.org/packages/40/a8/4dac1f8f8235e5d25b9955d02ff6f29396191d4e665d71122c3722ca83c5/pandas-2.3.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd7478f1463441ae4ca7308a70e90b33470fa593429f9d4c578dd00d1fa78838", size = 12769373, upload-time = "2025-09-29T23:17:35.846Z" }, - { url = "https://files.pythonhosted.org/packages/df/91/82cc5169b6b25440a7fc0ef3a694582418d875c8e3ebf796a6d6470aa578/pandas-2.3.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4793891684806ae50d1288c9bae9330293ab4e083ccd1c5e383c34549c6e4250", size = 13200444, upload-time = "2025-09-29T23:17:49.341Z" }, - { url = "https://files.pythonhosted.org/packages/10/ae/89b3283800ab58f7af2952704078555fa60c807fff764395bb57ea0b0dbd/pandas-2.3.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:28083c648d9a99a5dd035ec125d42439c6c1c525098c58af0fc38dd1a7a1b3d4", size = 13858459, upload-time = "2025-09-29T23:18:03.722Z" }, - { url = "https://files.pythonhosted.org/packages/85/72/530900610650f54a35a19476eca5104f38555afccda1aa11a92ee14cb21d/pandas-2.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:503cf027cf9940d2ceaa1a93cfb5f8c8c7e6e90720a2850378f0b3f3b1e06826", size = 11346086, upload-time = "2025-09-29T23:18:18.505Z" }, { url = "https://files.pythonhosted.org/packages/c1/fa/7ac648108144a095b4fb6aa3de1954689f7af60a14cf25583f4960ecb878/pandas-2.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:602b8615ebcc4a0c1751e71840428ddebeb142ec02c786e8ad6b1ce3c8dec523", size = 11578790, upload-time = "2025-09-29T23:18:30.065Z" }, { url = "https://files.pythonhosted.org/packages/9b/35/74442388c6cf008882d4d4bdfc4109be87e9b8b7ccd097ad1e7f006e2e95/pandas-2.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8fe25fc7b623b0ef6b5009149627e34d2a4657e880948ec3c840e9402e5c1b45", size = 10833831, upload-time = "2025-09-29T23:38:56.071Z" }, { url = "https://files.pythonhosted.org/packages/fe/e4/de154cbfeee13383ad58d23017da99390b91d73f8c11856f2095e813201b/pandas-2.3.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b468d3dad6ff947df92dcb32ede5b7bd41a9b3cceef0a30ed925f6d01fb8fa66", size = 12199267, upload-time = "2025-09-29T23:18:41.627Z" }, @@ -1483,24 +1458,24 @@ wheels = [ [[package]] name = "pathspec" -version = "0.12.1" +version = "1.0.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/b2/bb8e495d5262bfec41ab5cb18f522f1012933347fb5d9e62452d446baca2/pathspec-1.0.3.tar.gz", hash = "sha256:bac5cf97ae2c2876e2d25ebb15078eb04d76e4b98921ee31c6f85ade8b59444d", size = 130841, upload-time = "2026-01-09T15:46:46.009Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, + { url = "https://files.pythonhosted.org/packages/32/2b/121e912bd60eebd623f873fd090de0e84f322972ab25a7f9044c056804ed/pathspec-1.0.3-py3-none-any.whl", hash = "sha256:e80767021c1cc524aa3fb14bedda9c34406591343cc42797b386ce7b9354fb6c", size = 55021, upload-time = "2026-01-09T15:46:44.652Z" }, ] [[package]] name = "pdfminer-six" -version = "20251107" +version = "20260107" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "charset-normalizer" }, { name = "cryptography" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1d/50/5315f381a25dc80a8d2ea7c62d9a28c0137f10ccc263623a0db8b49fcced/pdfminer_six-20251107.tar.gz", hash = "sha256:5fb0c553799c591777f22c0c72b77fc2522d7d10c70654e25f4c5f1fd996e008", size = 7387104, upload-time = "2025-11-07T20:01:10.286Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/a4/5cec1112009f0439a5ca6afa8ace321f0ab2f48da3255b7a1c8953014670/pdfminer_six-20260107.tar.gz", hash = "sha256:96bfd431e3577a55a0efd25676968ca4ce8fd5b53f14565f85716ff363889602", size = 8512094, upload-time = "2026-01-07T13:29:12.937Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/64/29/d1d9f6b900191288b77613ddefb73ed35b48fb35e44aaf8b01b0422b759d/pdfminer_six-20251107-py3-none-any.whl", hash = "sha256:c09df33e4cbe6b26b2a79248a4ffcccafaa5c5d39c9fff0e6e81567f165b5401", size = 5620299, upload-time = "2025-11-07T20:01:08.722Z" }, + { url = "https://files.pythonhosted.org/packages/20/8b/28c4eaec9d6b036a52cb44720408f26b1a143ca9bce76cc19e8f5de00ab4/pdfminer_six-20260107-py3-none-any.whl", hash = "sha256:366585ba97e80dffa8f00cebe303d2f381884d8637af4ce422f1df3ef38111a9", size = 6592252, upload-time = "2026-01-07T13:29:10.742Z" }, ] [[package]] @@ -1524,13 +1499,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/bf/7b/7c7b2aeb4995906725f13b885884d5b22e4f2d55028e8941555d2789e5e7/pi_heif-1.1.1.tar.gz", hash = "sha256:42ece7c3b40569f295fd4d2b10f38d1cd5012ca548446a2ca33895f0d6900c4f", size = 18269861, upload-time = "2025-09-30T16:43:33.742Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/90/0e/bbbce44addb85c8ee9b67fb6d7ed91cf140b47c973f62adc97fcfb5e3424/pi_heif-1.1.1-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:b9b0527b83aac6fb2767dc32e3164946d53bac17983066be250c051fb832ef97", size = 1026889, upload-time = "2025-09-30T16:42:27.042Z" }, - { url = "https://files.pythonhosted.org/packages/28/93/c16b358c03f9c41e8500fcd16e108f02982b40574ba723d240e8b599d1dd/pi_heif-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4a1a32d6fd079c76530347095e8f692a824394696df671c13cbc4b658d89f5c2", size = 892290, upload-time = "2025-09-30T16:42:28.601Z" }, - { url = "https://files.pythonhosted.org/packages/98/c7/9feb9b615a025ad1e7b5a6b45cfa7527cf0ddabd1abc1161c32a2c72a342/pi_heif-1.1.1-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7517e46e2349c1a36aa966791eec9920b24f31fef97941bd42201b2dd91d89cd", size = 1292871, upload-time = "2025-09-30T16:42:29.975Z" }, - { url = "https://files.pythonhosted.org/packages/73/9e/2a7a68a18c080b2f3619e340e47ccd3714358c05d7e361a0ad8cdf22ca76/pi_heif-1.1.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a55a3ac46c75c478774b80ffa090b88199eedc79d2eb8acfac744a355200a777", size = 1416754, upload-time = "2025-09-30T16:42:32.461Z" }, - { url = "https://files.pythonhosted.org/packages/08/ab/9802a078c8acee45b042fdc3aa5c9cde4d452f0ce60564a8e3aee5eafb9e/pi_heif-1.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9268d9ece57796a0b42c8cb77c0cdc6d5ace9d40c4e5f62d2cfefd4f5267edf9", size = 2273713, upload-time = "2025-09-30T16:42:33.656Z" }, - { url = "https://files.pythonhosted.org/packages/c2/aa/5c89fd825f90f1236e51c7b2217a1bdbb83ecf7bf5727510bd55959f502c/pi_heif-1.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c6cf1af1a376dbb059b49f41fa8562e307cad35d8c22006a0c0b1ffa06d62edc", size = 2432356, upload-time = "2025-09-30T16:42:34.702Z" }, - { url = "https://files.pythonhosted.org/packages/c4/89/abe85f594b9941e63885b54001158dfcb0003bff5b13bc2edd7735e87902/pi_heif-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:aff3d92eec6e3bbf15383fbfd030d05c09b3d1582fd73013d906d58f5cc6f86e", size = 1887708, upload-time = "2025-09-30T16:42:36.348Z" }, { url = "https://files.pythonhosted.org/packages/eb/fa/ca7ea668a0b7f8af306ac536c198e510e344d145b229d76399e401984199/pi_heif-1.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:8858d7138380513175f91d1aca5ca9428e3da9016aaa06fd3b121a6f16c224f8", size = 1026893, upload-time = "2025-09-30T16:42:37.486Z" }, { url = "https://files.pythonhosted.org/packages/6e/2c/eedc87c37b3eb96cb87c3cda547efee7dbb91dd91c9e11be34a1a393b458/pi_heif-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e76543341fe149a9c8bc0244dfb2d2492820bfdc96d1993e6b0afbd6e1b67ce6", size = 892292, upload-time = "2025-09-30T16:42:38.538Z" }, { url = "https://files.pythonhosted.org/packages/7b/b4/38359f55ddc808311929004a4259e2fb7f542845289e4265269ca01c4af2/pi_heif-1.1.1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cefa2b67341cab289542b12d9811fe31fab67bca3d02c42669ccf050bfe6c277", size = 1294741, upload-time = "2025-09-30T16:42:39.52Z" }, @@ -1559,11 +1527,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/78/7fe775f2c56a3ef2edbffffd191748fe6e5636a0e7e7b7b3c7b7ab643d76/pi_heif-1.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ab123ca032517908c77485ca326a70ed3404adc20862d64055d726ee3f499351", size = 2274352, upload-time = "2025-09-30T16:43:05.117Z" }, { url = "https://files.pythonhosted.org/packages/4b/e2/60102499d884af9f0eac046309588bdc5032522d51ae4c451fa33b468d11/pi_heif-1.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:50da83f4061ce7b77b133c731c68e94f51ff3e9f04af562aa773b90795be3c23", size = 2433267, upload-time = "2025-09-30T16:43:06.437Z" }, { url = "https://files.pythonhosted.org/packages/2c/00/9de97f4b1fcf35a04aefef7b3dbf4aaeb9d9ae4661aeb69e7cb137bbeb03/pi_heif-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:d4a8021a7322e9910db88e7819335b32c276015ad5def6a2f63c429738f69f0f", size = 1953959, upload-time = "2025-09-30T16:43:07.453Z" }, - { url = "https://files.pythonhosted.org/packages/36/6e/c8327fd5f56f21946f7c61bb2fbf49b12fba9e743f206eb549b6d2744403/pi_heif-1.1.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:e0c9a66dbd6586392a5072394e2b5e52a175305d7d3c8b2b4cf8b93fc466f68a", size = 1015658, upload-time = "2025-09-30T16:43:19.9Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f0/aff9c0ebd35cce80b4d816d553ef4df848b23e94b4605a755dc2ccea54db/pi_heif-1.1.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:3be62304ce5ed586aaaa4709fe757599b27125cc19aae960e3268323ddcd0598", size = 888826, upload-time = "2025-09-30T16:43:21.002Z" }, - { url = "https://files.pythonhosted.org/packages/a3/1a/22ae35a91fc466cea41ec0deae87feb8fc2296c0b6ef6bfba23c0c70ef82/pi_heif-1.1.1-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df51083969f61a30c9c8a2f5b99cb2c265094602c2bf1b6c968670515d7abb36", size = 1253347, upload-time = "2025-09-30T16:43:22.052Z" }, - { url = "https://files.pythonhosted.org/packages/85/83/0d87161ae8ccf5938cf8d0f42bba3d423726f7de7fc11f25d2632089ee39/pi_heif-1.1.1-pp310-pypy310_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9831627d537949be0483d41c9187b9b4a7e7d3317e6fccd6d92ae26cb5af4c30", size = 1374160, upload-time = "2025-09-30T16:43:23.132Z" }, - { url = "https://files.pythonhosted.org/packages/e4/61/4ad4a8ae343d9e8fa9a9725c08674d5e5f7f00d282d469e17128d5780b55/pi_heif-1.1.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c04517789c7002c8ed4cdf3dc0fe04d9fe58d317cf28c87cd054dcd655ee3992", size = 1888096, upload-time = "2025-09-30T16:43:24.481Z" }, { url = "https://files.pythonhosted.org/packages/8d/da/c0ca44092f9a1c3e9a0fc167b156155981e90527927bd3fd1d8c2b2cbe58/pi_heif-1.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ba3c2a0f0667831ff14e21d08ceb04bc6f58be520131f45894a4cec8dc4d4a24", size = 1015587, upload-time = "2025-09-30T16:43:25.539Z" }, { url = "https://files.pythonhosted.org/packages/ec/c2/237c186c9e6ffd555f5477499cab83625c753e186807dc8b2834df1c050b/pi_heif-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:1a9c89d64e3fc959725568ea129a0ab593a9ab0607b23495809edbf1f4713f9b", size = 888743, upload-time = "2025-09-30T16:43:26.802Z" }, { url = "https://files.pythonhosted.org/packages/f3/91/0a8368ce4c62f729a46a893cb753431a8efa4b4a06ad4fd03817371cb5f4/pi_heif-1.1.1-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d93836c944299370f8c948075e1c800870b2411a19b87d480a5f5a405158189", size = 1253364, upload-time = "2025-09-30T16:43:28.076Z" }, @@ -1573,7 +1536,7 @@ wheels = [ [[package]] name = "pikepdf" -version = "10.0.2" +version = "10.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "deprecated" }, @@ -1581,150 +1544,132 @@ dependencies = [ { name = "packaging" }, { name = "pillow" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f7/79/9a63d5ccac66ace679cf93c84894db15074fe849d41cd39232cb09ec8819/pikepdf-10.0.2.tar.gz", hash = "sha256:7c85a2526253e35575edb2e28cdc740d004be4b7c5fda954f0e721ee1c423a52", size = 4548116, upload-time = "2025-11-10T18:10:08.765Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/e9/a1462d6160805ca80c8f4aafc941aaf410a92d0fcc683706e94f499c2fac/pikepdf-10.2.0.tar.gz", hash = "sha256:0f398b0daeb2ffd2358f75c06f1dd47b9ba76f1a77dfe938cccf7080c58227d7", size = 4568506, upload-time = "2026-01-09T22:54:25.847Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/48/fa/ab2b88c097b4542663065631f0a1f693ed2a6e585cf92d6226267d0f66ad/pikepdf-10.0.2-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:2698975488753fd0d8d06bf2d15c809f2b0be7f34eb75f068161d7313f331b3b", size = 4673132, upload-time = "2025-11-10T18:08:54.191Z" }, - { url = "https://files.pythonhosted.org/packages/a7/04/20985de6520c5d7018fc7d2fd9d2804d9348d39df3c08d11f115c522fcde/pikepdf-10.0.2-cp310-cp310-macosx_15_0_x86_64.whl", hash = "sha256:e018fae8df61b2d5aa376ff381178f9d6930ee68d24b26a9a4409f8ff7c7cb1e", size = 4972194, upload-time = "2025-11-10T18:08:58.588Z" }, - { url = "https://files.pythonhosted.org/packages/97/8b/b32af8b69f475a736904d34e83e136bae0ca7fe221090cd36ee136112efc/pikepdf-10.0.2-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af565ad6ff5d96611a657c1e12e1429749f36271e7e368f82ba3a8a4635ac3dd", size = 2379539, upload-time = "2025-11-10T18:09:00.278Z" }, - { url = "https://files.pythonhosted.org/packages/b0/ca/79f9886ad5322b603adc823b2663bcfb51a2729b9feb14dcb270e94528dd/pikepdf-10.0.2-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d150903852630b89d67832e56d7fb0b2bfd0e228f269d498de5d28b53078db9", size = 2596832, upload-time = "2025-11-10T18:09:01.889Z" }, - { url = "https://files.pythonhosted.org/packages/09/52/2793b5dd95611614b96cde0f78736910506abdab87a7038d126093c8f0ed/pikepdf-10.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:99c9cc66689120eba0aec858256ab4c3661e792a1d2a6c07575090c9a54d5bf3", size = 3574220, upload-time = "2025-11-10T18:09:04.25Z" }, - { url = "https://files.pythonhosted.org/packages/82/be/b47271b5e13bb0c678118a132066c29a90bfd6788224e1a73fecf2eb548d/pikepdf-10.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b75a29c2adeb8ae0277c76d232f75517c2cbd23f43212f8daac267d25f1cc656", size = 3757893, upload-time = "2025-11-10T18:09:05.962Z" }, - { url = "https://files.pythonhosted.org/packages/c4/41/4647c2fcd7bec9599b72fa5375822f019ce6d3d81e1f9032716cb1052b20/pikepdf-10.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:eaaa6711e3b3f061d45c5b4b774ca5abaa35e58054bf1f368cabd22610f7bdc1", size = 3721884, upload-time = "2025-11-10T18:09:07.576Z" }, - { url = "https://files.pythonhosted.org/packages/b4/bc/baff13dff8422c13e37bcb4b53bd55764cce88c7f6d8e7ff43f2dcb4f4ee/pikepdf-10.0.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:92a801d90cf7cab88c750d964de30cfe06dc04449cb51c38a863af75caa8b8cb", size = 4675949, upload-time = "2025-11-10T18:09:09.827Z" }, - { url = "https://files.pythonhosted.org/packages/d5/0d/158efe9a1a160b244c071e1893f154dfd905148c545d5f88d216b7f32f89/pikepdf-10.0.2-cp311-cp311-macosx_15_0_x86_64.whl", hash = "sha256:2f2c58f5b39e3e87d34d3a596922210f63e730a78c2aa6201667881b2c41a878", size = 4974326, upload-time = "2025-11-10T18:09:11.848Z" }, - { url = "https://files.pythonhosted.org/packages/48/0e/b2b6007d500dd6b76b6cffc8ec9f869395e55e19521a2f5bf988043c8302/pikepdf-10.0.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de987c50205a316a31bc46e5e6a32592244895cb9a95186ba5bcd398272e7d69", size = 2387154, upload-time = "2025-11-10T18:09:15.787Z" }, - { url = "https://files.pythonhosted.org/packages/ef/e5/094989c2db7d778fe47343d0a4dff610228e10eae2426be8ed6feb0f43b3/pikepdf-10.0.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d3736dfeba9eaf0aa710317d4dbdaee32d55be18d36a1b29fab2e4361cb7ae0", size = 2606870, upload-time = "2025-11-10T18:09:18.048Z" }, - { url = "https://files.pythonhosted.org/packages/dc/6d/5b6561a546e4036f6fa203cd8c5afe0874fe272b2b1c82b2350519ac9e8c/pikepdf-10.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d3b1fd46081dbc0302ef058f0bdadefe8f6db1de00b332759ad7cd32efa2705", size = 3583199, upload-time = "2025-11-10T18:09:19.781Z" }, - { url = "https://files.pythonhosted.org/packages/bc/b7/9794e2127eedbc01db1232bea4bfa86e38513fa3c78cba4f9d2837c6f230/pikepdf-10.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7ca982269f3fe084a9267d323c377f0afa6de33b3fd2dca4df67289001fce042", size = 3768564, upload-time = "2025-11-10T18:09:22.693Z" }, - { url = "https://files.pythonhosted.org/packages/b5/66/0a1534697bf6e5f9c7be52c4f3c162d4c5e470a07f5ba434e36239167b49/pikepdf-10.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:9bfab6b02cbcdd659cc347f837c2f4598f9773da9347eebc6a461c069e4566e0", size = 3722342, upload-time = "2025-11-10T18:09:24.406Z" }, - { url = "https://files.pythonhosted.org/packages/42/03/ef096d5bccf70606fcd40ef3519e048028144ebdd5735092398d9cf0ee3f/pikepdf-10.0.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:0a5e798d6b0759bae1e30b8b05853b5c8d4016129db658f3a3e3320781ef2376", size = 4687898, upload-time = "2025-11-10T18:09:26.145Z" }, - { url = "https://files.pythonhosted.org/packages/40/27/cd69b14359772b3a447aa6687da3436fcdf16664be06ca88aef984453217/pikepdf-10.0.2-cp312-cp312-macosx_15_0_x86_64.whl", hash = "sha256:8d8ecc258f90cd1287f8527dd3b94aa178be7c0e04f313ae4182f21c24fd328d", size = 4985422, upload-time = "2025-11-10T18:09:27.955Z" }, - { url = "https://files.pythonhosted.org/packages/f9/cb/442ff1273ac155d1cd0f81fb7acf9848f8c4b595616ed8d2d846b9327e31/pikepdf-10.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ca4d5d1ca3f7af568e62cadf1c64238490b6503a512894c99e48042e3eb3648", size = 2395555, upload-time = "2025-11-10T18:09:30.129Z" }, - { url = "https://files.pythonhosted.org/packages/e5/d0/9d4ed596e5419dd4bc8c162f0337398dc1ddc94e2d7bf6f3d0fb6eef561b/pikepdf-10.0.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fa22c5496e14fa3e89b94117fe56494b895cd02e53842f60331ac4bc078f04c7", size = 2631976, upload-time = "2025-11-10T18:09:32.214Z" }, - { url = "https://files.pythonhosted.org/packages/86/59/205f83746288590f22d1ff8b43fc93b193ddeca26261c61b5621d03048a1/pikepdf-10.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:df502e0c6d0731bd3f98523e5d501d4a21b36844f0061ef25f2500b18766fb51", size = 3588060, upload-time = "2025-11-10T18:09:34.252Z" }, - { url = "https://files.pythonhosted.org/packages/b2/54/4e9c8b53f22a4e527cf1087c5b26e127aa028c51d81cf53b10f34c34db8d/pikepdf-10.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:98464b4d8f0340ab38575cc41acf9061e2639c90e3a25c491c3ef3d01b92899f", size = 3791776, upload-time = "2025-11-10T18:09:36.055Z" }, - { url = "https://files.pythonhosted.org/packages/43/5c/8f817caad9d6fa64f715bdce4ab8d7c97b725ffa0ca5499092c71256ef9a/pikepdf-10.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:af0b763b4c17a6724d51d110a66df291d33d66387b53d35ee3fd8ade44f4b7d6", size = 3727628, upload-time = "2025-11-10T18:09:37.828Z" }, - { url = "https://files.pythonhosted.org/packages/b3/f1/eabc9e780f9d0fe0316ce0185a1064d28b18c219fd8d1b0609205c18ac39/pikepdf-10.0.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f06c8dbf1f6cab87815b7ed0e4b1359da8665c4bb51a9fbdd71824c0b1bbb28c", size = 4687891, upload-time = "2025-11-10T18:09:40.003Z" }, - { url = "https://files.pythonhosted.org/packages/e7/3a/ce1cf39d9eac09885efa69cc6bbe537ec2b7a24beded2fd0d9de779513f4/pikepdf-10.0.2-cp313-cp313-macosx_15_0_x86_64.whl", hash = "sha256:c3d421c6d4ef1aa394d30c683bb18c436817467c4db8279a4ff0f9d0a96aa323", size = 4985564, upload-time = "2025-11-10T18:09:42.624Z" }, - { url = "https://files.pythonhosted.org/packages/2f/f2/91664c4a7bda3fd3240e99a8ea602bb674ae88eea5dd798fa54c763da7e5/pikepdf-10.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0487fa6f64c26baa3f7131a917c37bb1183364a213678c155184944ca711881d", size = 2396504, upload-time = "2025-11-10T18:09:44.622Z" }, - { url = "https://files.pythonhosted.org/packages/12/8f/84717f30989f81ba94188a0185791039b683ed4ab43003ab5114aa07f154/pikepdf-10.0.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:55378c0c1d7c32c32466809fc4c7a08efd2d6a0f75e22b25e3791ab33001382b", size = 2635392, upload-time = "2025-11-10T18:09:46.301Z" }, - { url = "https://files.pythonhosted.org/packages/0c/1c/960ff2fe0c11ab936db04202da6be136909757ecf131690b58d2aeef4d67/pikepdf-10.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cb5d7278cf9655eedde443f737ffab57eeaf81b5585094644759c368d28ac1bd", size = 3588629, upload-time = "2025-11-10T18:09:48.042Z" }, - { url = "https://files.pythonhosted.org/packages/04/56/9e6d87d20c520afb8afe28cddf37ddaa4a70aaf9aec2e25d53522a91d9c4/pikepdf-10.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8e460726e0753b0196a241f11f80c90a30fcaf3e7bc7d908cf85594890cb981c", size = 3792852, upload-time = "2025-11-10T18:09:50.213Z" }, - { url = "https://files.pythonhosted.org/packages/51/55/8ea2c9aa063d04127eb548469f19be1e50777d7954f8d327e87fc0a5fe69/pikepdf-10.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:dd8289a0a8b7352fe6b2090d4e2e50b105d41a45c38e28bff0750728f20a0182", size = 3727568, upload-time = "2025-11-10T18:09:52.215Z" }, - { url = "https://files.pythonhosted.org/packages/f2/92/ddc07c58bb0e99f6c7ed5cdec63ed305bf22d29f875fb7a5b626f3eca177/pikepdf-10.0.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:821592290f33943d0dbde294a56db2b777b7edf519dabbe77d1a8e99a96f96ea", size = 4683719, upload-time = "2025-11-10T18:09:53.991Z" }, - { url = "https://files.pythonhosted.org/packages/9b/05/f43a0dde38720c960910ea7c2916a608d6dcb42d4cdf00b6a202704a0e35/pikepdf-10.0.2-cp314-cp314-macosx_15_0_x86_64.whl", hash = "sha256:64f46f9db208cd4166b73da31e201258e47cb3c3645d9856782e9fa041503268", size = 4986050, upload-time = "2025-11-10T18:09:56.36Z" }, - { url = "https://files.pythonhosted.org/packages/2a/7d/2899179c383d6dd53c7f74a772f6df16f4d6dca84584f777ebf1e1fb3f34/pikepdf-10.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:434ae183af796288d918be2825f950947a9b329850008bb97743f3297529a537", size = 2402087, upload-time = "2025-11-10T18:09:58.877Z" }, - { url = "https://files.pythonhosted.org/packages/a8/9f/c6989364ffa8b44b2c581316a3e59dac4497591336e941171e80e27bf664/pikepdf-10.0.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e23617b12fceee49a5b17b0ad7921ef50eaf4e1d23babec893b8dbc498bb3f2", size = 2637508, upload-time = "2025-11-10T18:10:00.684Z" }, - { url = "https://files.pythonhosted.org/packages/96/9c/72846bf3454637450cca8cc9620dbb6d7df035ed3ff41b656c4a516e5495/pikepdf-10.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:09da390be9d8558b77372a016498fc3d8edc9e5fd3928557f6012cb29f9114fb", size = 3595197, upload-time = "2025-11-10T18:10:02.688Z" }, - { url = "https://files.pythonhosted.org/packages/77/2c/b8221889158a748e3e74edbba9590fcb6516605fa169cb786b3d81f71129/pikepdf-10.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9165ef885b657a0602623a996ea230a450e7774b48e9d7bf463f46b0dbc738df", size = 3796398, upload-time = "2025-11-10T18:10:04.826Z" }, - { url = "https://files.pythonhosted.org/packages/14/ad/098baf14ed3779bd3df4652e3b22dd8b5030024bf128afe5061a9a45774c/pikepdf-10.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:275e3ea2a99336d91b4105fc873e21e23139ad0fe6c7ae093a102700cd9fa3e8", size = 3832266, upload-time = "2025-11-10T18:10:06.804Z" }, + { url = "https://files.pythonhosted.org/packages/10/dc/aa7293763b603a9080ffab7ab87c7b571d637a389e9fb2ba839b864ca283/pikepdf-10.2.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:fb93732127d5183a91300af39e1cda5ded309e8439daec93536331a472b5e190", size = 4727891, upload-time = "2026-01-09T22:53:26.262Z" }, + { url = "https://files.pythonhosted.org/packages/d2/dc/700c31f2c14f94d92483b10e1918390948ed20f6f572d82beb78ac5f94d0/pikepdf-10.2.0-cp311-cp311-macosx_15_0_x86_64.whl", hash = "sha256:ab7bd4629539cf2136a799dc3eaa2dfda59937035a97b0c5e22a7a3a4033cc49", size = 5030510, upload-time = "2026-01-09T22:53:28.023Z" }, + { url = "https://files.pythonhosted.org/packages/23/46/dc63364b05aa1913f2d7480cad62676bfb473065ba4b02d314dfd482f7dd/pikepdf-10.2.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f5623a5ba456d69dfeb86dc3bb3ec31ec1d120382d8c24804d1b430fce715ea", size = 2439498, upload-time = "2026-01-09T22:53:30.122Z" }, + { url = "https://files.pythonhosted.org/packages/59/b6/1f9b8ca588fd34d9e3df49a80c62016e0b42ce6e580146c46d9728fdb6e8/pikepdf-10.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dec4d12f294df378d122ae441c27c1e76fb0d15b1e9d7374ae70c26604559bab", size = 2666945, upload-time = "2026-01-09T22:53:32.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/31/b1e61fac59f0b807edde655a821ff83bb041ae1500234c52ed1a2403c44a/pikepdf-10.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c0cebe3235232f1bd3c5f7956218ce92241c94223cb80eba837d372a40c61765", size = 3638109, upload-time = "2026-01-09T22:53:34.144Z" }, + { url = "https://files.pythonhosted.org/packages/02/e9/a99bbf503c9d55e54553edff84ec67cac49d335fc33f3d5516c4746b6340/pikepdf-10.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d0908e845c9140e245ad89a19fdfc6e5a6d82fcb505b8cc2c0ce81439ac4f064", size = 3829538, upload-time = "2026-01-09T22:53:36.341Z" }, + { url = "https://files.pythonhosted.org/packages/fa/16/e5f5c1f58ecaf568f987958358330ea1a0f7d5edc31fea3a4cb68ec7a198/pikepdf-10.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:26cf455feedc03a2fd9bf9daf6a199c33605129182da045769b7df45448baf75", size = 3752882, upload-time = "2026-01-09T22:53:38.238Z" }, + { url = "https://files.pythonhosted.org/packages/73/18/598383493a0f0f0c4eecd09b8fe06dddb9d326a89e2623a134d43e051485/pikepdf-10.2.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:18c35d00baff72bfae82d67028bedb02ea2b208e1af5545c23cd681f2487a279", size = 4737716, upload-time = "2026-01-09T22:53:39.973Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/bec04784ba07d44f03b52ea524bcb7409bf7185ee8abec7ae29e3ac9e9ae/pikepdf-10.2.0-cp312-cp312-macosx_15_0_x86_64.whl", hash = "sha256:dd849d033b95de15965c095ebc4d78983099a11bb7b7897801dfaf3cb4083a35", size = 5042152, upload-time = "2026-01-09T22:53:41.808Z" }, + { url = "https://files.pythonhosted.org/packages/38/3e/148b3c8e101c8ac3a33f41e86c5739413575495e471bce45ee228aafcbd6/pikepdf-10.2.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9910efdc7907af3da9e7b2a125a1f67d512165ffa623f62825deeb642669a7a", size = 2445796, upload-time = "2026-01-09T22:53:44.027Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ef/b06f8fd68c34fed631cb8e3520dd955e59987de0eee6960dbc94bed11711/pikepdf-10.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0ec947e6429d7a3306153d32a0142462fdd8f905c5fe08c8a8e8c53b9c28a5c", size = 2693908, upload-time = "2026-01-09T22:53:46.039Z" }, + { url = "https://files.pythonhosted.org/packages/3e/bf/e5c40e9210e2ae8da7cad2cf6ae7d1db3b63a2916e6040645958e9ab4054/pikepdf-10.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b6383219a1cd31400403a69737a4e2a0c5d2a2c4cb9f380bcf45e33e8de802ea", size = 3643423, upload-time = "2026-01-09T22:53:48.333Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1b/969dfb29dc9fd7b82fa7bc065df498e8a3e7ddb81e982140634ee539a8db/pikepdf-10.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:46d2f9ef5a84949bfc11152a323558f94cf85d9d97e9c510c061c7f803028f3f", size = 3854816, upload-time = "2026-01-09T22:53:50.158Z" }, + { url = "https://files.pythonhosted.org/packages/52/df/36bdde2310573cde8de065ebcddb29a1b40d718dc7603ccb33989f9aa817/pikepdf-10.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:43f44c87b6e973c064e533f92d73755e931ae8ab1dd0574a7338fbbb7cddac26", size = 3759107, upload-time = "2026-01-09T22:53:51.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c5/e6f9e3407dd73ec570000a64747ff84e2f57b06b0477d1da6eaca5038162/pikepdf-10.2.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:09ff28d1de7fc7711a7ef8dfc40396d9243b64ee24c37cd1ab2a9f9827895caa", size = 4737680, upload-time = "2026-01-09T22:53:54.905Z" }, + { url = "https://files.pythonhosted.org/packages/eb/de/dffb785235ac2d930db86b215c1848d7258e625fa1949dd0633f8b72ab0a/pikepdf-10.2.0-cp313-cp313-macosx_15_0_x86_64.whl", hash = "sha256:62348b66e1401a4db0c64976b72dd74bb1a9eb3a33007a661500f4f8a64436bd", size = 5042150, upload-time = "2026-01-09T22:53:57.722Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f0/4d883f57304d98650ade30a8c73fe593582d9afd9a7dada1f5f3f4cce362/pikepdf-10.2.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b9e91780cb9ea3c6a350ffbcf03d5d95c30084d238afbd1d4b927cdb9e3649d", size = 2445446, upload-time = "2026-01-09T22:53:59.993Z" }, + { url = "https://files.pythonhosted.org/packages/62/65/ffe2555812a152d616accacea7c1c617c27a75590379ea7d9cc3a26bd92d/pikepdf-10.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:52360a49a22e9353ec9a08ff5713cec8aacaf3ef960c704bc0a89ca8f050bdad", size = 2696242, upload-time = "2026-01-09T22:54:01.687Z" }, + { url = "https://files.pythonhosted.org/packages/ed/8c/2f937b0e2867cd48b523122e08753571fc9847978e239d7b5db9bd46879c/pikepdf-10.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ba4c1046939eb22c24c396deb37f8e0500caaa66b73114be55377d5554b4167", size = 3643730, upload-time = "2026-01-09T22:54:04.177Z" }, + { url = "https://files.pythonhosted.org/packages/2e/17/f2919e4085c399e938bb945ea712dea70b3849e17cae6403f0cc1100e9ef/pikepdf-10.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3dcd8957a08e0a47f7a138904dca8cf73962fa17a096a47cd8bc33eb83a4f0a7", size = 3856645, upload-time = "2026-01-09T22:54:07.887Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d0/ba21879d45375da05211f8c01ed3be8ad88949ec85a9f3f784b5f58a03ca/pikepdf-10.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:936f746ec54f4ca2c3e3ad684a55f4067020bd3fd48e1330bd2fdf61cae284b8", size = 3759000, upload-time = "2026-01-09T22:54:10.056Z" }, + { url = "https://files.pythonhosted.org/packages/1d/6e/846902abe8286d3b4ab70893e9ffbeec99aadd93ba1536cf471b222bb910/pikepdf-10.2.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:194c9a81ecb49e425a5cd5162621270b5e42cf05709d87eac018bd6f9ce98f80", size = 4733930, upload-time = "2026-01-09T22:54:11.931Z" }, + { url = "https://files.pythonhosted.org/packages/8b/6d/abdbb794d2a512d4e828ef2014cc47ca263ad3fbd1b65f25f791b9c0bb1e/pikepdf-10.2.0-cp314-cp314-macosx_15_0_x86_64.whl", hash = "sha256:5adcf87dbfff4e1cd0a850db487274f474c94a6bf6347f3842c53da8d0eaa8df", size = 5042477, upload-time = "2026-01-09T22:54:13.708Z" }, + { url = "https://files.pythonhosted.org/packages/52/6c/6c42694fe1574a37aa2a40b4ba29a6713b4226436155ef7aa0bef649c117/pikepdf-10.2.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5de3cecbb35c4bc651e9326932974217be1d450d4a9840d77a592062eb507e27", size = 2448419, upload-time = "2026-01-09T22:54:15.6Z" }, + { url = "https://files.pythonhosted.org/packages/45/f4/aca3286aa37ace581afc8e3e0644a0cc55b9f9ceb31f28219d12ca11536c/pikepdf-10.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:77868fd25182a45a4f3dec3c461aea8c696ef9565894c5cde4394bc8c32fb069", size = 2697600, upload-time = "2026-01-09T22:54:18.123Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a6/9135f9f0189634de61410573a0712d849e0157e3902e6b867339cc7dbf1b/pikepdf-10.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9a10e15e2f4d0bba36a2b4328342d00eff1a5a31399e1d1a93483c70d3c2b0e", size = 3647720, upload-time = "2026-01-09T22:54:20.158Z" }, + { url = "https://files.pythonhosted.org/packages/83/60/f282077773a3321fad4cbfb16fe73ee3f8dd93b408df65c24779f12227c5/pikepdf-10.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a8f80ecf00fb15a760f218432a1046e7797cd14eaa6ccb52c8814ae8852745d8", size = 3859133, upload-time = "2026-01-09T22:54:22.358Z" }, + { url = "https://files.pythonhosted.org/packages/7a/8d/37a5b2af0d119053e50447a936adae826bc3d0cae482e78e89f0b0c1f626/pikepdf-10.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:d3e8c75407f898b79c2ba18a8e8468fdedac8af827867b967376c6a507c1f27e", size = 3865233, upload-time = "2026-01-09T22:54:24.097Z" }, ] [[package]] name = "pillow" -version = "12.0.0" +version = "12.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828, upload-time = "2025-10-15T18:24:14.008Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/02/d52c733a2452ef1ffcc123b68e6606d07276b0e358db70eabad7e40042b7/pillow-12.1.0.tar.gz", hash = "sha256:5c5ae0a06e9ea030ab786b0251b32c7e4ce10e58d983c0d5c56029455180b5b9", size = 46977283, upload-time = "2026-01-02T09:13:29.892Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/08/26e68b6b5da219c2a2cb7b563af008b53bb8e6b6fcb3fa40715fcdb2523a/pillow-12.0.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:3adfb466bbc544b926d50fe8f4a4e6abd8c6bffd28a26177594e6e9b2b76572b", size = 5289809, upload-time = "2025-10-15T18:21:27.791Z" }, - { url = "https://files.pythonhosted.org/packages/cb/e9/4e58fb097fb74c7b4758a680aacd558810a417d1edaa7000142976ef9d2f/pillow-12.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1ac11e8ea4f611c3c0147424eae514028b5e9077dd99ab91e1bd7bc33ff145e1", size = 4650606, upload-time = "2025-10-15T18:21:29.823Z" }, - { url = "https://files.pythonhosted.org/packages/4b/e0/1fa492aa9f77b3bc6d471c468e62bfea1823056bf7e5e4f1914d7ab2565e/pillow-12.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d49e2314c373f4c2b39446fb1a45ed333c850e09d0c59ac79b72eb3b95397363", size = 6221023, upload-time = "2025-10-15T18:21:31.415Z" }, - { url = "https://files.pythonhosted.org/packages/c1/09/4de7cd03e33734ccd0c876f0251401f1314e819cbfd89a0fcb6e77927cc6/pillow-12.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c7b2a63fd6d5246349f3d3f37b14430d73ee7e8173154461785e43036ffa96ca", size = 8024937, upload-time = "2025-10-15T18:21:33.453Z" }, - { url = "https://files.pythonhosted.org/packages/2e/69/0688e7c1390666592876d9d474f5e135abb4acb39dcb583c4dc5490f1aff/pillow-12.0.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d64317d2587c70324b79861babb9c09f71fbb780bad212018874b2c013d8600e", size = 6334139, upload-time = "2025-10-15T18:21:35.395Z" }, - { url = "https://files.pythonhosted.org/packages/ed/1c/880921e98f525b9b44ce747ad1ea8f73fd7e992bafe3ca5e5644bf433dea/pillow-12.0.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d77153e14b709fd8b8af6f66a3afbb9ed6e9fc5ccf0b6b7e1ced7b036a228782", size = 7026074, upload-time = "2025-10-15T18:21:37.219Z" }, - { url = "https://files.pythonhosted.org/packages/28/03/96f718331b19b355610ef4ebdbbde3557c726513030665071fd025745671/pillow-12.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:32ed80ea8a90ee3e6fa08c21e2e091bba6eda8eccc83dbc34c95169507a91f10", size = 6448852, upload-time = "2025-10-15T18:21:39.168Z" }, - { url = "https://files.pythonhosted.org/packages/3a/a0/6a193b3f0cc9437b122978d2c5cbce59510ccf9a5b48825096ed7472da2f/pillow-12.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c828a1ae702fc712978bda0320ba1b9893d99be0badf2647f693cc01cf0f04fa", size = 7117058, upload-time = "2025-10-15T18:21:40.997Z" }, - { url = "https://files.pythonhosted.org/packages/a7/c4/043192375eaa4463254e8e61f0e2ec9a846b983929a8d0a7122e0a6d6fff/pillow-12.0.0-cp310-cp310-win32.whl", hash = "sha256:bd87e140e45399c818fac4247880b9ce719e4783d767e030a883a970be632275", size = 6295431, upload-time = "2025-10-15T18:21:42.518Z" }, - { url = "https://files.pythonhosted.org/packages/92/c6/c2f2fc7e56301c21827e689bb8b0b465f1b52878b57471a070678c0c33cd/pillow-12.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:455247ac8a4cfb7b9bc45b7e432d10421aea9fc2e74d285ba4072688a74c2e9d", size = 7000412, upload-time = "2025-10-15T18:21:44.404Z" }, - { url = "https://files.pythonhosted.org/packages/b2/d2/5f675067ba82da7a1c238a73b32e3fd78d67f9d9f80fbadd33a40b9c0481/pillow-12.0.0-cp310-cp310-win_arm64.whl", hash = "sha256:6ace95230bfb7cd79ef66caa064bbe2f2a1e63d93471c3a2e1f1348d9f22d6b7", size = 2435903, upload-time = "2025-10-15T18:21:46.29Z" }, - { url = "https://files.pythonhosted.org/packages/0e/5a/a2f6773b64edb921a756eb0729068acad9fc5208a53f4a349396e9436721/pillow-12.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0fd00cac9c03256c8b2ff58f162ebcd2587ad3e1f2e397eab718c47e24d231cc", size = 5289798, upload-time = "2025-10-15T18:21:47.763Z" }, - { url = "https://files.pythonhosted.org/packages/2e/05/069b1f8a2e4b5a37493da6c5868531c3f77b85e716ad7a590ef87d58730d/pillow-12.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3475b96f5908b3b16c47533daaa87380c491357d197564e0ba34ae75c0f3257", size = 4650589, upload-time = "2025-10-15T18:21:49.515Z" }, - { url = "https://files.pythonhosted.org/packages/61/e3/2c820d6e9a36432503ead175ae294f96861b07600a7156154a086ba7111a/pillow-12.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:110486b79f2d112cf6add83b28b627e369219388f64ef2f960fef9ebaf54c642", size = 6230472, upload-time = "2025-10-15T18:21:51.052Z" }, - { url = "https://files.pythonhosted.org/packages/4f/89/63427f51c64209c5e23d4d52071c8d0f21024d3a8a487737caaf614a5795/pillow-12.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5269cc1caeedb67e6f7269a42014f381f45e2e7cd42d834ede3c703a1d915fe3", size = 8033887, upload-time = "2025-10-15T18:21:52.604Z" }, - { url = "https://files.pythonhosted.org/packages/f6/1b/c9711318d4901093c15840f268ad649459cd81984c9ec9887756cca049a5/pillow-12.0.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa5129de4e174daccbc59d0a3b6d20eaf24417d59851c07ebb37aeb02947987c", size = 6343964, upload-time = "2025-10-15T18:21:54.619Z" }, - { url = "https://files.pythonhosted.org/packages/41/1e/db9470f2d030b4995083044cd8738cdd1bf773106819f6d8ba12597d5352/pillow-12.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bee2a6db3a7242ea309aa7ee8e2780726fed67ff4e5b40169f2c940e7eb09227", size = 7034756, upload-time = "2025-10-15T18:21:56.151Z" }, - { url = "https://files.pythonhosted.org/packages/cc/b0/6177a8bdd5ee4ed87cba2de5a3cc1db55ffbbec6176784ce5bb75aa96798/pillow-12.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:90387104ee8400a7b4598253b4c406f8958f59fcf983a6cea2b50d59f7d63d0b", size = 6458075, upload-time = "2025-10-15T18:21:57.759Z" }, - { url = "https://files.pythonhosted.org/packages/bc/5e/61537aa6fa977922c6a03253a0e727e6e4a72381a80d63ad8eec350684f2/pillow-12.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bc91a56697869546d1b8f0a3ff35224557ae7f881050e99f615e0119bf934b4e", size = 7125955, upload-time = "2025-10-15T18:21:59.372Z" }, - { url = "https://files.pythonhosted.org/packages/1f/3d/d5033539344ee3cbd9a4d69e12e63ca3a44a739eb2d4c8da350a3d38edd7/pillow-12.0.0-cp311-cp311-win32.whl", hash = "sha256:27f95b12453d165099c84f8a8bfdfd46b9e4bda9e0e4b65f0635430027f55739", size = 6298440, upload-time = "2025-10-15T18:22:00.982Z" }, - { url = "https://files.pythonhosted.org/packages/4d/42/aaca386de5cc8bd8a0254516957c1f265e3521c91515b16e286c662854c4/pillow-12.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:b583dc9070312190192631373c6c8ed277254aa6e6084b74bdd0a6d3b221608e", size = 6999256, upload-time = "2025-10-15T18:22:02.617Z" }, - { url = "https://files.pythonhosted.org/packages/ba/f1/9197c9c2d5708b785f631a6dfbfa8eb3fb9672837cb92ae9af812c13b4ed/pillow-12.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:759de84a33be3b178a64c8ba28ad5c135900359e85fb662bc6e403ad4407791d", size = 2436025, upload-time = "2025-10-15T18:22:04.598Z" }, - { url = "https://files.pythonhosted.org/packages/2c/90/4fcce2c22caf044e660a198d740e7fbc14395619e3cb1abad12192c0826c/pillow-12.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371", size = 5249377, upload-time = "2025-10-15T18:22:05.993Z" }, - { url = "https://files.pythonhosted.org/packages/fd/e0/ed960067543d080691d47d6938ebccbf3976a931c9567ab2fbfab983a5dd/pillow-12.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082", size = 4650343, upload-time = "2025-10-15T18:22:07.718Z" }, - { url = "https://files.pythonhosted.org/packages/e7/a1/f81fdeddcb99c044bf7d6faa47e12850f13cee0849537a7d27eeab5534d4/pillow-12.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f", size = 6232981, upload-time = "2025-10-15T18:22:09.287Z" }, - { url = "https://files.pythonhosted.org/packages/88/e1/9098d3ce341a8750b55b0e00c03f1630d6178f38ac191c81c97a3b047b44/pillow-12.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d", size = 8041399, upload-time = "2025-10-15T18:22:10.872Z" }, - { url = "https://files.pythonhosted.org/packages/a7/62/a22e8d3b602ae8cc01446d0c57a54e982737f44b6f2e1e019a925143771d/pillow-12.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953", size = 6347740, upload-time = "2025-10-15T18:22:12.769Z" }, - { url = "https://files.pythonhosted.org/packages/4f/87/424511bdcd02c8d7acf9f65caa09f291a519b16bd83c3fb3374b3d4ae951/pillow-12.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8", size = 7040201, upload-time = "2025-10-15T18:22:14.813Z" }, - { url = "https://files.pythonhosted.org/packages/dc/4d/435c8ac688c54d11755aedfdd9f29c9eeddf68d150fe42d1d3dbd2365149/pillow-12.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79", size = 6462334, upload-time = "2025-10-15T18:22:16.375Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f2/ad34167a8059a59b8ad10bc5c72d4d9b35acc6b7c0877af8ac885b5f2044/pillow-12.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba", size = 7134162, upload-time = "2025-10-15T18:22:17.996Z" }, - { url = "https://files.pythonhosted.org/packages/0c/b1/a7391df6adacf0a5c2cf6ac1cf1fcc1369e7d439d28f637a847f8803beb3/pillow-12.0.0-cp312-cp312-win32.whl", hash = "sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0", size = 6298769, upload-time = "2025-10-15T18:22:19.923Z" }, - { url = "https://files.pythonhosted.org/packages/a2/0b/d87733741526541c909bbf159e338dcace4f982daac6e5a8d6be225ca32d/pillow-12.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a", size = 7001107, upload-time = "2025-10-15T18:22:21.644Z" }, - { url = "https://files.pythonhosted.org/packages/bc/96/aaa61ce33cc98421fb6088af2a03be4157b1e7e0e87087c888e2370a7f45/pillow-12.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad", size = 2436012, upload-time = "2025-10-15T18:22:23.621Z" }, - { url = "https://files.pythonhosted.org/packages/62/f2/de993bb2d21b33a98d031ecf6a978e4b61da207bef02f7b43093774c480d/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643", size = 4045493, upload-time = "2025-10-15T18:22:25.758Z" }, - { url = "https://files.pythonhosted.org/packages/0e/b6/bc8d0c4c9f6f111a783d045310945deb769b806d7574764234ffd50bc5ea/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4", size = 4120461, upload-time = "2025-10-15T18:22:27.286Z" }, - { url = "https://files.pythonhosted.org/packages/5d/57/d60d343709366a353dc56adb4ee1e7d8a2cc34e3fbc22905f4167cfec119/pillow-12.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399", size = 3576912, upload-time = "2025-10-15T18:22:28.751Z" }, - { url = "https://files.pythonhosted.org/packages/a4/a4/a0a31467e3f83b94d37568294b01d22b43ae3c5d85f2811769b9c66389dd/pillow-12.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5", size = 5249132, upload-time = "2025-10-15T18:22:30.641Z" }, - { url = "https://files.pythonhosted.org/packages/83/06/48eab21dd561de2914242711434c0c0eb992ed08ff3f6107a5f44527f5e9/pillow-12.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b", size = 4650099, upload-time = "2025-10-15T18:22:32.73Z" }, - { url = "https://files.pythonhosted.org/packages/fc/bd/69ed99fd46a8dba7c1887156d3572fe4484e3f031405fcc5a92e31c04035/pillow-12.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3", size = 6230808, upload-time = "2025-10-15T18:22:34.337Z" }, - { url = "https://files.pythonhosted.org/packages/ea/94/8fad659bcdbf86ed70099cb60ae40be6acca434bbc8c4c0d4ef356d7e0de/pillow-12.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07", size = 8037804, upload-time = "2025-10-15T18:22:36.402Z" }, - { url = "https://files.pythonhosted.org/packages/20/39/c685d05c06deecfd4e2d1950e9a908aa2ca8bc4e6c3b12d93b9cafbd7837/pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e", size = 6345553, upload-time = "2025-10-15T18:22:38.066Z" }, - { url = "https://files.pythonhosted.org/packages/38/57/755dbd06530a27a5ed74f8cb0a7a44a21722ebf318edbe67ddbd7fb28f88/pillow-12.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344", size = 7037729, upload-time = "2025-10-15T18:22:39.769Z" }, - { url = "https://files.pythonhosted.org/packages/ca/b6/7e94f4c41d238615674d06ed677c14883103dce1c52e4af16f000338cfd7/pillow-12.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27", size = 6459789, upload-time = "2025-10-15T18:22:41.437Z" }, - { url = "https://files.pythonhosted.org/packages/9c/14/4448bb0b5e0f22dd865290536d20ec8a23b64e2d04280b89139f09a36bb6/pillow-12.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79", size = 7130917, upload-time = "2025-10-15T18:22:43.152Z" }, - { url = "https://files.pythonhosted.org/packages/dd/ca/16c6926cc1c015845745d5c16c9358e24282f1e588237a4c36d2b30f182f/pillow-12.0.0-cp313-cp313-win32.whl", hash = "sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098", size = 6302391, upload-time = "2025-10-15T18:22:44.753Z" }, - { url = "https://files.pythonhosted.org/packages/6d/2a/dd43dcfd6dae9b6a49ee28a8eedb98c7d5ff2de94a5d834565164667b97b/pillow-12.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905", size = 7007477, upload-time = "2025-10-15T18:22:46.838Z" }, - { url = "https://files.pythonhosted.org/packages/77/f0/72ea067f4b5ae5ead653053212af05ce3705807906ba3f3e8f58ddf617e6/pillow-12.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a", size = 2435918, upload-time = "2025-10-15T18:22:48.399Z" }, - { url = "https://files.pythonhosted.org/packages/f5/5e/9046b423735c21f0487ea6cb5b10f89ea8f8dfbe32576fe052b5ba9d4e5b/pillow-12.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3", size = 5251406, upload-time = "2025-10-15T18:22:49.905Z" }, - { url = "https://files.pythonhosted.org/packages/12/66/982ceebcdb13c97270ef7a56c3969635b4ee7cd45227fa707c94719229c5/pillow-12.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced", size = 4653218, upload-time = "2025-10-15T18:22:51.587Z" }, - { url = "https://files.pythonhosted.org/packages/16/b3/81e625524688c31859450119bf12674619429cab3119eec0e30a7a1029cb/pillow-12.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b", size = 6266564, upload-time = "2025-10-15T18:22:53.215Z" }, - { url = "https://files.pythonhosted.org/packages/98/59/dfb38f2a41240d2408096e1a76c671d0a105a4a8471b1871c6902719450c/pillow-12.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d", size = 8069260, upload-time = "2025-10-15T18:22:54.933Z" }, - { url = "https://files.pythonhosted.org/packages/dc/3d/378dbea5cd1874b94c312425ca77b0f47776c78e0df2df751b820c8c1d6c/pillow-12.0.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a", size = 6379248, upload-time = "2025-10-15T18:22:56.605Z" }, - { url = "https://files.pythonhosted.org/packages/84/b0/d525ef47d71590f1621510327acec75ae58c721dc071b17d8d652ca494d8/pillow-12.0.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe", size = 7066043, upload-time = "2025-10-15T18:22:58.53Z" }, - { url = "https://files.pythonhosted.org/packages/61/2c/aced60e9cf9d0cde341d54bf7932c9ffc33ddb4a1595798b3a5150c7ec4e/pillow-12.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee", size = 6490915, upload-time = "2025-10-15T18:23:00.582Z" }, - { url = "https://files.pythonhosted.org/packages/ef/26/69dcb9b91f4e59f8f34b2332a4a0a951b44f547c4ed39d3e4dcfcff48f89/pillow-12.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef", size = 7157998, upload-time = "2025-10-15T18:23:02.627Z" }, - { url = "https://files.pythonhosted.org/packages/61/2b/726235842220ca95fa441ddf55dd2382b52ab5b8d9c0596fe6b3f23dafe8/pillow-12.0.0-cp313-cp313t-win32.whl", hash = "sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9", size = 6306201, upload-time = "2025-10-15T18:23:04.709Z" }, - { url = "https://files.pythonhosted.org/packages/c0/3d/2afaf4e840b2df71344ababf2f8edd75a705ce500e5dc1e7227808312ae1/pillow-12.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b", size = 7013165, upload-time = "2025-10-15T18:23:06.46Z" }, - { url = "https://files.pythonhosted.org/packages/6f/75/3fa09aa5cf6ed04bee3fa575798ddf1ce0bace8edb47249c798077a81f7f/pillow-12.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47", size = 2437834, upload-time = "2025-10-15T18:23:08.194Z" }, - { url = "https://files.pythonhosted.org/packages/54/2a/9a8c6ba2c2c07b71bec92cf63e03370ca5e5f5c5b119b742bcc0cde3f9c5/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9", size = 4045531, upload-time = "2025-10-15T18:23:10.121Z" }, - { url = "https://files.pythonhosted.org/packages/84/54/836fdbf1bfb3d66a59f0189ff0b9f5f666cee09c6188309300df04ad71fa/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2", size = 4120554, upload-time = "2025-10-15T18:23:12.14Z" }, - { url = "https://files.pythonhosted.org/packages/0d/cd/16aec9f0da4793e98e6b54778a5fbce4f375c6646fe662e80600b8797379/pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a", size = 3576812, upload-time = "2025-10-15T18:23:13.962Z" }, - { url = "https://files.pythonhosted.org/packages/f6/b7/13957fda356dc46339298b351cae0d327704986337c3c69bb54628c88155/pillow-12.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b", size = 5252689, upload-time = "2025-10-15T18:23:15.562Z" }, - { url = "https://files.pythonhosted.org/packages/fc/f5/eae31a306341d8f331f43edb2e9122c7661b975433de5e447939ae61c5da/pillow-12.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad", size = 4650186, upload-time = "2025-10-15T18:23:17.379Z" }, - { url = "https://files.pythonhosted.org/packages/86/62/2a88339aa40c4c77e79108facbd307d6091e2c0eb5b8d3cf4977cfca2fe6/pillow-12.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01", size = 6230308, upload-time = "2025-10-15T18:23:18.971Z" }, - { url = "https://files.pythonhosted.org/packages/c7/33/5425a8992bcb32d1cb9fa3dd39a89e613d09a22f2c8083b7bf43c455f760/pillow-12.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c", size = 8039222, upload-time = "2025-10-15T18:23:20.909Z" }, - { url = "https://files.pythonhosted.org/packages/d8/61/3f5d3b35c5728f37953d3eec5b5f3e77111949523bd2dd7f31a851e50690/pillow-12.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e", size = 6346657, upload-time = "2025-10-15T18:23:23.077Z" }, - { url = "https://files.pythonhosted.org/packages/3a/be/ee90a3d79271227e0f0a33c453531efd6ed14b2e708596ba5dd9be948da3/pillow-12.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e", size = 7038482, upload-time = "2025-10-15T18:23:25.005Z" }, - { url = "https://files.pythonhosted.org/packages/44/34/a16b6a4d1ad727de390e9bd9f19f5f669e079e5826ec0f329010ddea492f/pillow-12.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9", size = 6461416, upload-time = "2025-10-15T18:23:27.009Z" }, - { url = "https://files.pythonhosted.org/packages/b6/39/1aa5850d2ade7d7ba9f54e4e4c17077244ff7a2d9e25998c38a29749eb3f/pillow-12.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab", size = 7131584, upload-time = "2025-10-15T18:23:29.752Z" }, - { url = "https://files.pythonhosted.org/packages/bf/db/4fae862f8fad0167073a7733973bfa955f47e2cac3dc3e3e6257d10fab4a/pillow-12.0.0-cp314-cp314-win32.whl", hash = "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b", size = 6400621, upload-time = "2025-10-15T18:23:32.06Z" }, - { url = "https://files.pythonhosted.org/packages/2b/24/b350c31543fb0107ab2599464d7e28e6f856027aadda995022e695313d94/pillow-12.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b", size = 7142916, upload-time = "2025-10-15T18:23:34.71Z" }, - { url = "https://files.pythonhosted.org/packages/0f/9b/0ba5a6fd9351793996ef7487c4fdbde8d3f5f75dbedc093bb598648fddf0/pillow-12.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0", size = 2523836, upload-time = "2025-10-15T18:23:36.967Z" }, - { url = "https://files.pythonhosted.org/packages/f5/7a/ceee0840aebc579af529b523d530840338ecf63992395842e54edc805987/pillow-12.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6", size = 5255092, upload-time = "2025-10-15T18:23:38.573Z" }, - { url = "https://files.pythonhosted.org/packages/44/76/20776057b4bfd1aef4eeca992ebde0f53a4dce874f3ae693d0ec90a4f79b/pillow-12.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6", size = 4653158, upload-time = "2025-10-15T18:23:40.238Z" }, - { url = "https://files.pythonhosted.org/packages/82/3f/d9ff92ace07be8836b4e7e87e6a4c7a8318d47c2f1463ffcf121fc57d9cb/pillow-12.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1", size = 6267882, upload-time = "2025-10-15T18:23:42.434Z" }, - { url = "https://files.pythonhosted.org/packages/9f/7a/4f7ff87f00d3ad33ba21af78bfcd2f032107710baf8280e3722ceec28cda/pillow-12.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e", size = 8071001, upload-time = "2025-10-15T18:23:44.29Z" }, - { url = "https://files.pythonhosted.org/packages/75/87/fcea108944a52dad8cca0715ae6247e271eb80459364a98518f1e4f480c1/pillow-12.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca", size = 6380146, upload-time = "2025-10-15T18:23:46.065Z" }, - { url = "https://files.pythonhosted.org/packages/91/52/0d31b5e571ef5fd111d2978b84603fce26aba1b6092f28e941cb46570745/pillow-12.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925", size = 7067344, upload-time = "2025-10-15T18:23:47.898Z" }, - { url = "https://files.pythonhosted.org/packages/7b/f4/2dd3d721f875f928d48e83bb30a434dee75a2531bca839bb996bb0aa5a91/pillow-12.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8", size = 6491864, upload-time = "2025-10-15T18:23:49.607Z" }, - { url = "https://files.pythonhosted.org/packages/30/4b/667dfcf3d61fc309ba5a15b141845cece5915e39b99c1ceab0f34bf1d124/pillow-12.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4", size = 7158911, upload-time = "2025-10-15T18:23:51.351Z" }, - { url = "https://files.pythonhosted.org/packages/a2/2f/16cabcc6426c32218ace36bf0d55955e813f2958afddbf1d391849fee9d1/pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52", size = 6408045, upload-time = "2025-10-15T18:23:53.177Z" }, - { url = "https://files.pythonhosted.org/packages/35/73/e29aa0c9c666cf787628d3f0dcf379f4791fba79f4936d02f8b37165bdf8/pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a", size = 7148282, upload-time = "2025-10-15T18:23:55.316Z" }, - { url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630, upload-time = "2025-10-15T18:23:57.149Z" }, - { url = "https://files.pythonhosted.org/packages/1d/b3/582327e6c9f86d037b63beebe981425d6811104cb443e8193824ef1a2f27/pillow-12.0.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b22bd8c974942477156be55a768f7aa37c46904c175be4e158b6a86e3a6b7ca8", size = 5215068, upload-time = "2025-10-15T18:23:59.594Z" }, - { url = "https://files.pythonhosted.org/packages/fd/d6/67748211d119f3b6540baf90f92fae73ae51d5217b171b0e8b5f7e5d558f/pillow-12.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:805ebf596939e48dbb2e4922a1d3852cfc25c38160751ce02da93058b48d252a", size = 4614994, upload-time = "2025-10-15T18:24:01.669Z" }, - { url = "https://files.pythonhosted.org/packages/2d/e1/f8281e5d844c41872b273b9f2c34a4bf64ca08905668c8ae730eedc7c9fa/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cae81479f77420d217def5f54b5b9d279804d17e982e0f2fa19b1d1e14ab5197", size = 5246639, upload-time = "2025-10-15T18:24:03.403Z" }, - { url = "https://files.pythonhosted.org/packages/94/5a/0d8ab8ffe8a102ff5df60d0de5af309015163bf710c7bb3e8311dd3b3ad0/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeaefa96c768fc66818730b952a862235d68825c178f1b3ffd4efd7ad2edcb7c", size = 6986839, upload-time = "2025-10-15T18:24:05.344Z" }, - { url = "https://files.pythonhosted.org/packages/20/2e/3434380e8110b76cd9eb00a363c484b050f949b4bbe84ba770bb8508a02c/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f2d0abef9e4e2f349305a4f8cc784a8a6c2f58a8c4892eea13b10a943bd26e", size = 5313505, upload-time = "2025-10-15T18:24:07.137Z" }, - { url = "https://files.pythonhosted.org/packages/57/ca/5a9d38900d9d74785141d6580950fe705de68af735ff6e727cb911b64740/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdee52571a343d721fb2eb3b090a82d959ff37fc631e3f70422e0c2e029f3e76", size = 5963654, upload-time = "2025-10-15T18:24:09.579Z" }, - { url = "https://files.pythonhosted.org/packages/95/7e/f896623c3c635a90537ac093c6a618ebe1a90d87206e42309cb5d98a1b9e/pillow-12.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5", size = 6997850, upload-time = "2025-10-15T18:24:11.495Z" }, + { url = "https://files.pythonhosted.org/packages/43/c4/bf8328039de6cc22182c3ef007a2abfbbdab153661c0a9aa78af8d706391/pillow-12.1.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:a83e0850cb8f5ac975291ebfc4170ba481f41a28065277f7f735c202cd8e0af3", size = 5304057, upload-time = "2026-01-02T09:10:46.627Z" }, + { url = "https://files.pythonhosted.org/packages/43/06/7264c0597e676104cc22ca73ee48f752767cd4b1fe084662620b17e10120/pillow-12.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b6e53e82ec2db0717eabb276aa56cf4e500c9a7cec2c2e189b55c24f65a3e8c0", size = 4657811, upload-time = "2026-01-02T09:10:49.548Z" }, + { url = "https://files.pythonhosted.org/packages/72/64/f9189e44474610daf83da31145fa56710b627b5c4c0b9c235e34058f6b31/pillow-12.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:40a8e3b9e8773876d6e30daed22f016509e3987bab61b3b7fe309d7019a87451", size = 6232243, upload-time = "2026-01-02T09:10:51.62Z" }, + { url = "https://files.pythonhosted.org/packages/ef/30/0df458009be6a4caca4ca2c52975e6275c387d4e5c95544e34138b41dc86/pillow-12.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:800429ac32c9b72909c671aaf17ecd13110f823ddb7db4dfef412a5587c2c24e", size = 8037872, upload-time = "2026-01-02T09:10:53.446Z" }, + { url = "https://files.pythonhosted.org/packages/e4/86/95845d4eda4f4f9557e25381d70876aa213560243ac1a6d619c46caaedd9/pillow-12.1.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b022eaaf709541b391ee069f0022ee5b36c709df71986e3f7be312e46f42c84", size = 6345398, upload-time = "2026-01-02T09:10:55.426Z" }, + { url = "https://files.pythonhosted.org/packages/5c/1f/8e66ab9be3aaf1435bc03edd1ebdf58ffcd17f7349c1d970cafe87af27d9/pillow-12.1.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f345e7bc9d7f368887c712aa5054558bad44d2a301ddf9248599f4161abc7c0", size = 7034667, upload-time = "2026-01-02T09:10:57.11Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f6/683b83cb9b1db1fb52b87951b1c0b99bdcfceaa75febf11406c19f82cb5e/pillow-12.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d70347c8a5b7ccd803ec0c85c8709f036e6348f1e6a5bf048ecd9c64d3550b8b", size = 6458743, upload-time = "2026-01-02T09:10:59.331Z" }, + { url = "https://files.pythonhosted.org/packages/9a/7d/de833d63622538c1d58ce5395e7c6cb7e7dce80decdd8bde4a484e095d9f/pillow-12.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1fcc52d86ce7a34fd17cb04e87cfdb164648a3662a6f20565910a99653d66c18", size = 7159342, upload-time = "2026-01-02T09:11:01.82Z" }, + { url = "https://files.pythonhosted.org/packages/8c/40/50d86571c9e5868c42b81fe7da0c76ca26373f3b95a8dd675425f4a92ec1/pillow-12.1.0-cp311-cp311-win32.whl", hash = "sha256:3ffaa2f0659e2f740473bcf03c702c39a8d4b2b7ffc629052028764324842c64", size = 6328655, upload-time = "2026-01-02T09:11:04.556Z" }, + { url = "https://files.pythonhosted.org/packages/6c/af/b1d7e301c4cd26cd45d4af884d9ee9b6fab893b0ad2450d4746d74a6968c/pillow-12.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:806f3987ffe10e867bab0ddad45df1148a2b98221798457fa097ad85d6e8bc75", size = 7031469, upload-time = "2026-01-02T09:11:06.538Z" }, + { url = "https://files.pythonhosted.org/packages/48/36/d5716586d887fb2a810a4a61518a327a1e21c8b7134c89283af272efe84b/pillow-12.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:9f5fefaca968e700ad1a4a9de98bf0869a94e397fe3524c4c9450c1445252304", size = 2452515, upload-time = "2026-01-02T09:11:08.226Z" }, + { url = "https://files.pythonhosted.org/packages/20/31/dc53fe21a2f2996e1b7d92bf671cdb157079385183ef7c1ae08b485db510/pillow-12.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a332ac4ccb84b6dde65dbace8431f3af08874bf9770719d32a635c4ef411b18b", size = 5262642, upload-time = "2026-01-02T09:11:10.138Z" }, + { url = "https://files.pythonhosted.org/packages/ab/c1/10e45ac9cc79419cedf5121b42dcca5a50ad2b601fa080f58c22fb27626e/pillow-12.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:907bfa8a9cb790748a9aa4513e37c88c59660da3bcfffbd24a7d9e6abf224551", size = 4657464, upload-time = "2026-01-02T09:11:12.319Z" }, + { url = "https://files.pythonhosted.org/packages/ad/26/7b82c0ab7ef40ebede7a97c72d473bda5950f609f8e0c77b04af574a0ddb/pillow-12.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:efdc140e7b63b8f739d09a99033aa430accce485ff78e6d311973a67b6bf3208", size = 6234878, upload-time = "2026-01-02T09:11:14.096Z" }, + { url = "https://files.pythonhosted.org/packages/76/25/27abc9792615b5e886ca9411ba6637b675f1b77af3104710ac7353fe5605/pillow-12.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bef9768cab184e7ae6e559c032e95ba8d07b3023c289f79a2bd36e8bf85605a5", size = 8044868, upload-time = "2026-01-02T09:11:15.903Z" }, + { url = "https://files.pythonhosted.org/packages/0a/ea/f200a4c36d836100e7bc738fc48cd963d3ba6372ebc8298a889e0cfc3359/pillow-12.1.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:742aea052cf5ab5034a53c3846165bc3ce88d7c38e954120db0ab867ca242661", size = 6349468, upload-time = "2026-01-02T09:11:17.631Z" }, + { url = "https://files.pythonhosted.org/packages/11/8f/48d0b77ab2200374c66d344459b8958c86693be99526450e7aee714e03e4/pillow-12.1.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a6dfc2af5b082b635af6e08e0d1f9f1c4e04d17d4e2ca0ef96131e85eda6eb17", size = 7041518, upload-time = "2026-01-02T09:11:19.389Z" }, + { url = "https://files.pythonhosted.org/packages/1d/23/c281182eb986b5d31f0a76d2a2c8cd41722d6fb8ed07521e802f9bba52de/pillow-12.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:609e89d9f90b581c8d16358c9087df76024cf058fa693dd3e1e1620823f39670", size = 6462829, upload-time = "2026-01-02T09:11:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/25/ef/7018273e0faac099d7b00982abdcc39142ae6f3bd9ceb06de09779c4a9d6/pillow-12.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:43b4899cfd091a9693a1278c4982f3e50f7fb7cff5153b05174b4afc9593b616", size = 7166756, upload-time = "2026-01-02T09:11:23.559Z" }, + { url = "https://files.pythonhosted.org/packages/8f/c8/993d4b7ab2e341fe02ceef9576afcf5830cdec640be2ac5bee1820d693d4/pillow-12.1.0-cp312-cp312-win32.whl", hash = "sha256:aa0c9cc0b82b14766a99fbe6084409972266e82f459821cd26997a488a7261a7", size = 6328770, upload-time = "2026-01-02T09:11:25.661Z" }, + { url = "https://files.pythonhosted.org/packages/a7/87/90b358775a3f02765d87655237229ba64a997b87efa8ccaca7dd3e36e7a7/pillow-12.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:d70534cea9e7966169ad29a903b99fc507e932069a881d0965a1a84bb57f6c6d", size = 7033406, upload-time = "2026-01-02T09:11:27.474Z" }, + { url = "https://files.pythonhosted.org/packages/5d/cf/881b457eccacac9e5b2ddd97d5071fb6d668307c57cbf4e3b5278e06e536/pillow-12.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:65b80c1ee7e14a87d6a068dd3b0aea268ffcabfe0498d38661b00c5b4b22e74c", size = 2452612, upload-time = "2026-01-02T09:11:29.309Z" }, + { url = "https://files.pythonhosted.org/packages/dd/c7/2530a4aa28248623e9d7f27316b42e27c32ec410f695929696f2e0e4a778/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7b5dd7cbae20285cdb597b10eb5a2c13aa9de6cde9bb64a3c1317427b1db1ae1", size = 4062543, upload-time = "2026-01-02T09:11:31.566Z" }, + { url = "https://files.pythonhosted.org/packages/8f/1f/40b8eae823dc1519b87d53c30ed9ef085506b05281d313031755c1705f73/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:29a4cef9cb672363926f0470afc516dbf7305a14d8c54f7abbb5c199cd8f8179", size = 4138373, upload-time = "2026-01-02T09:11:33.367Z" }, + { url = "https://files.pythonhosted.org/packages/d4/77/6fa60634cf06e52139fd0e89e5bbf055e8166c691c42fb162818b7fda31d/pillow-12.1.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:681088909d7e8fa9e31b9799aaa59ba5234c58e5e4f1951b4c4d1082a2e980e0", size = 3601241, upload-time = "2026-01-02T09:11:35.011Z" }, + { url = "https://files.pythonhosted.org/packages/4f/bf/28ab865de622e14b747f0cd7877510848252d950e43002e224fb1c9ababf/pillow-12.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:983976c2ab753166dc66d36af6e8ec15bb511e4a25856e2227e5f7e00a160587", size = 5262410, upload-time = "2026-01-02T09:11:36.682Z" }, + { url = "https://files.pythonhosted.org/packages/1c/34/583420a1b55e715937a85bd48c5c0991598247a1fd2eb5423188e765ea02/pillow-12.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:db44d5c160a90df2d24a24760bbd37607d53da0b34fb546c4c232af7192298ac", size = 4657312, upload-time = "2026-01-02T09:11:38.535Z" }, + { url = "https://files.pythonhosted.org/packages/1d/fd/f5a0896839762885b3376ff04878f86ab2b097c2f9a9cdccf4eda8ba8dc0/pillow-12.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6b7a9d1db5dad90e2991645874f708e87d9a3c370c243c2d7684d28f7e133e6b", size = 6232605, upload-time = "2026-01-02T09:11:40.602Z" }, + { url = "https://files.pythonhosted.org/packages/98/aa/938a09d127ac1e70e6ed467bd03834350b33ef646b31edb7452d5de43792/pillow-12.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6258f3260986990ba2fa8a874f8b6e808cf5abb51a94015ca3dc3c68aa4f30ea", size = 8041617, upload-time = "2026-01-02T09:11:42.721Z" }, + { url = "https://files.pythonhosted.org/packages/17/e8/538b24cb426ac0186e03f80f78bc8dc7246c667f58b540bdd57c71c9f79d/pillow-12.1.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e115c15e3bc727b1ca3e641a909f77f8ca72a64fff150f666fcc85e57701c26c", size = 6346509, upload-time = "2026-01-02T09:11:44.955Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/632e58ec89a32738cabfd9ec418f0e9898a2b4719afc581f07c04a05e3c9/pillow-12.1.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6741e6f3074a35e47c77b23a4e4f2d90db3ed905cb1c5e6e0d49bff2045632bc", size = 7038117, upload-time = "2026-01-02T09:11:46.736Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a2/d40308cf86eada842ca1f3ffa45d0ca0df7e4ab33c83f81e73f5eaed136d/pillow-12.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:935b9d1aed48fcfb3f838caac506f38e29621b44ccc4f8a64d575cb1b2a88644", size = 6460151, upload-time = "2026-01-02T09:11:48.625Z" }, + { url = "https://files.pythonhosted.org/packages/f1/88/f5b058ad6453a085c5266660a1417bdad590199da1b32fb4efcff9d33b05/pillow-12.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5fee4c04aad8932da9f8f710af2c1a15a83582cfb884152a9caa79d4efcdbf9c", size = 7164534, upload-time = "2026-01-02T09:11:50.445Z" }, + { url = "https://files.pythonhosted.org/packages/19/ce/c17334caea1db789163b5d855a5735e47995b0b5dc8745e9a3605d5f24c0/pillow-12.1.0-cp313-cp313-win32.whl", hash = "sha256:a786bf667724d84aa29b5db1c61b7bfdde380202aaca12c3461afd6b71743171", size = 6332551, upload-time = "2026-01-02T09:11:52.234Z" }, + { url = "https://files.pythonhosted.org/packages/e5/07/74a9d941fa45c90a0d9465098fe1ec85de3e2afbdc15cc4766622d516056/pillow-12.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:461f9dfdafa394c59cd6d818bdfdbab4028b83b02caadaff0ffd433faf4c9a7a", size = 7040087, upload-time = "2026-01-02T09:11:54.822Z" }, + { url = "https://files.pythonhosted.org/packages/88/09/c99950c075a0e9053d8e880595926302575bc742b1b47fe1bbcc8d388d50/pillow-12.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:9212d6b86917a2300669511ed094a9406888362e085f2431a7da985a6b124f45", size = 2452470, upload-time = "2026-01-02T09:11:56.522Z" }, + { url = "https://files.pythonhosted.org/packages/b5/ba/970b7d85ba01f348dee4d65412476321d40ee04dcb51cd3735b9dc94eb58/pillow-12.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:00162e9ca6d22b7c3ee8e61faa3c3253cd19b6a37f126cad04f2f88b306f557d", size = 5264816, upload-time = "2026-01-02T09:11:58.227Z" }, + { url = "https://files.pythonhosted.org/packages/10/60/650f2fb55fdba7a510d836202aa52f0baac633e50ab1cf18415d332188fb/pillow-12.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7d6daa89a00b58c37cb1747ec9fb7ac3bc5ffd5949f5888657dfddde6d1312e0", size = 4660472, upload-time = "2026-01-02T09:12:00.798Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/5273a99478956a099d533c4f46cbaa19fd69d606624f4334b85e50987a08/pillow-12.1.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2479c7f02f9d505682dc47df8c0ea1fc5e264c4d1629a5d63fe3e2334b89554", size = 6268974, upload-time = "2026-01-02T09:12:02.572Z" }, + { url = "https://files.pythonhosted.org/packages/b4/26/0bf714bc2e73d5267887d47931d53c4ceeceea6978148ed2ab2a4e6463c4/pillow-12.1.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f188d580bd870cda1e15183790d1cc2fa78f666e76077d103edf048eed9c356e", size = 8073070, upload-time = "2026-01-02T09:12:04.75Z" }, + { url = "https://files.pythonhosted.org/packages/43/cf/1ea826200de111a9d65724c54f927f3111dc5ae297f294b370a670c17786/pillow-12.1.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fde7ec5538ab5095cc02df38ee99b0443ff0e1c847a045554cf5f9af1f4aa82", size = 6380176, upload-time = "2026-01-02T09:12:06.626Z" }, + { url = "https://files.pythonhosted.org/packages/03/e0/7938dd2b2013373fd85d96e0f38d62b7a5a262af21ac274250c7ca7847c9/pillow-12.1.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ed07dca4a8464bada6139ab38f5382f83e5f111698caf3191cb8dbf27d908b4", size = 7067061, upload-time = "2026-01-02T09:12:08.624Z" }, + { url = "https://files.pythonhosted.org/packages/86/ad/a2aa97d37272a929a98437a8c0ac37b3cf012f4f8721e1bd5154699b2518/pillow-12.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f45bd71d1fa5e5749587613037b172e0b3b23159d1c00ef2fc920da6f470e6f0", size = 6491824, upload-time = "2026-01-02T09:12:10.488Z" }, + { url = "https://files.pythonhosted.org/packages/a4/44/80e46611b288d51b115826f136fb3465653c28f491068a72d3da49b54cd4/pillow-12.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:277518bf4fe74aa91489e1b20577473b19ee70fb97c374aa50830b279f25841b", size = 7190911, upload-time = "2026-01-02T09:12:12.772Z" }, + { url = "https://files.pythonhosted.org/packages/86/77/eacc62356b4cf81abe99ff9dbc7402750044aed02cfd6a503f7c6fc11f3e/pillow-12.1.0-cp313-cp313t-win32.whl", hash = "sha256:7315f9137087c4e0ee73a761b163fc9aa3b19f5f606a7fc08d83fd3e4379af65", size = 6336445, upload-time = "2026-01-02T09:12:14.775Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3c/57d81d0b74d218706dafccb87a87ea44262c43eef98eb3b164fd000e0491/pillow-12.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0ddedfaa8b5f0b4ffbc2fa87b556dc59f6bb4ecb14a53b33f9189713ae8053c0", size = 7045354, upload-time = "2026-01-02T09:12:16.599Z" }, + { url = "https://files.pythonhosted.org/packages/ac/82/8b9b97bba2e3576a340f93b044a3a3a09841170ab4c1eb0d5c93469fd32f/pillow-12.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:80941e6d573197a0c28f394753de529bb436b1ca990ed6e765cf42426abc39f8", size = 2454547, upload-time = "2026-01-02T09:12:18.704Z" }, + { url = "https://files.pythonhosted.org/packages/8c/87/bdf971d8bbcf80a348cc3bacfcb239f5882100fe80534b0ce67a784181d8/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:5cb7bc1966d031aec37ddb9dcf15c2da5b2e9f7cc3ca7c54473a20a927e1eb91", size = 4062533, upload-time = "2026-01-02T09:12:20.791Z" }, + { url = "https://files.pythonhosted.org/packages/ff/4f/5eb37a681c68d605eb7034c004875c81f86ec9ef51f5be4a63eadd58859a/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:97e9993d5ed946aba26baf9c1e8cf18adbab584b99f452ee72f7ee8acb882796", size = 4138546, upload-time = "2026-01-02T09:12:23.664Z" }, + { url = "https://files.pythonhosted.org/packages/11/6d/19a95acb2edbace40dcd582d077b991646b7083c41b98da4ed7555b59733/pillow-12.1.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:414b9a78e14ffeb98128863314e62c3f24b8a86081066625700b7985b3f529bd", size = 3601163, upload-time = "2026-01-02T09:12:26.338Z" }, + { url = "https://files.pythonhosted.org/packages/fc/36/2b8138e51cb42e4cc39c3297713455548be855a50558c3ac2beebdc251dd/pillow-12.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e6bdb408f7c9dd2a5ff2b14a3b0bb6d4deb29fb9961e6eb3ae2031ae9a5cec13", size = 5266086, upload-time = "2026-01-02T09:12:28.782Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/649056e4d22e1caa90816bf99cef0884aed607ed38075bd75f091a607a38/pillow-12.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3413c2ae377550f5487991d444428f1a8ae92784aac79caa8b1e3b89b175f77e", size = 4657344, upload-time = "2026-01-02T09:12:31.117Z" }, + { url = "https://files.pythonhosted.org/packages/6c/6b/c5742cea0f1ade0cd61485dc3d81f05261fc2276f537fbdc00802de56779/pillow-12.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e5dcbe95016e88437ecf33544ba5db21ef1b8dd6e1b434a2cb2a3d605299e643", size = 6232114, upload-time = "2026-01-02T09:12:32.936Z" }, + { url = "https://files.pythonhosted.org/packages/bf/8f/9f521268ce22d63991601aafd3d48d5ff7280a246a1ef62d626d67b44064/pillow-12.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d0a7735df32ccbcc98b98a1ac785cc4b19b580be1bdf0aeb5c03223220ea09d5", size = 8042708, upload-time = "2026-01-02T09:12:34.78Z" }, + { url = "https://files.pythonhosted.org/packages/1a/eb/257f38542893f021502a1bbe0c2e883c90b5cff26cc33b1584a841a06d30/pillow-12.1.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c27407a2d1b96774cbc4a7594129cc027339fd800cd081e44497722ea1179de", size = 6347762, upload-time = "2026-01-02T09:12:36.748Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5a/8ba375025701c09b309e8d5163c5a4ce0102fa86bbf8800eb0d7ac87bc51/pillow-12.1.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15c794d74303828eaa957ff8070846d0efe8c630901a1c753fdc63850e19ecd9", size = 7039265, upload-time = "2026-01-02T09:12:39.082Z" }, + { url = "https://files.pythonhosted.org/packages/cf/dc/cf5e4cdb3db533f539e88a7bbf9f190c64ab8a08a9bc7a4ccf55067872e4/pillow-12.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c990547452ee2800d8506c4150280757f88532f3de2a58e3022e9b179107862a", size = 6462341, upload-time = "2026-01-02T09:12:40.946Z" }, + { url = "https://files.pythonhosted.org/packages/d0/47/0291a25ac9550677e22eda48510cfc4fa4b2ef0396448b7fbdc0a6946309/pillow-12.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b63e13dd27da389ed9475b3d28510f0f954bca0041e8e551b2a4eb1eab56a39a", size = 7165395, upload-time = "2026-01-02T09:12:42.706Z" }, + { url = "https://files.pythonhosted.org/packages/4f/4c/e005a59393ec4d9416be06e6b45820403bb946a778e39ecec62f5b2b991e/pillow-12.1.0-cp314-cp314-win32.whl", hash = "sha256:1a949604f73eb07a8adab38c4fe50791f9919344398bdc8ac6b307f755fc7030", size = 6431413, upload-time = "2026-01-02T09:12:44.944Z" }, + { url = "https://files.pythonhosted.org/packages/1c/af/f23697f587ac5f9095d67e31b81c95c0249cd461a9798a061ed6709b09b5/pillow-12.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f9f6a650743f0ddee5593ac9e954ba1bdbc5e150bc066586d4f26127853ab94", size = 7176779, upload-time = "2026-01-02T09:12:46.727Z" }, + { url = "https://files.pythonhosted.org/packages/b3/36/6a51abf8599232f3e9afbd16d52829376a68909fe14efe29084445db4b73/pillow-12.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:808b99604f7873c800c4840f55ff389936ef1948e4e87645eaf3fccbc8477ac4", size = 2543105, upload-time = "2026-01-02T09:12:49.243Z" }, + { url = "https://files.pythonhosted.org/packages/82/54/2e1dd20c8749ff225080d6ba465a0cab4387f5db0d1c5fb1439e2d99923f/pillow-12.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc11908616c8a283cf7d664f77411a5ed2a02009b0097ff8abbba5e79128ccf2", size = 5268571, upload-time = "2026-01-02T09:12:51.11Z" }, + { url = "https://files.pythonhosted.org/packages/57/61/571163a5ef86ec0cf30d265ac2a70ae6fc9e28413d1dc94fa37fae6bda89/pillow-12.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:896866d2d436563fa2a43a9d72f417874f16b5545955c54a64941e87c1376c61", size = 4660426, upload-time = "2026-01-02T09:12:52.865Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e1/53ee5163f794aef1bf84243f755ee6897a92c708505350dd1923f4afec48/pillow-12.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8e178e3e99d3c0ea8fc64b88447f7cac8ccf058af422a6cedc690d0eadd98c51", size = 6269908, upload-time = "2026-01-02T09:12:54.884Z" }, + { url = "https://files.pythonhosted.org/packages/bc/0b/b4b4106ff0ee1afa1dc599fde6ab230417f800279745124f6c50bcffed8e/pillow-12.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:079af2fb0c599c2ec144ba2c02766d1b55498e373b3ac64687e43849fbbef5bc", size = 8074733, upload-time = "2026-01-02T09:12:56.802Z" }, + { url = "https://files.pythonhosted.org/packages/19/9f/80b411cbac4a732439e629a26ad3ef11907a8c7fc5377b7602f04f6fe4e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdec5e43377761c5dbca620efb69a77f6855c5a379e32ac5b158f54c84212b14", size = 6381431, upload-time = "2026-01-02T09:12:58.823Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b7/d65c45db463b66ecb6abc17c6ba6917a911202a07662247e1355ce1789e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:565c986f4b45c020f5421a4cea13ef294dde9509a8577f29b2fc5edc7587fff8", size = 7068529, upload-time = "2026-01-02T09:13:00.885Z" }, + { url = "https://files.pythonhosted.org/packages/50/96/dfd4cd726b4a45ae6e3c669fc9e49deb2241312605d33aba50499e9d9bd1/pillow-12.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43aca0a55ce1eefc0aefa6253661cb54571857b1a7b2964bd8a1e3ef4b729924", size = 6492981, upload-time = "2026-01-02T09:13:03.314Z" }, + { url = "https://files.pythonhosted.org/packages/4d/1c/b5dc52cf713ae46033359c5ca920444f18a6359ce1020dd3e9c553ea5bc6/pillow-12.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0deedf2ea233722476b3a81e8cdfbad786f7adbed5d848469fa59fe52396e4ef", size = 7191878, upload-time = "2026-01-02T09:13:05.276Z" }, + { url = "https://files.pythonhosted.org/packages/53/26/c4188248bd5edaf543864fe4834aebe9c9cb4968b6f573ce014cc42d0720/pillow-12.1.0-cp314-cp314t-win32.whl", hash = "sha256:b17fbdbe01c196e7e159aacb889e091f28e61020a8abeac07b68079b6e626988", size = 6438703, upload-time = "2026-01-02T09:13:07.491Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0e/69ed296de8ea05cb03ee139cee600f424ca166e632567b2d66727f08c7ed/pillow-12.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27b9baecb428899db6c0de572d6d305cfaf38ca1596b5c0542a5182e3e74e8c6", size = 7182927, upload-time = "2026-01-02T09:13:09.841Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f5/68334c015eed9b5cff77814258717dec591ded209ab5b6fb70e2ae873d1d/pillow-12.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f61333d817698bdcdd0f9d7793e365ac3d2a21c1f1eb02b32ad6aefb8d8ea831", size = 2545104, upload-time = "2026-01-02T09:13:12.068Z" }, + { url = "https://files.pythonhosted.org/packages/8b/bc/224b1d98cffd7164b14707c91aac83c07b047fbd8f58eba4066a3e53746a/pillow-12.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ca94b6aac0d7af2a10ba08c0f888b3d5114439b6b3ef39968378723622fed377", size = 5228605, upload-time = "2026-01-02T09:13:14.084Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ca/49ca7769c4550107de049ed85208240ba0f330b3f2e316f24534795702ce/pillow-12.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:351889afef0f485b84078ea40fe33727a0492b9af3904661b0abbafee0355b72", size = 4622245, upload-time = "2026-01-02T09:13:15.964Z" }, + { url = "https://files.pythonhosted.org/packages/73/48/fac807ce82e5955bcc2718642b94b1bd22a82a6d452aea31cbb678cddf12/pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb0984b30e973f7e2884362b7d23d0a348c7143ee559f38ef3eaab640144204c", size = 5247593, upload-time = "2026-01-02T09:13:17.913Z" }, + { url = "https://files.pythonhosted.org/packages/d2/95/3e0742fe358c4664aed4fd05d5f5373dcdad0b27af52aa0972568541e3f4/pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:84cabc7095dd535ca934d57e9ce2a72ffd216e435a84acb06b2277b1de2689bd", size = 6989008, upload-time = "2026-01-02T09:13:20.083Z" }, + { url = "https://files.pythonhosted.org/packages/5a/74/fe2ac378e4e202e56d50540d92e1ef4ff34ed687f3c60f6a121bcf99437e/pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53d8b764726d3af1a138dd353116f774e3862ec7e3794e0c8781e30db0f35dfc", size = 5313824, upload-time = "2026-01-02T09:13:22.405Z" }, + { url = "https://files.pythonhosted.org/packages/f3/77/2a60dee1adee4e2655ac328dd05c02a955c1cd683b9f1b82ec3feb44727c/pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5da841d81b1a05ef940a8567da92decaa15bc4d7dedb540a8c219ad83d91808a", size = 5963278, upload-time = "2026-01-02T09:13:24.706Z" }, + { url = "https://files.pythonhosted.org/packages/2d/71/64e9b1c7f04ae0027f788a248e6297d7fcc29571371fe7d45495a78172c0/pillow-12.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:75af0b4c229ac519b155028fa1be632d812a519abba9b46b20e50c6caa184f19", size = 7029809, upload-time = "2026-01-02T09:13:26.541Z" }, ] [[package]] name = "platformdirs" -version = "4.5.0" +version = "4.5.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, + { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, ] [[package]] @@ -1750,43 +1695,45 @@ wheels = [ [[package]] name = "protobuf" -version = "6.33.0" +version = "6.33.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/19/ff/64a6c8f420818bb873713988ca5492cba3a7946be57e027ac63495157d97/protobuf-6.33.0.tar.gz", hash = "sha256:140303d5c8d2037730c548f8c7b93b20bb1dc301be280c378b82b8894589c954", size = 443463, upload-time = "2025-10-15T20:39:52.159Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/b8/cda15d9d46d03d4aa3a67cb6bffe05173440ccf86a9541afaf7ac59a1b6b/protobuf-6.33.4.tar.gz", hash = "sha256:dc2e61bca3b10470c1912d166fe0af67bfc20eb55971dcef8dfa48ce14f0ed91", size = 444346, upload-time = "2026-01-12T18:33:40.109Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/ee/52b3fa8feb6db4a833dfea4943e175ce645144532e8a90f72571ad85df4e/protobuf-6.33.0-cp310-abi3-win32.whl", hash = "sha256:d6101ded078042a8f17959eccd9236fb7a9ca20d3b0098bbcb91533a5680d035", size = 425593, upload-time = "2025-10-15T20:39:40.29Z" }, - { url = "https://files.pythonhosted.org/packages/7b/c6/7a465f1825872c55e0341ff4a80198743f73b69ce5d43ab18043699d1d81/protobuf-6.33.0-cp310-abi3-win_amd64.whl", hash = "sha256:9a031d10f703f03768f2743a1c403af050b6ae1f3480e9c140f39c45f81b13ee", size = 436882, upload-time = "2025-10-15T20:39:42.841Z" }, - { url = "https://files.pythonhosted.org/packages/e1/a9/b6eee662a6951b9c3640e8e452ab3e09f117d99fc10baa32d1581a0d4099/protobuf-6.33.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:905b07a65f1a4b72412314082c7dbfae91a9e8b68a0cc1577515f8df58ecf455", size = 427521, upload-time = "2025-10-15T20:39:43.803Z" }, - { url = "https://files.pythonhosted.org/packages/10/35/16d31e0f92c6d2f0e77c2a3ba93185130ea13053dd16200a57434c882f2b/protobuf-6.33.0-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e0697ece353e6239b90ee43a9231318302ad8353c70e6e45499fa52396debf90", size = 324445, upload-time = "2025-10-15T20:39:44.932Z" }, - { url = "https://files.pythonhosted.org/packages/e6/eb/2a981a13e35cda8b75b5585aaffae2eb904f8f351bdd3870769692acbd8a/protobuf-6.33.0-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:e0a1715e4f27355afd9570f3ea369735afc853a6c3951a6afe1f80d8569ad298", size = 339159, upload-time = "2025-10-15T20:39:46.186Z" }, - { url = "https://files.pythonhosted.org/packages/21/51/0b1cbad62074439b867b4e04cc09b93f6699d78fd191bed2bbb44562e077/protobuf-6.33.0-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:35be49fd3f4fefa4e6e2aacc35e8b837d6703c37a2168a55ac21e9b1bc7559ef", size = 323172, upload-time = "2025-10-15T20:39:47.465Z" }, - { url = "https://files.pythonhosted.org/packages/07/d1/0a28c21707807c6aacd5dc9c3704b2aa1effbf37adebd8caeaf68b17a636/protobuf-6.33.0-py3-none-any.whl", hash = "sha256:25c9e1963c6734448ea2d308cfa610e692b801304ba0908d7bfa564ac5132995", size = 170477, upload-time = "2025-10-15T20:39:51.311Z" }, + { url = "https://files.pythonhosted.org/packages/e0/be/24ef9f3095bacdf95b458543334d0c4908ccdaee5130420bf064492c325f/protobuf-6.33.4-cp310-abi3-win32.whl", hash = "sha256:918966612c8232fc6c24c78e1cd89784307f5814ad7506c308ee3cf86662850d", size = 425612, upload-time = "2026-01-12T18:33:29.656Z" }, + { url = "https://files.pythonhosted.org/packages/31/ad/e5693e1974a28869e7cd244302911955c1cebc0161eb32dfa2b25b6e96f0/protobuf-6.33.4-cp310-abi3-win_amd64.whl", hash = "sha256:8f11ffae31ec67fc2554c2ef891dcb561dae9a2a3ed941f9e134c2db06657dbc", size = 436962, upload-time = "2026-01-12T18:33:31.345Z" }, + { url = "https://files.pythonhosted.org/packages/66/15/6ee23553b6bfd82670207ead921f4d8ef14c107e5e11443b04caeb5ab5ec/protobuf-6.33.4-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:2fe67f6c014c84f655ee06f6f66213f9254b3a8b6bda6cda0ccd4232c73c06f0", size = 427612, upload-time = "2026-01-12T18:33:32.646Z" }, + { url = "https://files.pythonhosted.org/packages/2b/48/d301907ce6d0db75f959ca74f44b475a9caa8fcba102d098d3c3dd0f2d3f/protobuf-6.33.4-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:757c978f82e74d75cba88eddec479df9b99a42b31193313b75e492c06a51764e", size = 324484, upload-time = "2026-01-12T18:33:33.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/1c/e53078d3f7fe710572ab2dcffd993e1e3b438ae71cfc031b71bae44fcb2d/protobuf-6.33.4-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c7c64f259c618f0bef7bee042075e390debbf9682334be2b67408ec7c1c09ee6", size = 339256, upload-time = "2026-01-12T18:33:35.231Z" }, + { url = "https://files.pythonhosted.org/packages/e8/8e/971c0edd084914f7ee7c23aa70ba89e8903918adca179319ee94403701d5/protobuf-6.33.4-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:3df850c2f8db9934de4cf8f9152f8dc2558f49f298f37f90c517e8e5c84c30e9", size = 323311, upload-time = "2026-01-12T18:33:36.305Z" }, + { url = "https://files.pythonhosted.org/packages/75/b1/1dc83c2c661b4c62d56cc081706ee33a4fc2835bd90f965baa2663ef7676/protobuf-6.33.4-py3-none-any.whl", hash = "sha256:1fe3730068fcf2e595816a6c34fe66eeedd37d51d0400b72fabc848811fdc1bc", size = 170532, upload-time = "2026-01-12T18:33:39.199Z" }, ] [[package]] name = "psutil" -version = "7.1.3" +version = "7.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e1/88/bdd0a41e5857d5d703287598cbf08dad90aed56774ea52ae071bae9071b6/psutil-7.1.3.tar.gz", hash = "sha256:6c86281738d77335af7aec228328e944b30930899ea760ecf33a4dba66be5e74", size = 489059, upload-time = "2025-11-02T12:25:54.619Z" } +sdist = { url = "https://files.pythonhosted.org/packages/73/cb/09e5184fb5fc0358d110fc3ca7f6b1d033800734d34cac10f4136cfac10e/psutil-7.2.1.tar.gz", hash = "sha256:f7583aec590485b43ca601dd9cea0dcd65bd7bb21d30ef4ddbf4ea6b5ed1bdd3", size = 490253, upload-time = "2025-12-29T08:26:00.169Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/93/0c49e776b8734fef56ec9c5c57f923922f2cf0497d62e0f419465f28f3d0/psutil-7.1.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0005da714eee687b4b8decd3d6cc7c6db36215c9e74e5ad2264b90c3df7d92dc", size = 239751, upload-time = "2025-11-02T12:25:58.161Z" }, - { url = "https://files.pythonhosted.org/packages/6f/8d/b31e39c769e70780f007969815195a55c81a63efebdd4dbe9e7a113adb2f/psutil-7.1.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:19644c85dcb987e35eeeaefdc3915d059dac7bd1167cdcdbf27e0ce2df0c08c0", size = 240368, upload-time = "2025-11-02T12:26:00.491Z" }, - { url = "https://files.pythonhosted.org/packages/62/61/23fd4acc3c9eebbf6b6c78bcd89e5d020cfde4acf0a9233e9d4e3fa698b4/psutil-7.1.3-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95ef04cf2e5ba0ab9eaafc4a11eaae91b44f4ef5541acd2ee91d9108d00d59a7", size = 287134, upload-time = "2025-11-02T12:26:02.613Z" }, - { url = "https://files.pythonhosted.org/packages/30/1c/f921a009ea9ceb51aa355cb0cc118f68d354db36eae18174bab63affb3e6/psutil-7.1.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1068c303be3a72f8e18e412c5b2a8f6d31750fb152f9cb106b54090296c9d251", size = 289904, upload-time = "2025-11-02T12:26:05.207Z" }, - { url = "https://files.pythonhosted.org/packages/a6/82/62d68066e13e46a5116df187d319d1724b3f437ddd0f958756fc052677f4/psutil-7.1.3-cp313-cp313t-win_amd64.whl", hash = "sha256:18349c5c24b06ac5612c0428ec2a0331c26443d259e2a0144a9b24b4395b58fa", size = 249642, upload-time = "2025-11-02T12:26:07.447Z" }, - { url = "https://files.pythonhosted.org/packages/df/ad/c1cd5fe965c14a0392112f68362cfceb5230819dbb5b1888950d18a11d9f/psutil-7.1.3-cp313-cp313t-win_arm64.whl", hash = "sha256:c525ffa774fe4496282fb0b1187725793de3e7c6b29e41562733cae9ada151ee", size = 245518, upload-time = "2025-11-02T12:26:09.719Z" }, - { url = "https://files.pythonhosted.org/packages/2e/bb/6670bded3e3236eb4287c7bcdc167e9fae6e1e9286e437f7111caed2f909/psutil-7.1.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b403da1df4d6d43973dc004d19cee3b848e998ae3154cc8097d139b77156c353", size = 239843, upload-time = "2025-11-02T12:26:11.968Z" }, - { url = "https://files.pythonhosted.org/packages/b8/66/853d50e75a38c9a7370ddbeefabdd3d3116b9c31ef94dc92c6729bc36bec/psutil-7.1.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad81425efc5e75da3f39b3e636293360ad8d0b49bed7df824c79764fb4ba9b8b", size = 240369, upload-time = "2025-11-02T12:26:14.358Z" }, - { url = "https://files.pythonhosted.org/packages/41/bd/313aba97cb5bfb26916dc29cf0646cbe4dd6a89ca69e8c6edce654876d39/psutil-7.1.3-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f33a3702e167783a9213db10ad29650ebf383946e91bc77f28a5eb083496bc9", size = 288210, upload-time = "2025-11-02T12:26:16.699Z" }, - { url = "https://files.pythonhosted.org/packages/c2/fa/76e3c06e760927a0cfb5705eb38164254de34e9bd86db656d4dbaa228b04/psutil-7.1.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fac9cd332c67f4422504297889da5ab7e05fd11e3c4392140f7370f4208ded1f", size = 291182, upload-time = "2025-11-02T12:26:18.848Z" }, - { url = "https://files.pythonhosted.org/packages/0f/1d/5774a91607035ee5078b8fd747686ebec28a962f178712de100d00b78a32/psutil-7.1.3-cp314-cp314t-win_amd64.whl", hash = "sha256:3792983e23b69843aea49c8f5b8f115572c5ab64c153bada5270086a2123c7e7", size = 250466, upload-time = "2025-11-02T12:26:21.183Z" }, - { url = "https://files.pythonhosted.org/packages/00/ca/e426584bacb43a5cb1ac91fae1937f478cd8fbe5e4ff96574e698a2c77cd/psutil-7.1.3-cp314-cp314t-win_arm64.whl", hash = "sha256:31d77fcedb7529f27bb3a0472bea9334349f9a04160e8e6e5020f22c59893264", size = 245756, upload-time = "2025-11-02T12:26:23.148Z" }, - { url = "https://files.pythonhosted.org/packages/ef/94/46b9154a800253e7ecff5aaacdf8ebf43db99de4a2dfa18575b02548654e/psutil-7.1.3-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2bdbcd0e58ca14996a42adf3621a6244f1bb2e2e528886959c72cf1e326677ab", size = 238359, upload-time = "2025-11-02T12:26:25.284Z" }, - { url = "https://files.pythonhosted.org/packages/68/3a/9f93cff5c025029a36d9a92fef47220ab4692ee7f2be0fba9f92813d0cb8/psutil-7.1.3-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:bc31fa00f1fbc3c3802141eede66f3a2d51d89716a194bf2cd6fc68310a19880", size = 239171, upload-time = "2025-11-02T12:26:27.23Z" }, - { url = "https://files.pythonhosted.org/packages/ce/b1/5f49af514f76431ba4eea935b8ad3725cdeb397e9245ab919dbc1d1dc20f/psutil-7.1.3-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb428f9f05c1225a558f53e30ccbad9930b11c3fc206836242de1091d3e7dd3", size = 263261, upload-time = "2025-11-02T12:26:29.48Z" }, - { url = "https://files.pythonhosted.org/packages/e0/95/992c8816a74016eb095e73585d747e0a8ea21a061ed3689474fabb29a395/psutil-7.1.3-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56d974e02ca2c8eb4812c3f76c30e28836fffc311d55d979f1465c1feeb2b68b", size = 264635, upload-time = "2025-11-02T12:26:31.74Z" }, - { url = "https://files.pythonhosted.org/packages/55/4c/c3ed1a622b6ae2fd3c945a366e64eb35247a31e4db16cf5095e269e8eb3c/psutil-7.1.3-cp37-abi3-win_amd64.whl", hash = "sha256:f39c2c19fe824b47484b96f9692932248a54c43799a84282cfe58d05a6449efd", size = 247633, upload-time = "2025-11-02T12:26:33.887Z" }, - { url = "https://files.pythonhosted.org/packages/c9/ad/33b2ccec09bf96c2b2ef3f9a6f66baac8253d7565d8839e024a6b905d45d/psutil-7.1.3-cp37-abi3-win_arm64.whl", hash = "sha256:bd0d69cee829226a761e92f28140bec9a5ee9d5b4fb4b0cc589068dbfff559b1", size = 244608, upload-time = "2025-11-02T12:26:36.136Z" }, + { url = "https://files.pythonhosted.org/packages/77/8e/f0c242053a368c2aa89584ecd1b054a18683f13d6e5a318fc9ec36582c94/psutil-7.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ba9f33bb525b14c3ea563b2fd521a84d2fa214ec59e3e6a2858f78d0844dd60d", size = 129624, upload-time = "2025-12-29T08:26:04.255Z" }, + { url = "https://files.pythonhosted.org/packages/26/97/a58a4968f8990617decee234258a2b4fc7cd9e35668387646c1963e69f26/psutil-7.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:81442dac7abfc2f4f4385ea9e12ddf5a796721c0f6133260687fec5c3780fa49", size = 130132, upload-time = "2025-12-29T08:26:06.228Z" }, + { url = "https://files.pythonhosted.org/packages/db/6d/ed44901e830739af5f72a85fa7ec5ff1edea7f81bfbf4875e409007149bd/psutil-7.2.1-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea46c0d060491051d39f0d2cff4f98d5c72b288289f57a21556cc7d504db37fc", size = 180612, upload-time = "2025-12-29T08:26:08.276Z" }, + { url = "https://files.pythonhosted.org/packages/c7/65/b628f8459bca4efbfae50d4bf3feaab803de9a160b9d5f3bd9295a33f0c2/psutil-7.2.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35630d5af80d5d0d49cfc4d64c1c13838baf6717a13effb35869a5919b854cdf", size = 183201, upload-time = "2025-12-29T08:26:10.622Z" }, + { url = "https://files.pythonhosted.org/packages/fb/23/851cadc9764edcc18f0effe7d0bf69f727d4cf2442deb4a9f78d4e4f30f2/psutil-7.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:923f8653416604e356073e6e0bccbe7c09990acef442def2f5640dd0faa9689f", size = 139081, upload-time = "2025-12-29T08:26:12.483Z" }, + { url = "https://files.pythonhosted.org/packages/59/82/d63e8494ec5758029f31c6cb06d7d161175d8281e91d011a4a441c8a43b5/psutil-7.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cfbe6b40ca48019a51827f20d830887b3107a74a79b01ceb8cc8de4ccb17b672", size = 134767, upload-time = "2025-12-29T08:26:14.528Z" }, + { url = "https://files.pythonhosted.org/packages/05/c2/5fb764bd61e40e1fe756a44bd4c21827228394c17414ade348e28f83cd79/psutil-7.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:494c513ccc53225ae23eec7fe6e1482f1b8a44674241b54561f755a898650679", size = 129716, upload-time = "2025-12-29T08:26:16.017Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d2/935039c20e06f615d9ca6ca0ab756cf8408a19d298ffaa08666bc18dc805/psutil-7.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3fce5f92c22b00cdefd1645aa58ab4877a01679e901555067b1bd77039aa589f", size = 130133, upload-time = "2025-12-29T08:26:18.009Z" }, + { url = "https://files.pythonhosted.org/packages/77/69/19f1eb0e01d24c2b3eacbc2f78d3b5add8a89bf0bb69465bc8d563cc33de/psutil-7.2.1-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93f3f7b0bb07711b49626e7940d6fe52aa9940ad86e8f7e74842e73189712129", size = 181518, upload-time = "2025-12-29T08:26:20.241Z" }, + { url = "https://files.pythonhosted.org/packages/e1/6d/7e18b1b4fa13ad370787626c95887b027656ad4829c156bb6569d02f3262/psutil-7.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d34d2ca888208eea2b5c68186841336a7f5e0b990edec929be909353a202768a", size = 184348, upload-time = "2025-12-29T08:26:22.215Z" }, + { url = "https://files.pythonhosted.org/packages/98/60/1672114392dd879586d60dd97896325df47d9a130ac7401318005aab28ec/psutil-7.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2ceae842a78d1603753561132d5ad1b2f8a7979cb0c283f5b52fb4e6e14b1a79", size = 140400, upload-time = "2025-12-29T08:26:23.993Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7b/d0e9d4513c46e46897b46bcfc410d51fc65735837ea57a25170f298326e6/psutil-7.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:08a2f175e48a898c8eb8eace45ce01777f4785bc744c90aa2cc7f2fa5462a266", size = 135430, upload-time = "2025-12-29T08:26:25.999Z" }, + { url = "https://files.pythonhosted.org/packages/c5/cf/5180eb8c8bdf6a503c6919f1da28328bd1e6b3b1b5b9d5b01ae64f019616/psutil-7.2.1-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b2e953fcfaedcfbc952b44744f22d16575d3aa78eb4f51ae74165b4e96e55f42", size = 128137, upload-time = "2025-12-29T08:26:27.759Z" }, + { url = "https://files.pythonhosted.org/packages/c5/2c/78e4a789306a92ade5000da4f5de3255202c534acdadc3aac7b5458fadef/psutil-7.2.1-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:05cc68dbb8c174828624062e73078e7e35406f4ca2d0866c272c2410d8ef06d1", size = 128947, upload-time = "2025-12-29T08:26:29.548Z" }, + { url = "https://files.pythonhosted.org/packages/29/f8/40e01c350ad9a2b3cb4e6adbcc8a83b17ee50dd5792102b6142385937db5/psutil-7.2.1-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e38404ca2bb30ed7267a46c02f06ff842e92da3bb8c5bfdadbd35a5722314d8", size = 154694, upload-time = "2025-12-29T08:26:32.147Z" }, + { url = "https://files.pythonhosted.org/packages/06/e4/b751cdf839c011a9714a783f120e6a86b7494eb70044d7d81a25a5cd295f/psutil-7.2.1-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab2b98c9fc19f13f59628d94df5cc4cc4844bc572467d113a8b517d634e362c6", size = 156136, upload-time = "2025-12-29T08:26:34.079Z" }, + { url = "https://files.pythonhosted.org/packages/44/ad/bbf6595a8134ee1e94a4487af3f132cef7fce43aef4a93b49912a48c3af7/psutil-7.2.1-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f78baafb38436d5a128f837fab2d92c276dfb48af01a240b861ae02b2413ada8", size = 148108, upload-time = "2025-12-29T08:26:36.225Z" }, + { url = "https://files.pythonhosted.org/packages/1c/15/dd6fd869753ce82ff64dcbc18356093471a5a5adf4f77ed1f805d473d859/psutil-7.2.1-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:99a4cd17a5fdd1f3d014396502daa70b5ec21bf4ffe38393e152f8e449757d67", size = 147402, upload-time = "2025-12-29T08:26:39.21Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/d9317542e3f2b180c4306e3f45d3c922d7e86d8ce39f941bb9e2e9d8599e/psutil-7.2.1-cp37-abi3-win_amd64.whl", hash = "sha256:b1b0671619343aa71c20ff9767eced0483e4fc9e1f489d50923738caf6a03c17", size = 136938, upload-time = "2025-12-29T08:26:41.036Z" }, + { url = "https://files.pythonhosted.org/packages/3e/73/2ce007f4198c80fcf2cb24c169884f833fe93fbc03d55d302627b094ee91/psutil-7.2.1-cp37-abi3-win_arm64.whl", hash = "sha256:0d67c1822c355aa6f7314d92018fb4268a76668a536f133599b91edd48759442", size = 133836, upload-time = "2025-12-29T08:26:43.086Z" }, ] [[package]] @@ -1809,45 +1756,52 @@ wheels = [ [[package]] name = "pyarrow" -version = "21.0.0" +version = "23.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ef/c2/ea068b8f00905c06329a3dfcd40d0fcc2b7d0f2e355bdb25b65e0a0e4cd4/pyarrow-21.0.0.tar.gz", hash = "sha256:5051f2dccf0e283ff56335760cbc8622cf52264d67e359d5569541ac11b6d5bc", size = 1133487, upload-time = "2025-07-18T00:57:31.761Z" } +sdist = { url = "https://files.pythonhosted.org/packages/01/33/ffd9c3eb087fa41dd79c3cf20c4c0ae3cdb877c4f8e1107a446006344924/pyarrow-23.0.0.tar.gz", hash = "sha256:180e3150e7edfcd182d3d9afba72f7cf19839a497cc76555a8dce998a8f67615", size = 1167185, upload-time = "2026-01-18T16:19:42.218Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/17/d9/110de31880016e2afc52d8580b397dbe47615defbf09ca8cf55f56c62165/pyarrow-21.0.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:e563271e2c5ff4d4a4cbeb2c83d5cf0d4938b891518e676025f7268c6fe5fe26", size = 31196837, upload-time = "2025-07-18T00:54:34.755Z" }, - { url = "https://files.pythonhosted.org/packages/df/5f/c1c1997613abf24fceb087e79432d24c19bc6f7259cab57c2c8e5e545fab/pyarrow-21.0.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:fee33b0ca46f4c85443d6c450357101e47d53e6c3f008d658c27a2d020d44c79", size = 32659470, upload-time = "2025-07-18T00:54:38.329Z" }, - { url = "https://files.pythonhosted.org/packages/3e/ed/b1589a777816ee33ba123ba1e4f8f02243a844fed0deec97bde9fb21a5cf/pyarrow-21.0.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:7be45519b830f7c24b21d630a31d48bcebfd5d4d7f9d3bdb49da9cdf6d764edb", size = 41055619, upload-time = "2025-07-18T00:54:42.172Z" }, - { url = "https://files.pythonhosted.org/packages/44/28/b6672962639e85dc0ac36f71ab3a8f5f38e01b51343d7aa372a6b56fa3f3/pyarrow-21.0.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:26bfd95f6bff443ceae63c65dc7e048670b7e98bc892210acba7e4995d3d4b51", size = 42733488, upload-time = "2025-07-18T00:54:47.132Z" }, - { url = "https://files.pythonhosted.org/packages/f8/cc/de02c3614874b9089c94eac093f90ca5dfa6d5afe45de3ba847fd950fdf1/pyarrow-21.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:bd04ec08f7f8bd113c55868bd3fc442a9db67c27af098c5f814a3091e71cc61a", size = 43329159, upload-time = "2025-07-18T00:54:51.686Z" }, - { url = "https://files.pythonhosted.org/packages/a6/3e/99473332ac40278f196e105ce30b79ab8affab12f6194802f2593d6b0be2/pyarrow-21.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9b0b14b49ac10654332a805aedfc0147fb3469cbf8ea951b3d040dab12372594", size = 45050567, upload-time = "2025-07-18T00:54:56.679Z" }, - { url = "https://files.pythonhosted.org/packages/7b/f5/c372ef60593d713e8bfbb7e0c743501605f0ad00719146dc075faf11172b/pyarrow-21.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:9d9f8bcb4c3be7738add259738abdeddc363de1b80e3310e04067aa1ca596634", size = 26217959, upload-time = "2025-07-18T00:55:00.482Z" }, - { url = "https://files.pythonhosted.org/packages/94/dc/80564a3071a57c20b7c32575e4a0120e8a330ef487c319b122942d665960/pyarrow-21.0.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:c077f48aab61738c237802836fc3844f85409a46015635198761b0d6a688f87b", size = 31243234, upload-time = "2025-07-18T00:55:03.812Z" }, - { url = "https://files.pythonhosted.org/packages/ea/cc/3b51cb2db26fe535d14f74cab4c79b191ed9a8cd4cbba45e2379b5ca2746/pyarrow-21.0.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:689f448066781856237eca8d1975b98cace19b8dd2ab6145bf49475478bcaa10", size = 32714370, upload-time = "2025-07-18T00:55:07.495Z" }, - { url = "https://files.pythonhosted.org/packages/24/11/a4431f36d5ad7d83b87146f515c063e4d07ef0b7240876ddb885e6b44f2e/pyarrow-21.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:479ee41399fcddc46159a551705b89c05f11e8b8cb8e968f7fec64f62d91985e", size = 41135424, upload-time = "2025-07-18T00:55:11.461Z" }, - { url = "https://files.pythonhosted.org/packages/74/dc/035d54638fc5d2971cbf1e987ccd45f1091c83bcf747281cf6cc25e72c88/pyarrow-21.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:40ebfcb54a4f11bcde86bc586cbd0272bac0d516cfa539c799c2453768477569", size = 42823810, upload-time = "2025-07-18T00:55:16.301Z" }, - { url = "https://files.pythonhosted.org/packages/2e/3b/89fced102448a9e3e0d4dded1f37fa3ce4700f02cdb8665457fcc8015f5b/pyarrow-21.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8d58d8497814274d3d20214fbb24abcad2f7e351474357d552a8d53bce70c70e", size = 43391538, upload-time = "2025-07-18T00:55:23.82Z" }, - { url = "https://files.pythonhosted.org/packages/fb/bb/ea7f1bd08978d39debd3b23611c293f64a642557e8141c80635d501e6d53/pyarrow-21.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:585e7224f21124dd57836b1530ac8f2df2afc43c861d7bf3d58a4870c42ae36c", size = 45120056, upload-time = "2025-07-18T00:55:28.231Z" }, - { url = "https://files.pythonhosted.org/packages/6e/0b/77ea0600009842b30ceebc3337639a7380cd946061b620ac1a2f3cb541e2/pyarrow-21.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:555ca6935b2cbca2c0e932bedd853e9bc523098c39636de9ad4693b5b1df86d6", size = 26220568, upload-time = "2025-07-18T00:55:32.122Z" }, - { url = "https://files.pythonhosted.org/packages/ca/d4/d4f817b21aacc30195cf6a46ba041dd1be827efa4a623cc8bf39a1c2a0c0/pyarrow-21.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:3a302f0e0963db37e0a24a70c56cf91a4faa0bca51c23812279ca2e23481fccd", size = 31160305, upload-time = "2025-07-18T00:55:35.373Z" }, - { url = "https://files.pythonhosted.org/packages/a2/9c/dcd38ce6e4b4d9a19e1d36914cb8e2b1da4e6003dd075474c4cfcdfe0601/pyarrow-21.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:b6b27cf01e243871390474a211a7922bfbe3bda21e39bc9160daf0da3fe48876", size = 32684264, upload-time = "2025-07-18T00:55:39.303Z" }, - { url = "https://files.pythonhosted.org/packages/4f/74/2a2d9f8d7a59b639523454bec12dba35ae3d0a07d8ab529dc0809f74b23c/pyarrow-21.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:e72a8ec6b868e258a2cd2672d91f2860ad532d590ce94cdf7d5e7ec674ccf03d", size = 41108099, upload-time = "2025-07-18T00:55:42.889Z" }, - { url = "https://files.pythonhosted.org/packages/ad/90/2660332eeb31303c13b653ea566a9918484b6e4d6b9d2d46879a33ab0622/pyarrow-21.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b7ae0bbdc8c6674259b25bef5d2a1d6af5d39d7200c819cf99e07f7dfef1c51e", size = 42829529, upload-time = "2025-07-18T00:55:47.069Z" }, - { url = "https://files.pythonhosted.org/packages/33/27/1a93a25c92717f6aa0fca06eb4700860577d016cd3ae51aad0e0488ac899/pyarrow-21.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:58c30a1729f82d201627c173d91bd431db88ea74dcaa3885855bc6203e433b82", size = 43367883, upload-time = "2025-07-18T00:55:53.069Z" }, - { url = "https://files.pythonhosted.org/packages/05/d9/4d09d919f35d599bc05c6950095e358c3e15148ead26292dfca1fb659b0c/pyarrow-21.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:072116f65604b822a7f22945a7a6e581cfa28e3454fdcc6939d4ff6090126623", size = 45133802, upload-time = "2025-07-18T00:55:57.714Z" }, - { url = "https://files.pythonhosted.org/packages/71/30/f3795b6e192c3ab881325ffe172e526499eb3780e306a15103a2764916a2/pyarrow-21.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:cf56ec8b0a5c8c9d7021d6fd754e688104f9ebebf1bf4449613c9531f5346a18", size = 26203175, upload-time = "2025-07-18T00:56:01.364Z" }, - { url = "https://files.pythonhosted.org/packages/16/ca/c7eaa8e62db8fb37ce942b1ea0c6d7abfe3786ca193957afa25e71b81b66/pyarrow-21.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:e99310a4ebd4479bcd1964dff9e14af33746300cb014aa4a3781738ac63baf4a", size = 31154306, upload-time = "2025-07-18T00:56:04.42Z" }, - { url = "https://files.pythonhosted.org/packages/ce/e8/e87d9e3b2489302b3a1aea709aaca4b781c5252fcb812a17ab6275a9a484/pyarrow-21.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:d2fe8e7f3ce329a71b7ddd7498b3cfac0eeb200c2789bd840234f0dc271a8efe", size = 32680622, upload-time = "2025-07-18T00:56:07.505Z" }, - { url = "https://files.pythonhosted.org/packages/84/52/79095d73a742aa0aba370c7942b1b655f598069489ab387fe47261a849e1/pyarrow-21.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:f522e5709379d72fb3da7785aa489ff0bb87448a9dc5a75f45763a795a089ebd", size = 41104094, upload-time = "2025-07-18T00:56:10.994Z" }, - { url = "https://files.pythonhosted.org/packages/89/4b/7782438b551dbb0468892a276b8c789b8bbdb25ea5c5eb27faadd753e037/pyarrow-21.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:69cbbdf0631396e9925e048cfa5bce4e8c3d3b41562bbd70c685a8eb53a91e61", size = 42825576, upload-time = "2025-07-18T00:56:15.569Z" }, - { url = "https://files.pythonhosted.org/packages/b3/62/0f29de6e0a1e33518dec92c65be0351d32d7ca351e51ec5f4f837a9aab91/pyarrow-21.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:731c7022587006b755d0bdb27626a1a3bb004bb56b11fb30d98b6c1b4718579d", size = 43368342, upload-time = "2025-07-18T00:56:19.531Z" }, - { url = "https://files.pythonhosted.org/packages/90/c7/0fa1f3f29cf75f339768cc698c8ad4ddd2481c1742e9741459911c9ac477/pyarrow-21.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc56bc708f2d8ac71bd1dcb927e458c93cec10b98eb4120206a4091db7b67b99", size = 45131218, upload-time = "2025-07-18T00:56:23.347Z" }, - { url = "https://files.pythonhosted.org/packages/01/63/581f2076465e67b23bc5a37d4a2abff8362d389d29d8105832e82c9c811c/pyarrow-21.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:186aa00bca62139f75b7de8420f745f2af12941595bbbfa7ed3870ff63e25636", size = 26087551, upload-time = "2025-07-18T00:56:26.758Z" }, - { url = "https://files.pythonhosted.org/packages/c9/ab/357d0d9648bb8241ee7348e564f2479d206ebe6e1c47ac5027c2e31ecd39/pyarrow-21.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:a7a102574faa3f421141a64c10216e078df467ab9576684d5cd696952546e2da", size = 31290064, upload-time = "2025-07-18T00:56:30.214Z" }, - { url = "https://files.pythonhosted.org/packages/3f/8a/5685d62a990e4cac2043fc76b4661bf38d06efed55cf45a334b455bd2759/pyarrow-21.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:1e005378c4a2c6db3ada3ad4c217b381f6c886f0a80d6a316fe586b90f77efd7", size = 32727837, upload-time = "2025-07-18T00:56:33.935Z" }, - { url = "https://files.pythonhosted.org/packages/fc/de/c0828ee09525c2bafefd3e736a248ebe764d07d0fd762d4f0929dbc516c9/pyarrow-21.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:65f8e85f79031449ec8706b74504a316805217b35b6099155dd7e227eef0d4b6", size = 41014158, upload-time = "2025-07-18T00:56:37.528Z" }, - { url = "https://files.pythonhosted.org/packages/6e/26/a2865c420c50b7a3748320b614f3484bfcde8347b2639b2b903b21ce6a72/pyarrow-21.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:3a81486adc665c7eb1a2bde0224cfca6ceaba344a82a971ef059678417880eb8", size = 42667885, upload-time = "2025-07-18T00:56:41.483Z" }, - { url = "https://files.pythonhosted.org/packages/0a/f9/4ee798dc902533159250fb4321267730bc0a107d8c6889e07c3add4fe3a5/pyarrow-21.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fc0d2f88b81dcf3ccf9a6ae17f89183762c8a94a5bdcfa09e05cfe413acf0503", size = 43276625, upload-time = "2025-07-18T00:56:48.002Z" }, - { url = "https://files.pythonhosted.org/packages/5a/da/e02544d6997037a4b0d22d8e5f66bc9315c3671371a8b18c79ade1cefe14/pyarrow-21.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6299449adf89df38537837487a4f8d3bd91ec94354fdd2a7d30bc11c48ef6e79", size = 44951890, upload-time = "2025-07-18T00:56:52.568Z" }, - { url = "https://files.pythonhosted.org/packages/e5/4e/519c1bc1876625fe6b71e9a28287c43ec2f20f73c658b9ae1d485c0c206e/pyarrow-21.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:222c39e2c70113543982c6b34f3077962b44fca38c0bd9e68bb6781534425c10", size = 26371006, upload-time = "2025-07-18T00:56:56.379Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c0/57fe251102ca834fee0ef69a84ad33cc0ff9d5dfc50f50b466846356ecd7/pyarrow-23.0.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:5574d541923efcbfdf1294a2746ae3b8c2498a2dc6cd477882f6f4e7b1ac08d3", size = 34276762, upload-time = "2026-01-18T16:14:34.128Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4e/24130286548a5bc250cbed0b6bbf289a2775378a6e0e6f086ae8c68fc098/pyarrow-23.0.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:2ef0075c2488932e9d3c2eb3482f9459c4be629aa673b725d5e3cf18f777f8e4", size = 35821420, upload-time = "2026-01-18T16:14:40.699Z" }, + { url = "https://files.pythonhosted.org/packages/ee/55/a869e8529d487aa2e842d6c8865eb1e2c9ec33ce2786eb91104d2c3e3f10/pyarrow-23.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:65666fc269669af1ef1c14478c52222a2aa5c907f28b68fb50a203c777e4f60c", size = 44457412, upload-time = "2026-01-18T16:14:49.051Z" }, + { url = "https://files.pythonhosted.org/packages/36/81/1de4f0edfa9a483bbdf0082a05790bd6a20ed2169ea12a65039753be3a01/pyarrow-23.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:4d85cb6177198f3812db4788e394b757223f60d9a9f5ad6634b3e32be1525803", size = 47534285, upload-time = "2026-01-18T16:14:56.748Z" }, + { url = "https://files.pythonhosted.org/packages/f2/04/464a052d673b5ece074518f27377861662449f3c1fdb39ce740d646fd098/pyarrow-23.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1a9ff6fa4141c24a03a1a434c63c8fa97ce70f8f36bccabc18ebba905ddf0f17", size = 48157913, upload-time = "2026-01-18T16:15:05.114Z" }, + { url = "https://files.pythonhosted.org/packages/f4/1b/32a4de9856ee6688c670ca2def588382e573cce45241a965af04c2f61687/pyarrow-23.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:84839d060a54ae734eb60a756aeacb62885244aaa282f3c968f5972ecc7b1ecc", size = 50582529, upload-time = "2026-01-18T16:15:12.846Z" }, + { url = "https://files.pythonhosted.org/packages/db/c7/d6581f03e9b9e44ea60b52d1750ee1a7678c484c06f939f45365a45f7eef/pyarrow-23.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:a149a647dbfe928ce8830a713612aa0b16e22c64feac9d1761529778e4d4eaa5", size = 27542646, upload-time = "2026-01-18T16:15:18.89Z" }, + { url = "https://files.pythonhosted.org/packages/3d/bd/c861d020831ee57609b73ea721a617985ece817684dc82415b0bc3e03ac3/pyarrow-23.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5961a9f646c232697c24f54d3419e69b4261ba8a8b66b0ac54a1851faffcbab8", size = 34189116, upload-time = "2026-01-18T16:15:28.054Z" }, + { url = "https://files.pythonhosted.org/packages/8c/23/7725ad6cdcbaf6346221391e7b3eecd113684c805b0a95f32014e6fa0736/pyarrow-23.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:632b3e7c3d232f41d64e1a4a043fb82d44f8a349f339a1188c6a0dd9d2d47d8a", size = 35803831, upload-time = "2026-01-18T16:15:33.798Z" }, + { url = "https://files.pythonhosted.org/packages/57/06/684a421543455cdc2944d6a0c2cc3425b028a4c6b90e34b35580c4899743/pyarrow-23.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:76242c846db1411f1d6c2cc3823be6b86b40567ee24493344f8226ba34a81333", size = 44436452, upload-time = "2026-01-18T16:15:41.598Z" }, + { url = "https://files.pythonhosted.org/packages/c6/6f/8f9eb40c2328d66e8b097777ddcf38494115ff9f1b5bc9754ba46991191e/pyarrow-23.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b73519f8b52ae28127000986bf228fda781e81d3095cd2d3ece76eb5cf760e1b", size = 47557396, upload-time = "2026-01-18T16:15:51.252Z" }, + { url = "https://files.pythonhosted.org/packages/10/6e/f08075f1472e5159553501fde2cc7bc6700944bdabe49a03f8a035ee6ccd/pyarrow-23.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:068701f6823449b1b6469120f399a1239766b117d211c5d2519d4ed5861f75de", size = 48147129, upload-time = "2026-01-18T16:16:00.299Z" }, + { url = "https://files.pythonhosted.org/packages/7d/82/d5a680cd507deed62d141cc7f07f7944a6766fc51019f7f118e4d8ad0fb8/pyarrow-23.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1801ba947015d10e23bca9dd6ef5d0e9064a81569a89b6e9a63b59224fd060df", size = 50596642, upload-time = "2026-01-18T16:16:08.502Z" }, + { url = "https://files.pythonhosted.org/packages/a9/26/4f29c61b3dce9fa7780303b86895ec6a0917c9af927101daaaf118fbe462/pyarrow-23.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:52265266201ec25b6839bf6bd4ea918ca6d50f31d13e1cf200b4261cd11dc25c", size = 27660628, upload-time = "2026-01-18T16:16:15.28Z" }, + { url = "https://files.pythonhosted.org/packages/66/34/564db447d083ec7ff93e0a883a597d2f214e552823bfc178a2d0b1f2c257/pyarrow-23.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:ad96a597547af7827342ffb3c503c8316e5043bb09b47a84885ce39394c96e00", size = 34184630, upload-time = "2026-01-18T16:16:22.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/3a/3999daebcb5e6119690c92a621c4d78eef2ffba7a0a1b56386d2875fcd77/pyarrow-23.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:b9edf990df77c2901e79608f08c13fbde60202334a4fcadb15c1f57bf7afee43", size = 35796820, upload-time = "2026-01-18T16:16:29.441Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ee/39195233056c6a8d0976d7d1ac1cd4fe21fb0ec534eca76bc23ef3f60e11/pyarrow-23.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:36d1b5bc6ddcaff0083ceec7e2561ed61a51f49cce8be079ee8ed406acb6fdef", size = 44438735, upload-time = "2026-01-18T16:16:38.79Z" }, + { url = "https://files.pythonhosted.org/packages/2c/41/6a7328ee493527e7afc0c88d105ecca69a3580e29f2faaeac29308369fd7/pyarrow-23.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:4292b889cd224f403304ddda8b63a36e60f92911f89927ec8d98021845ea21be", size = 47557263, upload-time = "2026-01-18T16:16:46.248Z" }, + { url = "https://files.pythonhosted.org/packages/c6/ee/34e95b21ee84db494eae60083ddb4383477b31fb1fd19fd866d794881696/pyarrow-23.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dfd9e133e60eaa847fd80530a1b89a052f09f695d0b9c34c235ea6b2e0924cf7", size = 48153529, upload-time = "2026-01-18T16:16:53.412Z" }, + { url = "https://files.pythonhosted.org/packages/52/88/8a8d83cea30f4563efa1b7bf51d241331ee5cd1b185a7e063f5634eca415/pyarrow-23.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:832141cc09fac6aab1cd3719951d23301396968de87080c57c9a7634e0ecd068", size = 50598851, upload-time = "2026-01-18T16:17:01.133Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4c/2929c4be88723ba025e7b3453047dc67e491c9422965c141d24bab6b5962/pyarrow-23.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:7a7d067c9a88faca655c71bcc30ee2782038d59c802d57950826a07f60d83c4c", size = 27577747, upload-time = "2026-01-18T16:18:02.413Z" }, + { url = "https://files.pythonhosted.org/packages/64/52/564a61b0b82d72bd68ec3aef1adda1e3eba776f89134b9ebcb5af4b13cb6/pyarrow-23.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:ce9486e0535a843cf85d990e2ec5820a47918235183a5c7b8b97ed7e92c2d47d", size = 34446038, upload-time = "2026-01-18T16:17:07.861Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c9/232d4f9855fd1de0067c8a7808a363230d223c83aeee75e0fe6eab851ba9/pyarrow-23.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:075c29aeaa685fd1182992a9ed2499c66f084ee54eea47da3eb76e125e06064c", size = 35921142, upload-time = "2026-01-18T16:17:15.401Z" }, + { url = "https://files.pythonhosted.org/packages/96/f2/60af606a3748367b906bb82d41f0032e059f075444445d47e32a7ff1df62/pyarrow-23.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:799965a5379589510d888be3094c2296efd186a17ca1cef5b77703d4d5121f53", size = 44490374, upload-time = "2026-01-18T16:17:23.93Z" }, + { url = "https://files.pythonhosted.org/packages/ff/2d/7731543050a678ea3a413955a2d5d80d2a642f270aa57a3cb7d5a86e3f46/pyarrow-23.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ef7cac8fe6fccd8b9e7617bfac785b0371a7fe26af59463074e4882747145d40", size = 47527896, upload-time = "2026-01-18T16:17:33.393Z" }, + { url = "https://files.pythonhosted.org/packages/5a/90/f3342553b7ac9879413aed46500f1637296f3c8222107523a43a1c08b42a/pyarrow-23.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:15a414f710dc927132dd67c361f78c194447479555af57317066ee5116b90e9e", size = 48210401, upload-time = "2026-01-18T16:17:42.012Z" }, + { url = "https://files.pythonhosted.org/packages/f3/da/9862ade205ecc46c172b6ce5038a74b5151c7401e36255f15975a45878b2/pyarrow-23.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e0d2e6915eca7d786be6a77bf227fbc06d825a75b5b5fe9bcbef121dec32685", size = 50579677, upload-time = "2026-01-18T16:17:50.241Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4c/f11f371f5d4740a5dafc2e11c76bcf42d03dfdb2d68696da97de420b6963/pyarrow-23.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:4b317ea6e800b5704e5e5929acb6e2dc13e9276b708ea97a39eb8b345aa2658b", size = 27631889, upload-time = "2026-01-18T16:17:56.55Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/15aec78bcf43a0c004067bd33eb5352836a29a49db8581fc56f2b6ca88b7/pyarrow-23.0.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:20b187ed9550d233a872074159f765f52f9d92973191cd4b93f293a19efbe377", size = 34213265, upload-time = "2026-01-18T16:18:07.904Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/deb2c594bbba41c37c5d9aa82f510376998352aa69dfcb886cb4b18ad80f/pyarrow-23.0.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:18ec84e839b493c3886b9b5e06861962ab4adfaeb79b81c76afbd8d84c7d5fda", size = 35819211, upload-time = "2026-01-18T16:18:13.94Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e5/ee82af693cb7b5b2b74f6524cdfede0e6ace779d7720ebca24d68b57c36b/pyarrow-23.0.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:e438dd3f33894e34fd02b26bd12a32d30d006f5852315f611aa4add6c7fab4bc", size = 44502313, upload-time = "2026-01-18T16:18:20.367Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/95c61ad82236495f3c31987e85135926ba3ec7f3819296b70a68d8066b49/pyarrow-23.0.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:a244279f240c81f135631be91146d7fa0e9e840e1dfed2aba8483eba25cd98e6", size = 47585886, upload-time = "2026-01-18T16:18:27.544Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6e/a72d901f305201802f016d015de1e05def7706fff68a1dedefef5dc7eff7/pyarrow-23.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c4692e83e42438dba512a570c6eaa42be2f8b6c0f492aea27dec54bdc495103a", size = 48207055, upload-time = "2026-01-18T16:18:35.425Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e5/5de029c537630ca18828db45c30e2a78da03675a70ac6c3528203c416fe3/pyarrow-23.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae7f30f898dfe44ea69654a35c93e8da4cef6606dc4c72394068fd95f8e9f54a", size = 50619812, upload-time = "2026-01-18T16:18:43.553Z" }, + { url = "https://files.pythonhosted.org/packages/59/8d/2af846cd2412e67a087f5bda4a8e23dfd4ebd570f777db2e8686615dafc1/pyarrow-23.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:5b86bb649e4112fb0614294b7d0a175c7513738876b89655605ebb87c804f861", size = 28263851, upload-time = "2026-01-18T16:19:38.567Z" }, + { url = "https://files.pythonhosted.org/packages/7b/7f/caab863e587041156f6786c52e64151b7386742c8c27140f637176e9230e/pyarrow-23.0.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:ebc017d765d71d80a3f8584ca0566b53e40464586585ac64176115baa0ada7d3", size = 34463240, upload-time = "2026-01-18T16:18:49.755Z" }, + { url = "https://files.pythonhosted.org/packages/c9/fa/3a5b8c86c958e83622b40865e11af0857c48ec763c11d472c87cd518283d/pyarrow-23.0.0-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:0800cc58a6d17d159df823f87ad66cefebf105b982493d4bad03ee7fab84b993", size = 35935712, upload-time = "2026-01-18T16:18:55.626Z" }, + { url = "https://files.pythonhosted.org/packages/c5/08/17a62078fc1a53decb34a9aa79cf9009efc74d63d2422e5ade9fed2f99e3/pyarrow-23.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:3a7c68c722da9bb5b0f8c10e3eae71d9825a4b429b40b32709df5d1fa55beb3d", size = 44503523, upload-time = "2026-01-18T16:19:03.958Z" }, + { url = "https://files.pythonhosted.org/packages/cc/70/84d45c74341e798aae0323d33b7c39194e23b1abc439ceaf60a68a7a969a/pyarrow-23.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:bd5556c24622df90551063ea41f559b714aa63ca953db884cfb958559087a14e", size = 47542490, upload-time = "2026-01-18T16:19:11.208Z" }, + { url = "https://files.pythonhosted.org/packages/61/d9/d1274b0e6f19e235de17441e53224f4716574b2ca837022d55702f24d71d/pyarrow-23.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:54810f6e6afc4ffee7c2e0051b61722fbea9a4961b46192dcfae8ea12fa09059", size = 48233605, upload-time = "2026-01-18T16:19:19.544Z" }, + { url = "https://files.pythonhosted.org/packages/39/07/e4e2d568cb57543d84482f61e510732820cddb0f47c4bb7df629abfed852/pyarrow-23.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:14de7d48052cf4b0ed174533eafa3cfe0711b8076ad70bede32cf59f744f0d7c", size = 50603979, upload-time = "2026-01-18T16:19:26.717Z" }, + { url = "https://files.pythonhosted.org/packages/72/9c/47693463894b610f8439b2e970b82ef81e9599c757bf2049365e40ff963c/pyarrow-23.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:427deac1f535830a744a4f04a6ac183a64fcac4341b3f618e693c41b7b98d2b0", size = 28338905, upload-time = "2026-01-18T16:19:32.93Z" }, ] [[package]] @@ -1859,14 +1813,125 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, ] +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + [[package]] name = "pydeck" version = "0.9.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jinja2" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.3.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a1/ca/40e14e196864a0f61a92abb14d09b3d3da98f94ccb03b49cf51688140dab/pydeck-0.9.1.tar.gz", hash = "sha256:f74475ae637951d63f2ee58326757f8d4f9cd9f2a457cf42950715003e2cb605", size = 3832240, upload-time = "2024-05-10T15:36:21.153Z" } wheels = [ @@ -1884,34 +1949,62 @@ wheels = [ [[package]] name = "pymupdf" -version = "1.26.6" +version = "1.26.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ec/d7/a6f0e03a117fa2ad79c4b898203bb212b17804f92558a6a339298faca7bb/pymupdf-1.26.6.tar.gz", hash = "sha256:a2b4531cd4ab36d6f1f794bb6d3c33b49bda22f36d58bb1f3e81cbc10183bd2b", size = 84322494, upload-time = "2025-11-05T15:20:46.786Z" } +sdist = { url = "https://files.pythonhosted.org/packages/48/d6/09b28f027b510838559f7748807192149c419b30cb90e6d5f0cf916dc9dc/pymupdf-1.26.7.tar.gz", hash = "sha256:71add8bdc8eb1aaa207c69a13400693f06ad9b927bea976f5d5ab9df0bb489c3", size = 84327033, upload-time = "2025-12-11T21:48:50.694Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/5c/dec354eee5fe4966c715f33818ed4193e0e6c986cf8484de35b6c167fb8e/pymupdf-1.26.6-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:e46f320a136ad55e5219e8f0f4061bdf3e4c12b126d2740d5a49f73fae7ea176", size = 23178988, upload-time = "2025-11-05T14:31:19.834Z" }, - { url = "https://files.pythonhosted.org/packages/ec/a0/11adb742d18142bd623556cd3b5d64649816decc5eafd30efc9498657e76/pymupdf-1.26.6-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:6844cd2396553c0fa06de4869d5d5ecb1260e6fc3b9d85abe8fa35f14dd9d688", size = 22469764, upload-time = "2025-11-05T14:32:34.654Z" }, - { url = "https://files.pythonhosted.org/packages/e4/c8/377cf20e31f58d4c243bfcf2d3cb7466d5b97003b10b9f1161f11eb4a994/pymupdf-1.26.6-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:617ba69e02c44f0da1c0e039ea4a26cf630849fd570e169c71daeb8ac52a81d6", size = 23502227, upload-time = "2025-11-06T11:03:56.934Z" }, - { url = "https://files.pythonhosted.org/packages/4f/bf/6e02e3d84b32c137c71a0a3dcdba8f2f6e9950619a3bc272245c7c06a051/pymupdf-1.26.6-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:7777d0b7124c2ebc94849536b6a1fb85d158df3b9d873935e63036559391534c", size = 24115381, upload-time = "2025-11-05T14:33:54.338Z" }, - { url = "https://files.pythonhosted.org/packages/ab/9d/30f7fcb3776bfedde66c06297960debe4883b1667294a1ee9426c942e94d/pymupdf-1.26.6-cp310-abi3-win32.whl", hash = "sha256:8f3ef05befc90ca6bb0f12983200a7048d5bff3e1c1edef1bb3de60b32cb5274", size = 17203613, upload-time = "2025-11-05T17:19:47.494Z" }, - { url = "https://files.pythonhosted.org/packages/f9/e8/989f4eaa369c7166dc24f0eaa3023f13788c40ff1b96701f7047421554a8/pymupdf-1.26.6-cp310-abi3-win_amd64.whl", hash = "sha256:ce02ca96ed0d1acfd00331a4d41a34c98584d034155b06fd4ec0f051718de7ba", size = 18405680, upload-time = "2025-11-05T14:34:48.672Z" }, + { url = "https://files.pythonhosted.org/packages/94/35/cd74cea1787b2247702ef8522186bdef32e9cb30a099e6bb864627ef6045/pymupdf-1.26.7-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:07085718dfdae5ab83b05eb5eb397f863bcc538fe05135318a01ea353e7a1353", size = 23179369, upload-time = "2025-12-11T21:47:21.587Z" }, + { url = "https://files.pythonhosted.org/packages/72/74/448b6172927c829c6a3fba80078d7b0a016ebbe2c9ee528821f5ea21677a/pymupdf-1.26.7-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:31aa9c8377ea1eea02934b92f4dcf79fb2abba0bf41f8a46d64c3e31546a3c02", size = 22470101, upload-time = "2025-12-11T21:47:37.105Z" }, + { url = "https://files.pythonhosted.org/packages/65/e7/47af26f3ac76be7ac3dd4d6cc7ee105948a8355d774e5ca39857bf91c11c/pymupdf-1.26.7-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e419b609996434a14a80fa060adec72c434a1cca6a511ec54db9841bc5d51b3c", size = 23502486, upload-time = "2025-12-12T09:51:25.824Z" }, + { url = "https://files.pythonhosted.org/packages/2a/6b/3de1714d734ff949be1e90a22375d0598d3540b22ae73eb85c2d7d1f36a9/pymupdf-1.26.7-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:69dfc78f206a96e5b3ac22741263ebab945fdf51f0dbe7c5757c3511b23d9d72", size = 24115727, upload-time = "2025-12-11T21:47:51.274Z" }, + { url = "https://files.pythonhosted.org/packages/62/9b/f86224847949577a523be2207315ae0fd3155b5d909cd66c274d095349a3/pymupdf-1.26.7-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1d5106f46e1ca0d64d46bd51892372a4f82076bdc14a9678d33d630702abca36", size = 24324386, upload-time = "2025-12-12T14:58:45.483Z" }, + { url = "https://files.pythonhosted.org/packages/85/8e/a117d39092ca645fde8b903f4a941d9aa75b370a67b4f1f435f56393dc5a/pymupdf-1.26.7-cp310-abi3-win32.whl", hash = "sha256:7c9645b6f5452629c747690190350213d3e5bbdb6b2eca227d82702b327f6eee", size = 17203888, upload-time = "2025-12-12T13:59:57.613Z" }, + { url = "https://files.pythonhosted.org/packages/dd/c3/d0047678146c294469c33bae167c8ace337deafb736b0bf97b9bc481aa65/pymupdf-1.26.7-cp310-abi3-win_amd64.whl", hash = "sha256:425b1befe40d41b72eb0fe211711c7ae334db5eb60307e9dd09066ed060cceba", size = 18405952, upload-time = "2025-12-11T21:48:02.947Z" }, +] + +[[package]] +name = "pypdfium2" +version = "5.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/83/173dab58beb6c7e772b838199014c173a2436018dd7cfde9bbf4a3be15da/pypdfium2-5.3.0.tar.gz", hash = "sha256:2873ffc95fcb01f329257ebc64a5fdce44b36447b6b171fe62f7db5dc3269885", size = 268742, upload-time = "2026-01-05T16:29:03.02Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/a4/6bb5b5918c7fc236ec426be8a0205a984fe0a26ae23d5e4dd497398a6571/pypdfium2-5.3.0-py3-none-android_23_arm64_v8a.whl", hash = "sha256:885df6c78d41600cb086dc0c76b912d165b5bd6931ca08138329ea5a991b3540", size = 2763287, upload-time = "2026-01-05T16:28:24.21Z" }, + { url = "https://files.pythonhosted.org/packages/3e/64/24b41b906006bf07099b095f0420ee1f01a3a83a899f3e3731e4da99c06a/pypdfium2-5.3.0-py3-none-android_23_armeabi_v7a.whl", hash = "sha256:6e53dee6b333ee77582499eff800300fb5aa0c7eb8f52f95ccb5ca35ebc86d48", size = 2303285, upload-time = "2026-01-05T16:28:26.274Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c0/3ec73f4ded83ba6c02acf6e9d228501759d5d74fe57f1b93849ab92dcc20/pypdfium2-5.3.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ce4466bdd62119fe25a5f74d107acc9db8652062bf217057630c6ff0bb419523", size = 2816066, upload-time = "2026-01-05T16:28:28.099Z" }, + { url = "https://files.pythonhosted.org/packages/62/ca/e553b3b8b5c2cdc3d955cc313493ac27bbe63fc22624769d56ded585dd5e/pypdfium2-5.3.0-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:cc2647fd03db42b8a56a8835e8bc7899e604e2042cd6fedeea53483185612907", size = 2945545, upload-time = "2026-01-05T16:28:29.489Z" }, + { url = "https://files.pythonhosted.org/packages/a1/56/615b776071e95c8570d579038256d0c77969ff2ff381e427be4ab8967f44/pypdfium2-5.3.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35e205f537ddb4069e4b4e22af7ffe84fcf2d686c3fee5e5349f73268a0ef1ca", size = 2979892, upload-time = "2026-01-05T16:28:31.088Z" }, + { url = "https://files.pythonhosted.org/packages/df/10/27114199b765bdb7d19a9514c07036ad2fc3a579b910e7823ba167ead6de/pypdfium2-5.3.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5795298f44050797ac030994fc2525ea35d2d714efe70058e0ee22e5f613f27", size = 2765738, upload-time = "2026-01-05T16:28:33.18Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d7/2a3afa35e6c205a4f6264c33b8d2f659707989f93c30b336aa58575f66fa/pypdfium2-5.3.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7cd43dfceb77137e69e74c933d41506da1dddaff70f3a794fb0ad0d73e90d75", size = 3064338, upload-time = "2026-01-05T16:28:34.731Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f1/6658755cf6e369bb51d0bccb81c51c300404fbe67c2f894c90000b6442dd/pypdfium2-5.3.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d5956867558fd3a793e58691cf169718864610becb765bfe74dd83f05cbf1ae3", size = 3415059, upload-time = "2026-01-05T16:28:37.313Z" }, + { url = "https://files.pythonhosted.org/packages/f5/34/f86482134fa641deb1f524c45ec7ebd6fc8d404df40c5657ddfce528593e/pypdfium2-5.3.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3ff1071e9a782625822658dfe6e29e3a644a66960f8713bb17819f5a0ac5987", size = 2998517, upload-time = "2026-01-05T16:28:38.873Z" }, + { url = "https://files.pythonhosted.org/packages/09/34/40ab99425dcf503c172885904c5dc356c052bfdbd085f9f3cc920e0b8b25/pypdfium2-5.3.0-py3-none-manylinux_2_27_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f319c46ead49d289ab8c1ed2ea63c91e684f35bdc4cf4dc52191c441182ac481", size = 3673154, upload-time = "2026-01-05T16:28:40.347Z" }, + { url = "https://files.pythonhosted.org/packages/a5/67/0f7532f80825a7728a5cbff3f1104857f8f9fe49ebfd6cb25582a89ae8e1/pypdfium2-5.3.0-py3-none-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6dc67a186da0962294321cace6ccc0a4d212dbc5e9522c640d35725a812324b8", size = 2965002, upload-time = "2026-01-05T16:28:42.143Z" }, + { url = "https://files.pythonhosted.org/packages/ce/6c/c03d2a3d6621b77aac9604bce1c060de2af94950448787298501eac6c6a2/pypdfium2-5.3.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0ad0afd3d2b5b54d86287266fd6ae3fef0e0a1a3df9d2c4984b3e3f8f70e6330", size = 4130530, upload-time = "2026-01-05T16:28:44.264Z" }, + { url = "https://files.pythonhosted.org/packages/af/39/9ad1f958cbe35d4693ae87c09ebafda4bb3e4709c7ccaec86c1a829163a3/pypdfium2-5.3.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1afe35230dc3951b3e79b934c0c35a2e79e2372d06503fce6cf1926d2a816f47", size = 3746568, upload-time = "2026-01-05T16:28:45.897Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e2/4d32310166c2d6955d924737df8b0a3e3efc8d133344a98b10f96320157d/pypdfium2-5.3.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:00385793030cadce08469085cd21b168fd8ff981b009685fef3103bdc5fc4686", size = 4336683, upload-time = "2026-01-05T16:28:47.584Z" }, + { url = "https://files.pythonhosted.org/packages/14/ea/38c337ff12a8cec4b00fd4fdb0a63a70597a344581e20b02addbd301ab56/pypdfium2-5.3.0-py3-none-musllinux_1_2_ppc64le.whl", hash = "sha256:d911e82676398949697fef80b7f412078df14d725a91c10e383b727051530285", size = 4375030, upload-time = "2026-01-05T16:28:49.5Z" }, + { url = "https://files.pythonhosted.org/packages/a1/77/9d8de90c35d2fc383be8819bcde52f5821dacbd7404a0225e4010b99d080/pypdfium2-5.3.0-py3-none-musllinux_1_2_riscv64.whl", hash = "sha256:ca1dc625ed347fac3d9002a3ed33d521d5803409bd572e7b3f823c12ab2ef58f", size = 3928914, upload-time = "2026-01-05T16:28:51.433Z" }, + { url = "https://files.pythonhosted.org/packages/a5/39/9d4a6fbd78fcb6803b0ea5e4952a31d6182a0aaa2609cfcd0eb88446fdb8/pypdfium2-5.3.0-py3-none-musllinux_1_2_s390x.whl", hash = "sha256:ea4f9db2d3575f22cd41f4c7a855240ded842f135e59a961b5b1351a65ce2b6e", size = 4997777, upload-time = "2026-01-05T16:28:53.589Z" }, + { url = "https://files.pythonhosted.org/packages/9d/38/cdd4ed085c264234a59ad32df1dfe432c77a7403da2381e0fcc1ba60b74e/pypdfium2-5.3.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:0ea24409613df350223c6afc50911c99dca0d43ddaf2616c5a1ebdffa3e1bcb5", size = 4179895, upload-time = "2026-01-05T16:28:55.322Z" }, + { url = "https://files.pythonhosted.org/packages/93/4c/d2f40145c9012482699664f615d7ae540a346c84f68a8179449e69dcc4d8/pypdfium2-5.3.0-py3-none-win32.whl", hash = "sha256:5bf695d603f9eb8fdd7c1786add5cf420d57fbc81df142ed63c029ce29614df9", size = 2993570, upload-time = "2026-01-05T16:28:58.37Z" }, + { url = "https://files.pythonhosted.org/packages/2c/dc/1388ea650020c26ef3f68856b9227e7f153dcaf445e7e4674a0b8f26891e/pypdfium2-5.3.0-py3-none-win_amd64.whl", hash = "sha256:8365af22a39d4373c265f8e90e561cd64d4ddeaf5e6a66546a8caed216ab9574", size = 3102340, upload-time = "2026-01-05T16:28:59.933Z" }, + { url = "https://files.pythonhosted.org/packages/c8/71/a433668d33999b3aeb2c2dda18aaf24948e862ea2ee148078a35daac6c1c/pypdfium2-5.3.0-py3-none-win_arm64.whl", hash = "sha256:0b2c6bf825e084d91d34456be54921da31e9199d9530b05435d69d1a80501a12", size = 2940987, upload-time = "2026-01-05T16:29:01.511Z" }, ] [[package]] name = "pytest" -version = "9.0.0" +version = "9.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/da/1d/eb34f286b164c5e431a810a38697409cca1112cee04b287bb56ac486730b/pytest-9.0.0.tar.gz", hash = "sha256:8f44522eafe4137b0f35c9ce3072931a788a21ee40a2ed279e817d3cc16ed21e", size = 1562764, upload-time = "2025-11-08T17:25:33.34Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/72/99/cafef234114a3b6d9f3aaed0723b437c40c57bdb7b3e4c3a575bc4890052/pytest-9.0.0-py3-none-any.whl", hash = "sha256:e5ccdf10b0bac554970ee88fc1a4ad0ee5d221f8ef22321f9b7e4584e19d7f96", size = 373364, upload-time = "2025-11-08T17:25:31.811Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] [[package]] @@ -1989,15 +2082,6 @@ version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, - { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, - { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, - { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, - { url = "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", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, - { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, - { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, - { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, - { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, @@ -2056,16 +2140,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/04/0b/3c9baedbdf613ecaa7aa07027780b8867f57b6293b6ee50de316c9f3222b/pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540", size = 281750, upload-time = "2025-09-08T23:10:18.157Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/67/b9/52aa9ec2867528b54f1e60846728d8b4d84726630874fee3a91e66c7df81/pyzmq-27.1.0-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:508e23ec9bc44c0005c4946ea013d9317ae00ac67778bd47519fdf5a0e930ff4", size = 1329850, upload-time = "2025-09-08T23:07:26.274Z" }, - { url = "https://files.pythonhosted.org/packages/99/64/5653e7b7425b169f994835a2b2abf9486264401fdef18df91ddae47ce2cc/pyzmq-27.1.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:507b6f430bdcf0ee48c0d30e734ea89ce5567fd7b8a0f0044a369c176aa44556", size = 906380, upload-time = "2025-09-08T23:07:29.78Z" }, - { url = "https://files.pythonhosted.org/packages/73/78/7d713284dbe022f6440e391bd1f3c48d9185673878034cfb3939cdf333b2/pyzmq-27.1.0-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf7b38f9fd7b81cb6d9391b2946382c8237fd814075c6aa9c3b746d53076023b", size = 666421, upload-time = "2025-09-08T23:07:31.263Z" }, - { url = "https://files.pythonhosted.org/packages/30/76/8f099f9d6482450428b17c4d6b241281af7ce6a9de8149ca8c1c649f6792/pyzmq-27.1.0-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03ff0b279b40d687691a6217c12242ee71f0fba28bf8626ff50e3ef0f4410e1e", size = 854149, upload-time = "2025-09-08T23:07:33.17Z" }, - { url = "https://files.pythonhosted.org/packages/59/f0/37fbfff06c68016019043897e4c969ceab18bde46cd2aca89821fcf4fb2e/pyzmq-27.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:677e744fee605753eac48198b15a2124016c009a11056f93807000ab11ce6526", size = 1655070, upload-time = "2025-09-08T23:07:35.205Z" }, - { url = "https://files.pythonhosted.org/packages/47/14/7254be73f7a8edc3587609554fcaa7bfd30649bf89cd260e4487ca70fdaa/pyzmq-27.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:dd2fec2b13137416a1c5648b7009499bcc8fea78154cd888855fa32514f3dad1", size = 2033441, upload-time = "2025-09-08T23:07:37.432Z" }, - { url = "https://files.pythonhosted.org/packages/22/dc/49f2be26c6f86f347e796a4d99b19167fc94503f0af3fd010ad262158822/pyzmq-27.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:08e90bb4b57603b84eab1d0ca05b3bbb10f60c1839dc471fc1c9e1507bef3386", size = 1891529, upload-time = "2025-09-08T23:07:39.047Z" }, - { url = "https://files.pythonhosted.org/packages/a3/3e/154fb963ae25be70c0064ce97776c937ecc7d8b0259f22858154a9999769/pyzmq-27.1.0-cp310-cp310-win32.whl", hash = "sha256:a5b42d7a0658b515319148875fcb782bbf118dd41c671b62dae33666c2213bda", size = 567276, upload-time = "2025-09-08T23:07:40.695Z" }, - { url = "https://files.pythonhosted.org/packages/62/b2/f4ab56c8c595abcb26b2be5fd9fa9e6899c1e5ad54964e93ae8bb35482be/pyzmq-27.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:c0bb87227430ee3aefcc0ade2088100e528d5d3298a0a715a64f3d04c60ba02f", size = 632208, upload-time = "2025-09-08T23:07:42.298Z" }, - { url = "https://files.pythonhosted.org/packages/3b/e3/be2cc7ab8332bdac0522fdb64c17b1b6241a795bee02e0196636ec5beb79/pyzmq-27.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:9a916f76c2ab8d045b19f2286851a38e9ac94ea91faf65bd64735924522a8b32", size = 559766, upload-time = "2025-09-08T23:07:43.869Z" }, { url = "https://files.pythonhosted.org/packages/06/5d/305323ba86b284e6fcb0d842d6adaa2999035f70f8c38a9b6d21ad28c3d4/pyzmq-27.1.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:226b091818d461a3bef763805e75685e478ac17e9008f49fce2d3e52b3d58b86", size = 1333328, upload-time = "2025-09-08T23:07:45.946Z" }, { url = "https://files.pythonhosted.org/packages/bd/a0/fc7e78a23748ad5443ac3275943457e8452da67fda347e05260261108cbc/pyzmq-27.1.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0790a0161c281ca9723f804871b4027f2e8b5a528d357c8952d08cd1a9c15581", size = 908803, upload-time = "2025-09-08T23:07:47.551Z" }, { url = "https://files.pythonhosted.org/packages/7e/22/37d15eb05f3bdfa4abea6f6d96eb3bb58585fbd3e4e0ded4e743bc650c97/pyzmq-27.1.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c895a6f35476b0c3a54e3eb6ccf41bf3018de937016e6e18748317f25d4e925f", size = 668836, upload-time = "2025-09-08T23:07:49.436Z" }, @@ -2108,11 +2182,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/59/a5f38970f9bf07cee96128de79590bb354917914a9be11272cfc7ff26af0/pyzmq-27.1.0-cp314-cp314t-win32.whl", hash = "sha256:1f0b2a577fd770aa6f053211a55d1c47901f4d537389a034c690291485e5fe92", size = 587472, upload-time = "2025-09-08T23:08:58.18Z" }, { url = "https://files.pythonhosted.org/packages/70/d8/78b1bad170f93fcf5e3536e70e8fadac55030002275c9a29e8f5719185de/pyzmq-27.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:19c9468ae0437f8074af379e986c5d3d7d7bfe033506af442e8c879732bedbe0", size = 661401, upload-time = "2025-09-08T23:08:59.802Z" }, { url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" }, - { url = "https://files.pythonhosted.org/packages/f3/81/a65e71c1552f74dec9dff91d95bafb6e0d33338a8dfefbc88aa562a20c92/pyzmq-27.1.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c17e03cbc9312bee223864f1a2b13a99522e0dc9f7c5df0177cd45210ac286e6", size = 836266, upload-time = "2025-09-08T23:09:40.048Z" }, - { url = "https://files.pythonhosted.org/packages/58/ed/0202ca350f4f2b69faa95c6d931e3c05c3a397c184cacb84cb4f8f42f287/pyzmq-27.1.0-pp310-pypy310_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f328d01128373cb6763823b2b4e7f73bdf767834268c565151eacb3b7a392f90", size = 800206, upload-time = "2025-09-08T23:09:41.902Z" }, - { url = "https://files.pythonhosted.org/packages/47/42/1ff831fa87fe8f0a840ddb399054ca0009605d820e2b44ea43114f5459f4/pyzmq-27.1.0-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1790386614232e1b3a40a958454bdd42c6d1811837b15ddbb052a032a43f62", size = 567747, upload-time = "2025-09-08T23:09:43.741Z" }, - { url = "https://files.pythonhosted.org/packages/d1/db/5c4d6807434751e3f21231bee98109aa57b9b9b55e058e450d0aef59b70f/pyzmq-27.1.0-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:448f9cb54eb0cee4732b46584f2710c8bc178b0e5371d9e4fc8125201e413a74", size = 747371, upload-time = "2025-09-08T23:09:45.575Z" }, - { url = "https://files.pythonhosted.org/packages/26/af/78ce193dbf03567eb8c0dc30e3df2b9e56f12a670bf7eb20f9fb532c7e8a/pyzmq-27.1.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:05b12f2d32112bf8c95ef2e74ec4f1d4beb01f8b5e703b38537f8849f92cb9ba", size = 544862, upload-time = "2025-09-08T23:09:47.448Z" }, { url = "https://files.pythonhosted.org/packages/4c/c6/c4dcdecdbaa70969ee1fdced6d7b8f60cfabe64d25361f27ac4665a70620/pyzmq-27.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:18770c8d3563715387139060d37859c02ce40718d1faf299abddcdcc6a649066", size = 836265, upload-time = "2025-09-08T23:09:49.376Z" }, { url = "https://files.pythonhosted.org/packages/3e/79/f38c92eeaeb03a2ccc2ba9866f0439593bb08c5e3b714ac1d553e5c96e25/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:ac25465d42f92e990f8d8b0546b01c391ad431c3bf447683fdc40565941d0604", size = 800208, upload-time = "2025-09-08T23:09:51.073Z" }, { url = "https://files.pythonhosted.org/packages/49/0e/3f0d0d335c6b3abb9b7b723776d0b21fa7f3a6c819a0db6097059aada160/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53b40f8ae006f2734ee7608d59ed661419f087521edbfc2149c3932e9c14808c", size = 567747, upload-time = "2025-09-08T23:09:52.698Z" }, @@ -2136,15 +2205,15 @@ wheels = [ [[package]] name = "reportlab" -version = "4.4.4" +version = "4.4.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "charset-normalizer" }, { name = "pillow" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f8/fa/ed71f3e750afb77497641eb0194aeda069e271ce6d6931140f8787e0e69a/reportlab-4.4.4.tar.gz", hash = "sha256:cb2f658b7f4a15be2cc68f7203aa67faef67213edd4f2d4bdd3eb20dab75a80d", size = 3711935, upload-time = "2025-09-19T10:43:36.502Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/39/42cf24aee570a80e1903221ae3a92a2e34c324794a392eb036cbb6dc3839/reportlab-4.4.9.tar.gz", hash = "sha256:7cf487764294ee791a4781f5a157bebce262a666ae4bbb87786760a9676c9378", size = 3911246, upload-time = "2026-01-15T10:07:56.08Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/57/66/e040586fe6f9ae7f3a6986186653791fb865947f0b745290ee4ab026b834/reportlab-4.4.4-py3-none-any.whl", hash = "sha256:299b3b0534e7202bb94ed2ddcd7179b818dcda7de9d8518a57c85a58a1ebaadb", size = 1954981, upload-time = "2025-09-19T10:43:33.589Z" }, + { url = "https://files.pythonhosted.org/packages/17/77/546e50edfaba6a0e58e8ec5fdc4446510227cec9e8f40172b60941d5a633/reportlab-4.4.9-py3-none-any.whl", hash = "sha256:68e2d103ae8041a37714e8896ec9b79a1c1e911d68c3bd2ea17546568cf17bfd", size = 1954401, upload-time = "2026-01-15T09:27:59.133Z" }, ] [[package]] @@ -2176,143 +2245,133 @@ wheels = [ ] [[package]] -name = "roman-numerals-py" -version = "3.1.0" +name = "rich-rst" +version = "1.3.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/30/76/48fd56d17c5bdbdf65609abbc67288728a98ed4c02919428d4f52d23b24b/roman_numerals_py-3.1.0.tar.gz", hash = "sha256:be4bf804f083a4ce001b5eb7e3c0862479d10f94c936f6c4e5f250aa5ff5bd2d", size = 9017, upload-time = "2025-02-22T07:34:54.333Z" } +dependencies = [ + { name = "docutils" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/6d/a506aaa4a9eaa945ed8ab2b7347859f53593864289853c5d6d62b77246e0/rich_rst-1.3.2.tar.gz", hash = "sha256:a1196fdddf1e364b02ec68a05e8ff8f6914fee10fbca2e6b6735f166bb0da8d4", size = 14936, upload-time = "2025-10-14T16:49:45.332Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/53/97/d2cbbaa10c9b826af0e10fdf836e1bf344d9f0abb873ebc34d1f49642d3f/roman_numerals_py-3.1.0-py3-none-any.whl", hash = "sha256:9da2ad2fb670bcf24e81070ceb3be72f6c11c440d73bd579fbeca1e9f330954c", size = 7742, upload-time = "2025-02-22T07:34:52.422Z" }, + { url = "https://files.pythonhosted.org/packages/13/2f/b4530fbf948867702d0a3f27de4a6aab1d156f406d72852ab902c4d04de9/rich_rst-1.3.2-py3-none-any.whl", hash = "sha256:a99b4907cbe118cf9d18b0b44de272efa61f15117c61e39ebdc431baf5df722a", size = 12567, upload-time = "2025-10-14T16:49:42.953Z" }, +] + +[[package]] +name = "roman-numerals" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2", size = 9077, upload-time = "2025-12-17T18:25:34.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" }, ] [[package]] name = "rpds-py" -version = "0.28.0" +version = "0.30.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/48/dc/95f074d43452b3ef5d06276696ece4b3b5d696e7c9ad7173c54b1390cd70/rpds_py-0.28.0.tar.gz", hash = "sha256:abd4df20485a0983e2ca334a216249b6186d6e3c1627e106651943dbdb791aea", size = 27419, upload-time = "2025-10-22T22:24:29.327Z" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/82/f8/13bb772dc7cbf2c3c5b816febc34fa0cb2c64a08e0569869585684ce6631/rpds_py-0.28.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:7b6013db815417eeb56b2d9d7324e64fcd4fa289caeee6e7a78b2e11fc9b438a", size = 362820, upload-time = "2025-10-22T22:21:15.074Z" }, - { url = "https://files.pythonhosted.org/packages/84/91/6acce964aab32469c3dbe792cb041a752d64739c534e9c493c701ef0c032/rpds_py-0.28.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1a4c6b05c685c0c03f80dabaeb73e74218c49deea965ca63f76a752807397207", size = 348499, upload-time = "2025-10-22T22:21:17.658Z" }, - { url = "https://files.pythonhosted.org/packages/f1/93/c05bb1f4f5e0234db7c4917cb8dd5e2e0a9a7b26dc74b1b7bee3c9cfd477/rpds_py-0.28.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4794c6c3fbe8f9ac87699b131a1f26e7b4abcf6d828da46a3a52648c7930eba", size = 379356, upload-time = "2025-10-22T22:21:19.847Z" }, - { url = "https://files.pythonhosted.org/packages/5c/37/e292da436f0773e319753c567263427cdf6c645d30b44f09463ff8216cda/rpds_py-0.28.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2e8456b6ee5527112ff2354dd9087b030e3429e43a74f480d4a5ca79d269fd85", size = 390151, upload-time = "2025-10-22T22:21:21.569Z" }, - { url = "https://files.pythonhosted.org/packages/76/87/a4e3267131616e8faf10486dc00eaedf09bd61c87f01e5ef98e782ee06c9/rpds_py-0.28.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:beb880a9ca0a117415f241f66d56025c02037f7c4efc6fe59b5b8454f1eaa50d", size = 524831, upload-time = "2025-10-22T22:21:23.394Z" }, - { url = "https://files.pythonhosted.org/packages/e1/c8/4a4ca76f0befae9515da3fad11038f0fce44f6bb60b21fe9d9364dd51fb0/rpds_py-0.28.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6897bebb118c44b38c9cb62a178e09f1593c949391b9a1a6fe777ccab5934ee7", size = 404687, upload-time = "2025-10-22T22:21:25.201Z" }, - { url = "https://files.pythonhosted.org/packages/6a/65/118afe854424456beafbbebc6b34dcf6d72eae3a08b4632bc4220f8240d9/rpds_py-0.28.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1b553dd06e875249fd43efd727785efb57a53180e0fde321468222eabbeaafa", size = 382683, upload-time = "2025-10-22T22:21:26.536Z" }, - { url = "https://files.pythonhosted.org/packages/f7/bc/0625064041fb3a0c77ecc8878c0e8341b0ae27ad0f00cf8f2b57337a1e63/rpds_py-0.28.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:f0b2044fdddeea5b05df832e50d2a06fe61023acb44d76978e1b060206a8a476", size = 398927, upload-time = "2025-10-22T22:21:27.864Z" }, - { url = "https://files.pythonhosted.org/packages/5d/1a/fed7cf2f1ee8a5e4778f2054153f2cfcf517748875e2f5b21cf8907cd77d/rpds_py-0.28.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05cf1e74900e8da73fa08cc76c74a03345e5a3e37691d07cfe2092d7d8e27b04", size = 411590, upload-time = "2025-10-22T22:21:29.474Z" }, - { url = "https://files.pythonhosted.org/packages/c1/64/a8e0f67fa374a6c472dbb0afdaf1ef744724f165abb6899f20e2f1563137/rpds_py-0.28.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:efd489fec7c311dae25e94fe7eeda4b3d06be71c68f2cf2e8ef990ffcd2cd7e8", size = 559843, upload-time = "2025-10-22T22:21:30.917Z" }, - { url = "https://files.pythonhosted.org/packages/a9/ea/e10353f6d7c105be09b8135b72787a65919971ae0330ad97d87e4e199880/rpds_py-0.28.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ada7754a10faacd4f26067e62de52d6af93b6d9542f0df73c57b9771eb3ba9c4", size = 584188, upload-time = "2025-10-22T22:21:32.827Z" }, - { url = "https://files.pythonhosted.org/packages/18/b0/a19743e0763caf0c89f6fc6ba6fbd9a353b24ffb4256a492420c5517da5a/rpds_py-0.28.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c2a34fd26588949e1e7977cfcbb17a9a42c948c100cab890c6d8d823f0586457", size = 550052, upload-time = "2025-10-22T22:21:34.702Z" }, - { url = "https://files.pythonhosted.org/packages/de/bc/ec2c004f6c7d6ab1e25dae875cdb1aee087c3ebed5b73712ed3000e3851a/rpds_py-0.28.0-cp310-cp310-win32.whl", hash = "sha256:f9174471d6920cbc5e82a7822de8dfd4dcea86eb828b04fc8c6519a77b0ee51e", size = 215110, upload-time = "2025-10-22T22:21:36.645Z" }, - { url = "https://files.pythonhosted.org/packages/6c/de/4ce8abf59674e17187023933547d2018363e8fc76ada4f1d4d22871ccb6e/rpds_py-0.28.0-cp310-cp310-win_amd64.whl", hash = "sha256:6e32dd207e2c4f8475257a3540ab8a93eff997abfa0a3fdb287cae0d6cd874b8", size = 223850, upload-time = "2025-10-22T22:21:38.006Z" }, - { url = "https://files.pythonhosted.org/packages/a6/34/058d0db5471c6be7bef82487ad5021ff8d1d1d27794be8730aad938649cf/rpds_py-0.28.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:03065002fd2e287725d95fbc69688e0c6daf6c6314ba38bdbaa3895418e09296", size = 362344, upload-time = "2025-10-22T22:21:39.713Z" }, - { url = "https://files.pythonhosted.org/packages/5d/67/9503f0ec8c055a0782880f300c50a2b8e5e72eb1f94dfc2053da527444dd/rpds_py-0.28.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28ea02215f262b6d078daec0b45344c89e161eab9526b0d898221d96fdda5f27", size = 348440, upload-time = "2025-10-22T22:21:41.056Z" }, - { url = "https://files.pythonhosted.org/packages/68/2e/94223ee9b32332a41d75b6f94b37b4ce3e93878a556fc5f152cbd856a81f/rpds_py-0.28.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25dbade8fbf30bcc551cb352376c0ad64b067e4fc56f90e22ba70c3ce205988c", size = 379068, upload-time = "2025-10-22T22:21:42.593Z" }, - { url = "https://files.pythonhosted.org/packages/b4/25/54fd48f9f680cfc44e6a7f39a5fadf1d4a4a1fd0848076af4a43e79f998c/rpds_py-0.28.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c03002f54cc855860bfdc3442928ffdca9081e73b5b382ed0b9e8efe6e5e205", size = 390518, upload-time = "2025-10-22T22:21:43.998Z" }, - { url = "https://files.pythonhosted.org/packages/1b/85/ac258c9c27f2ccb1bd5d0697e53a82ebcf8088e3186d5d2bf8498ee7ed44/rpds_py-0.28.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9699fa7990368b22032baf2b2dce1f634388e4ffc03dfefaaac79f4695edc95", size = 525319, upload-time = "2025-10-22T22:21:45.645Z" }, - { url = "https://files.pythonhosted.org/packages/40/cb/c6734774789566d46775f193964b76627cd5f42ecf246d257ce84d1912ed/rpds_py-0.28.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b9b06fe1a75e05e0713f06ea0c89ecb6452210fd60e2f1b6ddc1067b990e08d9", size = 404896, upload-time = "2025-10-22T22:21:47.544Z" }, - { url = "https://files.pythonhosted.org/packages/1f/53/14e37ce83202c632c89b0691185dca9532288ff9d390eacae3d2ff771bae/rpds_py-0.28.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac9f83e7b326a3f9ec3ef84cda98fb0a74c7159f33e692032233046e7fd15da2", size = 382862, upload-time = "2025-10-22T22:21:49.176Z" }, - { url = "https://files.pythonhosted.org/packages/6a/83/f3642483ca971a54d60caa4449f9d6d4dbb56a53e0072d0deff51b38af74/rpds_py-0.28.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:0d3259ea9ad8743a75a43eb7819324cdab393263c91be86e2d1901ee65c314e0", size = 398848, upload-time = "2025-10-22T22:21:51.024Z" }, - { url = "https://files.pythonhosted.org/packages/44/09/2d9c8b2f88e399b4cfe86efdf2935feaf0394e4f14ab30c6c5945d60af7d/rpds_py-0.28.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9a7548b345f66f6695943b4ef6afe33ccd3f1b638bd9afd0f730dd255c249c9e", size = 412030, upload-time = "2025-10-22T22:21:52.665Z" }, - { url = "https://files.pythonhosted.org/packages/dd/f5/e1cec473d4bde6df1fd3738be8e82d64dd0600868e76e92dfeaebbc2d18f/rpds_py-0.28.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c9a40040aa388b037eb39416710fbcce9443498d2eaab0b9b45ae988b53f5c67", size = 559700, upload-time = "2025-10-22T22:21:54.123Z" }, - { url = "https://files.pythonhosted.org/packages/8d/be/73bb241c1649edbf14e98e9e78899c2c5e52bbe47cb64811f44d2cc11808/rpds_py-0.28.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8f60c7ea34e78c199acd0d3cda37a99be2c861dd2b8cf67399784f70c9f8e57d", size = 584581, upload-time = "2025-10-22T22:21:56.102Z" }, - { url = "https://files.pythonhosted.org/packages/9c/9c/ffc6e9218cd1eb5c2c7dbd276c87cd10e8c2232c456b554169eb363381df/rpds_py-0.28.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1571ae4292649100d743b26d5f9c63503bb1fedf538a8f29a98dce2d5ba6b4e6", size = 549981, upload-time = "2025-10-22T22:21:58.253Z" }, - { url = "https://files.pythonhosted.org/packages/5f/50/da8b6d33803a94df0149345ee33e5d91ed4d25fc6517de6a25587eae4133/rpds_py-0.28.0-cp311-cp311-win32.whl", hash = "sha256:5cfa9af45e7c1140af7321fa0bef25b386ee9faa8928c80dc3a5360971a29e8c", size = 214729, upload-time = "2025-10-22T22:21:59.625Z" }, - { url = "https://files.pythonhosted.org/packages/12/fd/b0f48c4c320ee24c8c20df8b44acffb7353991ddf688af01eef5f93d7018/rpds_py-0.28.0-cp311-cp311-win_amd64.whl", hash = "sha256:dd8d86b5d29d1b74100982424ba53e56033dc47720a6de9ba0259cf81d7cecaa", size = 223977, upload-time = "2025-10-22T22:22:01.092Z" }, - { url = "https://files.pythonhosted.org/packages/b4/21/c8e77a2ac66e2ec4e21f18a04b4e9a0417ecf8e61b5eaeaa9360a91713b4/rpds_py-0.28.0-cp311-cp311-win_arm64.whl", hash = "sha256:4e27d3a5709cc2b3e013bf93679a849213c79ae0573f9b894b284b55e729e120", size = 217326, upload-time = "2025-10-22T22:22:02.944Z" }, - { url = "https://files.pythonhosted.org/packages/b8/5c/6c3936495003875fe7b14f90ea812841a08fca50ab26bd840e924097d9c8/rpds_py-0.28.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:6b4f28583a4f247ff60cd7bdda83db8c3f5b05a7a82ff20dd4b078571747708f", size = 366439, upload-time = "2025-10-22T22:22:04.525Z" }, - { url = "https://files.pythonhosted.org/packages/56/f9/a0f1ca194c50aa29895b442771f036a25b6c41a35e4f35b1a0ea713bedae/rpds_py-0.28.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d678e91b610c29c4b3d52a2c148b641df2b4676ffe47c59f6388d58b99cdc424", size = 348170, upload-time = "2025-10-22T22:22:06.397Z" }, - { url = "https://files.pythonhosted.org/packages/18/ea/42d243d3a586beb72c77fa5def0487daf827210069a95f36328e869599ea/rpds_py-0.28.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e819e0e37a44a78e1383bf1970076e2ccc4dc8c2bbaa2f9bd1dc987e9afff628", size = 378838, upload-time = "2025-10-22T22:22:07.932Z" }, - { url = "https://files.pythonhosted.org/packages/e7/78/3de32e18a94791af8f33601402d9d4f39613136398658412a4e0b3047327/rpds_py-0.28.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5ee514e0f0523db5d3fb171f397c54875dbbd69760a414dccf9d4d7ad628b5bd", size = 393299, upload-time = "2025-10-22T22:22:09.435Z" }, - { url = "https://files.pythonhosted.org/packages/13/7e/4bdb435afb18acea2eb8a25ad56b956f28de7c59f8a1d32827effa0d4514/rpds_py-0.28.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3fa06d27fdcee47f07a39e02862da0100cb4982508f5ead53ec533cd5fe55e", size = 518000, upload-time = "2025-10-22T22:22:11.326Z" }, - { url = "https://files.pythonhosted.org/packages/31/d0/5f52a656875cdc60498ab035a7a0ac8f399890cc1ee73ebd567bac4e39ae/rpds_py-0.28.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:46959ef2e64f9e4a41fc89aa20dbca2b85531f9a72c21099a3360f35d10b0d5a", size = 408746, upload-time = "2025-10-22T22:22:13.143Z" }, - { url = "https://files.pythonhosted.org/packages/3e/cd/49ce51767b879cde77e7ad9fae164ea15dce3616fe591d9ea1df51152706/rpds_py-0.28.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8455933b4bcd6e83fde3fefc987a023389c4b13f9a58c8d23e4b3f6d13f78c84", size = 386379, upload-time = "2025-10-22T22:22:14.602Z" }, - { url = "https://files.pythonhosted.org/packages/6a/99/e4e1e1ee93a98f72fc450e36c0e4d99c35370220e815288e3ecd2ec36a2a/rpds_py-0.28.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:ad50614a02c8c2962feebe6012b52f9802deec4263946cddea37aaf28dd25a66", size = 401280, upload-time = "2025-10-22T22:22:16.063Z" }, - { url = "https://files.pythonhosted.org/packages/61/35/e0c6a57488392a8b319d2200d03dad2b29c0db9996f5662c3b02d0b86c02/rpds_py-0.28.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e5deca01b271492553fdb6c7fd974659dce736a15bae5dad7ab8b93555bceb28", size = 412365, upload-time = "2025-10-22T22:22:17.504Z" }, - { url = "https://files.pythonhosted.org/packages/ff/6a/841337980ea253ec797eb084665436007a1aad0faac1ba097fb906c5f69c/rpds_py-0.28.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:735f8495a13159ce6a0d533f01e8674cec0c57038c920495f87dcb20b3ddb48a", size = 559573, upload-time = "2025-10-22T22:22:19.108Z" }, - { url = "https://files.pythonhosted.org/packages/e7/5e/64826ec58afd4c489731f8b00729c5f6afdb86f1df1df60bfede55d650bb/rpds_py-0.28.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:961ca621ff10d198bbe6ba4957decca61aa2a0c56695384c1d6b79bf61436df5", size = 583973, upload-time = "2025-10-22T22:22:20.768Z" }, - { url = "https://files.pythonhosted.org/packages/b6/ee/44d024b4843f8386a4eeaa4c171b3d31d55f7177c415545fd1a24c249b5d/rpds_py-0.28.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2374e16cc9131022e7d9a8f8d65d261d9ba55048c78f3b6e017971a4f5e6353c", size = 553800, upload-time = "2025-10-22T22:22:22.25Z" }, - { url = "https://files.pythonhosted.org/packages/7d/89/33e675dccff11a06d4d85dbb4d1865f878d5020cbb69b2c1e7b2d3f82562/rpds_py-0.28.0-cp312-cp312-win32.whl", hash = "sha256:d15431e334fba488b081d47f30f091e5d03c18527c325386091f31718952fe08", size = 216954, upload-time = "2025-10-22T22:22:24.105Z" }, - { url = "https://files.pythonhosted.org/packages/af/36/45f6ebb3210887e8ee6dbf1bc710ae8400bb417ce165aaf3024b8360d999/rpds_py-0.28.0-cp312-cp312-win_amd64.whl", hash = "sha256:a410542d61fc54710f750d3764380b53bf09e8c4edbf2f9141a82aa774a04f7c", size = 227844, upload-time = "2025-10-22T22:22:25.551Z" }, - { url = "https://files.pythonhosted.org/packages/57/91/f3fb250d7e73de71080f9a221d19bd6a1c1eb0d12a1ea26513f6c1052ad6/rpds_py-0.28.0-cp312-cp312-win_arm64.whl", hash = "sha256:1f0cfd1c69e2d14f8c892b893997fa9a60d890a0c8a603e88dca4955f26d1edd", size = 217624, upload-time = "2025-10-22T22:22:26.914Z" }, - { url = "https://files.pythonhosted.org/packages/d3/03/ce566d92611dfac0085c2f4b048cd53ed7c274a5c05974b882a908d540a2/rpds_py-0.28.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e9e184408a0297086f880556b6168fa927d677716f83d3472ea333b42171ee3b", size = 366235, upload-time = "2025-10-22T22:22:28.397Z" }, - { url = "https://files.pythonhosted.org/packages/00/34/1c61da1b25592b86fd285bd7bd8422f4c9d748a7373b46126f9ae792a004/rpds_py-0.28.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:edd267266a9b0448f33dc465a97cfc5d467594b600fe28e7fa2f36450e03053a", size = 348241, upload-time = "2025-10-22T22:22:30.171Z" }, - { url = "https://files.pythonhosted.org/packages/fc/00/ed1e28616848c61c493a067779633ebf4b569eccaacf9ccbdc0e7cba2b9d/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85beb8b3f45e4e32f6802fb6cd6b17f615ef6c6a52f265371fb916fae02814aa", size = 378079, upload-time = "2025-10-22T22:22:31.644Z" }, - { url = "https://files.pythonhosted.org/packages/11/b2/ccb30333a16a470091b6e50289adb4d3ec656fd9951ba8c5e3aaa0746a67/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d2412be8d00a1b895f8ad827cc2116455196e20ed994bb704bf138fe91a42724", size = 393151, upload-time = "2025-10-22T22:22:33.453Z" }, - { url = "https://files.pythonhosted.org/packages/8c/d0/73e2217c3ee486d555cb84920597480627d8c0240ff3062005c6cc47773e/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cf128350d384b777da0e68796afdcebc2e9f63f0e9f242217754e647f6d32491", size = 517520, upload-time = "2025-10-22T22:22:34.949Z" }, - { url = "https://files.pythonhosted.org/packages/c4/91/23efe81c700427d0841a4ae7ea23e305654381831e6029499fe80be8a071/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a2036d09b363aa36695d1cc1a97b36865597f4478470b0697b5ee9403f4fe399", size = 408699, upload-time = "2025-10-22T22:22:36.584Z" }, - { url = "https://files.pythonhosted.org/packages/ca/ee/a324d3198da151820a326c1f988caaa4f37fc27955148a76fff7a2d787a9/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8e1e9be4fa6305a16be628959188e4fd5cd6f1b0e724d63c6d8b2a8adf74ea6", size = 385720, upload-time = "2025-10-22T22:22:38.014Z" }, - { url = "https://files.pythonhosted.org/packages/19/ad/e68120dc05af8b7cab4a789fccd8cdcf0fe7e6581461038cc5c164cd97d2/rpds_py-0.28.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0a403460c9dd91a7f23fc3188de6d8977f1d9603a351d5db6cf20aaea95b538d", size = 401096, upload-time = "2025-10-22T22:22:39.869Z" }, - { url = "https://files.pythonhosted.org/packages/99/90/c1e070620042459d60df6356b666bb1f62198a89d68881816a7ed121595a/rpds_py-0.28.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d7366b6553cdc805abcc512b849a519167db8f5e5c3472010cd1228b224265cb", size = 411465, upload-time = "2025-10-22T22:22:41.395Z" }, - { url = "https://files.pythonhosted.org/packages/68/61/7c195b30d57f1b8d5970f600efee72a4fad79ec829057972e13a0370fd24/rpds_py-0.28.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5b43c6a3726efd50f18d8120ec0551241c38785b68952d240c45ea553912ac41", size = 558832, upload-time = "2025-10-22T22:22:42.871Z" }, - { url = "https://files.pythonhosted.org/packages/b0/3d/06f3a718864773f69941d4deccdf18e5e47dd298b4628062f004c10f3b34/rpds_py-0.28.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0cb7203c7bc69d7c1585ebb33a2e6074492d2fc21ad28a7b9d40457ac2a51ab7", size = 583230, upload-time = "2025-10-22T22:22:44.877Z" }, - { url = "https://files.pythonhosted.org/packages/66/df/62fc783781a121e77fee9a21ead0a926f1b652280a33f5956a5e7833ed30/rpds_py-0.28.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a52a5169c664dfb495882adc75c304ae1d50df552fbd68e100fdc719dee4ff9", size = 553268, upload-time = "2025-10-22T22:22:46.441Z" }, - { url = "https://files.pythonhosted.org/packages/84/85/d34366e335140a4837902d3dea89b51f087bd6a63c993ebdff59e93ee61d/rpds_py-0.28.0-cp313-cp313-win32.whl", hash = "sha256:2e42456917b6687215b3e606ab46aa6bca040c77af7df9a08a6dcfe8a4d10ca5", size = 217100, upload-time = "2025-10-22T22:22:48.342Z" }, - { url = "https://files.pythonhosted.org/packages/3c/1c/f25a3f3752ad7601476e3eff395fe075e0f7813fbb9862bd67c82440e880/rpds_py-0.28.0-cp313-cp313-win_amd64.whl", hash = "sha256:e0a0311caedc8069d68fc2bf4c9019b58a2d5ce3cd7cb656c845f1615b577e1e", size = 227759, upload-time = "2025-10-22T22:22:50.219Z" }, - { url = "https://files.pythonhosted.org/packages/e0/d6/5f39b42b99615b5bc2f36ab90423ea404830bdfee1c706820943e9a645eb/rpds_py-0.28.0-cp313-cp313-win_arm64.whl", hash = "sha256:04c1b207ab8b581108801528d59ad80aa83bb170b35b0ddffb29c20e411acdc1", size = 217326, upload-time = "2025-10-22T22:22:51.647Z" }, - { url = "https://files.pythonhosted.org/packages/5c/8b/0c69b72d1cee20a63db534be0df271effe715ef6c744fdf1ff23bb2b0b1c/rpds_py-0.28.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:f296ea3054e11fc58ad42e850e8b75c62d9a93a9f981ad04b2e5ae7d2186ff9c", size = 355736, upload-time = "2025-10-22T22:22:53.211Z" }, - { url = "https://files.pythonhosted.org/packages/f7/6d/0c2ee773cfb55c31a8514d2cece856dd299170a49babd50dcffb15ddc749/rpds_py-0.28.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5a7306c19b19005ad98468fcefeb7100b19c79fc23a5f24a12e06d91181193fa", size = 342677, upload-time = "2025-10-22T22:22:54.723Z" }, - { url = "https://files.pythonhosted.org/packages/e2/1c/22513ab25a27ea205144414724743e305e8153e6abe81833b5e678650f5a/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5d9b86aa501fed9862a443c5c3116f6ead8bc9296185f369277c42542bd646b", size = 371847, upload-time = "2025-10-22T22:22:56.295Z" }, - { url = "https://files.pythonhosted.org/packages/60/07/68e6ccdb4b05115ffe61d31afc94adef1833d3a72f76c9632d4d90d67954/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e5bbc701eff140ba0e872691d573b3d5d30059ea26e5785acba9132d10c8c31d", size = 381800, upload-time = "2025-10-22T22:22:57.808Z" }, - { url = "https://files.pythonhosted.org/packages/73/bf/6d6d15df80781d7f9f368e7c1a00caf764436518c4877fb28b029c4624af/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a5690671cd672a45aa8616d7374fdf334a1b9c04a0cac3c854b1136e92374fe", size = 518827, upload-time = "2025-10-22T22:22:59.826Z" }, - { url = "https://files.pythonhosted.org/packages/7b/d3/2decbb2976cc452cbf12a2b0aaac5f1b9dc5dd9d1f7e2509a3ee00421249/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9f1d92ecea4fa12f978a367c32a5375a1982834649cdb96539dcdc12e609ab1a", size = 399471, upload-time = "2025-10-22T22:23:01.968Z" }, - { url = "https://files.pythonhosted.org/packages/b1/2c/f30892f9e54bd02e5faca3f6a26d6933c51055e67d54818af90abed9748e/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d252db6b1a78d0a3928b6190156042d54c93660ce4d98290d7b16b5296fb7cc", size = 377578, upload-time = "2025-10-22T22:23:03.52Z" }, - { url = "https://files.pythonhosted.org/packages/f0/5d/3bce97e5534157318f29ac06bf2d279dae2674ec12f7cb9c12739cee64d8/rpds_py-0.28.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:d61b355c3275acb825f8777d6c4505f42b5007e357af500939d4a35b19177259", size = 390482, upload-time = "2025-10-22T22:23:05.391Z" }, - { url = "https://files.pythonhosted.org/packages/e3/f0/886bd515ed457b5bd93b166175edb80a0b21a210c10e993392127f1e3931/rpds_py-0.28.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:acbe5e8b1026c0c580d0321c8aae4b0a1e1676861d48d6e8c6586625055b606a", size = 402447, upload-time = "2025-10-22T22:23:06.93Z" }, - { url = "https://files.pythonhosted.org/packages/42/b5/71e8777ac55e6af1f4f1c05b47542a1eaa6c33c1cf0d300dca6a1c6e159a/rpds_py-0.28.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8aa23b6f0fc59b85b4c7d89ba2965af274346f738e8d9fc2455763602e62fd5f", size = 552385, upload-time = "2025-10-22T22:23:08.557Z" }, - { url = "https://files.pythonhosted.org/packages/5d/cb/6ca2d70cbda5a8e36605e7788c4aa3bea7c17d71d213465a5a675079b98d/rpds_py-0.28.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7b14b0c680286958817c22d76fcbca4800ddacef6f678f3a7c79a1fe7067fe37", size = 575642, upload-time = "2025-10-22T22:23:10.348Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d4/407ad9960ca7856d7b25c96dcbe019270b5ffdd83a561787bc682c797086/rpds_py-0.28.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:bcf1d210dfee61a6c86551d67ee1031899c0fdbae88b2d44a569995d43797712", size = 544507, upload-time = "2025-10-22T22:23:12.434Z" }, - { url = "https://files.pythonhosted.org/packages/51/31/2f46fe0efcac23fbf5797c6b6b7e1c76f7d60773e525cb65fcbc582ee0f2/rpds_py-0.28.0-cp313-cp313t-win32.whl", hash = "sha256:3aa4dc0fdab4a7029ac63959a3ccf4ed605fee048ba67ce89ca3168da34a1342", size = 205376, upload-time = "2025-10-22T22:23:13.979Z" }, - { url = "https://files.pythonhosted.org/packages/92/e4/15947bda33cbedfc134490a41841ab8870a72a867a03d4969d886f6594a2/rpds_py-0.28.0-cp313-cp313t-win_amd64.whl", hash = "sha256:7b7d9d83c942855e4fdcfa75d4f96f6b9e272d42fffcb72cd4bb2577db2e2907", size = 215907, upload-time = "2025-10-22T22:23:15.5Z" }, - { url = "https://files.pythonhosted.org/packages/08/47/ffe8cd7a6a02833b10623bf765fbb57ce977e9a4318ca0e8cf97e9c3d2b3/rpds_py-0.28.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:dcdcb890b3ada98a03f9f2bb108489cdc7580176cb73b4f2d789e9a1dac1d472", size = 353830, upload-time = "2025-10-22T22:23:17.03Z" }, - { url = "https://files.pythonhosted.org/packages/f9/9f/890f36cbd83a58491d0d91ae0db1702639edb33fb48eeb356f80ecc6b000/rpds_py-0.28.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f274f56a926ba2dc02976ca5b11c32855cbd5925534e57cfe1fda64e04d1add2", size = 341819, upload-time = "2025-10-22T22:23:18.57Z" }, - { url = "https://files.pythonhosted.org/packages/09/e3/921eb109f682aa24fb76207698fbbcf9418738f35a40c21652c29053f23d/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fe0438ac4a29a520ea94c8c7f1754cdd8feb1bc490dfda1bfd990072363d527", size = 373127, upload-time = "2025-10-22T22:23:20.216Z" }, - { url = "https://files.pythonhosted.org/packages/23/13/bce4384d9f8f4989f1a9599c71b7a2d877462e5fd7175e1f69b398f729f4/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8a358a32dd3ae50e933347889b6af9a1bdf207ba5d1a3f34e1a38cd3540e6733", size = 382767, upload-time = "2025-10-22T22:23:21.787Z" }, - { url = "https://files.pythonhosted.org/packages/23/e1/579512b2d89a77c64ccef5a0bc46a6ef7f72ae0cf03d4b26dcd52e57ee0a/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e80848a71c78aa328fefaba9c244d588a342c8e03bda518447b624ea64d1ff56", size = 517585, upload-time = "2025-10-22T22:23:23.699Z" }, - { url = "https://files.pythonhosted.org/packages/62/3c/ca704b8d324a2591b0b0adcfcaadf9c862375b11f2f667ac03c61b4fd0a6/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f586db2e209d54fe177e58e0bc4946bea5fb0102f150b1b2f13de03e1f0976f8", size = 399828, upload-time = "2025-10-22T22:23:25.713Z" }, - { url = "https://files.pythonhosted.org/packages/da/37/e84283b9e897e3adc46b4c88bb3f6ec92a43bd4d2f7ef5b13459963b2e9c/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ae8ee156d6b586e4292491e885d41483136ab994e719a13458055bec14cf370", size = 375509, upload-time = "2025-10-22T22:23:27.32Z" }, - { url = "https://files.pythonhosted.org/packages/1a/c2/a980beab869d86258bf76ec42dec778ba98151f253a952b02fe36d72b29c/rpds_py-0.28.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a805e9b3973f7e27f7cab63a6b4f61d90f2e5557cff73b6e97cd5b8540276d3d", size = 392014, upload-time = "2025-10-22T22:23:29.332Z" }, - { url = "https://files.pythonhosted.org/packages/da/b5/b1d3c5f9d3fa5aeef74265f9c64de3c34a0d6d5cd3c81c8b17d5c8f10ed4/rpds_py-0.28.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5d3fd16b6dc89c73a4da0b4ac8b12a7ecc75b2864b95c9e5afed8003cb50a728", size = 402410, upload-time = "2025-10-22T22:23:31.14Z" }, - { url = "https://files.pythonhosted.org/packages/74/ae/cab05ff08dfcc052afc73dcb38cbc765ffc86f94e966f3924cd17492293c/rpds_py-0.28.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6796079e5d24fdaba6d49bda28e2c47347e89834678f2bc2c1b4fc1489c0fb01", size = 553593, upload-time = "2025-10-22T22:23:32.834Z" }, - { url = "https://files.pythonhosted.org/packages/70/80/50d5706ea2a9bfc9e9c5f401d91879e7c790c619969369800cde202da214/rpds_py-0.28.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:76500820c2af232435cbe215e3324c75b950a027134e044423f59f5b9a1ba515", size = 576925, upload-time = "2025-10-22T22:23:34.47Z" }, - { url = "https://files.pythonhosted.org/packages/ab/12/85a57d7a5855a3b188d024b099fd09c90db55d32a03626d0ed16352413ff/rpds_py-0.28.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bbdc5640900a7dbf9dd707fe6388972f5bbd883633eb68b76591044cfe346f7e", size = 542444, upload-time = "2025-10-22T22:23:36.093Z" }, - { url = "https://files.pythonhosted.org/packages/6c/65/10643fb50179509150eb94d558e8837c57ca8b9adc04bd07b98e57b48f8c/rpds_py-0.28.0-cp314-cp314-win32.whl", hash = "sha256:adc8aa88486857d2b35d75f0640b949759f79dc105f50aa2c27816b2e0dd749f", size = 207968, upload-time = "2025-10-22T22:23:37.638Z" }, - { url = "https://files.pythonhosted.org/packages/b4/84/0c11fe4d9aaea784ff4652499e365963222481ac647bcd0251c88af646eb/rpds_py-0.28.0-cp314-cp314-win_amd64.whl", hash = "sha256:66e6fa8e075b58946e76a78e69e1a124a21d9a48a5b4766d15ba5b06869d1fa1", size = 218876, upload-time = "2025-10-22T22:23:39.179Z" }, - { url = "https://files.pythonhosted.org/packages/0f/e0/3ab3b86ded7bb18478392dc3e835f7b754cd446f62f3fc96f4fe2aca78f6/rpds_py-0.28.0-cp314-cp314-win_arm64.whl", hash = "sha256:a6fe887c2c5c59413353b7c0caff25d0e566623501ccfff88957fa438a69377d", size = 212506, upload-time = "2025-10-22T22:23:40.755Z" }, - { url = "https://files.pythonhosted.org/packages/51/ec/d5681bb425226c3501eab50fc30e9d275de20c131869322c8a1729c7b61c/rpds_py-0.28.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7a69df082db13c7070f7b8b1f155fa9e687f1d6aefb7b0e3f7231653b79a067b", size = 355433, upload-time = "2025-10-22T22:23:42.259Z" }, - { url = "https://files.pythonhosted.org/packages/be/ec/568c5e689e1cfb1ea8b875cffea3649260955f677fdd7ddc6176902d04cd/rpds_py-0.28.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b1cde22f2c30ebb049a9e74c5374994157b9b70a16147d332f89c99c5960737a", size = 342601, upload-time = "2025-10-22T22:23:44.372Z" }, - { url = "https://files.pythonhosted.org/packages/32/fe/51ada84d1d2a1d9d8f2c902cfddd0133b4a5eb543196ab5161d1c07ed2ad/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5338742f6ba7a51012ea470bd4dc600a8c713c0c72adaa0977a1b1f4327d6592", size = 372039, upload-time = "2025-10-22T22:23:46.025Z" }, - { url = "https://files.pythonhosted.org/packages/07/c1/60144a2f2620abade1a78e0d91b298ac2d9b91bc08864493fa00451ef06e/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e1460ebde1bcf6d496d80b191d854adedcc619f84ff17dc1c6d550f58c9efbba", size = 382407, upload-time = "2025-10-22T22:23:48.098Z" }, - { url = "https://files.pythonhosted.org/packages/45/ed/091a7bbdcf4038a60a461df50bc4c82a7ed6d5d5e27649aab61771c17585/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e3eb248f2feba84c692579257a043a7699e28a77d86c77b032c1d9fbb3f0219c", size = 518172, upload-time = "2025-10-22T22:23:50.16Z" }, - { url = "https://files.pythonhosted.org/packages/54/dd/02cc90c2fd9c2ef8016fd7813bfacd1c3a1325633ec8f244c47b449fc868/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3bbba5def70b16cd1c1d7255666aad3b290fbf8d0fe7f9f91abafb73611a91", size = 399020, upload-time = "2025-10-22T22:23:51.81Z" }, - { url = "https://files.pythonhosted.org/packages/ab/81/5d98cc0329bbb911ccecd0b9e19fbf7f3a5de8094b4cda5e71013b2dd77e/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3114f4db69ac5a1f32e7e4d1cbbe7c8f9cf8217f78e6e002cedf2d54c2a548ed", size = 377451, upload-time = "2025-10-22T22:23:53.711Z" }, - { url = "https://files.pythonhosted.org/packages/b4/07/4d5bcd49e3dfed2d38e2dcb49ab6615f2ceb9f89f5a372c46dbdebb4e028/rpds_py-0.28.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4b0cb8a906b1a0196b863d460c0222fb8ad0f34041568da5620f9799b83ccf0b", size = 390355, upload-time = "2025-10-22T22:23:55.299Z" }, - { url = "https://files.pythonhosted.org/packages/3f/79/9f14ba9010fee74e4f40bf578735cfcbb91d2e642ffd1abe429bb0b96364/rpds_py-0.28.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cf681ac76a60b667106141e11a92a3330890257e6f559ca995fbb5265160b56e", size = 403146, upload-time = "2025-10-22T22:23:56.929Z" }, - { url = "https://files.pythonhosted.org/packages/39/4c/f08283a82ac141331a83a40652830edd3a4a92c34e07e2bbe00baaea2f5f/rpds_py-0.28.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1e8ee6413cfc677ce8898d9cde18cc3a60fc2ba756b0dec5b71eb6eb21c49fa1", size = 552656, upload-time = "2025-10-22T22:23:58.62Z" }, - { url = "https://files.pythonhosted.org/packages/61/47/d922fc0666f0dd8e40c33990d055f4cc6ecff6f502c2d01569dbed830f9b/rpds_py-0.28.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b3072b16904d0b5572a15eb9d31c1954e0d3227a585fc1351aa9878729099d6c", size = 576782, upload-time = "2025-10-22T22:24:00.312Z" }, - { url = "https://files.pythonhosted.org/packages/d3/0c/5bafdd8ccf6aa9d3bfc630cfece457ff5b581af24f46a9f3590f790e3df2/rpds_py-0.28.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b670c30fd87a6aec281c3c9896d3bae4b205fd75d79d06dc87c2503717e46092", size = 544671, upload-time = "2025-10-22T22:24:02.297Z" }, - { url = "https://files.pythonhosted.org/packages/2c/37/dcc5d8397caa924988693519069d0beea077a866128719351a4ad95e82fc/rpds_py-0.28.0-cp314-cp314t-win32.whl", hash = "sha256:8014045a15b4d2b3476f0a287fcc93d4f823472d7d1308d47884ecac9e612be3", size = 205749, upload-time = "2025-10-22T22:24:03.848Z" }, - { url = "https://files.pythonhosted.org/packages/d7/69/64d43b21a10d72b45939a28961216baeb721cc2a430f5f7c3bfa21659a53/rpds_py-0.28.0-cp314-cp314t-win_amd64.whl", hash = "sha256:7a4e59c90d9c27c561eb3160323634a9ff50b04e4f7820600a2beb0ac90db578", size = 216233, upload-time = "2025-10-22T22:24:05.471Z" }, - { url = "https://files.pythonhosted.org/packages/ae/bc/b43f2ea505f28119bd551ae75f70be0c803d2dbcd37c1b3734909e40620b/rpds_py-0.28.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f5e7101145427087e493b9c9b959da68d357c28c562792300dd21a095118ed16", size = 363913, upload-time = "2025-10-22T22:24:07.129Z" }, - { url = "https://files.pythonhosted.org/packages/28/f2/db318195d324c89a2c57dc5195058cbadd71b20d220685c5bd1da79ee7fe/rpds_py-0.28.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:31eb671150b9c62409a888850aaa8e6533635704fe2b78335f9aaf7ff81eec4d", size = 350452, upload-time = "2025-10-22T22:24:08.754Z" }, - { url = "https://files.pythonhosted.org/packages/ae/f2/1391c819b8573a4898cedd6b6c5ec5bc370ce59e5d6bdcebe3c9c1db4588/rpds_py-0.28.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48b55c1f64482f7d8bd39942f376bfdf2f6aec637ee8c805b5041e14eeb771db", size = 380957, upload-time = "2025-10-22T22:24:10.826Z" }, - { url = "https://files.pythonhosted.org/packages/5a/5c/e5de68ee7eb7248fce93269833d1b329a196d736aefb1a7481d1e99d1222/rpds_py-0.28.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:24743a7b372e9a76171f6b69c01aedf927e8ac3e16c474d9fe20d552a8cb45c7", size = 391919, upload-time = "2025-10-22T22:24:12.559Z" }, - { url = "https://files.pythonhosted.org/packages/fb/4f/2376336112cbfeb122fd435d608ad8d5041b3aed176f85a3cb32c262eb80/rpds_py-0.28.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:389c29045ee8bbb1627ea190b4976a310a295559eaf9f1464a1a6f2bf84dde78", size = 528541, upload-time = "2025-10-22T22:24:14.197Z" }, - { url = "https://files.pythonhosted.org/packages/68/53/5ae232e795853dd20da7225c5dd13a09c0a905b1a655e92bdf8d78a99fd9/rpds_py-0.28.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:23690b5827e643150cf7b49569679ec13fe9a610a15949ed48b85eb7f98f34ec", size = 405629, upload-time = "2025-10-22T22:24:16.001Z" }, - { url = "https://files.pythonhosted.org/packages/b9/2d/351a3b852b683ca9b6b8b38ed9efb2347596973849ba6c3a0e99877c10aa/rpds_py-0.28.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f0c9266c26580e7243ad0d72fc3e01d6b33866cfab5084a6da7576bcf1c4f72", size = 384123, upload-time = "2025-10-22T22:24:17.585Z" }, - { url = "https://files.pythonhosted.org/packages/e0/15/870804daa00202728cc91cb8e2385fa9f1f4eb49857c49cfce89e304eae6/rpds_py-0.28.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:4c6c4db5d73d179746951486df97fd25e92396be07fc29ee8ff9a8f5afbdfb27", size = 400923, upload-time = "2025-10-22T22:24:19.512Z" }, - { url = "https://files.pythonhosted.org/packages/53/25/3706b83c125fa2a0bccceac951de3f76631f6bd0ee4d02a0ed780712ef1b/rpds_py-0.28.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a3b695a8fa799dd2cfdb4804b37096c5f6dba1ac7f48a7fbf6d0485bcd060316", size = 413767, upload-time = "2025-10-22T22:24:21.316Z" }, - { url = "https://files.pythonhosted.org/packages/ef/f9/ce43dbe62767432273ed2584cef71fef8411bddfb64125d4c19128015018/rpds_py-0.28.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:6aa1bfce3f83baf00d9c5fcdbba93a3ab79958b4c7d7d1f55e7fe68c20e63912", size = 561530, upload-time = "2025-10-22T22:24:22.958Z" }, - { url = "https://files.pythonhosted.org/packages/46/c9/ffe77999ed8f81e30713dd38fd9ecaa161f28ec48bb80fa1cd9118399c27/rpds_py-0.28.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:7b0f9dceb221792b3ee6acb5438eb1f02b0cb2c247796a72b016dcc92c6de829", size = 585453, upload-time = "2025-10-22T22:24:24.779Z" }, - { url = "https://files.pythonhosted.org/packages/ed/d2/4a73b18821fd4669762c855fd1f4e80ceb66fb72d71162d14da58444a763/rpds_py-0.28.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:5d0145edba8abd3db0ab22b5300c99dc152f5c9021fab861be0f0544dc3cbc5f", size = 552199, upload-time = "2025-10-22T22:24:26.54Z" }, -] - -[[package]] -name = "shellingham" -version = "1.5.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, ] [[package]] @@ -2353,65 +2412,64 @@ wheels = [ [[package]] name = "sphinx" -version = "8.1.3" +version = "9.0.4" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.11'", + "python_full_version < '3.12'", ] dependencies = [ - { name = "alabaster", marker = "python_full_version < '3.11'" }, - { name = "babel", marker = "python_full_version < '3.11'" }, - { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, - { name = "docutils", marker = "python_full_version < '3.11'" }, - { name = "imagesize", marker = "python_full_version < '3.11'" }, - { name = "jinja2", marker = "python_full_version < '3.11'" }, - { name = "packaging", marker = "python_full_version < '3.11'" }, - { name = "pygments", marker = "python_full_version < '3.11'" }, - { name = "requests", marker = "python_full_version < '3.11'" }, - { name = "snowballstemmer", marker = "python_full_version < '3.11'" }, - { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.11'" }, - { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.11'" }, - { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.11'" }, - { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.11'" }, - { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.11'" }, - { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.11'" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "alabaster", marker = "python_full_version < '3.12'" }, + { name = "babel", marker = "python_full_version < '3.12'" }, + { name = "colorama", marker = "python_full_version < '3.12' and sys_platform == 'win32'" }, + { name = "docutils", marker = "python_full_version < '3.12'" }, + { name = "imagesize", marker = "python_full_version < '3.12'" }, + { name = "jinja2", marker = "python_full_version < '3.12'" }, + { name = "packaging", marker = "python_full_version < '3.12'" }, + { name = "pygments", marker = "python_full_version < '3.12'" }, + { name = "requests", marker = "python_full_version < '3.12'" }, + { name = "roman-numerals", marker = "python_full_version < '3.12'" }, + { name = "snowballstemmer", marker = "python_full_version < '3.12'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.12'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.12'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.12'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.12'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.12'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" } +sdist = { url = "https://files.pythonhosted.org/packages/42/50/a8c6ccc36d5eacdfd7913ddccd15a9cee03ecafc5ee2bc40e1f168d85022/sphinx-9.0.4.tar.gz", hash = "sha256:594ef59d042972abbc581d8baa577404abe4e6c3b04ef61bd7fc2acbd51f3fa3", size = 8710502, upload-time = "2025-12-04T07:45:27.343Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/60/1ddff83a56d33aaf6f10ec8ce84b4c007d9368b21008876fceda7e7381ef/sphinx-8.1.3-py3-none-any.whl", hash = "sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2", size = 3487125, upload-time = "2024-10-13T20:27:10.448Z" }, + { url = "https://files.pythonhosted.org/packages/c6/3f/4bbd76424c393caead2e1eb89777f575dee5c8653e2d4b6afd7a564f5974/sphinx-9.0.4-py3-none-any.whl", hash = "sha256:5bebc595a5e943ea248b99c13814c1c5e10b3ece718976824ffa7959ff95fffb", size = 3917713, upload-time = "2025-12-04T07:45:24.944Z" }, ] [[package]] name = "sphinx" -version = "8.2.3" +version = "9.1.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", ] dependencies = [ - { name = "alabaster", marker = "python_full_version >= '3.11'" }, - { name = "babel", marker = "python_full_version >= '3.11'" }, - { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, - { name = "docutils", marker = "python_full_version >= '3.11'" }, - { name = "imagesize", marker = "python_full_version >= '3.11'" }, - { name = "jinja2", marker = "python_full_version >= '3.11'" }, - { name = "packaging", marker = "python_full_version >= '3.11'" }, - { name = "pygments", marker = "python_full_version >= '3.11'" }, - { name = "requests", marker = "python_full_version >= '3.11'" }, - { name = "roman-numerals-py", marker = "python_full_version >= '3.11'" }, - { name = "snowballstemmer", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.11'" }, + { name = "alabaster", marker = "python_full_version >= '3.12'" }, + { name = "babel", marker = "python_full_version >= '3.12'" }, + { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, + { name = "docutils", marker = "python_full_version >= '3.12'" }, + { name = "imagesize", marker = "python_full_version >= '3.12'" }, + { name = "jinja2", marker = "python_full_version >= '3.12'" }, + { name = "packaging", marker = "python_full_version >= '3.12'" }, + { name = "pygments", marker = "python_full_version >= '3.12'" }, + { name = "requests", marker = "python_full_version >= '3.12'" }, + { name = "roman-numerals", marker = "python_full_version >= '3.12'" }, + { name = "snowballstemmer", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/38/ad/4360e50ed56cb483667b8e6dadf2d3fda62359593faabbe749a27c4eaca6/sphinx-8.2.3.tar.gz", hash = "sha256:398ad29dee7f63a75888314e9424d40f52ce5a6a87ae88e7071e80af296ec348", size = 8321876, upload-time = "2025-03-02T22:31:59.658Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/31/53/136e9eca6e0b9dc0e1962e2c908fbea2e5ac000c2a2fbd9a35797958c48b/sphinx-8.2.3-py3-none-any.whl", hash = "sha256:4405915165f13521d875a8c29c8970800a0141c14cc5416a38feca4ea5d9b9c3", size = 3589741, upload-time = "2025-03-02T22:31:56.836Z" }, + { url = "https://files.pythonhosted.org/packages/73/f7/b1884cb3188ab181fc81fa00c266699dab600f927a964df02ec3d5d1916a/sphinx-9.1.0-py3-none-any.whl", hash = "sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978", size = 3921742, upload-time = "2025-12-31T15:09:25.561Z" }, ] [[package]] @@ -2419,8 +2477,8 @@ name = "sphinx-issues" version = "5.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3a/62/b55f1c482ce20acee71185dbebf0497a48d23b325b48925d95d5ce0e4666/sphinx_issues-5.0.1.tar.gz", hash = "sha256:6da131d4545af00be4b48ec7c4086ea82c1371a05116bbe5779f57cff34bf16a", size = 14370, upload-time = "2025-04-10T13:41:41.945Z" } wheels = [ @@ -2429,17 +2487,17 @@ wheels = [ [[package]] name = "sphinx-rtd-theme" -version = "3.0.2" +version = "3.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "docutils" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "sphinxcontrib-jquery" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/91/44/c97faec644d29a5ceddd3020ae2edffa69e7d00054a8c7a6021e82f20335/sphinx_rtd_theme-3.0.2.tar.gz", hash = "sha256:b7457bc25dda723b20b086a670b9953c859eab60a2a03ee8eb2bb23e176e5f85", size = 7620463, upload-time = "2024-11-13T11:06:04.545Z" } +sdist = { url = "https://files.pythonhosted.org/packages/84/68/a1bfbf38c0f7bccc9b10bbf76b94606f64acb1552ae394f0b8285bfaea25/sphinx_rtd_theme-3.1.0.tar.gz", hash = "sha256:b44276f2c276e909239a4f6c955aa667aaafeb78597923b1c60babc76db78e4c", size = 7620915, upload-time = "2026-01-12T16:03:31.17Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/85/77/46e3bac77b82b4df5bb5b61f2de98637724f246b4966cfc34bc5895d852a/sphinx_rtd_theme-3.0.2-py2.py3-none-any.whl", hash = "sha256:422ccc750c3a3a311de4ae327e82affdaf59eb695ba4936538552f3b00f4ee13", size = 7655561, upload-time = "2024-11-13T11:06:02.094Z" }, + { url = "https://files.pythonhosted.org/packages/87/c7/b5c8015d823bfda1a346adb2c634a2101d50bb75d421eb6dcb31acd25ebc/sphinx_rtd_theme-3.1.0-py2.py3-none-any.whl", hash = "sha256:1785824ae8e6632060490f67cf3a72d404a85d2d9fc26bce3619944de5682b89", size = 7655617, upload-time = "2026-01-12T16:03:28.101Z" }, ] [[package]] @@ -2474,8 +2532,8 @@ name = "sphinxcontrib-jquery" version = "4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/de/f3/aa67467e051df70a6330fe7770894b3e4f09436dea6881ae0b4f3d87cad8/sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a", size = 122331, upload-time = "2023-03-14T15:01:01.944Z" } wheels = [ @@ -2491,6 +2549,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, ] +[[package]] +name = "sphinxcontrib-mermaid" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "pyyaml" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/a5/65a5c439cc14ba80483b9891e9350f11efb80cd3bdccb222f0c738068c78/sphinxcontrib_mermaid-2.0.0.tar.gz", hash = "sha256:cf4f7d453d001132eaba5d1fdf53d42049f02e913213cf8337427483bfca26f4", size = 18194, upload-time = "2026-01-13T17:13:42.563Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/de/bd96c69b62e967bffd02c6d89dfca9471b04e761c466725fc39746abf41d/sphinxcontrib_mermaid-2.0.0-py3-none-any.whl", hash = "sha256:59a73249bbee2c74b1a4db036f8e8899ade65982bdda6712cf22b4f4e9874bb5", size = 14055, upload-time = "2026-01-13T17:13:41.481Z" }, +] + [[package]] name = "sphinxcontrib-qthelp" version = "2.0.0" @@ -2525,7 +2598,7 @@ wheels = [ [[package]] name = "streamlit" -version = "1.51.0" +version = "1.53.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "altair" }, @@ -2533,8 +2606,7 @@ dependencies = [ { name = "cachetools" }, { name = "click" }, { name = "gitpython" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.3.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy" }, { name = "packaging" }, { name = "pandas" }, { name = "pillow" }, @@ -2548,21 +2620,21 @@ dependencies = [ { name = "typing-extensions" }, { name = "watchdog", marker = "sys_platform != 'darwin'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/59/6d/327ddd5fc35fcf2aeecb4040668337f5565a1c6c95b1e892b8bfd4bb9031/streamlit-1.51.0.tar.gz", hash = "sha256:1e742a9c0b698f466c6f5bf58d333beda5a1fbe8de660743976791b5c1446ef6", size = 9742904, upload-time = "2025-10-29T17:07:39.082Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/b1/5e5fd38d4a5f97163ff071d76e8d6b3aa43e03f86bf94fd0265c43e43fa3/streamlit-1.53.0.tar.gz", hash = "sha256:0114116d34589f2e652bf4ac735a3aca69807e659f92f99c98e7b620d000838f", size = 8650270, upload-time = "2026-01-14T19:52:24.94Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/60/868371b6482ccd9ef423c6f62650066cf8271fdb2ee84f192695ad6b7a96/streamlit-1.51.0-py3-none-any.whl", hash = "sha256:4008b029f71401ce54946bb09a6a3e36f4f7652cbb48db701224557738cfda38", size = 10171702, upload-time = "2025-10-29T17:07:35.97Z" }, + { url = "https://files.pythonhosted.org/packages/a8/54/47ed40f34403205b2c9aab04472e864d1b496b4381b9bf408cf2c20e144c/streamlit-1.53.0-py3-none-any.whl", hash = "sha256:e8b65210bd1a785d121340b794a47c7c912d8da401af9e4403e16c84e3bc4410", size = 9110100, upload-time = "2026-01-14T19:52:22.589Z" }, ] [[package]] name = "streamlit-pdf-viewer" -version = "0.0.26" +version = "0.0.27" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "streamlit" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a8/86/19070a89e0140ec3744908cfee660d9f5a0498f7eeaeb916006222f64598/streamlit_pdf_viewer-0.0.26.tar.gz", hash = "sha256:360560b04b01f4805dccbece16e649077e2983a753102c4c95338d8c5234a7d2", size = 2556416, upload-time = "2025-06-23T20:53:49.039Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/22/59f714ab28357ce29eac8f61295d36f334e49849bd42db3c6faa55073087/streamlit_pdf_viewer-0.0.27.tar.gz", hash = "sha256:72985d30cac26650e126f31270ed3e8aa7ee87f277464271e0c323208c856a37", size = 2276543, upload-time = "2026-01-09T17:35:29.333Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/c6/f9309b16cd987c61f3144489fdae57d6692f24e272880b65d57600e6ae36/streamlit_pdf_viewer-0.0.26-py3-none-any.whl", hash = "sha256:d6fcc9b1bb0d79e07d3c3506b5467bf79b9c3aada15ffa052e9bf3b913df33aa", size = 2598801, upload-time = "2025-06-23T20:53:47.562Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d7/e89f9b86d1da41b9151d8b58761ef8c73994225fe091aa64b65056167d98/streamlit_pdf_viewer-0.0.27-py3-none-any.whl", hash = "sha256:e10bb1cfe8e7e7fd6dd0b095e0422555d82537b7abe0cbcddf29533f3382933f", size = 2316344, upload-time = "2026-01-09T17:35:27.773Z" }, ] [[package]] @@ -2585,70 +2657,75 @@ wheels = [ [[package]] name = "tomli" -version = "2.3.0" +version = "2.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" }, - { url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" }, - { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" }, - { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" }, - { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" }, - { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" }, - { url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" }, - { url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" }, - { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" }, - { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" }, - { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" }, - { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" }, - { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" }, - { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" }, - { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" }, - { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" }, - { url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" }, - { url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" }, - { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" }, - { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" }, - { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" }, - { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" }, - { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" }, - { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" }, - { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" }, - { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" }, - { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" }, - { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" }, - { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" }, - { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" }, - { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" }, - { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" }, - { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" }, - { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" }, - { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" }, - { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" }, - { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" }, - { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" }, - { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" }, - { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" }, - { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, ] [[package]] name = "tornado" -version = "6.5.2" +version = "6.5.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/09/ce/1eb500eae19f4648281bb2186927bb062d2438c2e5093d1360391afd2f90/tornado-6.5.2.tar.gz", hash = "sha256:ab53c8f9a0fa351e2c0741284e06c7a45da86afb544133201c5cc8578eb076a0", size = 510821, upload-time = "2025-08-08T18:27:00.78Z" } +sdist = { url = "https://files.pythonhosted.org/packages/37/1d/0a336abf618272d53f62ebe274f712e213f5a03c0b2339575430b8362ef2/tornado-6.5.4.tar.gz", hash = "sha256:a22fa9047405d03260b483980635f0b041989d8bcc9a313f8fe18b411d84b1d7", size = 513632, upload-time = "2025-12-15T19:21:03.836Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/48/6a7529df2c9cc12efd2e8f5dd219516184d703b34c06786809670df5b3bd/tornado-6.5.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:2436822940d37cde62771cff8774f4f00b3c8024fe482e16ca8387b8a2724db6", size = 442563, upload-time = "2025-08-08T18:26:42.945Z" }, - { url = "https://files.pythonhosted.org/packages/f2/b5/9b575a0ed3e50b00c40b08cbce82eb618229091d09f6d14bce80fc01cb0b/tornado-6.5.2-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:583a52c7aa94ee046854ba81d9ebb6c81ec0fd30386d96f7640c96dad45a03ef", size = 440729, upload-time = "2025-08-08T18:26:44.473Z" }, - { url = "https://files.pythonhosted.org/packages/1b/4e/619174f52b120efcf23633c817fd3fed867c30bff785e2cd5a53a70e483c/tornado-6.5.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0fe179f28d597deab2842b86ed4060deec7388f1fd9c1b4a41adf8af058907e", size = 444295, upload-time = "2025-08-08T18:26:46.021Z" }, - { url = "https://files.pythonhosted.org/packages/95/fa/87b41709552bbd393c85dd18e4e3499dcd8983f66e7972926db8d96aa065/tornado-6.5.2-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b186e85d1e3536d69583d2298423744740986018e393d0321df7340e71898882", size = 443644, upload-time = "2025-08-08T18:26:47.625Z" }, - { url = "https://files.pythonhosted.org/packages/f9/41/fb15f06e33d7430ca89420283a8762a4e6b8025b800ea51796ab5e6d9559/tornado-6.5.2-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e792706668c87709709c18b353da1f7662317b563ff69f00bab83595940c7108", size = 443878, upload-time = "2025-08-08T18:26:50.599Z" }, - { url = "https://files.pythonhosted.org/packages/11/92/fe6d57da897776ad2e01e279170ea8ae726755b045fe5ac73b75357a5a3f/tornado-6.5.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:06ceb1300fd70cb20e43b1ad8aaee0266e69e7ced38fa910ad2e03285009ce7c", size = 444549, upload-time = "2025-08-08T18:26:51.864Z" }, - { url = "https://files.pythonhosted.org/packages/9b/02/c8f4f6c9204526daf3d760f4aa555a7a33ad0e60843eac025ccfd6ff4a93/tornado-6.5.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:74db443e0f5251be86cbf37929f84d8c20c27a355dd452a5cfa2aada0d001ec4", size = 443973, upload-time = "2025-08-08T18:26:53.625Z" }, - { url = "https://files.pythonhosted.org/packages/ae/2d/f5f5707b655ce2317190183868cd0f6822a1121b4baeae509ceb9590d0bd/tornado-6.5.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b5e735ab2889d7ed33b32a459cac490eda71a1ba6857b0118de476ab6c366c04", size = 443954, upload-time = "2025-08-08T18:26:55.072Z" }, - { url = "https://files.pythonhosted.org/packages/e8/59/593bd0f40f7355806bf6573b47b8c22f8e1374c9b6fd03114bd6b7a3dcfd/tornado-6.5.2-cp39-abi3-win32.whl", hash = "sha256:c6f29e94d9b37a95013bb669616352ddb82e3bfe8326fccee50583caebc8a5f0", size = 445023, upload-time = "2025-08-08T18:26:56.677Z" }, - { url = "https://files.pythonhosted.org/packages/c7/2a/f609b420c2f564a748a2d80ebfb2ee02a73ca80223af712fca591386cafb/tornado-6.5.2-cp39-abi3-win_amd64.whl", hash = "sha256:e56a5af51cc30dd2cae649429af65ca2f6571da29504a07995175df14c18f35f", size = 445427, upload-time = "2025-08-08T18:26:57.91Z" }, - { url = "https://files.pythonhosted.org/packages/5e/4f/e1f65e8f8c76d73658b33d33b81eed4322fb5085350e4328d5c956f0c8f9/tornado-6.5.2-cp39-abi3-win_arm64.whl", hash = "sha256:d6c33dc3672e3a1f3618eb63b7ef4683a7688e7b9e6e8f0d9aa5726360a004af", size = 444456, upload-time = "2025-08-08T18:26:59.207Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a9/e94a9d5224107d7ce3cc1fab8d5dc97f5ea351ccc6322ee4fb661da94e35/tornado-6.5.4-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d6241c1a16b1c9e4cc28148b1cda97dd1c6cb4fb7068ac1bedc610768dff0ba9", size = 443909, upload-time = "2025-12-15T19:20:48.382Z" }, + { url = "https://files.pythonhosted.org/packages/db/7e/f7b8d8c4453f305a51f80dbb49014257bb7d28ccb4bbb8dd328ea995ecad/tornado-6.5.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2d50f63dda1d2cac3ae1fa23d254e16b5e38153758470e9956cbc3d813d40843", size = 442163, upload-time = "2025-12-15T19:20:49.791Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b5/206f82d51e1bfa940ba366a8d2f83904b15942c45a78dd978b599870ab44/tornado-6.5.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1cf66105dc6acb5af613c054955b8137e34a03698aa53272dbda4afe252be17", size = 445746, upload-time = "2025-12-15T19:20:51.491Z" }, + { url = "https://files.pythonhosted.org/packages/8e/9d/1a3338e0bd30ada6ad4356c13a0a6c35fbc859063fa7eddb309183364ac1/tornado-6.5.4-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50ff0a58b0dc97939d29da29cd624da010e7f804746621c78d14b80238669335", size = 445083, upload-time = "2025-12-15T19:20:52.778Z" }, + { url = "https://files.pythonhosted.org/packages/50/d4/e51d52047e7eb9a582da59f32125d17c0482d065afd5d3bc435ff2120dc5/tornado-6.5.4-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5fb5e04efa54cf0baabdd10061eb4148e0be137166146fff835745f59ab9f7f", size = 445315, upload-time = "2025-12-15T19:20:53.996Z" }, + { url = "https://files.pythonhosted.org/packages/27/07/2273972f69ca63dbc139694a3fc4684edec3ea3f9efabf77ed32483b875c/tornado-6.5.4-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9c86b1643b33a4cd415f8d0fe53045f913bf07b4a3ef646b735a6a86047dda84", size = 446003, upload-time = "2025-12-15T19:20:56.101Z" }, + { url = "https://files.pythonhosted.org/packages/d1/83/41c52e47502bf7260044413b6770d1a48dda2f0246f95ee1384a3cd9c44a/tornado-6.5.4-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:6eb82872335a53dd063a4f10917b3efd28270b56a33db69009606a0312660a6f", size = 445412, upload-time = "2025-12-15T19:20:57.398Z" }, + { url = "https://files.pythonhosted.org/packages/10/c7/bc96917f06cbee182d44735d4ecde9c432e25b84f4c2086143013e7b9e52/tornado-6.5.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6076d5dda368c9328ff41ab5d9dd3608e695e8225d1cd0fd1e006f05da3635a8", size = 445392, upload-time = "2025-12-15T19:20:58.692Z" }, + { url = "https://files.pythonhosted.org/packages/0c/1a/d7592328d037d36f2d2462f4bc1fbb383eec9278bc786c1b111cbbd44cfa/tornado-6.5.4-cp39-abi3-win32.whl", hash = "sha256:1768110f2411d5cd281bac0a090f707223ce77fd110424361092859e089b38d1", size = 446481, upload-time = "2025-12-15T19:21:00.008Z" }, + { url = "https://files.pythonhosted.org/packages/d6/6d/c69be695a0a64fd37a97db12355a035a6d90f79067a3cf936ec2b1dc38cd/tornado-6.5.4-cp39-abi3-win_amd64.whl", hash = "sha256:fa07d31e0cd85c60713f2b995da613588aa03e1303d75705dca6af8babc18ddc", size = 446886, upload-time = "2025-12-15T19:21:01.287Z" }, + { url = "https://files.pythonhosted.org/packages/50/49/8dc3fd90902f70084bd2cd059d576ddb4f8bb44c2c7c0e33a11422acb17e/tornado-6.5.4-cp39-abi3-win_arm64.whl", hash = "sha256:053e6e16701eb6cbe641f308f4c1a9541f91b6261991160391bfc342e8a551a1", size = 445910, upload-time = "2025-12-15T19:21:02.571Z" }, ] [[package]] @@ -2660,25 +2737,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" }, ] -[[package]] -name = "typer-slim" -version = "0.20.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8e/45/81b94a52caed434b94da65729c03ad0fb7665fab0f7db9ee54c94e541403/typer_slim-0.20.0.tar.gz", hash = "sha256:9fc6607b3c6c20f5c33ea9590cbeb17848667c51feee27d9e314a579ab07d1a3", size = 106561, upload-time = "2025-10-20T17:03:46.642Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/dd/5cbf31f402f1cc0ab087c94d4669cfa55bd1e818688b910631e131d74e75/typer_slim-0.20.0-py3-none-any.whl", hash = "sha256:f42a9b7571a12b97dddf364745d29f12221865acef7a2680065f9bb29c7dc89d", size = 47087, upload-time = "2025-10-20T17:03:44.546Z" }, -] - -[package.optional-dependencies] -standard = [ - { name = "rich" }, - { name = "shellingham" }, -] - [[package]] name = "types-humanfriendly" version = "10.0.1.20250319" @@ -2707,21 +2765,53 @@ wheels = [ ] [[package]] -name = "tzdata" -version = "2025.2" +name = "typing-inspection" +version = "0.4.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, +] + +[[package]] +name = "uharfbuzz" +version = "0.53.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/64/25/0323ac6cc4dc20c93d294d6deda891cd3b5d069309ed4256784a616c45bd/uharfbuzz-0.53.2.tar.gz", hash = "sha256:5151cbd986f080bbd2f4d531dbe9a03fb179cefb0fd864ba351aa522e58c9e23", size = 1712956, upload-time = "2025-12-28T01:03:24.303Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/72/1e19d87f3ba246cbc88dbd60d03e4cbac8df3aad5d84d22cf32fc2a50e79/uharfbuzz-0.53.2-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:56d0ceb8af633035b37e9bb8bd642a657eba64cb99636058075d40ad8188758f", size = 2720623, upload-time = "2025-12-28T01:03:04.215Z" }, + { url = "https://files.pythonhosted.org/packages/81/d1/999fde3cced5abdb21a99bdc66e9a430957d7b86aaf06acaf7f76f530a22/uharfbuzz-0.53.2-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f5c65204de46425f58e5b5a6892e7eba3cbcc7dba2c55d95760ba3356a1a546", size = 1646498, upload-time = "2025-12-28T01:03:06.18Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ba/58551ae2ff695360b8e7922f82fa4ce3951cf31e6172039cccc37f87436f/uharfbuzz-0.53.2-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e9c211a0f576b145abdea67391847cc8e490ed7cfbd2b26e85d3d8035b1e3f60", size = 1704735, upload-time = "2025-12-28T01:03:07.952Z" }, + { url = "https://files.pythonhosted.org/packages/0e/26/3cbab4c18419103398904317c1e80115cfc28a82a0c1e5e05593a39de3c9/uharfbuzz-0.53.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e58af854612a9536cff7f440a520873e44cafba501245f473f90d4fb8a7da31d", size = 2663678, upload-time = "2025-12-28T01:03:09.792Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3d/104d1ba2c0e535a0d9744e0a68f711d022c46ec7c7000f35eefd574ba197/uharfbuzz-0.53.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b1b0dee5060df82e2c09130bbef1e80979ce6b26dcfe6f5d9b9a77bc0ed4d8da", size = 2755854, upload-time = "2025-12-28T01:03:11.424Z" }, + { url = "https://files.pythonhosted.org/packages/37/0b/903fc46bd2407baf1cf4c922450f843c293c7b0dab90e7e190be502837e2/uharfbuzz-0.53.2-cp310-abi3-win32.whl", hash = "sha256:8b6a12c50bd94e1a2e9bbf04a737299a76b5ebedcba4c2bf4494233d5b298748", size = 996506, upload-time = "2025-12-28T01:03:13.433Z" }, + { url = "https://files.pythonhosted.org/packages/a7/a4/eb61d2007531634589cce9ee9928c6b6514b8aba01c79cd9b9cbbeff42a7/uharfbuzz-0.53.2-cp310-abi3-win_amd64.whl", hash = "sha256:741134803e14cbece5fde6189fcd4d97ba817fd572b604a3b7f29eb2343e3d11", size = 1244515, upload-time = "2025-12-28T01:03:15.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/0ed81abe167c8ac7660c2e71ef99d8b7a7b85cb849f6e49f7fdf470a2052/uharfbuzz-0.53.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:d7b0c695c480d19e72f33a8326a7e553ed6f657e49d140c05c76fa7b38fa32f9", size = 1334807, upload-time = "2025-12-28T01:03:16.795Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c4/c81544da27418a3dfbf8a5346c8ec6efbb0a3b5485c2626624d71c9814af/uharfbuzz-0.53.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ff46f74084bfdebddb5573a52921d213087a1b8f7a826790ab8da78416e79e44", size = 1235691, upload-time = "2025-12-28T01:03:18.558Z" }, + { url = "https://files.pythonhosted.org/packages/32/e9/e5f648bb7d1e34d23a46070cbd7cf2492bdd09f29233d467fcfb684cf838/uharfbuzz-0.53.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9de020cede02533a71f8042120ca0b771a8a853e7e0118a5fb7dba3280117ae", size = 1495155, upload-time = "2025-12-28T01:03:19.82Z" }, + { url = "https://files.pythonhosted.org/packages/08/7d/f0df05341c5348fed011a5f991c4a78a6346c6f478f0026f9973d5667ecd/uharfbuzz-0.53.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f703dca534f0cdff1920e7c7f2e4338e9a4dc27b1ad2ccdbe93ebbdbe6e050b5", size = 1558438, upload-time = "2025-12-28T01:03:21.249Z" }, + { url = "https://files.pythonhosted.org/packages/32/30/b1399f400b74a1aeffbd6c2570e78d611ec205a7ebbdd5c8e247c0592c84/uharfbuzz-0.53.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:ca7fea0ceab920c18e14a49d6414c63ce1c98e7d575ecd7717cd226411da1e9d", size = 1231881, upload-time = "2025-12-28T01:03:22.626Z" }, ] [[package]] name = "urllib3" -version = "2.5.0" +version = "2.6.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] [[package]] @@ -2730,9 +2820,6 @@ version = "6.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" }, - { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" }, - { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" }, { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, @@ -2742,8 +2829,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, - { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" }, - { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" }, { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, @@ -2771,18 +2856,6 @@ version = "2.0.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/49/2a/6de8a50cb435b7f42c46126cf1a54b2aab81784e74c8595c8e025e8f36d3/wrapt-2.0.1.tar.gz", hash = "sha256:9c9c635e78497cacb81e84f8b11b23e0aacac7a136e73b8e5b2109a1d9fc468f", size = 82040, upload-time = "2025-11-07T00:45:33.312Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/61/0d/12d8c803ed2ce4e5e7d5b9f5f602721f9dfef82c95959f3ce97fa584bb5c/wrapt-2.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:64b103acdaa53b7caf409e8d45d39a8442fe6dcfec6ba3f3d141e0cc2b5b4dbd", size = 77481, upload-time = "2025-11-07T00:43:11.103Z" }, - { url = "https://files.pythonhosted.org/packages/05/3e/4364ebe221ebf2a44d9fc8695a19324692f7dd2795e64bd59090856ebf12/wrapt-2.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:91bcc576260a274b169c3098e9a3519fb01f2989f6d3d386ef9cbf8653de1374", size = 60692, upload-time = "2025-11-07T00:43:13.697Z" }, - { url = "https://files.pythonhosted.org/packages/1f/ff/ae2a210022b521f86a8ddcdd6058d137c051003812b0388a5e9a03d3fe10/wrapt-2.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ab594f346517010050126fcd822697b25a7031d815bb4fbc238ccbe568216489", size = 61574, upload-time = "2025-11-07T00:43:14.967Z" }, - { url = "https://files.pythonhosted.org/packages/c6/93/5cf92edd99617095592af919cb81d4bff61c5dbbb70d3c92099425a8ec34/wrapt-2.0.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:36982b26f190f4d737f04a492a68accbfc6fa042c3f42326fdfbb6c5b7a20a31", size = 113688, upload-time = "2025-11-07T00:43:18.275Z" }, - { url = "https://files.pythonhosted.org/packages/a0/0a/e38fc0cee1f146c9fb266d8ef96ca39fb14a9eef165383004019aa53f88a/wrapt-2.0.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23097ed8bc4c93b7bf36fa2113c6c733c976316ce0ee2c816f64ca06102034ef", size = 115698, upload-time = "2025-11-07T00:43:19.407Z" }, - { url = "https://files.pythonhosted.org/packages/b0/85/bef44ea018b3925fb0bcbe9112715f665e4d5309bd945191da814c314fd1/wrapt-2.0.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8bacfe6e001749a3b64db47bcf0341da757c95959f592823a93931a422395013", size = 112096, upload-time = "2025-11-07T00:43:16.5Z" }, - { url = "https://files.pythonhosted.org/packages/7c/0b/733a2376e413117e497aa1a5b1b78e8f3a28c0e9537d26569f67d724c7c5/wrapt-2.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8ec3303e8a81932171f455f792f8df500fc1a09f20069e5c16bd7049ab4e8e38", size = 114878, upload-time = "2025-11-07T00:43:20.81Z" }, - { url = "https://files.pythonhosted.org/packages/da/03/d81dcb21bbf678fcda656495792b059f9d56677d119ca022169a12542bd0/wrapt-2.0.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:3f373a4ab5dbc528a94334f9fe444395b23c2f5332adab9ff4ea82f5a9e33bc1", size = 111298, upload-time = "2025-11-07T00:43:22.229Z" }, - { url = "https://files.pythonhosted.org/packages/c9/d5/5e623040e8056e1108b787020d56b9be93dbbf083bf2324d42cde80f3a19/wrapt-2.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f49027b0b9503bf6c8cdc297ca55006b80c2f5dd36cecc72c6835ab6e10e8a25", size = 113361, upload-time = "2025-11-07T00:43:24.301Z" }, - { url = "https://files.pythonhosted.org/packages/a1/f3/de535ccecede6960e28c7b722e5744846258111d6c9f071aa7578ea37ad3/wrapt-2.0.1-cp310-cp310-win32.whl", hash = "sha256:8330b42d769965e96e01fa14034b28a2a7600fbf7e8f0cc90ebb36d492c993e4", size = 58035, upload-time = "2025-11-07T00:43:28.96Z" }, - { url = "https://files.pythonhosted.org/packages/21/15/39d3ca5428a70032c2ec8b1f1c9d24c32e497e7ed81aed887a4998905fcc/wrapt-2.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:1218573502a8235bb8a7ecaed12736213b22dcde9feab115fa2989d42b5ded45", size = 60383, upload-time = "2025-11-07T00:43:25.804Z" }, - { url = "https://files.pythonhosted.org/packages/43/c2/dfd23754b7f7a4dce07e08f4309c4e10a40046a83e9ae1800f2e6b18d7c1/wrapt-2.0.1-cp310-cp310-win_arm64.whl", hash = "sha256:eda8e4ecd662d48c28bb86be9e837c13e45c58b8300e43ba3c9b4fa9900302f7", size = 58894, upload-time = "2025-11-07T00:43:27.074Z" }, { url = "https://files.pythonhosted.org/packages/98/60/553997acf3939079dab022e37b67b1904b5b0cc235503226898ba573b10c/wrapt-2.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e17283f533a0d24d6e5429a7d11f250a58d28b4ae5186f8f47853e3e70d2590", size = 77480, upload-time = "2025-11-07T00:43:30.573Z" }, { url = "https://files.pythonhosted.org/packages/2d/50/e5b3d30895d77c52105c6d5cbf94d5b38e2a3dd4a53d22d246670da98f7c/wrapt-2.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:85df8d92158cb8f3965aecc27cf821461bb5f40b450b03facc5d9f0d4d6ddec6", size = 60690, upload-time = "2025-11-07T00:43:31.594Z" }, { url = "https://files.pythonhosted.org/packages/f0/40/660b2898703e5cbbb43db10cdefcc294274458c3ca4c68637c2b99371507/wrapt-2.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c1be685ac7700c966b8610ccc63c3187a72e33cab53526a27b2a285a662cd4f7", size = 61578, upload-time = "2025-11-07T00:43:32.918Z" },