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..c78ad0a --- /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: macos-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 Poppler + run: brew install poppler + - 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..fbab32a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,187 @@ +# File: .github/workflows/release.yml +on: + push: + branches: + - main + +jobs: + + build: + runs-on: macos-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-ocrmac + uv pip install dist/ocrbridge_ocrmac-*.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-ocrmac' }} + needs: + - build + - release + + environment: + name: pypi + url: https://pypi.org/project/ocrbridge-ocrmac/ + + 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..4cf5b06 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,154 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is `ocrbridge-ocrmac`, an OCR engine for the OCR Bridge architecture that uses Apple's Vision framework. It is a **macOS-only** package that integrates with the broader OCR Bridge plugin ecosystem via entry points. + +**Key constraint**: All code must run on macOS (Darwin platform). The engine performs platform validation at runtime. + +## Architecture + +### Entry Point System + +The engine registers itself with OCR Bridge using Python entry points: + +```toml +[project.entry-points."ocrbridge.engines"] +ocrmac = "ocrbridge.engines.ocrmac:OcrmacEngine" +``` + +This allows OCR Bridge to automatically discover and load the engine at runtime. + +### Core Components + +- `src/ocrbridge/engines/ocrmac/engine.py` - Main `OcrmacEngine` class implementing `OCREngine` interface from `ocrbridge-core` +- `src/ocrbridge/engines/ocrmac/models.py` - `OcrmacParams` and `RecognitionLevel` enum for configuration +- `src/ocrbridge/engines/ocrmac/__init__.py` - Public API exports + +### OCR Processing Flow + +1. **Format validation**: Check file extension against supported formats (`.jpg`, `.jpeg`, `.png`, `.pdf`, `.tiff`, `.tif`) +2. **Platform validation**: Verify running on macOS (Darwin) +3. **LiveText validation**: For `RecognitionLevel.LIVETEXT`, verify macOS Sonoma 14.0+ +4. **PDF handling**: PDFs are converted to images via `pdf2image` (300 DPI, 2 threads), then each page is processed separately +5. **OCR execution**: Use `ocrmac` library with specified `recognition_level` and `languages` +6. **HOCR conversion**: Convert ocrmac annotations (relative coords, bottom-left origin) to HOCR XML (absolute pixels, top-left origin) +7. **Multi-page merging**: For PDFs, merge individual page HOCR into single document + +### Coordinate System Transformation + +Critical detail in `_convert_to_hocr()` at `src/ocrbridge/engines/ocrmac/engine.py:247-311`: + +- **ocrmac output**: Relative coordinates (0.0-1.0), bottom-left origin +- **HOCR format**: Absolute pixel coordinates, top-left origin +- **Y-axis flip**: `y_min = int((1.0 - bbox[1] - bbox[3]) * image_height)` + +### Recognition Levels + +From `src/ocrbridge/engines/ocrmac/models.py:11-28`: + +- `fast`: ~131ms per image (Vision framework, fewer languages) +- `balanced`: ~150ms per image (default, Vision framework) +- `accurate`: ~207ms per image (Vision framework, highest accuracy) +- `livetext`: ~174ms per image (LiveText framework, **requires macOS Sonoma 14.0+**) + +## Development Commands + +This project uses `uv` for dependency management and `make` for common tasks. + +### Setup +```bash +make install # Sync dependencies including dev extras (uv sync --extra dev) +``` + +### Testing +```bash +make test # Run pytest +pytest tests/test_specific.py::test_function # Run single test +``` + +### Code Quality +```bash +make lint # Run ruff linting (uv run ruff check src tests) +make format # Format code with ruff (uv run ruff format src tests) +make typecheck # Run pyright type checking (strict mode) +make check # Run lint + typecheck + test +make all # Run check + format (default target) +``` + +### Standards + +- **Line length**: 100 characters (configured in `pyproject.toml`) +- **Type checking**: Strict mode with pyright, Python 3.10+ compatibility +- **Python path**: `src` is added to `pythonpath` for imports +- **Ruff linting**: Rules E, F, I, N, W enabled + +## Commit Messages + +Follow Conventional Commits specification (enforced via `commitlint.config.js`): + +``` +[optional scope]: + +[optional body] + +[optional footer(s)] +``` + +**Types**: `feat:`, `fix:`, `build:`, `chore:`, `ci:`, `docs:`, `style:`, `refactor:`, `perf:`, `test:` + +**Breaking changes**: Use `!` suffix or `BREAKING CHANGE:` footer + +## Release Process + +This project uses `python-semantic-release` for automated versioning and releases. The version is stored in `pyproject.toml:project.version`. + +### CI/CD Workflows + +Three GitHub Actions workflows in `.github/workflows/`: +- `python-package.yml` - Runs lint, typecheck, and tests on PRs +- `conventional-commits.yml` - Validates commit message format +- `release.yml` - Automated release on push to `main` (build → test → release → deploy to PyPI) + +Build command includes: +```bash +uv lock --upgrade-package "$PACKAGE_NAME" +git add uv.lock +uv build +``` + +## Dependencies + +### Runtime +- `ocrbridge-core>=0.1.0` - Base engine interface +- `ocrmac>=0.2.2` - Apple Vision framework wrapper (macOS only) +- `pdf2image>=1.17.0` - PDF to image conversion +- `Pillow>=10.0.0` - Image processing + +### Development +- `pytest~=8.0` - Testing framework +- `ruff>=0.1.0` - Linting and formatting +- `pyright>=1.1.0` - Type checking +- `pytest-cov>=7.0.0` - Coverage reporting +- `python-semantic-release>=10.5.2` - Automated releases + +## Testing + +### Sample Files + +The `samples/` directory contains test files for development: +- `contract_de_photo.pdf`, `contract_de_scan.pdf` - German contract samples +- `contract_en_photo.pdf`, `contract_en_scan.pdf` - English contract samples +- `numbers_gs150.jpg`, `stock_gs200.jpg` - Grayscale test images + +Use these for manual testing during development. + +## Important Notes + +- **macOS only**: This package will not work on Windows or Linux. Platform checks are enforced at runtime. +- **LiveText requirement**: `RecognitionLevel.LIVETEXT` requires macOS Sonoma 14.0+ and will raise `OCRProcessingError` on older versions. +- **Language codes**: Must be IETF BCP 47 format (e.g., `en-US`, `fr-FR`, `zh-Hans`), maximum 5 languages. +- **PDF processing**: Each PDF page creates a temporary PNG file that is deleted after processing. +- **HOCR output**: Always returns valid HOCR XML with proper DOCTYPE and namespace declarations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c1cdc7f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,213 @@ + +# Contributing to ocrbridge-ocrmac + +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-ocrmac/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-ocrmac/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-ocrmac/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-ocrmac/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-ocrmac, **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-ocrmac/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-ocrmac/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-ocrmac 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..1713a32 --- /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..382c946 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1247 @@ +{ + "name": "ocrbridge-ocrmac", + "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..433d58c --- /dev/null +++ b/package.json @@ -0,0 +1,7 @@ +{ + "type": "module", + "devDependencies": { + "@commitlint/cli": "^20.1.0", + "@commitlint/config-conventional": "^20.0.0" + } +} diff --git a/pyproject.toml b/pyproject.toml index 4bc9e4f..341776d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,8 +3,8 @@ name = "ocrbridge-ocrmac" version = "0.1.0" description = "ocrmac (Apple Vision) OCR engine for OCR Bridge" readme = "README.md" -requires-python = ">=3.11" -license = {text = "MIT"} +requires-python = ">=3.10" +license = { text = "MIT" } dependencies = [ "ocrbridge-core>=0.1.0", @@ -15,10 +15,11 @@ dependencies = [ [project.optional-dependencies] dev = [ - "pytest>=7.4.0", - "pytest-cov>=4.1.0", + "pytest~=8.0", "ruff>=0.1.0", "pyright>=1.1.0", + "pytest-cov>=7.0.0", + "python-semantic-release>=10.5.2", ] # Entry point for engine discovery @@ -40,6 +41,20 @@ target-version = "py311" select = ["E", "F", "I", "N", "W"] ignore = [] +[tool.pytest.ini_options] +pythonpath = ["src"] +testpaths = ["tests"] + [tool.pyright] typeCheckingMode = "strict" -pythonVersion = "3.11" +pythonVersion = "3.10" +reportMissingTypeStubs = false +reportPrivateUsage = "none" + +[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/ocrmac/engine.py b/src/ocrbridge/engines/ocrmac/engine.py index d948c05..9774037 100644 --- a/src/ocrbridge/engines/ocrmac/engine.py +++ b/src/ocrbridge/engines/ocrmac/engine.py @@ -5,12 +5,21 @@ import tempfile import xml.etree.ElementTree as ET from pathlib import Path +from typing import Sequence, Tuple -from ocrbridge.core import OCREngine, OCRProcessingError, UnsupportedFormatError -from pdf2image import convert_from_path +from pdf2image import convert_from_path # type: ignore[reportUnknownVariableType] from PIL import Image -from .models import OcrmacParams, RecognitionLevel +from ocrbridge.core import ( # type: ignore[reportMissingTypeStubs] + OCREngine, + OCREngineParams, + OCRProcessingError, + UnsupportedFormatError, +) + +from .models import OcrmacParams, RecognitionLevel # type: ignore[reportMissingTypeStubs] + +Annotation = tuple[str, float, Tuple[float, float, float, float]] class OcrmacEngine(OCREngine): @@ -34,8 +43,7 @@ def _validate_platform(self) -> None: """Validate that we're running on macOS.""" if platform.system() != "Darwin": raise OCRProcessingError( - "ocrmac is only available on macOS systems. " - f"Current platform: {platform.system()}" + f"ocrmac is only available on macOS systems. Current platform: {platform.system()}" ) def _validate_livetext_requirement(self, recognition_level: RecognitionLevel) -> None: @@ -53,14 +61,15 @@ def _validate_livetext_requirement(self, recognition_level: RecognitionLevel) -> major_version = int(mac_version.split(".")[0]) if major_version < 14: raise OCRProcessingError( - f"LiveText requires macOS Sonoma (14.0) or later. Current version: {mac_version}" + ( + "LiveText requires macOS Sonoma (14.0) or later. " + f"Current version: {mac_version}" + ) ) except (ValueError, IndexError) as e: - raise OCRProcessingError( - f"Invalid macOS version format: {mac_version}" - ) from e + raise OCRProcessingError(f"Invalid macOS version format: {mac_version}") from e - def process(self, file_path: Path, params: OcrmacParams | None = None) -> str: + def process(self, file_path: Path, params: OCREngineParams | None = None) -> str: """Process document using ocrmac and return HOCR XML. Args: @@ -80,6 +89,8 @@ def process(self, file_path: Path, params: OcrmacParams | None = None) -> str: # Use defaults if no params provided if params is None: params = OcrmacParams() + elif not isinstance(params, OcrmacParams): + params = OcrmacParams.model_validate(params.model_dump()) # Validate LiveText requirements self._validate_livetext_requirement(params.recognition_level) @@ -109,14 +120,16 @@ def process(self, file_path: Path, params: OcrmacParams | None = None) -> str: def _process_image(self, image_path: Path, params: OcrmacParams) -> str: """Process image with ocrmac.""" try: - ocrmac = importlib.import_module("ocrmac") + ocrmac = importlib.import_module("ocrmac.ocrmac") except ImportError as e: raise OCRProcessingError( "ocrmac not installed. Install with: pip install ocrmac" ) from e # Determine framework - framework_type = "livetext" if params.recognition_level == RecognitionLevel.LIVETEXT else "vision" + framework_type = ( + "livetext" if params.recognition_level == RecognitionLevel.LIVETEXT else "vision" + ) # Create OCR instance if params.recognition_level == RecognitionLevel.LIVETEXT: @@ -145,16 +158,14 @@ def _process_image(self, image_path: Path, params: OcrmacParams) -> str: image_width, image_height = img.size # Convert to HOCR - hocr_content = self._convert_to_hocr( - annotations, image_width, image_height, params - ) + hocr_content = self._convert_to_hocr(annotations, image_width, image_height, params) return hocr_content def _process_pdf(self, pdf_path: Path, params: OcrmacParams) -> str: """Process PDF by converting to images then OCR.""" try: - ocrmac = importlib.import_module("ocrmac") + ocrmac = importlib.import_module("ocrmac.ocrmac") except ImportError as e: raise OCRProcessingError( "ocrmac not installed. Install with: pip install ocrmac" @@ -167,7 +178,7 @@ def _process_pdf(self, pdf_path: Path, params: OcrmacParams) -> str: raise OCRProcessingError(f"PDF conversion failed: {e}") # Process each page - page_hocr_list = [] + page_hocr_list: list[str] = [] for image in images: # Save temp image with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp_file: @@ -176,7 +187,11 @@ def _process_pdf(self, pdf_path: Path, params: OcrmacParams) -> str: try: # Process page - framework_type = "livetext" if params.recognition_level == RecognitionLevel.LIVETEXT else "vision" + framework_type = ( + "livetext" + if params.recognition_level == RecognitionLevel.LIVETEXT + else "vision" + ) if params.recognition_level == RecognitionLevel.LIVETEXT: ocr_instance = ocrmac.OCR( @@ -199,9 +214,7 @@ def _process_pdf(self, pdf_path: Path, params: OcrmacParams) -> str: annotations = ocr_instance.recognize() image_width, image_height = image.size - page_hocr = self._convert_to_hocr( - annotations, image_width, image_height, params - ) + page_hocr = self._convert_to_hocr(annotations, image_width, image_height, params) page_hocr_list.append(page_hocr) finally: temp_path.unlink(missing_ok=True) @@ -232,7 +245,11 @@ def _merge_hocr_pages(self, page_hocr_list: list[str]) -> str: """ def _convert_to_hocr( - self, annotations: list, image_width: int, image_height: int, params: OcrmacParams + self, + annotations: Sequence[Annotation], + image_width: int, + image_height: int, + params: OcrmacParams, ) -> str: """Convert ocrmac annotations to HOCR format. @@ -243,22 +260,24 @@ def _convert_to_hocr( # Head head = ET.SubElement(html, "head") - ET.SubElement(head, "meta", attrib={ - "http-equiv": "content-type", - "content": "text/html; charset=utf-8" - }) - ET.SubElement(head, "meta", attrib={ - "name": "ocr-system", - "content": "ocrmac" - }) + ET.SubElement( + head, + "meta", + attrib={"http-equiv": "content-type", "content": "text/html; charset=utf-8"}, + ) + ET.SubElement(head, "meta", attrib={"name": "ocr-system", "content": "ocrmac"}) # Body body = ET.SubElement(html, "body") - page = ET.SubElement(body, "div", attrib={ - "class": "ocr_page", - "id": "page_1", - "title": f"bbox 0 0 {image_width} {image_height}" - }) + page = ET.SubElement( + body, + "div", + attrib={ + "class": "ocr_page", + "id": "page_1", + "title": f"bbox 0 0 {image_width} {image_height}", + }, + ) # Convert annotations to words for idx, annotation in enumerate(annotations, start=1): @@ -271,11 +290,17 @@ def _convert_to_hocr( y_max = int((1.0 - bbox[1]) * image_height) # Create word element - word_elem = ET.SubElement(page, "span", attrib={ - "class": "ocrx_word", - "id": f"word_1_{idx}", - "title": f"bbox {x_min} {y_min} {x_max} {y_max}; x_wconf {int(confidence * 100)}" - }) + word_elem = ET.SubElement( + page, + "span", + attrib={ + "class": "ocrx_word", + "id": f"word_1_{idx}", + "title": ( + f"bbox {x_min} {y_min} {x_max} {y_max}; x_wconf {int(confidence * 100)}" + ), + }, + ) word_elem.text = text # Generate HOCR XML diff --git a/src/ocrbridge/engines/ocrmac/models.py b/src/ocrbridge/engines/ocrmac/models.py index f8b743d..2898c91 100644 --- a/src/ocrbridge/engines/ocrmac/models.py +++ b/src/ocrbridge/engines/ocrmac/models.py @@ -3,9 +3,10 @@ import re from enum import Enum -from ocrbridge.core.models import OCREngineParams from pydantic import Field, field_validator +from ocrbridge.core.models import OCREngineParams # type: ignore[reportMissingTypeStubs] + class RecognitionLevel(str, Enum): """ocrmac recognition level options. @@ -40,7 +41,10 @@ class OcrmacParams(OCREngineParams): recognition_level: RecognitionLevel = Field( default=RecognitionLevel.BALANCED, - description="Recognition level: fast (~131ms), balanced (default, ~150ms), accurate (~207ms), livetext (~174ms, requires macOS Sonoma 14.0+)", + description=( + "Recognition level: fast (~131ms), balanced (default, ~150ms), " + "accurate (~207ms), livetext (~174ms, requires macOS Sonoma 14.0+)" + ), ) @field_validator("languages") diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..2800a67 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests package placeholder.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..c79b551 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,195 @@ +"""Shared pytest fixtures and utilities for tests.""" + +import xml.etree.ElementTree as ET +from pathlib import Path +from typing import Any, Callable +from unittest.mock import MagicMock + +import pytest + + +@pytest.fixture +def samples_dir() -> Path: + """Return path to samples directory.""" + return Path(__file__).parent.parent / "samples" + + +@pytest.fixture +def sample_jpg(samples_dir: Path) -> Path: + """Return path to sample JPG file.""" + return samples_dir / "numbers_gs150.jpg" + + +@pytest.fixture +def sample_jpg_2(samples_dir: Path) -> Path: + """Return path to second sample JPG file.""" + return samples_dir / "stock_gs200.jpg" + + +@pytest.fixture +def sample_pdf_en(samples_dir: Path) -> Path: + """Return path to English contract PDF.""" + return samples_dir / "contract_en_photo.pdf" + + +@pytest.fixture +def sample_pdf_de(samples_dir: Path) -> Path: + """Return path to German contract PDF.""" + return samples_dir / "contract_de_scan.pdf" + + +@pytest.fixture +def mock_ocrmac_annotations() -> list[tuple[str, float, tuple[float, float, float, float]]]: + """Return mock ocrmac annotation data. + + Format: [(text, confidence, (x_min, y_min, width, height)), ...] + Coordinates are relative (0.0-1.0) from bottom-left origin. + """ + return [ + ("Hello", 0.95, (0.1, 0.8, 0.2, 0.1)), # Top-left area + ("World", 0.92, (0.4, 0.8, 0.25, 0.1)), # Top-center area + ("Test", 0.98, (0.1, 0.5, 0.15, 0.08)), # Middle-left area + ] + + +@pytest.fixture +def mock_ocrmac_module() -> MagicMock: + """Return a mock ocrmac module.""" + mock_module = MagicMock() + + # Create a mock OCR class + mock_ocr_class = MagicMock() + mock_ocr_instance = MagicMock() + + # Configure the OCR class to return the instance when instantiated + mock_ocr_class.return_value = mock_ocr_instance + + # Set the OCR class on the module + mock_module.OCR = mock_ocr_class + + # Configure recognize() to return empty list by default + mock_ocr_instance.recognize.return_value = [] + + return mock_module + + +@pytest.fixture +def hocr_validator() -> Callable[[str], ET.Element]: + """Return a function to validate and parse HOCR XML.""" + + def validate(hocr_xml: str) -> ET.Element: + """Validate HOCR XML structure and return parsed root element. + + Args: + hocr_xml: HOCR XML string + + Returns: + Parsed XML root element + + Raises: + AssertionError: If validation fails + """ + # Parse XML + root = ET.fromstring(hocr_xml) + + # Validate root element + assert root.tag == "{http://www.w3.org/1999/xhtml}html" + # Note: ElementTree stores namespace in tag, not as attribute + # Check that xmlns is in original string + assert 'xmlns="http://www.w3.org/1999/xhtml"' in hocr_xml + + # Validate structure + head = root.find("{http://www.w3.org/1999/xhtml}head") + assert head is not None, "Missing element" + + body = root.find("{http://www.w3.org/1999/xhtml}body") + assert body is not None, "Missing element" + + # Validate meta tags + meta_tags = head.findall("{http://www.w3.org/1999/xhtml}meta") + assert len(meta_tags) >= 2, "Missing meta tags" + + # Find ocr-system meta tag + ocr_system_meta = None + for meta in meta_tags: + if meta.attrib.get("name") == "ocr-system": + ocr_system_meta = meta + break + + assert ocr_system_meta is not None, "Missing ocr-system meta tag" + assert ocr_system_meta.attrib.get("content") == "ocrmac" + + return root + + return validate + + +@pytest.fixture +def bbox_parser() -> Callable[[str], dict[str, Any]]: + """Return a function to parse bbox from HOCR title attribute.""" + + def parse(title: str) -> dict[str, Any]: + """Parse bbox and confidence from HOCR title attribute. + + Args: + title: HOCR title attribute value (e.g., "bbox 0 0 100 200; x_wconf 95") + + Returns: + Dictionary with bbox coordinates and confidence + """ + result: dict[str, Any] = {} + + for part in title.split(";"): + part = part.strip() + if part.startswith("bbox "): + coords = part[5:].split() + result["bbox"] = { + "x_min": int(coords[0]), + "y_min": int(coords[1]), + "x_max": int(coords[2]), + "y_max": int(coords[3]), + } + elif part.startswith("x_wconf "): + result["confidence"] = int(part[8:]) + + return result + + return parse + + +@pytest.fixture(scope="session") +def livetext_available() -> bool: + """Check if LiveText is actually available and working. + + Returns True if LiveText can be safely used, False otherwise. + """ + try: + import platform + + from ocrmac.ocrmac import LIVETEXT_AVAILABLE + + # Check if LiveText is reported as available + if not LIVETEXT_AVAILABLE: + return False + + # Check for reasonable macOS version (12.x - 16.x range) + mac_version = platform.mac_ver()[0] + if mac_version: + try: + major_version = int(mac_version.split(".")[0]) + # Version should be in reasonable range (12-16) and >= 14 for LiveText + if major_version < 14 or major_version > 16: + return False + except (ValueError, IndexError): + return False + + return True + except Exception: + return False + + +def pytest_configure(config: Any) -> None: + """Register custom pytest markers.""" + config.addinivalue_line( + "markers", "integration: mark test as integration test (requires macOS and ocrmac)" + ) diff --git a/tests/test_engine_integration.py b/tests/test_engine_integration.py new file mode 100644 index 0000000..d31d099 --- /dev/null +++ b/tests/test_engine_integration.py @@ -0,0 +1,437 @@ +"""Integration tests for ocrmac engine (requires macOS and ocrmac installed).""" + +import platform +import xml.etree.ElementTree as ET +from pathlib import Path +from typing import Callable + +import pytest + +from ocrbridge.engines.ocrmac import OcrmacEngine, OcrmacParams, RecognitionLevel + +# Skip all tests in this module if not on macOS +pytestmark = pytest.mark.skipif( + platform.system() != "Darwin", reason="Integration tests require macOS" +) + + +@pytest.fixture +def engine() -> OcrmacEngine: + """Create engine instance.""" + return OcrmacEngine() + + +@pytest.mark.integration +class TestImageProcessing: + """Integration tests for image processing.""" + + def test_process_jpg_image( + self, + engine: OcrmacEngine, + sample_jpg: Path, + hocr_validator: Callable[[str], ET.Element], + ) -> None: + """Test processing a real JPG image.""" + result = engine.process(sample_jpg) + + # Validate HOCR structure + root = hocr_validator(result) + + # Check that we have some OCR results + words = root.findall(".//{http://www.w3.org/1999/xhtml}span[@class='ocrx_word']") + assert len(words) > 0, "Expected OCR to find some words" + + # Verify page dimensions are present + page = root.find(".//{http://www.w3.org/1999/xhtml}div[@class='ocr_page']") + assert page is not None + assert "bbox" in page.attrib.get("title", "") + + def test_process_second_jpg_image( + self, + engine: OcrmacEngine, + sample_jpg_2: Path, + hocr_validator: Callable[[str], ET.Element], + ) -> None: + """Test processing a second JPG image.""" + result = engine.process(sample_jpg_2) + + # Validate HOCR structure + root = hocr_validator(result) + + # Check that we have OCR results + words = root.findall(".//{http://www.w3.org/1999/xhtml}span[@class='ocrx_word']") + assert len(words) > 0, "Expected OCR to find some words" + + def test_process_with_fast_recognition( + self, + engine: OcrmacEngine, + sample_jpg: Path, + hocr_validator: Callable[[str], ET.Element], + ) -> None: + """Test processing with FAST recognition level.""" + params = OcrmacParams(recognition_level=RecognitionLevel.FAST) + result = engine.process(sample_jpg, params) + + # Should return valid HOCR + root = hocr_validator(result) + words = root.findall(".//{http://www.w3.org/1999/xhtml}span[@class='ocrx_word']") + assert len(words) > 0 + + def test_process_with_balanced_recognition( + self, + engine: OcrmacEngine, + sample_jpg: Path, + hocr_validator: Callable[[str], ET.Element], + ) -> None: + """Test processing with BALANCED recognition level (default).""" + params = OcrmacParams(recognition_level=RecognitionLevel.BALANCED) + result = engine.process(sample_jpg, params) + + # Should return valid HOCR + root = hocr_validator(result) + words = root.findall(".//{http://www.w3.org/1999/xhtml}span[@class='ocrx_word']") + assert len(words) > 0 + + def test_process_with_accurate_recognition( + self, + engine: OcrmacEngine, + sample_jpg: Path, + hocr_validator: Callable[[str], ET.Element], + ) -> None: + """Test processing with ACCURATE recognition level.""" + params = OcrmacParams(recognition_level=RecognitionLevel.ACCURATE) + result = engine.process(sample_jpg, params) + + # Should return valid HOCR + root = hocr_validator(result) + words = root.findall(".//{http://www.w3.org/1999/xhtml}span[@class='ocrx_word']") + assert len(words) > 0 + + def test_process_with_livetext_recognition( + self, + engine: OcrmacEngine, + sample_jpg: Path, + hocr_validator: Callable[[str], ET.Element], + livetext_available: bool, + ) -> None: + """Test processing with LIVETEXT recognition level (Sonoma 14.0+ only).""" + if not livetext_available: + pytest.skip("LiveText not available or not working on this system") + + params = OcrmacParams(recognition_level=RecognitionLevel.LIVETEXT) + result = engine.process(sample_jpg, params) + + # Should return valid HOCR + root = hocr_validator(result) + words = root.findall(".//{http://www.w3.org/1999/xhtml}span[@class='ocrx_word']") + assert len(words) > 0 + + def test_process_with_language_preference( + self, + engine: OcrmacEngine, + sample_jpg: Path, + hocr_validator: Callable[[str], ET.Element], + ) -> None: + """Test processing with language preference.""" + params = OcrmacParams(languages=["en-US"]) + result = engine.process(sample_jpg, params) + + # Should return valid HOCR + root = hocr_validator(result) + words = root.findall(".//{http://www.w3.org/1999/xhtml}span[@class='ocrx_word']") + assert len(words) > 0 + + def test_process_with_multiple_languages( + self, + engine: OcrmacEngine, + sample_jpg: Path, + hocr_validator: Callable[[str], ET.Element], + ) -> None: + """Test processing with multiple language preferences.""" + params = OcrmacParams(languages=["en-US", "de-DE", "fr-FR"]) + result = engine.process(sample_jpg, params) + + # Should return valid HOCR + root = hocr_validator(result) + words = root.findall(".//{http://www.w3.org/1999/xhtml}span[@class='ocrx_word']") + assert len(words) > 0 + + +@pytest.mark.integration +class TestPDFProcessing: + """Integration tests for PDF processing.""" + + def test_process_english_pdf( + self, + engine: OcrmacEngine, + sample_pdf_en: Path, + hocr_validator: Callable[[str], ET.Element], + ) -> None: + """Test processing an English PDF.""" + result = engine.process(sample_pdf_en) + + # Validate HOCR structure + root = hocr_validator(result) + + # Check that we have OCR results + words = root.findall(".//{http://www.w3.org/1999/xhtml}span[@class='ocrx_word']") + assert len(words) > 0, "Expected OCR to find words in PDF" + + # Check for page structure + pages = root.findall(".//{http://www.w3.org/1999/xhtml}div[@class='ocr_page']") + assert len(pages) > 0, "Expected at least one page" + + def test_process_german_pdf( + self, + engine: OcrmacEngine, + sample_pdf_de: Path, + hocr_validator: Callable[[str], ET.Element], + ) -> None: + """Test processing a German PDF.""" + params = OcrmacParams(languages=["de-DE"]) + result = engine.process(sample_pdf_de, params) + + # Validate HOCR structure + root = hocr_validator(result) + + # Check that we have OCR results + words = root.findall(".//{http://www.w3.org/1999/xhtml}span[@class='ocrx_word']") + assert len(words) > 0, "Expected OCR to find words in German PDF" + + def test_pdf_multipage_structure( + self, + engine: OcrmacEngine, + sample_pdf_en: Path, + hocr_validator: Callable[[str], ET.Element], + ) -> None: + """Test that multi-page PDFs have correct structure.""" + result = engine.process(sample_pdf_en) + + # Validate HOCR structure + root = hocr_validator(result) + + # Find all pages + pages = root.findall(".//{http://www.w3.org/1999/xhtml}div[@class='ocr_page']") + assert len(pages) >= 1, "Expected at least one page" + + # Each page should have bbox in title + for page in pages: + title = page.attrib.get("title", "") + assert "bbox" in title, f"Page missing bbox in title: {title}" + + def test_pdf_with_fast_recognition( + self, + engine: OcrmacEngine, + sample_pdf_en: Path, + hocr_validator: Callable[[str], ET.Element], + ) -> None: + """Test PDF processing with FAST recognition level.""" + params = OcrmacParams(recognition_level=RecognitionLevel.FAST) + result = engine.process(sample_pdf_en, params) + + # Should return valid HOCR + root = hocr_validator(result) + words = root.findall(".//{http://www.w3.org/1999/xhtml}span[@class='ocrx_word']") + assert len(words) > 0 + + def test_pdf_with_accurate_recognition( + self, + engine: OcrmacEngine, + sample_pdf_en: Path, + hocr_validator: Callable[[str], ET.Element], + ) -> None: + """Test PDF processing with ACCURATE recognition level.""" + params = OcrmacParams(recognition_level=RecognitionLevel.ACCURATE) + result = engine.process(sample_pdf_en, params) + + # Should return valid HOCR + root = hocr_validator(result) + words = root.findall(".//{http://www.w3.org/1999/xhtml}span[@class='ocrx_word']") + assert len(words) > 0 + + +@pytest.mark.integration +class TestHOCROutput: + """Integration tests for HOCR output validation.""" + + def test_hocr_has_xml_declaration(self, engine: OcrmacEngine, sample_jpg: Path) -> None: + """Test that HOCR output has XML declaration.""" + result = engine.process(sample_jpg) + assert result.startswith('') + + def test_hocr_has_doctype(self, engine: OcrmacEngine, sample_jpg: Path) -> None: + """Test that HOCR output has DOCTYPE.""" + result = engine.process(sample_jpg) + assert " None: + """Test that HOCR output has XHTML namespace.""" + result = engine.process(sample_jpg) + assert 'xmlns="http://www.w3.org/1999/xhtml"' in result + + def test_hocr_word_bboxes_are_valid(self, engine: OcrmacEngine, sample_jpg: Path) -> None: + """Test that all word bboxes are valid (x_min < x_max, y_min < y_max).""" + result = engine.process(sample_jpg) + root = ET.fromstring(result) + + words = root.findall(".//{http://www.w3.org/1999/xhtml}span[@class='ocrx_word']") + + for word in words: + title = word.attrib.get("title", "") + # Extract bbox + bbox_part = [p for p in title.split(";") if p.strip().startswith("bbox")] + assert len(bbox_part) > 0, f"No bbox found in word title: {title}" + + coords = bbox_part[0].strip()[5:].split() + x_min, y_min, x_max, y_max = map(int, coords) + + assert x_min < x_max, f"Invalid bbox: x_min ({x_min}) >= x_max ({x_max})" + assert y_min < y_max, f"Invalid bbox: y_min ({y_min}) >= y_max ({y_max})" + assert x_min >= 0, f"Invalid bbox: x_min ({x_min}) < 0" + assert y_min >= 0, f"Invalid bbox: y_min ({y_min}) < 0" + + def test_hocr_confidence_in_range(self, engine: OcrmacEngine, sample_jpg: Path) -> None: + """Test that all confidence values are in range 0-100.""" + result = engine.process(sample_jpg) + root = ET.fromstring(result) + + words = root.findall(".//{http://www.w3.org/1999/xhtml}span[@class='ocrx_word']") + + for word in words: + title = word.attrib.get("title", "") + # Extract confidence + conf_part = [p for p in title.split(";") if p.strip().startswith("x_wconf")] + + if conf_part: + conf_str = conf_part[0].strip()[8:] + confidence = int(conf_str) + assert 0 <= confidence <= 100, f"Confidence out of range: {confidence}" + + def test_hocr_words_have_text(self, engine: OcrmacEngine, sample_jpg: Path) -> None: + """Test that all word elements have non-empty text.""" + result = engine.process(sample_jpg) + root = ET.fromstring(result) + + words = root.findall(".//{http://www.w3.org/1999/xhtml}span[@class='ocrx_word']") + + for word in words: + text = word.text + assert text is not None, "Word element has None text" + assert len(text.strip()) > 0, "Word element has empty text" + + def test_hocr_page_bbox_matches_image_size( + self, engine: OcrmacEngine, sample_jpg: Path + ) -> None: + """Test that page bbox matches actual image dimensions.""" + from PIL import Image + + # Get actual image dimensions + with Image.open(sample_jpg) as img: + img_width, img_height = img.size + + # Process and check HOCR + result = engine.process(sample_jpg) + root = ET.fromstring(result) + + page = root.find(".//{http://www.w3.org/1999/xhtml}div[@class='ocr_page']") + assert page is not None + + title = page.attrib.get("title", "") + bbox_part = [p for p in title.split(";") if p.strip().startswith("bbox")] + assert len(bbox_part) > 0 + + coords = bbox_part[0].strip()[5:].split() + x_min, y_min, x_max, y_max = map(int, coords) + + assert x_min == 0, "Page bbox x_min should be 0" + assert y_min == 0, "Page bbox y_min should be 0" + assert x_max == img_width, f"Page bbox x_max ({x_max}) != image width ({img_width})" + assert y_max == img_height, f"Page bbox y_max ({y_max}) != image height ({img_height})" + + +@pytest.mark.integration +class TestEndToEnd: + """End-to-end integration tests.""" + + def test_complete_workflow_jpg( + self, + engine: OcrmacEngine, + sample_jpg: Path, + hocr_validator: Callable[[str], ET.Element], + ) -> None: + """Test complete workflow from JPG to HOCR.""" + # Process with custom params + params = OcrmacParams(languages=["en-US"], recognition_level=RecognitionLevel.BALANCED) + result = engine.process(sample_jpg, params) + + # Validate structure + root = hocr_validator(result) + + # Verify we got results + words = root.findall(".//{http://www.w3.org/1999/xhtml}span[@class='ocrx_word']") + assert len(words) > 0 + + # Verify XML is well-formed + assert "" in result + + def test_complete_workflow_pdf( + self, + engine: OcrmacEngine, + sample_pdf_en: Path, + hocr_validator: Callable[[str], ET.Element], + ) -> None: + """Test complete workflow from PDF to HOCR.""" + # Process with custom params + params = OcrmacParams(languages=["en-US"], recognition_level=RecognitionLevel.FAST) + result = engine.process(sample_pdf_en, params) + + # Validate structure + root = hocr_validator(result) + + # Verify we got results + words = root.findall(".//{http://www.w3.org/1999/xhtml}span[@class='ocrx_word']") + assert len(words) > 0 + + # Verify pages + pages = root.findall(".//{http://www.w3.org/1999/xhtml}div[@class='ocr_page']") + assert len(pages) >= 1 + + def test_default_params_workflow( + self, + engine: OcrmacEngine, + sample_jpg: Path, + hocr_validator: Callable[[str], ET.Element], + ) -> None: + """Test workflow with default parameters.""" + # Process with no params (should use defaults) + result = engine.process(sample_jpg) + + # Should still work + root = hocr_validator(result) + words = root.findall(".//{http://www.w3.org/1999/xhtml}span[@class='ocrx_word']") + assert len(words) > 0 + + def test_multiple_files_workflow( + self, + engine: OcrmacEngine, + sample_jpg: Path, + sample_jpg_2: Path, + hocr_validator: Callable[[str], ET.Element], + ) -> None: + """Test processing multiple files in sequence.""" + # Process first file + result1 = engine.process(sample_jpg) + root1 = hocr_validator(result1) + words1 = root1.findall(".//{http://www.w3.org/1999/xhtml}span[@class='ocrx_word']") + assert len(words1) > 0 + + # Process second file + result2 = engine.process(sample_jpg_2) + root2 = hocr_validator(result2) + words2 = root2.findall(".//{http://www.w3.org/1999/xhtml}span[@class='ocrx_word']") + assert len(words2) > 0 + + # Results should be different + assert result1 != result2 diff --git a/tests/test_engine_unit.py b/tests/test_engine_unit.py new file mode 100644 index 0000000..588d7f0 --- /dev/null +++ b/tests/test_engine_unit.py @@ -0,0 +1,507 @@ +"""Unit tests for ocrmac engine (mocked, runs on any platform).""" + +import tempfile +import xml.etree.ElementTree as ET +from pathlib import Path +from typing import Any, Callable +from unittest.mock import Mock, patch + +import pytest +from ocrbridge.core import OCRProcessingError, UnsupportedFormatError +from PIL import Image + +from ocrbridge.engines.ocrmac import OcrmacEngine, OcrmacParams, RecognitionLevel + + +class TestEngineProperties: + """Tests for engine properties.""" + + def test_engine_name(self) -> None: + """Test engine name property.""" + engine = OcrmacEngine() + assert engine.name == "ocrmac" + + def test_supported_formats(self) -> None: + """Test supported formats property.""" + engine = OcrmacEngine() + formats = engine.supported_formats + + assert ".jpg" in formats + assert ".jpeg" in formats + assert ".png" in formats + assert ".pdf" in formats + assert ".tiff" in formats + assert ".tif" in formats + assert len(formats) == 6 + + def test_supported_formats_immutable(self) -> None: + """Test that supported formats is a set.""" + engine = OcrmacEngine() + assert isinstance(engine.supported_formats, set) + + +class TestPlatformValidation: + """Tests for platform validation.""" + + @patch("platform.system") + def test_validate_platform_on_darwin(self, mock_system: Mock) -> None: + """Test platform validation succeeds on macOS.""" + mock_system.return_value = "Darwin" + engine = OcrmacEngine() + engine._validate_platform() # Should not raise + + @patch("platform.system") + def test_validate_platform_on_windows(self, mock_system: Mock) -> None: + """Test platform validation fails on Windows.""" + mock_system.return_value = "Windows" + engine = OcrmacEngine() + + with pytest.raises(OCRProcessingError) as exc_info: + engine._validate_platform() + + assert "only available on macOS" in str(exc_info.value) + assert "Windows" in str(exc_info.value) + + @patch("platform.system") + def test_validate_platform_on_linux(self, mock_system: Mock) -> None: + """Test platform validation fails on Linux.""" + mock_system.return_value = "Linux" + engine = OcrmacEngine() + + with pytest.raises(OCRProcessingError) as exc_info: + engine._validate_platform() + + assert "only available on macOS" in str(exc_info.value) + assert "Linux" in str(exc_info.value) + + +class TestLiveTextValidation: + """Tests for LiveText version validation.""" + + @patch("platform.mac_ver") + def test_livetext_on_sonoma_14_0(self, mock_mac_ver: Mock) -> None: + """Test LiveText validation succeeds on macOS Sonoma 14.0.""" + mock_mac_ver.return_value = ("14.0", ("", "", ""), "") + engine = OcrmacEngine() + engine._validate_livetext_requirement(RecognitionLevel.LIVETEXT) # Should not raise + + @patch("platform.mac_ver") + def test_livetext_on_sonoma_14_5(self, mock_mac_ver: Mock) -> None: + """Test LiveText validation succeeds on macOS Sonoma 14.5.""" + mock_mac_ver.return_value = ("14.5.1", ("", "", ""), "") + engine = OcrmacEngine() + engine._validate_livetext_requirement(RecognitionLevel.LIVETEXT) # Should not raise + + @patch("platform.mac_ver") + def test_livetext_on_sequoia_15_0(self, mock_mac_ver: Mock) -> None: + """Test LiveText validation succeeds on macOS Sequoia 15.0+.""" + mock_mac_ver.return_value = ("15.0", ("", "", ""), "") + engine = OcrmacEngine() + engine._validate_livetext_requirement(RecognitionLevel.LIVETEXT) # Should not raise + + @patch("platform.mac_ver") + def test_livetext_on_ventura_13(self, mock_mac_ver: Mock) -> None: + """Test LiveText validation fails on macOS Ventura 13.x.""" + mock_mac_ver.return_value = ("13.5", ("", "", ""), "") + engine = OcrmacEngine() + + with pytest.raises(OCRProcessingError) as exc_info: + engine._validate_livetext_requirement(RecognitionLevel.LIVETEXT) + + assert "requires macOS Sonoma (14.0) or later" in str(exc_info.value) + assert "13.5" in str(exc_info.value) + + @patch("platform.mac_ver") + def test_livetext_on_monterey_12(self, mock_mac_ver: Mock) -> None: + """Test LiveText validation fails on macOS Monterey 12.x.""" + mock_mac_ver.return_value = ("12.6", ("", "", ""), "") + engine = OcrmacEngine() + + with pytest.raises(OCRProcessingError) as exc_info: + engine._validate_livetext_requirement(RecognitionLevel.LIVETEXT) + + assert "requires macOS Sonoma (14.0) or later" in str(exc_info.value) + + @patch("platform.mac_ver") + def test_livetext_no_version_available(self, mock_mac_ver: Mock) -> None: + """Test LiveText validation fails when version cannot be determined.""" + mock_mac_ver.return_value = ("", ("", "", ""), "") + engine = OcrmacEngine() + + with pytest.raises(OCRProcessingError) as exc_info: + engine._validate_livetext_requirement(RecognitionLevel.LIVETEXT) + + assert "Unable to determine macOS version" in str(exc_info.value) + + @patch("platform.mac_ver") + def test_livetext_invalid_version_format(self, mock_mac_ver: Mock) -> None: + """Test LiveText validation fails with invalid version format.""" + mock_mac_ver.return_value = ("invalid", ("", "", ""), "") + engine = OcrmacEngine() + + with pytest.raises(OCRProcessingError) as exc_info: + engine._validate_livetext_requirement(RecognitionLevel.LIVETEXT) + + assert "Invalid macOS version format" in str(exc_info.value) + + def test_non_livetext_levels_skip_validation(self) -> None: + """Test that non-LiveText recognition levels skip validation.""" + engine = OcrmacEngine() + # These should not raise even if platform.mac_ver() returns bad data + engine._validate_livetext_requirement(RecognitionLevel.FAST) + engine._validate_livetext_requirement(RecognitionLevel.BALANCED) + engine._validate_livetext_requirement(RecognitionLevel.ACCURATE) + + +class TestFileValidation: + """Tests for file validation.""" + + @patch("platform.system", return_value="Darwin") + def test_file_not_found(self, mock_system: Mock) -> None: + """Test that missing file raises error.""" + engine = OcrmacEngine() + non_existent = Path("/tmp/does_not_exist_12345.jpg") + + with pytest.raises(OCRProcessingError) as exc_info: + engine.process(non_existent) + + assert "File not found" in str(exc_info.value) + + @patch("platform.system", return_value="Darwin") + def test_unsupported_format(self, mock_system: Mock) -> None: + """Test that unsupported format raises error.""" + engine = OcrmacEngine() + + # Create a temporary file with unsupported extension + with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as tmp: + tmp_path = Path(tmp.name) + + try: + with pytest.raises(UnsupportedFormatError) as exc_info: + engine.process(tmp_path) + + assert "Unsupported file format: .txt" in str(exc_info.value) + assert ".jpg" in str(exc_info.value) + assert ".pdf" in str(exc_info.value) + finally: + tmp_path.unlink(missing_ok=True) + + @patch("platform.system", return_value="Darwin") + def test_supported_formats_accepted(self, mock_system: Mock) -> None: + """Test that all supported formats are accepted during validation.""" + engine = OcrmacEngine() + supported = [".jpg", ".jpeg", ".png", ".pdf", ".tiff", ".tif"] + + for ext in supported: + with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as tmp: + tmp_path = Path(tmp.name) + + try: + # File exists and format is valid, but will fail later in processing + # (that's ok, we're just testing format validation here) + with patch.object(engine, "_process_image"), patch.object(engine, "_process_pdf"): + try: + engine.process(tmp_path) + except Exception: + pass # Expected since file is empty + finally: + tmp_path.unlink(missing_ok=True) + + +class TestHOCRConversion: + """Tests for HOCR conversion and coordinate transformation.""" + + def test_convert_to_hocr_basic( + self, + mock_ocrmac_annotations: list[tuple[str, float, tuple[float, float, float, float]]], + hocr_validator: Callable[[str], ET.Element], + ) -> None: + """Test basic HOCR conversion.""" + engine = OcrmacEngine() + params = OcrmacParams() + + hocr = engine._convert_to_hocr(mock_ocrmac_annotations, 1000, 800, params) + + # Validate structure + root = hocr_validator(hocr) + + # Check body contains page + body = root.find("{http://www.w3.org/1999/xhtml}body") + assert body is not None + + page = body.find(".//{http://www.w3.org/1999/xhtml}div[@class='ocr_page']") + assert page is not None + assert page.attrib.get("id") == "page_1" + assert "bbox 0 0 1000 800" in page.attrib.get("title", "") + + def test_convert_to_hocr_words( + self, + mock_ocrmac_annotations: list[tuple[str, float, tuple[float, float, float, float]]], + bbox_parser: Callable[[str], dict[str, Any]], + ) -> None: + """Test HOCR word elements.""" + engine = OcrmacEngine() + params = OcrmacParams() + + hocr = engine._convert_to_hocr(mock_ocrmac_annotations, 1000, 800, params) + root = ET.fromstring(hocr) + + # Find all word elements + words = root.findall(".//{http://www.w3.org/1999/xhtml}span[@class='ocrx_word']") + assert len(words) == 3 + + # Check first word + assert words[0].text == "Hello" + assert words[0].attrib.get("id") == "word_1_1" + + # Check second word + assert words[1].text == "World" + assert words[1].attrib.get("id") == "word_1_2" + + # Check third word + assert words[2].text == "Test" + assert words[2].attrib.get("id") == "word_1_3" + + def test_coordinate_transformation(self, bbox_parser: Callable[[str], dict[str, Any]]) -> None: + """Test coordinate transformation from ocrmac to HOCR format. + + ocrmac: relative coords (0.0-1.0), bottom-left origin + HOCR: absolute pixels, top-left origin + """ + engine = OcrmacEngine() + params = OcrmacParams() + + # Test annotation at bottom-left corner + # ocrmac: x=0.1, y=0.1 (from bottom), width=0.2, height=0.1 + # For 1000x800 image: + # - x: 0.1 * 1000 = 100 + # - width: 0.2 * 1000 = 200, so x_max = 300 + # - y_min (from top): (1.0 - 0.1 - 0.1) * 800 = 0.8 * 800 = 640 + # - y_max (from top): (1.0 - 0.1) * 800 = 0.9 * 800 = 720 + annotations = [("Bottom", 0.95, (0.1, 0.1, 0.2, 0.1))] + + hocr = engine._convert_to_hocr(annotations, 1000, 800, params) + root = ET.fromstring(hocr) + + word = root.find(".//{http://www.w3.org/1999/xhtml}span[@class='ocrx_word']") + assert word is not None + + title = word.attrib.get("title", "") + bbox = bbox_parser(title) + + assert bbox["bbox"]["x_min"] == 100 + assert bbox["bbox"]["x_max"] == 300 + assert bbox["bbox"]["y_min"] == 640 + assert bbox["bbox"]["y_max"] == 720 + + def test_confidence_conversion(self, bbox_parser: Callable[[str], dict[str, Any]]) -> None: + """Test confidence conversion from 0-1 to 0-100.""" + engine = OcrmacEngine() + params = OcrmacParams() + + annotations = [ + ("High", 0.95, (0.1, 0.1, 0.2, 0.1)), + ("Medium", 0.75, (0.3, 0.1, 0.2, 0.1)), + ("Low", 0.50, (0.5, 0.1, 0.2, 0.1)), + ] + + hocr = engine._convert_to_hocr(annotations, 1000, 800, params) + root = ET.fromstring(hocr) + + words = root.findall(".//{http://www.w3.org/1999/xhtml}span[@class='ocrx_word']") + + title1 = words[0].attrib.get("title", "") + assert "x_wconf 95" in title1 + + title2 = words[1].attrib.get("title", "") + assert "x_wconf 75" in title2 + + title3 = words[2].attrib.get("title", "") + assert "x_wconf 50" in title3 + + def test_empty_annotations(self, hocr_validator: Callable[[str], ET.Element]) -> None: + """Test HOCR conversion with empty annotations.""" + engine = OcrmacEngine() + params = OcrmacParams() + + hocr = engine._convert_to_hocr([], 1000, 800, params) + + # Should still have valid structure + root = hocr_validator(hocr) + body = root.find("{http://www.w3.org/1999/xhtml}body") + assert body is not None + + # No word elements + words = root.findall(".//{http://www.w3.org/1999/xhtml}span[@class='ocrx_word']") + assert len(words) == 0 + + +class TestHOCRPageMerging: + """Tests for HOCR page merging.""" + + def test_merge_single_page(self, hocr_validator: Callable[[str], ET.Element]) -> None: + """Test merging single page returns original.""" + engine = OcrmacEngine() + + page_hocr = """ + + + + + + +
Page 1
+""" + + result = engine._merge_hocr_pages([page_hocr]) + assert result == page_hocr + + def test_merge_multiple_pages(self, hocr_validator: Callable[[str], ET.Element]) -> None: + """Test merging multiple pages.""" + engine = OcrmacEngine() + + page1 = """ + + + + + + +
Page 1
+""" + + page2 = """ + + + + + + +
Page 2
+""" + + result = engine._merge_hocr_pages([page1, page2]) + + # Validate structure + root = hocr_validator(result) + body = root.find("{http://www.w3.org/1999/xhtml}body") + assert body is not None + + # Check both pages are present + body_text = ET.tostring(body, encoding="unicode") + assert "Page 1" in body_text + assert "Page 2" in body_text + + def test_merge_empty_pages(self, hocr_validator: Callable[[str], ET.Element]) -> None: + """Test merging with empty pages.""" + engine = OcrmacEngine() + + page1 = """ + + + + + + + +""" + + page2 = """ + + + + + + + +""" + + result = engine._merge_hocr_pages([page1, page2]) + + # Should have valid structure + hocr_validator(result) + + +class TestProcessMethod: + """Tests for main process method.""" + + @patch("platform.system", return_value="Windows") + def test_process_fails_on_non_darwin(self, mock_system: Mock) -> None: + """Test that process fails on non-Darwin platforms.""" + engine = OcrmacEngine() + + with tempfile.NamedTemporaryFile(suffix=".jpg") as tmp: + tmp_path = Path(tmp.name) + + with pytest.raises(OCRProcessingError) as exc_info: + engine.process(tmp_path) + + assert "only available on macOS" in str(exc_info.value) + + @patch("platform.system", return_value="Darwin") + def test_process_uses_default_params(self, mock_system: Mock) -> None: + """Test that process uses default params when none provided.""" + engine = OcrmacEngine() + + # Create a temporary image file + with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp: + tmp_path = Path(tmp.name) + # Create a simple image + img = Image.new("RGB", (100, 100), color="white") + img.save(tmp_path) + + try: + # Mock _process_image to avoid actual OCR + with patch.object(engine, "_process_image", return_value=""): + engine.process(tmp_path) + # If we get here without exception, default params were used + finally: + tmp_path.unlink(missing_ok=True) + + @patch("platform.system", return_value="Darwin") + @patch("platform.mac_ver", return_value=("13.0", ("", "", ""), "")) + def test_process_validates_livetext(self, mock_mac_ver: Mock, mock_system: Mock) -> None: + """Test that process validates LiveText requirements.""" + engine = OcrmacEngine() + params = OcrmacParams(recognition_level=RecognitionLevel.LIVETEXT) + + with tempfile.NamedTemporaryFile(suffix=".jpg") as tmp: + tmp_path = Path(tmp.name) + + with pytest.raises(OCRProcessingError) as exc_info: + engine.process(tmp_path, params) + + assert "LiveText requires macOS Sonoma" in str(exc_info.value) + + @patch("platform.system", return_value="Darwin") + def test_process_routes_to_pdf_handler(self, mock_system: Mock) -> None: + """Test that PDF files are routed to _process_pdf.""" + engine = OcrmacEngine() + + with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp: + tmp_path = Path(tmp.name) + + try: + with patch.object(engine, "_process_pdf", return_value="") as mock_pdf: + engine.process(tmp_path) + mock_pdf.assert_called_once() + finally: + tmp_path.unlink(missing_ok=True) + + @patch("platform.system", return_value="Darwin") + def test_process_routes_to_image_handler(self, mock_system: Mock) -> None: + """Test that image files are routed to _process_image.""" + engine = OcrmacEngine() + + with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp: + tmp_path = Path(tmp.name) + # Create a simple image + img = Image.new("RGB", (100, 100), color="white") + img.save(tmp_path) + + try: + with patch.object(engine, "_process_image", return_value="") as mock_image: + engine.process(tmp_path) + mock_image.assert_called_once() + finally: + tmp_path.unlink(missing_ok=True) diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..b77cbe2 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,179 @@ +"""Unit tests for ocrmac models.""" + +import pytest +from pydantic import ValidationError + +from ocrbridge.engines.ocrmac.models import OcrmacParams, RecognitionLevel + + +class TestRecognitionLevel: + """Tests for RecognitionLevel enum.""" + + def test_recognition_level_values(self) -> None: + """Test that all recognition levels have correct values.""" + assert RecognitionLevel.FAST.value == "fast" + assert RecognitionLevel.BALANCED.value == "balanced" + assert RecognitionLevel.ACCURATE.value == "accurate" + assert RecognitionLevel.LIVETEXT.value == "livetext" + + def test_recognition_level_is_string_enum(self) -> None: + """Test that RecognitionLevel is a string enum.""" + assert isinstance(RecognitionLevel.FAST, str) + assert isinstance(RecognitionLevel.BALANCED, str) + + def test_recognition_level_count(self) -> None: + """Test that we have exactly 4 recognition levels.""" + assert len(RecognitionLevel) == 4 + + +class TestOcrmacParams: + """Tests for OcrmacParams model.""" + + def test_default_values(self) -> None: + """Test that default values are set correctly.""" + params = OcrmacParams() + assert params.languages is None + assert params.recognition_level == RecognitionLevel.BALANCED + + def test_explicit_values(self) -> None: + """Test setting explicit values.""" + params = OcrmacParams( + languages=["en-US", "fr-FR"], recognition_level=RecognitionLevel.ACCURATE + ) + assert params.languages == ["en-US", "fr-FR"] + assert params.recognition_level == RecognitionLevel.ACCURATE + + def test_recognition_level_from_string(self) -> None: + """Test that recognition level can be set from string value.""" + params = OcrmacParams(recognition_level="fast") # type: ignore[reportArgumentType] + assert params.recognition_level == RecognitionLevel.FAST + + # Language validation tests + + def test_valid_language_codes(self) -> None: + """Test valid IETF BCP 47 language codes.""" + valid_codes = [ + ["en"], + ["en-US"], + ["fr-FR"], + ["zh-Hans"], + ["zh-Hans-CN"], + ["de-DE"], + ["ja-JP"], + ["pt-BR"], + ] + + for codes in valid_codes: + params = OcrmacParams(languages=codes) + assert params.languages == codes + + def test_multiple_languages(self) -> None: + """Test setting multiple languages.""" + params = OcrmacParams(languages=["en-US", "fr-FR", "de-DE"]) + assert params.languages == ["en-US", "fr-FR", "de-DE"] + + def test_max_five_languages(self) -> None: + """Test that exactly 5 languages is allowed.""" + params = OcrmacParams(languages=["en-US", "fr-FR", "de-DE", "es-ES", "it-IT"]) + assert len(params.languages) == 5 # type: ignore[reportOptionalMemberAccess] + + def test_too_many_languages(self) -> None: + """Test that more than 5 languages raises error.""" + with pytest.raises(ValidationError) as exc_info: + OcrmacParams(languages=["en-US", "fr-FR", "de-DE", "es-ES", "it-IT", "pt-BR"]) + + errors = exc_info.value.errors() + # Pydantic validates max_length constraint + assert any(e["type"] == "too_long" for e in errors) + + def test_invalid_language_code_format(self) -> None: + """Test that invalid language code format raises error.""" + invalid_codes = [ + ["english"], # Not BCP 47 + ["en_US"], # Underscore instead of hyphen + ["e"], # Too short + ["engl"], # Too long for language code + ["en-usa"], # Region too long + ["123"], # Numbers + [""], # Empty string + ] + + for codes in invalid_codes: + with pytest.raises(ValidationError) as exc_info: + OcrmacParams(languages=codes) + + # Should raise a value_error from our custom validator + error_str = str(exc_info.value) + assert "Invalid IETF BCP 47 language code" in error_str or "value_error" in error_str + + def test_empty_language_list(self) -> None: + """Test that empty language list raises error.""" + with pytest.raises(ValidationError): + OcrmacParams(languages=[]) + + def test_none_languages(self) -> None: + """Test that None is valid for languages.""" + params = OcrmacParams(languages=None) + assert params.languages is None + + def test_case_insensitive_validation(self) -> None: + """Test that language validation is case-insensitive.""" + # BCP 47 is case-insensitive but has conventions + params = OcrmacParams(languages=["EN-us"]) # Mixed case + assert params.languages == ["EN-us"] # Preserves input case + + # Recognition level tests + + def test_all_recognition_levels(self) -> None: + """Test setting all recognition levels.""" + for level in RecognitionLevel: + params = OcrmacParams(recognition_level=level) + assert params.recognition_level == level + + def test_invalid_recognition_level(self) -> None: + """Test that invalid recognition level raises error.""" + with pytest.raises(ValidationError): + OcrmacParams(recognition_level="invalid") # type: ignore[reportArgumentType] + + # Serialization tests + + def test_model_dump(self) -> None: + """Test model serialization.""" + params = OcrmacParams(languages=["en-US"], recognition_level=RecognitionLevel.FAST) + dumped = params.model_dump() + + assert dumped["languages"] == ["en-US"] + assert dumped["recognition_level"] == "fast" + + def test_model_dump_json(self) -> None: + """Test JSON serialization.""" + params = OcrmacParams(languages=["en-US"], recognition_level=RecognitionLevel.ACCURATE) + json_str = params.model_dump_json() + + assert "en-US" in json_str + assert "accurate" in json_str + + def test_model_validate(self) -> None: + """Test model validation from dict.""" + data = {"languages": ["fr-FR"], "recognition_level": "balanced"} + params = OcrmacParams.model_validate(data) + + assert params.languages == ["fr-FR"] + assert params.recognition_level == RecognitionLevel.BALANCED + + # Edge cases + + def test_languages_with_script_and_region(self) -> None: + """Test language codes with both script and region.""" + params = OcrmacParams(languages=["zh-Hans-CN", "zh-Hant-TW"]) + assert params.languages == ["zh-Hans-CN", "zh-Hant-TW"] + + def test_languages_with_only_script(self) -> None: + """Test language codes with only script.""" + params = OcrmacParams(languages=["zh-Hans", "zh-Hant"]) + assert params.languages == ["zh-Hans", "zh-Hant"] + + def test_three_letter_language_codes(self) -> None: + """Test three-letter ISO 639-2 language codes.""" + params = OcrmacParams(languages=["eng", "fra", "deu"]) + assert params.languages == ["eng", "fra", "deu"] diff --git a/tests/test_module.py b/tests/test_module.py new file mode 100644 index 0000000..dcd5711 --- /dev/null +++ b/tests/test_module.py @@ -0,0 +1,253 @@ +"""Module-level tests for ocrbridge.engines.ocrmac.""" + +import importlib.metadata +import sys + +import pytest + + +class TestModuleImports: + """Tests for module imports.""" + + def test_import_engine(self) -> None: + """Test importing OcrmacEngine.""" + from ocrbridge.engines.ocrmac import OcrmacEngine + + assert OcrmacEngine is not None + assert OcrmacEngine.__name__ == "OcrmacEngine" + + def test_import_params(self) -> None: + """Test importing OcrmacParams.""" + from ocrbridge.engines.ocrmac import OcrmacParams + + assert OcrmacParams is not None + assert OcrmacParams.__name__ == "OcrmacParams" + + def test_import_recognition_level(self) -> None: + """Test importing RecognitionLevel.""" + from ocrbridge.engines.ocrmac import RecognitionLevel + + assert RecognitionLevel is not None + assert RecognitionLevel.__name__ == "RecognitionLevel" + + def test_import_all_from_package(self) -> None: + """Test importing all public APIs.""" + from ocrbridge.engines import ocrmac + + assert hasattr(ocrmac, "OcrmacEngine") + assert hasattr(ocrmac, "OcrmacParams") + assert hasattr(ocrmac, "RecognitionLevel") + + def test_import_submodules(self) -> None: + """Test importing submodules directly.""" + from ocrbridge.engines.ocrmac import engine, models + + assert engine is not None + assert models is not None + + +class TestModuleExports: + """Tests for __all__ exports.""" + + def test_all_exports(self) -> None: + """Test that __all__ contains expected exports.""" + from ocrbridge.engines import ocrmac + + assert hasattr(ocrmac, "__all__") + expected = {"OcrmacEngine", "OcrmacParams", "RecognitionLevel"} + assert set(ocrmac.__all__) == expected + + def test_all_exports_importable(self) -> None: + """Test that all items in __all__ are actually importable.""" + from ocrbridge.engines import ocrmac + + for name in ocrmac.__all__: + assert hasattr(ocrmac, name), f"{name} in __all__ but not exported" + + def test_star_import(self) -> None: + """Test that star import only imports __all__ items.""" + # Import in a clean namespace + import ocrbridge.engines.ocrmac as ocrmac_module + + # Get __all__ exports + all_exports = ocrmac_module.__all__ + + # Verify each export exists + for name in all_exports: + assert hasattr(ocrmac_module, name) + + +class TestModuleVersion: + """Tests for module version.""" + + def test_version_string_exists(self) -> None: + """Test that __version__ is defined.""" + from ocrbridge.engines import ocrmac + + assert hasattr(ocrmac, "__version__") + assert isinstance(ocrmac.__version__, str) + + def test_version_format(self) -> None: + """Test that version follows semantic versioning.""" + from ocrbridge.engines import ocrmac + + version = ocrmac.__version__ + parts = version.split(".") + + # Should have at least major.minor.patch + assert len(parts) >= 3, f"Invalid version format: {version}" + + # Major, minor, patch should be numeric + try: + major = int(parts[0]) + minor = int(parts[1]) + # Patch might have suffix like "0-beta" + patch = int(parts[2].split("-")[0]) + assert major >= 0 and minor >= 0 and patch >= 0 + except ValueError as e: + pytest.fail(f"Version parts not numeric: {version} - {e}") + + def test_version_matches_pyproject(self) -> None: + """Test that __version__ matches version in pyproject.toml.""" + from pathlib import Path + + from ocrbridge.engines import ocrmac + + # Read pyproject.toml + pyproject_path = Path(__file__).parent.parent / "pyproject.toml" + with open(pyproject_path, "rb") as f: + if sys.version_info >= (3, 11): + import tomllib + + pyproject = tomllib.load(f) + else: + import tomli # type: ignore[reportMissingImports] + + pyproject = tomli.load(f) # type: ignore[reportUnknownMemberType] + + pyproject_version = pyproject["project"]["version"] # type: ignore[reportUnknownVariableType] + assert ocrmac.__version__ == pyproject_version + + +class TestEntryPoints: + """Tests for entry points.""" + + def test_entry_point_registered(self) -> None: + """Test that ocrbridge.engines entry point is registered.""" + try: + entry_points = importlib.metadata.entry_points() + + # Handle both old and new API + if hasattr(entry_points, "select"): + # Python 3.10+ (new API) + engine_eps = entry_points.select(group="ocrbridge.engines") + else: + # Python 3.9 (old API) + engine_eps = entry_points.get("ocrbridge.engines", []) # type: ignore[reportAttributeAccessIssue] + + # Find ocrmac entry point + ocrmac_ep = None + for ep in engine_eps: + if ep.name == "ocrmac": + ocrmac_ep = ep + break + + assert ocrmac_ep is not None, "ocrmac entry point not found" + assert "ocrbridge.engines.ocrmac" in ocrmac_ep.value + assert "OcrmacEngine" in ocrmac_ep.value + + except importlib.metadata.PackageNotFoundError: + pytest.skip("Package not installed, cannot test entry points") + + def test_entry_point_loadable(self) -> None: + """Test that entry point can be loaded.""" + try: + entry_points = importlib.metadata.entry_points() + + # Handle both old and new API + if hasattr(entry_points, "select"): + engine_eps = entry_points.select(group="ocrbridge.engines") + else: + engine_eps = entry_points.get("ocrbridge.engines", []) # type: ignore[reportAttributeAccessIssue] + + # Find and load ocrmac entry point + for ep in engine_eps: + if ep.name == "ocrmac": + engine_class = ep.load() + assert engine_class is not None + assert engine_class.__name__ == "OcrmacEngine" + return + + pytest.fail("ocrmac entry point not found") + + except importlib.metadata.PackageNotFoundError: + pytest.skip("Package not installed, cannot test entry points") + + +class TestModuleDocstrings: + """Tests for module docstrings.""" + + def test_module_has_docstring(self) -> None: + """Test that module has a docstring.""" + from ocrbridge.engines import ocrmac + + assert ocrmac.__doc__ is not None + assert len(ocrmac.__doc__.strip()) > 0 + + def test_engine_class_has_docstring(self) -> None: + """Test that OcrmacEngine has a docstring.""" + from ocrbridge.engines.ocrmac import OcrmacEngine + + assert OcrmacEngine.__doc__ is not None + assert len(OcrmacEngine.__doc__.strip()) > 0 + assert "ocrmac" in OcrmacEngine.__doc__.lower() + + def test_params_class_has_docstring(self) -> None: + """Test that OcrmacParams has a docstring.""" + from ocrbridge.engines.ocrmac import OcrmacParams + + assert OcrmacParams.__doc__ is not None + assert len(OcrmacParams.__doc__.strip()) > 0 + + def test_recognition_level_has_docstring(self) -> None: + """Test that RecognitionLevel has a docstring.""" + from ocrbridge.engines.ocrmac import RecognitionLevel + + assert RecognitionLevel.__doc__ is not None + assert len(RecognitionLevel.__doc__.strip()) > 0 + + +class TestModuleStructure: + """Tests for module structure.""" + + def test_engine_module_exists(self) -> None: + """Test that engine.py module exists.""" + from ocrbridge.engines.ocrmac import engine + + assert engine is not None + + def test_models_module_exists(self) -> None: + """Test that models.py module exists.""" + from ocrbridge.engines.ocrmac import models + + assert models is not None + + def test_engine_in_engine_module(self) -> None: + """Test that OcrmacEngine is in engine module.""" + from ocrbridge.engines.ocrmac.engine import OcrmacEngine + + assert OcrmacEngine is not None + + def test_models_in_models_module(self) -> None: + """Test that models are in models module.""" + from ocrbridge.engines.ocrmac.models import OcrmacParams, RecognitionLevel + + assert OcrmacParams is not None + assert RecognitionLevel is not None + + def test_no_private_exports_in_all(self) -> None: + """Test that __all__ doesn't contain private names.""" + from ocrbridge.engines import ocrmac + + for name in ocrmac.__all__: + assert not name.startswith("_"), f"Private name in __all__: {name}"