diff --git a/.github/workflows/conventional-commits.yml b/.github/workflows/conventional-commits.yml new file mode 100644 index 0000000..930bad8 --- /dev/null +++ b/.github/workflows/conventional-commits.yml @@ -0,0 +1,35 @@ +name: CI + +on: [push, pull_request] + +permissions: + contents: read + +jobs: + commitlint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Setup node + uses: actions/setup-node@v4 + with: + node-version: lts/* + cache: npm + - name: Install commitlint + run: npm install -D @commitlint/cli @commitlint/config-conventional + - name: Print versions + run: | + git --version + node --version + npm --version + npx commitlint --version + + - name: Validate current commit (last commit) with commitlint + if: github.event_name == 'push' + run: npx commitlint --last --verbose + + - name: Validate PR commits with commitlint + if: github.event_name == 'pull_request' + run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose \ No newline at end of file diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml new file mode 100644 index 0000000..6620953 --- /dev/null +++ b/.github/workflows/python-package.yml @@ -0,0 +1,41 @@ +# This workflow will install Python dependencies, run tests and lint with a variety of Python versions +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python + +name: Python package + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14",] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - uses: yezz123/setup-uv@v4 + with: + uv-version: "0.9.3" + - name: Install system dependencies + run: sudo apt-get update && sudo apt-get install -y poppler-utils + - name: Install dependencies + run: make install + - name: Lint with Ruff + run: make lint + - name: Verify formatting + run: make format + - name: Type check with pyright + run: make typecheck + - name: Test with pytest + run: make test \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..69ea4bb --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,187 @@ +# File: .github/workflows/release.yml +on: + push: + branches: + - main + +jobs: + + build: + runs-on: ubuntu-latest + permissions: + contents: read + env: + dist_artifacts_name: dist + dist_artifacts_dir: dist + lock_file_artifact: uv.lock + steps: + - name: Setup | Checkout Repository at workflow sha + uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + + - name: Setup | Force correct release branch on workflow sha + run: git checkout -B ${{ github.ref_name }} + + - name: Setup | Install uv + uses: asdf-vm/actions/install@1902764435ca0dd2f3388eea723a4f92a4eb8302 # v4.0.2 + + - name: Setup | Install Python & Project dependencies + run: uv sync --extra build + + - name: Build | Build next version artifacts + id: version + env: + GH_TOKEN: "none" + run: uv run semantic-release -v version --no-changelog --no-commit --no-tag + + - name: Upload | Distribution Artifacts + if: ${{ steps.version.outputs.released == 'true' }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: ${{ env.dist_artifacts_name }} + path: ${{ format('{0}/**', env.dist_artifacts_dir) }} + if-no-files-found: error + retention-days: 2 + + - name: Upload | Lock File Artifact + if: ${{ steps.version.outputs.released == 'true' }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: ${{ env.lock_file_artifact }} + path: ${{ env.lock_file_artifact }} + if-no-files-found: error + retention-days: 2 + + outputs: + new-release-detected: ${{ steps.version.outputs.released }} + new-release-version: ${{ steps.version.outputs.version }} + new-release-tag: ${{ steps.version.outputs.tag }} + new-release-is-prerelease: ${{ steps.version.outputs.is_prerelease }} + distribution-artifacts: ${{ env.dist_artifacts_name }} + lock-file-artifact: ${{ env.lock_file_artifact }} + + + test-e2e: + needs: build + runs-on: ubuntu-latest + steps: + - name: Setup | Checkout Repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.sha }} + fetch-depth: 1 + + - name: Setup | Download Distribution Artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + if: ${{ needs.build.outputs.new-release-detected == 'true' }} + id: artifact-download + with: + name: ${{ needs.build.outputs.distribution-artifacts }} + path: ./dist + + - name: Setup | Install uv + uses: asdf-vm/actions/install@1902764435ca0dd2f3388eea723a4f92a4eb8302 # v4.0.2 + + - name: Setup | Install Python & Project dependencies + run: uv sync --extra test + + - name: Setup | Install distribution artifact + if: ${{ steps.artifact-download.outcome == 'success' }} + run: | + uv pip uninstall ocrbridge-easyocr + uv pip install dist/ocrbridge_easyocr-*.whl + + - name: Test | Run pytest + run: uv run pytest -vv + + + release: + runs-on: ubuntu-latest + needs: + - build + - test-e2e + + if: ${{ needs.build.outputs.new-release-detected == 'true' }} + + concurrency: + group: ${{ github.workflow }}-release-${{ github.ref_name }} + cancel-in-progress: false + + permissions: + contents: write + + steps: + - name: Setup | Checkout Repository on Release Branch + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.ref_name }} + + - name: Setup | Force release branch to be at workflow sha + run: git reset --hard ${{ github.sha }} + + - name: Setup | Install uv + uses: asdf-vm/actions/install@1902764435ca0dd2f3388eea723a4f92a4eb8302 # v4.0.2 + + - name: Setup | Install Python & Project dependencies + run: uv sync --extra build + + - name: Setup | Download Build Artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + id: artifact-download + with: + name: ${{ needs.build.outputs.distribution-artifacts }} + path: dist + + - name: Setup | Download Lock File Artifact + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: ${{ needs.build.outputs.lock-file-artifact }} + + - name: Setup | Stage Lock File for Version Commit + run: git add uv.lock + + - name: Release | Create Release + id: release + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + uv run semantic-release -v --strict version --skip-build + uv run semantic-release publish + + outputs: + released: ${{ steps.release.outputs.released }} + new-release-version: ${{ steps.release.outputs.version }} + new-release-tag: ${{ steps.release.outputs.tag }} + + + deploy: + name: Deploy + runs-on: ubuntu-latest + if: ${{ needs.release.outputs.released == 'true' && github.repository == 'OCRBridge/ocrbridge-easyocr' }} + needs: + - build + - release + + environment: + name: pypi + url: https://pypi.org/project/ocrbridge-easyocr/ + + permissions: + id-token: write + + steps: + - name: Setup | Download Build Artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + id: artifact-download + with: + name: ${{ needs.build.outputs.distribution-artifacts }} + path: dist + + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4 + with: + packages-dir: dist + print-hash: true + verbose: true \ No newline at end of file diff --git a/.gitignore b/.gitignore index cac53fc..dc7c126 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,9 @@ wheels/ .installed.cfg *.egg +# Node modules +node_modules/ + # Virtual environments venv/ env/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..2653961 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.11.11 \ No newline at end of file diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..df520e7 --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +uv 0.7.12 \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..17b3886 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,115 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is the EasyOCR engine implementation for OCR Bridge - a plugin that provides deep learning-based OCR using the EasyOCR library. It's part of the larger OCR Bridge architecture which provides a unified interface for different OCR engines. + +## Development Commands + +### Setup +```zsh +make install # Install dependencies with uv (includes dev extras) +``` + +### Testing & Quality +```zsh +make test # Run pytest test suite +make lint # Run ruff linter +make format # Format code with ruff +make typecheck # Type check with pyright +make check # Run all checks: lint + typecheck + test +make all # Run check + format (default target) +``` + +### Running Single Tests +```zsh +uv run pytest tests/test_specific.py # Run specific test file +uv run pytest tests/test_specific.py::test_fn # Run specific test function +uv run pytest -k "test_pattern" # Run tests matching pattern +``` + +### Building +```zsh +uv build # Build distribution packages +``` + +## Architecture + +### Entry Point System +This package uses Python entry points for automatic discovery by OCR Bridge: +- Entry point: `ocrbridge.engines` → `easyocr = "ocrbridge.engines.easyocr:EasyOCREngine"` +- Defined in `pyproject.toml` under `[project.entry-points]` +- The engine is automatically discovered when installed alongside ocrbridge-core + +### Core Components + +**`src/ocrbridge/engines/easyocr/engine.py`** +- `EasyOCREngine`: Main engine class implementing the `OCREngine` interface from ocrbridge-core +- GPU detection and automatic device selection via `detect_gpu_availability()` and `get_easyocr_device()` +- Handles both single images and multi-page PDFs +- PDF processing: converts pages to images via pdf2image, processes each page, merges HOCR output +- Lazy initialization: EasyOCR Reader is created on first use and cached/reused for same language configuration + +**`src/ocrbridge/engines/easyocr/models.py`** +- `EasyOCRParams`: Pydantic model for engine parameters (extends `OCREngineParams` from core) +- Validates language codes against 80+ supported EasyOCR languages +- Parameters: `languages` (list, max 5), `text_threshold` (0.0-1.0), `link_threshold` (0.0-1.0) +- Language codes use EasyOCR format (e.g., "en", "ch_sim", "ja"), NOT Tesseract format + +### Dependencies +- **ocrbridge-core**: Core interfaces and utilities (OCREngine, easyocr_to_hocr converter) +- **easyocr**: Deep learning OCR library (~2GB with PyTorch) +- **torch**: PyTorch for neural network models and GPU support +- **pdf2image**: Converts PDF pages to images for processing +- **Pillow**: Image handling +- **numpy**: Array operations for image data + +### GPU Support +- Engine automatically detects CUDA availability via `torch.cuda.is_available()` +- Gracefully falls back to CPU if GPU unavailable +- No configuration needed - handled transparently in `_create_reader()` + +## Code Quality Standards + +### Commit Messages +Follow Conventional Commits format: +``` +[optional scope]: + +[optional body] + +[optional footer] +``` + +Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`, `ci`, `perf`, `build` + +Examples: +- `feat(engine): add batch processing support` +- `fix: resolve GPU memory leak in multi-page PDFs` +- `docs: update language code examples` + +### Python Style +- **Line length**: 100 characters (Ruff configured) +- **Python version**: 3.11+ (uses modern type hints like `list[str]`, `tuple[float, float]`) +- **Type checking**: Strict mode with pyright +- **Linting**: Ruff with rules E, F, I, N, W enabled +- Type annotations required on all public functions +- Use `cast()` for complex types from untyped libraries (easyocr, pdf2image) + +### Testing +- Tests in `tests/` directory +- Pytest markers available: + - `@pytest.mark.integration`: Tests requiring external dependencies + - `@pytest.mark.slow`: Long-running tests +- Sample test files in `samples/`: PDFs and images for testing various scenarios +- Pythonpath configured to include `src/` for imports + +## CI/CD +- GitHub Actions workflows in `.github/workflows/`: + - `python-package.yml`: Tests on Python 3.10-3.14 + - `release.yml`: Automated releases with semantic versioning + - `conventional-commits.yml`: Validates commit message format +- Uses `uv` package manager (version 0.9.3 in CI) +- All quality checks must pass: lint, format, typecheck, test diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..1e59554 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,213 @@ + +# Contributing to ocrbridge-easyocr + +First off, thanks for taking the time to contribute! ❤️ + +All types of contributions are encouraged and valued. See the [Table of Contents](#table-of-contents) for different ways to help and details about how this project handles them. Please make sure to read the relevant section before making your contribution. It will make it a lot easier for us maintainers and smooth out the experience for all involved. The community looks forward to your contributions. 🎉 + +> And if you like the project, but just don't have time to contribute, that's fine. There are other easy ways to support the project and show your appreciation, which we would also be very happy about: +> - Star the project +> - Tweet about it +> - Refer this project in your project's readme +> - Mention the project at local meetups and tell your friends/colleagues + + +## Table of Contents + +- [I Have a Question](#i-have-a-question) + - [I Want To Contribute](#i-want-to-contribute) + - [Reporting Bugs](#reporting-bugs) + - [Suggesting Enhancements](#suggesting-enhancements) + - [Your First Code Contribution](#your-first-code-contribution) + - [Improving The Documentation](#improving-the-documentation) +- [Styleguides](#styleguides) + - [Commit Messages](#commit-messages) +- [Join The Project Team](#join-the-project-team) + + + +## I Have a Question + +> If you want to ask a question, we assume that you have read the available [Documentation](README.md). + +Before you ask a question, it is best to search for existing [Issues](https://github.com/OCRBridge/ocrbridge-easyocr/issues) that might help you. In case you have found a suitable issue and still need clarification, you can write your question in this issue. It is also advisable to search the internet for answers first. + +If you then still feel the need to ask a question and need clarification, we recommend the following: + +- Open an [Issue](https://github.com/OCRBridge/ocrbridge-easyocr/issues/new). +- Provide as much context as you can about what you're running into. +- Provide project and platform versions (nodejs, npm, etc), depending on what seems relevant. + +We will then take care of the issue as soon as possible. + +## I Want To Contribute + +> ### Legal Notice +> When contributing to this project, you must agree that you have authored 100% of the content, that you have the necessary rights to the content and that the content you contribute may be provided under the project licence. + +### Reporting Bugs + + +#### Before Submitting a Bug Report + +A good bug report shouldn't leave others needing to chase you up for more information. Therefore, we ask you to investigate carefully, collect information and describe the issue in detail in your report. Please complete the following steps in advance to help us fix any potential bug as fast as possible. + +- Make sure that you are using the latest version. +- Determine if your bug is really a bug and not an error on your side e.g. using incompatible environment components/versions (Make sure that you have read the [documentation](). If you are looking for support, you might want to check [this section](#i-have-a-question)). +- To see if other users have experienced (and potentially already solved) the same issue you are having, check if there is not already a bug report existing for your bug or error in the [bug tracker](https://github.com/OCRBridge/ocrbridge-easyocr/issues?q=label%3Abug). +- Also make sure to search the internet (including Stack Overflow) to see if users outside of the GitHub community have discussed the issue. +- Collect information about the bug: + - Stack trace (Traceback) + - OS, Platform and Version (Windows, Linux, macOS, x86, ARM) + - Version of the interpreter, compiler, SDK, runtime environment, package manager, depending on what seems relevant. + - Possibly your input and the output + - Can you reliably reproduce the issue? And can you also reproduce it with older versions? + + +#### How Do I Submit a Good Bug Report? + +> You must never report security related issues, vulnerabilities or bugs including sensitive information to the issue tracker, or elsewhere in public. Instead sensitive bugs must be sent by email to <>. + + +We use GitHub issues to track bugs and errors. If you run into an issue with the project: + +- Open an [Issue](https://github.com/OCRBridge/ocrbridge-easyocr/issues/new). (Since we can't be sure at this point whether it is a bug or not, we ask you not to talk about a bug yet and not to label the issue.) +- Explain the behavior you would expect and the actual behavior. +- Please provide as much context as possible and describe the *reproduction steps* that someone else can follow to recreate the issue on their own. This usually includes your code. For good bug reports you should isolate the problem and create a reduced test case. +- Provide the information you collected in the previous section. + +Once it's filed: + +- The project team will label the issue accordingly. +- A team member will try to reproduce the issue with your provided steps. If there are no reproduction steps or no obvious way to reproduce the issue, the team will ask you for those steps and mark the issue as `needs-repro`. Bugs with the `needs-repro` tag will not be addressed until they are reproduced. +- If the team is able to reproduce the issue, it will be marked `needs-fix`, as well as possibly other tags (such as `critical`), and the issue will be left to be [implemented by someone](#your-first-code-contribution). + + + + +### Suggesting Enhancements + +This section guides you through submitting an enhancement suggestion for ocrbridge-easyocr, **including completely new features and minor improvements to existing functionality**. Following these guidelines will help maintainers and the community to understand your suggestion and find related suggestions. + + +#### Before Submitting an Enhancement + +- Make sure that you are using the latest version. +- Read the [documentation]() carefully and find out if the functionality is already covered, maybe by an individual configuration. +- Perform a [search](https://github.com/OCRBridge/ocrbridge-easyocr/issues) to see if the enhancement has already been suggested. If it has, add a comment to the existing issue instead of opening a new one. +- Find out whether your idea fits with the scope and aims of the project. It's up to you to make a strong case to convince the project's developers of the merits of this feature. Keep in mind that we want features that will be useful to the majority of our users and not just a small subset. If you're just targeting a minority of users, consider writing an add-on/plugin library. + + +#### How Do I Submit a Good Enhancement Suggestion? + +Enhancement suggestions are tracked as [GitHub issues](https://github.com/OCRBridge/ocrbridge-easyocr/issues). + +- Use a **clear and descriptive title** for the issue to identify the suggestion. +- Provide a **step-by-step description of the suggested enhancement** in as many details as possible. +- **Describe the current behavior** and **explain which behavior you expected to see instead** and why. At this point you can also tell which alternatives do not work for you. +- You may want to **include screenshots or screen recordings** which help you demonstrate the steps or point out the part which the suggestion is related to. You can use [LICEcap](https://www.cockos.com/licecap/) to record GIFs on macOS and Windows, and the built-in [screen recorder in GNOME](https://help.gnome.org/users/gnome-help/stable/screen-shot-record.html.en) or [SimpleScreenRecorder](https://github.com/MaartenBaert/ssr) on Linux. +- **Explain why this enhancement would be useful** to most ocrbridge-easyocr users. You may also want to point out the other projects that solved it better and which could serve as inspiration. + + + +### Your First Code Contribution + + +### Improving The Documentation + + +## Styleguides +### Commit Messages + +The commit message should be structured as follows: + +--- + +``` +[optional scope]: + +[optional body] + +[optional footer(s)] +``` +--- + +
+The commit contains the following structural elements, to communicate intent to the +consumers of your library: + +1. **fix:** a commit of the _type_ `fix` patches a bug in your codebase (this correlates with [`PATCH`](http://semver.org/#summary) in Semantic Versioning). +1. **feat:** a commit of the _type_ `feat` introduces a new feature to the codebase (this correlates with [`MINOR`](http://semver.org/#summary) in Semantic Versioning). +1. **BREAKING CHANGE:** a commit that has a footer `BREAKING CHANGE:`, or appends a `!` after the type/scope, introduces a breaking API change (correlating with [`MAJOR`](http://semver.org/#summary) in Semantic Versioning). +A BREAKING CHANGE can be part of commits of any _type_. +1. _types_ other than `fix:` and `feat:` are allowed, for example [@commitlint/config-conventional](https://github.com/conventional-changelog/commitlint/tree/master/%40commitlint/config-conventional) (based on the [Angular convention](https://github.com/angular/angular/blob/22b96b9/CONTRIBUTING.md#-commit-message-guidelines)) recommends `build:`, `chore:`, + `ci:`, `docs:`, `style:`, `refactor:`, `perf:`, `test:`, and others. +1. _footers_ other than `BREAKING CHANGE: ` may be provided and follow a convention similar to + [git trailer format](https://git-scm.com/docs/git-interpret-trailers). + +Additional types are not mandated by the Conventional Commits specification, and have no implicit effect in Semantic Versioning (unless they include a BREAKING CHANGE). +

+A scope may be provided to a commit's type, to provide additional contextual information and is contained within parenthesis, e.g., `feat(parser): add ability to parse arrays`. + +#### Examples + +##### Commit message with description and breaking change footer +``` +feat: allow provided config object to extend other configs + +BREAKING CHANGE: `extends` key in config file is now used for extending other config files +``` + +##### Commit message with `!` to draw attention to breaking change +``` +feat!: send an email to the customer when a product is shipped +``` + +##### Commit message with scope and `!` to draw attention to breaking change +``` +feat(api)!: send an email to the customer when a product is shipped +``` + +##### Commit message with both `!` and BREAKING CHANGE footer +``` +chore!: drop support for Node 6 + +BREAKING CHANGE: use JavaScript features not available in Node 6. +``` + +##### Commit message with no body +``` +docs: correct spelling of CHANGELOG +``` + +##### Commit message with scope +``` +feat(lang): add Polish language +``` + +##### Commit message with multi-paragraph body and multiple footers +``` +fix: prevent racing of requests + +Introduce a request id and a reference to latest request. Dismiss +incoming responses other than from latest request. + +Remove timeouts which were used to mitigate the racing issue but are +obsolete now. + +Reviewed-by: Z +Refs: #123 +``` + +## Join The Project Team + + + +## Attribution +This guide is based on the [contributing.md](https://contributing.md/generator)! diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..787d253 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 OCRBridge + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..23a539e --- /dev/null +++ b/Makefile @@ -0,0 +1,36 @@ +UV := uv +RUFF := ruff +PYRIGHT := pyright + +DEFAULT_GOAL := all + +help: + @printf "Available targets:\n" + @printf " %-12s%s\n" "install" "sync dependencies (includes dev extras)" + @printf " %-12s%s\n" "lint" "ruff lint checks" + @printf " %-12s%s\n" "format" "ruff formatter" + @printf " %-12s%s\n" "typecheck" "pyright" + @printf " %-12s%s\n" "test" "pytest" + @printf " %-12s%s\n" "check" "lint + typecheck + test" + @printf " %-12s%s\n" "all" "check + format" + +install: + $(UV) sync --extra dev + +lint: install + $(UV) run $(RUFF) check src tests + +format: install + $(UV) run $(RUFF) format src tests + +typecheck: install + $(UV) run $(PYRIGHT) + +test: install + $(UV) run pytest + +check: lint typecheck test + +all: check format + +.PHONY: install lint format typecheck test check all help diff --git a/commitlint.config.js b/commitlint.config.js new file mode 100644 index 0000000..600d7e6 --- /dev/null +++ b/commitlint.config.js @@ -0,0 +1,6 @@ +export default { + extends: ['@commitlint/config-conventional'], + rules: { + 'body-max-line-length': [2, 'always', 150] + } +}; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..58dc1ea --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1247 @@ +{ + "name": "ocrbridge-easyocr", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "@commitlint/cli": "^20.1.0", + "@commitlint/config-conventional": "^20.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@commitlint/cli": { + "version": "20.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-20.1.0.tgz", + "integrity": "sha512-pW5ujjrOovhq5RcYv5xCpb4GkZxkO2+GtOdBW2/qrr0Ll9tl3PX0aBBobGQl3mdZUbOBgwAexEQLeH6uxL0VYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/format": "^20.0.0", + "@commitlint/lint": "^20.0.0", + "@commitlint/load": "^20.1.0", + "@commitlint/read": "^20.0.0", + "@commitlint/types": "^20.0.0", + "tinyexec": "^1.0.0", + "yargs": "^17.0.0" + }, + "bin": { + "commitlint": "cli.js" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/config-conventional": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-20.0.0.tgz", + "integrity": "sha512-q7JroPIkDBtyOkVe9Bca0p7kAUYxZMxkrBArCfuD3yN4KjRAenP9PmYwnn7rsw8Q+hHq1QB2BRmBh0/Z19ZoJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^20.0.0", + "conventional-changelog-conventionalcommits": "^7.0.2" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/config-validator": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-20.0.0.tgz", + "integrity": "sha512-BeyLMaRIJDdroJuYM2EGhDMGwVBMZna9UiIqV9hxj+J551Ctc6yoGuGSmghOy/qPhBSuhA6oMtbEiTmxECafsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^20.0.0", + "ajv": "^8.11.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/ensure": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-20.0.0.tgz", + "integrity": "sha512-WBV47Fffvabe68n+13HJNFBqiMH5U1Ryls4W3ieGwPC0C7kJqp3OVQQzG2GXqOALmzrgAB+7GXmyy8N9ct8/Fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^20.0.0", + "lodash.camelcase": "^4.3.0", + "lodash.kebabcase": "^4.1.1", + "lodash.snakecase": "^4.1.1", + "lodash.startcase": "^4.4.0", + "lodash.upperfirst": "^4.3.1" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/execute-rule": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/@commitlint/execute-rule/-/execute-rule-20.0.0.tgz", + "integrity": "sha512-xyCoOShoPuPL44gVa+5EdZsBVao/pNzpQhkzq3RdtlFdKZtjWcLlUFQHSWBuhk5utKYykeJPSz2i8ABHQA+ZZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/format": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-20.0.0.tgz", + "integrity": "sha512-zrZQXUcSDmQ4eGGrd+gFESiX0Rw+WFJk7nW4VFOmxub4mAATNKBQ4vNw5FgMCVehLUKG2OT2LjOqD0Hk8HvcRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^20.0.0", + "chalk": "^5.3.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/is-ignored": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-20.0.0.tgz", + "integrity": "sha512-ayPLicsqqGAphYIQwh9LdAYOVAQ9Oe5QCgTNTj+BfxZb9b/JW222V5taPoIBzYnAP0z9EfUtljgBk+0BN4T4Cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^20.0.0", + "semver": "^7.6.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/lint": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-20.0.0.tgz", + "integrity": "sha512-kWrX8SfWk4+4nCexfLaQT3f3EcNjJwJBsSZ5rMBw6JCd6OzXufFHgel2Curos4LKIxwec9WSvs2YUD87rXlxNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/is-ignored": "^20.0.0", + "@commitlint/parse": "^20.0.0", + "@commitlint/rules": "^20.0.0", + "@commitlint/types": "^20.0.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/load": { + "version": "20.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-20.1.0.tgz", + "integrity": "sha512-qo9ER0XiAimATQR5QhvvzePfeDfApi/AFlC1G+YN+ZAY8/Ua6IRrDrxRvQAr+YXUKAxUsTDSp9KXeXLBPsNRWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/config-validator": "^20.0.0", + "@commitlint/execute-rule": "^20.0.0", + "@commitlint/resolve-extends": "^20.1.0", + "@commitlint/types": "^20.0.0", + "chalk": "^5.3.0", + "cosmiconfig": "^9.0.0", + "cosmiconfig-typescript-loader": "^6.1.0", + "lodash.isplainobject": "^4.0.6", + "lodash.merge": "^4.6.2", + "lodash.uniq": "^4.5.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/message": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-20.0.0.tgz", + "integrity": "sha512-gLX4YmKnZqSwkmSB9OckQUrI5VyXEYiv3J5JKZRxIp8jOQsWjZgHSG/OgEfMQBK9ibdclEdAyIPYggwXoFGXjQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/parse": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-20.0.0.tgz", + "integrity": "sha512-j/PHCDX2bGM5xGcWObOvpOc54cXjn9g6xScXzAeOLwTsScaL4Y+qd0pFC6HBwTtrH92NvJQc+2Lx9HFkVi48cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^20.0.0", + "conventional-changelog-angular": "^7.0.0", + "conventional-commits-parser": "^5.0.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/read": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-20.0.0.tgz", + "integrity": "sha512-Ti7Y7aEgxsM1nkwA4ZIJczkTFRX/+USMjNrL9NXwWQHqNqrBX2iMi+zfuzZXqfZ327WXBjdkRaytJ+z5vNqTOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/top-level": "^20.0.0", + "@commitlint/types": "^20.0.0", + "git-raw-commits": "^4.0.0", + "minimist": "^1.2.8", + "tinyexec": "^1.0.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/resolve-extends": { + "version": "20.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-20.1.0.tgz", + "integrity": "sha512-cxKXQrqHjZT3o+XPdqDCwOWVFQiae++uwd9dUBC7f2MdV58ons3uUvASdW7m55eat5sRiQ6xUHyMWMRm6atZWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/config-validator": "^20.0.0", + "@commitlint/types": "^20.0.0", + "global-directory": "^4.0.1", + "import-meta-resolve": "^4.0.0", + "lodash.mergewith": "^4.6.2", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/rules": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-20.0.0.tgz", + "integrity": "sha512-gvg2k10I/RfvHn5I5sxvVZKM1fl72Sqrv2YY/BnM7lMHcYqO0E2jnRWoYguvBfEcZ39t+rbATlciggVe77E4zA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/ensure": "^20.0.0", + "@commitlint/message": "^20.0.0", + "@commitlint/to-lines": "^20.0.0", + "@commitlint/types": "^20.0.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/to-lines": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/@commitlint/to-lines/-/to-lines-20.0.0.tgz", + "integrity": "sha512-2l9gmwiCRqZNWgV+pX1X7z4yP0b3ex/86UmUFgoRt672Ez6cAM2lOQeHFRUTuE6sPpi8XBCGnd8Kh3bMoyHwJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/top-level": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/@commitlint/top-level/-/top-level-20.0.0.tgz", + "integrity": "sha512-drXaPSP2EcopukrUXvUXmsQMu3Ey/FuJDc/5oiW4heoCfoE5BdLQyuc7veGeE3aoQaTVqZnh4D5WTWe2vefYKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^7.0.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/types": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-20.0.0.tgz", + "integrity": "sha512-bVUNBqG6aznYcYjTjnc3+Cat/iBgbgpflxbIBTnsHTX0YVpnmINPEkSRWymT2Q8aSH3Y7aKnEbunilkYe8TybA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/conventional-commits-parser": "^5.0.0", + "chalk": "^5.3.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@types/conventional-commits-parser": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@types/conventional-commits-parser/-/conventional-commits-parser-5.0.2.tgz", + "integrity": "sha512-BgT2szDXnVypgpNxOK8aL5SGjUdaQbC++WZNjF1Qge3Og2+zhHj+RWhmehLhYyvQwqAmvezruVfOf8+3m74W+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "24.10.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", + "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-ify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz", + "integrity": "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==", + "dev": true, + "license": "MIT" + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/compare-func": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz", + "integrity": "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-ify": "^1.0.0", + "dot-prop": "^5.1.0" + } + }, + "node_modules/conventional-changelog-angular": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-7.0.0.tgz", + "integrity": "sha512-ROjNchA9LgfNMTTFSIWPzebCwOGFdgkEq45EnvvrmSLvCtAw0HSmrCs7/ty+wAeYUZyNay0YMUNYFTRL72PkBQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "compare-func": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/conventional-changelog-conventionalcommits": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-7.0.2.tgz", + "integrity": "sha512-NKXYmMR/Hr1DevQegFB4MwfM5Vv0m4UIxKZTTYuD98lpTknaZlSRrDOG4X7wIXpGkfsYxZTghUN+Qq+T0YQI7w==", + "dev": true, + "license": "ISC", + "dependencies": { + "compare-func": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/conventional-commits-parser": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-5.0.0.tgz", + "integrity": "sha512-ZPMl0ZJbw74iS9LuX9YIAiW8pfM5p3yh2o/NbXHbkFuZzY5jvdi5jFycEOkmBW5H5I7nA+D6f3UcsCLP2vvSEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-text-path": "^2.0.0", + "JSONStream": "^1.3.5", + "meow": "^12.0.1", + "split2": "^4.0.0" + }, + "bin": { + "conventional-commits-parser": "cli.mjs" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/cosmiconfig": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", + "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cosmiconfig-typescript-loader": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-6.2.0.tgz", + "integrity": "sha512-GEN39v7TgdxgIoNcdkRE3uiAzQt3UXLyHbRHD6YoL048XAeOomyxaP+Hh/+2C6C2wYjxJ2onhJcsQp+L4YEkVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "jiti": "^2.6.1" + }, + "engines": { + "node": ">=v18" + }, + "peerDependencies": { + "@types/node": "*", + "cosmiconfig": ">=9", + "typescript": ">=5" + } + }, + "node_modules/dargs": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/dargs/-/dargs-8.1.0.tgz", + "integrity": "sha512-wAV9QHOsNbwnWdNW2FYvE1P56wtgSbM+3SZcdGiWQILwVjACCXDCI3Ai8QlCjMDB8YK5zySiXZYBiwGmNY3lnw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dot-prop": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", + "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-obj": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/find-up": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-7.0.0.tgz", + "integrity": "sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^7.2.0", + "path-exists": "^5.0.0", + "unicorn-magic": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/git-raw-commits": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-4.0.0.tgz", + "integrity": "sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dargs": "^8.0.0", + "meow": "^12.0.1", + "split2": "^4.0.0" + }, + "bin": { + "git-raw-commits": "cli.mjs" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/global-directory": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", + "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "4.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ini": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", + "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-text-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-text-path/-/is-text-path-2.0.0.tgz", + "integrity": "sha512-+oDTluR6WEjdXEJMnC2z6A4FRwFoYuvShVVEGsS7ewc0UTi2QtAKMDJuL4BDEVt+5T7MjFo12RP8ghOM75oKJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "text-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", + "dev": true, + "engines": [ + "node >= 0.2.0" + ], + "license": "MIT" + }, + "node_modules/JSONStream": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.5.tgz", + "integrity": "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==", + "dev": true, + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "jsonparse": "^1.2.0", + "through": ">=2.2.7 <3" + }, + "bin": { + "JSONStream": "bin.js" + }, + "engines": { + "node": "*" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz", + "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^6.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.kebabcase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz", + "integrity": "sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.mergewith": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", + "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.startcase": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.startcase/-/lodash.startcase-4.4.0.tgz", + "integrity": "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.upperfirst": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz", + "integrity": "sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/meow": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/meow/-/meow-12.1.1.tgz", + "integrity": "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", + "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", + "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", + "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/text-extensions": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/text-extensions/-/text-extensions-2.4.0.tgz", + "integrity": "sha512-te/NtwBwfiNRLf9Ijqx3T0nlqZiQ2XrrtBvu+cLL8ZRrGkO0NHTug8MYFKyoSrv/sHTaSKfilUkizV6XhxMJ3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/unicorn-magic": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..28c398b --- /dev/null +++ b/package.json @@ -0,0 +1,7 @@ +{ + "type": "module", + "devDependencies": { + "@commitlint/cli": "^20.1.0", + "@commitlint/config-conventional": "^20.0.0" + } +} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 05b58bf..6b45c9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,11 +17,14 @@ dependencies = [ [project.optional-dependencies] dev = [ - "pytest>=7.4.0", - "pytest-cov>=4.1.0", + "pytest~=8.0", + "pytest-cov>=7.0.0", + "pytest-mock>=3.12.0", "ruff>=0.1.0", "pyright>=1.1.0", + "python-semantic-release>=10.5.2", ] +test = ["pytest~=8.0"] # Entry point for engine discovery [project.entry-points."ocrbridge.engines"] @@ -44,4 +47,37 @@ ignore = [] [tool.pyright] typeCheckingMode = "strict" -pythonVersion = "3.11" +pythonVersion = "3.10" +include = ["src"] +exclude = ["tests"] +extraPaths = ["typings"] +reportMissingTypeStubs = "none" + +[tool.pytest.ini_options] +pythonpath = ["src"] +testpaths = ["tests"] +markers = [ + "integration: marks tests as integration tests requiring Tesseract binary", + "slow: marks tests as slow running", +] + +[tool.coverage.run] +source = ["src"] +omit = ["*/tests/*", "*/__pycache__/*", "*/.venv/*"] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise AssertionError", + "raise NotImplementedError", + "if __name__ == .__main__.:", +] + +[tool.semantic_release] +build_command = """ + uv lock --upgrade-package "$PACKAGE_NAME" + git add uv.lock + uv build +""" +version_toml = ["pyproject.toml:project.version"] diff --git a/samples/contract_de_photo.pdf b/samples/contract_de_photo.pdf new file mode 100644 index 0000000..863d12a Binary files /dev/null and b/samples/contract_de_photo.pdf differ diff --git a/samples/contract_de_scan.pdf b/samples/contract_de_scan.pdf new file mode 100644 index 0000000..15cbb96 Binary files /dev/null and b/samples/contract_de_scan.pdf differ diff --git a/samples/contract_en_photo.pdf b/samples/contract_en_photo.pdf new file mode 100644 index 0000000..c4d30a7 Binary files /dev/null and b/samples/contract_en_photo.pdf differ diff --git a/samples/contract_en_scan.pdf b/samples/contract_en_scan.pdf new file mode 100644 index 0000000..fd0d015 Binary files /dev/null and b/samples/contract_en_scan.pdf differ diff --git a/samples/numbers_gs150.jpg b/samples/numbers_gs150.jpg new file mode 100644 index 0000000..39162e7 Binary files /dev/null and b/samples/numbers_gs150.jpg differ diff --git a/samples/stock_gs200.jpg b/samples/stock_gs200.jpg new file mode 100644 index 0000000..f6f49ca Binary files /dev/null and b/samples/stock_gs200.jpg differ diff --git a/src/ocrbridge/engines/easyocr/engine.py b/src/ocrbridge/engines/easyocr/engine.py index 42544a0..442696f 100644 --- a/src/ocrbridge/engines/easyocr/engine.py +++ b/src/ocrbridge/engines/easyocr/engine.py @@ -2,15 +2,26 @@ import tempfile from pathlib import Path +from typing import Any, Mapping, Sequence, cast import numpy as np +import pdf2image as _pdf2image +from PIL import Image + from ocrbridge.core import OCREngine, OCRProcessingError, UnsupportedFormatError +from ocrbridge.core.models import OCREngineParams from ocrbridge.core.utils import easyocr_to_hocr -from pdf2image import convert_from_path -from PIL import Image from .models import EasyOCRParams +pdf2image = cast(Any, _pdf2image) + +EasyOCRReader = Any +Point = tuple[float, float] +BoundingBox = Sequence[Point] +EasyOCRResult = tuple[BoundingBox, str, float] +EasyOCRResults = list[EasyOCRResult] + def detect_gpu_availability() -> bool: """Detect if CUDA GPU is available for EasyOCR. @@ -53,7 +64,7 @@ class EasyOCREngine(OCREngine): def __init__(self): """Initialize EasyOCR engine.""" - self.reader = None + self.reader: EasyOCRReader | None = None self._current_languages: list[str] | None = None @property @@ -66,7 +77,23 @@ def supported_formats(self) -> set[str]: """Return supported file extensions.""" return {".jpg", ".jpeg", ".png", ".pdf", ".tiff", ".tif"} - def _create_reader(self, languages: list[str]): + def _coerce_params(self, params: OCREngineParams | None) -> EasyOCRParams: + """Ensure params are EasyOCR-compatible.""" + if params is None: + return EasyOCRParams() + + if isinstance(params, EasyOCRParams): + return params + + if hasattr(params, "model_dump"): + data = getattr(params, "model_dump")() + if isinstance(data, Mapping): + typed_data = cast("Mapping[str, Any]", data) + return EasyOCRParams(**dict(typed_data)) + + raise OCRProcessingError("EasyOCR engine requires EasyOCRParams") + + def _create_reader(self, languages: list[str]) -> EasyOCRReader: """Create EasyOCR Reader instance with specified configuration. Args: @@ -86,14 +113,14 @@ def _create_reader(self, languages: list[str]): use_gpu, _ = get_easyocr_device() # Create reader with language list and GPU setting - reader = easyocr.Reader( + reader: EasyOCRReader = easyocr.Reader( lang_list=languages, gpu=use_gpu, ) return reader - def process(self, file_path: Path, params: EasyOCRParams | None = None) -> str: + def process(self, file_path: Path, params: OCREngineParams | None = None) -> str: """Process document with EasyOCR and return HOCR XML. Args: @@ -107,9 +134,7 @@ def process(self, file_path: Path, params: EasyOCRParams | None = None) -> str: OCRProcessingError: If EasyOCR processing fails UnsupportedFormatError: If file format not supported """ - # Use defaults if no params provided - if params is None: - params = EasyOCRParams() + easyocr_params = self._coerce_params(params) # Validate file exists if not file_path.exists(): @@ -124,16 +149,16 @@ def process(self, file_path: Path, params: EasyOCRParams | None = None) -> str: ) # Create or recreate reader if languages changed - if self.reader is None or self._current_languages != params.languages: - self.reader = self._create_reader(params.languages) - self._current_languages = params.languages + if self.reader is None or self._current_languages != easyocr_params.languages: + self.reader = self._create_reader(easyocr_params.languages) + self._current_languages = easyocr_params.languages try: # Handle PDF separately if suffix == ".pdf": - return self._process_pdf(file_path, params) + return self._process_pdf(file_path, easyocr_params) else: - return self._process_image(file_path, params) + return self._process_image(file_path, easyocr_params) except Exception as e: raise OCRProcessingError(f"EasyOCR processing failed: {e}") from e @@ -148,11 +173,17 @@ def _process_image(self, image_path: Path, params: EasyOCRParams) -> str: Returns: HOCR XML string """ + if self.reader is None: + raise OCRProcessingError("EasyOCR reader is not initialized") + # Process image with EasyOCR - results = self.reader.readtext( # type: ignore - str(image_path), - detail=1, # Include bounding boxes and confidence - paragraph=False, # Return individual text boxes + results = cast( + EasyOCRResults, + self.reader.readtext( + str(image_path), + detail=1, # Include bounding boxes and confidence + paragraph=False, # Return individual text boxes + ), ) # Convert results to HOCR format @@ -172,21 +203,30 @@ def _process_pdf(self, pdf_path: Path, params: EasyOCRParams) -> str: """ # Convert PDF to images try: - images = convert_from_path(str(pdf_path), dpi=300, thread_count=2) + images = cast( + list[Image.Image], + pdf2image.convert_from_path(str(pdf_path), dpi=300, thread_count=2), + ) except Exception as e: raise OCRProcessingError(f"PDF conversion failed: {str(e)}") # Process each page - page_hocr_list = [] + if self.reader is None: + raise OCRProcessingError("EasyOCR reader is not initialized") + + page_hocr_list: list[str] = [] for image in images: # Convert PIL Image to numpy array for EasyOCR img_array = np.array(image) # Run EasyOCR on page image - results = self.reader.readtext( # type: ignore - img_array, - detail=1, - paragraph=False, + results = cast( + EasyOCRResults, + self.reader.readtext( + img_array, + detail=1, + paragraph=False, + ), ) # Convert results to HOCR for this page @@ -204,7 +244,7 @@ def _process_pdf(self, pdf_path: Path, params: EasyOCRParams) -> str: # Merge all pages into single HOCR document if len(page_hocr_list) == 1: - hocr_content = page_hocr_list[0] + hocr_content: str = page_hocr_list[0] else: hocr_content = self._merge_hocr_pages(page_hocr_list) @@ -241,7 +281,7 @@ def _merge_hocr_pages(self, page_hocr_list: list[str]) -> str: return hocr_template - def _to_hocr(self, easyocr_results: list, image_path: Path) -> str: + def _to_hocr(self, easyocr_results: EasyOCRResults, image_path: Path) -> str: """Convert EasyOCR results to HOCR XML format. Args: diff --git a/src/ocrbridge/engines/easyocr/models.py b/src/ocrbridge/engines/easyocr/models.py index 0dba165..fd42117 100644 --- a/src/ocrbridge/engines/easyocr/models.py +++ b/src/ocrbridge/engines/easyocr/models.py @@ -1,22 +1,92 @@ """EasyOCR engine parameter models.""" -from ocrbridge.core.models import OCREngineParams from pydantic import Field, field_validator +from ocrbridge.core.models import OCREngineParams + # EasyOCR supported languages (80+ languages) EASYOCR_SUPPORTED_LANGUAGES = { # Latin scripts - "en", "fr", "de", "es", "pt", "it", "nl", "pl", "ru", "tr", - "sv", "cs", "da", "no", "fi", "ro", "hu", "sk", "hr", "sr", - "bg", "uk", "be", "lt", "lv", "et", "sl", "sq", "is", "ga", - "cy", "af", "ms", "id", "tl", "vi", "sw", + "en", + "fr", + "de", + "es", + "pt", + "it", + "nl", + "pl", + "ru", + "tr", + "sv", + "cs", + "da", + "no", + "fi", + "ro", + "hu", + "sk", + "hr", + "sr", + "bg", + "uk", + "be", + "lt", + "lv", + "et", + "sl", + "sq", + "is", + "ga", + "cy", + "af", + "ms", + "id", + "tl", + "vi", + "sw", # Asian scripts - "ch_sim", "ch_tra", "ja", "ko", "th", "hi", "bn", "ta", "te", - "kn", "ml", "mr", "ne", "si", "ur", "fa", "ar", "he", "my", - "km", "lo", "ka", "hy", "mn", + "ch_sim", + "ch_tra", + "ja", + "ko", + "th", + "hi", + "bn", + "ta", + "te", + "kn", + "ml", + "mr", + "ne", + "si", + "ur", + "fa", + "ar", + "he", + "my", + "km", + "lo", + "ka", + "hy", + "mn", # Additional - "az", "kk", "uz", "ky", "tg", "pa", "gu", "or", "as", "oc", - "eu", "ca", "gl", "mt", "la", "eo", "mi", + "az", + "kk", + "uz", + "ky", + "tg", + "pa", + "gu", + "or", + "as", + "oc", + "eu", + "ca", + "gl", + "mt", + "la", + "eo", + "mi", } @@ -60,8 +130,11 @@ def validate_languages(cls, v: list[str]) -> list[str]: if invalid_langs: raise ValueError( - f"Unsupported EasyOCR language codes: {invalid_langs}. " - f"Use EasyOCR format (e.g., 'en', 'ch_sim', 'ja'), not Tesseract format ('eng', 'chi_sim')" + ( + f"Unsupported EasyOCR language codes: {invalid_langs}. " + "Use EasyOCR format (e.g., 'en', 'ch_sim', 'ja'), " + "not Tesseract format ('eng', 'chi_sim')" + ) ) return v diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..3c1664a --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests package placeholder to satisfy linting.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..f52cfad --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,125 @@ +"""Shared pytest fixtures for ocrbridge-easyocr tests.""" + +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from ocrbridge.engines.easyocr import EasyOCRParams + + +@pytest.fixture +def samples_dir() -> Path: + """Return the path to the samples directory.""" + return Path(__file__).parent.parent / "samples" + + +@pytest.fixture +def sample_jpg_stock(samples_dir: Path) -> Path: + """Return path to stock grayscale JPEG sample.""" + return samples_dir / "stock_gs200.jpg" + + +@pytest.fixture +def sample_jpg_numbers(samples_dir: Path) -> Path: + """Return path to numbers grayscale JPEG sample.""" + return samples_dir / "numbers_gs150.jpg" + + +@pytest.fixture +def sample_pdf_en_scan(samples_dir: Path) -> Path: + """Return path to English scanned contract PDF.""" + return samples_dir / "contract_en_scan.pdf" + + +@pytest.fixture +def sample_pdf_en_photo(samples_dir: Path) -> Path: + """Return path to English photographed contract PDF.""" + return samples_dir / "contract_en_photo.pdf" + + +@pytest.fixture +def sample_pdf_de_scan(samples_dir: Path) -> Path: + """Return path to German scanned contract PDF.""" + return samples_dir / "contract_de_scan.pdf" + + +@pytest.fixture +def sample_pdf_de_photo(samples_dir: Path) -> Path: + """Return path to German photographed contract PDF.""" + return samples_dir / "contract_de_photo.pdf" + + +@pytest.fixture +def default_params() -> EasyOCRParams: + """Return default EasyOCRParams instance.""" + return EasyOCRParams() + + +@pytest.fixture +def custom_params() -> EasyOCRParams: + """Return EasyOCRParams with custom settings.""" + return EasyOCRParams( + languages=["en", "de"], + text_threshold=0.8, + link_threshold=0.6, + ) + + +@pytest.fixture +def mock_easyocr_reader(mocker: Any) -> MagicMock: + """Return a mocked EasyOCR Reader instance.""" + mock_reader = MagicMock() + # Mock readtext method to return sample results + mock_reader.readtext.return_value = [ + ( + [[10, 10], [100, 10], [100, 50], [10, 50]], # bounding box + "Sample Text", # detected text + 0.95, # confidence + ), + ( + [[10, 60], [150, 60], [150, 100], [10, 100]], + "Another Line", + 0.88, + ), + ] + return mock_reader + + +@pytest.fixture +def mock_easyocr_class(mocker: Any, mock_easyocr_reader: MagicMock) -> MagicMock: + """Mock the easyocr.Reader class to return a mock reader.""" + mock_class = mocker.patch("easyocr.Reader", return_value=mock_easyocr_reader) + return mock_class + + +@pytest.fixture +def mock_torch_cuda_available(mocker: Any) -> MagicMock: + """Mock torch.cuda.is_available() to return True.""" + return mocker.patch("torch.cuda.is_available", return_value=True) + + +@pytest.fixture +def mock_torch_cuda_unavailable(mocker: Any) -> MagicMock: + """Mock torch.cuda.is_available() to return False.""" + return mocker.patch("torch.cuda.is_available", return_value=False) + + +@pytest.fixture +def mock_pdf2image(mocker: Any) -> MagicMock: + """Mock pdf2image.convert_from_path to return PIL Image objects.""" + from PIL import Image + + # Create a simple test image + mock_image = Image.new("RGB", (800, 600), color="white") + mock_convert = mocker.patch("pdf2image.convert_from_path", return_value=[mock_image]) + return mock_convert + + +@pytest.fixture +def temp_output_dir(tmp_path: Path) -> Path: + """Create and return a temporary directory for test outputs.""" + output_dir = tmp_path / "output" + output_dir.mkdir() + return output_dir diff --git a/tests/test_engine_integration.py b/tests/test_engine_integration.py new file mode 100644 index 0000000..1587574 --- /dev/null +++ b/tests/test_engine_integration.py @@ -0,0 +1,209 @@ +"""Integration tests for EasyOCR engine with real OCR processing. + +These tests require EasyOCR to be installed and will actually run OCR on sample files. +They are marked as integration tests and may be slower than unit tests. +""" + +from pathlib import Path + +import pytest + +from ocrbridge.engines.easyocr import EasyOCREngine, EasyOCRParams + + +@pytest.mark.integration +class TestEasyOCREngineIntegration: + """Integration tests using real EasyOCR processing.""" + + def test_process_jpeg_image_english(self, sample_jpg_stock: Path) -> None: + """Test processing a JPEG image with English text.""" + engine = EasyOCREngine() + params = EasyOCRParams(languages=["en"]) + + result = engine.process(sample_jpg_stock, params) + + # Verify HOCR structure + assert result.startswith('" in result + assert "" in result + assert "" in result + assert "ocr-system" in result or "easyocr" in result.lower() + + def test_process_jpeg_image_numbers(self, sample_jpg_numbers: Path) -> None: + """Test processing a JPEG image with numbers.""" + engine = EasyOCREngine() + params = EasyOCRParams(languages=["en"]) + + result = engine.process(sample_jpg_numbers, params) + + # Verify valid HOCR output + assert result.startswith(' None: + """Test processing an English scanned PDF.""" + engine = EasyOCREngine() + params = EasyOCRParams(languages=["en"]) + + result = engine.process(sample_pdf_en_scan, params) + + # Verify HOCR structure + assert result.startswith('" in result + assert "ocr-system" in result or "easyocr" in result.lower() + + def test_process_pdf_english_photo(self, sample_pdf_en_photo: Path) -> None: + """Test processing an English photographed PDF.""" + engine = EasyOCREngine() + params = EasyOCRParams(languages=["en"]) + + result = engine.process(sample_pdf_en_photo, params) + + # Verify HOCR structure + assert result.startswith(' None: + """Test processing a German scanned PDF.""" + engine = EasyOCREngine() + params = EasyOCRParams(languages=["de"]) + + result = engine.process(sample_pdf_de_scan, params) + + # Verify HOCR structure + assert result.startswith(' None: + """Test processing a German photographed PDF.""" + engine = EasyOCREngine() + params = EasyOCRParams(languages=["de"]) + + result = engine.process(sample_pdf_de_photo, params) + + # Verify HOCR structure + assert result.startswith(' None: + """Test processing with multiple language support.""" + engine = EasyOCREngine() + params = EasyOCRParams(languages=["en", "de"]) + + result = engine.process(sample_jpg_stock, params) + + # Verify HOCR structure + assert result.startswith(' None: + """Test processing with custom confidence thresholds.""" + engine = EasyOCREngine() + params = EasyOCRParams( + languages=["en"], + text_threshold=0.8, + link_threshold=0.6, + ) + + result = engine.process(sample_jpg_stock, params) + + # Verify HOCR structure + assert result.startswith(' None: + """Test that reader is reused for same language across multiple files.""" + engine = EasyOCREngine() + params = EasyOCRParams(languages=["en"]) + + # Process first image + result1 = engine.process(sample_jpg_stock, params) + reader1 = engine.reader + + # Process second image with same language + result2 = engine.process(sample_jpg_numbers, params) + reader2 = engine.reader + + # Reader should be the same instance + assert reader1 is reader2 + assert result1.startswith(' None: + """Test that reader is recreated when language changes.""" + engine = EasyOCREngine() + + # Process with English + params_en = EasyOCRParams(languages=["en"]) + result1 = engine.process(sample_pdf_en_scan, params_en) + reader1 = engine.reader + + # Process with German (should recreate reader) + params_de = EasyOCRParams(languages=["de"]) + result2 = engine.process(sample_pdf_de_scan, params_de) + reader2 = engine.reader + + # Reader should be different instances + assert reader1 is not reader2 + assert result1.startswith(' None: + """Test that HOCR output contains text box elements.""" + engine = EasyOCREngine() + params = EasyOCRParams(languages=["en"]) + + result = engine.process(sample_jpg_stock, params) + + # HOCR should contain structural elements + # The exact structure depends on easyocr_to_hocr implementation + # but should have basic HOCR structure + assert "" in result + assert result.count("<") > 10 # Should have multiple elements + + +@pytest.mark.integration +@pytest.mark.slow +class TestEasyOCREngineSlowIntegration: + """Slower integration tests for heavy operations.""" + + def test_process_all_sample_files_sequentially( + self, + sample_jpg_stock: Path, + sample_jpg_numbers: Path, + sample_pdf_en_scan: Path, + sample_pdf_de_scan: Path, + ) -> None: + """Test processing multiple files in sequence.""" + engine = EasyOCREngine() + + # Process different file types + files_and_langs = [ + (sample_jpg_stock, ["en"]), + (sample_jpg_numbers, ["en"]), + (sample_pdf_en_scan, ["en"]), + (sample_pdf_de_scan, ["de"]), + ] + + results = [] + for file_path, languages in files_and_langs: + params = EasyOCRParams(languages=languages) + result = engine.process(file_path, params) + results.append(result) + + # All results should be valid HOCR + assert len(results) == 4 + for result in results: + assert result.startswith(' None: + """Test engine name property returns 'easyocr'.""" + engine = EasyOCREngine() + assert engine.name == "easyocr" + + def test_supported_formats_property(self) -> None: + """Test supported_formats returns correct set of extensions.""" + engine = EasyOCREngine() + expected_formats = {".jpg", ".jpeg", ".png", ".pdf", ".tiff", ".tif"} + assert engine.supported_formats == expected_formats + + def test_initialization(self) -> None: + """Test engine initializes with None reader and languages.""" + engine = EasyOCREngine() + assert engine.reader is None + assert engine._current_languages is None + + +class TestCoerceParams: + """Test suite for _coerce_params method.""" + + def test_coerce_none_returns_default(self) -> None: + """Test _coerce_params with None returns default EasyOCRParams.""" + engine = EasyOCREngine() + params = engine._coerce_params(None) + + assert isinstance(params, EasyOCRParams) + assert params.languages == ["en"] + assert params.text_threshold == 0.7 + + def test_coerce_easyocr_params_returns_same(self) -> None: + """Test _coerce_params with EasyOCRParams returns same instance.""" + engine = EasyOCREngine() + original_params = EasyOCRParams(languages=["en", "de"]) + + params = engine._coerce_params(original_params) + + assert params is original_params + + def test_coerce_compatible_params_converts(self) -> None: + """Test _coerce_params converts compatible params with model_dump.""" + engine = EasyOCREngine() + + # Create a mock params object with model_dump method + mock_params = Mock() + mock_params.model_dump.return_value = { + "languages": ["fr", "de"], + "text_threshold": 0.8, + "link_threshold": 0.6, + } + + params = engine._coerce_params(mock_params) + + assert isinstance(params, EasyOCRParams) + assert params.languages == ["fr", "de"] + assert params.text_threshold == 0.8 + assert params.link_threshold == 0.6 + + def test_coerce_incompatible_params_raises_error(self) -> None: + """Test _coerce_params raises error for incompatible params.""" + engine = EasyOCREngine() + + # Create params without model_dump method + mock_params = Mock(spec=[]) # No methods + + with pytest.raises(OCRProcessingError) as exc_info: + engine._coerce_params(mock_params) + + assert "EasyOCR engine requires EasyOCRParams" in str(exc_info.value) + + +class TestCreateReader: + """Test suite for _create_reader method.""" + + def test_create_reader_with_languages(self, mocker: Any) -> None: + """Test _create_reader creates reader with specified languages.""" + engine = EasyOCREngine() + + # Mock easyocr.Reader + mock_reader_class = mocker.patch("easyocr.Reader") + mock_reader_instance = MagicMock() + mock_reader_class.return_value = mock_reader_instance + + # Mock get_easyocr_device + mocker.patch( + "ocrbridge.engines.easyocr.engine.get_easyocr_device", + return_value=(False, "cpu"), + ) + + reader = engine._create_reader(["en", "de"]) + + assert reader is mock_reader_instance + mock_reader_class.assert_called_once_with(lang_list=["en", "de"], gpu=False) + + def test_create_reader_with_gpu(self, mocker: Any) -> None: + """Test _create_reader respects GPU availability.""" + engine = EasyOCREngine() + + mock_reader_class = mocker.patch("easyocr.Reader") + mock_reader_instance = MagicMock() + mock_reader_class.return_value = mock_reader_instance + + # Mock GPU available + mocker.patch( + "ocrbridge.engines.easyocr.engine.get_easyocr_device", + return_value=(True, "cuda:0"), + ) + + engine._create_reader(["en"]) + + mock_reader_class.assert_called_once_with(lang_list=["en"], gpu=True) + + def test_create_reader_easyocr_not_installed(self, mocker: Any) -> None: + """Test _create_reader raises error when easyocr not installed.""" + engine = EasyOCREngine() + + # Mock ImportError when importing easyocr + mocker.patch("builtins.__import__", side_effect=ImportError("No module named 'easyocr'")) + + with pytest.raises(OCRProcessingError) as exc_info: + engine._create_reader(["en"]) + + assert "EasyOCR not installed" in str(exc_info.value) + + +class TestProcessImage: + """Test suite for _process_image method.""" + + def test_process_image_success(self, mocker: Any, tmp_path: Path) -> None: + """Test _process_image processes image successfully.""" + engine = EasyOCREngine() + + # Create a mock reader + mock_reader = MagicMock() + mock_results = [ + ([[10, 10], [100, 10], [100, 50], [10, 50]], "Test", 0.95), + ] + mock_reader.readtext.return_value = mock_results + engine.reader = mock_reader + + # Create a temporary test image + test_image = tmp_path / "test.jpg" + img = Image.new("RGB", (200, 100), color="white") + img.save(test_image) + + # Mock _to_hocr method + mock_to_hocr = mocker.patch.object( + engine, + "_to_hocr", + return_value="mock output", + ) + + params = EasyOCRParams() + result = engine._process_image(test_image, params) + + assert result == "mock output" + mock_reader.readtext.assert_called_once_with( + str(test_image), + detail=1, + paragraph=False, + ) + mock_to_hocr.assert_called_once_with(mock_results, test_image) + + def test_process_image_no_reader_raises_error(self) -> None: + """Test _process_image raises error when reader is None.""" + engine = EasyOCREngine() + engine.reader = None + + params = EasyOCRParams() + + with pytest.raises(OCRProcessingError) as exc_info: + engine._process_image(Path("/fake/path.jpg"), params) + + assert "reader is not initialized" in str(exc_info.value) + + +class TestProcessPDF: + """Test suite for _process_pdf method.""" + + def test_process_pdf_single_page(self, mocker: Any, tmp_path: Path) -> None: + """Test _process_pdf processes single-page PDF.""" + engine = EasyOCREngine() + + # Mock reader + mock_reader = MagicMock() + mock_reader.readtext.return_value = [ + ([[10, 10], [100, 10], [100, 50], [10, 50]], "Page 1", 0.95), + ] + engine.reader = mock_reader + + # Mock pdf2image + mock_image = Image.new("RGB", (800, 600), color="white") + mock_convert = mocker.patch( + "ocrbridge.engines.easyocr.engine.pdf2image.convert_from_path", + return_value=[mock_image], + ) + + # Mock _to_hocr + mocker.patch.object( + engine, + "_to_hocr", + return_value="page 1", + ) + + # Mock numpy.array + mocker.patch("ocrbridge.engines.easyocr.engine.np.array", return_value="mock_array") + + pdf_path = tmp_path / "test.pdf" + pdf_path.touch() # Create empty file + + params = EasyOCRParams() + result = engine._process_pdf(pdf_path, params) + + assert result == "page 1" + mock_convert.assert_called_once_with(str(pdf_path), dpi=300, thread_count=2) + + def test_process_pdf_multiple_pages(self, mocker: Any, tmp_path: Path) -> None: + """Test _process_pdf processes multi-page PDF.""" + engine = EasyOCREngine() + + mock_reader = MagicMock() + mock_reader.readtext.return_value = [ + ([[10, 10], [100, 10], [100, 50], [10, 50]], "Text", 0.95), + ] + engine.reader = mock_reader + + # Mock pdf2image with 3 pages + mock_images = [ + Image.new("RGB", (800, 600), color="white"), + Image.new("RGB", (800, 600), color="white"), + Image.new("RGB", (800, 600), color="white"), + ] + mocker.patch( + "ocrbridge.engines.easyocr.engine.pdf2image.convert_from_path", + return_value=mock_images, + ) + + # Mock _to_hocr to return different content per page + page_hocrs = ["page 1", "page 2", "page 3"] + mocker.patch.object(engine, "_to_hocr", side_effect=page_hocrs) + + # Mock _merge_hocr_pages + mock_merge = mocker.patch.object( + engine, + "_merge_hocr_pages", + return_value="merged", + ) + + mocker.patch("ocrbridge.engines.easyocr.engine.np.array", return_value="mock_array") + + pdf_path = tmp_path / "test.pdf" + pdf_path.touch() + + params = EasyOCRParams() + result = engine._process_pdf(pdf_path, params) + + assert result == "merged" + mock_merge.assert_called_once_with(page_hocrs) + + def test_process_pdf_conversion_failure(self, mocker: Any, tmp_path: Path) -> None: + """Test _process_pdf raises error on PDF conversion failure.""" + engine = EasyOCREngine() + engine.reader = MagicMock() + + # Mock pdf2image to raise exception + mocker.patch( + "ocrbridge.engines.easyocr.engine.pdf2image.convert_from_path", + side_effect=Exception("PDF error"), + ) + + pdf_path = tmp_path / "test.pdf" + pdf_path.touch() + + params = EasyOCRParams() + + with pytest.raises(OCRProcessingError) as exc_info: + engine._process_pdf(pdf_path, params) + + assert "PDF conversion failed" in str(exc_info.value) + + def test_process_pdf_no_reader_raises_error(self, mocker: Any, tmp_path: Path) -> None: + """Test _process_pdf raises error when reader is None.""" + engine = EasyOCREngine() + engine.reader = None + + mock_image = Image.new("RGB", (800, 600), color="white") + mocker.patch( + "ocrbridge.engines.easyocr.engine.pdf2image.convert_from_path", + return_value=[mock_image], + ) + + pdf_path = tmp_path / "test.pdf" + pdf_path.touch() + + params = EasyOCRParams() + + with pytest.raises(OCRProcessingError) as exc_info: + engine._process_pdf(pdf_path, params) + + assert "reader is not initialized" in str(exc_info.value) + + +class TestMergeHOCRPages: + """Test suite for _merge_hocr_pages method.""" + + def test_merge_single_page(self) -> None: + """Test _merge_hocr_pages with single page returns same content.""" + engine = EasyOCREngine() + page_hocr = "
Page 1
" + + # When there's only one page, it's returned as-is in process methods + # This tests the merge logic directly + result = engine._merge_hocr_pages([page_hocr]) + + assert "
Page 1
" in result + assert "easyocr" in result + + def test_merge_multiple_pages(self) -> None: + """Test _merge_hocr_pages combines multiple pages correctly.""" + engine = EasyOCREngine() + + page1 = "
Page 1
" + page2 = "
Page 2
" + page3 = "
Page 3
" + + result = engine._merge_hocr_pages([page1, page2, page3]) + + assert "
Page 1
" in result + assert "
Page 2
" in result + assert "
Page 3
" in result + assert result.startswith(' None: + """Test _merge_hocr_pages creates valid HOCR structure.""" + engine = EasyOCREngine() + + page1 = "
Page 1
" + result = engine._merge_hocr_pages([page1]) + + assert '" in result + assert "" in result + assert "" in result + assert "" in result + + +class TestToHOCR: + """Test suite for _to_hocr method.""" + + def test_to_hocr_with_valid_image(self, mocker: Any, tmp_path: Path) -> None: + """Test _to_hocr converts results using image dimensions.""" + engine = EasyOCREngine() + + # Create test image + test_image = tmp_path / "test.jpg" + img = Image.new("RGB", (800, 600), color="white") + img.save(test_image) + + # Mock easyocr_to_hocr from core + mock_converter = mocker.patch( + "ocrbridge.engines.easyocr.engine.easyocr_to_hocr", + return_value="converted", + ) + + easyocr_results = [ + ([[10, 10], [100, 10], [100, 50], [10, 50]], "Test", 0.95), + ] + + result = engine._to_hocr(easyocr_results, test_image) + + assert result == "converted" + mock_converter.assert_called_once_with(easyocr_results, 800, 600) + + def test_to_hocr_with_invalid_image_uses_defaults(self, mocker: Any) -> None: + """Test _to_hocr uses default dimensions when image can't be opened.""" + engine = EasyOCREngine() + + # Mock easyocr_to_hocr from core + mock_converter = mocker.patch( + "ocrbridge.engines.easyocr.engine.easyocr_to_hocr", + return_value="converted", + ) + + easyocr_results = [ + ([[10, 10], [100, 10], [100, 50], [10, 50]], "Test", 0.95), + ] + + # Use non-existent path + result = engine._to_hocr(easyocr_results, Path("/nonexistent/image.jpg")) + + assert result == "converted" + # Should use default dimensions 1000x1000 + mock_converter.assert_called_once_with(easyocr_results, 1000, 1000) + + +class TestProcess: + """Test suite for main process method.""" + + def test_process_nonexistent_file_raises_error(self) -> None: + """Test process raises error for non-existent file.""" + engine = EasyOCREngine() + + with pytest.raises(OCRProcessingError) as exc_info: + engine.process(Path("/nonexistent/file.jpg")) + + assert "File not found" in str(exc_info.value) + + def test_process_unsupported_format_raises_error(self, tmp_path: Path) -> None: + """Test process raises error for unsupported file format.""" + engine = EasyOCREngine() + + # Create a file with unsupported extension + unsupported_file = tmp_path / "test.txt" + unsupported_file.touch() + + with pytest.raises(UnsupportedFormatError) as exc_info: + engine.process(unsupported_file) + + assert "Unsupported file format: .txt" in str(exc_info.value) + + def test_process_creates_reader_on_first_use(self, mocker: Any, tmp_path: Path) -> None: + """Test process creates reader on first use.""" + engine = EasyOCREngine() + + # Create test image + test_image = tmp_path / "test.jpg" + img = Image.new("RGB", (200, 100), color="white") + img.save(test_image) + + # Mock _create_reader + mock_reader = MagicMock() + mock_create = mocker.patch.object(engine, "_create_reader", return_value=mock_reader) + + # Mock _process_image + mocker.patch.object(engine, "_process_image", return_value="test") + + result = engine.process(test_image) + + assert result == "test" + mock_create.assert_called_once_with(["en"]) # Default language + assert engine.reader is mock_reader + assert engine._current_languages == ["en"] + + def test_process_reuses_reader_same_language(self, mocker: Any, tmp_path: Path) -> None: + """Test process reuses reader for same language.""" + engine = EasyOCREngine() + + # Set up existing reader + mock_reader = MagicMock() + engine.reader = mock_reader + engine._current_languages = ["en"] + + test_image = tmp_path / "test.jpg" + img = Image.new("RGB", (200, 100), color="white") + img.save(test_image) + + mock_create = mocker.patch.object(engine, "_create_reader") + mocker.patch.object(engine, "_process_image", return_value="test") + + result = engine.process(test_image, EasyOCRParams(languages=["en"])) + + assert result == "test" + mock_create.assert_not_called() # Should not create new reader + + def test_process_recreates_reader_different_language(self, mocker: Any, tmp_path: Path) -> None: + """Test process recreates reader when languages change.""" + engine = EasyOCREngine() + + # Set up existing reader with English + old_reader = MagicMock() + engine.reader = old_reader + engine._current_languages = ["en"] + + test_image = tmp_path / "test.jpg" + img = Image.new("RGB", (200, 100), color="white") + img.save(test_image) + + # Mock new reader creation + new_reader = MagicMock() + mock_create = mocker.patch.object(engine, "_create_reader", return_value=new_reader) + mocker.patch.object(engine, "_process_image", return_value="test") + + result = engine.process(test_image, EasyOCRParams(languages=["de", "fr"])) + + assert result == "test" + mock_create.assert_called_once_with(["de", "fr"]) + assert engine.reader is new_reader + assert engine._current_languages == ["de", "fr"] + + def test_process_routes_to_process_image_for_jpg(self, mocker: Any, tmp_path: Path) -> None: + """Test process routes to _process_image for JPEG files.""" + engine = EasyOCREngine() + + test_image = tmp_path / "test.jpg" + img = Image.new("RGB", (200, 100), color="white") + img.save(test_image) + + mocker.patch.object(engine, "_create_reader", return_value=MagicMock()) + mock_process_image = mocker.patch.object( + engine, + "_process_image", + return_value="image", + ) + mock_process_pdf = mocker.patch.object(engine, "_process_pdf") + + result = engine.process(test_image) + + assert result == "image" + mock_process_image.assert_called_once() + mock_process_pdf.assert_not_called() + + def test_process_routes_to_process_pdf_for_pdf(self, mocker: Any, tmp_path: Path) -> None: + """Test process routes to _process_pdf for PDF files.""" + engine = EasyOCREngine() + + test_pdf = tmp_path / "test.pdf" + test_pdf.touch() + + mocker.patch.object(engine, "_create_reader", return_value=MagicMock()) + mock_process_image = mocker.patch.object(engine, "_process_image") + mock_process_pdf = mocker.patch.object( + engine, + "_process_pdf", + return_value="pdf", + ) + + result = engine.process(test_pdf) + + assert result == "pdf" + mock_process_pdf.assert_called_once() + mock_process_image.assert_not_called() + + def test_process_handles_default_params(self, mocker: Any, tmp_path: Path) -> None: + """Test process handles None params correctly.""" + engine = EasyOCREngine() + + test_image = tmp_path / "test.jpg" + img = Image.new("RGB", (200, 100), color="white") + img.save(test_image) + + mocker.patch.object(engine, "_create_reader", return_value=MagicMock()) + mocker.patch.object(engine, "_process_image", return_value="test") + + # Pass None for params + result = engine.process(test_image, None) + + assert result == "test" + assert engine._current_languages == ["en"] # Should use default diff --git a/tests/test_entry_point.py b/tests/test_entry_point.py new file mode 100644 index 0000000..cea1a1e --- /dev/null +++ b/tests/test_entry_point.py @@ -0,0 +1,149 @@ +"""Tests for Python entry point discovery.""" + +from importlib.metadata import entry_points + +from ocrbridge.core import OCREngine + +from ocrbridge.engines.easyocr import EasyOCREngine + + +class TestEntryPoint: + """Test suite for ocrbridge.engines entry point.""" + + def test_entry_point_exists(self) -> None: + """Test that the easyocr entry point is registered.""" + # Get entry points for ocrbridge.engines group + eps = entry_points() + + # Handle both old and new entry_points() API + if hasattr(eps, "select"): + # Python 3.10+ API + engine_eps = eps.select(group="ocrbridge.engines") + else: + # Python 3.9 API + engine_eps = eps.get("ocrbridge.engines", []) + + # Convert to list and check + engine_list = list(engine_eps) + entry_names = [ep.name for ep in engine_list] + + assert "easyocr" in entry_names, ( + f"Entry point 'easyocr' not found in ocrbridge.engines. Found: {entry_names}" + ) + + def test_entry_point_loads_correct_class(self) -> None: + """Test that the entry point loads the EasyOCREngine class.""" + # Get entry points + eps = entry_points() + + if hasattr(eps, "select"): + engine_eps = eps.select(group="ocrbridge.engines") + else: + engine_eps = eps.get("ocrbridge.engines", []) + + # Find the easyocr entry point + easyocr_ep = None + for ep in engine_eps: + if ep.name == "easyocr": + easyocr_ep = ep + break + + assert easyocr_ep is not None, "easyocr entry point not found" + + # Load the entry point + engine_class = easyocr_ep.load() + + # Verify it's the correct class + assert engine_class is EasyOCREngine + + def test_entry_point_class_is_instantiable(self) -> None: + """Test that the entry point class can be instantiated.""" + # Get entry points + eps = entry_points() + + if hasattr(eps, "select"): + engine_eps = eps.select(group="ocrbridge.engines") + else: + engine_eps = eps.get("ocrbridge.engines", []) + + # Find and load the easyocr entry point + easyocr_ep = None + for ep in engine_eps: + if ep.name == "easyocr": + easyocr_ep = ep + break + + assert easyocr_ep is not None, "easyocr entry point not found" + + engine_class = easyocr_ep.load() + + # Instantiate the class + engine = engine_class() + + # Verify instance + assert isinstance(engine, EasyOCREngine) + assert isinstance(engine, OCREngine) + + def test_entry_point_engine_has_required_interface(self) -> None: + """Test that the entry point engine implements the OCREngine interface.""" + # Get entry points + eps = entry_points() + + if hasattr(eps, "select"): + engine_eps = eps.select(group="ocrbridge.engines") + else: + engine_eps = eps.get("ocrbridge.engines", []) + + # Find and load the easyocr entry point + easyocr_ep = None + for ep in engine_eps: + if ep.name == "easyocr": + easyocr_ep = ep + break + + assert easyocr_ep is not None, "easyocr entry point not found" + + engine_class = easyocr_ep.load() + engine = engine_class() + + # Check required properties exist + assert hasattr(engine, "name") + assert hasattr(engine, "supported_formats") + assert hasattr(engine, "process") + + # Check properties have correct types + assert isinstance(engine.name, str) + assert isinstance(engine.supported_formats, set) + assert callable(engine.process) + + # Check values + assert engine.name == "easyocr" + assert len(engine.supported_formats) > 0 + + +class TestEntryPointValue: + """Test suite for entry point configuration values.""" + + def test_entry_point_module_path(self) -> None: + """Test that entry point points to correct module path.""" + # Get entry points + eps = entry_points() + + if hasattr(eps, "select"): + engine_eps = eps.select(group="ocrbridge.engines") + else: + engine_eps = eps.get("ocrbridge.engines", []) + + # Find the easyocr entry point + easyocr_ep = None + for ep in engine_eps: + if ep.name == "easyocr": + easyocr_ep = ep + break + + assert easyocr_ep is not None + + # Check the entry point value + # Format should be: "ocrbridge.engines.easyocr:EasyOCREngine" + assert "ocrbridge.engines.easyocr" in easyocr_ep.value + assert "EasyOCREngine" in easyocr_ep.value diff --git a/tests/test_gpu_detection.py b/tests/test_gpu_detection.py new file mode 100644 index 0000000..6b7866a --- /dev/null +++ b/tests/test_gpu_detection.py @@ -0,0 +1,143 @@ +"""Tests for GPU detection helper functions.""" + +from typing import Any + +from ocrbridge.engines.easyocr.engine import detect_gpu_availability, get_easyocr_device + + +class TestDetectGPUAvailability: + """Test suite for detect_gpu_availability function.""" + + def test_cuda_available(self, mocker: Any) -> None: + """Test detect_gpu_availability returns True when CUDA is available.""" + # Mock torch.cuda.is_available() to return True + mock_torch = mocker.MagicMock() + mock_torch.cuda.is_available.return_value = True + mocker.patch.dict("sys.modules", {"torch": mock_torch}) + + # Clear any existing torch import + import sys + + if "torch" in sys.modules: + del sys.modules["torch"] + + # Re-mock torch after clearing + mock_torch = mocker.MagicMock() + mock_torch.cuda.is_available.return_value = True + mocker.patch.dict("sys.modules", {"torch": mock_torch}) + + result = detect_gpu_availability() + assert result is True + + def test_cuda_unavailable(self, mocker: Any) -> None: + """Test detect_gpu_availability returns False when CUDA is unavailable.""" + mock_torch = mocker.MagicMock() + mock_torch.cuda.is_available.return_value = False + mocker.patch.dict("sys.modules", {"torch": mock_torch}) + + result = detect_gpu_availability() + assert result is False + + def test_torch_not_installed(self, mocker: Any) -> None: + """Test detect_gpu_availability returns False when torch not installed.""" + + # Mock ImportError when trying to import torch + def raise_import_error(*args: Any, **kwargs: Any) -> None: + raise ImportError("No module named 'torch'") + + mocker.patch("builtins.__import__", side_effect=raise_import_error) + + result = detect_gpu_availability() + assert result is False + + def test_torch_import_exception(self, mocker: Any) -> None: + """Test detect_gpu_availability returns False on unexpected exception.""" + + # Mock an unexpected exception during torch import + def raise_exception(*args: Any, **kwargs: Any) -> None: + if args[0] == "torch": + raise RuntimeError("Unexpected error") + return mocker.DEFAULT + + mocker.patch("builtins.__import__", side_effect=raise_exception) + + result = detect_gpu_availability() + assert result is False + + +class TestGetEasyOCRDevice: + """Test suite for get_easyocr_device function.""" + + def test_returns_gpu_when_cuda_available(self, mocker: Any) -> None: + """Test get_easyocr_device returns GPU device when CUDA is available.""" + # Mock detect_gpu_availability to return True + mocker.patch( + "ocrbridge.engines.easyocr.engine.detect_gpu_availability", + return_value=True, + ) + + # Mock torch.cuda.current_device() + mock_torch = mocker.MagicMock() + mock_torch.cuda.current_device.return_value = 0 + mocker.patch.dict("sys.modules", {"torch": mock_torch}) + + use_gpu, device_name = get_easyocr_device() + + assert use_gpu is True + assert device_name == "cuda:0" + + def test_returns_gpu_with_specific_device(self, mocker: Any) -> None: + """Test get_easyocr_device returns correct device ID.""" + mocker.patch( + "ocrbridge.engines.easyocr.engine.detect_gpu_availability", + return_value=True, + ) + + # Mock torch.cuda.current_device() to return device 1 + mock_torch = mocker.MagicMock() + mock_torch.cuda.current_device.return_value = 1 + mocker.patch.dict("sys.modules", {"torch": mock_torch}) + + use_gpu, device_name = get_easyocr_device() + + assert use_gpu is True + assert device_name == "cuda:1" + + def test_returns_cpu_when_cuda_unavailable(self, mocker: Any) -> None: + """Test get_easyocr_device returns CPU when CUDA is unavailable.""" + mocker.patch( + "ocrbridge.engines.easyocr.engine.detect_gpu_availability", + return_value=False, + ) + + use_gpu, device_name = get_easyocr_device() + + assert use_gpu is False + assert device_name == "cpu" + + def test_returns_cpu_when_torch_not_available(self, mocker: Any) -> None: + """Test get_easyocr_device returns CPU when torch is not installed.""" + # Mock detect_gpu_availability to return False (torch not available) + mocker.patch( + "ocrbridge.engines.easyocr.engine.detect_gpu_availability", + return_value=False, + ) + + use_gpu, device_name = get_easyocr_device() + + assert use_gpu is False + assert device_name == "cpu" + + def test_return_types(self, mocker: Any) -> None: + """Test get_easyocr_device returns correct types.""" + mocker.patch( + "ocrbridge.engines.easyocr.engine.detect_gpu_availability", + return_value=False, + ) + + result = get_easyocr_device() + + assert isinstance(result, tuple) + assert len(result) == 2 + assert isinstance(result[0], bool) + assert isinstance(result[1], str) diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..dead185 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,187 @@ +"""Tests for EasyOCR parameter models.""" + +import pytest +from pydantic import ValidationError + +from ocrbridge.engines.easyocr.models import EASYOCR_SUPPORTED_LANGUAGES, EasyOCRParams + + +class TestEasyOCRParams: + """Test suite for EasyOCRParams validation.""" + + def test_default_params(self) -> None: + """Test EasyOCRParams with default values.""" + params = EasyOCRParams() + assert params.languages == ["en"] + assert params.text_threshold == 0.7 + assert params.link_threshold == 0.7 + + def test_valid_single_language(self) -> None: + """Test EasyOCRParams with a single valid language.""" + params = EasyOCRParams(languages=["de"]) + assert params.languages == ["de"] + + def test_valid_multiple_languages(self) -> None: + """Test EasyOCRParams with multiple valid languages.""" + params = EasyOCRParams(languages=["en", "de", "fr"]) + assert params.languages == ["en", "de", "fr"] + + def test_valid_max_languages(self) -> None: + """Test EasyOCRParams with maximum 5 languages.""" + params = EasyOCRParams(languages=["en", "de", "fr", "es", "it"]) + assert len(params.languages) == 5 + + def test_valid_asian_languages(self) -> None: + """Test EasyOCRParams with Asian language codes.""" + params = EasyOCRParams(languages=["ch_sim", "ja", "ko"]) + assert params.languages == ["ch_sim", "ja", "ko"] + + def test_invalid_language_code(self) -> None: + """Test EasyOCRParams rejects invalid language codes.""" + with pytest.raises(ValidationError) as exc_info: + EasyOCRParams(languages=["eng"]) # Tesseract format, not EasyOCR + + error = exc_info.value.errors()[0] + assert "Unsupported EasyOCR language codes" in error["msg"] + assert "eng" in error["msg"] + + def test_invalid_multiple_language_codes(self) -> None: + """Test EasyOCRParams rejects multiple invalid language codes.""" + with pytest.raises(ValidationError) as exc_info: + EasyOCRParams(languages=["eng", "chi_sim", "invalid"]) + + error = exc_info.value.errors()[0] + assert "Unsupported EasyOCR language codes" in error["msg"] + + def test_empty_languages_list(self) -> None: + """Test EasyOCRParams rejects empty language list.""" + with pytest.raises(ValidationError) as exc_info: + EasyOCRParams(languages=[]) + + # Should fail on min_length constraint + errors = exc_info.value.errors() + assert any("at least 1" in err["msg"].lower() for err in errors) + + def test_too_many_languages(self) -> None: + """Test EasyOCRParams rejects more than 5 languages.""" + with pytest.raises(ValidationError) as exc_info: + EasyOCRParams(languages=["en", "de", "fr", "es", "it", "pt"]) + + # Should fail on max_length constraint + errors = exc_info.value.errors() + assert any("at most 5" in err["msg"].lower() for err in errors) + + def test_valid_text_threshold_range(self) -> None: + """Test EasyOCRParams accepts valid text threshold values.""" + params_min = EasyOCRParams(text_threshold=0.0) + assert params_min.text_threshold == 0.0 + + params_mid = EasyOCRParams(text_threshold=0.5) + assert params_mid.text_threshold == 0.5 + + params_max = EasyOCRParams(text_threshold=1.0) + assert params_max.text_threshold == 1.0 + + def test_invalid_text_threshold_below_range(self) -> None: + """Test EasyOCRParams rejects text threshold below 0.0.""" + with pytest.raises(ValidationError) as exc_info: + EasyOCRParams(text_threshold=-0.1) + + errors = exc_info.value.errors() + assert any("greater than or equal to 0" in err["msg"].lower() for err in errors) + + def test_invalid_text_threshold_above_range(self) -> None: + """Test EasyOCRParams rejects text threshold above 1.0.""" + with pytest.raises(ValidationError) as exc_info: + EasyOCRParams(text_threshold=1.1) + + errors = exc_info.value.errors() + assert any("less than or equal to 1" in err["msg"].lower() for err in errors) + + def test_valid_link_threshold_range(self) -> None: + """Test EasyOCRParams accepts valid link threshold values.""" + params_min = EasyOCRParams(link_threshold=0.0) + assert params_min.link_threshold == 0.0 + + params_mid = EasyOCRParams(link_threshold=0.5) + assert params_mid.link_threshold == 0.5 + + params_max = EasyOCRParams(link_threshold=1.0) + assert params_max.link_threshold == 1.0 + + def test_invalid_link_threshold_below_range(self) -> None: + """Test EasyOCRParams rejects link threshold below 0.0.""" + with pytest.raises(ValidationError) as exc_info: + EasyOCRParams(link_threshold=-0.1) + + errors = exc_info.value.errors() + assert any("greater than or equal to 0" in err["msg"].lower() for err in errors) + + def test_invalid_link_threshold_above_range(self) -> None: + """Test EasyOCRParams rejects link threshold above 1.0.""" + with pytest.raises(ValidationError) as exc_info: + EasyOCRParams(link_threshold=1.1) + + errors = exc_info.value.errors() + assert any("less than or equal to 1" in err["msg"].lower() for err in errors) + + def test_custom_params(self) -> None: + """Test EasyOCRParams with all custom values.""" + params = EasyOCRParams( + languages=["en", "de"], + text_threshold=0.8, + link_threshold=0.6, + ) + assert params.languages == ["en", "de"] + assert params.text_threshold == 0.8 + assert params.link_threshold == 0.6 + + def test_model_dump(self) -> None: + """Test EasyOCRParams can be serialized to dict.""" + params = EasyOCRParams(languages=["en", "ja"], text_threshold=0.9) + data = params.model_dump() + + assert isinstance(data, dict) + assert data["languages"] == ["en", "ja"] + assert data["text_threshold"] == 0.9 + assert data["link_threshold"] == 0.7 + + def test_model_fields_have_descriptions(self) -> None: + """Test that all fields have descriptions.""" + schema = EasyOCRParams.model_json_schema() + + assert "languages" in schema["properties"] + assert "description" in schema["properties"]["languages"] + assert "text_threshold" in schema["properties"] + assert "description" in schema["properties"]["text_threshold"] + assert "link_threshold" in schema["properties"] + assert "description" in schema["properties"]["link_threshold"] + + +class TestEasyOCRSupportedLanguages: + """Test suite for EASYOCR_SUPPORTED_LANGUAGES constant.""" + + def test_supported_languages_is_set(self) -> None: + """Test EASYOCR_SUPPORTED_LANGUAGES is a set.""" + assert isinstance(EASYOCR_SUPPORTED_LANGUAGES, set) + + def test_supported_languages_not_empty(self) -> None: + """Test EASYOCR_SUPPORTED_LANGUAGES contains languages.""" + assert len(EASYOCR_SUPPORTED_LANGUAGES) > 0 + + def test_common_languages_supported(self) -> None: + """Test common languages are in the supported set.""" + common_langs = ["en", "de", "fr", "es", "it", "ch_sim", "ja", "ko"] + for lang in common_langs: + assert lang in EASYOCR_SUPPORTED_LANGUAGES + + def test_language_codes_are_strings(self) -> None: + """Test all language codes are strings.""" + for lang in EASYOCR_SUPPORTED_LANGUAGES: + assert isinstance(lang, str) + + def test_language_codes_lowercase(self) -> None: + """Test all language codes are lowercase or contain underscore.""" + for lang in EASYOCR_SUPPORTED_LANGUAGES: + # EasyOCR uses lowercase with underscores (e.g., "ch_sim") + assert lang.islower() or "_" in lang diff --git a/tests/test_performance.py b/tests/test_performance.py new file mode 100644 index 0000000..1377bc4 --- /dev/null +++ b/tests/test_performance.py @@ -0,0 +1,234 @@ +"""Performance and benchmark tests for EasyOCR engine. + +These tests measure processing time and resource usage. +They are marked as slow tests and may take significant time to run. +""" + +import time +from pathlib import Path + +import pytest + +from ocrbridge.engines.easyocr import EasyOCREngine, EasyOCRParams + + +@pytest.mark.slow +@pytest.mark.integration +class TestEasyOCRPerformance: + """Performance benchmark tests for EasyOCR engine.""" + + def test_jpeg_processing_time(self, sample_jpg_stock: Path) -> None: + """Benchmark JPEG image processing time.""" + engine = EasyOCREngine() + params = EasyOCRParams(languages=["en"]) + + start_time = time.time() + result = engine.process(sample_jpg_stock, params) + elapsed_time = time.time() - start_time + + # Verify result is valid + assert result.startswith(' None: + """Benchmark PDF processing time.""" + engine = EasyOCREngine() + params = EasyOCRParams(languages=["en"]) + + start_time = time.time() + result = engine.process(sample_pdf_en_scan, params) + elapsed_time = time.time() - start_time + + # Verify result is valid + assert result.startswith(' None: + """Benchmark EasyOCR reader initialization time.""" + engine = EasyOCREngine() + + start_time = time.time() + engine._create_reader(["en"]) + elapsed_time = time.time() - start_time + + print(f"\nReader initialization time: {elapsed_time:.2f} seconds") + + # Reader initialization can be slow on first load (model download/loading) + # Allow up to 180 seconds for model loading + assert elapsed_time < 180.0, f"Reader initialization took too long: {elapsed_time:.2f}s" + + def test_sequential_processing_performance( + self, + sample_jpg_stock: Path, + sample_jpg_numbers: Path, + ) -> None: + """Benchmark sequential processing of multiple images.""" + engine = EasyOCREngine() + params = EasyOCRParams(languages=["en"]) + + # Process multiple images sequentially + files = [sample_jpg_stock, sample_jpg_numbers, sample_jpg_stock] + + start_time = time.time() + results = [] + for file_path in files: + result = engine.process(file_path, params) + results.append(result) + elapsed_time = time.time() - start_time + + # Verify all results are valid + assert len(results) == 3 + for result in results: + assert result.startswith(' None: + """Benchmark overhead of switching languages.""" + engine = EasyOCREngine() + + # First processing with English (includes reader creation) + start_time = time.time() + result1 = engine.process(sample_pdf_en_scan, EasyOCRParams(languages=["en"])) + time_with_en = time.time() - start_time + + # Second processing with German (requires reader recreation) + start_time = time.time() + result2 = engine.process(sample_pdf_de_scan, EasyOCRParams(languages=["de"])) + time_with_de = time.time() - start_time + + assert result1.startswith(' None: + """Verify that reader reuse provides performance benefit.""" + engine = EasyOCREngine() + params = EasyOCRParams(languages=["en"]) + + # First run (includes reader creation) + start_time = time.time() + result1 = engine.process(sample_jpg_stock, params) + first_run_time = time.time() - start_time + + # Second run (reuses reader) + start_time = time.time() + result2 = engine.process(sample_jpg_stock, params) + second_run_time = time.time() - start_time + + assert result1.startswith(' None: + """Test processing all sample files to verify engine handles variety.""" + engine = EasyOCREngine() + + samples = [ + (sample_jpg_stock, ["en"]), + (sample_jpg_numbers, ["en"]), + (sample_pdf_en_scan, ["en"]), + (sample_pdf_en_photo, ["en"]), + (sample_pdf_de_scan, ["de"]), + (sample_pdf_de_photo, ["de"]), + ] + + results = [] + timings = [] + + for file_path, languages in samples: + params = EasyOCRParams(languages=languages) + start_time = time.time() + result = engine.process(file_path, params) + elapsed = time.time() - start_time + + results.append(result) + timings.append((file_path.name, elapsed)) + + # Verify all processed successfully + assert len(results) == len(samples) + for result in results: + assert result.startswith(' None: + """Test performance impact of using multiple languages.""" + engine_single = EasyOCREngine() + engine_multi = EasyOCREngine() + + # Single language + params_single = EasyOCRParams(languages=["en"]) + start_time = time.time() + result_single = engine_single.process(sample_jpg_stock, params_single) + time_single = time.time() - start_time + + # Multiple languages + params_multi = EasyOCRParams(languages=["en", "de", "fr"]) + start_time = time.time() + result_multi = engine_multi.process(sample_jpg_stock, params_multi) + time_multi = time.time() - start_time + + assert result_single.startswith('