commit 9f97f3abbe6af5e182ddef1c5b1f017013f22044 Author: wehub-resource-sync Date: Mon Jul 13 12:23:44 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..9f4464c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,76 @@ +name: Bug Report +description: Report a bug (crashes, errors, unexpected behavior) +title: "[Bug] " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to report a bug! + + **Note:** If this is a parsing quality issue (incorrect text, bad formatting), please use the "Parsing Issue" template instead. + + - type: textarea + id: description + attributes: + label: Description + description: A clear description of the bug + validations: + required: true + + - type: textarea + id: reproduce + attributes: + label: Steps to Reproduce + description: How can we reproduce this issue? + placeholder: | + 1. Run `lit parse ...` + 2. See error + validations: + required: true + + - type: textarea + id: error + attributes: + label: Error Message + description: The full error message or stack trace + render: text + validations: + required: false + + - type: input + id: version + attributes: + label: LiteParse Version + description: Run `lit --version` to get this + placeholder: "0.1.0" + validations: + required: true + + - type: dropdown + id: os + attributes: + label: Operating System + options: + - macOS (Apple Silicon) + - macOS (Intel) + - Linux + - Windows + - Other + validations: + required: true + + - type: input + id: node + attributes: + label: Node.js Version + description: Run `node --version` to get this + placeholder: "v20.0.0" + validations: + required: true + + - type: textarea + id: additional + attributes: + label: Additional Context + description: Any other relevant information diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..d896c00 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Documentation + url: https://github.com/run-llama/liteparse#readme + about: Check the README for usage documentation + - name: Discussions + url: https://github.com/run-llama/liteparse/discussions + about: Ask questions and share ideas diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..5c4f146 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,35 @@ +name: Feature Request +description: Suggest a new feature or improvement +title: "[Feature] " +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: Problem Statement + description: What problem would this feature solve? + placeholder: "I'm always frustrated when..." + validations: + required: true + + - type: textarea + id: solution + attributes: + label: Proposed Solution + description: How do you think this should work? + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives Considered + description: Have you considered any alternative solutions or workarounds? + validations: + required: false + + - type: textarea + id: additional + attributes: + label: Additional Context + description: Any other context, mockups, or examples diff --git a/.github/ISSUE_TEMPLATE/parsing_issue.yml b/.github/ISSUE_TEMPLATE/parsing_issue.yml new file mode 100644 index 0000000..9e198cd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/parsing_issue.yml @@ -0,0 +1,92 @@ +name: Parsing Issue +description: Report an issue with document parsing (incorrect output, missing text, etc.) +title: "[Parsing] " +labels: ["parsing", "bug"] +body: + - type: markdown + attributes: + value: | + ## Important: Document Required + + **Parsing issues without a reproducible example will be closed.** + + To investigate parsing problems, we need to see the actual document. Please either: + - Attach the document to this issue (drag and drop below) + - Provide a public link to the document + - If the document is confidential, provide a minimal reproduction with a similar document + + - type: textarea + id: description + attributes: + label: Description + description: Describe the parsing issue you're experiencing + placeholder: "The text on page 3 is missing / The table formatting is incorrect / etc." + validations: + required: true + + - type: textarea + id: document + attributes: + label: Document + description: | + **Required:** Attach the document or provide a link. + Drag and drop your file here, or paste a public URL. + placeholder: "Drag and drop your document here, or paste a URL" + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected Output + description: What did you expect the output to look like? + placeholder: "I expected the table to have 3 columns with aligned text..." + validations: + required: true + + - type: textarea + id: actual + attributes: + label: Actual Output + description: What did you actually get? (paste relevant portions) + render: text + validations: + required: true + + - type: textarea + id: command + attributes: + label: Command Used + description: The exact command or code you ran + render: bash + placeholder: "lit parse document.pdf --format json" + validations: + required: true + + - type: input + id: version + attributes: + label: LiteParse Version + description: Run `lit --version` to get this + placeholder: "0.1.0" + validations: + required: true + + - type: dropdown + id: os + attributes: + label: Operating System + options: + - macOS (Apple Silicon) + - macOS (Intel) + - Linux + - Windows + - Other + validations: + required: true + + - type: textarea + id: additional + attributes: + label: Additional Context + description: Any other relevant information (OCR server used, config file, etc.) diff --git a/.github/workflows/ci-node.yml b/.github/workflows/ci-node.yml new file mode 100644 index 0000000..e412292 --- /dev/null +++ b/.github/workflows/ci-node.yml @@ -0,0 +1,223 @@ +name: CI - Node Bindings + +on: + push: + branches: [main] + paths: + - "crates/**" + - "packages/node/**" + - ".github/workflows/ci-node.yml" + pull_request: + branches: [main] + paths: + - "crates/**" + - "packages/node/**" + - ".github/workflows/ci-node.yml" + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + target: aarch64-apple-darwin + node-arch: darwin-arm64 + pdfium-dylib: libpdfium.dylib + + - os: macos-26-intel + target: x86_64-apple-darwin + node-arch: darwin-x64 + pdfium-dylib: libpdfium.dylib + + - os: ubuntu-22.04 + target: x86_64-unknown-linux-gnu + node-arch: linux-x64-gnu + pdfium-dylib: libpdfium.so + + - os: ubuntu-22.04-arm + target: aarch64-unknown-linux-gnu + node-arch: linux-arm64-gnu + pdfium-dylib: libpdfium.so + + - os: ubuntu-22.04 + target: x86_64-unknown-linux-musl + node-arch: linux-x64-musl + pdfium-dylib: libpdfium.so + use-container: true + + - os: windows-latest + target: x86_64-pc-windows-msvc + node-arch: win32-x64-msvc + pdfium-dylib: pdfium.dll + + - os: windows-11-arm + target: aarch64-pc-windows-msvc + node-arch: win32-arm64-msvc + pdfium-dylib: pdfium.dll + + runs-on: ${{ matrix.os }} + name: Build ${{ matrix.node-arch }} + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - uses: dtolnay/rust-toolchain@1.95.0 + with: + targets: ${{ matrix.target }} + + - name: Install system dependencies (Linux glibc) + if: runner.os == 'Linux' && !matrix.use-container + run: | + sudo apt-get update + sudo apt-get install -y libtesseract-dev libleptonica-dev + + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + ~/.cache/pdfium-rs + ~/Library/Caches/pdfium-rs + target + key: ${{ matrix.os }}-${{ matrix.target }}-cargo-node-${{ hashFiles('**/Cargo.lock') }} + + - name: Force Ninja generator (Windows) + if: runner.os == 'Windows' + run: echo "CMAKE_GENERATOR=Ninja" >> $GITHUB_ENV + shell: bash + + - name: Install dependencies + working-directory: packages/node + run: npm install --ignore-scripts + + - name: Build native module + if: ${{ !matrix.use-container }} + working-directory: packages/node + shell: bash + run: npx napi build --cargo-cwd ../../crates/liteparse-napi --platform --release --js false --dts native.d.ts --target ${{ matrix.target }} . + + - name: Build native module (musl container) + if: ${{ matrix.use-container }} + run: | + chmod +x scripts/build-musl-node.sh + docker run --rm \ + -v "$GITHUB_WORKSPACE":/work \ + -v "$HOME/.cargo/registry":/root/.cargo/registry \ + -v "$HOME/.cargo/git":/root/.cargo/git \ + -v "$HOME/.cache/pdfium-rs":/root/.cache/pdfium-rs \ + -w /work/packages/node \ + node:20-alpine \ + /work/scripts/build-musl-node.sh + + - name: Copy pdfium dylib (Unix) + if: runner.os != 'Windows' + working-directory: packages/node + run: bash scripts/copy-pdfium.sh . + + - name: Copy pdfium dylib (Windows) + if: runner.os == 'Windows' + working-directory: packages/node + shell: pwsh + run: | + $cacheBase = "$env:LOCALAPPDATA\pdfium-rs" + if (-not (Test-Path $cacheBase)) { + $cacheBase = "$env:USERPROFILE\.cache\pdfium-rs" + } + $dll = Get-ChildItem -Path $cacheBase -Recurse -Filter "pdfium.dll" | Select-Object -First 1 + if ($dll) { + Copy-Item $dll.FullName -Destination ".\pdfium.dll" + Write-Host "Copied $($dll.FullName) -> .\pdfium.dll" + } else { + Write-Error "Could not find pdfium.dll in cache" + exit 1 + } + + - name: Build TypeScript + working-directory: packages/node + run: npx tsc + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: bindings-${{ matrix.node-arch }} + path: | + packages/node/*.node + packages/node/${{ matrix.pdfium-dylib }} + packages/node/libc++.so.1 + packages/node/libc++abi.so.1 + packages/node/libunwind.so.1 + packages/node/libgcc_s.so.1 + if-no-files-found: warn + + test: + needs: build + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + node-arch: darwin-arm64 + - os: macos-26-intel + node-arch: darwin-x64 + - os: ubuntu-22.04 + node-arch: linux-x64-gnu + - os: ubuntu-22.04 + node-arch: linux-x64-musl + use-container: true + - os: windows-latest + node-arch: win32-x64-msvc + - os: windows-11-arm + node-arch: win32-arm64-msvc + runs-on: ${{ matrix.os }} + name: Test ${{ matrix.node-arch }} + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies + working-directory: packages/node + run: npm install --ignore-scripts + + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + name: bindings-${{ matrix.node-arch }} + path: packages/node + + - name: Build TypeScript + working-directory: packages/node + run: npx tsc + + - name: Smoke test + if: ${{ !matrix.use-container }} + working-directory: packages/node + run: | + node -e " + import('./dist/lib.js').then(async ({ LiteParse }) => { + const p = new LiteParse({ ocrEnabled: false, quiet: true }); + console.log('Config:', JSON.stringify(p.getConfig())); + console.log('Native module loaded successfully'); + }); + " + + - name: Smoke test (musl container) + if: ${{ matrix.use-container }} + run: | + chmod +x scripts/smoke-test-musl-node.sh + docker run --rm \ + -v "$GITHUB_WORKSPACE":/work \ + -w /work/packages/node \ + node:20-alpine \ + /work/scripts/smoke-test-musl-node.sh diff --git a/.github/workflows/ci-python.yml b/.github/workflows/ci-python.yml new file mode 100644 index 0000000..5c21bd6 --- /dev/null +++ b/.github/workflows/ci-python.yml @@ -0,0 +1,342 @@ +name: CI - Python Bindings + +on: + push: + branches: [main] + paths: + - "crates/**" + - "packages/python/**" + - ".github/workflows/ci-python.yml" + pull_request: + branches: [main] + paths: + - "crates/**" + - "packages/python/**" + - ".github/workflows/ci-python.yml" + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + target: aarch64-apple-darwin + + - os: macos-26-intel + target: x86_64-apple-darwin + + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + + - os: ubuntu-24.04-arm + target: aarch64-unknown-linux-gnu + + - os: ubuntu-latest + target: x86_64-unknown-linux-musl + musl: true + + - os: windows-latest + target: x86_64-pc-windows-msvc + + - os: windows-11-arm + target: aarch64-pc-windows-msvc + + runs-on: ${{ matrix.os }} + name: Build ${{ matrix.target }} + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - uses: dtolnay/rust-toolchain@1.95.0 + with: + targets: ${{ matrix.target }} + + - name: Install system dependencies (Linux) + if: runner.os == 'Linux' && !matrix.musl + run: | + sudo apt-get update + sudo apt-get install -y libtesseract-dev libleptonica-dev + + - name: Install system dependencies (macOS) + if: runner.os == 'macOS' + run: brew install tesseract leptonica + + - name: Force Ninja generator (Windows) + if: runner.os == 'Windows' + run: echo "CMAKE_GENERATOR=Ninja" >> $GITHUB_ENV + shell: bash + + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + ~/.cache/pdfium-rs + ~/Library/Caches/pdfium-rs + target + key: ${{ runner.os }}-${{ matrix.target }}-cargo-python-${{ hashFiles('**/Cargo.lock') }} + + - name: Build wheels (glibc) + if: ${{ !matrix.musl }} + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.target }} + args: --release --out dist --find-interpreter + manylinux: 2_28 + working-directory: packages/python + before-script-linux: | + if command -v yum &> /dev/null; then + yum install -y tesseract-devel leptonica-devel openssl-devel pkgconfig perl-IPC-Cmd || true + elif command -v apt-get &> /dev/null; then + apt-get update && apt-get install -y libtesseract-dev libleptonica-dev libssl-dev pkg-config || true + elif command -v apk &> /dev/null; then + apk add tesseract-ocr-dev leptonica-dev openssl-dev pkgconf || true + fi + + - name: Build wheels (musl container) + if: ${{ matrix.musl }} + # Same pattern as the node musl build: run inside a vanilla + # python:3-alpine with clang+libc++ from apk. Bypasses maturin-action's + # musllinux_1_2 cross image, which causes FORTIFY_SOURCE issues with + # tesseract's C++ build and broken host-openssl for build-deps. + run: | + chmod +x scripts/build-musl-py.sh + docker run --rm \ + -v "$GITHUB_WORKSPACE":/work \ + -v "$HOME/.cargo/registry":/root/.cargo/registry \ + -v "$HOME/.cargo/git":/root/.cargo/git \ + -v "$HOME/.cache/pdfium-rs":/root/.cache/pdfium-rs \ + -w /work \ + python:3.12-alpine \ + /work/scripts/build-musl-py.sh + # The container runs as root; fix perms so subsequent steps can + # read/upload the produced wheel. + sudo chmod -R a+rw packages/python/dist/ || true + + - name: Copy pdfium dylib into wheel (Unix) + if: runner.os != 'Windows' && !matrix.musl + working-directory: packages/python + run: | + # Fix permissions from Docker/maturin build + sudo chmod -R a+rw dist/ || true + # Find pdfium in target/ (mounted from Docker) or cache + PDFIUM_LIB=$(find ../../target -name "libpdfium.so" -type f 2>/dev/null | head -1 || true) + if [ -z "$PDFIUM_LIB" ]; then + PDFIUM_LIB=$(find ~/.cache/pdfium-rs -name "libpdfium.so" -type f 2>/dev/null | head -1 || true) + fi + if [ -n "$PDFIUM_LIB" ]; then + export PDFIUM_LIB_PATH="$(dirname "$PDFIUM_LIB")" + fi + bash scripts/copy-pdfium.sh + for whl in dist/*.whl; do + python -c " + import zipfile, os, sys + whl = sys.argv[1] + if sys.platform == 'darwin': + dylib = 'libpdfium.dylib' + else: + dylib = 'libpdfium.so' + dylib_path = os.path.join('liteparse', dylib) + if not os.path.exists(dylib_path): + print(f'Warning: {dylib_path} not found, skipping') + sys.exit(0) + with zipfile.ZipFile(whl, 'a') as z: + z.write(dylib_path, f'liteparse/{dylib}') + print(f'Added {dylib} to {whl}') + " "$whl" + done + + - name: Copy pdfium dylib into wheel (Windows) + if: runner.os == 'Windows' + working-directory: packages/python + shell: pwsh + run: | + $cacheBase = "$env:LOCALAPPDATA\pdfium-rs" + if (-not (Test-Path $cacheBase)) { + $cacheBase = "$env:USERPROFILE\.cache\pdfium-rs" + } + $dll = Get-ChildItem -Path $cacheBase -Recurse -Filter "pdfium.dll" | Select-Object -First 1 + if (-not $dll) { + $dll = Get-ChildItem -Path "..\..\target" -Recurse -Filter "pdfium.dll" | Select-Object -First 1 + } + if ($dll) { + Copy-Item $dll.FullName -Destination "liteparse\pdfium.dll" + Write-Host "Copied $($dll.FullName) -> liteparse\pdfium.dll" + foreach ($whl in Get-ChildItem dist\*.whl) { + python -c @" + import zipfile, sys, os + whl = sys.argv[1] + dll = os.path.join('liteparse', 'pdfium.dll') + if os.path.exists(dll): + with zipfile.ZipFile(whl, 'a') as z: + z.write(dll, 'liteparse/pdfium.dll') + print(f'Added pdfium.dll to {whl}') + "@ $whl.FullName + } + } else { + Write-Error "Could not find pdfium.dll" + exit 1 + } + + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-${{ matrix.target }} + path: packages/python/dist/*.whl + if-no-files-found: error + + sdist: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Build sdist + uses: PyO3/maturin-action@v1 + with: + command: sdist + args: --out dist + working-directory: packages/python + + - name: Upload sdist + uses: actions/upload-artifact@v4 + with: + name: sdist + path: packages/python/dist/*.tar.gz + if-no-files-found: error + + test: + needs: build + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + target: aarch64-apple-darwin + - os: macos-26-intel + target: x86_64-apple-darwin + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + - os: ubuntu-latest + target: x86_64-unknown-linux-musl + use-container: true + - os: windows-latest + target: x86_64-pc-windows-msvc + - os: windows-11-arm + target: aarch64-pc-windows-msvc + runs-on: ${{ matrix.os }} + name: Test ${{ matrix.target }} + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Download wheels + uses: actions/download-artifact@v4 + with: + name: wheels-${{ matrix.target }} + path: dist + + - name: Install wheel + if: ${{ !matrix.use-container }} + run: pip install --no-index --find-links dist liteparse + + - name: Smoke test + if: ${{ !matrix.use-container }} + run: | + python -c " + from liteparse import LiteParse, ParseResult, ParsedPage, TextItem, ScreenshotResult + parser = LiteParse(ocr_enabled=False, quiet=True) + print('LiteParse loaded successfully') + print(repr(parser)) + " + + - name: Parse test + if: ${{ !matrix.use-container }} + run: | + python -c " + from liteparse import LiteParse + parser = LiteParse(ocr_enabled=False, quiet=True, max_pages=2) + result = parser.parse('demo/docs/apple-10k-2024.pdf') + assert result.num_pages == 2, f'Expected 2 pages, got {result.num_pages}' + assert len(result.text) > 0, 'Expected non-empty text' + page = result.get_page(1) + assert page is not None, 'Expected page 1' + assert len(page.text_items) > 0, 'Expected text items' + print(f'Parsed {result.num_pages} pages, {len(result.text)} chars') + print('Parse test passed') + " + + - name: Bytes parse test + if: ${{ !matrix.use-container }} + run: | + python -c " + from liteparse import LiteParse + parser = LiteParse(ocr_enabled=False, quiet=True, max_pages=1) + with open('demo/docs/apple-10k-2024.pdf', 'rb') as f: + data = f.read() + result = parser.parse(data) + assert result.num_pages == 1 + assert len(result.text) > 0 + print('Bytes parse test passed') + " + + - name: Screenshot test + if: ${{ !matrix.use-container }} + run: | + python -c " + from liteparse import LiteParse + parser = LiteParse(ocr_enabled=False, quiet=True) + screenshots = parser.screenshot('demo/docs/apple-10k-2024.pdf', page_numbers=[1]) + assert len(screenshots) == 1 + assert screenshots[0].page_num == 1 + assert len(screenshots[0].image_bytes) > 0 + print(f'Screenshot: {screenshots[0].width}x{screenshots[0].height}, {len(screenshots[0].image_bytes)} bytes') + print('Screenshot test passed') + " + + - name: CLI test + if: ${{ !matrix.use-container }} + run: | + lit --version + lit parse demo/docs/apple-10k-2024.pdf --no-ocr --max-pages 1 -q | head -5 + echo "CLI test passed" + + - name: Test in musl container + if: ${{ matrix.use-container }} + run: | + docker run --rm \ + -v "$GITHUB_WORKSPACE":/work \ + -w /work \ + python:3.12-alpine \ + sh -c ' + pip install --no-index --find-links dist liteparse + python -c " + from liteparse import LiteParse, ParseResult, ParsedPage, TextItem, ScreenshotResult + parser = LiteParse(ocr_enabled=False, quiet=True) + print(repr(parser)) + print(\"LiteParse loaded successfully\") + " + python -c " + from liteparse import LiteParse + parser = LiteParse(ocr_enabled=False, quiet=True, max_pages=2) + result = parser.parse(\"demo/docs/apple-10k-2024.pdf\") + assert result.num_pages == 2, f\"Expected 2 pages, got {result.num_pages}\" + assert len(result.text) > 0, \"Expected non-empty text\" + print(f\"Parsed {result.num_pages} pages, {len(result.text)} chars\") + print(\"Parse test passed\") + " + lit --version + lit parse demo/docs/apple-10k-2024.pdf --no-ocr --max-pages 1 -q | head -5 + echo "CLI test passed" + ' diff --git a/.github/workflows/ci-wasm.yml b/.github/workflows/ci-wasm.yml new file mode 100644 index 0000000..8d80c64 --- /dev/null +++ b/.github/workflows/ci-wasm.yml @@ -0,0 +1,119 @@ +name: CI - WASM Bindings + +on: + push: + branches: [main] + paths: + - 'crates/**' + - 'packages/wasm/**' + - 'scripts/browser-compat/**' + - 'scripts/edge-compat/**' + - '.github/workflows/ci-wasm.yml' + pull_request: + branches: [main] + paths: + - 'crates/**' + - 'packages/wasm/**' + - 'scripts/browser-compat/**' + - 'scripts/edge-compat/**' + - '.github/workflows/ci-wasm.yml' + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + runs-on: ubuntu-latest + name: Build WASM + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - uses: dtolnay/rust-toolchain@1.95.0 + with: + targets: wasm32-unknown-unknown + + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + ~/.cache/pdfium-rs + target + key: ${{ runner.os }}-cargo-wasm-${{ hashFiles('**/Cargo.lock') }} + + - name: Install wasm-pack + run: cargo install wasm-pack --locked + + - name: Build WASM package + working-directory: packages/wasm + run: npm run build + + - name: Verify output + run: | + test -f packages/wasm/pkg/liteparse_wasm_bg.wasm + test -f packages/wasm/pkg/liteparse_wasm.js + test -f packages/wasm/pkg/liteparse_wasm.d.ts + WASM_SIZE=$(stat --format=%s packages/wasm/pkg/liteparse_wasm_bg.wasm) + echo "WASM binary size: $(numfmt --to=iec $WASM_SIZE)" + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: wasm-pkg + path: packages/wasm/pkg/ + if-no-files-found: error + + browser-test: + runs-on: ubuntu-latest + needs: build + name: Browser PDF parse test + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Download WASM artifact + uses: actions/download-artifact@v4 + with: + name: wasm-pkg + path: packages/wasm/pkg/ + + - name: Install Playwright + run: | + npm install playwright@latest + npx playwright install --with-deps chromium + + - name: Run browser parse test + run: node scripts/browser-compat/wasm-test.mjs + + edge-test: + runs-on: ubuntu-latest + needs: build + name: Edge runtime PDF parse test + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Download WASM artifact + uses: actions/download-artifact@v4 + with: + name: wasm-pkg + path: packages/wasm/pkg/ + + - name: Install Miniflare + run: npm install miniflare@latest + + - name: Run edge runtime parse test + run: node scripts/edge-compat/wasm-test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..45e2457 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,112 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + CARGO_TERM_COLOR: always + SKIP_INTEGRATION_TESTS: yes + +jobs: + fmt: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: rustup toolchain install stable --profile minimal --component rustfmt + - run: cargo fmt --all -- --check + + clippy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libtesseract-dev libleptonica-dev + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + ~/.cache/pdfium-rs + target + key: ${{ runner.os }}-cargo-clippy-${{ hashFiles('**/Cargo.lock') }} + - run: rustup toolchain install stable --profile minimal --component clippy + - run: cargo clippy --all-targets + + build-and-test: + strategy: + matrix: + os: [ubuntu-latest, macos-latest, macos-26-intel, windows-latest, windows-11-arm] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - name: Install system dependencies (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libtesseract-dev libleptonica-dev + + - name: Install system dependencies (macOS) + if: runner.os == 'macOS' + run: brew install tesseract leptonica + + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + ~/.cache/pdfium-rs + ~/Library/Caches/pdfium-rs + ${{ runner.os == 'Windows' && format('{0}\pdfium-rs', env.LOCALAPPDATA) || '' }} + target + key: ${{ matrix.os }}-cargo-build-${{ hashFiles('**/Cargo.lock') }} + + - name: Force Ninja generator (Windows) + if: runner.os == 'Windows' + run: echo "CMAKE_GENERATOR=Ninja" >> $GITHUB_ENV + shell: bash + + - run: cargo build --workspace --all-targets --exclude liteparse-python --exclude liteparse-napi + - run: cargo test --workspace --exclude liteparse-python --exclude liteparse-napi + + - name: CLI smoke test + if: runner.os != 'Windows' + run: | + ./target/debug/lit --version + ./target/debug/lit parse demo/docs/apple-10k-2024.pdf --no-ocr --max-pages 1 -q | head -5 + + - name: OCR smoke test (runtime tessdata download) + shell: bash + run: | + # Wipe any tessdata that tesseract-rs/build.rs downloaded during + # `cargo build`, so the parse exercises the runtime first-use + # download path (the code path that npm/pip prebuilt users hit). + rm -rf "$HOME/Library/Application Support/tesseract-rs" \ + "$HOME/.tesseract-rs" \ + "${APPDATA:-}/tesseract-rs" 2>/dev/null || true + + LIT=./target/debug/lit + [ -f "$LIT.exe" ] && LIT="$LIT.exe" + "$LIT" parse demo/docs/long_tiny_text.pdf --max-pages 1 -q -o ocr-out.txt + + # If OCR ran, page 1 has substantial text beyond just the + # "--- Page 1 ---" header (~16 bytes). A tessdata-load failure + # surfaces as systemic OCR failure and an empty/tiny output. + size=$(wc -c < ocr-out.txt) + echo "OCR output size: $size bytes" + test "$size" -gt 200 + + # Test that docker builds + build-docker-image: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build the base Docker image + run: docker build . + - name: Build the full Docker image + run: docker build . -f full.Dockerfile diff --git a/.github/workflows/e2e-output.yml b/.github/workflows/e2e-output.yml new file mode 100644 index 0000000..1519540 --- /dev/null +++ b/.github/workflows/e2e-output.yml @@ -0,0 +1,148 @@ +name: E2E Output Validation + +on: + pull_request: + branches: [main] + push: + branches: [main] + +# For PRs, we need write permissions to add labels +permissions: + contents: read + pull-requests: write + +jobs: + compare-outputs: + runs-on: ubuntu-latest + outputs: + has_changes: ${{ steps.compare.outputs.has_changes }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libtesseract-dev libleptonica-dev \ + tesseract-ocr libreoffice imagemagick ghostscript + + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + ~/.cache/pdfium-rs + target + key: ${{ runner.os }}-cargo-e2e-${{ hashFiles('**/Cargo.lock') }} + + - name: Build + run: cargo build --release + + - name: Download from HuggingFace + env: + HF_TOKEN: ${{ secrets.HF_TOKEN }} + run: | + pip install huggingface_hub + hf download 'llamaindex/liteparse_cicd_data' --local-dir expected-dataset --repo-type dataset + + - name: Compare outputs + id: compare + run: ./scripts/compare-outputs.sh expected-dataset + + - name: Upload comparison results + if: always() + uses: actions/upload-artifact@v4 + with: + name: comparison-results + path: comparison-output.txt + + - name: Add label for changed outputs (PR only) + if: github.event_name == 'pull_request' && steps.compare.outputs.has_changes == 'true' + uses: actions/github-script@v7 + with: + script: | + github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: ['output-changed'] + }) + + - name: Check for approval label (PR only) + id: check-approved + if: github.event_name == 'pull_request' && steps.compare.outputs.has_changes == 'true' + uses: actions/github-script@v7 + with: + script: | + const { data: labels } = await github.rest.issues.listLabelsOnIssue({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number + }); + const hasApproval = labels.some(label => label.name === 'output-approved'); + core.setOutput('has_approval', hasApproval.toString()); + console.log(`Has output-approved label: ${hasApproval}`); + + - name: Require approval for output changes + if: steps.compare.outputs.has_changes == 'true' && github.event_name == 'pull_request' && steps.check-approved.outputs.has_approval != 'true' + run: | + echo "::warning::This PR changes liteparse output. Please review the comparison results." + echo "" + echo "To approve these changes:" + echo "1. Review the comparison output in the artifacts" + echo "2. Add the 'output-approved' label to this PR" + echo "3. Re-run this workflow" + exit 1 + + # This job only runs on main branch after merge when outputs changed + upload-dataset: + runs-on: ubuntu-latest + needs: compare-outputs + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.compare-outputs.outputs.has_changes == 'true' + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libtesseract-dev libleptonica-dev \ + tesseract-ocr libreoffice imagemagick ghostscript + + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + ~/.cache/pdfium-rs + target + key: ${{ runner.os }}-cargo-e2e-${{ hashFiles('**/Cargo.lock') }} + + - name: Build + run: cargo build --release + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: pip install huggingface_hub + + - name: Download existing dataset from HuggingFace + env: + HF_TOKEN: ${{ secrets.HF_TOKEN }} + run: | + hf download 'llamaindex/liteparse_cicd_data' --local-dir expected-dataset --repo-type dataset + + - name: Generate and upload dataset + env: + HF_TOKEN: ${{ secrets.HF_TOKEN }} + run: | + if [ -z "$HF_TOKEN" ]; then + echo "Warning: HF_TOKEN not set, skipping upload" + exit 0 + fi + ./scripts/upload-dataset.sh expected-dataset ${{ vars.HF_DATASET_REPO || 'llamaindex/liteparse_cicd_data' }} diff --git a/.github/workflows/ocr_servers.yml b/.github/workflows/ocr_servers.yml new file mode 100644 index 0000000..ff29d1b --- /dev/null +++ b/.github/workflows/ocr_servers.yml @@ -0,0 +1,45 @@ +name: Validate OCR Servers + +on: + pull_request: + +jobs: + testing_paddleocr: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.12"] + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b + with: + python-version: ${{ matrix.python-version }} + enable-cache: true + + - name: Run Tests on Main Package + run: uv run pytest test_server.py + working-directory: ocr/paddleocr/ + + testing_easyocr: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.12"] + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b + with: + python-version: ${{ matrix.python-version }} + enable-cache: true + + - name: Run Tests on Main Package + run: uv run pytest test_server.py + working-directory: ocr/easyocr/ diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..eb2bb07 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,34 @@ +name: Deploy Demo to GitHub Pages + +on: + push: + branches: [main] + paths: [wasm-demo-site/**] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/configure-pages@v5 + + - uses: actions/upload-pages-artifact@v3 + with: + path: wasm-demo-site + + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/release-crates.yml b/.github/workflows/release-crates.yml new file mode 100644 index 0000000..00f32b5 --- /dev/null +++ b/.github/workflows/release-crates.yml @@ -0,0 +1,218 @@ +name: Release - Crates & CLI + +on: + workflow_dispatch: + inputs: + version: + description: 'Version to release (e.g. 2.1.0)' + required: true + type: string + dry-run: + description: 'Dry run (build but do not publish)' + type: boolean + default: false + +permissions: + contents: write + actions: write + +env: + CARGO_TERM_COLOR: always + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Validate version + run: | + CARGO_VERSION=$(grep '^version' crates/liteparse/Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/') + if [ "$CARGO_VERSION" != "${{ inputs.version }}" ]; then + echo "::error::Version mismatch: crates/liteparse/Cargo.toml has $CARGO_VERSION, expected ${{ inputs.version }}" + exit 1 + fi + echo "Version validated: ${{ inputs.version }}" + + publish-crates: + needs: validate + if: ${{ !inputs.dry-run }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libtesseract-dev libleptonica-dev + + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + ~/.cache/pdfium-rs + target + key: ${{ runner.os }}-cargo-release-${{ hashFiles('**/Cargo.lock') }} + + - name: Publish to crates.io + run: | + publish_if_new() { + local crate=$1 + local version=$(cargo metadata --format-version 1 --no-deps | jq -r ".packages[] | select(.name == \"$crate\") | .version") + if cargo search "$crate" --limit 1 | grep -q "\"$version\""; then + echo "$crate@$version already published, skipping" + else + cargo publish -p "$crate" --allow-dirty + fi + } + publish_if_new liteparse-pdfium-sys + publish_if_new liteparse-pdfium + publish_if_new liteparse + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + + build-binaries: + needs: validate + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-gnu + os: ubuntu-22.04 + artifact: lit-linux-x64 + pdfium-dylib: libpdfium.so + - target: aarch64-unknown-linux-gnu + os: ubuntu-22.04-arm + artifact: lit-linux-arm64 + pdfium-dylib: libpdfium.so + - target: aarch64-apple-darwin + os: macos-latest + artifact: lit-darwin-arm64 + pdfium-dylib: libpdfium.dylib + - target: x86_64-apple-darwin + os: macos-26-intel + artifact: lit-darwin-x64 + pdfium-dylib: libpdfium.dylib + - target: x86_64-pc-windows-msvc + os: windows-latest + artifact: lit-windows-x64 + pdfium-dylib: pdfium.dll + - target: aarch64-pc-windows-msvc + os: windows-11-arm + artifact: lit-windows-arm64 + pdfium-dylib: pdfium.dll + + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@1.95.0 + with: + targets: ${{ matrix.target }} + + - name: Install system dependencies (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libtesseract-dev libleptonica-dev + + - name: Install system dependencies (macOS) + if: runner.os == 'macOS' + run: brew install tesseract leptonica + + - name: Force Ninja generator (Windows) + if: runner.os == 'Windows' + run: echo "CMAKE_GENERATOR=Ninja" >> $GITHUB_ENV + shell: bash + + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + ~/.cache/pdfium-rs + ~/Library/Caches/pdfium-rs + ${{ runner.os == 'Windows' && format('{0}\pdfium-rs', env.LOCALAPPDATA) || '' }} + target + key: ${{ runner.os }}-${{ matrix.target }}-cargo-release-${{ hashFiles('**/Cargo.lock') }} + + - name: Build + run: cargo build --release --target ${{ matrix.target }} -p liteparse + + - name: Stage binary and pdfium (Unix) + if: runner.os != 'Windows' + shell: bash + run: | + mkdir -p staging/${{ matrix.artifact }} + cp target/${{ matrix.target }}/release/lit staging/${{ matrix.artifact }}/lit + chmod +x staging/${{ matrix.artifact }}/lit + bash packages/node/scripts/copy-pdfium.sh staging/${{ matrix.artifact }} + + - name: Stage binary and pdfium (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path "staging/${{ matrix.artifact }}" | Out-Null + Copy-Item "target/${{ matrix.target }}/release/lit.exe" -Destination "staging/${{ matrix.artifact }}/lit.exe" + $cacheBase = "$env:LOCALAPPDATA\pdfium-rs" + if (-not (Test-Path $cacheBase)) { + $cacheBase = "$env:USERPROFILE\.cache\pdfium-rs" + } + $dll = Get-ChildItem -Path $cacheBase -Recurse -Filter "pdfium.dll" | Select-Object -First 1 + if ($dll) { + Copy-Item $dll.FullName -Destination "staging/${{ matrix.artifact }}/pdfium.dll" + Write-Host "Copied $($dll.FullName) -> staging/${{ matrix.artifact }}/pdfium.dll" + } else { + Write-Error "Could not find pdfium.dll in cache" + exit 1 + } + + - name: Package tarball + shell: bash + run: | + mkdir -p dist + tar -czf dist/${{ matrix.artifact }}.tar.gz -C staging ${{ matrix.artifact }} + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact }} + path: dist/${{ matrix.artifact }}.tar.gz + if-no-files-found: error + + github-release: + needs: [build-binaries, publish-crates] + if: ${{ always() && needs.build-binaries.result == 'success' && (needs.publish-crates.result == 'success' || needs.publish-crates.result == 'skipped') }} + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Create git tag + if: ${{ !inputs.dry-run }} + run: | + git tag "crates-v${{ inputs.version }}" + git push origin "crates-v${{ inputs.version }}" + + - name: Create GitHub Release + if: ${{ !inputs.dry-run }} + uses: softprops/action-gh-release@v2 + with: + tag_name: crates-v${{ inputs.version }} + name: "Crates & CLI v${{ inputs.version }}" + generate_release_notes: true + files: artifacts/**/*.tar.gz + + - name: Summary (dry run) + if: ${{ inputs.dry-run }} + run: | + echo "## Dry Run Summary" >> $GITHUB_STEP_SUMMARY + echo "Would release crates-v${{ inputs.version }}" >> $GITHUB_STEP_SUMMARY + echo "Artifacts:" >> $GITHUB_STEP_SUMMARY + find artifacts -name "*.tar.gz" -exec echo "- {}" \; >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/release-docker.yml b/.github/workflows/release-docker.yml new file mode 100644 index 0000000..69444cd --- /dev/null +++ b/.github/workflows/release-docker.yml @@ -0,0 +1,143 @@ +name: Release - Docker + +on: + workflow_dispatch: + inputs: + version: + description: 'Version to release (e.g. 2.1.0)' + required: true + type: string + dry-run: + description: 'Dry run (build but do not push)' + type: boolean + default: false + +env: + REGISTRY: ghcr.io + IMAGE_NAME_BASE: run-llama/liteparse-base + IMAGE_NAME: run-llama/liteparse + +jobs: + build-and-publish-base: + runs-on: ubuntu-latest + permissions: + contents: write + packages: write + id-token: write + + steps: + - uses: actions/checkout@v4 + + - name: Install cosign + uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 #v3.5.0 + with: + cosign-release: "v2.2.4" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + + - name: Log into registry + if: ${{ !inputs.dry-run }} + uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push base image + id: build-and-push + uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 + with: + platforms: linux/amd64,linux/arm64 + context: . + push: ${{ !inputs.dry-run }} + tags: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_BASE }}:v${{ inputs.version }} + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_BASE }}:latest + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Sign the image + if: ${{ !inputs.dry-run }} + env: + DIGEST: ${{ steps.build-and-push.outputs.digest }} + run: | + cosign sign --yes "${{ env.REGISTRY }}/${{ env.IMAGE_NAME_BASE }}@${DIGEST}" + + build-and-publish-full: + runs-on: ubuntu-latest + permissions: + contents: write + packages: write + id-token: write + + steps: + - uses: actions/checkout@v4 + + - name: Install cosign + uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 #v3.5.0 + with: + cosign-release: "v2.2.4" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + + - name: Log into registry + if: ${{ !inputs.dry-run }} + uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push full image + id: build-and-push + uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 + with: + platforms: linux/amd64,linux/arm64 + context: . + file: full.Dockerfile + push: ${{ !inputs.dry-run }} + tags: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:v${{ inputs.version }} + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Sign the image + if: ${{ !inputs.dry-run }} + env: + DIGEST: ${{ steps.build-and-push.outputs.digest }} + run: | + cosign sign --yes "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${DIGEST}" + + create-release: + needs: [build-and-publish-base, build-and-publish-full] + if: ${{ !inputs.dry-run }} + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + + - name: Create git tag + run: | + git tag "docker-v${{ inputs.version }}" + git push origin "docker-v${{ inputs.version }}" + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: docker-v${{ inputs.version }} + name: "Docker v${{ inputs.version }}" + body: | + Docker images published to GHCR: + - `${{ env.REGISTRY }}/${{ env.IMAGE_NAME_BASE }}:v${{ inputs.version }}` + - `${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:v${{ inputs.version }}` diff --git a/.github/workflows/release-node.yml b/.github/workflows/release-node.yml new file mode 100644 index 0000000..76c6d8e --- /dev/null +++ b/.github/workflows/release-node.yml @@ -0,0 +1,460 @@ +name: Release - Node.js + +on: + workflow_dispatch: + inputs: + version: + description: "Version to release (e.g. 2.1.0)" + required: true + type: string + beta: + description: "Publish as beta (npm tag: beta instead of latest)" + type: boolean + default: false + dry-run: + description: "Dry run (build but do not publish)" + type: boolean + default: false + +permissions: + contents: write + id-token: write + +env: + CARGO_TERM_COLOR: always + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Validate version + run: | + PKG_VERSION=$(node -p "require('./packages/node/package.json').version") + if [ "$PKG_VERSION" != "${{ inputs.version }}" ]; then + echo "::error::Version mismatch: package.json has $PKG_VERSION, expected ${{ inputs.version }}" + exit 1 + fi + echo "Version validated: ${{ inputs.version }}" + + build-bindings: + needs: validate + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + target: aarch64-apple-darwin + node-arch: darwin-arm64 + pdfium-dylib: libpdfium.dylib + + - os: macos-26-intel + target: x86_64-apple-darwin + node-arch: darwin-x64 + pdfium-dylib: libpdfium.dylib + + - os: ubuntu-22.04 + target: x86_64-unknown-linux-gnu + node-arch: linux-x64-gnu + pdfium-dylib: libpdfium.so + + - os: ubuntu-22.04-arm + target: aarch64-unknown-linux-gnu + node-arch: linux-arm64-gnu + pdfium-dylib: libpdfium.so + + - os: ubuntu-22.04 + target: x86_64-unknown-linux-musl + node-arch: linux-x64-musl + pdfium-dylib: libpdfium.so + use-container: true + + - os: windows-latest + target: x86_64-pc-windows-msvc + node-arch: win32-x64-msvc + pdfium-dylib: pdfium.dll + + - os: windows-11-arm + target: aarch64-pc-windows-msvc + node-arch: win32-arm64-msvc + pdfium-dylib: pdfium.dll + + runs-on: ${{ matrix.os }} + name: Build ${{ matrix.node-arch }} + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - uses: dtolnay/rust-toolchain@1.95.0 + with: + targets: ${{ matrix.target }} + + - name: Install system dependencies (Linux glibc) + if: runner.os == 'Linux' && !matrix.use-container + run: | + sudo apt-get update + sudo apt-get install -y libtesseract-dev libleptonica-dev + + - name: Force Ninja generator (Windows) + if: runner.os == 'Windows' + run: echo "CMAKE_GENERATOR=Ninja" >> $GITHUB_ENV + shell: bash + + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + ~/.cache/pdfium-rs + ~/Library/Caches/pdfium-rs + ${{ runner.os == 'Windows' && format('{0}\pdfium-rs', env.LOCALAPPDATA) || '' }} + target + key: ${{ matrix.os }}-${{ matrix.target }}-cargo-node-${{ hashFiles('**/Cargo.lock') }} + + - name: Install dependencies + working-directory: packages/node + run: npm install --ignore-scripts + + - name: Build native module + if: ${{ !matrix.use-container }} + working-directory: packages/node + shell: bash + run: npx napi build --cargo-cwd ../../crates/liteparse-napi --platform --release --js false --dts native.d.ts --target ${{ matrix.target }} . + + - name: Build native module (musl container) + if: ${{ matrix.use-container }} + run: | + chmod +x scripts/build-musl-node.sh + docker run --rm \ + -v "$GITHUB_WORKSPACE":/work \ + -v "$HOME/.cargo/registry":/root/.cargo/registry \ + -v "$HOME/.cargo/git":/root/.cargo/git \ + -v "$HOME/.cache/pdfium-rs":/root/.cache/pdfium-rs \ + -w /work/packages/node \ + node:20-alpine \ + /work/scripts/build-musl-node.sh + + - name: Copy pdfium dylib (Unix) + if: runner.os != 'Windows' + working-directory: packages/node + run: bash scripts/copy-pdfium.sh . + + - name: Copy pdfium dylib (Windows) + if: runner.os == 'Windows' + working-directory: packages/node + shell: pwsh + run: | + $cacheBase = "$env:LOCALAPPDATA\pdfium-rs" + if (-not (Test-Path $cacheBase)) { + $cacheBase = "$env:USERPROFILE\.cache\pdfium-rs" + } + $dll = Get-ChildItem -Path $cacheBase -Recurse -Filter "pdfium.dll" | Select-Object -First 1 + if ($dll) { + Copy-Item $dll.FullName -Destination ".\pdfium.dll" + Write-Host "Copied $($dll.FullName) -> .\pdfium.dll" + } else { + Write-Error "Could not find pdfium.dll in cache" + exit 1 + } + + - name: Build TypeScript + working-directory: packages/node + run: npx tsc + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: bindings-${{ matrix.node-arch }} + path: | + packages/node/*.node + packages/node/${{ matrix.pdfium-dylib }} + packages/node/libc++.so.1 + packages/node/libc++abi.so.1 + packages/node/libunwind.so.1 + packages/node/libgcc_s.so.1 + if-no-files-found: warn + + test-bindings: + needs: build-bindings + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + node-arch: darwin-arm64 + - os: macos-26-intel + node-arch: darwin-x64 + - os: ubuntu-22.04 + node-arch: linux-x64-gnu + - os: windows-latest + node-arch: win32-x64-msvc + - os: windows-11-arm + node-arch: win32-arm64-msvc + runs-on: ${{ matrix.os }} + name: Test ${{ matrix.node-arch }} + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies + working-directory: packages/node + run: npm install --ignore-scripts + + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + name: bindings-${{ matrix.node-arch }} + path: packages/node + + - name: Build TypeScript + working-directory: packages/node + run: npx tsc + + - name: Smoke test + working-directory: packages/node + run: | + node -e " + import('./dist/lib.js').then(async ({ LiteParse }) => { + const p = new LiteParse({ ocrEnabled: false, quiet: true }); + console.log('Config:', JSON.stringify(p.getConfig())); + console.log('Native module loaded successfully'); + }); + " + + build-cli: + needs: validate + strategy: + matrix: + include: + - target: x86_64-unknown-linux-gnu + os: ubuntu-22.04 + artifact: lit-linux-x64 + - target: aarch64-unknown-linux-gnu + os: ubuntu-22.04 + artifact: lit-linux-arm64 + use-cross: true + - target: aarch64-apple-darwin + os: macos-latest + artifact: lit-darwin-arm64 + - target: x86_64-apple-darwin + os: macos-26-intel + artifact: lit-darwin-x64 + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@1.95.0 + with: + targets: ${{ matrix.target }} + + - name: Install system dependencies (Linux native) + if: runner.os == 'Linux' && !matrix.use-cross + run: | + sudo apt-get update + sudo apt-get install -y libtesseract-dev libleptonica-dev + + - name: Install cross-compilation tools (Linux ARM64) + if: matrix.use-cross + run: | + sudo apt-get update + sudo apt-get install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu + echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" >> $GITHUB_ENV + + - name: Install system dependencies (macOS) + if: runner.os == 'macOS' + run: brew install tesseract leptonica + + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + ~/.cache/pdfium-rs + ~/Library/Caches/pdfium-rs + target + key: ${{ matrix.os }}-${{ matrix.target }}-cargo-cli-${{ hashFiles('**/Cargo.lock') }} + + - name: Build + run: | + if [ "${{ matrix.use-cross }}" = "true" ]; then + cargo build --release --target ${{ matrix.target }} --no-default-features -p liteparse + else + cargo build --release --target ${{ matrix.target }} -p liteparse + fi + + - name: Package binary + run: | + mkdir -p dist + cp target/${{ matrix.target }}/release/lit dist/${{ matrix.artifact }} + chmod +x dist/${{ matrix.artifact }} + tar -czf dist/${{ matrix.artifact }}.tar.gz -C dist ${{ matrix.artifact }} + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact }} + path: dist/${{ matrix.artifact }}.tar.gz + + publish: + needs: [test-bindings, build-cli] + runs-on: ubuntu-latest + name: Publish & Release + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + registry-url: https://registry.npmjs.org + + - name: Install dependencies + working-directory: packages/node + run: npm install --ignore-scripts + + - name: Build TypeScript + working-directory: packages/node + run: npx tsc + + - name: Download all binding artifacts + uses: actions/download-artifact@v4 + with: + pattern: bindings-* + path: artifacts + + - name: Download all CLI artifacts + uses: actions/download-artifact@v4 + with: + pattern: lit-* + path: cli-artifacts + + - name: Prepare platform packages + working-directory: packages/node + run: | + declare -A PLATFORMS=( + ["darwin-arm64"]="aarch64-apple-darwin" + ["darwin-x64"]="x86_64-apple-darwin" + ["linux-x64-gnu"]="x86_64-unknown-linux-gnu" + ["linux-x64-musl"]="x86_64-unknown-linux-musl" + ["linux-arm64-gnu"]="aarch64-unknown-linux-gnu" + ["win32-x64-msvc"]="x86_64-pc-windows-msvc" + ["win32-arm64-msvc"]="aarch64-pc-windows-msvc" + ) + + for ARCH in "${!PLATFORMS[@]}"; do + PKG_NAME="@llamaindex/liteparse-${ARCH}" + PKG_DIR="npm/${ARCH}" + ARTIFACT_DIR="../../artifacts/bindings-${ARCH}" + + if [ ! -d "$ARTIFACT_DIR" ]; then + echo "Skipping ${ARCH} - no artifacts found" + continue + fi + + mkdir -p "$PKG_DIR" + + cp "$ARTIFACT_DIR"/*.node "$PKG_DIR/" 2>/dev/null || true + cp "$ARTIFACT_DIR"/*.dylib "$PKG_DIR/" 2>/dev/null || true + cp "$ARTIFACT_DIR"/*.so "$PKG_DIR/" 2>/dev/null || true + cp "$ARTIFACT_DIR"/*.dll "$PKG_DIR/" 2>/dev/null || true + + LIBC="" + case "$ARCH" in + darwin-arm64) OS="darwin"; CPU="arm64" ;; + darwin-x64) OS="darwin"; CPU="x64" ;; + win32-x64-msvc) OS="win32"; CPU="x64" ;; + win32-arm64-msvc) OS="win32"; CPU="arm64" ;; + linux-x64-gnu) OS="linux"; CPU="x64"; LIBC="glibc" ;; + linux-x64-musl) OS="linux"; CPU="x64"; LIBC="musl" ;; + linux-arm64-gnu) OS="linux"; CPU="arm64"; LIBC="glibc" ;; + esac + + VERSION=${{ inputs.version }} + + LIBC_LINE="" + if [ -n "$LIBC" ]; then + LIBC_LINE="\"libc\": [\"${LIBC}\"]," + fi + + cat > "$PKG_DIR/package.json" << PKGJSON + { + "name": "${PKG_NAME}", + "version": "${VERSION}", + "os": ["${OS}"], + "cpu": ["${CPU}"], + ${LIBC_LINE} + "main": "liteparse.${ARCH}.node", + "files": ["*.node", "*.dylib", "*.so", "*.dll"], + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/run-llama/liteparse.git" + } + } + PKGJSON + + echo "Created platform package: ${PKG_NAME}" + ls -la "$PKG_DIR/" + done + + - name: Publish platform packages + if: ${{ !inputs.dry-run }} + working-directory: packages/node + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_TAG: ${{ inputs.beta && 'beta' || 'latest' }} + run: | + for dir in npm/*/; do + if [ -f "$dir/package.json" ]; then + echo "Publishing $(node -p "require('./${dir}/package.json').name")..." + cd "$dir" + npm publish --access public --provenance --tag "$NPM_TAG" || true + cd ../../ + fi + done + + - name: Publish main package + if: ${{ !inputs.dry-run }} + working-directory: packages/node + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_TAG: ${{ inputs.beta && 'beta' || 'latest' }} + run: npm publish --access public --provenance --tag "$NPM_TAG" + + - name: Create git tag + if: ${{ !inputs.dry-run }} + run: | + git tag "node-v${{ inputs.version }}" + git push origin "node-v${{ inputs.version }}" + + - name: Create GitHub Release + if: ${{ !inputs.dry-run }} + uses: softprops/action-gh-release@v2 + with: + tag_name: node-v${{ inputs.version }} + name: "Node.js v${{ inputs.version }}" + generate_release_notes: true + files: cli-artifacts/**/*.tar.gz + + - name: Summary (dry run) + if: ${{ inputs.dry-run }} + run: | + echo "## Dry Run Summary" >> $GITHUB_STEP_SUMMARY + echo "Would release node-v${{ inputs.version }}" >> $GITHUB_STEP_SUMMARY + echo "### Platform packages:" >> $GITHUB_STEP_SUMMARY + for dir in packages/node/npm/*/; do + if [ -f "$dir/package.json" ]; then + echo "- $(node -p "require('./${dir}/package.json').name")" >> $GITHUB_STEP_SUMMARY + fi + done + echo "### CLI artifacts:" >> $GITHUB_STEP_SUMMARY + find cli-artifacts -name "*.tar.gz" -exec echo "- {}" \; >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/release-python.yml b/.github/workflows/release-python.yml new file mode 100644 index 0000000..663b37d --- /dev/null +++ b/.github/workflows/release-python.yml @@ -0,0 +1,392 @@ +name: Release - Python + +on: + workflow_dispatch: + inputs: + version: + description: "Version to release (e.g. 2.1.0)" + required: true + type: string + dry-run: + description: "Dry run (build but do not publish)" + type: boolean + default: false + +permissions: + contents: write + id-token: write + +env: + CARGO_TERM_COLOR: always + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Validate version + run: | + PYPROJECT_VERSION=$(python3 -c " + import re + with open('packages/python/pyproject.toml') as f: + match = re.search(r'^version\s*=\s*\"(.+?)\"', f.read(), re.MULTILINE) + print(match.group(1)) + ") + if [ "$PYPROJECT_VERSION" != "${{ inputs.version }}" ]; then + echo "::error::Version mismatch: pyproject.toml has $PYPROJECT_VERSION, expected ${{ inputs.version }}" + exit 1 + fi + echo "Version validated: ${{ inputs.version }}" + + build-wheels: + needs: validate + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + target: aarch64-apple-darwin + + - os: macos-26-intel + target: x86_64-apple-darwin + + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + + - os: ubuntu-24.04-arm + target: aarch64-unknown-linux-gnu + + - os: ubuntu-latest + target: x86_64-unknown-linux-musl + musl: true + + - os: windows-latest + target: x86_64-pc-windows-msvc + + - os: windows-11-arm + target: aarch64-pc-windows-msvc + + runs-on: ${{ matrix.os }} + name: Build ${{ matrix.target }} + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - uses: dtolnay/rust-toolchain@1.95.0 + with: + targets: ${{ matrix.target }} + + - name: Install system dependencies (Linux) + if: runner.os == 'Linux' && !matrix.musl + run: | + sudo apt-get update + sudo apt-get install -y libtesseract-dev libleptonica-dev + + - name: Install system dependencies (macOS) + if: runner.os == 'macOS' + run: brew install tesseract leptonica + + - name: Force Ninja generator (Windows) + if: runner.os == 'Windows' + run: echo "CMAKE_GENERATOR=Ninja" >> $GITHUB_ENV + shell: bash + + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + ~/.cache/pdfium-rs + ~/Library/Caches/pdfium-rs + ${{ runner.os == 'Windows' && format('{0}\pdfium-rs', env.LOCALAPPDATA) || '' }} + target + key: ${{ runner.os }}-${{ matrix.target }}-cargo-python-${{ hashFiles('**/Cargo.lock') }} + + - name: Stage pdfium for wheel inclusion + if: ${{ !matrix.musl }} + shell: bash + # Pre-download pdfium and place it at packages/python/liteparse/ + # so the `[tool.maturin] include` rule packs it into the wheel during + # the build below. This is what gets the dylib listed in the wheel's + # RECORD manifest — appending it after the build leaves RECORD out of + # sync, which Poetry warns about. + run: bash packages/python/scripts/download-pdfium.sh ${{ matrix.target }} + + - name: Build wheels (glibc) + if: ${{ !matrix.musl }} + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.target }} + args: --release --out dist --find-interpreter + manylinux: 2_28 + working-directory: packages/python + before-script-linux: | + if command -v yum &> /dev/null; then + yum install -y tesseract-devel leptonica-devel openssl-devel pkgconfig perl-IPC-Cmd || true + elif command -v apt-get &> /dev/null; then + apt-get update && apt-get install -y libtesseract-dev libleptonica-dev libssl-dev pkg-config || true + elif command -v apk &> /dev/null; then + apk add tesseract-ocr-dev leptonica-dev openssl-dev pkgconf || true + fi + + - name: Build wheels (musl container) + if: ${{ matrix.musl }} + # Same pattern as the node musl build: run inside a vanilla + # python:3-alpine with clang+libc++ from apk. Bypasses maturin-action's + # musllinux_1_2 cross image, which causes FORTIFY_SOURCE issues with + # tesseract's C++ build and broken host-openssl for build-deps. + run: | + chmod +x scripts/build-musl-py.sh + docker run --rm \ + -v "$GITHUB_WORKSPACE":/work \ + -v "$HOME/.cargo/registry":/root/.cargo/registry \ + -v "$HOME/.cargo/git":/root/.cargo/git \ + -v "$HOME/.cache/pdfium-rs":/root/.cache/pdfium-rs \ + -w /work \ + python:3.12-alpine \ + /work/scripts/build-musl-py.sh + # The container runs as root; fix perms so subsequent steps can + # read/upload the produced wheel. + sudo chmod -R a+rw packages/python/dist/ || true + + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-${{ matrix.target }} + path: packages/python/dist/*.whl + if-no-files-found: error + + build-sdist: + needs: validate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Build sdist + uses: PyO3/maturin-action@v1 + with: + command: sdist + args: --out dist + working-directory: packages/python + + - name: Upload sdist + uses: actions/upload-artifact@v4 + with: + name: sdist + path: packages/python/dist/*.tar.gz + if-no-files-found: error + + test-wheels: + needs: build-wheels + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + target: aarch64-apple-darwin + - os: macos-26-intel + target: x86_64-apple-darwin + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + - os: windows-latest + target: x86_64-pc-windows-msvc + - os: windows-11-arm + target: aarch64-pc-windows-msvc + runs-on: ${{ matrix.os }} + name: Test ${{ matrix.target }} + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Download wheels + uses: actions/download-artifact@v4 + with: + name: wheels-${{ matrix.target }} + path: dist + + - name: Install wheel + run: pip install --no-index --find-links dist liteparse + + - name: Smoke test + run: | + python -c " + from liteparse import LiteParse, ParseResult, ParsedPage, TextItem, ScreenshotResult + parser = LiteParse(ocr_enabled=False, quiet=True) + print('LiteParse loaded successfully') + print(repr(parser)) + " + + - name: Parse test + run: | + python -c " + from liteparse import LiteParse + parser = LiteParse(ocr_enabled=False, quiet=True, max_pages=2) + result = parser.parse('demo/docs/apple-10k-2024.pdf') + assert result.num_pages == 2, f'Expected 2 pages, got {result.num_pages}' + assert len(result.text) > 0, 'Expected non-empty text' + page = result.get_page(1) + assert page is not None, 'Expected page 1' + assert len(page.text_items) > 0, 'Expected text items' + print(f'Parsed {result.num_pages} pages, {len(result.text)} chars') + " + + - name: Bytes parse test + run: | + python -c " + from liteparse import LiteParse + parser = LiteParse(ocr_enabled=False, quiet=True, max_pages=1) + with open('demo/docs/apple-10k-2024.pdf', 'rb') as f: + data = f.read() + result = parser.parse(data) + assert result.num_pages == 1 + assert len(result.text) > 0 + print('Bytes parse test passed') + " + + - name: Screenshot test + run: | + python -c " + from liteparse import LiteParse + parser = LiteParse(ocr_enabled=False, quiet=True) + screenshots = parser.screenshot('demo/docs/apple-10k-2024.pdf', page_numbers=[1]) + assert len(screenshots) == 1 + assert screenshots[0].page_num == 1 + assert len(screenshots[0].image_bytes) > 0 + print(f'Screenshot: {screenshots[0].width}x{screenshots[0].height}, {len(screenshots[0].image_bytes)} bytes') + " + + - name: CLI test + run: | + lit --version + lit parse demo/docs/apple-10k-2024.pdf --no-ocr --max-pages 1 -q | head -5 + echo "CLI test passed" + + build-cli: + needs: validate + strategy: + matrix: + include: + - target: x86_64-unknown-linux-gnu + os: ubuntu-22.04 + artifact: lit-linux-x64 + - target: aarch64-unknown-linux-gnu + os: ubuntu-22.04-arm + artifact: lit-linux-arm64 + - target: aarch64-apple-darwin + os: macos-latest + artifact: lit-darwin-arm64 + - target: x86_64-apple-darwin + os: macos-26-intel + artifact: lit-darwin-x64 + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@1.95.0 + with: + targets: ${{ matrix.target }} + + - name: Install system dependencies (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libtesseract-dev libleptonica-dev + + - name: Install system dependencies (macOS) + if: runner.os == 'macOS' + run: brew install tesseract leptonica + + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + ~/.cache/pdfium-rs + ~/Library/Caches/pdfium-rs + target + key: ${{ runner.os }}-${{ matrix.target }}-cargo-cli-${{ hashFiles('**/Cargo.lock') }} + + - name: Build + run: cargo build --release --target ${{ matrix.target }} -p liteparse + + - name: Package binary + run: | + mkdir -p dist + cp target/${{ matrix.target }}/release/lit dist/${{ matrix.artifact }} + chmod +x dist/${{ matrix.artifact }} + tar -czf dist/${{ matrix.artifact }}.tar.gz -C dist ${{ matrix.artifact }} + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact }} + path: dist/${{ matrix.artifact }}.tar.gz + + publish: + needs: [test-wheels, build-sdist, build-cli] + runs-on: ubuntu-latest + name: Publish & Release + + steps: + - uses: actions/checkout@v4 + + - name: Download all wheels + uses: actions/download-artifact@v4 + with: + pattern: wheels-* + path: dist + merge-multiple: true + + - name: Download sdist + uses: actions/download-artifact@v4 + with: + name: sdist + path: dist + + - name: Download CLI artifacts + uses: actions/download-artifact@v4 + with: + pattern: lit-* + path: cli-artifacts + + - name: Publish to PyPI + if: ${{ !inputs.dry-run }} + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist/ + password: ${{ secrets.PYPI_TOKEN }} + skip-existing: true + + - name: Create git tag + if: ${{ !inputs.dry-run }} + run: | + git tag "python-v${{ inputs.version }}" + git push origin "python-v${{ inputs.version }}" + + - name: Create GitHub Release + if: ${{ !inputs.dry-run }} + uses: softprops/action-gh-release@v2 + with: + tag_name: python-v${{ inputs.version }} + name: "Python v${{ inputs.version }}" + generate_release_notes: true + files: cli-artifacts/**/*.tar.gz + + - name: Summary (dry run) + if: ${{ inputs.dry-run }} + run: | + echo "## Dry Run Summary" >> $GITHUB_STEP_SUMMARY + echo "Would release python-v${{ inputs.version }}" >> $GITHUB_STEP_SUMMARY + echo "### Wheels:" >> $GITHUB_STEP_SUMMARY + find dist -name "*.whl" -exec echo "- {}" \; >> $GITHUB_STEP_SUMMARY + echo "### CLI artifacts:" >> $GITHUB_STEP_SUMMARY + find cli-artifacts -name "*.tar.gz" -exec echo "- {}" \; >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/release-wasm.yml b/.github/workflows/release-wasm.yml new file mode 100644 index 0000000..de0cc66 --- /dev/null +++ b/.github/workflows/release-wasm.yml @@ -0,0 +1,133 @@ +name: Release - WASM + +on: + workflow_dispatch: + inputs: + version: + description: 'Version to release (e.g. 2.1.0)' + required: true + type: string + beta: + description: 'Publish as beta (npm tag: beta instead of latest)' + type: boolean + default: false + dry-run: + description: 'Dry run (build but do not publish)' + type: boolean + default: false + +permissions: + contents: write + id-token: write + +env: + CARGO_TERM_COLOR: always + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Validate version + run: | + PKG_VERSION=$(node -p "require('./packages/wasm/package.json').version") + if [ "$PKG_VERSION" != "${{ inputs.version }}" ]; then + echo "::error::Version mismatch: package.json has $PKG_VERSION, expected ${{ inputs.version }}" + exit 1 + fi + echo "Version validated: ${{ inputs.version }}" + + build: + needs: validate + runs-on: ubuntu-latest + name: Build WASM + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - uses: dtolnay/rust-toolchain@1.95.0 + with: + targets: wasm32-unknown-unknown + + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + ~/.cache/pdfium-rs + target + key: ${{ runner.os }}-cargo-wasm-release-${{ hashFiles('**/Cargo.lock') }} + + - name: Install wasm-pack + run: cargo install wasm-pack --locked + + - name: Build WASM package + working-directory: packages/wasm + run: npm run build + + - name: Verify output + run: | + test -f packages/wasm/pkg/liteparse_wasm_bg.wasm + test -f packages/wasm/pkg/liteparse_wasm.js + test -f packages/wasm/pkg/liteparse_wasm.d.ts + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: wasm-pkg + path: packages/wasm/pkg/ + if-no-files-found: error + + publish: + needs: build + runs-on: ubuntu-latest + name: Publish to npm + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + registry-url: https://registry.npmjs.org + + - name: Download WASM artifacts + uses: actions/download-artifact@v4 + with: + name: wasm-pkg + path: packages/wasm/pkg + + - name: Publish + if: ${{ !inputs.dry-run }} + working-directory: packages/wasm + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_TAG: ${{ inputs.beta && 'beta' || 'latest' }} + run: npm publish --access public --provenance --tag "$NPM_TAG" --ignore-scripts + + - name: Create git tag + if: ${{ !inputs.dry-run }} + run: | + git tag "wasm-v${{ inputs.version }}" + git push origin "wasm-v${{ inputs.version }}" + + - name: Create GitHub Release + if: ${{ !inputs.dry-run }} + uses: softprops/action-gh-release@v2 + with: + tag_name: wasm-v${{ inputs.version }} + name: "WASM v${{ inputs.version }}" + generate_release_notes: true + + - name: Summary (dry run) + if: ${{ inputs.dry-run }} + run: | + echo "## Dry Run Summary" >> $GITHUB_STEP_SUMMARY + echo "Would release wasm-v${{ inputs.version }}" >> $GITHUB_STEP_SUMMARY + echo "### Package: @llamaindex/liteparse-wasm" >> $GITHUB_STEP_SUMMARY + WASM_SIZE=$(stat --format=%s packages/wasm/pkg/liteparse_wasm_bg.wasm) + echo "WASM binary size: $(numfmt --to=iec $WASM_SIZE)" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/sync-docs.yml b/.github/workflows/sync-docs.yml new file mode 100644 index 0000000..9a43b48 --- /dev/null +++ b/.github/workflows/sync-docs.yml @@ -0,0 +1,49 @@ +name: Sync Docs to Developer Hub +on: + push: + branches: [main] + paths: + - "docs/**" + workflow_dispatch: +jobs: + sync-docs: + runs-on: ubuntu-latest + steps: + - name: Checkout source repo + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b + + - name: Install Rust nightly + uses: dtolnay/rust-toolchain@nightly + + - name: Install rustdoc-md + run: cargo install rustdoc-md + + - name: Checkout docs repo + uses: actions/checkout@v4 + with: + repository: run-llama/developers + token: ${{ secrets.DEVELOPER_HUB_TOKEN }} + path: developer-hub + + - name: Generate API docs + run: ./scripts/generate-api-docs.sh + + - name: Sync docs + run: ./scripts/sync-docs-to-developer-hub.sh developer-hub + + - name: Commit and push + working-directory: developer-hub + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add src/content/docs/liteparse + if git diff --staged --quiet; then + echo "No docs changes to sync." + exit 0 + fi + SOURCE_SHA="${GITHUB_SHA::8}" + git commit -m "sync: liteparse docs from run-llama/liteparse@${SOURCE_SHA}" + git push diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a620ebc --- /dev/null +++ b/.gitignore @@ -0,0 +1,80 @@ +# Dependencies +node_modules/ +pnpm-lock.yaml + +# Build output +dist/ +dist-browser-compat/ +bin/ +sea-prep.blob +sea-config.json + +# Environment +*claude* +.env +.env.local + +# TypeScript +*.tsbuildinfo + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* +pnpm-debug.log* + +# Test output +screenshots/ +*.pdf +!tests/fixtures/*.pdf + +# Temporary files +tmp/ +temp/ +*.txt +*.png +!integration_tests_data/receipt.png +!integration_tests_data/sample3.docx.doc +!integration_tests_data/sample.pdf +deno.json + +# Python files +*venv* +__pycache__ +*.pyc +*cache* + +# Test/dev directories (local testing) +e2e-output/ +parsed_dataset/ +hard_docs/ +hard_docs_parsed/ +test-docs/ +test-docs-output/ +arxiv_screenshots/ +more_hard_docs/ +nytimes_imgs/ +ac/ +ltt/ + +# OCR data files +*.traineddata + +# Docs +.docs-preview/ +docs/src/content/docs/liteparse/api.md +docs/src/content/docs/liteparse/.api-tmp/ + +# Rust +packages/rust/target/ +packages/rust/vendor/ +target/ diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..ad28926 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,6 @@ +dist +node_modules +src/vendor +*.md +pnpm-lock.yaml +package-lock.json diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..ea0dbea --- /dev/null +++ b/.prettierrc @@ -0,0 +1,8 @@ +{ + "semi": true, + "singleQuote": false, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 100, + "bracketSpacing": true +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e751c75 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,174 @@ +# LiteParse - Agent Documentation + +> This file provides comprehensive context for AI coding agents working on this codebase. + +## Project Overview + +**LiteParse** is an open-source PDF parsing library written in **Rust**, focused on fast, lightweight document processing with spatial text extraction. It runs entirely locally with zero cloud dependencies by default. + +Language bindings are provided for **Node.js/TypeScript** (via napi-rs), **Python** (via PyO3), and **WebAssembly** (via wasm-bindgen). + +### Key Capabilities +- **Spatial text extraction** with precise bounding boxes +- **Flexible OCR** (built-in Tesseract or pluggable HTTP servers) +- **Multi-format support** (PDFs, DOCX, XLSX, PPTX, images via conversion) +- **Multi-language bindings**: Rust, Node.js/TypeScript, Python, Browser (WASM) +- **CLI** available from all installation methods (`cargo`, `npm`, `pip`) + +## Directory Structure + +``` +liteparse/ +├── crates/ +│ ├── liteparse/ # Core Rust library + CLI binary +│ │ └── src/ +│ │ ├── main.rs # CLI entry point (clap) +│ │ ├── lib.rs # Library root +│ │ ├── parser.rs # LiteParse orchestrator +│ │ ├── config.rs # Configuration types and defaults +│ │ ├── types.rs # Core data types (ParseResult, TextItem, etc.) +│ │ ├── projection.rs # Spatial grid projection (layout reconstruction) +│ │ ├── extract.rs # Raw text extraction from PDFium +│ │ ├── render.rs # Page rendering / screenshots +│ │ ├── conversion.rs # Non-PDF format conversion (LibreOffice, ImageMagick) +│ │ ├── ocr_merge.rs # Merging OCR results with native text +│ │ ├── error.rs # Error types +│ │ ├── ocr/ # OCR engine implementations +│ │ │ ├── mod.rs # OcrEngine trait +│ │ │ ├── tesseract.rs # Built-in Tesseract OCR +│ │ │ └── http_simple.rs # HTTP OCR server client +│ │ └── output/ # Output formatters +│ │ ├── mod.rs +│ │ ├── json.rs +│ │ └── text.rs +│ ├── liteparse-napi/ # Node.js bindings (napi-rs) +│ ├── liteparse-python/ # Python bindings (PyO3 / maturin) +│ ├── liteparse-wasm/ # WASM bindings (wasm-bindgen) +│ ├── pdfium/ # Rust wrapper around PDFium C API +│ └── pdfium-sys/ # PDFium FFI (C → Rust) bindings +├── packages/ +│ ├── node/ # npm package: TS wrapper + CLI around native binary +│ │ └── src/ +│ │ ├── lib.ts # Public LiteParse class for Node.js +│ │ ├── cli.ts # CLI entry point (commander) +│ │ └── native.ts # Native binary loader +│ ├── python/ # PyPI package: Python wrapper around native binary +│ │ └── liteparse/ +│ │ ├── __init__.py +│ │ ├── parser.py # Public LiteParse class for Python +│ │ ├── types.py # Python dataclass types +│ │ └── cli.py # CLI entry point +│ └── wasm/ # WASM npm package +├── ocr/ # Example OCR server implementations +│ ├── easyocr/ # EasyOCR wrapper server +│ └── paddleocr/ # PaddleOCR wrapper server +└── Cargo.toml # Workspace root +``` + +## Data Flow + +1. **Input**: File path or raw bytes received (any supported format) +2. **Conversion** (if needed): Non-PDF formats converted to PDF via LibreOffice/ImageMagick +3. **PDF Loading**: PDFium extracts text items, images, metadata +4. **OCR** (if enabled): Pages rendered and OCR'd for text-sparse areas +5. **Grid Projection**: Spatial reconstruction of text layout using anchor system +6. **Post-processing**: Bounding boxes, text cleanup +7. **Output**: Formatted as JSON or plain text + +## Key Design Decisions + +### 1. Rust Core with Language Bindings +The core parsing logic is written in Rust for performance and safety. Language-specific crates expose the same API surface: +- `liteparse-napi` → Node.js via napi-rs +- `liteparse-python` → Python via PyO3/maturin +- `liteparse-wasm` → Browser via wasm-bindgen + +Each binding crate is thin — it wraps the core `liteparse` crate's types and async API. + +### 2. OCR Engine Trait +OCR functionality uses a trait-based abstraction (`OcrEngine`). This allows: +- Built-in Tesseract (default, compiled in via `tesseract-rs`) +- HTTP OCR server client for remote engines +- Custom JS-side OCR in the WASM build via a callback interface + +### 3. Spatial Grid Projection +The most complex (and important!) part of the codebase (`crates/liteparse/src/projection.rs`). Uses: +- **Anchor-based layout**: Tracks text alignment (left, right, center, floating) +- **Forward anchors**: Carry alignment information between lines +- **Column detection**: Identifies multi-column layouts +- **Rotation handling**: Transforms 90°, 180°, 270° rotated text to correct reading order +- **OCR merging**: Combines native PDF text with OCR results, preserving confidence scores and source flags in output + +### 4. Selective OCR +OCR only runs on embedded images where text extraction failed, not the entire document. This balances accuracy with performance. + +### 5. Configuration +Uses a default-first approach where users only override what they need. See `crates/liteparse/src/config.rs` for defaults. + +### 6. Format Conversion via External Tools +Rather than implementing format parsers, LiteParse converts non-PDF formats using system tools (LibreOffice, ImageMagick) into PDF. This provides broad format support with minimal code. + +## Common Tasks + +### Adding a New Output Format +1. Create new file in `crates/liteparse/src/output/` +2. Add variant to `OutputFormat` enum in `config.rs` +3. Wire it up in `main.rs` and binding crates + +### Adding a New OCR Engine +1. Implement `OcrEngine` trait in `crates/liteparse/src/ocr/` +2. Add initialization logic in `parser.rs` +3. Add configuration options in `config.rs` + +### Modifying Text Extraction Logic +Key files in `crates/liteparse/src/`: +- `projection.rs` — Layout reconstruction (most complex) +- `extract.rs` — Raw text item extraction from PDFium +- `ocr_merge.rs` — Merging OCR and native text + +### Adding CLI Options +1. Add field to `LiteParseConfig` in `config.rs` +2. Add clap arg in `main.rs` +3. Wire through `parser.rs` +4. Expose in binding crates (`liteparse-napi`, `liteparse-python`, `liteparse-wasm`) + +### Adding / Modifying Node.js Wrapper +- Edit `packages/node/src/lib.ts` for library API changes +- Edit `packages/node/src/cli.ts` for CLI changes +- The native binary interface is defined in `packages/node/src/native.ts` + +### Adding / Modifying Python Wrapper +- Edit `packages/python/liteparse/parser.py` for library API changes +- Types are in `packages/python/liteparse/types.py` +- CLI entry point is `packages/python/liteparse/cli.py` + +## Key Dependencies + +| Dependency | Purpose | +|------------|---------| +| `pdfium` (C library) | PDF text extraction and rendering | +| `tesseract-rs` | Built-in OCR engine (optional, via `tesseract` feature) | +| `clap` | CLI framework | +| `serde` / `serde_json` | Serialization | +| `tokio` | Async runtime | +| `reqwest` | HTTP client (for OCR server) | +| `image` | Image processing (PNG encoding) | +| `napi-rs` | Node.js native bindings | +| `pyo3` / `maturin` | Python native bindings | +| `wasm-bindgen` | WASM bindings | + +## Entry Points + +- **Rust CLI**: `crates/liteparse/src/main.rs` +- **Rust Library**: `crates/liteparse/src/lib.rs` → `parser.rs` contains `LiteParse` struct +- **Node.js**: `packages/node/src/lib.ts` exports `LiteParse` class +- **Python**: `packages/python/liteparse/parser.py` exports `LiteParse` class +- **WASM**: `crates/liteparse-wasm/` exposes `LiteParse` via wasm-bindgen + +## Related Documentation + +- [User-facing documentation](README.md) +- [OCR API Specification](OCR_API_SPEC.md) +- [WASM package README](packages/wasm/README.md) +- [Python package README](packages/python/README.md) +- [OCR server examples](ocr/README.md) diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d9c3352 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,125 @@ +# @llamaindex/liteparse + +## 1.5.3 + +### Patch Changes + +- [#151](https://github.com/run-llama/liteparse/pull/151) [`e3d04b4`](https://github.com/run-llama/liteparse/commit/e3d04b44a922834a6be58def3373005fd3e669e0) Thanks [@logan-markewich](https://github.com/logan-markewich)! - Browser support guide + +## 1.5.2 + +### Patch Changes + +- [#140](https://github.com/run-llama/liteparse/pull/140) [`dae7e90`](https://github.com/run-llama/liteparse/commit/dae7e907d434efc052b808db0fe73b9e63feca5f) Thanks [@logan-markewich](https://github.com/logan-markewich)! - Better overlap detection and deduplication + +## 1.5.1 + +### Patch Changes + +- [#137](https://github.com/run-llama/liteparse/pull/137) [`64579fb`](https://github.com/run-llama/liteparse/commit/64579fb5e9d77c02686338a5fd8e51a172c0ac3e) Thanks [@abhinab-choudhury](https://github.com/abhinab-choudhury)! - Update CLI version tag + +## 1.5.0 + +### Minor Changes + +- [#130](https://github.com/run-llama/liteparse/pull/130) [`e29ba71`](https://github.com/run-llama/liteparse/commit/e29ba71853a011fd1bd0e3a4819e9783ef937155) Thanks [@logan-markewich](https://github.com/logan-markewich)! - Move to pdf.js v5 + +## 1.4.6 + +### Patch Changes + +- [#120](https://github.com/run-llama/liteparse/pull/120) [`9cde441`](https://github.com/run-llama/liteparse/commit/9cde441b106b9dbee055b228bf011c197126bef5) Thanks [@apprakash](https://github.com/apprakash)! - fix(gridProjection): merge sub-pixel overlapping text runs in canMerge + +## 1.4.5 + +### Patch Changes + +- [#118](https://github.com/run-llama/liteparse/pull/118) [`4fdf3c9`](https://github.com/run-llama/liteparse/commit/4fdf3c9358c22e50e742c2b7d866ae35b83a63cd) Thanks [@mkh09353](https://github.com/mkh09353)! - Honor the OCR rotation-correction option in the built-in Tesseract engine by mapping it to Tesseract.js auto-rotation. + +## 1.4.4 + +### Patch Changes + +- [#112](https://github.com/run-llama/liteparse/pull/112) [`0eda8fc`](https://github.com/run-llama/liteparse/commit/0eda8fc27d6ad2cf835894efecc22c5239025447) Thanks [@logan-markewich](https://github.com/logan-markewich)! - Improve buggy-font handling resolution + +## 1.4.3 + +### Patch Changes + +- [#108](https://github.com/run-llama/liteparse/pull/108) [`f4ee121`](https://github.com/run-llama/liteparse/commit/f4ee121d800b01f007a1d970d8197eda6673b392) Thanks [@Winds-AI](https://github.com/Winds-AI)! - Fix OCR bullet line spacing inflation + +## 1.4.2 + +### Patch Changes + +- [#91](https://github.com/run-llama/liteparse/pull/91) [`5bb3a3b`](https://github.com/run-llama/liteparse/commit/5bb3a3b214b148bec86aaf979ea561611a7df763) Thanks [@AdemBoukhris457](https://github.com/AdemBoukhris457)! - fix: use path.join for screenshot output filepath + +- [#97](https://github.com/run-llama/liteparse/pull/97) [`1100bdb`](https://github.com/run-llama/liteparse/commit/1100bdbcb7293abb63d3eb38ff295669618265e0) Thanks [@AdemBoukhris457](https://github.com/AdemBoukhris457)! - fix: return null from extension detection for unrecognizable formats + +- [#89](https://github.com/run-llama/liteparse/pull/89) [`71f6621`](https://github.com/run-llama/liteparse/commit/71f6621dd413195b3634b747c6cf7cde90966035) Thanks [@AdemBoukhris457](https://github.com/AdemBoukhris457)! - perf: cache PDFium document across page operations + +- [#99](https://github.com/run-llama/liteparse/pull/99) [`b7a3080`](https://github.com/run-llama/liteparse/commit/b7a3080d89dccc4fd3cec65f559d4ebeff12e9bc) Thanks [@Winds-AI](https://github.com/Winds-AI)! - fix: validate ImageMagick executables before using convert + +- [#95](https://github.com/run-llama/liteparse/pull/95) [`2718912`](https://github.com/run-llama/liteparse/commit/2718912b520ffc475d8e2541f5430b0823bd0acd) Thanks [@AdemBoukhris457](https://github.com/AdemBoukhris457)! - fix: guard indexOf before splice in grid anchor resolution + +## 1.4.1 + +### Patch Changes + +- [#84](https://github.com/run-llama/liteparse/pull/84) [`53e02df`](https://github.com/run-llama/liteparse/commit/53e02dff71d8f83cb2539b3a856889a5c3a38b52) Thanks [@AdemBoukhris457](https://github.com/AdemBoukhris457)! - Ensure parse cleans up temp files + +- [#86](https://github.com/run-llama/liteparse/pull/86) [`48d86f1`](https://github.com/run-llama/liteparse/commit/48d86f1f2a0fc9d0cad29d3d30476f5cd0844d85) Thanks [@AdemBoukhris457](https://github.com/AdemBoukhris457)! - Ensure screenshot converts formats when possible + +## 1.4.0 + +### Minor Changes + +- [#64](https://github.com/run-llama/liteparse/pull/64) [`ab3df58`](https://github.com/run-llama/liteparse/commit/ab3df583fcbf6f0333a0649f7b4bd7331e5d547a) Thanks [@llrightll](https://github.com/llrightll)! - Add confidence scores to TextItems + +- [#71](https://github.com/run-llama/liteparse/pull/71) [`57adda1`](https://github.com/run-llama/liteparse/commit/57adda15e6a45832e7f3a1311fb475c7221c1dc8) Thanks [@saravananravi08](https://github.com/saravananravi08)! - Add internal image detection for OCR + +### Patch Changes + +- [#78](https://github.com/run-llama/liteparse/pull/78) [`d341371`](https://github.com/run-llama/liteparse/commit/d341371eae7c2fa8feb234af732cf30e978230b3) Thanks [@logan-markewich](https://github.com/logan-markewich)! - Improve searchItems output on complex text + +## 1.3.2 + +### Patch Changes + +- [#55](https://github.com/run-llama/liteparse/pull/55) [`b57cb61`](https://github.com/run-llama/liteparse/commit/b57cb61de9371cbc1cf91f01aafc7e1fe912e520) Thanks [@hexapode](https://github.com/hexapode)! - Improve text projection on justified text + +## 1.3.1 + +### Patch Changes + +- [#70](https://github.com/run-llama/liteparse/pull/70) [`243dc05`](https://github.com/run-llama/liteparse/commit/243dc0556769a59cf59e6565a5657b7d2630fc97) Thanks [@saravananravi08](https://github.com/saravananravi08)! - fix: resolve standard font loading failure in Node.js + +## 1.3.0 + +### Minor Changes + +- [#67](https://github.com/run-llama/liteparse/pull/67) [`0542758`](https://github.com/run-llama/liteparse/commit/0542758f6239a1897d7553727ce3ec58c61ea7fe) Thanks [@logan-markewich](https://github.com/logan-markewich)! - Bbox utils and tesseract error handling + +## 1.2.0 + +### Minor Changes + +- [#56](https://github.com/run-llama/liteparse/pull/56) [`31b43f9`](https://github.com/run-llama/liteparse/commit/31b43f9666ce6df85e90a44be1e859c615bda757) Thanks [@logan-markewich](https://github.com/logan-markewich)! - Add CLI Stdin support for files, urls, etc. + +## 1.1.0 + +### Minor Changes + +- [#51](https://github.com/run-llama/liteparse/pull/51) [`7b421c6`](https://github.com/run-llama/liteparse/commit/7b421c61f2e2ffa04e68bb2bbe02dbf18e261507) Thanks [@logan-markewich](https://github.com/logan-markewich)! - Support for password protected PDFs + +## 1.0.1 + +### Patch Changes + +- [#40](https://github.com/run-llama/liteparse/pull/40) [`bb863c4`](https://github.com/run-llama/liteparse/commit/bb863c46f568c5c192e7c6ec608e350303668bba) Thanks [@logan-markewich](https://github.com/logan-markewich)! - Add support for TESSDATA_PREFIX and better error messaging on tesseract network errors + +## 1.0.0 + +### Major Changes + +- [#31](https://github.com/run-llama/liteparse/pull/31) [`56ba21c`](https://github.com/run-llama/liteparse/commit/56ba21cb63e8223440b039f49eab710ba089e375) Thanks [@logan-markewich](https://github.com/logan-markewich)! - LiteParse v1.0 launch diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..355a8ca --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,203 @@ +# Contributing to LiteParse + +Thank you for your interest in contributing to LiteParse! This document provides guidelines and information for contributors. + +## Getting Started + +1. Fork the repository +2. Clone your fork: + ```bash + git clone https://github.com/YOUR_USERNAME/liteparse.git + cd liteparse + ``` +3. Install prerequisites (see [Development Prerequisites](#development-prerequisites)) + +## What to Contribute? + +In this project, we welcome a wide range of contributions, but we do want to maintain the spirit of the project. We are primarily focused on: + +- Core algorithms for PDF parsing and text extraction +- OCR integrations and improvements +- Different types or modifications to output formats + +We are less interested in: + +- Markdown output +- Any LLM integration or agent code +- Anything that doesn't directly relate to improving the core parsing and extraction capabilities + +## Architecture Overview + +LiteParse is written in Rust with bindings for multiple platforms: + +``` +crates/ +├── liteparse/ # Core Rust library (parsing, grid projection, OCR, output) +├── pdfium-sys/ # Raw FFI bindings to PDFium (auto-downloads pdfium) +├── pdfium/ # Safe Rust wrapper around pdfium-sys +├── liteparse-napi/ # Node.js native addon (napi-rs) +├── liteparse-python/ # Python extension module (PyO3 + maturin) +└── liteparse-wasm/ # WebAssembly bindings (wasm-bindgen + wasm-pack) + +packages/ +├── node/ # Node.js package (@llamaindex/liteparse) +├── python/ # Python package (liteparse) +└── wasm/ # WASM package (@llamaindex/liteparse-wasm) +``` + +## Development Prerequisites + +You'll need the following tools installed: + +| Tool | Purpose | Install | +|------|---------|---------| +| **Rust toolchain** | Core library and all bindings | [rustup.rs](https://rustup.rs) | +| **napi-rs CLI** | Node.js native addon builds | `npm i -g @napi-rs/cli` | +| **maturin** | Python extension builds | `pip install maturin` | +| **wasm-pack** | WebAssembly builds | `cargo install wasm-pack` | + +PDFium is **auto-downloaded** by the `pdfium-sys` build script — no manual setup needed. For WASM, a static `libpdfium.a` is downloaded and linked into the `.wasm` binary. For native targets, a shared library (`.dylib`/`.so`/`.dll`) is downloaded and copied to the build output. + +## Building + +### Important: Workspace-wide `cargo build` will fail + +The binding crates (`liteparse-python`, `liteparse-napi`, `liteparse-wasm`) each require their own specialized toolchain to link correctly. A plain `cargo build` at the workspace root will fail because, for example, the Python bindings need a Python interpreter to resolve `_Py*` symbols. + +### Core Rust library only + +```bash +cargo build -p liteparse +``` + +### Node.js bindings (napi-rs) + +```bash +cd packages/node +npm install +npm run build # Builds Rust → .node addon, copies pdfium, compiles TS +``` + +Individual steps: +```bash +npm run build:rs # napi build (compiles liteparse-napi crate) +npm run build:pdfium # Copies pdfium shared library alongside the addon +npm run build:ts # Compiles TypeScript wrapper +``` + +To test locally, import from the package directly or use `npm link`: +```js +import { LiteParse } from './packages/node/dist/lib.js'; +``` + +### Python bindings (maturin + PyO3) + +```bash +cd packages/python +maturin develop # Builds Rust and installs into active virtualenv +``` + +`maturin develop` compiles the `liteparse-python` crate and installs the resulting package into your current Python virtual environment. Then test with: +```python +import liteparse +``` + +### WASM bindings (wasm-pack) + +```bash +cd packages/wasm +npm run build # Browser target (--target web) +npm run build:bundler # Bundler target (webpack/vite) +npm run build:nodejs # Node.js target +``` + +PDFium is statically linked into the `.wasm` binary — the output in `packages/wasm/pkg/` is fully self-contained. + +## Development Workflow + +### Testing Local Changes + +```bash +# Parse a document (Node.js CLI) +cd packages/node && npm run build +node dist/cli.js parse document.pdf + +# Python CLI +cd packages/python && maturin develop +lit parse document.pdf +``` + +### Linting & Formatting + +```bash +cargo fmt # Format Rust code +cargo clippy # Lint Rust code +``` + +### Debugging Grid Projection + +When working on the grid projection algorithm, you can enable built-in debug logging and visual output instead of adding ad-hoc `console.log` statements. + +**Debug logging** traces every decision the projection makes — block detection, anchor extraction, snap assignment, rendering, and flowing text classification: + +```bash +lit parse document.pdf --debug +lit parse document.pdf --debug --debug-page 3 +lit parse document.pdf --debug --debug-text-filter "Total" "Revenue" +lit parse document.pdf --debug --debug-region "0,100,300,200" +lit parse document.pdf --debug --debug-output ./debug-output +``` + +**Visual grid export** generates PNG images showing text boxes color-coded by snap type (blue=left, red=right, green=center, gray=floating, yellow=flowing) with anchor lines overlaid: + +```bash +lit parse document.pdf --debug-visualize +lit parse document.pdf --debug-visualize --debug-output ./my-debug +``` + +## Pull Requests + +1. Fork and create a feature branch from `main` +2. Make your changes +3. Ensure linting passes (`cargo fmt --check && cargo clippy`) +4. Submit a pull request + +When you submit a PR, a number of CICD checks will run. Among these, your code will be tested against a regression suite of documents to ensure that your changes don't break existing parsing capabilities. It will be up to the maintainers discretion to determine if any changes to the regression set are expected/positive or unexpected/negative. + +### PR Guidelines + +- Keep PRs focused on a single change +- Update documentation if needed +- Add tests for new functionality +- For parsing issues, include a test document if possible + +## Reporting Issues + +### Parsing Issues + +If you're reporting a problem with document parsing: + +1. **You must attach the document** or provide a way to reproduce the issue +2. Include the command you ran +3. Show the expected vs actual output +4. Include your LiteParse version (`lit --version`) + +Issues without reproducible examples will be closed. + +### Bug Reports + +For other bugs: +1. Describe what you expected vs what happened +2. Include steps to reproduce +3. Include error messages/stack traces +4. Include version information + +## Questions? + +- Open a [Discussion](https://github.com/run-llama/liteparse/discussions) for questions +- Check existing issues before opening new ones +- Read the [README](README.md) for usage documentation + +## License + +By contributing, you agree that your contributions will be licensed under the Apache 2.0 License. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..8179e9e --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3456 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-lc-rs" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bindgen" +version = "0.71.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn", +] + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq 0.4.2", + "cpufeatures 0.3.0", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "bzip2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" +dependencies = [ + "bzip2-sys", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "cc" +version = "1.2.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "ctor" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "deflate64" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "file-format" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55d9ccda37e95b4f0978a3074b4a9939979103a7256459cfb449c9c84d1adf23" + +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.7.5", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "liteparse" +version = "2.5.0" +dependencies = [ + "blake3", + "clap", + "file-format", + "image", + "liteparse-pdfium", + "liteparse-pdfium-sys", + "ordered-float", + "reqwest 0.13.3", + "rmp", + "serde", + "serde_json", + "serial_test", + "tempfile", + "tesseract-rs", + "thiserror 2.0.18", + "tokio", + "url", + "web-time", +] + +[[package]] +name = "liteparse-napi" +version = "2.5.0" +dependencies = [ + "image", + "liteparse", + "liteparse-pdfium", + "liteparse-pdfium-sys", + "napi", + "napi-build", + "napi-derive", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "liteparse-pdfium" +version = "1.3.0" +dependencies = [ + "blake3", + "liteparse-pdfium-sys", +] + +[[package]] +name = "liteparse-pdfium-sys" +version = "1.3.0" +dependencies = [ + "bindgen", + "flate2", + "libloading", + "tar", + "ureq", +] + +[[package]] +name = "liteparse-python" +version = "2.5.0" +dependencies = [ + "anyhow", + "clap", + "image", + "liteparse", + "liteparse-pdfium", + "liteparse-pdfium-sys", + "pyo3", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "liteparse-wasm" +version = "2.5.0" +dependencies = [ + "console_error_panic_hook", + "js-sys", + "liteparse", + "serde", + "serde-wasm-bindgen", + "serde_json", + "tsify-next", + "wasm-bindgen", + "wasm-bindgen-futures", +] + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "lzma-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" +dependencies = [ + "byteorder", + "crc", +] + +[[package]] +name = "lzma-sys" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "napi" +version = "2.16.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55740c4ae1d8696773c78fdafd5d0e5fe9bc9f1b071c7ba493ba5c413a9184f3" +dependencies = [ + "bitflags", + "ctor", + "napi-derive", + "napi-sys", + "once_cell", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "napi-build" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9c366d2c8c60b86fa632df75f745509b52f9128f91a6bad4c796e44abb505e1" + +[[package]] +name = "napi-derive" +version = "2.16.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cbe2585d8ac223f7d34f13701434b9d5f4eb9c332cccce8dee57ea18ab8ab0c" +dependencies = [ + "cfg-if", + "convert_case", + "napi-derive-backend", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "napi-derive-backend" +version = "1.0.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1639aaa9eeb76e91c6ae66da8ce3e89e921cd3885e99ec85f4abacae72fc91bf" +dependencies = [ + "convert_case", + "once_cell", + "proc-macro2", + "quote", + "regex", + "semver", + "syn", +] + +[[package]] +name = "napi-sys" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "427802e8ec3a734331fec1035594a210ce1ff4dc5bc1950530920ab717964ea3" +dependencies = [ + "libloading", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl" +version = "0.10.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "ordered-float" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" +dependencies = [ + "num-traits", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "pyo3" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd274650b21d4bfc26a0a47587962c1edb425f69287324355cd040c3ea66071c" +dependencies = [ + "libc", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", +] + +[[package]] +name = "pyo3-build-config" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e2a7d2f0d013342f295c048ad19237add5154a55b1c5a254c0ec93d4109078" +dependencies = [ + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca85c467da1bbc8d866eea5deff9cf29ea5f7785054a17da36e65bda9c05845b" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ac53762fd065daa3194dd09337a38bd793a188100fd1a9304c4ab312d901771" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca3a1557399783172dc5bf39cfca835157732532cba56b71d2292161e53b362" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.52.0", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "reqwest" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scc" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" +dependencies = [ + "sdd", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sdd" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serial_test" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f" +dependencies = [ + "futures-executor", + "futures-util", + "log", + "once_cell", + "parking_lot", + "scc", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tesseract-rs" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d9125df4e77364963a9dc5dd2588bda37e30046336c79ca32a38f911e49a563" +dependencies = [ + "cc", + "cmake", + "glob", + "libc", + "reqwest 0.12.28", + "thiserror 1.0.69", + "zip", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tsify-next" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d0f2208feeb5f7a6edb15a2389c14cd42480ef6417318316bb866da5806a61d" +dependencies = [ + "serde", + "serde-wasm-bindgen", + "tsify-next-macros", + "wasm-bindgen", +] + +[[package]] +name = "tsify-next-macros" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f81253930d0d388a3ab8fa4ae56da9973ab171ef833d1be2e9080fc3ce502bd6" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "xz2" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" +dependencies = [ + "lzma-sys", +] + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "aes", + "arbitrary", + "bzip2", + "constant_time_eq 0.3.1", + "crc32fast", + "crossbeam-utils", + "deflate64", + "displaydoc", + "flate2", + "getrandom 0.3.4", + "hmac", + "indexmap", + "lzma-rs", + "memchr", + "pbkdf2", + "sha1", + "thiserror 2.0.18", + "time", + "xz2", + "zeroize", + "zopfli", + "zstd", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..32b4e71 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,15 @@ +[workspace] +resolver = "2" +members = [ + "crates/pdfium-sys", + "crates/pdfium", + "crates/liteparse", + "crates/liteparse-napi", + "crates/liteparse-python", + "crates/liteparse-wasm", +] + +[workspace.package] +edition = "2024" +license = "Apache-2.0" +repository = "https://github.com/run-llama/liteparse" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..dbe7209 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +# Stage 1: Build from source +FROM rust:1-bookworm AS builder + +RUN apt-get update && apt-get install -y \ + libclang-dev \ + libtesseract-dev \ + libleptonica-dev \ + cmake \ + g++ \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /build +COPY Cargo.toml Cargo.lock ./ +COPY crates/ ./crates/ + +RUN cargo build --release + +# Stage 2: Minimal runtime image +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + libtesseract5 \ + liblept5 \ + tesseract-ocr-eng \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /build/target/release/lit /usr/local/bin/lit +# pdfium shared library +COPY --from=builder /root/.cache/pdfium-rs/ /usr/local/lib/pdfium-rs/ + +# Ensure pdfium is discoverable at runtime +ENV LD_LIBRARY_PATH="/usr/local/lib/pdfium-rs" + +RUN ln -s /usr/local/bin/lit /usr/local/bin/liteparse + +CMD ["/bin/sh"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..36dc61f --- /dev/null +++ b/Makefile @@ -0,0 +1,31 @@ +.PHONY: test lint lint-strict format format-check build + +all: test lint format + +test: + $(info ****************** running tests ******************) + cargo test --all + +lint-strict: + $(info ****************** running clippy in strict mode ******************) + cargo clippy --all-targets --all-features -- -D warnings # treat warnings as errors + +lint: + $(info ****************** running clippy in strict mode ******************) + cargo clippy --all-targets --all-features + +lint-fix: + $(info ****************** running clippy in strict mode ******************) + cargo clippy --fix --allow-dirty --all-targets --all-features + +format: + $(info ****************** formatting ******************) + cargo fmt --all + +format-check: + $(info ****************** checking formatting ******************) + cargo fmt --all --check + +build: + $(info ****************** building ******************) + cargo build diff --git a/OCR_API_SPEC.md b/OCR_API_SPEC.md new file mode 100644 index 0000000..ebf75ad --- /dev/null +++ b/OCR_API_SPEC.md @@ -0,0 +1,245 @@ +# LiteParse OCR API Specification + +This document defines the standard HTTP API that OCR servers must implement to work with LiteParse. + +## Overview + +LiteParse expects a simple HTTP endpoint that accepts an image and returns text with bounding boxes. Your OCR server can internally use any OCR engine (EasyOCR, PaddleOCR, Tesseract, Cloud APIs, etc.) as long as it conforms to this API. + +## Endpoint + +``` +POST /ocr +``` + +## Request Format + +**Content-Type:** `multipart/form-data` + +**Fields:** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `file` | binary | Yes | Image file (PNG, JPG, etc.) | +| `language` | string | No | Language code (default: `en`) | + +### Language Codes + +Use ISO 639-1 two-letter codes: +- `en` - English +- `zh` - Chinese +- `ja` - Japanese +- `ko` - Korean +- `fr` - French +- `de` - German +- `es` - Spanish +- `ar` - Arabic +- etc. + +Your server should map these to whatever format your underlying OCR engine expects. + +## Response Format + +**Content-Type:** `application/json` + +**Structure:** + +```json +{ + "results": [ + { + "text": "recognized text", + "bbox": [x1, y1, x2, y2], + "confidence": 0.95, + "polygon": [[x1, y1], [x2, y2], [x3, y3], [x4, y4]] + } + ] +} +``` + +**Fields:** + +| Field | Type | Description | +|-------|------|-------------| +| `results` | array | Array of text detection results | +| `results[].text` | string | Recognized text content | +| `results[].bbox` | [number, number, number, number] | Axis-aligned bounding box `[x1, y1, x2, y2]` where (x1,y1) is top-left and (x2,y2) is bottom-right | +| `results[].confidence` | number | Confidence score between 0.0 and 1.0 | +| `results[].polygon` | [[number, number], ×4] | **Optional.** 4-point detection polygon ordered top-left → top-right → bottom-right → bottom-left **in the glyphs' upright reading frame**. Lets LiteParse recover rotation for vertical/sideways text. | + +## Example + +### Request + +```bash +curl -X POST http://localhost:8080/ocr \ + -F "file=@document.png" \ + -F "language=en" +``` + +### Response + +```json +{ + "results": [ + { + "text": "Hello", + "bbox": [10, 20, 60, 40], + "confidence": 0.98 + }, + { + "text": "World", + "bbox": [70, 20, 130, 40], + "confidence": 0.97 + } + ] +} +``` + +## Error Handling + +Return appropriate HTTP status codes: + +- `200 OK` - Success +- `400 Bad Request` - Invalid request (missing file, invalid language, etc.) +- `500 Internal Server Error` - OCR processing failed + +Error response format: + +```json +{ + "error": "Description of the error" +} +``` + +## Implementation Notes + +### Coordinate System + +- Origin (0,0) is at the **top-left** of the image +- X increases to the right +- Y increases downward +- All coordinates are in pixels + +### Bounding Box Format + +Always return axis-aligned bounding boxes as `[x1, y1, x2, y2]`: +- `x1, y1` = top-left corner +- `x2, y2` = bottom-right corner +- `x2 > x1` and `y2 > y1` + +If your OCR engine returns rotated boxes or polygon coordinates, convert them to axis-aligned boxes by taking min/max coordinates. **Additionally**, you are encouraged to forward the raw 4-point polygon as `polygon` (TL → TR → BR → BL in the upright reading frame) — LiteParse uses it to detect vertical/sideways text (e.g. legal-document sidebars) and route it through its rotation reading-order handler instead of flattening it into body lines. + +### Confidence Scores + +- Normalize to range 0.0 to 1.0 +- 1.0 = 100% confident +- 0.0 = 0% confident +- If your OCR engine doesn't provide confidence, use `1.0` + +### Text Ordering + +Results should be ordered by reading order (top-to-bottom, left-to-right for most languages). + +## Example Implementations + +See the `/ocr` directory for reference implementations: + +- `ocr/easyocr/` - Wrapper for EasyOCR +- `ocr/paddleocr/` - Wrapper for PaddleOCR +- `ocr/suryaocr/` - Wrapper for Surya OCR 2 (multilingual) + +## Testing Your Server + +Quick test: + +```bash +# 1. Start your server +python server.py + +# 2. Test with curl +curl -X POST http://localhost:8080/ocr \ + -F "file=@test.png" \ + -F "language=en" \ + | jq . + +# 3. Expected output: +# { +# "results": [ +# { +# "text": "...", +# "bbox": [x1, y1, x2, y2], +# "confidence": 0.xx +# } +# ] +# } +``` + +Use with LiteParse: + +```bash +lit parse document.pdf --ocr-server-url http://localhost:8080/ocr +``` + +## FAQ + +### Q: What if my OCR returns rotated bounding boxes? + +Convert to axis-aligned boxes: + +```python +def polygon_to_bbox(polygon): + """Convert polygon [[x1,y1], [x2,y2], ...] to [x1, y1, x2, y2]""" + xs = [point[0] for point in polygon] + ys = [point[1] for point in polygon] + return [min(xs), min(ys), max(xs), max(ys)] +``` + +### Q: What if my OCR doesn't return confidence scores? + +Just return `1.0` for all results. + +### Q: Can I return empty results? + +Yes, return `{"results": []}` if no text is detected. + +### Q: Should I filter low-confidence results? + +You can, but LiteParse will also handle filtering based on its own thresholds. + +### Q: What image formats should I accept? + +At minimum: PNG, JPG. Optionally: TIFF, WebP, BMP, GIF. + +### Q: Should I handle rotation correction? + +Optional. If your OCR engine supports it, you can auto-correct rotation before processing. + +### Q: What about multi-page documents? + +LiteParse handles page splitting. Your server only needs to process single images. + +### Q: Performance considerations? + +- Keep server response time under 10 seconds per image +- Support concurrent requests +- Consider GPU acceleration for better performance +- Cache OCR models in memory (don't reload per request) + +## Compliance Checklist + +- [ ] Accepts `POST /ocr` endpoint +- [ ] Accepts `file` and `language` form fields +- [ ] Returns JSON with `results` array +- [ ] Each result has `text`, `bbox`, and `confidence` +- [ ] Bounding boxes in `[x1, y1, x2, y2]` format +- [ ] (Optional but recommended) `polygon` field with 4-point TL→TR→BR→BL polygon for rotated detections +- [ ] Confidence normalized to 0.0-1.0 range +- [ ] Returns 200 status on success +- [ ] Returns appropriate error codes and messages +- [ ] Handles common image formats (PNG, JPG) +- [ ] Processes images in under 10 seconds + +## Support + +Questions? Open an issue on GitHub or refer to the example implementations in `/ocr`. diff --git a/README.md b/README.md new file mode 100644 index 0000000..afc18c4 --- /dev/null +++ b/README.md @@ -0,0 +1,461 @@ +# LiteParse + +[![CI](https://github.com/run-llama/liteparse/actions/workflows/ci.yml/badge.svg)](https://github.com/run-llama/liteparse/actions/workflows/ci.yml) +| +[![Crates.io version](https://img.shields.io/crates/v/liteparse.svg)](https://crates.io/crates/liteparse) +| +[![npm version](https://img.shields.io/npm/v/@llamaindex/liteparse.svg)](https://www.npmjs.com/package/@llamaindex/liteparse) +| +[![wasm version](https://img.shields.io/npm/v/@llamaindex/liteparse-wasm.svg)](https://www.npmjs.com/package/@llamaindex/liteparse-wasm) +| +[![PyPI version](https://img.shields.io/pypi/v/liteparse.svg)](https://pypi.org/project/liteparse/) +| +[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) +| +[Docs](https://developers.llamaindex.ai/liteparse/) + +English | [简体中文](README.zh-CN.md) + +out + +> Looking for LiteParse V1? Follow this link to [the old code](https://github.com/run-llama/liteparse/tree/logan/liteparse-v1) + +LiteParse is a standalone OSS PDF parsing tool focused exclusively on **fast and light** parsing. It provides high-quality spatial text parsing with bounding boxes, without proprietary LLM features or cloud dependencies. Everything runs locally on your machine. + +**Hitting the limits of local parsing?** +For complex documents (dense tables, multi-column layouts, charts, handwritten text, or +scanned PDFs), you'll get significantly better results with [LlamaParse](https://developers.llamaindex.ai/python/cloud/llamaparse/?utm_source=github&utm_medium=liteparse), +our cloud-based document parser built for production document pipelines. LlamaParse handles the +hard stuff so your models see clean, structured data and markdown. + +> [Sign up for LlamaParse free](https://cloud.llamaindex.ai?utm_source=github&utm_medium=liteparse) + +## Overview + +- **Fast Text Parsing**: Spatial text parsing using PDFium +- **Flexible OCR System**: + - **Built-in**: Tesseract (zero setup, bundled with the library) + - **HTTP Servers**: Plug in any OCR server (EasyOCR, PaddleOCR, custom) + - **Standard API**: Simple, well-defined OCR API specification +- **Complexity Detection**: Cheaply check whether a document needs OCR or heavier parsing — route, reject, or estimate cost before a full parse +- **Screenshot Generation**: Generate high-quality page screenshots for LLM agents +- **Multiple Output Formats**: Markdown, JSON, and Text +- **Markdown Output**: Structured Markdown with headings, tables, lists, images, and links — great for feeding LLMs and RAG pipelines +- **Bounding Boxes**: Precise text positioning information +- **Multi-language**: Use from Rust, Node.js/TypeScript, Python, or the browser (WASM) +- **Multi-platform**: Linux, macOS (Intel/ARM), Windows + +```mermaid +flowchart LR + subgraph Input["Input Formats"] + direction TB + PDF["PDF"] + DOCX["DOCX"] + XLSX["XLSX"] + PPTX["PPTX"] + IMG["Images"] + end + + subgraph Core["Rust Core"] + direction TB + CONV["Format Conversion\nLibreOffice / ImageMagick"] + EXTRACT["Text Extraction\nPDFium C library"] + OCR["Selective OCR\nTesseract / HTTP / Custom"] + MERGE["OCR Merge\nNative text + OCR results"] + PROJ["Grid Projection\nSpatial layout reconstruction"] + CONV --> EXTRACT + EXTRACT --> OCR --> MERGE --> PROJ + EXTRACT --> MERGE + end + + subgraph Output[" Output "] + direction TB + JSON["Structured JSON\ntext + bounding boxes"] + TEXT["Plain Text\nlayout-preserved"] + SCREEN["Screenshots\nPNG rendering"] + end + + subgraph Bindings["Language Bindings"] + direction TB + NAPI["Node.js / TypeScript\nnapi-rs"] + PYO3["Python\nPyO3"] + WASM["Browser / WASM\nwasm-bindgen"] + CLI["CLI\ncargo / npm / pip"] + NAPI ~~~ PYO3 ~~~ WASM ~~~ CLI + end + + PDF --> EXTRACT + DOCX & XLSX & PPTX & IMG --> CONV + PROJ --> JSON & TEXT & SCREEN + JSON & TEXT & SCREEN --> Bindings + + style Input fill:#F5F5F5,color:#000000,stroke:#37D7FA,stroke-width:2px + style Core fill:#F5F5F5,color:#000000,stroke:#3E18F9,stroke-width:2px + style Output fill:#F5F5F5,color:#000000,stroke:#FF8705,stroke-width:2px + style Bindings fill:#F5F5F5,color:#000000,stroke:#FF8DF2,stroke-width:2px + + style PDF fill:#96E7F9,color:#000000,stroke:#37D7FA,stroke-width:1px + style DOCX fill:#96E7F9,color:#000000,stroke:#37D7FA,stroke-width:1px + style XLSX fill:#96E7F9,color:#000000,stroke:#37D7FA,stroke-width:1px + style PPTX fill:#96E7F9,color:#000000,stroke:#37D7FA,stroke-width:1px + style IMG fill:#96E7F9,color:#000000,stroke:#37D7FA,stroke-width:1px + + style CONV fill:#92AEFF,color:#000000,stroke:#4B72FE,stroke-width:1px + style EXTRACT fill:#92AEFF,color:#000000,stroke:#4B72FE,stroke-width:1px + style OCR fill:#92AEFF,color:#000000,stroke:#4B72FE,stroke-width:1px + style MERGE fill:#92AEFF,color:#000000,stroke:#4B72FE,stroke-width:1px + style PROJ fill:#4B72FE,color:#FFFFFF,stroke:#3E18F9,stroke-width:2px + + style JSON fill:#FFBD74,color:#000000,stroke:#FF8705,stroke-width:1px + style TEXT fill:#FFBD74,color:#000000,stroke:#FF8705,stroke-width:1px + style SCREEN fill:#FFBD74,color:#000000,stroke:#FF8705,stroke-width:1px + + style NAPI fill:#FFBFF8,color:#000000,stroke:#FF8DF2,stroke-width:1px + style PYO3 fill:#FFBFF8,color:#000000,stroke:#FF8DF2,stroke-width:1px + style WASM fill:#FFBFF8,color:#000000,stroke:#FF8DF2,stroke-width:1px + style CLI fill:#FFBFF8,color:#000000,stroke:#FF8DF2,stroke-width:1px +``` + +## Installation + +Install via your preferred package manager. All versions (except WASM) ship with the same `lit` CLI. + +| Language | Install | Library Docs | +|----------|---------|--------------| +| **Node.js / TypeScript** | `npm i -g @llamaindex/liteparse` | [Node.js README](packages/node/README.md) | +| **Python** | `pip install liteparse` | [Python README](packages/python/README.md) | +| **Rust** | `cargo install liteparse` (CLI) / `cargo add liteparse` (lib) | [Rust README (crates.io)](crates/liteparse/README.md) | +| **Browser (WASM)** | `npm i @llamaindex/liteparse-wasm` | [WASM README](packages/wasm/README.md) | + +### Agent Skill + +You can use `liteparse` as an agent skill, downloading it with the `skills` CLI tool: + +```bash +npx skills add run-llama/llamaparse-agent-skills --skill liteparse +``` + +Or copy-pasting the [`SKILL.md`](https://github.com/run-llama/llamaparse-agent-skills/blob/main/skills/liteparse/SKILL.md) file to your own skills setup. + +## CLI Usage + +The CLI is the same across all installations (`npm`, `pip`, `cargo install`). + +### Parse Files + +```bash +# Basic parsing +lit parse document.pdf + +# Parse to Markdown — headings, tables, lists, images, links +lit parse document.pdf --format markdown -o output.md + +# Parse with specific format +lit parse document.pdf --format json -o output.json + +# Parse specific pages +lit parse document.pdf --target-pages "1-5,10,15-20" + +# Parse without OCR +lit parse document.pdf --no-ocr + +# Parse a remote PDF +curl -sL https://example.com/report.pdf | lit parse - +``` + +### Markdown Output + +LiteParse can render documents directly to Markdown. This means reconstructing headings, +tables, lists, images, and links from the spatial layout. This is ideal for +feeding documents to LLMs and RAG pipelines. This mode is purely heuristics and rule-based, +so complex documents may not render perfectly, but it will be fast. + +```bash +# Render to Markdown +lit parse document.pdf --format markdown -o output.md + +# Strip images instead of emitting placeholders +lit parse document.pdf --format markdown --image-mode off + +# Extract embedded images to disk and reference them from the markdown +lit parse document.pdf --format markdown --image-mode embed --image-output-dir ./images + +# Emit link text as plain text (no [text](url) syntax) +lit parse document.pdf --format markdown --no-links +``` + +Image handling is controlled by `--image-mode`: + +| Mode | Behavior | +|------|----------| +| `placeholder` (default) | Emits `![](image_pN_K.png)` references in reading order | +| `off` | Strips images entirely | +| `embed` | Writes each image's PNG bytes to `--image-output-dir` and references them | + +> Markdown reconstruction quality varies with document complexity. For the +> hardest documents (dense tables, multi-column layouts, scans), +> [LlamaParse](https://developers.llamaindex.ai/python/cloud/llamaparse/?utm_source=github&utm_medium=liteparse) +> remains the most accurate option. + +### Check Complexity + +Before committing to a full parse, check whether a document actually needs OCR or +heavier processing. This is a cheap, text-layer-only pass — useful for routing +documents to different pipelines, rejecting ones you can't handle, or estimating cost. + +```bash +# Print the complexity verdict and per-page JSON +lit is-complex document.pdf + +# Use as a shell predicate — only parse with --no-ocr when the document is simple +lit is-complex document.pdf --quiet && lit parse document.pdf --no-ocr + +# List the pages that need OCR +lit is-complex document.pdf --compact | jq '[.[] | select(.needs_ocr) | .page_number]' +``` + +It always prints per-page JSON to **stdout**, a human-readable verdict to **stderr**, and +exits **non-zero when any page needs OCR**. Each page carries a `needs_ocr` verdict and a +list of `reasons` (`scanned`, `no-text`, `sparse-text`, `embedded-images`, `garbled`, +`vector-text`). + +### Batch Parsing + +Parse an entire directory of documents: + +```bash +lit batch-parse ./input-directory ./output-directory +``` + +### Generate Screenshots + +Screenshots are essential for LLM agents to extract visual information that text alone cannot capture. + +```bash +# Screenshot all pages +lit screenshot document.pdf -o ./screenshots + +# Screenshot specific pages +lit screenshot document.pdf --target-pages "1,3,5" -o ./screenshots + +# Custom DPI +lit screenshot document.pdf --dpi 300 -o ./screenshots +``` + +### CLI Reference + +#### Parse Command + +``` +lit parse [OPTIONS] + +Options: + -o, --output Output file path + --format Output format: json|text|markdown [default: text] + --no-ocr Disable OCR + --ocr-language OCR language, Tesseract format [default: eng] + --ocr-server-url HTTP OCR server URL (uses Tesseract if not provided) + --tessdata-path Path to tessdata directory + --max-pages Max pages to parse [default: 1000] + --target-pages Pages to parse (e.g., "1-5,10,15-20") + --dpi Rendering DPI [default: 150] + --image-mode Markdown image handling: off|placeholder|embed [default: placeholder] + --image-output-dir Where to write images when --image-mode embed + --no-links Emit link anchor text as plain text (no [text](url)) in markdown + --preserve-small-text Keep very small text + --password Password for encrypted documents + --num-workers Concurrent OCR workers [default: CPU cores - 1] + -q, --quiet Suppress progress output + -h, --help Print help +``` + +#### Batch Parse Command + +``` +lit batch-parse [OPTIONS] + +Options: + --format Output format: json|text|markdown [default: text] + --no-ocr Disable OCR + --ocr-language OCR language [default: eng] + --ocr-server-url HTTP OCR server URL + --tessdata-path Path to tessdata directory + --max-pages Max pages per file [default: 1000] + --dpi Rendering DPI [default: 150] + --recursive Recursively search input directory + --extension Only process files with this extension (e.g., ".pdf") + --password Password for encrypted documents + --num-workers Concurrent OCR workers + -q, --quiet Suppress progress output + -h, --help Print help +``` + +#### Screenshot Command + +``` +lit screenshot [OPTIONS] + +Options: + -o, --output-dir Output directory [default: ./screenshots] + --target-pages Pages to screenshot (e.g., "1,3,5" or "1-5") + --dpi Rendering DPI [default: 150] + --password Password for encrypted documents + -q, --quiet Suppress progress output + -h, --help Print help +``` + +#### Is-Complex Command + +``` +lit is-complex [OPTIONS] + +Options: + --compact Emit dense, whitespace-free JSON instead of pretty-printed + --max-pages Max pages to check [default: 1000] + --target-pages Pages to check (e.g., "1-5,10,15-20") + --password Password for encrypted documents + -q, --quiet Suppress the stderr verdict + -h, --help Print help +``` + +Prints per-page JSON to stdout and a `COMPLEX`/`SIMPLE` verdict to stderr; exits non-zero +when any page needs OCR, so it composes as a shell predicate. + +## OCR Setup + +### Default: Tesseract + +Tesseract is bundled and works out of the box: + +```bash +lit parse document.pdf # OCR enabled by default +lit parse document.pdf --ocr-language fra # Specify language +lit parse document.pdf --no-ocr # Disable OCR +``` + +For offline or air-gapped environments, set `TESSDATA_PREFIX` to a directory containing pre-downloaded `.traineddata` files: + +```bash +export TESSDATA_PREFIX=/path/to/tessdata +lit parse document.pdf --ocr-language eng +``` + +Or pass the path directly: + +```bash +lit parse document.pdf --tessdata-path /path/to/tessdata +``` + +### Optional: HTTP OCR Servers + +For higher accuracy or better performance, you can use an HTTP OCR server. We provide ready-to-use example wrappers for popular OCR engines: + +- [EasyOCR](ocr/easyocr/README.md) +- [PaddleOCR](ocr/paddleocr/README.md) + +You can integrate any OCR service by implementing the simple LiteParse OCR API specification (see [`OCR_API_SPEC.md`](OCR_API_SPEC.md)). + +The API requires: +- POST `/ocr` endpoint +- Accepts `file` and `language` parameters +- Returns JSON: `{ results: [{ text, bbox: [x1,y1,x2,y2], confidence }] }` + +## Multi-Format Input Support + +LiteParse supports **automatic conversion** of various document formats to PDF before parsing. + +### Supported Input Formats + +#### Office Documents (via LibreOffice) +- **Word**: `.doc`, `.docx`, `.docm`, `.odt`, `.rtf`, `.pages` +- **PowerPoint**: `.ppt`, `.pptx`, `.pptm`, `.odp`, `.key` +- **Spreadsheets**: `.xls`, `.xlsx`, `.xlsm`, `.ods`, `.csv`, `.tsv`, `.numbers` + +Install LibreOffice for automatic conversion: + +```bash +# macOS +brew install --cask libreoffice + +# Ubuntu/Debian +apt-get install libreoffice + +# Windows +choco install libreoffice-fresh +``` + +> _On Windows, you may need to add LibreOffice's program directory (usually `C:\Program Files\LibreOffice\program`) to your PATH._ + +#### Images (via ImageMagick) +- **Formats**: `.jpg`, `.jpeg`, `.png`, `.gif`, `.bmp`, `.tiff`, `.webp`, `.svg` + +Install ImageMagick for image-to-PDF conversion: + +```bash +# macOS +brew install imagemagick + +# Ubuntu/Debian +apt-get install imagemagick + +# Windows +choco install imagemagick.app +``` + +## Environment Variables + +| Variable | Description | +|----------|-------------| +| `TESSDATA_PREFIX` | Path to a directory containing Tesseract `.traineddata` files. Used for offline/air-gapped environments. | + +## Development + +The project is a Rust workspace with the core library and language-specific binding crates. + +``` +crates/ +├── liteparse/ # Core library + CLI binary +├── liteparse-napi/ # Node.js bindings (napi-rs) +├── liteparse-python/ # Python bindings (PyO3) +├── liteparse-wasm/ # WASM bindings (wasm-bindgen) +├── pdfium/ # PDFium Rust wrapper +└── pdfium-sys/ # PDFium FFI bindings +packages/ +├── node/ # npm package (TS wrapper + native binary) +├── python/ # PyPI package (Python wrapper + native binary) +└── wasm/ # WASM npm package +``` + +### Building + +```bash +# Build the CLI +cargo build --release -p liteparse + +# Build Node.js bindings +cd packages/node && npm run build + +# Build Python bindings +cd packages/python && maturin develop --release + +# Build WASM +cd packages/wasm && npm run build +``` + +We provide a fairly rich `AGENTS.md`/`CLAUDE.md` that we recommend using to help with development + coding agents. + +## License + +Apache 2.0 + +## Credits + +Built on top of: + +- [PDFium](https://pdfium.googlesource.com/pdfium/) - PDF rendering and text extraction +- [Tesseract](https://github.com/tesseract-ocr/tesseract) - OCR engine (via tesseract-rs) +- [EasyOCR](https://github.com/JaidedAI/EasyOCR) - HTTP OCR server (optional) +- [PaddleOCR](https://github.com/PaddlePaddle/PaddleOCR) - HTTP OCR server (optional) +- [napi-rs](https://napi.rs/) - Node.js native bindings +- [PyO3](https://pyo3.rs/) - Python native bindings +- [wasm-bindgen](https://github.com/wasm-bindgen/wasm-bindgen) - WebAssembly bindings diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..ad7cb16 --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`run-llama/liteparse` +- 原始仓库:https://github.com/run-llama/liteparse +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..021aa87 --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,377 @@ +# LiteParse + +[![CI](https://github.com/run-llama/liteparse/actions/workflows/ci.yml/badge.svg)](https://github.com/run-llama/liteparse/actions/workflows/ci.yml) +| +[![Crates.io version](https://img.shields.io/crates/v/liteparse.svg)](https://crates.io/crates/liteparse) +| +[![npm version](https://img.shields.io/npm/v/@llamaindex/liteparse.svg)](https://www.npmjs.com/package/@llamaindex/liteparse) +| +[![wasm version](https://img.shields.io/npm/v/@llamaindex/liteparse-wasm.svg)](https://www.npmjs.com/package/@llamaindex/liteparse-wasm) +| +[![PyPI version](https://img.shields.io/pypi/v/liteparse.svg)](https://pypi.org/project/liteparse/) +| +[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) +| +[文档](https://developers.llamaindex.ai/liteparse/) + +[English](README.md) | 简体中文 + +out + +> 在找 LiteParse V1?请访问 [旧版代码仓库](https://github.com/run-llama/liteparse/tree/logan/liteparse-v1)。 + +LiteParse 是一款独立的开源 PDF 解析工具,专注于**快速、轻量**的文档解析。它能输出高质量的、带有标注(bounding box)的空间文本信息,无需依赖任何闭源大模型或云端服务,所有处理都在本地完成。 + +**本地解析能力不够用?** +对于复杂文档(密集表格、多栏布局、图表、手写文字或扫描版 PDF),我们的云端文档解析工具 [LlamaParse](https://developers.llamaindex.ai/python/cloud/llamaparse/?utm_source=github&utm_medium=liteparse) 能给出明显更好的结果。它专为生产级文档处理流水线设计,会替你搞定那些棘手的情况,让模型直接拿到干净、结构化的数据和 markdown。 + +> [免费注册 LlamaParse](https://cloud.llamaindex.ai?utm_source=github&utm_medium=liteparse) + +## 概览 + +- **快速文本解析**:基于 PDFium 的空间文本解析 +- **灵活的 OCR 体系**: + - **内置**:Tesseract(开箱即用,已随库打包) + - **HTTP 服务**:可接入任意 OCR 服务(EasyOCR、PaddleOCR 或自建服务) + - **标准化 API**:简洁、定义清晰的 OCR API 规范 +- **页面截图生成**:为 LLM 智能体生成高质量的页面截图 +- **多种输出格式**:JSON 和纯文本 +- **边界框信息**:精确的文本位置坐标 +- **多语言绑定**:可在 Rust、Node.js / TypeScript、Python 以及浏览器(WASM)中使用 +- **跨平台**:支持 Linux、macOS(Intel / ARM)和 Windows + +```mermaid +flowchart LR + subgraph Input["Input Formats"] + direction TB + PDF["PDF"] + DOCX["DOCX"] + XLSX["XLSX"] + PPTX["PPTX"] + IMG["Images"] + end + + subgraph Core["Rust Core"] + direction TB + CONV["Format Conversion\nLibreOffice / ImageMagick"] + EXTRACT["Text Extraction\nPDFium C library"] + OCR["Selective OCR\nTesseract / HTTP / Custom"] + MERGE["OCR Merge\nNative text + OCR results"] + PROJ["Grid Projection\nSpatial layout reconstruction"] + CONV --> EXTRACT + EXTRACT --> OCR --> MERGE --> PROJ + EXTRACT --> MERGE + end + + subgraph Output[" Output "] + direction TB + JSON["Structured JSON\ntext + bounding boxes"] + TEXT["Plain Text\nlayout-preserved"] + SCREEN["Screenshots\nPNG rendering"] + end + + subgraph Bindings["Language Bindings"] + direction TB + NAPI["Node.js / TypeScript\nnapi-rs"] + PYO3["Python\nPyO3"] + WASM["Browser / WASM\nwasm-bindgen"] + CLI["CLI\ncargo / npm / pip"] + NAPI ~~~ PYO3 ~~~ WASM ~~~ CLI + end + + PDF --> EXTRACT + DOCX & XLSX & PPTX & IMG --> CONV + PROJ --> JSON & TEXT & SCREEN + JSON & TEXT & SCREEN --> Bindings + + style Input fill:#F5F5F5,color:#000000,stroke:#37D7FA,stroke-width:2px + style Core fill:#F5F5F5,color:#000000,stroke:#3E18F9,stroke-width:2px + style Output fill:#F5F5F5,color:#000000,stroke:#FF8705,stroke-width:2px + style Bindings fill:#F5F5F5,color:#000000,stroke:#FF8DF2,stroke-width:2px + + style PDF fill:#96E7F9,color:#000000,stroke:#37D7FA,stroke-width:1px + style DOCX fill:#96E7F9,color:#000000,stroke:#37D7FA,stroke-width:1px + style XLSX fill:#96E7F9,color:#000000,stroke:#37D7FA,stroke-width:1px + style PPTX fill:#96E7F9,color:#000000,stroke:#37D7FA,stroke-width:1px + style IMG fill:#96E7F9,color:#000000,stroke:#37D7FA,stroke-width:1px + + style CONV fill:#92AEFF,color:#000000,stroke:#4B72FE,stroke-width:1px + style EXTRACT fill:#92AEFF,color:#000000,stroke:#4B72FE,stroke-width:1px + style OCR fill:#92AEFF,color:#000000,stroke:#4B72FE,stroke-width:1px + style MERGE fill:#92AEFF,color:#000000,stroke:#4B72FE,stroke-width:1px + style PROJ fill:#4B72FE,color:#FFFFFF,stroke:#3E18F9,stroke-width:2px + + style JSON fill:#FFBD74,color:#000000,stroke:#FF8705,stroke-width:1px + style TEXT fill:#FFBD74,color:#000000,stroke:#FF8705,stroke-width:1px + style SCREEN fill:#FFBD74,color:#000000,stroke:#FF8705,stroke-width:1px + + style NAPI fill:#FFBFF8,color:#000000,stroke:#FF8DF2,stroke-width:1px + style PYO3 fill:#FFBFF8,color:#000000,stroke:#FF8DF2,stroke-width:1px + style WASM fill:#FFBFF8,color:#000000,stroke:#FF8DF2,stroke-width:1px + style CLI fill:#FFBFF8,color:#000000,stroke:#FF8DF2,stroke-width:1px +``` + +## 安装 + +可通过你常用的包管理器安装。除 WASM 之外,所有版本都附带相同的 `lit` 命令行工具。 + +| 语言 | 安装命令 | 库文档 | +|----------|---------|--------------| +| **Node.js / TypeScript** | `npm i @llamaindex/liteparse` | [Node.js README](packages/node/README.md) | +| **Python** | `pip install liteparse` | [Python README](packages/python/README.md) | +| **Rust** | `cargo install liteparse`(CLI)/ `cargo add liteparse`(库) | [Rust README(crates.io)](crates/liteparse/README.md) | +| **浏览器(WASM)** | `npm i @llamaindex/liteparse-wasm` | [WASM README](packages/wasm/README.md) | + +### Agent Skill + +你也可以把 `liteparse` 当作 agent skill 使用,通过 `skills` CLI 工具下载: + +```bash +npx skills add run-llama/llamaparse-agent-skills --skill liteparse +``` + +或者直接把 [`SKILL.md`](https://github.com/run-llama/llamaparse-agent-skills/blob/main/skills/liteparse/SKILL.md) 文件复制到你自己的 skills 目录中。 + +## 命令行用法 + +无论通过 `npm`、`pip` 还是 `cargo install` 安装,命令行接口都是一致的。 + +### 解析文件 + +```bash +# 基本解析 +lit parse document.pdf + +# 指定输出格式 +lit parse document.pdf --format json -o output.json + +# 只解析特定页 +lit parse document.pdf --target-pages "1-5,10,15-20" + +# 关闭 OCR +lit parse document.pdf --no-ocr + +# 解析远程 PDF +curl -sL https://example.com/report.pdf | lit parse - +``` + +### 批量解析 + +对整个目录中的文档进行批量解析: + +```bash +lit batch-parse ./input-directory ./output-directory +``` + +### 生成截图 + +页面截图对 LLM 智能体很关键——它能让模型获取那些仅靠文本无法表达的视觉信息。 + +```bash +# 截图所有页 +lit screenshot document.pdf -o ./screenshots + +# 只截特定页 +lit screenshot document.pdf --target-pages "1,3,5" -o ./screenshots + +# 自定义 DPI +lit screenshot document.pdf --dpi 300 -o ./screenshots +``` + +### 命令行参考 + +#### Parse 命令 + +``` +lit parse [OPTIONS] + +Options: + -o, --output Output file path + --format Output format: json|text [default: text] + --no-ocr Disable OCR + --ocr-language OCR language, Tesseract format [default: eng] + --ocr-server-url HTTP OCR server URL (uses Tesseract if not provided) + --tessdata-path Path to tessdata directory + --max-pages Max pages to parse [default: 1000] + --target-pages Pages to parse (e.g., "1-5,10,15-20") + --dpi Rendering DPI [default: 150] + --preserve-small-text Keep very small text + --password Password for encrypted documents + --num-workers Concurrent OCR workers [default: CPU cores - 1] + -q, --quiet Suppress progress output + -h, --help Print help +``` + +#### Batch Parse 命令 + +``` +lit batch-parse [OPTIONS] + +Options: + --format Output format: json|text [default: text] + --no-ocr Disable OCR + --ocr-language OCR language [default: eng] + --ocr-server-url HTTP OCR server URL + --tessdata-path Path to tessdata directory + --max-pages Max pages per file [default: 1000] + --dpi Rendering DPI [default: 150] + --recursive Recursively search input directory + --extension Only process files with this extension (e.g., ".pdf") + --password Password for encrypted documents + --num-workers Concurrent OCR workers + -q, --quiet Suppress progress output + -h, --help Print help +``` + +#### Screenshot 命令 + +``` +lit screenshot [OPTIONS] + +Options: + -o, --output-dir Output directory [default: ./screenshots] + --target-pages Pages to screenshot (e.g., "1,3,5" or "1-5") + --dpi Rendering DPI [default: 150] + --password Password for encrypted documents + -q, --quiet Suppress progress output + -h, --help Print help +``` + +## OCR 配置 + +### 默认方案:Tesseract + +Tesseract 已经随库打包,开箱即用: + +```bash +lit parse document.pdf # 默认启用 OCR +lit parse document.pdf --ocr-language fra # 指定识别语言 +lit parse document.pdf --no-ocr # 关闭 OCR +``` + +如果运行在离线或内网隔离环境,可以把 `TESSDATA_PREFIX` 指向一个预先准备好的、放有 `.traineddata` 文件的目录: + +```bash +export TESSDATA_PREFIX=/path/to/tessdata +lit parse document.pdf --ocr-language eng +``` + +也可以直接通过命令行参数传入路径: + +```bash +lit parse document.pdf --tessdata-path /path/to/tessdata +``` + +### 可选方案:HTTP OCR 服务 + +如果对识别精度或性能有更高要求,可以接入 HTTP OCR 服务。我们为几款常用 OCR 引擎提供了开箱即用的封装示例: + +- [EasyOCR](ocr/easyocr/README.md) +- [PaddleOCR](ocr/paddleocr/README.md) + +你也可以通过实现 LiteParse 简洁的 OCR API 规范(参考 [`OCR_API_SPEC.md`](OCR_API_SPEC.md))来接入任何 OCR 服务。 + +接口要求: +- 提供 POST `/ocr` 端点 +- 接收 `file` 和 `language` 参数 +- 返回如下结构的 JSON:`{ results: [{ text, bbox: [x1,y1,x2,y2], confidence }] }` + +## 多格式输入支持 + +LiteParse 支持**自动将多种文档格式转换为 PDF** 后再解析。 + +### 支持的输入格式 + +#### 办公文档(通过 LibreOffice) +- **Word**:`.doc`、`.docx`、`.docm`、`.odt`、`.rtf`、`.pages` +- **PowerPoint**:`.ppt`、`.pptx`、`.pptm`、`.odp`、`.key` +- **电子表格**:`.xls`、`.xlsx`、`.xlsm`、`.ods`、`.csv`、`.tsv`、`.numbers` + +安装 LibreOffice 以启用自动转换: + +```bash +# macOS +brew install --cask libreoffice + +# Ubuntu/Debian +apt-get install libreoffice + +# Windows +choco install libreoffice-fresh +``` + +> _Windows 上可能需要把 LibreOffice 的 program 目录(通常是 `C:\Program Files\LibreOffice\program`)加入 PATH。_ + +#### 图像(通过 ImageMagick) +- **支持格式**:`.jpg`、`.jpeg`、`.png`、`.gif`、`.bmp`、`.tiff`、`.webp`、`.svg` + +安装 ImageMagick 以启用图像转 PDF: + +```bash +# macOS +brew install imagemagick + +# Ubuntu/Debian +apt-get install imagemagick + +# Windows +choco install imagemagick.app +``` + +## 环境变量 + +| 变量 | 说明 | +|----------|-------------| +| `TESSDATA_PREFIX` | 指向存放 Tesseract `.traineddata` 文件的目录路径,用于离线或内网隔离环境。 | + +## 开发 + +整个项目是一个 Rust workspace,包含核心库以及各个语言绑定子 crate。 + +``` +crates/ +├── liteparse/ # 核心库 + CLI 二进制 +├── liteparse-napi/ # Node.js 绑定(napi-rs) +├── liteparse-python/ # Python 绑定(PyO3) +├── liteparse-wasm/ # WASM 绑定(wasm-bindgen) +├── pdfium/ # PDFium 的 Rust 封装 +└── pdfium-sys/ # PDFium FFI 绑定 +packages/ +├── node/ # npm 包(TS 封装 + 原生二进制) +├── python/ # PyPI 包(Python 封装 + 原生二进制) +└── wasm/ # WASM npm 包 +``` + +### 构建 + +```bash +# 构建 CLI +cargo build --release -p liteparse + +# 构建 Node.js 绑定 +cd packages/node && npm run build + +# 构建 Python 绑定 +cd packages/python && maturin develop --release + +# 构建 WASM +cd packages/wasm && npm run build +``` + +我们提供了内容比较详尽的 `AGENTS.md` / `CLAUDE.md`,推荐配合代码 agent 一起开发时参考。 + +## 许可证 + +Apache 2.0 + +## 鸣谢 + +LiteParse 构建在以下项目之上: + +- [PDFium](https://pdfium.googlesource.com/pdfium/) —— PDF 渲染与文本提取 +- [Tesseract](https://github.com/tesseract-ocr/tesseract) —— OCR 引擎(通过 tesseract-rs 接入) +- [EasyOCR](https://github.com/JaidedAI/EasyOCR) —— HTTP OCR 服务(可选) +- [PaddleOCR](https://github.com/PaddlePaddle/PaddleOCR) —— HTTP OCR 服务(可选) +- [napi-rs](https://napi.rs/) —— Node.js 原生绑定 +- [PyO3](https://pyo3.rs/) —— Python 原生绑定 +- [wasm-bindgen](https://github.com/wasm-bindgen/wasm-bindgen) —— WebAssembly 绑定 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..43ac5b4 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,44 @@ +# Security Policy + +## Reporting a Vulnerability + +If you discover a security vulnerability in LiteParse, please report it responsibly: + +1. **Do NOT open a public issue** for security vulnerabilities +2. Email security concerns to: security@llamaindex.ai +3. Include as much detail as possible: + - Description of the vulnerability + - Steps to reproduce + - Potential impact + - Suggested fix (if any) + +## Scope + +### In Scope + +Security issues we will address: + +- Remote code execution in the CLI tool or library +- Vulnerabilities in LiteParse's own code that could be exploited +- Dependency vulnerabilities with known, exploitable CVEs + +### Out of Scope + +LiteParse is intended to be a local CLI tool and library, designed to process documents you provide. The following are not security vulnerabilities we will address: + +- **Malicious input files** - Processing untrusted documents (zip bombs, malformed PDFs, path traversal in filenames, etc.) is the user's responsibility. If you're building a service that accepts untrusted uploads, you must implement your own validation, sandboxing, and resource limits. +- **Denial of service via large/complex files** - Documents that cause high memory usage, long processing times, or crashes are not security issues. Use `--max-pages`, timeouts, and resource limits in your deployment. +- **Issues requiring a server setup** - LiteParse does not include or recommend any specific production server deployment. Security of web services built on top of LiteParse is the deployer's responsibility. +- **Theoretical attacks without proof of concept** - Please include a working demonstration. + +### Building Secure Services + +If you're exposing LiteParse through a web service or API: + +1. Validate uploads: Check file types, sizes, and origins before processing +2. Use sandboxing: Run parsing in isolated containers with resource limits +3. Set timeouts: Don't allow unbounded processing time +4. Limit concurrency: Prevent resource exhaustion from parallel requests +5. Don't trust filenames: Sanitize any paths derived from user input + +These concerns are standard for any document processing service and are outside LiteParse's scope. diff --git a/crates/liteparse-napi/Cargo.toml b/crates/liteparse-napi/Cargo.toml new file mode 100644 index 0000000..54c7b40 --- /dev/null +++ b/crates/liteparse-napi/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "liteparse-napi" +version = "2.5.1" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Node.js native bindings for LiteParse" + +[lib] +crate-type = ["cdylib"] + +[features] +default = ["tesseract"] +tesseract = ["liteparse/tesseract"] + +[dependencies] +liteparse = { package = "liteparse", version = "2.5.1", path = "../liteparse", default-features = false } +pdfium-sys = { package = "liteparse-pdfium-sys", version = "1.3.0", path = "../pdfium-sys" } +napi = { version = "2", features = ["async", "serde-json", "napi9"] } +napi-derive = "2" +pdfium = { package = "liteparse-pdfium", version = "1.3.0", path = "../pdfium" } +image = { version = "0.25", default-features = false, features = ["png"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["full"] } + +[build-dependencies] +napi-build = "2" diff --git a/crates/liteparse-napi/build.rs b/crates/liteparse-napi/build.rs new file mode 100644 index 0000000..6cd8f4d --- /dev/null +++ b/crates/liteparse-napi/build.rs @@ -0,0 +1,33 @@ +extern crate napi_build; + +use std::env; + +fn main() { + napi_build::setup(); + + let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + + // Set rpath so the .node binary finds libpdfium next to itself at runtime. + // In the npm package, libpdfium is bundled alongside the .node file. + match target_os.as_str() { + "macos" => { + // @loader_path = directory containing the .node file + println!("cargo:rustc-link-arg=-Wl,-rpath,@loader_path"); + } + "linux" => { + // $ORIGIN = directory containing the .node file + println!("cargo:rustc-link-arg=-Wl,-rpath,$ORIGIN"); + } + _ => { + // Windows: DLLs are found via PATH or same directory automatically + } + } + + // Also add the build-time pdfium path so local dev builds work without + // copying libpdfium manually. + if let Ok(lib_path) = env::var("DEP_PDFIUM_LIB_PATH") + && (target_os == "macos" || target_os == "linux") + { + println!("cargo:rustc-link-arg=-Wl,-rpath,{lib_path}"); + } +} diff --git a/crates/liteparse-napi/src/lib.rs b/crates/liteparse-napi/src/lib.rs new file mode 100644 index 0000000..cb87613 --- /dev/null +++ b/crates/liteparse-napi/src/lib.rs @@ -0,0 +1,147 @@ +use napi::bindgen_prelude::*; +use napi_derive::napi; + +mod types; + +use types::{ + JsLiteParseConfig, JsPageComplexityStats, JsPageInput, JsParseResult, JsScreenshotResult, + JsTextItem, +}; + +/// Main LiteParse parser class. +#[napi] +pub struct LiteParse { + inner: liteparse::parser::LiteParse, + config: liteparse::config::LiteParseConfig, +} + +#[napi] +impl LiteParse { + /// Create a new LiteParse instance with optional configuration. + /// Any fields not provided will use defaults. + #[napi(constructor)] + pub fn new(config: Option) -> Self { + let rust_config = config.map(|c| c.into_rust()).unwrap_or_default(); + let inner = liteparse::parser::LiteParse::new(rust_config.clone()); + Self { + inner, + config: rust_config, + } + } + + /// Parse a document. Accepts a file path (string) or raw PDF bytes (Buffer). + #[napi] + pub async fn parse(&self, input: Either) -> Result { + use liteparse::types::PdfInput; + + let pdf_input = match input { + Either::A(path) => PdfInput::Path(path), + Either::B(buf) => PdfInput::Bytes(buf.to_vec()), + }; + + let result = self + .inner + .parse_input(pdf_input) + .await + .map_err(|e| Error::from_reason(e.to_string()))?; + + Ok(JsParseResult::from_rust(&result, &self.config)) + } + + /// Parse from pre-extracted pages, skipping PDFium text extraction. + /// + /// The caller supplies pages already populated with text items in viewport + /// space (top-left origin, 72 DPI). Runs only grid projection + the + /// configured output formatter, so it never loads PDFium. Use when an + /// external extractor owns text extraction (e.g. to keep its own + /// font-recovery pipeline). + #[napi] + pub fn parse_pages(&self, pages: Vec) -> Result { + let rust_pages: Vec<_> = pages.iter().map(JsPageInput::to_rust).collect(); + let result = self.inner.parse_from_pages(rust_pages, Vec::new()); + Ok(JsParseResult::from_rust(&result, &self.config)) + } + + /// Determine per-page complexity. Returns one entry per parsed page with + /// signals (text coverage, images, garbled text, vector area) and a + /// `needsOcr` verdict — a cheap pre-OCR check to decide whether a document + /// needs advanced parsing. Accepts a file path (string) or raw PDF bytes. + #[napi] + pub async fn is_complex( + &self, + input: Either, + ) -> Result> { + use liteparse::types::PdfInput; + + let pdf_input = match input { + Either::A(path) => PdfInput::Path(path), + Either::B(buf) => PdfInput::Bytes(buf.to_vec()), + }; + + let stats = self + .inner + .is_complex(pdf_input) + .await + .map_err(|e| Error::from_reason(e.to_string()))?; + + Ok(stats.iter().map(JsPageComplexityStats::from_rust).collect()) + } + + /// Take screenshots of document pages. Returns PNG image buffers. + /// + /// Non-PDF files are automatically converted to PDF before rendering when + /// LibreOffice/ImageMagick are available. + #[napi] + pub async fn screenshot( + &self, + input: Either, + page_numbers: Option>, + ) -> Result> { + use liteparse::types::PdfInput; + + let pdf_input = match input { + Either::A(path) => PdfInput::Path(path), + Either::B(buf) => PdfInput::Bytes(buf.to_vec()), + }; + + let results = self + .inner + .screenshot_input(pdf_input, page_numbers) + .await + .map_err(|e| Error::from_reason(e.to_string()))?; + + Ok(results + .into_iter() + .map(|r| JsScreenshotResult { + page_num: r.page_num, + width: r.width, + height: r.height, + image_buffer: r.image_bytes.into(), + }) + .collect()) + } + + /// Get the current configuration. + #[napi(getter)] + pub fn config(&self) -> JsLiteParseConfig { + JsLiteParseConfig::from_rust(&self.config) + } +} + +/// Search text items for phrase matches, returning merged items with combined bounding boxes. +#[napi] +pub fn search_items( + items: Vec, + phrase: String, + case_sensitive: Option, +) -> Vec { + let rust_items: Vec<_> = items.iter().map(|i| i.to_rust()).collect(); + let options = liteparse::search::SearchOptions { + phrase, + case_sensitive: case_sensitive.unwrap_or(false), + }; + liteparse::search::search_items(&rust_items, &options) + .iter() + .map(JsTextItem::from_rust) + .collect() +} diff --git a/crates/liteparse-napi/src/types.rs b/crates/liteparse-napi/src/types.rs new file mode 100644 index 0000000..7c0b1cf --- /dev/null +++ b/crates/liteparse-napi/src/types.rs @@ -0,0 +1,514 @@ +use std::collections::HashMap; + +use napi_derive::napi; + +use liteparse::config::{CropBox, ImageMode, LiteParseConfig, OutputFormat}; +use liteparse::parser::ParseResult; +use liteparse::types::{GraphicPrimitive, Page, ParsedPage, Rect, TextItem, WordBox}; + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- + +#[napi(object)] +#[derive(Clone)] +pub struct JsLiteParseConfig { + /// OCR language code (e.g., "eng", "fra"). + pub ocr_language: Option, + /// Whether OCR is enabled. + pub ocr_enabled: Option, + /// HTTP OCR server URL. If set, uses HTTP OCR instead of Tesseract. + pub ocr_server_url: Option, + /// Extra HTTP headers sent with every request to `ocrServerUrl` + /// (e.g. `{ Authorization: "Bearer " }`). + pub ocr_server_headers: Option>, + /// Path to tessdata directory for Tesseract. + pub tessdata_path: Option, + /// Maximum number of pages to parse. + pub max_pages: Option, + /// Specific pages to parse (e.g., "1-5,10,15-20"). + pub target_pages: Option, + /// DPI for rendering pages (used for OCR and screenshots). + pub dpi: Option, + /// Output format: "json", "text", or "markdown". + pub output_format: Option, + /// Keep very small text that would normally be filtered out. + pub preserve_very_small_text: Option, + /// Password for encrypted/protected documents. + pub password: Option, + /// Suppress progress output. + pub quiet: Option, + /// Number of concurrent OCR workers (default: CPU cores - 1). + pub num_workers: Option, + /// How to surface raster images in markdown output: "off", "placeholder" + /// (default — emits `![](image_pN_K.png)` references with no bytes), or + /// "embed" (also returns each image's PNG bytes on `images`). + pub image_mode: Option, + /// Render hyperlink annotations as `[text](url)` in markdown output + /// (default true). Set false for plain anchor text. + pub extract_links: Option, + /// Whether a systemic OCR failure aborts the whole parse (default true). + /// Set false to keep already-recovered native text and return partial + /// results when OCR is unavailable, instead of rejecting. + pub ocr_failure_fatal: Option, + /// OCR request-hedging schedule (ms). Empty/unset = no hedging. Multiple + /// delays (e.g. `[0, 5000, 10000]`) fire duplicate requests per attempt and + /// take the first success — lower tail latency at the cost of extra load. + pub ocr_hedge_delays_ms: Option>, + /// Emit per-word sub-boxes on each text item (`TextItem.words`). Default + /// false. Word boxes roughly double the text-item payload, so enable only + /// for word-level bbox attribution. + pub emit_word_boxes: Option, + /// Restrict output to a page sub-region. Each field is the fraction of the + /// page cropped from that side; a text item survives only if it lies + /// entirely inside the remaining rectangle. Unset keeps the whole page. + pub crop_box: Option, + /// Drop diagonal text (rotation >2° off the nearest right angle). Default + /// false. Use to exclude rotated watermarks/stamps from the output. + pub skip_diagonal_text: Option, +} + +/// A page sub-region as the fraction cropped from each side (top-left origin, +/// each in `[0, 1]`). +#[napi(object)] +#[derive(Clone)] +pub struct JsCropBox { + pub top: f64, + pub right: f64, + pub bottom: f64, + pub left: f64, +} + +impl JsLiteParseConfig { + pub fn into_rust(self) -> LiteParseConfig { + let mut cfg = LiteParseConfig::default(); + if let Some(v) = self.ocr_language { + cfg.ocr_language = v; + } + if let Some(v) = self.ocr_enabled { + cfg.ocr_enabled = v; + } + if let Some(v) = self.ocr_server_url { + cfg.ocr_server_url = Some(v); + } + if let Some(v) = self.ocr_server_headers { + cfg.ocr_server_headers = v.into_iter().collect(); + } + if let Some(v) = self.tessdata_path { + cfg.tessdata_path = Some(v); + } + if let Some(v) = self.max_pages { + cfg.max_pages = v as usize; + } + if let Some(v) = self.target_pages { + cfg.target_pages = Some(v); + } + if let Some(v) = self.dpi { + cfg.dpi = v as f32; + } + if let Some(v) = self.output_format { + cfg.output_format = match v.as_str() { + "text" => OutputFormat::Text, + "markdown" | "md" => OutputFormat::Markdown, + _ => OutputFormat::Json, + }; + } + if let Some(v) = self.preserve_very_small_text { + cfg.preserve_very_small_text = v; + } + if let Some(v) = self.password { + cfg.password = Some(v); + } + if let Some(v) = self.quiet { + cfg.quiet = v; + } + if let Some(v) = self.num_workers { + cfg.num_workers = v as usize; + } + if let Some(v) = self.image_mode { + cfg.image_mode = match v.as_str() { + "off" | "none" => ImageMode::Off, + "embed" => ImageMode::Embed, + _ => ImageMode::Placeholder, + }; + } + if let Some(v) = self.extract_links { + cfg.extract_links = v; + } + if let Some(v) = self.ocr_failure_fatal { + cfg.ocr_failure_fatal = v; + } + if let Some(v) = self.ocr_hedge_delays_ms { + cfg.ocr_hedge_delays_ms = v.into_iter().map(u64::from).collect(); + } + if let Some(v) = self.emit_word_boxes { + cfg.emit_word_boxes = v; + } + if let Some(v) = self.crop_box { + cfg.crop_box = Some(CropBox { + top: v.top as f32, + right: v.right as f32, + bottom: v.bottom as f32, + left: v.left as f32, + }); + } + if let Some(v) = self.skip_diagonal_text { + cfg.skip_diagonal_text = v; + } + cfg + } + + pub fn from_rust(cfg: &LiteParseConfig) -> Self { + Self { + ocr_language: Some(cfg.ocr_language.clone()), + ocr_enabled: Some(cfg.ocr_enabled), + ocr_server_url: cfg.ocr_server_url.clone(), + ocr_server_headers: if cfg.ocr_server_headers.is_empty() { + None + } else { + Some(cfg.ocr_server_headers.iter().cloned().collect()) + }, + tessdata_path: cfg.tessdata_path.clone(), + max_pages: Some(cfg.max_pages as u32), + target_pages: cfg.target_pages.clone(), + dpi: Some(cfg.dpi as f64), + output_format: Some(match cfg.output_format { + OutputFormat::Json => "json".to_string(), + OutputFormat::Text => "text".to_string(), + OutputFormat::Markdown => "markdown".to_string(), + }), + preserve_very_small_text: Some(cfg.preserve_very_small_text), + password: cfg.password.clone(), + quiet: Some(cfg.quiet), + num_workers: Some(cfg.num_workers as u32), + image_mode: Some(match cfg.image_mode { + ImageMode::Off => "off".to_string(), + ImageMode::Placeholder => "placeholder".to_string(), + ImageMode::Embed => "embed".to_string(), + }), + extract_links: Some(cfg.extract_links), + ocr_failure_fatal: Some(cfg.ocr_failure_fatal), + ocr_hedge_delays_ms: Some( + cfg.ocr_hedge_delays_ms + .iter() + .map(|&v| u32::try_from(v).unwrap_or(u32::MAX)) + .collect(), + ), + emit_word_boxes: Some(cfg.emit_word_boxes), + crop_box: cfg.crop_box.map(|c| JsCropBox { + top: c.top as f64, + right: c.right as f64, + bottom: c.bottom as f64, + left: c.left as f64, + }), + skip_diagonal_text: Some(cfg.skip_diagonal_text), + } + } +} + +// --------------------------------------------------------------------------- +// TextItem +// --------------------------------------------------------------------------- + +/// One word's sub-box within a `JsTextItem`, in the same viewport space. +#[napi(object)] +#[derive(Clone)] +pub struct JsWordBox { + pub text: String, + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, +} + +impl JsWordBox { + pub fn from_rust(word: &WordBox) -> Self { + Self { + text: word.text.clone(), + x: word.x as f64, + y: word.y as f64, + width: word.width as f64, + height: word.height as f64, + } + } +} + +#[napi(object)] +#[derive(Clone)] +pub struct JsTextItem { + pub text: String, + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, + pub font_name: Option, + pub font_size: Option, + pub confidence: Option, + /// Rotation in degrees (viewport space). Defaults to 0 when omitted. + pub rotation: Option, + /// Per-word sub-boxes for attribution. Empty for items with no word split + /// (e.g. OCR-sourced or single-token items). + pub words: Vec, +} + +impl JsTextItem { + pub fn to_rust(&self) -> TextItem { + TextItem { + text: self.text.clone(), + x: self.x as f32, + y: self.y as f32, + width: self.width as f32, + height: self.height as f32, + rotation: self.rotation.unwrap_or(0.0) as f32, + font_name: self.font_name.clone(), + font_size: self.font_size.map(|v| v as f32), + confidence: self.confidence.map(|v| v as f32), + ..Default::default() + } + } + + pub fn from_rust(item: &TextItem) -> Self { + Self { + text: item.text.clone(), + x: item.x as f64, + y: item.y as f64, + width: item.width as f64, + height: item.height as f64, + rotation: Some(item.rotation as f64), + font_name: item.font_name.clone(), + font_size: item.font_size.map(|v| v as f64), + confidence: item.confidence.map(|v| v as f64).or(Some(1.0)), + words: item.words.iter().map(JsWordBox::from_rust).collect(), + } + } +} + +// --------------------------------------------------------------------------- +// Graphic primitive (pre-extracted vector graphics) +// --------------------------------------------------------------------------- + +/// A vector-graphic primitive supplied by an external extractor. `kind` selects +/// the variant: `"stroke"` (uses `x1/y1/x2/y2`) or `"rect"` (uses +/// `x/y/width/height`). Coordinates are viewport space (top-left origin, 72 +/// DPI), matching the text items. `has_fill`/`has_stroke` carry the paint +/// intent even when no color is known, so ruled-table edge detection still +/// treats a colorless stroked rect as stroked. +#[napi(object)] +#[derive(Clone)] +pub struct JsGraphic { + /// "stroke" or "rect". Anything else is dropped. + pub kind: String, + // Stroke endpoints (used when kind == "stroke"). + pub x1: Option, + pub y1: Option, + pub x2: Option, + pub y2: Option, + // Rect bbox top-left + size (used when kind == "rect"). + pub x: Option, + pub y: Option, + pub width: Option, + pub height: Option, + /// Whether the path is filled. Drives Rect `fill` presence. + pub has_fill: Option, + /// Whether the path is stroked. Drives Rect `stroke` presence. + pub has_stroke: Option, + /// Fill color as ARGB hex (e.g. "ff000000"). May be absent even when filled. + pub fill_color: Option, + /// Stroke color as ARGB hex. May be absent even when stroked. + pub stroke_color: Option, + /// Stroke line width in points. + pub line_width: Option, +} + +impl JsGraphic { + pub fn to_rust(&self) -> Option { + match self.kind.as_str() { + "stroke" => Some(GraphicPrimitive::Stroke { + x1: self.x1.unwrap_or(0.0) as f32, + y1: self.y1.unwrap_or(0.0) as f32, + x2: self.x2.unwrap_or(0.0) as f32, + y2: self.y2.unwrap_or(0.0) as f32, + color: self.stroke_color.clone(), + width: self.line_width.unwrap_or(0.0) as f32, + }), + "rect" => Some(GraphicPrimitive::Rect { + bbox: Rect { + x: self.x.unwrap_or(0.0) as f32, + y: self.y.unwrap_or(0.0) as f32, + width: self.width.unwrap_or(0.0) as f32, + height: self.height.unwrap_or(0.0) as f32, + }, + fill: if self.has_fill.unwrap_or(false) { + Some(self.fill_color.clone().unwrap_or_default()) + } else { + None + }, + stroke: if self.has_stroke.unwrap_or(false) { + Some(self.stroke_color.clone().unwrap_or_default()) + } else { + None + }, + }), + _ => None, + } + } +} + +// --------------------------------------------------------------------------- +// Page input (pre-extracted) +// --------------------------------------------------------------------------- + +/// A page of pre-extracted text supplied by an external extractor. Coordinates +/// are viewport space (top-left origin, 72 DPI). `graphics` enables ruled-table +/// and horizontal-rule detection; struct nodes are still unsupported on this +/// path, so tagged-heading detection remains unavailable until they are added. +#[napi(object)] +#[derive(Clone)] +pub struct JsPageInput { + pub page_number: u32, + pub page_width: f64, + pub page_height: f64, + pub text_items: Vec, + pub graphics: Option>, +} + +impl JsPageInput { + pub fn to_rust(&self) -> Page { + Page { + page_number: self.page_number as usize, + page_width: self.page_width as f32, + page_height: self.page_height as f32, + text_items: self.text_items.iter().map(JsTextItem::to_rust).collect(), + graphics: self + .graphics + .as_ref() + .map(|gs| gs.iter().filter_map(JsGraphic::to_rust).collect()) + .unwrap_or_default(), + struct_nodes: Vec::new(), + image_refs: Vec::new(), + } + } +} + +// --------------------------------------------------------------------------- +// ParsedPage +// --------------------------------------------------------------------------- + +#[napi(object)] +#[derive(Clone)] +pub struct JsParsedPage { + pub page_num: u32, + pub width: f64, + pub height: f64, + pub text: String, + pub markdown: String, + pub text_items: Vec, +} + +impl JsParsedPage { + pub fn from_rust(page: &ParsedPage) -> Self { + Self { + page_num: page.page_number as u32, + width: page.page_width as f64, + height: page.page_height as f64, + text: page.text.clone(), + markdown: page.markdown.clone(), + text_items: page.text_items.iter().map(JsTextItem::from_rust).collect(), + } + } +} + +// --------------------------------------------------------------------------- +// ParseResult +// --------------------------------------------------------------------------- + +#[napi(object)] +#[derive(Clone)] +pub struct JsParseResult { + pub pages: Vec, + pub text: String, + pub images: Vec, +} + +#[napi(object)] +#[derive(Clone)] +pub struct JsExtractedImage { + pub id: String, + pub page: u32, + pub format: String, + pub bytes: napi::bindgen_prelude::Buffer, +} + +// --------------------------------------------------------------------------- +// ScreenshotResult +// --------------------------------------------------------------------------- + +#[napi(object)] +#[derive(Clone)] +pub struct JsScreenshotResult { + pub page_num: u32, + pub width: u32, + pub height: u32, + pub image_buffer: napi::bindgen_prelude::Buffer, +} + +#[napi(object)] +#[derive(Clone)] +pub struct JsPageComplexityStats { + pub page_number: u32, + pub text_length: u32, + pub text_coverage: f64, + pub has_substantial_images: bool, + pub image_block_count: u32, + pub image_coverage: f64, + pub largest_image_coverage: f64, + pub full_page_image: bool, + pub uncovered_vector_area: Option, + pub is_garbled: bool, + pub page_area: f64, + pub needs_ocr: bool, + pub reasons: Vec, +} + +impl JsPageComplexityStats { + pub fn from_rust(stats: &liteparse::ocr_merge::PageComplexityStats) -> Self { + Self { + page_number: stats.page_number as u32, + text_length: stats.text_length as u32, + text_coverage: stats.text_coverage as f64, + has_substantial_images: stats.has_substantial_images, + image_block_count: stats.image_block_count as u32, + image_coverage: stats.image_coverage as f64, + largest_image_coverage: stats.largest_image_coverage as f64, + full_page_image: stats.full_page_image, + uncovered_vector_area: stats.uncovered_vector_area.map(|v| v as f64), + is_garbled: stats.is_garbled, + page_area: stats.page_area as f64, + needs_ocr: stats.needs_ocr, + reasons: stats + .reasons + .iter() + .map(|r| r.as_str().to_string()) + .collect(), + } + } +} + +impl JsParseResult { + pub fn from_rust(result: &ParseResult, _config: &LiteParseConfig) -> Self { + Self { + pages: result.pages.iter().map(JsParsedPage::from_rust).collect(), + text: result.text.clone(), + images: result + .images + .iter() + .map(|img| JsExtractedImage { + id: img.id.clone(), + page: img.page, + format: img.format.clone(), + bytes: img.bytes.clone().into(), + }) + .collect(), + } + } +} diff --git a/crates/liteparse-python/Cargo.toml b/crates/liteparse-python/Cargo.toml new file mode 100644 index 0000000..7561028 --- /dev/null +++ b/crates/liteparse-python/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "liteparse-python" +version = "2.5.1" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Python bindings for LiteParse" + +[lib] +name = "_liteparse" +crate-type = ["cdylib"] + +[features] +default = ["tesseract"] +tesseract = ["liteparse/tesseract"] + +[dependencies] +liteparse = { package = "liteparse", version = "2.5.1", path = "../liteparse", default-features = false } +pdfium-sys = { package = "liteparse-pdfium-sys", version = "1.3.0", path = "../pdfium-sys" } +pdfium = { package = "liteparse-pdfium", version = "1.3.0", path = "../pdfium" } +clap = { version = "4.5.55", features = ["derive"] } +pyo3 = { version = "0.29", features = ["extension-module"] } +anyhow = "1.0.102" +image = { version = "0.25", default-features = false, features = ["png"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["full"] } diff --git a/crates/liteparse-python/build.rs b/crates/liteparse-python/build.rs new file mode 100644 index 0000000..decf075 --- /dev/null +++ b/crates/liteparse-python/build.rs @@ -0,0 +1,24 @@ +use std::env; + +fn main() { + // Get the pdfium lib path from the pdfium-sys link metadata + let lib_path = + env::var("DEP_PDFIUM_LIB_PATH").expect("DEP_PDFIUM_LIB_PATH not set (from pdfium-sys)"); + + let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + + // Tell the linker to set @loader_path as an rpath on macOS, + // or $ORIGIN on Linux, so the .so can find libpdfium next to itself. + match target_os.as_str() { + "macos" => { + println!("cargo:rustc-link-arg=-Wl,-rpath,@loader_path"); + } + "linux" => { + println!("cargo:rustc-link-arg=-Wl,-rpath,$ORIGIN"); + } + _ => {} + } + + // Emit the lib path so maturin/CI scripts can find and bundle libpdfium + println!("cargo:rustc-env=PDFIUM_LIB_DIR={lib_path}"); +} diff --git a/crates/liteparse-python/src/cli.rs b/crates/liteparse-python/src/cli.rs new file mode 100644 index 0000000..a0475ad --- /dev/null +++ b/crates/liteparse-python/src/cli.rs @@ -0,0 +1,440 @@ +use clap::{Args, Parser, Subcommand}; +use liteparse::config::{ImageMode, LiteParseConfig, OutputFormat}; +use liteparse::conversion; +use liteparse::output::{json, text}; +use liteparse::parser::LiteParse; + +#[derive(Parser, Debug)] +#[command( + name = "lit", + version, + about = "OSS document parsing tool (supports PDF, DOCX, XLSX, images, and more)" +)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand, Debug)] +enum Commands { + /// Parse a document file (PDF, DOCX, XLSX, PPTX, images, etc.) + Parse(ParseCommand), + /// Generate screenshots of document pages (PDF, DOCX, XLSX, images, etc.) + Screenshot(ScreenshotCommand), + /// Parse multiple documents in batch mode + BatchParse(BatchParseCommand), +} + +#[derive(Args, Debug)] +struct ParseCommand { + file: String, + #[arg(short, long)] + output: Option, + #[arg(long, default_value = "text")] + format: String, + #[arg(long)] + no_ocr: bool, + #[arg(long, default_value = "eng")] + ocr_language: String, + #[arg(long, default_value = None)] + ocr_server_url: Option, + /// Extra header for OCR server requests, "Name: Value" (repeatable). + /// e.g. --ocr-server-header "Authorization: Bearer " + #[arg(long = "ocr-server-header", value_parser = parse_header)] + ocr_server_headers: Vec<(String, String)>, + #[arg(long)] + tessdata_path: Option, + #[arg(long, default_value = "1000")] + max_pages: usize, + #[arg(long)] + target_pages: Option, + #[arg(long, default_value = "150")] + dpi: f32, + #[arg(long)] + preserve_small_text: bool, + #[arg(long)] + password: Option, + #[arg(short, long)] + quiet: bool, + #[arg(long)] + num_workers: Option, + /// How to surface raster images in markdown output: `off`, `placeholder` + /// (default), or `embed` (extracts PNG bytes, written next to the output + /// when `--image-output-dir` is set). + #[arg(long, default_value = "placeholder")] + image_mode: String, + /// Directory to write embedded images to when `--image-mode embed` is set. + /// Each image is written as `image_{id}.png` to match the markdown + /// references. Created if missing. + #[arg(long)] + image_output_dir: Option, + /// Disable hyperlink extraction. By default URI link annotations render as + /// `[text](url)` in markdown output; pass this to emit plain anchor text. + #[arg(long)] + no_links: bool, +} + +#[derive(Args, Debug)] +struct ScreenshotCommand { + file: String, + #[arg(short, long, default_value = "./screenshots")] + output_dir: String, + #[arg(long)] + target_pages: Option, + #[arg(long, default_value = "150")] + dpi: f32, + #[arg(long)] + password: Option, + #[arg(short, long)] + quiet: bool, +} + +#[derive(Args, Debug)] +struct BatchParseCommand { + input_dir: String, + output_dir: String, + #[arg(long, default_value = "text")] + format: String, + #[arg(long)] + no_ocr: bool, + #[arg(long, default_value = "eng")] + ocr_language: String, + #[arg(long, default_value = None)] + ocr_server_url: Option, + /// Extra header for OCR server requests, "Name: Value" (repeatable). + /// e.g. --ocr-server-header "Authorization: Bearer " + #[arg(long = "ocr-server-header", value_parser = parse_header)] + ocr_server_headers: Vec<(String, String)>, + #[arg(long)] + tessdata_path: Option, + #[arg(long, default_value = "1000")] + max_pages: usize, + #[arg(long, default_value = "150")] + dpi: f32, + #[arg(long)] + recursive: bool, + #[arg(long)] + extension: Option, + #[arg(long)] + password: Option, + #[arg(short, long)] + quiet: bool, + #[arg(long)] + num_workers: Option, +} + +/// Parse a `Name: Value` header string into a `(name, value)` pair. +fn parse_header(s: &str) -> Result<(String, String), String> { + let (name, value) = s + .split_once(':') + .ok_or_else(|| format!("invalid header '{}', expected 'Name: Value'", s))?; + let name = name.trim(); + if name.is_empty() { + return Err(format!("invalid header '{}', empty header name", s)); + } + Ok((name.to_string(), value.trim().to_string())) +} + +fn parse_output_format(s: &str) -> Result { + match s.to_lowercase().as_str() { + "json" => Ok(OutputFormat::Json), + "text" => Ok(OutputFormat::Text), + "markdown" | "md" => Ok(OutputFormat::Markdown), + _ => Err(format!( + "unknown format '{}', expected 'json', 'text', or 'markdown'", + s + )), + } +} + +fn parse_image_mode(s: &str) -> Result { + match s.to_lowercase().as_str() { + "off" | "none" => Ok(ImageMode::Off), + "placeholder" => Ok(ImageMode::Placeholder), + "embed" => Ok(ImageMode::Embed), + _ => Err(format!( + "unknown image-mode '{}', expected 'off', 'placeholder', or 'embed'", + s + )), + } +} + +/// Run the CLI with the given args (typically from sys.argv). +pub fn run_cli(args: Vec) -> Result<(), Box> { + let cli = Cli::parse_from(args); + let rt = tokio::runtime::Runtime::new()?; + + match cli.command { + Commands::Parse(cmd) => { + let format = parse_output_format(&cmd.format)?; + let image_mode = parse_image_mode(&cmd.image_mode)?; + let mut config = LiteParseConfig { + ocr_language: cmd.ocr_language, + ocr_enabled: !cmd.no_ocr, + tessdata_path: cmd.tessdata_path, + max_pages: cmd.max_pages, + target_pages: cmd.target_pages, + dpi: cmd.dpi, + output_format: format, + preserve_very_small_text: cmd.preserve_small_text, + password: cmd.password, + quiet: cmd.quiet, + ocr_server_url: cmd.ocr_server_url, + ocr_server_headers: cmd.ocr_server_headers, + image_mode, + extract_links: !cmd.no_links, + ..Default::default() + }; + if let Some(n) = cmd.num_workers { + config.num_workers = n; + } + let lp = LiteParse::new(config); + let result = rt.block_on(lp.parse(&cmd.file))?; + let formatted = match lp.config().output_format { + OutputFormat::Json => json::format_json(&result.pages)?, + OutputFormat::Text => text::format_text(&result.pages), + OutputFormat::Markdown => result.text.clone(), + }; + if let Some(dir) = cmd.image_output_dir.as_deref() + && !result.images.is_empty() + { + std::fs::create_dir_all(dir)?; + for img in &result.images { + let path = format!("{}/image_{}.{}", dir, img.id, img.format); + std::fs::write(&path, &img.bytes)?; + } + if !cmd.quiet { + eprintln!( + "[liteparse] wrote {} image(s) to {}", + result.images.len(), + dir + ); + } + } + match cmd.output { + Some(path) => { + std::fs::write(&path, &formatted)?; + if !cmd.quiet { + eprintln!("[liteparse] wrote output to {}", path); + } + } + None => println!("{}", formatted), + } + } + + Commands::Screenshot(cmd) => { + let target_pages = cmd + .target_pages + .as_ref() + .map(|s| liteparse::config::parse_target_pages(s)) + .transpose() + .map_err(|e| format!("invalid --target-pages: {}", e))?; + + std::fs::create_dir_all(&cmd.output_dir)?; + + let config = LiteParseConfig { + target_pages: cmd.target_pages.clone(), + dpi: cmd.dpi, + password: cmd.password.clone(), + quiet: cmd.quiet, + ..Default::default() + }; + let lp = LiteParse::new(config); + let results = rt.block_on(lp.screenshot(&cmd.file, target_pages))?; + + for result in results { + let output_path = format!("{}/page_{}.png", cmd.output_dir, result.page_num); + std::fs::write(&output_path, &result.image_bytes)?; + if !cmd.quiet { + eprintln!( + "[liteparse] screenshot page {} → {}", + result.page_num, output_path + ); + } + } + } + + Commands::BatchParse(cmd) => { + let format = parse_output_format(&cmd.format)?; + let ext_filter = cmd.extension.as_ref().map(|e| { + let e = e.to_lowercase(); + if e.starts_with('.') { + e + } else { + format!(".{}", e) + } + }); + + let mut config = LiteParseConfig { + ocr_language: cmd.ocr_language, + ocr_enabled: !cmd.no_ocr, + tessdata_path: cmd.tessdata_path, + max_pages: cmd.max_pages, + target_pages: None, + dpi: cmd.dpi, + output_format: format.clone(), + preserve_very_small_text: false, + password: cmd.password, + quiet: cmd.quiet, + ocr_server_url: cmd.ocr_server_url, + ocr_server_headers: cmd.ocr_server_headers, + ..Default::default() + }; + if let Some(n) = cmd.num_workers { + config.num_workers = n; + } + + let lp = LiteParse::new(config); + let out_ext = match format { + OutputFormat::Json => "json", + OutputFormat::Markdown => "md", + OutputFormat::Text => "txt", + }; + + std::fs::create_dir_all(&cmd.output_dir)?; + let files = collect_files(&cmd.input_dir, cmd.recursive, ext_filter.as_deref())?; + + if files.is_empty() { + eprintln!("[liteparse] no matching files found in {}", cmd.input_dir); + return Ok(()); + } + if !cmd.quiet { + eprintln!("[liteparse] found {} files to process", files.len()); + } + + let mut success = 0usize; + let mut errors = 0usize; + + for file_path in &files { + let t0 = std::time::Instant::now(); + let out_path = + batch_output_path(file_path, &cmd.input_dir, &cmd.output_dir, out_ext); + + if let Some(parent) = out_path.parent() { + std::fs::create_dir_all(parent)?; + } + + match rt.block_on(lp.parse(file_path)) { + Ok(result) => { + let fmt_result: Result> = + match lp.config().output_format { + OutputFormat::Json => { + json::format_json(&result.pages).map_err(|e| e.into()) + } + OutputFormat::Text => Ok(text::format_text(&result.pages)), + OutputFormat::Markdown => Ok(result.text.clone()), + }; + match fmt_result { + Ok(formatted) => { + std::fs::write(&out_path, &formatted)?; + success += 1; + if !cmd.quiet { + let elapsed = t0.elapsed().as_secs_f64() * 1000.0; + eprintln!( + "[liteparse] {} → {} ({:.1}ms)", + file_path, + out_path.display(), + elapsed + ); + } + } + Err(e) => { + eprintln!("[liteparse] error formatting {}: {}", file_path, e); + errors += 1; + } + } + } + Err(e) => { + eprintln!("[liteparse] error parsing {}: {}", file_path, e); + errors += 1; + } + } + } + + eprintln!( + "[liteparse] batch complete: {} succeeded, {} failed", + success, errors + ); + if errors > 0 { + std::process::exit(1); + } + } + } + + Ok(()) +} + +fn collect_files( + dir: &str, + recursive: bool, + ext_filter: Option<&str>, +) -> Result, Box> { + let mut files = Vec::new(); + collect_files_inner(std::path::Path::new(dir), recursive, ext_filter, &mut files)?; + files.sort(); + Ok(files) +} + +fn batch_output_path( + file_path: &str, + input_dir: &str, + output_dir: &str, + out_ext: &str, +) -> std::path::PathBuf { + let file_path = std::path::Path::new(file_path); + let rel = file_path + .strip_prefix(std::path::Path::new(input_dir)) + .unwrap_or(file_path); + + std::path::Path::new(output_dir) + .join(rel) + .with_extension(out_ext) +} + +#[cfg(test)] +mod tests { + use super::batch_output_path; + use std::path::Path; + + #[test] + fn batch_output_path_preserves_output_dir_without_trailing_slash() { + let out_path = batch_output_path("docs/report.pdf", "docs", "out", "txt"); + + assert_eq!(out_path, Path::new("out/report.txt")); + } + + #[test] + fn batch_output_path_mirrors_nested_files_without_trailing_slash() { + let out_path = batch_output_path("docs/nested/report.pdf", "docs", "out", "md"); + + assert_eq!(out_path, Path::new("out/nested/report.md")); + } +} + +fn collect_files_inner( + dir: &std::path::Path, + recursive: bool, + ext_filter: Option<&str>, + files: &mut Vec, +) -> Result<(), Box> { + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + if recursive { + collect_files_inner(&path, recursive, ext_filter, files)?; + } + continue; + } + let path_str = path.to_string_lossy().to_string(); + if let Some(filter) = ext_filter { + if !path_str.to_lowercase().ends_with(filter) { + continue; + } + } else if !conversion::is_supported_extension(&path_str) { + continue; + } + files.push(path_str); + } + Ok(()) +} diff --git a/crates/liteparse-python/src/lib.rs b/crates/liteparse-python/src/lib.rs new file mode 100644 index 0000000..85fadf2 --- /dev/null +++ b/crates/liteparse-python/src/lib.rs @@ -0,0 +1,694 @@ +use std::collections::HashMap; + +use pyo3::prelude::*; +use pyo3::types::PyBytes; + +use liteparse::config::{CropBox, ImageMode, LiteParseConfig, OutputFormat}; +use liteparse::types::PdfInput; + +mod cli; + +// --------------------------------------------------------------------------- +// Python type wrappers +// --------------------------------------------------------------------------- + +#[pyclass(frozen, from_py_object)] +#[derive(Clone)] +struct PyWordBox { + #[pyo3(get)] + text: String, + #[pyo3(get)] + x: f64, + #[pyo3(get)] + y: f64, + #[pyo3(get)] + width: f64, + #[pyo3(get)] + height: f64, +} + +#[pymethods] +impl PyWordBox { + fn __repr__(&self) -> String { + format!( + "WordBox(text={:?}, x={}, y={}, width={}, height={})", + self.text, self.x, self.y, self.width, self.height + ) + } +} + +impl PyWordBox { + fn from_rust(word: liteparse::types::WordBox) -> Self { + Self { + text: word.text, + x: word.x as f64, + y: word.y as f64, + width: word.width as f64, + height: word.height as f64, + } + } +} + +#[pyclass(frozen, from_py_object)] +#[derive(Clone)] +struct PyTextItem { + #[pyo3(get)] + text: String, + #[pyo3(get)] + x: f64, + #[pyo3(get)] + y: f64, + #[pyo3(get)] + width: f64, + #[pyo3(get)] + height: f64, + #[pyo3(get)] + font_name: Option, + #[pyo3(get)] + font_size: Option, + #[pyo3(get)] + confidence: Option, + /// Rotation in degrees (viewport space). Defaults to 0. + #[pyo3(get)] + rotation: f64, + /// Per-word sub-boxes for attribution. Empty unless the parse was + /// configured with `emit_word_boxes=True`. + #[pyo3(get)] + words: Vec, +} + +#[pymethods] +impl PyTextItem { + fn __repr__(&self) -> String { + format!( + "TextItem(text={:?}, x={}, y={}, width={}, height={})", + self.text, self.x, self.y, self.width, self.height + ) + } +} + +impl PyTextItem { + fn to_rust(&self) -> liteparse::types::TextItem { + liteparse::types::TextItem { + text: self.text.clone(), + x: self.x as f32, + y: self.y as f32, + width: self.width as f32, + height: self.height as f32, + rotation: self.rotation as f32, + font_name: self.font_name.clone(), + font_size: self.font_size.map(|v| v as f32), + confidence: self.confidence.map(|v| v as f32), + ..Default::default() + } + } + + fn from_rust(item: liteparse::types::TextItem) -> Self { + Self { + text: item.text, + x: item.x as f64, + y: item.y as f64, + width: item.width as f64, + height: item.height as f64, + font_name: item.font_name, + font_size: item.font_size.map(|v| v as f64), + confidence: item.confidence.map(|v| v as f64).or(Some(1.0)), + rotation: item.rotation as f64, + words: item.words.into_iter().map(PyWordBox::from_rust).collect(), + } + } +} + +#[pyclass(frozen, from_py_object)] +#[derive(Clone)] +struct PyParsedPage { + #[pyo3(get)] + page_num: u32, + #[pyo3(get)] + width: f64, + #[pyo3(get)] + height: f64, + #[pyo3(get)] + text: String, + #[pyo3(get)] + markdown: String, + #[pyo3(get)] + text_items: Vec, +} + +#[pymethods] +impl PyParsedPage { + fn __repr__(&self) -> String { + format!( + "ParsedPage(page_num={}, width={}, height={}, text_items={})", + self.page_num, + self.width, + self.height, + self.text_items.len() + ) + } +} + +impl PyParsedPage { + fn from_rust(page: liteparse::types::ParsedPage) -> Self { + Self { + page_num: page.page_number as u32, + width: page.page_width as f64, + height: page.page_height as f64, + text: page.text, + markdown: page.markdown, + text_items: page + .text_items + .into_iter() + .map(PyTextItem::from_rust) + .collect(), + } + } +} + +#[pyclass(frozen, from_py_object)] +#[derive(Clone)] +struct PyParseResult { + #[pyo3(get)] + pages: Vec, + #[pyo3(get)] + text: String, + #[pyo3(get)] + images: Vec, +} + +#[pymethods] +impl PyParseResult { + #[getter] + fn num_pages(&self) -> usize { + self.pages.len() + } + + fn get_page(&self, page_num: u32) -> Option { + self.pages.iter().find(|p| p.page_num == page_num).cloned() + } + + fn __repr__(&self) -> String { + format!( + "ParseResult(pages={}, text_len={}, images={})", + self.pages.len(), + self.text.len(), + self.images.len() + ) + } +} + +impl PyParseResult { + fn from_rust(result: liteparse::parser::ParseResult) -> Self { + Self { + pages: result + .pages + .into_iter() + .map(PyParsedPage::from_rust) + .collect(), + text: result.text, + images: result + .images + .into_iter() + .map(PyExtractedImage::from_rust) + .collect(), + } + } +} + +#[pyclass(frozen, from_py_object)] +#[derive(Clone)] +struct PyExtractedImage { + #[pyo3(get)] + id: String, + #[pyo3(get)] + page: u32, + #[pyo3(get)] + format: String, + bytes_buffer: Vec, +} + +#[pymethods] +impl PyExtractedImage { + #[getter] + fn bytes<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + PyBytes::new(py, &self.bytes_buffer) + } + + fn __repr__(&self) -> String { + format!( + "ExtractedImage(id='{}', page={}, format='{}', bytes_len={})", + self.id, + self.page, + self.format, + self.bytes_buffer.len() + ) + } +} + +impl PyExtractedImage { + fn from_rust(img: liteparse::types::ExtractedImage) -> Self { + Self { + id: img.id, + page: img.page, + format: img.format, + bytes_buffer: img.bytes, + } + } +} + +#[pyclass(frozen, from_py_object)] +#[derive(Clone)] +struct PyScreenshotResult { + #[pyo3(get)] + page_num: u32, + #[pyo3(get)] + width: u32, + #[pyo3(get)] + height: u32, + image_buffer: Vec, +} + +#[pymethods] +impl PyScreenshotResult { + #[getter] + fn image_bytes<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + PyBytes::new(py, &self.image_buffer) + } + + fn __repr__(&self) -> String { + format!( + "ScreenshotResult(page_num={}, width={}, height={})", + self.page_num, self.width, self.height + ) + } +} + +#[pyclass(frozen, from_py_object)] +#[derive(Clone)] +struct PyPageComplexityStats { + #[pyo3(get)] + page_number: usize, + #[pyo3(get)] + text_length: usize, + #[pyo3(get)] + text_coverage: f32, + #[pyo3(get)] + has_substantial_images: bool, + #[pyo3(get)] + image_block_count: usize, + #[pyo3(get)] + image_coverage: f32, + #[pyo3(get)] + largest_image_coverage: f32, + #[pyo3(get)] + full_page_image: bool, + #[pyo3(get)] + uncovered_vector_area: Option, + #[pyo3(get)] + is_garbled: bool, + #[pyo3(get)] + page_area: f32, + #[pyo3(get)] + needs_ocr: bool, + #[pyo3(get)] + reasons: Vec, +} + +#[pymethods] +impl PyPageComplexityStats { + fn __repr__(&self) -> String { + format!( + "PageComplexityStats(page_number={}, text_length={}, text_coverage={:.2}, needs_ocr={})", + self.page_number, self.text_length, self.text_coverage, self.needs_ocr + ) + } +} + +impl PyPageComplexityStats { + fn from_rust(stats: &liteparse::ocr_merge::PageComplexityStats) -> Self { + Self { + page_number: stats.page_number, + text_length: stats.text_length, + text_coverage: stats.text_coverage, + has_substantial_images: stats.has_substantial_images, + image_block_count: stats.image_block_count, + image_coverage: stats.image_coverage, + largest_image_coverage: stats.largest_image_coverage, + full_page_image: stats.full_page_image, + uncovered_vector_area: stats.uncovered_vector_area, + is_garbled: stats.is_garbled, + page_area: stats.page_area, + needs_ocr: stats.needs_ocr, + reasons: stats + .reasons + .iter() + .map(|r| r.as_str().to_string()) + .collect(), + } + } +} + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- + +#[pyclass(frozen, from_py_object)] +#[derive(Clone)] +struct PyLiteParseConfig { + #[pyo3(get)] + ocr_language: String, + #[pyo3(get)] + ocr_enabled: bool, + #[pyo3(get)] + ocr_server_url: Option, + #[pyo3(get)] + ocr_server_headers: Option>, + #[pyo3(get)] + tessdata_path: Option, + #[pyo3(get)] + max_pages: usize, + #[pyo3(get)] + target_pages: Option, + #[pyo3(get)] + dpi: f32, + #[pyo3(get)] + output_format: String, + #[pyo3(get)] + preserve_very_small_text: bool, + #[pyo3(get)] + password: Option, + #[pyo3(get)] + quiet: bool, + #[pyo3(get)] + num_workers: usize, +} + +#[pymethods] +impl PyLiteParseConfig { + fn __repr__(&self) -> String { + format!( + "LiteParseConfig(ocr_enabled={}, dpi={}, max_pages={})", + self.ocr_enabled, self.dpi, self.max_pages + ) + } +} + +impl PyLiteParseConfig { + fn from_rust(cfg: &LiteParseConfig) -> Self { + Self { + ocr_language: cfg.ocr_language.clone(), + ocr_enabled: cfg.ocr_enabled, + ocr_server_url: cfg.ocr_server_url.clone(), + ocr_server_headers: if cfg.ocr_server_headers.is_empty() { + None + } else { + Some(cfg.ocr_server_headers.iter().cloned().collect()) + }, + tessdata_path: cfg.tessdata_path.clone(), + max_pages: cfg.max_pages, + target_pages: cfg.target_pages.clone(), + dpi: cfg.dpi, + output_format: match cfg.output_format { + OutputFormat::Json => "json".to_string(), + OutputFormat::Text => "text".to_string(), + OutputFormat::Markdown => "markdown".to_string(), + }, + preserve_very_small_text: cfg.preserve_very_small_text, + password: cfg.password.clone(), + quiet: cfg.quiet, + num_workers: cfg.num_workers, + } + } +} + +// --------------------------------------------------------------------------- +// Main LiteParse class +// --------------------------------------------------------------------------- + +#[pyclass] +struct LiteParse { + inner: liteparse::parser::LiteParse, + config: LiteParseConfig, + runtime: tokio::runtime::Runtime, +} + +#[pymethods] +impl LiteParse { + #[new] + #[pyo3(signature = ( + *, + ocr_language = None, + ocr_enabled = None, + ocr_server_url = None, + ocr_server_headers = None, + tessdata_path = None, + max_pages = None, + target_pages = None, + dpi = None, + output_format = None, + preserve_very_small_text = None, + password = None, + quiet = None, + num_workers = None, + image_mode = None, + extract_links = None, + ocr_failure_fatal = None, + ocr_hedge_delays_ms = None, + emit_word_boxes = None, + crop_box = None, + skip_diagonal_text = None, + ))] + fn new( + ocr_language: Option, + ocr_enabled: Option, + ocr_server_url: Option, + ocr_server_headers: Option>, + tessdata_path: Option, + max_pages: Option, + target_pages: Option, + dpi: Option, + output_format: Option, + preserve_very_small_text: Option, + password: Option, + quiet: Option, + num_workers: Option, + image_mode: Option, + extract_links: Option, + ocr_failure_fatal: Option, + ocr_hedge_delays_ms: Option>, + emit_word_boxes: Option, + crop_box: Option<(f32, f32, f32, f32)>, + skip_diagonal_text: Option, + ) -> PyResult { + let mut cfg = LiteParseConfig::default(); + if let Some(v) = ocr_language { + cfg.ocr_language = v; + } + if let Some(v) = ocr_enabled { + cfg.ocr_enabled = v; + } + if let Some(v) = ocr_server_url { + cfg.ocr_server_url = Some(v); + } + if let Some(v) = ocr_server_headers { + cfg.ocr_server_headers = v.into_iter().collect(); + } + if let Some(v) = tessdata_path { + cfg.tessdata_path = Some(v); + } + if let Some(v) = max_pages { + cfg.max_pages = v; + } + if let Some(v) = target_pages { + cfg.target_pages = Some(v); + } + if let Some(v) = dpi { + cfg.dpi = v; + } + if let Some(v) = output_format { + cfg.output_format = match v.as_str() { + "text" => OutputFormat::Text, + "markdown" | "md" => OutputFormat::Markdown, + _ => OutputFormat::Json, + }; + } + if let Some(v) = preserve_very_small_text { + cfg.preserve_very_small_text = v; + } + if let Some(v) = password { + cfg.password = Some(v); + } + if let Some(v) = quiet { + cfg.quiet = v; + } + if let Some(v) = num_workers { + cfg.num_workers = v; + } + if let Some(v) = image_mode { + cfg.image_mode = match v.as_str() { + "off" | "none" => ImageMode::Off, + "embed" => ImageMode::Embed, + _ => ImageMode::Placeholder, + }; + } + if let Some(v) = extract_links { + cfg.extract_links = v; + } + if let Some(v) = ocr_failure_fatal { + cfg.ocr_failure_fatal = v; + } + if let Some(v) = ocr_hedge_delays_ms { + cfg.ocr_hedge_delays_ms = v; + } + if let Some(v) = emit_word_boxes { + cfg.emit_word_boxes = v; + } + if let Some((top, right, bottom, left)) = crop_box { + cfg.crop_box = Some(CropBox { + top, + right, + bottom, + left, + }); + } + if let Some(v) = skip_diagonal_text { + cfg.skip_diagonal_text = v; + } + + let inner = liteparse::parser::LiteParse::new(cfg.clone()); + let runtime = tokio::runtime::Runtime::new() + .map_err(|e| PyErr::new::(e.to_string()))?; + + Ok(Self { + inner, + config: cfg, + runtime, + }) + } + + /// Parse a document from a file path. + fn parse(&self, py: Python<'_>, input: String) -> PyResult { + let pdf_input = PdfInput::Path(input); + let result = py + .detach(|| self.runtime.block_on(self.inner.parse_input(pdf_input))) + .map_err(|e| PyErr::new::(e.to_string()))?; + Ok(PyParseResult::from_rust(result)) + } + + /// Parse a document from raw bytes. + fn parse_bytes(&self, py: Python<'_>, data: Vec) -> PyResult { + let pdf_input = PdfInput::Bytes(data); + let result = py + .detach(|| self.runtime.block_on(self.inner.parse_input(pdf_input))) + .map_err(|e| PyErr::new::(e.to_string()))?; + Ok(PyParseResult::from_rust(result)) + } + + /// Determine per-page complexity for a document at the given path. Returns + /// a list of PageComplexityStats — a cheap pre-OCR check with per-page + /// signals and a `needs_ocr` verdict. + fn is_complex(&self, py: Python<'_>, input: String) -> PyResult> { + let pdf_input = PdfInput::Path(input); + let stats = py + .detach(|| self.runtime.block_on(self.inner.is_complex(pdf_input))) + .map_err(|e| PyErr::new::(e.to_string()))?; + Ok(stats.iter().map(PyPageComplexityStats::from_rust).collect()) + } + + /// Determine per-page complexity for a document from raw bytes. + fn is_complex_bytes( + &self, + py: Python<'_>, + data: Vec, + ) -> PyResult> { + let pdf_input = PdfInput::Bytes(data); + let stats = py + .detach(|| self.runtime.block_on(self.inner.is_complex(pdf_input))) + .map_err(|e| PyErr::new::(e.to_string()))?; + Ok(stats.iter().map(PyPageComplexityStats::from_rust).collect()) + } + + /// Take screenshots of document pages. Returns a list of ScreenshotResult. + /// + /// Non-PDF files are automatically converted to PDF before rendering when + /// LibreOffice/ImageMagick are available. + #[pyo3(signature = (input, page_numbers = None))] + fn screenshot( + &self, + py: Python<'_>, + input: String, + page_numbers: Option>, + ) -> PyResult> { + py.detach(|| { + let results = self + .runtime + .block_on(self.inner.screenshot(&input, page_numbers)) + .map_err(|e| PyErr::new::(e.to_string()))?; + + Ok(results + .into_iter() + .map(|r| PyScreenshotResult { + page_num: r.page_num, + width: r.width, + height: r.height, + image_buffer: r.image_bytes, + }) + .collect()) + }) + } + + /// Get the resolved configuration. + #[getter] + fn config(&self) -> PyLiteParseConfig { + PyLiteParseConfig::from_rust(&self.config) + } + + fn __repr__(&self) -> String { + format!( + "LiteParse(ocr_enabled={}, dpi={}, max_pages={})", + self.config.ocr_enabled, self.config.dpi, self.config.max_pages + ) + } +} + +// --------------------------------------------------------------------------- +// Module +// --------------------------------------------------------------------------- + +/// Search text items for phrase matches, returning merged items with combined bounding boxes. +#[pyfunction] +#[pyo3(signature = (items, phrase, *, case_sensitive = false))] +fn search_items(items: Vec, phrase: String, case_sensitive: bool) -> Vec { + let rust_items: Vec<_> = items.iter().map(|i| i.to_rust()).collect(); + let options = liteparse::search::SearchOptions { + phrase, + case_sensitive, + }; + liteparse::search::search_items(&rust_items, &options) + .into_iter() + .map(PyTextItem::from_rust) + .collect() +} + +/// Run the `lit` CLI with the given arguments. +#[pyfunction] +fn run_cli(args: Vec) -> PyResult<()> { + cli::run_cli(args).map_err(|e| PyErr::new::(e.to_string())) +} + +#[pymodule] +fn _liteparse(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_function(wrap_pyfunction!(run_cli, m)?)?; + m.add_function(wrap_pyfunction!(search_items, m)?)?; + Ok(()) +} diff --git a/crates/liteparse-wasm/Cargo.toml b/crates/liteparse-wasm/Cargo.toml new file mode 100644 index 0000000..64e56aa --- /dev/null +++ b/crates/liteparse-wasm/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "liteparse-wasm" +version = "2.5.1" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "LiteParse PDF parser — WebAssembly bindings for the browser" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +liteparse = { path = "../liteparse", default-features = false } +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +js-sys = "0.3" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde-wasm-bindgen = "0.6" +tsify-next = { version = "0.5", default-features = false, features = ["js"] } +console_error_panic_hook = { version = "0.1", optional = true } + +[features] +default = ["panic_hook"] +panic_hook = ["dep:console_error_panic_hook"] + +[package.metadata.wasm-pack.profile.release] +wasm-opt = ["-O3"] diff --git a/crates/liteparse-wasm/build.rs b/crates/liteparse-wasm/build.rs new file mode 100644 index 0000000..ba700dd --- /dev/null +++ b/crates/liteparse-wasm/build.rs @@ -0,0 +1,15 @@ +use std::env; + +fn main() { + let target_arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default(); + + // pdfium's wasm build links libsetjmp.a for __c_longjmp, but that archive's + // single object also defines the __wasm_setjmp/__wasm_longjmp helpers that + // Rust's own codegen already emits. The definitions are identical LLVM SjLj + // helpers, so allow the linker to keep the first and resolve the otherwise + // missing __c_longjmp from the same archive. Must live here (the final + // cdylib) because rustc-link-arg is not propagated from dependency crates. + if target_arch == "wasm32" { + println!("cargo:rustc-link-arg=--allow-multiple-definition"); + } +} diff --git a/crates/liteparse-wasm/src/lib.rs b/crates/liteparse-wasm/src/lib.rs new file mode 100644 index 0000000..73bcd57 --- /dev/null +++ b/crates/liteparse-wasm/src/lib.rs @@ -0,0 +1,666 @@ +#![cfg(target_arch = "wasm32")] +//! WebAssembly bindings for LiteParse. +//! +//! Exposes a small JS-facing API mirroring `packages/node`: +//! - `LiteParse` class with `new(config)`, `parse(Uint8Array)` +//! - JS-side OCR callback bridge (any object with an async `recognize` method) + +mod wasi_stubs; + +use std::collections::HashMap; +use std::pin::Pin; + +use js_sys::{Function, Reflect, Uint8Array}; +use serde::{Deserialize, Serialize}; +use tsify_next::Tsify; +use wasm_bindgen::prelude::*; +use wasm_bindgen_futures::JsFuture; + +use liteparse::config::{ + CropBox as CoreCropBox, ImageMode, LiteParseConfig as CoreConfig, OutputFormat, +}; +use liteparse::ocr::{OcrEngine, OcrOptions, OcrResult as CoreOcrResult}; +use liteparse::parser::LiteParse as CoreLiteParse; +use liteparse::search; +use liteparse::types::PdfInput; + +// --------------------------------------------------------------------------- +// Setup +// --------------------------------------------------------------------------- + +#[wasm_bindgen(start)] +pub fn __wasm_start() { + #[cfg(feature = "panic_hook")] + console_error_panic_hook::set_once(); +} + +// --------------------------------------------------------------------------- +// JS-facing config (camelCase to match the Node package) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize, Default, Tsify)] +#[tsify(into_wasm_abi, from_wasm_abi)] +#[serde(rename_all = "camelCase", default)] +pub struct LiteParseConfig { + ocr_language: Option, + ocr_enabled: Option, + ocr_server_url: Option, + ocr_server_headers: Option>, + tessdata_path: Option, + max_pages: Option, + target_pages: Option, + dpi: Option, + #[tsify(type = "\"json\" | \"text\" | \"markdown\" | \"md\"")] + output_format: Option, + #[tsify(type = "\"off\" | \"none\" | \"placeholder\" | \"embed\"")] + image_mode: Option, + extract_links: Option, + ocr_failure_fatal: Option, + ocr_hedge_delays_ms: Option>, + preserve_very_small_text: Option, + password: Option, + quiet: Option, + emit_word_boxes: Option, + /// Restrict output to a page sub-region. Each field is the fraction of the + /// page cropped from that side; a text item survives only if it lies + /// entirely inside the remaining rectangle. Unset keeps the whole page. + crop_box: Option, + /// Drop diagonal text (rotation >2° off the nearest right angle). Default + /// false. Use to exclude rotated watermarks/stamps from the output. + skip_diagonal_text: Option, +} + +/// A page sub-region as the fraction cropped from each side (top-left origin, +/// each in `[0, 1]`). +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, Tsify)] +#[tsify(into_wasm_abi, from_wasm_abi)] +#[serde(rename_all = "camelCase", default)] +pub struct CropBox { + top: f32, + right: f32, + bottom: f32, + left: f32, +} + +impl LiteParseConfig { + fn into_core(self) -> Result { + let mut cfg = CoreConfig::default(); + if let Some(v) = self.ocr_language { + cfg.ocr_language = v; + } + if let Some(v) = self.ocr_enabled { + cfg.ocr_enabled = v; + } + if self.ocr_server_url.is_some() { + cfg.ocr_server_url = self.ocr_server_url; + } + if let Some(v) = self.ocr_server_headers { + cfg.ocr_server_headers = v.into_iter().collect(); + } + if self.tessdata_path.is_some() { + cfg.tessdata_path = self.tessdata_path; + } + if let Some(v) = self.max_pages { + cfg.max_pages = v; + } + if self.target_pages.is_some() { + cfg.target_pages = self.target_pages; + } + if let Some(v) = self.dpi { + cfg.dpi = v; + } + if let Some(v) = self.output_format { + cfg.output_format = match v.as_str() { + "json" => OutputFormat::Json, + "text" => OutputFormat::Text, + "markdown" | "md" => OutputFormat::Markdown, + other => { + return Err(JsError::new(&format!( + "invalid outputFormat: {} (expected 'json', 'text', or 'markdown')", + other + ))); + } + }; + } + if let Some(v) = self.image_mode { + cfg.image_mode = match v.as_str() { + "off" | "none" => ImageMode::Off, + "embed" => ImageMode::Embed, + _ => ImageMode::Placeholder, + }; + } + if let Some(v) = self.extract_links { + cfg.extract_links = v; + } + if let Some(v) = self.ocr_failure_fatal { + cfg.ocr_failure_fatal = v; + } + if let Some(v) = self.ocr_hedge_delays_ms { + cfg.ocr_hedge_delays_ms = v; + } + if let Some(v) = self.preserve_very_small_text { + cfg.preserve_very_small_text = v; + } + if self.password.is_some() { + cfg.password = self.password; + } + if let Some(v) = self.quiet { + cfg.quiet = v; + } + if let Some(v) = self.emit_word_boxes { + cfg.emit_word_boxes = v; + } + if let Some(v) = self.crop_box { + cfg.crop_box = Some(CoreCropBox { + top: v.top, + right: v.right, + bottom: v.bottom, + left: v.left, + }); + } + if let Some(v) = self.skip_diagonal_text { + cfg.skip_diagonal_text = v; + } + cfg.num_workers = 1; + Ok(cfg) + } + + fn from_core(cfg: &CoreConfig) -> Self { + Self { + ocr_language: Some(cfg.ocr_language.clone()), + ocr_enabled: Some(cfg.ocr_enabled), + ocr_server_url: cfg.ocr_server_url.clone(), + ocr_server_headers: if cfg.ocr_server_headers.is_empty() { + None + } else { + Some(cfg.ocr_server_headers.iter().cloned().collect()) + }, + tessdata_path: cfg.tessdata_path.clone(), + max_pages: Some(cfg.max_pages), + target_pages: cfg.target_pages.clone(), + dpi: Some(cfg.dpi), + output_format: Some(match cfg.output_format { + OutputFormat::Json => "json".into(), + OutputFormat::Text => "text".into(), + OutputFormat::Markdown => "markdown".into(), + }), + image_mode: Some(match cfg.image_mode { + ImageMode::Off => "off".into(), + ImageMode::Placeholder => "placeholder".into(), + ImageMode::Embed => "embed".into(), + }), + extract_links: Some(cfg.extract_links), + ocr_failure_fatal: Some(cfg.ocr_failure_fatal), + ocr_hedge_delays_ms: Some(cfg.ocr_hedge_delays_ms.clone()), + preserve_very_small_text: Some(cfg.preserve_very_small_text), + password: cfg.password.clone(), + quiet: Some(cfg.quiet), + emit_word_boxes: Some(cfg.emit_word_boxes), + crop_box: cfg.crop_box.map(|c| CropBox { + top: c.top, + right: c.right, + bottom: c.bottom, + left: c.left, + }), + skip_diagonal_text: Some(cfg.skip_diagonal_text), + } + } +} + +// --------------------------------------------------------------------------- +// JS-facing parse result +// --------------------------------------------------------------------------- + +#[derive(Serialize, Tsify)] +#[tsify(into_wasm_abi)] +#[serde(rename_all = "camelCase")] +pub struct WordBox { + pub text: String, + pub x: f32, + pub y: f32, + pub width: f32, + pub height: f32, +} + +#[derive(Serialize, Tsify)] +#[tsify(into_wasm_abi)] +#[serde(rename_all = "camelCase")] +pub struct TextItem { + pub text: String, + pub x: f32, + pub y: f32, + pub width: f32, + pub height: f32, + #[serde(skip_serializing_if = "Option::is_none")] + pub font_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub font_size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub confidence: Option, + /// Rotation in degrees (viewport space). + pub rotation: f32, + /// Per-word sub-boxes for attribution. Omitted when empty (the default — + /// only populated when parsing with `emitWordBoxes: true`). + #[serde(skip_serializing_if = "Option::is_none")] + pub words: Option>, +} + +#[derive(Serialize, Tsify)] +#[tsify(into_wasm_abi)] +#[serde(rename_all = "camelCase")] +pub struct ParsedPage { + pub page_num: usize, + pub width: f32, + pub height: f32, + pub text: String, + pub markdown: String, + pub text_items: Vec, +} + +#[derive(Serialize, Tsify)] +#[tsify(into_wasm_abi)] +#[serde(rename_all = "camelCase")] +pub struct ParseResult { + pub pages: Vec, + pub text: String, + pub images: Vec, +} + +#[derive(Serialize, Tsify)] +#[tsify(into_wasm_abi)] +#[serde(rename_all = "camelCase")] +pub struct ExtractedImage { + pub id: String, + pub page: u32, + pub format: String, + /// Raw image bytes, serialized as a JS `number[]`. Callers that want a + /// Uint8Array can wrap with `new Uint8Array(image.bytes)`. + pub bytes: Vec, +} + +// --------------------------------------------------------------------------- +// JS OCR engine bridge +// --------------------------------------------------------------------------- + +/// Wraps a JS object that exposes an async `recognize(imageData, width, height, language)` +/// method, returning `Promise>`. +/// +/// `JsValue` is `!Send`, but on `wasm32` (single-threaded) the trait does not +/// require `Send + Sync`, so this works. +struct JsOcrEngine { + name: String, + obj: JsValue, +} + +impl JsOcrEngine { + fn new(obj: JsValue) -> Self { + Self { + name: "js-callback".into(), + obj, + } + } +} + +impl OcrEngine for JsOcrEngine { + fn name(&self) -> &str { + &self.name + } + + fn recognize<'a, 'b: 'a, 'c: 'a>( + &'a self, + image_data: &'c [u8], + width: u32, + height: u32, + options: &'b OcrOptions, + ) -> Pin< + Box< + dyn Future< + Output = Result, Box>, + > + '_, + >, + > { + // Copy bytes into a JS Uint8Array up-front (must happen on the + // current thread anyway in wasm). + let arr = Uint8Array::new_with_length(image_data.len() as u32); + arr.copy_from(image_data); + let language = options.language.clone(); + + Box::pin(async move { + let recognize: JsValue = Reflect::get(&self.obj, &JsValue::from_str("recognize")) + .map_err(|e| format!("ocrEngine.recognize lookup failed: {:?}", e))?; + let recognize: Function = recognize + .dyn_into::() + .map_err(|_| "ocrEngine.recognize is not a function".to_string())?; + + let args = js_sys::Array::new(); + args.push(&arr); + args.push(&JsValue::from_f64(width as f64)); + args.push(&JsValue::from_f64(height as f64)); + args.push(&JsValue::from_str(&language)); + + let promise = recognize + .apply(&self.obj, &args) + .map_err(|e| format!("ocrEngine.recognize threw: {:?}", e))?; + let promise: js_sys::Promise = promise + .dyn_into::() + .map_err(|_| "ocrEngine.recognize did not return a Promise".to_string())?; + + let resolved = JsFuture::from(promise) + .await + .map_err(|e| format!("ocrEngine.recognize rejected: {:?}", e))?; + + let parsed: Vec = serde_wasm_bindgen::from_value(resolved) + .map_err(|e| format!("ocrEngine.recognize result decode failed: {:?}", e))?; + + Ok(parsed + .into_iter() + .map(|r| CoreOcrResult { + text: r.text, + bbox: r.bbox, + confidence: r.confidence, + polygon: r.polygon, + }) + .collect()) + }) + } +} + +#[derive(Deserialize, Tsify)] +#[tsify(from_wasm_abi)] +#[serde(rename_all = "camelCase")] +pub struct OcrResult { + pub text: String, + pub bbox: [f32; 4], + pub confidence: f32, + #[serde(default)] + pub polygon: Option<[[f32; 2]; 4]>, +} + +// --------------------------------------------------------------------------- +// LiteParse class (JS-facing) +// --------------------------------------------------------------------------- + +// Hand-written TS types that tsify can't derive: the JS-implemented OCR engine +// interface, and the constructor init object (the config plus an optional +// `ocrEngine`). `LiteParseConfig` and `OcrResult` are generated by tsify. +#[wasm_bindgen(typescript_custom_section)] +const TS_EXTRA: &'static str = r#" +export interface OcrEngine { + recognize(imageData: Uint8Array, width: number, height: number, language: string): Promise; +} + +export interface LiteParseInit extends LiteParseConfig { + ocrEngine?: OcrEngine; +} +"#; + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(typescript_type = "LiteParseInit")] + pub type LiteParseInit; +} + +#[wasm_bindgen] +pub struct LiteParse { + inner: CoreLiteParse, + config: CoreConfig, +} + +#[wasm_bindgen] +impl LiteParse { + /// Construct a new parser. `config` is a JS object (all fields optional). + /// If `config.ocrEngine` is present, it is wired up as the OCR backend. + #[wasm_bindgen(constructor)] + pub fn new(config: LiteParseInit) -> Result { + let config: JsValue = config.into(); + let ocr_engine_js = if config.is_object() { + Reflect::get(&config, &JsValue::from_str("ocrEngine")) + .ok() + .filter(|v| !v.is_undefined() && !v.is_null()) + } else { + None + }; + + let js_cfg: LiteParseConfig = if config.is_undefined() || config.is_null() { + LiteParseConfig::default() + } else { + serde_wasm_bindgen::from_value(config) + .map_err(|e| JsError::new(&format!("invalid config: {}", e)))? + }; + let core_cfg = js_cfg.into_core()?; + let mut parser = CoreLiteParse::new(core_cfg.clone()); + if let Some(js_engine) = ocr_engine_js { + parser = parser.with_ocr_engine(std::sync::Arc::new(JsOcrEngine::new(js_engine))); + } + Ok(LiteParse { + inner: parser, + config: core_cfg, + }) + } + + /// Return the resolved config (camelCase JS object). + #[wasm_bindgen(getter)] + pub fn config(&self) -> LiteParseConfig { + LiteParseConfig::from_core(&self.config) + } + + /// Parse PDF bytes. Returns `Promise`. + pub async fn parse(&self, data: Vec) -> Result { + let result = self + .inner + .parse_input(PdfInput::Bytes(data)) + .await + .map_err(|e| JsError::new(&format!("parse failed: {}", e)))?; + + let pages: Vec = result + .pages + .iter() + .map(|p| ParsedPage { + page_num: p.page_number, + width: p.page_width, + height: p.page_height, + text: p.text.clone(), + markdown: p.markdown.clone(), + text_items: p + .text_items + .iter() + .map(|i| TextItem { + text: i.text.clone(), + x: i.x, + y: i.y, + width: i.width, + height: i.height, + font_name: i.font_name.clone(), + font_size: i.font_size, + confidence: i.confidence, + rotation: i.rotation, + words: if i.words.is_empty() { + None + } else { + Some( + i.words + .iter() + .map(|w| WordBox { + text: w.text.clone(), + x: w.x, + y: w.y, + width: w.width, + height: w.height, + }) + .collect(), + ) + }, + }) + .collect(), + }) + .collect(); + + let images: Vec = result + .images + .iter() + .map(|img| ExtractedImage { + id: img.id.clone(), + page: img.page, + format: img.format.clone(), + bytes: img.bytes.clone(), + }) + .collect(); + + Ok(ParseResult { + pages, + text: result.text.clone(), + images, + }) + } + + /// Determine per-page complexity for the given PDF bytes. Returns + /// `Promise` — a cheap pre-OCR check with per-page + /// signals and a `needsOcr` verdict. + #[wasm_bindgen(js_name = isComplex)] + pub async fn is_complex(&self, data: Vec) -> Result, JsError> { + let stats = self + .inner + .is_complex(PdfInput::Bytes(data)) + .await + .map_err(|e| JsError::new(&format!("is_complex failed: {}", e)))?; + + Ok(stats.iter().map(PageComplexityStats::from_rust).collect()) + } +} + +#[derive(Serialize, Tsify)] +#[tsify(into_wasm_abi)] +#[serde(rename_all = "camelCase")] +pub struct PageComplexityStats { + page_number: usize, + text_length: usize, + text_coverage: f32, + has_substantial_images: bool, + image_block_count: usize, + image_coverage: f32, + largest_image_coverage: f32, + full_page_image: bool, + uncovered_vector_area: Option, + is_garbled: bool, + page_area: f32, + needs_ocr: bool, + reasons: Vec, +} + +impl PageComplexityStats { + fn from_rust(stats: &liteparse::ocr_merge::PageComplexityStats) -> Self { + Self { + page_number: stats.page_number, + text_length: stats.text_length, + text_coverage: stats.text_coverage, + has_substantial_images: stats.has_substantial_images, + image_block_count: stats.image_block_count, + image_coverage: stats.image_coverage, + largest_image_coverage: stats.largest_image_coverage, + full_page_image: stats.full_page_image, + uncovered_vector_area: stats.uncovered_vector_area, + is_garbled: stats.is_garbled, + page_area: stats.page_area, + needs_ocr: stats.needs_ocr, + reasons: stats + .reasons + .iter() + .map(|r| r.as_str().to_string()) + .collect(), + } + } +} + +// --------------------------------------------------------------------------- +// searchItems (standalone function) +// --------------------------------------------------------------------------- + +#[derive(Deserialize, Tsify)] +#[tsify(from_wasm_abi)] +#[serde(rename_all = "camelCase")] +pub struct SearchTextItem { + pub text: String, + pub x: f32, + pub y: f32, + pub width: f32, + pub height: f32, + #[serde(default)] + pub font_name: Option, + #[serde(default)] + pub font_size: Option, + #[serde(default)] + pub confidence: Option, +} + +#[derive(Deserialize, Tsify)] +#[tsify(from_wasm_abi)] +#[serde(rename_all = "camelCase", default)] +pub struct SearchOptions { + pub phrase: String, + pub case_sensitive: bool, +} + +impl Default for SearchOptions { + fn default() -> Self { + Self { + phrase: String::new(), + case_sensitive: false, + } + } +} + +/// Search text items for phrase matches, returning merged items with combined bounding boxes. +#[wasm_bindgen(js_name = "searchItems")] +pub fn search_items(items: Vec, options: SearchOptions) -> Vec { + let rust_items: Vec = items + .into_iter() + .map(|i| liteparse::types::TextItem { + text: i.text, + x: i.x, + y: i.y, + width: i.width, + height: i.height, + font_name: i.font_name, + font_size: i.font_size, + confidence: i.confidence, + ..Default::default() + }) + .collect(); + + let options = search::SearchOptions { + phrase: options.phrase, + case_sensitive: options.case_sensitive, + }; + + let results = search::search_items(&rust_items, &options); + results + .iter() + .map(|i| TextItem { + text: i.text.clone(), + x: i.x, + y: i.y, + width: i.width, + height: i.height, + font_name: i.font_name.clone(), + font_size: i.font_size, + confidence: i.confidence, + rotation: i.rotation, + words: if i.words.is_empty() { + None + } else { + Some( + i.words + .iter() + .map(|w| WordBox { + text: w.text.clone(), + x: w.x, + y: w.y, + width: w.width, + height: w.height, + }) + .collect(), + ) + }, + }) + .collect() +} diff --git a/crates/liteparse-wasm/src/wasi_stubs.rs b/crates/liteparse-wasm/src/wasi_stubs.rs new file mode 100644 index 0000000..41c3612 --- /dev/null +++ b/crates/liteparse-wasm/src/wasi_stubs.rs @@ -0,0 +1,87 @@ +//! Stub implementations of libc functions that pdfium's statically-linked +//! wasi-libc expects at runtime under the "env" module namespace. +//! +//! WASI preview1 syscalls (wasi_snapshot_preview1::*) cannot be stubbed from +//! Rust because they live in a different WASM import module namespace. Those +//! are provided in JavaScript — see packages/wasm/scripts/patch-wasi-imports.js. + +// --------------------------------------------------------------------------- +// env:: stubs (libc / pthreads) +// --------------------------------------------------------------------------- + +#[unsafe(no_mangle)] +pub extern "C" fn getpid() -> i32 { + 1 +} + +#[unsafe(no_mangle)] +pub extern "C" fn pthread_mutex_init(_mutex: *mut u8, _attr: *const u8) -> i32 { + 0 +} + +#[unsafe(no_mangle)] +pub extern "C" fn pthread_mutex_lock(_mutex: *mut u8) -> i32 { + 0 +} + +#[unsafe(no_mangle)] +pub extern "C" fn pthread_mutex_unlock(_mutex: *mut u8) -> i32 { + 0 +} + +#[unsafe(no_mangle)] +pub extern "C" fn pthread_mutex_destroy(_mutex: *mut u8) -> i32 { + 0 +} + +// --------------------------------------------------------------------------- +// setjmp / longjmp support (new WASM exception handling SjLj model) +// +// Chromium's clang uses the new WASM SjLj lowering where: +// - __c_longjmp is a WebAssembly.Tag with 1 param (env: i32) +// - __wasm_setjmp(env, label, table) — 3 params +// - __wasm_setjmp_test(table, env) -> label — 2 params +// +// The table is a flat array of (env, label) pairs on the stack, zero-terminated. +// --------------------------------------------------------------------------- + +#[unsafe(no_mangle)] +pub extern "C" fn __wasm_setjmp(env: u32, label: u32, table: *mut u32) { + unsafe { + let mut i = 0; + loop { + if *table.add(i) == 0 { + *table.add(i) = env; + *table.add(i + 1) = label; + *table.add(i + 2) = 0; // sentinel + return; + } + i += 2; + } + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn __wasm_setjmp_test(table: *const u32, env: u32) -> u32 { + unsafe { + let mut i = 0; + loop { + let stored_env = *table.add(i); + if stored_env == 0 { + return 0; + } + if stored_env == env { + return *table.add(i + 1); + } + i += 2; + } + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn __wasm_longjmp(_env: *mut u8, _val: i32) { + // longjmp throws the __c_longjmp WASM exception tag, which we can't do + // from Rust. Trap instead — in practice, FreeType's setjmp error recovery + // rarely triggers during normal PDF parsing. + core::arch::wasm32::unreachable(); +} diff --git a/crates/liteparse/Cargo.toml b/crates/liteparse/Cargo.toml new file mode 100644 index 0000000..50cd943 --- /dev/null +++ b/crates/liteparse/Cargo.toml @@ -0,0 +1,53 @@ +[package] +name = "liteparse" +version = "2.5.1" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Fast, lightweight PDF and document parsing with spatial text extraction" +readme = "README.md" + +[lib] +name = "liteparse" + +[[bin]] +name = "lit" +path = "src/main.rs" + +[features] +default = ["tesseract", "static-embed"] +tesseract = ["dep:tesseract-rs"] +# The model2vec static-embedding engine for schema extraction (EXTRACT_PLAN.md +# Phase 2). Default-on for native builds; binding crates (wasm/napi/python) use +# `default-features = false` and opt in when their extract surface lands. +# BM25-only extraction stays available without this feature. +static-embed = ["dep:tokenizers", "dep:safetensors"] + +[dependencies] +blake3 = "1" +clap = { version = "4.5.55", features = ["derive"] } +file-format = { version = "0.29.0", features = ["reader"] } +image = { version = "0.25", default-features = false, features = ["png"] } +ordered-float = "5.3.0" +pdfium = { package = "liteparse-pdfium", version = "1.3.0", path = "../pdfium" } +pdfium-sys = { package = "liteparse-pdfium-sys", version = "1.3.0", path = "../pdfium-sys" } +reqwest = { version = "0.13.3", default-features = false, features = ["json", "form", "multipart", "rustls"] } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.149" +tempfile = "3.27.0" +thiserror = "2" +tesseract-rs = { version = "0.2", features = ["build-tesseract"], optional = true } +tokio = { version = "1.52.3", features = ["rt", "macros", "sync", "time", "io-util"] } +url = "2" +web-time = "1.1.0" +safetensors = { version = "0.6", optional = true } +# default-features off drops the `onig` C regex backend (and hub/http extras); +# `fancy-regex` is the pure-Rust backend, keeping the build C-free. +tokenizers = { version = "0.23.1", default-features = false, features = ["fancy-regex"], optional = true } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +rmp = "0.8" +tokio = { version = "1.52.3", features = ["rt-multi-thread", "fs", "process"] } + +[dev-dependencies] +serial_test = "3" diff --git a/crates/liteparse/README.md b/crates/liteparse/README.md new file mode 100644 index 0000000..0cfd892 --- /dev/null +++ b/crates/liteparse/README.md @@ -0,0 +1,171 @@ +# LiteParse + +[![Crates.io version](https://img.shields.io/crates/v/liteparse.svg)](https://crates.io/crates/liteparse) +[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) + +Rust library and CLI for fast, lightweight PDF and document parsing with spatial text extraction. Runs entirely locally with zero cloud dependencies. + +> LiteParse is also available for [Node.js/TypeScript](https://www.npmjs.com/package/@llamaindex/liteparse), [Python](https://pypi.org/project/liteparse/), and the [browser (WASM)](https://www.npmjs.com/package/@llamaindex/liteparse-wasm). See the [project README](https://github.com/run-llama/liteparse) for all options. + +## Installation + +Add to your `Cargo.toml`: + +```toml +[dependencies] +liteparse = "2" +``` + +Or install the CLI: + +```bash +cargo install liteparse +``` + +## Quick Start + +```rust +use liteparse::{LiteParse, LiteParseConfig}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let parser = LiteParse::new(LiteParseConfig::default()); + let result = parser.parse("document.pdf").await?; + + println!("{}", result.text); + + for page in &result.pages { + println!("Page {}: {} text items", page.page_num, page.text_items.len()); + } + + Ok(()) +} +``` + +## Configuration + +```rust +use liteparse::{LiteParse, LiteParseConfig, OutputFormat}; + +let config = LiteParseConfig { + ocr_enabled: true, // Enable OCR (default: true) + ocr_language: "eng".to_string(), // Tesseract language code + ocr_server_url: None, // HTTP OCR server URL (optional) + tessdata_path: None, // Path to tessdata directory (optional) + max_pages: 1000, // Max pages to parse + target_pages: Some("1-5,10".into()), // Specific pages (optional) + dpi: 150.0, // Rendering DPI + output_format: OutputFormat::Json, // Json | Text | Markdown + preserve_very_small_text: false, // Keep tiny text + password: None, // Password for protected documents + quiet: false, // Suppress progress output + ..Default::default() +}; + +let parser = LiteParse::new(config); +``` + +## Markdown Output + +LiteParse can render documents directly to Markdown, including headings, tables, lists, +images, and links reconstructed from the spatial layout. Set +`output_format: OutputFormat::Markdown`; the rendered Markdown is returned on +`result.text`. Two related knobs control Markdown rendering: + +- `image_mode` (`ImageMode::Placeholder` default | `Off` | `Embed`) — how raster + images are surfaced in the output. +- `extract_links` (default `true`) — render hyperlink annotations as + `[text](url)`; set `false` for plain anchor text. + +```rust +use liteparse::config::{ImageMode, LiteParseConfig, OutputFormat}; + +let config = LiteParseConfig { + output_format: OutputFormat::Markdown, + image_mode: ImageMode::Placeholder, + extract_links: true, + ..Default::default() +}; +let result = LiteParse::new(config).parse("document.pdf").await?; +println!("{}", result.text); // rendered Markdown +``` + +> Reconstruction quality varies with document complexity. + +## Parsing from Bytes + +```rust +use liteparse::types::PdfInput; + +let pdf_bytes: Vec = std::fs::read("document.pdf")?; +let result = parser.parse_input(PdfInput::Bytes(pdf_bytes)).await?; +println!("{}", result.text); +``` + +## Document Complexity + +Before committing to a full parse, check whether a document needs OCR or heavier +processing. `is_complex` is a cheap, text-layer-only pass that returns a +`PageComplexityStats` per page with a `needs_ocr` verdict and the signals behind it — +useful for routing documents to different pipelines, rejecting ones you can't handle, or +estimating cost. + +```rust +use liteparse::types::PdfInput; + +let parser = LiteParse::new(LiteParseConfig::default()); +let pages = parser.is_complex(PdfInput::Path("document.pdf".into())).await?; + +if pages.iter().any(|p| p.needs_ocr) { + // Route to the OCR-enabled pipeline, inspect `p.reasons`, etc. + for page in pages.iter().filter(|p| p.needs_ocr) { + println!("Page {} needs OCR: {:?}", page.page_number, page.reasons); + } +} +``` + +`reasons` is a `Vec` (`Scanned`, `NoText`, `SparseText`, +`EmbeddedImages`, `Garbled`, `VectorText`); new variants may be added over time, so match +leniently. + +## Custom OCR Engine + +Implement the `OcrEngine` trait to plug in your own OCR backend: + +```rust +use liteparse::ocr::OcrEngine; +use std::sync::Arc; + +let parser = LiteParse::new(LiteParseConfig::default()) + .with_ocr_engine(Arc::new(my_engine)); +``` + +## Features + +- **`tesseract`** (default) — Built-in Tesseract OCR via `tesseract-rs`. Disable with `default-features = false` if you don't need OCR or want to use an HTTP OCR server instead. + +## Supported Formats + +- PDF (`.pdf`) +- Microsoft Office (`.docx`, `.xlsx`, `.pptx`, etc.) — requires LibreOffice +- OpenDocument (`.odt`, `.ods`, `.odp`) — requires LibreOffice +- Images (`.png`, `.jpg`, `.tiff`, etc.) — requires ImageMagick + +## CLI + +The crate also builds the `lit` CLI binary: + +```bash +lit parse document.pdf +lit parse document.pdf --format json -o output.json +lit parse document.pdf --format markdown -o output.md +lit screenshot document.pdf -o ./screenshots +lit batch-parse ./input ./output +lit is-complex document.pdf +``` + +See `lit --help` for all options. + +## License + +Apache-2.0 diff --git a/crates/liteparse/src/config.rs b/crates/liteparse/src/config.rs new file mode 100644 index 0000000..f452fee --- /dev/null +++ b/crates/liteparse/src/config.rs @@ -0,0 +1,284 @@ +use serde::{Deserialize, Serialize}; + +/// Configuration for LiteParse document parsing. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LiteParseConfig { + /// OCR language code (Tesseract format: "eng", "fra", "deu", etc.). + pub ocr_language: String, + /// Whether OCR is enabled. When true, runs on text-sparse pages and embedded images. + pub ocr_enabled: bool, + /// HTTP OCR server URL (uses Tesseract if not provided) + pub ocr_server_url: Option, + /// Extra HTTP headers sent with every request to `ocr_server_url`, as + /// `(name, value)` pairs. Use for auth, e.g. `("Authorization", "Bearer …")`. + /// Ignored when `ocr_server_url` is None. + pub ocr_server_headers: Vec<(String, String)>, + /// Path to tessdata directory. Falls back to TESSDATA_PREFIX env var if not set. + pub tessdata_path: Option, + /// Maximum number of pages to parse. + pub max_pages: usize, + /// Specific pages to parse (e.g., "1-5,10,15-20"). None means all pages. + pub target_pages: Option, + /// DPI for rendering pages (used for OCR and screenshots). + pub dpi: f32, + /// Output format. + pub output_format: OutputFormat, + /// Keep very small text that would normally be filtered out. + pub preserve_very_small_text: bool, + /// Password for encrypted/protected documents. + pub password: Option, + /// Suppress progress output. + pub quiet: bool, + /// Number of concurrent OCR workers. Defaults to (number of CPU cores - 1), minimum 1. + pub num_workers: usize, + /// Controls how raster images are surfaced in markdown output. Has no + /// effect on JSON / text outputs. + pub image_mode: ImageMode, + /// Extract hyperlink annotations and render them as `[text](url)` in + /// markdown output. Default on. Disable for benchmark parity with + /// plain-text ground truth (the GT corpora never use link syntax). + pub extract_links: bool, + /// Whether a systemic OCR failure (every OCR task failed *and* at least one + /// was a text-sparse page whose primary text source was OCR) aborts the + /// whole parse. Default `true`: surface the root cause instead of silently + /// emitting blank pages. Set `false` to keep already-recovered native text + /// and return partial results when OCR is unavailable — useful for callers + /// that prefer a degraded document over a hard failure (e.g. when the host + /// has its own OCR fallback or treats OCR as best-effort enrichment). + pub ocr_failure_fatal: bool, + /// OCR request-hedging schedule (milliseconds) for the HTTP OCR engine. + /// Empty (default) = no hedging. With multiple delays (e.g. + /// `[0, 5000, 10000, 15000, 20000]`), each OCR attempt fires a duplicate + /// request at every delay and takes the first to succeed — trading extra + /// OCR-server load for lower tail latency on a slow/stuck pod. No effect on + /// the Tesseract engine. + pub ocr_hedge_delays_ms: Vec, + /// Emit per-word sub-boxes on each `TextItem` (`TextItem.words`). Default + /// `false`: a text item already carries its own box, and word boxes roughly + /// double the text-item payload (size + napi marshalling), so they are only + /// worth computing for callers doing word-level bbox attribution. When + /// `false`, `TextItem.words` is always empty and the per-word tracking is + /// skipped entirely (zero allocation). + pub emit_word_boxes: bool, + /// Restrict output to a sub-region of every page. Each field is the + /// fraction of the page to crop away from that side (e.g. `left = 0.5` + /// discards the left half). A text item is kept only when it lies + /// *entirely* inside the surviving rectangle + /// `[left·W, (1-right)·W] × [top·H, (1-bottom)·H]` (top-left origin). + /// `None` (default) keeps the whole page. Applied after OCR merge, so it + /// also drops OCR-sourced text outside the region. + pub crop_box: Option, + /// Drop diagonal (skewed) text — items whose rotation is more than 2° + /// off the nearest right angle (0/90/180/270). Default `false`. + pub skip_diagonal_text: bool, +} + +/// A page sub-region expressed as the fraction cropped from each side. +/// All fields are in `[0, 1]`; `left + right < 1` and `top + bottom < 1` +/// for a non-empty region. Origin is top-left, matching `TextItem` coordinates. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] +pub struct CropBox { + pub top: f32, + pub right: f32, + pub bottom: f32, + pub left: f32, +} + +/// Image handling for the markdown emitter. +/// +/// * `Off` — strip image references entirely. +/// * `Placeholder` (default) — emit `![](image_pN_K.png)` references in +/// reading order at each image's y position, but do **not** extract or +/// return pixel bytes. Keeps response size small while letting the LLM see +/// where figures live in the document. +/// * `Embed` — same references, plus bytes returned via `ParseResult.images`. +/// Opt-in because pixel bytes can dwarf the text payload on image-heavy +/// PDFs. (Bytes plumbing lands in stage 11b — current variant is parsed but +/// behaves like `Placeholder` until then.) +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ImageMode { + Off, + Placeholder, + Embed, +} + +/// Supported output formats. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum OutputFormat { + Json, + Text, + Markdown, +} + +impl Default for LiteParseConfig { + fn default() -> Self { + Self { + ocr_language: "eng".to_string(), + // OCR is on by default only when a built-in engine is compiled in + // (the `tesseract` feature). Builds without a built-in engine + // (`tesseract` disabled, or WASM) default to OFF, so a consumer that + // just wants native text extraction works out of the box instead of + // hitting "OCR enabled but no engine available". OCR is still fully + // available in those builds by opting in explicitly: set + // `ocr_enabled = true` together with an `ocr_server_url` (or, on + // WASM, an `ocrEngine` callback). That explicit request still fails + // fast if no engine is reachable, so a real misconfiguration is + // never silently swallowed. + ocr_enabled: cfg!(feature = "tesseract"), + ocr_server_url: None, + ocr_server_headers: Vec::new(), + tessdata_path: None, + max_pages: 1000, + target_pages: None, + dpi: 150.0, + output_format: OutputFormat::Json, + preserve_very_small_text: false, + password: None, + quiet: false, + num_workers: default_num_workers(), + image_mode: ImageMode::Placeholder, + extract_links: true, + ocr_failure_fatal: true, + ocr_hedge_delays_ms: Vec::new(), + emit_word_boxes: false, + crop_box: None, + skip_diagonal_text: false, + } + } +} + +/// Returns the default number of OCR workers: CPU cores - 1, minimum 1. +fn default_num_workers() -> usize { + std::thread::available_parallelism() + .map(|n| n.get().saturating_sub(1).max(1)) + .unwrap_or(1) +} + +/// Upper bound on the number of pages a `--target-pages` argument may expand +/// to. Ranges are materialised eagerly into a `Vec`, so without a cap a +/// tiny argument like `1-4294967295` would try to allocate ~17 GB and the +/// process would be OOM-killed before the document is even opened. No real +/// document approaches this many pages, so the cap only ever rejects nonsense +/// input. +const MAX_TARGET_PAGES: u64 = 100_000; + +#[doc(hidden)] +pub fn parse_target_pages(s: &str) -> Result, String> { + let mut pages = Vec::new(); + for part in s.split(',') { + let part = part.trim(); + if part.contains('-') { + let bounds: Vec<&str> = part.splitn(2, '-').collect(); + let start: u32 = bounds[0] + .trim() + .parse() + .map_err(|_| format!("invalid page number: {}", bounds[0]))?; + let end: u32 = bounds[1] + .trim() + .parse() + .map_err(|_| format!("invalid page number: {}", bounds[1]))?; + if start > end { + return Err(format!("invalid range: {}-{}", start, end)); + } + // Reject before expanding so an oversized range can never allocate + // gigabytes. `end >= start`, so the span cannot underflow. + let span = end as u64 - start as u64 + 1; + if pages.len() as u64 + span > MAX_TARGET_PAGES { + return Err(format!( + "too many target pages: {}-{} exceeds the limit of {}", + start, end, MAX_TARGET_PAGES + )); + } + for p in start..=end { + pages.push(p); + } + } else { + let p: u32 = part + .parse() + .map_err(|_| format!("invalid page number: {}", part))?; + if pages.len() as u64 + 1 > MAX_TARGET_PAGES { + return Err(format!( + "too many target pages: exceeds the limit of {}", + MAX_TARGET_PAGES + )); + } + pages.push(p); + } + } + pages.sort(); + pages.dedup(); + Ok(pages) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_target_pages() { + assert_eq!( + parse_target_pages("1-5,10,15-20").unwrap(), + vec![1, 2, 3, 4, 5, 10, 15, 16, 17, 18, 19, 20] + ); + assert_eq!(parse_target_pages("3").unwrap(), vec![3]); + assert_eq!(parse_target_pages("1,1,2").unwrap(), vec![1, 2]); + assert!(parse_target_pages("5-3").is_err()); + assert!(parse_target_pages("abc").is_err()); + } + + #[test] + fn test_parse_target_pages_with_whitespace() { + assert_eq!(parse_target_pages(" 1 , 2 - 4 ").unwrap(), vec![1, 2, 3, 4]); + } + + #[test] + fn test_parse_target_pages_single_range() { + assert_eq!(parse_target_pages("2-2").unwrap(), vec![2]); + } + + #[test] + fn test_parse_target_pages_rejects_oversized_range() { + // The headline OOM repro from #269: a 12-character argument must not + // be allowed to expand into a multi-gigabyte allocation. + assert!(parse_target_pages("1-4294967295").is_err()); + assert!(parse_target_pages("0-4294967295").is_err()); + // The cap is on the total page count, so multiple ranges that + // together exceed the limit are rejected too. + assert!(parse_target_pages("1-60000,60001-120000").is_err()); + // A selection within the limit is still parsed normally. + assert_eq!(parse_target_pages("1-1000").unwrap().len(), 1000); + } + + #[test] + fn test_default_config() { + let c = LiteParseConfig::default(); + assert_eq!(c.ocr_language, "eng"); + // OCR defaults on only when a built-in engine is compiled in. + assert_eq!(c.ocr_enabled, cfg!(feature = "tesseract")); + assert_eq!(c.max_pages, 1000); + assert_eq!(c.dpi, 150.0); + assert_eq!(c.output_format, OutputFormat::Json); + assert!(!c.preserve_very_small_text); + assert!(!c.quiet); + assert!(c.password.is_none()); + } + + #[test] + fn test_output_format_lowercase_serde() { + let s = serde_json::to_string(&OutputFormat::Json).unwrap(); + assert_eq!(s, "\"json\""); + let back: OutputFormat = serde_json::from_str("\"text\"").unwrap(); + assert_eq!(back, OutputFormat::Text); + } + + #[test] + fn test_config_roundtrip() { + let c = LiteParseConfig::default(); + let s = serde_json::to_string(&c).unwrap(); + let back: LiteParseConfig = serde_json::from_str(&s).unwrap(); + assert_eq!(back.ocr_language, c.ocr_language); + assert_eq!(back.output_format, c.output_format); + } +} diff --git a/crates/liteparse/src/conversion.rs b/crates/liteparse/src/conversion.rs new file mode 100644 index 0000000..95d8d2f --- /dev/null +++ b/crates/liteparse/src/conversion.rs @@ -0,0 +1,869 @@ +use file_format::FileFormat; +use tempfile::TempDir; + +use crate::error::LiteParseError; +use std::{ + fmt::{self, Display}, + path::Path, +}; + +/// Supported file extensions for conversion (non-PDF formats). +const OFFICE_EXTENSIONS: &[&str] = &[ + "doc", "docx", "docm", "dot", "dotm", "dotx", "odt", "ott", "rtf", "pages", +]; +const PRESENTATION_EXTENSIONS: &[&str] = &[ + "ppt", "pptx", "pptm", "pot", "potm", "potx", "odp", "otp", "key", +]; +const SPREADSHEET_EXTENSIONS: &[&str] = &[ + "xls", "xlsx", "xlsm", "xlsb", "ods", "ots", "csv", "tsv", "numbers", +]; +const IMAGE_EXTENSIONS: &[&str] = &[ + "jpg", "jpeg", "png", "gif", "bmp", "tiff", "tif", "webp", "svg", +]; + +/// Plain-text extensions that cannot be rendered as page images. +const TEXT_ONLY_EXTENSIONS: &[&str] = &["txt", "md", "markdown", "log"]; + +/// Extensions that require Ghostscript for ImageMagick conversion. +const GHOSTSCRIPT_REQUIRED_EXTENSIONS: &[&str] = &["svg", "eps", "ps", "ai"]; + +/// A resolved external command with its executable path and any required prefix args. +#[derive(Debug, Clone)] +pub struct ResolvedCommand { + pub command: String, + pub args: Vec, + pub resolved_path: String, +} + +#[derive(Debug, Clone)] +pub struct ConversionResult { + pub pdf_path: String, + pub original_extension: String, +} + +enum ConversionTool { + LibreOffice, + ImageMagick, +} + +impl Display for ConversionTool { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Self::ImageMagick => "ImageMagick", + Self::LibreOffice => "LibreOffice", + }; + write!(f, "{}", s) + } +} + +/// Check if a file is a PDF (no conversion needed). +pub fn is_pdf(path: &str) -> bool { + Path::new(path) + .extension() + .map(|ext| ext.eq_ignore_ascii_case("pdf")) + .unwrap_or(false) +} + +/// Check if a file extension is supported (either PDF or convertible). +/// Returns true when the extension denotes a plain-text format with no page layout. +pub fn is_text_only_extension(ext: &str) -> bool { + TEXT_ONLY_EXTENSIONS.contains(&ext.to_lowercase().as_str()) +} + +pub fn screenshot_text_format_error(ext: &str) -> LiteParseError { + LiteParseError::Conversion(format!( + "Cannot screenshot text-based format (.{ext}). Convert to PDF first." + )) +} + +/// Keeps converted PDF temp directories alive until rendering or parsing completes. +/// All temp dirs are cleaned up automatically when this guard is dropped. +#[derive(Debug)] +pub struct PdfInputGuard { + #[allow(dead_code)] + temps: Vec, +} + +/// Resolve a document input to a PDF suitable for rendering or text extraction. +/// +/// When `reject_text_formats` is true, plain-text files (`.txt`, etc.) return a +/// clear error instead of attempting conversion. +pub async fn resolve_pdf_input( + input: crate::types::PdfInput, + password: Option<&str>, + reject_text_formats: bool, +) -> Result<(crate::types::PdfInput, PdfInputGuard), LiteParseError> { + use crate::types::PdfInput; + + match input { + PdfInput::Path(p) => { + let ext = Path::new(&p) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or(""); + if reject_text_formats && is_text_only_extension(ext) { + return Err(screenshot_text_format_error(ext)); + } + if is_pdf(&p) { + Ok((PdfInput::Path(p), PdfInputGuard { temps: Vec::new() })) + } else { + let (converted, tmp_dir) = convert_to_pdf(&p, password).await?; + let temps = tmp_dir.into_iter().collect(); + Ok((PdfInput::Path(converted.pdf_path), PdfInputGuard { temps })) + } + } + PdfInput::Bytes(b) => { + let ext = guess_extension_from_data(&b); + if ext.as_deref() == Some("pdf") { + return Ok((PdfInput::Bytes(b), PdfInputGuard { temps: Vec::new() })); + } + if reject_text_formats && ext.as_ref().is_some_and(|e| is_text_only_extension(e)) { + return Err(screenshot_text_format_error(ext.as_ref().unwrap())); + } + let (converted, temps) = convert_data_to_pdf(b, password).await?; + Ok((PdfInput::Path(converted.pdf_path), PdfInputGuard { temps })) + } + } +} + +pub fn is_supported_extension(path: &str) -> bool { + let ext = match Path::new(path).extension().and_then(|e| e.to_str()) { + Some(e) => e.to_lowercase(), + None => return false, + }; + + if ext == "pdf" { + return true; + } + + OFFICE_EXTENSIONS.contains(&ext.as_str()) + || PRESENTATION_EXTENSIONS.contains(&ext.as_str()) + || SPREADSHEET_EXTENSIONS.contains(&ext.as_str()) + || IMAGE_EXTENSIONS.contains(&ext.as_str()) +} + +/// Attempt to convert a non-PDF file to PDF. +/// +/// Currently stubbed out — returns an error directing users to install +/// LibreOffice (for office documents) or ImageMagick (for images). +pub async fn convert_to_pdf( + path: &str, + password: Option<&str>, +) -> Result<(ConversionResult, Option), LiteParseError> { + let ext = Path::new(path) + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_lowercase()) + .unwrap_or_default(); + + if ext == "pdf" { + return Ok(( + ConversionResult { + pdf_path: path.to_string(), + original_extension: ext, + }, + None, + )); + } + + let tool = if OFFICE_EXTENSIONS.contains(&ext.as_str()) + || PRESENTATION_EXTENSIONS.contains(&ext.as_str()) + || SPREADSHEET_EXTENSIONS.contains(&ext.as_str()) + { + ConversionTool::LibreOffice + } else if IMAGE_EXTENSIONS.contains(&ext.as_str()) { + ConversionTool::ImageMagick + } else { + return Err(LiteParseError::Conversion(format!( + "unsupported file format: .{}", + ext + ))); + }; + + let tmp_dir = tempfile::Builder::new().prefix("liteparse-").tempdir()?; + let pdf_path = match tool { + ConversionTool::ImageMagick => { + convert_image_to_pdf(path, tmp_dir.path().to_str().unwrap()).await? + } + ConversionTool::LibreOffice => { + convert_office_document(path, tmp_dir.path().to_str().unwrap(), password).await? + } + }; + + Ok(( + ConversionResult { + pdf_path, + original_extension: ext, + }, + Some(tmp_dir), + )) +} + +/// Execute command with timeout +pub async fn execute_command( + command: &str, + args: Vec<&str>, + timeout_ms: u64, +) -> Result { + let proc = tokio::process::Command::new(command) + .args(args) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true) + .spawn()?; + match tokio::time::timeout( + tokio::time::Duration::from_millis(timeout_ms), + proc.wait_with_output(), + ) + .await + { + Ok(Ok(output)) => { + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + if output.status.success() { + Ok(stdout) + } else { + Err(LiteParseError::Conversion(format!( + "Command failed: {stderr}" + ))) + } + } + Ok(Err(e)) => Err(LiteParseError::Conversion(format!("Command error: {e}"))), + Err(_) => Err(LiteParseError::Conversion(format!( + "Command timed out after {timeout_ms}ms" + ))), + } +} + +/// Execute a command for PowerShel +pub async fn execute_powershell(command: &str, timeout_ms: u64) -> Result { + execute_command( + "powershell", + vec!["-NoProfile", "-Command", command], + timeout_ms, + ) + .await +} + +fn get_resolved_path_from_output(output: &str, use_last_line: bool) -> Option { + let lines: Vec = output + .lines() + .map(|l| l.trim().to_string()) + .filter(|l| !l.is_empty()) + .collect(); + if lines.is_empty() { + return None; + } + let l = if use_last_line { + lines.last() + } else { + lines.first() + }; + + l.cloned() +} + +/// Resolve the actual executable path for a command. +pub async fn resolve_command_path(command: &str) -> Option { + if std::env::consts::FAMILY == "windows" { + let ps = format!("(Get-Command '{command}' -ErrorAction Stop).Source"); + match execute_powershell(&ps, 5000).await { + Ok(out) => get_resolved_path_from_output(&out, true), + Err(_) => None, + } + } else { + match execute_command("which", vec![command], 5000).await { + Ok(out) => get_resolved_path_from_output(&out, false), + Err(_) => None, + } + } +} + +/// Check if a command is available on Unix-like platforms (via `which`). +pub async fn is_command_available(command: &str) -> bool { + execute_command("which", vec![command], 5000).await.is_ok() +} + +/// Check if a command is available on Windows (via PowerShell `Get-Command`). +pub async fn is_command_available_windows(command: &str) -> bool { + execute_powershell(&format!("Get-Command {command}"), 5000) + .await + .is_ok() +} + +/// Check if a file path exists and is executable. +pub async fn is_path_executable(file_path: &str) -> bool { + let p = std::path::PathBuf::from(file_path); + match tokio::fs::metadata(&p).await { + Ok(meta) => { + if !meta.is_file() { + return false; + } + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + meta.permissions().mode() & 0o111 != 0 + } + + #[cfg(windows)] + { + return true; + } + } + Err(_) => false, + } +} + +/// Detect whether a resolved path points at the built-in Windows +/// `System32\convert.exe` (which is unrelated to ImageMagick). +fn is_windows_system_convert(file_path: &str) -> bool { + let normalized = file_path.replace('/', "\\").to_lowercase(); + let system_root = std::env::var("SystemRoot").unwrap_or_else(|_| "C:\\Windows".to_string()); + let system32_convert = format!("{system_root}\\System32\\convert.exe") + .replace('/', "\\") + .to_lowercase(); + normalized == system32_convert +} + +/// Verify an executable identifies itself as ImageMagick via `-version`. +async fn is_image_magick_binary(executable_path: &str, args: &[&str]) -> bool { + let mut full_args: Vec<&str> = args.to_vec(); + full_args.push("-version"); + match execute_command(executable_path, full_args, 5000).await { + Ok(out) => out.to_lowercase().contains("imagemagick"), + Err(_) => false, + } +} + +async fn resolve_image_magick_command(command: &str) -> Option { + let resolved_path = resolve_command_path(command).await?; + + if command == "convert" + && std::env::consts::FAMILY == "windows" + && is_windows_system_convert(&resolved_path) + { + return None; + } + + if !is_image_magick_binary(&resolved_path, &[]).await { + return None; + } + + Some(ResolvedCommand { + command: resolved_path.clone(), + args: Vec::new(), + resolved_path, + }) +} + +/// Find LibreOffice command - handles different installation methods. +pub async fn find_libre_office_command() -> Option { + if is_command_available("libreoffice").await + || is_command_available_windows("libreoffice").await + { + return Some("libreoffice".to_string()); + } + + if is_command_available("soffice").await || is_command_available_windows("soffice").await { + return Some("soffice".to_string()); + } + + let mac_os_paths = [ + "/Applications/LibreOffice.app/Contents/MacOS/soffice", + "/Applications/LibreOffice.app/Contents/MacOS/libreoffice", + ]; + + let windows_paths = ["C:\\Program Files\\Libreoffice\\program\\soffice.exe"]; + + for lib_path in mac_os_paths.iter() { + if is_path_executable(lib_path).await { + return Some(lib_path.to_string()); + } + } + + for lib_path in windows_paths.iter() { + if is_path_executable(lib_path).await { + return Some(lib_path.to_string()); + } + } + + None +} + +/// Find ImageMagick command - handles v6 (`convert`) and v7 (`magick`). +pub async fn find_image_magick_command() -> Option { + if let Some(cmd) = resolve_image_magick_command("magick").await { + return Some(cmd); + } + resolve_image_magick_command("convert").await +} + +/// Convert office documents using LibreOffice. +pub async fn convert_office_document( + file_path: &str, + output_dir: &str, + password: Option<&str>, +) -> Result { + let libre_office_cmd = find_libre_office_command().await.ok_or_else(|| { + LiteParseError::Conversion( + "LibreOffice is not installed. Please install LibreOffice to convert office documents. \ + On macOS: brew install --cask libreoffice, On Ubuntu: apt-get install libreoffice, \ + On Windows: choco install libreoffice-fresh".into() + ) + })?; + // LibreOffice serializes on a per-user profile lock. Concurrent invocations + // sharing the same profile race for the lock: the loser either silently + // exits with status 0 producing no output, or crashes on shared state. + // Give every invocation its own throwaway UserInstallation profile. + let user_profile_dir = tempfile::Builder::new() + .prefix("liteparse-lo-profile-") + .tempdir()?; + let user_profile_file_url = + url::Url::from_file_path(user_profile_dir.path()).map_err(|_| { + LiteParseError::Conversion(format!( + "failed to convert temp profile path to file URL: {}", + user_profile_dir.path().display() + )) + })?; + let user_profile_url = format!("-env:UserInstallation={user_profile_file_url}"); + let infilter_arg; + let mut args: Vec<&str> = vec![ + &user_profile_url, + "--headless", + "--invisible", + "--convert-to", + "pdf", + "--outdir", + output_dir, + ]; + if let Some(pw) = password { + infilter_arg = format!("--infilter=:{pw}"); + args.push(&infilter_arg); + } + args.push(file_path); + + execute_command(&libre_office_cmd, args, 120_000).await?; + find_pdf_in_dir(output_dir).await +} + +/// Scan `output_dir` for the first `.pdf` file and return its path. +/// +/// LibreOffice sanitises filenames during conversion (e.g. strips parentheses, +/// leading digit sequences, spaces) so the output PDF stem often differs from +/// the input file stem. Since `output_dir` is always a fresh temp directory +/// that holds exactly one file after a successful conversion, scanning for any +/// `.pdf` entry is more robust than constructing a fixed `.pdf` path. +async fn find_pdf_in_dir(output_dir: &str) -> Result { + let mut read_dir = tokio::fs::read_dir(output_dir) + .await + .map_err(|e| LiteParseError::Conversion(format!("cannot read output dir: {e}")))?; + while let Some(entry) = read_dir + .next_entry() + .await + .map_err(|e| LiteParseError::Conversion(format!("error reading output dir: {e}")))? + { + let path = entry.path(); + if path + .extension() + .map(|e| e.eq_ignore_ascii_case("pdf")) + .unwrap_or(false) + { + return Ok(path.to_string_lossy().to_string()); + } + } + Err(LiteParseError::Conversion( + "LibreOffice conversion succeeded but output PDF not found".into(), + )) +} + +/// Convert images to PDF using ImageMagick. +pub async fn convert_image_to_pdf( + file_path: &str, + output_dir: &str, +) -> Result { + let image_magick = find_image_magick_command().await.ok_or_else(|| { + LiteParseError::Conversion( + "ImageMagick is not installed. Please install ImageMagick to convert images. \ + On macOS: brew install imagemagick, On Ubuntu: apt-get install imagemagick, \ + On Windows: choco install imagemagick.app" + .into(), + ) + })?; + + let ext = Path::new(file_path) + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_lowercase()) + .unwrap_or_default(); + let needs_ghostscript = GHOSTSCRIPT_REQUIRED_EXTENSIONS.contains(&ext.as_str()); + + if needs_ghostscript { + let has_ghostscript = + is_command_available("gs").await || is_command_available_windows("gs").await; + if !has_ghostscript { + return Err(LiteParseError::Conversion(format!( + "Ghostscript is required to convert {} files but is not installed. \ + On macOS: brew install ghostscript, On Ubuntu: apt-get install ghostscript, \ + On Windows: choco install ghostscript", + ext.to_uppercase() + ))); + } + } + + let base_name = Path::new(file_path) + .file_stem() + .and_then(|s| s.to_str()) + .ok_or_else(|| LiteParseError::Conversion(format!("invalid file path: {file_path}")))?; + let pdf_path = Path::new(output_dir) + .join(format!("{base_name}.pdf")) + .to_string_lossy() + .to_string(); + + let mut args: Vec<&str> = image_magick.args.iter().map(|s| s.as_str()).collect(); + args.push(file_path); + args.push("-density"); + args.push("150"); + args.push("-units"); + args.push("PixelsPerInch"); + args.push(&pdf_path); + + match execute_command(&image_magick.command, args, 60_000).await { + Ok(_) => Ok(pdf_path), + Err(error) => { + let error_msg = error.to_string(); + if error_msg.contains("gs") && error_msg.contains("command not found") { + return Err(LiteParseError::Conversion(format!( + "Ghostscript is required to convert {} files but is not installed. \ + On macOS: brew install ghostscript, On Ubuntu: apt-get install ghostscript, \ + On Windows: choco install ghostscript", + ext.to_uppercase() + ))); + } + if error_msg.contains("FailedToExecuteCommand") && error_msg.contains("gs") { + return Err(LiteParseError::Conversion(format!( + "Ghostscript failed during {} conversion. \ + Ensure Ghostscript is properly installed: brew install ghostscript", + ext.to_uppercase() + ))); + } + Err(error) + } + } +} + +pub fn guess_extension_from_data(data: &[u8]) -> Option { + // `file-format` inspects ZIP-based containers via their central directory + // (requires the `reader` feature), so DOCX/XLSX/PPTX/ODF resolve to their + // specific format instead of a generic "zip" regardless of entry ordering + // or file size. Unrecognized input falls back to the generic binary format, + // which we surface as `None` to match the prior contract. + let fmt = FileFormat::from_bytes(data); + if fmt == FileFormat::ArbitraryBinaryData { + return None; + } + Some(fmt.extension().to_string()) +} + +pub async fn convert_data_to_pdf( + data: Vec, + password: Option<&str>, +) -> Result<(ConversionResult, Vec), LiteParseError> { + let ext = guess_extension_from_data(&data); + let staging_dir = tempfile::Builder::new() + .prefix("liteparse-staging-") + .tempdir()?; + let tmp_path = staging_dir + .path() + .join(format!("input.{}", ext.unwrap_or("bin".to_string()))); + tokio::fs::write(&tmp_path, data).await?; + let (converted, output_dir) = convert_to_pdf(tmp_path.to_str().unwrap(), password).await?; + let mut temps = vec![staging_dir]; + if let Some(d) = output_dir { + temps.push(d); + } + Ok((converted, temps)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_pdf() { + assert!(is_pdf("foo.pdf")); + assert!(is_pdf("foo.PDF")); + assert!(is_pdf("/abs/dir/Bar.Pdf")); + assert!(!is_pdf("foo.docx")); + assert!(!is_pdf("foo")); + assert!(!is_pdf("")); + } + + #[test] + fn test_is_supported_extension() { + assert!(is_supported_extension("a.pdf")); + assert!(is_supported_extension("A.DOCX")); + assert!(is_supported_extension("a.pptx")); + assert!(is_supported_extension("a.xlsx")); + assert!(is_supported_extension("a.png")); + assert!(is_supported_extension("a.svg")); + assert!(!is_supported_extension("a.exe")); + assert!(!is_supported_extension("noext")); + } + + /// Build a minimal but structurally valid ZIP (stored, empty entries) whose + /// central directory lists `names`. This exercises the same code path + /// `file-format` uses to disambiguate ZIP-based office formats. + fn build_zip(names: &[&str]) -> Vec { + let mut out = Vec::new(); + let mut offsets = Vec::new(); + for name in names { + offsets.push(out.len() as u32); + let nb = name.as_bytes(); + out.extend_from_slice(&0x0403_4b50u32.to_le_bytes()); // local header sig + out.extend_from_slice(&20u16.to_le_bytes()); // version needed + out.extend_from_slice(&[0u8; 20]); // flags..uncompressed size (all zero) + out.extend_from_slice(&(nb.len() as u16).to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); // extra len + out.extend_from_slice(nb); + } + let cd_offset = out.len() as u32; + let mut central = Vec::new(); + for (i, name) in names.iter().enumerate() { + let nb = name.as_bytes(); + central.extend_from_slice(&0x0201_4b50u32.to_le_bytes()); // central header sig + central.extend_from_slice(&20u16.to_le_bytes()); // version made by + central.extend_from_slice(&20u16.to_le_bytes()); // version needed + central.extend_from_slice(&[0u8; 20]); // flags..uncompressed size + central.extend_from_slice(&(nb.len() as u16).to_le_bytes()); + central.extend_from_slice(&[0u8; 8]); // extra/comment/disk/internal attrs + central.extend_from_slice(&0u32.to_le_bytes()); // external attrs + central.extend_from_slice(&offsets[i].to_le_bytes()); // local header offset + central.extend_from_slice(nb); + } + let cd_size = central.len() as u32; + out.extend_from_slice(¢ral); + out.extend_from_slice(&0x0605_4b50u32.to_le_bytes()); // EOCD sig + out.extend_from_slice(&[0u8; 4]); // disk numbers + out.extend_from_slice(&(names.len() as u16).to_le_bytes()); + out.extend_from_slice(&(names.len() as u16).to_le_bytes()); + out.extend_from_slice(&cd_size.to_le_bytes()); + out.extend_from_slice(&cd_offset.to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); // comment len + out + } + + #[test] + fn test_guess_extension_disambiguates_zip_office_formats() { + // ZIP-based office formats must resolve to their specific type, not a + // generic "zip", even though they share the PK signature. + assert_eq!( + guess_extension_from_data(&build_zip(&["[Content_Types].xml", "word/document.xml"])) + .as_deref(), + Some("docx") + ); + assert_eq!( + guess_extension_from_data(&build_zip(&["[Content_Types].xml", "xl/workbook.xml"])) + .as_deref(), + Some("xlsx") + ); + assert_eq!( + guess_extension_from_data(&build_zip(&["[Content_Types].xml", "ppt/presentation.xml"])) + .as_deref(), + Some("pptx") + ); + // A plain ZIP with no office markers stays "zip". + assert_eq!( + guess_extension_from_data(&build_zip(&["random/file.txt"])).as_deref(), + Some("zip") + ); + } + + #[test] + fn test_conversion_tool_display() { + assert_eq!(ConversionTool::ImageMagick.to_string(), "ImageMagick"); + assert_eq!(ConversionTool::LibreOffice.to_string(), "LibreOffice"); + } + + #[test] + fn test_get_resolved_path_from_output_first_and_last() { + let out = " /usr/bin/foo\n\n/opt/bin/foo\n"; + assert_eq!( + get_resolved_path_from_output(out, false).as_deref(), + Some("/usr/bin/foo") + ); + assert_eq!( + get_resolved_path_from_output(out, true).as_deref(), + Some("/opt/bin/foo") + ); + } + + #[test] + fn test_get_resolved_path_from_output_empty() { + assert!(get_resolved_path_from_output("", false).is_none()); + assert!(get_resolved_path_from_output(" \n \n", true).is_none()); + } + + #[test] + fn test_is_windows_system_convert() { + // SAFETY: tests run single-threaded for env modification scope + unsafe { std::env::set_var("SystemRoot", "C:\\Windows") }; + assert!(is_windows_system_convert( + "C:\\Windows\\System32\\convert.exe" + )); + assert!(is_windows_system_convert("C:/Windows/System32/convert.exe")); + assert!(!is_windows_system_convert( + "C:\\Program Files\\ImageMagick\\convert.exe" + )); + } + + #[test] + fn test_guess_extension_from_data_png() { + let png_header = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]; + assert_eq!( + guess_extension_from_data(&png_header).as_deref(), + Some("png") + ); + } + + #[test] + fn test_guess_extension_from_data_unknown() { + assert!(guess_extension_from_data(&[0u8, 1, 2, 3]).is_none()); + } + + #[tokio::test] + async fn test_execute_command_failure() { + let r = execute_command("ls", vec!["/this/definitely/does/not/exist"], 5000).await; + assert!(r.is_err()); + } + + #[tokio::test] + async fn test_execute_command_timeout() { + let r = execute_command("sleep", vec!["5"], 50).await; + assert!(r.is_err()); + assert!(r.unwrap_err().to_string().contains("timed out")); + } + + #[tokio::test] + async fn test_execute_command_spawn_error() { + let r = execute_command("definitely_not_a_real_command_xyz123", vec![], 1000).await; + assert!(r.is_err()); + } + + #[tokio::test] + async fn test_is_path_executable_nonexistent() { + assert!(!is_path_executable("/no/such/path/zzz").await); + } + + #[test] + fn test_is_text_only_extension() { + assert!(is_text_only_extension("txt")); + assert!(is_text_only_extension("TXT")); + assert!(is_text_only_extension("md")); + assert!(!is_text_only_extension("pdf")); + assert!(!is_text_only_extension("docx")); + } + + #[tokio::test] + async fn test_resolve_pdf_input_rejects_text_for_screenshot() { + use crate::types::PdfInput; + + let err = resolve_pdf_input(PdfInput::Path("/tmp/readme.txt".into()), None, true) + .await + .unwrap_err() + .to_string(); + assert!(err.contains("Cannot screenshot text-based format")); + } + + #[tokio::test] + async fn test_resolve_pdf_input_pdf_bytes_zero_disk() { + use crate::types::PdfInput; + + let pdf_bytes = b"%PDF-1.4\n"; + let (input, guard) = resolve_pdf_input(PdfInput::Bytes(pdf_bytes.to_vec()), None, true) + .await + .unwrap(); + assert!(matches!(input, PdfInput::Bytes(_))); + assert!(guard.temps.is_empty()); + } + + #[tokio::test] + async fn test_convert_to_pdf_passthrough_pdf() { + let (res, _) = convert_to_pdf("/some/file.pdf", None).await.unwrap(); + assert_eq!(res.pdf_path, "/some/file.pdf"); + assert_eq!(res.original_extension, "pdf"); + } + + #[tokio::test] + async fn test_convert_to_pdf_unsupported() { + let r = convert_to_pdf("/some/file.xyz", None).await; + assert!(r.is_err()); + assert!(r.unwrap_err().to_string().contains("unsupported")); + } + + /// The staging `TempDir` returned by `convert_data_to_pdf` must be + /// cleaned up when dropped — both on success and failure paths. + /// Here we verify the failure path: the error propagates, and once + /// the returned temps are dropped the staging directory is gone. + #[tokio::test] + async fn test_convert_data_to_pdf_staging_cleaned_on_drop() { + // Build a staging dir manually so we can inspect its path after drop. + let staging_dir = tempfile::Builder::new() + .prefix("liteparse-staging-") + .tempdir() + .unwrap(); + let staging_path = staging_dir.path().to_path_buf(); + assert!(staging_path.exists()); + + // Dropping the TempDir removes the directory. + drop(staging_dir); + assert!( + !staging_path.exists(), + "staging temp dir should be removed on drop" + ); + } + + // ── find_pdf_in_dir ────────────────────────────────────────────────────── + + /// Regression test for the LibreOffice filename-sanitisation bug. + /// + /// LibreOffice strips characters like parentheses and leading digit + /// sequences from filenames, so the output PDF stem often differs from the + /// input file stem. `find_pdf_in_dir` must locate the PDF regardless of + /// what LibreOffice chose to call it. + /// + /// Scenario: input was `52304751_AnuragLahare_E2 (1).docx`; LibreOffice + /// wrote `AnuragLahare_E2_1_.pdf` instead of the expected + /// `52304751_AnuragLahare_E2 (1).pdf`. + #[tokio::test] + async fn test_find_pdf_in_dir_returns_sanitised_name() { + let tmp = tempfile::tempdir().unwrap(); + // Simulate LibreOffice writing a sanitised filename. + let sanitised = tmp.path().join("AnuragLahare_E2_1_.pdf"); + tokio::fs::write(&sanitised, b"%PDF-1.4").await.unwrap(); + + let result = find_pdf_in_dir(tmp.path().to_str().unwrap()).await; + assert!(result.is_ok(), "should find PDF even with sanitised name"); + assert!( + result.unwrap().ends_with("AnuragLahare_E2_1_.pdf"), + "returned path should point to the sanitised file" + ); + } + + /// When LibreOffice somehow produces no PDF (unexpected failure), the + /// helpful error message must still be returned. + #[tokio::test] + async fn test_find_pdf_in_dir_empty_dir_returns_error() { + let tmp = tempfile::tempdir().unwrap(); + let result = find_pdf_in_dir(tmp.path().to_str().unwrap()).await; + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("output PDF not found"), + "error message should mention missing PDF" + ); + } +} diff --git a/crates/liteparse/src/error.rs b/crates/liteparse/src/error.rs new file mode 100644 index 0000000..bb99e54 --- /dev/null +++ b/crates/liteparse/src/error.rs @@ -0,0 +1,40 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum LiteParseError { + #[error("PDF error: {0}")] + Pdf(#[from] pdfium::PdfiumError), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + + #[error("image error: {0}")] + Image(#[from] image::ImageError), + + #[error("OCR failed: {0}")] + Ocr(String), + + #[error("conversion error: {0}")] + Conversion(String), + + #[error("invalid config: {0}")] + Config(String), + + #[error("{0}")] + Other(String), +} + +impl From for LiteParseError { + fn from(s: String) -> Self { + LiteParseError::Other(s) + } +} + +impl From<&str> for LiteParseError { + fn from(s: &str) -> Self { + LiteParseError::Other(s.to_string()) + } +} diff --git a/crates/liteparse/src/extract.rs b/crates/liteparse/src/extract.rs new file mode 100644 index 0000000..fada7b9 --- /dev/null +++ b/crates/liteparse/src/extract.rs @@ -0,0 +1,2181 @@ +use crate::error::LiteParseError; +use crate::glyph_names::resolve_glyph_name; +use crate::types::{ + ExtractedImage, GraphicPrimitive, ImageRef, OutlineTarget, Page as LitePage, PdfInput, Rect, + StructNode, TextItem, WordBox, +}; +use image::ImageEncoder; +use pdfium::{ + Document, Font, FontType, Library, Page, PathObject, PdfLink, RectF, SegmentKind, TextPage, +}; + +/// Open a PDF from path or bytes with an optional password. +/// +/// The returned [`Document`] borrows from the provided [`Library`], which +/// holds the process-global PDFium lock. The lock is released when the +/// `Library` is dropped, so callers must keep `lib` alive for as long as any +/// `Document` / `Page` / `TextPage` etc. derived from it is in use. +pub(crate) fn load_document_from_input<'lib>( + lib: &'lib Library, + input: &PdfInput, + password: Option<&str>, +) -> Result, LiteParseError> { + match input { + PdfInput::Path(path) => Ok(lib.load_document(path, password)?), + PdfInput::Bytes(data) => Ok(lib.load_document_from_bytes(data, password)?), + } +} + +/// Extract pages from a `PdfInput` (file path or bytes) with filtering. +/// +/// This convenience entry point acquires the PDFium lock internally for the +/// full extraction. Callers that already hold a [`Library`] (e.g. because +/// they're also rendering bitmaps in the same critical section) should call +/// [`extract_pages_from_document`] directly. +pub fn extract_pages_from_input( + input: &PdfInput, + target_pages: Option<&[u32]>, + max_pages: usize, + password: Option<&str>, +) -> Result, LiteParseError> { + let lib = Library::init(); + let document = load_document_from_input(&lib, input, password)?; + extract_pages_from_document(&document, target_pages, max_pages) +} + +/// Extract pages from an already-open PDFium document. +pub(crate) fn extract_pages_from_document( + document: &Document, + target_pages: Option<&[u32]>, + max_pages: usize, +) -> Result, LiteParseError> { + Ok(extract_pages_and_images(document, target_pages, max_pages, false, false, None, false)?.0) +} + +/// Same as `extract_pages_from_document` but optionally also renders every +/// raster image object to PNG bytes (when `render_images = true`). Returned +/// `ExtractedImage`s carry the same ids the markdown emitter will reference, +/// so callers can match them up by id. When `render_images = false` the +/// returned image vec is always empty. +pub(crate) fn extract_pages_and_images( + document: &Document, + target_pages: Option<&[u32]>, + max_pages: usize, + render_images: bool, + extract_links: bool, + glyph_resolver: Option<&dyn crate::GlyphResolver>, + emit_word_boxes: bool, +) -> Result<(Vec, Vec), LiteParseError> { + let page_count = document.page_count(); + let mut pages = Vec::new(); + let mut images: Vec = Vec::new(); + + for page_index in 0..page_count { + let page_number = page_index as u32 + 1; + + if let Some(targets) = target_pages + && !targets.contains(&page_number) + { + continue; + } + + if pages.len() >= max_pages { + break; + } + + let page = document.page(page_index)?; + let text_page = page.text()?; + let view_box = page.view_box().unwrap_or(RectF { + left: 0.0, + top: page.height(), + right: page.width(), + bottom: 0.0, + }); + let mut text_items = extract_page_text_items( + &page, + &text_page, + &view_box, + glyph_resolver, + emit_word_boxes, + )?; + if extract_links { + assign_links(&mut text_items, &page.links(&view_box)); + } + let graphics = extract_page_graphics(&page, &view_box); + assign_strikethrough(&mut text_items, &graphics); + let struct_nodes = extract_page_struct_nodes(&page, &view_box); + let image_refs = extract_page_image_refs(&page, page_number); + + if render_images && !image_refs.is_empty() { + images.extend(render_page_images(&page, page_number, &image_refs)); + } + + pages.push(LitePage { + page_number: page_number as usize, + page_width: page.width(), + page_height: page.height(), + text_items, + graphics, + struct_nodes, + image_refs, + }); + } + + Ok((pages, images)) +} + +/// Assign hyperlink URIs to text items whose bbox center falls inside a link +/// annotation's rectangle. Both the item bbox and the link rect are in +/// viewport space. First matching link wins. +/// +/// A link rect taller than `MULTILINE_DROP_FACTOR`× the height of the text it +/// covers is a multi-line annotation given to us as a single *union* box (no +/// per-line quad points). Its true anchor — which words on the intervening +/// lines are actually linked — is unrecoverable, so we drop it rather than +/// wrap a whole sentence in a misleading link. Well-formed multi-line links +/// expose quad points and arrive here as one single-line rect per line. +fn assign_links(items: &mut [TextItem], links: &[PdfLink]) { + if links.is_empty() { + return; + } + const MULTILINE_DROP_FACTOR: f32 = 1.8; + for link in links { + let r = &link.rect; + let covered: Vec = items + .iter() + .enumerate() + .filter(|(_, it)| { + let cx = it.x + it.width / 2.0; + let cy = it.y + it.height / 2.0; + cx >= r.left && cx <= r.right && cy >= r.top && cy <= r.bottom + }) + .map(|(i, _)| i) + .collect(); + if covered.is_empty() { + continue; + } + let mut heights: Vec = covered.iter().map(|&i| items[i].height).collect(); + heights.sort_by(f32::total_cmp); + let median_h = heights[heights.len() / 2]; + if median_h > 0.0 && (r.bottom - r.top) > MULTILINE_DROP_FACTOR * median_h { + continue; + } + for &i in &covered { + if items[i].link.is_none() { + items[i].link = Some(link.uri.clone()); + } + } + } +} + +/// Max thickness (pt) for a stroke/rect to count as a strikethrough line. +const STRIKE_MAX_THICKNESS_PT: f32 = 2.0; +/// A strike line must horizontally cover at least this fraction of the item. +const STRIKE_MIN_COVER_FRACTION: f32 = 0.6; + +/// Mark text items whose vertical *middle* band is crossed by a thin horizontal +/// line (a strikethrough). The line may be drawn as a `Stroke` or as a thin +/// filled `Rect`. Underlines (near the baseline) and overlines (near the top) +/// are excluded by the band check; table rules / HRs almost never pass through +/// the middle of a glyph run, and the per-item width-coverage gate keeps long +/// dividers from tagging incidental text they happen to cross. +fn assign_strikethrough(items: &mut [TextItem], graphics: &[GraphicPrimitive]) { + // Reduce graphics to horizontal segments: (xmin, xmax, y_center). + let mut segs: Vec<(f32, f32, f32)> = Vec::new(); + for g in graphics { + match g { + GraphicPrimitive::Stroke { + x1, + y1, + x2, + y2, + width, + .. + } => { + let dy = (y1 - y2).abs(); + let dx = (x1 - x2).abs(); + if dy <= STRIKE_MAX_THICKNESS_PT && *width <= STRIKE_MAX_THICKNESS_PT && dx > dy { + segs.push((x1.min(*x2), x1.max(*x2), (y1 + y2) * 0.5)); + } + } + GraphicPrimitive::Rect { bbox, .. } => { + // A thin, wide filled rect acts as a line. + if bbox.height <= STRIKE_MAX_THICKNESS_PT && bbox.width > bbox.height { + segs.push((bbox.x, bbox.x + bbox.width, bbox.y + bbox.height * 0.5)); + } + } + } + } + if segs.is_empty() { + return; + } + + for item in items.iter_mut() { + if item.width <= 0.0 || item.height <= 0.0 || item.text.trim().is_empty() { + continue; + } + // Viewport space is top-left origin, so `y` is the top edge. The middle + // band sits below the top and above the baseline, excluding over/underlines. + let band_top = item.y + item.height * 0.20; + let band_bot = item.y + item.height * 0.65; + let (ix0, ix1) = (item.x, item.x + item.width); + for &(sx0, sx1, sy) in &segs { + if sy < band_top || sy > band_bot { + continue; + } + let overlap = (ix1.min(sx1) - ix0.max(sx0)).max(0.0); + if overlap >= item.width * STRIKE_MIN_COVER_FRACTION { + item.strike = true; + break; + } + } + } +} + +/// Walk the document outline (bookmarks). Returns entries in pre-order. +/// Empty when the PDF has no outline. +pub(crate) fn extract_outline(document: &Document) -> Vec { + document + .outline() + .into_iter() + .filter_map(|e| { + Some(OutlineTarget { + level: e.level, + title: e.title, + page_index: e.page_index?, + y_pdf: e.y, + }) + }) + .collect() +} + +/// Walk the page's structure tree (tagged PDFs). Returns nodes in pre-order; +/// empty when the page is untagged. +fn extract_page_struct_nodes(page: &Page, view_box: &RectF) -> Vec { + page.struct_tree(view_box) + .into_iter() + .map(|n| StructNode { + role: n.role, + mcids: n.mcids, + bbox: n.bbox.map(|b| Rect { + x: b.left, + y: b.top, + width: b.right - b.left, + height: b.bottom - b.top, + }), + alt_text: n.alt_text, + }) + .collect() +} + +/// Extract raw text items and print each page as a JSON-line object to stdout. +pub fn extract(pdf_path: &str, page_num: Option) -> Result<(), LiteParseError> { + let target_pages: Option> = page_num.map(|p| vec![p]); + let pages = extract_pages_from_input( + &PdfInput::Path(pdf_path.to_string()), + target_pages.as_deref(), + usize::MAX, + None, + )?; + for page in &pages { + println!("{}", serde_json::to_string(page)?); + } + Ok(()) +} + +/// Check if the page has any visible (non-render-mode-3) printable characters. +/// Used to decide whether to skip invisible text or use it (OCR text layers). +/// Determine whether invisible (render mode 3) characters should be skipped. +/// +/// Returns true only when the page has a clear mix of visible and invisible +/// text with the visible portion dominating — this indicates the invisible +/// text is likely a redundant OCR layer over a native-text PDF. +/// +/// When invisible text is the majority, or the only text on the page, +/// returns false so we keep it (it IS the content, e.g. scanned PDFs with +/// an OCR text layer and no native text). +fn should_skip_invisible(text_page: &TextPage, char_count: i32) -> bool { + let mut visible = 0u32; + let mut invisible = 0u32; + + for i in 0..char_count { + let Some(ch) = text_page.char_at(i) else { + continue; + }; + let unicode = ch.unicode(); + if unicode == 0 || unicode == 0xFFFE || unicode == 0xFFFF { + continue; + } + if let Some(c) = char::from_u32(unicode) + && (c.is_whitespace() || c.is_control()) + { + continue; + } + if ch.is_generated() { + continue; + } + if ch.text_render_mode() == Some(3) { + invisible += 1; + } else { + visible += 1; + } + } + + // Only skip invisible text when visible text clearly dominates. + // If invisible text is a significant portion (>30% of all text), + // keep it — the page likely has mixed content where both matter. + if visible == 0 { + return false; // All invisible → keep it + } + if invisible == 0 { + return false; // No invisible text to skip + } + let total = visible + invisible; + let invisible_ratio = invisible as f64 / total as f64; + invisible_ratio < 0.3 +} + +/// Minimum image extent (in PDF points) below which we ignore the image +/// object. Filters out hairline rasterized rules, icons embedded in glyphs, +/// and other sub-25pt fragments that would otherwise pollute the figure +/// stream. Matches the threshold used by `ocr_merge::has_images`. +const IMAGE_MIN_SIZE_PT: f32 = 25.0; + +/// Max fraction of the page each axis can cover. Drops full-page background +/// images (scanned pages, watermarks). +const IMAGE_MAX_COVERAGE: f32 = 0.9; + +/// Render every image referenced in `refs` to PNG bytes using +/// `Page::render_image_object`. Returns one `ExtractedImage` per ref. Used by +/// the parser only when `ImageMode::Embed` is configured — otherwise the +/// extraction loop skips this entirely. Failures for individual images are +/// silently dropped (a malformed embedded image shouldn't fail the whole +/// parse). +pub(crate) fn render_page_images( + page: &Page, + page_number: u32, + refs: &[ImageRef], +) -> Vec { + let mut out = Vec::with_capacity(refs.len()); + for r in refs { + let bmp = match page.render_image_object(r.obj_index) { + Ok(b) => b, + Err(_) => continue, + }; + let w = bmp.width().max(0) as u32; + let h = bmp.height().max(0) as u32; + if w == 0 || h == 0 { + continue; + } + let rgba = bmp.to_rgba(); + let png = match encode_png(&rgba, w, h) { + Ok(p) => p, + Err(_) => continue, + }; + out.push(ExtractedImage { + id: r.id.clone(), + page: page_number, + bbox: r.bbox.clone(), + format: "png".into(), + bytes: png, + }); + } + out +} + +/// Encode RGBA pixel bytes to PNG. Lives here (always-compiled) rather than in +/// `render` so the image-embed path is available on wasm, where the `render` +/// module (page rasterization / screenshots) is compiled out. +pub(crate) fn encode_png(rgba: &[u8], width: u32, height: u32) -> Result, LiteParseError> { + let mut png_buf = Vec::new(); + let encoder = image::codecs::png::PngEncoder::new(&mut png_buf); + encoder.write_image(rgba, width, height, image::ColorType::Rgba8.into())?; + Ok(png_buf) +} + +/// Walk image objects on a page and return a stable per-page `ImageRef` for +/// each one. `obj_index` is the index among image-typed page objects (not all +/// page objects), so a later embed pass can pull pixel bytes via +/// `Page::render_image_object`. IDs are scoped to the page number so they +/// remain stable across runs. +fn extract_page_image_refs(page: &Page, page_number: u32) -> Vec { + page.image_bounds(IMAGE_MIN_SIZE_PT, IMAGE_MAX_COVERAGE) + .into_iter() + .enumerate() + .map(|(i, b)| ImageRef { + id: format!("p{}_{}", page_number, i), + bbox: Rect { + x: b.x, + y: b.y, + width: b.width, + height: b.height, + }, + obj_index: i, + }) + .collect() +} + +/// Extract simplified vector graphics from a page. We keep only what the +/// markdown layout pass cares about: +/// - filled paths → a single bounding `Rect` (covers cell backgrounds / +/// code-block fills / banner fills regardless of internal complexity); +/// - stroked paths → one `Stroke` per `LineTo` between consecutive points, +/// plus the implicit closing stroke when a subpath has its close flag set. +/// +/// BezierTo segments don't emit strokes (we just advance the current point so +/// later LineTos start from the right place). +fn extract_page_graphics(page: &Page, view_box: &RectF) -> Vec { + let paths: Vec = page.path_objects(view_box); + let mut out = Vec::new(); + + for path in &paths { + // Filled paths: emit one Rect for the full bbox. Cheap signal for + // cell backgrounds / figure clusters / code-block fills. + if path.is_filled { + out.push(GraphicPrimitive::Rect { + bbox: rectf_to_rect(&path.bbox), + fill: path.fill_color.as_ref().map(color_to_argb_hex), + stroke: path.stroke_color.as_ref().map(color_to_argb_hex), + }); + } + + if !path.is_stroked { + continue; + } + + // Stroked paths: walk segments and emit one Stroke per LineTo. + let color = path.stroke_color.as_ref().map(color_to_argb_hex); + let mut current: Option<(f32, f32)> = None; + let mut subpath_start: Option<(f32, f32)> = None; + + for seg in &path.segments { + match seg.kind { + SegmentKind::MoveTo => { + current = Some((seg.x, seg.y)); + subpath_start = Some((seg.x, seg.y)); + } + SegmentKind::LineTo => { + if let Some((px, py)) = current { + out.push(GraphicPrimitive::Stroke { + x1: px, + y1: py, + x2: seg.x, + y2: seg.y, + color: color.clone(), + width: path.stroke_width, + }); + } + current = Some((seg.x, seg.y)); + if seg.close + && let (Some((cx, cy)), Some((sx, sy))) = (current, subpath_start) + && (cx - sx).hypot(cy - sy) > 0.01 + { + out.push(GraphicPrimitive::Stroke { + x1: cx, + y1: cy, + x2: sx, + y2: sy, + color: color.clone(), + width: path.stroke_width, + }); + } + } + SegmentKind::BezierTo => { + // Don't synthesize a stroke for a curve; just advance. + current = Some((seg.x, seg.y)); + } + } + } + } + + out +} + +fn rectf_to_rect(r: &RectF) -> Rect { + Rect { + x: r.left, + y: r.top, + width: r.right - r.left, + height: r.bottom - r.top, + } +} + +/// Fold typographic punctuation to its ASCII equivalent so extracted text +/// matches plain-ASCII transcriptions: curly quotes → `'`/`"`, the dash family +/// (en/em/figure/non-breaking/minus) → `-`. Applied to every decoded character +/// at extraction time so all output formats are consistent. +fn normalize_punct(c: char) -> char { + match c { + '\u{2018}' | '\u{2019}' | '\u{201A}' | '\u{2032}' => '\'', + '\u{201C}' | '\u{201D}' | '\u{201E}' | '\u{2033}' => '"', + '\u{2010}' | '\u{2011}' | '\u{2012}' | '\u{2013}' | '\u{2014}' | '\u{2015}' + | '\u{2212}' => '-', + _ => c, + } +} + +/// Character-level text extraction. +/// +/// Instead of using PDFium's rect API (which splits text at every font attribute +/// change), we iterate through individual characters and group them by spatial +/// proximity. This keeps words like "A-MEM" together even when internal characters +/// have different font sizes (e.g. small-caps), and keeps punctuation attached to +/// adjacent text (e.g. citation commas/semicolons). +/// +/// Segments break at: +/// - Line changes (large vertical shift) +/// - Column breaks (large horizontal gap) +/// - Explicit newline characters +fn extract_page_text_items( + page: &Page, + text_page: &TextPage, + view_box: &RectF, + glyph_resolver: Option<&dyn crate::GlyphResolver>, + emit_word_boxes: bool, +) -> Result, LiteParseError> { + let char_count = text_page.char_count(); + if char_count <= 0 { + return Ok(Vec::new()); + } + + // Hard limit: gaps larger than this always cause a split (column breaks). + const MAX_INLINE_GAP: f32 = 15.0; + + let debug = std::env::var("LITEPARSE_DEBUG").is_ok(); + let dbg_gaps = std::env::var("LITEPARSE_DEBUG_GAPS").is_ok(); + // Empirical per-font space calibration: for fonts that expose no + // space-glyph metric, recover the genuine inter-word gap from the spaces + // PDFium *does* emit for that font (normalized by rendered em height) and + // feed it through the same threshold rule the metric path uses. + let mut font_space_cal: std::collections::HashMap> = + std::collections::HashMap::new(); + + // Pre-scan: check if ALL text on this page is invisible (render mode 3). + // Some scanned PDFs have an invisible OCR text layer as the only text. + // In that case we should use the invisible text rather than skipping it. + let skip_invisible = should_skip_invisible(text_page, char_count); + + if debug { + eprintln!("[extract-debug] char_count={char_count}, skip_invisible={skip_invisible}"); + } + + let page_rotation = page.rotation(); + let vp_xform = page.viewport_transform(view_box); + let mut items: Vec = Vec::new(); + let mut seg = SegmentBuilder::new(emit_word_boxes); + let garbage_fonts = detect_garbage_unicode_fonts(text_page, char_count); + let mut glyph_decoder = GlyphDecoder::new( + std::env::var("LITEPARSE_DEBUG_GLYPH").is_ok(), + garbage_fonts, + glyph_resolver, + ); + + for i in 0..char_count { + let ch = text_page.char_at_unchecked(i); + let unicode = ch.unicode(); + let is_generated = ch.is_generated(); + + // Skip invisible text (render mode 3) only when the page also has visible text. + // If all text is invisible, it's likely an OCR text layer and we should keep it. + if skip_invisible && ch.text_render_mode() == Some(3) { + if debug { + let c_display = char::from_u32(unicode).unwrap_or('?'); + eprintln!( + "[extract-debug] i={i} SKIP invisible char='{c_display}' unicode=0x{unicode:04X}" + ); + } + continue; + } + + // Glyph-name recovery: when the font's unicode mapping is missing or + // untrusted, resolve the charcode's PostScript glyph name instead. + let decoded: Option<&str> = if is_generated { + None + } else { + glyph_decoder.decode(&ch, unicode) + }; + // A glyph the decoder recovered (glyph-name / reverse-cmap / outline-hash + // resolver) carries correct text even though PDFium still reports a + // /ToUnicode map error for its raw char code. Don't count these toward the + // item's unmapped-char tally. + let recovered = decoded.is_some(); + + // Skip null / invalid sentinels (unless the glyph name recovered them) + if decoded.is_none() && (unicode == 0 || unicode == 0xFFFE || unicode == 0xFFFF) { + if debug { + eprintln!("[extract-debug] i={i} SKIP sentinel unicode=0x{unicode:04X}"); + } + continue; + } + + // Map to a Rust char, with special-case replacements. + // Some PDF fonts encode ligatures as control characters; expand them. + // We use the first char for segment decisions, then append trailing chars. + let (c, ligature_tail): (char, &str) = if let Some(s) = decoded { + let mut it = s.chars(); + (it.next().unwrap(), it.as_str()) + } else { + match unicode { + 0x01 => (' ', ""), // SOH → space: buggy subset fonts (e.g. some + // Calibri/Cambria embeds) encode the space glyph as 0x01. Left as + // a raw control char it fuses adjacent words ("StatisticsCheatSheet"); + // as a space it drives the normal pending-space word break below. + 0x02 => ('-', ""), // STX → hyphen (common in some PDF encodings) + 0x1A => ('f', "f"), // ff ligature + 0x1B => ('f', "t"), // ft ligature + 0x1C => ('f', "i"), // fi ligature + 0x1D => ('T', "h"), // Th ligature + 0x1E => ('f', "fi"), // ffi ligature + 0x1F => ('f', "l"), // fl ligature + _ => match char::from_u32(unicode) { + Some(ch_mapped) => (ch_mapped, ""), + None => { + if debug { + eprintln!("[extract-debug] i={i} SKIP invalid unicode=0x{unicode:04X}"); + } + continue; + } + }, + } + }; + let c = normalize_punct(c); + + // Newlines: flush the current segment + if c == '\n' || c == '\r' { + seg.flush(&mut items); + continue; + } + + // Spaces: mark that we're in a pending-space state. + if c == ' ' { + seg.mark_pending_space(); + continue; + } + + // Skip non-space generated characters (synthetic glyphs) + if is_generated { + if debug { + eprintln!( + "[extract-debug] i={i} SKIP generated char='{c}' unicode=0x{unicode:04X}" + ); + } + continue; + } + + // Get loose bounds in viewport space for the item bounding box + let Some(loose_box) = ch.loose_char_box() else { + if debug { + eprintln!("[extract-debug] i={i} SKIP no loose_char_box char='{c}'"); + } + continue; + }; + let vp_loose = vp_xform.transform_bounds(&loose_box); + + // Skip zero-height characters (phantom dots from dot leader decorations) + if vp_loose.bottom - vp_loose.top < 0.5 { + if debug { + eprintln!( + "[extract-debug] i={i} SKIP zero-height char='{c}' height={:.2} vp=({:.1},{:.1})-({:.1},{:.1})", + vp_loose.bottom - vp_loose.top, + vp_loose.left, + vp_loose.top, + vp_loose.right, + vp_loose.bottom + ); + } + continue; + } + + // Also get strict char box for gap calculation (stays in viewport space) + let Some(strict_box) = ch.char_box() else { + if debug { + eprintln!("[extract-debug] i={i} SKIP no char_box char='{c}'"); + } + continue; + }; + let strict_rect = RectF { + left: strict_box.left as f32, + top: strict_box.top as f32, + right: strict_box.right as f32, + bottom: strict_box.bottom as f32, + }; + let vp_strict = vp_xform.transform_bounds(&strict_rect); + + if seg.has_content { + // Use viewport-space coordinates for gap/overlap checks + let y_tolerance: f32 = 2.0; + let y_overlap = vp_loose.top < seg.vp_bottom + y_tolerance + && vp_loose.bottom > seg.vp_top - y_tolerance; + + let gap = vp_strict.left - seg.last_char_right; + + // Detect line change using complementary checks: + // 1. Strict vertical separation: char's strict top is well below last char's strict bottom + // 2. Line wrap: char goes back leftward AND strict top is below last char's strict bottom + // (even slightly), indicating text wrapped to a new line within the same text object + // 3. Very large leftward jump: if the char jumps back by more than the current + // segment width, it's definitely a new line (handles OCR text with tall bounding + // boxes that overlap vertically between lines) + let strict_below = vp_strict.top > seg.last_char_bottom; + let large_leftward_jump = gap < -5.0; + let seg_width = seg.vp_right - seg.vp_left; + let very_large_leftward_jump = seg_width > 20.0 && gap < -(seg_width * 0.5); + let line_changed = vp_strict.top > seg.last_char_bottom + y_tolerance + || (strict_below && large_leftward_jump) + || very_large_leftward_jump; + + // Dot leader detection: break at the boundary between dots and non-dots. + // This prevents items like "Total . . . . 330,100" from merging. + let dot_leader_break = if seg.pending_space { + // With a pending space: break at dot/non-dot transitions + (c == '.' && seg.has_non_dot_content()) + || (c != '.' && !seg.has_non_dot_content() && seg.char_count >= 3) + } else { + // Without a pending space: break when a dot follows non-dot content + // with a gap larger than typical intra-word spacing (dot leader dots + // are spaced apart, unlike periods in abbreviations like "U.S."). + // A loosely-kerned abbreviation/sentence period sits at ~1x the + // average char width; genuine no-space dot leaders run far wider + // (2x+). The 2x cutoff avoids shearing the trailing period off + // abbreviations like "Sci."/"Chem." when the font kerns the + // period a hair loose, which would drop it entirely downstream. + c == '.' && seg.has_non_dot_content() && gap > seg.avg_char_width() * 2.0 + }; + + if dbg_gaps && y_overlap && !line_changed && gap > 0.0 { + let fs = if seg.font_size > 0.0 { + seg.font_size + } else { + seg.vp_bottom - seg.vp_top + }; + let split = gap >= MAX_INLINE_GAP + || (seg.pending_space && gap > seg.avg_char_width() * 2.2); + let loose_gap = vp_strict.left - seg.last_char_loose_right; + let em_vp = (vp_loose.bottom - vp_loose.top).abs(); + let space_w = ch.font_space_width().map(|w| w * em_vp).unwrap_or(-1.0); + eprintln!( + "[gap] {} gap={:.2} loose={:.2} sw={:.2} g/sw={:.2} fs={:.2} g/fs={:.2} avgcw={:.2} g/cw={:.2} ps={} -> after='{:.20}' next='{}'", + if split { "SPLIT" } else { "merge" }, + gap, + loose_gap, + space_w, + if space_w > 0.0 { + loose_gap / space_w + } else { + 0.0 + }, + fs, + if fs > 0.0 { gap / fs } else { 0.0 }, + seg.avg_char_width(), + gap / seg.avg_char_width().max(0.1), + seg.pending_space as u8, + seg.text, + c, + ); + } + if !y_overlap || line_changed || gap >= MAX_INLINE_GAP || dot_leader_break { + seg.flush(&mut items); + seg.start(c, &vp_loose, &vp_strict, &ch, recovered, page_rotation); + seg.append_ligature_tail(ligature_tail); + } else if seg.pending_space { + let avg_cw = seg.avg_char_width(); + if gap > avg_cw * 2.2 { + seg.flush(&mut items); + seg.start(c, &vp_loose, &vp_strict, &ch, recovered, page_rotation); + seg.append_ligature_tail(ligature_tail); + } else { + // Genuine inline space PDFium emitted: sample its size + // (loose gap / em height) per font, alpha-alpha only, to + // calibrate the no-space-metric recovery below. + if let Some(fk) = seg.font_name.as_ref() { + let prev_alnum = seg + .text + .chars() + .last() + .is_some_and(|p| p.is_ascii_alphanumeric()); + if prev_alnum && c.is_ascii_alphanumeric() { + let em_vp = (vp_loose.bottom - vp_loose.top).abs(); + let loose_gap = vp_strict.left - seg.last_char_loose_right; + if em_vp > 0.0 && loose_gap > 0.0 { + let s = font_space_cal.entry(fk.clone()).or_default(); + if s.len() < 512 { + s.push(loose_gap / em_vp); + } + } + } + } + seg.commit_pending_space(); + seg.push_char(c, &vp_loose, &vp_strict, &ch, recovered); + seg.append_ligature_tail(ligature_tail); + } + } else { + // Missing-space recovery: PDFium sometimes omits the space glyph + // between words, fusing them ("of the" -> "ofthe"). Detect it from + // the advance-relative gap (measured against the previous char's + // LOOSE right edge, so intra-word kerning/overhang is subtracted out) + // compared to the font's actual ASCII-space advance. Only fires + // between two ASCII alphanumerics, which keeps abbreviation dots, + // hyphens, and CJK untouched. When the font exposes no space-glyph + // metric (common in embedded subset fonts) fall back to a fraction + // of the rendered em height as the space estimate. + let em_vp = (vp_loose.bottom - vp_loose.top).abs(); + let space_w = ch.font_space_width().map(|w| w * em_vp).unwrap_or(0.0); + let loose_gap = vp_strict.left - seg.last_char_loose_right; + let both_alnum = c.is_ascii_alphanumeric() + && seg + .text + .chars() + .last() + .is_some_and(|p| p.is_ascii_alphanumeric()); + let thresh = if space_w > 0.0 { + 0.7 * space_w + } else { + // No space-glyph metric. Prefer an empirically-recovered + // space width (median genuine-space ratio for this font × + // em height) run through the same 0.7 factor as the metric + // path; fall back to a fixed em fraction when we lack + // enough samples for the font. + let calibrated = seg + .font_name + .as_ref() + .and_then(|fk| font_space_cal.get(fk)) + .filter(|s| s.len() >= MIN_SPACE_CAL_SAMPLES) + .and_then(|s| median_f32(s)) + .map(|ratio| 0.7 * ratio * em_vp); + calibrated.unwrap_or(0.35 * em_vp) + }; + if both_alnum && thresh > 0.0 && loose_gap > thresh { + seg.text.push(' '); + seg.break_word(); + } + seg.push_char(c, &vp_loose, &vp_strict, &ch, recovered); + seg.append_ligature_tail(ligature_tail); + } + } else { + seg.start(c, &vp_loose, &vp_strict, &ch, recovered, page_rotation); + seg.append_ligature_tail(ligature_tail); + } + } + + seg.flush(&mut items); + + // Drop items entirely outside the page view box. Print-spread / imposed + // PDFs carry the neighbouring page's text at x beyond the page edge in + // the same content stream; viewers never show it. Partially-visible + // items are kept. + let vb_w = (view_box.right - view_box.left).abs(); + let vb_h = (view_box.top - view_box.bottom).abs(); + let pre_clip_count = items.len(); + items.retain(|it| { + it.x < vb_w + && it.x + it.width.max(0.1) > 0.0 + && it.y < vb_h + && it.y + it.height.max(0.1) > 0.0 + }); + if debug && items.len() < pre_clip_count { + eprintln!( + "[extract-debug] off-page clip removed {} items", + pre_clip_count - items.len() + ); + } + + if debug { + eprintln!("[extract-debug] items before dedup: {}", items.len()); + } + + // Dedup: remove items with identical text and overlapping bounding boxes. + // Some PDFs (especially those with chart/figure annotations) produce duplicate + // text objects at the same position. + let pre_dedup_count = items.len(); + dedup_overlapping_items(&mut items, debug); + + if debug && items.len() < pre_dedup_count { + eprintln!( + "[extract-debug] dedup removed {} items ({} → {})", + pre_dedup_count - items.len(), + pre_dedup_count, + items.len() + ); + } + + Ok(items) +} + +/// Remove duplicate text items: exact text matches with any bbox overlap, +/// and near-duplicates (different text) with high bbox overlap (>50% area). +fn dedup_overlapping_items(items: &mut Vec, debug: bool) { + if items.len() < 2 { + return; + } + + let mut keep = vec![true; items.len()]; + for i in 0..items.len() { + if !keep[i] { + continue; + } + for j in (i + 1)..items.len() { + if !keep[j] { + continue; + } + + let a = &items[i]; + let b = &items[j]; + + // Diagonal (non-right-angle) text has a *loose* axis-aligned + // bounding box — the hull of a rotated glyph run is far larger than + // the ink, so two stacked lines of the same skewed block (e.g. + // "Paris has the" above "eiffel tower" at 51°) report heavy bbox + // overlap even though the glyphs never touch. Dedup keys off AABB + // overlap, so it would wrongly drop one of those lines. Skip the + // comparison entirely when either item is diagonal; true duplicate + // stamps are upright and still handled below. + if is_diagonal_rotation(a.rotation) || is_diagonal_rotation(b.rotation) { + continue; + } + + // Compute intersection area + let ix_left = a.x.max(b.x); + let ix_right = (a.x + a.width).min(b.x + b.width); + let iy_top = a.y.max(b.y); + let iy_bottom = (a.y + a.height).min(b.y + b.height); + + if ix_left >= ix_right || iy_top >= iy_bottom { + continue; // no overlap + } + + let intersection = (ix_right - ix_left) * (iy_bottom - iy_top); + let area_a = a.width * a.height; + let area_b = b.width * b.height; + let smaller_area = area_a.min(area_b); + + if items[i].text == items[j].text { + // Exact text match: require strong bounding-box overlap before + // dedup. The same word routinely appears more than once on a + // page in different positions; firing on any overlap would drop + // a legitimate occurrence when two identical words' bboxes share + // even a sliver of area (e.g. one column's word vertically + // adjacent to another column's identical word with a slack + // loose-box), corrupting that line. + // + // Require ≥50% overlap of the smaller item — same threshold + // as the non-exact branch. True duplicate stamps overlap + // essentially 100%; unrelated repeats overlap 0%. + let strong_overlap = smaller_area > 0.0 && intersection / smaller_area > 0.5; + if !strong_overlap { + continue; + } + if debug { + eprintln!( + "[extract-debug] DEDUP exact-match drop i={i} text='{}' at ({:.1},{:.1} {}x{}) in favor of j={j} at ({:.1},{:.1} {}x{}) overlap_ratio={:.2}", + items[i].text, + items[i].x, + items[i].y, + items[i].width, + items[i].height, + items[j].x, + items[j].y, + items[j].width, + items[j].height, + intersection / smaller_area + ); + } + keep[i] = false; + break; // i is gone, move to next i + } else if smaller_area > 0.0 && intersection / smaller_area > 0.5 { + // Different text but >50% overlap of the smaller item: + // likely overlapping text layers (e.g. old/new branding). + // Keep the later one (rendered on top in PDF paint order). + // + // However, skip dedup when the items have very different sizes + // (area ratio > 5x). This happens when a small cell value sits + // inside a row-spanning element like a dotted leader — these are + // separate content, not overlapping layers. + let larger_area = area_a.max(area_b); + if larger_area / smaller_area > 5.0 { + if debug { + eprintln!( + "[extract-debug] DEDUP skip (area ratio {:.1}x) i={i} text='{}' j={j} text='{}'", + larger_area / smaller_area, + items[i].text, + items[j].text + ); + } + continue; + } + if debug { + eprintln!( + "[extract-debug] DEDUP overlap drop i={i} text='{}' at ({:.1},{:.1} {}x{}) in favor of j={j} text='{}' at ({:.1},{:.1} {}x{}) overlap_ratio={:.2}", + items[i].text, + items[i].x, + items[i].y, + items[i].width, + items[i].height, + items[j].text, + items[j].x, + items[j].y, + items[j].width, + items[j].height, + intersection / smaller_area + ); + } + keep[i] = false; + break; // i is gone, move to next i + } + } + } + + let mut idx = 0; + items.retain(|_| { + let k = keep[idx]; + idx += 1; + k + }); +} + +/// True when `rotation` (degrees) is more than 2° off the nearest right angle +/// (0/90/180/270). A page's diagonal watermark/stamp text +/// is classified identically on both sides. +fn is_diagonal_rotation(rotation: f32) -> bool { + let nearest_right_angle = (rotation / 90.0).round() * 90.0; + (rotation - nearest_right_angle).abs() > 2.0 +} + +/// Apply caller-requested content filters to already-extracted (and +/// OCR-merged) pages, in place, just before grid projection: +/// +/// * `skip_diagonal` drops skewed text (watermarks, rotated stamps). +/// * `crop_box` keeps only items lying *entirely* inside the surviving page +/// region — fractions cropped from each side, top-left origin. +/// +/// Running here (after OCR merge, before projection) means both native and +/// OCR-sourced items are filtered and removed text never reaches the output. +/// No-op when neither filter is requested. +pub(crate) fn apply_content_filters( + pages: &mut [LitePage], + crop_box: Option<&crate::config::CropBox>, + skip_diagonal: bool, +) { + if crop_box.is_none() && !skip_diagonal { + return; + } + for page in pages.iter_mut() { + if skip_diagonal { + page.text_items + .retain(|it| !is_diagonal_rotation(it.rotation)); + } + if let Some(cb) = crop_box { + let w = page.page_width; + let h = page.page_height; + let min_x = cb.left * w; + let max_x = (1.0 - cb.right) * w; + let min_y = cb.top * h; + let max_y = (1.0 - cb.bottom) * h; + page.text_items.retain(|it| { + it.x >= min_x + && it.x + it.width <= max_x + && it.y >= min_y + && it.y + it.height <= max_y + }); + } + } +} + +/// Adjust character angle for page rotation. +/// PDFium returns counter-clockwise angle in PDF space; page /Rotate is clockwise. +fn adjust_angle_for_rotation(angle_rad: f32, page_rotation: i32) -> f32 { + use std::f32::consts::PI; + let mut a = angle_rad; + match page_rotation { + 1 => a -= 3.0 * PI / 2.0, // 90° + 2 => a -= PI, // 180° + 3 => a -= PI / 2.0, // 270° + _ => {} + } + a = a.rem_euclid(2.0 * PI); + a +} + +/// Decompose scale factors from a 2D affine matrix. +/// Computes eigenvalues of M^T * M. +fn decompose_scale(m: &pdfium::Matrix) -> (f32, f32) { + let (a, b, c, d) = (m.a as f64, m.b as f64, m.c as f64, m.d as f64); + // M^T * M + let mt_a = a * a + b * b; + let mt_b = a * c + b * d; + let mt_d = c * c + d * d; + let first = (mt_a + mt_d) / 2.0; + let disc = ((mt_a + mt_d).powi(2) - 4.0 * (mt_a * mt_d - mt_b * mt_b)).sqrt() / 2.0; + let sx = (first + disc).sqrt(); + let sy = (first - disc).sqrt(); + let sx = if sx.is_nan() { 1.0 } else { sx }; + let sy = if sy.is_nan() { 1.0 } else { sy }; + (sx as f32, sy as f32) +} + +/// Minimum genuine-space samples required before trusting per-font calibration. +const MIN_SPACE_CAL_SAMPLES: usize = 6; + +/// Median of a slice of finite, non-negative f32 values. Returns None if empty. +fn median_f32(values: &[f32]) -> Option { + if values.is_empty() { + return None; + } + let mut v: Vec = values.to_vec(); + v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let mid = v.len() / 2; + if v.len().is_multiple_of(2) { + Some((v[mid - 1] + v[mid]) / 2.0) + } else { + Some(v[mid]) + } +} + +/// Check if a font is "buggy" based on its name and type. +fn is_buggy_font(font_name: &str, font_type: FontType) -> bool { + // TrueType subset fonts: name starts with "TT" or contains "+TT" + if font_name.starts_with("TT") || font_name.contains("+TT") { + return true; + } + // Type1 fonts with 6-char prefix + underscore: "ABCDEF_..." + if font_type == FontType::Type1 && font_name.len() >= 7 { + let bytes = font_name.as_bytes(); + if bytes[6] == b'_' { + return true; + } + } + false +} + +/// Check if a Unicode codepoint indicates buggy encoding. +/// C0 controls (<=0x1F), DEL + C1 controls (0x7F-0x9F), and the private use area. +/// None of these are ever legitimate rendered text; C1 controls in particular +/// are emitted by a common class of subset fonts that mangle ToUnicode into +/// the 0x80-0x9F range. +fn is_buggy_codepoint(unicode: u32) -> bool { + unicode <= 0x1F || (0x7F..=0x9F).contains(&unicode) || (unicode > 0xE000 && unicode <= 0xF8FF) +} + +fn color_to_argb_hex(c: &pdfium::Color) -> String { + format!("{:02x}{:02x}{:02x}{:02x}", c.a, c.r, c.g, c.b) +} + +/// Per-page glyph-name-based unicode recovery (fork API). +/// +/// When a font has no /ToUnicode CMap, PDFium derives unicode from the +/// encoding alone — garbage for custom/Identity encodings (Mode 10 glyph +/// soup), and guessed control-code expansions for ligatures (Mode 16). The +/// PostScript glyph name the font assigns to the charcode (from /Encoding +/// /Differences or the embedded font program) resolved against the Adobe +/// Glyph List is the authoritative signal in both cases. +struct GlyphDecoder<'a> { + fonts: std::collections::HashMap, + /// Chars arrive in runs per text object; cache the last object's font key + /// to skip the FPDFTextObj_GetFont FFI call on the common path. + last_obj: usize, + last_key: usize, + /// Font handles whose /ToUnicode the prescan flagged as garbage (high + /// fraction of control/PUA unicodes across the page). + garbage_fonts: std::collections::HashSet, + /// Optional last-resort recovery hook for untrusted glyphs that the + /// built-in glyph-name / reverse-cmap recovery could not decode. + resolver: Option<&'a dyn crate::GlyphResolver>, + debug: bool, +} + +struct FontGlyphInfo { + font: Font, + /// No /ToUnicode and no standard base encoding (or the prescan flagged + /// the ToUnicode as garbage): PDFium's unicode values for this font are + /// untrusted, so every charcode gets a recovery try. + untrusted: bool, + /// The font's name matches the buggy-subset heuristic while + /// declaring a *standard* base encoding (e.g. MacRomanEncoding) — the + /// encoding is a lie, so PDFium derives glyph names from it that are just + /// as wrong as the unicode. Skip glyph-name recovery for these and rely on + /// the embedded cmap / outline-hash resolver instead (matches the C path, + /// which ignores glyph names for `PARSE_TEXT_FONT_BUGGY` fonts). + encoding_lies: bool, + /// charcode → resolved replacement text (None = unrecoverable) + cache: std::collections::HashMap>, + /// Lazily-built glyph_index → unicode map from the embedded font + /// program's cmap table (None = not yet built, Some(None) = unavailable). + reverse_cmap: Option>>, +} + +impl<'a> GlyphDecoder<'a> { + fn new( + debug: bool, + garbage_fonts: std::collections::HashSet, + resolver: Option<&'a dyn crate::GlyphResolver>, + ) -> Self { + Self { + fonts: std::collections::HashMap::new(), + garbage_fonts, + resolver, + last_obj: 0, + last_key: 0, + debug, + } + } + + /// Returns replacement text for this char when its glyph name resolves + /// and the current unicode is suspicious (control/PUA/sentinel/map-error) + /// or the font's unicode mapping is untrusted altogether. + fn decode(&mut self, ch: &pdfium::TextChar, unicode: u32) -> Option<&str> { + let cheap_suspicious = matches!(unicode, 0 | 0xFFFE | 0xFFFF) + || (unicode < 0x20 && !matches!(unicode, 0x09 | 0x0A | 0x0D)) + || (0xE000..=0xF8FF).contains(&unicode); + + let obj_ptr = ch.text_object()?; + let obj = obj_ptr as usize; + let key = if obj == self.last_obj { + self.last_key + } else { + let font = unsafe { Font::from_text_object(obj_ptr) }?; + let key = font.handle() as usize; + let debug = self.debug; + let garbage = self.garbage_fonts.contains(&key); + self.fonts.entry(key).or_insert_with(|| { + let has_to_unicode = font.has_to_unicode(); + let encoding = font.encoding(); + // Embedded subset fonts whose name matches the "buggy + // font" heuristic (TrueType `+TT` / Type1 `......_` subset tags) + // routinely lie about their encoding: a standard base encoding + // (e.g. MacRomanEncoding) decodes to a shifted alphabet because + // the embedded glyph program doesn't follow it. PDFium's unicode + // for these looks plausible (printable letters), so the cheap + // per-glyph suspicion checks never fire — flag the whole font + // untrusted so every glyph goes through recovery. Mirrors the C + // path's `PARSE_TEXT_FONT_BUGGY` name flagging (embedded && + // isBuggyFont). + let name_buggy = font.is_embedded() + && font + .base_name() + .is_some_and(|name| is_buggy_font(&name, font.font_type())); + let untrusted = garbage + || name_buggy + || (!has_to_unicode + && !matches!( + encoding.as_deref(), + Some("WinAnsiEncoding") + | Some("MacRomanEncoding") + | Some("MacExpertEncoding") + | Some("StandardEncoding") + )); + if debug { + eprintln!( + "[glyph] font={:?} to_unicode={} encoding={:?} garbage={} name_buggy={} untrusted={}", + font.base_name(), + has_to_unicode, + encoding, + garbage, + name_buggy, + untrusted + ); + } + FontGlyphInfo { + font, + untrusted, + encoding_lies: name_buggy, + cache: std::collections::HashMap::new(), + reverse_cmap: None, + } + }); + self.last_obj = obj; + self.last_key = key; + key + }; + let info = self.fonts.get_mut(&key)?; + + // map-error FFI check is the expensive part of "suspicious"; only + // consult it when the cheap checks and font trust don't decide. + if !info.untrusted && !cheap_suspicious && !ch.has_unicode_map_error() { + return None; + } + let debug = self.debug; + let resolver = self.resolver; + + let char_code = ch.char_code(); + let encoding_lies = info.encoding_lies; + let FontGlyphInfo { + font, + cache, + reverse_cmap, + .. + } = info; + let resolved = cache + .entry(char_code) + .or_insert_with(|| { + let name = font.char_glyph_name(char_code); + // Glyph names of buggy-subset fonts are derived from a lying + // base encoding, so they mis-decode exactly like PDFium's + // unicode (e.g. charcode 0x53 → name "S" but the glyph draws + // 'R'). Skip name recovery for them so the embedded-cmap / + // outline-hash resolver below — the only trustworthy signals — + // get the chance to correct the glyph. + let resolved = if encoding_lies { + None + } else { + name.as_deref() + .and_then(resolve_glyph_name) + .filter(|r| r.chars().all(|c| !c.is_control())) + }; + // Fallback: reverse-map the glyph index through the embedded + // font program's own cmap table. + let resolved = resolved.or_else(|| { + let glyph = font.char_glyph_index(char_code)?; + let map = reverse_cmap + .get_or_insert_with(|| { + let data = font.font_data(); + let map = data.as_deref().and_then(crate::font_cmap::reverse_cmap); + if debug { + eprintln!( + "[glyph] reverse_cmap build: data={:?} bytes, entries={:?}", + data.as_ref().map(|d| d.len()), + map.as_ref().map(|m| m.len()) + ); + } + map + }) + .as_ref()?; + let u = *map.get(&glyph)?; + if (0xE000..=0xF8FF).contains(&u) { + return None; + } + // Synthetic subset cmaps just echo the charcode back + // (charcode-identity, not semantic unicode). A recovery + // that "resolves" to the charcode itself is that + // signature, not a real mapping — keep PDFium's value. + if u == char_code && u != unicode { + return None; + } + let c = char::from_u32(u).filter(|c| !c.is_control())?; + Some(match crate::glyph_names::presentation_form_expansion(c) { + Some(s) => s.to_string(), + None => c.to_string(), + }) + }); + // Last resort: hand the glyph's vector outline to the injected + // resolver. Only reached for untrusted glyphs the deterministic + // recovery above could not decode. + let resolved = resolved.or_else(|| { + let resolver = resolver?; + let segments = + font.glyph_path_segments(char_code, crate::GLYPH_RESOLVER_FONT_SIZE)?; + let text = resolver.resolve(&segments)?; + if text.is_empty() || text.chars().any(|c| c.is_control()) { + return None; + } + if debug { + eprintln!("[glyph] cc=0x{char_code:04X} resolver -> {text:?}"); + } + Some(text) + }); + if debug { + eprintln!( + "[glyph] cc=0x{char_code:04X} unicode=0x{unicode:04X} name={name:?} -> {resolved:?}" + ); + } + resolved + }); + // Don't double-expand a ligature PDFium already split. With no + // /ToUnicode, PDFium derives per-char unicodes from the glyph names + // itself, expanding a single ligature glyph (e.g. the "fi" glyph at + // char_code 0x02) into separate 'f' and 'i' TextChar entries that all + // share that one char_code. Resolving the multi-char glyph name ("fi") + // once per entry would emit "fi"+"fi" → "fifind". When PDFium already + // gave a clean (non-suspicious) char that is part of the resolved + // string, it has done the expansion — keep its char. Suspicious-char + // recoveries (control-code ligatures, glyph soup) still expand. + if let Some(r) = resolved.as_deref() + && r.chars().count() > 1 + && !cheap_suspicious + && let Some(u) = char::from_u32(unicode) + && r.contains(u) + { + return None; + } + resolved.as_deref() + } +} + +/// Prescan: flag fonts whose /ToUnicode maps a high fraction of chars into +/// control/PUA/sentinel codepoints — a structurally present but garbage CMap +/// (e.g. `text_simple__spd`). Chars from flagged fonts get glyph-name / +/// reverse-cmap recovery even when their individual unicode looks plausible. +fn detect_garbage_unicode_fonts( + text_page: &TextPage, + char_count: i32, +) -> std::collections::HashSet { + let mut counts: std::collections::HashMap = std::collections::HashMap::new(); + let mut last_obj: usize = 0; + let mut last_key: usize = 0; + for i in 0..char_count { + let ch = text_page.char_at_unchecked(i); + if ch.is_generated() { + continue; + } + let unicode = ch.unicode(); + if matches!(unicode, 0x09 | 0x0A | 0x0D | 0x20) { + continue; + } + let Some(obj_ptr) = ch.text_object() else { + continue; + }; + let obj = obj_ptr as usize; + let key = if obj == last_obj { + last_key + } else { + let Some(font) = (unsafe { Font::from_text_object(obj_ptr) }) else { + continue; + }; + last_obj = obj; + last_key = font.handle() as usize; + last_key + }; + let entry = counts.entry(key).or_insert((0, 0)); + entry.0 += 1; + let suspicious = matches!(unicode, 0 | 0xFFFE | 0xFFFF) + || unicode < 0x20 + || (0xE000..=0xF8FF).contains(&unicode); + if suspicious { + entry.1 += 1; + } + } + counts + .into_iter() + .filter(|&(_, (total, suspicious))| total >= 20 && suspicious * 10 >= total) + .map(|(key, _)| key) + .collect() +} + +/// Whether a just-decoded glyph should count toward its item's unmapped-char +/// tally (which sets `TextItem::has_unicode_map_error` by majority vote in +/// `flush`). +fn counts_as_unmapped(recovered: bool, raw_map_error: bool) -> bool { + !recovered && raw_map_error +} + +/// Accumulates characters into a single TextItem segment. +struct SegmentBuilder { + text: String, + // Viewport-space bounding box (union of loose bounds, top-left origin) + vp_left: f32, + vp_right: f32, + vp_top: f32, + vp_bottom: f32, + // Right edge of last char strict bounds (for gap calculation) + last_char_right: f32, + // Right edge of last char LOOSE bounds (advance-relative gap calculation) + last_char_loose_right: f32, + // Bottom of last char strict bounds (for line-change detection) + last_char_bottom: f32, + // Count of non-space characters (for avg width calculation) + char_count: usize, + // Count of characters whose Unicode came from PDFium's char-code fallback + // (no usable ToUnicode / glyph-name mapping, e.g. Type3 fonts). + unmapped_char_count: usize, + // Font metadata (captured from the first character) + font_name: Option, + font_size: f32, + font_height: Option, + font_ascent: Option, + font_descent: Option, + font_weight: Option, + font_flags: Option, + font_is_buggy: bool, + font_is_embedded: bool, + font: Option, + rotation_deg: f32, + text_width: f32, + mcid: Option, + fill_color: Option, + stroke_color: Option, + has_content: bool, + pending_space: bool, + // Per-word sub-boxes, finalized at each inter-word space break. The + // currently-open word is accumulated in the `word_*` fields below and + // flushed into `words` by `break_word`. + words: Vec, + cur_word: String, + word_left: f32, + word_right: f32, + word_top: f32, + word_bottom: f32, + word_has: bool, + // When false, per-word tracking is skipped entirely and `words` stays empty. + emit_words: bool, +} + +impl SegmentBuilder { + fn new(emit_words: bool) -> Self { + Self { + text: String::new(), + vp_left: f32::MAX, + vp_right: f32::MIN, + vp_top: f32::MAX, + vp_bottom: f32::MIN, + last_char_right: f32::MIN, + last_char_loose_right: f32::MIN, + last_char_bottom: f32::MIN, + char_count: 0, + unmapped_char_count: 0, + font_name: None, + font_size: 0.0, + font_height: None, + font_ascent: None, + font_descent: None, + font_weight: None, + font_flags: None, + font_is_buggy: false, + font_is_embedded: false, + font: None, + rotation_deg: 0.0, + text_width: 0.0, + mcid: None, + fill_color: None, + stroke_color: None, + has_content: false, + pending_space: false, + words: Vec::new(), + cur_word: String::new(), + word_left: f32::MAX, + word_right: f32::MIN, + word_top: f32::MAX, + word_bottom: f32::MIN, + word_has: false, + emit_words, + } + } + + /// Extend the currently-open word with a character's loose box, opening a + /// fresh word if none is active. No-op unless word emission is enabled. + fn add_word_char(&mut self, c: char, vp_loose: &RectF) { + if !self.emit_words { + return; + } + if self.word_has { + self.word_left = self.word_left.min(vp_loose.left); + self.word_right = self.word_right.max(vp_loose.right); + self.word_top = self.word_top.min(vp_loose.top); + self.word_bottom = self.word_bottom.max(vp_loose.bottom); + } else { + self.cur_word.clear(); + self.word_left = vp_loose.left; + self.word_right = vp_loose.right; + self.word_top = vp_loose.top; + self.word_bottom = vp_loose.bottom; + self.word_has = true; + } + self.cur_word.push(c); + } + + /// Finalize the open word (if any) into `words` and reset the accumulator. + /// Called at each inter-word space and at segment flush. + fn break_word(&mut self) { + if !self.word_has { + return; + } + let trimmed = self.cur_word.trim(); + if !trimmed.is_empty() { + self.words.push(WordBox { + text: trimmed.to_string(), + x: self.word_left, + y: self.word_top, + width: self.word_right - self.word_left, + height: self.word_bottom - self.word_top, + }); + } + self.cur_word.clear(); + self.word_has = false; + } + + /// Average width of non-space characters in the current segment. + /// Prefers actual glyph widths (text_width) over bbox width, since bbox + /// includes inter-character gaps that inflate the average and cause + /// separate table cell values to merge into one item. + fn avg_char_width(&self) -> f32 { + if self.char_count == 0 { + return 5.0; + } + if self.text_width > 0.0 { + self.text_width / self.char_count as f32 + } else { + (self.vp_right - self.vp_left) / self.char_count as f32 + } + } + + /// Start a new segment with the given character. + fn start( + &mut self, + c: char, + vp_loose: &RectF, + vp_strict: &RectF, + ch: &pdfium::TextChar, + recovered: bool, + page_rotation: i32, + ) { + self.text.clear(); + self.text.push(c); + self.vp_left = vp_loose.left; + self.vp_right = vp_loose.right; + self.vp_top = vp_loose.top; + self.vp_bottom = vp_loose.bottom; + self.last_char_right = vp_strict.right; + self.last_char_loose_right = vp_loose.right; + self.last_char_bottom = vp_strict.bottom; + self.char_count = 1; + self.unmapped_char_count = if counts_as_unmapped(recovered, ch.has_unicode_map_error()) { + 1 + } else { + 0 + }; + self.has_content = true; + self.pending_space = false; + self.words.clear(); + self.word_has = false; + self.add_word_char(c, vp_loose); + self.text_width = 0.0; + self.font_is_buggy = false; + self.font_is_embedded = false; + self.font = None; + + // Font info + if let Some((name, flags)) = ch.font_info() { + self.font_name = Some(name); + self.font_flags = Some(flags); + } else { + self.font_name = None; + self.font_flags = None; + } + + let fs = ch.font_size() as f32; + self.font_size = if fs > 0.0 { + fs + } else { + (vp_loose.bottom - vp_loose.top).abs() + }; + + self.font_weight = { + let w = ch.font_weight(); + if w > 0 { Some(w) } else { None } + }; + + // Angle adjusted for page rotation + let angle_rad = ch.angle(); + self.rotation_deg = if angle_rad >= 0.0 { + adjust_angle_for_rotation(angle_rad, page_rotation).to_degrees() + } else { + 0.0 + }; + + // Font object for ascent/descent/glyph widths/buggy detection + if let Some(obj) = ch.text_object() { + if let Some(font) = unsafe { Font::from_text_object(obj) } { + if let Some(name) = font.base_name() { + let ft = font.font_type(); + self.font_is_embedded = font.is_embedded(); + + if self.font_is_embedded && is_buggy_font(&name, ft) { + self.font_is_buggy = true; + } + + self.font_name = Some(name); + } + + self.font_ascent = font.ascent(self.font_size); + self.font_descent = font.descent(self.font_size); + + // Glyph width for first char + let char_code = ch.char_code(); + if let Some(w) = font.glyph_width_from_char_code(char_code, self.font_size) { + self.text_width += w; + } + + self.font = Some(font); + } + + // fontHeight = fontSize * scaleY + if let Some(matrix) = ch.matrix() { + let (_sx, sy) = decompose_scale(&matrix); + self.font_height = Some(self.font_size * sy); + } + } + + // Colors from first glyph + self.stroke_color = ch.stroke_color().map(|c| color_to_argb_hex(&c)); + self.fill_color = ch.fill_color().map(|c| color_to_argb_hex(&c)); + + // Marked content from first glyph + self.mcid = ch.marked_content_id(); + + // Check codepoint for buggy encoding + let unicode = ch.unicode(); + if !self.font_is_buggy && self.font_is_embedded && is_buggy_codepoint(unicode) { + self.font_is_buggy = true; + } + } + + /// Add a visible character to the current segment. + fn push_char( + &mut self, + c: char, + vp_loose: &RectF, + vp_strict: &RectF, + ch: &pdfium::TextChar, + recovered: bool, + ) { + self.text.push(c); + self.add_word_char(c, vp_loose); + self.vp_left = self.vp_left.min(vp_loose.left); + self.vp_right = self.vp_right.max(vp_loose.right); + self.vp_top = self.vp_top.min(vp_loose.top); + self.vp_bottom = self.vp_bottom.max(vp_loose.bottom); + self.last_char_right = vp_strict.right; + self.last_char_loose_right = vp_loose.right; + self.last_char_bottom = vp_strict.bottom; + self.char_count += 1; + if counts_as_unmapped(recovered, ch.has_unicode_map_error()) { + self.unmapped_char_count += 1; + } + + // Accumulate glyph width + if let Some(ref font) = self.font { + let char_code = ch.char_code(); + if ch.is_generated() { + if let Some(w) = font.glyph_width(ch.unicode(), self.font_size) { + self.text_width += w; + } + } else if let Some(w) = font.glyph_width_from_char_code(char_code, self.font_size) { + self.text_width += w; + } + } + + // Check codepoint for buggy encoding on subsequent chars + if !self.font_is_buggy && self.font_is_embedded { + let unicode = ch.unicode(); + if is_buggy_codepoint(unicode) { + self.font_is_buggy = true; + } + } + } + + /// Append extra characters to the segment text (for ligature expansion). + /// Does not update bounding boxes or char count. + fn append_ligature_tail(&mut self, tail: &str) { + self.text.push_str(tail); + if self.word_has { + self.cur_word.push_str(tail); + } + } + + /// Returns true if the segment contains any characters that aren't dots or spaces. + fn has_non_dot_content(&self) -> bool { + self.text + .chars() + .any(|c| c != '.' && c != ' ' && c != '·' && c != '•') + } + + /// Record that a space was seen. + fn mark_pending_space(&mut self) { + if self.has_content { + self.pending_space = true; + } + } + + /// Commit a pending space into the segment text. + fn commit_pending_space(&mut self) { + if self.pending_space { + self.break_word(); + self.text.push(' '); + self.pending_space = false; + } + } + + /// Flush the current segment into the items list and reset. + fn flush(&mut self, items: &mut Vec) { + if !self.has_content { + return; + } + + self.break_word(); + let trimmed = self.text.trim(); + if !trimmed.is_empty() { + let width = self.vp_right - self.vp_left; + let height = self.vp_bottom - self.vp_top; + + items.push(TextItem { + text: trimmed.to_string(), + x: self.vp_left, + y: self.vp_top, + width, + height, + rotation: self.rotation_deg, + font_name: self.font_name.clone(), + font_size: Some(if self.font_size > 0.0 { + self.font_size + } else { + height + }), + font_height: self.font_height, + font_ascent: self.font_ascent, + font_descent: self.font_descent, + font_weight: self.font_weight, + font_flags: self.font_flags, + text_width: if self.text_width > 0.0 { + Some(self.text_width) + } else { + None + }, + font_is_buggy: self.font_is_buggy, + // Majority vote: a stray mapped char (e.g. a space) inside an + // otherwise unmappable Type3 run must not rescue the item. + has_unicode_map_error: self.unmapped_char_count * 2 >= self.char_count.max(1), + mcid: self.mcid, + fill_color: self.fill_color.clone(), + stroke_color: self.stroke_color.clone(), + confidence: None, + link: None, + strike: false, + words: std::mem::take(&mut self.words), + }); + } + + *self = Self::new(self.emit_words); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::f32::consts::PI; + + // A glyph PDFium flags with a raw /ToUnicode map error normally counts + // toward the item's unmapped tally... + #[test] + fn unrecovered_map_error_counts_as_unmapped() { + assert!(counts_as_unmapped(false, true)); + } + + // ...but once the decoder recovers it (glyph-name / reverse-cmap / + // outline-hash resolver) the text is correct, so it must NOT count — this + // is the fix that stops the OCR merge from discarding fully-recovered + // buggy-font items as "unusable native". + #[test] + fn recovered_map_error_does_not_count_as_unmapped() { + assert!(!counts_as_unmapped(true, true)); + } + + // A cleanly-mapped glyph never counts, recovered or not. + #[test] + fn cleanly_mapped_glyph_never_counts_as_unmapped() { + assert!(!counts_as_unmapped(false, false)); + assert!(!counts_as_unmapped(true, false)); + } + + fn strike_item() -> TextItem { + TextItem { + text: "word".to_string(), + x: 100.0, + y: 100.0, + width: 40.0, + height: 10.0, + ..Default::default() + } + } + + fn h_stroke(x1: f32, x2: f32, y: f32) -> GraphicPrimitive { + GraphicPrimitive::Stroke { + x1, + y1: y, + x2, + y2: y, + color: None, + width: 0.5, + } + } + + #[test] + fn strike_midline_stroke_detected() { + let mut items = [strike_item()]; + // Line through the vertical middle (y≈105) spanning the item width. + assign_strikethrough(&mut items, &[h_stroke(100.0, 140.0, 105.0)]); + assert!(items[0].strike); + } + + #[test] + fn strike_underline_not_detected() { + let mut items = [strike_item()]; + // Line near the baseline (bottom, y≈110) is an underline, not a strike. + assign_strikethrough(&mut items, &[h_stroke(100.0, 140.0, 110.0)]); + assert!(!items[0].strike); + } + + #[test] + fn strike_short_line_not_detected() { + let mut items = [strike_item()]; + // Mid-band but only covers ~25% of the item width. + assign_strikethrough(&mut items, &[h_stroke(100.0, 110.0, 105.0)]); + assert!(!items[0].strike); + } + + fn ti(text: &str, x: f32, y: f32, w: f32, h: f32) -> TextItem { + TextItem { + text: text.to_string(), + x, + y, + width: w, + height: h, + ..Default::default() + } + } + + #[test] + fn dedup_drops_earlier_exact_duplicate() { + let mut items = vec![ + ti("hello", 0.0, 0.0, 10.0, 5.0), + ti("hello", 1.0, 0.0, 10.0, 5.0), + ]; + dedup_overlapping_items(&mut items, false); + assert_eq!(items.len(), 1); + assert_eq!(items[0].x, 1.0); + } + + #[test] + fn dedup_keeps_non_overlapping() { + let mut items = vec![ti("a", 0.0, 0.0, 5.0, 5.0), ti("b", 100.0, 100.0, 5.0, 5.0)]; + dedup_overlapping_items(&mut items, false); + assert_eq!(items.len(), 2); + } + + #[test] + fn dedup_drops_earlier_when_different_text_overlaps_heavily() { + let mut items = vec![ + ti("old", 0.0, 0.0, 10.0, 5.0), + ti("new", 0.0, 0.0, 10.0, 5.0), + ]; + dedup_overlapping_items(&mut items, false); + assert_eq!(items.len(), 1); + assert_eq!(items[0].text, "new"); + } + + #[test] + fn dedup_keeps_both_when_different_text_overlaps_lightly() { + let mut items = vec![ + ti("aaa", 0.0, 0.0, 10.0, 5.0), + ti("bbb", 9.0, 0.0, 10.0, 5.0), + ]; + dedup_overlapping_items(&mut items, false); + assert_eq!(items.len(), 2); + } + + #[test] + fn dedup_keeps_overlapping_diagonal_lines() { + // Two stacked lines of one rotated block: their loose axis-aligned + // bounding boxes overlap heavily, but both must survive (the + // "Paris has the" / "eiffel tower" @51° regression). + let mut items = vec![ + TextItem { + rotation: 51.0, + ..ti("Paris has the", 0.0, 0.0, 100.0, 40.0) + }, + TextItem { + rotation: 51.0, + ..ti("eiffel tower", 5.0, 5.0, 100.0, 40.0) + }, + ]; + dedup_overlapping_items(&mut items, false); + assert_eq!(items.len(), 2); + } + + #[test] + fn dedup_noop_for_empty_or_single() { + let mut empty: Vec = vec![]; + dedup_overlapping_items(&mut empty, false); + assert!(empty.is_empty()); + let mut one = vec![ti("x", 0.0, 0.0, 1.0, 1.0)]; + dedup_overlapping_items(&mut one, false); + assert_eq!(one.len(), 1); + } + + fn rot(text: &str, rotation: f32) -> TextItem { + TextItem { + rotation, + ..ti(text, 10.0, 10.0, 20.0, 5.0) + } + } + + fn page_with(items: Vec) -> LitePage { + LitePage { + page_number: 1, + page_width: 100.0, + page_height: 100.0, + text_items: items, + graphics: Vec::new(), + struct_nodes: Vec::new(), + image_refs: Vec::new(), + } + } + + fn texts(page: &LitePage) -> Vec<&str> { + page.text_items.iter().map(|it| it.text.as_str()).collect() + } + + #[test] + fn is_diagonal_rotation_matches() { + // Within 2° of a right angle → not diagonal. + assert!(!is_diagonal_rotation(0.0)); + assert!(!is_diagonal_rotation(1.9)); + assert!(!is_diagonal_rotation(90.0)); + assert!(!is_diagonal_rotation(271.5)); + assert!(!is_diagonal_rotation(358.5)); + // More than 2° off → diagonal (2.57° San-francisco case, 51°, 324°). + assert!(is_diagonal_rotation(2.57)); + assert!(is_diagonal_rotation(51.0)); + assert!(is_diagonal_rotation(324.0)); + } + + #[test] + fn skip_diagonal_keeps_only_upright_text() { + let mut pages = vec![page_with(vec![ + rot("upright", 0.0), + rot("slightly-skewed", 2.57), + rot("diagonal", 51.0), + rot("landscape", 90.0), + ])]; + apply_content_filters(&mut pages, None, true); + assert_eq!(texts(&pages[0]), vec!["upright", "landscape"]); + } + + #[test] + fn crop_box_keeps_only_items_fully_inside_region() { + // 100×100 page; crop away the left half (left = 0.5) → survivors must + // sit entirely within x ∈ [50, 100]. + let cb = crate::config::CropBox { + top: 0.0, + right: 0.0, + bottom: 0.0, + left: 0.5, + }; + let mut pages = vec![page_with(vec![ + ti("left", 10.0, 10.0, 20.0, 5.0), // fully left → dropped + ti("straddle", 45.0, 10.0, 20.0, 5.0), // crosses x=50 → dropped + ti("right", 60.0, 10.0, 20.0, 5.0), // fully right → kept + ])]; + apply_content_filters(&mut pages, Some(&cb), false); + assert_eq!(texts(&pages[0]), vec!["right"]); + } + + #[test] + fn content_filters_noop_without_options() { + let mut pages = vec![page_with(vec![rot("diagonal", 45.0)])]; + apply_content_filters(&mut pages, None, false); + assert_eq!(pages[0].text_items.len(), 1); + } + + #[test] + fn adjust_angle_no_rotation() { + assert!((adjust_angle_for_rotation(0.5, 0) - 0.5).abs() < 1e-6); + } + + #[test] + fn adjust_angle_180() { + let r = adjust_angle_for_rotation(PI, 2); + assert!(r.abs() < 1e-5 || (r - 2.0 * PI).abs() < 1e-5); + } + + #[test] + fn adjust_angle_wraps_into_0_2pi() { + let r = adjust_angle_for_rotation(0.0, 1); + assert!((0.0..2.0 * PI).contains(&r)); + } + + #[test] + fn decompose_scale_identity() { + let m = pdfium::Matrix { + a: 1.0, + b: 0.0, + c: 0.0, + d: 1.0, + e: 0.0, + f: 0.0, + }; + let (sx, sy) = decompose_scale(&m); + assert!((sx - 1.0).abs() < 1e-5); + assert!((sy - 1.0).abs() < 1e-5); + } + + #[test] + fn decompose_scale_uniform() { + let m = pdfium::Matrix { + a: 2.0, + b: 0.0, + c: 0.0, + d: 2.0, + e: 0.0, + f: 0.0, + }; + let (sx, sy) = decompose_scale(&m); + assert!((sx - 2.0).abs() < 1e-4); + assert!((sy - 2.0).abs() < 1e-4); + } + + #[test] + fn buggy_font_truetype_subset_prefix() { + assert!(is_buggy_font("TTFoo", FontType::TrueType)); + assert!(is_buggy_font("ABCDEF+TTBar", FontType::TrueType)); + assert!(!is_buggy_font("Arial", FontType::TrueType)); + } + + #[test] + fn buggy_font_type1_underscore() { + assert!(is_buggy_font("ABCDEF_Foo", FontType::Type1)); + assert!(!is_buggy_font("ABCDEF_Foo", FontType::TrueType)); + assert!(!is_buggy_font("Short", FontType::Type1)); + } + + #[test] + fn buggy_codepoint_ranges() { + assert!(is_buggy_codepoint(0x00)); + assert!(is_buggy_codepoint(0x1F)); + assert!(!is_buggy_codepoint(0x20)); + assert!(is_buggy_codepoint(0xE001)); + assert!(is_buggy_codepoint(0xF8FF)); + assert!(!is_buggy_codepoint(0xE000)); + assert!(!is_buggy_codepoint(0xF900)); + // DEL + C1 controls (0x7F-0x9F): mangled-ToUnicode signature. + assert!(is_buggy_codepoint(0x7F)); + assert!(is_buggy_codepoint(0x80)); + assert!(is_buggy_codepoint(0x9F)); + assert!(!is_buggy_codepoint(0xA0)); + } + + #[test] + fn color_to_argb_hex_formats() { + let c = pdfium::Color { + r: 0xAB, + g: 0xCD, + b: 0xEF, + a: 0x12, + }; + assert_eq!(color_to_argb_hex(&c), "12abcdef"); + let z = pdfium::Color { + r: 0, + g: 0, + b: 0, + a: 0, + }; + assert_eq!(color_to_argb_hex(&z), "00000000"); + } + + #[test] + fn extract_pages_from_input_missing_file_errors() { + let res = extract_pages_from_input( + &PdfInput::Path("/nonexistent/path/does-not-exist.pdf".to_string()), + None, + usize::MAX, + None, + ); + assert!(res.is_err()); + } +} diff --git a/crates/liteparse/src/figure_cluster.rs b/crates/liteparse/src/figure_cluster.rs new file mode 100644 index 0000000..986292a --- /dev/null +++ b/crates/liteparse/src/figure_cluster.rs @@ -0,0 +1,390 @@ +//! Figure-region clustering from page vector graphics. +//! +//! Groups significant vector graphics (filled rects, stroke clusters that +//! aren't HR-like or table-grid-like) into figure bounding rectangles. These +//! rects are fed into the XY-cut layout pass as obstacles, so the recursion +//! partitions around figures (e.g. an abstract column next to a figure that +//! straddles two columns on the first page of an academic paper). +//! +//! Detection is intentionally cautious — false positives here corrupt the +//! reading order. When in doubt we drop the cluster. + +use crate::types::{GraphicPrimitive, Rect, TextItem}; + +/// Min figure dimension on each axis (points). +const FIG_MIN_EXTENT_PT: f32 = 30.0; +/// Min figure area (points²). +const FIG_MIN_AREA_PT2: f32 = 1500.0; +/// Max figure dimension as a fraction of the page (drop full-page backgrounds +/// and any cluster that has grown to consume virtually the whole page). +const FIG_MAX_FRACTION: f32 = 0.95; +/// Cluster two primitives when their bboxes overlap or are within this gap on +/// both axes. +const FIG_CLUSTER_GAP_PT: f32 = 10.0; +/// Drop horizontal-rule-like strokes (long thin horizontal) before clustering +/// so a section divider doesn't become a degenerate "figure". +const HR_MIN_WIDTH_FRACTION: f32 = 0.3; +const HR_MAX_THICKNESS_PT: f32 = 2.0; +/// Aspect ratio threshold above which a cluster is considered "linear" (e.g. a +/// single rule, an underline cluster) rather than a figure. +const FIG_MAX_ASPECT_RATIO: f32 = 15.0; +/// Min primitives in a cluster unless any single primitive is itself large. +const FIG_LARGE_SOLO_AREA: f32 = 5000.0; +/// Fraction of a cluster's area that may be covered by text before we treat +/// the cluster as a text background rather than a figure. +const FIG_MAX_TEXT_COVERAGE: f32 = 0.55; + +/// Detect figure rectangles on a page. +/// +/// Returns a list of bounding rectangles, each enclosing a vector-graphics +/// cluster judged to be a figure (chart, diagram, embedded illustration). +/// Empty when the page has no significant graphics. +pub fn detect_figure_rects( + graphics: &[GraphicPrimitive], + text_items: &[TextItem], + page_width: f32, + page_height: f32, +) -> Vec { + if graphics.is_empty() || page_width <= 0.0 || page_height <= 0.0 { + return Vec::new(); + } + + // Pre-filter primitives. + let hr_min_width = page_width * HR_MIN_WIDTH_FRACTION; + let mut bboxes: Vec = Vec::with_capacity(graphics.len()); + for g in graphics { + let bbox = g.bbox(); + // Drop full-page rects (page background paint). + if bbox.width >= page_width * FIG_MAX_FRACTION + && bbox.height >= page_height * FIG_MAX_FRACTION + { + continue; + } + match g { + GraphicPrimitive::Stroke { + x1, + y1, + x2, + y2, + width, + .. + } => { + let dx = (x2 - x1).abs(); + let dy = (y2 - y1).abs(); + // HR-like: long thin horizontal stroke → section divider, not figure. + if dy <= HR_MAX_THICKNESS_PT && *width <= HR_MAX_THICKNESS_PT && dx >= hr_min_width + { + continue; + } + // Tiny strokes (glyph artifacts, sub-pixel cleanup). + if dx < 4.0 && dy < 4.0 { + continue; + } + } + GraphicPrimitive::Rect { .. } => { + // Tiny rects also dropped to avoid pulling glyph artifacts in. + if bbox.width < 2.0 && bbox.height < 2.0 { + continue; + } + } + } + bboxes.push(bbox); + } + if bboxes.is_empty() { + return Vec::new(); + } + + // Union-find: link primitives whose bboxes are close on BOTH axes. + let n = bboxes.len(); + let mut parent: Vec = (0..n).collect(); + for i in 0..n { + for j in (i + 1)..n { + if close_enough(&bboxes[i], &bboxes[j], FIG_CLUSTER_GAP_PT) { + uf_union(&mut parent, i, j); + } + } + } + + use std::collections::HashMap; + let mut groups: HashMap = HashMap::new(); + for (i, bb) in bboxes.iter().enumerate() { + let r = uf_find(&mut parent, i); + groups + .entry(r) + .and_modify(|entry| { + entry.0 = union_rect(&entry.0, bb); + entry.1 += 1; + }) + .or_insert_with(|| (bb.clone(), 1)); + } + + let mut out = Vec::new(); + for (bbox, count) in groups.into_values() { + if !is_figure_cluster(&bbox, count, page_width, page_height, text_items) { + continue; + } + out.push(bbox); + } + // Sort by y then x so callers see deterministic ordering. + out.sort_by(|a, b| a.y.total_cmp(&b.y).then(a.x.total_cmp(&b.x))); + out +} + +fn is_figure_cluster( + bbox: &Rect, + primitive_count: usize, + page_width: f32, + page_height: f32, + text_items: &[TextItem], +) -> bool { + if bbox.width < FIG_MIN_EXTENT_PT || bbox.height < FIG_MIN_EXTENT_PT { + return false; + } + let area = bbox.width * bbox.height; + if area < FIG_MIN_AREA_PT2 { + return false; + } + if bbox.width >= page_width * FIG_MAX_FRACTION && bbox.height >= page_height * FIG_MAX_FRACTION + { + return false; + } + let ratio = if bbox.width >= bbox.height { + bbox.width / bbox.height.max(0.01) + } else { + bbox.height / bbox.width.max(0.01) + }; + if ratio > FIG_MAX_ASPECT_RATIO { + return false; + } + if primitive_count < 2 && area < FIG_LARGE_SOLO_AREA { + return false; + } + if text_coverage(bbox, text_items) > FIG_MAX_TEXT_COVERAGE { + return false; + } + true +} + +fn close_enough(a: &Rect, b: &Rect, gap: f32) -> bool { + let x_gap = (a.x - (b.x + b.width)).max(b.x - (a.x + a.width)); + let y_gap = (a.y - (b.y + b.height)).max(b.y - (a.y + a.height)); + x_gap <= gap && y_gap <= gap +} + +fn union_rect(a: &Rect, b: &Rect) -> Rect { + let x = a.x.min(b.x); + let y = a.y.min(b.y); + let x2 = (a.x + a.width).max(b.x + b.width); + let y2 = (a.y + a.height).max(b.y + b.height); + Rect { + x, + y, + width: x2 - x, + height: y2 - y, + } +} + +/// Fraction of `bbox` area covered by text. Computed by y-banding the items +/// inside `bbox` into lines (same y within `±0.5 × median_h`) and taking the +/// union bbox of each band — that's a much better proxy for "this region is +/// dominated by text" than summing per-glyph rectangles, which leaves the +/// large inter-word and inter-cell gaps unaccounted for and lets text-dense +/// tables slip below a reasonable coverage threshold. +fn text_coverage(bbox: &Rect, text_items: &[TextItem]) -> f32 { + let bbox_area = bbox.width * bbox.height; + if bbox_area <= 0.0 { + return 0.0; + } + // Collect items that overlap the bbox (any intersection), keep their + // clipped extents. + let mut clipped: Vec<(f32, f32, f32, f32)> = Vec::new(); + let mut heights: Vec = Vec::new(); + for it in text_items { + let ix0 = it.x.max(bbox.x); + let iy0 = it.y.max(bbox.y); + let ix1 = (it.x + it.width).min(bbox.x + bbox.width); + let iy1 = (it.y + it.height).min(bbox.y + bbox.height); + if ix1 <= ix0 || iy1 <= iy0 { + continue; + } + clipped.push((ix0, iy0, ix1, iy1)); + let h = it.height.max(iy1 - iy0).max(0.0); + if h > 0.5 { + heights.push(h); + } + } + if clipped.is_empty() { + return 0.0; + } + let median_h = { + let mut h = heights; + if h.is_empty() { + 8.0 + } else { + h.sort_by(|a, b| a.total_cmp(b)); + h[h.len() / 2].max(4.0) + } + }; + let band = median_h * 0.5; + // Sort by y midpoint, then greedy y-band into "lines": items whose + // y-midpoints are within `band` form one line. + clipped.sort_by(|a, b| { + let am = (a.1 + a.3) * 0.5; + let bm = (b.1 + b.3) * 0.5; + am.total_cmp(&bm) + }); + let mut covered = 0.0f32; + let mut iter = clipped.into_iter(); + let mut cur = iter.next().unwrap(); + for nxt in iter { + let cur_mid = (cur.1 + cur.3) * 0.5; + let nxt_mid = (nxt.1 + nxt.3) * 0.5; + if (nxt_mid - cur_mid).abs() <= band { + cur.0 = cur.0.min(nxt.0); + cur.1 = cur.1.min(nxt.1); + cur.2 = cur.2.max(nxt.2); + cur.3 = cur.3.max(nxt.3); + } else { + covered += (cur.2 - cur.0) * (cur.3 - cur.1); + cur = nxt; + } + } + covered += (cur.2 - cur.0) * (cur.3 - cur.1); + (covered / bbox_area).min(1.0) +} + +fn uf_find(parent: &mut [usize], mut x: usize) -> usize { + while parent[x] != x { + parent[x] = parent[parent[x]]; + x = parent[x]; + } + x +} + +fn uf_union(parent: &mut [usize], a: usize, b: usize) { + let ra = uf_find(parent, a); + let rb = uf_find(parent, b); + if ra != rb { + parent[ra] = rb; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn stroke(x1: f32, y1: f32, x2: f32, y2: f32) -> GraphicPrimitive { + GraphicPrimitive::Stroke { + x1, + y1, + x2, + y2, + color: None, + width: 0.5, + } + } + + fn rect(x: f32, y: f32, w: f32, h: f32) -> GraphicPrimitive { + GraphicPrimitive::Rect { + bbox: Rect { + x, + y, + width: w, + height: h, + }, + fill: None, + stroke: None, + } + } + + #[test] + fn empty_input_returns_no_figures() { + let figs = detect_figure_rects(&[], &[], 612.0, 792.0); + assert!(figs.is_empty()); + } + + #[test] + fn long_thin_hr_is_not_a_figure() { + // A divider stroke 400pt wide × 0.5pt thick. + let g = vec![stroke(100.0, 400.0, 500.0, 400.0)]; + let figs = detect_figure_rects(&g, &[], 612.0, 792.0); + assert!(figs.is_empty(), "HR should not be classified as figure"); + } + + #[test] + fn dense_stroke_cluster_becomes_a_figure() { + // 30 short strokes packed into a 200×150pt area — looks like a chart. + let mut g = Vec::new(); + for i in 0..6 { + for j in 0..5 { + let x = 100.0 + i as f32 * 30.0; + let y = 200.0 + j as f32 * 25.0; + g.push(stroke(x, y, x + 20.0, y + 15.0)); + } + } + let figs = detect_figure_rects(&g, &[], 612.0, 792.0); + assert_eq!(figs.len(), 1, "expected one figure cluster"); + let f = &figs[0]; + assert!(f.width >= 100.0 && f.height >= 100.0); + assert!(f.x >= 90.0 && f.x <= 110.0); + } + + #[test] + fn large_solo_rect_qualifies_as_figure() { + // A single filled rect 200×100pt — area > FIG_LARGE_SOLO_AREA. + let g = vec![rect(100.0, 300.0, 200.0, 100.0)]; + let figs = detect_figure_rects(&g, &[], 612.0, 792.0); + assert_eq!(figs.len(), 1); + } + + #[test] + fn full_page_background_rect_is_skipped() { + let g = vec![rect(0.0, 0.0, 612.0, 792.0)]; + let figs = detect_figure_rects(&g, &[], 612.0, 792.0); + assert!(figs.is_empty(), "full-page rect should not be a figure"); + } + + #[test] + fn text_heavy_rect_is_treated_as_background_not_figure() { + // A 200×100pt rect entirely filled with text — should be rejected. + let g = vec![rect(100.0, 100.0, 200.0, 100.0)]; + let mut text = Vec::new(); + for row in 0..8 { + text.push(TextItem { + text: "lorem ipsum dolor".into(), + x: 105.0, + y: 105.0 + row as f32 * 12.0, + width: 190.0, + height: 10.0, + ..Default::default() + }); + } + let figs = detect_figure_rects(&g, &text, 612.0, 792.0); + assert!( + figs.is_empty(), + "text-covered rect should not be classified as a figure" + ); + } + + #[test] + fn distant_strokes_do_not_merge() { + // Two clusters far apart (>>10pt gap on both axes). + let mut g = Vec::new(); + for i in 0..6 { + for j in 0..5 { + let x = 50.0 + i as f32 * 25.0; + let y = 100.0 + j as f32 * 20.0; + g.push(stroke(x, y, x + 18.0, y + 12.0)); + } + } + for i in 0..6 { + for j in 0..5 { + let x = 400.0 + i as f32 * 25.0; + let y = 500.0 + j as f32 * 20.0; + g.push(stroke(x, y, x + 18.0, y + 12.0)); + } + } + let figs = detect_figure_rects(&g, &[], 612.0, 792.0); + assert_eq!(figs.len(), 2, "expected two separate figure clusters"); + } +} diff --git a/crates/liteparse/src/font_cmap.rs b/crates/liteparse/src/font_cmap.rs new file mode 100644 index 0000000..8fe25dc --- /dev/null +++ b/crates/liteparse/src/font_cmap.rs @@ -0,0 +1,219 @@ +//! Reverse-cmap recovery: parse an embedded sfnt (TrueType/OpenType) font +//! program's `cmap` table and invert it to a glyph-index → unicode map. +//! +//! Used when a font's /ToUnicode is missing or garbage AND its glyph names +//! are unavailable (typical of CID TrueType subsets). The embedded font's own +//! character map is the last structural source of truth tying glyphs back to +//! unicode. Pure Rust, no deps; only formats 4, 12, 6 and 0 are parsed (these +//! cover essentially all real-world fonts). + +use std::collections::HashMap; + +/// Build glyph_index → unicode from an sfnt font program. Returns None when +/// the data is not sfnt (e.g. bare CFF) or has no usable cmap subtable. +pub fn reverse_cmap(data: &[u8]) -> Option> { + let font = sfnt_slice(data)?; + let cmap = find_table(font, b"cmap")?; + + let num_subtables = read_u16(cmap, 2)? as usize; + // Pick the best unicode subtable: full-repertoire (fmt 12) beats BMP (fmt 4). + let mut best: Option<(u32, u32)> = None; // (score, offset) + for i in 0..num_subtables { + let rec = 4 + i * 8; + let platform = read_u16(cmap, rec)?; + let encoding = read_u16(cmap, rec + 2)?; + let offset = read_u32(cmap, rec + 4)?; + // Only true unicode subtables. Mac Roman (1,0) and symbol (3,0) cmaps + // encode charcodes, not unicode — reversing them echoes garbage (e.g. + // Wingdings (1,0) maps the checkmark glyph back to 'ü'). + let score = match (platform, encoding) { + (3, 10) | (0, 4) | (0, 6) => 4, // UCS-4 + (3, 1) | (0, 0..=3) => 3, // BMP + _ => 0, + }; + if score > 0 && best.is_none_or(|(s, _)| score > s) { + best = Some((score, offset)); + } + } + let (_, offset) = best?; + let sub = cmap.get(offset as usize..)?; + + let mut map: HashMap = HashMap::new(); + let mut add = |glyph: u32, unicode: u32| { + if glyph == 0 || unicode == 0 { + return; + } + // On collision prefer non-PUA, then the smaller codepoint (ASCII / + // canonical forms over compatibility duplicates). + match map.entry(glyph) { + std::collections::hash_map::Entry::Vacant(e) => { + e.insert(unicode); + } + std::collections::hash_map::Entry::Occupied(mut e) => { + let cur = *e.get(); + let cur_pua = (0xE000..=0xF8FF).contains(&cur); + let new_pua = (0xE000..=0xF8FF).contains(&unicode); + if (cur_pua && !new_pua) || (cur_pua == new_pua && unicode < cur) { + e.insert(unicode); + } + } + } + }; + + match read_u16(sub, 0)? { + 0 => { + // Byte encoding table: 256 glyph ids at offset 6 + for code in 0..256u32 { + let g = *sub.get(6 + code as usize)? as u32; + add(g, code); + } + } + 4 => { + let seg_count = (read_u16(sub, 6)? / 2) as usize; + let end_codes = 14; + let start_codes = end_codes + seg_count * 2 + 2; + let id_deltas = start_codes + seg_count * 2; + let id_range_offsets = id_deltas + seg_count * 2; + for seg in 0..seg_count { + let end = read_u16(sub, end_codes + seg * 2)? as u32; + let start = read_u16(sub, start_codes + seg * 2)? as u32; + let delta = read_u16(sub, id_deltas + seg * 2)? as u32; + let range_offset = read_u16(sub, id_range_offsets + seg * 2)? as usize; + if start == 0xFFFF && end == 0xFFFF { + continue; + } + for code in start..=end.min(0xFFFE) { + let glyph = if range_offset == 0 { + (code + delta) & 0xFFFF + } else { + // glyphIdArray indexing relative to this rangeOffset slot + let slot = + id_range_offsets + seg * 2 + range_offset + (code - start) as usize * 2; + let g = read_u16(sub, slot)? as u32; + if g == 0 { 0 } else { (g + delta) & 0xFFFF } + }; + add(glyph, code); + } + } + } + 6 => { + let first = read_u16(sub, 6)? as u32; + let count = read_u16(sub, 8)? as usize; + for i in 0..count { + let g = read_u16(sub, 10 + i * 2)? as u32; + add(g, first + i as u32); + } + } + 12 => { + let n_groups = read_u32(sub, 12)? as usize; + for i in 0..n_groups.min(100_000) { + let rec = 16 + i * 12; + let start = read_u32(sub, rec)?; + let end = read_u32(sub, rec + 4)?; + let start_glyph = read_u32(sub, rec + 8)?; + if end < start || end - start > 0x10000 { + continue; + } + for off in 0..=(end - start) { + add(start_glyph + off, start + off); + } + } + } + _ => return None, + } + + if map.is_empty() { None } else { Some(map) } +} + +/// Resolve TTC wrappers and validate the sfnt magic. +fn sfnt_slice(data: &[u8]) -> Option<&[u8]> { + let tag = data.get(..4)?; + if tag == b"ttcf" { + let first = read_u32(data, 12)? as usize; + let sub = data.get(first..)?; + let m = sub.get(..4)?; + return (m == [0, 1, 0, 0] || m == b"OTTO" || m == b"true").then_some(sub); + } + (tag == [0, 1, 0, 0] || tag == b"OTTO" || tag == b"true").then_some(data) +} + +fn find_table<'a>(font: &'a [u8], tag: &[u8; 4]) -> Option<&'a [u8]> { + let num_tables = read_u16(font, 4)? as usize; + for i in 0..num_tables { + let rec = 12 + i * 16; + if font.get(rec..rec + 4)? == tag { + let offset = read_u32(font, rec + 8)? as usize; + let length = read_u32(font, rec + 12)? as usize; + return font.get(offset..offset.checked_add(length)?); + } + } + None +} + +fn read_u16(data: &[u8], offset: usize) -> Option { + let b = data.get(offset..offset + 2)?; + Some(u16::from_be_bytes([b[0], b[1]])) +} + +fn read_u32(data: &[u8], offset: usize) -> Option { + let b = data.get(offset..offset + 4)?; + Some(u32::from_be_bytes([b[0], b[1], b[2], b[3]])) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Build a minimal sfnt with a single format-4 cmap subtable mapping + /// 'A'..='C' (0x41..0x43) to glyphs 10..12. + fn minimal_format4_font() -> Vec { + // format 4 subtable: one real segment + terminator segment + let mut sub: Vec = Vec::new(); + sub.extend([0, 4]); // format + sub.extend([0, 0]); // length (unchecked) + sub.extend([0, 0]); // language + sub.extend([0, 4]); // segCountX2 = 4 (2 segments) + sub.extend([0, 0, 0, 0, 0, 0]); // searchRange/entrySelector/rangeShift + sub.extend([0x00, 0x43, 0xFF, 0xFF]); // endCodes + sub.extend([0, 0]); // reservedPad + sub.extend([0x00, 0x41, 0xFF, 0xFF]); // startCodes + // idDelta: glyph = code + delta mod 65536; 10 - 0x41 = -55 = 0xFFC9 + sub.extend([0xFF, 0xC9, 0x00, 0x01]); // idDeltas + sub.extend([0, 0, 0, 0]); // idRangeOffsets + + let mut cmap: Vec = Vec::new(); + cmap.extend([0, 0]); // version + cmap.extend([0, 1]); // numTables + cmap.extend([0, 3, 0, 1]); // platform 3, encoding 1 + cmap.extend(12u32.to_be_bytes()); // subtable offset + cmap.extend(&sub); + + let mut font: Vec = Vec::new(); + font.extend([0, 1, 0, 0]); // sfnt version + font.extend([0, 1]); // numTables + font.extend([0, 0, 0, 0, 0, 0]); // search fields + font.extend(b"cmap"); + font.extend([0, 0, 0, 0]); // checksum + font.extend(28u32.to_be_bytes()); // offset (12 + 16) + font.extend((cmap.len() as u32).to_be_bytes()); + font.extend(&cmap); + font + } + + #[test] + fn parses_format4_and_inverts() { + let font = minimal_format4_font(); + let map = reverse_cmap(&font).unwrap(); + assert_eq!(map.get(&10), Some(&0x41)); // A + assert_eq!(map.get(&11), Some(&0x42)); // B + assert_eq!(map.get(&12), Some(&0x43)); // C + assert!(!map.contains_key(&0)); + } + + #[test] + fn rejects_non_sfnt() { + assert!(reverse_cmap(b"%!PS-AdobeFont").is_none()); + assert!(reverse_cmap(&[1, 0, 0, 0]).is_none()); // bare CFF header + assert!(reverse_cmap(&[]).is_none()); + } +} diff --git a/crates/liteparse/src/font_db_resolver.rs b/crates/liteparse/src/font_db_resolver.rs new file mode 100644 index 0000000..a5c1184 --- /dev/null +++ b/crates/liteparse/src/font_db_resolver.rs @@ -0,0 +1,191 @@ +//! Built-in [`GlyphResolver`] backed by a fragmented glyph-outline → unicode +//! database / map. +//! +//! The database is a directory of `%02x%02x.msgpack` shards. Each shard is a +//! stream of concatenated 2-element msgpack arrays `[hash, unicode]`: +//! * `hash` — a 16-byte `bin` (the first half of the glyph's 32-byte BLAKE3 +//! path hash; the shard filename is its first two bytes). +//! * `unicode` — a positive integer codepoint. +//! +//! To resolve a glyph we hash its outline segments exactly as the producer did +//! — little-endian `{i32 segment_type, f32 x, f32 y}` per segment, BLAKE3, +//! truncated to 16 bytes — load the matching shard, and look the key up. +//! +//! Not compiled for `wasm32` (no filesystem). Construct directly, or let +//! [`crate::LiteParse::new`] auto-wire one when `LITEPARSE_FONT_DB_DIR` is set. + +use std::collections::HashMap; +use std::io::{BufReader, Read}; +use std::path::PathBuf; +use std::sync::{Arc, RwLock}; + +use crate::glyph_resolver::GlyphResolver; + +/// On-disk key width: first 16 bytes of the glyph's BLAKE3 path hash +/// (`PARSE_FONT_FRAGMENTED_HASH_SIZE` = `BLAKE3_OUT_LEN / 2`). +const KEY_LEN: usize = 16; + +type ShardMap = HashMap<[u8; KEY_LEN], u32>; + +/// Resolves buggy-font glyphs against an on-disk outline → unicode database. +pub struct FontDbResolver { + dir: PathBuf, + /// Shards loaded on demand and memoised by their 2-byte prefix. `None` + /// records a shard that is absent or unreadable so we don't retry it per + /// glyph (mirrors the C path treating a missing fragment as "no match"). + shards: RwLock>>>, + debug: bool, +} + +impl FontDbResolver { + /// Create a resolver reading shards from `dir`. No I/O happens here; shards + /// load lazily on first lookup, so an empty or missing directory simply + /// yields no matches. + pub fn new(dir: impl Into) -> Self { + Self { + dir: dir.into(), + shards: RwLock::new(HashMap::new()), + debug: std::env::var("LITEPARSE_DEBUG_GLYPH").is_ok(), + } + } + + /// First 16 bytes of the BLAKE3 hash of the LE-packed outline segments. + fn glyph_key(segments: &[(i32, f32, f32)]) -> [u8; KEY_LEN] { + let mut hasher = blake3::Hasher::new(); + for &(seg_type, x, y) in segments { + hasher.update(&seg_type.to_le_bytes()); + hasher.update(&x.to_le_bytes()); + hasher.update(&y.to_le_bytes()); + } + let full = hasher.finalize(); + let mut key = [0u8; KEY_LEN]; + key.copy_from_slice(&full.as_bytes()[..KEY_LEN]); + key + } + + /// Return the shard for `prefix`, loading + memoising it on first use. + fn shard(&self, prefix: u16) -> Option> { + if let Some(slot) = self.shards.read().ok()?.get(&prefix) { + return slot.clone(); + } + let loaded = self.load_shard(prefix).map(Arc::new); + if let Ok(mut w) = self.shards.write() { + // Another thread may have inserted while we loaded; keep theirs. + return w.entry(prefix).or_insert(loaded).clone(); + } + loaded + } + + fn load_shard(&self, prefix: u16) -> Option { + let path = self + .dir + .join(format!("{:02x}{:02x}.msgpack", prefix >> 8, prefix & 0xff)); + let file = match std::fs::File::open(&path) { + Ok(f) => f, + Err(_) => { + if self.debug { + eprintln!("[glyph] font-db shard not found: {}", path.display()); + } + return None; + } + }; + let mut rd = BufReader::new(file); + let mut map = ShardMap::new(); + // Each record is a 2-array `[bin(16+), positive int]`. The stream ends + // (or a malformed record appears) and `read_array_len` errors — stop. + loop { + match rmp::decode::read_array_len(&mut rd) { + Ok(2) => {} + _ => break, + } + let bin_len = match rmp::decode::read_bin_len(&mut rd) { + Ok(n) => n as usize, + Err(_) => break, + }; + if bin_len < KEY_LEN { + break; + } + let mut key = [0u8; KEY_LEN]; + if rd.read_exact(&mut key).is_err() { + break; + } + // Consume any bytes beyond the 16-byte key (the C reader also keeps + // only the first 16 of a `>= 16`-byte bin). + if bin_len > KEY_LEN { + let mut skip = vec![0u8; bin_len - KEY_LEN]; + if rd.read_exact(&mut skip).is_err() { + break; + } + } + match rmp::decode::read_int::(&mut rd) { + Ok(unicode) => { + map.insert(key, unicode); + } + Err(_) => break, + } + } + if self.debug { + eprintln!( + "[glyph] font-db shard {:02x}{:02x}: {} entries", + prefix >> 8, + prefix & 0xff, + map.len() + ); + } + Some(map) + } +} + +impl GlyphResolver for FontDbResolver { + fn resolve(&self, segments: &[(i32, f32, f32)]) -> Option { + let key = Self::glyph_key(segments); + let prefix = u16::from(key[0]) << 8 | u16::from(key[1]); + let unicode = *self.shard(prefix)?.get(&key)?; + let c = char::from_u32(unicode).filter(|c| !c.is_control())?; + Some(match crate::glyph_names::presentation_form_expansion(c) { + Some(s) => s.to_string(), + None => c.to_string(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Write `[bin(key), uint(unicode)]` records into the correct shard files + /// under `dir`, matching the on-disk format `load_shard` reads. + fn write_shards(dir: &std::path::Path, records: &[([u8; KEY_LEN], u32)]) { + let mut by_prefix: HashMap> = HashMap::new(); + for (key, unicode) in records { + let prefix = u16::from(key[0]) << 8 | u16::from(key[1]); + let buf = by_prefix.entry(prefix).or_default(); + rmp::encode::write_array_len(buf, 2).unwrap(); + rmp::encode::write_bin(buf, key).unwrap(); + rmp::encode::write_uint(buf, u64::from(*unicode)).unwrap(); + } + for (prefix, buf) in by_prefix { + let path = dir.join(format!("{:02x}{:02x}.msgpack", prefix >> 8, prefix & 0xff)); + std::fs::write(path, buf).unwrap(); + } + } + + #[test] + fn resolves_known_glyph_and_misses_unknown() { + let segs = vec![(2i32, 1.0f32, 2.0f32), (0, 3.5, -1.0), (1, 0.0, 0.0)]; + let key = FontDbResolver::glyph_key(&segs); + let dir = tempfile::tempdir().unwrap(); + write_shards(dir.path(), &[(key, 0x160)]); // U+0160 'Š' + + let resolver = FontDbResolver::new(dir.path()); + assert_eq!(resolver.resolve(&segs).as_deref(), Some("Š")); + // A glyph whose hash isn't in the (loaded) shard → no match. + assert_eq!(resolver.resolve(&[(2i32, 9.0f32, 9.0f32)]), None); + } + + #[test] + fn missing_directory_yields_no_match() { + let resolver = FontDbResolver::new("/nonexistent/liteparse/font/db"); + assert_eq!(resolver.resolve(&[(2i32, 1.0f32, 2.0f32)]), None); + } +} diff --git a/crates/liteparse/src/glyph_names.rs b/crates/liteparse/src/glyph_names.rs new file mode 100644 index 0000000..1374a51 --- /dev/null +++ b/crates/liteparse/src/glyph_names.rs @@ -0,0 +1,493 @@ +//! PostScript glyph-name → Unicode resolution (Adobe Glyph List conventions). +//! +//! Used to recover text when a font has no usable /ToUnicode CMap. Glyph names +//! come from the PDF's /Encoding /Differences array or the embedded font +//! program (via the fork's `FPDFFont_GetCharGlyphName`). +//! +//! Implements the AGL "reverse mapping" algorithm: +//! 1. Strip any variant suffix after the first '.' ("fi.alt" → "fi"). +//! 2. Split on '_' and resolve each component ("f_i" → "fi"). +//! 3. Per component: `uni` + 4n hex digits, `u` + 4-6 hex digits, or a lookup +//! in the static AGL subset table below. + +/// Resolve a glyph name to its Unicode string. Returns None when the name is +/// meaningless (subset-generated names like "g42", "cid1234") or unknown. +pub fn resolve_glyph_name(name: &str) -> Option { + let base = name.split('.').next()?; + if base.is_empty() { + return None; + } + let mut out = String::new(); + for component in base.split('_') { + for c in resolve_component(component)?.chars() { + match presentation_form_expansion(c) { + Some(s) => out.push_str(s), + None => out.push(c), + } + } + } + if out.is_empty() { None } else { Some(out) } +} + +/// ASCII expansion for ligature presentation forms, matching the existing +/// extraction-time ligature handling. +pub fn presentation_form_expansion(c: char) -> Option<&'static str> { + match c { + '\u{FB00}' => Some("ff"), + '\u{FB01}' => Some("fi"), + '\u{FB02}' => Some("fl"), + '\u{FB03}' => Some("ffi"), + '\u{FB04}' => Some("ffl"), + '\u{FB05}' | '\u{FB06}' => Some("st"), + _ => None, + } +} + +fn resolve_component(comp: &str) -> Option { + if comp.is_empty() { + return None; + } + + // uniXXXX (one or more groups of exactly 4 uppercase hex digits) + if let Some(hex) = comp.strip_prefix("uni") + && !hex.is_empty() + && hex.len() % 4 == 0 + && hex.bytes().all(is_agl_hex_digit) + { + let mut s = String::new(); + for group in hex.as_bytes().chunks(4) { + let v = u32::from_str_radix(std::str::from_utf8(group).ok()?, 16).ok()?; + // Surrogate range is invalid scalar values + s.push(char::from_u32(v)?); + } + return Some(s); + } + + // uXXXX / uXXXXX / uXXXXXX + if let Some(hex) = comp.strip_prefix('u') + && (4..=6).contains(&hex.len()) + && hex.bytes().all(is_agl_hex_digit) + { + let v = u32::from_str_radix(hex, 16).ok()?; + return char::from_u32(v).map(String::from); + } + + // Static table lookup + if let Ok(idx) = AGL_SUBSET.binary_search_by(|(n, _)| (*n).cmp(comp)) { + return Some(AGL_SUBSET[idx].1.to_string()); + } + + None +} + +fn is_agl_hex_digit(b: u8) -> bool { + b.is_ascii_digit() || (b'A'..=b'F').contains(&b) +} + +/// Curated subset of the Adobe Glyph List, sorted by name for binary search. +/// Covers ASCII, Latin-1/Latin Extended accents, ligatures, common punctuation +/// and symbols, and Greek — the ranges that show up in real-world Differences +/// arrays and embedded Latin font programs. +static AGL_SUBSET: &[(&str, &str)] = &[ + ("A", "A"), + ("AE", "Æ"), + ("Aacute", "Á"), + ("Abreve", "Ă"), + ("Acircumflex", "Â"), + ("Adieresis", "Ä"), + ("Agrave", "À"), + ("Alpha", "Α"), + ("Amacron", "Ā"), + ("Aogonek", "Ą"), + ("Aring", "Å"), + ("Atilde", "Ã"), + ("B", "B"), + ("Beta", "Β"), + ("C", "C"), + ("Cacute", "Ć"), + ("Ccaron", "Č"), + ("Ccedilla", "Ç"), + ("Chi", "Χ"), + ("D", "D"), + ("Dcaron", "Ď"), + ("Dcroat", "Đ"), + ("Delta", "Δ"), + ("E", "E"), + ("Eacute", "É"), + ("Ecaron", "Ě"), + ("Ecircumflex", "Ê"), + ("Edieresis", "Ë"), + ("Egrave", "È"), + ("Emacron", "Ē"), + ("Eogonek", "Ę"), + ("Epsilon", "Ε"), + ("Eta", "Η"), + ("Eth", "Ð"), + ("Euro", "€"), + ("F", "F"), + ("G", "G"), + ("Gamma", "Γ"), + ("Gbreve", "Ğ"), + ("H", "H"), + ("I", "I"), + ("Iacute", "Í"), + ("Icircumflex", "Î"), + ("Idieresis", "Ï"), + ("Idotaccent", "İ"), + ("Igrave", "Ì"), + ("Imacron", "Ī"), + ("Iota", "Ι"), + ("J", "J"), + ("K", "K"), + ("Kappa", "Κ"), + ("L", "L"), + ("Lacute", "Ĺ"), + ("Lambda", "Λ"), + ("Lcaron", "Ľ"), + ("Lslash", "Ł"), + ("M", "M"), + ("Mu", "Μ"), + ("N", "N"), + ("Nacute", "Ń"), + ("Ncaron", "Ň"), + ("Ntilde", "Ñ"), + ("Nu", "Ν"), + ("O", "O"), + ("OE", "Œ"), + ("Oacute", "Ó"), + ("Ocircumflex", "Ô"), + ("Odieresis", "Ö"), + ("Ograve", "Ò"), + ("Ohungarumlaut", "Ő"), + ("Omacron", "Ō"), + ("Omega", "Ω"), + ("Omicron", "Ο"), + ("Oslash", "Ø"), + ("Otilde", "Õ"), + ("P", "P"), + ("Phi", "Φ"), + ("Pi", "Π"), + ("Psi", "Ψ"), + ("Q", "Q"), + ("R", "R"), + ("Racute", "Ŕ"), + ("Rcaron", "Ř"), + ("Rho", "Ρ"), + ("S", "S"), + ("Sacute", "Ś"), + ("Scaron", "Š"), + ("Scedilla", "Ş"), + ("Sigma", "Σ"), + ("T", "T"), + ("Tau", "Τ"), + ("Tbar", "Ŧ"), + ("Tcaron", "Ť"), + ("Theta", "Θ"), + ("Thorn", "Þ"), + ("U", "U"), + ("Uacute", "Ú"), + ("Ucircumflex", "Û"), + ("Udieresis", "Ü"), + ("Ugrave", "Ù"), + ("Uhungarumlaut", "Ű"), + ("Umacron", "Ū"), + ("Uogonek", "Ų"), + ("Upsilon", "Υ"), + ("Uring", "Ů"), + ("V", "V"), + ("W", "W"), + ("X", "X"), + ("Xi", "Ξ"), + ("Y", "Y"), + ("Yacute", "Ý"), + ("Ydieresis", "Ÿ"), + ("Z", "Z"), + ("Zacute", "Ź"), + ("Zcaron", "Ž"), + ("Zdotaccent", "Ż"), + ("Zeta", "Ζ"), + ("a", "a"), + ("aacute", "á"), + ("abreve", "ă"), + ("acircumflex", "â"), + ("acute", "´"), + ("adieresis", "ä"), + ("ae", "æ"), + ("agrave", "à"), + ("alpha", "α"), + ("amacron", "ā"), + ("ampersand", "&"), + ("aogonek", "ą"), + ("approxequal", "≈"), + ("aring", "å"), + ("asciicircum", "^"), + ("asciitilde", "~"), + ("asterisk", "*"), + ("at", "@"), + ("atilde", "ã"), + ("b", "b"), + ("backslash", "\\"), + ("bar", "|"), + ("beta", "β"), + ("braceleft", "{"), + ("braceright", "}"), + ("bracketleft", "["), + ("bracketright", "]"), + ("breve", "˘"), + ("brokenbar", "¦"), + ("bullet", "•"), + ("c", "c"), + ("cacute", "ć"), + ("caron", "ˇ"), + ("ccaron", "č"), + ("ccedilla", "ç"), + ("cedilla", "¸"), + ("cent", "¢"), + ("chi", "χ"), + ("circumflex", "ˆ"), + ("colon", ":"), + ("comma", ","), + ("copyright", "©"), + ("currency", "¤"), + ("d", "d"), + ("dagger", "†"), + ("daggerdbl", "‡"), + ("dcaron", "ď"), + ("dcroat", "đ"), + ("degree", "°"), + ("delta", "δ"), + ("dieresis", "¨"), + ("divide", "÷"), + ("dollar", "$"), + ("dotaccent", "˙"), + ("dotlessi", "ı"), + ("e", "e"), + ("eacute", "é"), + ("ecaron", "ě"), + ("ecircumflex", "ê"), + ("edieresis", "ë"), + ("egrave", "è"), + ("eight", "8"), + ("ellipsis", "…"), + ("emacron", "ē"), + ("emdash", "—"), + ("endash", "–"), + ("eogonek", "ę"), + ("epsilon", "ε"), + ("equal", "="), + ("eta", "η"), + ("eth", "ð"), + ("exclam", "!"), + ("exclamdown", "¡"), + ("f", "f"), + ("ff", "ff"), + ("ffi", "ffi"), + ("ffl", "ffl"), + ("fi", "fi"), + ("five", "5"), + ("fl", "fl"), + ("florin", "ƒ"), + ("four", "4"), + ("fraction", "⁄"), + ("g", "g"), + ("gamma", "γ"), + ("gbreve", "ğ"), + ("germandbls", "ß"), + ("grave", "`"), + ("greater", ">"), + ("greaterequal", "≥"), + ("guillemotleft", "«"), + ("guillemotright", "»"), + ("guilsinglleft", "‹"), + ("guilsinglright", "›"), + ("h", "h"), + ("hungarumlaut", "˝"), + ("hyphen", "-"), + ("i", "i"), + ("iacute", "í"), + ("icircumflex", "î"), + ("idieresis", "ï"), + ("igrave", "ì"), + ("imacron", "ī"), + ("infinity", "∞"), + ("iota", "ι"), + ("j", "j"), + ("k", "k"), + ("kappa", "κ"), + ("l", "l"), + ("lacute", "ĺ"), + ("lambda", "λ"), + ("lcaron", "ľ"), + ("less", "<"), + ("lessequal", "≤"), + ("logicalnot", "¬"), + ("lslash", "ł"), + ("m", "m"), + ("macron", "¯"), + ("minus", "−"), + ("mu", "μ"), + ("multiply", "×"), + ("n", "n"), + ("nacute", "ń"), + ("ncaron", "ň"), + ("nine", "9"), + ("notequal", "≠"), + ("ntilde", "ñ"), + ("nu", "ν"), + ("numbersign", "#"), + ("o", "o"), + ("oacute", "ó"), + ("ocircumflex", "ô"), + ("odieresis", "ö"), + ("oe", "œ"), + ("ogonek", "˛"), + ("ograve", "ò"), + ("ohungarumlaut", "ő"), + ("omacron", "ō"), + ("omega", "ω"), + ("omicron", "ο"), + ("one", "1"), + ("onehalf", "½"), + ("onequarter", "¼"), + ("onesuperior", "¹"), + ("ordfeminine", "ª"), + ("ordmasculine", "º"), + ("oslash", "ø"), + ("otilde", "õ"), + ("p", "p"), + ("paragraph", "¶"), + ("parenleft", "("), + ("parenright", ")"), + ("partialdiff", "∂"), + ("percent", "%"), + ("period", "."), + ("periodcentered", "·"), + ("perthousand", "‰"), + ("phi", "φ"), + ("pi", "π"), + ("plus", "+"), + ("plusminus", "±"), + ("psi", "ψ"), + ("q", "q"), + ("question", "?"), + ("questiondown", "¿"), + ("quotedbl", "\""), + ("quotedblbase", "„"), + ("quotedblleft", "“"), + ("quotedblright", "”"), + ("quoteleft", "‘"), + ("quoteright", "’"), + ("quotesinglbase", "‚"), + ("quotesingle", "'"), + ("r", "r"), + ("racute", "ŕ"), + ("radical", "√"), + ("rcaron", "ř"), + ("registered", "®"), + ("rho", "ρ"), + ("ring", "˚"), + ("s", "s"), + ("sacute", "ś"), + ("scaron", "š"), + ("scedilla", "ş"), + ("section", "§"), + ("semicolon", ";"), + ("seven", "7"), + ("sigma", "σ"), + ("sigma1", "ς"), + ("six", "6"), + ("slash", "/"), + ("space", " "), + ("sterling", "£"), + ("summation", "∑"), + ("t", "t"), + ("tau", "τ"), + ("tbar", "ŧ"), + ("tcaron", "ť"), + ("theta", "θ"), + ("thorn", "þ"), + ("three", "3"), + ("threequarters", "¾"), + ("threesuperior", "³"), + ("tilde", "˜"), + ("trademark", "™"), + ("two", "2"), + ("twosuperior", "²"), + ("u", "u"), + ("uacute", "ú"), + ("ucircumflex", "û"), + ("udieresis", "ü"), + ("ugrave", "ù"), + ("uhungarumlaut", "ű"), + ("umacron", "ū"), + ("underscore", "_"), + ("uogonek", "ų"), + ("upsilon", "υ"), + ("uring", "ů"), + ("v", "v"), + ("w", "w"), + ("x", "x"), + ("xi", "ξ"), + ("y", "y"), + ("yacute", "ý"), + ("ydieresis", "ÿ"), + ("yen", "¥"), + ("z", "z"), + ("zacute", "ź"), + ("zcaron", "ž"), + ("zdotaccent", "ż"), + ("zero", "0"), + ("zeta", "ζ"), +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn agl_subset_is_sorted_and_unique() { + for w in AGL_SUBSET.windows(2) { + assert!(w[0].0 < w[1].0, "{} >= {}", w[0].0, w[1].0); + } + } + + #[test] + fn resolves_plain_names() { + assert_eq!(resolve_glyph_name("germandbls").as_deref(), Some("ß")); + assert_eq!(resolve_glyph_name("adieresis").as_deref(), Some("ä")); + assert_eq!(resolve_glyph_name("fl").as_deref(), Some("fl")); + assert_eq!(resolve_glyph_name("ffi").as_deref(), Some("ffi")); + assert_eq!(resolve_glyph_name("A").as_deref(), Some("A")); + assert_eq!(resolve_glyph_name("space").as_deref(), Some(" ")); + } + + #[test] + fn resolves_uni_and_u_forms() { + assert_eq!(resolve_glyph_name("uni0041").as_deref(), Some("A")); + assert_eq!(resolve_glyph_name("uniFB01").as_deref(), Some("fi")); + assert_eq!(resolve_glyph_name("uni00410042").as_deref(), Some("AB")); + assert_eq!(resolve_glyph_name("u1F600").as_deref(), Some("😀")); + assert_eq!(resolve_glyph_name("u0041").as_deref(), Some("A")); + // lowercase hex is not AGL-valid + assert_eq!(resolve_glyph_name("uni00e9"), None); + // surrogate halves are invalid + assert_eq!(resolve_glyph_name("uniD800"), None); + } + + #[test] + fn resolves_compounds_and_suffixes() { + assert_eq!(resolve_glyph_name("f_i").as_deref(), Some("fi")); + assert_eq!(resolve_glyph_name("f_f_l").as_deref(), Some("ffl")); + assert_eq!(resolve_glyph_name("fi.alt").as_deref(), Some("fi")); + assert_eq!(resolve_glyph_name("uni0041.sc").as_deref(), Some("A")); + } + + #[test] + fn rejects_meaningless_names() { + assert_eq!(resolve_glyph_name("g42"), None); + assert_eq!(resolve_glyph_name("cid1234"), None); + assert_eq!(resolve_glyph_name("glyph7"), None); + assert_eq!(resolve_glyph_name(""), None); + assert_eq!(resolve_glyph_name(".notdef"), None); + } +} diff --git a/crates/liteparse/src/glyph_resolver.rs b/crates/liteparse/src/glyph_resolver.rs new file mode 100644 index 0000000..fe270e7 --- /dev/null +++ b/crates/liteparse/src/glyph_resolver.rs @@ -0,0 +1,40 @@ +//! Optional out-of-tree glyph recovery hook. +//! +//! liteparse's built-in recovery for fonts with missing/garbage `/ToUnicode` +//! (PostScript glyph-name → Adobe Glyph List, then the embedded font program's +//! reverse cmap; see [`crate::extract`]) is deterministic but cannot decode +//! buggy/obfuscated fonts whose glyph names and cmap are also junk. A +//! [`GlyphResolver`] lets a caller plug in a richer recovery strategy — e.g. a +//! glyph-outline → unicode database — without liteparse taking on that +//! dependency. +//! +//! The resolver is supplied the glyph's *vector outline* (path segments), not a +//! pdfium handle, so the implementation needs no pdfium of its own and stays +//! decoupled from liteparse's font internals. It is consulted only as a last +//! resort, after the built-in recovery has failed on a glyph liteparse already +//! considers untrusted. + +/// Font size, in points, at which glyph path segments are sampled before being +/// handed to a [`GlyphResolver`]. +/// +/// Resolvers that key on a hash of the outline must sample at this exact size +/// for their keys to line up. +pub const GLYPH_RESOLVER_FONT_SIZE: f32 = 10.0; + +/// Recovers the unicode text for a glyph liteparse's built-in cmap/AGL recovery +/// could not decode, from the glyph's vector outline. +/// +/// Implementations live out-of-tree (the published `@llamaindex/liteparse` +/// ships no resolver). Inject one with [`crate::LiteParse::with_glyph_resolver`]. +pub trait GlyphResolver: Send + Sync { + /// Resolve a glyph from its outline. + /// + /// `segments` is one `(segment_type, x, y)` triple per path segment, as + /// produced by `pdfium::Font::glyph_path_segments` at + /// [`GLYPH_RESOLVER_FONT_SIZE`] — `segment_type` is the raw pdfium + /// `FPDF_SEGMENT_*` value (LINETO=0, BEZIERTO=1, MOVETO=2). + /// + /// Return the replacement text (one or more chars, e.g. for a ligature), + /// or `None` if the glyph is not recognized. + fn resolve(&self, segments: &[(i32, f32, f32)]) -> Option; +} diff --git a/crates/liteparse/src/lib.rs b/crates/liteparse/src/lib.rs new file mode 100644 index 0000000..688b23b --- /dev/null +++ b/crates/liteparse/src/lib.rs @@ -0,0 +1,52 @@ +//! LiteParse — open-source PDF parsing with spatial text extraction, OCR, and bounding boxes. +//! +//! This crate is the core Rust library. Language bindings for Node.js, Python, +//! and WebAssembly re-export the same types with language-idiomatic wrappers. +//! + +// ── Public API re-exports ────────────────────────────────────────────── +pub use config::{LiteParseConfig, OutputFormat}; +pub use error::LiteParseError; +#[cfg(not(target_arch = "wasm32"))] +pub use font_db_resolver::FontDbResolver; +pub use glyph_resolver::{GLYPH_RESOLVER_FONT_SIZE, GlyphResolver}; +pub use parser::{LiteParse, ParseResult, ScreenshotResult}; +pub use search::{SearchOptions, search_items}; +pub use types::{ParsedPage, TextItem, WordBox}; + +// ── Modules with user-facing types (visible in docs) ─────────────────── +pub mod config; +pub mod error; +pub mod glyph_resolver; +pub mod parser; +pub mod search; +pub mod types; + +// ── Internal modules (available for binding crates, hidden from docs) ── +#[cfg(not(target_arch = "wasm32"))] +#[doc(hidden)] +pub mod conversion; +#[doc(hidden)] +pub mod extract; +#[doc(hidden)] +pub mod figure_cluster; +#[doc(hidden)] +pub mod font_cmap; +#[cfg(not(target_arch = "wasm32"))] +#[doc(hidden)] +pub mod font_db_resolver; +#[doc(hidden)] +pub mod glyph_names; +#[doc(hidden)] +pub mod markdown_layout; +#[doc(hidden)] +pub mod ocr; +#[doc(hidden)] +pub mod ocr_merge; +#[doc(hidden)] +pub mod output; +#[doc(hidden)] +pub mod projection; +#[cfg(not(target_arch = "wasm32"))] +#[doc(hidden)] +pub mod render; diff --git a/crates/liteparse/src/main.rs b/crates/liteparse/src/main.rs new file mode 100644 index 0000000..7308067 --- /dev/null +++ b/crates/liteparse/src/main.rs @@ -0,0 +1,634 @@ +use clap::{Args, Parser, Subcommand}; +use liteparse::config::{LiteParseConfig, OutputFormat}; +use liteparse::conversion; +use liteparse::extract; +use liteparse::output::{json, text}; +use liteparse::parser::LiteParse; +use liteparse::render; +use liteparse::types::PdfInput; + +#[derive(Parser, Debug)] +#[command( + name = "lit", + version, + about = "OSS document parsing tool (supports PDF, DOCX, XLSX, images, and more)" +)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand, Debug)] +enum Commands { + /// Parse a document file (PDF, DOCX, XLSX, PPTX, images, etc.) + Parse(ParseCommand), + /// Generate screenshots of document pages (PDF, DOCX, XLSX, images, etc.) + Screenshot(ScreenshotCommand), + /// Parse multiple documents in batch mode + BatchParse(BatchParseCommand), + /// Check if a document is "complex" enough to require OCR or other advanced parsing + IsComplex(IsComplexCommand), + /// Extract raw text items from a PDF file (no grid projection) [dev tool] + #[command(hide = true)] + Extract(ExtractCommand), + /// Extract embedded image bounding boxes from a page [dev tool] + #[command(hide = true)] + ImageBounds(ExtractCommand), +} + +#[derive(Args, Debug)] +struct ParseCommand { + /// Input file path + file: String, + + /// Output file path + #[arg(short, long)] + output: Option, + + /// Output format: json, text, or markdown + #[arg(long, default_value = "text")] + format: String, + + /// Disable OCR + #[arg(long)] + no_ocr: bool, + + /// OCR language (Tesseract format, e.g. "eng", "fra", "deu") + #[arg(long, default_value = "eng")] + ocr_language: String, + + /// HTTP OCR server URL (uses Tesseract if not provided) + #[arg(long, default_value = None)] + ocr_server_url: Option, + + /// Extra header for OCR server requests, "Name: Value" (repeatable). + /// e.g. --ocr-server-header "Authorization: Bearer " + #[arg(long = "ocr-server-header", value_parser = parse_header)] + ocr_server_headers: Vec<(String, String)>, + + /// Path to tessdata directory (overrides TESSDATA_PREFIX env var) + #[arg(long)] + tessdata_path: Option, + + /// Max pages to parse + #[arg(long, default_value = "1000")] + max_pages: usize, + + /// Target pages (e.g., "1-5,10,15-20") + #[arg(long)] + target_pages: Option, + + /// DPI for rendering (default: 150) + #[arg(long, default_value = "150")] + dpi: f32, + + /// Preserve very small text + #[arg(long)] + preserve_small_text: bool, + + /// Password for encrypted/protected documents + #[arg(long)] + password: Option, + + /// Suppress progress output + #[arg(short, long)] + quiet: bool, + + /// Number of concurrent OCR workers (default: CPU cores - 1) + #[arg(long)] + num_workers: Option, + + /// How to surface raster images in markdown output: + /// `off` strips them, `placeholder` (default) emits `![](image_pN_K.png)` + /// references in reading order, `embed` extracts each image's PNG bytes + /// and writes them next to the markdown output when `--image-output-dir` + /// is set. + #[arg(long, default_value = "placeholder")] + image_mode: String, + + /// Directory to write embedded images to when `--image-mode embed` is + /// set. Each image is written as `image_{id}.png` to match the + /// references in the markdown output. Has no effect for other image + /// modes. Created if missing. + #[arg(long)] + image_output_dir: Option, + + /// Disable hyperlink extraction. By default, URI link annotations are + /// rendered as `[text](url)` in markdown output. Pass this to emit the + /// anchor text as plain text instead (e.g. for plain-text benchmark + /// parity, where ground truth uses no link syntax). + #[arg(long)] + no_links: bool, +} + +#[derive(Args, Debug)] +struct ScreenshotCommand { + /// Input document path (PDF, DOCX, XLSX, images, etc.) + file: String, + + /// Output directory for screenshots + #[arg(short, long, default_value = "./screenshots")] + output_dir: String, + + /// Target pages (e.g., "1,3,5" or "1-5"). Defaults to all pages. + #[arg(long)] + target_pages: Option, + + /// DPI for rendering + #[arg(long, default_value = "150")] + dpi: f32, + + /// Password for encrypted/protected documents + #[arg(long)] + password: Option, + + /// Suppress progress output + #[arg(short, long)] + quiet: bool, +} + +#[derive(Args, Debug)] +struct BatchParseCommand { + /// Input directory + input_dir: String, + + /// Output directory + output_dir: String, + + /// Output format: json, text, or markdown + #[arg(long, default_value = "text")] + format: String, + + /// Disable OCR + #[arg(long)] + no_ocr: bool, + + /// OCR language (Tesseract format, e.g. "eng", "fra", "deu") + #[arg(long, default_value = "eng")] + ocr_language: String, + + /// HTTP OCR server URL (uses Tesseract if not provided) + #[arg(long, default_value = None)] + ocr_server_url: Option, + + /// Extra header for OCR server requests, "Name: Value" (repeatable). + /// e.g. --ocr-server-header "Authorization: Bearer " + #[arg(long = "ocr-server-header", value_parser = parse_header)] + ocr_server_headers: Vec<(String, String)>, + + /// Path to tessdata directory (overrides TESSDATA_PREFIX env var) + #[arg(long)] + tessdata_path: Option, + + /// Max pages to parse per file + #[arg(long, default_value = "1000")] + max_pages: usize, + + /// DPI for rendering + #[arg(long, default_value = "150")] + dpi: f32, + + /// Recursively search input directory + #[arg(long)] + recursive: bool, + + /// Only process files with this extension (e.g., ".pdf") + #[arg(long)] + extension: Option, + + /// Password for encrypted/protected documents + #[arg(long)] + password: Option, + + /// Suppress progress output + #[arg(short, long)] + quiet: bool, + + /// Number of concurrent OCR workers (default: CPU cores - 1) + #[arg(long)] + num_workers: Option, +} + +#[derive(Args, Debug)] +struct ExtractCommand { + /// Input PDF file path + #[arg(long)] + pdf_path: String, + + /// Target page number (1-based) + #[arg(long)] + page_num: Option, +} + +#[derive(Args, Debug)] +struct IsComplexCommand { + /// Input file path + file: String, + + /// Emit dense, whitespace-free JSON instead of pretty-printed (still valid + /// for `jq` and friends). + #[arg(long)] + compact: bool, + + /// Max pages to parse + #[arg(long, default_value = "1000")] + max_pages: usize, + + /// Target pages (e.g., "1-5,10,15-20") + #[arg(long)] + target_pages: Option, + + /// Password for encrypted/protected documents + #[arg(long)] + password: Option, + + /// Suppress progress output + #[arg(short, long)] + quiet: bool, +} + +fn parse_output_format(s: &str) -> Result { + match s.to_lowercase().as_str() { + "json" => Ok(OutputFormat::Json), + "text" => Ok(OutputFormat::Text), + "markdown" | "md" => Ok(OutputFormat::Markdown), + _ => Err(format!( + "unknown format '{}', expected 'json', 'text', or 'markdown'", + s + )), + } +} + +/// Parse a `Name: Value` header string into a `(name, value)` pair. +fn parse_header(s: &str) -> Result<(String, String), String> { + let (name, value) = s + .split_once(':') + .ok_or_else(|| format!("invalid header '{}', expected 'Name: Value'", s))?; + let name = name.trim(); + if name.is_empty() { + return Err(format!("invalid header '{}', empty header name", s)); + } + Ok((name.to_string(), value.trim().to_string())) +} + +fn parse_image_mode(s: &str) -> Result { + use liteparse::config::ImageMode; + match s.to_lowercase().as_str() { + "off" | "none" => Ok(ImageMode::Off), + "placeholder" => Ok(ImageMode::Placeholder), + "embed" => Ok(ImageMode::Embed), + _ => Err(format!( + "unknown image-mode '{}', expected 'off', 'placeholder', or 'embed'", + s + )), + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let cli = Cli::parse(); + + match cli.command { + Commands::Parse(cmd) => { + let format = parse_output_format(&cmd.format)?; + let image_mode = parse_image_mode(&cmd.image_mode)?; + + let mut config = LiteParseConfig { + ocr_language: cmd.ocr_language, + ocr_enabled: !cmd.no_ocr, + tessdata_path: cmd.tessdata_path, + max_pages: cmd.max_pages, + target_pages: cmd.target_pages, + dpi: cmd.dpi, + output_format: format, + preserve_very_small_text: cmd.preserve_small_text, + password: cmd.password, + quiet: cmd.quiet, + ocr_server_url: cmd.ocr_server_url, + ocr_server_headers: cmd.ocr_server_headers, + image_mode, + extract_links: !cmd.no_links, + ..Default::default() + }; + if let Some(n) = cmd.num_workers { + config.num_workers = n; + } + + let lp = LiteParse::new(config); + let result = lp.parse(&cmd.file).await?; + let formatted = match lp.config().output_format { + OutputFormat::Json => json::format_json(&result.pages)?, + OutputFormat::Text => text::format_text(&result.pages), + OutputFormat::Markdown => result.text.clone(), + }; + if let Some(dir) = cmd.image_output_dir.as_deref() + && !result.images.is_empty() + { + std::fs::create_dir_all(dir)?; + for img in &result.images { + let path = format!("{}/image_{}.{}", dir, img.id, img.format); + std::fs::write(&path, &img.bytes)?; + } + if !cmd.quiet { + eprintln!( + "[liteparse] wrote {} image(s) to {}", + result.images.len(), + dir + ); + } + } + + match cmd.output { + Some(path) => { + std::fs::write(&path, &formatted)?; + if !cmd.quiet { + eprintln!("[liteparse] wrote output to {}", path); + } + } + None => { + println!("{}", formatted); + } + } + } + + Commands::Screenshot(cmd) => { + let target_pages = cmd + .target_pages + .as_ref() + .map(|s| liteparse::config::parse_target_pages(s)) + .transpose() + .map_err(|e| format!("invalid --target-pages: {}", e))?; + + std::fs::create_dir_all(&cmd.output_dir)?; + + let config = LiteParseConfig { + target_pages: cmd.target_pages.clone(), + dpi: cmd.dpi, + password: cmd.password.clone(), + quiet: cmd.quiet, + ..Default::default() + }; + let lp = LiteParse::new(config); + let results = lp.screenshot(&cmd.file, target_pages).await?; + + for result in results { + let output_path = format!("{}/page_{}.png", cmd.output_dir, result.page_num); + std::fs::write(&output_path, &result.image_bytes)?; + + if !cmd.quiet { + eprintln!( + "[liteparse] screenshot page {} → {}", + result.page_num, output_path + ); + } + } + } + + Commands::BatchParse(cmd) => { + let format = parse_output_format(&cmd.format)?; + let ext_filter = cmd.extension.as_ref().map(|e| { + let e = e.to_lowercase(); + if e.starts_with('.') { + e + } else { + format!(".{}", e) + } + }); + + let mut config = LiteParseConfig { + ocr_language: cmd.ocr_language, + ocr_enabled: !cmd.no_ocr, + tessdata_path: cmd.tessdata_path, + max_pages: cmd.max_pages, + target_pages: None, + dpi: cmd.dpi, + output_format: format.clone(), + preserve_very_small_text: false, + password: cmd.password, + quiet: cmd.quiet, + ocr_server_url: cmd.ocr_server_url, + ocr_server_headers: cmd.ocr_server_headers, + ..Default::default() + }; + if let Some(n) = cmd.num_workers { + config.num_workers = n; + } + + let lp = LiteParse::new(config); + let out_ext = match format { + OutputFormat::Json => "json", + OutputFormat::Markdown => "md", + OutputFormat::Text => "txt", + }; + + std::fs::create_dir_all(&cmd.output_dir)?; + + let files = collect_files(&cmd.input_dir, cmd.recursive, ext_filter.as_deref())?; + + if files.is_empty() { + eprintln!("[liteparse] no matching files found in {}", cmd.input_dir); + return Ok(()); + } + + if !cmd.quiet { + eprintln!("[liteparse] found {} files to process", files.len()); + } + + let mut success = 0usize; + let mut errors = 0usize; + + for file_path in &files { + let t0 = web_time::Instant::now(); + + let out_path = + batch_output_path(file_path, &cmd.input_dir, &cmd.output_dir, out_ext); + + if let Some(parent) = out_path.parent() { + std::fs::create_dir_all(parent)?; + } + + match lp.parse(file_path).await { + Ok(result) => { + let fmt_result: Result> = + match lp.config().output_format { + OutputFormat::Json => { + json::format_json(&result.pages).map_err(|e| e.into()) + } + OutputFormat::Text => Ok(text::format_text(&result.pages)), + OutputFormat::Markdown => Ok(result.text.clone()), + }; + match fmt_result { + Ok(formatted) => { + std::fs::write(&out_path, &formatted)?; + success += 1; + if !cmd.quiet { + let elapsed = t0.elapsed().as_secs_f64() * 1000.0; + eprintln!( + "[liteparse] {} → {} ({:.1}ms)", + file_path, + out_path.display(), + elapsed + ); + } + } + Err(e) => { + eprintln!("[liteparse] error formatting {}: {}", file_path, e); + errors += 1; + } + } + } + Err(e) => { + eprintln!("[liteparse] error parsing {}: {}", file_path, e); + errors += 1; + } + } + } + + eprintln!( + "[liteparse] batch complete: {} succeeded, {} failed", + success, errors + ); + + if errors > 0 { + std::process::exit(1); + } + } + + Commands::Extract(cmd) => { + extract::extract(&cmd.pdf_path, cmd.page_num)?; + } + + Commands::ImageBounds(cmd) => { + render::image_bounds(&cmd.pdf_path, cmd.page_num)?; + } + + Commands::IsComplex(cmd) => { + let config = LiteParseConfig { + max_pages: cmd.max_pages, + target_pages: cmd.target_pages, + password: cmd.password, + quiet: cmd.quiet, + ..Default::default() + }; + let lp = LiteParse::new(config); + let is_complex = lp.is_complex(PdfInput::Path(cmd.file)).await?; + + let complex_pages = is_complex.iter().filter(|c| c.needs_ocr).count(); + + // Always emit JSON on stdout so the command composes with `jq` and + // friends without a flag. Pretty by default for human readability; + // `--compact` drops the whitespace. Both parse identically. + let json = if cmd.compact { + serde_json::to_string(&is_complex)? + } else { + serde_json::to_string_pretty(&is_complex)? + }; + println!("{}", json); + + // The human-readable verdict goes to stderr so it never pollutes the + // JSON on stdout. The exit code below carries the same signal for + // scripts that don't want to read either stream. + if !cmd.quiet { + let verdict = if complex_pages > 0 { + "COMPLEX" + } else { + "SIMPLE" + }; + eprintln!( + "{} — {}/{} page(s) need OCR", + verdict, + complex_pages, + is_complex.len() + ); + } + + // Exit non-zero when any page needs OCR, so the command is usable as + // a shell predicate: exit 0 (simple) → `&& parse --no-ocr` is safe. + if complex_pages > 0 { + std::process::exit(1); + } + } + } + + Ok(()) +} + +/// Collect files from a directory, optionally recursively, with an optional extension filter. +fn collect_files( + dir: &str, + recursive: bool, + ext_filter: Option<&str>, +) -> Result, Box> { + let mut files = Vec::new(); + collect_files_inner(std::path::Path::new(dir), recursive, ext_filter, &mut files)?; + files.sort(); + Ok(files) +} + +fn batch_output_path( + file_path: &str, + input_dir: &str, + output_dir: &str, + out_ext: &str, +) -> std::path::PathBuf { + let file_path = std::path::Path::new(file_path); + let rel = file_path + .strip_prefix(std::path::Path::new(input_dir)) + .unwrap_or(file_path); + + std::path::Path::new(output_dir) + .join(rel) + .with_extension(out_ext) +} + +#[cfg(test)] +mod tests { + use super::batch_output_path; + use std::path::Path; + + #[test] + fn batch_output_path_preserves_output_dir_without_trailing_slash() { + let out_path = batch_output_path("docs/report.pdf", "docs", "out", "txt"); + + assert_eq!(out_path, Path::new("out/report.txt")); + } + + #[test] + fn batch_output_path_mirrors_nested_files_without_trailing_slash() { + let out_path = batch_output_path("docs/nested/report.pdf", "docs", "out", "md"); + + assert_eq!(out_path, Path::new("out/nested/report.md")); + } +} + +fn collect_files_inner( + dir: &std::path::Path, + recursive: bool, + ext_filter: Option<&str>, + files: &mut Vec, +) -> Result<(), Box> { + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + + if path.is_dir() { + if recursive { + collect_files_inner(&path, recursive, ext_filter, files)?; + } + continue; + } + + let path_str = path.to_string_lossy().to_string(); + + if let Some(filter) = ext_filter { + if !path_str.to_lowercase().ends_with(filter) { + continue; + } + } else if !conversion::is_supported_extension(&path_str) { + continue; + } + + files.push(path_str); + } + Ok(()) +} diff --git a/crates/liteparse/src/markdown_layout/blocks.rs b/crates/liteparse/src/markdown_layout/blocks.rs new file mode 100644 index 0000000..b5d25bb --- /dev/null +++ b/crates/liteparse/src/markdown_layout/blocks.rs @@ -0,0 +1,388 @@ +use super::inline::escape_inline; +use super::paragraphs::{ParaAccum, is_soft_hyphen_break}; +use super::tables::escape_table_cell; + +/// Coarse block representation: the output of page classification, consumed by +/// `render_blocks` to produce the final markdown string. +#[derive(Debug, Clone)] +pub enum Block { + Heading { + level: u8, + text: String, + }, + Paragraph { + text: String, + bold: bool, + italic: bool, + }, + ListItem { + ordered: bool, + marker: String, + level: u8, + text: String, + bold: bool, + italic: bool, + }, + /// Fenced code block — content rendered between triple-backtick fences. + /// Each entry in `lines` is one source line; preserved as-is (only trailing + /// whitespace stripped) so indentation survives. `lang` is a best-effort + /// language hint emitted as the fence info-string (e.g. ```` ```python ````) + /// when the body matches a known language; `None` emits a bare fence. + CodeBlock { + lines: Vec, + lang: Option, + }, + /// Confident table emitted as a markdown pipe table. `header` is `None` + /// when the first row didn't qualify (e.g. wasn't bold and the table mode + /// can't otherwise distinguish it). + Table { + header: Option>, + rows: Vec>, + }, + /// Tabular-looking region we couldn't classify confidently — rendered + /// verbatim inside a fenced block to preserve visual structure for the + /// downstream LLM. Strictly better than emitting a mangled pipe table. + GridFallback { + lines: Vec, + }, + /// A horizontal rule detected from a long thin horizontal stroke in the + /// page's vector graphics (e.g. divider line between sections). + HorizontalRule, + /// Reference to a raster image on the page. Rendered as + /// `![](image_{id}.png)`. Suppressed entirely when `ImageMode::Off`. + Figure { + id: String, + }, +} + +/// Resolve a `ParaAccum` to a `Block::Paragraph`. When the paragraph was +/// uniformly styled across all lines, return the raw text with block-level +/// `bold`/`italic` flags set so the renderer wraps it once. Otherwise return +/// the per-line inline-styled text with the flags cleared. +pub(super) fn paragraph_from_accum(accum: ParaAccum) -> Block { + match accum.uniform { + Some((bold, italic)) if bold || italic => Block::Paragraph { + text: escape_inline(&accum.raw), + bold, + italic, + }, + Some(_) => Block::Paragraph { + // Uniformly plain — no emphasis markers anywhere, so the raw text + // (with markdown specials escaped) is the right rendering. + text: escape_inline(&accum.raw), + bold: false, + italic: false, + }, + None => Block::Paragraph { + text: accum.inline, + bold: false, + italic: false, + }, + } +} + +/// Wrap `text` in markdown emphasis markers based on `bold`/`italic`. Both → +/// `***text***`. Headings deliberately skip this (the `#` is the emphasis). +fn wrap_emphasis(text: &str, bold: bool, italic: bool) -> String { + if text.trim().is_empty() { + return text.to_string(); + } + match (bold, italic) { + (true, true) => format!("***{text}***"), + (true, false) => format!("**{text}**"), + (false, true) => format!("*{text}*"), + (false, false) => text.to_string(), + } +} + +/// Render a list of blocks to a markdown string. +pub fn render_blocks(blocks: &[Block]) -> String { + let mut out = String::new(); + for (i, block) in blocks.iter().enumerate() { + if i > 0 { + // A word hyphenated across a soft line wrap sometimes lands in two + // *separate* paragraph blocks (the classifier mis-split mid-word): + // `…they dis-` ‖ `lodged…`. When the previous block ends with + // `-` and this block is a plain paragraph beginning with a + // lowercase letter, splice them: drop the hyphen and join with no + // separator, healing both the word and the spurious break. The + // lowercase + plain-text gates keep real compounds (`well-` then a + // capitalized `Known`) and emphasised/heading/table starts intact. + if let ( + Block::Paragraph { .. }, + Block::Paragraph { + text, + bold: false, + italic: false, + }, + ) = (&blocks[i - 1], block) + && is_soft_hyphen_break(&out, text) + { + while out.ends_with(|c: char| c.is_whitespace()) { + out.pop(); + } + out.pop(); // the soft hyphen + out.push_str(text); + continue; + } + // Consecutive list items render as a tight list (single newline). + // Everything else gets a blank line between blocks. + let tight = matches!(block, Block::ListItem { .. }) + && matches!(blocks[i - 1], Block::ListItem { .. }); + if tight { + out.push('\n'); + } else { + out.push_str("\n\n"); + } + } + match block { + Block::Heading { level, text } => { + let level = (*level).clamp(1, 6) as usize; + out.push_str(&"#".repeat(level)); + out.push(' '); + out.push_str(text); + } + Block::Paragraph { text, bold, italic } => { + out.push_str(&wrap_emphasis(text, *bold, *italic)); + } + Block::ListItem { + ordered, + marker, + level, + text, + bold, + italic, + } => { + let indent = " ".repeat((*level).min(6) as usize); + out.push_str(&indent); + if *ordered { + // Preserve the original marker (e.g. `138.` for footnotes + // or `iii)` for roman numerals) so semantic numbering + // survives the round-trip. CommonMark requires `1.` / + // `1)` style but most LLM consumers tolerate alt forms, + // and the alternative — renumbering as `1.` — drops info. + out.push_str(marker); + out.push(' '); + } else { + out.push_str("- "); + } + out.push_str(&wrap_emphasis(text, *bold, *italic)); + } + Block::Table { header, rows } => { + // GFM requires a header row before the separator. When the + // detector found no header, promote the first body row instead + // of synthesizing a blank `| | |` header — a visible empty + // row reads as sloppy output and carries no information. + let (head, body): (Option<&[String]>, &[Vec]) = match header { + Some(h) => (Some(h.as_slice()), rows.as_slice()), + None => match rows.split_first() { + Some((first, rest)) => (Some(first.as_slice()), rest), + None => (None, rows.as_slice()), + }, + }; + let column_count = head.map(|h| h.len()).unwrap_or(0); + if column_count == 0 { + continue; + } + out.push_str("| "); + for (i, cell) in head.unwrap().iter().enumerate() { + if i > 0 { + out.push_str(" | "); + } + out.push_str(&escape_table_cell(cell)); + } + out.push_str(" |\n"); + out.push('|'); + for _ in 0..column_count { + out.push_str("---|"); + } + for row in body { + out.push_str("\n| "); + for (i, cell) in row.iter().enumerate() { + if i > 0 { + out.push_str(" | "); + } + out.push_str(&escape_table_cell(cell)); + } + out.push_str(" |"); + } + } + Block::GridFallback { lines } => { + out.push_str("```text\n"); + for line in lines { + out.push_str(line); + out.push('\n'); + } + out.push_str("```"); + } + Block::CodeBlock { lines, lang } => { + // Pick a fence that doesn't appear inside the body. Standard + // triple-backtick handles ~all real-world code; bump to ~~~ if + // the body itself contains ``` (rare). + let fence = if lines.iter().any(|l| l.contains("```")) { + "~~~" + } else { + "```" + }; + out.push_str(fence); + if let Some(lang) = lang { + out.push_str(lang); + } + out.push('\n'); + for line in lines { + out.push_str(line); + out.push('\n'); + } + out.push_str(fence); + } + Block::HorizontalRule => { + out.push_str("---"); + } + Block::Figure { id, .. } => { + out.push_str("![](image_"); + out.push_str(id); + out.push_str(".png)"); + } + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn render_blocks_formats_markdown() { + let blocks = vec![ + Block::Heading { + level: 1, + text: "Title".into(), + }, + Block::Paragraph { + text: "A paragraph.".into(), + bold: false, + italic: false, + }, + Block::Heading { + level: 2, + text: "Sub".into(), + }, + ]; + let s = render_blocks(&blocks); + assert_eq!(s, "# Title\n\nA paragraph.\n\n## Sub"); + } + + #[test] + fn render_lists_are_tight() { + let blocks = vec![ + Block::Paragraph { + text: "Intro.".into(), + bold: false, + italic: false, + }, + Block::ListItem { + ordered: false, + marker: "•".into(), + level: 0, + text: "a".into(), + bold: false, + italic: false, + }, + Block::ListItem { + ordered: false, + marker: "•".into(), + level: 0, + text: "b".into(), + bold: false, + italic: false, + }, + Block::Paragraph { + text: "Outro.".into(), + bold: false, + italic: false, + }, + ]; + let s = render_blocks(&blocks); + assert_eq!(s, "Intro.\n\n- a\n- b\n\nOutro."); + + // Ordered: original marker preserved + let s = render_blocks(&[ + Block::ListItem { + ordered: true, + marker: "138.".into(), + level: 0, + text: "footnote".into(), + bold: false, + italic: false, + }, + Block::ListItem { + ordered: true, + marker: "139.".into(), + level: 0, + text: "next footnote".into(), + bold: false, + italic: false, + }, + ]); + assert_eq!(s, "138. footnote\n139. next footnote"); + } + + #[test] + fn render_emphasis_combinations() { + assert_eq!(wrap_emphasis("hi", false, false), "hi"); + assert_eq!(wrap_emphasis("hi", true, false), "**hi**"); + assert_eq!(wrap_emphasis("hi", false, true), "*hi*"); + assert_eq!(wrap_emphasis("hi", true, true), "***hi***"); + } + + #[test] + fn code_block_escapes_internal_fence() { + let blocks = vec![Block::CodeBlock { + lines: vec!["body containing ``` backticks".into()], + lang: None, + }]; + let s = render_blocks(&blocks); + assert!(s.starts_with("~~~\n")); + assert!(s.ends_with("~~~")); + } + + #[test] + fn renders_table_to_pipe_format() { + let blocks = vec![Block::Table { + header: Some(vec!["a".into(), "b".into()]), + rows: vec![vec!["1".into(), "2".into()], vec!["3".into(), "4".into()]], + }]; + let s = render_blocks(&blocks); + assert_eq!(s, "| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |"); + } + + #[test] + fn splices_hyphen_split_across_paragraph_blocks() { + let p = |t: &str| Block::Paragraph { + text: t.into(), + bold: false, + italic: false, + }; + // Mid-word hyphen split into two paragraphs heals into one. + let s = render_blocks(&[p("they dis-"), p("lodged the part")]); + assert_eq!(s, "they dislodged the part"); + // Capitalized continuation is a real compound break — left intact. + let s = render_blocks(&[p("the well-"), p("Known fact")]); + assert_eq!(s, "the well-\n\nKnown fact"); + // Trailing dash not preceded by a letter doesn't splice. + let s = render_blocks(&[p("a -"), p("dash line")]); + assert_eq!(s, "a -\n\ndash line"); + } + + #[test] + fn render_table_without_header_promotes_first_row() { + let blocks = vec![Block::Table { + header: None, + rows: vec![vec!["h1".into(), "h2".into()], vec!["1".into(), "2".into()]], + }]; + let s = render_blocks(&blocks); + // No blank `| | |` header: the first row becomes the header. + assert_eq!(s, "| h1 | h2 |\n|---|---|\n| 1 | 2 |"); + } +} diff --git a/crates/liteparse/src/markdown_layout/classify.rs b/crates/liteparse/src/markdown_layout/classify.rs new file mode 100644 index 0000000..2858d2b --- /dev/null +++ b/crates/liteparse/src/markdown_layout/classify.rs @@ -0,0 +1,1464 @@ +use crate::types::{OutlineTarget, ParsedPage, ProjectedLine}; + +use super::blocks::{Block, paragraph_from_accum}; +use super::headings::{ + HEADING_MAX_TEXT_CHARS, MAX_HEADING_LEVELS, heading_level_for, heading_size_of, + is_caption_line, is_toc_title, looks_like_bold_heading, looks_like_numbered_bold_heading, + outline_heading_level, page_is_toc, struct_heading_level, +}; +use super::hr::detect_horizontal_rules; +use super::inline::{ + append_inline_continuation, line_uniform_style, render_line_inline, render_list_item_text, +}; +use super::lists::{ + LIST_INDENT_STEP_PT, detect_ordered_list_lines, parse_list_marker, + split_ordered_marker_for_emit, +}; +use super::paragraphs::{ + ParaAccum, append_to_paragraph, collapse_whitespace, continues_heading, continues_list_item, + continues_paragraph, ends_hyphenated, ends_sentence_final, is_soft_hyphen_break, +}; +use super::repetition::is_header_or_footer; +use super::tables::{detect_ruled_tables, detect_tables, merge_table_runs}; + +/// A document-order page interruption that breaks the normal text flow: either +/// a horizontal rule (from vector graphics) or a figure injection (a raster +/// image ref). Collected into one y-sorted stream so the two kinds interleave +/// correctly by vertical position rather than emitting all of one before the +/// other. +#[derive(Clone)] +enum Interruption { + Hr, + Figure(crate::types::ImageRef), + /// A ruled table detected by the global pass, already fully built. Emitted + /// at its top-y position like other interruptions; its lines were pulled + /// out of the region pipeline so it won't be re-detected per-region. + Table(super::blocks::Block), +} + +/// Returns true if any span on the line is rotated more than ~5° off +/// horizontal — used to exclude sidebar / margin-stamp text (arXiv banners, +/// watermarks, vertical legends) from the body-size and heading-size +/// histograms so it doesn't compete with normal-flow text for heading slots. +pub(super) fn is_rotated_line(line: &ProjectedLine) -> bool { + line.spans.iter().any(|s| { + let r = s.rotation.abs() % 360.0; + // Anything more than ~5° off the horizontal axes is "rotated" for + // our purposes. 0° and 180° are both horizontal text. + !(r < 5.0 || (175.0..=185.0).contains(&r) || (355.0..=360.0).contains(&r)) + }) +} + +/// Classify a single page's `ProjectedLine`s into blocks. +#[cfg(test)] +pub(super) fn classify_page(page: &ParsedPage, heading_map: &[(f32, u8)]) -> Vec { + classify_page_with_filters( + page, + heading_map, + &std::collections::HashSet::new(), + &[], + crate::config::ImageMode::Placeholder, + &std::collections::HashSet::new(), + ) +} + +/// Same as `classify_page` but also strips lines matching a precomputed +/// running header/footer set. Use this when emitting a whole document so +/// repeating chrome (titles, page numbers) doesn't show up in every page. +/// +/// `outline` is the document outline filtered to entries whose `page_index` +/// matches this page (or the full outline — out-of-page entries are +/// ignored). Heading promotion from struct tree + outline outranks the +/// font-size heading map. +pub fn classify_page_with_filters( + page: &ParsedPage, + heading_map: &[(f32, u8)], + header_footer: &std::collections::HashSet, + outline: &[OutlineTarget], + image_mode: crate::config::ImageMode, + chrome_indices: &std::collections::HashSet, +) -> Vec { + let debug = *super::flags::DEBUG_MD; + + // TOC suppression: when ≥3 lines on this page look like TOC entries + // (alpha body + trailing page-number), demote heading promotion so each + // entry stays a paragraph instead of becoming a fake H1/H2. The TOC's + // own title ("Contents", "Table of Contents") is shorter without a + // trailing number — it falls through the size/bold heuristics with no + // special handling needed. + // Fallback TOC detection: when projection truncates the trailing page + // numbers on TOC entries (common when entries use dot-leaders that the + // projection eats), `page_is_toc` misses. If a `Contents` / `Table of + // Contents` title is the first text on the page, treat the rest as TOC + // entries even without page-number tails. Guard: don't fire if the page + // has a substantial body paragraph (≥3 lines of ≥80 chars each) — that's + // a real content page that just happens to mention "contents" near the + // top. + let toc_page = page_is_toc(page) || { + let mut saw_title = false; + let mut long_lines = 0usize; + for line in &page.projected_lines { + if is_rotated_line(line) { + continue; + } + let t = line.text.trim(); + if t.is_empty() { + continue; + } + if !saw_title { + if is_toc_title(t) { + saw_title = true; + continue; + } + if t.chars().count() > 40 { + break; + } + } else if t.chars().count() >= 80 { + // Dot-leader fragments ("`. . . . . . . .`") are part of + // TOC layout, not body paragraphs — skip them. + let alpha = t.chars().filter(|c| c.is_alphabetic()).count(); + let alpha_ratio = alpha as f32 / t.chars().count() as f32; + if alpha_ratio < 0.3 { + continue; + } + long_lines += 1; + if long_lines >= 3 { + saw_title = false; + break; + } + } + } + saw_title + }; + + // Strip running header/footer lines up-front so they don't leak into + // table detection (a repeating two-column footer would otherwise look + // like a 2-row table) or paragraph grouping. + let need_filter = !header_footer.is_empty() || !chrome_indices.is_empty(); + let filtered_owned: Vec = if !need_filter { + Vec::new() + } else { + page.projected_lines + .iter() + .enumerate() + .filter(|(idx, l)| { + !chrome_indices.contains(idx) && !is_header_or_footer(l, page, header_footer) + }) + .map(|(_, l)| l.clone()) + .collect() + }; + let lines: &[ProjectedLine] = if !need_filter { + &page.projected_lines + } else { + &filtered_owned + }; + + // Global ruled-table pass. `detect_ruled_tables` is otherwise called per + // xy_cut leaf, but a ruled table whose rows scatter across several leaves + // never has its full text in any single leaf and gets rejected as + // mostly-empty. Run it once over all lines, pull the consumed lines out of + // the region pipeline, and emit each table as a y-positioned interruption. + // Runs before cross-region merge so that pass (and the region indices its + // runs carry) operate on the already-filtered line list. + let mut global_ruled_tables: Vec<(f32, Block)> = Vec::new(); + let mut global_ruled_consumed: std::collections::HashSet = + std::collections::HashSet::new(); + for (run, consumed) in super::tables::detect_ruled_tables_global( + lines, + &page.graphics, + page.page_width, + page.page_height, + ) { + // Only rescue tables the per-region path can't see whole: those + // whose rows scatter across ≥2 xy_cut leaves. A table living in a + // single region is already handled (often better) by per-region + // detection — replacing it here only risks regressions. + let mut groups: std::collections::HashMap<&Vec, Vec> = + std::collections::HashMap::new(); + for &i in &consumed { + groups.entry(&lines[i].region_path).or_default().push(i); + } + if groups.len() < 2 { + continue; + } + // If any single leaf's share of these lines already forms a table + // on its own, the per-region path handles this content — don't + // override (a decorative frame can span a data table plus surrounding + // prose; the data region tables cleanly by itself, while the frame + // would fuse prose into garbage cells). + let already_handled = groups.values().any(|idxs| { + if idxs.len() < 2 { + return false; + } + let sub: Vec = idxs.iter().map(|&i| lines[i].clone()).collect(); + !super::tables::detect_ruled_tables( + &sub, + &page.graphics, + page.page_width, + page.page_height, + ) + .is_empty() + || !super::tables::detect_tables(&sub).is_empty() + }); + if already_handled { + continue; + } + // Single-column gate. A decorative banner/frame around a prose + // column (just a left+right border, no interior verticals) unions + // into a 1-column "grid" whose lone cell is the whole paragraph + // block. A real data table always has ≥2 columns; a 1-col ruled + // region is a framed text box, never tabular. Word-count gating can't + // be used here: genuine scattered description grids have equally long + // cells. + if let Block::Table { header, rows } = &run.block { + let cols = header + .as_ref() + .map(|h| h.len()) + .or_else(|| rows.first().map(Vec::len)) + .unwrap_or(0); + if cols < 2 { + continue; + } + } + let top_y = consumed + .iter() + .map(|&i| lines[i].bbox.y) + .fold(f32::INFINITY, f32::min); + global_ruled_tables.push((top_y, run.block)); + global_ruled_consumed.extend(consumed); + } + let global_ruled_owned: Option> = if global_ruled_consumed.is_empty() { + None + } else { + Some( + lines + .iter() + .enumerate() + .filter(|(i, _)| !global_ruled_consumed.contains(i)) + .map(|(_, l)| l.clone()) + .collect(), + ) + }; + let lines: &[ProjectedLine] = global_ruled_owned.as_deref().unwrap_or(lines); + + // Cross-region table re-merge: when a V-cut sliced a table into sibling + // leaves, fuse the slices back into single rows (one synthetic region) + // before grouping. Each successful merge carries its own validated table + // runs, which are handed to `classify_region` so emission is guaranteed + // even for shapes the standard detectors would reject in normal flow. + // TOC pages are skipped: their split number/title/page-number leaves + // baseline-align perfectly and fuse into a convincing-but-wrong table. + let mut cross_merged_owned: Option> = None; + let mut cross_region_runs: Vec<(Vec, Vec)> = Vec::new(); + for _ in 0..3 { + if toc_page { + break; + } + let cur: &[ProjectedLine] = cross_merged_owned.as_deref().unwrap_or(lines); + let Some(m) = super::cross_region::find_cross_region_table_merge(cur) else { + break; + }; + let mut next: Vec = Vec::with_capacity(cur.len()); + next.extend_from_slice(&cur[..m.start]); + let merged_path = m.merged[0].region_path.clone(); + next.extend(m.merged); + next.extend_from_slice(&cur[m.end..]); + cross_region_runs.push((merged_path, m.runs)); + cross_merged_owned = Some(next); + } + let lines: &[ProjectedLine] = cross_merged_owned.as_deref().unwrap_or(lines); + + // Region-grouped pipeline: split the filtered line list into contiguous + // runs sharing a `region_path` (one xy_cut leaf each) and classify each + // leaf as its own mini-page. Paragraph / list / code / heading state is + // scoped per leaf so a column-wrap can't silently fuse two leaves into one + // paragraph, and table detection runs per-leaf so a misfired borderless + // table can't consume lines from a different leaf (otherwise a spurious + // table starting in one column's footnote area eats the first lines of the + // next column, dropping those words from any block). Cross-leaf merges + // happen only in `stitch_regions` at the + // end, where the rule is explicit and inspectable. + let mut region_ranges: Vec<(usize, usize)> = Vec::new(); + { + let mut s = 0; + while s < lines.len() { + let path = &lines[s].region_path; + let mut e = s + 1; + while e < lines.len() && lines[e].region_path == *path { + e += 1; + } + region_ranges.push((s, e)); + s = e; + } + } + + // Page-level interruptions (HRs from vector graphics + figure refs) need + // to interleave with text by y-position. Collect once, then dispatch into + // each region's classify call by y-band so they emit in the right slot. + // Two interruptions belonging to *no* region (e.g. an HR in a margin band + // that no leaf covers) are emitted between regions in y order. + let mut all_interruptions: Vec<(f32, Interruption)> = detect_horizontal_rules(page) + .into_iter() + .map(|y| (y, Interruption::Hr)) + .collect(); + if !matches!(image_mode, crate::config::ImageMode::Off) { + for r in &page.image_refs { + all_interruptions.push((r.bbox.y, Interruption::Figure(r.clone()))); + } + } + for (y, block) in global_ruled_tables { + all_interruptions.push((y, Interruption::Table(block))); + } + all_interruptions.sort_by(|a, b| a.0.total_cmp(&b.0)); + + // region_boundary_idx[k] = index into `blocks` where region k's first + // block lives. Used by `stitch_regions` to know where to test cross-leaf + // paragraph continuation. Stored alongside the region's last line so the + // stitcher can use the same `continues_paragraph` cross-region rule that + // governs intra-region merging — no second source of truth. + let mut region_boundaries: Vec = Vec::new(); + let mut interrupt_cursor = 0usize; + let mut blocks: Vec = Vec::new(); + + let push_interruption = |blocks: &mut Vec, kind: Interruption| { + blocks.push(match kind { + Interruption::Hr => Block::HorizontalRule, + Interruption::Figure(r) => Block::Figure { id: r.id }, + Interruption::Table(b) => b, + }); + }; + + for (rstart, rend) in region_ranges { + let region_lines = &lines[rstart..rend]; + + // Compute this region's y-extent so we can slot in interruptions that + // fall above or inside it. xy_cut leaves are rectangular partitions, + // so a leaf's y-band is well-defined. + let mut y_min = f32::INFINITY; + let mut y_max = f32::NEG_INFINITY; + for l in region_lines { + y_min = y_min.min(l.bbox.y); + y_max = y_max.max(l.bbox.y + l.bbox.height); + } + + // Emit any interruptions that sit above this region's top edge before + // we start emitting the region's own blocks. + while interrupt_cursor < all_interruptions.len() + && all_interruptions[interrupt_cursor].0 < y_min + { + let (_, kind) = all_interruptions[interrupt_cursor].clone(); + push_interruption(&mut blocks, kind); + interrupt_cursor += 1; + } + + // Hand the region its in-band interruptions (y in [y_min, y_max]) so + // the per-line loop can interleave them by y, matching the old + // page-level behavior within a leaf. + let mut region_interruptions: Vec<(f32, Interruption)> = Vec::new(); + while interrupt_cursor < all_interruptions.len() + && all_interruptions[interrupt_cursor].0 <= y_max + { + region_interruptions.push(all_interruptions[interrupt_cursor].clone()); + interrupt_cursor += 1; + } + + let region_start = blocks.len(); + let precomputed_tables = cross_region_runs + .iter() + .find(|(path, _)| *path == lines[rstart].region_path) + .map(|(_, runs)| runs.clone()); + let region_blocks = classify_region( + region_lines, + region_interruptions, + page, + heading_map, + outline, + toc_page, + debug, + precomputed_tables, + ); + if !region_blocks.is_empty() { + region_boundaries.push(region_start); + blocks.extend(region_blocks); + } + } + + // Trailing interruptions below the last region. + while interrupt_cursor < all_interruptions.len() { + let (_, kind) = all_interruptions[interrupt_cursor].clone(); + push_interruption(&mut blocks, kind); + interrupt_cursor += 1; + } + + stitch_regions(blocks, ®ion_boundaries) +} + +/// Mutable per-line flow state threaded through `classify_region`: the active +/// paragraph accumulator, the active code run, and the current list run. The +/// flush/reset/emit methods live here so list-state resets collapse to method +/// calls; methods on the struct also sidestep the borrow-checker friction of +/// threading five `&mut Option<…>` cells through a free closure. +#[derive(Default)] +struct FlowState { + paragraph: Option, + code: Option>, + list_base_indent: Option, + last_list_item_idx: Option, + last_list_line: Option, +} + +impl FlowState { + /// Drop the active list run. A heading, table, code block, or paragraph + /// break all terminate the current list. + fn reset_list(&mut self) { + self.list_base_indent = None; + self.last_list_item_idx = None; + self.last_list_line = None; + } + + /// Emit the active paragraph (if it has non-blank content) and clear it. + fn flush_paragraph(&mut self, blocks: &mut Vec) { + if let Some(acc) = self.paragraph.take() + && !acc.raw.trim().is_empty() + { + blocks.push(paragraph_from_accum(acc)); + } + } + + /// Emit the active code run (if non-empty) and clear it. + fn flush_code(&mut self, blocks: &mut Vec) { + if let Some(lines) = self.code.take() + && !lines.is_empty() + { + let lang = detect_code_language(&lines); + blocks.push(Block::CodeBlock { lines, lang }); + } + } + + /// Emit any interruptions whose y is at or above `before_y`, flushing the + /// active paragraph/code/list state first so each lands as its own block. + fn emit_before( + &mut self, + blocks: &mut Vec, + iter: &mut std::iter::Peekable>, + before_y: f32, + ) { + while let Some((y, _)) = iter.peek() { + if *y > before_y { + break; + } + let (_, kind) = iter.next().unwrap(); + self.flush_paragraph(blocks); + self.flush_code(blocks); + self.reset_list(); + blocks.push(match kind { + Interruption::Hr => Block::HorizontalRule, + Interruption::Figure(r) => Block::Figure { id: r.id }, + Interruption::Table(b) => b, + }); + } + } +} + +/// Classify the lines of a single xy_cut leaf into a sequence of blocks. All +/// per-line state (active paragraph, code run, list run, heading_run) is local +/// to this call — nothing crosses a leaf boundary except through the explicit +/// `stitch_regions` post-pass. Page-level signals (`heading_map`, +/// `struct_nodes`, `outline`, header/footer y-bands) are still consulted +/// because they're computed once per document. +#[allow(clippy::too_many_arguments)] +fn classify_region( + lines: &[ProjectedLine], + interruptions: Vec<(f32, Interruption)>, + page: &ParsedPage, + heading_map: &[(f32, u8)], + outline: &[OutlineTarget], + toc_page: bool, + debug: bool, + precomputed_tables: Option>, +) -> Vec { + let mut blocks: Vec = Vec::new(); + let mut state = FlowState::default(); + let mut heading_run: Option<(u8, usize)> = None; + // Track whether we've already emitted a TOC title on this page. Once seen, + // any subsequent line matching `is_toc_title` (e.g. "Index", "List of + // Figures") is a TOC entry pointing at that chapter, not another title, + // and must stay suppressed. + let mut toc_title_emitted = false; + + // Per-region table detection. Indices are local to `lines`. Page-level + // graphics are still consulted for ruled-table detection because path + // objects are page-coordinate; the detector intersects them against the + // sub-list's line bboxes anyway. + let ruled_runs = detect_ruled_tables(lines, &page.graphics, page.page_width, page.page_height); + let borderless_runs = precomputed_tables.unwrap_or_else(|| detect_tables(lines)); + let table_runs = merge_table_runs(ruled_runs, borderless_runs); + + // Region-wide pre-pass: which line indices carry a lettered/roman marker + // (`a.`, `i.`) that belongs to a confirmed sequential list run. Only these + // are treated as list items below — a lone `A.` / initial is left alone. + // Lines already inside a detected table are excluded so enumerated table + // footnotes (`(a)`, `(b)` below a table) aren't pulled out as a list, + // which would disturb the table's row/track inference. + let table_covered: std::collections::HashSet = table_runs + .iter() + .flat_map(|run| run.start..run.end) + .collect(); + let ordered_list_lines = detect_ordered_list_lines(lines, &table_covered); + + const TABLE_HR_SUPPRESS_HEADROOM_ROWS: f32 = 4.0; + let table_y_extents: Vec<(f32, f32)> = table_runs + .iter() + .map(|run| { + let top_line = &lines[run.start]; + let row_h = top_line.bbox.height.max(super::MIN_ROW_HEIGHT_PT); + let top = top_line.bbox.y - row_h * TABLE_HR_SUPPRESS_HEADROOM_ROWS; + let last = &lines[run.end.saturating_sub(1).max(run.start)]; + let bot = last.bbox.y + last.bbox.height; + (top, bot) + }) + .collect(); + + let mut table_iter = table_runs.into_iter().peekable(); + + let in_table_band = |y: f32| { + table_y_extents + .iter() + .any(|(top, bot)| y >= *top - 2.0 && y <= *bot + 2.0) + }; + let mut region_interruptions: Vec<(f32, Interruption)> = interruptions + .into_iter() + .filter(|(y, _)| !in_table_band(*y)) + .collect(); + region_interruptions.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut interruptions = region_interruptions.into_iter().peekable(); + + let mut idx = 0; + while idx < lines.len() { + if let Some(run) = table_iter.peek() + && run.start == idx + { + // Flush any interruptions above this table's top edge first. + let table_top = lines[run.start].bbox.y; + state.emit_before(&mut blocks, &mut interruptions, table_top); + state.flush_paragraph(&mut blocks); + state.flush_code(&mut blocks); + state.reset_list(); + let run = table_iter.next().unwrap(); + blocks.push(run.block); + idx = run.end; + continue; + } + let line_idx = idx; + let line = &lines[line_idx]; + // Emit any interruptions that fall above this line. + state.emit_before(&mut blocks, &mut interruptions, line.bbox.y); + idx += 1; + let text = line.text.trim(); + if text.is_empty() { + continue; + } + // Skip rotated text (vertical sidebars, arXiv banners, watermarks). + // Including it would either inject a paragraph of disconnected + // characters or be misclassified as a heading. + if is_rotated_line(line) { + continue; + } + if debug { + eprintln!( + "[md] y={:.1} h={:.1} size={:.2} anchor={:?} indent={:.1} text={:?}", + line.bbox.y, + line.bbox.height, + line.dominant_font_size, + line.anchor, + line.indent_x, + text + ); + } + + // Code block detection runs first so a mono heading-shaped line + // (rare but plausible — e.g., a code identifier in a large mono font) + // still becomes code. Mono content also wouldn't make a useful + // heading. + if line.all_mono { + state.flush_paragraph(&mut blocks); + state.reset_list(); + state + .code + .get_or_insert_with(Vec::new) + .push(line.text.trim_end().to_string()); + continue; + } + // Any non-mono line ends the current code block (if any). + state.flush_code(&mut blocks); + + // Decorative divider / flourish lines (`* * * *`, a lone em-dash). + // Handled before heading/paragraph classification so the ornament + // neither glues onto the next paragraph nor gets promoted to a heading. + if let Some(is_rule) = decorative_divider_kind(text) { + state.flush_paragraph(&mut blocks); + state.reset_list(); + heading_run = None; + if is_rule { + blocks.push(Block::HorizontalRule); + } + if debug { + eprintln!( + "[md decorative] {} '{}'", + if is_rule { "rule" } else { "drop" }, + text.chars().take(40).collect::(), + ); + } + continue; + } + + // Priority chain: tagged-PDF struct tree → outline → font-size map. + let tagged_level = struct_heading_level(line, &page.struct_nodes); + // Caption lines ("Figure 7", "Table 3.") are routinely set in a + // distinct (and slightly larger) font that lands them in the + // font-size heading map. Suppress font-size promotion for them; + // outline / struct-tree signals still win since those are explicit. + let is_first_toc_title = is_toc_title(text) && !toc_title_emitted; + let toc_suppress = toc_page && !is_first_toc_title; + // Outline entries on a TOC page are the TOC itself — every entry + // prefix-matches an outline target ("Introduction", "Part I", ...). + // Promoting them to `##` shreds the document's heading structure (a + // TOC page is just `# Contents`). Tagged-PDF struct levels are explicit + // and still win. + let outline_level = tagged_level.or_else(|| { + if toc_suppress { + None + } else { + outline_heading_level(line, page.page_height, outline, text) + } + }); + // Rotated lines (margin stamps like a sideways "arXiv:…" watermark) are + // excluded from `build_heading_map`, but their size can still coincide + // with a map entry built from horizontal text. Suppress font-size + // promotion for them so they don't surface as spurious headings. + let size_level = + if is_caption_line(text) || toc_suppress || is_rotated_line(line) || line.in_figure { + None + } else { + heading_level_for(heading_size_of(line), heading_map) + }; + // Guard against height-jitter false headings: a line that flows from + // the previous line (same paragraph) AND starts lowercase is a + // mid-paragraph continuation, not a heading — even if its + // height-estimated size jittered into a heading slot. A real heading + // has a break above it and is capitalized, so it won't satisfy both. + // Outline / struct-tree levels are explicit and bypass this guard. + let size_level = size_level.filter(|_| { + let starts_lower = text.chars().next().is_some_and(|c| c.is_lowercase()); + let prev = state + .paragraph + .as_ref() + .map(|p| &p.last) + .or(state.last_list_line.map(|i| &lines[i])); + // A previous line ending in a mid-word hyphen wrap means this + // line is its continuation regardless of capitalization + // ("…SOLAR 10.7 Billion-" / "Parameter Model: We have…"). + let prev_hyphen_wrap = prev.is_some_and(|p| ends_hyphenated(&p.text)); + // `prev` is the *live body paragraph* (or list line) immediately + // above, so it only exists mid-paragraph — never right after a + // heading. A lowercase line that *completes a sentence* (ends with + // terminal punctuation) while its predecessor is left open (no + // terminal punctuation) is a wrapped sentence tail, not a heading — + // even when a centered short tail's large left-edge indent defeats + // `continues_paragraph` ("…journalistic or" → "scholarly works."). + // Requiring the tail to end a sentence keeps genuine lowercase + // headings (which rarely end in `.`/`!`/`?`) promoted. + let sentence_tail = starts_lower + && ends_sentence_final(text) + && prev.is_some_and(|p| !ends_sentence_final(&p.text)); + let is_continuation = + prev.is_some_and(|p| continues_paragraph(p, line)) || sentence_tail; + !((starts_lower || prev_hyphen_wrap) && is_continuation) + }); + // Long-text guard: a real heading is a label, not a sentence with + // multiple clauses. Footnotes / citations / captions that happen to + // be set slightly larger than body text (e.g. a 10.7pt citation + // over a 9pt body) score as the smallest heading level in the size + // map; without a length cap they emit as `##`-shaped sentences. + // Threshold is generous — book titles and long subsection labels + // can run 100+ chars — but a 200-char citation is unambiguously + // not a heading. + let size_level = size_level.filter(|_| text.chars().count() <= HEADING_MAX_TEXT_CHARS); + // Attribution lines under figures/charts are never section headings, + // even when the chart caption font is slightly above body size. + let size_level = + size_level.filter(|_| !crate::markdown_layout::headings::is_attribution_line(text)); + // Run-in label guard: a size-promoted line that contains a mid-line + // ". " followed by ≥3 more words AND ends with a mid-word "-" wrap + // is almost always a paragraph leading with a bold run-in label + // ("Base model. Any n-layer transformer architec-"), not a section + // heading. Both signals must fire — a heading occasionally has either + // on its own (e.g. abbreviation periods, intentional hyphen). + let size_level = size_level.filter(|_| { + let has_mid_sentence = text.contains(". ") || text.contains(": "); + let mid_word_wrap = text.trim_end().ends_with('-'); + !(has_mid_sentence && mid_word_wrap) + }); + // A standalone "Contents" / "Table of Contents" / "Index" line is + // almost always a real H1, even when `page_is_toc` is false — many + // TOCs list entries without inline trailing page numbers, so the + // page-level detector misses them. `is_toc_title` matches the *entire* + // trimmed line against an exact whitelist, so it can't fire mid-prose. + let toc_title_level = if is_first_toc_title { Some(1u8) } else { None }; + if is_first_toc_title { + toc_title_emitted = true; + } + let level = outline_level + .or(size_level) + .or(toc_title_level) + .map(|l| l.clamp(1, MAX_HEADING_LEVELS as u8)); + let mut demoted_heading = false; + // Unconditional wrap-continuation merge: when the previous block is a + // live heading_run and this line continues it (same font, same region, + // tight gap), merge regardless of whether this line itself qualifies + // as a heading. Catches body-size wrap continuations of bold headings + // ("Cellular Cycle\nand Replication") where line 2 has the same + // sub-body size and would otherwise emit as a stray bold paragraph. + // Gate the unconditional merge: only fold body-sized continuations that + // *look* like the rest of a heading, not body prose. `continues_heading` + // only enforces structural agreement (font/bold/region/gap), so a body + // paragraph that happens to share those traits (e.g. a heading followed + // by an all-bold body paragraph) would otherwise be absorbed. + // Reject the candidate if it starts a list item (`l.`, `1.`, `· `…) or + // contains a mid-sentence period — both are unambiguous prose signals + // and never appear inside a real wrapped heading. + let cont_looks_like_heading = parse_list_marker(text).is_none() && !text.contains(". "); + if level.is_none() + && cont_looks_like_heading + && let Some((run_level, run_idx)) = heading_run.as_ref() + && continues_heading(&lines[*run_idx], line) + && let Some(Block::Heading { + level: last_level, + text: htext, + }) = blocks.last_mut() + && *last_level == *run_level + { + let combined_chars = htext.chars().count() + 1 + text.chars().count(); + if combined_chars <= HEADING_MAX_TEXT_CHARS { + let run_level = *run_level; + if debug { + eprintln!( + "[MD heading-wrap-uncond] merge h{} '{}' <- '{}' (prev_idx={} cur_idx={} combined={})", + run_level, + htext.chars().take(40).collect::(), + text.chars().take(60).collect::(), + run_idx, + line_idx, + combined_chars + ); + } + append_inline_continuation(htext, text, &collapse_whitespace(text)); + heading_run = Some((run_level, line_idx)); + continue; + } + } + if let Some(level) = level { + // Merge a wrapped continuation into the heading directly above when + // it flows as one block: same level, the heading is still the last + // emitted block, and the line continues the paragraph. A real + // section heading is followed by body text (which breaks the run), + // so only a genuinely multi-line heading/caption merges here. + if let Some((run_level, run_idx)) = heading_run.as_ref() + && *run_level == level + && continues_heading(&lines[*run_idx], line) + && let Some(Block::Heading { + level: last_level, + text: htext, + }) = blocks.last_mut() + && *last_level == level + { + // Length guard: a wrapped multi-line heading is fine, but a + // run that grows past `HEADING_MAX_TEXT_CHARS` is almost + // certainly a footnote/citation block whose font is only + // slightly above body (e.g. 10.7pt over 9pt body). Demote + // the existing heading block back to a paragraph and let + // the current line continue that paragraph below. The + // first line scored as a solo heading because it was under + // the limit on its own; only the second-or-later line tips + // the cumulative content into footnote territory. + let combined_chars = htext.chars().count() + 1 + text.chars().count(); + if combined_chars > HEADING_MAX_TEXT_CHARS { + let demoted = std::mem::take(htext); + blocks.pop(); + state.paragraph = Some(ParaAccum { + raw: demoted.clone(), + inline: demoted, + last: lines[*run_idx].clone(), + uniform: None, + }); + heading_run = None; + demoted_heading = true; + } else { + append_inline_continuation(htext, text, &collapse_whitespace(text)); + heading_run = Some((level, line_idx)); + continue; + } + } + if !demoted_heading { + state.flush_paragraph(&mut blocks); + state.reset_list(); + if debug { + eprintln!( + "[MD heading-emit size/outline] h{} idx={} '{}' size={:.2}", + level, + line_idx, + text.chars().take(80).collect::(), + line.dominant_font_size, + ); + } + blocks.push(Block::Heading { + level, + text: collapse_whitespace(text), + }); + heading_run = Some((level, line_idx)); + continue; + } + } + + // List item? Bullets/decimals come from `parse_list_marker`; lettered + // and roman markers are only accepted when the region pre-pass + // confirmed them as part of a sequential run. + let list_marker: Option<(bool, String, String)> = parse_list_marker(text) + .map(|(o, m, r)| (o, m, r.to_string())) + .or_else(|| { + if ordered_list_lines.contains(&line_idx) { + split_ordered_marker_for_emit(text).map(|(m, r)| (true, m, r)) + } else { + None + } + }); + if let Some((ordered, marker, rest)) = list_marker { + let rest = rest.as_str(); + // Numbered bold-section heading: "1. **Foo**" / "5. **The dynamics**". + // When the post-marker body is uniformly bold + body-sized, + // standalone (paragraph-break gap above and below), short, and + // mostly alpha, treat it as a heading rather than the first item + // of an ordered list. Without this, decimal-numbered section + // headings in technical/legal/scientific PDFs silently emit as + // ordered list items and lose all heading structure. + if ordered + && !toc_suppress + && looks_like_numbered_bold_heading( + line, + rest, + state + .paragraph + .as_ref() + .map(|p| &p.last) + .or(state.last_list_line.map(|i| &lines[i])), + ) + { + state.flush_paragraph(&mut blocks); + state.reset_list(); + let level = (heading_map.len() as u8 + 1).clamp(1, MAX_HEADING_LEVELS as u8); + blocks.push(Block::Heading { + level, + text: collapse_whitespace(text), + }); + continue; + } + state.flush_paragraph(&mut blocks); + let base = *state.list_base_indent.get_or_insert(line.indent_x); + let level = (((line.indent_x - base) / LIST_INDENT_STEP_PT) + .round() + .max(0.0)) as u8; + state.last_list_item_idx = Some(blocks.len()); + state.last_list_line = Some(line_idx); + // Render the list-item text via the inline pipeline so per-span + // emphasis surfaces. We strip the marker from `rest` (already + // done by `parse_list_marker`), but emphasis lives on `line.spans`, + // which still contain the marker span — render the line and then + // peel the marker off the front of the rendered string. + let item_text = render_list_item_text(line, &marker, rest); + blocks.push(Block::ListItem { + ordered, + marker, + level, + text: item_text, + bold: false, + italic: false, + }); + continue; + } + + // Continuation of a list item: same gap/font rules as paragraphs, but + // hanging-indent tolerant. Wrapped bodies either left-flush below the + // marker (footnote style) or align under the marker's text (indented + // right) — `continues_list_item` accepts both. + if let Some(item_idx) = state.last_list_item_idx + && let Some(prev_idx) = state.last_list_line + && continues_list_item(&lines[prev_idx], line) + && let Some(Block::ListItem { + text: prev_text, .. + }) = blocks.get_mut(item_idx) + { + // De-hyphenate against the prior rendered text, then append the + // inline-styled continuation. + let cont_inline = render_line_inline(line); + append_inline_continuation(prev_text, text, &cont_inline); + state.last_list_line = Some(line_idx); + continue; + } + + // Bold body-size heading. Section headings in academic / technical + // PDFs are routinely body-sized + bold (e.g. "Abstract", + // "1 Introduction"); without this rule they look just like a bold + // first sentence of a paragraph. Runs after list-marker detection so + // bold bullet items stay as list items. + let prev_for_gap = state + .paragraph + .as_ref() + .map(|p| &p.last) + .or(state.last_list_line.map(|i| &lines[i])); + let next_for_gap = lines.get(idx); + if !toc_suppress && looks_like_bold_heading(line, prev_for_gap, next_for_gap) { + state.flush_paragraph(&mut blocks); + state.reset_list(); + // Level: one deeper than the deepest size-based level we already + // have. With an empty heading_map this lands on H1; with a full + // 6-level map it caps at H6. + let level = (heading_map.len() as u8 + 1).clamp(1, MAX_HEADING_LEVELS as u8); + if debug { + eprintln!( + "[MD heading-emit bold] h{} idx={} '{}' size={:.2}", + level, + line_idx, + text.chars().take(80).collect::(), + line.dominant_font_size, + ); + } + blocks.push(Block::Heading { + level, + text: collapse_whitespace(text), + }); + // Arm the heading_run so a wrapped continuation on the next line + // merges into this heading instead of emitting as a second one. + heading_run = Some((level, line_idx)); + continue; + } + + match state.paragraph.as_mut() { + Some(acc) if continues_paragraph(&acc.last, line) => { + append_to_paragraph(acc, line); + } + _ => { + state.flush_paragraph(&mut blocks); + state.reset_list(); + let inline = render_line_inline(line); + let raw = collapse_whitespace(text); + // Strike has no block-level Paragraph flag, so a struck line + // must keep the per-line `inline` rendering (which emits `~~…~~`) + // rather than take the uniform raw-text fast path. + let uniform = line_uniform_style(line) + .filter(|s| !s.strike) + .map(|s| (s.bold, s.italic)); + state.paragraph = Some(ParaAccum { + raw, + inline, + last: line.clone(), + uniform, + }); + } + } + } + + state.flush_paragraph(&mut blocks); + state.flush_code(&mut blocks); + // Flush any trailing interruptions that sat below the last text line. + state.emit_before(&mut blocks, &mut interruptions, f32::INFINITY); + blocks +} + +/// Classify a line that is *purely decorative* — no alphanumeric content, made +/// up only of divider symbols (`* * * *`, `———`, `____`). Returns: +/// - `Some(true)` → a section divider (≥3 symbols): emit a thematic break. +/// - `Some(false)` → a lone 1–2 char, drop it, tool small +/// - `None` → not decorative; classify normally. +/// +/// Without this, a `* * * *` divider line flows into the paragraph accumulator +/// and glues the ornament onto the start of the following paragraph, and a lone +/// decorative dash gets size-promoted to a `# -` heading. +fn decorative_divider_kind(text: &str) -> Option { + let mut symbols = 0usize; + for c in text.chars() { + if c.is_whitespace() { + continue; + } + if c.is_alphanumeric() || !is_divider_symbol(c) { + return None; + } + symbols += 1; + } + if symbols == 0 { + return None; + } + Some(symbols >= 3) +} + +/// Characters treated as decorative divider/ornament glyphs. Deliberately +/// conservative — excludes `.`, `=`, `~`, `+`, `#` which carry meaning elsewhere +/// (dot leaders, ellipses, setext-ish rules, headers). +fn is_divider_symbol(c: char) -> bool { + matches!( + c, + '*' | '-' | '_' | '–' | '—' | '•' | '·' | '●' | '▪' | '■' | '◦' | '★' | '☆' + ) +} + +/// Best-effort language hint for a fenced code block, used as the fence +/// info-string. Conservative: returns `Some` only when the body carries +/// strong, language-specific signals; otherwise `None` (bare fence). This +/// feeds markdown consumers that key off the language tag (e.g. syntax +/// highlighting). +fn detect_code_language(lines: &[String]) -> Option { + let body = lines.join("\n"); + let trimmed = body.trim_start(); + + // JSON: starts with a brace/bracket and reads as an object/array with + // quoted keys. Checked first because `{`/`[` are unambiguous openers. + if let Some(first) = trimmed.chars().find(|c| !c.is_whitespace()) + && (first == '{' || first == '[') + && body.contains("\":") + && body.matches('{').count() + body.matches('[').count() >= 1 + { + return Some("json".to_string()); + } + + // C / C++: preprocessor includes, namespace qualifiers, stream ops. + let cpp_hits = [ + "#include", + "std::", + "int main", + "nullptr", + "->", + "::", + "template<", + ] + .iter() + .filter(|s| body.contains(**s)) + .count(); + if cpp_hits >= 2 { + return Some("cpp".to_string()); + } + + // Python: keyword-led lines, f-strings, comprehensions, dunder/`self.`. + let py_signals = [ + "self.", "import ", "from ", "def ", "class ", "print(", "lambda ", "elif ", "f'", "f\"", + "__", " for ", "len(", "sorted(", "range(", + ]; + let py_hits = py_signals.iter().filter(|s| body.contains(**s)).count(); + // A colon-terminated control line (`if ...:`, `for ...:`, `def ...:`) is a + // strong Python tell that other curly-brace languages lack. + let py_colon_block = lines.iter().any(|l| { + let t = l.trim_end(); + t.ends_with(':') + && [ + "if ", "for ", "while ", "def ", "class ", "elif ", "else", "try", "except", + "with ", + ] + .iter() + .any(|kw| t.trim_start().starts_with(kw)) + }); + if py_hits >= 2 || (py_hits >= 1 && py_colon_block) { + return Some("python".to_string()); + } + + None +} + +/// Cross-region merging. Walks the concatenated block stream and, at each +/// region boundary, tries to fuse the last block of region A with the first +/// block of region B when they represent one logical unit split across leaves. +/// This is the *only* place per-region scoping is relaxed — every other +/// detector now treats a region as a hard wall — so the merge rules here are +/// deliberately narrow and inspectable. +/// +/// Currently supports: +/// - **Paragraph → Paragraph**: same cross-region rule as +/// `continues_paragraph`'s `region_path != region_path` arm: the previous +/// paragraph ends mid-sentence (no terminal punctuation) and the next +/// starts lowercase. Heals a column wrap losing its tail to a separate +/// block. +/// - **Hyphen splice**: previous paragraph ends `-` and next starts +/// ASCII-lowercase. Already handled in `render_blocks` for the adjacent +/// case, but doing it here too lets the merged block flow through the +/// uniform paragraph-rendering path and avoids a `\n\n` slipping in when +/// the renderer sees an intervening non-paragraph block. +/// +/// Lists, code blocks, headings, and tables are *not* fused across regions. +/// A bullet point split across columns is rare and a false merge is worse +/// than a true split; a table split across leaves indicates a projection-side +/// issue better fixed there than papered over here. +fn stitch_regions(blocks: Vec, region_starts: &[usize]) -> Vec { + if region_starts.len() <= 1 { + return blocks; + } + let boundary_set: std::collections::HashSet = + region_starts.iter().skip(1).copied().collect(); + let mut out: Vec = Vec::with_capacity(blocks.len()); + for (i, block) in blocks.into_iter().enumerate() { + if boundary_set.contains(&i) + && let Some(prev) = out.last_mut() + && let ( + Block::Paragraph { + text: prev_text, .. + }, + Block::Paragraph { + text: cur_text, + bold: false, + italic: false, + }, + ) = (prev, &block) + { + let prev_trim = prev_text.trim_end(); + let starts_lower = cur_text + .trim_start() + .chars() + .next() + .is_some_and(|c| c.is_lowercase()); + // Hyphen-splice path: a mid-word soft hyphen break across the + // leaf boundary. Drop the hyphen, join with no separator. + if is_soft_hyphen_break(prev_text, cur_text) { + // Pop the trailing `-` (it may have whitespace after it from + // a previous trim, so re-trim). + while prev_text.ends_with(|c: char| c.is_whitespace()) { + prev_text.pop(); + } + prev_text.pop(); // the '-' + prev_text.push_str(cur_text.trim_start()); + continue; + } + let ends_open = !prev_trim.ends_with(|c: char| { + matches!( + c, + '.' | '!' | '?' | ':' | ';' | '”' | '"' | ')' | ']' | '。' | '』' | '」' + ) + }); + if ends_open && starts_lower { + prev_text.push(' '); + prev_text.push_str(cur_text.trim_start()); + continue; + } + } + out.push(block); + } + out +} + +#[cfg(test)] +mod tests { + use super::super::blocks::{Block, render_blocks}; + use super::super::headings::{build_heading_map, compute_body_size}; + use super::super::repetition::compute_header_footer_set; + use super::super::test_helpers::{ + header_footer_page, line, mono_line, page, page_with_graphics, stroke, styled_line, + }; + use super::*; + use crate::types::TextItem; + + #[test] + fn classify_emits_heading_and_paragraph() { + let p = page(vec![ + line("Title of the document goes here", 50.0, 50.0, 18.0, 18.0), + line("First sentence of the para-", 50.0, 80.0, 10.0, 10.0), + line("graph continues here.", 50.0, 92.0, 10.0, 10.0), + line("Another paragraph.", 50.0, 130.0, 10.0, 10.0), + ]); + let pages = vec![p]; + let body = compute_body_size(&pages); + let map = build_heading_map(&pages, body); + let blocks = classify_page(&pages[0], &map); + assert_eq!(blocks.len(), 3); + match &blocks[0] { + Block::Heading { level, text } => { + assert_eq!(*level, 1); + assert_eq!(text, "Title of the document goes here"); + } + other => panic!("expected heading, got {other:?}"), + } + match &blocks[1] { + Block::Paragraph { text: t, .. } => { + assert!(t.contains("paragraph continues"), "got: {t}"); + assert!(!t.contains("para-"), "de-hyphenation failed: {t}"); + } + other => panic!("expected paragraph, got {other:?}"), + } + match &blocks[2] { + Block::Paragraph { text: t, .. } => assert_eq!(t, "Another paragraph."), + other => panic!("expected paragraph, got {other:?}"), + } + } + + #[test] + fn paragraph_break_on_big_gap() { + let p = page(vec![ + line("Line A.", 50.0, 80.0, 10.0, 10.0), + line("Line B.", 50.0, 200.0, 10.0, 10.0), + ]); + let pages = vec![p]; + let body = compute_body_size(&pages); + let map = build_heading_map(&pages, body); + let blocks = classify_page(&pages[0], &map); + assert_eq!(blocks.len(), 2); + } + + #[test] + fn classify_emits_list_items() { + let p = page(vec![ + line("Intro line.", 50.0, 50.0, 10.0, 10.0), + line("• first bullet", 60.0, 80.0, 10.0, 10.0), + line("• second bullet", 60.0, 92.0, 10.0, 10.0), + line("◦ nested item", 72.0, 104.0, 10.0, 10.0), + line("• back to top", 60.0, 116.0, 10.0, 10.0), + ]); + let pages = vec![p]; + let body = compute_body_size(&pages); + let map = build_heading_map(&pages, body); + let blocks = classify_page(&pages[0], &map); + let list_items: Vec<&Block> = blocks + .iter() + .filter(|b| matches!(b, Block::ListItem { .. })) + .collect(); + assert_eq!(list_items.len(), 4); + if let Block::ListItem { level, text, .. } = list_items[0] { + assert_eq!(*level, 0); + assert_eq!(text, "first bullet"); + } else { + panic!(); + } + // The "- nested item" line is indented +12pt from the base bullet. + if let Block::ListItem { level, .. } = list_items[2] { + assert_eq!(*level, 1); + } else { + panic!(); + } + } + + #[test] + fn classify_emits_code_block() { + let p = page(vec![ + line("Intro line.", 50.0, 50.0, 10.0, 10.0), + mono_line(" let x = 1;", 80.0), + mono_line(" let y = x + 2;", 92.0), + line("After the code.", 50.0, 120.0, 10.0, 10.0), + ]); + let pages = vec![p]; + let body = compute_body_size(&pages); + let map = build_heading_map(&pages, body); + let blocks = classify_page(&pages[0], &map); + // Expect: Paragraph("Intro line."), CodeBlock(2 lines), Paragraph("After...") + assert_eq!(blocks.len(), 3); + match &blocks[1] { + Block::CodeBlock { lines, .. } => { + assert_eq!(lines.len(), 2); + assert!(lines[0].contains("let x = 1;")); + assert!(lines[1].contains("let y = x + 2;")); + } + other => panic!("expected code block, got {other:?}"), + } + let s = render_blocks(&blocks); + assert!(s.contains("```\n let x = 1;")); + assert!(s.ends_with("After the code.")); + } + + #[test] + fn detect_code_language_classifies_common_langs() { + let py = vec![ + "self.mm_list = sorted([x for x in self.files_list])".to_string(), + "self.mm_total = len(self.mm_list)".to_string(), + ]; + assert_eq!(detect_code_language(&py).as_deref(), Some("python")); + + let py_block = vec![ + "if item.total > 0:".to_string(), + " print('many')".to_string(), + ]; + assert_eq!(detect_code_language(&py_block).as_deref(), Some("python")); + + let json = vec![ + "{".to_string(), + " \"formatVersion\": \"1.0\",".to_string(), + "}".to_string(), + ]; + assert_eq!(detect_code_language(&json).as_deref(), Some("json")); + + let cpp = vec![ + "#include ".to_string(), + "std::vector v;".to_string(), + ]; + assert_eq!(detect_code_language(&cpp).as_deref(), Some("cpp")); + + // Ambiguous / unknown content stays untagged (bare fence). + let unknown = vec!["let x = 1;".to_string(), "let y = x + 2;".to_string()]; + assert_eq!(detect_code_language(&unknown), None); + } + + #[test] + fn classify_marks_paragraph_bold_when_all_lines_bold() { + let mut a = line("Bold line one.", 50.0, 50.0, 10.0, 10.0); + let mut b = line("bold continuation.", 50.0, 62.0, 10.0, 10.0); + // Mark the underlying spans as bold so per-span style detection sees + // it — the new inline pipeline reads from `spans`, not the per-line + // `all_bold` shortcut flag. + let bold_span = TextItem { + text: "x".into(), + font_name: Some("Arial-Bold".into()), + ..Default::default() + }; + a.spans = vec![bold_span.clone()]; + b.spans = vec![bold_span]; + a.all_bold = true; + b.all_bold = true; + let p = page(vec![a, b]); + let pages = vec![p]; + let body = compute_body_size(&pages); + let map = build_heading_map(&pages, body); + let blocks = classify_page(&pages[0], &map); + assert_eq!(blocks.len(), 1); + match &blocks[0] { + Block::Paragraph { bold, italic, .. } => { + assert!(*bold); + assert!(!*italic); + } + other => panic!("expected paragraph, got {other:?}"), + } + let s = render_blocks(&blocks); + assert!(s.starts_with("**") && s.ends_with("**"), "got: {s}"); + } + + #[test] + fn detects_simple_borderless_table() { + use super::super::test_helpers::line_with_spans; + let lines = vec![ + line_with_spans( + &[("Name", 50.0), ("Age", 150.0), ("City", 250.0)], + 100.0, + 10.0, + ), + line_with_spans( + &[("Alice", 50.0), ("30", 150.0), ("NYC", 250.0)], + 115.0, + 10.0, + ), + line_with_spans(&[("Bob", 50.0), ("25", 150.0), ("LA", 250.0)], 130.0, 10.0), + ]; + let p = page(lines); + let pages = vec![p]; + let body = compute_body_size(&pages); + let map = build_heading_map(&pages, body); + let blocks = classify_page(&pages[0], &map); + assert_eq!(blocks.len(), 1, "got: {blocks:?}"); + match &blocks[0] { + Block::Table { header, rows } => { + // Header isn't bold so no header row promoted. + assert!(header.is_none()); + assert_eq!(rows.len(), 3); + assert_eq!(rows[0][0], "Name"); + assert_eq!(rows[1][2], "NYC"); + } + other => panic!("expected table, got {other:?}"), + } + } + + #[test] + fn full_format_strips_header_footer() { + let pages = vec![ + header_footer_page(1, "Acme Confidential", "Page 1 of 2", "First page body."), + header_footer_page(2, "Acme Confidential", "Page 2 of 2", "Second page body."), + ]; + let body = compute_body_size(&pages); + let map = build_heading_map(&pages, body); + let set = compute_header_footer_set(&pages); + let blocks = classify_page_with_filters( + &pages[0], + &map, + &set, + &[], + crate::config::ImageMode::Placeholder, + &std::collections::HashSet::new(), + ); + let s = render_blocks(&blocks); + assert!(!s.contains("Acme Confidential"), "got: {s}"); + assert!(!s.contains("Page 1 of 2"), "got: {s}"); + assert!(s.contains("First page body.")); + } + + #[test] + fn classify_paragraph_with_mid_line_bold() { + // First line has a bold word mid-line → not uniformly styled; paragraph + // should emit baked-in `**bold**` inside the text and `bold=false` at + // the block level. + let a = styled_line( + &[ + ("a sentence with a", 50.0, Some("Arial")), + ("bold", 180.0, Some("Arial-Bold")), + ("word in it.", 230.0, Some("Arial")), + ], + 50.0, + 10.0, + ); + let p = page(vec![a]); + let pages = vec![p]; + let body = compute_body_size(&pages); + let map = build_heading_map(&pages, body); + let blocks = classify_page(&pages[0], &map); + assert_eq!(blocks.len(), 1, "got: {blocks:?}"); + match &blocks[0] { + Block::Paragraph { text, bold, italic } => { + assert!(!*bold, "mixed-style paragraph shouldn't set block bold"); + assert!(!*italic); + assert!(text.contains("**bold**"), "got: {text}"); + } + other => panic!("expected paragraph, got {other:?}"), + } + } + + #[test] + fn classify_list_item_strips_marker_under_emphasis() { + // Whole bullet line is bold (marker + text). Rendered text should be + // wrapped, with the marker dropped (the renderer prints it). + let l = styled_line( + &[ + ("•", 60.0, Some("Arial-Bold")), + ("important item", 80.0, Some("Arial-Bold")), + ], + 50.0, + 10.0, + ); + let p = page(vec![l]); + let pages = vec![p]; + let body = compute_body_size(&pages); + let map = build_heading_map(&pages, body); + let blocks = classify_page(&pages[0], &map); + assert_eq!(blocks.len(), 1); + match &blocks[0] { + Block::ListItem { text, .. } => { + assert_eq!(text, "**important item**"); + } + other => panic!("expected list item, got {other:?}"), + } + } + + #[test] + fn hr_emitted_between_lines_by_y_order() { + let lines = vec![ + line("before the rule", 50.0, 100.0, 10.0, 10.0), + line("after the rule", 50.0, 300.0, 10.0, 10.0), + ]; + // Stroke between the two lines, far from either's baseline. + let p = page_with_graphics(lines, vec![stroke(50.0, 200.0, 450.0, 200.0, 0.5)]); + let blocks = classify_page(&p, &[]); + let has_hr = blocks + .iter() + .position(|b| matches!(b, Block::HorizontalRule)); + assert!(has_hr.is_some(), "expected an HR block, got {blocks:?}"); + // HR must land between the two paragraphs, not before/after both. + let pos = has_hr.unwrap(); + assert!(pos > 0 && pos < blocks.len() - 1); + } +} diff --git a/crates/liteparse/src/markdown_layout/cross_region.rs b/crates/liteparse/src/markdown_layout/cross_region.rs new file mode 100644 index 0000000..a0472ce --- /dev/null +++ b/crates/liteparse/src/markdown_layout/cross_region.rs @@ -0,0 +1,764 @@ +//! Cross-region table re-merge. +//! +//! When the XY-cut commits a V-cut *through* a table (the column gutter of the +//! table looks like a layout gutter), the table's lines land in two or more +//! sibling leaves and no per-region detector ever sees the rows whole. This +//! pass runs before region grouping: it finds side-by-side leaf groups whose +//! baselines align across the cut, fuses the same-baseline lines back into +//! single rows, and validates that the fused zone actually classifies as a +//! table. Only on successful validation are the original lines replaced — a +//! failed candidate leaves the page untouched, so the V-cut's wins on +//! multi-column prose are preserved. + +use super::blocks::Block; +use super::tables::{TableRun, detect_tables}; +use crate::types::ProjectedLine; + +/// Minimum fraction of the smaller side's y-band that must overlap the other +/// side's. Side-by-side table halves overlap almost fully; a sidebar next to +/// the tail of a column does not. +const CR_MIN_Y_OVERLAP_FRAC: f32 = 0.5; +/// Minimum horizontal gap between the two sides (the sliced gutter). +const CR_MIN_COL_GAP_PT: f32 = 8.0; +/// Baseline-cluster tolerance as a fraction of the median line height. +const CR_ROW_TOL_FACTOR: f32 = 0.6; +/// At least this many baselines must align across the cut. +const CR_MIN_ALIGNED_ROWS: usize = 3; +/// ...and they must account for this fraction of the sparser side's lines. +const CR_MIN_ALIGNED_FRAC: f32 = 0.4; +/// A side "looks like prose" when it has at least this many lines... +const CR_PROSE_MIN_LINES: usize = 3; +/// ...whose median width fills this fraction of the side's width... +const CR_PROSE_WIDTH_FRAC: f32 = 0.7; +/// ...and whose summed line heights fill this fraction of the y-band. +const CR_PROSE_VFILL: f32 = 0.55; +/// A prose side is dominated by single-cell lines: when this fraction or more +/// of a side's lines split into ≥2 cells, it's a sliced table half (row +/// fragments with internal column gaps), not flowing text. +const CR_PROSE_MAX_MULTI_CELL_FRAC: f32 = 0.3; +/// Validation A: the detected table run(s) must cover this fraction of the +/// fused lines. +const CR_VALIDATE_MIN_COVERAGE: f32 = 0.6; +/// Validation A: reject a detected run when more than this fraction of its +/// non-empty cells run ≥ `CR_LONG_CELL_WORDS` words. Fused newspaper / +/// reference columns align into convincing grids, but their "cells" are +/// sentence fragments; real table cells are short. +const CR_VALIDATE_MAX_LONG_CELL_FRAC: f32 = 0.3; +const CR_LONG_CELL_WORDS: usize = 5; +/// Direct 2-col path: max chars for a left-column label cell. +const CR_LABEL_MAX_CHARS: usize = 60; +/// Direct 2-col path: minimum row count. +const CR_TWO_COL_MIN_ROWS: usize = 3; +/// A left-side line continues the previous row's label when its top is within +/// this multiple of line height of the previous label line's bottom. +const CR_LABEL_WRAP_GAP_FACTOR: f32 = 1.5; + +/// A validated cross-region merge: replace `lines[start..end]` with `merged` +/// (all sharing one synthetic region path) and hand `runs` (indices local to +/// `merged`) to the region classifier so the table emission is guaranteed. +pub(super) struct CrossRegionMerge { + pub(super) start: usize, + pub(super) end: usize, + pub(super) merged: Vec, + pub(super) runs: Vec, +} + +struct Leaf { + start: usize, + end: usize, + x0: f32, + x1: f32, + y0: f32, + y1: f32, +} + +/// Maximum x-overlap between sibling V-cut slices, as a fraction of the +/// narrower side's width. +const CR_SIBLING_MAX_X_OVERLAP_FRAC: f32 = 0.4; + +/// True when two leaves are direct children of the same region-tree node and +/// overlap only modestly in x — the signature of a V-cut whose slices were +/// widened by straddling rows. +fn sibling_v_cut_pair(a: &Leaf, b: &Leaf, lines: &[ProjectedLine]) -> bool { + let pa = &lines[a.start].region_path; + let pb = &lines[b.start].region_path; + if pa.len() != pb.len() || pa.is_empty() || pa[..pa.len() - 1] != pb[..pb.len() - 1] { + return false; + } + let x_overlap = a.x1.min(b.x1) - a.x0.max(b.x0); + let narrow = (a.x1 - a.x0).min(b.x1 - b.x0).max(1.0); + x_overlap < CR_SIBLING_MAX_X_OVERLAP_FRAC * narrow +} + +fn build_leaves(lines: &[ProjectedLine]) -> Vec { + let mut leaves = Vec::new(); + let mut s = 0; + while s < lines.len() { + let path = &lines[s].region_path; + let mut e = s + 1; + while e < lines.len() && lines[e].region_path == *path { + e += 1; + } + let (mut x0, mut x1) = (f32::INFINITY, f32::NEG_INFINITY); + let (mut y0, mut y1) = (f32::INFINITY, f32::NEG_INFINITY); + for l in &lines[s..e] { + x0 = x0.min(l.bbox.x); + x1 = x1.max(l.bbox.x + l.bbox.width); + y0 = y0.min(l.bbox.y); + y1 = y1.max(l.bbox.y + l.bbox.height); + } + leaves.push(Leaf { + start: s, + end: e, + x0, + x1, + y0, + y1, + }); + s = e; + } + leaves +} + +fn median(mut v: Vec) -> f32 { + if v.is_empty() { + return 0.0; + } + v.sort_by(|a, b| a.total_cmp(b)); + v[v.len() / 2] +} + +/// Longest common prefix of the region paths of all lines in the set. +fn common_path_prefix(lines: &[&ProjectedLine]) -> Vec { + let mut prefix: Vec = lines[0].region_path.clone(); + for l in &lines[1..] { + let n = prefix + .iter() + .zip(l.region_path.iter()) + .take_while(|(a, b)| a == b) + .count(); + prefix.truncate(n); + } + prefix +} + +/// Does this side's line set look like a column of flowing prose? Dense +/// vertical fill + lines that fill the column width. A table fragment has +/// short, sparse lines on at least one side. +fn side_prose_metrics(side: &[&ProjectedLine], side_x0: f32, side_x1: f32) -> (f32, f32) { + let side_width = (side_x1 - side_x0).max(1.0); + let med_width = median(side.iter().map(|l| l.bbox.width).collect()); + let (mut y0, mut y1) = (f32::INFINITY, f32::NEG_INFINITY); + let mut sum_h = 0.0f32; + for l in side { + y0 = y0.min(l.bbox.y); + y1 = y1.max(l.bbox.y + l.bbox.height); + sum_h += l.bbox.height; + } + (med_width / side_width, sum_h / (y1 - y0).max(1.0)) +} + +fn side_looks_like_prose(side: &[&ProjectedLine], side_x0: f32, side_x1: f32) -> bool { + if side.len() < CR_PROSE_MIN_LINES { + return false; + } + let (width_fill, vfill) = side_prose_metrics(side, side_x0, side_x1); + if width_fill < CR_PROSE_WIDTH_FRAC || vfill < CR_PROSE_VFILL { + return false; + } + // A sliced *wide* table's row-halves also fill their side's width and + // y-band, but they carry internal cell gaps; flowing prose doesn't. + let multi_cell = side + .iter() + .filter(|l| super::tables::split_cells(l).len() >= 2) + .count(); + (multi_cell as f32) < CR_PROSE_MAX_MULTI_CELL_FRAC * side.len() as f32 +} + +struct Cluster<'a> { + left: Vec<&'a ProjectedLine>, + right: Vec<&'a ProjectedLine>, +} + +impl<'a> Cluster<'a> { + fn members(&self) -> impl Iterator { + self.left.iter().chain(self.right.iter()) + } +} + +/// Cluster the set's lines into baseline rows: lines whose y-centers fall +/// within `tol` of the cluster seed share a row. +fn cluster_rows<'a>(set: &[(&'a ProjectedLine, bool)], tol: f32) -> Vec> { + let mut sorted: Vec<&(&ProjectedLine, bool)> = set.iter().collect(); + sorted.sort_by(|a, b| { + let ya = a.0.bbox.y + a.0.bbox.height * 0.5; + let yb = b.0.bbox.y + b.0.bbox.height * 0.5; + ya.total_cmp(&yb) + }); + let mut clusters: Vec<(f32, Cluster)> = Vec::new(); + for (line, is_left) in sorted { + let yc = line.bbox.y + line.bbox.height * 0.5; + match clusters.last_mut() { + Some((seed_y, c)) if (yc - *seed_y).abs() <= tol => { + if *is_left { + c.left.push(line); + } else { + c.right.push(line); + } + } + _ => { + let mut c = Cluster { + left: Vec::new(), + right: Vec::new(), + }; + if *is_left { + c.left.push(line); + } else { + c.right.push(line); + } + clusters.push((yc, c)); + } + } + } + clusters.into_iter().map(|(_, c)| c).collect() +} + +/// Fuse one baseline cluster into a single `ProjectedLine`: spans concatenate +/// (cell boundaries fall out of the span gaps in `split_cells`), text joins +/// with gap-proportional spacing, styles AND together. +fn fuse_cluster(cluster: &Cluster, path: &[u16]) -> ProjectedLine { + let mut members: Vec<&ProjectedLine> = cluster.members().copied().collect(); + members.sort_by(|a, b| a.bbox.x.total_cmp(&b.bbox.x)); + + let first = members[0]; + let mut fused = first.clone(); + fused.region_path = path.to_vec(); + for m in &members[1..] { + let prev_right = fused.bbox.x + fused.bbox.width; + let gap = (m.bbox.x - prev_right).max(0.0); + let approx_char_w = (fused.dominant_font_size * 0.5).max(2.0); + let n_spaces = ((gap / approx_char_w) as usize).clamp(2, 24); + fused.text.push_str(&" ".repeat(n_spaces)); + fused.text.push_str(&m.text); + fused.spans.extend(m.spans.iter().cloned()); + let x1 = (fused.bbox.x + fused.bbox.width).max(m.bbox.x + m.bbox.width); + let y1 = (fused.bbox.y + fused.bbox.height).max(m.bbox.y + m.bbox.height); + fused.bbox.x = fused.bbox.x.min(m.bbox.x); + fused.bbox.y = fused.bbox.y.min(m.bbox.y); + fused.bbox.width = x1 - fused.bbox.x; + fused.bbox.height = y1 - fused.bbox.y; + fused.all_bold &= m.all_bold; + fused.all_italic &= m.all_italic; + fused.all_mono &= m.all_mono; + fused.all_strike &= m.all_strike; + fused.font_size_is_estimated |= m.font_size_is_estimated; + } + fused.spans.sort_by(|a, b| a.x.total_cmp(&b.x)); + fused +} + +/// Validation A: the fused zone re-classifies as a table via the standard +/// detectors, covering most of its lines. +fn validate_via_detectors(merged: &[ProjectedLine]) -> Option> { + let runs = detect_tables(merged); + let covered: usize = runs + .iter() + .filter(|r| match &r.block { + Block::Table { header, rows } => { + let cols = header + .as_ref() + .map(|h| h.len()) + .or_else(|| rows.first().map(|row| row.len())) + .unwrap_or(0); + if cols < 2 { + return false; + } + let (mut cells, mut long) = (0usize, 0usize); + for row in rows { + for cell in row { + let t = cell.trim(); + if t.is_empty() { + continue; + } + cells += 1; + if t.split_whitespace().count() >= CR_LONG_CELL_WORDS { + long += 1; + } + } + } + cells > 0 && (long as f32) <= CR_VALIDATE_MAX_LONG_CELL_FRAC * cells as f32 + } + _ => false, + }) + .map(|r| r.end - r.start) + .sum(); + if covered as f32 >= CR_VALIDATE_MIN_COVERAGE * merged.len() as f32 { + Some(runs) + } else { + None + } +} + +/// Validation B: direct 2-column construction for label/description shapes +/// the standard detectors reject (e.g. labels with list-marker prefixes — +/// `is_label_like` is intentionally strict in normal flow, but here the V-cut +/// geometry has already established two columns). Rows are anchored on +/// left-side lines; a left line within wrap distance of the previous one +/// continues the label cell, right-side lines append to the current row's +/// description cell. +fn try_two_col_direct(clusters: &[Cluster], merged_len: usize, tol: f32) -> Option> { + let mut rows: Vec<(String, String, bool)> = Vec::new(); // (left, right, bold) + let mut last_label_bottom: Option = None; + + for cluster in clusters { + if !cluster.left.is_empty() { + let text = cluster + .left + .iter() + .map(|l| l.text.trim()) + .collect::>() + .join(" "); + let top = cluster + .left + .iter() + .map(|l| l.bbox.y) + .fold(f32::INFINITY, f32::min); + let bottom = cluster + .left + .iter() + .map(|l| l.bbox.y + l.bbox.height) + .fold(f32::NEG_INFINITY, f32::max); + let h = (bottom - top).max(1.0); + let wraps = match (last_label_bottom, rows.last()) { + (Some(lb), Some(_)) => top - lb <= CR_LABEL_WRAP_GAP_FACTOR * h.min(tol * 2.0), + _ => false, + }; + if wraps { + let row = rows.last_mut().unwrap(); + if !row.0.is_empty() { + row.0.push(' '); + } + row.0.push_str(&text); + } else { + let bold = cluster.left.iter().all(|l| l.all_bold); + rows.push((text, String::new(), bold)); + } + last_label_bottom = Some(bottom); + } + if !cluster.right.is_empty() { + let text = cluster + .right + .iter() + .map(|l| l.text.trim()) + .collect::>() + .join(" "); + if rows.is_empty() { + rows.push((String::new(), String::new(), false)); + } + let row = rows.last_mut().unwrap(); + if !row.1.is_empty() { + row.1.push(' '); + } + row.1.push_str(&text); + } + } + + if rows.len() < CR_TWO_COL_MIN_ROWS { + return None; + } + // Labels must stay label-shaped after wrap joins; a left side of long + // sentences is prose the anti-prose gate missed. + if rows + .iter() + .any(|(l, _, _)| l.chars().count() > CR_LABEL_MAX_CHARS) + { + return None; + } + let both = rows + .iter() + .filter(|(l, r, _)| !l.is_empty() && !r.is_empty()) + .count(); + if both < 2 { + return None; + } + + // First row promotes to header when bold or when both cells are short + // header-ish words ("Area" / "Competence"). + let header = { + let (l, r, bold) = &rows[0]; + if !l.is_empty() + && !r.is_empty() + && (*bold || (l.chars().count() <= 20 && r.chars().count() <= 20)) + { + Some(vec![l.clone(), r.clone()]) + } else { + None + } + }; + let body: Vec> = rows + .iter() + .skip(if header.is_some() { 1 } else { 0 }) + .map(|(l, r, _)| vec![l.clone(), r.clone()]) + .collect(); + if body.len() < 2 { + return None; + } + Some(vec![TableRun { + start: 0, + end: merged_len, + body_start: if header.is_some() { 1 } else { 0 }, + block: Block::Table { header, rows: body }, + }]) +} + +/// Find one validated cross-region table merge on this page, or `None`. +/// Callers may re-invoke on the spliced result to catch multiple sliced +/// tables per page. +pub(super) fn find_cross_region_table_merge(lines: &[ProjectedLine]) -> Option { + let debug = *super::flags::DEBUG_CROSS_REGION; + let leaves = build_leaves(lines); + if debug { + for (k, l) in leaves.iter().enumerate() { + eprintln!( + "[cross-region] leaf {k}: lines [{},{}) x=[{:.1},{:.1}] y=[{:.1},{:.1}] path={:?}", + l.start, l.end, l.x0, l.x1, l.y0, l.y1, lines[l.start].region_path + ); + } + } + if leaves.len() < 2 { + return None; + } + + for i in 0..leaves.len() { + for j in (i + 1)..leaves.len() { + let (a, b) = (&leaves[i], &leaves[j]); + let (left, right) = if a.x1 <= b.x0 - CR_MIN_COL_GAP_PT { + (a, b) + } else if b.x1 <= a.x0 - CR_MIN_COL_GAP_PT { + (b, a) + } else if sibling_v_cut_pair(a, b, lines) { + // Direct siblings of one region-tree node that overlap in y + // can only come from a V-cut. Their bbox x-extents may overlap + // (table rows straddling the cut widen the leaf), so the + // disjointness test above misses them. + if a.x0 <= b.x0 { (a, b) } else { (b, a) } + } else { + continue; + }; + let ov = left.y1.min(right.y1) - left.y0.max(right.y0); + let min_h = (left.y1 - left.y0).min(right.y1 - right.y0).max(1.0); + if ov < CR_MIN_Y_OVERLAP_FRAC * min_h { + continue; + } + // For disjoint sides this is the gutter midpoint; for overlapping + // sibling slices it's the midpoint of the contested band. + let cut_x = (left.x1.min(right.x1) + right.x0.max(left.x0)) * 0.5; + // Union band: a tall side may pair with the first of several + // stacked leaves on the other side; the extension below pulls the + // rest in, so the band must cover both sides fully. + let band = (left.y0.min(right.y0) - 2.0, left.y1.max(right.y1) + 2.0); + + // Extend the pair to every leaf living inside the overlap band and + // entirely on one side of the cut (a side may itself be H-cut into + // multiple leaves). + let mut set_leaves: Vec = Vec::new(); + for (k, leaf) in leaves.iter().enumerate() { + let leaf_h = (leaf.y1 - leaf.y0).max(1.0); + let leaf_ov = leaf.y1.min(band.1) - leaf.y0.max(band.0); + if leaf_ov < 0.5 * leaf_h { + continue; + } + // The leaf must sit predominantly on one side of the cut: + // its overhang across the cut stays within the straddle + // allowance (widened slices), measured against its own width. + let width = (leaf.x1 - leaf.x0).max(1.0); + let center = (leaf.x0 + leaf.x1) * 0.5; + let overhang = if center < cut_x { + (leaf.x1 - cut_x).max(0.0) + } else { + (cut_x - leaf.x0).max(0.0) + }; + if overhang <= CR_SIBLING_MAX_X_OVERLAP_FRAC * width { + set_leaves.push(k); + } + } + if set_leaves.len() < 2 { + continue; + } + + // The set's line ranges must tile a contiguous range — a foreign + // leaf interleaved in reading order means this isn't a clean + // sliced zone. + let lo = set_leaves.iter().map(|&k| leaves[k].start).min().unwrap(); + let hi = set_leaves.iter().map(|&k| leaves[k].end).max().unwrap(); + let covered: usize = set_leaves + .iter() + .map(|&k| leaves[k].end - leaves[k].start) + .sum(); + if covered != hi - lo { + continue; + } + + // Split the set's lines into sides of the cut. + let mut set: Vec<(&ProjectedLine, bool)> = Vec::new(); + for &k in &set_leaves { + let leaf = &leaves[k]; + let is_left = (leaf.x0 + leaf.x1) * 0.5 < cut_x; + for l in &lines[leaf.start..leaf.end] { + set.push((l, is_left)); + } + } + let left_lines: Vec<&ProjectedLine> = + set.iter().filter(|(_, s)| *s).map(|(l, _)| *l).collect(); + let right_lines: Vec<&ProjectedLine> = + set.iter().filter(|(_, s)| !*s).map(|(l, _)| *l).collect(); + if left_lines.is_empty() || right_lines.is_empty() { + continue; + } + + // Anti-prose gate: two side-by-side prose columns must stay split. + let lx0 = left_lines + .iter() + .map(|l| l.bbox.x) + .fold(f32::INFINITY, f32::min); + let rx0 = right_lines + .iter() + .map(|l| l.bbox.x) + .fold(f32::INFINITY, f32::min); + let lx1 = left_lines + .iter() + .map(|l| l.bbox.x + l.bbox.width) + .fold(f32::NEG_INFINITY, f32::max); + let rx1 = right_lines + .iter() + .map(|l| l.bbox.x + l.bbox.width) + .fold(f32::NEG_INFINITY, f32::max); + if debug { + let (lw, lv) = side_prose_metrics(&left_lines, lx0, lx1); + let (rw, rv) = side_prose_metrics(&right_lines, rx0, rx1); + eprintln!( + "[cross-region] candidate cut@{cut_x:.1}: left n={} wfill={lw:.2} vfill={lv:.2} | right n={} wfill={rw:.2} vfill={rv:.2}", + left_lines.len(), + right_lines.len() + ); + } + // Either side reading as flowing prose means the V-cut was right: + // a body column next to a sidebar / second column must stay split. + if side_looks_like_prose(&left_lines, lx0, lx1) + || side_looks_like_prose(&right_lines, rx0, rx1) + { + if debug { + eprintln!("[cross-region] reject cut@{cut_x:.1}: a side is prose-like"); + } + continue; + } + + // Row alignment across the cut. + let tol = CR_ROW_TOL_FACTOR + * median(set.iter().map(|(l, _)| l.bbox.height).collect()).max(1.0); + let clusters = cluster_rows(&set, tol); + let aligned = clusters + .iter() + .filter(|c| !c.left.is_empty() && !c.right.is_empty()) + .count(); + let min_side = left_lines.len().min(right_lines.len()); + if aligned < CR_MIN_ALIGNED_ROWS + || (aligned as f32) < CR_MIN_ALIGNED_FRAC * min_side as f32 + { + if debug { + eprintln!( + "[cross-region] reject cut@{cut_x:.1}: aligned={aligned} of min_side={min_side}" + ); + } + continue; + } + + // Fuse and validate. + let all: Vec<&ProjectedLine> = set.iter().map(|(l, _)| *l).collect(); + let path = common_path_prefix(&all); + let merged: Vec = + clusters.iter().map(|c| fuse_cluster(c, &path)).collect(); + + let runs = validate_via_detectors(&merged) + .or_else(|| try_two_col_direct(&clusters, merged.len(), tol)); + let Some(runs) = runs else { + if debug { + eprintln!( + "[cross-region] reject cut@{cut_x:.1}: fused zone failed table validation" + ); + } + continue; + }; + if debug { + eprintln!( + "[cross-region] MERGE cut@{cut_x:.1}: lines [{lo},{hi}) -> {} fused rows, {} run(s)", + merged.len(), + runs.len() + ); + } + return Some(CrossRegionMerge { + start: lo, + end: hi, + merged, + runs, + }); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::markdown_layout::test_helpers::{line, line_with_spans}; + + fn at_region(mut l: ProjectedLine, path: &[u16]) -> ProjectedLine { + l.region_path = path.to_vec(); + l + } + + /// A 3-col numeric table sliced by a V-cut: label column in one leaf, + /// two numeric columns in the other. Validation A (standard detectors on + /// the fused rows) should accept. + #[test] + fn sliced_numeric_table_is_merged() { + let mut lines = Vec::new(); + // Left leaf: row labels. + for (i, label) in ["MANILA", "CEBU", "CAGAYAN DE ORO", "SUBIC"] + .iter() + .enumerate() + { + let y = 100.0 + i as f32 * 20.0; + lines.push(at_region( + line_with_spans(&[(label, 50.0)], y, 10.0), + &[0, 0], + )); + } + // Right leaf: two numeric columns per row. + for (i, (a, b)) in [ + ("2454", "6125"), + ("1138", "79500"), + ("958", "13196"), + ("313", "136"), + ] + .iter() + .enumerate() + { + let y = 100.0 + i as f32 * 20.0; + lines.push(at_region( + line_with_spans(&[(a, 200.0), (b, 280.0)], y, 10.0), + &[0, 1], + )); + } + let m = find_cross_region_table_merge(&lines).expect("should merge"); + assert_eq!((m.start, m.end), (0, 8)); + assert_eq!(m.merged.len(), 4); + assert!(!m.runs.is_empty()); + // Every fused row carries the label and both numbers. + assert!(m.merged[0].text.contains("MANILA")); + assert!(m.merged[0].text.contains("2454")); + assert!(m.merged[0].text.contains("6125")); + } + + /// Two side-by-side prose columns with coincidentally aligned baselines + /// must NOT merge — that's the layout the V-cut exists to protect. + #[test] + fn two_column_prose_is_not_merged() { + let mut lines = Vec::new(); + for i in 0..8 { + let y = 100.0 + i as f32 * 12.0; + lines.push(at_region( + line("the quick brown fox jumps over it", 50.0, y, 10.0, 10.0), + &[0, 0], + )); + } + for i in 0..8 { + let y = 100.0 + i as f32 * 12.0; + lines.push(at_region( + line("a second column of body text here", 250.0, y, 10.0, 10.0), + &[0, 1], + )); + } + assert!(find_cross_region_table_merge(&lines).is_none()); + } + + /// Label / description shape with list-marker labels (the doc-146 shape): + /// standard detectors reject it, the direct 2-col path accepts. + #[test] + fn sliced_label_description_table_uses_direct_path() { + let mut lines = Vec::new(); + lines.push(at_region(line("Area", 50.0, 100.0, 10.0, 10.0), &[0, 0])); + for (i, label) in [ + "1. Embodying values", + "2. Embracing complexity", + "3. Envisioning futures", + ] + .iter() + .enumerate() + { + let y = 150.0 + i as f32 * 50.0; + lines.push(at_region(line(label, 50.0, y, 10.0, 10.0), &[0, 0])); + } + lines.push(at_region( + line("Competence", 250.0, 100.0, 10.0, 10.0), + &[0, 1], + )); + for (i, desc) in [ + "1.1 Valuing sustainability 1.2 Supporting fairness", + "2.1 Systems thinking 2.2 Critical thinking", + "3.1 Futures literacy 3.2 Adaptability", + ] + .iter() + .enumerate() + { + let y = 150.0 + i as f32 * 50.0; + lines.push(at_region(line(desc, 250.0, y, 10.0, 10.0), &[0, 1])); + } + let m = find_cross_region_table_merge(&lines).expect("should merge via direct path"); + assert_eq!(m.runs.len(), 1); + match &m.runs[0].block { + Block::Table { header, rows } => { + assert_eq!( + header.as_deref(), + Some(&["Area".to_string(), "Competence".to_string()][..]) + ); + assert_eq!(rows.len(), 3); + assert!(rows[0][1].contains("1.1 Valuing sustainability")); + } + other => panic!("expected table, got {other:?}"), + } + } + + /// A single region (no V-cut) never merges. + #[test] + fn single_leaf_is_untouched() { + let lines: Vec = (0..6) + .map(|i| { + at_region( + line("text", 50.0, 100.0 + i as f32 * 12.0, 10.0, 10.0), + &[0], + ) + }) + .collect(); + assert!(find_cross_region_table_merge(&lines).is_none()); + } + + /// Vertically stacked leaves (H-cut siblings) never pair: no y-overlap. + #[test] + fn stacked_leaves_do_not_pair() { + let mut lines = Vec::new(); + for i in 0..4 { + lines.push(at_region( + line("alpha beta", 50.0, 100.0 + i as f32 * 12.0, 10.0, 10.0), + &[0], + )); + } + for i in 0..4 { + lines.push(at_region( + line("gamma delta", 50.0, 300.0 + i as f32 * 12.0, 10.0, 10.0), + &[1], + )); + } + assert!(find_cross_region_table_merge(&lines).is_none()); + } +} diff --git a/crates/liteparse/src/markdown_layout/flags.rs b/crates/liteparse/src/markdown_layout/flags.rs new file mode 100644 index 0000000..3fdab7e --- /dev/null +++ b/crates/liteparse/src/markdown_layout/flags.rs @@ -0,0 +1,20 @@ +//! Debug-logging flags, read once from the environment. +//! +//! Each flag mirrors a `LITEPARSE_*` env var and is `true` when the variable is +//! set. `env::var` allocates on every call, so these cache the lookup in a +//! `LazyLock` rather than re-reading on per-line hot paths. + +use std::sync::LazyLock; + +macro_rules! env_set_flag { + ($(#[$m:meta])* $name:ident, $var:literal) => { + $(#[$m])* + pub(super) static $name: LazyLock = + LazyLock::new(|| std::env::var($var).is_ok()); + }; +} + +env_set_flag!(DEBUG_MD, "LITEPARSE_DEBUG_MD"); +env_set_flag!(DEBUG_TABLE, "LITEPARSE_DEBUG_TABLE"); +env_set_flag!(DEBUG_RULED, "LITEPARSE_DEBUG_RULED"); +env_set_flag!(DEBUG_CROSS_REGION, "LITEPARSE_DEBUG_CROSS_REGION"); diff --git a/crates/liteparse/src/markdown_layout/headings.rs b/crates/liteparse/src/markdown_layout/headings.rs new file mode 100644 index 0000000..97a1f83 --- /dev/null +++ b/crates/liteparse/src/markdown_layout/headings.rs @@ -0,0 +1,1020 @@ +use crate::types::{OutlineTarget, ParsedPage, ProjectedLine, StructNode}; + +use super::classify::is_rotated_line; +use super::inline::line_all_bold; +use super::paragraphs::continues_paragraph; +use super::tables::{TABLE_MIN_COLUMNS, split_cells}; + +/// Tolerance in points for "is this size larger than the body size". +pub(super) const HEADING_SIZE_EPSILON: f32 = 0.5; + +/// Margin (points) a *height-estimated* size must clear over body before it +/// counts as a heading size. When PDFium bakes the font size into the text +/// matrix, `dominant_font_size` falls back to bbox height, which jitters ±1pt +/// line-to-line (descenders, parens, capitals). The small `HEADING_SIZE_EPSILON` +/// then admits jittered body lines as a bogus heading level (e.g. 10pt over a +/// 9pt body), promoting every tall-glyph body line to a heading. Real headings +/// in these docs are ≥2pt over body, so a wider margin filters the jitter while +/// keeping genuine headings. +pub(super) const ESTIMATED_HEADING_SIZE_MARGIN: f32 = 1.5; + +/// Cap on heading levels (matches plan: H1..H6). +pub(super) const MAX_HEADING_LEVELS: usize = 6; + +/// A normalized line text that recurs at least this many times among +/// heading-candidate lines (size > body) is treated as chart/figure label +/// spam, not a heading, and is excluded from the heading-size map. Example: +/// the "Input-Input Layer5" attention-plot labels in academic papers render +/// at 23–28pt (far above body) and would otherwise hijack H1..H3, demoting the +/// real title and section headings. Genuine headings are not byte-identical +/// across the document, so this never drops a real heading size *unless* that +/// size is occupied solely by the repeated string — and the exclusion is +/// per-line, so a repeated string sharing a font size with real headings +/// leaves that level intact. +pub(super) const REPEATED_HEADING_LINE_MIN: usize = 3; + +/// Normalize a line's text for repeat-detection: collapse internal whitespace +/// and lowercase, so trivially-different renderings of the same label string +/// still hash together. +fn normalize_repeat_key(text: &str) -> String { + text.split_whitespace() + .collect::>() + .join(" ") + .to_lowercase() +} + +/// Tighter tolerance for matching against the heading-size map. Keeps the +/// heading detector strict so descender-induced height jitter doesn't promote +/// regular body lines to headings. +pub(super) const FONT_SIZE_HEADING_TOLERANCE: f32 = 0.6; + +/// Maximum characters in a "bold body-size heading" candidate. Section +/// headings like "Abstract", "1 Introduction", "2.1 Related Work" are short; +/// a bold body-size line longer than this is almost always a bold *sentence* +/// inside a paragraph, not a heading. +pub(super) const BOLD_HEADING_MAX_CHARS: usize = 80; +/// Maximum length for a font-size-promoted heading. Looser than +/// `BOLD_HEADING_MAX_CHARS` (which gates the bold-detection path) because +/// genuine title lines can run long. Tight enough to reject footnotes +/// and citations that score slightly above body size — those are +/// multi-clause sentences that exceed any reasonable label length. +pub(super) const HEADING_MAX_TEXT_CHARS: usize = 140; + +/// Recognize a section-number prefix like "1", "1.5", "A.2", "Sec. 2", +/// "Ch. 3", "§4". Used to exempt numbered subsection headings from the +/// bold-heading run-in guard (which would otherwise reject "1.5. Migrant +/// Workers..." because of the embedded ". " after the section number). +pub(super) fn is_section_number_prefix(s: &str) -> bool { + let t = s.trim(); + if t.is_empty() { + return false; + } + // Strip optional "Sec", "Ch", "Chapter", "§" lead-in. + let stripped = t + .strip_prefix('§') + .or_else(|| { + for lead in [ + "Sec.", "Sec", "Ch.", "Ch", "Chapter", "Chap.", "Chap", "Part", + ] { + if let Some(rest) = t.strip_prefix(lead) { + return Some(rest.trim_start()); + } + } + None + }) + .unwrap_or(t); + // Remaining must be a dotted numeric / alphanumeric section identifier. + // Examples accepted: "1", "1.5", "1.5.2", "A.2", "IV". + if stripped.is_empty() { + // "§" alone counts as a section marker context. + return true; + } + let mut saw_digit = false; + let mut prev_dot = true; + for c in stripped.chars() { + if c.is_ascii_digit() { + saw_digit = true; + prev_dot = false; + } else if c.is_ascii_uppercase() && !prev_dot { + // Allow a leading letter ("A.2") but not letters mid-segment. + return false; + } else if c.is_ascii_uppercase() { + prev_dot = false; + } else if c == '.' { + if prev_dot { + return false; + } + prev_dot = true; + } else { + return false; + } + } + saw_digit +} + +/// Recognize an attribution / annotation prefix like "Source:", +/// "Note:", "Adapted from", "Reproduced from", "Image:" — these +/// commonly appear as isolated bold-styled lines beneath charts and +/// figures, but they are never section headings. +pub(super) fn is_attribution_line(text: &str) -> bool { + let t = text.trim_start(); + const PREFIXES: &[&str] = &[ + "Source:", + "Sources:", + "Note:", + "Notes:", + "Adapted from", + "Reproduced from", + "Reprinted from", + "Image:", + "Image source:", + "Photo:", + "Photo credit:", + "Credit:", + "Caption:", + ]; + for p in PREFIXES { + if t.len() >= p.len() && t.is_char_boundary(p.len()) && t[..p.len()].eq_ignore_ascii_case(p) + { + return true; + } + } + false +} + +/// Recognize caption-prefix lines like "Figure 7", "Fig. 12.", "Table 3:", +/// "Tab. 5", "Equation (4)" — these routinely render in a slightly distinct +/// font/size that lands them in the heading_map and gets them promoted to a +/// document-level heading. We want to keep them as plain paragraphs. +pub(super) fn is_caption_line(text: &str) -> bool { + let t = text.trim_start(); + // Try each known prefix: must be followed by a number (optionally with + // separators) within the first ~20 chars. + const PREFIXES: &[&str] = &[ + "Figure", + "Figures", + "Fig.", + "Fig ", + "Table", + "Tables", + "Tab.", + "Tab ", + "Equation", + "Eq.", + "Eq ", + "Scheme", + "Chart", + "Plate", + "Photo", + "Algorithm", + "Listing", + ]; + let lower_t_first_word: String = t + .chars() + .take_while(|c| c.is_alphabetic() || *c == '.') + .collect(); + for p in PREFIXES { + let p_trim = p.trim_end(); + if lower_t_first_word.eq_ignore_ascii_case(p_trim) { + // Look at what follows the prefix word. + let rest = t[lower_t_first_word.len()..].trim_start(); + // Allow a leading "(" then digit, or directly a digit / roman numeral. + let mut chars = rest.chars(); + if let Some(c0) = chars.next() + && (c0.is_ascii_digit() + || (c0 == '(' && chars.next().is_some_and(|c| c.is_ascii_digit())) + || matches!(c0, 'I' | 'V' | 'X' | 'L' | 'C')) + { + return true; + } + } + } + false +} + +/// Trailing page-number extractor for TOC-entry shaped lines. Returns the +/// numeric value of the trailing page number when the line looks like a TOC +/// entry: alphabetic body, separator that *includes whitespace* (to reject +/// decimals like "94.2"), then a trailing arabic 1–4 digit number. Roman +/// numerals are accepted in `looks_like_toc_entry` but not returned for the +/// monotonic-sequence check. +pub(super) fn toc_entry_arabic_number(text: &str) -> Option { + let s = text.trim(); + if s.is_empty() { + return None; + } + let chars: Vec = s.chars().collect(); + let n = chars.len(); + let mut tail_start = n; + while tail_start > 0 && chars[tail_start - 1].is_ascii_digit() { + tail_start -= 1; + } + let tail_len = n - tail_start; + if tail_len == 0 || tail_len > 4 { + return None; + } + // Separator: ≥1 whitespace + optional '.' leaders. The mandatory + // whitespace rules out decimals ("94.2") and inline number suffixes + // ("Recall3 7 94.2") that aren't TOC entries. + let mut sep_end = tail_start; + let mut saw_ws = false; + while sep_end > 0 { + let c = chars[sep_end - 1]; + if c.is_whitespace() { + sep_end -= 1; + saw_ws = true; + } else if c == '.' { + sep_end -= 1; + } else { + break; + } + } + if !saw_ws { + return None; + } + // Body must (a) carry meaningful alpha content and (b) NOT end with a + // digit — that would mean we sliced a multi-part number ("vol 5 12"). + let body = &chars[..sep_end]; + let alpha = body.iter().filter(|c| c.is_alphabetic()).count(); + // Require ≥8 alpha chars in the body. This keeps real one-word headings + // like "Chapter 7" / "Section 4" out of the TOC bucket while still + // accepting typical TOC entries ("Introduction 7", "Conclusion 127", ...). + if alpha < 8 { + return None; + } + if body + .iter() + .rev() + .find(|c| !c.is_whitespace()) + .is_some_and(|c| c.is_ascii_digit()) + { + return None; + } + let tail: String = chars[tail_start..].iter().collect(); + tail.parse::().ok() +} + +/// Roman-or-arabic TOC-entry detector — used by tests and the TOC-page +/// detector to count TOC-shaped lines that the strict arabic extractor +/// above misses (e.g. "Author's Note ... ix"). +pub(super) fn looks_like_toc_entry(text: &str) -> bool { + if toc_entry_arabic_number(text).is_some() { + return true; + } + // Try Roman numerals at the tail. + let s = text.trim(); + let chars: Vec = s.chars().collect(); + let n = chars.len(); + let mut tail_start = n; + while tail_start > 0 + && matches!( + chars[tail_start - 1], + 'i' | 'v' | 'x' | 'l' | 'c' | 'd' | 'm' | 'I' | 'V' | 'X' | 'L' | 'C' | 'D' | 'M' + ) + { + tail_start -= 1; + } + let tail_len = n - tail_start; + if !(2..=6).contains(&tail_len) { + return false; + } + let mut sep_end = tail_start; + let mut saw_ws = false; + while sep_end > 0 { + let c = chars[sep_end - 1]; + if c.is_whitespace() { + sep_end -= 1; + saw_ws = true; + } else if c == '.' { + sep_end -= 1; + } else { + break; + } + } + if !saw_ws { + return false; + } + let alpha = chars[..sep_end] + .iter() + .filter(|c| c.is_alphabetic()) + .count(); + alpha >= 5 +} + +/// Returns true if `text` is the canonical title of a TOC page: "Contents", +/// "Table of Contents", "Index", etc. We use this to let the TOC's own title +/// through the heading promotion that's otherwise suppressed on TOC pages. +pub(super) fn is_toc_title(text: &str) -> bool { + let t = text.trim().trim_end_matches(':').to_ascii_lowercase(); + matches!( + t.as_str(), + "contents" + | "table of contents" + | "table of content" + | "index" + | "list of figures" + | "list of tables" + | "table of figures" + | "toc" + ) +} + +/// TOC-page detection. A page is a TOC iff it carries ≥4 arabic-trailing +/// TOC-entry lines whose page numbers form a *mostly non-decreasing* +/// sequence (≥70% of adjacent pairs satisfy `next >= prev`). The +/// monotonicity check is what separates real TOCs from chart/graph pages +/// with random axis-value tails. Lines whose tail isn't arabic but matches +/// the looser `looks_like_toc_entry` (e.g. roman numerals "ix", "xi") still +/// count toward the row floor — they just don't participate in the +/// monotonicity check. +pub(super) fn page_is_toc(page: &ParsedPage) -> bool { + let mut nums: Vec = Vec::new(); + let mut total_toc_like = 0usize; + for line in &page.projected_lines { + if is_rotated_line(line) { + continue; + } + if let Some(n) = toc_entry_arabic_number(&line.text) { + nums.push(n); + total_toc_like += 1; + } else if looks_like_toc_entry(&line.text) { + total_toc_like += 1; + } + } + if total_toc_like < 4 || nums.len() < 3 { + return false; + } + let mut nondec = 0usize; + for w in nums.windows(2) { + if w[1] >= w[0] { + nondec += 1; + } + } + let frac = nondec as f32 / (nums.len() - 1) as f32; + frac >= 0.7 +} + +/// Returns true if `line` looks like a section heading rendered in body-size +/// bold text (a very common style for academic / technical PDFs where every +/// "real" heading uses the same font size as body, distinguished only by +/// weight). Requires: +/// - uniformly bold across all spans +/// - short (≤ `BOLD_HEADING_MAX_CHARS`) +/// - paragraph-break gap above (or first line on the page) +/// - paragraph-break gap below (or last line on the page) +pub(super) fn looks_like_bold_heading( + line: &ProjectedLine, + prev: Option<&ProjectedLine>, + next: Option<&ProjectedLine>, +) -> bool { + let text = line.text.trim(); + if text.is_empty() || text.chars().count() > BOLD_HEADING_MAX_CHARS { + return false; + } + // Captions ("Figure 7", "Table 3.") are commonly bold body-sized lines + // that would otherwise satisfy every other rule here. Keep them as + // paragraphs so they don't appear in the heading hierarchy. + if is_caption_line(text) { + return false; + } + // Attribution lines ("Source: …", "Note: …", "Adapted from …") commonly + // appear as isolated bold lines beneath charts/figures. Never headings. + if is_attribution_line(text) { + return false; + } + // Accept a line whose spans are all bold (italic may vary) and non-mono. + // A strict single-style check would reject headings that mix bold and + // bold-italic spans (e.g. "**4** ***Foo*** **Bar**"), which are common + // for numbered section headings. + if !line_all_bold(line) { + return false; + } + // Run-in labels — a bold lead-in that ends in a period and flows straight + // into the body sentence ("United Kingdom.", "Model merging.") — read as + // emphasis, not block headings. A trailing '.' is the giveaway; real + // section headings (numbered or titled) don't terminate in a period. + // ':' is deliberately allowed ("Reference frameworks:"). + if text.ends_with('.') { + return false; + } + // Run-in label: a bold line whose first sentence ends in ". " followed + // by body prose (multi-word continuation that doesn't look like a + // sub-numbered heading) is a paragraph lead, not a section heading. + // Tight constraints to avoid rejecting numbered subsection headings like + // "1.5. Migrant Workers" or "Sec. 2. Method": + // - ≥3 space-separated words after the break + // - line ends with mid-word "-" (wrap continuation) OR is >50 chars + // - first char after the break is uppercase ASCII letter + let run_in_break = text + .find(". ") + .map(|p| (p, 2)) + .or_else(|| text.find(": ").map(|p| (p, 2))); + if let Some((pos, sep_len)) = run_in_break { + let before = &text[..pos]; + // Section-number prefix exemption: when the segment before ". " is a + // numbered section identifier (e.g. "1", "1.5", "A.2", "Sec. 2", + // "Ch. 3", "§4"), this is a numbered subsection heading like + // "1.5. Migrant Workers..." — the period is part of the section + // number, not a sentence terminator. Skip the run-in rejection. + let is_section_number = is_section_number_prefix(before); + let after = text[pos + sep_len..].trim(); + let starts_upper = after.chars().next().is_some_and(|c| c.is_ascii_uppercase()); + let word_count = after.split_whitespace().count(); + let ends_hyphen = text.trim_end().ends_with('-'); + if !is_section_number + && starts_upper + && ((word_count >= 2 && ends_hyphen) || (word_count >= 3 && text.chars().count() > 50)) + { + if *super::flags::DEBUG_MD { + eprintln!( + "[MD bold-heading REJECT run-in] '{}' (pos={} word_count={} ends_hyphen={} len={})", + text.chars().take(80).collect::(), + pos, + word_count, + ends_hyphen, + text.chars().count() + ); + } + return false; + } + } + // Block headings are capitalized. A bold line starting lowercase is almost + // always a stray bold word or a borderless table cell, not a heading. + if text.chars().next().is_some_and(|c| c.is_lowercase()) { + return false; + } + // Reject bold-uniform lines dominated by digits / punctuation — these are + // almost always cells inside a tabular layout the table detector didn't + // pick up (results tables, scoreboards, math display). A real section + // heading is mostly letters: "1 Introduction" passes (~92% alpha across + // non-whitespace chars), "47.5 14" doesn't (0%), "BLEU-1 25.87" doesn't. + if alpha_ratio(text) < 0.5 { + return false; + } + // Reject when the line itself looks tabular: ≥3 cells separated by font-size + // gaps. A bold body-size line with that many cell tracks is almost always a + // table header row, not a section heading. Without this guard, multi-line + // table headers ("Model Method ... | F1 BLEU-1 F1 BLEU-1 ...") get promoted + // to H3 instead of being absorbed by the table detector. + // Tabular shape rejection. Two passes because the projection sometimes + // collapses a wide multi-column line into a single span with column- + // padding spaces — span-based detection misses those. + if split_cells(line).len() >= TABLE_MIN_COLUMNS { + return false; + } + // Text-based fallback: ≥3 tokens separated by runs of 2+ spaces (the + // projection inserts alignment padding between cells) → table header row + // collapsed into one span, not a section heading. + let multi_space_tokens = text + .split(" ") + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .count(); + if multi_space_tokens >= TABLE_MIN_COLUMNS { + return false; + } + let gap_above_ok = match prev { + None => true, + Some(p) => !continues_paragraph(p, line), + }; + if !gap_above_ok { + return false; + } + match next { + None => true, + // A wrapped multi-line bold heading ("Cellular Cycle\nand Replication") + // would otherwise be rejected here because line 1's next line continues + // the paragraph. Accept the case where `next` is itself an all-bold + // line — the `heading_run` merge in classify.rs will absorb the + // continuation. Without this, wrapped bold-only headings silently emit + // as bold paragraphs and don't reach the heading hierarchy at all. + Some(n) => !continues_paragraph(line, n) || line_all_bold(n), + } +} + +/// Returns true if `line` is a numbered section heading like "1. **Foo**" — +/// `parse_list_marker` already matched the "N." / "N)" prefix; this checks +/// that the body after the marker is uniformly bold body-size text and that +/// the line has a paragraph break above it. When true the caller should emit a +/// Heading at `heading_map.len()+1` rather than a ListItem. Mirrors +/// `looks_like_bold_heading`'s gating modulo the marker. +pub(super) fn looks_like_numbered_bold_heading( + line: &ProjectedLine, + rest: &str, + prev: Option<&ProjectedLine>, +) -> bool { + let rest_trim = rest.trim(); + if rest_trim.is_empty() || rest_trim.chars().count() > BOLD_HEADING_MAX_CHARS { + return false; + } + if is_caption_line(&line.text) { + return false; + } + if rest_trim.ends_with('.') + && rest_trim + .chars() + .filter(|c| *c == '.' || *c == '?' || *c == '!') + .count() + >= 2 + { + // "1. Sentence one. Sentence two." → not a heading. + return false; + } + // The spans after the marker must all be bold and non-mono. Marker + // characters are typically `'0'..='9'`, `'.'`, `')'`, plus whitespace — + // identify and skip them at the front of the span list. + let mut saw_bold_body = false; + let mut saw_non_bold_body = false; + for span in &line.spans { + let text = span.text.trim(); + if text.is_empty() { + continue; + } + let is_marker = text + .chars() + .all(|c| c.is_ascii_digit() || c == '.' || c == ')' || c == '('); + if is_marker { + continue; + } + if crate::projection::is_mono_item(span) { + return false; + } + if crate::projection::is_bold_item(span) { + saw_bold_body = true; + } else { + saw_non_bold_body = true; + } + } + if !saw_bold_body || saw_non_bold_body { + return false; + } + // Mostly alphabetic — same intuition as `looks_like_bold_heading`'s + // alpha-ratio filter: rejects tabular bold rows of digits. + if alpha_ratio(rest_trim) < 0.5 { + return false; + } + // Paragraph-break gap above. We deliberately don't require gap_below: + // a numbered section heading is often followed by another bold body + // line (a sub-heading or a multi-line title continuation) which would + // satisfy `continues_paragraph`. The numbered+bold combination is + // distinctive enough that the false-positive risk is small. + match prev { + None => true, + Some(p) => !continues_paragraph(p, line), + } +} + +/// Compute the body font size as the char-weighted mode across all lines in +/// all pages. Rotated lines are excluded so a long rotated sidebar can't +/// pull the body estimate. Falls back to `0.0` when no font-size info is +/// available. +/// A size whose char-weight is at least this fraction of the top size's weight +/// is considered a co-dominant body block. When two sizes are co-dominant, the +/// larger one is the body: dense small-print blocks (references, +/// acknowledgments, footnotes) routinely out-weigh the main narrative by raw +/// char count, but the body is never *smaller* than its own footnotes. +const BODY_CODOMINANT_FRACTION: f32 = 0.5; + +pub fn compute_body_size(pages: &[ParsedPage]) -> f32 { + use std::collections::HashMap; + let mut weights: HashMap = HashMap::new(); + for page in pages { + for line in &page.projected_lines { + if is_rotated_line(line) { + continue; + } + let size = heading_size_of(line); + if size <= 0.0 { + continue; + } + let chars = line.text.chars().count().max(1); + let key = (size * 100.0).round() as u32; + let entry = weights.entry(key).or_insert((size, 0)); + entry.1 += chars; + } + } + let max_weight = weights.values().map(|(_, n)| *n).max().unwrap_or(0); + if max_weight == 0 { + return 0.0; + } + let threshold = (max_weight as f32 * BODY_CODOMINANT_FRACTION) as usize; + // Among sizes that are co-dominant with the heaviest size, pick the + // largest. This rescues the true body when a dense references/footnote + // block at a smaller size would otherwise win the raw char-weight vote. + weights + .values() + .filter(|(_, n)| *n >= threshold) + .map(|(s, _)| *s) + .fold(0.0_f32, f32::max) +} + +/// Minimum total non-whitespace characters across all occurrences at a font +/// size for it to qualify as a heading level. Low floor — just a noise guard +/// against 1-2 char artifacts. The real legend-token discriminator is +/// `MIN_HEADING_AVG_LINE_CHARS` below. +const MIN_HEADING_TOTAL_CHARS: usize = 10; + +/// Minimum average non-whitespace characters *per line* at a font size for it +/// to qualify as a heading. This is what separates a real heading (a coherent +/// line like "Annual Events" = 12 chars/line, or "A-MEM: Agentic Memory for +/// LLM Agents" = 31) from scattered chart-legend tokens at a one-off display +/// size (paper.pdf's "A-mem"/"Base" ~ 4-5 chars/line). A per-line average is +/// robust where a total-char floor was not: a single short-but-real heading +/// (14pt "Aligning with Your Identity" = 24 non-ws chars, over 10.5pt body) +/// clears it, while many tiny repeated tokens do not no matter how many. +const MIN_HEADING_AVG_LINE_CHARS: f32 = 8.0; + +/// Maximum average characters per line for a size to qualify as a heading. +/// A "size larger than body" with very long lines is almost always a +/// large-print body block (callouts, footnotes-as-display, intro paragraph), +/// not a real heading. +const MAX_HEADING_AVG_LINE_CHARS: f32 = 200.0; + +/// Minimum fraction of non-whitespace chars at a size that must be alphabetic +/// for it to qualify as a heading. Drops sizes dominated by digits (graph +/// axes, results tables, math display) which otherwise pollute the top +/// heading slots. +const MIN_HEADING_ALPHA_RATIO: f32 = 0.5; + +/// Build a heading-size → level map: sizes strictly larger than `body_size`, +/// filtered by the quality guards `MIN_HEADING_TOTAL_CHARS`, +/// `MIN_HEADING_AVG_LINE_CHARS`, `MAX_HEADING_AVG_LINE_CHARS`, and +/// `MIN_HEADING_ALPHA_RATIO` (drop one-off equation / figure-label / legend +/// artifacts), sorted descending, mapped to levels 1..=MAX_HEADING_LEVELS. +pub fn build_heading_map(pages: &[ParsedPage], body_size: f32) -> Vec<(f32, u8)> { + use std::collections::HashMap; + let heading_margin = |line: &ProjectedLine| { + if line.font_size_is_estimated { + ESTIMATED_HEADING_SIZE_MARGIN + } else { + HEADING_SIZE_EPSILON + } + }; + // First pass: frequency of each normalized candidate-line text. A string + // that recurs ≥ REPEATED_HEADING_LINE_MIN times is chart/figure label spam + // (e.g. repeated attention-plot labels at 23–28pt), not a heading. + let mut text_freq: HashMap = HashMap::new(); + for page in pages { + for line in &page.projected_lines { + if is_rotated_line(line) || is_caption_line(&line.text) || line.in_figure { + continue; + } + if heading_size_of(line) > body_size + heading_margin(line) { + *text_freq + .entry(normalize_repeat_key(&line.text)) + .or_insert(0) += 1; + } + } + } + // (size_key → (size, line_count, total_chars, alpha_chars)) + let mut sizes: HashMap = HashMap::new(); + for page in pages { + for line in &page.projected_lines { + if is_rotated_line(line) || line.in_figure { + continue; + } + // Captions ("Figure 7", "Table 3.") often render slightly larger + // than body and would otherwise inflate / hijack the heading map. + if is_caption_line(&line.text) { + continue; + } + // Exclude repeated chart/figure labels (per-line, so a repeated + // string sharing a size with real headings still leaves that level). + if text_freq + .get(&normalize_repeat_key(&line.text)) + .copied() + .unwrap_or(0) + >= REPEATED_HEADING_LINE_MIN + { + continue; + } + let size = heading_size_of(line); + let margin = heading_margin(line); + if size > body_size + margin { + let key = (size * 100.0).round() as u32; + let entry = sizes.entry(key).or_insert((size, 0, 0, 0)); + entry.1 += 1; + for c in line.text.chars() { + if c.is_whitespace() { + continue; + } + entry.2 += 1; + if c.is_alphabetic() { + entry.3 += 1; + } + } + } + } + } + let all: Vec<(f32, usize, usize, usize)> = sizes.into_values().collect(); + // Always apply quality filters: total-char floor, average-line cap, and + // alpha-ratio floor. The total-char floor (rather than a line-count one) + // lets one-off titles survive — a 31-char title on a single line passes — + // while still rejecting chart-legend tokens like "A-mem" + "Base" that + // total fewer chars across 4 occurrences than a single heading line does. + let mut kept: Vec = all + .iter() + .filter(|(_, lines, chars, alpha)| { + let alpha_ratio = if *chars == 0 { + 0.0 + } else { + (*alpha as f32) / (*chars as f32) + }; + let avg_line_chars = *chars as f32 / (*lines).max(1) as f32; + *chars >= MIN_HEADING_TOTAL_CHARS + && (MIN_HEADING_AVG_LINE_CHARS..=MAX_HEADING_AVG_LINE_CHARS) + .contains(&avg_line_chars) + && alpha_ratio >= MIN_HEADING_ALPHA_RATIO + }) + .map(|(s, _, _, _)| *s) + .collect(); + kept.sort_by(|a, b| b.total_cmp(a)); + kept.truncate(MAX_HEADING_LEVELS); + kept.into_iter() + .enumerate() + .map(|(i, s)| (s, (i + 1) as u8)) + .collect() +} + +/// Fraction of non-whitespace characters in `text` that are alphabetic. +/// Returns 0.0 for an empty/all-whitespace string. The heading heuristics use +/// this to reject digit-dominated tabular rows ("47.5 14", "BLEU-1 25.87") +/// that would otherwise be promoted to headings. +fn alpha_ratio(text: &str) -> f32 { + let mut alpha = 0usize; + let mut total = 0usize; + for c in text.chars() { + if c.is_whitespace() { + continue; + } + total += 1; + if c.is_alphabetic() { + alpha += 1; + } + } + if total == 0 { + return 0.0; + } + alpha as f32 / total as f32 +} + +/// Size a line presents to heading detection: the precise matrix-derived +/// `heading_font_size` when available (matrix-baked-size lines), else the +/// `dominant_font_size`. Used by `compute_body_size`, `build_heading_map`, and +/// the per-line heading-level lookup — NOT by table/paragraph grouping. +pub(super) fn heading_size_of(line: &ProjectedLine) -> f32 { + line.heading_font_size.unwrap_or(line.dominant_font_size) +} + +pub(super) fn heading_level_for(size: f32, heading_map: &[(f32, u8)]) -> Option { + for (s, level) in heading_map { + if (size - *s).abs() < FONT_SIZE_HEADING_TOLERANCE { + return Some(*level); + } + } + None +} + +/// Highest-priority heading source: a struct-tree node `H1`..`H6` directly +/// tagging this line via its `mcid`. Available only for tagged PDFs. +pub(super) fn struct_heading_level( + line: &ProjectedLine, + struct_nodes: &[StructNode], +) -> Option { + let mcid = line.mcid?; + for node in struct_nodes { + if !node.mcids.contains(&mcid) { + continue; + } + if let Some(level) = parse_heading_role(&node.role) { + return Some(level); + } + } + None +} + +/// Parse a struct-tree role string like "H1" or "H3" into a heading level. +/// Returns None for non-heading roles (P, L, Figure, Table, ...). +fn parse_heading_role(role: &str) -> Option { + let trimmed = role.trim(); + if !trimmed.starts_with('H') && !trimmed.starts_with('h') { + return None; + } + let digits = &trimmed[1..]; + let n: u8 = digits.parse().ok()?; + if (1..=6).contains(&n) { Some(n) } else { None } +} + +/// Second-priority heading source: a document outline (bookmark) entry that +/// points at this page near this line's y coordinate, with a title that +/// prefix-matches the line text. Used on untagged PDFs that ship a TOC. +pub(super) fn outline_heading_level( + line: &ProjectedLine, + page_height: f32, + outline: &[OutlineTarget], + line_text: &str, +) -> Option { + if outline.is_empty() { + return None; + } + let normalized_line = normalize_outline_text(line_text); + if normalized_line.is_empty() { + return None; + } + let row_h = line.bbox.height.max(super::MIN_ROW_HEIGHT_PT); + let y_tolerance = row_h * 1.5; + for entry in outline { + let normalized_title = normalize_outline_text(&entry.title); + if normalized_title.is_empty() { + continue; + } + // Spatial check is *strict* only when the entry actually carries a + // usable y (in-page). Many bookmarks point at "top of page" with a + // Y outside the MediaBox or no Y at all — in that case we still + // accept any line on the page that prefix-matches the title. + let y_ok = match entry.y_pdf { + Some(y) => { + let y_view = page_height - y; + if y_view < 0.0 || y_view > page_height { + true + } else { + (y_view - line.bbox.y).abs() <= y_tolerance + } + } + None => true, + }; + if !y_ok { + continue; + } + // Short outline titles ("Nutrition") would otherwise false-match any + // paragraph that happens to start with them. Require the matched line + // to be heading-shaped: not much longer than the title itself. + let line_len = normalized_line.chars().count(); + let title_len = normalized_title.chars().count(); + let max_line_len = (title_len * 3).max(120); + // Multiple sentences → almost certainly prose, not a heading line. + let sentence_breaks = normalized_line.matches(". ").count(); + if line_len > max_line_len || sentence_breaks >= 2 { + continue; + } + // Run-in label guard: a single ". " sentence break is OK on a heading + // only when the line is roughly title-shaped. When the line carries + // substantial body text past the title (e.g. "Base model. Any n-layer + // transformer architec-"), it's a paragraph leading with a bold + // run-in label, not a section heading. Title is the *prefix* of the + // matched line, so excess body length = line_len - title_len. + if sentence_breaks >= 1 && line_len > title_len + 15 { + continue; + } + if normalized_line.starts_with(&normalized_title) + || normalized_title.starts_with(&normalized_line) + { + return Some(entry.level.min(MAX_HEADING_LEVELS as u8)); + } + } + None +} + +/// Lowercase + collapse whitespace, for forgiving outline-title vs line-text +/// comparison. Outline titles often have trailing numbering or punctuation +/// not present on the rendered line (and vice versa). +fn normalize_outline_text(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut prev_space = true; + for c in s.chars() { + if c.is_whitespace() { + if !prev_space { + out.push(' '); + prev_space = true; + } + } else { + for lc in c.to_lowercase() { + out.push(lc); + } + prev_space = false; + } + } + if out.ends_with(' ') { + out.pop(); + } + out +} + +#[cfg(test)] +mod tests { + use super::super::test_helpers::{line, page}; + use super::*; + + #[test] + fn toc_entry_arabic_extracts_trailing_page_number() { + assert_eq!(toc_entry_arabic_number("Introduction 7"), Some(7)); + assert_eq!( + toc_entry_arabic_number("1. A Fountain in the Square 1"), + Some(1) + ); + assert_eq!( + toc_entry_arabic_number("6. For the Love of Iran . . . 41"), + Some(41) + ); + } + + #[test] + fn toc_entry_arabic_rejects_decimals_and_axis_labels() { + // "94.2" → decimal, not a TOC entry + assert_eq!(toc_entry_arabic_number("OCR-Recall3 7 94.2"), None); + // Chapter 7 — too short an alpha body to be a TOC entry + assert_eq!(toc_entry_arabic_number("Chapter 7"), None); + // No separator + assert_eq!(toc_entry_arabic_number("Section1"), None); + } + + #[test] + fn is_toc_title_matches_common_variants() { + assert!(is_toc_title("Contents")); + assert!(is_toc_title("Table of Contents")); + assert!(is_toc_title("table of contents")); + assert!(is_toc_title("Index")); + assert!(!is_toc_title("Introduction")); + } + + #[test] + fn page_is_toc_requires_monotonic_page_numbers() { + // Real TOC: monotonically increasing page numbers. + let pages_toc = page(vec![ + line("Table of contents", 50.0, 30.0, 18.0, 18.0), + line("Introduction 7", 50.0, 60.0, 12.0, 12.0), + line("Part I: New Children 21", 50.0, 72.0, 12.0, 12.0), + line("Part II: From Solitary 45", 50.0, 84.0, 12.0, 12.0), + line("Part III: Commercial 71", 50.0, 96.0, 12.0, 12.0), + line("Conclusion 127", 50.0, 108.0, 12.0, 12.0), + ]); + assert!(page_is_toc(&pages_toc)); + + // Chart page: trailing numbers but NOT monotonic and many fail the + // separator/alpha rules anyway. + let pages_chart = page(vec![ + line("OCR-Recall is 94.2", 50.0, 60.0, 9.0, 9.0), + line("Precision rate 89.0", 50.0, 72.0, 9.0, 9.0), + line("F1 score 80.4", 50.0, 84.0, 9.0, 9.0), + ]); + assert!(!page_is_toc(&pages_chart)); + } + + #[test] + fn body_size_picks_most_common() { + let pages = vec![page(vec![ + line("Title", 50.0, 50.0, 18.0, 18.0), + line("body line one", 50.0, 80.0, 10.0, 10.0), + line("body line two", 50.0, 92.0, 10.0, 10.0), + line("body line three", 50.0, 104.0, 10.0, 10.0), + ])]; + let body = compute_body_size(&pages); + assert!((body - 10.0).abs() < 0.01, "body size = {body}"); + } + + #[test] + fn heading_map_descending_levels() { + // Heading text needs to clear `MIN_HEADING_TOTAL_CHARS` (10) so the + // size qualifies as a real heading rather than chart-legend noise. + let pages = vec![page(vec![ + line("The largest heading on the page", 50.0, 50.0, 24.0, 24.0), + line("A smaller heading right below it", 50.0, 80.0, 18.0, 18.0), + // Several lines of body so it beats the heading text in the + // char-weighted body-size mode. + line( + "body text line one with plenty of content", + 50.0, + 110.0, + 10.0, + 10.0, + ), + line( + "body text line two with plenty of content", + 50.0, + 122.0, + 10.0, + 10.0, + ), + line( + "body text line three with even more content", + 50.0, + 134.0, + 10.0, + 10.0, + ), + line( + "body text line four with even more content", + 50.0, + 146.0, + 10.0, + 10.0, + ), + ])]; + let body = compute_body_size(&pages); + let map = build_heading_map(&pages, body); + assert_eq!(map.len(), 2); + assert_eq!(map[0].1, 1); + assert_eq!(map[1].1, 2); + assert!(map[0].0 > map[1].0); + } +} diff --git a/crates/liteparse/src/markdown_layout/hr.rs b/crates/liteparse/src/markdown_layout/hr.rs new file mode 100644 index 0000000..7e8f4ad --- /dev/null +++ b/crates/liteparse/src/markdown_layout/hr.rs @@ -0,0 +1,132 @@ +use crate::types::{GraphicPrimitive, ParsedPage}; + +/// Maximum stroke thickness (or |y1-y2|) for a stroke to count as a candidate HR. +/// Thicker shapes are filled rects, not rules. +const HR_MAX_THICKNESS_PT: f32 = 2.0; + +/// Minimum fraction of page width a horizontal stroke must span to count as an HR. +/// Shorter strokes are typically table borders, list bullets, or inline marks. +const HR_MIN_WIDTH_FRACTION: f32 = 0.3; + +/// Vertical tolerance (points) for treating a stroke as "underlining" the +/// nearest text line. Strokes within this band of a text line's baseline are +/// dropped — they're underlines, not rules. +const HR_UNDERLINE_PROXIMITY_PT: f32 = 3.0; + +/// Detect horizontal rules from a page's vector graphics. +/// +/// Returns the y-coordinates (viewport space) of accepted HRs, sorted ascending. +/// An HR is a roughly horizontal stroke that spans at least +/// `HR_MIN_WIDTH_FRACTION` of the page width, is thinner than +/// `HR_MAX_THICKNESS_PT`, and does not sit on the baseline of any text line +/// (which would make it an underline). +pub(super) fn detect_horizontal_rules(page: &ParsedPage) -> Vec { + if page.graphics.is_empty() || page.page_width <= 0.0 { + return Vec::new(); + } + let min_width = page.page_width * HR_MIN_WIDTH_FRACTION; + let mut ys: Vec = Vec::new(); + + for g in &page.graphics { + let GraphicPrimitive::Stroke { + x1, + y1, + x2, + y2, + width, + .. + } = g + else { + continue; + }; + let (x1, y1, x2, y2, width) = (*x1, *y1, *x2, *y2, *width); + let dy = (y1 - y2).abs(); + let dx = (x1 - x2).abs(); + if dy > HR_MAX_THICKNESS_PT || width > HR_MAX_THICKNESS_PT { + continue; + } + if dx < min_width { + continue; + } + let y = (y1 + y2) * 0.5; + let xmin = x1.min(x2); + let xmax = x1.max(x2); + + // Drop if this stroke sits on a text-line baseline — it's an underline, + // not a divider. + let is_underline = page.projected_lines.iter().any(|line| { + let bottom = line.bbox.y + line.bbox.height; + (y - bottom).abs() < HR_UNDERLINE_PROXIMITY_PT + && xmin >= line.bbox.x - 2.0 + && xmax <= line.bbox.x + line.bbox.width + 2.0 + }); + if is_underline { + continue; + } + ys.push(y); + } + + // Sort + dedup near-duplicates (some PDFs draw the same rule twice). + ys.sort_by(|a, b| a.total_cmp(b)); + ys.dedup_by(|a, b| (*a - *b).abs() < 1.0); + ys +} + +#[cfg(test)] +mod tests { + use super::super::test_helpers::{line, page_with_graphics, stroke}; + use super::*; + + #[test] + fn hr_long_thin_horizontal_stroke_detected() { + // 400pt wide stroke on a 612pt page → comfortably above 30% threshold. + let p = page_with_graphics(vec![], vec![stroke(50.0, 200.0, 450.0, 200.5, 0.5)]); + let ys = detect_horizontal_rules(&p); + assert_eq!(ys, vec![200.25]); + } + + #[test] + fn hr_short_stroke_rejected() { + // 50pt wide — table border or list bullet, not an HR. + let p = page_with_graphics(vec![], vec![stroke(50.0, 200.0, 100.0, 200.0, 0.5)]); + assert!(detect_horizontal_rules(&p).is_empty()); + } + + #[test] + fn hr_vertical_stroke_rejected() { + let p = page_with_graphics(vec![], vec![stroke(50.0, 50.0, 50.0, 500.0, 0.5)]); + assert!(detect_horizontal_rules(&p).is_empty()); + } + + #[test] + fn hr_thick_stroke_rejected() { + // 4pt-thick stroke → a filled bar, not a rule. + let p = page_with_graphics(vec![], vec![stroke(50.0, 200.0, 450.0, 200.0, 4.0)]); + assert!(detect_horizontal_rules(&p).is_empty()); + } + + #[test] + fn hr_underline_at_text_baseline_dropped() { + // Text line at y=100 height=10 → bottom at y=110. Stroke at y=111 within + // the line's horizontal extent → underline, not an HR. + let text_line = line( + "Some underlined heading text on the page", + 50.0, + 100.0, + 10.0, + 10.0, + ); + let bottom = text_line.bbox.y + text_line.bbox.height; + let p = page_with_graphics( + vec![text_line.clone()], + vec![stroke( + 50.0, + bottom + 1.0, + 50.0 + text_line.bbox.width, + bottom + 1.0, + 0.5, + )], + ); + assert!(detect_horizontal_rules(&p).is_empty()); + } +} diff --git a/crates/liteparse/src/markdown_layout/inline.rs b/crates/liteparse/src/markdown_layout/inline.rs new file mode 100644 index 0000000..e0c1e0d --- /dev/null +++ b/crates/liteparse/src/markdown_layout/inline.rs @@ -0,0 +1,418 @@ +use crate::projection::{is_bold_item, is_italic_item, is_mono_item, is_strike_item}; +use crate::types::{ProjectedLine, TextItem}; + +use super::paragraphs::{collapse_whitespace, dehyphenate_join}; + +/// Per-span style flags used by the inline-emphasis renderer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct SpanStyle { + pub(super) bold: bool, + pub(super) italic: bool, + pub(super) mono: bool, + pub(super) strike: bool, +} + +impl SpanStyle { + pub(super) fn from_item(item: &TextItem) -> Self { + SpanStyle { + bold: is_bold_item(item), + italic: is_italic_item(item), + mono: is_mono_item(item), + strike: is_strike_item(item), + } + } + + pub(super) fn is_plain(self) -> bool { + !self.bold && !self.italic && !self.mono && !self.strike + } +} + +/// Escape characters that would otherwise be interpreted as markdown emphasis. +/// Deliberately narrow: only `*`, `_`, and backslash. Aggressive escaping +/// (`#`, `[`, backticks, etc.) breaks more output than it saves in practice. +pub(super) fn escape_inline(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '\\' | '*' | '_' => { + out.push('\\'); + out.push(c); + } + _ => out.push(c), + } + } + out +} + +/// Wrap `inner` in a markdown inline link to `url`. Uses the angle-bracket +/// destination form when the URL contains characters that would otherwise +/// terminate or break the `(url)` form (whitespace or parentheses). +fn apply_link(inner: &str, url: &str) -> String { + if url.contains([' ', '\t', '(', ')']) { + format!("[{}](<{}>)", inner, url) + } else { + format!("[{}]({})", inner, url) + } +} + +/// Wrap `inner` with the markdown markers for `style`. Mono wins over bold/italic: +/// inline code (`` `…` ``) doesn't compose with emphasis in CommonMark, so when +/// a span is mono we drop the `**/*` wrap. Bold + italic → `***…***`. +fn apply_style(inner: &str, style: SpanStyle) -> String { + let styled = if style.mono { + // Use backticks; if inner already contains backticks, switch to a + // longer fence (pair of backticks plus a space buffer) per CommonMark. + if inner.contains('`') { + format!("`` {} ``", inner) + } else { + format!("`{}`", inner) + } + } else { + match (style.bold, style.italic) { + (true, true) => format!("***{}***", inner), + (true, false) => format!("**{}**", inner), + (false, true) => format!("*{}*", inner), + (false, false) => inner.to_string(), + } + }; + // Strikethrough (GFM `~~…~~`) wraps outermost so it composes with emphasis + // and inline code (`~~**foo**~~`, `~~`bar`~~`). + if style.strike { + format!("~~{}~~", styled) + } else { + styled + } +} + +/// Render a `ProjectedLine` to markdown with per-span emphasis. Adjacent +/// same-style spans are merged into a single emphasis run; whitespace between +/// spans is preserved as one space (the underlying projection output already +/// has the right inter-word spacing baked into span text). +/// +/// Per-line shortcut: when every non-whitespace span shares the same style, +/// emit one outer wrap around the collapsed line text instead of run-by-run +/// (avoids `**foo** **bar** **baz**` noise on uniformly styled lines). +/// +/// Falls back to `collapse_whitespace(line.text)` when the line has no usable +/// spans (e.g. OCR-only lines where TextItem styling isn't populated). +pub(super) fn render_line_inline(line: &ProjectedLine) -> String { + let spans: Vec<&TextItem> = line + .spans + .iter() + .filter(|s| !s.text.trim().is_empty()) + .collect(); + if spans.is_empty() { + return collapse_whitespace(&line.text); + } + + // Sort spans by x so we render in visual reading order regardless of + // extraction order. Stable so equal-x spans keep their original sequence. + let mut spans = spans; + spans.sort_by(|a, b| a.x.total_cmp(&b.x)); + + let styles: Vec = spans.iter().map(|s| SpanStyle::from_item(s)).collect(); + let links: Vec> = spans.iter().map(|s| s.link.as_deref()).collect(); + + // Per-line shortcut — only when the whole line shares one style AND carries + // no hyperlinks (a link must wrap only the spans it covers, so link lines + // always take the per-group path below). + let uniform = styles.iter().all(|s| *s == styles[0]) && links.iter().all(|l| l.is_none()); + if uniform { + let joined = collapse_whitespace(&line.text); + if joined.is_empty() { + return joined; + } + let escaped = escape_inline(&joined); + if styles[0].is_plain() { + return escaped; + } + return apply_style(&escaped, styles[0]); + } + + // Group consecutive spans by style. Within a group, span texts join with + // a single space (we lose intra-group spacing precision; acceptable). + let mut out = String::new(); + let mut i = 0; + while i < spans.len() { + let style = styles[i]; + let link = links[i]; + let mut j = i + 1; + while j < spans.len() && styles[j] == style && links[j] == link { + j += 1; + } + let mut group_text = String::new(); + for span in &spans[i..j] { + if !group_text.is_empty() && !group_text.ends_with(' ') { + group_text.push(' '); + } + group_text.push_str(span.text.trim()); + } + let group_text = collapse_whitespace(&group_text); + let escaped = escape_inline(&group_text); + let mut rendered = if style.is_plain() { + escaped + } else { + apply_style(&escaped, style) + }; + if let Some(url) = link { + rendered = apply_link(&rendered, url); + } + if !out.is_empty() && !out.ends_with(' ') { + out.push(' '); + } + out.push_str(&rendered); + i = j; + } + out +} + +/// Render the text portion of a list item with per-span emphasis. The marker +/// itself isn't included in the output (the renderer handles it separately). +/// +/// When the line is uniformly styled we wrap the marker-stripped `rest` with +/// the line's style — this avoids the awkward emphasis-marker mismatch we'd +/// hit if we naively stripped a leading bullet out of an already-wrapped +/// rendered line (`**• item**` → `** item**`). +/// +/// When the line is mixed-style we render the full line via the inline pipeline +/// and then best-effort-strip the marker prefix (with optional emphasis wrap +/// around it). On any failure we fall back to plain escaped `rest`. +pub(super) fn render_list_item_text(line: &ProjectedLine, marker: &str, rest: &str) -> String { + if let Some(style) = line_uniform_style(line) { + let plain = collapse_whitespace(rest); + let escaped = escape_inline(&plain); + return if style.is_plain() { + escaped + } else { + apply_style(&escaped, style) + }; + } + let full = render_line_inline(line); + if let Some(stripped) = strip_leading_marker_from_inline(&full, marker) { + return stripped; + } + escape_inline(&collapse_whitespace(rest)) +} + +/// Try to strip a leading list marker (optionally wrapped in emphasis markers) +/// off `s`. Recognizes `***MARK*** `, `**MARK** `, `*MARK* `, `` `MARK` ``, +/// and bare `MARK `. Returns the suffix on a match. +fn strip_leading_marker_from_inline(s: &str, marker: &str) -> Option { + for wrap in ["***", "**", "*", "`"] { + let prefix = format!("{wrap}{marker}{wrap} "); + if let Some(rest) = s.strip_prefix(&prefix) { + return Some(rest.to_string()); + } + } + let prefix = format!("{marker} "); + s.strip_prefix(&prefix).map(|r| r.to_string()) +} + +/// Append an inline-rendered continuation line to an existing list-item body. +/// De-hyphenates against the raw text boundary (mirrors the paragraph rule) +/// and falls back to a space join otherwise. +pub(super) fn append_inline_continuation( + prev_text: &mut String, + next_raw: &str, + next_inline: &str, +) { + let next_raw = collapse_whitespace(next_raw); + dehyphenate_join(prev_text, &next_raw, next_inline); +} + +/// Returns the shared `SpanStyle` of `line` when every non-whitespace span on +/// the line has the same style; `None` when spans disagree. Mono is folded +/// into the style for the purpose of "uniform" — a fully-mono line is +/// uniform-mono. Used by the paragraph-level optimization to decide whether +/// to wrap once around the whole paragraph or fall back to per-line inline. +pub(super) fn line_uniform_style(line: &ProjectedLine) -> Option { + // A line carrying any hyperlink can't use the uniform fast path: the link + // wraps only its own spans, so such lines must render via the per-group + // path in `render_line_inline`. + if line + .spans + .iter() + .any(|s| !s.text.trim().is_empty() && s.link.is_some()) + { + return None; + } + let mut iter = line + .spans + .iter() + .filter(|s| !s.text.trim().is_empty()) + .map(SpanStyle::from_item); + let first = iter.next()?; + for s in iter { + if s != first { + return None; + } + } + Some(first) +} + +/// True when every non-whitespace span on the line is bold and non-mono. +/// Unlike `line_uniform_style`, this tolerates per-span *italic* variation, +/// so a heading whose spans mix bold and bold-italic (e.g. +/// "**4** ***Foo*** **Bar**") still reads as a single bold line. Returns +/// false for an empty / all-whitespace line. +pub(super) fn line_all_bold(line: &ProjectedLine) -> bool { + let mut saw_span = false; + for span in &line.spans { + if span.text.trim().is_empty() { + continue; + } + if is_mono_item(span) || !is_bold_item(span) { + return false; + } + saw_span = true; + } + saw_span +} + +#[cfg(test)] +mod tests { + use super::super::test_helpers::styled_line; + use super::*; + + #[test] + fn render_line_inline_mid_line_bold() { + // Plain span, then bold span: should produce a mid-line `**bold**` run. + let l = styled_line( + &[ + ("regular text with", 50.0, Some("Arial")), + ("bold word", 200.0, Some("Arial-Bold")), + ], + 100.0, + 10.0, + ); + let out = render_line_inline(&l); + assert!(out.contains("regular text with"), "got: {out}"); + assert!(out.contains("**bold word**"), "got: {out}"); + assert!( + !out.starts_with("**"), + "mid-line shouldn't open with bold: {out}" + ); + } + + #[test] + fn render_line_inline_uniform_bold_uses_shortcut() { + // All spans bold → single outer wrap, no per-span noise. + let l = styled_line( + &[ + ("first", 50.0, Some("Arial-Bold")), + ("second", 100.0, Some("Arial-Bold")), + ], + 100.0, + 10.0, + ); + let out = render_line_inline(&l); + assert!(out.starts_with("**") && out.ends_with("**"), "got: {out}"); + // Only one bold run, not two — the shortcut should kick in. + assert_eq!(out.matches("**").count(), 2, "got: {out}"); + } + + #[test] + fn render_line_inline_escapes_emphasis_chars() { + let l = styled_line(&[("5*4=20", 50.0, Some("Arial"))], 100.0, 10.0); + let out = render_line_inline(&l); + assert_eq!(out, "5\\*4=20"); + } + + #[test] + fn render_line_inline_italic_then_bold() { + let l = styled_line( + &[ + ("italic", 50.0, Some("Arial-Italic")), + ("plain", 100.0, Some("Arial")), + ("bold", 150.0, Some("Arial-Bold")), + ], + 100.0, + 10.0, + ); + let out = render_line_inline(&l); + assert!(out.contains("*italic*"), "got: {out}"); + assert!(out.contains("plain"), "got: {out}"); + assert!(out.contains("**bold**"), "got: {out}"); + } + + #[test] + fn render_line_inline_wraps_link_span() { + // Plain span followed by a linked span → `[anchor](url)` mid-line. + let mut l = styled_line( + &[ + ("see", 50.0, Some("Arial")), + ("the docs", 150.0, Some("Arial")), + ], + 100.0, + 10.0, + ); + l.spans[1].link = Some("https://example.com/docs".to_string()); + let out = render_line_inline(&l); + assert!(out.contains("see"), "got: {out}"); + assert!( + out.contains("[the docs](https://example.com/docs)"), + "got: {out}" + ); + } + + #[test] + fn render_line_inline_link_wraps_outside_emphasis() { + // An italic linked span → `[*anchor*](url)` (link outside emphasis). + let mut l = styled_line(&[("cite", 50.0, Some("Arial-Italic"))], 100.0, 10.0); + l.spans[0].link = Some("https://example.com/p.pdf".to_string()); + let out = render_line_inline(&l); + assert_eq!(out, "[*cite*](https://example.com/p.pdf)"); + } + + #[test] + fn render_line_inline_link_url_with_space_uses_angle_brackets() { + let mut l = styled_line(&[("link", 50.0, Some("Arial"))], 100.0, 10.0); + l.spans[0].link = Some("https://example.com/a b".to_string()); + let out = render_line_inline(&l); + assert_eq!(out, "[link]()"); + } + + #[test] + fn render_line_inline_strike_span() { + // A struck-through span mid-line → `~~word~~`, plain neighbors untouched. + let mut l = styled_line( + &[ + ("keep this", 50.0, Some("Arial")), + ("removed", 150.0, Some("Arial")), + ], + 100.0, + 10.0, + ); + l.spans[1].strike = true; + let out = render_line_inline(&l); + assert!(out.contains("keep this"), "got: {out}"); + assert!(out.contains("~~removed~~"), "got: {out}"); + } + + #[test] + fn render_line_inline_strike_composes_with_bold() { + // Strike wraps outside emphasis: `~~**word**~~`. + let mut l = styled_line(&[("gone", 50.0, Some("Arial-Bold"))], 100.0, 10.0); + l.spans[0].strike = true; + let out = render_line_inline(&l); + assert_eq!(out, "~~**gone**~~"); + } + + #[test] + fn render_line_inline_mono_span() { + let l = styled_line( + &[ + ("call", 50.0, Some("Arial")), + ("foo()", 100.0, Some("Courier")), + ("on it", 150.0, Some("Arial")), + ], + 100.0, + 10.0, + ); + let out = render_line_inline(&l); + assert!(out.contains("`foo()`"), "got: {out}"); + // Plain spans stay unwrapped. + assert!(out.contains("call")); + assert!(out.contains("on it")); + } +} diff --git a/crates/liteparse/src/markdown_layout/lists.rs b/crates/liteparse/src/markdown_layout/lists.rs new file mode 100644 index 0000000..191aff5 --- /dev/null +++ b/crates/liteparse/src/markdown_layout/lists.rs @@ -0,0 +1,462 @@ +use std::collections::HashSet; + +use crate::types::ProjectedLine; + +/// Roughly one indent step in PDF points. Used to bucket list items into +/// nesting levels relative to the first item of the list. +pub(super) const LIST_INDENT_STEP_PT: f32 = 12.0; + +/// Characters recognized as bullet markers when followed by whitespace. +/// Limited to glyphs that are unlikely to appear at line-start in normal prose. +/// `\u{f0b7}` is the Symbol-font bullet (0xB7) that Word/Adobe emit into the +/// Private Use Area; it isn't remapped to `•` during extraction, so recognize +/// it here rather than let it read as an undecodable label. +const BULLET_CHARS: &[char] = &['•', '·', '◦', '▪', '▸', '▶', '●', '○', '■', '□', '\u{f0b7}']; + +/// Detect a list marker at the start of `text`. Returns `(ordered, marker_str, +/// remainder)` when matched; otherwise `None`. +/// +/// Recognizes: +/// - Unicode bullet characters (`BULLET_CHARS`) followed by whitespace. +/// - Decimal-prefix markers like `1.` / `1)` / `12.` / `12)` followed by +/// whitespace — kept strict (digits only) so things like footnote callers +/// (`1` alone) and section refs (`A.1`) don't match. +pub(super) fn parse_list_marker(text: &str) -> Option<(bool, String, &str)> { + let trimmed = text.trim_start(); + if trimmed.is_empty() { + return None; + } + + let mut chars = trimmed.chars(); + let first = chars.next()?; + + // Unicode bullet + if BULLET_CHARS.contains(&first) { + let rest = chars.as_str(); + if let Some(rest_trim) = rest.strip_prefix(|c: char| c.is_whitespace()) { + return Some((false, first.to_string(), rest_trim.trim_start())); + } + } + + // Decimal: 1. / 1) / 12. / 12) + if first.is_ascii_digit() { + let mut digit_end = 1; + for c in trimmed[1..].chars() { + if c.is_ascii_digit() { + digit_end += c.len_utf8(); + } else { + break; + } + } + // Cap to keep us from matching page-number-like prefixes + if digit_end <= 3 { + let after_digits = &trimmed[digit_end..]; + let mut after_iter = after_digits.chars(); + if let Some(punct) = after_iter.next() + && (punct == '.' || punct == ')') + { + let after_punct = after_iter.as_str(); + if let Some(rest_trim) = after_punct.strip_prefix(|c: char| c.is_whitespace()) { + let marker = format!("{}{}", &trimmed[..digit_end], punct); + return Some((true, marker, rest_trim.trim_start())); + } + } + } + } + + None +} + +// ── Lettered / roman ordered-list detection ─────────────────────────────── +// +// `parse_list_marker` deliberately ignores alphabetic (`a.`) and roman (`i.`) +// markers because a single one is indistinguishable from an initial +// ("J. Smith") or a section letter ("A. Background"). The disambiguating +// signal is *sequence*: a real lettered/roman list has consecutive siblings +// (`a, b, c` / `i, ii, iii`) starting at value 1, at a consistent indent. +// `detect_ordered_list_lines` does a region-wide pre-pass to find those runs; +// only their member lines are then treated as list items by the classifier. + +/// Marker family. Case is tracked so an `a, b, c` run doesn't absorb an +/// unrelated `A.` and vice-versa. +#[derive(Clone, Copy, PartialEq, Eq)] +enum MarkerKind { + AlphaLower, + AlphaUpper, + RomanLower, + RomanUpper, +} + +/// Max line-index gap between consecutive members of one run. A list item may +/// wrap over a few body lines, but a marker sitting dozens of lines below its +/// predecessor belongs to different content — don't chain across it. +const ORDERED_RUN_MAX_LINE_GAP: usize = 12; +/// Indent tolerance for members of one run (same as the description-list +/// track tolerance — sub-point jitter from projection). +const ORDERED_RUN_INDENT_TOL_PT: f32 = 8.0; + +/// Value of a lowercase roman-numeral string, or `None` if not a valid roman +/// numeral. Case-normalize before calling. +fn roman_value(s: &str) -> Option { + if s.is_empty() { + return None; + } + let mut total: i64 = 0; + let mut prev: i64 = 0; + for c in s.chars().rev() { + let v: i64 = match c { + 'i' => 1, + 'v' => 5, + 'x' => 10, + 'l' => 50, + 'c' => 100, + 'd' => 500, + 'm' => 1000, + _ => return None, + }; + if v < prev { + total -= v; + } else { + total += v; + prev = v; + } + } + if total > 0 { Some(total as u32) } else { None } +} + +/// Possible `(kind, value)` interpretations of a marker body. A single letter +/// like `i` is ambiguous — both alpha (9th letter) and roman (1) — so both are +/// returned and the sequencer picks whichever continues a run. +fn marker_interpretations(body: &str) -> Vec<(MarkerKind, u32)> { + let mut out = Vec::new(); + let chars: Vec = body.chars().collect(); + if chars.len() == 1 && chars[0].is_ascii_alphabetic() { + let c = chars[0]; + if c.is_ascii_lowercase() { + out.push((MarkerKind::AlphaLower, (c as u32) - ('a' as u32) + 1)); + if let Some(v) = roman_value(&c.to_string()) { + out.push((MarkerKind::RomanLower, v)); + } + } else { + out.push((MarkerKind::AlphaUpper, (c as u32) - ('A' as u32) + 1)); + if let Some(v) = roman_value(&c.to_ascii_lowercase().to_string()) { + out.push((MarkerKind::RomanUpper, v)); + } + } + return out; + } + // Multi-char: only roman numerals qualify (real lists don't use "ab."). + if chars.iter().all(|c| c.is_ascii_lowercase()) { + if let Some(v) = roman_value(body) { + out.push((MarkerKind::RomanLower, v)); + } + } else if chars.iter().all(|c| c.is_ascii_uppercase()) { + if let Some(v) = roman_value(&body.to_ascii_lowercase()) { + out.push((MarkerKind::RomanUpper, v)); + } + } + out +} + +/// Split a leading ordered-marker token (`a.`, `iv)`, `(a)`) off `text`, +/// returning `(body, marker, rest)` where `body` is the inner letters, `marker` +/// is the full token as written, and `rest` is the trimmed remainder. Requires +/// whitespace after the marker and a non-empty remainder. Does *not* validate +/// that `body` is a real letter/roman marker — the caller does that via +/// `marker_interpretations`. +fn split_ordered_marker(text: &str) -> Option<(&str, String, &str)> { + let t = text.trim_start(); + let chars: Vec = t.chars().collect(); + if chars.is_empty() { + return None; + } + // Paren-wrapped: "(a)" / "(iv)" + if chars[0] == '(' { + let close = chars.iter().position(|&c| c == ')')?; + if close < 2 { + return None; // "()" + } + let body_len: usize = chars[1..close].iter().map(|c| c.len_utf8()).sum(); + let after_paren = &t[1 + body_len + 1..]; + let rest = after_paren.strip_prefix(|c: char| c.is_whitespace())?; + let rest = rest.trim_start(); + if rest.is_empty() { + return None; + } + let body = &t[1..1 + body_len]; + let marker = format!("({body})"); + return Some((body, marker, rest)); + } + // Suffix form: letters followed by '.' or ')' + let letter_chars = chars.iter().take_while(|c| c.is_ascii_alphabetic()).count(); + if letter_chars == 0 || letter_chars > 7 { + return None; + } + let body_len: usize = chars[..letter_chars].iter().map(|c| c.len_utf8()).sum(); + let punct = chars.get(letter_chars).copied()?; + if punct != '.' && punct != ')' { + return None; + } + let after_punct = &t[body_len + punct.len_utf8()..]; + let rest = after_punct.strip_prefix(|c: char| c.is_whitespace())?; + let rest = rest.trim_start(); + if rest.is_empty() { + return None; + } + let body = &t[..body_len]; + let marker = format!("{body}{punct}"); + Some((body, marker, rest)) +} + +/// A candidate marker line, in region-local index order. +struct OrderedCandidate { + idx: usize, + indent: f32, + interps: Vec<(MarkerKind, u32)>, +} + +/// An in-progress run of sequential markers sharing kind + indent. +struct OrderedRun { + kind: MarkerKind, + next: u32, + indent: f32, + last_idx: usize, + members: Vec, +} + +/// Region-wide pre-pass: return the set of line indices whose leading +/// alphabetic / roman marker belongs to a confirmed ordered-list run (≥2 +/// sequential siblings starting at value 1, consistent indent, no large +/// document-order gap). Callers treat those lines as ordered list items; +/// unconfirmed one-off markers (initials, lone section letters) are left alone. +pub(super) fn detect_ordered_list_lines( + lines: &[ProjectedLine], + table_covered: &HashSet, +) -> HashSet { + let candidates: Vec = lines + .iter() + .enumerate() + .filter_map(|(i, l)| { + if table_covered.contains(&i) { + return None; + } + let (body, _marker, _rest) = split_ordered_marker(l.text.trim())?; + let interps = marker_interpretations(body); + if interps.is_empty() { + None + } else { + Some(OrderedCandidate { + idx: i, + indent: l.indent_x, + interps, + }) + } + }) + .collect(); + + let mut runs: Vec = Vec::new(); + for c in &candidates { + // Prefer extending the most recently touched matching run (keeps nested + // levels — an inner `i.` opens its own run while the outer `a,b,c` run + // stays open at its own indent). + let mut extended = false; + for run in runs.iter_mut().rev() { + if c.idx - run.last_idx <= ORDERED_RUN_MAX_LINE_GAP + && (run.indent - c.indent).abs() <= ORDERED_RUN_INDENT_TOL_PT + && c.interps + .iter() + .any(|(k, v)| *k == run.kind && *v == run.next) + { + run.members.push(c.idx); + run.next += 1; + run.last_idx = c.idx; + extended = true; + break; + } + } + if extended { + continue; + } + // Start a new run only at the first item of a sequence (value 1): + // `a`, `A`, `i`, `I`. This is what keeps stray `J.` / `A.` out. + if let Some(&(kind, _)) = c.interps.iter().find(|(_, v)| *v == 1) { + runs.push(OrderedRun { + kind, + next: 2, + indent: c.indent, + last_idx: c.idx, + members: vec![c.idx], + }); + } + } + + let mut confirmed = HashSet::new(); + for run in runs { + if run.members.len() >= 2 { + confirmed.extend(run.members); + } + } + confirmed +} + +/// For a line already confirmed as an ordered-list member by +/// `detect_ordered_list_lines`, extract `(marker, rest)` for emission. +pub(super) fn split_ordered_marker_for_emit(text: &str) -> Option<(String, String)> { + let (_body, marker, rest) = split_ordered_marker(text)?; + Some((marker, rest.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_list_marker_bullets() { + let (ordered, marker, rest) = parse_list_marker("• item one").unwrap(); + assert!(!ordered); + assert_eq!(marker, "•"); + assert_eq!(rest, "item one"); + } + + #[test] + fn parse_list_marker_decimal() { + let (ordered, marker, rest) = parse_list_marker("1. first").unwrap(); + assert!(ordered); + assert_eq!(marker, "1."); + assert_eq!(rest, "first"); + + let (ordered, marker, rest) = parse_list_marker("12) twelfth").unwrap(); + assert!(ordered); + assert_eq!(marker, "12)"); + assert_eq!(rest, "twelfth"); + } + + #[test] + fn parse_list_marker_rejects_prose() { + assert!(parse_list_marker("This sentence.").is_none()); + // Bare digit with no terminator → not a list + assert!(parse_list_marker("2023 was a year").is_none()); + // Footnote caller / page number style — no whitespace after + assert!(parse_list_marker("1.5x growth").is_none()); + } + + fn ll(text: &str, x: f32, y: f32) -> ProjectedLine { + super::super::test_helpers::line(text, x, y, 11.0, 10.0) + } + + #[test] + fn roman_value_parses() { + assert_eq!(roman_value("i"), Some(1)); + assert_eq!(roman_value("iv"), Some(4)); + assert_eq!(roman_value("ix"), Some(9)); + assert_eq!(roman_value("xiv"), Some(14)); + assert_eq!(roman_value("q"), None); + } + + #[test] + fn split_ordered_marker_forms() { + assert_eq!( + split_ordered_marker("a. hello"), + Some(("a", "a.".to_string(), "hello")) + ); + assert_eq!( + split_ordered_marker("iv) hello"), + Some(("iv", "iv)".to_string(), "hello")) + ); + assert_eq!( + split_ordered_marker("(a) hello"), + Some(("a", "(a)".to_string(), "hello")) + ); + // No space after marker → not a marker ("i.e." must not split). + assert!(split_ordered_marker("i.e. therefore").is_none()); + // No remainder. + assert!(split_ordered_marker("a.").is_none()); + } + + #[test] + fn detects_lettered_and_roman_sequences() { + // a./b./c. at one indent, i./ii./iii. deeper — both confirmed. + let lines = vec![ + ll("a. first item", 20.0, 100.0), + ll("b. second item", 20.0, 120.0), + ll("c. third item", 20.0, 140.0), + ll("i. sub one", 40.0, 160.0), + ll("ii. sub two", 40.0, 180.0), + ll("iii. sub three", 40.0, 200.0), + ]; + let got = detect_ordered_list_lines(&lines, &HashSet::new()); + assert_eq!(got.len(), 6, "all six markers should be confirmed"); + assert!((0..6).all(|i| got.contains(&i))); + } + + #[test] + fn lone_marker_is_not_a_list() { + // A single "A. Background" heading letter has no sibling → not a list. + let lines = vec![ + ll("A. Background", 20.0, 100.0), + ll( + "Some prose about the background of the matter at hand.", + 20.0, + 120.0, + ), + ]; + assert!(detect_ordered_list_lines(&lines, &HashSet::new()).is_empty()); + } + + #[test] + fn initials_are_not_a_list() { + // "J. Smith" / "M. Jones" are initials, not sequential markers (J=10, + // M=13, neither starts a run and they aren't consecutive). + let lines = vec![ + ll( + "J. Smith reported the findings to the committee.", + 20.0, + 100.0, + ), + ll( + "M. Jones seconded the motion without objection.", + 20.0, + 120.0, + ), + ]; + assert!(detect_ordered_list_lines(&lines, &HashSet::new()).is_empty()); + } + + #[test] + fn does_not_chain_across_large_gaps() { + // "a." then "b." tens of lines apart are unrelated, not a 2-item list. + let mut lines = vec![ll("a. opening clause", 20.0, 100.0)]; + for k in 0..14 { + lines.push(ll( + "ordinary body prose line here", + 20.0, + 120.0 + k as f32 * 20.0, + )); + } + lines.push(ll("b. much later clause", 20.0, 420.0)); + assert!(detect_ordered_list_lines(&lines, &HashSet::new()).is_empty()); + } + + #[test] + fn table_covered_markers_are_excluded() { + // Enumerated table footnotes `(a)`, `(b)` inside a detected table must + // not be pulled out as a list — that would disturb the table. + let lines = vec![ + ll( + "(a) first footnote of the table below the divider rule", + 20.0, + 100.0, + ), + ll( + "(b) second footnote of the table below the divider rule", + 20.0, + 120.0, + ), + ]; + // Without the guard they'd confirm as a 2-item run… + assert_eq!(detect_ordered_list_lines(&lines, &HashSet::new()).len(), 2); + // …but excluding their line indices leaves nothing. + let covered: HashSet = [0usize, 1].into_iter().collect(); + assert!(detect_ordered_list_lines(&lines, &covered).is_empty()); + } +} diff --git a/crates/liteparse/src/markdown_layout/mod.rs b/crates/liteparse/src/markdown_layout/mod.rs new file mode 100644 index 0000000..5bbff91 --- /dev/null +++ b/crates/liteparse/src/markdown_layout/mod.rs @@ -0,0 +1,35 @@ +//! Block classification for the markdown emitter. +//! +//! Consumes `ProjectedLine` entries from each `ParsedPage` and groups them into +//! a sequence of `Block`s: headings, paragraphs, list items, code blocks, +//! tables (ruled and borderless), horizontal rules, and figures. Tabular +//! regions that can't be classified confidently fall back to a fenced grid +//! projection rather than a mangled pipe table. + +mod blocks; +mod classify; +mod cross_region; +mod flags; +mod headings; +mod hr; +mod inline; +mod lists; +mod paragraphs; +mod repetition; +mod tables; + +pub use blocks::{Block, render_blocks}; +pub use classify::classify_page_with_filters; +pub use headings::{build_heading_map, compute_body_size}; +pub use repetition::{compute_header_footer_set, detect_single_page_chrome}; +pub use tables::detect_table_rects; + +/// Minimum plausible text-row height in points. Floors a `bbox.height` before +/// it is multiplied into a band / tolerance window, so a degenerate near-zero +/// glyph box can't collapse that window to nothing. Shared by the table +/// HR-suppress headroom (`classify.rs`) and outline y-tolerance +/// (`headings.rs`) so the two stay in sync. +const MIN_ROW_HEIGHT_PT: f32 = 8.0; + +#[cfg(test)] +pub(crate) mod test_helpers; diff --git a/crates/liteparse/src/markdown_layout/paragraphs.rs b/crates/liteparse/src/markdown_layout/paragraphs.rs new file mode 100644 index 0000000..a6141fb --- /dev/null +++ b/crates/liteparse/src/markdown_layout/paragraphs.rs @@ -0,0 +1,324 @@ +use crate::types::{Anchor, ProjectedLine}; + +use super::inline::{SpanStyle, line_all_bold, line_uniform_style, render_line_inline}; + +/// Multiplier on line height used as the paragraph-break threshold. +const PARAGRAPH_GAP_MULTIPLIER: f32 = 1.5; + +/// Tolerance for treating two font sizes as "the same" when grouping +/// paragraph lines, when at least one side falls back to a bbox-height +/// estimate. Set above descender-driven jitter (~1pt) but below a +/// 12→14pt section-heading step that often follows a smaller caption +/// line with the same bold style. +const FONT_SIZE_PARAGRAPH_TOLERANCE: f32 = 1.5; +/// Tolerance when both lines report real Tf-set sizes. Tight, so a +/// 12pt-bold caption line directly above a 14pt-bold section heading +/// doesn't accidentally merge them into a single paragraph that swallows +/// the heading. +const FONT_SIZE_PARAGRAPH_TOLERANCE_REAL: f32 = 0.5; + +/// Pick the right size-equality tolerance depending on whether either +/// side has an estimated (jitter-prone) font size. +fn font_size_paragraph_tolerance(prev: &ProjectedLine, cur: &ProjectedLine) -> f32 { + if prev.font_size_is_estimated || cur.font_size_is_estimated { + FONT_SIZE_PARAGRAPH_TOLERANCE + } else { + FONT_SIZE_PARAGRAPH_TOLERANCE_REAL + } +} + +/// Tolerance in points for treating two indent positions as "the same column". +const INDENT_TOLERANCE: f32 = 6.0; + +/// Collapse runs of whitespace into single spaces. The projected text from +/// `projection.rs` pads with column-alignment spaces (e.g. `for instance`) +/// which look fine as a layout grid but are wrong for prose. +pub(super) fn collapse_whitespace(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut prev_space = false; + for c in s.chars() { + if c.is_whitespace() { + if !prev_space && !out.is_empty() { + out.push(' '); + } + prev_space = true; + } else { + out.push(c); + prev_space = false; + } + } + if out.ends_with(' ') { + out.pop(); + } + out +} + +/// True when `text` ends with a mid-word soft hyphen — a trailing `-` whose +/// preceding character is a letter. Distinguishes a wrapped word (`architec-`) +/// from a dangling list marker (`-`) or a numeric range (`5-`), neither of +/// which should be rejoined. Unicode-aware, so accented stems (`café-`) +/// qualify. Trims trailing whitespace first. +/// +/// Single source of truth for the "ends open on a hyphen" test; the heading +/// continuation guard, region stitching, and block joining all key off this. +/// (Note: `stitch_regions` has an unrelated local `ends_open` that tests +/// sentence-terminal punctuation — different concept, hence the distinct name.) +pub(super) fn ends_hyphenated(text: &str) -> bool { + let t = text.trim_end(); + t.ends_with('-') && t.chars().rev().nth(1).is_some_and(|c| c.is_alphabetic()) +} + +/// True when `text` ends a sentence: its last non-space character (ignoring a +/// trailing closing quote / bracket) is `.`, `!`, or `?`. The heading +/// lowercase-continuation guard uses the negation — a previous line that does +/// *not* end a sentence is an open clause that a following lowercase line +/// continues, even when its left-edge indent defeats `continues_paragraph` +/// (centered short tail lines like "…journalistic or" → "scholarly works."). +pub(super) fn ends_sentence_final(text: &str) -> bool { + let t = text + .trim_end() + .trim_end_matches(|c| matches!(c, '"' | '\'' | ')' | ']' | '»' | '”' | '’')); + t.chars() + .next_back() + .is_some_and(|c| matches!(c, '.' | '!' | '?')) +} + +/// True when `prev` ends with a soft hyphen (see [`ends_hyphenated`]) and +/// `next` begins with a lowercase letter — the signal that a single word was +/// split across a line / block / region break and should be rejoined with the +/// hyphen dropped and no separator (`architec-` + `ture` → `architecture`). A +/// capitalized continuation (`well-` + `Known`) is left as a real compound. +pub(super) fn is_soft_hyphen_break(prev: &str, next: &str) -> bool { + ends_hyphenated(prev) + && next + .trim_start() + .chars() + .next() + .is_some_and(|c| c.is_lowercase()) +} + +/// Append `to_append` onto `prev`, de-hyphenating across the boundary. When +/// the boundary is a soft hyphen break (see [`is_soft_hyphen_break`], tested +/// against `check` — the plain text of the continuation), the trailing hyphen +/// is dropped and the text concatenated directly (`co-` + `operate` → +/// `cooperate`); otherwise the join is a single space. +/// +/// `check` and `to_append` are separate so a caller tracking a styled `inline` +/// representation can test the condition against the raw text while appending +/// the inline-rendered chunk. Plain-text callers pass the same string twice. +pub(super) fn dehyphenate_join(prev: &mut String, check: &str, to_append: &str) { + if check.is_empty() { + return; + } + if prev.is_empty() { + prev.push_str(to_append); + return; + } + if is_soft_hyphen_break(prev, check) { + while prev.ends_with(|c: char| c.is_whitespace()) { + prev.pop(); + } + prev.pop(); // the soft hyphen + prev.push_str(to_append.trim_start()); + } else { + prev.push(' '); + prev.push_str(to_append); + } +} + +/// Decide whether `cur` is a wrapped continuation of a heading line `prev`. +/// Looser than `continues_paragraph`: drops the indent-shift check, because +/// a centered wrapped heading has shifting left edges by line (the second +/// line is shorter, so its left edge is further right than the first), and +/// the projection's anchor classifier often labels short heading lines as +/// `Anchor::Left` rather than `Anchor::Center` since they don't anchor to a +/// column-center cluster. Without this relaxation, multi-line wrapped +/// headings like `# Author's Note to the` + `# 2021 Edition` fail the +/// merge check and emit as two separate `#` blocks. +pub(super) fn continues_heading(prev: &ProjectedLine, cur: &ProjectedLine) -> bool { + let centered_mismatch = (prev.anchor == Anchor::Center) ^ (cur.anchor == Anchor::Center); + if centered_mismatch { + return false; + } + if (prev.dominant_font_size - cur.dominant_font_size).abs() + > font_size_paragraph_tolerance(prev, cur) + { + return false; + } + if let (Some(p), Some(c)) = (line_uniform_style(prev), line_uniform_style(cur)) + && p.bold != c.bold + { + return false; + } + if line_all_bold(prev) != line_all_bold(cur) { + return false; + } + if prev.region_path != cur.region_path { + return false; + } + let prev_bottom = prev.bbox.y + prev.bbox.height; + let gap = cur.bbox.y - prev_bottom; + let line_height = prev.bbox.height.max(cur.bbox.height).max(1.0); + gap <= line_height * PARAGRAPH_GAP_MULTIPLIER +} + +/// Decide whether `cur` continues the paragraph started by `prev`. +pub(super) fn continues_paragraph(prev: &ProjectedLine, cur: &ProjectedLine) -> bool { + paragraph_flow(prev, cur, false) +} + +/// List-item continuation: like `continues_paragraph`, but tolerant of a +/// hanging indent. A wrapped list-item body commonly aligns under the marker's +/// *text* — i.e. indented to the right of the marker line — which +/// `continues_paragraph` would read as a new indented block. +pub(super) fn continues_list_item(prev: &ProjectedLine, cur: &ProjectedLine) -> bool { + paragraph_flow(prev, cur, true) +} + +fn paragraph_flow(prev: &ProjectedLine, cur: &ProjectedLine, allow_hanging_indent: bool) -> bool { + // Anchor only signals a paragraph break when one of the lines is clearly + // centered while the other isn't — justified prose routinely alternates + // between Left / Right / Floating dominant anchors as line widths flex, + // and treating those as paragraph breaks shreds normal text. + let centered_mismatch = (prev.anchor == Anchor::Center) ^ (cur.anchor == Anchor::Center); + if centered_mismatch { + return false; + } + if (prev.dominant_font_size - cur.dominant_font_size).abs() + > font_size_paragraph_tolerance(prev, cur) + { + return false; + } + // Uniform-bold ↔ non-bold transition is a paragraph break. Catches + // body-size headings that share font size with the surrounding prose but + // are emitted in a bold variant (e.g. Brill-Bold over Brill-Roman). Both + // sides must have a uniform style for this to fire; mid-line emphasis + // (None style) falls through to the gap/indent checks so prose with + // mid-paragraph bold spans doesn't get fragmented. + if let (Some(p), Some(c)) = (line_uniform_style(prev), line_uniform_style(cur)) + && p.bold != c.bold + { + return false; + } + // Same intent, but italic-tolerant: an all-bold line (which may mix bold + // and bold-italic spans, so `line_uniform_style` yields None) adjacent to + // a non-all-bold line is a paragraph break. Catches a bold section heading + // hugging the body paragraph below it. + if line_all_bold(prev) != line_all_bold(cur) { + return false; + } + if prev.region_path != cur.region_path { + // Cross-region continuation: the same paragraph can wrap from the + // bottom of one column into the top of the next. Only bridge regions + // when the previous line clearly breaks mid-sentence (no terminal + // punctuation) AND the next line starts with a lowercase letter — a + // strict signal that catches the column-wrap case while rejecting + // unrelated paragraphs that happen to sit in adjacent leaves. + let prev_trim = prev.text.trim_end(); + let ends_open = !prev_trim.ends_with(|c: char| { + matches!( + c, + '.' | '!' | '?' | ':' | ';' | '”' | '"' | ')' | ']' | '。' | '』' | '」' + ) + }); + let starts_lower = cur + .text + .trim_start() + .chars() + .next() + .is_some_and(|c| c.is_lowercase()); + return ends_open && starts_lower; + } + if !allow_hanging_indent + && (prev.indent_x - cur.indent_x).abs() > INDENT_TOLERANCE + && cur.anchor == Anchor::Left + { + // Indent change on a left-aligned block usually means a new paragraph + // (block-quote, list, indented passage, etc.). Allow first-line indent + // by checking only when the *next* line shifts right relative to prev. + if cur.indent_x > prev.indent_x + INDENT_TOLERANCE { + return false; + } + } + // Vertical gap check. + let prev_bottom = prev.bbox.y + prev.bbox.height; + let gap = cur.bbox.y - prev_bottom; + let line_height = prev.bbox.height.max(cur.bbox.height).max(1.0); + gap <= line_height * PARAGRAPH_GAP_MULTIPLIER +} + +/// Paragraph accumulator state. We track two parallel representations of the +/// running paragraph text: +/// +/// - `raw` — plain text (no emphasis markers). Used for the paragraph-uniform +/// shortcut: if every contributing line had the same uniform style, we wrap +/// the whole paragraph once with `wrap_emphasis(raw, …)` to avoid the +/// `**foo** **bar** **baz**` per-line noise. +/// - `inline` — per-line markdown with emphasis baked in via +/// `render_line_inline`. Used when the paragraph contains mid-line emphasis +/// shifts or lines with differing uniform styles. +/// +/// `uniform` is `Some((bold, italic))` while every line so far has been a +/// uniformly-styled line sharing the same (bold, italic) flags, and `None` as +/// soon as that invariant breaks. +pub(super) struct ParaAccum { + pub(super) raw: String, + pub(super) inline: String, + pub(super) last: ProjectedLine, + pub(super) uniform: Option<(bool, bool)>, +} + +/// Append `next_line` to a paragraph accumulator. Maintains both the `raw` and +/// `inline` text representations and updates the running `uniform` flag. +/// De-hyphenation runs on the `raw` boundary; the `inline` boundary mirrors it +/// when the trailing char is still a literal `-` (i.e. the hyphen sits outside +/// any emphasis wrap — the common case). +pub(super) fn append_to_paragraph(accum: &mut ParaAccum, next_line: &ProjectedLine) { + let next_raw = collapse_whitespace(next_line.text.trim()); + if next_raw.is_empty() { + return; + } + let next_inline = render_line_inline(next_line); + // A struck line has no block-level flag, so it can't use the uniform + // raw-text fast path — drop it to `None` to force the `inline` rendering. + let next_uniform: Option = line_uniform_style(next_line).filter(|s| !s.strike); + + if accum.raw.is_empty() { + accum.raw.push_str(&next_raw); + accum.inline.push_str(&next_inline); + accum.uniform = next_uniform.map(|s| (s.bold, s.italic)); + accum.last = next_line.clone(); + return; + } + + // Raw side de-hyphenates against its own boundary. The inline side keys off + // the same raw lowercase test but checks *its own* trailing char: a hyphen + // tucked inside an emphasis wrap ends in `*`/`` ` `` rather than `-`, so it + // won't strip and falls through to a space join — exactly the prior + // behavior, now via one helper. + dehyphenate_join(&mut accum.raw, &next_raw, &next_raw); + dehyphenate_join(&mut accum.inline, &next_raw, &next_inline); + + // Maintain the running uniform-style flag. + accum.uniform = match (accum.uniform, next_uniform) { + (Some(cur), Some(s)) if cur == (s.bold, s.italic) => Some(cur), + _ => None, + }; + accum.last = next_line.clone(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dehyphenate_join_only_strips_before_lowercase() { + let mut s = String::from("co-"); + dehyphenate_join(&mut s, "operate", "operate"); + assert_eq!(s, "cooperate"); + + let mut s = String::from("Vitamin-"); + dehyphenate_join(&mut s, "A", "A"); + assert_eq!(s, "Vitamin- A"); + } +} diff --git a/crates/liteparse/src/markdown_layout/repetition.rs b/crates/liteparse/src/markdown_layout/repetition.rs new file mode 100644 index 0000000..fc20e14 --- /dev/null +++ b/crates/liteparse/src/markdown_layout/repetition.rs @@ -0,0 +1,552 @@ +use crate::types::{ParsedPage, ProjectedLine}; + +use super::paragraphs::collapse_whitespace; + +/// Fraction of page height treated as the "top band" for header detection. +/// Most running headers sit within the top 8–12% of a page; 12% gives some +/// slack for two-line headers without sweeping in body text. +const HEADER_BAND_FRACTION: f32 = 0.12; + +/// Fraction of page height treated as the "bottom band" for footer detection. +const FOOTER_BAND_FRACTION: f32 = 0.12; + +/// Fraction of pages on which a normalized line must appear (in the same +/// band) to be classified as a running header/footer. +const HEADER_FOOTER_MIN_FRACTION: f32 = 0.5; + +/// Absolute floor on header/footer matches — single-page docs can't have a +/// "running" header by definition, and a single match on a 2-page doc is too +/// weak to act on. +const HEADER_FOOTER_MIN_PAGES: usize = 2; + +/// Normalize a line for cross-page header/footer matching. Lowercases, +/// collapses whitespace, and replaces every run of ASCII digits with `#` so +/// `Page 1 of 6` and `Page 2 of 6` collapse to the same key. +pub(super) fn normalize_for_repetition(s: &str) -> String { + let collapsed = collapse_whitespace(s).to_lowercase(); + let mut out = String::with_capacity(collapsed.len()); + let mut in_digits = false; + for c in collapsed.chars() { + if c.is_ascii_digit() { + if !in_digits { + out.push('#'); + in_digits = true; + } + } else { + out.push(c); + in_digits = false; + } + } + out +} + +/// Cross-page repetition detector. Returns the set of normalized strings that +/// appear in the top or bottom band of ≥ `HEADER_FOOTER_MIN_FRACTION` of +/// pages (capped below by `HEADER_FOOTER_MIN_PAGES`). The caller uses this to +/// filter `ProjectedLine`s before classification. +/// +/// "Same band" means a line whose top is within `HEADER_BAND_FRACTION` of the +/// page top (header) or whose bottom is within `FOOTER_BAND_FRACTION` of the +/// page bottom (footer). Header and footer bands are tracked separately so a +/// company name that appears as both a header and a body-section title on +/// different pages isn't stripped from the body. +pub fn compute_header_footer_set(pages: &[ParsedPage]) -> std::collections::HashSet { + use std::collections::{HashMap, HashSet}; + let mut set: HashSet = HashSet::new(); + if pages.len() < HEADER_FOOTER_MIN_PAGES { + return set; + } + // Two counters keyed by `(band, normalized_text)` — band is `'h'` or `'f'`. + let mut counts: HashMap<(char, String), HashSet> = HashMap::new(); + for page in pages { + let header_cutoff = page.page_height * HEADER_BAND_FRACTION; + let footer_cutoff = page.page_height * (1.0 - FOOTER_BAND_FRACTION); + for line in &page.projected_lines { + let text = line.text.trim(); + if text.is_empty() { + continue; + } + let norm = normalize_for_repetition(text); + if norm.is_empty() { + continue; + } + // Header band: top of line within the top band. + if line.bbox.y <= header_cutoff { + counts + .entry(('h', norm.clone())) + .or_default() + .insert(page.page_number); + } + // Footer band: bottom of line at or below the footer cutoff. + let line_bottom = line.bbox.y + line.bbox.height; + if line_bottom >= footer_cutoff { + counts + .entry(('f', norm)) + .or_default() + .insert(page.page_number); + } + } + } + let threshold = (pages.len() as f32 * HEADER_FOOTER_MIN_FRACTION) + .ceil() + .max(HEADER_FOOTER_MIN_PAGES as f32) as usize; + for ((_, norm), pages_seen) in counts { + if pages_seen.len() >= threshold { + set.insert(norm); + } + } + set +} + +/// Fraction of page height treated as the top band for the single-page +/// chrome detector (slightly wider than the cross-page band — single-page +/// chrome can sit a bit deeper, e.g. a citation block above a paper title). +const SP_TOP_BAND_FRACTION: f32 = 0.15; +/// Fraction of page height for the bottom band of the single-page detector. +const SP_BOTTOM_BAND_FRACTION: f32 = 0.15; +/// Minimum gap (in multiples of body line height) between candidate chrome +/// and the rest of the page content. A real header/footer is visually +/// separated; body lines that happen to sit near the top/bottom are not. +const SP_ISOLATION_GAP_RATIO: f32 = 1.0; + +/// Heuristic check: does the line text match a known running-chrome +/// signature? URLs/DOIs, journal-article preamble ("Please cite this +/// article…"), `Page N of M`, copyright marks, volume/issue markers, and +/// journal citation lines (year + page range + ≤ ~120 chars). +/// +/// Returns true only for unambiguous chrome patterns — false-positive +/// recall on body prose is the main risk to guard. +fn matches_chrome_pattern(text: &str) -> bool { + let t = text.trim(); + if t.is_empty() { + return false; + } + let lower = t.to_lowercase(); + + // URL / DOI prefixes — chrome lines are very often a citation URL. + if lower.contains("http://") + || lower.contains("https://") + || lower.starts_with("www.") + || lower.contains(" www.") + || lower.contains("doi:") + || lower.contains("doi.org/") + || lower.contains("dx.doi.org") + { + return true; + } + + // Common journal-paper top-banner phrases. + if lower.contains("please cite this article") + || lower.contains("contents lists available at") + || lower.contains("available online at") + || lower.contains("downloaded from") + { + return true; + } + + // Copyright / trademark chrome. + if t.contains('©') || lower.contains("copyright ") || lower.contains("all rights reserved") { + return true; + } + + // "Page N", "Page N of M", standalone page numbers. + if let Some(rest) = lower.strip_prefix("page ") { + let head: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect(); + if !head.is_empty() { + return true; + } + } + // Lone page number (≤4 digits, possibly with a leading "p." or similar). + if t.chars().all(|c| c.is_ascii_digit()) && t.len() <= 4 { + return true; + } + + // Volume / Issue markers ("Vol. 24" / "Vol 24" / "No. 3"). + let lb = lower.as_bytes(); + for i in 0..lb.len().saturating_sub(4) { + // ASCII-only check — safe to byte-index because the prefix we look + // for ("vol") is pure ASCII and we test bytes one at a time. + let starts_word = i == 0 || !lb[i - 1].is_ascii_alphanumeric(); + if !starts_word { + continue; + } + if lb[i] == b'v' && lb[i + 1] == b'o' && lb[i + 2] == b'l' { + let sep = lb[i + 3]; + if sep == b'.' || sep == b' ' || sep == b',' { + // any digit later in the line is enough + if lb[i + 4..].iter().any(|b| b.is_ascii_digit()) { + return true; + } + } + } + } + // Journal-cite-style: contains a 4-digit year (19xx/20xx) AND a numeric + // page range (digits[-–]digits) AND short enough to plausibly be chrome. + if t.len() <= 120 && has_year(&lower) && has_digit_range(t) { + return true; + } + + false +} + +fn has_year(lower: &str) -> bool { + let bytes = lower.as_bytes(); + for i in 0..bytes.len().saturating_sub(3) { + let starts_word = i == 0 || !bytes[i - 1].is_ascii_alphanumeric(); + if !starts_word { + continue; + } + if ((bytes[i] == b'1' && bytes[i + 1] == b'9') + || (bytes[i] == b'2' && bytes[i + 1] == b'0')) + && bytes[i + 2].is_ascii_digit() + && bytes[i + 3].is_ascii_digit() + { + let ends_word = i + 4 >= bytes.len() || !bytes[i + 4].is_ascii_alphanumeric(); + if ends_word { + return true; + } + } + } + false +} + +fn has_digit_range(t: &str) -> bool { + // Look for digit, optional spaces, '-' or '–', optional spaces, digit. + let chars: Vec = t.chars().collect(); + let mut i = 0; + while i < chars.len() { + if chars[i].is_ascii_digit() { + let mut j = i + 1; + while j < chars.len() && chars[j].is_ascii_digit() { + j += 1; + } + let g1_len = j - i; + // Skip optional spaces. + let mut k = j; + while k < chars.len() && chars[k] == ' ' { + k += 1; + } + if k < chars.len() && (chars[k] == '-' || chars[k] == '–' || chars[k] == '—') { + let mut m = k + 1; + while m < chars.len() && chars[m] == ' ' { + m += 1; + } + if m < chars.len() && chars[m].is_ascii_digit() { + let mut n = m + 1; + while n < chars.len() && chars[n].is_ascii_digit() { + n += 1; + } + let g2_len = n - m; + // A `YYYY-MM` / `YYYY-MM-DD` date is not a page range. A real + // citation page range never starts with a 4-digit calendar + // year whose second component is a 1-2 digit month — that + // pattern is an administrative date (e.g. a "MS-2024-07" + // tracking number), so skip it and keep scanning for a + // genuine range elsewhere on the line. + let g1_is_year = g1_len == 4 + && ((chars[i] == '1' && chars[i + 1] == '9') + || (chars[i] == '2' && chars[i + 1] == '0')); + if !(g1_is_year && g2_len <= 2) { + return true; + } + i = n; + continue; + } + } + i = j; + } else { + i += 1; + } + } + false +} + +/// Detect running header/footer chrome on a single page using position + +/// isolation + (pattern hint OR font-size delta from body). Returns the set +/// of `projected_lines` indices to strip before classification. +/// +/// Designed to complement `compute_header_footer_set`: the cross-page +/// detector needs ≥2 pages and matching repetition; this one fires on +/// single-page docs or on multi-page docs where chrome doesn't repeat +/// consistently (e.g. per-page citation tags). Conservative by design — +/// requires a strong signature (pattern OR clear size delta) plus an +/// isolation gap, so real titles and section headings are preserved. +pub fn detect_single_page_chrome( + page: &ParsedPage, + body_size: f32, +) -> std::collections::HashSet { + use std::collections::HashSet; + let mut out: HashSet = HashSet::new(); + if page.projected_lines.is_empty() { + return out; + } + let h = page.page_height; + if h <= 0.0 { + return out; + } + let top_cutoff = h * SP_TOP_BAND_FRACTION; + let bottom_cutoff = h * (1.0 - SP_BOTTOM_BAND_FRACTION); + + // Cache line top/bottom so isolation checks are straightforward. + let tops: Vec = page.projected_lines.iter().map(|l| l.bbox.y).collect(); + let bots: Vec = page + .projected_lines + .iter() + .map(|l| l.bbox.y + l.bbox.height) + .collect(); + + for (idx, line) in page.projected_lines.iter().enumerate() { + let text = line.text.trim(); + if text.is_empty() { + continue; + } + let in_top = bots[idx] <= top_cutoff; + let in_bottom = tops[idx] >= bottom_cutoff; + if !(in_top || in_bottom) { + continue; + } + + // Isolation: gap to the nearest non-band neighbor must be ≥ the + // configured ratio of body line height. For a top header we look + // *down*; for a bottom footer we look *up*. Use body size as the gap + // reference when known; otherwise fall back to the line's own height. + let gap_ref = if body_size > 0.0 { + body_size + } else { + line.bbox.height.max(1.0) + }; + let required_gap = SP_ISOLATION_GAP_RATIO * gap_ref; + // Isolation: gap to the nearest neighbor line (by y) on the body + // side must be ≥ required_gap. Multi-line chrome counts as one + // unit — we check the gap from this group's outer edge to the + // nearest *non-chrome-candidate* line. As a simple proxy, look + // at the nearest line in y-order whose pattern doesn't match. + let isolated = if in_top { + page.projected_lines + .iter() + .enumerate() + .filter(|(j, l)| { + *j != idx && tops[*j] > bots[idx] && !matches_chrome_pattern(l.text.trim()) + }) + .map(|(j, _)| tops[j] - bots[idx]) + .min_by(|a, b| a.total_cmp(b)) + .map(|gap| gap >= required_gap) + .unwrap_or(true) + } else { + page.projected_lines + .iter() + .enumerate() + .filter(|(j, l)| { + *j != idx && bots[*j] < tops[idx] && !matches_chrome_pattern(l.text.trim()) + }) + .map(|(j, _)| tops[idx] - bots[j]) + .min_by(|a, b| a.total_cmp(b)) + .map(|gap| gap >= required_gap) + .unwrap_or(true) + }; + if !isolated { + continue; + } + + // Require a chrome pattern signature — drops the false-positive risk + // of nuking real titles/headings that happen to sit in the band. + // Font-size delta alone is too coarse: section titles routinely + // differ from body by ≥15% but are not chrome. + if !matches_chrome_pattern(text) { + continue; + } + + // Length guard: avoid stripping long paragraphs that happen to sit in + // the band and start with a URL — chrome lines are short. 200 char + // ceiling accommodates the 'Please cite' preamble (often 100-150 + // chars) without admitting body paragraphs. + if text.chars().count() > 200 { + continue; + } + + out.insert(idx); + } + + out +} + +/// Returns true if `line` (located on `page`) matches the running +/// header/footer set: the line sits in the top or bottom band AND its +/// normalized text is in `header_footer`. +pub(super) fn is_header_or_footer( + line: &ProjectedLine, + page: &ParsedPage, + header_footer: &std::collections::HashSet, +) -> bool { + if header_footer.is_empty() { + return false; + } + let header_cutoff = page.page_height * HEADER_BAND_FRACTION; + let footer_cutoff = page.page_height * (1.0 - FOOTER_BAND_FRACTION); + let in_band = line.bbox.y <= header_cutoff || line.bbox.y + line.bbox.height >= footer_cutoff; + if !in_band { + return false; + } + let norm = normalize_for_repetition(line.text.trim()); + header_footer.contains(&norm) +} + +#[cfg(test)] +mod tests { + use super::super::test_helpers::header_footer_page; + use super::*; + + #[test] + fn normalize_collapses_digits_and_case() { + assert_eq!(normalize_for_repetition("Page 1 of 6"), "page # of #"); + assert_eq!(normalize_for_repetition("PAGE 12 OF 6"), "page # of #"); + assert_eq!(normalize_for_repetition("Confidential"), "confidential"); + } + + #[test] + fn detects_repeating_header_and_footer() { + let pages = vec![ + header_footer_page(1, "Acme Confidential", "Page 1 of 3", "Body one."), + header_footer_page(2, "Acme Confidential", "Page 2 of 3", "Body two."), + header_footer_page(3, "Acme Confidential", "Page 3 of 3", "Body three."), + ]; + let set = compute_header_footer_set(&pages); + assert!(set.contains("acme confidential")); + assert!(set.contains("page # of #")); + } + + #[test] + fn skips_repetition_check_on_single_page() { + let pages = vec![header_footer_page(1, "Solo", "footer", "body")]; + let set = compute_header_footer_set(&pages); + assert!(set.is_empty()); + } + + #[test] + fn body_text_not_classified_as_header() { + // Same text in the body of every page should NOT be stripped — only + // text within the top/bottom band qualifies. + let mut pages = Vec::new(); + for n in 1..=3 { + let mut p = header_footer_page(n, "unique header", "unique footer", "shared body text"); + // Move "shared body text" out of header/footer bands (already at y=50 in mid-page). + // No-op — just illustrating intent. + p.projected_lines[0].text = format!("unique header {n}"); + p.projected_lines[2].text = format!("unique footer {n}"); + pages.push(p); + } + let set = compute_header_footer_set(&pages); + // "shared body text" sits mid-page — never matched. + assert!(!set.contains("shared body text")); + } + + // ----- single-page chrome detector tests ----- + + use super::super::test_helpers::{line, page}; + + #[test] + fn chrome_pattern_recognizes_common_signatures() { + assert!(matches_chrome_pattern("http://example.com/foo")); + assert!(matches_chrome_pattern("www.nature.com/scientificreports/")); + assert!(matches_chrome_pattern( + "Please cite this article in press as: ..." + )); + assert!(matches_chrome_pattern("Page 12 of 24")); + assert!(matches_chrome_pattern("9")); + assert!(matches_chrome_pattern("© 2023 Acme Corp")); + assert!(matches_chrome_pattern( + "Cell Chemical Biology 24, 1–9, November 16, 2017" + )); + // Not chrome + assert!(!matches_chrome_pattern( + "The quick brown fox jumps over the lazy dog." + )); + assert!(!matches_chrome_pattern("Introduction")); + // Title with year but no range — should not be stripped as chrome. + assert!(!matches_chrome_pattern("Acme Annual Report 2023")); + // Administrative key-value band with a YYYY-MM tracking date — the + // date must not be read as a journal page range. + assert!(!matches_chrome_pattern( + "SERFF Tracking #: FBLB-134215544 State Tracking #: Company Tracking #: MS-2024-07" + )); + // A genuine YYYY-MM / YYYY-MM-DD date is not a page range. + assert!(!has_digit_range("Filed 2024-07")); + assert!(!has_digit_range("Generated 2025-01-23")); + // A real page range still registers, even with a year on the line. + assert!(has_digit_range("Vol 24, 1-9, 2017")); + } + + #[test] + fn detects_top_url_chrome_on_single_page() { + // 792pt page; top band = 0..118pt. + let lines = vec![ + // Chrome line at y=20 (in top band) + line("www.nature.com/scientificreports/", 50.0, 20.0, 10.0, 10.0), + // Body title well below chrome (clear gap) + line("Main Body Title", 50.0, 200.0, 14.0, 14.0), + line("Body prose line one.", 50.0, 220.0, 10.0, 10.0), + line("Body prose line two.", 50.0, 232.0, 10.0, 10.0), + ]; + let p = page(lines); + let strip = detect_single_page_chrome(&p, 10.0); + assert!(strip.contains(&0), "top-band URL should strip"); + assert!(!strip.contains(&1)); + assert!(!strip.contains(&2)); + } + + #[test] + fn detects_bottom_journal_citation_chrome() { + let lines = vec![ + line("Body line.", 50.0, 300.0, 10.0, 10.0), + line("More body.", 50.0, 312.0, 10.0, 10.0), + // Footer at y=770 (page height 792, bottom band 673..792) + line( + "Cell Chemical Biology 24, 1–9, November 16, 2017", + 50.0, + 770.0, + 10.0, + 10.0, + ), + ]; + let p = page(lines); + let strip = detect_single_page_chrome(&p, 10.0); + assert!(strip.contains(&2), "bottom journal-cite should strip"); + assert!(!strip.contains(&0)); + } + + #[test] + fn preserves_title_at_top_without_chrome_pattern() { + // A document title at the top should NOT be stripped — no pattern + // hint, and font-size delta alone is not a chrome signal. + let lines = vec![ + line("My Important Document", 50.0, 30.0, 18.0, 18.0), + line("Author Name", 50.0, 60.0, 10.0, 10.0), + line("Body prose here.", 50.0, 200.0, 10.0, 10.0), + ]; + let p = page(lines); + let strip = detect_single_page_chrome(&p, 10.0); + assert!( + strip.is_empty(), + "title without chrome pattern must survive, got {:?}", + strip + ); + } + + #[test] + fn chrome_with_no_isolation_gap_is_not_stripped() { + // URL at top followed *immediately* by body — no gap → don't strip. + let lines = vec![ + line("http://example.com/foo", 50.0, 20.0, 10.0, 10.0), + // Next line within 1× body size below — no isolation. + line("Body line right after.", 50.0, 32.0, 10.0, 10.0), + line("Continuing body.", 50.0, 44.0, 10.0, 10.0), + ]; + let p = page(lines); + let strip = detect_single_page_chrome(&p, 10.0); + assert!( + strip.is_empty(), + "no isolation gap means it's part of body, got {:?}", + strip + ); + } +} diff --git a/crates/liteparse/src/markdown_layout/tables.rs b/crates/liteparse/src/markdown_layout/tables.rs new file mode 100644 index 0000000..f4039ee --- /dev/null +++ b/crates/liteparse/src/markdown_layout/tables.rs @@ -0,0 +1,4411 @@ +use crate::types::{GraphicPrimitive, ProjectedLine, Rect, TextItem}; + +use super::blocks::Block; +use super::paragraphs::collapse_whitespace; +use crate::projection::is_bold_item; + +/// Minimum cells per row for a region to qualify as a table. +pub(super) const TABLE_MIN_COLUMNS: usize = 3; + +/// Minimum consecutive rows for a region to qualify as a table. +const TABLE_MIN_ROWS: usize = 2; + +/// Gap between adjacent spans (in multiples of dominant font size) above which +/// we treat the gap as a cell boundary. +const TABLE_CELL_GAP_FONT_MULTIPLIER: f32 = 1.0; + +/// Tolerance (points) for matching a cell's start-x to an existing column +/// track when extending a candidate table run. +const TABLE_TRACK_TOLERANCE_PT: f32 = 6.0; + +/// Minimum gap (in multiples of the dominant font size) required between two +/// adjacent column tracks for them to count as distinct columns. The two +/// callers — inferred-track detection and the ruled-grid track derivation — +/// must use the same value or one stage can fuse columns the other split. +const TABLE_MIN_TRACK_GAP_FONT_MULT: f32 = 1.5; +/// Absolute floor (points) on the inter-track gap, paired with +/// `TABLE_MIN_TRACK_GAP_FONT_MULT` so tiny-font pages keep a sane minimum. +const TABLE_MIN_TRACK_GAP_FLOOR_PT: f32 = 12.0; + +/// Minimum fraction of a row's cells that must carry text for that row to +/// anchor the header/body split in ruled-grid collapse. +const TABLE_ROW_MIN_FILL: f32 = 0.9; + +/// Floor for the sparse-new-row path: a partial-cell line whose bottom-gap +/// exceeds this fraction qualifies as a real new row (with empty cells at +/// missing tracks) instead of being treated as a wrap continuation. Below +/// this fraction, the existing wrap-merge path runs unchanged. +const TABLE_SPARSE_ROW_MIN_BOTTOM_GAP_FRAC: f32 = 0.5; + +/// Maximum vertical gap between consecutive table rows, expressed in multiples +/// of the line height. Looser than the paragraph rule because table rows often +/// have more vertical padding than prose lines. +const TABLE_ROW_GAP_MULTIPLIER: f32 = 2.5; + +/// Maximum coefficient-of-variation for row spacing within a confident table +/// (rejecting irregular spacing that's more likely prose or a footer block). +const TABLE_ROW_SPACING_MAX_CV: f32 = 0.5; + +/// One cell within a tabular row: contributing spans aggregated to text and +/// its leftmost x position, used to align cells across rows into column +/// "tracks". +#[derive(Debug, Clone)] +pub(super) struct TableCell { + pub(super) start_x: f32, + /// Right edge of the cell (x of the last span's right). Used by + /// `recover_merged_cell` to detect cells that straddle two column tracks + /// when the projection merged two adjacent words into one span. + pub(super) end_x: f32, + pub(super) text: String, + pub(super) bold: bool, +} + +/// A contiguous tabular run: line indices `[start, end)` plus the detected +/// rows. Used so the line-classifier can skip the consumed range and so +/// fallback rendering can reach back for the original projected text. +#[derive(Debug, Clone)] +pub(super) struct TableRun { + pub(super) start: usize, + pub(super) end: usize, + /// First line index of the table's *body* rows. Differs from `start` when + /// header lines above the body were absorbed into the run; the cluster + /// re-extraction pass must not re-bin those header lines as body rows. + pub(super) body_start: usize, + pub(super) block: Block, +} + +/// Split a `ProjectedLine`'s spans into cells. A gap larger than +/// `TABLE_CELL_GAP_FONT_MULTIPLIER × font_size` between adjacent spans starts +/// a new cell; otherwise spans join into the same cell with a single space. +pub(super) fn split_cells(line: &ProjectedLine) -> Vec { + // Skip whitespace-only spans before computing gaps — leading/trailing + // empty items would otherwise add spurious cell boundaries. + let mut spans: Vec<&TextItem> = line + .spans + .iter() + .filter(|s| !s.text.trim().is_empty()) + .collect(); + spans.sort_by(|a, b| a.x.total_cmp(&b.x)); + if spans.is_empty() { + return Vec::new(); + } + let font_size = if line.dominant_font_size > 0.0 { + line.dominant_font_size + } else { + line.bbox.height.max(1.0) + }; + let gap_threshold = font_size * TABLE_CELL_GAP_FONT_MULTIPLIER; + + let mut cells: Vec = Vec::new(); + let mut current_text = String::new(); + let mut current_start = spans[0].x; + let mut current_bold_chars: usize = 0; + let mut current_total_chars: usize = 0; + let mut prev_right = spans[0].x; + + for (i, span) in spans.iter().enumerate() { + let gap = span.x - prev_right; + let break_cell = i > 0 && gap > gap_threshold; + if break_cell { + let bold = current_total_chars > 0 && current_bold_chars * 2 > current_total_chars; + cells.push(TableCell { + start_x: current_start, + end_x: prev_right, + text: collapse_whitespace(current_text.trim()), + bold, + }); + current_text.clear(); + current_start = span.x; + current_bold_chars = 0; + current_total_chars = 0; + } + if !current_text.is_empty() && !current_text.ends_with(' ') { + current_text.push(' '); + } + current_text.push_str(&span.text); + let n = span.text.chars().count(); + current_total_chars += n; + if is_bold_item(span) { + current_bold_chars += n; + } + prev_right = span.x + span.width.max(0.0); + } + if !current_text.trim().is_empty() { + let bold = current_total_chars > 0 && current_bold_chars * 2 > current_total_chars; + cells.push(TableCell { + start_x: current_start, + end_x: prev_right, + text: collapse_whitespace(current_text.trim()), + bold, + }); + } + cells +} + +/// When a candidate row has fewer cells than the established column count, +/// look for cells whose x-range straddles multiple column tracks (likely two +/// or more adjacent words that PDFium merged into a single text run) and +/// split each on internal whitespace at the boundaries nearest to the +/// straddled tracks. +/// +/// Returns the patched cells if every short cell could be cleanly split to +/// recover `tracks.len()` cells total; otherwise `None`. +fn recover_merged_cell(mut cells: Vec, tracks: &[f32]) -> Option> { + let target = tracks.len(); + if cells.len() >= target { + return None; + } + // Repeatedly find the cell that straddles the most tracks (≥2) and split + // it. Each iteration strictly grows `cells.len()`, so termination is + // guaranteed; if no cell straddles ≥2 tracks before we hit the target, + // recovery fails. + while cells.len() < target { + let mut best_i: Option = None; + let mut best_count: usize = 1; + let mut best_contained: Vec = Vec::new(); + for (i, cell) in cells.iter().enumerate() { + let contained: Vec = tracks + .iter() + .copied() + .filter(|t| { + *t >= cell.start_x - TABLE_TRACK_TOLERANCE_PT + && *t <= cell.end_x + TABLE_TRACK_TOLERANCE_PT + }) + .collect(); + if contained.len() > best_count { + best_count = contained.len(); + best_i = Some(i); + best_contained = contained; + } + } + let i = best_i?; + let cell = cells[i].clone(); + // Split the merged cell text at each contained track after the first. + let pieces = split_text_at_x_anchors( + cell.text.trim(), + cell.start_x, + cell.end_x - cell.start_x, + &best_contained[1..], + )?; + if pieces.iter().any(|p| p.is_empty()) { + return None; + } + // Synthesize new TableCells aligned with each track. + let mut new_cells: Vec = Vec::with_capacity(pieces.len()); + for (p, piece) in pieces.iter().enumerate() { + let start_x = if p == 0 { + cell.start_x + } else { + best_contained[p] + }; + let end_x = if p + 1 < best_contained.len() { + (best_contained[p + 1] - 1.0).max(start_x) + } else { + cell.end_x + }; + new_cells.push(TableCell { + start_x, + end_x, + text: piece.clone(), + bold: cell.bold, + }); + } + cells.remove(i); + for (offset, c) in new_cells.into_iter().enumerate() { + cells.insert(i + offset, c); + } + } + if cells.len() == target { + Some(cells) + } else { + None + } +} + +/// Vertical-gap check for table rows. Looser than paragraph continuation +/// because table rows often have extra padding between them. +fn table_rows_adjacent(prev: &ProjectedLine, cur: &ProjectedLine) -> bool { + // Intentionally don't require region_path equality. Indented sub-group + // rows (e.g. an indented "MEMORYBANK" row in a grouped academic results + // table) sometimes land in a different XY-cut leaf than the rest of the + // table — but the column-track alignment and y-gap checks below are + // strong enough signals on their own to keep us from spuriously + // bridging unrelated regions. + let prev_bottom = prev.bbox.y + prev.bbox.height; + let gap = cur.bbox.y - prev_bottom; + let line_height = prev.bbox.height.max(cur.bbox.height).max(1.0); + gap >= -line_height && gap <= line_height * TABLE_ROW_GAP_MULTIPLIER +} + +/// Coefficient of variation (std-dev / mean) of inter-row vertical gaps. +/// Returns 0.0 for runs with <2 gaps (nothing to compare). Used to reject +/// runs whose row spacing is too irregular to be a real table. +fn row_spacing_cv(rows: &[(usize, &ProjectedLine, Vec)]) -> f32 { + if rows.len() < 3 { + return 0.0; + } + let gaps: Vec = rows + .windows(2) + .map(|w| (w[1].1.bbox.y - w[0].1.bbox.y).abs()) + .collect(); + let mean = gaps.iter().sum::() / gaps.len() as f32; + if mean <= 0.0 { + return f32::INFINITY; + } + let var = gaps.iter().map(|g| (g - mean).powi(2)).sum::() / gaps.len() as f32; + var.sqrt() / mean +} + +/// Test whether a candidate cell aligns with the column at index `k` in +/// `track_ranges`. Track ranges are the `(start_x, end_x)` of the header +/// (first-row) cell that defined the column. A body cell aligns to column `k` +/// if any of these hold: +/// +/// - its centroid sits inside the header cell's x-range (handles centered or +/// wider body cells like `Offset Binary` under a narrower `OUTPUT FORMAT`); +/// - its start_x matches the header start_x within tolerance (left alignment); +/// - its end_x matches the header end_x within tolerance (right alignment). +/// +/// Accepting any of these (rather than start_x alone) recovers tables whose +/// body cells are center- or right-aligned within the column. +fn cell_aligns_track(cell: &TableCell, track_range: (f32, f32)) -> bool { + let (ts, te) = track_range; + let tol = TABLE_TRACK_TOLERANCE_PT; + let center = (cell.start_x + cell.end_x) * 0.5; + if center >= ts - tol && center <= te + tol { + return true; + } + if (cell.start_x - ts).abs() <= tol { + return true; + } + if (cell.end_x - te).abs() <= tol { + return true; + } + false +} + +/// Pick the best matching column index for `cell`, preferring center +/// containment, then start_x match, then end_x match. Returns `None` when no +/// column aligns. +fn match_track_idx(cell: &TableCell, track_ranges: &[(f32, f32)]) -> Option { + let tol = TABLE_TRACK_TOLERANCE_PT; + let center = (cell.start_x + cell.end_x) * 0.5; + // Prefer centroid-in-range. + if let Some((i, _)) = track_ranges + .iter() + .enumerate() + .filter(|(_, (s, e))| center >= s - tol && center <= e + tol) + .min_by(|(_, (s1, e1)), (_, (s2, e2))| { + let c1 = (s1 + e1) * 0.5; + let c2 = (s2 + e2) * 0.5; + (center - c1).abs().total_cmp(&(center - c2).abs()) + }) + { + return Some(i); + } + // Fall back to nearest start_x within tolerance. + if let Some((i, _)) = track_ranges + .iter() + .enumerate() + .filter(|(_, (s, _))| (cell.start_x - s).abs() <= tol) + .min_by(|(_, (s1, _)), (_, (s2, _))| { + (cell.start_x - s1) + .abs() + .total_cmp(&(cell.start_x - s2).abs()) + }) + { + return Some(i); + } + // Fall back to nearest end_x within tolerance (right-aligned cells). + track_ranges + .iter() + .enumerate() + .filter(|(_, (_, e))| (cell.end_x - e).abs() <= tol) + .min_by(|(_, (_, e1)), (_, (_, e2))| { + (cell.end_x - e1).abs().total_cmp(&(cell.end_x - e2).abs()) + }) + .map(|(i, _)| i) +} + +/// Maximum number of rows to walk forward when inferring tracks from raw +/// item positions. 12 covers most real tables while bounding the cost. +const TABLE_TRACK_INFERENCE_MAX_ROWS: usize = 12; + +/// Walk forward from `start_idx` collecting raw text-item start-x positions +/// across all adjacent rows, then single-link cluster them at +/// `TABLE_TRACK_TOLERANCE_PT`. Returns cluster centroids sorted ascending. +/// +/// Unlike `split_cells`-derived tracks, this is immune to the +/// `TABLE_CELL_GAP_FONT_MULTIPLIER` knife-edge that collapses tightly-kerned +/// numeric columns into a single cell (e.g. `$448 $427 7%` at 14pt with +/// 13.9pt inter-item gaps). It also surfaces tracks witnessed by even a +/// single row when other rows in the same table have PDFium-level merged +/// spans that hide the full column geometry. +fn infer_tracks_from_raw_items(lines: &[ProjectedLine], start_idx: usize) -> Vec { + let mut xs: Vec = Vec::new(); + let push_row_xs = |xs: &mut Vec, line: &ProjectedLine| { + let row_xs: Vec = line + .spans + .iter() + .filter(|s| !s.text.trim().is_empty()) + .map(|s| s.x) + .collect(); + // Skip 0- or 1-item rows — they don't carry column info and can + // introduce noise from single-cell prose lines. + if row_xs.len() >= 2 { + xs.extend(row_xs); + } + }; + push_row_xs(&mut xs, &lines[start_idx]); + let mut j = start_idx + 1; + let mut rows_used = 1; + while j < lines.len() && rows_used < TABLE_TRACK_INFERENCE_MAX_ROWS { + if !table_rows_adjacent(&lines[j - 1], &lines[j]) { + break; + } + push_row_xs(&mut xs, &lines[j]); + j += 1; + rows_used += 1; + } + xs.sort_by(f32::total_cmp); + // Each cluster carries its support = how many raw item x's fell into it. + // A genuine column recurs once per body row, so its support ≈ the row + // count; a header line sitting a few points off the data anchor (a + // centered "Number of" above a right-aligned numeric column) injects a + // separate cluster supported by a single item. + let mut clusters: Vec<(f32, usize)> = Vec::new(); + let mut current_sum = 0.0f32; + let mut current_count = 0usize; + let mut current_anchor = f32::NEG_INFINITY; + for &x in &xs { + if current_count == 0 || (x - current_anchor).abs() <= TABLE_TRACK_TOLERANCE_PT { + current_sum += x; + current_count += 1; + current_anchor = current_sum / current_count as f32; + } else { + clusters.push((current_sum / current_count as f32, current_count)); + current_sum = x; + current_count = 1; + current_anchor = x; + } + } + if current_count > 0 { + clusters.push((current_sum / current_count as f32, current_count)); + } + // Prune single-item phantom tracks when there is a clear multi-row body + // (some cluster supported by ≥3 items). Without this, header cells offset + // from their data column manufacture extra close-spaced tracks that + // collapse `min_gap` and force the inferred path to bail, dropping back to + // the header-seeded path that silently discards an unaligned column. + // Conservative: only prunes when a strong body signal exists, and only the + // weakest (single-item) clusters, so small/sparse tables are untouched. + let max_support = clusters.iter().map(|c| c.1).max().unwrap_or(0); + if max_support >= 3 { + clusters.retain(|c| c.1 >= 2); + } + clusters.into_iter().map(|c| c.0).collect() +} + +/// Build a row's cells against a fixed set of column anchors. Each raw item +/// is assigned to the track its x-extent covers; items that span multiple +/// tracks (PDFium-level merged spans like `$1,298 $1,263 5%` at one anchor +/// reaching past two more anchors) are split on internal whitespace at +/// boundaries closest to each crossed anchor. +/// +/// Returns `Some(cells)` of length `tracks.len()` (some cells may have empty +/// text), or `None` if any item can't be assigned cleanly (out-of-band item, +/// or a multi-track item with no usable whitespace boundary). +fn cells_from_raw_items_with_tracks( + line: &ProjectedLine, + tracks: &[f32], +) -> Option> { + let mut spans: Vec<&TextItem> = line + .spans + .iter() + .filter(|s| !s.text.trim().is_empty()) + .collect(); + spans.sort_by(|a, b| a.x.total_cmp(&b.x)); + // Require ≥ 2 PDFium spans on the row. A 1-span row spanning multiple + // tracks is almost always prose (wrapped paragraph whose x-range + // happens to overlap the track region); shredding it at whitespace + // anchors corrupts the body text. Real merged-numeric table rows still + // have a label span and a values span (≥ 2). + if spans.len() < 2 { + return None; + } + let tol = TABLE_TRACK_TOLERANCE_PT; + let mut cells: Vec = tracks + .iter() + .map(|&t| TableCell { + start_x: t, + end_x: t, + text: String::new(), + bold: false, + }) + .collect(); + let push_text = |dst: &mut String, src: &str| { + let src = src.trim(); + if src.is_empty() { + return; + } + if !dst.is_empty() && !dst.ends_with(' ') { + dst.push(' '); + } + dst.push_str(src); + }; + for span in &spans { + let x0 = span.x; + let x1 = span.x + span.width.max(0.0); + let covered: Vec = tracks + .iter() + .enumerate() + .filter(|&(_, &t)| t >= x0 - tol && t <= x1 + tol) + .map(|(i, _)| i) + .collect(); + // For spans that cover multiple tracks (multi-column-spanning items + // we'd want to split), the span's leftmost x must anchor at the + // leftmost covered track within tolerance. Otherwise the item is + // non-tabular content (a wrapped paragraph / footnote whose x-range + // merely happens to overlap the track region) that we shouldn't + // shred at whitespace boundaries. + if covered.len() > 1 { + let left_track = tracks[covered[0]]; + if (x0 - left_track).abs() > tol { + return None; + } + } + match covered.len() { + 0 => return None, + 1 => { + let idx = covered[0]; + push_text(&mut cells[idx].text, &span.text); + cells[idx].end_x = cells[idx].end_x.max(x1); + if is_bold_item(span) { + cells[idx].bold = true; + } + } + _ => { + let pieces = split_span_at_anchors(span, &covered, tracks)?; + let bold = is_bold_item(span); + for (idx, piece) in covered.iter().zip(pieces.iter()) { + if piece.is_empty() { + return None; + } + push_text(&mut cells[*idx].text, piece); + if bold { + cells[*idx].bold = true; + } + } + } + } + } + for cell in &mut cells { + cell.text = collapse_whitespace(cell.text.trim()); + } + Some(cells) +} + +/// Letters strictly outnumber digits — discriminates word-like labels +/// (group headers) from merged numeric runs ("$448 $427 7%"). +fn is_alpha_dominant(text: &str) -> bool { + let letters = text.chars().filter(|c| c.is_alphabetic()).count(); + let digits = text.chars().filter(|c| c.is_ascii_digit()).count(); + letters > digits +} + +/// Measurement-value shape: a decimal number ("2.28"), currency/percent, +/// comma-grouped number ("1,240"), or a dash placeholder ("--" / "—"). +/// Bare integers (years, codes) deliberately do NOT match — they appear in +/// legitimate header layers. +fn is_value_like(text: &str) -> bool { + let t = text.trim(); + if t.is_empty() { + return false; + } + if t.chars().all(|c| matches!(c, '-' | '—' | '–')) { + return true; + } + if t.contains('$') || t.contains('%') || t.contains('±') { + return true; + } + let chars: Vec = t.chars().collect(); + chars + .windows(3) + .any(|w| w[0].is_ascii_digit() && (w[1] == '.' || w[1] == ',') && w[2].is_ascii_digit()) +} + +/// Split `text` (treated as occupying `[x0, x0 + width]`) into +/// `anchors.len() + 1` trimmed pieces by mapping each anchor x to the +/// whitespace boundary whose linearly-interpolated x is closest. Returns +/// `None` if the text is empty, there are no anchors, or any anchor has no +/// usable whitespace boundary (e.g. unbroken text like a long hex string). +/// Pieces may be empty strings — callers that require non-empty pieces must +/// check. +fn split_text_at_x_anchors( + text: &str, + x0: f32, + width: f32, + anchors: &[f32], +) -> Option> { + let chars: Vec = text.chars().collect(); + let n = chars.len(); + if n == 0 || anchors.is_empty() { + return None; + } + let w = width.max(1.0); + let mut split_indices: Vec = Vec::new(); + for &target in anchors { + let mut best: Option<(usize, f32)> = None; + for (k, ch) in chars.iter().enumerate() { + if !ch.is_whitespace() || split_indices.contains(&k) { + continue; + } + let x = x0 + (k as f32 / n as f32) * w; + let d = (x - target).abs(); + if best.as_ref().is_none_or(|b| d < b.1) { + best = Some((k, d)); + } + } + let (k, _) = best?; + split_indices.push(k); + } + split_indices.sort(); + let mut pieces: Vec = Vec::new(); + let mut prev = 0usize; + for &k in &split_indices { + pieces.push(chars[prev..k].iter().collect::().trim().to_string()); + prev = k; + } + pieces.push(chars[prev..].iter().collect::().trim().to_string()); + Some(pieces) +} + +/// Split a multi-track-spanning span's text into one piece per covered track +/// by picking whitespace positions whose linearly-interpolated x is closest +/// to each subsequent anchor. Returns `Some(pieces)` of length +/// `covered.len()` when every split lands on a real whitespace boundary; +/// `None` if no usable boundary exists (e.g. unbroken text like a long +/// hex string). +fn split_span_at_anchors( + span: &TextItem, + covered: &[usize], + tracks: &[f32], +) -> Option> { + if covered.len() < 2 { + return None; + } + let anchors: Vec = covered[1..].iter().map(|&idx| tracks[idx]).collect(); + split_text_at_x_anchors(&span.text, span.x, span.width, &anchors) +} + +/// Like `try_detect_table` but seeds column tracks from the union of raw +/// item start-x positions across the candidate window rather than from the +/// first row's `split_cells` output. Use this first to unlock tables where +/// the cell-gap heuristic collapses adjacent numeric columns into one cell +/// in every row. Returns `None` when (a) inferred tracks are no richer than +/// per-row bucketing (no win to be had), or (b) the inferred-track candidate +/// fails any soundness check — in which case `try_detect_table`'s existing +/// logic should run. +/// Shared epilogue for the table detectors. Given the accumulated `rows`, walk +/// back to absorb wrapped header lines, optionally promote a bold first row to +/// the header (only when `bold_first_row_eligible` and no header was absorbed), +/// build the body, and construct the `TableRun`. The bold-eligibility predicate +/// differs between callers (the inferred path additionally requires non-empty +/// cells), so it's computed at the call site and passed in. +fn finalize_table_run( + lines: &[ProjectedLine], + start_idx: usize, + floor: usize, + rows: &[(usize, &ProjectedLine, Vec)], + track_ranges: &[(f32, f32)], + column_count: usize, + end: usize, + bold_first_row_eligible: bool, +) -> Option { + // Walk back above the detected body and absorb header lines that align to + // the same column tracks but weren't includable as body rows (merged / + // partial header cells). Multiple wrapped header lines collapse into one + // markdown header row, joined per-column top-to-bottom. + let absorbed = absorb_header_lines(lines, start_idx, track_ranges, column_count, floor); + + // Promote the first body row to header iff it qualifies and we didn't + // already absorb an explicit header above. `row_start` is the index of the + // first body row within `rows`: 0 when the header came from absorbed lines, + // 1 when the bold-first-row promotion consumes rows[0]. + let first_row = &rows[0].2; + let bold_header_qualifies = absorbed.is_none() && bold_first_row_eligible; + let (run_start, header, row_start) = match absorbed { + Some((hstart, header_texts)) => (hstart, Some(header_texts), 0), + None if bold_header_qualifies => ( + start_idx, + Some(first_row.iter().map(|c| c.text.clone()).collect()), + 1, + ), + None => (start_idx, None, 0), + }; + let body_rows: Vec> = rows[row_start..] + .iter() + .map(|(_, _, cells)| cells.iter().map(|c| c.text.clone()).collect()) + .collect(); + if header.is_none() && body_rows.len() < TABLE_MIN_ROWS { + return None; + } + + if *super::flags::DEBUG_TABLE { + eprintln!( + "[tbl-detect @{start_idx}..{end}] cols={column_count} header={header:?} rows={}", + body_rows.len() + ); + } + Some(TableRun { + start: run_start, + end, + body_start: start_idx, + block: Block::Table { + header, + rows: body_rows, + }, + }) +} + +fn try_detect_table_inferred( + lines: &[ProjectedLine], + start_idx: usize, + floor: usize, +) -> Option { + let dbgt = *super::flags::DEBUG_TABLE; + let seed_txt: String = lines[start_idx] + .spans + .iter() + .map(|s| s.text.trim()) + .collect::>() + .join("|"); + macro_rules! bail { + ($($a:tt)*) => {{ + if dbgt { + eprintln!("[tbl-inferred bail @{start_idx} \"{:.40}\"] {}", seed_txt, format!($($a)*)); + } + return None; + }}; + } + + let baseline_cells = split_cells(&lines[start_idx]); + let tracks = infer_tracks_from_raw_items(lines, start_idx); + if dbgt { + eprintln!( + "[tbl-inferred try @{start_idx} \"{:.40}\"] tracks={} baseline={} xs=[{}]", + seed_txt, + tracks.len(), + baseline_cells.len(), + tracks + .iter() + .map(|t| format!("{t:.0}")) + .collect::>() + .join(",") + ); + } + if tracks.len() < TABLE_MIN_COLUMNS { + bail!("tracks {} < MIN_COLUMNS", tracks.len()); + } + // Only bother if we'd actually unlock more columns than the default path. + if tracks.len() <= baseline_cells.len() { + bail!( + "tracks {} <= baseline {}", + tracks.len(), + baseline_cells.len() + ); + } + // Reject "tracks" that are really inter-word positions in prose. Real + // table columns are separated by visible whitespace gutters wider than + // the body font; word positions in running prose cluster at < 1× font + // size apart. Threshold at 1.5× the seed line's dominant font size, with + // a 12pt absolute floor for small fonts. + let font_size = if lines[start_idx].dominant_font_size > 0.0 { + lines[start_idx].dominant_font_size + } else { + lines[start_idx].bbox.height.max(1.0) + }; + let min_track_gap = + (font_size * TABLE_MIN_TRACK_GAP_FONT_MULT).max(TABLE_MIN_TRACK_GAP_FLOOR_PT); + let min_gap = tracks + .windows(2) + .map(|w| w[1] - w[0]) + .fold(f32::INFINITY, f32::min); + if min_gap < min_track_gap { + bail!("min_gap {min_gap:.1} < {min_track_gap:.1}"); + } + let column_count = tracks.len(); + let track_ranges: Vec<(f32, f32)> = tracks.iter().map(|&t| (t, t)).collect(); + let tracks_right_edge = *tracks.last().unwrap() + TABLE_TRACK_TOLERANCE_PT.max(8.0); + + // Seed row: require a strong structural signal — its raw PDFium span + // count must be ≥ tracks.len() AND each span must single-cover (no + // multi-track splits in the seeding row). This rejects prose lines + // where 2-3 spans happen to anchor at inferred tracks but actually + // each span's x-extent covers multiple tracks, which would produce a + // shred-on-whitespace seed that's indistinguishable from a real + // table. Subsequent rows can still have merged spans recovered via + // the multi-cover split path. + // + // The strong row need not be `start_idx` itself: a stacked or partial + // header (a centered "Number of" above a right-aligned numeric column, + // fewer cells than the body) legitimately leads the table. Scan forward + // for the first strong row and seed the body there; `finalize_table_run` + // absorbs the header lines above it. Without this the body would be seeded + // on the header, the run would break at the first unalignable header cell, + // and the table would fall to the header-seeded path that drops a column. + let tol = TABLE_TRACK_TOLERANCE_PT; + let is_strong_row = |line: &ProjectedLine| -> bool { + let spans: Vec<&TextItem> = line + .spans + .iter() + .filter(|s| !s.text.trim().is_empty()) + .collect(); + if spans.len() < tracks.len() { + return false; + } + spans.iter().all(|s| { + let x0 = s.x; + let x1 = s.x + s.width.max(0.0); + tracks + .iter() + .filter(|&&t| t >= x0 - tol && t <= x1 + tol) + .count() + == 1 + }) + }; + let mut body_start = None; + { + let mut k = start_idx; + let mut used = 0; + while k < lines.len() && used < TABLE_TRACK_INFERENCE_MAX_ROWS { + if k > start_idx && !table_rows_adjacent(&lines[k - 1], &lines[k]) { + break; + } + if is_strong_row(&lines[k]) { + body_start = Some(k); + break; + } + k += 1; + used += 1; + } + } + let Some(body_start) = body_start else { + bail!("no strong body row in window"); + }; + let Some(first) = cells_from_raw_items_with_tracks(&lines[body_start], &tracks) else { + bail!("body row cells unassignable"); + }; + if first.iter().filter(|c| !c.text.is_empty()).count() < TABLE_MIN_COLUMNS { + bail!("body populated cells < MIN_COLUMNS"); + } + let mut rows: Vec<(usize, &ProjectedLine, Vec)> = + vec![(body_start, &lines[body_start], first)]; + + let mut j = body_start + 1; + while j < lines.len() { + if lines[j].bbox.x > tracks_right_edge { + j += 1; + continue; + } + if !table_rows_adjacent(rows.last().unwrap().1, &lines[j]) { + break; + } + let Some(cells) = cells_from_raw_items_with_tracks(&lines[j], &tracks) else { + if dbgt { + let rt: String = lines[j] + .spans + .iter() + .map(|s| s.text.trim()) + .collect::>() + .join("|"); + eprintln!("[tbl-inferred trunc @{j} \"{:.40}\"] row unassignable", rt); + } + break; + }; + // Drop rows that contribute zero populated cells (all out-of-band + // or empty after splitting) — they'd add noise without content. + if cells.iter().all(|c| c.text.is_empty()) { + break; + } + rows.push((j, &lines[j], cells)); + j += 1; + } + if rows.len() < TABLE_MIN_ROWS { + bail!("rows {} < MIN_ROWS", rows.len()); + } + // When the body was seeded below `start_idx` (the lead line was a header + // we skipped over to find a strong row), demand a clearly multi-row body + // before committing. A strong row found inside a header band can otherwise + // anchor a 2-row pseudo-table that consumes the lead lines and starves the + // header-seeded path of the real table below it. Already-strong seeds + // (body_start == start_idx) keep the + // standard MIN_ROWS threshold and are unaffected. + if body_start > start_idx && rows.len() < 3 { + bail!("advanced body_start but only {} rows", rows.len()); + } + let cv = row_spacing_cv(&rows); + if cv > TABLE_ROW_SPACING_MAX_CV { + // Defer to the existing path, which can fall back to GridFallback. + bail!("row spacing cv {cv:.2} > {TABLE_ROW_SPACING_MAX_CV}"); + } + let end = j; + + let bold_eligible = rows[0].2.iter().all(|c| c.bold && !c.text.is_empty()); + finalize_table_run( + lines, + body_start, + floor, + &rows, + &track_ranges, + column_count, + end, + bold_eligible, + ) +} + +/// Try to extend a candidate table starting at `start_idx`. On success returns +/// a `TableRun` with `Block::Table` or `Block::GridFallback`; on failure +/// returns `None` (and the caller should fall through to per-line +/// classification). +fn try_detect_table(lines: &[ProjectedLine], start_idx: usize, floor: usize) -> Option { + let first_cells = split_cells(&lines[start_idx]); + if first_cells.len() < TABLE_MIN_COLUMNS { + return None; + } + + let mut rows: Vec<(usize, &ProjectedLine, Vec)> = + vec![(start_idx, &lines[start_idx], first_cells.clone())]; + let column_count = first_cells.len(); + let tracks: Vec = first_cells.iter().map(|c| c.start_x).collect(); + let track_ranges: Vec<(f32, f32)> = first_cells.iter().map(|c| (c.start_x, c.end_x)).collect(); + + // Right edge of the established column tracks (last track + a track-width + // worth of slack). Used to identify lines that sit entirely in a different + // page column and should be skipped over rather than breaking the run — + // common on two-column pages where the projection interleaves left and + // right column lines in y-order. + let track_max_x = first_cells + .iter() + .map(|c| c.end_x.max(c.start_x)) + .fold(f32::NEG_INFINITY, f32::max); + let tracks_right_edge = track_max_x + TABLE_TRACK_TOLERANCE_PT.max(8.0); + + let mut j = start_idx + 1; + while j < lines.len() { + // Skip lines that sit entirely to the right of the table's column + // tracks — almost certainly content from a different page column. + // Use the line's leftmost span x; if it's past the table's right edge + // we won't break the run, just step over. + if lines[j].bbox.x > tracks_right_edge { + j += 1; + continue; + } + if !table_rows_adjacent(rows.last().unwrap().1, &lines[j]) { + break; + } + let mut cells = split_cells(&lines[j]); + if cells.len() < column_count && cells.len() >= TABLE_MIN_COLUMNS { + // PDFium occasionally merges two (or more) adjacent words into one + // text run when inter-word kerning is tighter than the gap + // threshold — common in tightly-set numeric tables (e.g. the + // "MEMORYBANK 5.00 4.77" case on page 6 of the AMEM paper). + // Recover by splitting straddling cells on internal whitespace. + if let Some(patched) = recover_merged_cell(cells.clone(), &tracks) { + cells = patched; + } + } + // Partial-cell line handling: when a line has *fewer* cells than the + // established column count, decide between (a) wrap of prior row's + // multi-line cell, (b) sparse new row (some columns just empty), + // (c) break-run. Order matters — the wrap path runs first so + // tightly-stacked continuation baselines fold into the prior row; the + // sparse-row path only triggers when there's a clear inter-row gap + // *AND* every cell maps to a distinct column track. + if cells.len() < column_count && !cells.is_empty() { + let prev_line = rows.last().unwrap().1; + let prev_y_top = prev_line.bbox.y; + let prev_bottom = prev_line.bbox.y + prev_line.bbox.height; + let line_height = prev_line.bbox.height.max(lines[j].bbox.height).max(1.0); + let centroid_dy = lines[j].bbox.y - prev_y_top; + let bottom_gap = lines[j].bbox.y - prev_bottom; + // Map each cell to its column track once. `match_track_idx` + // returns `Some` exactly when `cell_aligns_track` would return + // `true`, so `mapping.len() == cells.len()` is the same + // "every cell aligns to a track" predicate — and the mapping is + // already in hand for the paths below, no separate unwrap whose + // safety depends on the two functions staying in lockstep. + let mapping: Vec = cells + .iter() + .filter_map(|c| match_track_idx(c, &track_ranges)) + .collect(); + let all_align_track = mapping.len() == cells.len(); + // Sparse-new-row path runs FIRST. When the line sits a clear + // inter-row gap below the previous row AND its cells map to + // distinct tracks, treat it as a new row with empty cells at + // the missing tracks. This catches a sparse data row following a + // wide header (e.g. `"1.0 April 30, Original"` under a 5-column + // header), which a naive wrap path would merge into the header. + if all_align_track + && cells.len() >= 2 + && bottom_gap >= line_height * TABLE_SPARSE_ROW_MIN_BOTTOM_GAP_FRAC + { + let mut distinct = mapping.clone(); + distinct.sort_unstable(); + distinct.dedup(); + if distinct.len() == mapping.len() { + let mut padded: Vec = (0..column_count) + .map(|i| TableCell { + start_x: tracks[i], + end_x: tracks[i], + text: String::new(), + bold: false, + }) + .collect(); + for (c, &idx) in cells.iter().zip(&mapping) { + padded[idx] = c.clone(); + } + rows.push((j, &lines[j], padded)); + j += 1; + continue; + } + } + // Wrap path (existing, unchanged): tight stack against prior + // row, multi-line cell continuation. + if centroid_dy <= line_height * 1.5 && all_align_track { + let prev_cells = &mut rows.last_mut().unwrap().2; + for (c, &idx) in cells.iter().zip(&mapping) { + if !prev_cells[idx].text.is_empty() && !c.text.is_empty() { + prev_cells[idx].text.push(' '); + } + prev_cells[idx].text.push_str(&c.text); + } + j += 1; + continue; + } + } + // If the row has *more* cells than column_count, it likely picked up + // content from an adjacent page column that the projection placed on + // the same line (e.g. left-table-row + right-column body text). Try + // to recover by keeping only the cells whose center lands inside one + // of our established column tracks; drop the rest. + if cells.len() > column_count { + let kept: Vec = cells + .iter() + .filter(|c| match_track_idx(c, &track_ranges).is_some()) + .cloned() + .collect(); + if kept.len() == column_count { + cells = kept; + } else { + break; + } + } + if cells.len() != column_count { + break; + } + // Allow at most one column track to drift out of tolerance, which lets + // grouped row-labels in academic tables (e.g. an indented "MEMORYBANK" + // row whose label column shifts right by ~30pt while the numeric + // columns stay aligned) stay in the same run. Without this slack a + // single indented label fragments a 6-row table into three 2-row chunks. + let misaligned = cells + .iter() + .zip(track_ranges.iter()) + .filter(|(c, r)| !cell_aligns_track(c, **r)) + .count(); + if misaligned > 1 { + break; + } + rows.push((j, &lines[j], cells)); + j += 1; + } + + if rows.len() < TABLE_MIN_ROWS { + return None; + } + + let cv = row_spacing_cv(&rows); + let end = j; + + if cv > TABLE_ROW_SPACING_MAX_CV { + // Suggestive layout but the row cadence is too irregular to trust as a + // clean table — surface as a fenced fallback so the structure is at + // least preserved. + let raw: Vec = rows + .iter() + .map(|(_, line, _)| line.text.trim_end().to_string()) + .collect(); + return Some(TableRun { + start: start_idx, + end, + body_start: start_idx, + block: Block::GridFallback { lines: raw }, + }); + } + + // Promote the first body row to header iff every cell in it is bold + // (a bold-or-filled heuristic; fills require fork data). Skipped inside + // `finalize_table_run` when a header was absorbed. + let bold_eligible = rows[0].2.iter().all(|c| c.bold); + finalize_table_run( + lines, + start_idx, + floor, + &rows, + &track_ranges, + column_count, + end, + bold_eligible, + ) +} + +/// Walk backward from `start_idx` (not below `floor`), pulling in lines whose +/// cells all align to the table's `tracks` as header rows. Returns the new +/// start index and a single merged header row (`column_count` columns) with +/// each absorbed line's text appended into its nearest column track. +fn absorb_header_lines( + lines: &[ProjectedLine], + start_idx: usize, + track_ranges: &[(f32, f32)], + column_count: usize, + floor: usize, +) -> Option<(usize, Vec)> { + let dbgt = *super::flags::DEBUG_TABLE; + let mut absorbed: Vec> = Vec::new(); + let mut j = start_idx; + while j > floor { + let cand = j - 1; + let cells = split_cells(&lines[cand]); + if dbgt { + let texts: Vec<&str> = cells.iter().map(|c| c.text.as_str()).collect(); + eprintln!( + "[tbl-absorb cand @{cand} {:?}] cells={texts:?} extents={:?}", + lines[cand].text.chars().take(40).collect::(), + cells + .iter() + .map(|c| (c.start_x as i32, c.end_x as i32)) + .collect::>() + ); + } + // A header line must carry at least two cells (a single cell is a + // title/caption, not a header) and sit tight above the row below it. + if cells.len() < 2 { + break; + } + if !table_rows_adjacent(&lines[cand], &lines[j]) { + break; + } + if cells.len() > column_count { + break; + } + let all_align = cells + .iter() + .all(|c| track_ranges.iter().any(|r| cell_aligns_track(c, *r))); + if !all_align { + break; + } + absorbed.push(cells); + j = cand; + } + if absorbed.is_empty() { + return None; + } + // Collected bottom-up; reverse so text reads top-to-bottom per column. + absorbed.reverse(); + let mut header = vec![String::new(); column_count]; + for cells in &absorbed { + for c in cells { + let Some(idx) = match_track_idx(c, track_ranges) else { + continue; + }; + if !header[idx].is_empty() && !c.text.is_empty() { + header[idx].push(' '); + } + header[idx].push_str(&c.text); + } + } + Some((j, header)) +} + +/// Scan `lines` once and return all detected tabular regions (sorted by +/// `start`). Caller uses these as cut-points so the per-line classifier never +/// sees lines inside a table. +pub(super) fn detect_tables(lines: &[ProjectedLine]) -> Vec { + let mut out = Vec::new(); + let mut i = 0; + let mut floor = 0; + while i < lines.len() { + if let Some(run) = try_detect_table_inferred(lines, i, floor) { + floor = run.end; + i = run.end; + out.push(run); + } else if let Some(run) = try_detect_table(lines, i, floor) { + floor = run.end; + i = run.end; + out.push(run); + } else if let Some(run) = try_detect_description_list(lines, i) { + floor = run.end; + i = run.end; + out.push(run); + } else { + i += 1; + } + } + let mut merged = merge_consecutive_table_runs(out, lines); + // Iterate to fixpoint (bounded): a merged cluster is itself a table run + // that may now sit adjacent to the next fragment cluster. + for _ in 0..4 { + let before = merged.len(); + merged = merge_fragmented_table_clusters(merged, lines); + if merged.len() == before { + break; + } + } + merged +} + +// ── Description-list 2-column table detector ────────────────────────────── +// +// Catches borderless 2-column tables that the main `try_detect_table` rejects +// because `TABLE_MIN_COLUMNS = 3`. Signature: +// +// - ≥ DESC_LIST_MIN_ROWS rows where col 0 is a short label (≤ DESC_LIST_LABEL_MAX_CHARS) +// and col 1 is anything (typically a paragraph or bullet list). +// - Stable x-anchors for both columns (within DESC_LIST_TRACK_TOL_PT). +// - Clear inter-column gap (col1.start_x - col0.end_x ≥ DESC_LIST_MIN_COL_GAP_PT). +// - Asymmetric content: at least one row's col 1 is meaningfully longer than +// its col 0 — rules out symmetric two-column body prose / newspaper layouts. +// +// Handles two PDFium quirks: +// - Wrap continuations: a single-cell line at col 1's anchor extends the +// previous row's col 1. +// - Merged-span rows: PDFium occasionally emits both columns of a row as a +// single text item starting at col 0's anchor (kerning happens to be tight +// across the column gap). We split on the whitespace position closest to +// col 1's anchor and treat the result as a normal 2-cell row. + +const DESC_LIST_MIN_ROWS: usize = 2; +const DESC_LIST_LABEL_MAX_CHARS: usize = 40; +const DESC_LIST_LABEL_MAX_WORDS: usize = 4; +const DESC_LIST_TRACK_TOL_PT: f32 = 8.0; +const DESC_LIST_MIN_COL_GAP_PT: f32 = 12.0; + +/// Discriminates "label-like" col-0 text from prose fragments. Real +/// description-list labels are short noun-phrases (1-4 words, no terminal +/// sentence punctuation, no internal sentence boundary). Body prose that +/// happens to be projection-merged with a right-column line tends to fail at +/// least one of these. +fn is_label_like(text: &str) -> bool { + let trimmed = text.trim(); + if trimmed.is_empty() { + return false; + } + if trimmed.chars().count() > DESC_LIST_LABEL_MAX_CHARS { + return false; + } + // Pure bullet glyph (or bullet+digit like "1.") is not a label — that's a + // list item, which the list classifier handles. Lets us avoid claiming + // bulleted lists as 2-col description tables. + if is_bullet_only(trimmed) { + return false; + } + let word_count = trimmed.split_whitespace().count(); + if word_count == 0 || word_count > DESC_LIST_LABEL_MAX_WORDS { + return false; + } + // Internal sentence boundary ("foo. Bar") = prose, not a label. + // A trailing period is fine ("Item.") and a trailing colon is fine + // ("Note:"); both are common in real labels. + let bytes = trimmed.as_bytes(); + for i in 0..bytes.len().saturating_sub(2) { + if bytes[i] == b'.' && bytes[i + 1] == b' ' { + let next = bytes[i + 2]; + if next.is_ascii_uppercase() { + return false; + } + } + } + true +} + +/// Cell text reads as a page number reference: pure digits, pure roman +/// numerals (i, ii, iv, …, IX, X), or a digit followed by trivial punctuation. +fn is_page_ref(text: &str) -> bool { + let t = text.trim(); + if t.is_empty() { + return false; + } + if t.chars().all(|c| c.is_ascii_digit()) { + return true; + } + let lower = t.to_ascii_lowercase(); + if lower + .chars() + .all(|c| matches!(c, 'i' | 'v' | 'x' | 'l' | 'c' | 'd' | 'm')) + { + // Cap length so multi-word lowercase Latin words don't pass (e.g. + // "mix", "civil" would all be made of roman-numeral letters). + if t.chars().count() <= 6 { + return true; + } + } + false +} + +fn is_bullet_only(text: &str) -> bool { + let t = text.trim(); + if t.is_empty() { + return false; + } + let only_glyph = t.chars().all(|c| { + matches!( + c, + '•' | '●' + | '○' + | '◦' + | '▪' + | '■' + | '□' + | '‣' + | '⁃' + | '*' + | '-' + | '–' + | '—' + | '⮚' + | '►' + | '▶' + // Symbol-font bullet in the Private Use Area (undecoded 0xB7). + | '\u{f0b7}' + ) + }); + if only_glyph { + return true; + } + // Numeric list marker: "1.", "1)", "(1)", "i.", "ii.", "a.", "(a)" etc. — + // all are list markers, not table labels. + let chars: Vec = t.chars().collect(); + // Paren-wrapped marker: "(1)" or single-letter "(a)". + let is_paren_marker = chars.first() == Some(&'(') && chars.last() == Some(&')') && { + let inner = &chars[1..chars.len() - 1]; + inner.iter().all(|c| c.is_ascii_digit()) + || (inner.len() == 1 && inner[0].is_ascii_alphabetic()) + }; + if is_paren_marker && chars.len() <= 5 { + return true; + } + let trailing = chars.last().copied(); + if matches!(trailing, Some('.') | Some(')')) { + let body: String = chars[..chars.len() - 1].iter().collect(); + if !body.is_empty() + && (body.chars().all(|c| c.is_ascii_digit()) + || body + .chars() + .all(|c| matches!(c, 'i' | 'v' | 'x' | 'I' | 'V' | 'X')) + // Single-letter lettered-list marker: "a.", "b)", "A." — a + // short marker column beside wrapped body text is a nested + // ordered list, not a 2-column description table. + || (body.chars().count() == 1 + && body.chars().next().is_some_and(|c| c.is_ascii_alphabetic()))) + { + return true; + } + } + false +} + +/// Heuristic: line text reads like a figure or table caption. +/// Used to break a description-list run before absorbing a caption that +/// happens to straddle the table's column anchors. +fn looks_like_caption(text: &str) -> bool { + let trimmed = text.trim_start(); + let lower = trimmed.to_ascii_lowercase(); + for prefix in ["figure ", "fig. ", "fig ", "table ", "tab. ", "tab "] { + if let Some(rest) = lower.strip_prefix(prefix) { + // Require a digit (or roman) right after to avoid matching prose + // sentences that happen to start with "Table" / "Figure". + if rest.chars().next().is_some_and(|c| c.is_ascii_digit()) { + return true; + } + } + } + false +} + +fn try_detect_description_list(lines: &[ProjectedLine], start_idx: usize) -> Option { + let first = split_cells(&lines[start_idx]); + if first.len() != 2 { + return None; + } + let col0_x = first[0].start_x; + let col0_end = first[0].end_x; + let col1_x = first[1].start_x; + if col1_x - col0_end < DESC_LIST_MIN_COL_GAP_PT { + return None; + } + if !is_label_like(&first[0].text) { + return None; + } + + let mut rows: Vec<(usize, String, String)> = + vec![(start_idx, first[0].text.clone(), first[1].text.clone())]; + // Track how many rows came from the *actual* 2-cell path (i.e. PDFium + // emitted two distinct spans with a clear gap). The merged-span split path + // is a recovery hack for tight-kerning cases — when it's the only thing + // extending the run, we're almost certainly slicing prose, not a table. + let mut real_two_cell_rows: usize = 1; + + let mut j = start_idx + 1; + while j < lines.len() { + let prev_line = &lines[rows.last().unwrap().0]; + if !table_rows_adjacent(prev_line, &lines[j]) { + break; + } + // Caption / divider guard: a line whose text begins with a figure or + // table caption marker is never a row in the *current* description + // list — it's the caption sitting below it. Stop here rather than + // greedily splitting it on whitespace into a bogus row. + if looks_like_caption(&lines[j].text) { + break; + } + // Spacing guard: if rows have a clear inter-row cadence and this line + // sits markedly farther below than the run's typical row gap, treat + // it as a different block (caption / next paragraph) even though + // `table_rows_adjacent` is generous up to 2.5× line height. + if rows.len() >= 2 { + let prev_y = prev_line.bbox.y; + let cur_y = lines[j].bbox.y; + let cur_gap = cur_y - prev_y; + let prior_gaps: Vec = rows + .windows(2) + .map(|w| lines[w[1].0].bbox.y - lines[w[0].0].bbox.y) + .collect(); + if let Some(&max_prior) = prior_gaps.iter().max_by(|a, b| a.total_cmp(b)) + && cur_gap > max_prior * 1.6 + && cur_gap > lines[j].bbox.height.max(prev_line.bbox.height) + { + break; + } + } + let cells = split_cells(&lines[j]); + match cells.len() { + 2 => { + let c0_aligned = (cells[0].start_x - col0_x).abs() <= DESC_LIST_TRACK_TOL_PT; + let c1_aligned = (cells[1].start_x - col1_x).abs() <= DESC_LIST_TRACK_TOL_PT; + if c0_aligned && c1_aligned && is_label_like(&cells[0].text) { + rows.push((j, cells[0].text.clone(), cells[1].text.clone())); + real_two_cell_rows += 1; + j += 1; + continue; + } + break; + } + 1 => { + let cell = &cells[0]; + let c0_aligned = (cell.start_x - col0_x).abs() <= DESC_LIST_TRACK_TOL_PT; + let c1_aligned = (cell.start_x - col1_x).abs() <= DESC_LIST_TRACK_TOL_PT; + if c1_aligned { + if !rows.last().unwrap().2.is_empty() { + rows.last_mut().unwrap().2.push(' '); + } + rows.last_mut().unwrap().2.push_str(&cell.text); + j += 1; + continue; + } + // Merged-span row: single cell starts at col 0 but extends past + // col 1's anchor. Split on the whitespace closest to col 1. + let straddles = c0_aligned && cell.end_x > col1_x + DESC_LIST_TRACK_TOL_PT; + if straddles + && let Some((left, right)) = + split_merged_at_anchor(&cell.text, cell.start_x, cell.end_x, col1_x) + && is_label_like(&left) + { + rows.push((j, left, right)); + j += 1; + continue; + } + break; + } + _ => break, + } + } + + if rows.len() < DESC_LIST_MIN_ROWS { + return None; + } + + // Anti-false-positive #1: require ≥2 rows that came from the actual + // 2-cell path. A run extended entirely by the merged-span split is almost + // certainly slicing body prose where a heading happens to have a section + // number cleanly tab-stopped left of the title. + if real_two_cell_rows < 2 { + return None; + } + // Anti-false-positive #1b: at least one row must have BOTH columns + // containing alphabetic characters. Filters two common shapes that are + // *not* description-list tables: TOC entries (col 1 = page number) and + // footnote lists (col 0 = footnote number). Real description-list tables + // have at least one row of word-on-word. + let has_alpha_pair = rows.iter().any(|(_, c0, c1)| { + c0.chars().any(|c| c.is_alphabetic()) && c1.chars().any(|c| c.is_alphabetic()) + }); + if !has_alpha_pair { + return None; + } + // Anti-false-positive #1c: if *every* col 1 reads as a page-number + // (digits or roman numerals), the run is a TOC. TOCs match the alpha + // pair check only when one of the page refs happens to be a roman + // numeral like "v" or "vi" alongside an alpha col 0. + let all_page_refs = rows.iter().all(|(_, _, c1)| is_page_ref(c1)); + if all_page_refs { + return None; + } + // Anti-false-positive #2: at least one of + // (a) ≥3 rows (cadence is the signal — short symmetric pairs that + // repeat 3+ times are tabular), + // (b) one row's col 1 is substantially longer than col 0 (paragraph + // cell next to a label cell — the classic description-list shape). + let asymmetric = rows + .iter() + .any(|(_, c0, c1)| c1.chars().count() >= c0.chars().count().saturating_mul(2).max(20)); + if rows.len() < 3 && !asymmetric { + return None; + } + + let body: Vec> = rows + .iter() + .map(|(_, c0, c1)| vec![c0.clone(), c1.clone()]) + .collect(); + Some(TableRun { + start: start_idx, + end: j, + body_start: start_idx, + block: Block::Table { + header: None, + rows: body, + }, + }) +} + +/// Split a merged-column text item on the whitespace position whose linear +/// x-estimate is closest to `anchor_x`. Returns trimmed (left, right) halves, +/// or `None` if no usable whitespace split exists. +fn split_merged_at_anchor( + text: &str, + start_x: f32, + end_x: f32, + anchor_x: f32, +) -> Option<(String, String)> { + let width = (end_x - start_x).max(1.0); + let ratio = ((anchor_x - start_x) / width).clamp(0.0, 1.0); + let chars: Vec = text.chars().collect(); + if chars.is_empty() { + return None; + } + let target = ((chars.len() as f32) * ratio) as usize; + let mut best: Option = None; + let mut best_dist = usize::MAX; + for (i, c) in chars.iter().enumerate() { + if c.is_whitespace() { + let d = i.abs_diff(target); + if d < best_dist { + best_dist = d; + best = Some(i); + } + } + } + let split = best?; + let left: String = chars[..split].iter().collect(); + let right: String = chars[split + 1..].iter().collect(); + let left = left.trim().to_string(); + let right = right.trim().to_string(); + if left.is_empty() || right.is_empty() { + return None; + } + Some((left, right)) +} + +// ── Cross-run merging (post-pass over `detect_tables` output) ────────────── +// +// `try_detect_table` walks lines top-to-bottom and breaks the run whenever the +// column count or track alignment changes. That breaks two common shapes into +// separate runs: +// +// B1 — multi-line header with row-label-column missing. The header rows have +// N cells aligned to body tracks 2..N+1; the body rows have N+1 cells +// including a leading row-label. Detect_tables emits an N-col "header" +// run + an (N+1)-col body run. +// +// B4 — a single table interrupted by a category divider that fragments it +// into two sibling runs with identical column structure. +// +// The pass below walks adjacent run pairs and merges them when they're +// vertically immediate (A.end == B.start), reasonably close in y, and share +// either identical tracks (Case Same) or A's tracks are a 1-column-shorter +// subset of B's tracks (Case Subset). Subset merges fold A into B's header. +// +// Guards: +// - Only merge `Block::Table` pairs (skip `GridFallback`). +// - A.end must equal B.start so no non-table content between the runs +// gets dropped. +// - A's body row count is capped (`TABLE_HEADER_MAX_ABSORB_ROWS`) so a +// real standalone table that happens to neighbor another isn't absorbed. +// - Vertical gap between A's last line and B's first line is capped by a +// small multiple of the line height. + +/// A run with this many or fewer body rows can be folded as header content of +/// a following table. Above this we treat A as its own complete table. +const TABLE_HEADER_MAX_ABSORB_ROWS: usize = 3; + +/// Cap on the y-gap between two consecutive runs for them to be merge +/// candidates, in multiples of line height. Larger gaps mean visually +/// distinct tables. +const TABLE_MERGE_MAX_Y_GAP_LINES: f32 = 2.0; + +fn merge_consecutive_table_runs(runs: Vec, lines: &[ProjectedLine]) -> Vec { + if runs.len() < 2 { + return runs; + } + let mut out: Vec = Vec::with_capacity(runs.len()); + for run in runs { + if let Some(prev) = out.last() + && let Some(merged) = try_merge_pair(prev, &run, lines) + { + out.pop(); + out.push(merged); + continue; + } + out.push(run); + } + out +} + +// ── Fragmented-cluster re-extraction ─────────────────────────────────────── +// +// `try_detect_table` closes a run whenever the per-row cell count changes, so +// one real table with sparse rows (row-label column present on some rows +// only, empty value columns on others) fragments into several runs at +// different column counts, with stranded single rows between them. The pairs +// of fragments rarely satisfy `try_merge_pair`'s same-cols / |A|+1==|B| +// cases. This pass takes the opposite approach: instead of mapping fragment +// columns onto each other, it re-derives the *union* column track set from +// the raw PDFium span positions across the whole cluster and re-extracts +// every line against those tracks. Sparse rows get empty cells, merged spans +// split at track anchors, and the cluster emits as one table. +// +// Only fires when ≥2 table runs are already y-adjacent — prose never enters +// this path, so the false-positive surface is limited to "two genuinely +// separate stacked tables", which the both-complete guard rejects. + +/// Max non-table lines between two runs for them to join one cluster. +const TABLE_CLUSTER_MAX_INTERSTITIAL_LINES: usize = 2; + +/// Abort the cluster merge when more than this fraction of the window's +/// lines can't be binned into the union tracks. +const TABLE_CLUSTER_MAX_FAILED_ROW_FRAC: f32 = 0.3; + +/// Max header lines walked above the cluster body by the union header pass. +const TABLE_CLUSTER_MAX_HEADER_LINES: usize = 4; + +fn merge_fragmented_table_clusters(runs: Vec, lines: &[ProjectedLine]) -> Vec { + if runs.len() < 2 { + return runs; + } + let dbgt = *super::flags::DEBUG_TABLE; + let mut out: Vec = Vec::with_capacity(runs.len()); + let mut i = 0; + while i < runs.len() { + let mut j = i + 1; + while j < runs.len() && cluster_adjacent(&runs[j - 1], &runs[j], lines) { + j += 1; + } + if j - i >= 2 { + let floor = out.last().map(|r| r.end).unwrap_or(0); + if let Some(merged) = build_union_table(&runs[i..j], lines, floor) { + if dbgt { + eprintln!( + "[tbl-cluster] merged {} runs (lines {}..{}) into one table", + j - i, + merged.start, + merged.end + ); + } + out.push(merged); + i = j; + continue; + } else if dbgt { + eprintln!( + "[tbl-cluster] union build failed for {} runs @{}..{}", + j - i, + runs[i].start, + runs[j - 1].end + ); + } + } + out.push(runs[i].clone()); + i += 1; + } + out +} + +fn cluster_adjacent(a: &TableRun, b: &TableRun, lines: &[ProjectedLine]) -> bool { + let (a_header, a_rows_len) = match &a.block { + Block::Table { header, rows } => (header.is_some(), rows.len()), + _ => return false, + }; + let (b_header, b_rows_len) = match &b.block { + Block::Table { header, rows } => (header.is_some(), rows.len()), + _ => return false, + }; + if b.start.saturating_sub(a.end) > TABLE_CLUSTER_MAX_INTERSTITIAL_LINES { + return false; + } + if a.end == 0 || a.end > lines.len() || b.start >= lines.len() { + return false; + } + let a_last = &lines[a.end - 1]; + let b_first = &lines[b.start]; + let line_height = a_last.bbox.height.max(b_first.bbox.height).max(1.0); + let gap = b_first.bbox.y - (a_last.bbox.y + a_last.bbox.height); + if gap > line_height * TABLE_MERGE_MAX_Y_GAP_LINES || gap < -line_height { + return false; + } + // Two complete-looking tables (both with explicit headers and real bodies) + // separated by a visible gap are most likely genuinely separate tables. + let both_complete = a_header && b_header && a_rows_len >= 3 && b_rows_len >= 3; + if both_complete && gap > line_height { + return false; + } + // Track compatibility: fragments of one table share column geometry (the + // narrower fragment's tracks are a subset of the wider one's), while two + // genuinely different stacked tables (e.g. a 2-col label/value list above + // a 4-col transaction table) do not. Without this gate the union merge + // fuses them and shreds both. Require ≥75% of the narrower run's tracks + // to align to the wider run's tracks. + let dbgt = *super::flags::DEBUG_TABLE; + let (Some(a_tracks), Some(b_tracks)) = (run_body_tracks(a, lines), run_body_tracks(b, lines)) + else { + // Inferred-path runs often have no line whose split_cells count + // matches the declared column count, so tracks can't be re-derived. + // Stay permissive — build_union_table's own soundness guards (width + // check, failed-row fraction) still gate the actual merge. + return true; + }; + let (narrow, wide) = if a_tracks.len() <= b_tracks.len() { + (&a_tracks, &b_tracks) + } else { + (&b_tracks, &a_tracks) + }; + let matched = narrow + .iter() + .filter(|&&t| { + wide.iter() + .any(|&w| subset_match_score(t, w, TABLE_SUBSET_TRACK_TOLERANCE_PT).is_some()) + }) + .count(); + let ok = (matched as f32) >= (narrow.len() as f32) * 0.75; + if !ok && dbgt { + eprintln!( + "[tbl-cluster] adjacency reject @{}..{}: tracks {}/{} matched (narrow=[{}] wide=[{}])", + a.start, + b.end, + matched, + narrow.len(), + narrow + .iter() + .map(|t| format!("{:.0}-{:.0}", t.0, t.1)) + .collect::>() + .join(","), + wide.iter() + .map(|t| format!("{:.0}-{:.0}", t.0, t.1)) + .collect::>() + .join(",") + ); + } + ok +} + +/// Union column tracks across a window: every row with ≥2 gap-split cells +/// contributes its cell start-x positions; positions cluster at +/// `TABLE_TRACK_TOLERANCE_PT`. Cells (not raw spans) are the unit here — +/// multi-word cell content emits several PDFium spans whose x positions are +/// word starts, not column starts, and would shred the track set. +fn union_tracks_in_window(lines: &[ProjectedLine], start: usize, end: usize) -> Vec<(f32, usize)> { + let mut xs: Vec = Vec::new(); + for line in &lines[start..end.min(lines.len())] { + let cells = split_cells(line); + if cells.len() >= 2 { + xs.extend(cells.iter().map(|c| c.start_x)); + } + } + xs.sort_by(f32::total_cmp); + let mut clusters: Vec<(f32, usize)> = Vec::new(); + let mut current_sum = 0.0f32; + let mut current_count = 0usize; + let mut current_anchor = f32::NEG_INFINITY; + for &x in &xs { + // Looser tolerance than the per-run paths: center-aligned columns + // jitter their cell start-x by the half-width difference of their + // content (±10pt is routine); near-duplicate tracks get coalesced + // by the caller. + if current_count == 0 || (x - current_anchor).abs() <= TABLE_SUBSET_TRACK_TOLERANCE_PT { + current_sum += x; + current_count += 1; + current_anchor = current_sum / current_count as f32; + } else { + clusters.push((current_sum / current_count as f32, current_count)); + current_sum = x; + current_count = 1; + current_anchor = x; + } + } + if current_count > 0 { + clusters.push((current_sum / current_count as f32, current_count)); + } + clusters +} + +/// Bin a line into the union tracks via its gap-split cells when the raw-span +/// path fails: each cell maps to the nearest track (by start-x, within the +/// loose subset tolerance) and all cells must map to distinct tracks. Unlike +/// the body-detection paths this accepts 1-cell rows — inside a confirmed +/// table cluster a lone label at a track is a sparse row (e.g. a row-label +/// city with every value column empty), not prose. +fn sparse_row_via_cells(line: &ProjectedLine, tracks: &[f32]) -> Option> { + let cells = split_cells(line); + if cells.is_empty() { + return None; + } + let tol = TABLE_SUBSET_TRACK_TOLERANCE_PT; + let mut mapping: Vec = Vec::with_capacity(cells.len()); + for c in &cells { + let (idx, d) = tracks + .iter() + .enumerate() + .map(|(i, &t)| (i, (c.start_x - t).abs())) + .min_by(|a, b| a.1.total_cmp(&b.1))?; + if d > tol { + return None; + } + mapping.push(idx); + } + let mut distinct = mapping.clone(); + distinct.sort_unstable(); + distinct.dedup(); + if distinct.len() != mapping.len() { + return None; + } + let mut row = vec![String::new(); tracks.len()]; + for (c, &idx) in cells.iter().zip(&mapping) { + row[idx] = c.text.clone(); + } + Some(row) +} + +/// Walk upward from the cluster's body start, binning each candidate header +/// line's raw spans into the union tracks. A span covering multiple tracks +/// (a group header like `EFETIVO` spanning its `ENFERMARIA`/`QUARTO` +/// sub-columns) replicates its text across every covered track — the pipe +/// table flattening of a colspan'd grid header. Layers stack top-to-bottom +/// per column. Returns `(new_start, header)`. +fn union_header_from_above( + lines: &[ProjectedLine], + body_start: usize, + floor: usize, + tracks: &[f32], +) -> Option<(usize, Vec)> { + let tol = TABLE_TRACK_TOLERANCE_PT; + // Fallback assignment for centered header cells whose extent doesn't + // reach the track anchor: nearest track within half the local gap. + let assign_nearest = |x_center: f32| -> Option { + let (idx, d) = tracks + .iter() + .enumerate() + .map(|(i, &t)| (i, (x_center - t).abs())) + .min_by(|a, b| a.1.total_cmp(&b.1))?; + let local_gap = if idx + 1 < tracks.len() { + tracks[idx + 1] - tracks[idx] + } else if idx > 0 { + tracks[idx] - tracks[idx - 1] + } else { + f32::INFINITY + }; + if d <= (local_gap * 0.5).max(TABLE_SUBSET_TRACK_TOLERANCE_PT) { + Some(idx) + } else { + None + } + }; + let mut layers: Vec> = Vec::new(); + let mut j = body_start; + while j > floor && layers.len() < TABLE_CLUSTER_MAX_HEADER_LINES { + let cand = j - 1; + if !table_rows_adjacent(&lines[cand], &lines[j]) { + break; + } + let spans: Vec<&TextItem> = lines[cand] + .spans + .iter() + .filter(|s| !s.text.trim().is_empty()) + .collect(); + if spans.len() < 2 { + break; + } + let mut layer = vec![String::new(); tracks.len()]; + let mut ok = true; + for s in &spans { + let x0 = s.x; + let x1 = s.x + s.width.max(0.0); + let covered: Vec = tracks + .iter() + .enumerate() + .filter(|&(_, &t)| t >= x0 - tol && t <= x1 + tol) + .map(|(i, _)| i) + .collect(); + let targets: Vec = if !covered.is_empty() { + covered + } else if let Some(idx) = assign_nearest((x0 + x1) * 0.5) { + vec![idx] + } else { + ok = false; + break; + }; + for idx in targets { + let dst = &mut layer[idx]; + if !dst.is_empty() { + dst.push(' '); + } + dst.push_str(s.text.trim()); + } + } + if !ok || layer.iter().filter(|t| !t.is_empty()).count() < 2 { + break; + } + layers.push(layer); + j = cand; + } + if layers.is_empty() { + return None; + } + layers.reverse(); + let header: Vec = (0..tracks.len()) + .map(|col| { + let mut parts: Vec<&str> = Vec::new(); + for layer in &layers { + let s = layer[col].as_str(); + if s.is_empty() || parts.last() == Some(&s) { + continue; + } + parts.push(s); + } + parts.join(" ") + }) + .collect(); + if header.iter().all(|h| h.is_empty()) { + return None; + } + Some((j, header)) +} + +/// Re-extract a cluster of adjacent table runs as one table against the +/// union track set. Returns `None` (leaving the original runs untouched) +/// when the union tracks are unsound or too many lines fail to bin. +fn build_union_table( + cluster: &[TableRun], + lines: &[ProjectedLine], + floor: usize, +) -> Option { + let dbgt = *super::flags::DEBUG_TABLE; + let window_start = cluster.first()?.body_start; + let window_end = cluster.last()?.end.min(lines.len()); + if window_start >= window_end { + return None; + } + let mut supported = union_tracks_in_window(lines, window_start, window_end); + // Drop low-support tracks: a column that only 1-2 cells across the whole + // cluster ever start at is noise (wrapped-cell continuation indents, + // intra-cell splits on widely-tracked text), not a real column. + let window_len = window_end - window_start; + let min_support = 2.max(window_len / 10); + supported.retain(|&(_, n)| n >= min_support); + // Coalesce adjacent tracks closer than a real column gutter — these are + // start-x jitter of one center-aligned column (content of different + // widths), not two columns. Real word-gap prose would coalesce down to a + // handful of wide tracks and then fail the width check below. + let mut font_sizes: Vec = lines[window_start..window_end] + .iter() + .map(|l| { + if l.dominant_font_size > 0.0 { + l.dominant_font_size + } else { + l.bbox.height.max(1.0) + } + }) + .collect(); + font_sizes.sort_by(f32::total_cmp); + let median_font = font_sizes[font_sizes.len() / 2]; + let min_track_gap = + (median_font * TABLE_MIN_TRACK_GAP_FONT_MULT).max(TABLE_MIN_TRACK_GAP_FLOOR_PT); + let mut coalesced: Vec<(f32, usize)> = Vec::with_capacity(supported.len()); + for (t, n) in supported { + match coalesced.last_mut() { + Some((last, last_n)) if t - *last < min_track_gap => { + // Support-weighted midpoint keeps the anchor near the + // dominant alignment. + *last = (*last * *last_n as f32 + t * n as f32) / (*last_n + n) as f32; + *last_n += n; + } + _ => coalesced.push((t, n)), + } + } + let tracks: Vec = coalesced.into_iter().map(|(t, _)| t).collect(); + if tracks.len() < TABLE_MIN_COLUMNS { + return None; + } + // The union must be at least as wide as the widest fragment — otherwise + // re-extraction would lose columns the per-run detection already found + // (and a word-gap prose union that coalesced to nothing lands here too). + let max_run_cols = cluster.iter().filter_map(run_column_count).max()?; + if tracks.len() < max_run_cols { + if dbgt { + eprintln!( + "[tbl-cluster] reject: {} tracks < widest fragment {max_run_cols} ([{}])", + tracks.len(), + tracks + .iter() + .map(|t| format!("{t:.0}")) + .collect::>() + .join(",") + ); + } + return None; + } + + // NOTE: no vertical wrap-merge here. Gap-based merging of multi-line + // cells doesn't work — inside uniformly-leaded tables the gap between a + // wrapped cell line and a genuine next row is identical, so any threshold + // either shreds multi-line cells (no merge) or fuses adjacent logical rows + // (merge). Re-extracted clusters keep one row per line; multi-line-cell + // recovery needs a stronger signal (ruled-grid row boundaries, or + // label-column row anchoring). + let mut rows: Vec> = Vec::new(); + let mut failed_count = 0usize; + for line in &lines[window_start..window_end] { + if let Some(cells) = cells_from_raw_items_with_tracks(line, &tracks) { + if cells.iter().any(|c| !c.text.is_empty()) { + rows.push(cells.into_iter().map(|c| c.text).collect()); + } + } else if let Some(row) = sparse_row_via_cells(line, &tracks) { + rows.push(row); + } else { + // The line couldn't bin but still carries content — keep its + // full text as a column-0 row rather than dropping it. Counted + // toward the abort threshold below. + failed_count += 1; + let text = line.text.trim(); + if !text.is_empty() { + let mut row = vec![String::new(); tracks.len()]; + row[0] = collapse_whitespace(text); + rows.push(row); + } + } + } + let window_len = window_end - window_start; + if (failed_count as f32) > (window_len as f32) * TABLE_CLUSTER_MAX_FAILED_ROW_FRAC { + if dbgt { + eprintln!("[tbl-cluster] reject: {failed_count}/{window_len} lines unbinnable"); + } + return None; + } + if rows.len() < TABLE_MIN_ROWS { + return None; + } + + let absorbed = union_header_from_above(lines, window_start, floor, &tracks); + let (start, header) = match absorbed { + Some((hstart, header)) => (hstart, Some(header)), + None => (window_start, None), + }; + Some(TableRun { + start, + end: window_end, + body_start: window_start, + block: Block::Table { header, rows }, + }) +} + +fn run_column_count(run: &TableRun) -> Option { + match &run.block { + Block::Table { header, rows } => header + .as_ref() + .map(|h| h.len()) + .or_else(|| rows.first().map(|r| r.len())), + _ => None, + } +} + +/// Re-derive column tracks from the run's source lines. Aggregates min start_x +/// and max end_x across *every* line whose `split_cells` count matches the +/// run's declared column count, so a column with tight per-row content (e.g. +/// a right-aligned numeric body cell) still produces a track wide enough to +/// match a wider header cell that aligns to the same column. +fn run_body_tracks(run: &TableRun, lines: &[ProjectedLine]) -> Option> { + let n_cols = run_column_count(run)?; + let mut acc: Option> = None; + for line in &lines[run.start..run.end.min(lines.len())] { + let cells = split_cells(line); + if cells.len() != n_cols { + continue; + } + let row: Vec<(f32, f32)> = cells.iter().map(|c| (c.start_x, c.end_x)).collect(); + acc = Some(match acc { + None => row, + Some(prev) => prev + .into_iter() + .zip(row) + .map(|((ps, pe), (s, e))| (ps.min(s), pe.max(e))) + .collect(), + }); + } + acc +} + +fn tracks_align_same(a: &[(f32, f32)], b: &[(f32, f32)]) -> bool { + if a.len() != b.len() { + return false; + } + a.iter().zip(b.iter()).all(|(ta, tb)| { + let ca = (ta.0 + ta.1) * 0.5; + let cb = (tb.0 + tb.1) * 0.5; + (ca - cb).abs() <= TABLE_TRACK_TOLERANCE_PT + }) +} + +/// Score the alignment between two tracks. Returns `None` if they don't align. +/// Distance is `min(start_diff, end_diff, center_interior_match)` so a header +/// cell sitting at the edge of a wide body cell doesn't spuriously match. +fn subset_match_score(ta: (f32, f32), tb: (f32, f32), tol: f32) -> Option { + let d_start = (ta.0 - tb.0).abs(); + let d_end = (ta.1 - tb.1).abs(); + let ca = (ta.0 + ta.1) * 0.5; + // Center-in-range only counts when a's center falls in b's interior + // half — guards against a narrow header touching the edge of a wide + // row-label cell next door. + let interior_lo = tb.0 + (tb.1 - tb.0) * 0.25; + let interior_hi = tb.1 - (tb.1 - tb.0) * 0.25; + let d_center = if ca >= interior_lo && ca <= interior_hi { + 0.0 + } else { + f32::INFINITY + }; + let d = d_start.min(d_end).min(d_center); + if d <= tol { Some(d) } else { None } +} + +/// Looser tolerance for cross-run subset matching. Header cells and body +/// cells often have different content widths (e.g. `(percent)` header is +/// 65pt wide while the body's `12` is 10pt wide), so the per-row track +/// tolerance is too tight here. Combined with the interior-only center +/// check in `subset_match_score`, this stays conservative. +const TABLE_SUBSET_TRACK_TOLERANCE_PT: f32 = 12.0; + +/// Map A's tracks to B's tracks (requires `|A| + 1 == |B|`). Tries every +/// possible "skip one B column" assignment and picks the lowest-total-error +/// option. Returns `None` when no skip yields a fully-aligned mapping. +fn subset_mapping(a: &[(f32, f32)], b: &[(f32, f32)]) -> Option> { + if a.len() + 1 != b.len() { + return None; + } + let tol = TABLE_SUBSET_TRACK_TOLERANCE_PT; + let mut best: Option<(Vec, f32)> = None; + for skip in 0..b.len() { + let mut mapping = Vec::with_capacity(a.len()); + let mut total = 0.0f32; + let mut ok = true; + for (i, &ai) in a.iter().enumerate() { + let bi = if i < skip { i } else { i + 1 }; + match subset_match_score(ai, b[bi], tol) { + Some(d) => { + mapping.push(bi); + total += d; + } + None => { + ok = false; + break; + } + } + } + if ok && best.as_ref().is_none_or(|(_, e)| total < *e) { + best = Some((mapping, total)); + } + } + best.map(|(m, _)| m) +} + +/// Insert empty strings into `row` so that its content lands at the mapped +/// columns in a `target_len`-wide row. +fn pad_row_to_layout(row: &[String], mapping: &[usize], target_len: usize) -> Vec { + let mut out: Vec = vec![String::new(); target_len]; + for (a_idx, &b_idx) in mapping.iter().enumerate() { + if b_idx < target_len && a_idx < row.len() { + out[b_idx] = row[a_idx].clone(); + } + } + out +} + +/// Maximum number of non-table lines allowed between two runs being merged. +/// Each interstitial line must be a short single-cell label (category +/// divider, group header) to qualify — anything longer or multi-cell is +/// real content and rejects the merge. +const TABLE_MERGE_MAX_INTERSTITIAL: usize = 1; + +/// Cap on the character count of an interstitial label line that the merge +/// will absorb as a body row. +const TABLE_MERGE_MAX_INTERSTITIAL_CHARS: usize = 60; + +fn is_absorbable_interstitial(line: &ProjectedLine) -> bool { + let cells = split_cells(line); + if cells.len() > 1 { + return false; + } + let text = line.text.trim(); + if text.len() > TABLE_MERGE_MAX_INTERSTITIAL_CHARS { + return false; + } + // Reject sentence-shaped prose: ends in . ! ? (a real label rarely does) + if let Some(last) = text.chars().last() + && matches!(last, '.' | '!' | '?') + && text.len() > 6 + { + return false; + } + true +} + +fn try_merge_pair(a: &TableRun, b: &TableRun, lines: &[ProjectedLine]) -> Option { + // Allow up to `TABLE_MERGE_MAX_INTERSTITIAL` short label lines between + // A's end and B's start. Each interstitial gets preserved as a body + // row of the merged table so no content is dropped. + let interstitial = b.start.saturating_sub(a.end); + if interstitial > TABLE_MERGE_MAX_INTERSTITIAL { + return None; + } + let interstitial_texts: Vec = if interstitial == 0 { + Vec::new() + } else { + let slice = &lines[a.end..b.start]; + if !slice.iter().all(is_absorbable_interstitial) { + return None; + } + slice.iter().map(|l| l.text.trim().to_string()).collect() + }; + let (a_header, a_rows) = match &a.block { + Block::Table { header, rows } => (header.clone(), rows.clone()), + _ => return None, + }; + let (b_header, b_rows) = match &b.block { + Block::Table { header, rows } => (header.clone(), rows.clone()), + _ => return None, + }; + let a_cols = run_column_count(a)?; + let b_cols = run_column_count(b)?; + let a_tracks = run_body_tracks(a, lines)?; + let b_tracks = run_body_tracks(b, lines)?; + + if a.end == 0 || a.end > lines.len() || b.start >= lines.len() { + return None; + } + let a_last = &lines[a.end - 1]; + let b_first = &lines[b.start]; + let line_height = a_last.bbox.height.max(b_first.bbox.height).max(1.0); + let gap = b_first.bbox.y - (a_last.bbox.y + a_last.bbox.height); + if gap > line_height * TABLE_MERGE_MAX_Y_GAP_LINES { + return None; + } + if gap < -line_height { + return None; + } + + // Case Same: identical tracks, concat rows. + if a_cols == b_cols && tracks_align_same(&a_tracks, &b_tracks) { + // Don't merge two complete-looking tables across a noticeable gap. + let both_complete = + a_header.is_some() && b_header.is_some() && a_rows.len() >= 3 && b_rows.len() >= 3; + if both_complete && gap > line_height * 1.0 { + return None; + } + let header = a_header.clone().or_else(|| b_header.clone()); + let mut rows = a_rows.clone(); + // Preserve interstitial label lines as body rows, content in col 0. + for text in &interstitial_texts { + let mut row = vec![String::new(); b_cols]; + row[0] = text.clone(); + rows.push(row); + } + // If both runs had explicit headers, we kept A's; preserve B's + // header text as a body row so its content isn't dropped. + if a_header.is_some() + && b_header.is_some() + && let Some(bh) = b_header.clone() + { + rows.push(bh); + } + rows.extend(b_rows.iter().cloned()); + return Some(TableRun { + start: a.start, + end: b.end, + body_start: a.body_start, + block: Block::Table { header, rows }, + }); + } + + // Case Subset: A has 1 fewer column; fold A into B's header. + if a_cols + 1 == b_cols && a_rows.len() <= TABLE_HEADER_MAX_ABSORB_ROWS { + let mapping = subset_mapping(&a_tracks, &b_tracks)?; + + // Compose header rows top-to-bottom: A.header -> A.rows -> B.header. + let mut header_layers: Vec> = Vec::new(); + if let Some(h) = &a_header { + header_layers.push(pad_row_to_layout(h, &mapping, b_cols)); + } + for row in &a_rows { + header_layers.push(pad_row_to_layout(row, &mapping, b_cols)); + } + if let Some(h) = &b_header { + header_layers.push(h.clone()); + } + if header_layers.is_empty() { + return None; + } + let merged_header: Vec = (0..b_cols) + .map(|col| { + let mut parts: Vec = Vec::new(); + for layer in &header_layers { + let s = layer.get(col).map(|s| s.as_str()).unwrap_or(""); + if s.is_empty() { + continue; + } + if parts.last().map(|p| p.as_str()) == Some(s) { + continue; + } + parts.push(s.to_string()); + } + parts.join(" ") + }) + .collect(); + // Preserve interstitial label lines as body rows ahead of B's rows. + let mut merged_rows: Vec> = Vec::new(); + for text in &interstitial_texts { + let mut row = vec![String::new(); b_cols]; + row[0] = text.clone(); + merged_rows.push(row); + } + merged_rows.extend(b_rows.iter().cloned()); + return Some(TableRun { + start: a.start, + end: b.end, + // A was folded into B's header, so the merged table's body + // begins where B's body did. + body_start: b.body_start, + block: Block::Table { + header: Some(merged_header), + rows: merged_rows, + }, + }); + } + + None +} + +// ── Ruled-grid table detection ───────────────────────────────────────────── +// +// Detect tables drawn with explicit horizontal + vertical rules (the "Strong" +// mode in MARKDOWN_PLAN.md). Strokes are clustered into H/V grid lines, then +// union-find groups crossing lines into table regions. For each region the +// distinct row/column boundaries form a cell grid; text lines are assigned to +// cells by centroid containment. +// +// Ruled tables are detected before the borderless `detect_tables`. The caller +// merges the two outputs; overlapping ranges defer to the ruled run because +// path-based geometry is a strictly stronger signal than text alignment alone. + +/// Horizontal segment in viewport coords (top-left origin). `y` is the rule's +/// y-position; `x_min..x_max` is its horizontal span. Endpoints of multiple +/// short segments sharing a y get unioned into one wider segment during +/// clustering. +#[derive(Debug, Clone, Copy)] +struct HSeg { + x_min: f32, + x_max: f32, + y: f32, +} + +#[derive(Debug, Clone, Copy)] +struct VSeg { + y_min: f32, + y_max: f32, + x: f32, +} + +/// Strokes are considered "axis-aligned" when the perpendicular delta is at +/// most this many points. Generous to absorb antialiased near-pixel strokes. +const TABLE_AXIS_TOLERANCE_PT: f32 = 1.0; + +/// Two H lines (or two V lines) are merged into one grid line when their +/// perpendicular coords are within this many points. Slightly looser than the +/// axis tolerance because rules drawn at the same row can have ±1pt jitter +/// from different stroke widths. +const TABLE_GRID_CLUSTER_PT: f32 = 2.0; + +/// Slack added when checking whether a V line "crosses" an H line. Helps +/// when rules don't quite reach the corner because the PDF drew them as +/// individual segments with small gaps. +const TABLE_CROSS_TOLERANCE_PT: f32 = 3.0; + +/// Cluster tolerance for ruled column boundaries. Wider than +/// `TABLE_GRID_CLUSTER_PT` because adjacent cell-border rects draw paired +/// edges 4-6pt apart; real columns are ≥ ~10pt wide so the mean-centered +/// merge cannot eat a genuine narrow column. +const TABLE_COL_BOUNDARY_CLUSTER_PT: f32 = 6.0; + +/// Reject ruled-table candidates whose empty-cell fraction exceeds this. +/// This can't be loosened to recover blank worksheets/forms: a real sparse +/// table (e.g. a 4-col version history, ~75% empty) and a spurious grid from +/// decorative layout boxes (e.g. a TOC, also ~75% empty) are indistinguishable +/// on empty-fraction, and relaxing it surfaces more false tables than real +/// forms recovered. +const TABLE_MAX_EMPTY_CELL_FRACTION: f32 = 0.30; + +/// Fraction of a row or column that must be populated to qualify the grid as +/// a structural fill-in form (e.g. comparison charts with row labels + header +/// row but otherwise empty cells). When this signature is met, the empty-cell +/// fraction filter relaxes to `TABLE_MAX_EMPTY_CELL_FRACTION_WITH_SPINE`. +const TABLE_SPINE_FILL_FRACTION: f32 = 0.7; + +/// Max characters in any single col-0 or row-0 cell when applying the spine +/// bypass. Real labels and headers are short (1-5 words ≈ 50 chars); a column +/// of multi-sentence prose triggers `col0_fill` but isn't a structural label +/// column. +const TABLE_SPINE_MAX_CELL_CHARS: usize = 60; + +/// Ceiling on empty-cell fraction even when a spine is detected. Caps how +/// aggressively the fill-in-form bypass can override the base filter — past +/// 75% empty, even a strong spine isn't enough to distinguish from decorative +/// page chrome. +const TABLE_MAX_EMPTY_CELL_FRACTION_WITH_SPINE: f32 = 0.75; + +/// Reject candidates whose grid covers nearly the whole page — almost always +/// a page border, not a real table. +const TABLE_MAX_PAGE_COVERAGE: f32 = 0.95; + +/// Global-pass only: minimum fraction of the column extent a horizontal rule +/// must span to count as a row boundary. A full-height left/right border can +/// union a stray box (logo, sidebar) into a grid component through the shared +/// vertical line; that box's short rules would otherwise become phantom top +/// rows that vacuum surrounding prose into the table. Real row boundaries span +/// most of the table width. +const RULED_HLINE_MIN_COVERAGE: f32 = 0.5; + +/// Extract horizontal and vertical line segments from a page's graphics. Each +/// `Stroke` becomes one HSeg or VSeg depending on orientation; each stroked +/// `Rect` contributes its four edges (cell-border rects, table frames). +fn extract_h_v_segments(graphics: &[GraphicPrimitive]) -> (Vec, Vec) { + let mut hs = Vec::new(); + let mut vs = Vec::new(); + for g in graphics { + match g { + GraphicPrimitive::Stroke { x1, y1, x2, y2, .. } => { + let (x1, y1, x2, y2) = (*x1, *y1, *x2, *y2); + let dy = (y1 - y2).abs(); + let dx = (x1 - x2).abs(); + if dy <= TABLE_AXIS_TOLERANCE_PT && dx > 1.0 { + hs.push(HSeg { + x_min: x1.min(x2), + x_max: x1.max(x2), + y: (y1 + y2) * 0.5, + }); + } else if dx <= TABLE_AXIS_TOLERANCE_PT && dy > 1.0 { + vs.push(VSeg { + y_min: y1.min(y2), + y_max: y1.max(y2), + x: (x1 + x2) * 0.5, + }); + } + } + GraphicPrimitive::Rect { bbox, stroke, .. } => { + if stroke.is_none() { + continue; + } + let top = bbox.y; + let bottom = bbox.y + bbox.height; + let left = bbox.x; + let right = bbox.x + bbox.width; + if bbox.width > 1.0 { + hs.push(HSeg { + x_min: left, + x_max: right, + y: top, + }); + hs.push(HSeg { + x_min: left, + x_max: right, + y: bottom, + }); + } + if bbox.height > 1.0 { + vs.push(VSeg { + y_min: top, + y_max: bottom, + x: left, + }); + vs.push(VSeg { + y_min: top, + y_max: bottom, + x: right, + }); + } + } + } + } + (hs, vs) +} + +/// Cluster H segments sharing a y-coordinate (within `TABLE_GRID_CLUSTER_PT`) +/// into a single wider grid line whose x-extent is the union of the inputs. +fn cluster_h_segments(mut segs: Vec) -> Vec { + if segs.is_empty() { + return segs; + } + segs.sort_by(|a, b| a.y.total_cmp(&b.y)); + let mut out: Vec = Vec::with_capacity(segs.len()); + for seg in segs { + if let Some(last) = out.last_mut() + && (last.y - seg.y).abs() <= TABLE_GRID_CLUSTER_PT + { + last.x_min = last.x_min.min(seg.x_min); + last.x_max = last.x_max.max(seg.x_max); + continue; + } + out.push(seg); + } + out +} + +fn cluster_v_segments(mut segs: Vec) -> Vec { + if segs.is_empty() { + return segs; + } + segs.sort_by(|a, b| a.x.total_cmp(&b.x)); + let mut out: Vec = Vec::with_capacity(segs.len()); + for seg in segs { + if let Some(last) = out.last_mut() + && (last.x - seg.x).abs() <= TABLE_GRID_CLUSTER_PT + { + last.y_min = last.y_min.min(seg.y_min); + last.y_max = last.y_max.max(seg.y_max); + continue; + } + out.push(seg); + } + out +} + +/// Union-find root with path compression. +fn uf_find(parent: &mut [usize], mut x: usize) -> usize { + while parent[x] != x { + parent[x] = parent[parent[x]]; + x = parent[x]; + } + x +} + +fn uf_union(parent: &mut [usize], a: usize, b: usize) { + let ra = uf_find(parent, a); + let rb = uf_find(parent, b); + if ra != rb { + parent[ra] = rb; + } +} + +/// Group H/V grid lines that cross each other into connected components. +/// Each component is a candidate ruled table — typically one component per +/// distinct table on the page. Returns `(h_indices, v_indices)` per component, +/// dropping components without ≥2 H and ≥2 V lines (a single L-shape doesn't +/// make a table). +fn find_grid_components(hs: &[HSeg], vs: &[VSeg]) -> Vec<(Vec, Vec)> { + let n_h = hs.len(); + let n_v = vs.len(); + if n_h < 2 || n_v < 2 { + return Vec::new(); + } + let n = n_h + n_v; + let mut parent: Vec = (0..n).collect(); + let mut connected = vec![false; n]; + + let tol = TABLE_CROSS_TOLERANCE_PT; + for (i, h) in hs.iter().enumerate() { + for (j, v) in vs.iter().enumerate() { + let v_crosses_h_x = v.x >= h.x_min - tol && v.x <= h.x_max + tol; + let h_crosses_v_y = h.y >= v.y_min - tol && h.y <= v.y_max + tol; + if v_crosses_h_x && h_crosses_v_y { + uf_union(&mut parent, i, n_h + j); + connected[i] = true; + connected[n_h + j] = true; + } + } + } + + use std::collections::HashMap; + let mut groups: HashMap, Vec)> = HashMap::new(); + for (i, &is_connected) in connected[..n_h].iter().enumerate() { + if !is_connected { + continue; + } + let r = uf_find(&mut parent, i); + groups.entry(r).or_default().0.push(i); + } + for j in 0..n_v { + if !connected[n_h + j] { + continue; + } + let r = uf_find(&mut parent, n_h + j); + groups.entry(r).or_default().1.push(j); + } + let mut comps: Vec<(Vec, Vec)> = groups + .into_values() + .filter(|(h_idx, v_idx)| h_idx.len() >= 2 && v_idx.len() >= 2) + .collect(); + // `HashMap::into_values` yields components in nondeterministic order, which + // leaks into table emission order and downstream overlap resolution. Sort + // by the topmost horizontal-segment index (h_idx is ascending by + // construction) so the output is stable run-to-run. + comps.sort_by_key(|(h_idx, _)| h_idx[0]); + comps +} + +/// Which ruled-table detection pass is running. The global pass reassembles a +/// grid from page-level graphics (lines arrive in xy_cut leaf order and grid +/// rules may include short decorative horizontals), so it applies extra +/// filtering the per-region pass does not need. +#[derive(Clone, Copy, PartialEq, Eq)] +enum RuledPass { + PerRegion, + Global, +} + +/// Filter a vector down to the elements whose `keep` flag is set. `keep` must +/// be at least as long as `items`. +fn filter_by(items: Vec, keep: &[bool]) -> Vec { + items + .into_iter() + .zip(keep.iter()) + .filter(|(_, k)| **k) + .map(|(v, _)| v) + .collect() +} + +/// The per-cell grids `build_ruled_table` fills, collapses, and reads as one +/// unit. Bundling them lets the row/column-collapse passes filter every layer +/// through a single `retain_rows`/`retain_cols` call instead of five parallel +/// `filter().map().collect()` chains — removing the "forgot to filter one +/// array" bug class. +struct CellGrid { + /// Rendered cell text (each multi-column span whitespace-split at the + /// crossed column boundaries). + text: Vec>, + /// Per-cell bold flag — starts `true`, cleared when any non-bold span + /// lands in the cell. + is_bold: Vec>, + /// Per-cell "carries text" flag. + has_text: Vec>, + /// Colspan-semantics grid: a span crossing ≥2 column boundaries replicates + /// its FULL text into every covered cell instead of being split. Consulted + /// only for header-band flattening — a group header like "North America" + /// centered over its sub-columns must replicate, while the same geometry in + /// a data row is a merged run that should split. + repl: Vec>, + /// Per-row flag: the row contains an alpha-dominant multi-column span (a + /// group-header label, as opposed to a mostly-digit merged data run). + row_alpha_spanner: Vec, +} + +impl CellGrid { + fn new(n_rows: usize, n_cols: usize) -> Self { + CellGrid { + text: vec![vec![String::new(); n_cols]; n_rows], + is_bold: vec![vec![true; n_cols]; n_rows], + has_text: vec![vec![false; n_cols]; n_rows], + repl: vec![vec![String::new(); n_cols]; n_rows], + row_alpha_spanner: vec![false; n_rows], + } + } + + fn n_rows(&self) -> usize { + self.text.len() + } + + fn n_cols(&self) -> usize { + self.text.first().map(|r| r.len()).unwrap_or(0) + } + + /// Append trimmed text to a cell, marking it as text-bearing. No-op for + /// blank input. + fn push_text(&mut self, row: usize, col: usize, txt: &str) { + let txt = txt.trim(); + if txt.is_empty() { + return; + } + if !self.text[row][col].is_empty() { + self.text[row][col].push(' '); + } + self.text[row][col].push_str(txt); + self.has_text[row][col] = true; + } + + /// Append trimmed text to the colspan-semantics (`repl`) cell. No-op for + /// blank input. + fn push_repl(&mut self, row: usize, col: usize, txt: &str) { + let txt = txt.trim(); + if txt.is_empty() { + return; + } + let dst = &mut self.repl[row][col]; + if !dst.is_empty() { + dst.push(' '); + } + dst.push_str(txt); + } + + /// Keep only rows `r` where `keep[r]`; every layer filters identically. + fn retain_rows(&mut self, keep: &[bool]) { + self.text = filter_by(std::mem::take(&mut self.text), keep); + self.is_bold = filter_by(std::mem::take(&mut self.is_bold), keep); + self.has_text = filter_by(std::mem::take(&mut self.has_text), keep); + self.repl = filter_by(std::mem::take(&mut self.repl), keep); + self.row_alpha_spanner = filter_by(std::mem::take(&mut self.row_alpha_spanner), keep); + } + + /// Keep only columns `c` where `keep[c]`; the per-cell layers filter, the + /// per-row `row_alpha_spanner` is unaffected. + fn retain_cols(&mut self, keep: &[bool]) { + self.text = std::mem::take(&mut self.text) + .into_iter() + .map(|row| filter_by(row, keep)) + .collect(); + self.is_bold = std::mem::take(&mut self.is_bold) + .into_iter() + .map(|row| filter_by(row, keep)) + .collect(); + self.has_text = std::mem::take(&mut self.has_text) + .into_iter() + .map(|row| filter_by(row, keep)) + .collect(); + self.repl = std::mem::take(&mut self.repl) + .into_iter() + .map(|row| filter_by(row, keep)) + .collect(); + } + + /// Drop "phantom rows" produced by stacked thin border-strip rects (some + /// generators rule each visual row as top-strip ~1pt / body ~22pt / + /// bottom-strip ~5pt, each surviving the 2pt clustering as its own grid + /// row). A row is dropped + /// iff it has no text in any cell AND its height is < 80% of the median + /// non-empty row height — the height gate preserves real fill-in-the-blank + /// forms whose empty body rows are full height. `ys` are the row boundaries + /// (length `n_rows + 1`). Returns the kept rows' heights. + fn collapse_phantom_rows(&mut self, ys: &[f32]) -> Vec { + let n_rows = self.n_rows(); + let row_heights: Vec = (0..n_rows).map(|r| ys[r + 1] - ys[r]).collect(); + let nonempty_heights: Vec = (0..n_rows) + .filter(|r| self.has_text[*r].iter().any(|t| *t)) + .map(|r| row_heights[r]) + .collect(); + let median_h = if !nonempty_heights.is_empty() { + let mut s = nonempty_heights.clone(); + s.sort_by(|a, b| a.total_cmp(b)); + s[s.len() / 2] + } else { + let mut s = row_heights.clone(); + s.sort_by(|a, b| a.total_cmp(b)); + s[s.len() / 2] + }; + let keep: Vec = (0..n_rows) + .map(|r| { + let has_text = self.has_text[r].iter().any(|t| *t); + has_text || row_heights[r] >= median_h * 0.8 + }) + .collect(); + let kept_row_heights: Vec = (0..n_rows) + .filter(|r| keep[*r]) + .map(|r| row_heights[r]) + .collect(); + self.retain_rows(&keep); + kept_row_heights + } + + /// Mirror `collapse_phantom_rows` on columns: some ruled tables draw + /// left/right borders as thin strip rects ~5pt wide, which become + /// phantom text-less columns. Drop a column iff it is both empty AND + /// narrower than 30% of the median text-bearing column. `xs` are the column + /// boundaries (length `n_cols + 1`). + fn collapse_phantom_cols(&mut self, xs: &[f32]) { + let n_rows = self.n_rows(); + let n_cols = self.n_cols(); + let col_widths: Vec = (0..n_cols).map(|c| xs[c + 1] - xs[c]).collect(); + let nonempty_col_widths: Vec = (0..n_cols) + .filter(|c| (0..n_rows).any(|r| self.has_text[r][*c])) + .map(|c| col_widths[c]) + .collect(); + let median_w = if !nonempty_col_widths.is_empty() { + let mut s = nonempty_col_widths.clone(); + s.sort_by(|a, b| a.total_cmp(b)); + s[s.len() / 2] + } else { + let mut s = col_widths.clone(); + s.sort_by(|a, b| a.total_cmp(b)); + s[s.len() / 2] + }; + let keep_col: Vec = (0..n_cols) + .map(|c| { + let has_text = (0..n_rows).any(|r| self.has_text[r][c]); + has_text || col_widths[c] >= median_w * 0.3 + }) + .collect(); + self.retain_cols(&keep_col); + } +} + +/// Bin each line's text into the grid cells defined by column boundaries `xs` +/// and row boundaries `ys`. A projected line frequently spans several ruled +/// columns (one baseline = one line), so binning whole lines by centroid would +/// lump an entire row into one cell and leave the rest empty — the empty-cell +/// filter would then reject the real table. Instead bin each line's raw spans +/// by span center; spans whose x-extent crosses one or more interior column +/// boundaries are split at the whitespace nearest each crossed boundary (same +/// interpolation as the inferred-track path). Returns the filled grid and the +/// consumed line indices, or `None` if no line landed inside the grid or too +/// many spans straddle interior boundaries (a decorative box over prose, not a +/// table). +fn assign_cells( + lines: &[ProjectedLine], + xs: &[f32], + ys: &[f32], + dbg: bool, +) -> Option<(CellGrid, Vec)> { + let n_rows = ys.len() - 1; + let n_cols = xs.len() - 1; + let mut grid = CellGrid::new(n_rows, n_cols); + let mut consumed_indices: Vec = Vec::new(); + const GRID_X_SLACK_PT: f32 = 6.0; + // Straddle census: spans that cross an interior column boundary by a + // clear margin on both sides. A real ruled table keeps text inside cells + // (the occasional PDFium merged run aside); decorative slide/layout boxes + // over flowing prose slice through most runs. + const STRADDLE_MARGIN_PT: f32 = 3.0; + let mut span_total = 0usize; + let mut span_straddle = 0usize; + + // Iterate lines top-to-bottom. The global pass receives lines in xy_cut + // leaf order (column-by-column), which scrambles a multi-line cell's text + // when concatenated in array order; y-sorted iteration restores reading + // order within each cell. Per-region lines are already y-ordered, so this + // is a no-op there. + let mut line_order: Vec = (0..lines.len()).collect(); + line_order.sort_by(|&a, &b| lines[a].bbox.y.total_cmp(&lines[b].bbox.y)); + for idx in line_order { + let line = &lines[idx]; + let cy = line.bbox.y + line.bbox.height * 0.5; + if cy < ys[0] || cy > ys[n_rows] { + continue; + } + // Line-level row bucket. Used by the no-spans fallback and as the + // default when a span's own y doesn't resolve. Spans bin by their own + // baseline below — a projected line can merge text from several ruled + // rows (e.g. a multi-baseline wrapped header emitted as one line). + let row = match find_bucket(ys, cy) { + Some(r) => r, + None => continue, + }; + if line.text.trim().is_empty() { + continue; + } + + // Only consume the line when its text sits inside the grid + // horizontally — a line poking past the grid edge belongs (at least + // partly) to surrounding prose, and consuming it would lose text. + let line_x0 = line.bbox.x; + let line_x1 = line.bbox.x + line.bbox.width; + if line_x0 < xs[0] - GRID_X_SLACK_PT || line_x1 > xs[n_cols] + GRID_X_SLACK_PT { + if dbg { + eprintln!( + "[ruled] skip-overhang row={row} x={line_x0:.0}..{line_x1:.0} grid={:.0}..{:.0} text={:?}", + xs[0], + xs[n_cols], + &line.text.chars().take(60).collect::() + ); + } + continue; + } + + let mut text_spans: Vec<&TextItem> = line + .spans + .iter() + .filter(|s| !s.text.trim().is_empty()) + .collect(); + text_spans.sort_by(|a, b| a.x.total_cmp(&b.x)); + + if text_spans.is_empty() { + // No raw text spans (synthetic/OCR lines): old whole-line centroid path. + let cx = line.bbox.x + line.bbox.width * 0.5; + if let Some(col) = find_bucket(xs, cx.clamp(xs[0], xs[n_cols])) { + grid.push_text(row, col, &line.text); + grid.push_repl(row, col, &line.text); + if !line.all_bold { + grid.is_bold[row][col] = false; + } + consumed_indices.push(idx); + } + continue; + } + + for span in text_spans { + // Per-span row: a span carries its own baseline y, which may sit + // in a different ruled row than the line centroid. + let span_cy = span.y + span.height * 0.5; + let row = find_bucket(ys, span_cy.clamp(ys[0], ys[n_rows])).unwrap_or(row); + let sx0 = (span.x).clamp(xs[0], xs[n_cols]); + let sx1 = (span.x + span.width).clamp(xs[0], xs[n_cols]); + let c_lo = find_bucket(xs, sx0).unwrap_or(0); + let c_hi = find_bucket(xs, sx1).unwrap_or(n_cols - 1); + span_total += 1; + { + let m0 = (span.x + STRADDLE_MARGIN_PT).clamp(xs[0], xs[n_cols]); + let m1 = (span.x + span.width - STRADDLE_MARGIN_PT).clamp(xs[0], xs[n_cols]); + if m1 > m0 && find_bucket(xs, m0) != find_bucket(xs, m1) { + span_straddle += 1; + } + } + if c_lo == c_hi { + grid.push_text(row, c_lo, &span.text); + grid.push_repl(row, c_lo, &span.text); + if !line.all_bold { + grid.is_bold[row][c_lo] = false; + } + continue; + } + // Multi-column span: replicate the full text into every covered + // cell of the colspan-semantics grid, and flag the row when the + // label is alpha-dominant (group headers are words; merged data + // runs are mostly digits). + for col in c_lo..=c_hi { + grid.push_repl(row, col, &span.text); + } + if is_alpha_dominant(&span.text) { + grid.row_alpha_spanner[row] = true; + } + // Span crosses interior boundaries: split at the whitespace + // nearest each crossed boundary x (xs[k] is column k's left + // boundary, which is exactly the split target). + let covered: Vec = (c_lo..=c_hi).collect(); + if let Some(pieces) = split_span_at_anchors(span, &covered, xs) { + for (k, piece) in pieces.iter().enumerate() { + grid.push_text(row, c_lo + k, piece); + if !line.all_bold { + grid.is_bold[row][c_lo + k] = false; + } + } + } else { + // No whitespace to split on — assign whole span by center. + let cx = (span.x + span.width * 0.5).clamp(xs[0], xs[n_cols]); + if let Some(col) = find_bucket(xs, cx) { + grid.push_text(row, col, &span.text); + if !line.all_bold { + grid.is_bold[row][col] = false; + } + } + } + } + consumed_indices.push(idx); + } + + if consumed_indices.is_empty() { + if dbg { + eprintln!("[ruled] REJECT no-lines-consumed"); + } + return None; + } + + let straddle_frac = if span_total > 0 { + span_straddle as f32 / span_total as f32 + } else { + 0.0 + }; + if dbg { + eprintln!("[ruled] straddle {span_straddle}/{span_total} = {straddle_frac:.2}"); + } + if span_total >= 6 && straddle_frac > 0.45 { + if dbg { + eprintln!("[ruled] REJECT straddle-frac {straddle_frac:.2}"); + } + return None; + } + + Some((grid, consumed_indices)) +} + +/// Colspan header-band flattening: a sparse top row whose alpha spanning label +/// covers several columns ("North America" over its Revenue/Units sub-columns) +/// followed by a dense label row is a stacked header. Flatten rows `0..=b` from +/// the colspan-semantics (`repl`) grid into one header row (per-column +/// top-to-bottom join), so each column carries its full layer chain ("North +/// America Revenue") — that chain is what header-keyed consumers (and readers) +/// need. Returns `(header_row, body_start)` or `None`. +fn flatten_header_band( + cells: &[Vec], + cell_has_text: &[Vec], + cells_repl: &[Vec], + row_alpha_spanner: &[bool], + n_rows: usize, + n_cols: usize, + dbg: bool, +) -> Option<(Vec, usize)> { + let row_fill = + |r: usize| cell_has_text[r].iter().filter(|t| **t).count() as f32 / n_cols as f32; + (0..n_rows) + .find(|r| row_fill(*r) >= TABLE_ROW_MIN_FILL) + .and_then(|b| { + let nonempty = cell_has_text[b].iter().filter(|t| **t).count(); + let alpha_cells = (0..n_cols) + .filter(|c| cell_has_text[b][*c] && is_alpha_dominant(&cells[b][*c])) + .count(); + // The bottom header layer may carry digit labels (years like + // "2024") but never measurement values. A decimal / % / $ / + // dash-placeholder / comma-grouped number in the anchor row means + // it's the first DATA row of a table whose real header just + // missed the 0.9-fill anchor (colspan header covering <90% of + // columns) — folding it would eat a data row (DS5795A_page4). + let has_value_cell = + (0..n_cols).any(|c| cell_has_text[b][c] && is_value_like(&cells[b][c])); + let qualifies = (1..=3).contains(&b) + && b + 1 < n_rows + && (0..b).any(|r| row_alpha_spanner[r]) + && alpha_cells * 2 >= nonempty + && !has_value_cell; + if !qualifies { + return None; + } + let header: Vec = (0..n_cols) + .map(|c| { + let mut parts: Vec<&str> = Vec::new(); + for row in cells_repl.iter().take(b + 1) { + let s = row[c].as_str(); + if s.is_empty() || parts.last() == Some(&s) { + continue; + } + parts.push(s); + } + parts.join(" ") + }) + .collect(); + if header.iter().all(|h| h.is_empty()) { + return None; + } + if dbg { + eprintln!("[ruled] colspan header flatten: rows 0..={b} -> {header:?}"); + } + Some((header, b + 1)) + }) +} + +/// Stacked-header merge: some generators rule every text baseline of a +/// wrapped header cell, slicing one logical header row into several thin sparse +/// rows ("Rated" / "Voltage" / "(VDC)" each in its own band). When the top +/// `k ≥ 2` rows are individually sparse but their union covers most columns AND +/// a dense data row follows, collapse them into a single header row (per-column +/// top-to-bottom join). `flattened` is whether `flatten_header_band` already +/// fired (the two are mutually exclusive). Returns the possibly-merged grid +/// plus a flag for whether the merge happened. +#[allow(clippy::type_complexity)] +fn merge_stacked_header( + cells: Vec>, + cell_has_text: Vec>, + cell_is_bold: Vec>, + n_rows: usize, + n_cols: usize, + kept_row_heights: &[f32], + flattened: bool, + dbg: bool, +) -> ( + Vec>, + Vec>, + Vec>, + usize, + bool, +) { + let row_fill = + |r: usize, has: &[Vec]| has[r].iter().filter(|t| **t).count() as f32 / n_cols as f32; + // Anchor on the first fully-dense row (the first real data row). + let k = (0..n_rows) + .find(|r| row_fill(*r, &cell_has_text) >= TABLE_ROW_MIN_FILL) + .unwrap_or(0); + let union_cols = (0..n_cols) + .filter(|c| (0..k).any(|r| cell_has_text[r][*c])) + .count(); + // Tightness: the band rows must be noticeably shorter than the data + // rows below — ruled-per-baseline header bands sit at text leading + // (~7pt) while real rows carry cell padding. Without this, a table + // whose first body rows are legitimately sparse would get merged. + let band_tight = if k >= 2 && k < kept_row_heights.len() { + let mut below: Vec = kept_row_heights[k..].to_vec(); + below.sort_by(|a, b| a.total_cmp(b)); + let median_below = below[below.len() / 2]; + kept_row_heights[..k] + .iter() + .all(|h| *h <= 0.75 * median_below) + } else { + false + }; + if flattened || k < 2 || k >= n_rows || !band_tight || (union_cols as f32) < 0.7 * n_cols as f32 + { + return (cells, cell_has_text, cell_is_bold, n_rows, false); + } + if dbg { + eprintln!("[ruled] stacked-header merge: top {k} rows → 1"); + } + let mut merged_row = vec![String::new(); n_cols]; + let mut merged_has = vec![false; n_cols]; + let mut merged_bold = vec![true; n_cols]; + for r in 0..k { + for c in 0..n_cols { + if cell_has_text[r][c] { + if !merged_row[c].is_empty() { + merged_row[c].push(' '); + } + merged_row[c].push_str(&cells[r][c]); + merged_has[c] = true; + if !cell_is_bold[r][c] { + merged_bold[c] = false; + } + } + } + } + let mut new_cells = vec![merged_row]; + let mut new_has = vec![merged_has]; + let mut new_bold = vec![merged_bold]; + new_cells.extend(cells[k..].iter().cloned()); + new_has.extend(cell_has_text[k..].iter().cloned()); + new_bold.extend(cell_is_bold[k..].iter().cloned()); + let nr = new_cells.len(); + (new_cells, new_has, new_bold, nr, true) +} + +/// Density gate: a grid that is mostly empty cells is rejected unless it shows +/// strong table evidence. Three escape hatches keep real tables: +/// - **col0 spine**: a filled, short-text first column (a label column). +/// - **long-prose table**: a large (≥5×3) grid with a bold header band covering +/// ≥3 columns and a dense (≥70%-fill) inner description column — a +/// multi-line legal/reference table whose description wraps over many empty +/// continuation rows. +/// - **flattened header**: a fired colspan header flatten is table evidence on +/// par with a spine. +/// +/// With a spine but above the higher WITH_SPINE ceiling, still reject (unless a +/// long-prose table). `flattened` is whether `flatten_header_band` fired. +/// +/// Returns `true` to keep the table, `false` to reject it. +/// +fn passes_density_gate( + cells: &[Vec], + cell_has_text: &[Vec], + cell_is_bold: &[Vec], + n_rows: usize, + n_cols: usize, + flattened: bool, + dbg: bool, +) -> bool { + let total = n_rows * n_cols; + let empty_count = cell_has_text + .iter() + .flatten() + .filter(|filled| !**filled) + .count(); + let empty_frac = (empty_count as f32) / (total as f32); + if empty_frac <= TABLE_MAX_EMPTY_CELL_FRACTION { + return true; + } + let col0_fill = (0..n_rows).filter(|r| cell_has_text[*r][0]).count() as f32 / n_rows as f32; + let col0_max_chars = (0..n_rows) + .filter(|r| cell_has_text[*r][0]) + .map(|r| cells[r][0].len()) + .max() + .unwrap_or(0); + let col0_spine = + col0_fill >= TABLE_SPINE_FILL_FRACTION && col0_max_chars <= TABLE_SPINE_MAX_CELL_CHARS; + // Header may span multiple visual rows (the grid detector slices on each + // text baseline). Treat the first ≤4 rows as the header band and require + // their *union* to cover most columns AND be all-bold. + let header_band = n_rows.min(4); + let mut header_cols_covered = vec![false; n_cols]; + let mut header_all_bold = true; + for r in 0..header_band { + for c in 0..n_cols { + if cell_has_text[r][c] { + header_cols_covered[c] = true; + if !cell_is_bold[r][c] { + header_all_bold = false; + } + } + } + } + let header_coverage = header_cols_covered.iter().filter(|t| **t).count(); + let dense_inner_col = (1..n_cols).any(|c| { + let col_fill = (0..n_rows).filter(|r| cell_has_text[*r][c]).count() as f32 / n_rows as f32; + col_fill >= TABLE_SPINE_FILL_FRACTION + }); + // Header coverage doesn't need to span every column — wide-cell legal + // tables often spread the header across many visual baselines and only a + // few columns land in the top-4-rows band. Require ≥3 columns covered as + // evidence of a real header, not just a title. + let long_prose_table = + n_rows >= 5 && n_cols >= 3 && header_coverage >= 3 && header_all_bold && dense_inner_col; + if !col0_spine && !long_prose_table && !flattened { + if dbg { + let fills: Vec = (0..n_rows) + .map(|r| cell_has_text[r].iter().filter(|t| **t).count()) + .collect(); + eprintln!( + "[ruled] REJECT empty-frac {empty_frac:.2} ({n_rows}x{n_cols}, no spine/long-prose) row_fills={fills:?}" + ); + } + return false; + } + if empty_frac > TABLE_MAX_EMPTY_CELL_FRACTION_WITH_SPINE && !long_prose_table { + if dbg { + eprintln!("[ruled] REJECT empty-frac-with-spine {empty_frac:.2}"); + } + return false; + } + true +} + +/// Build a `TableRun` for one ruled-grid component. Returns `None` if the +/// resulting grid is too small (< 2 cols or < 2 rows), covers nearly the +/// whole page (likely the page border), or is mostly empty cells. +fn build_ruled_table( + hs: &[HSeg], + vs: &[VSeg], + h_indices: &[usize], + v_indices: &[usize], + lines: &[ProjectedLine], + page_width: f32, + page_height: f32, + pass: RuledPass, +) -> Option<(TableRun, Vec)> { + let dbg = *super::flags::DEBUG_RULED; + let mut xs: Vec = v_indices.iter().map(|&i| vs[i].x).collect(); + xs.sort_by(|a, b| a.total_cmp(b)); + // Coarser, mean-centered clustering for column boundaries: cell-border + // rects contribute paired edges 4-6pt apart that would otherwise become + // phantom 5pt "columns" the span splitter then shreds text into. + cluster_boundaries(&mut xs, TABLE_COL_BOUNDARY_CLUSTER_PT); + + // Distinct row y-coords (cluster again — multiple H lines may share a y). + // In the global pass, first drop horizontal rules that span only a small + // fraction of the column extent (see `RULED_HLINE_MIN_COVERAGE`). + let raw_ys = |idxs: &[usize]| { + let mut v: Vec = idxs.iter().map(|&i| hs[i].y).collect(); + v.sort_by(|a, b| a.total_cmp(b)); + dedup_close(&mut v, TABLE_GRID_CLUSTER_PT); + v + }; + let ys: Vec = if pass == RuledPass::Global && xs.len() >= 2 { + let col_lo = xs[0]; + let col_hi = xs[xs.len() - 1]; + let extent = (col_hi - col_lo).max(1.0); + let kept: Vec = h_indices + .iter() + .copied() + .filter(|&i| { + let h = &hs[i]; + let ov = (h.x_max.min(col_hi) - h.x_min.max(col_lo)).max(0.0); + ov / extent >= RULED_HLINE_MIN_COVERAGE + }) + .collect(); + let filtered = raw_ys(&kept); + if filtered.len() >= 3 { + filtered + } else { + raw_ys(h_indices) + } + } else { + raw_ys(h_indices) + }; + if dbg { + eprintln!( + "[ruled] component: ys={:?} xs={:?} ({} lines in scope)", + ys, + xs, + lines.len() + ); + } + + // Need ≥2 row boundaries (1 row) and ≥2 column boundaries (1 col); but + // a 1×1 grid is just a callout box, so also require ≥1 inner divider + // (i.e. ys.len() ≥ 3 for ≥2 rows). Single-column tables (`xs.len() == 2`) + // are accepted when row evidence is strong enough — extra guards apply + // below after the empty-row collapse. + if ys.len() < 3 || xs.len() < 2 { + if dbg { + eprintln!( + "[ruled] REJECT grid-too-small ys={} xs={}", + ys.len(), + xs.len() + ); + } + return None; + } + + let n_rows = ys.len() - 1; + let n_cols = xs.len() - 1; + let bbox = crate::types::Rect { + x: xs[0], + y: ys[0], + width: xs[n_cols] - xs[0], + height: ys[n_rows] - ys[0], + }; + + // Reject page-border-as-table. + if page_width > 0.0 && page_height > 0.0 { + let coverage = (bbox.width / page_width) * (bbox.height / page_height); + if coverage > TABLE_MAX_PAGE_COVERAGE { + if dbg { + eprintln!("[ruled] REJECT page-coverage {coverage:.2}"); + } + return None; + } + } + + // Assign text to cells, then reject if no lines landed inside the grid or + // too many spans straddle interior column boundaries (decorative box, not + // a table). + let (mut grid, consumed_indices) = assign_cells(lines, &xs, &ys, dbg)?; + + // Collapse phantom rows (text-less thin border-strip rects) then phantom + // columns (text-less narrow border strips). A real table with one phantom + // border-strip column drops exactly one; a chart whose vertical grid-lines + // merged with text data collapses to 1 col and is rejected below, letting + // the borderless detector handle it. + let kept_row_heights = grid.collapse_phantom_rows(&ys); + let n_rows = grid.n_rows(); + if n_rows < 2 { + if dbg { + eprintln!("[ruled] REJECT rows-after-collapse {n_rows}"); + } + return None; + } + grid.collapse_phantom_cols(&xs); + let n_cols = grid.n_cols(); + if n_cols == 0 { + return None; + } + // Single-column tables are ambiguous (could be a captioned card) — require + // ≥3 rows of geometric + textual evidence. + if n_cols == 1 && n_rows < 3 { + return None; + } + + // Hand the collapsed grids back to plain locals for the header-detection + // stages below. + let CellGrid { + text: cells, + is_bold: cell_is_bold, + has_text: cell_has_text, + repl: cells_repl, + row_alpha_spanner, + } = grid; + + let flattened_header = flatten_header_band( + &cells, + &cell_has_text, + &cells_repl, + &row_alpha_spanner, + n_rows, + n_cols, + dbg, + ); + + let (cells, cell_has_text, cell_is_bold, n_rows, merged_stacked_header) = merge_stacked_header( + cells, + cell_has_text, + cell_is_bold, + n_rows, + n_cols, + &kept_row_heights, + flattened_header.is_some(), + dbg, + ); + + if !passes_density_gate( + &cells, + &cell_has_text, + &cell_is_bold, + n_rows, + n_cols, + flattened_header.is_some(), + dbg, + ) { + return None; + } + + // Header preference order: flattened colspan band (carries the full + // per-column layer chain) > merged stacked-header band > bold first row. + let header_qualifies = merged_stacked_header + || (cell_has_text[0] + .iter() + .zip(cell_is_bold[0].iter()) + .all(|(has, bold)| !has || *bold) + && cell_has_text[0].iter().any(|has| *has)); + let (header, body_start) = match flattened_header { + Some((h, bs)) => (Some(h), bs), + None if header_qualifies => (Some(cells[0].clone()), 1), + None => (None, 0), + }; + let body_rows: Vec> = cells[body_start..].to_vec(); + if body_rows.is_empty() { + return None; + } + + // Line index span this table covers. + let start = *consumed_indices.iter().min().unwrap(); + let end = *consumed_indices.iter().max().unwrap() + 1; + + Some(( + TableRun { + start, + end, + body_start: start, + block: Block::Table { + header, + rows: body_rows, + }, + }, + consumed_indices, + )) +} + +/// Single-link cluster a sorted Vec of boundary coordinates, replacing each +/// chain of entries (adjacent gap ≤ `tol`) with its mean. Unlike +/// `dedup_close` (keep-first), the mean centers the boundary between the +/// paired edges that cell-border rects produce (left border strip + right +/// border strip of adjacent cells, typically 4-6pt apart), so the split +/// target lands between cells rather than inside one. +fn cluster_boundaries(v: &mut Vec, tol: f32) { + if v.len() < 2 { + return; + } + let mut out: Vec = Vec::with_capacity(v.len()); + let mut cluster_sum = v[0]; + let mut cluster_n = 1usize; + let mut last = v[0]; + for &x in v.iter().skip(1) { + if x - last <= tol { + cluster_sum += x; + cluster_n += 1; + } else { + out.push(cluster_sum / cluster_n as f32); + cluster_sum = x; + cluster_n = 1; + } + last = x; + } + out.push(cluster_sum / cluster_n as f32); + *v = out; +} + +/// In-place dedup of a sorted Vec, collapsing entries within `tol` to the +/// first of each cluster. +fn dedup_close(v: &mut Vec, tol: f32) { + if v.len() < 2 { + return; + } + let mut out: Vec = Vec::with_capacity(v.len()); + for x in v.iter().copied() { + if let Some(&last) = out.last() + && (x - last).abs() <= tol + { + continue; + } + out.push(x); + } + *v = out; +} + +/// Find the bucket index `i` such that `boundaries[i] <= val < boundaries[i+1]`. +/// Returns `None` if `val` is outside the boundaries. +fn find_bucket(boundaries: &[f32], val: f32) -> Option { + if boundaries.len() < 2 || val < boundaries[0] || val > *boundaries.last().unwrap() { + return None; + } + for (i, w) in boundaries.windows(2).enumerate() { + if val >= w[0] && val <= w[1] { + return Some(i); + } + } + None +} + +/// Detect candidate ruled-table bounding rectangles from page graphics alone. +/// +/// Unlike `detect_ruled_tables`, this runs *before* projection and ignores text +/// content entirely — its only job is to find the bbox of every H/V grid +/// component so the XY-cut layout pass can treat those regions as obstacles +/// and avoid slicing tables column-wise (the failure mode that produces +/// column-major reading order). Empty-cell-fraction +/// and other quality filters are deliberately skipped here: we want the bbox +/// even of sparse forms or partially-filled grids, because the obstacle +/// machinery only cares about geometry. +pub fn detect_table_rects( + graphics: &[GraphicPrimitive], + page_width: f32, + page_height: f32, +) -> Vec { + let (hs, vs) = extract_h_v_segments(graphics); + let hs = cluster_h_segments(hs); + let vs = cluster_v_segments(vs); + if hs.len() < 2 || vs.len() < 2 { + return Vec::new(); + } + let components = find_grid_components(&hs, &vs); + let mut out = Vec::new(); + for (h_idx, v_idx) in components { + let ys: Vec = h_idx.iter().map(|&i| hs[i].y).collect(); + let xs: Vec = v_idx.iter().map(|&i| vs[i].x).collect(); + let y_min = ys.iter().copied().fold(f32::INFINITY, f32::min); + let y_max = ys.iter().copied().fold(f32::NEG_INFINITY, f32::max); + let x_min = xs.iter().copied().fold(f32::INFINITY, f32::min); + let x_max = xs.iter().copied().fold(f32::NEG_INFINITY, f32::max); + let w = x_max - x_min; + let h = y_max - y_min; + if w < 5.0 || h < 5.0 { + continue; + } + // Skip whole-page borders — same rationale as `TABLE_MAX_PAGE_COVERAGE` + // in the post-projection detector. + if page_width > 0.0 + && page_height > 0.0 + && w / page_width >= TABLE_MAX_PAGE_COVERAGE + && h / page_height >= TABLE_MAX_PAGE_COVERAGE + { + continue; + } + out.push(Rect { + x: x_min, + y: y_min, + width: w, + height: h, + }); + } + out +} + +/// Detect ruled-grid tables on a page from its vector graphics. Returns runs +/// in document order (sorted by `start`). +fn detect_ruled_tables_impl( + lines: &[ProjectedLine], + graphics: &[GraphicPrimitive], + page_width: f32, + page_height: f32, + pass: RuledPass, +) -> Vec<(TableRun, Vec)> { + let (hs, vs) = extract_h_v_segments(graphics); + let hs = cluster_h_segments(hs); + let vs = cluster_v_segments(vs); + if hs.len() < 2 || vs.len() < 2 { + return Vec::new(); + } + let components = find_grid_components(&hs, &vs); + let mut out = Vec::new(); + for (h_idx, v_idx) in components { + if let Some(run) = build_ruled_table( + &hs, + &vs, + &h_idx, + &v_idx, + lines, + page_width, + page_height, + pass, + ) { + out.push(run); + } + } + out.sort_by_key(|(r, _)| r.start); + out +} + +/// Per-region ruled-table detection. Drops the consumed-line bookkeeping the +/// global pass needs. +pub(super) fn detect_ruled_tables( + lines: &[ProjectedLine], + graphics: &[GraphicPrimitive], + page_width: f32, + page_height: f32, +) -> Vec { + detect_ruled_tables_impl( + lines, + graphics, + page_width, + page_height, + RuledPass::PerRegion, + ) + .into_iter() + .map(|(r, _)| r) + .collect() +} + +/// Page-level ruled-table detection over *all* lines. A table whose rows +/// scatter across several xy_cut leaves never has its full text in any single +/// leaf, so per-region detection rejects it as mostly-empty; running once +/// globally reassembles it. Returns each run with the set of line indices it +/// consumed so the caller can pull them out of the region pipeline. +pub(super) fn detect_ruled_tables_global( + lines: &[ProjectedLine], + graphics: &[GraphicPrimitive], + page_width: f32, + page_height: f32, +) -> Vec<(TableRun, Vec)> { + detect_ruled_tables_impl(lines, graphics, page_width, page_height, RuledPass::Global) +} + +/// Count filled (non-empty) cells in a TableRun. GridFallback returns 0 so +/// it never beats a real Table in density comparisons. +fn run_filled_cells(run: &TableRun) -> usize { + match &run.block { + Block::Table { header, rows } => { + let header_filled = header + .as_ref() + .map(|h| h.iter().filter(|c| !c.trim().is_empty()).count()) + .unwrap_or(0); + let body_filled: usize = rows + .iter() + .flat_map(|r| r.iter()) + .filter(|c| !c.trim().is_empty()) + .count(); + header_filled + body_filled + } + _ => 0, + } +} + +/// Merge ruled-grid runs with borderless runs into a single sorted list. When +/// ranges overlap the ruled run normally wins (path-based geometry is a +/// stronger signal than text-alignment heuristics), with two exceptions: +/// 1. A single-column ruled run yields to a multi-column borderless run +/// covering the same range (vertical separators may be implicit). +/// 2. A sparse ruled run yields to a denser borderless run — decorative +/// vector boxes around titles / callout banners produce ruled "tables" +/// with few filled cells; when a borderless detector finds a much denser +/// real table in the same region, prefer it. +pub(super) fn merge_table_runs( + mut ruled: Vec, + borderless: Vec, +) -> Vec { + let mut kept: Vec = Vec::with_capacity(ruled.len()); + for r in ruled.drain(..) { + let is_one_col = matches!(&r.block, Block::Table { rows, .. } if rows.first().map(|row| row.len()).unwrap_or(0) <= 1); + if is_one_col { + let beaten = borderless.iter().any(|b| { + let overlaps = !(b.end <= r.start || b.start >= r.end); + if !overlaps { + return false; + } + matches!(&b.block, Block::Table { rows, .. } if rows.first().map(|row| row.len()).unwrap_or(0) >= 2) + }); + if beaten { + continue; + } + } + // Density check: if a borderless run overlaps and carries + // substantially more filled cells, the ruled run is most likely + // a decorative grid (page chrome, title banner) wrapping the real + // table the borderless detector already found. + let ruled_density = run_filled_cells(&r); + let beaten_by_density = borderless.iter().any(|b| { + let overlaps = !(b.end <= r.start || b.start >= r.end); + if !overlaps { + return false; + } + run_filled_cells(b) >= ruled_density * 2 + 4 + }); + if beaten_by_density { + continue; + } + kept.push(r); + } + for b in borderless { + let overlaps = kept.iter().any(|r| !(b.end <= r.start || b.start >= r.end)); + if !overlaps { + kept.push(b); + } + } + kept.sort_by_key(|r| r.start); + kept +} + +/// Escape `|` and `\n` inside a markdown table cell so the pipe-table grammar +/// stays valid. Newlines should be impossible inside a single cell (we built +/// cells from spans on the same projected line) but guard anyway. +pub(super) fn escape_table_cell(s: &str) -> String { + s.replace('\\', "\\\\") + .replace('|', "\\|") + .replace('\n', " ") +} + +#[cfg(test)] +mod tests { + use super::super::test_helpers::{line, line_with_spans, rect_borders, stroke}; + use super::*; + + #[test] + fn split_cells_splits_on_wide_gaps() { + let l = line_with_spans(&[("A", 50.0), ("B", 150.0), ("C", 250.0)], 100.0, 10.0); + let cells = split_cells(&l); + assert_eq!(cells.len(), 3); + assert_eq!(cells[0].text, "A"); + assert_eq!(cells[1].text, "B"); + assert_eq!(cells[2].text, "C"); + } + + #[test] + fn recover_merged_cell_splits_off_by_one() { + // Mimics the page-6 case: row 0 establishes 3 tracks at 50/150/250. + // Row 1's projection merges "MEMORYBANK" + "5.00" into one span at + // x=50 width=110, so split_cells yields 2 cells while the table + // expects 3. Recovery must split on whitespace at the missing track. + let row = vec![ + TableCell { + start_x: 50.0, + end_x: 160.0, + text: "MEMORYBANK 5.00".into(), + bold: false, + }, + TableCell { + start_x: 250.0, + end_x: 280.0, + text: "4.77".into(), + bold: false, + }, + ]; + let tracks = vec![50.0, 150.0, 250.0]; + let out = recover_merged_cell(row, &tracks).expect("recovery should succeed"); + assert_eq!(out.len(), 3); + assert_eq!(out[0].text, "MEMORYBANK"); + assert_eq!(out[1].text, "5.00"); + assert_eq!(out[2].text, "4.77"); + } + + #[test] + fn recover_merged_cell_splits_off_by_two() { + // Three merged tokens in one cell: "MEMORYBANK 13.18 10.03" straddles + // tracks at 50/150/250 and the row has only 2 cells, off by 2. + let row = vec![ + TableCell { + start_x: 50.0, + end_x: 260.0, + text: "MEMORYBANK 13.18 10.03".into(), + bold: false, + }, + TableCell { + start_x: 350.0, + end_x: 380.0, + text: "7.61".into(), + bold: false, + }, + ]; + let tracks = vec![50.0, 150.0, 250.0, 350.0]; + let out = recover_merged_cell(row, &tracks).expect("recovery should succeed"); + assert_eq!(out.len(), 4); + assert_eq!(out[0].text, "MEMORYBANK"); + assert_eq!(out[1].text, "13.18"); + assert_eq!(out[2].text, "10.03"); + assert_eq!(out[3].text, "7.61"); + } + + #[test] + fn recover_merged_cell_bails_without_enough_whitespace() { + // A cell that straddles two tracks but has no internal whitespace + // (e.g. a hyphenated token) can't be safely split — return None. + let row = vec![TableCell { + start_x: 50.0, + end_x: 200.0, + text: "ABC-DEF-GHI".into(), + bold: false, + }]; + let tracks = vec![50.0, 150.0]; + assert!(recover_merged_cell(row, &tracks).is_none()); + } + + #[test] + fn split_cells_keeps_close_spans_together() { + // Two spans 2pt apart at 10pt font (gap < font_size) → same cell. + let l = line_with_spans(&[("Hello", 50.0), ("world", 80.0)], 100.0, 10.0); + let cells = split_cells(&l); + assert_eq!(cells.len(), 1); + assert_eq!(cells[0].text, "Hello world"); + } + + #[test] + fn absorbs_partial_header_line_above_body() { + // A header line with only two track-aligned cells sits above a clean + // 3-column body. It can't start the table on its own (fewer than + // TABLE_MIN_COLUMNS cells) but should be walked back in as the header. + let lines = vec![ + line_with_spans(&[("Name", 50.0), ("Scores", 150.0)], 100.0, 10.0), + line_with_spans(&[("A", 50.0), ("1", 150.0), ("2", 250.0)], 115.0, 10.0), + line_with_spans(&[("B", 50.0), ("3", 150.0), ("4", 250.0)], 130.0, 10.0), + line_with_spans(&[("C", 50.0), ("5", 150.0), ("6", 250.0)], 145.0, 10.0), + ]; + let runs = detect_tables(&lines); + assert_eq!(runs.len(), 1); + let run = &runs[0]; + assert_eq!(run.start, 0, "header line should be absorbed into the run"); + assert_eq!(run.end, 4); + match &run.block { + Block::Table { header, rows } => { + let header = header.as_ref().expect("header should be present"); + assert_eq!( + header, + &vec!["Name".to_string(), "Scores".to_string(), String::new()] + ); + // All three body rows survive — the header came from above, so + // rows[0] is not consumed as a header. + assert_eq!(rows.len(), 3); + } + other => panic!("expected Block::Table, got {other:?}"), + } + } + + #[test] + fn does_not_absorb_single_cell_title_above_body() { + // A one-cell title/caption above a table is NOT a header row and must + // not be absorbed. + let lines = vec![ + line_with_spans(&[("Results", 50.0)], 100.0, 10.0), + line_with_spans(&[("A", 50.0), ("1", 150.0), ("2", 250.0)], 115.0, 10.0), + line_with_spans(&[("B", 50.0), ("3", 150.0), ("4", 250.0)], 130.0, 10.0), + line_with_spans(&[("C", 50.0), ("5", 150.0), ("6", 250.0)], 145.0, 10.0), + ]; + let runs = detect_tables(&lines); + assert_eq!(runs.len(), 1); + assert_eq!( + runs[0].start, 1, + "single-cell title must stay out of the run" + ); + } + + #[test] + fn rejects_table_when_row_count_too_low() { + let lines = vec![line_with_spans( + &[("A", 50.0), ("B", 150.0), ("C", 250.0)], + 100.0, + 10.0, + )]; + let runs = detect_tables(&lines); + assert!(runs.is_empty()); + } + + #[test] + fn rejects_table_when_column_count_too_low() { + let lines = vec![ + line_with_spans(&[("A", 50.0), ("B", 200.0)], 100.0, 10.0), + line_with_spans(&[("C", 50.0), ("D", 200.0)], 115.0, 10.0), + ]; + let runs = detect_tables(&lines); + assert!(runs.is_empty()); + } + + #[test] + fn escapes_pipe_inside_cell() { + assert_eq!(escape_table_cell("a|b"), "a\\|b"); + } + + #[test] + fn ruled_table_2x2_detected() { + // 2 rows × 2 cols grid: 3 H lines (y=100,140,180), 3 V lines (x=50,150,250) + // Cell text dropped in the centroid of each cell. + let mut graphics = Vec::new(); + for y in [100.0_f32, 140.0, 180.0] { + graphics.push(stroke(50.0, y, 250.0, y, 0.5)); + } + for x in [50.0_f32, 150.0, 250.0] { + graphics.push(stroke(x, 100.0, x, 180.0, 0.5)); + } + + // Text lines: one per cell, centered. + let lines = vec![ + line("a", 90.0, 115.0, 10.0, 10.0), // row 0, col 0 + line("b", 190.0, 115.0, 10.0, 10.0), // row 0, col 1 + line("c", 90.0, 155.0, 10.0, 10.0), // row 1, col 0 + line("d", 190.0, 155.0, 10.0, 10.0), // row 1, col 1 + ]; + + let runs = detect_ruled_tables(&lines, &graphics, 612.0, 792.0); + assert_eq!(runs.len(), 1, "expected 1 ruled table, got {runs:?}"); + match &runs[0].block { + Block::Table { header, rows } => { + assert!(header.is_none(), "no bold first row → no header"); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0], vec!["a", "b"]); + assert_eq!(rows[1], vec!["c", "d"]); + } + other => panic!("expected Block::Table, got {other:?}"), + } + } + + #[test] + fn ruled_table_rect_borders_detected() { + // Same 2×2 table but drawn as 4 individual cell rects (each cell is a + // stroked rectangle). Each rect contributes 4 strokes via + // extract_h_v_segments. + let mut graphics = Vec::new(); + graphics.extend(rect_borders(50.0, 100.0, 100.0, 40.0)); // r0 c0 + graphics.extend(rect_borders(150.0, 100.0, 100.0, 40.0)); // r0 c1 + graphics.extend(rect_borders(50.0, 140.0, 100.0, 40.0)); // r1 c0 + graphics.extend(rect_borders(150.0, 140.0, 100.0, 40.0)); // r1 c1 + + let lines = vec![ + line("a", 90.0, 115.0, 10.0, 10.0), + line("b", 190.0, 115.0, 10.0, 10.0), + line("c", 90.0, 155.0, 10.0, 10.0), + line("d", 190.0, 155.0, 10.0, 10.0), + ]; + let runs = detect_ruled_tables(&lines, &graphics, 612.0, 792.0); + assert_eq!(runs.len(), 1); + } + + #[test] + fn ruled_table_page_border_rejected() { + // Single big rect covering ~the whole page → should NOT be treated as a + // table even though it has H+V lines on all four sides. + let graphics = rect_borders(10.0, 10.0, 590.0, 770.0); + let lines = vec![line("body text", 50.0, 400.0, 10.0, 10.0)]; + let runs = detect_ruled_tables(&lines, &graphics, 612.0, 792.0); + assert!( + runs.is_empty(), + "page-border rect should not become a table, got {runs:?}" + ); + } + + #[test] + fn ruled_table_mostly_empty_rejected() { + // 3×3 grid with text in only one cell — empty fraction 8/9 ≈ 89% >> 30%. + let mut graphics = Vec::new(); + for y in [100.0_f32, 130.0, 160.0, 190.0] { + graphics.push(stroke(50.0, y, 350.0, y, 0.5)); + } + for x in [50.0_f32, 150.0, 250.0, 350.0] { + graphics.push(stroke(x, 100.0, x, 190.0, 0.5)); + } + let lines = vec![line("only", 90.0, 115.0, 10.0, 10.0)]; + let runs = detect_ruled_tables(&lines, &graphics, 612.0, 792.0); + assert!(runs.is_empty()); + } + + #[test] + fn ruled_table_first_row_bold_becomes_header() { + // 2×2 with first row text marked all_bold → header promotion. + let mut graphics = Vec::new(); + for y in [100.0_f32, 140.0, 180.0] { + graphics.push(stroke(50.0, y, 250.0, y, 0.5)); + } + for x in [50.0_f32, 150.0, 250.0] { + graphics.push(stroke(x, 100.0, x, 180.0, 0.5)); + } + let mut a = line("Name", 90.0, 115.0, 10.0, 10.0); + let mut b = line("Score", 190.0, 115.0, 10.0, 10.0); + a.all_bold = true; + b.all_bold = true; + let lines = vec![ + a, + b, + line("alice", 90.0, 155.0, 10.0, 10.0), + line("99", 190.0, 155.0, 10.0, 10.0), + ]; + let runs = detect_ruled_tables(&lines, &graphics, 612.0, 792.0); + assert_eq!(runs.len(), 1); + match &runs[0].block { + Block::Table { header, rows } => { + assert_eq!( + header.as_deref(), + Some(&["Name".into(), "Score".into()][..]) + ); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0], vec!["alice", "99"]); + } + other => panic!("expected Block::Table, got {other:?}"), + } + } + + #[test] + fn merge_prefers_ruled_when_overlapping() { + let ruled = vec![TableRun { + start: 5, + end: 10, + body_start: 5, + block: Block::Table { + header: None, + rows: vec![vec!["ruled".into()]], + }, + }]; + let borderless = vec![TableRun { + start: 6, + end: 11, + body_start: 6, + block: Block::GridFallback { + lines: vec!["bl".into()], + }, + }]; + let merged = merge_table_runs(ruled, borderless); + assert_eq!(merged.len(), 1); + assert!(matches!(&merged[0].block, Block::Table { .. })); + } + + // ── merge_consecutive_table_runs ───────────────────────────────────── + // + // Lines fixtures used by these tests are synthetic 3-cell and 4-cell + // rows at known x positions so the re-derived tracks match the runs we + // construct manually. + + fn three_col_line(label: &str, y: f32) -> ProjectedLine { + line_with_spans(&[(label, 50.0), (label, 150.0), (label, 250.0)], y, 10.0) + } + + fn four_col_line(label: &str, y: f32) -> ProjectedLine { + line_with_spans( + &[ + (label, 50.0), + (label, 150.0), + (label, 250.0), + (label, 350.0), + ], + y, + 10.0, + ) + } + + // A row whose three cells sit at tracks 2..4 of a 4-col layout + // (subset of the 4-col tracks: missing leftmost column at x=50). + fn three_col_subset_line(label: &str, y: f32) -> ProjectedLine { + line_with_spans(&[(label, 150.0), (label, 250.0), (label, 350.0)], y, 10.0) + } + + #[test] + fn merge_same_column_count_concatenates_rows() { + let lines = vec![ + three_col_line("h1", 10.0), + three_col_line("h2", 25.0), + three_col_line("b1", 40.0), + three_col_line("b2", 55.0), + three_col_line("b3", 70.0), + ]; + let a = TableRun { + start: 0, + end: 2, + body_start: 0, + block: Block::Table { + header: Some(vec!["A".into(), "B".into(), "C".into()]), + rows: vec![vec!["1".into(), "2".into(), "3".into()]], + }, + }; + let b = TableRun { + start: 2, + end: 5, + body_start: 2, + block: Block::Table { + header: None, + rows: vec![ + vec!["x".into(), "y".into(), "z".into()], + vec!["p".into(), "q".into(), "r".into()], + vec!["m".into(), "n".into(), "o".into()], + ], + }, + }; + let merged = merge_consecutive_table_runs(vec![a, b], &lines); + assert_eq!(merged.len(), 1, "expected single merged run"); + match &merged[0].block { + Block::Table { header, rows } => { + assert_eq!(header.as_deref().map(|h| h.len()), Some(3)); + assert_eq!(rows.len(), 4); + assert_eq!(rows[0], vec!["1", "2", "3"]); + assert_eq!(rows[3], vec!["m", "n", "o"]); + } + other => panic!("expected Block::Table, got {other:?}"), + } + } + + #[test] + fn merge_subset_columns_folds_into_header() { + // A is a 2-row 3-col "header" whose tracks land on columns 2..4 of + // B's 4-col body (i.e. the row-label column is missing in A). After + // merge: one 4-col table whose header has empty col 0 and B's body + // rows. + let lines = vec![ + three_col_subset_line("2011", 10.0), + three_col_subset_line("(pct)", 25.0), + four_col_line("body", 40.0), + four_col_line("body", 55.0), + four_col_line("body", 70.0), + ]; + let a = TableRun { + start: 0, + end: 2, + body_start: 0, + block: Block::Table { + header: None, + rows: vec![ + vec!["2011".into(), "2010".into(), "Avg".into()], + vec!["(pct)".into(), "(pct)".into(), "(pct)".into()], + ], + }, + }; + let b = TableRun { + start: 2, + end: 5, + body_start: 2, + block: Block::Table { + header: None, + rows: vec![ + vec!["Q3".into(), "10".into(), "20".into(), "30".into()], + vec!["Q4".into(), "11".into(), "21".into(), "31".into()], + vec!["YR".into(), "12".into(), "22".into(), "32".into()], + ], + }, + }; + let merged = merge_consecutive_table_runs(vec![a, b], &lines); + assert_eq!(merged.len(), 1); + match &merged[0].block { + Block::Table { header, rows } => { + let h = header.as_deref().expect("expected header"); + assert_eq!(h.len(), 4); + assert_eq!(h[0], ""); + // Adjacent identical pieces are deduped per column. + assert_eq!(h[1], "2011 (pct)"); + assert_eq!(h[2], "2010 (pct)"); + assert_eq!(h[3], "Avg (pct)"); + assert_eq!(rows.len(), 3); + assert_eq!(rows[0], vec!["Q3", "10", "20", "30"]); + } + other => panic!("expected Block::Table, got {other:?}"), + } + } + + #[test] + fn merge_skips_distant_runs() { + // Same shape as the same-column test but B is far below A. + let lines = vec![ + three_col_line("h1", 10.0), + three_col_line("h2", 25.0), + three_col_line("b1", 200.0), // ~16× line height below + three_col_line("b2", 215.0), + ]; + let a = TableRun { + start: 0, + end: 2, + body_start: 0, + block: Block::Table { + header: Some(vec!["A".into(), "B".into(), "C".into()]), + rows: vec![vec!["1".into(), "2".into(), "3".into()]], + }, + }; + let b = TableRun { + start: 2, + end: 4, + body_start: 2, + block: Block::Table { + header: None, + rows: vec![ + vec!["x".into(), "y".into(), "z".into()], + vec!["p".into(), "q".into(), "r".into()], + ], + }, + }; + let merged = merge_consecutive_table_runs(vec![a, b], &lines); + assert_eq!(merged.len(), 2, "distant runs should not merge"); + } + + #[test] + fn merge_skips_large_prior_run() { + // A has 5 body rows — large enough that it's a real standalone + // table, not a header to fold into B. + let lines: Vec = (0..10) + .map(|i| three_col_subset_line("x", 10.0 + i as f32 * 15.0)) + .chain((0..3).map(|i| four_col_line("y", 160.0 + i as f32 * 15.0))) + .collect(); + let a = TableRun { + start: 0, + end: 10, + body_start: 0, + block: Block::Table { + header: None, + rows: (0..10) + .map(|_| vec!["a".into(), "b".into(), "c".into()]) + .collect(), + }, + }; + let b = TableRun { + start: 10, + end: 13, + body_start: 10, + block: Block::Table { + header: None, + rows: (0..3) + .map(|_| vec!["1".into(), "2".into(), "3".into(), "4".into()]) + .collect(), + }, + }; + let merged = merge_consecutive_table_runs(vec![a, b], &lines); + assert_eq!(merged.len(), 2, "large prior run should not be absorbed"); + } + + #[test] + fn merge_skips_two_col_diff() { + // A is 3-col, B is 5-col — too large a column-count delta to be a + // header-vs-body relationship. + let lines = vec![ + three_col_subset_line("x", 10.0), + three_col_subset_line("y", 25.0), + line_with_spans( + &[ + ("a", 50.0), + ("b", 150.0), + ("c", 250.0), + ("d", 350.0), + ("e", 450.0), + ], + 40.0, + 10.0, + ), + line_with_spans( + &[ + ("a", 50.0), + ("b", 150.0), + ("c", 250.0), + ("d", 350.0), + ("e", 450.0), + ], + 55.0, + 10.0, + ), + ]; + let a = TableRun { + start: 0, + end: 2, + body_start: 0, + block: Block::Table { + header: None, + rows: vec![ + vec!["x".into(), "y".into(), "z".into()], + vec!["x".into(), "y".into(), "z".into()], + ], + }, + }; + let b = TableRun { + start: 2, + end: 4, + body_start: 2, + block: Block::Table { + header: None, + rows: vec![ + vec!["1".into(), "2".into(), "3".into(), "4".into(), "5".into()], + vec!["1".into(), "2".into(), "3".into(), "4".into(), "5".into()], + ], + }, + }; + let merged = merge_consecutive_table_runs(vec![a, b], &lines); + assert_eq!(merged.len(), 2, "2-col difference should not merge"); + } + + #[test] + fn merge_grid_fallback_left_alone() { + let lines = vec![ + three_col_line("a", 10.0), + three_col_line("b", 25.0), + three_col_line("c", 40.0), + ]; + let a = TableRun { + start: 0, + end: 2, + body_start: 0, + block: Block::Table { + header: None, + rows: vec![ + vec!["a".into(), "b".into(), "c".into()], + vec!["a".into(), "b".into(), "c".into()], + ], + }, + }; + let b = TableRun { + start: 2, + end: 3, + body_start: 2, + block: Block::GridFallback { + lines: vec!["fallback".into()], + }, + }; + let merged = merge_consecutive_table_runs(vec![a, b], &lines); + assert_eq!(merged.len(), 2, "grid fallback should not be merged"); + } + + #[test] + fn merge_rejects_long_prose_interstitial() { + // A multi-cell or long prose line between runs must not be silently + // dropped by a merge. + let lines = vec![ + three_col_line("h", 10.0), + three_col_line("h", 25.0), + line_with_spans( + &[ + ("This", 50.0), + ("is", 150.0), + ("real", 250.0), + ("content", 350.0), + ], + 40.0, + 10.0, + ), + three_col_line("b", 55.0), + three_col_line("b", 70.0), + ]; + let a = TableRun { + start: 0, + end: 2, + body_start: 0, + block: Block::Table { + header: Some(vec!["A".into(), "B".into(), "C".into()]), + rows: vec![vec!["1".into(), "2".into(), "3".into()]], + }, + }; + let b = TableRun { + start: 3, + end: 5, + body_start: 3, + block: Block::Table { + header: None, + rows: vec![ + vec!["x".into(), "y".into(), "z".into()], + vec!["p".into(), "q".into(), "r".into()], + ], + }, + }; + let merged = merge_consecutive_table_runs(vec![a, b], &lines); + assert_eq!(merged.len(), 2, "multi-cell interstitial should not merge"); + } + + #[test] + fn merge_absorbs_single_cell_interstitial_as_body_row() { + // Apple-earnings / NASS shape: 4-col header rows + a single-cell + // category divider ("Topsoil") + 5-col body. Divider must be + // preserved as a body row in the merged table. + let lines = vec![ + three_col_subset_line("h", 10.0), + three_col_subset_line("h", 25.0), + line_with_spans(&[("Topsoil", 50.0)], 40.0, 10.0), + four_col_line("body", 55.0), + four_col_line("body", 70.0), + four_col_line("body", 85.0), + ]; + let a = TableRun { + start: 0, + end: 2, + body_start: 0, + block: Block::Table { + header: None, + rows: vec![ + vec!["2011".into(), "2010".into(), "Avg".into()], + vec!["(pct)".into(), "(pct)".into(), "(pct)".into()], + ], + }, + }; + let b = TableRun { + start: 3, + end: 6, + body_start: 3, + block: Block::Table { + header: None, + rows: vec![ + vec!["Q3".into(), "10".into(), "20".into(), "30".into()], + vec!["Q4".into(), "11".into(), "21".into(), "31".into()], + vec!["YR".into(), "12".into(), "22".into(), "32".into()], + ], + }, + }; + let merged = merge_consecutive_table_runs(vec![a, b], &lines); + assert_eq!(merged.len(), 1); + match &merged[0].block { + Block::Table { header, rows } => { + assert!(header.is_some()); + assert_eq!(rows.len(), 4, "interstitial + 3 body rows"); + assert_eq!(rows[0][0], "Topsoil"); + assert_eq!(rows[1], vec!["Q3", "10", "20", "30"]); + } + other => panic!("expected Block::Table, got {other:?}"), + } + } + + #[test] + fn is_bullet_only_recognizes_lettered_markers() { + // Digit and roman markers were already handled; lettered ordered-list + // markers must be too, so a nested a./b./c. list is not mistaken for a + // description-list table label. + for m in ["a.", "b.", "c.", "z.", "A.", "a)", "B)", "(a)", "(B)"] { + assert!(is_bullet_only(m), "{m:?} should be a bullet/list marker"); + assert!( + !is_label_like(m), + "{m:?} should not be treated as a table label" + ); + } + // Genuine short labels stay labels. + for l in ["Name:", "Term", "Rate", "Fee.", "AB.", "ab."] { + assert!(!is_bullet_only(l), "{l:?} should not be a bullet marker"); + } + } + + #[test] + fn lettered_list_is_not_a_description_table() { + // Two-level lettered list: short marker column (col 0) beside wrapped + // body text (col 1). Regression for the nested-list → pipe-table bug. + let lines = vec![ + line_with_spans( + &[ + ("a.", 80.0), + ("The Committee may retain outside advisors.", 120.0), + ], + 100.0, + 10.0, + ), + line_with_spans( + &[ + ("b.", 80.0), + ("The Committee shall have sole authority.", 120.0), + ], + 88.0, + 10.0, + ), + line_with_spans( + &[ + ("c.", 80.0), + ("In retaining advice the Committee considers:", 120.0), + ], + 76.0, + 10.0, + ), + ]; + assert!( + try_detect_description_list(&lines, 0).is_none(), + "lettered list must not be detected as a description-list table" + ); + } +} diff --git a/crates/liteparse/src/markdown_layout/test_helpers.rs b/crates/liteparse/src/markdown_layout/test_helpers.rs new file mode 100644 index 0000000..2fbe925 --- /dev/null +++ b/crates/liteparse/src/markdown_layout/test_helpers.rs @@ -0,0 +1,206 @@ +//! Shared test fixtures for `markdown_layout` submodule tests. + +use crate::types::{Anchor, GraphicPrimitive, ParsedPage, ProjectedLine, Rect, TextItem}; + +pub(crate) fn line(text: &str, x: f32, y: f32, h: f32, size: f32) -> ProjectedLine { + ProjectedLine { + text: text.into(), + bbox: Rect { + x, + y, + width: text.chars().count() as f32 * (size * 0.5), + height: h, + }, + anchor: Anchor::Left, + indent_x: x, + dominant_font_size: size, + font_size_is_estimated: false, + heading_font_size: None, + dominant_font_name: Some("Arial".into()), + all_bold: false, + all_italic: false, + all_mono: false, + all_strike: false, + spans: vec![TextItem::default()], + region_path: Vec::new(), + mcid: None, + in_figure: false, + } +} + +pub(crate) fn page(lines: Vec) -> ParsedPage { + ParsedPage { + page_number: 1, + page_width: 612.0, + page_height: 792.0, + text: String::new(), + markdown: String::new(), + text_items: vec![], + projected_lines: lines, + regions: crate::types::Region::default(), + graphics: vec![], + figures: vec![], + struct_nodes: vec![], + image_refs: vec![], + } +} + +pub(crate) fn mono_line(text: &str, y: f32) -> ProjectedLine { + let mut l = line(text, 50.0, y, 10.0, 10.0); + l.all_mono = true; + l +} + +/// Build a line whose spans are placed at explicit x positions — used to +/// drive the table detector, which relies on per-span x for cell splitting. +pub(crate) fn line_with_spans(cells: &[(&str, f32)], y: f32, size: f32) -> ProjectedLine { + let spans: Vec = cells + .iter() + .map(|(t, x)| TextItem { + text: (*t).into(), + x: *x, + y, + width: t.chars().count() as f32 * size * 0.5, + height: size, + font_size: Some(size), + font_name: Some("Arial".into()), + ..Default::default() + }) + .collect(); + let min_x = spans.iter().map(|s| s.x).fold(f32::INFINITY, f32::min); + let max_x = spans + .iter() + .map(|s| s.x + s.width) + .fold(f32::NEG_INFINITY, f32::max); + ProjectedLine { + text: cells + .iter() + .map(|(t, _)| *t) + .collect::>() + .join(" "), + bbox: Rect { + x: min_x, + y, + width: (max_x - min_x).max(0.0), + height: size, + }, + anchor: Anchor::Left, + indent_x: min_x, + dominant_font_size: size, + font_size_is_estimated: false, + heading_font_size: None, + dominant_font_name: Some("Arial".into()), + all_bold: false, + all_italic: false, + all_mono: false, + all_strike: false, + spans, + region_path: Vec::new(), + mcid: None, + in_figure: false, + } +} + +/// Build a line whose spans carry explicit per-span font metadata. Lets us +/// exercise the mid-line emphasis pipeline without needing real PDF input. +pub(crate) fn styled_line(spans: &[(&str, f32, Option<&str>)], y: f32, size: f32) -> ProjectedLine { + let items: Vec = spans + .iter() + .map(|(t, x, font)| TextItem { + text: (*t).into(), + x: *x, + y, + width: t.chars().count() as f32 * size * 0.5, + height: size, + font_size: Some(size), + font_name: font.map(String::from), + ..Default::default() + }) + .collect(); + let joined: String = spans + .iter() + .map(|(t, _, _)| *t) + .collect::>() + .join(" "); + let min_x = items.iter().map(|s| s.x).fold(f32::INFINITY, f32::min); + let max_x = items + .iter() + .map(|s| s.x + s.width) + .fold(f32::NEG_INFINITY, f32::max); + ProjectedLine { + text: joined, + bbox: Rect { + x: min_x, + y, + width: (max_x - min_x).max(0.0), + height: size, + }, + anchor: Anchor::Left, + indent_x: min_x, + dominant_font_size: size, + font_size_is_estimated: false, + heading_font_size: None, + dominant_font_name: Some("Arial".into()), + all_bold: false, + all_italic: false, + all_mono: false, + all_strike: false, + spans: items, + region_path: Vec::new(), + mcid: None, + in_figure: false, + } +} + +pub(crate) fn stroke(x1: f32, y1: f32, x2: f32, y2: f32, width: f32) -> GraphicPrimitive { + GraphicPrimitive::Stroke { + x1, + y1, + x2, + y2, + width, + color: None, + } +} + +pub(crate) fn page_with_graphics( + lines: Vec, + graphics: Vec, +) -> ParsedPage { + let mut p = page(lines); + p.graphics = graphics; + p +} + +pub(crate) fn header_footer_page(n: usize, header: &str, footer: &str, body: &str) -> ParsedPage { + // Page height 100 → header band ≤12pt, footer band ≥88pt. + let lines = vec![ + line(header, 50.0, 5.0, 8.0, 8.0), + line(body, 50.0, 50.0, 10.0, 10.0), + line(footer, 50.0, 92.0, 6.0, 6.0), + ]; + ParsedPage { + page_number: n, + page_width: 612.0, + page_height: 100.0, + text: String::new(), + markdown: String::new(), + text_items: vec![], + projected_lines: lines, + regions: crate::types::Region::default(), + graphics: vec![], + figures: vec![], + struct_nodes: vec![], + image_refs: vec![], + } +} + +/// Helper: build the four borders of a rectangle as four strokes. +pub(crate) fn rect_borders(x: f32, y: f32, w: f32, h: f32) -> Vec { + vec![ + stroke(x, y, x + w, y, 0.5), // top + stroke(x, y + h, x + w, y + h, 0.5), // bottom + stroke(x, y, x, y + h, 0.5), // left + stroke(x + w, y, x + w, y + h, 0.5), // right + ] +} diff --git a/crates/liteparse/src/ocr/http_simple.rs b/crates/liteparse/src/ocr/http_simple.rs new file mode 100644 index 0000000..5e8bc1e --- /dev/null +++ b/crates/liteparse/src/ocr/http_simple.rs @@ -0,0 +1,526 @@ +use std::{io::Cursor, pin::Pin, time::Duration}; + +use image::ImageFormat; +use reqwest::{ + Client, + multipart::{Form, Part}, +}; +use serde::{Deserialize, Serialize}; + +use crate::ocr::{OcrEngine, OcrOptions, OcrResult}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct HttpOcrResponseItem { + text: String, + bbox: [f32; 4], + confidence: f32, + /// Optional 4-point polygon [[x,y]×4] of the (possibly rotated) detection, + /// ordered top-left → top-right → bottom-right → bottom-left in the + /// glyphs' upright reading frame. + #[serde(default)] + polygon: Option<[[f32; 2]; 4]>, +} + +impl HttpOcrResponseItem { + fn into_ocr_result(self) -> OcrResult { + OcrResult { + text: self.text, + bbox: self.bbox, + confidence: self.confidence, + polygon: self.polygon, + } + } +} + +/// A single detection from an OCR worker, which emits +/// EasyOCR/PaddleOCR-style positional tuples: `[polygon, text, confidence]`, +/// where `polygon` is a list of `[x, y]` points (4 for both engines) ordered +/// top-left → top-right → bottom-right → bottom-left. +#[derive(Debug, Deserialize)] +struct ProdOcrItem(Vec<[f32; 2]>, String, f32); + +impl ProdOcrItem { + fn into_ocr_result(self) -> OcrResult { + let ProdOcrItem(poly, text, confidence) = self; + // Axis-aligned bbox from the polygon's min/max extents. + let mut min_x = f32::INFINITY; + let mut min_y = f32::INFINITY; + let mut max_x = f32::NEG_INFINITY; + let mut max_y = f32::NEG_INFINITY; + for [x, y] in &poly { + min_x = min_x.min(*x); + min_y = min_y.min(*y); + max_x = max_x.max(*x); + max_y = max_y.max(*y); + } + // Forward the raw polygon only when it's exactly 4 points, so the + // projector can recover rotation for sideways text. + let polygon = match poly.as_slice() { + [a, b, c, d] => Some([*a, *b, *c, *d]), + _ => None, + }; + OcrResult { + text, + bbox: [min_x, min_y, max_x, max_y], + confidence, + polygon, + } + } +} + +/// Accepts either the LiteParse standard response or the prod OCR +/// worker response. Untagged: serde tries `Standard` first (keyed on +/// `results` with object items), then falls back to `Prod` (keyed on `result` +/// with positional-tuple items). +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum HttpOcrResponse { + Standard { results: Vec }, + Prod { result: Vec }, +} + +impl HttpOcrResponse { + fn into_results(self) -> Vec { + match self { + HttpOcrResponse::Standard { results } => { + results.into_iter().map(|i| i.into_ocr_result()).collect() + } + HttpOcrResponse::Prod { result } => { + result.into_iter().map(|i| i.into_ocr_result()).collect() + } + } + } +} + +/// HTTP-based OCR engine that conforms to LiteParse OCR API specification. +/// The server must implement the API defined in OCR_API_SPEC.md: +/// - POST /ocr endpoint +/// - Accepts multipart/form-data with 'file' and 'language' fields +/// - Returns JSON: { results: [{ text, bbox: [x1,y1,x2,y2], confidence }] } +/// See ocr/easyocr/ and ocr/paddleocr/ for example server implementations. +pub struct HttpOcrEngine { + pub name: String, + server_url: String, + /// Extra headers (name, value) sent with every request, e.g. auth tokens. + headers: Vec<(String, String)>, + /// Retry/backoff policy. Defaults to worker-parity semantics. + retry: OcrRetryConfig, +} + +/// Retry/backoff policy for OCR HTTP requests. The default is up to 10 +/// attempts, 1s base backoff doubling to a 10s cap, plus jitter, with a fast +/// path for dropped connections) so that liteparse-driven OCR is resilient +/// to a down / rate-limited `/ocr` endpoint. +/// Without this, a transient outage or a 429 burst exhausts the old 3-attempt / +/// sub-second-backoff budget and the page's OCR is lost. +#[derive(Debug, Clone)] +pub struct OcrRetryConfig { + /// Total attempts (1 initial + retries) before giving up on a request. + pub max_attempts: u32, + /// Backoff before the first retry; doubles each subsequent retry. + pub base_backoff_ms: u64, + /// Upper bound the doubling backoff is clamped to. + pub max_backoff_ms: u64, + /// Maximum random jitter added to each backoff, to de-correlate the + /// concurrent per-page retries (avoids a thundering herd when the server + /// recovers and every parked page retries at the same instant). + pub jitter_ms: u64, + /// Short fixed backoff for a mid-stream connection drop ("socket hang up"), + /// which is usually a single dropped keepalive rather than overload. + pub fast_retry_ms: u64, + /// Per-request timeout. + pub request_timeout_ms: u64, + /// Request-hedging schedule, in milliseconds. Empty or single-element = + /// no hedging (one request per attempt — the default). With multiple + /// delays (e.g. `[0, 5000, 10000]`), each attempt fires a *duplicate* + /// request at every delay and takes the first to succeed, cancelling the + /// rest — a tail-latency trick that trades extra OCR-server load for + /// lower p99 latency when a request lands on a slow/stuck pod. + /// Opt-in: callers enable it via config. + pub hedge_delays_ms: Vec, +} + +impl Default for OcrRetryConfig { + fn default() -> Self { + Self { + max_attempts: 10, + base_backoff_ms: 1000, + max_backoff_ms: 10_000, + jitter_ms: 500, + fast_retry_ms: 500, + request_timeout_ms: 60_000, + hedge_delays_ms: Vec::new(), + } + } +} + +/// Pseudo-random jitter in `0..=max` milliseconds. Derived from the wall-clock +/// nanosecond fraction rather than a `rand` dependency; concurrent tasks sample +/// at slightly different instants, which is enough to spread out retries. +fn jitter_ms(max: u64) -> u64 { + if max == 0 { + return 0; + } + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| u64::from(d.subsec_nanos()) % (max + 1)) + .unwrap_or(0) +} + +/// True for a mid-stream connection drop (peer reset / "socket hang up" / +/// broken pipe). Walks the error source chain because reqwest wraps the +/// underlying hyper/io cause. These get the short `fast_retry_ms` backoff. +fn is_connection_drop(err: &reqwest::Error) -> bool { + let mut source: Option<&(dyn std::error::Error + 'static)> = Some(err); + while let Some(e) = source { + let msg = e.to_string().to_ascii_lowercase(); + if msg.contains("connection reset") + || msg.contains("hang up") + || msg.contains("broken pipe") + || msg.contains("connection closed") + || msg.contains("incompletemessage") + { + return true; + } + source = e.source(); + } + false +} + +/// Send a single OCR request and return the raw response body. Encodes the +/// multipart form fresh each call (a `Form` is consumed by `send`). +async fn send_one( + client: &Client, + url: &str, + headers: &[(String, String)], + png_bytes: &[u8], + language: &str, + timeout_ms: u64, +) -> Result { + let form = Form::new() + .part( + "file", + Part::bytes(png_bytes.to_vec()) + .file_name("image.png") + .mime_str("image/png")?, + ) + .text("language", language.to_string()); + let mut request = client + .post(url) + .multipart(form) + .timeout(Duration::from_millis(timeout_ms)); + for (name, value) in headers { + request = request.header(name.as_str(), value.as_str()); + } + match request.send().await.and_then(|r| r.error_for_status()) { + Ok(resp) => resp.text().await, + Err(e) => Err(e), + } +} + +/// Whether a failed request is worth retrying. Transient transport problems +/// (connection refused/reset, timeouts) and overload/5xx status codes are +/// retryable; a 4xx like 400/401/404 is a deterministic caller/config error +/// that a retry would only repeat. +fn is_retryable(err: &reqwest::Error) -> bool { + if err.is_timeout() || err.is_connect() { + return true; + } + if let Some(status) = err.status() { + return matches!(status.as_u16(), 408 | 425 | 429 | 500 | 502 | 503 | 504); + } + // No status and not a clean connect/timeout classification (e.g. a body + // read that died mid-stream): treat as transient and retry. + err.is_body() || err.is_request() +} + +impl HttpOcrEngine { + pub fn new(server_url: String) -> Self { + Self::with_headers(server_url, Vec::new()) + } + + pub fn with_headers(server_url: String, headers: Vec<(String, String)>) -> Self { + Self { + name: "http-ocr".to_string(), + server_url, + headers, + retry: OcrRetryConfig::default(), + } + } + + /// Override the retry/backoff policy + pub fn with_retry(mut self, retry: OcrRetryConfig) -> Self { + self.retry = retry; + self + } + + /// Send one OCR request, or — when `hedge_delays_ms` has more than one + /// entry — a hedged group: fire a duplicate request at each delay and + /// return the first success, aborting the slower in-flight duplicates. + /// Falls back to a plain single request (the common case) when hedging is + /// not configured. + async fn send_hedged( + &self, + client: &Client, + png_bytes: &[u8], + language: &str, + ) -> Result { + let delays = &self.retry.hedge_delays_ms; + let timeout_ms = self.retry.request_timeout_ms; + + // Single-request fast path: no hedging, or a single (possibly delayed) + // request. Borrows directly — no task spawn / cloning. + if delays.len() <= 1 { + if let Some(&d) = delays.first().filter(|&&d| d > 0) { + tokio::time::sleep(Duration::from_millis(d)).await; + } + return send_one( + client, + &self.server_url, + &self.headers, + png_bytes, + language, + timeout_ms, + ) + .await; + } + + // Hedged path: spawn one task per delay (spawned tasks need owned data, + // so clone the cheap bits and the PNG per hedge — the duplicate upload + // is the cost hedging deliberately pays). The first Ok wins and the + // remaining tasks are aborted (which cancels their in-flight requests); + // if all fail, surface the last error so the retry loop can back off. + let (tx, mut rx) = tokio::sync::mpsc::channel(delays.len()); + let mut handles = Vec::with_capacity(delays.len()); + for &delay in delays { + let tx = tx.clone(); + let client = client.clone(); + let url = self.server_url.clone(); + let headers = self.headers.clone(); + let png = png_bytes.to_vec(); + let lang = language.to_string(); + handles.push(tokio::spawn(async move { + if delay > 0 { + tokio::time::sleep(Duration::from_millis(delay)).await; + } + let res = send_one(&client, &url, &headers, &png, &lang, timeout_ms).await; + let _ = tx.send(res).await; + })); + } + drop(tx); + + let mut last_err: Option = None; + while let Some(res) = rx.recv().await { + match res { + Ok(body) => { + for h in &handles { + h.abort(); + } + return Ok(body); + } + Err(e) => last_err = Some(e), + } + } + // The channel only closes after every hedge task has sent its result, + // so at least one error is present when we reach here. + Err(last_err.expect("hedge group always yields at least one result")) + } +} + +impl OcrEngine for HttpOcrEngine { + fn name(&self) -> &str { + &self.name + } + + fn recognize<'a, 'b: 'a, 'c: 'a>( + &'a self, + image_data: &'c [u8], + width: u32, + height: u32, + options: &'b OcrOptions, + ) -> Pin< + Box< + dyn Future, Box>> + + Send + + '_, + >, + > { + Box::pin(async move { + // Encode raw RGB bytes as PNG for the server + let img: image::RgbImage = + image::ImageBuffer::from_raw(width, height, image_data.to_vec()) + .ok_or("failed to create image buffer from raw RGB data")?; + let mut png_bytes = Vec::new(); + img.write_to(&mut Cursor::new(&mut png_bytes), ImageFormat::Png)?; + + let client = Client::new(); + + // Retry loop: each attempt sends one OCR request (or a hedged group + // of duplicates when configured) and backs off exponentially on + // transient failures. The PNG bytes above are encoded once and + // cloned per request. + let max_attempts = self.retry.max_attempts.max(1); + let mut attempt: u32 = 0; + let raw = loop { + attempt += 1; + match self + .send_hedged(&client, &png_bytes, &options.language) + .await + { + Ok(body) => break body, + Err(e) => { + if attempt >= max_attempts || !is_retryable(&e) { + return Err(e.into()); + } + // A dropped connection ("socket hang up") gets a short + // fixed backoff; everything else gets exponential + // backoff clamped to the cap. Jitter spreads concurrent + // page retries so they don't all hit a recovering + // server at once. + let base = if is_connection_drop(&e) { + self.retry.fast_retry_ms + } else { + (self + .retry + .base_backoff_ms + .saturating_mul(2u64.saturating_pow(attempt - 1))) + .min(self.retry.max_backoff_ms) + }; + let delay = base + jitter_ms(self.retry.jitter_ms); + if std::env::var("LITEPARSE_DEBUG_OCR").is_ok() { + eprintln!( + "[ocr-http] attempt {attempt}/{max_attempts} failed ({e}); retrying in {delay}ms" + ); + } + tokio::time::sleep(Duration::from_millis(delay)).await; + } + } + }; + // Parse from the buffered body (rather than `.json()`) so a + // malformed/unexpected response can surface a snippet of what the + // server actually returned. + let response: HttpOcrResponse = serde_json::from_str(&raw).map_err(|e| { + let snippet: String = raw.chars().take(200).collect(); + format!("OCR server returned unparseable response: {e}; body starts: {snippet}") + })?; + let results = response.into_results(); + if std::env::var("LITEPARSE_DEBUG_OCR").is_ok() { + eprintln!( + "[ocr-http] {} bytes -> {} result(s)", + raw.len(), + results.len() + ); + } + Ok(results) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_new_sets_name_and_url() { + let e = HttpOcrEngine::new("http://example.com/ocr".into()); + assert_eq!(e.name(), "http-ocr"); + assert_eq!(e.server_url, "http://example.com/ocr"); + } + + #[test] + fn test_response_deserializes() { + let raw = r#"{"results":[{"text":"hi","bbox":[1.0,2.0,3.0,4.0],"confidence":0.85}]}"#; + let parsed: HttpOcrResponse = serde_json::from_str(raw).unwrap(); + let results = parsed.into_results(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].text, "hi"); + assert_eq!(results[0].bbox, [1.0, 2.0, 3.0, 4.0]); + assert!((results[0].confidence - 0.85).abs() < 1e-6); + } + + #[test] + fn test_response_deserializes_empty() { + let raw = r#"{"results":[]}"#; + let parsed: HttpOcrResponse = serde_json::from_str(raw).unwrap(); + assert!(parsed.into_results().is_empty()); + } + + #[test] + fn test_prod_response_deserializes() { + let raw = r#"{"document_angle":-90,"result":[[[[10.0,20.0],[60.0,20.0],[60.0,40.0],[10.0,40.0]],"hi",0.85]]}"#; + let parsed: HttpOcrResponse = serde_json::from_str(raw).unwrap(); + let results = parsed.into_results(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].text, "hi"); + // bbox is the polygon's min/max extents. + assert_eq!(results[0].bbox, [10.0, 20.0, 60.0, 40.0]); + assert!((results[0].confidence - 0.85).abs() < 1e-6); + // 4-point polygon forwarded as-is (TL → TR → BR → BL). + assert_eq!( + results[0].polygon, + Some([[10.0, 20.0], [60.0, 20.0], [60.0, 40.0], [10.0, 40.0]]) + ); + } + + #[test] + fn test_prod_response_empty() { + let raw = r#"{"document_angle":null,"result":[]}"#; + let parsed: HttpOcrResponse = serde_json::from_str(raw).unwrap(); + assert!(parsed.into_results().is_empty()); + } + + #[tokio::test] + async fn test_recognize_network_error() { + // Single attempt so the connection-refused failure surfaces fast + // instead of grinding through the default multi-attempt backoff. + let e = HttpOcrEngine::new("http://127.0.0.1:1/ocr".into()).with_retry(OcrRetryConfig { + max_attempts: 1, + ..Default::default() + }); + let opts = OcrOptions { + language: "eng".into(), + dpi: 150.0, + }; + let r = e.recognize(&[0u8; 4], 1, 1, &opts).await; + assert!(r.is_err()); + } + + #[test] + fn test_default_retry_matches() { + let c = OcrRetryConfig::default(); + assert_eq!(c.max_attempts, 10); + assert_eq!(c.base_backoff_ms, 1000); + assert_eq!(c.max_backoff_ms, 10_000); + } + + #[test] + fn test_jitter_within_bounds() { + for _ in 0..1000 { + assert!(jitter_ms(500) <= 500); + } + assert_eq!(jitter_ms(0), 0); + } + + #[test] + fn test_default_has_no_hedging() { + assert!(OcrRetryConfig::default().hedge_delays_ms.is_empty()); + } + + // Exercises the hedged spawn/mpsc path: two duplicate requests against a + // dead endpoint must both fail and the call returns Err (rather than + // hanging or panicking). Single attempt + zero backoff keeps it fast. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_hedged_all_fail_returns_error() { + let e = HttpOcrEngine::new("http://127.0.0.1:1/ocr".into()).with_retry(OcrRetryConfig { + max_attempts: 1, + hedge_delays_ms: vec![0, 10], + ..Default::default() + }); + let opts = OcrOptions { + language: "eng".into(), + dpi: 150.0, + }; + let r = e.recognize(&[0u8; 4], 1, 1, &opts).await; + assert!(r.is_err()); + } +} diff --git a/crates/liteparse/src/ocr/mod.rs b/crates/liteparse/src/ocr/mod.rs new file mode 100644 index 0000000..b860b11 --- /dev/null +++ b/crates/liteparse/src/ocr/mod.rs @@ -0,0 +1,130 @@ +use std::pin::Pin; + +#[cfg(not(target_arch = "wasm32"))] +pub mod http_simple; +#[cfg(all(feature = "tesseract", not(target_arch = "wasm32")))] +pub mod tesseract; + +/// A single word-level OCR result with bounding box and confidence. +#[derive(Debug, Clone)] +pub struct OcrResult { + pub text: String, + /// Bounding box in pixel coordinates: [x1, y1, x2, y2] (left, top, right, bottom). + pub bbox: [f32; 4], + /// Confidence score in 0.0–1.0 range. + pub confidence: f32, + /// Optional 4-point polygon of the (possibly rotated) detection, + /// ordered top-left → top-right → bottom-right → bottom-left in the + /// glyphs' upright reading frame. When present, allows the projector + /// to recover orientation for rotated text. None for axis-aligned-only + /// engines (e.g. Tesseract word boxes). + pub polygon: Option<[[f32; 2]; 4]>, +} + +pub struct OcrOptions { + pub language: String, + /// Resolution (pixels per inch) the page image was rendered at. OCR engines + /// that can't infer DPI from raw pixel buffers (e.g. Tesseract fed RGB bytes) + /// use this so their internal point-size estimates are correct. + pub dpi: f32, +} + +/// On native targets, `OcrEngine` and its returned futures must be `Send` so +/// they can be moved across thread boundaries by the multi-threaded tokio +/// runtime. On wasm32 there is only a single thread and JS-backed engines +/// hold `!Send` types (`JsValue`, `js_sys::Function`, ...), so we relax +/// those bounds for the wasm target. +#[cfg(not(target_arch = "wasm32"))] +pub trait OcrEngine: Send + Sync { + fn name(&self) -> &str; + /// Whether this engine prefers a single-channel grayscale buffer: engines + /// that binarize internally (Tesseract) do; color-trained engines want RGB. + fn prefers_grayscale(&self) -> bool { + false + } + fn recognize<'a, 'b: 'a, 'c: 'a>( + &'a self, + image_data: &'c [u8], + width: u32, + height: u32, + options: &'b OcrOptions, + ) -> Pin< + Box< + dyn Future, Box>> + + Send + + '_, + >, + >; +} + +#[cfg(target_arch = "wasm32")] +pub trait OcrEngine: Send + Sync { + fn name(&self) -> &str; + /// Whether this engine prefers a single-channel grayscale buffer: engines + /// that binarize internally (Tesseract) do; color-trained engines want RGB. + fn prefers_grayscale(&self) -> bool { + false + } + fn recognize<'a, 'b: 'a, 'c: 'a>( + &'a self, + image_data: &'c [u8], + width: u32, + height: u32, + options: &'b OcrOptions, + ) -> Pin< + Box< + dyn Future, Box>> + + '_, + >, + >; +} + +#[cfg(test)] +mod tests { + use super::*; + + struct DummyEngine; + impl OcrEngine for DummyEngine { + fn name(&self) -> &str { + "dummy" + } + fn recognize<'a, 'b: 'a, 'c: 'a>( + &'a self, + _image_data: &'c [u8], + _width: u32, + _height: u32, + options: &'b OcrOptions, + ) -> Pin< + Box< + dyn Future< + Output = Result, Box>, + > + Send + + '_, + >, + > { + Box::pin(async move { + Ok(vec![OcrResult { + text: format!("lang={}", options.language), + bbox: [0.0, 0.0, 10.0, 10.0], + confidence: 0.9, + polygon: None, + }]) + }) + } + } + + #[tokio::test] + async fn test_engine_trait_object() { + let engine: Box = Box::new(DummyEngine); + assert_eq!(engine.name(), "dummy"); + let opts = OcrOptions { + language: "eng".into(), + dpi: 150.0, + }; + let r = engine.recognize(&[], 1, 1, &opts).await.unwrap(); + assert_eq!(r.len(), 1); + assert_eq!(r[0].text, "lang=eng"); + assert_eq!(r[0].bbox, [0.0, 0.0, 10.0, 10.0]); + assert!((r[0].confidence - 0.9).abs() < 1e-6); + } +} diff --git a/crates/liteparse/src/ocr/tesseract.rs b/crates/liteparse/src/ocr/tesseract.rs new file mode 100644 index 0000000..fac913e --- /dev/null +++ b/crates/liteparse/src/ocr/tesseract.rs @@ -0,0 +1,308 @@ +use std::path::{Path, PathBuf}; +use std::pin::Pin; + +use super::{OcrEngine, OcrOptions, OcrResult}; +use tesseract_rs::{TessPageIteratorLevel, TessPageSegMode, TesseractAPI}; + +const TESSDATA_BASE_URL: &str = "https://github.com/tesseract-ocr/tessdata_best/raw/main"; + +pub struct TesseractOcrEngine { + tessdata_path: Option, +} + +impl TesseractOcrEngine { + pub fn new(tessdata_path: Option) -> Self { + Self { tessdata_path } + } + + fn normalize_language_code(lang: &str) -> &str { + match lang.to_lowercase().trim() { + "en" => "eng", + "fr" => "fra", + "de" => "deu", + "es" => "spa", + "it" => "ita", + "pt" => "por", + "ru" => "rus", + "zh" | "zh-cn" => "chi_sim", + "zh-tw" => "chi_tra", + "ja" => "jpn", + "ko" => "kor", + "ar" => "ara", + "hi" => "hin", + "th" => "tha", + "vi" => "vie", + _ => lang.trim(), + } + } + + /// Normalize a tesseract language spec, which may be a single code or a + /// `+`-separated list (e.g. `ita+eng`). Each component is normalized + /// independently and rejoined with `+`. + fn normalize_language(lang: &str) -> String { + lang.split('+') + .filter(|part| !part.trim().is_empty()) + .map(Self::normalize_language_code) + .collect::>() + .join("+") + } +} + +impl OcrEngine for TesseractOcrEngine { + fn name(&self) -> &str { + "tesseract" + } + + // Tesseract binarizes internally (Leptonica/Otsu on luma), so a grayscale + // buffer is equivalent input at a third of the memory. + fn prefers_grayscale(&self) -> bool { + true + } + + fn recognize<'a, 'b: 'a, 'c: 'a>( + &'a self, + image_data: &'c [u8], + width: u32, + height: u32, + options: &'b OcrOptions, + ) -> Pin< + Box< + dyn Future, Box>> + + Send + + '_, + >, + > { + Box::pin(async move { + let language = Self::normalize_language(&options.language); + + let api = TesseractAPI::new(); + + // Determine tessdata path: explicit config > TESSDATA_PREFIX env > tesseract-rs default + let tessdata_path = self + .tessdata_path + .clone() + .or_else(|| std::env::var("TESSDATA_PREFIX").ok()); + + let resolved_path = tessdata_path.unwrap_or_else(default_tessdata_dir); + for code in language.split('+') { + ensure_traineddata(Path::new(&resolved_path), code).await?; + } + api.init(&resolved_path, &language)?; + + // Match the tesseract CLI's default page segmentation mode (PSM_AUTO, + // i.e. 3). The C++ library's own default when SetPageSegMode is never + // called is PSM_SINGLE_BLOCK (6), which assumes the image is a single + // uniform block of text and performs poorly on full-page layouts. + api.set_page_seg_mode(TessPageSegMode::PSM_AUTO)?; + + // Channels inferred from the buffer: 1 = grayscale, 3 = RGB. + let bytes_per_pixel = if width > 0 && height > 0 { + (image_data.len() / (width as usize * height as usize)).clamp(1, 4) as i32 + } else { + 3 + }; + let bytes_per_line = width as i32 * bytes_per_pixel; + api.set_image( + image_data, + width as i32, + height as i32, + bytes_per_pixel, + bytes_per_line, + )?; + + // Tesseract can't infer DPI from a raw RGB buffer (there's no image + // header), so it falls back to a guess and warns. Tell it the actual + // render resolution so its internal point-size/threshold heuristics are + // correct. Must come after set_image, which resets the resolution. + api.set_source_resolution(options.dpi.round() as i32)?; + + api.recognize()?; + + let iter = api.get_iterator()?; + + let mut results = Vec::new(); + loop { + if let Ok((text, left, top, right, bottom, confidence)) = + iter.get_word_with_bounds() + { + // tesseract-rs returns confidence 0-100, normalize to 0-1 + let conf = confidence / 100.0; + + // Filter low confidence (below 30%, matching TS behavior) + if conf > 0.3 && !text.trim().is_empty() { + results.push(OcrResult { + text, + bbox: [left as f32, top as f32, right as f32, bottom as f32], + confidence: conf, + polygon: None, + }); + } + } + + match iter.next(TessPageIteratorLevel::RIL_WORD) { + Ok(true) => continue, + _ => break, + } + } + + Ok(results) + }) + } +} + +/// Default tessdata directory. Matches the locations used by tesseract-rs's +/// build-tesseract feature so any traineddata it downloaded at build time is +/// also picked up here. +fn default_tessdata_dir() -> String { + #[cfg(target_os = "macos")] + { + if let Ok(home) = std::env::var("HOME") { + return format!("{}/Library/Application Support/tesseract-rs/tessdata", home); + } + } + #[cfg(target_os = "linux")] + { + if let Ok(home) = std::env::var("HOME") { + return format!("{}/.tesseract-rs/tessdata", home); + } + } + #[cfg(target_os = "windows")] + { + if let Some(base) = std::env::var("APPDATA").ok().or_else(|| { + std::env::var("USERPROFILE") + .ok() + .map(|p| format!("{}\\AppData\\Roaming", p)) + }) { + return format!("{}\\tesseract-rs\\tessdata", base); + } + } + "tessdata".to_string() +} + +/// Ensure `.traineddata` exists in `dir`. If missing, downloads it from +/// the upstream `tessdata_best` repo — mirroring tesseract.js's first-use +/// download behavior. Concurrent calls are safe via an atomic rename. +async fn ensure_traineddata( + dir: &Path, + language: &str, +) -> Result<(), Box> { + let filename = format!("{}.traineddata", language); + let final_path: PathBuf = dir.join(&filename); + if final_path.exists() { + return Ok(()); + } + + tokio::fs::create_dir_all(dir).await?; + + let url = format!("{}/{}", TESSDATA_BASE_URL, filename); + let response = reqwest::get(&url).await?; + if !response.status().is_success() { + return Err(format!( + "failed to download tessdata for language \"{}\" from {}: HTTP {}", + language, + url, + response.status() + ) + .into()); + } + let bytes = response.bytes().await?; + + // Write to a temp file in the same directory, then atomically rename. + // This makes concurrent first-use safe: whichever rename lands last wins, + // and partial files never appear at the final path. + let tmp_path = dir.join(format!( + "{}.traineddata.tmp.{}", + language, + std::process::id() + )); + tokio::fs::write(&tmp_path, &bytes).await?; + if let Err(e) = tokio::fs::rename(&tmp_path, &final_path).await { + // If another task beat us to it, the file exists — that's fine. + let _ = tokio::fs::remove_file(&tmp_path).await; + if !final_path.exists() { + return Err(e.into()); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_normalize_language_known_codes() { + assert_eq!(TesseractOcrEngine::normalize_language("en"), "eng"); + assert_eq!(TesseractOcrEngine::normalize_language("EN"), "eng"); + assert_eq!(TesseractOcrEngine::normalize_language(" fr "), "fra"); + assert_eq!(TesseractOcrEngine::normalize_language("zh"), "chi_sim"); + assert_eq!(TesseractOcrEngine::normalize_language("zh-tw"), "chi_tra"); + assert_eq!(TesseractOcrEngine::normalize_language("ja"), "jpn"); + } + + #[test] + fn test_normalize_language_passthrough_for_unknown() { + assert_eq!(TesseractOcrEngine::normalize_language("eng"), "eng"); + assert_eq!(TesseractOcrEngine::normalize_language("xyz"), "xyz"); + } + + #[test] + fn test_normalize_language_multi() { + // `+`-separated specs normalize each component independently. + assert_eq!(TesseractOcrEngine::normalize_language("ita+eng"), "ita+eng"); + assert_eq!(TesseractOcrEngine::normalize_language("it+en"), "ita+eng"); + assert_eq!( + TesseractOcrEngine::normalize_language(" it + en "), + "ita+eng" + ); + assert_eq!(TesseractOcrEngine::normalize_language("eng+xyz"), "eng+xyz"); + // Empty components (leading/trailing/double `+`) are dropped. + assert_eq!(TesseractOcrEngine::normalize_language("eng+"), "eng"); + assert_eq!(TesseractOcrEngine::normalize_language("+eng"), "eng"); + assert_eq!( + TesseractOcrEngine::normalize_language("ita++eng"), + "ita+eng" + ); + } + + #[test] + fn test_engine_name() { + let e = TesseractOcrEngine::new(None); + assert_eq!(e.name(), "tesseract"); + } + + #[test] + fn test_new_stores_tessdata_path() { + let e = TesseractOcrEngine::new(Some("/custom/tessdata".to_string())); + assert_eq!(e.tessdata_path.as_deref(), Some("/custom/tessdata")); + } + + #[test] + fn test_default_tessdata_dir_non_empty() { + let d = default_tessdata_dir(); + assert!(!d.is_empty()); + } + + #[cfg(target_os = "windows")] + #[test] + fn test_default_tessdata_dir_windows_uses_appdata() { + // Sanity check the Windows path uses backslashes and includes tesseract-rs/tessdata. + let d = default_tessdata_dir(); + assert!( + d.ends_with("\\tesseract-rs\\tessdata"), + "unexpected default path on windows: {}", + d + ); + } + + #[tokio::test] + async fn test_ensure_traineddata_skips_when_present() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("xyz.traineddata"); + std::fs::write(&path, b"stub").unwrap(); + // Should be a no-op (no network); language "xyz" doesn't exist upstream + // so any actual download attempt would fail. + ensure_traineddata(tmp.path(), "xyz").await.unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"stub"); + } +} diff --git a/crates/liteparse/src/ocr_merge.rs b/crates/liteparse/src/ocr_merge.rs new file mode 100644 index 0000000..95baa3c --- /dev/null +++ b/crates/liteparse/src/ocr_merge.rs @@ -0,0 +1,1096 @@ +use std::sync::Arc; + +use crate::error::LiteParseError; +use crate::ocr::{OcrEngine, OcrOptions, OcrResult}; +use crate::types::{Page, TextItem}; +use pdfium::{Document, ImageBounds}; +use serde::Serialize; + +/// Minimum dark filled-path area (pt², ~72 DPI page space) not covered by +/// native text before a page is sent to OCR. 400 pt² is roughly one word at +/// a 10–12pt size; anything smaller is likely a bullet, icon, dot leader, or +/// decoration whose loss is acceptable. A false trigger only costs an extra +/// OCR pass (the overlap filter discards OCR results that duplicate native +/// text); measured on a 121-page financial report, the trigger fires on ~3 +/// pages at this threshold. +const UNCOVERED_VECTOR_AREA_THRESHOLD: f32 = 400.0; + +/// Minimum side length (pt) for a raster image object to count toward image +/// coverage; smaller objects (rule lines, bullets, icons) are ignored. +const MIN_IMAGE_SIZE_PT: f32 = 25.0; + +/// A single image at or above this fraction of the page is treated as a +/// full-page background and ignored. +const MAX_IMAGE_PAGE_COVERAGE: f32 = 0.9; + +/// Owned page bitmap prepared for OCR. Indices refer to positions in the `pages` slice. +pub(crate) struct RenderedPage { + pub idx: usize, + /// Tightly-packed pixels: 1 byte/px (grayscale) or 3 (RGB). + pub pixels: Vec, + pub width: u32, + pub height: u32, +} + +/// Why a page was flagged as needing more than the cheap text-only path. +/// Multiple reasons can apply to one page (e.g. a sparse page whose little +/// text is also garbled). Empty exactly when `needs_ocr` is false. +/// +/// This is the discriminator a caller routes on: a scan goes to OCR, dense +/// vector text to a vector-aware pass, and so on. New variants will be added +/// as the routing function learns to recommend heavier pipelines (tables, +/// charts, LLM passes), so callers should treat unknown variants leniently. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ComplexityReason { + /// A single raster covers essentially the whole page and there is little or + /// no extractable text behind it — a scanned/photographed page. + Scanned, + /// Almost no extractable native text, and no full-page raster behind it + /// (a blank page, or a near-empty cover/divider). + NoText, + /// Some real text, but it covers very little of the page — typically a + /// figure-heavy page with only thin captions. + SparseText, + /// Substantial embedded raster figures sit alongside the native text. + EmbeddedImages, + /// The native text decodes to garbage (broken cmap / Type3 char-code + /// fallback), so the visible glyphs and the extracted text disagree. + Garbled, + /// Text is painted as filled vector outlines, outside the text layer, so + /// no native text items represent it. + VectorText, +} + +impl ComplexityReason { + pub fn as_str(&self) -> &'static str { + match self { + ComplexityReason::Scanned => "scanned", + ComplexityReason::NoText => "no-text", + ComplexityReason::SparseText => "sparse-text", + ComplexityReason::EmbeddedImages => "embedded-images", + ComplexityReason::Garbled => "garbled", + ComplexityReason::VectorText => "vector-text", + } + } +} + +#[derive(Debug, Serialize)] +pub struct PageComplexityStats { + pub page_number: usize, + pub text_length: usize, + pub text_coverage: f32, + pub has_substantial_images: bool, + /// Number of raster image objects counted on the page, after the size and + /// single-image coverage filters. + pub image_block_count: usize, + /// Combined area of the counted images over the page area, clamped to 1.0. + /// Stacked or overlapping images can inflate the raw sum past the truly + /// covered fraction, hence the clamp — read it as "summed image-bbox area," + /// not unique covered area. + pub image_coverage: f32, + /// Area of the single largest counted image over the page area, clamped to + /// 1.0. Useful for telling a single full-bleed scan apart from many small + /// inline figures that sum to a similar `image_coverage`. + pub largest_image_coverage: f32, + /// A single raster covering ≥90% of the page is present. Such full-page + /// backgrounds are excluded from `image_coverage`/`largest_image_coverage` + /// (they're not inline figures), so this flag is the only signal that + /// distinguishes a scan from a genuinely blank page — both otherwise report + /// no text and no counted images. + pub full_page_image: bool, + /// Filled vector-outline area not covered by native text, in pt². `None` + /// when a cheaper predicate already flagged the page for OCR, so this + /// expensive page-object walk was skipped (it wasn't the deciding signal). + pub uncovered_vector_area: Option, + pub is_garbled: bool, + pub page_area: f32, + /// Whether the page needs more than the cheap text-only path. Equivalent to + /// `!reasons.is_empty()`; kept as a flat bool for the common predicate case + /// and as the internal per-page OCR gate. + pub needs_ocr: bool, + /// Every reason the page was flagged, in no particular priority order. + pub reasons: Vec, +} + +pub(crate) fn calculate_page_complexity( + page: &Page, + page_obj: &pdfium::Page, +) -> Result { + // Count only usable native text. Substitution-cipher-style corrupt + // encodings (e.g. PDFs with a broken cmap) produce long "text" that looks + // populated but is unreadable — without this, such pages bypass OCR + // because text_length >= 20 and coverage looks fine. The same applies to + // unmappable items (Type3 fonts with no ToUnicode), whose text is a + // char-code fallback and whose bounding boxes come from deceptive + // declared metrics. + let text_length: usize = page + .text_items + .iter() + .filter(|item| !is_unusable_native(item)) + .map(|item| item.text.len()) + .sum(); + // Collect every raster ≥ MIN_IMAGE_SIZE_PT, including full-page ones, so a + // scan can be told apart from a blank page. The "counted" subset below then + // drops full-page backgrounds, matching the old + // `image_bounds(.., MAX_IMAGE_PAGE_COVERAGE)` behaviour for inline figures. + let pw = page.page_width; + let ph = page.page_height; + let all_images = page_obj.image_bounds(MIN_IMAGE_SIZE_PT, f32::INFINITY); + let is_full_page = |b: &ImageBounds| { + b.width > pw * MAX_IMAGE_PAGE_COVERAGE && b.height > ph * MAX_IMAGE_PAGE_COVERAGE + }; + let full_page_image = all_images.iter().any(is_full_page); + let image_bounds: Vec<&ImageBounds> = all_images.iter().filter(|b| !is_full_page(b)).collect(); + let has_images = !image_bounds.is_empty(); + + let page_area = pw * ph; + + let (image_area_sum, largest_image_area) = + image_bounds + .iter() + .fold((0.0_f32, 0.0_f32), |(sum, max), b| { + let area = b.width.max(0.0) * b.height.max(0.0); + (sum + area, max.max(area)) + }); + let (image_coverage, largest_image_coverage) = if page_area > 0.0 { + ( + (image_area_sum / page_area).min(1.0), + (largest_image_area / page_area).min(1.0), + ) + } else { + (0.0, 0.0) + }; + let text_bbox_area: f32 = page + .text_items + .iter() + .filter(|item| !is_unusable_native(item)) + .map(|item| item.width * item.height) + .sum(); + let text_coverage = if page_area > 0.0 { + text_bbox_area / page_area + } else { + 0.0 + }; + + // Low spatial coverage only signals a scan/sparse page when there also + // isn't much native text. A text-dense page (e.g. a ruled table with + // wide intra-cell whitespace) is spatially sparse but needs no OCR. + let sparse_text = text_length < 2000 && text_coverage < 0.15; + let is_garbled = page_is_garbled(page); + + let mut reasons = Vec::new(); + if text_length < 20 { + // Too little text to be the page's content. A full-page raster behind + // it means a scan; otherwise it's effectively blank. + reasons.push(if full_page_image { + ComplexityReason::Scanned + } else { + ComplexityReason::NoText + }); + } else if sparse_text { + // There is real text, but it's too thin to be the whole page. + reasons.push(ComplexityReason::SparseText); + } + if has_images { + reasons.push(ComplexityReason::EmbeddedImages); + } + if is_garbled { + reasons.push(ComplexityReason::Garbled); + } + let mut needs_ocr = !reasons.is_empty(); + + // Text drawn as filled vector outlines lives outside the text layer + // entirely: no text items, no image XObjects, so none of the cheap + // predicates fire on such a text-dense page. Detect it by measuring filled + // path area that native text doesn't account for. Checked last so this + // relatively expensive page-object walk only runs when the cheap predicates + // all pass; when they don't, the area is left unmeasured (`None`). + let uncovered_vector_area = if !needs_ocr { + let path_bounds = page_obj.filled_path_bounds(3.0, 0.9); + let uncovered = uncovered_path_area(&path_bounds, &page.text_items); + if uncovered >= UNCOVERED_VECTOR_AREA_THRESHOLD { + needs_ocr = true; + reasons.push(ComplexityReason::VectorText); + } + Some(uncovered) + } else { + None + }; + + Ok(PageComplexityStats { + page_number: page.page_number, + text_length, + text_coverage, + has_substantial_images: has_images, + image_block_count: image_bounds.len(), + image_coverage, + largest_image_coverage, + full_page_image, + uncovered_vector_area, + is_garbled, + page_area, + needs_ocr, + reasons, + }) +} + +/// Render pages that need OCR from an already-open document. +/// +/// The pdfium `Document` holds raw pointers that are not `Send`, so callers must +/// drop it before awaiting the OCR engine. +pub(crate) fn render_pages_for_ocr( + document: &Document, + pages: &[Page], + dpi: f32, + grayscale: bool, +) -> Result, LiteParseError> { + let mut rendered = Vec::new(); + for (idx, page) in pages.iter().enumerate() { + let page_obj = document.page((page.page_number - 1) as i32)?; + let page_complexity = calculate_page_complexity(page, &page_obj)?; + + if !page_complexity.needs_ocr { + continue; + } + + let bitmap = page_obj.render(dpi)?; + let width = bitmap.width() as u32; + let height = bitmap.height() as u32; + // Grayscale or RGB per the engine; see `OcrEngine::prefers_grayscale`. + let pixels = if grayscale { + bitmap.to_luma() + } else { + bitmap.to_rgb() + }; + + rendered.push(RenderedPage { + idx, + pixels, + width, + height, + }); + } + Ok(rendered) +} + +/// Run OCR on pre-rendered page bitmaps and merge results into `pages`. +pub(crate) async fn ocr_and_merge_rendered( + pages: &mut [Page], + rendered: Vec, + dpi: f32, + ocr_engine: Arc, + ocr_language: &str, + num_workers: usize, + ocr_failure_fatal: bool, +) -> Result<(), LiteParseError> { + // Phase 1: spawn one async task per page. A semaphore limits how many run + // `recognize` concurrently to `num_workers`. + // + // The permit MUST be acquired in async context (`acquire_owned().await`), + // not inside `spawn_blocking` via `block_on`. Acquiring it on a blocking + // thread parks that OS thread until a permit is free; with more pages than + // tokio's blocking pool (default `max_blocking_threads = 512`), every pool + // thread ends up parked waiting on the semaphore. The single task holding + // the permit then calls `recognize`, whose HTTP client resolves DNS via its + // own internal `spawn_blocking` — which can never get a thread, so the + // request never goes out, the permit is never released, and the whole OCR + // pass deadlocks. Acquiring the permit asynchronously parks the lightweight + // task instead, so only `num_workers` blocking threads are ever consumed. + let num_workers = num_workers.max(1); + let semaphore = Arc::new(tokio::sync::Semaphore::new(num_workers)); + let mut handles = Vec::with_capacity(rendered.len()); + + let handle = tokio::runtime::Handle::current(); + + for r in rendered { + let engine = ocr_engine.clone(); + let sem = semaphore.clone(); + let language = ocr_language.to_string(); + let page_number = pages[r.idx].page_number; + let rt_handle = handle.clone(); + + handles.push(( + r.idx, + page_number, + tokio::spawn(async move { + // Park the task (not an OS thread) until a permit is available. + let _permit = sem.acquire_owned().await.expect("semaphore closed"); + let options = OcrOptions { language, dpi }; + // Offload the (possibly CPU-blocking, e.g. Tesseract) recognize + // onto a blocking thread. Because the permit is already held, + // at most `num_workers` blocking threads are in use at once, + // leaving the rest of the pool free for the HTTP client's + // internal DNS resolution. + match tokio::task::spawn_blocking(move || { + rt_handle.block_on(engine.recognize(&r.pixels, r.width, r.height, &options)) + }) + .await + { + Ok(result) => result, + Err(join_err) => { + Err(Box::new(join_err) as Box) + } + } + }), + )); + } + + // Phase 3: collect results and merge into pages. + let scale_factor = 72.0 / dpi; + + // Track OCR task outcomes so we can distinguish a systemic failure (e.g. + // missing Tesseract language data, which fails identically on every page) + // from incidental per-page failures. Without this, every page logs the same + // error and `parse()` still returns "success" with no OCR text. + // + // We additionally track whether any *sparse-text* page failed: a page is + // rendered for OCR if it has sparse native text OR merely contains an image + // (`needs_ocr = text_length < 20 || text_coverage < 0.15 || has_images`). + // A native-text PDF with a logo on every page is rendered for OCR + // enrichment but already has all its text. We must only fail loud when OCR + // failure destroyed a sparse page's likely primary text source — otherwise + // a broken OCR setup would abort perfectly good native-text documents. + let total_tasks = handles.len(); + let mut failed_tasks = 0usize; + let mut failed_sparse_text_page = false; + let mut first_error: Option = None; + + for (idx, page_number, handle) in handles { + let ocr_results: Vec = match handle.await { + Ok(Ok(results)) => results, + Ok(Err(e)) => { + failed_tasks += 1; + failed_sparse_text_page |= page_has_sparse_native_text(&pages[idx]); + // Only log the first failure to avoid flooding stderr with an + // identical message for every page. + if first_error.is_none() { + let msg = e.to_string(); + eprintln!("[ocr] failed for page {}: {}", page_number, msg); + first_error = Some(msg); + } + continue; + } + Err(e) => { + failed_tasks += 1; + failed_sparse_text_page |= page_has_sparse_native_text(&pages[idx]); + if first_error.is_none() { + let msg = e.to_string(); + eprintln!("[ocr] task panicked for page {}: {}", page_number, msg); + first_error = Some(msg); + } + continue; + } + }; + + if ocr_results.is_empty() { + continue; + } + + let page = &mut pages[idx]; + // Drop unusable native items (substitution-cipher cmap corruption, or + // unmappable Type3 text) so OCR can replace them. Without this, + // garbled-but-spatially-present native text suppresses every OCR + // result that overlaps it via the overlap check below, leaving the + // output stuck with unreadable text. We apply both per-item and + // per-page checks: short garbled labels ("GDWH", "XVG") can't be + // flagged alone, but their host page can. + if page_is_garbled(page) { + page.text_items.clear(); + } else { + page.text_items.retain(|item| !is_unusable_native(item)); + } + + // Only check overlap against native (already-extracted) PDF text. Comparing + // each OCR result against previously-accepted OCR results caused adjacent + // OCR lines whose bounding boxes touched within tolerance to suppress each + // other, dropping every second line on scanned pages. + let native_count = page.text_items.len(); + for r in &ocr_results { + if r.confidence <= 0.1 { + continue; + } + + // Prefer the screen-space axis-aligned bbox derived from the polygon + // (when present) so rotated detections carry a tight upright bbox. + // The polygon also lets us recover an explicit rotation angle so the + // projector can route rotated sidebar text through its rotation + // reading-order handler instead of mistaking it for body text. + let (ocr_x, ocr_y, ocr_w, ocr_h, rotation) = match r.polygon { + Some(poly) => { + let xs = [poly[0][0], poly[1][0], poly[2][0], poly[3][0]]; + let ys = [poly[0][1], poly[1][1], poly[2][1], poly[3][1]]; + let x_min = xs.iter().copied().fold(f32::INFINITY, f32::min); + let x_max = xs.iter().copied().fold(f32::NEG_INFINITY, f32::max); + let y_min = ys.iter().copied().fold(f32::INFINITY, f32::min); + let y_max = ys.iter().copied().fold(f32::NEG_INFINITY, f32::max); + let rot = polygon_rotation_deg(&poly); + ( + x_min * scale_factor, + y_min * scale_factor, + (x_max - x_min) * scale_factor, + (y_max - y_min) * scale_factor, + rot, + ) + } + None => ( + r.bbox[0] * scale_factor, + r.bbox[1] * scale_factor, + (r.bbox[2] - r.bbox[0]) * scale_factor, + (r.bbox[3] - r.bbox[1]) * scale_factor, + 0.0, + ), + }; + + if overlaps_existing_text( + &page.text_items[..native_count], + ocr_x, + ocr_y, + ocr_w, + ocr_h, + 2.0, + ) { + continue; + } + + let cleaned = clean_ocr_table_artifacts(&r.text); + if cleaned.is_empty() { + continue; + } + + // For native rotated text the font_size approximates line height, + // which for 90/270° rotations corresponds to the *narrow* screen + // dimension. Use the perpendicular extent for rotated OCR text so + // downstream font-size heuristics stay sane. + let font_size_hint = if rotation == 90.0 || rotation == 270.0 { + ocr_w.max(1.0) + } else { + ocr_h + }; + + page.text_items.push(TextItem { + text: cleaned, + x: ocr_x, + y: ocr_y, + width: ocr_w, + height: ocr_h, + rotation, + font_name: Some("OCR".to_string()), + font_size: Some(font_size_hint), + confidence: Some((r.confidence * 1000.0).round() / 1000.0), + ..Default::default() + }); + } + } + + // If every OCR task failed *and* at least one of those failures was on a + // sparse-text page (the same length/coverage predicate that sends pages to + // OCR as text-poor in `render_pages_for_ocr`), treat it as a systemic + // failure. Returning an error surfaces the root cause (e.g. missing language + // data) instead of silently emitting an empty or mostly-empty page. We + // deliberately do NOT fail when the only failures were on pages that already + // had substantial native text and were merely rendered for image-based OCR + // enrichment — a broken OCR setup must not abort an otherwise-good + // native-text document. + if total_tasks > 0 && failed_tasks == total_tasks && failed_sparse_text_page { + let detail = first_error.unwrap_or_else(|| "unknown error".to_string()); + if ocr_failure_fatal { + return Err(LiteParseError::Ocr(format!( + "OCR failed for all {} page(s): {}", + total_tasks, detail + ))); + } + // Non-fatal mode: the caller prefers partial results over a hard abort, + // so keep whatever native text was extracted and continue. Surface the + // root cause as a warning so a broken OCR setup is still visible. + eprintln!( + "[ocr] OCR failed for all {} page(s): {} — continuing with partial (native-text) results (ocr_failure_fatal=false)", + total_tasks, detail + ); + } + + // Surface a concise summary for partial failures without flooding stderr. + if failed_tasks > 0 { + eprintln!( + "[ocr] {}/{} page(s) failed OCR; continuing with partial results", + failed_tasks, total_tasks + ); + } + + Ok(()) +} + +/// True when the page's native (already-extracted) text is sparse enough that +/// OCR is likely its primary text source. Mirrors the non-image predicates in +/// `render_pages_for_ocr` (`text_length < 20 || text_coverage < 0.15`) so the +/// systemic-failure guard matches the same pages that were rendered because +/// their native text was insufficient. +fn page_has_sparse_native_text(page: &Page) -> bool { + let text_length: usize = page + .text_items + .iter() + .filter(|item| !is_unusable_native(item)) + .map(|item| item.text.len()) + .sum(); + let page_area = page.page_width * page.page_height; + let text_bbox_area: f32 = page + .text_items + .iter() + .filter(|item| !is_unusable_native(item)) + .map(|item| item.width * item.height) + .sum(); + let text_coverage = if page_area > 0.0 { + text_bbox_area / page_area + } else { + 0.0 + }; + + text_length < 20 || text_coverage < 0.15 +} + +/// A native text item that cannot be trusted as a text source: either its +/// Unicode mapping failed outright (Type3 fonts with no ToUnicode — the text +/// is a char-code fallback and the bbox comes from deceptive declared +/// metrics), or its content looks substitution-cipher garbled. +fn is_unusable_native(item: &TextItem) -> bool { + item.has_unicode_map_error || is_likely_garbled(&item.text) +} + +/// Total area of filled vector paths not accounted for by native text items. +/// Glyph outlines drawn as paths produce filled regions with no overlapping +/// text item; rules and table borders are stroke-only and already excluded +/// upstream, and shading rects behind real text are subtracted away by the +/// text overlap. Coverage is approximated by summing per-item intersections +/// (clamped to the path's own area), which can only over-estimate coverage — +/// i.e. err toward not triggering OCR. +fn uncovered_path_area(paths: &[ImageBounds], items: &[TextItem]) -> f32 { + let mut uncovered = 0.0f32; + for p in paths { + let p_area = p.width * p.height; + if p_area <= 0.0 { + continue; + } + let mut covered = 0.0f32; + for item in items { + let ix = (p.x + p.width).min(item.x + item.width) - p.x.max(item.x); + let iy = (p.y + p.height).min(item.y + item.height) - p.y.max(item.y); + if ix > 0.0 && iy > 0.0 { + covered += ix * iy; + if covered >= p_area { + break; + } + } + } + uncovered += (p_area - covered).max(0.0); + } + uncovered +} + +/// Heuristic for substitution-cipher / broken-cmap garbling: real Latin-script +/// text has a vowel ratio of roughly 30–45%, but a substitution permutation +/// almost always maps the original A/E/I/O/U onto non-vowel letters, driving +/// the apparent vowel ratio to near zero. Texts without enough ASCII letters +/// to judge (non-Latin scripts, numbers, short labels) are treated as fine. +fn is_likely_garbled(text: &str) -> bool { + let (letters, vowels) = count_letters_and_vowels(text); + if letters < 10 { + return false; + } + vowels * 10 < letters +} + +fn count_letters_and_vowels(text: &str) -> (usize, usize) { + let mut letters = 0usize; + let mut vowels = 0usize; + for ch in text.chars() { + if ch.is_ascii_alphabetic() { + letters += 1; + if matches!(ch.to_ascii_lowercase(), 'a' | 'e' | 'i' | 'o' | 'u') { + vowels += 1; + } + } + } + (letters, vowels) +} + +/// Page-level garbled check: even when individual items are too short to judge +/// in isolation (e.g. "GDWH", "FXUUHQFB XVG"), a page whose aggregate vowel +/// ratio collapses to single digits is almost certainly substitution-encoded. +/// Used to drop all native items on the page before OCR merge, so short +/// garbled labels don't suppress overlapping OCR results. +fn page_is_garbled(page: &Page) -> bool { + let mut total_letters = 0usize; + let mut total_vowels = 0usize; + for it in &page.text_items { + let (l, v) = count_letters_and_vowels(&it.text); + total_letters += l; + total_vowels += v; + } + if total_letters < 30 { + return false; + } + // Real Latin-script vowel ratios sit ~30–45% across English, Portuguese, + // Spanish, French, etc. A page-wide ratio under 20% is well outside any + // natural-language range and signals substitution-style corruption. (A + // simple +3 Caesar shift still leaves some U/Y letters from the original + // O/Y mapping, so a 10% bound is too tight to catch this in practice.) + total_vowels * 5 < total_letters +} + +/// Recover a discrete CCW rotation in degrees from a 4-point OCR polygon. +/// Returns one of 0.0, 90.0, 180.0, 270.0 — snapping to the nearest right +/// angle — or 0.0 for nearly-square/degenerate polygons. +/// +/// Point ordering varies between OCR engines: some emit TL→TR→BR→BL in the +/// glyphs' upright reading frame (so poly[0]→poly[1] is always the reading +/// direction), but others (notably PaddleOCR 3.x with +/// `use_textline_orientation=True`) emit polygons in screen-axis order, where +/// poly[0]→poly[1] is always horizontal in screen space regardless of how the +/// text actually reads. To handle both, we pick the *longer* of the two +/// adjacent edges as the reading direction — the text always runs along the +/// long axis of its bounding quadrilateral. +fn polygon_rotation_deg(poly: &[[f32; 2]; 4]) -> f32 { + let e0 = [poly[1][0] - poly[0][0], poly[1][1] - poly[0][1]]; + let e1 = [poly[2][0] - poly[1][0], poly[2][1] - poly[1][1]]; + let len0 = (e0[0] * e0[0] + e0[1] * e0[1]).sqrt(); + let len1 = (e1[0] * e1[0] + e1[1] * e1[1]).sqrt(); + if len0.max(len1) < 1.0 { + return 0.0; + } + // Treat near-square polygons as un-rotated — there's no reliable reading + // axis to pick from. Single-char/CJK detections fall in here. + let (longer, shorter) = if len0 >= len1 { + (len0, len1) + } else { + (len1, len0) + }; + if shorter > 0.0 && longer / shorter < 1.3 { + return 0.0; + } + let reading = if len0 >= len1 { e0 } else { e1 }; + // atan2 with screen-down y; negate to get the conventional CCW angle. + let angle_ccw = -reading[1].atan2(reading[0]).to_degrees(); + let normalized = angle_ccw.rem_euclid(360.0); + ((normalized / 90.0).round() as i32 * 90).rem_euclid(360) as f32 +} + +/// Check if an OCR bounding box overlaps with any existing text item. +fn overlaps_existing_text( + items: &[TextItem], + ocr_x: f32, + ocr_y: f32, + ocr_w: f32, + ocr_h: f32, + tolerance: f32, +) -> bool { + for item in items { + let item_right = item.x + item.width; + let item_bottom = item.y + item.height; + + let overlap_x = ocr_x < item_right + tolerance && ocr_x + ocr_w > item.x - tolerance; + let overlap_y = ocr_y < item_bottom + tolerance && ocr_y + ocr_h > item.y - tolerance; + + if overlap_x && overlap_y { + return true; + } + } + false +} + +/// Clean common OCR artifacts from table border misreads. +/// OCR often misreads vertical table border lines as bracket-like characters. +fn clean_ocr_table_artifacts(text: &str) -> String { + let trimmed = text.trim(); + if trimmed.is_empty() { + return String::new(); + } + + // Strip leading/trailing border artifact characters: | [ ] ( ) { } + let without_artifacts: &str = trimmed + .trim_start_matches(['|', '[', ']', '(', ')', '{', '}']) + .trim_end_matches(['|', '[', ']', '(', ')', '{', '}']) + .trim(); + + if without_artifacts.is_empty() { + return trimmed.to_string(); + } + + // Only use cleaned version if core content looks numeric-ish + // This avoids incorrectly stripping brackets from content like "(note)" + let is_numeric_ish = without_artifacts + .chars() + .all(|c| c.is_ascii_digit() || matches!(c, ',' | '.' | ' ' | '%' | '-' | '+' | '*' | '/')) + || without_artifacts == "N/A" + || without_artifacts == "Z" + || without_artifacts == "-"; + + if is_numeric_ish { + without_artifacts.to_string() + } else { + trimmed.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_polygon_rotation_horizontal() { + let p = [[0.0, 0.0], [100.0, 0.0], [100.0, 20.0], [0.0, 20.0]]; + assert_eq!(polygon_rotation_deg(&p), 0.0); + } + + #[test] + fn test_polygon_rotation_90_ccw() { + // Upright text rotated 90° CCW: TL→TR edge points upward (screen y decreasing). + let p = [[10.0, 100.0], [10.0, 0.0], [30.0, 0.0], [30.0, 100.0]]; + assert_eq!(polygon_rotation_deg(&p), 90.0); + } + + #[test] + fn test_polygon_rotation_270_ccw() { + // Upright text rotated 270° CCW (= 90° CW): TL→TR edge points downward. + let p = [[10.0, 0.0], [10.0, 100.0], [30.0, 100.0], [30.0, 0.0]]; + assert_eq!(polygon_rotation_deg(&p), 270.0); + } + + #[test] + fn test_polygon_rotation_screen_axis_vertical() { + // PaddleOCR-style: tall+narrow sidebar polygon in screen-axis order + // (smallest-y first). poly[0]→poly[1] is the SHORT horizontal edge, + // not the reading direction. The longer edge picks out the rotation. + let p = [[20.0, 50.0], [50.0, 50.0], [50.0, 750.0], [20.0, 750.0]]; + let r = polygon_rotation_deg(&p); + assert!(r == 90.0 || r == 270.0, "expected 90 or 270, got {r}"); + } + + #[test] + fn test_polygon_rotation_near_square() { + // Single-char detections (CJK glyphs, etc.) should not be classified + // as rotated — there's no reliable reading axis. + let p = [[0.0, 0.0], [20.0, 0.0], [20.0, 22.0], [0.0, 22.0]]; + assert_eq!(polygon_rotation_deg(&p), 0.0); + } + + #[test] + fn test_polygon_rotation_180() { + let p = [[100.0, 20.0], [0.0, 20.0], [0.0, 0.0], [100.0, 0.0]]; + assert_eq!(polygon_rotation_deg(&p), 180.0); + } + + #[test] + fn test_clean_ocr_table_artifacts() { + assert_eq!(clean_ocr_table_artifacts("44520]"), "44520"); + assert_eq!(clean_ocr_table_artifacts("|123"), "123"); + assert_eq!(clean_ocr_table_artifacts("0.3|"), "0.3"); + assert_eq!(clean_ocr_table_artifacts("(note)"), "(note)"); + assert_eq!(clean_ocr_table_artifacts("|hello|"), "|hello|"); + assert_eq!(clean_ocr_table_artifacts("N/A"), "N/A"); + assert_eq!(clean_ocr_table_artifacts(""), ""); + assert_eq!(clean_ocr_table_artifacts("|||"), "|||"); + } + + fn make_item(x: f32, y: f32, w: f32, h: f32) -> TextItem { + TextItem { + text: "x".into(), + x, + y, + width: w, + height: h, + ..Default::default() + } + } + + #[test] + fn test_overlaps_existing_text_inside() { + let items = vec![make_item(10.0, 10.0, 20.0, 5.0)]; + assert!(overlaps_existing_text(&items, 12.0, 11.0, 5.0, 2.0, 2.0)); + } + + #[test] + fn test_overlaps_existing_text_disjoint() { + let items = vec![make_item(10.0, 10.0, 20.0, 5.0)]; + assert!(!overlaps_existing_text(&items, 100.0, 100.0, 5.0, 5.0, 2.0)); + } + + #[test] + fn test_overlaps_existing_text_tolerance() { + let items = vec![make_item(10.0, 10.0, 20.0, 5.0)]; + // Just outside but within tolerance + assert!(overlaps_existing_text(&items, 31.0, 10.0, 5.0, 5.0, 2.0)); + // Beyond tolerance + assert!(!overlaps_existing_text(&items, 35.0, 10.0, 5.0, 5.0, 2.0)); + } + + #[test] + fn test_overlaps_empty() { + assert!(!overlaps_existing_text(&[], 0.0, 0.0, 1.0, 1.0, 0.0)); + } + + fn pb(x: f32, y: f32, w: f32, h: f32) -> ImageBounds { + ImageBounds { + x, + y, + width: w, + height: h, + } + } + + #[test] + fn test_uncovered_path_area_no_text() { + // A sentence-sized outlined region with no native text at all. + let paths = vec![pb(50.0, 300.0, 200.0, 12.0)]; + let area = uncovered_path_area(&paths, &[]); + assert!((area - 2400.0).abs() < 1.0); + assert!(area >= UNCOVERED_VECTOR_AREA_THRESHOLD); + } + + #[test] + fn test_uncovered_path_area_fully_covered_by_text() { + // Shading rect behind real text: fully covered, must not trigger. + let paths = vec![pb(50.0, 300.0, 200.0, 12.0)]; + let items = vec![make_item(40.0, 295.0, 250.0, 25.0)]; + let area = uncovered_path_area(&paths, &items); + assert_eq!(area, 0.0); + } + + #[test] + fn test_uncovered_path_area_partial_coverage() { + // Half the outlined region is covered by a text item. + let paths = vec![pb(0.0, 0.0, 100.0, 10.0)]; + let items = vec![make_item(0.0, 0.0, 50.0, 10.0)]; + let area = uncovered_path_area(&paths, &items); + assert!((area - 500.0).abs() < 1.0); + } + + #[test] + fn test_uncovered_path_area_small_decoration_below_threshold() { + // A few bullet-sized filled paths shouldn't reach the threshold. + let paths = vec![pb(10.0, 10.0, 8.0, 8.0), pb(10.0, 30.0, 8.0, 8.0)]; + let area = uncovered_path_area(&paths, &[]); + assert!(area < UNCOVERED_VECTOR_AREA_THRESHOLD); + } + + #[test] + fn test_unusable_native_unicode_map_error() { + let mut item = make_item(0.0, 0.0, 10.0, 10.0); + assert!(!is_unusable_native(&item)); + item.has_unicode_map_error = true; + assert!(is_unusable_native(&item)); + } + + #[test] + fn test_clean_ocr_keeps_whitespace_trimmed() { + assert_eq!(clean_ocr_table_artifacts(" "), ""); + assert_eq!(clean_ocr_table_artifacts(" 123 "), "123"); + } + + // A mock OCR engine that always fails, simulating a systemic error such as + // missing Tesseract language data (the root cause behind issue #253). + struct FailingEngine; + impl OcrEngine for FailingEngine { + fn name(&self) -> &str { + "failing" + } + fn recognize<'a, 'b: 'a, 'c: 'a>( + &'a self, + _image_data: &'c [u8], + _width: u32, + _height: u32, + _options: &'b OcrOptions, + ) -> std::pin::Pin< + Box< + dyn Future< + Output = Result, Box>, + > + Send + + '_, + >, + > { + Box::pin(async move { Err("Error opening data file tessdata/eng.traineddata".into()) }) + } + } + + fn make_blank_page(page_number: usize) -> Page { + Page { + page_number, + page_width: 100.0, + page_height: 100.0, + text_items: Vec::new(), + graphics: Vec::new(), + struct_nodes: Vec::new(), + image_refs: Vec::new(), + } + } + + fn make_rendered(idx: usize) -> RenderedPage { + RenderedPage { + idx, + // 1x1 grayscale pixel; the engine never inspects it. + pixels: vec![0u8], + width: 1, + height: 1, + } + } + + // A page that already has substantial native text coverage, as would be the + // case for a native-text PDF page that was only rendered for OCR because it + // also contains an image. + fn make_native_text_page(page_number: usize) -> Page { + Page { + page_number, + page_width: 100.0, + page_height: 100.0, + text_items: vec![TextItem { + text: "this page already has real native text content".into(), + x: 0.0, + y: 0.0, + width: 50.0, + height: 50.0, + ..Default::default() + }], + graphics: Vec::new(), + struct_nodes: Vec::new(), + image_refs: Vec::new(), + } + } + + // A page with >20 bytes of native text but very low page coverage. These + // are still text-poor enough that `render_pages_for_ocr` sends them to OCR + // (`text_coverage < 0.15`), so a systemic OCR failure should not be silently + // swallowed. + fn make_low_coverage_text_page(page_number: usize) -> Page { + Page { + page_number, + page_width: 100.0, + page_height: 100.0, + text_items: vec![TextItem { + text: "small native header that is not enough".into(), + x: 0.0, + y: 0.0, + width: 10.0, + height: 5.0, + ..Default::default() + }], + graphics: Vec::new(), + struct_nodes: Vec::new(), + image_refs: Vec::new(), + } + } + + // When every OCR task fails (e.g. missing language data), the function must + // return an error instead of silently reporting success with no OCR text. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_all_pages_fail_returns_error() { + let mut pages = vec![make_blank_page(1), make_blank_page(2)]; + let rendered = vec![make_rendered(0), make_rendered(1)]; + let engine: Arc = Arc::new(FailingEngine); + + let result = + ocr_and_merge_rendered(&mut pages, rendered, 72.0, engine, "eng", 2, true).await; + + let err = result.expect_err("expected systemic OCR failure to be surfaced"); + let msg = err.to_string(); + assert!( + msg.contains("OCR failed for all 2 page(s)"), + "unexpected error message: {msg}" + ); + assert!( + msg.contains("traineddata"), + "error should carry the underlying cause: {msg}" + ); + } + + // With no rendered pages there is nothing to OCR; this must remain a no-op + // success rather than tripping the all-failed guard. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_no_rendered_pages_is_ok() { + let mut pages = vec![make_blank_page(1)]; + let engine: Arc = Arc::new(FailingEngine); + + let result = + ocr_and_merge_rendered(&mut pages, Vec::new(), 72.0, engine, "eng", 2, true).await; + + assert!(result.is_ok(), "empty OCR set should succeed: {result:?}"); + } + + // Regression guard: when OCR fails but every failing page already had native + // text (it was only rendered for image-based enrichment), a broken OCR setup + // must NOT abort the parse — the native text is still valid output. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_native_text_pages_not_failed_on_ocr_error() { + let mut pages = vec![make_native_text_page(1), make_native_text_page(2)]; + let rendered = vec![make_rendered(0), make_rendered(1)]; + let engine: Arc = Arc::new(FailingEngine); + + let result = + ocr_and_merge_rendered(&mut pages, rendered, 72.0, engine, "eng", 2, true).await; + + assert!( + result.is_ok(), + "OCR failure on already-native-text pages must not abort the parse: {result:?}" + ); + // Native text is preserved untouched. + assert_eq!(pages[0].text_items.len(), 1); + assert_eq!(pages[1].text_items.len(), 1); + } + + // When failures span both a sparse-text page and a native-text page, the + // sparse-text page lost its likely primary text source, so we still fail + // loud. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_mixed_failure_with_sparse_text_page_returns_error() { + let mut pages = vec![make_native_text_page(1), make_blank_page(2)]; + let rendered = vec![make_rendered(0), make_rendered(1)]; + let engine: Arc = Arc::new(FailingEngine); + + let result = + ocr_and_merge_rendered(&mut pages, rendered, 72.0, engine, "eng", 2, true).await; + + let err = result.expect_err("a text-starved page losing all OCR must surface an error"); + assert!( + err.to_string().contains("OCR failed for all 2 page(s)"), + "unexpected error message: {err}" + ); + } + + // Regression guard for the review finding: low-coverage pages are rendered + // for OCR even when their native text length is >20 bytes. A systemic OCR + // failure on such pages must still surface as an error. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_low_coverage_text_page_failure_returns_error() { + let mut pages = vec![make_low_coverage_text_page(1)]; + let rendered = vec![make_rendered(0)]; + let engine: Arc = Arc::new(FailingEngine); + + let result = + ocr_and_merge_rendered(&mut pages, rendered, 72.0, engine, "eng", 2, true).await; + + let err = result.expect_err("low-coverage text page losing OCR must surface an error"); + assert!( + err.to_string().contains("OCR failed for all 1 page(s)"), + "unexpected error message: {err}" + ); + } + + // With `ocr_failure_fatal = false`, a systemic OCR failure that would + // normally abort (every task failed, a sparse-text page among them) must + // instead return Ok and preserve whatever native text was extracted, so a + // caller with its own fallback gets partial results rather than nothing. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_non_fatal_systemic_failure_returns_partial() { + let mut pages = vec![make_native_text_page(1), make_blank_page(2)]; + let rendered = vec![make_rendered(0), make_rendered(1)]; + let engine: Arc = Arc::new(FailingEngine); + + let result = + ocr_and_merge_rendered(&mut pages, rendered, 72.0, engine, "eng", 2, false).await; + + assert!( + result.is_ok(), + "non-fatal mode must not abort on systemic OCR failure: {result:?}" + ); + // The native-text page keeps its text; the blank page simply has no OCR. + assert_eq!(pages[0].text_items.len(), 1); + } +} diff --git a/crates/liteparse/src/output/json.rs b/crates/liteparse/src/output/json.rs new file mode 100644 index 0000000..442ec2a --- /dev/null +++ b/crates/liteparse/src/output/json.rs @@ -0,0 +1,132 @@ +use crate::types::ParsedPage; +use serde::Serialize; + +#[derive(Debug, Serialize)] +pub(crate) struct JsonTextItem { + pub text: String, + pub x: f32, + pub y: f32, + pub width: f32, + pub height: f32, + #[serde(skip_serializing_if = "Option::is_none")] + pub font_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub font_size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub confidence: Option, +} + +#[derive(Debug, Serialize)] +pub(crate) struct JsonPage { + pub page: usize, + pub width: f32, + pub height: f32, + pub text: String, + pub text_items: Vec, +} + +#[derive(Debug, Serialize)] +pub(crate) struct ParseResultJson { + pub pages: Vec, +} + +/// Build structured JSON output from parsed pages. +pub(crate) fn build_json(pages: &[ParsedPage]) -> ParseResultJson { + ParseResultJson { + pages: pages + .iter() + .map(|page| JsonPage { + page: page.page_number, + width: page.page_width, + height: page.page_height, + text: page.text.clone(), + text_items: page + .text_items + .iter() + .map(|item| JsonTextItem { + text: item.text.clone(), + x: item.x, + y: item.y, + width: item.width, + height: item.height, + font_name: item.font_name.clone(), + font_size: item.font_size, + confidence: item.confidence.or(Some(1.0)), + }) + .collect(), + }) + .collect(), + } +} + +/// Format parsed pages as pretty-printed JSON string. +pub fn format_json(pages: &[ParsedPage]) -> Result { + let result = build_json(pages); + serde_json::to_string_pretty(&result) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{ParsedPage, TextItem}; + + fn item(text: &str, conf: Option) -> TextItem { + TextItem { + text: text.into(), + x: 1.0, + y: 2.0, + width: 3.0, + height: 4.0, + font_name: Some("Helv".into()), + font_size: Some(10.0), + confidence: conf, + ..Default::default() + } + } + + fn page(items: Vec) -> ParsedPage { + ParsedPage { + page_number: 1, + page_width: 612.0, + page_height: 792.0, + text: "txt".into(), + markdown: String::new(), + text_items: items, + projected_lines: vec![], + regions: crate::types::Region::default(), + graphics: vec![], + figures: vec![], + struct_nodes: vec![], + image_refs: vec![], + } + } + + #[test] + fn test_build_json_native_text_defaults_confidence_to_one() { + let j = build_json(&[page(vec![item("hi", None)])]); + assert_eq!(j.pages.len(), 1); + assert_eq!(j.pages[0].page, 1); + assert_eq!(j.pages[0].text_items[0].confidence, Some(1.0)); + assert_eq!(j.pages[0].text_items[0].font_name.as_deref(), Some("Helv")); + } + + #[test] + fn test_build_json_preserves_ocr_confidence() { + let j = build_json(&[page(vec![item("hi", Some(0.42))])]); + assert_eq!(j.pages[0].text_items[0].confidence, Some(0.42)); + } + + #[test] + fn test_format_json_pretty() { + let s = format_json(&[page(vec![item("hi", None)])]).unwrap(); + assert!(s.contains("\n")); + assert!(s.contains("\"text\": \"hi\"")); + assert!(s.contains("\"page\": 1")); + } + + #[test] + fn test_build_json_empty() { + let j = build_json(&[]); + assert!(j.pages.is_empty()); + } +} diff --git a/crates/liteparse/src/output/markdown.rs b/crates/liteparse/src/output/markdown.rs new file mode 100644 index 0000000..3d5caf1 --- /dev/null +++ b/crates/liteparse/src/output/markdown.rs @@ -0,0 +1,271 @@ +use crate::config::ImageMode; +use crate::markdown_layout::{ + build_heading_map, classify_page_with_filters, compute_body_size, compute_header_footer_set, + detect_single_page_chrome, render_blocks, +}; +use crate::types::{OutlineTarget, ParsedPage}; + +/// Format parsed pages as markdown. +/// +/// Whole-document signals (body font size, heading-level map, repeating +/// header/footer set) are computed once up front, then each page is classified +/// into blocks ([`classify_page_with_filters`]) and rendered. Block classes +/// cover headings, paragraphs (with de-hyphenation and inline emphasis), lists, +/// code blocks, ruled and borderless tables, horizontal rules, and figures. +/// +/// Pages are emitted in order, separated by `\n\n-----\n\n`. +/// Pages that contain no projected lines (e.g. blank +/// or fully-OCR pages without font-size info) fall back to the projected text +/// wrapped in a fenced block so we never silently drop content. +pub fn format_markdown( + pages: &[ParsedPage], + outline: &[OutlineTarget], + image_mode: ImageMode, +) -> String { + format_markdown_pages(pages, outline, image_mode).join("\n\n-----\n\n") +} + +/// Render each page to its own markdown string, returning one entry per input +/// page (in order). [`format_markdown`] joins these with page separators to +/// build the document output; callers wanting per-page markdown (e.g. to +/// populate `ParsedPage.markdown`) use this directly. Doc-level context +/// (body size, heading map, header/footer set) is still computed across all +/// pages so a single page renders identically whether requested alone or as +/// part of the document. +pub fn format_markdown_pages( + pages: &[ParsedPage], + outline: &[OutlineTarget], + image_mode: ImageMode, +) -> Vec { + if pages.is_empty() { + return Vec::new(); + } + + let body_size = compute_body_size(pages); + let heading_map = build_heading_map(pages, body_size); + let header_footer = compute_header_footer_set(pages); + + pages + .iter() + .map(|page| { + if page.projected_lines.is_empty() { + // No structural metadata for this page — fall back to the + // projection text inside a fence so nothing is dropped. + let mut out = String::from("```text\n"); + out.push_str(&page.text); + if !page.text.ends_with('\n') { + out.push('\n'); + } + out.push_str("```"); + return out; + } + + // Filter outline entries to this page so the classifier's y/title + // match is a O(entries_on_page) scan per line, not O(whole doc). + let target_index = (page.page_number as i32).saturating_sub(1); + let page_outline: Vec = outline + .iter() + .filter(|e| e.page_index == target_index) + .cloned() + .collect(); + let chrome_indices = detect_single_page_chrome(page, body_size); + let mut blocks = classify_page_with_filters( + page, + &heading_map, + &header_footer, + &page_outline, + image_mode, + &chrome_indices, + ); + // A page whose only surviving blocks are horizontal rules (all its + // text was stripped as chrome) should render empty, not as a stack + // of bare `---` separators. + let has_content = blocks.iter().any(|b| { + !matches!( + b, + crate::markdown_layout::Block::HorizontalRule + | crate::markdown_layout::Block::Figure { .. } + ) + }); + if !has_content { + blocks.retain(|b| !matches!(b, crate::markdown_layout::Block::HorizontalRule)); + } + dedupe_rules(&mut blocks); + render_blocks(&blocks) + }) + .collect() +} + +/// Collapse cosmetic horizontal-rule noise on a single page's block stream: +/// drop leading/trailing rules (which would otherwise abut the `-----` page +/// separator) and collapse runs of consecutive rules to one. Rules come from +/// two sources — vector-graphics detection and decorative divider text — and +/// doubling up reads as sloppy output to a human, while carrying no extra +/// structure for an LLM. +fn dedupe_rules(blocks: &mut Vec) { + use crate::markdown_layout::Block::HorizontalRule; + while matches!(blocks.first(), Some(HorizontalRule)) { + blocks.remove(0); + } + while matches!(blocks.last(), Some(HorizontalRule)) { + blocks.pop(); + } + blocks.dedup_by(|a, b| matches!((a, b), (HorizontalRule, HorizontalRule))); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{Anchor, ProjectedLine, Rect, TextItem}; + + fn line(text: &str, x: f32, y: f32, h: f32, size: f32) -> ProjectedLine { + ProjectedLine { + text: text.into(), + bbox: Rect { + x, + y, + width: text.chars().count() as f32 * (size * 0.5), + height: h, + }, + anchor: Anchor::Left, + indent_x: x, + dominant_font_size: size, + font_size_is_estimated: false, + heading_font_size: None, + dominant_font_name: Some("Arial".into()), + all_bold: false, + all_italic: false, + all_mono: false, + all_strike: false, + spans: vec![TextItem::default()], + region_path: Vec::new(), + mcid: None, + in_figure: false, + } + } + + fn page_with(n: usize, lines: Vec) -> ParsedPage { + ParsedPage { + page_number: n, + page_width: 612.0, + page_height: 792.0, + text: "fallback".into(), + markdown: String::new(), + text_items: vec![], + projected_lines: lines, + regions: crate::types::Region::default(), + graphics: vec![], + figures: vec![], + struct_nodes: vec![], + image_refs: vec![], + } + } + + #[test] + fn test_empty() { + assert_eq!(format_markdown(&[], &[], ImageMode::Placeholder), ""); + } + + #[test] + fn dedupe_rules_drops_edges_and_collapses_runs() { + use crate::markdown_layout::Block::{self, HorizontalRule, Paragraph}; + let p = |t: &str| Paragraph { + text: t.into(), + bold: false, + italic: false, + }; + let mut blocks = vec![ + HorizontalRule, + p("a"), + HorizontalRule, + HorizontalRule, + p("b"), + HorizontalRule, + ]; + dedupe_rules(&mut blocks); + let kinds: Vec = blocks + .iter() + .map(|b| matches!(b, Block::HorizontalRule)) + .collect(); + // Leading + trailing rules gone; the doubled interior run collapsed to one. + assert_eq!(kinds, vec![false, true, false]); + } + + #[test] + fn test_fallback_when_no_projected_lines() { + let p = ParsedPage { + page_number: 1, + page_width: 0.0, + page_height: 0.0, + text: "hello".into(), + markdown: String::new(), + text_items: vec![], + projected_lines: vec![], + regions: crate::types::Region::default(), + graphics: vec![], + figures: vec![], + struct_nodes: vec![], + image_refs: vec![], + }; + let out = format_markdown(&[p], &[], ImageMode::Placeholder); + assert!(out.contains("```text")); + assert!(out.contains("hello")); + } + + #[test] + fn test_heading_and_paragraph() { + let p = page_with( + 1, + vec![ + line("My Title For This Test Document", 50.0, 50.0, 18.0, 18.0), + // Enough body text to dominate the char-weighted body-size + // mode so the title at 18pt registers as larger-than-body. + line("First sentence of body prose here.", 50.0, 80.0, 10.0, 10.0), + line( + "Second sentence of body prose here.", + 50.0, + 92.0, + 10.0, + 10.0, + ), + line( + "Third sentence of body prose here.", + 50.0, + 104.0, + 10.0, + 10.0, + ), + ], + ); + let out = format_markdown(&[p], &[], ImageMode::Placeholder); + assert!(out.contains("# My Title For This Test Document")); + assert!(out.contains("First sentence of body prose here.")); + } + + #[test] + fn test_multi_page_separator() { + let a = page_with(1, vec![line("A page.", 50.0, 80.0, 10.0, 10.0)]); + let b = page_with(2, vec![line("B page.", 50.0, 80.0, 10.0, 10.0)]); + let out = format_markdown(&[a, b], &[], ImageMode::Placeholder); + assert!(out.contains("-----")); + assert!(out.find("A page.").unwrap() < out.find("B page.").unwrap()); + } + + #[test] + fn test_per_page_matches_joined_document() { + let a = page_with(1, vec![line("A page.", 50.0, 80.0, 10.0, 10.0)]); + let b = page_with(2, vec![line("B page.", 50.0, 80.0, 10.0, 10.0)]); + let pages = [a, b]; + let per_page = format_markdown_pages(&pages, &[], ImageMode::Placeholder); + assert_eq!(per_page.len(), 2); + assert!(per_page[0].contains("A page.")); + assert!(per_page[1].contains("B page.")); + // The per-page strings carry no separator on their own; the document + // form is exactly the join. + assert!(!per_page[0].contains("-----")); + assert_eq!( + format_markdown(&pages, &[], ImageMode::Placeholder), + per_page.join("\n\n-----\n\n") + ); + } +} diff --git a/crates/liteparse/src/output/mod.rs b/crates/liteparse/src/output/mod.rs new file mode 100644 index 0000000..c2d2804 --- /dev/null +++ b/crates/liteparse/src/output/mod.rs @@ -0,0 +1,3 @@ +pub mod json; +pub mod markdown; +pub mod text; diff --git a/crates/liteparse/src/output/text.rs b/crates/liteparse/src/output/text.rs new file mode 100644 index 0000000..64348b3 --- /dev/null +++ b/crates/liteparse/src/output/text.rs @@ -0,0 +1,52 @@ +use crate::types::ParsedPage; + +/// Format parsed pages as plain text with page headers. +pub fn format_text(pages: &[ParsedPage]) -> String { + pages + .iter() + .map(|page| format!("\n--- Page {} ---\n{}", page.page_number, page.text)) + .collect::>() + .join("\n\n") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn page(n: usize, text: &str) -> ParsedPage { + ParsedPage { + page_number: n, + page_width: 0.0, + page_height: 0.0, + text: text.into(), + markdown: String::new(), + text_items: vec![], + projected_lines: vec![], + regions: crate::types::Region::default(), + graphics: vec![], + figures: vec![], + struct_nodes: vec![], + image_refs: vec![], + } + } + + #[test] + fn test_format_text_empty() { + assert_eq!(format_text(&[]), ""); + } + + #[test] + fn test_format_text_single_page() { + let out = format_text(&[page(1, "hello")]); + assert!(out.contains("--- Page 1 ---")); + assert!(out.contains("hello")); + } + + #[test] + fn test_format_text_multiple_pages_joined() { + let out = format_text(&[page(1, "a"), page(2, "b")]); + assert!(out.contains("--- Page 1 ---")); + assert!(out.contains("--- Page 2 ---")); + assert!(out.find("a").unwrap() < out.find("b").unwrap()); + } +} diff --git a/crates/liteparse/src/parser.rs b/crates/liteparse/src/parser.rs new file mode 100644 index 0000000..1877214 --- /dev/null +++ b/crates/liteparse/src/parser.rs @@ -0,0 +1,510 @@ +use crate::config::{LiteParseConfig, parse_target_pages}; +#[cfg(not(target_arch = "wasm32"))] +use crate::conversion; +use crate::error::LiteParseError; +use crate::extract; +use crate::ocr::OcrEngine; +#[cfg(not(target_arch = "wasm32"))] +use crate::ocr::http_simple::HttpOcrEngine; +#[cfg(feature = "tesseract")] +use crate::ocr::tesseract::TesseractOcrEngine; +use crate::ocr_merge; +use crate::output::markdown; +use crate::projection; +#[cfg(not(target_arch = "wasm32"))] +use crate::render; +use crate::types::{ExtractedImage, OutlineTarget, Page, ParsedPage, PdfInput}; +use pdfium::Library; + +/// Result of parsing a document. +pub struct ParseResult { + /// Parsed pages with projected text layout. + pub pages: Vec, + /// Full document text, concatenated from all pages. + pub text: String, + /// Document outline (bookmarks) when present. Used by the markdown + /// emitter as a high-priority heading source on untagged PDFs. + pub outline: Vec, + /// Raster images extracted from the document. Empty unless the parser + /// was configured with `ImageMode::Embed`. Each entry carries the same + /// `id` the markdown emitter referenced in `![](image_{id}.png)`, so the + /// caller can match them up without parsing markdown. + pub images: Vec, +} + +/// Result of rendering a single page screenshot. +#[derive(Debug, Clone)] +pub struct ScreenshotResult { + pub page_num: u32, + pub width: u32, + pub height: u32, + pub image_bytes: Vec, +} + +/// Env var pointing at a fragmented glyph-outline → unicode font database +/// directory (`%02x%02x.msgpack` shards). When set, [`LiteParse::new`] +/// auto-wires a [`crate::FontDbResolver`] so buggy/obfuscated-font glyphs are +/// recovered without any extra wiring. Unset (default) leaves the hook dormant. +#[cfg(not(target_arch = "wasm32"))] +const FONT_DB_DIR_ENV: &str = "LITEPARSE_FONT_DB_DIR"; + +/// Build the default glyph resolver from the environment, if configured. +#[cfg(not(target_arch = "wasm32"))] +fn default_glyph_resolver() -> Option> { + let dir = std::env::var_os(FONT_DB_DIR_ENV)?; + if dir.is_empty() { + return None; + } + Some(std::sync::Arc::new(crate::FontDbResolver::new(dir))) +} + +#[cfg(target_arch = "wasm32")] +fn default_glyph_resolver() -> Option> { + None +} + +/// Main LiteParse orchestrator. +/// +/// ### Thread safety +/// +/// `LiteParse` is `Send + Sync` and safe to share across threads (e.g. +/// behind an `Arc`, or used concurrently from a multi-threaded `tokio` +/// runtime). +/// +/// PDFium itself is **not** thread-safe, so all PDFium FFI work — document +/// loading, page rendering, text extraction — is serialized through a +/// process-global lock held by [`pdfium::Library`]. From a caller's +/// perspective, this means concurrent `parse_*` / `screenshot*` calls are +/// safe but their PDFium portions run sequentially. The OCR pass and grid +/// projection (which dominate runtime for OCR-heavy documents) run outside +/// the lock and remain fully concurrent. +pub struct LiteParse { + config: LiteParseConfig, + /// Optional caller-provided OCR engine. When set, this overrides the + /// built-in selection logic (HTTP OCR / Tesseract). This is the primary + /// mechanism for plugging an OCR engine in environments without the + /// built-ins (e.g. WASM, where the JS side supplies a callback engine). + ocr_engine_override: Option>, + /// Optional caller-provided glyph recovery hook. When set, it is consulted + /// as a last resort for buggy/obfuscated-font glyphs that liteparse's + /// built-in cmap/AGL recovery could not decode. The published package ships + /// none; the platform build injects an outline → unicode font-DB resolver. + glyph_resolver: Option>, +} + +impl LiteParse { + pub fn new(config: LiteParseConfig) -> Self { + Self { + config, + ocr_engine_override: None, + glyph_resolver: default_glyph_resolver(), + } + } + + /// Override the OCR engine. When set, the engine is used regardless of + /// `ocr_server_url` / built-in Tesseract availability. + pub fn with_ocr_engine(mut self, engine: std::sync::Arc) -> Self { + self.ocr_engine_override = Some(engine); + self + } + + /// Inject a glyph recovery hook. When set, glyphs that liteparse considers + /// untrusted and cannot decode with its built-in cmap/AGL recovery are + /// passed to the resolver as vector-outline segments for a final attempt. + pub fn with_glyph_resolver( + mut self, + resolver: std::sync::Arc, + ) -> Self { + self.glyph_resolver = Some(resolver); + self + } + + /// Parse the configured `target_pages` string (e.g. `"1-5,10"`) into an + /// explicit page list, or `None` when no selection was configured. + fn resolve_target_pages(&self) -> Result>, LiteParseError> { + self.config + .target_pages + .as_ref() + .map(|s| parse_target_pages(s)) + .transpose() + .map_err(|e| format!("invalid --target-pages: {}", e).into()) + } + + /// Determine the complexity of each page in a document, returning a vector + /// of `PageComplexityStats` for each page. This is useful for deciding + /// whether to enable OCR on a per-page basis, or for other heuristics. + pub async fn is_complex( + &self, + input: PdfInput, + ) -> Result, LiteParseError> { + let log = |msg: &str| { + if !self.config.quiet { + eprintln!("{}", msg); + } + }; + + let t0 = web_time::Instant::now(); + + #[cfg(not(target_arch = "wasm32"))] + let (validated_input, _guard) = + conversion::resolve_pdf_input(input, self.config.password.as_deref(), false).await?; + + #[cfg(target_arch = "wasm32")] + let validated_input = input; + + // Determine which pages to extract + let target_pages = self.resolve_target_pages()?; + + // Load the document and extract text items. Complexity signals derive + // from the text layer and page objects only — embedded image rasters + // and hyperlinks are irrelevant here, so both are skipped to keep this + // pass fast (its whole purpose is a cheap pre-OCR check). + let password = self.config.password.as_deref(); + + let lib = Library::init(); + let document = extract::load_document_from_input(&lib, &validated_input, password)?; + + let (pages, _) = extract::extract_pages_and_images( + &document, + target_pages.as_deref(), + self.config.max_pages, + false, // render_images: image rasters not needed for complexity + false, // extract_links: irrelevant for complexity stats + self.glyph_resolver.as_deref(), + false, // emit_word_boxes: word boxes not needed for complexity stats + )?; + let t_extract = web_time::Instant::now(); + log(&format!( + "[liteparse] extract: {:.1}ms ({} pages)", + t_extract.duration_since(t0).as_secs_f64() * 1000.0, + pages.len() + )); + + let t_complexity = web_time::Instant::now(); + let page_complexities = pages + .iter() + .map(|page| { + let page_obj = document.page((page.page_number - 1) as i32)?; + ocr_merge::calculate_page_complexity(page, &page_obj) + }) + .collect::, _>>()?; + log(&format!( + "[liteparse] complexity: {:.1}ms", + t_complexity.duration_since(t_extract).as_secs_f64() * 1000.0 + )); + + Ok(page_complexities) + } + + /// Parse a document from a file path, returning structured results. + /// + /// Non-PDF files are automatically converted to PDF first (requires + /// LibreOffice/ImageMagick on the system). + /// + /// Not available on `wasm32` — the browser has no filesystem. Use + /// [`LiteParse::parse_input`] with [`PdfInput::Bytes`] instead. + #[cfg(not(target_arch = "wasm32"))] + pub async fn parse(&self, input: &str) -> Result { + self.parse_input(PdfInput::Path(input.to_string())).await + } + + /// Parse a document from either a file path or raw bytes. + /// + /// Use `PdfInput::Path` for files on disk or `PdfInput::Bytes` for + /// in-memory PDF data (e.g. from a network response or Node.js Buffer). + pub async fn parse_input(&self, input: PdfInput) -> Result { + let log = |msg: &str| { + if !self.config.quiet { + eprintln!("{}", msg); + } + }; + + let t0 = web_time::Instant::now(); + + #[cfg(not(target_arch = "wasm32"))] + let (validated_input, _guard) = + conversion::resolve_pdf_input(input, self.config.password.as_deref(), false).await?; + + #[cfg(target_arch = "wasm32")] + let validated_input = input; + + // Determine which pages to extract + let target_pages = self.resolve_target_pages()?; + + // Extract text (and pre-render OCR pages in one PDF load when OCR is on). + // The PDFium lock is acquired for this entire critical section and + // released before any `.await` below — OCR (network / CPU) and grid + // projection (pure Rust) do not touch PDFium, so they can run + // concurrently with other `LiteParse` calls. + let password = self.config.password.as_deref(); + let render_images = matches!(self.config.image_mode, crate::config::ImageMode::Embed); + + // Build the OCR engine up front so the renderer knows whether to emit a + // grayscale buffer (cheaper, for engines that binarize internally) or RGB. + let ocr_engine: Option> = if self.config.ocr_enabled { + Some(if let Some(e) = self.ocr_engine_override.clone() { + e + } else { + #[cfg(not(target_arch = "wasm32"))] + { + if let Some(ref url) = self.config.ocr_server_url { + std::sync::Arc::new( + HttpOcrEngine::with_headers( + url.clone(), + self.config.ocr_server_headers.clone(), + ) + .with_retry( + crate::ocr::http_simple::OcrRetryConfig { + hedge_delays_ms: self.config.ocr_hedge_delays_ms.clone(), + ..Default::default() + }, + ), + ) + } else { + #[cfg(feature = "tesseract")] + { + std::sync::Arc::new(TesseractOcrEngine::new( + self.config.tessdata_path.clone(), + )) + } + #[cfg(not(feature = "tesseract"))] + { + return Err("OCR enabled but no --ocr-server-url provided and tesseract feature is disabled".into()); + } + } + } + #[cfg(target_arch = "wasm32")] + { + return Err( + "OCR enabled but no `ocrEngine` callback was provided (WASM builds have no built-in OCR engine)".into(), + ); + } + }) + } else { + None + }; + let ocr_grayscale = ocr_engine.as_ref().is_some_and(|e| e.prefers_grayscale()); + + let (pages, ocr_rendered, outline, images) = { + let lib = Library::init(); + let document = extract::load_document_from_input(&lib, &validated_input, password)?; + let outline = extract::extract_outline(&document); + let (pages, images) = extract::extract_pages_and_images( + &document, + target_pages.as_deref(), + self.config.max_pages, + render_images, + self.config.extract_links + && self.config.output_format == crate::config::OutputFormat::Markdown, + self.glyph_resolver.as_deref(), + self.config.emit_word_boxes, + )?; + let t_extract = web_time::Instant::now(); + log(&format!( + "[liteparse] extract: {:.1}ms ({} pages)", + t_extract.duration_since(t0).as_secs_f64() * 1000.0, + pages.len() + )); + let rendered = if self.config.ocr_enabled { + let r = ocr_merge::render_pages_for_ocr( + &document, + &pages, + self.config.dpi, + ocr_grayscale, + )?; + log(&format!( + "[liteparse] ocr render: {:.1}ms ({} pages)", + web_time::Instant::now() + .duration_since(t_extract) + .as_secs_f64() + * 1000.0, + r.len() + )); + r + } else { + Vec::new() + }; + // `lib` is dropped here, releasing the PDFium lock. + (pages, rendered, outline, images) + }; + let mut pages = pages; + let t1 = web_time::Instant::now(); + + // OCR pass (engine resolved before the render block above). + if let Some(engine) = ocr_engine { + ocr_merge::ocr_and_merge_rendered( + &mut pages, + ocr_rendered, + self.config.dpi, + engine, + &self.config.ocr_language, + self.config.num_workers, + self.config.ocr_failure_fatal, + ) + .await?; + } + let t_ocr = web_time::Instant::now(); + log(&format!( + "[liteparse] ocr: {:.1}ms", + t_ocr.duration_since(t1).as_secs_f64() * 1000.0 + )); + + // Caller-requested content filters (page-region crop, diagonal-text + // removal). Runs after OCR merge so it also drops OCR text outside the + // crop region, and before projection so filtered items never surface. + extract::apply_content_filters( + &mut pages, + self.config.crop_box.as_ref(), + self.config.skip_diagonal_text, + ); + + // Grid projection + let mut parsed_pages = projection::project_pages_to_grid(pages); + let t2 = web_time::Instant::now(); + log(&format!( + "[liteparse] project: {:.1}ms", + t2.duration_since(t_ocr).as_secs_f64() * 1000.0 + )); + + let full_text = if self.config.output_format == crate::config::OutputFormat::Markdown { + let page_md = + markdown::format_markdown_pages(&parsed_pages, &outline, self.config.image_mode); + let md = page_md.join("\n\n-----\n\n"); + for (page, md) in parsed_pages.iter_mut().zip(page_md) { + page.markdown = md; + } + let t3 = web_time::Instant::now(); + log(&format!( + "[liteparse] markdown: {:.1}ms", + t3.duration_since(t2).as_secs_f64() * 1000.0 + )); + md + } else { + parsed_pages + .iter() + .map(|p| p.text.as_str()) + .collect::>() + .join("\n\n") + }; + + let total = web_time::Instant::now().duration_since(t0).as_secs_f64() * 1000.0; + log(&format!("[liteparse] total: {:.1}ms", total)); + + Ok(ParseResult { + pages: parsed_pages, + text: full_text, + outline, + images, + }) + } + + /// Parse from pre-extracted pages, skipping PDFium text extraction. + /// + /// The caller supplies `Page`s already populated with text items (and, + /// optionally, graphics / struct nodes / image refs) in viewport space + /// (top-left origin, 72 DPI). This runs only grid projection and the + /// configured output formatter, so it touches neither PDFium nor OCR and + /// is fully synchronous. Used when an external extractor (e.g. with its + /// own font-recovery pipeline) owns text extraction. + pub fn parse_from_pages(&self, pages: Vec, outline: Vec) -> ParseResult { + let mut parsed_pages = projection::project_pages_to_grid(pages); + + let full_text = if self.config.output_format == crate::config::OutputFormat::Markdown { + let page_md = + markdown::format_markdown_pages(&parsed_pages, &outline, self.config.image_mode); + let md = page_md.join("\n\n-----\n\n"); + for (page, md) in parsed_pages.iter_mut().zip(page_md) { + page.markdown = md; + } + md + } else { + parsed_pages + .iter() + .map(|p| p.text.as_str()) + .collect::>() + .join("\n\n") + }; + + ParseResult { + pages: parsed_pages, + text: full_text, + outline, + images: Vec::new(), + } + } + + /// Generate screenshots of document pages as PNG bytes. + /// + /// Non-PDF files are automatically converted to PDF first (requires + /// LibreOffice/ImageMagick on the system). Plain-text formats cannot be + /// rendered and return a clear error. + #[cfg(not(target_arch = "wasm32"))] + pub async fn screenshot( + &self, + input: &str, + page_numbers: Option>, + ) -> Result, LiteParseError> { + self.screenshot_input(PdfInput::Path(input.to_string()), page_numbers) + .await + } + + /// Generate screenshots from a file path or raw bytes. + #[cfg(not(target_arch = "wasm32"))] + pub async fn screenshot_input( + &self, + input: PdfInput, + page_numbers: Option>, + ) -> Result, LiteParseError> { + let log = |msg: &str| { + if !self.config.quiet { + eprintln!("{}", msg); + } + }; + + let (validated_input, _guard) = + conversion::resolve_pdf_input(input, self.config.password.as_deref(), true).await?; + + if let PdfInput::Path(ref path) = validated_input + && !conversion::is_pdf(path) + { + log("[liteparse] converted input to PDF for screenshot rendering"); + } + + let rendered = render::render_pages_to_png( + &validated_input, + page_numbers.as_deref(), + self.config.dpi, + self.config.password.as_deref(), + )?; + + Ok(rendered + .into_iter() + .map(|page| ScreenshotResult { + page_num: page.page_num, + width: page.width, + height: page.height, + image_bytes: page.png_bytes, + }) + .collect()) + } + + pub fn config(&self) -> &LiteParseConfig { + &self.config + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[allow(clippy::field_reassign_with_default)] + fn test_new_stores_config() { + let mut cfg = LiteParseConfig::default(); + cfg.ocr_enabled = false; + cfg.max_pages = 7; + let lp = LiteParse::new(cfg); + assert!(!lp.config().ocr_enabled); + assert_eq!(lp.config().max_pages, 7); + } +} diff --git a/crates/liteparse/src/projection.rs b/crates/liteparse/src/projection.rs new file mode 100644 index 0000000..f9f7859 --- /dev/null +++ b/crates/liteparse/src/projection.rs @@ -0,0 +1,5430 @@ +use crate::types::*; +use std::collections::{BTreeMap, HashMap, HashSet}; + +const FLOATING_SPACES: usize = 2; +const COLUMN_SPACES: usize = 4; + +// Flowing text detection constants +const FLOWING_MAX_TOTAL_ANCHORS: usize = 4; +const FLOWING_MAX_LEFT_ANCHORS: usize = 3; +const FLOWING_MIN_LINES: usize = 3; +const FLOWING_WIDE_LINE_RATIO: f32 = 0.5; +const FLOWING_WIDE_LINE_THRESHOLD: f32 = 0.6; +const FLOWING_COLUMN_GAP_MULTIPLIER: f32 = 4.0; +const FLOWING_MIN_LINE_ITEMS: usize = 3; +const FLOWING_SPACE_HEIGHT_RATIO: f32 = 0.15; +const FLOWING_SPACE_MIN_THRESHOLD: f32 = 0.3; +// Space threshold as a fraction of the line's median character advance +// (horizontal em proxy), used to bound the height-based threshold from above. +// `height*0.15` over-estimates the inter-word gap for tall display/heading +// fonts (their ascender+leading make them ~2.7× their advance vs ~2× for body +// text), so it sits above the genuine word gaps and swallows the spaces +// ("WhichJournalistsdoPeople"). 0.30 is the body-text crossover — body height ≈ +// 2×advance, so `advance*0.30 ≈ height*0.15` — so taking the smaller of the two +// leaves body text unchanged and only bites tall fonts. +const FLOWING_SPACE_ADV_RATIO: f32 = 0.30; +const FLOWING_MAX_INDENT: usize = 8; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SnapKind { + Left, + Right, + Center, +} + +struct LineRange { + start: usize, + end: usize, +} + +fn compute_median_textbox_size(items: &[ProjectedTextItem]) -> (f32, f32) { + if items.is_empty() { + return (0.0, 0.0); + } + + // Match TS behavior: median width is computed as average character width. + let mut widths: Vec = items + .iter() + .filter_map(|item| { + if item.item.width <= 0.0 { + return None; + } + let char_len = item.item.text.chars().count(); + if char_len == 0 { + return None; + } + Some(item.item.width / char_len as f32) + }) + .collect(); + let mut heights: Vec = items + .iter() + .filter_map(|item| { + if item.item.height > 0.0 { + Some(item.item.height) + } else { + None + } + }) + .collect(); + + if widths.is_empty() { + widths.push(1.0); + } + if heights.is_empty() { + heights.push(1.0); + } + + widths.sort_by(|a, b| a.total_cmp(b)); + heights.sort_by(|a, b| a.total_cmp(b)); + + let width_mid = widths.len() / 2; + let height_mid = heights.len() / 2; + + let median_width = if widths.len().is_multiple_of(2) { + (widths[width_mid - 1] + widths[width_mid]) / 2.0 + } else { + widths[width_mid] + }; + + let median_height = if heights.len().is_multiple_of(2) { + (heights[height_mid - 1] + heights[height_mid]) / 2.0 + } else { + heights[height_mid] + }; + + (median_width, median_height) +} + +fn canonical_rotation(rotation: f32) -> i32 { + let r = rotation.rem_euclid(360.0); + + let candidates = [0.0f32, 90.0, 180.0, 270.0]; + let mut best = 0.0f32; + let mut best_delta = f32::INFINITY; + for c in candidates { + // Circular angular distance, so rotations just under 360° are treated + // as near 0° (e.g. 359° is 1° from upright, not 89° from 270°). + let raw = (r - c).abs(); + let delta = raw.min(360.0 - raw); + if delta < best_delta { + best_delta = delta; + best = c; + } + } + + if best_delta <= 2.0 { + best as i32 + } else { + r.round() as i32 + } +} + +fn handle_rotation_reading_order(items: &mut [ProjectedTextItem], page_height: f32) { + if !items + .iter() + .any(|b| canonical_rotation(b.item.rotation) != 0) + { + return; + } + + // Group all items by rotation value. + let mut groups_by_rotation: HashMap> = HashMap::new(); + for (idx, bbox) in items.iter().enumerate() { + let r = canonical_rotation(bbox.item.rotation); + groups_by_rotation.entry(r).or_default().push(idx); + } + + // For 90/270° groups, further split into spatially distinct clusters by y proximity. + // This prevents e.g. top and bottom pin labels from being merged into one group. + let mut bbox_groups: Vec> = Vec::new(); + for (rot, mut group) in groups_by_rotation { + group.sort_by(|a, b| items[*a].item.y.total_cmp(&items[*b].item.y)); + if (rot == 90 || rot == 270) && group.len() > 1 { + // Split when y gap between consecutive items exceeds a threshold + let max_h = group + .iter() + .map(|idx| items[*idx].item.height) + .fold(0.0f32, |a, b| a.max(b)); + let gap_threshold = max_h * 3.0; + let mut cluster: Vec = vec![group[0]]; + for i in 1..group.len() { + let prev_bottom = items[group[i - 1]].item.y + items[group[i - 1]].item.height; + let cur_top = items[group[i]].item.y; + if cur_top - prev_bottom > gap_threshold { + bbox_groups.push(std::mem::take(&mut cluster)); + } + cluster.push(group[i]); + } + if !cluster.is_empty() { + bbox_groups.push(cluster); + } + } else { + bbox_groups.push(group); + } + } + + // Sort each subgroup by y. + for group in &mut bbox_groups { + group.sort_by(|a, b| items[*a].item.y.total_cmp(&items[*b].item.y)); + } + + bbox_groups.sort_by(|a, b| { + let min_x_a = a + .iter() + .map(|idx| items[*idx].item.x) + .fold(f32::INFINITY, |acc, v| acc.min(v)); + let min_x_b = b + .iter() + .map(|idx| items[*idx].item.x) + .fold(f32::INFINITY, |acc, v| acc.min(v)); + min_x_a.total_cmp(&min_x_b) + }); + + for group_idx in 0..bbox_groups.len() { + let group = bbox_groups[group_idx].clone(); + if group.is_empty() { + continue; + } + + let group_rotation = canonical_rotation(items[group[0]].item.rotation); + if group_rotation != 90 && group_rotation != 270 { + continue; + } + + // Long-span guard: a rotated group whose screen-space extent along its + // reading direction spans a large fraction of the page is almost + // always sidebar/marginalia (page footers, signature stamps, vertical + // disclaimers on legal/scanned docs) — never an inline label. Force the + // separate-block branch so it doesn't get flattened onto body lines. + // + // For 90/270° text the reading direction is screen-vertical, so the + // group's vertical screen span is the relevant axis. + let group_min_y = group + .iter() + .map(|idx| items[*idx].item.y) + .fold(f32::INFINITY, f32::min); + let group_max_y = group + .iter() + .map(|idx| items[*idx].item.y + items[*idx].item.height) + .fold(f32::NEG_INFINITY, f32::max); + let group_vspan = (group_max_y - group_min_y).max(0.0); + let long_span = page_height > 0.0 && group_vspan > page_height * 0.4; + + // Check if non-rotated/other-rotated items visually overlap or are near this group. + // Use a proximity margin so rotated labels in diagrams (e.g. pin diagrams) + // that are close to but don't strictly overlap non-rotated items are kept inline. + let mut global_overlap = false; + 'outer: for (other_idx, other_bbox) in items.iter().enumerate() { + let other_rot = canonical_rotation(other_bbox.item.rotation); + if other_rot == group_rotation { + continue; + } + + for group_item_idx in &group { + if *group_item_idx == other_idx { + continue; + } + let b = &items[*group_item_idx].item; + let o = &other_bbox.item; + // Proximity margin: use the max height of both items + let margin = b.height.max(o.height); + // Proper range overlap with margin on y-axis + let x_overlap = b.x < o.x + o.width && b.x + b.width > o.x; + let y_overlap = b.y < o.y + o.height + margin && b.y + b.height + margin > o.y; + if x_overlap && y_overlap { + global_overlap = true; + break 'outer; + } + } + } + + if global_overlap && !long_span { + // For 90/270° groups kept inline, compute a common y from the + // group's average vertical midpoint so all labels land on one row. + // Keep original w/h to preserve x-spacing between labels. + if group_rotation == 90 || group_rotation == 270 { + let avg_cy: f32 = group + .iter() + .map(|idx| items[*idx].item.y + items[*idx].item.height / 2.0) + .sum::() + / group.len() as f32; + let avg_w: f32 = group.iter().map(|idx| items[*idx].item.width).sum::() + / group.len() as f32; + let common_y = avg_cy - avg_w / 2.0; + + for idx in &group { + if items[*idx].d != 0.0 { + items[*idx].item.y += items[*idx].d; + items[*idx].d = 0.0; + } + items[*idx].item.y = common_y; + items[*idx].item.height = avg_w; + items[*idx].item.rotation = 0.0; + items[*idx].rotated = true; + } + } else { + for idx in &group { + if items[*idx].d != 0.0 { + items[*idx].item.y += items[*idx].d; + items[*idx].d = 0.0; + } + items[*idx].item.rotation = 0.0; + items[*idx].rotated = true; + } + } + } else { + let group_max_x = group + .iter() + .map(|idx| items[*idx].item.x + items[*idx].item.width) + .fold(f32::NEG_INFINITY, |acc, v| acc.max(v)); + + let mut delta_y = 0.0f32; + if group_idx != 0 { + let previous_group = &bbox_groups[group_idx - 1]; + let previous_group_max_y = previous_group + .iter() + .map(|idx| items[*idx].item.y + items[*idx].item.height) + .fold(f32::NEG_INFINITY, |acc, v| acc.max(v)); + delta_y = previous_group_max_y + page_height; + } + + if group_rotation == 90 { + for idx in &group { + let new_x = items[*idx].item.y.round(); + let new_y = items[*idx].item.x + delta_y; + let new_w = items[*idx].item.height; + let new_h = items[*idx].item.width; + items[*idx].item.x = new_x; + items[*idx].item.y = new_y; + items[*idx].item.width = new_w; + items[*idx].item.height = new_h; + items[*idx].item.rotation = 0.0; + items[*idx].rotated = true; + } + } + + if group_rotation == 270 { + let max_y = group + .iter() + .map(|idx| items[*idx].item.y + items[*idx].item.height) + .fold(f32::NEG_INFINITY, |acc, v| acc.max(v)); + for idx in &group { + let new_x = (max_y - items[*idx].item.y - items[*idx].item.height).round(); + let new_y = items[*idx].item.x + delta_y; + let new_w = items[*idx].item.height; + let new_h = items[*idx].item.width; + items[*idx].item.x = new_x; + items[*idx].item.y = new_y; + items[*idx].item.width = new_w; + items[*idx].item.height = new_h; + items[*idx].item.rotation = 0.0; + items[*idx].rotated = true; + } + } + + let global_delta = delta_y + group_max_x + page_height; + for other_group in &bbox_groups[(group_idx + 1)..] { + for idx in other_group { + let rot = canonical_rotation(items[*idx].item.rotation); + if rot == 90 || rot == 270 { + items[*idx].d += global_delta; + } else { + items[*idx].item.y += global_delta; + } + } + } + } + } + + // Handle 180-degree rotation conservatively. + // Unlike TS, we don't have extractor-provided rx/ry fields, so normalize to unrotated + // and preserve local ordering by x. + for group in &bbox_groups { + if group.is_empty() { + continue; + } + let rotation = canonical_rotation(items[group[0]].item.rotation); + if rotation == 180 { + let mut sorted = group.clone(); + sorted.sort_by(|a, b| items[*a].item.x.total_cmp(&items[*b].item.x)); + for idx in sorted { + items[idx].item.rotation = 0.0; + items[idx].rotated = true; + } + } + } + + items.sort_by(|a, b| a.item.y.total_cmp(&b.item.y)); +} + +fn clean_projected_items(items: &mut Vec, page_width: f32) { + // Rust equivalent of cleanRawText margin cleanup. + // Keep this conservative: only remove likely margin line numbers when they appear isolated. + let midpoint = page_width * 0.5; + let margin_left = midpoint - 5.0; + let margin_right = midpoint + 20.0; + + let mut has_non_margin_by_line: HashMap = HashMap::new(); + for item in items.iter() { + let line_key = item.item.y.round() as i32; + if !item.is_margin_line_number { + has_non_margin_by_line.insert(line_key, true); + } + } + + items.retain(|item| { + let line_key = item.item.y.round() as i32; + let line_has_content = has_non_margin_by_line + .get(&line_key) + .copied() + .unwrap_or(false); + let center = item.item.x + item.item.width * 0.5; + let text = item.item.text.trim(); + let looks_like_line_number = { + let chars: Vec = text.chars().collect(); + if chars.is_empty() || chars.len() > 3 { + false + } else { + let mut digit_count = 0usize; + let mut valid = true; + for (idx, c) in chars.iter().enumerate() { + if c.is_ascii_digit() { + digit_count += 1; + } else if *c == 'O' && idx == chars.len() - 1 { + // OCR confusion 0->O + } else { + valid = false; + break; + } + } + valid && (1..=2).contains(&digit_count) + } + }; + + let likely_margin = item.is_margin_line_number + || (center > margin_left + && center < margin_right + && looks_like_line_number + && item.item.width < 15.0); + + !likely_margin || line_has_content + }); +} + +fn form_lines( + items: &mut Vec, + median_width: f32, + median_height: f32, + page_width: f32, +) -> Vec> { + // Y-tolerance for sorting: items within this threshold are considered same line + let y_sort_tolerance: f32 = (median_height * 0.5).max(5.0); + + let dbg_lines = std::env::var("LITEPARSE_DEBUG_LINES").is_ok(); + let dump_lines = |stage: &str, lines: &[Vec]| { + if !dbg_lines { + return; + } + eprintln!( + "[form_lines:{stage}] {} lines (mh={median_height:.1})", + lines.len() + ); + for (li, ln) in lines.iter().enumerate() { + if ln.is_empty() { + continue; + } + let min_y = ln.iter().map(|i| i.item.y).fold(f32::INFINITY, f32::min); + let max_y = ln + .iter() + .map(|i| i.item.y + i.item.height) + .fold(f32::NEG_INFINITY, f32::max); + let txt: String = ln + .iter() + .map(|i| i.item.text.trim()) + .collect::>() + .join("|"); + eprintln!( + " [{li:2}] y={min_y:.1}..{max_y:.1} n={} {:.70}", + ln.len(), + txt + ); + } + }; + + // For two-column documents, detect and mark margin line numbers + if page_width > 0.0 { + let midpoint = page_width / 2.0; + let margin_left = midpoint - 5.0; + let margin_right = midpoint + 20.0; + + fn is_margin_line_number_text(text: &str) -> bool { + let trimmed = text.trim(); + if trimmed.is_empty() { + return false; + } + let chars: Vec = trimmed.chars().collect(); + if chars.len() > 3 { + return false; + } + + let mut digit_count = 0usize; + for (idx, c) in chars.iter().enumerate() { + if c.is_ascii_digit() { + digit_count += 1; + } else if *c == 'O' && idx == chars.len() - 1 { + // OCR confusion: 0 -> O + } else { + return false; + } + } + (1..=2).contains(&digit_count) + } + + for item in items.iter_mut() { + let center = item.item.x + item.item.width / 2.0; + + if center > margin_left + && center < margin_right + && is_margin_line_number_text(&item.item.text) + && item.item.width < 15.0 + { + item.is_margin_line_number = true; + } + } + } + + // Sort by y then x. Snap y to a grid so items on the same visual line + // get identical keys — this keeps the comparator transitive (total order). + let snap_y = |y: f32| -> i64 { + if y_sort_tolerance > 0.0 { + (y / y_sort_tolerance).round() as i64 + } else { + (y * 1000.0).round() as i64 + } + }; + items.sort_by(|a, b| { + let ya = snap_y(a.item.y); + let yb = snap_y(b.item.y); + ya.cmp(&yb).then_with(|| a.item.x.total_cmp(&b.item.x)) + }); + + fn can_merge( + prev: &ProjectedTextItem, + cur: &ProjectedTextItem, + y_tolerance: f32, + h_tolerance: f32, + ) -> bool { + if (cur.item.y - prev.item.y).abs() <= y_tolerance + && (cur.item.height - prev.item.height).abs() <= h_tolerance + { + let delta_x = cur.item.x - (prev.item.x + prev.item.width); + return (-0.5..0.0).contains(&delta_x) || (0.0..0.1).contains(&delta_x); + } + + false + } + + fn merge_bbox(prev: &ProjectedTextItem, cur: &ProjectedTextItem) -> (f32, f32, f32, f32) { + let x1 = prev.item.x.min(cur.item.x); + let y1 = prev.item.y.min(cur.item.y); + let x2 = (prev.item.x + prev.item.width).max(cur.item.x + cur.item.width); + let y2 = (prev.item.y + prev.item.height).max(cur.item.y + cur.item.height); + (x1, y1, x2 - x1, y2 - y1) + } + + fn merge_orig_bbox(prev: &mut ProjectedTextItem, cur: &ProjectedTextItem) { + let x1 = prev.orig_x.min(cur.orig_x); + let y1 = prev.orig_y.min(cur.orig_y); + let x2 = (prev.orig_x + prev.orig_width).max(cur.orig_x + cur.orig_width); + let y2 = (prev.orig_y + prev.orig_height).max(cur.orig_y + cur.orig_height); + + prev.orig_x = x1; + prev.orig_y = y1; + prev.orig_width = x2 - x1; + prev.orig_height = y2 - y1; + } + + // Merge continuous bbox items in a single linear pass. + let merge_y_tolerance = 0.1; + let merge_h_tolerance = 0.1; + + let mut merged_items: Vec = Vec::with_capacity(items.len()); + for cur in items.drain(..) { + let should_merge = merged_items + .last() + .map(|prev| can_merge(prev, &cur, merge_y_tolerance, merge_h_tolerance)) + .unwrap_or(false); + + if should_merge { + if let Some(prev) = merged_items.last_mut() { + let merged = merge_bbox(prev, &cur); + prev.item.text.push_str(&cur.item.text); + prev.item.x = merged.0; + prev.item.y = merged.1; + prev.item.width = merged.2; + prev.item.height = merged.3; + merge_orig_bbox(prev, &cur); + } + } else { + merged_items.push(cur); + } + } + + *items = merged_items; + + // try to find the bounding box that forms a line and group items by line + let mut lines: Vec> = Vec::new(); + let mut current_line: Vec = Vec::new(); + let mut current_line_min_y = f32::INFINITY; + let mut current_line_max_y = f32::NEG_INFINITY; + for item in items.drain(..) { + if !current_line.is_empty() { + let mut line_collide = false; + for line_item in current_line.iter() { + let overlap_length = (line_item.item.x + line_item.item.width) + .min(item.item.x + item.item.width) + - line_item.item.x.max(item.item.x); + + if overlap_length > f32::max(5.0, median_width / 3.0) { + line_collide = true; + break; + } + } + + // Don't merge margin line numbers with regular content + let cur_line_has_margin = current_line.iter().any(|i| i.is_margin_line_number); + let cur_item_has_margin = item.is_margin_line_number; + let margin_mismatch = cur_line_has_margin != cur_item_has_margin; + + // For rotated text, use y-tolerance based merging since heights may be inconsistent + let y_tolerance_merge = if item.rotated { + (median_height * 2.0).max(20.0) + } else { + 0.0 + }; + let y_within_tolerance = + item.rotated && (item.item.y - current_line_min_y).abs() < y_tolerance_merge; + + // Prevent "snowball" effect: when two columns have slightly offset + // y-values, the line range keeps expanding as items from alternating + // columns are added, merging multiple visual rows into one mega-line. + // Cap the line height to a reasonable multiple of median text height. + let proposed_min_y = current_line_min_y.min(item.item.y); + let proposed_max_y = current_line_max_y.max(item.item.y + item.item.height); + // too_tall threshold: prefer the smallest *trusted* item height + // already in the current line as the line-height baseline. PDFium + // reports inflated em-box heights for items with matrix-baked + // sizing (fs=1.0); using the global `median_height` then permits + // a real heading row to absorb the next visual row below it. + // Falls back to median_height when no smaller anchor is available. + let line_anchor_h = current_line + .iter() + .filter_map(|i| { + if matches!(i.item.font_size, Some(fs) if fs > 1.5) + || i.item.height < median_height + { + Some(i.item.height) + } else { + None + } + }) + .fold(f32::INFINITY, f32::min); + let too_tall_threshold = if line_anchor_h.is_finite() { + (line_anchor_h * 1.8).max(median_height * 0.6) + } else { + median_height * 1.8 + }; + let too_tall = (proposed_max_y - proposed_min_y) > too_tall_threshold; + + if !line_collide + && !margin_mismatch + && !too_tall + && (y_within_tolerance + || (item.item.y + item.item.height * 0.5 >= current_line_min_y + && item.item.y + item.item.height * 0.5 <= current_line_max_y) + || (item.item.y >= current_line_min_y && item.item.y <= current_line_max_y)) + { + current_line_min_y = current_line_min_y.min(item.item.y); + current_line_max_y = current_line_max_y.max(item.item.y + item.item.height); + current_line.push(item); + } else { + lines.push(std::mem::take(&mut current_line)); + current_line_min_y = item.item.y; + current_line_max_y = item.item.y + item.item.height; + current_line.push(item); + } + } else { + current_line_min_y = item.item.y; + current_line_max_y = item.item.y + item.item.height; + current_line.push(item); + } + } + + if !current_line.is_empty() { + lines.push(current_line); + } + + // sort each line by x + for line in lines.iter_mut() { + line.sort_by(|a, b| a.item.x.total_cmp(&b.item.x)); + } + + // sort lines by y + lines.sort_by(|a, b| { + let ay = a.first().map(|v| v.item.y).unwrap_or(f32::INFINITY); + let by = b.first().map(|v| v.item.y).unwrap_or(f32::INFINITY); + ay.total_cmp(&by) + }); + + dump_lines("after_group", &lines); + + // merge 'words' + const MERGE_THRESHOLD: f32 = 1.0; + + fn looks_like_table_number(text: &str) -> bool { + // Strip trailing statistical-significance markers ("0.77**", "0.31 *") + // so decorated correlation/p-value cells still count as numbers. Without + // this, the `both_are_numbers` guard below misfires and two adjacent + // stat cells get merged into one, destroying the table's column tracks. + let trimmed = text + .trim() + .trim_end_matches(|c: char| c == '*' || c == '†' || c == '‡' || c.is_whitespace()); + if trimmed.chars().count() < 2 { + return false; + } + + let mut chars = trimmed.chars().peekable(); + if matches!(chars.peek(), Some('$')) { + chars.next(); + } + if matches!(chars.peek(), Some('-')) { + chars.next(); + } + + let mut has_digit = false; + let mut has_decimal = false; + for c in chars { + if c.is_ascii_digit() { + has_digit = true; + } else if c == ',' { + continue; + } else if c == '.' { + if has_decimal { + return false; + } + has_decimal = true; + } else if c == '%' { + return has_digit && trimmed.ends_with('%'); + } else { + return false; + } + } + + has_digit + } + + for line in lines.iter_mut() { + let mut merged_line: Vec = Vec::with_capacity(line.len()); + for item in line.drain(..) { + if let Some(prev) = merged_line.last_mut() { + let both_are_numbers = looks_like_table_number(&prev.item.text) + && looks_like_table_number(&item.item.text); + + let delta_x = item.item.x - prev.item.x - prev.item.width; + // Don't merge items with noticeably different y positions (>1.5px). + // Items on the same baseline typically differ by <0.5px. + let y_diff = (item.item.y - prev.item.y).abs(); + let y_compatible = y_diff <= 1.5; + + // Decimal-fragment continuation: PDFium sometimes emits the + // fractional part of a number ("15.6", "200.4") as a separate, + // x-contiguous run beginning with the decimal point (".6"). + // `both_are_numbers` below treats them as two distinct table + // values and keeps them apart, leaving "15 .6" in the rendered + // cell and injecting a spurious column track between them. A + // real pair of adjacent table numbers always has a visible + // inter-cell gap, never a digit butted directly against a + // leading-decimal run, so this is safe to join silently. + let is_decimal_continuation = y_compatible + && (-2.0..=1.0).contains(&delta_x) + && prev + .item + .text + .chars() + .last() + .is_some_and(|c| c.is_ascii_digit()) + && { + let mut cs = item.item.text.chars(); + cs.next() == Some('.') && cs.next().is_some_and(|c| c.is_ascii_digit()) + }; + if is_decimal_continuation { + prev.item.width = item.item.x + item.item.width - prev.item.x; + prev.item.text.push_str(&item.item.text); + merge_orig_bbox(prev, &item); + continue; + } + + // Word-boundary guard: when prev ends with an alphabetic + // character and item starts with one, a positive delta_x is + // very likely a missing-space gap (PDFium omitted the space + // glyph between two words), not a deliberate silent join. + // Silent-merging here fuses "of the" → "ofthe". Skip the + // silent path so the items stay separate and the line + // builder inserts a space based on `gap > space_threshold`. + // Punctuation joins (`Standards`+`.`, `width`+`)`, etc.) + // and overlapping/negative-gap merges still go through. + let alpha_alpha_gap = delta_x > 0.0 + && prev + .item + .text + .chars() + .last() + .is_some_and(|c| c.is_alphabetic()) + && item + .item + .text + .chars() + .next() + .is_some_and(|c| c.is_alphabetic()); + if y_compatible + && !both_are_numbers + && !alpha_alpha_gap + && delta_x <= MERGE_THRESHOLD + { + prev.item.width = item.item.x + item.item.width - prev.item.x; + prev.item.text.push_str(&item.item.text); + merge_orig_bbox(prev, &item); + continue; + } + + let prev_len = prev.item.text.chars().count().max(1) as f32; + let avg_char_width = prev.item.width / prev_len; + if y_compatible && !both_are_numbers && delta_x < avg_char_width { + prev.item.width = item.item.x + item.item.width - prev.item.x; + if !prev.item.text.ends_with(' ') { + prev.item.text.push(' '); + } + prev.item.text.push_str(&item.item.text); + merge_orig_bbox(prev, &item); + continue; + } + } + + merged_line.push(item); + } + + *line = merged_line; + } + + // Merge overlapping lines when there is no horizontal bbox overlap. + let mut i = 1usize; + while i < lines.len() { + let (previous_min_y, previous_max_y) = { + let previous = &lines[i - 1]; + let min_y = previous + .iter() + .map(|v| v.item.y) + .fold(f32::INFINITY, |a, b| a.min(b)); + let max_y = previous + .iter() + .map(|v| v.item.y + v.item.height) + .fold(f32::NEG_INFINITY, |a, b| a.max(b)); + (min_y, max_y) + }; + + let (current_min_y, current_max_y) = { + let current = &lines[i]; + let min_y = current + .iter() + .map(|v| v.item.y) + .fold(f32::INFINITY, |a, b| a.min(b)); + let max_y = current + .iter() + .map(|v| v.item.y + v.item.height) + .fold(f32::NEG_INFINITY, |a, b| a.max(b)); + (min_y, max_y) + }; + + // Do the two lines overlap vertically? + let lines_overlap = previous_max_y > current_min_y && previous_min_y < current_max_y; + + if lines_overlap { + let bbox_overlap = { + let previous = &lines[i - 1]; + let current = &lines[i]; + current.iter().any(|bbox| { + previous.iter().any(|prev_bbox| { + (bbox.item.x >= prev_bbox.item.x + && bbox.item.x <= prev_bbox.item.x + prev_bbox.item.width) + || (prev_bbox.item.x >= bbox.item.x + && prev_bbox.item.x <= bbox.item.x + bbox.item.width) + }) + }) + }; + + if !bbox_overlap { + if dbg_lines { + let pj: String = lines[i - 1] + .iter() + .map(|x| x.item.text.trim()) + .collect::>() + .join("|"); + let cj: String = lines[i] + .iter() + .map(|x| x.item.text.trim()) + .collect::>() + .join("|"); + eprintln!( + "[form_lines:overlap-merge] py={previous_min_y:.1}..{previous_max_y:.1} cy={current_min_y:.1}..{current_max_y:.1}\n prev={pj:.60}\n cur ={cj:.60}" + ); + } + let mut current = lines.remove(i); + lines[i - 1].append(&mut current); + lines[i - 1].sort_by(|a, b| a.item.x.total_cmp(&b.item.x)); + continue; + } + } + + i += 1; + } + + dump_lines("after_overlap_merge", &lines); + + // Insert blank lines for vertical gaps between lines. + let mut i = 1; + while i < lines.len() { + let prev_metrics = representative_line_metrics(&lines[i - 1], median_height); + let cur_metrics = representative_line_metrics(&lines[i], median_height); + let y_delta = cur_metrics.0 - prev_metrics.1; // cur_top - prev_bottom + let reference_height = median_height.max(prev_metrics.2.min(cur_metrics.2)); + + if y_delta > reference_height { + let num_blank = ((y_delta / reference_height).round() as usize).saturating_sub(1); + let to_insert = num_blank.clamp(1, 10); + for _ in 0..to_insert { + lines.insert(i, Vec::new()); + i += 1; + } + } + i += 1; + } + + lines +} + +/// Returns (top, bottom, height) for representative items in a line, +/// filtering out items much shorter than median height. +fn representative_line_metrics( + line: &[ProjectedTextItem], + global_median_height: f32, +) -> (f32, f32, f32) { + if line.is_empty() { + return (0.0, 0.0, 0.0); + } + + let min_representative = global_median_height * 0.5; + let has_representative = line.iter().any(|b| b.item.height >= min_representative); + + let top = line + .iter() + .filter(|b| !has_representative || b.item.height >= min_representative) + .map(|b| b.item.y) + .fold(f32::INFINITY, f32::min); + let bottom = line + .iter() + .filter(|b| !has_representative || b.item.height >= min_representative) + .map(|b| b.item.y + b.item.height) + .fold(f32::NEG_INFINITY, f32::max); + (top, bottom, bottom - top) +} + +#[derive(Clone, Debug, Default)] +struct BoxMeta { + left_anchor: Option, + right_anchor: Option, + center_anchor: Option, + snap: Option, + should_space: usize, + force_unsnapped: bool, + rendered: bool, + projected_x: usize, +} + +fn anchor_key(x: f32) -> i32 { + (x * 4.0).round() as i32 +} + +fn anchor_to_x(key: i32) -> f32 { + key as f32 / 4.0 +} + +/// Visual (character) length of a string, not byte length. +/// Use this for column calculations instead of `.len()`. +fn char_len(s: &str) -> usize { + s.chars().count() +} + +fn trim_end_len(s: &str) -> usize { + char_len(s.trim_end()) +} + +fn trim_end_in_place(s: &mut String) { + let trimmed = s.trim_end().len(); // byte len for truncate + s.truncate(trimmed); +} + +fn line_space_end(raw_line: &str, should_space: usize) -> usize { + let mut space_end = 0usize; + if !raw_line.ends_with(' ') { + space_end = should_space; + } + if should_space > 1 { + let trailing_spaces = char_len(raw_line).saturating_sub(trim_end_len(raw_line)); + if trailing_spaces < should_space { + space_end = should_space - trailing_spaces; + } + } + space_end +} + +fn can_render_bbox(meta_line: &[BoxMeta], idx: usize) -> bool { + for m in meta_line.iter().take(idx) { + if !m.rendered { + return false; + } + } + true +} + +fn merge_nearby_anchor_groups(collection: &mut HashMap>) { + const MERGE_TOLERANCE: i32 = 8; // 2 units in quarter-point anchor key space + + let sorted_keys: Vec = { + let mut keys: Vec = collection.keys().copied().collect(); + keys.sort_unstable(); + keys + }; + + for (i, anchor) in sorted_keys.iter().enumerate() { + if !collection.contains_key(anchor) { + continue; + } + for next_anchor in sorted_keys.iter().skip(i + 1) { + if !collection.contains_key(next_anchor) { + continue; + } + if next_anchor - anchor > MERGE_TOLERANCE { + break; + } + + let current_len = collection.get(anchor).map(|v| v.len()).unwrap_or(0); + let next_len = collection.get(next_anchor).map(|v| v.len()).unwrap_or(0); + + if next_len > current_len { + if let Some(cur_items) = collection.remove(anchor) + && let Some(next_items) = collection.get_mut(next_anchor) + { + next_items.extend(cur_items); + } + break; + } else if let Some(next_items) = collection.remove(next_anchor) + && let Some(cur_items) = collection.get_mut(anchor) + { + cur_items.extend(next_items); + } + } + } +} + +fn update_forward_anchor_right_bound( + snap_map: &[i32], + forward_anchor: &mut BTreeMap, + right_bound: i32, + anchor_target: usize, +) { + const POSITION_TOLERANCE: i32 = 8; // 2 units in quarter-point anchor key space + + for (idx, anchor) in snap_map.iter().enumerate().rev() { + if *anchor < right_bound { + return; + } + + let entry = forward_anchor.entry(*anchor).or_insert(0); + if anchor_target > *entry { + *entry = anchor_target; + } + + let mut j = idx; + while j > 0 { + let nearby_anchor = snap_map[j - 1]; + if *anchor - nearby_anchor > POSITION_TOLERANCE { + break; + } + let nearby_entry = forward_anchor.entry(nearby_anchor).or_insert(0); + if anchor_target > *nearby_entry { + *nearby_entry = anchor_target; + } + j -= 1; + } + } +} + +fn compress_wide_spaces(line: &str, min_run: usize, replace_with: usize) -> String { + let mut out = String::with_capacity(line.len()); + let bytes = line.as_bytes(); + let mut i = 0usize; + while i < bytes.len() { + if bytes[i] == b' ' { + let start = i; + while i < bytes.len() && bytes[i] == b' ' { + i += 1; + } + let run_len = i - start; + if run_len >= min_run { + out.push_str(&" ".repeat(replace_with)); + } else { + out.push_str(&" ".repeat(run_len)); + } + } else { + out.push(bytes[i] as char); + i += 1; + } + } + out +} + +fn fix_sparse_blocks(raw_lines: &mut [String], start: usize, end: usize) { + let mut total = 0usize; + let mut whitespace = 0usize; + + for line in raw_lines.iter_mut().take(end).skip(start) { + trim_end_in_place(line); + if line.is_empty() { + continue; + } + total += line.len(); + whitespace += line.chars().filter(|c| c.is_whitespace()).count(); + } + + if total >= 500 && (whitespace as f32 / total as f32) > 0.8 { + for line in raw_lines.iter_mut().take(end).skip(start) { + if line.is_empty() { + continue; + } + *line = compress_wide_spaces(line, COLUMN_SPACES, FLOATING_SPACES); + } + } +} + +// --------------------------------------------------------------------------- +// Block segmentation & flowing text detection +// --------------------------------------------------------------------------- + +/// Segments page lines into blocks separated by double blank lines. +fn segment_blocks(lines: &[Vec]) -> Vec { + let mut blocks = Vec::new(); + let mut empty_count = 0usize; + let mut start: Option = None; + + for (line_idx, line) in lines.iter().enumerate() { + if line.is_empty() { + empty_count += 1; + if empty_count > 1 { + if let Some(s) = start { + // Include the trailing double-blank at the end of the block + blocks.push(LineRange { + start: s, + end: line_idx + 1, + }); + } + start = None; + } + } else { + empty_count = 0; + if start.is_none() { + start = Some(line_idx); + } + } + } + + if let Some(s) = start { + blocks.push(LineRange { + start: s, + end: lines.len(), + }); + } + + // If no blocks found, treat entire page as one block + if blocks.is_empty() && !lines.is_empty() { + blocks.push(LineRange { + start: 0, + end: lines.len(), + }); + } + + blocks +} + +/// Extract anchor maps for a single block of lines. +/// Returns (anchor_left, anchor_right, anchor_center) with absolute line indices. +fn extract_block_anchors( + lines: &[Vec], + block: &LineRange, +) -> (AnchorMap, AnchorMap, AnchorMap) { + let mut anchor_left: AnchorMap = HashMap::new(); + let mut anchor_right: AnchorMap = HashMap::new(); + let mut anchor_center: AnchorMap = HashMap::new(); + + for (line_idx, line) in lines.iter().enumerate().take(block.end).skip(block.start) { + for (box_idx, bbox) in line.iter().enumerate() { + // Skip rotated items from anchor detection — their coordinates are + // transformed and would create spurious column anchors that stretch + // the layout. They'll render as floating items at their natural positions. + if bbox.rotated { + continue; + } + let left_key = anchor_key(bbox.item.x); + let right_key = anchor_key(bbox.item.x + bbox.item.width); + let center_key = anchor_key(bbox.item.x + bbox.item.width * 0.5); + anchor_left + .entry(left_key) + .or_default() + .push((line_idx, box_idx)); + anchor_right + .entry(right_key) + .or_default() + .push((line_idx, box_idx)); + anchor_center + .entry(center_key) + .or_default() + .push((line_idx, box_idx)); + } + } + + (anchor_left, anchor_right, anchor_center) +} + +/// Remove vertically isolated items from anchor groups. +/// Items must have a neighbor within `page_height * delta` to survive. +fn delta_min_filter( + collection: &mut HashMap>, + lines: &[Vec], + page_height: f32, + delta: f32, +) { + let max_delta = page_height * delta; + + for members in collection.values_mut() { + // Sort members by y coordinate + members.sort_by(|a, b| { + let ya = lines[a.0][a.1].item.y; + let yb = lines[b.0][b.1].item.y; + ya.total_cmp(&yb) + }); + + let mut keep = vec![false; members.len()]; + for i in 0..members.len() { + let y_cur = lines[members[i].0][members[i].1].item.y; + if i > 0 { + let y_prev = lines[members[i - 1].0][members[i - 1].1].item.y; + if y_cur - y_prev < max_delta { + keep[i] = true; + keep[i - 1] = true; + } + } + if i + 1 < members.len() { + let y_next = lines[members[i + 1].0][members[i + 1].1].item.y; + if y_next - y_cur < max_delta { + keep[i] = true; + } + } + } + + let mut idx = 0; + members.retain(|_| { + let k = keep[idx]; + idx += 1; + k + }); + } + + collection.retain(|_, v| !v.is_empty()); +} + +/// Remove anchors where text from other items visually crosses the anchor x-position +/// between every consecutive pair of anchor members. +fn intercept_filter( + collection: &mut HashMap>, + lines: &[Vec], +) { + let anchors_to_remove: Vec = collection + .iter() + .filter_map(|(anchor_key_val, members)| { + if members.len() < 2 { + return None; + } + + let anchor_x = anchor_to_x(*anchor_key_val); + let mut any_pair_clear = false; + + for i in 1..members.len() { + let y1 = lines[members[i - 1].0][members[i - 1].1].item.y; + let y2 = lines[members[i].0][members[i].1].item.y; + let (y_min, y_max) = if y1 < y2 { (y1, y2) } else { (y2, y1) }; + + let mut intercepted = false; + for line in lines.iter() { + if line.is_empty() { + continue; + } + let line_y = line[0].item.y; + if line_y > y_min && line_y < y_max { + for item in line { + if item.item.x < anchor_x && item.item.x + item.item.width > anchor_x { + intercepted = true; + break; + } + } + if intercepted { + break; + } + } + } + + if !intercepted { + any_pair_clear = true; + break; + } + } + + if !any_pair_clear { + Some(*anchor_key_val) + } else { + None + } + }) + .collect(); + + for key in anchors_to_remove { + collection.remove(&key); + } +} + +/// Try to align floating bboxes (not in any surviving anchor) to nearby anchors +/// on adjacent lines within the given margin. +fn try_align_floating( + target: &mut HashMap>, + lines: &[Vec], + block: &LineRange, + anchored: &HashSet<(usize, usize)>, + margin: f32, + ref_x_fn: fn(&TextItem) -> f32, + anchor_key_fn: fn(&TextItem) -> i32, +) { + let mut additions: Vec<(i32, (usize, usize))> = Vec::new(); + + for line_idx in block.start..block.end { + for box_idx in 0..lines[line_idx].len() { + if anchored.contains(&(line_idx, box_idx)) { + continue; + } + // Skip rotated items — they are excluded from anchor extraction + // and should remain floating to avoid spurious snap assignments. + if lines[line_idx][box_idx].rotated { + continue; + } + + let ref_x = ref_x_fn(&lines[line_idx][box_idx].item); + + // Check adjacent lines for candidate anchors + let mut candidate_anchor: Option = None; + let mut prev_diff = margin + 1.0; + + let adj_lines: [Option; 2] = [ + if line_idx > block.start { + Some(line_idx - 1) + } else { + None + }, + if line_idx + 1 < block.end { + Some(line_idx + 1) + } else { + None + }, + ]; + + for adj_opt in &adj_lines { + let Some(adj_line_idx) = adj_opt else { + continue; + }; + for adj_box in &lines[*adj_line_idx] { + let cand_key = anchor_key_fn(&adj_box.item); + if !target.contains_key(&cand_key) { + continue; + } + let x_diff = (anchor_to_x(cand_key) - ref_x).abs(); + if x_diff <= margin && x_diff < prev_diff { + candidate_anchor = Some(cand_key); + prev_diff = x_diff; + } + } + } + + if let Some(key) = candidate_anchor { + additions.push((key, (line_idx, box_idx))); + } + } + } + + // Apply additions after iteration to avoid borrow conflicts + for (key, item) in additions { + if let Some(members) = target.get_mut(&key) + && !members.contains(&item) + { + members.push(item); + } + } +} + +/// Maximum horizontal gap between consecutive items on a line. +fn line_max_gap(line: &[ProjectedTextItem]) -> f32 { + let mut max_gap: f32 = 0.0; + for i in 1..line.len() { + let gap = line[i].item.x - (line[i - 1].item.x + line[i - 1].item.width); + if gap > max_gap { + max_gap = gap; + } + } + max_gap +} + +/// Check if a line has a column-like gap: one gap that is much larger than +/// the typical inter-word gaps on the same line. This catches two-column +/// layouts where the absolute column gap is below the threshold but is +/// clearly an outlier relative to other gaps on the line. +fn line_has_column_gap(line: &[ProjectedTextItem], median_width: f32, page_width: f32) -> bool { + if line.len() < 2 { + return false; + } + let midpoint = page_width * 0.5; + for i in 1..line.len() { + let prev_end = line[i - 1].item.x + line[i - 1].item.width; + let cur_start = line[i].item.x; + let gap = cur_start - prev_end; + // A gap is a column separator if it's large enough (>2x median char width) + // AND items on either side span across the page midpoint. + if gap > median_width * 2.0 && prev_end < midpoint && cur_start > midpoint { + return true; + } + } + false +} + +/// Check if a block of lines is flowing paragraph text (vs structured/tabular). +fn is_flowing_text_block( + lines: &[Vec], + block: &LineRange, + anchor_left: &HashMap>, + anchor_right: &HashMap>, + anchor_center: &HashMap>, + page_width: f32, + median_width: f32, +) -> bool { + let total_anchors = anchor_left.len() + anchor_right.len() + anchor_center.len(); + if total_anchors > FLOWING_MAX_TOTAL_ANCHORS { + return false; + } + if anchor_left.len() > FLOWING_MAX_LEFT_ANCHORS { + return false; + } + + let mut non_empty_lines = 0usize; + let mut wide_lines = 0usize; + let mut column_gap_lines = 0usize; + + for line in lines.iter().take(block.end).skip(block.start) { + if line.is_empty() { + continue; + } + non_empty_lines += 1; + + let line_start = line[0].item.x; + let line_end = line.last().map(|b| b.item.x + b.item.width).unwrap_or(0.0); + if line_end - line_start > page_width * FLOWING_WIDE_LINE_RATIO { + wide_lines += 1; + } + if line_has_column_gap(line, median_width, page_width) { + column_gap_lines += 1; + } + } + + if non_empty_lines < FLOWING_MIN_LINES { + return false; + } + + // If multiple lines have column gaps, this is a multi-column block, + // not flowing text. + if column_gap_lines >= 2 { + return false; + } + + (wide_lines as f32 / non_empty_lines as f32) > FLOWING_WIDE_LINE_THRESHOLD +} + +/// Render a single line as flowing text with indentation. +fn render_line_as_flowing_text( + line: &mut [ProjectedTextItem], + min_x: f32, + median_width: f32, + meta_line: &mut [BoxMeta], +) -> String { + if line.is_empty() { + return String::new(); + } + + let indent = ((line[0].item.x - min_x) / median_width) + .round() + .max(0.0) + .min(FLOWING_MAX_INDENT as f32) as usize; + + let mut result = " ".repeat(indent); + + // Median per-glyph advance across the line, used to bound the space + // threshold so tall display fonts don't get a threshold above their real + // word gaps. `+inf` (no bound) when the line has no measurable advances. + let adv_threshold = { + let mut advs: Vec = line + .iter() + .filter_map(|it| { + let n = it.item.text.chars().filter(|c| !c.is_whitespace()).count(); + (n > 0 && it.item.width > 0.0).then(|| it.item.width / n as f32) + }) + .collect(); + if advs.is_empty() { + f32::INFINITY + } else { + advs.sort_by(f32::total_cmp); + advs[advs.len() / 2] * FLOWING_SPACE_ADV_RATIO + } + }; + + for i in 0..line.len() { + if i > 0 { + let prev = &line[i - 1].item; + let cur = &line[i].item; + let gap = cur.x - (prev.x + prev.width); + let space_threshold = (cur.height * FLOWING_SPACE_HEIGHT_RATIO) + .min(adv_threshold) + .max(FLOWING_SPACE_MIN_THRESHOLD); + if gap > space_threshold && !result.ends_with(' ') { + result.push(' '); + } + } + result.push_str(&line[i].item.text); + meta_line[i].rendered = true; + line[i].rendered = true; + } + + result +} + +/// Render an entire block as flowing text (all lines). +fn render_flowing_block( + lines: &mut [Vec], + block: &LineRange, + raw_lines: &mut [String], + meta: &mut [Vec], + median_width: f32, +) { + let mut min_x = f32::INFINITY; + for line in lines.iter().take(block.end).skip(block.start) { + if !line.is_empty() { + min_x = min_x.min(line[0].item.x); + } + } + if min_x == f32::INFINITY { + min_x = 0.0; + } + + for line_idx in block.start..block.end { + if lines[line_idx].is_empty() { + continue; + } + raw_lines[line_idx] = render_line_as_flowing_text( + &mut lines[line_idx], + min_x, + median_width, + &mut meta[line_idx], + ); + } +} + +/// Per-line flowing detection within structured blocks. +/// Identifies individual lines that should be rendered as flowing text. +fn detect_and_render_flowing_lines( + lines: &mut [Vec], + block: &LineRange, + raw_lines: &mut [String], + meta: &mut [Vec], + median_width: f32, + page_width: f32, +) { + let column_gap_threshold = median_width * FLOWING_COLUMN_GAP_MULTIPLIER; + + // Find block's left margin + let mut block_min_x = f32::INFINITY; + for line in lines.iter().take(block.end).skip(block.start) { + if !line.is_empty() { + block_min_x = block_min_x.min(line[0].item.x); + } + } + if block_min_x == f32::INFINITY { + block_min_x = 0.0; + } + + let mut flowing_lines: HashSet = HashSet::new(); + + // First pass: detect clearly flowing lines (wide span, no column gaps, enough items) + for line_idx in block.start..block.end { + let line = &lines[line_idx]; + if line.len() < FLOWING_MIN_LINE_ITEMS { + continue; + } + + let line_start = line[0].item.x; + let line_end = line.last().map(|b| b.item.x + b.item.width).unwrap_or(0.0); + let line_span = line_end - line_start; + + // Skip lines where items belong to multiple different snap groups + // (e.g., left-column and right-column items on the same line bridged + // by a margin line number) + let has_mixed_snaps = { + let mut first_snap: Option = None; + let mut mixed = false; + for m in meta[line_idx].iter() { + if let Some(s) = m.snap { + match first_snap { + None => first_snap = Some(s), + Some(fs) if fs != s => { + mixed = true; + break; + } + _ => {} + } + } + } + mixed + }; + + if !has_mixed_snaps + && line_span > page_width * FLOWING_WIDE_LINE_RATIO + && line_max_gap(line) < column_gap_threshold + && !line_has_column_gap(line, median_width, page_width) + { + flowing_lines.insert(line_idx); + } + } + + // Forward sweep: propagate flowing status downward + for line_idx in block.start..block.end { + let line = &lines[line_idx]; + if flowing_lines.contains(&line_idx) || line.is_empty() { + continue; + } + let has_mixed_snaps = { + let mut first_snap: Option = None; + let mut mixed = false; + for m in meta[line_idx].iter() { + if let Some(s) = m.snap { + match first_snap { + None => first_snap = Some(s), + Some(fs) if fs != s => { + mixed = true; + break; + } + _ => {} + } + } + } + mixed + }; + if !has_mixed_snaps + && line_idx > block.start + && flowing_lines.contains(&(line_idx - 1)) + && line_max_gap(line) < column_gap_threshold + && !line_has_column_gap(line, median_width, page_width) + { + flowing_lines.insert(line_idx); + } + } + + // Backward sweep: propagate flowing status upward + for line_idx in (block.start..block.end).rev() { + if flowing_lines.contains(&line_idx) || lines[line_idx].is_empty() { + continue; + } + let has_mixed_snaps = { + let mut first_snap: Option = None; + let mut mixed = false; + for m in meta[line_idx].iter() { + if let Some(s) = m.snap { + match first_snap { + None => first_snap = Some(s), + Some(fs) if fs != s => { + mixed = true; + break; + } + _ => {} + } + } + } + mixed + }; + if !has_mixed_snaps + && line_idx + 1 < block.end + && flowing_lines.contains(&(line_idx + 1)) + && line_max_gap(&lines[line_idx]) < column_gap_threshold + && !line_has_column_gap(&lines[line_idx], median_width, page_width) + { + flowing_lines.insert(line_idx); + } + } + + // Render flowing lines + for &line_idx in &flowing_lines { + raw_lines[line_idx] = render_line_as_flowing_text( + &mut lines[line_idx], + block_min_x, + median_width, + &mut meta[line_idx], + ); + } +} + +// --------------------------------------------------------------------------- +// Main grid projection +// --------------------------------------------------------------------------- + +fn project_to_grid( + page: &Page, + mut projection_boxes: Vec, +) -> (Vec, String) { + if projection_boxes.is_empty() { + return (Vec::new(), String::new()); + } + + // Filter out items that are purely dots + let mut dot_count = 0usize; + projection_boxes.iter().for_each(|item| { + if item + .item + .text + .chars() + .all(|c| c == '.' || c == '·' || c == '•') + { + dot_count += 1; + } + }); + + if dot_count > 100 && (dot_count as f64) > (projection_boxes.len() as f64) * 0.05 { + projection_boxes.retain(|item| { + !item + .item + .text + .chars() + .all(|c| c == '.' || c == '·' || c == '•') + }); + } + + // Round dimensions + for item in projection_boxes.iter_mut() { + item.item.width = item.item.width.round(); + item.item.height = item.item.height.round(); + } + + // Compute median distances + let (median_width, median_height) = compute_median_textbox_size(&projection_boxes); + + // Handle reading order rotations + handle_rotation_reading_order(&mut projection_boxes, page.page_height); + + // Form lines of boxes + let mut lines = form_lines( + &mut projection_boxes, + median_width, + median_height, + page.page_width, + ); + if lines.is_empty() { + return (Vec::new(), String::new()); + } + + // Segment into blocks + let blocks = segment_blocks(&lines); + + let debug = std::env::var("LITEPARSE_DEBUG").is_ok(); + if debug { + eprintln!("[debug] median_width={median_width:.2}, median_height={median_height:.2}"); + eprintln!("[debug] {} blocks, {} lines", blocks.len(), lines.len()); + for (i, b) in blocks.iter().enumerate() { + eprintln!("[debug] block {i}: lines {}-{}", b.start, b.end); + } + } + + let mut meta: Vec> = lines + .iter() + .map(|line| vec![BoxMeta::default(); line.len()]) + .collect(); + + let mut raw_lines = vec![String::new(); lines.len()]; + + // Page-scoped forward anchors (carry alignment across blocks) + let mut forward_left: BTreeMap = BTreeMap::new(); + let mut forward_right: BTreeMap = BTreeMap::new(); + let mut forward_center: BTreeMap = BTreeMap::new(); + let mut forward_floating: BTreeMap = BTreeMap::new(); + + for block in &blocks { + // --- Anchor extraction (per block) --- + let (mut anchor_left, mut anchor_right, mut anchor_center) = + extract_block_anchors(&lines, block); + + merge_nearby_anchor_groups(&mut anchor_left); + merge_nearby_anchor_groups(&mut anchor_right); + merge_nearby_anchor_groups(&mut anchor_center); + + // Isolation filtering: remove vertically isolated anchor members + delta_min_filter(&mut anchor_left, &lines, page.page_height, 0.25); + delta_min_filter(&mut anchor_right, &lines, page.page_height, 0.17); + delta_min_filter(&mut anchor_center, &lines, page.page_height, 0.05); + + // Intercept filtering: remove anchors crossed by other text + intercept_filter(&mut anchor_left, &lines); + intercept_filter(&mut anchor_right, &lines); + intercept_filter(&mut anchor_center, &lines); + + // Try to align floating bboxes to nearby existing anchors + // Align floating items to nearby anchors. Use type-specific skip sets + // so items with e.g. only a left anchor can still be aligned to a right anchor. + let left_anchored: HashSet<(usize, usize)> = anchor_left + .values() + .flat_map(|v| v.iter().copied()) + .collect(); + try_align_floating( + &mut anchor_left, + &lines, + block, + &left_anchored, + 4.0, + |item| item.x, + |item| anchor_key(item.x), + ); + let right_anchored: HashSet<(usize, usize)> = anchor_right + .values() + .flat_map(|v| v.iter().copied()) + .collect(); + try_align_floating( + &mut anchor_right, + &lines, + block, + &right_anchored, + 4.0, + |item| item.x + item.width, + |item| anchor_key(item.x + item.width), + ); + let center_anchored: HashSet<(usize, usize)> = anchor_center + .values() + .flat_map(|v| v.iter().copied()) + .collect(); + try_align_floating( + &mut anchor_center, + &lines, + block, + ¢er_anchored, + 4.0, + |item| item.x + item.width * 0.5, + |item| anchor_key(item.x + item.width * 0.5), + ); + + // Remove singletons + anchor_left.retain(|_, v| v.len() >= 2); + anchor_right.retain(|_, v| v.len() >= 2); + anchor_center.retain(|_, v| v.len() >= 2); + + // --- Flowing block detection --- + if is_flowing_text_block( + &lines, + block, + &anchor_left, + &anchor_right, + &anchor_center, + page.page_width, + median_width, + ) { + render_flowing_block(&mut lines, block, &mut raw_lines, &mut meta, median_width); + continue; + } + + // --- Assign anchors to items in this block --- + for (anchor, members) in &anchor_left { + for &(li, bi) in members { + meta[li][bi].left_anchor = Some(*anchor); + } + } + for (anchor, members) in &anchor_right { + for &(li, bi) in members { + meta[li][bi].right_anchor = Some(*anchor); + } + } + for (anchor, members) in &anchor_center { + for &(li, bi) in members { + meta[li][bi].center_anchor = Some(*anchor); + } + } + + // Resolve snap kind (strongest anchor wins; tie-break: left > right > center) + for meta_line in meta.iter_mut().take(block.end).skip(block.start) { + for m in meta_line { + let left_count = m + .left_anchor + .and_then(|k| anchor_left.get(&k).map(|v| v.len())) + .unwrap_or(0); + let right_count = m + .right_anchor + .and_then(|k| anchor_right.get(&k).map(|v| v.len())) + .unwrap_or(0); + let center_count = m + .center_anchor + .and_then(|k| anchor_center.get(&k).map(|v| v.len())) + .unwrap_or(0); + + if left_count == 0 && right_count == 0 && center_count == 0 { + continue; + } + + // Prefer left alignment when left and right counts are close. + // In justified text, the right margin often collects slightly more + // members than the left (due to indented paragraphs breaking the + // left margin but keeping the right). A left-bias prevents justified + // body text from right-snapping, which causes ragged left margins. + let left_biased = left_count > 0 + && right_count > 0 + && left_count as f64 >= right_count as f64 * 0.8; + + let kind = + if (left_count >= right_count || left_biased) && left_count >= center_count { + SnapKind::Left + } else if right_count >= left_count && right_count >= center_count { + SnapKind::Right + } else { + SnapKind::Center + }; + m.snap = Some(kind); + } + } + + // Fixup pass: In justified text columns, most items share both a left + // and right anchor. Items that lost their left anchor (e.g., indented + // first lines) get right-snapped, but they should render at their + // natural position. If a right anchor's members are overwhelmingly + // also left-anchored, right-only members are likely justified body text + // and should be unsnapped (rendered floating at natural x). + // + // Additional guard: the item's left x must be near the left x of the + // left-anchored members (within half a page width). This prevents + // unsnapping items in a different column that happen to share a right margin. + { + // For each right anchor: (total, has_left, median_left_x of left-anchored members) + let mut right_anchor_info: HashMap = HashMap::new(); + for (anchor_key, members) in &anchor_right { + let total = members.len(); + let mut left_xs: Vec = Vec::new(); + let mut has_left = 0usize; + for &(li, bi) in members { + if meta[li][bi].left_anchor.is_some() { + has_left += 1; + left_xs.push(lines[li][bi].item.x); + } + } + left_xs.sort_by(|a, b| a.total_cmp(b)); + let median_x = if left_xs.is_empty() { + 0.0 + } else { + left_xs[left_xs.len() / 2] + }; + right_anchor_info.insert(*anchor_key, (total, has_left, median_x)); + } + + for line_idx in block.start..block.end { + for (box_idx, m) in meta[line_idx].iter_mut().enumerate() { + if m.snap != Some(SnapKind::Right) { + continue; + } + // Only fix items with no left anchor + if m.left_anchor.is_some() { + continue; + } + let Some(right_key) = m.right_anchor else { + continue; + }; + let (total, has_left, median_left_x) = right_anchor_info + .get(&right_key) + .copied() + .unwrap_or((0, 0, 0.0)); + // Require overwhelming majority (90%+) of members to have left anchors + if total < 10 || (has_left as f64 / total as f64) < 0.9 { + continue; + } + // Check that the item is near the same column as the left-anchored members + let item_x = lines[line_idx][box_idx].item.x; + let x_distance = (item_x - median_left_x).abs(); + if x_distance > page.page_width * 0.25 { + continue; + } + if debug { + let preview: String = lines[line_idx] + .get(box_idx) + .map(|t| t.item.text.chars().take(30).collect()) + .unwrap_or_default(); + eprintln!( + "[debug] FIXUP unsnap: line={} right_anchor={} total={} has_left={} median_x={:.1} item_x={:.1} text='{}'", + line_idx, right_key, total, has_left, median_left_x, item_x, preview + ); + } + m.snap = None; + m.right_anchor = None; + } + } + } + + // Symmetric fixup: In multi-column layouts, short right-column lines + // may only have a left anchor (no right anchor) because their right + // edge doesn't align with full-width lines. Meanwhile, the full-width + // lines in the same left anchor group get right-snapped. The short + // lines get left-snapped and placed too far left. + // + // Detection: if a left anchor has many right-snapped members AND the + // anchor position is past the page midpoint (indicating right column), + // unsnap the remaining left-only members so they float and inherit + // correct positioning from forward anchors. + { + let page_mid_key = anchor_key(page.page_width * 0.4); + + let mut left_anchor_info: HashMap = HashMap::new(); + for (left_ak, members) in &anchor_left { + let total = members.len(); + let mut right_snapped = 0usize; + for &(li, bi) in members { + if meta[li][bi].snap == Some(SnapKind::Right) { + right_snapped += 1; + } + } + left_anchor_info.insert(*left_ak, (total, right_snapped)); + } + + for line_idx in block.start..block.end { + for (box_idx, m) in meta[line_idx].iter_mut().enumerate() { + if m.snap != Some(SnapKind::Left) { + continue; + } + // Only fix items with no right anchor (short lines) + if m.right_anchor.is_some() { + continue; + } + let Some(left_key) = m.left_anchor else { + continue; + }; + // Only apply to items past the page midpoint (right column) + if left_key < page_mid_key { + continue; + } + let (total, right_snapped) = + left_anchor_info.get(&left_key).copied().unwrap_or((0, 0)); + // Require a meaningful group where most members are right-snapped + if total < 4 || (right_snapped as f64 / total as f64) < 0.5 { + continue; + } + if debug { + let preview: String = lines[line_idx] + .get(box_idx) + .map(|t| t.item.text.chars().take(30).collect()) + .unwrap_or_default(); + eprintln!( + "[debug] FIXUP left-unsnap: line={} left_anchor={} total={} right_snapped={} text='{}'", + line_idx, left_key, total, right_snapped, preview + ); + } + m.snap = None; + m.left_anchor = None; + m.force_unsnapped = true; + } + } + } + + // --- Per-line flowing detection (within structured blocks) --- + detect_and_render_flowing_lines( + &mut lines, + block, + &mut raw_lines, + &mut meta, + median_width, + page.page_width, + ); + + // --- Compute spacing hints (skip already-rendered flowing items) --- + for line_idx in block.start..block.end { + for box_idx in 0..lines[line_idx].len() { + if meta[line_idx][box_idx].rendered { + continue; + } + if box_idx == 0 || meta[line_idx][box_idx - 1].rendered { + meta[line_idx][box_idx].should_space = 0; + continue; + } + let prev = &lines[line_idx][box_idx - 1].item; + let cur = &lines[line_idx][box_idx].item; + let x_delta = cur.x - (prev.x + prev.width); + + let mut should_space = 0usize; + if x_delta > 2.0 { + should_space = 1; + let prev_len = prev.text.chars().count().max(1) as f32; + let prev_char_width = (prev.width / prev_len).max(0.1); + if x_delta > prev_char_width * 2.0 { + let column_gap_threshold = page.page_width * 0.1; + // Also detect column gaps using the relative gap method: + // if this line has a gap that's an outlier compared to other gaps, + // it's a column separator even if below the absolute threshold. + let has_relative_column_gap = + line_has_column_gap(&lines[line_idx], median_width, page.page_width); + let same_column = x_delta < column_gap_threshold + && !(has_relative_column_gap && x_delta > median_width * 2.0); + + let cur_snap = meta[line_idx][box_idx].snap; + let prev_snap = meta[line_idx][box_idx - 1].snap; + let cur_is_left_snap = cur_snap == Some(SnapKind::Left); + let prev_is_right_snap = prev_snap == Some(SnapKind::Right); + let both_snapped = cur_snap.is_some() && prev_snap.is_some(); + let force_unsnapped = meta[line_idx][box_idx].force_unsnapped; + + if (!force_unsnapped && x_delta > prev_char_width * 8.0) + || cur_is_left_snap + || prev_is_right_snap + || both_snapped + { + should_space = if same_column { + FLOATING_SPACES + } else { + COLUMN_SPACES + }; + } else { + should_space = if same_column { 1 } else { FLOATING_SPACES }; + } + } + } + meta[line_idx][box_idx].should_space = should_space; + } + } + + // --- Build block-scoped snap lists --- + let mut left_snaps: Vec = anchor_left.keys().copied().collect(); + let mut right_snaps: Vec = anchor_right.keys().copied().collect(); + let mut center_snaps: Vec = anchor_center.keys().copied().collect(); + left_snaps.sort_unstable(); + right_snaps.sort_unstable(); + center_snaps.sort_unstable(); + + let mut floating_set: HashSet = HashSet::new(); + for line_idx in block.start..block.end { + for (box_idx, bbox) in lines[line_idx].iter().enumerate() { + if meta[line_idx][box_idx].snap.is_none() && !meta[line_idx][box_idx].rendered { + floating_set.insert(anchor_key(bbox.item.x)); + } + } + } + let mut floating_snaps: Vec = floating_set.into_iter().collect(); + floating_snaps.sort_unstable(); + + // --- Main rendering loop (scoped to block) --- + let mut has_changed = true; + while has_changed + || !left_snaps.is_empty() + || !right_snaps.is_empty() + || !center_snaps.is_empty() + { + has_changed = false; + + // Render floating/unsnapped items first + for line_idx in block.start..block.end { + for box_idx in 0..lines[line_idx].len() { + if meta[line_idx][box_idx].rendered { + continue; + } + + if !meta[line_idx][box_idx].force_unsnapped { + if meta[line_idx][box_idx].snap.is_some() { + continue; + } + + let x_key = anchor_key(lines[line_idx][box_idx].item.x); + let center_key = anchor_key( + lines[line_idx][box_idx].item.x + + lines[line_idx][box_idx].item.width * 0.5, + ); + if left_snaps.first().copied().is_some_and(|v| v < x_key) + || right_snaps.first().copied().is_some_and(|v| v < x_key) + || center_snaps + .first() + .copied() + .is_some_and(|v| v < center_key) + { + continue; + } + } else { + // Force-unsnapped items (e.g. short right-column lines) + // must wait until ALL snaps are processed so that forward + // anchors from their column neighbors are established. + if !left_snaps.is_empty() + || !right_snaps.is_empty() + || !center_snaps.is_empty() + { + continue; + } + } + + if !can_render_bbox(&meta[line_idx], box_idx) { + break; + } + + let (bbox_x, bbox_w, bbox_text) = { + let b = &lines[line_idx][box_idx].item; + (b.x, b.width, b.text.clone()) + }; + let mut target_x = ((bbox_x / median_width).round() as isize) + .max(0) + .min(COLUMN_SPACES as isize) + as usize; + + let x_key = anchor_key(bbox_x); + let last_snap_left = forward_left + .range(..=x_key) + .map(|(_, v)| *v) + .max() + .unwrap_or(0); + + let line_max = last_snap_left.max( + trim_end_len(&raw_lines[line_idx]) + meta[line_idx][box_idx].should_space, + ); + if target_x < line_max { + target_x = line_max; + } + + if !meta[line_idx][box_idx].force_unsnapped { + let floating_key = anchor_key(bbox_x); + if let Some(floating_anchor) = forward_floating.get(&floating_key).copied() + && target_x < floating_anchor + { + let adjusted = floating_anchor.min(target_x + 4); + if adjusted > target_x { + target_x = adjusted; + } + } + } + + if debug && bbox_text.contains("Translation") { + eprintln!( + "[debug] FLOATING render '{bbox_text:.30}' line={line_idx} target_x={target_x} last_snap_left={} raw_len={} should_space={}", + forward_left + .range(..=anchor_key(bbox_x)) + .map(|(_, v)| *v) + .max() + .unwrap_or(0), + char_len(&raw_lines[line_idx]), + meta[line_idx][box_idx].should_space + ); + eprintln!("[debug] raw_line so far: '{}'", &raw_lines[line_idx]); + } + + trim_end_in_place(&mut raw_lines[line_idx]); + let before_len = char_len(&raw_lines[line_idx]); + if target_x > before_len { + raw_lines[line_idx].push_str(&" ".repeat(target_x - before_len)); + } + let start_x = char_len(&raw_lines[line_idx]); + raw_lines[line_idx].push_str(&bbox_text); + + meta[line_idx][box_idx].rendered = true; + meta[line_idx][box_idx].projected_x = start_x; + lines[line_idx][box_idx].rendered = true; + lines[line_idx][box_idx].num_spaces = start_x.saturating_sub(before_len); + has_changed = true; + + let next_should_space = if box_idx + 1 < lines[line_idx].len() { + meta[line_idx][box_idx + 1].should_space + } else { + 0 + }; + let right_bound = anchor_key(bbox_x + bbox_w); + let target_len = char_len(&raw_lines[line_idx]) + next_should_space; + + update_forward_anchor_right_bound( + &left_snaps, + &mut forward_left, + right_bound, + target_len, + ); + update_forward_anchor_right_bound( + &right_snaps, + &mut forward_right, + right_bound, + target_len, + ); + update_forward_anchor_right_bound( + &floating_snaps, + &mut forward_floating, + right_bound, + target_len, + ); + } + } + + // Pick next snap to process + let left_first = left_snaps.first().copied(); + let right_first = right_snaps.first().copied(); + let center_first = center_snaps.first().copied(); + + let next_kind = match (left_first, right_first, center_first) { + (None, None, None) => None, + (Some(_), None, None) => Some(SnapKind::Left), + (None, Some(_), None) => Some(SnapKind::Right), + (None, None, Some(_)) => Some(SnapKind::Center), + (Some(l), Some(r), None) => Some(if l <= r { + SnapKind::Left + } else { + SnapKind::Right + }), + (Some(l), None, Some(c)) => Some(if l <= c { + SnapKind::Left + } else { + SnapKind::Center + }), + (None, Some(r), Some(c)) => Some(if r <= c { + SnapKind::Right + } else { + SnapKind::Center + }), + (Some(l), Some(r), Some(c)) => { + if l <= r && l <= c { + Some(SnapKind::Left) + } else if r <= l && r <= c { + Some(SnapKind::Right) + } else { + Some(SnapKind::Center) + } + } + }; + + let Some(kind) = next_kind else { + continue; + }; + + let current_anchor = match kind { + SnapKind::Left => left_snaps.first().copied(), + SnapKind::Right => right_snaps.first().copied(), + SnapKind::Center => center_snaps.first().copied(), + }; + + let Some(current_anchor) = current_anchor else { + continue; + }; + + // Find items in this block matching the current anchor + let mut turn_items: Vec<(usize, usize)> = Vec::new(); + for line_idx in block.start..block.end { + for (box_idx, m) in meta[line_idx] + .iter() + .enumerate() + .take(lines[line_idx].len()) + { + if m.rendered { + continue; + } + let matches = match kind { + SnapKind::Left => { + m.left_anchor == Some(current_anchor) + && m.snap != Some(SnapKind::Right) + && m.snap != Some(SnapKind::Center) + } + SnapKind::Right => { + m.right_anchor == Some(current_anchor) + && m.snap == Some(SnapKind::Right) + } + SnapKind::Center => { + m.center_anchor == Some(current_anchor) + && m.snap == Some(SnapKind::Center) + } + }; + if matches { + turn_items.push((line_idx, box_idx)); + } + } + } + + if turn_items.is_empty() { + match kind { + SnapKind::Left => { + left_snaps.remove(0); + } + SnapKind::Right => { + right_snaps.remove(0); + } + SnapKind::Center => { + center_snaps.remove(0); + } + } + continue; + } + + has_changed = true; + + let mut target_x = ((anchor_to_x(current_anchor) / median_width).round() as isize) + .max(0) + .min(COLUMN_SPACES as isize) as usize; + + let line_max = match kind { + SnapKind::Left => turn_items + .iter() + .map(|(li, bi)| { + char_len(&raw_lines[*li]) + + line_space_end(&raw_lines[*li], meta[*li][*bi].should_space) + + 1 + }) + .max() + .unwrap_or(0), + SnapKind::Right => turn_items + .iter() + .map(|(li, bi)| { + let bbox = &lines[*li][*bi].item; + let x_key = anchor_key(bbox.x); + let last_snap_left = forward_left + .range(..=x_key) + .map(|(_, v)| *v) + .max() + .unwrap_or(0); + let left_bound = last_snap_left + .max(trim_end_len(&raw_lines[*li]) + meta[*li][*bi].should_space); + left_bound + bbox.text.chars().count() + }) + .max() + .unwrap_or(0), + SnapKind::Center => turn_items + .iter() + .map(|(li, bi)| { + let text_half = lines[*li][*bi].item.text.chars().count() / 2; + char_len(&raw_lines[*li]) + + text_half + + line_space_end(&raw_lines[*li], meta[*li][*bi].should_space) + }) + .max() + .unwrap_or(0), + }; + + if target_x < line_max { + target_x = line_max; + } + + match kind { + SnapKind::Left => { + if let Some(v) = forward_left.get(¤t_anchor).copied() { + target_x = target_x.max(v); + } + forward_left.insert(current_anchor, target_x); + } + SnapKind::Right => { + if let Some(v) = forward_right.get(¤t_anchor).copied() { + target_x = target_x.max(v); + } + forward_right.insert(current_anchor, target_x); + } + SnapKind::Center => { + if let Some(v) = forward_center.get(¤t_anchor).copied() { + target_x = target_x.max(v); + } + forward_center.insert(current_anchor, target_x); + } + } + + if debug { + let sample_text: Vec = turn_items + .iter() + .take(2) + .map(|(li, bi)| { + let t = &lines[*li][*bi].item.text; + format!("'{:.30}'", t) + }) + .collect(); + eprintln!( + "[debug] SNAP {:?} anchor={current_anchor} target_x={target_x} line_max={line_max} items={} samples={}", + kind, + turn_items.len(), + sample_text.join(", ") + ); + } + + for (line_idx, box_idx) in turn_items { + let (bbox_x, bbox_w, bbox_text) = { + let b = &lines[line_idx][box_idx].item; + (b.x, b.width, b.text.clone()) + }; + match kind { + SnapKind::Left => { + let before = char_len(&raw_lines[line_idx]); + if target_x > before { + raw_lines[line_idx].push_str(&" ".repeat(target_x - before)); + } + let start_x = char_len(&raw_lines[line_idx]); + raw_lines[line_idx].push_str(&bbox_text); + meta[line_idx][box_idx].projected_x = start_x; + lines[line_idx][box_idx].num_spaces = start_x.saturating_sub(before); + } + SnapKind::Right => { + trim_end_in_place(&mut raw_lines[line_idx]); + let text_len = bbox_text.chars().count(); + let before = char_len(&raw_lines[line_idx]); + let trim_len = trim_end_len(&raw_lines[line_idx]); + if target_x > trim_len + text_len { + let pad = target_x - char_len(&raw_lines[line_idx]) - text_len; + raw_lines[line_idx].push_str(&" ".repeat(pad)); + } + let start_x = char_len(&raw_lines[line_idx]); + raw_lines[line_idx].push_str(&bbox_text); + meta[line_idx][box_idx].projected_x = start_x; + lines[line_idx][box_idx].num_spaces = start_x.saturating_sub(before); + } + SnapKind::Center => { + let text_half = bbox_text.chars().count() / 2; + let before = char_len(&raw_lines[line_idx]); + if target_x > char_len(&raw_lines[line_idx]) + text_half { + let pad = target_x - char_len(&raw_lines[line_idx]) - text_half; + raw_lines[line_idx].push_str(&" ".repeat(pad)); + } + let start_x = char_len(&raw_lines[line_idx]); + raw_lines[line_idx].push_str(&bbox_text); + meta[line_idx][box_idx].projected_x = start_x; + lines[line_idx][box_idx].num_spaces = start_x.saturating_sub(before); + } + } + + meta[line_idx][box_idx].rendered = true; + lines[line_idx][box_idx].rendered = true; + + let next_should_space = if box_idx + 1 < lines[line_idx].len() { + meta[line_idx][box_idx + 1].should_space + } else { + 0 + }; + let right_bound = anchor_key(bbox_x + bbox_w); + let target_len = char_len(&raw_lines[line_idx]) + next_should_space; + update_forward_anchor_right_bound( + &left_snaps, + &mut forward_left, + right_bound, + target_len, + ); + update_forward_anchor_right_bound( + &right_snaps, + &mut forward_right, + right_bound, + target_len, + ); + update_forward_anchor_right_bound( + &floating_snaps, + &mut forward_floating, + right_bound, + target_len, + ); + } + + match kind { + SnapKind::Left => { + left_snaps.remove(0); + } + SnapKind::Right => { + right_snaps.remove(0); + } + SnapKind::Center => { + center_snaps.remove(0); + } + } + } + + // Fallback: render anything still not rendered in this block + for line_idx in block.start..block.end { + for box_idx in 0..lines[line_idx].len() { + if meta[line_idx][box_idx].rendered { + continue; + } + if !raw_lines[line_idx].is_empty() && !raw_lines[line_idx].ends_with(' ') { + raw_lines[line_idx].push(' '); + } + let start_x = char_len(&raw_lines[line_idx]); + raw_lines[line_idx].push_str(&lines[line_idx][box_idx].item.text); + meta[line_idx][box_idx].rendered = true; + meta[line_idx][box_idx].projected_x = start_x; + lines[line_idx][box_idx].rendered = true; + } + } + } + + // Fixup: align rotated floating items with snapped items on adjacent lines. + // Rotated labels (e.g. pin names) are excluded from anchors and render at + // their natural x/median_width position. If a neighboring line has snapped + // content at a similar PDF x but a different column, shift the rotated items + // to match, keeping relative spacing intact. + for block in &blocks { + for line_idx in block.start..block.end { + // Collect rotated floating items on this line + let rotated_items: Vec = (0..meta[line_idx].len()) + .filter(|&bi| { + bi < lines[line_idx].len() + && lines[line_idx][bi].rotated + && meta[line_idx][bi].snap.is_none() + }) + .collect(); + if rotated_items.is_empty() { + continue; + } + + // Find the first rotated item's PDF x and rendered column + let first_bi = rotated_items[0]; + let rot_pdf_x = lines[line_idx][first_bi].item.x; + let rot_col = meta[line_idx][first_bi].projected_x; + + // Scan nearby lines (up to 4 away) for left/right-snapped items + // at a similar PDF x. Prefer non-center snaps since center-snapped + // items can be pulled out of position by unrelated anchor groups. + { + let scan_range = 4usize; + let lo = if line_idx >= block.start + scan_range { + line_idx - scan_range + } else { + block.start + }; + let hi = (line_idx + scan_range + 1).min(block.end); + + let mut best: Option<(f32, usize, bool)> = None; // (x_diff, col, is_center) + for adj in lo..hi { + if adj == line_idx { + continue; + } + for (bi, m) in meta[adj].iter().enumerate() { + if bi >= lines[adj].len() || m.snap.is_none() { + continue; + } + let is_center = m.snap == Some(SnapKind::Center); + let adj_pdf_x = lines[adj][bi].item.x; + let x_diff = (adj_pdf_x - rot_pdf_x).abs(); + if x_diff < median_width * 3.0 { + let dominated = if let Some((prev_diff, _, prev_center)) = best { + // Prefer non-center over center; among same type prefer closer x + if !is_center && prev_center { + true + } else if is_center && !prev_center { + false + } else { + x_diff < prev_diff + } + } else { + true + }; + if dominated { + best = Some((x_diff, m.projected_x, is_center)); + } + } + } + } + let best = best.map(|(d, c, _)| (d, c)); + + if let Some((_, adj_col)) = best + && adj_col < rot_col + { + let shift = rot_col - adj_col; + // Rebuild the line: shift all rotated items left + let first_col = meta[line_idx][first_bi].projected_x; + let pre = &raw_lines[line_idx][..raw_lines[line_idx] + .char_indices() + .nth(first_col) + .map(|(i, _)| i) + .unwrap_or(raw_lines[line_idx].len())]; + let pre_trimmed = pre.trim_end(); + let mut new_line = pre_trimmed.to_string(); + + for &bi in &rotated_items { + let old_col = meta[line_idx][bi].projected_x; + let new_col = old_col.saturating_sub(shift); + let cur_len = char_len(&new_line); + if new_col > cur_len { + new_line.push_str(&" ".repeat(new_col - cur_len)); + } + let start = char_len(&new_line); + new_line.push_str(&lines[line_idx][bi].item.text); + meta[line_idx][bi].projected_x = start; + } + raw_lines[line_idx] = new_line; + + // Also shift center-snapped items on immediately adjacent + // lines whose PDF x is close to the rotated items' x range. + let rot_x_min = rotated_items + .iter() + .map(|&bi| lines[line_idx][bi].item.x) + .fold(f32::INFINITY, f32::min); + let rot_x_max = rotated_items + .iter() + .map(|&bi| { + let b = &lines[line_idx][bi].item; + b.x + b.width + }) + .fold(f32::NEG_INFINITY, f32::max); + + let immediate_adj: Vec = [ + line_idx.checked_sub(1).filter(|&l| l >= block.start), + Some(line_idx + 1).filter(|&l| l < block.end), + ] + .into_iter() + .flatten() + .collect(); + + // Also shift center-snapped items on immediately adjacent + // lines whose PDF x overlaps the rotated items' x range. + // These items share the same spatial region but may have been + // pulled out of position by center-snap constraints. + let corrected_first_col = meta[line_idx][first_bi].projected_x; + + for adj in immediate_adj { + for bi in 0..lines[adj].len() { + if meta[adj][bi].snap != Some(SnapKind::Center) { + continue; + } + let b = &lines[adj][bi].item; + if b.x + b.width < rot_x_min - median_width * 2.0 + || b.x > rot_x_max + median_width * 2.0 + { + continue; + } + let old_col = meta[adj][bi].projected_x; + // Compute where this item should start based on + // the PDF x offset from the first rotated item. + let x_offset = b.x - lines[line_idx][first_bi].item.x; + let col_offset = (x_offset / median_width).round().max(0.0) as usize; + let target_col = corrected_first_col + col_offset; + if target_col > old_col { + let byte_start = raw_lines[adj] + .char_indices() + .nth(old_col) + .map(|(i, _)| i) + .unwrap_or(raw_lines[adj].len()); + let pre = raw_lines[adj][..byte_start].trim_end().to_string(); + let post_text = &raw_lines[adj][byte_start..]; + let post_trimmed = post_text.trim_end(); + let mut rebuilt = pre; + if target_col > char_len(&rebuilt) { + rebuilt.push_str(&" ".repeat(target_col - char_len(&rebuilt))); + } + let start = char_len(&rebuilt); + rebuilt.push_str(post_trimmed); + raw_lines[adj] = rebuilt; + meta[adj][bi].projected_x = start; + } + } + } + } + } + } + } + + // Fix sparse blocks (per block) + for block in &blocks { + fix_sparse_blocks(&mut raw_lines, block.start, block.end); + } + + // Persist projected positions and flatten in line order. + let mut flattened: Vec = + Vec::with_capacity(lines.iter().map(|l| l.len()).sum()); + for (line_idx, line) in lines.into_iter().enumerate() { + for (box_idx, mut item) in line.into_iter().enumerate() { + item.force_unsnapped = meta[line_idx][box_idx].force_unsnapped; + item.num_spaces = meta[line_idx][box_idx].should_space; + + if let Some(snap) = meta[line_idx][box_idx].snap { + match snap { + SnapKind::Left => { + item.snap = Snap::Left; + item.anchor = Anchor::Left; + } + SnapKind::Right => { + item.snap = Snap::Right; + item.anchor = Anchor::Right; + } + SnapKind::Center => { + item.snap = Snap::Center; + item.anchor = Anchor::Center; + } + } + } + flattened.push(item); + } + } + + clean_projected_items(&mut flattened, page.page_width); + + let text = raw_lines + .into_iter() + .map(|l| l.trim_end().to_string()) + .collect::>() + .join("\n"); + + let text = clean_rendered_text(&text); + + (flattened, text) +} + +/// Post-rendering text cleanup: +/// - Remove top margin (leading empty lines) +/// - Remove bottom margin (trailing empty lines) +/// - Remove left margin (consistent leading whitespace) +/// - Replace null characters with spaces +fn clean_rendered_text(text: &str) -> String { + let text = text.replace('\0', " "); + let lines: Vec<&str> = text.split('\n').collect(); + + // Find bounds of content and minimum left indentation + let mut min_x: Option = None; + let mut min_y: Option = None; + let mut max_y: Option = None; + + for (i, line) in lines.iter().enumerate() { + if line.trim().is_empty() { + continue; + } + let leading = line.len() - line.trim_start().len(); + min_x = Some(min_x.map_or(leading, |m: usize| m.min(leading))); + if min_y.is_none() { + min_y = Some(i); + } + max_y = Some(i); + } + + let (min_x, min_y, max_y) = match (min_x, min_y, max_y) { + (Some(x), Some(y1), Some(y2)) => (x, y1, y2), + _ => return String::new(), + }; + + lines[min_y..=max_y] + .iter() + .map(|line| { + if line.len() > min_x { + &line[min_x..] + } else { + line.trim_end() + } + }) + .collect::>() + .join("\n") +} + +pub fn project_pages_to_grid(pages: Vec) -> Vec { + pages + .into_iter() + .map(|page| { + let projection_boxes = page + .text_items + .iter() + .map(|item| ProjectedTextItem { + orig_x: item.x, + orig_y: item.y, + orig_width: item.width, + orig_height: item.height, + orig_rotation: item.rotation, + item: item.clone(), + snap: Snap::Left, + anchor: Anchor::Left, + is_dup: false, + rendered: false, + num_spaces: 0, + force_unsnapped: false, + is_margin_line_number: false, + rotated: false, + d: 0.0, + }) + .collect(); + + let (projected_items, text) = project_to_grid(&page, projection_boxes); + // Detect figure regions from the page's vector graphics before + // XY-cut runs so the layout recursion can treat them as obstacles + // (partition the page around figures rather than slicing through). + let figures = crate::figure_cluster::detect_figure_rects( + &page.graphics, + &page.text_items, + page.page_width, + page.page_height, + ); + // Pre-projection ruled-table detection. Feeds XY-cut as obstacles + // so a table's inter-column gaps don't get picked as page-level + // V-cuts (column-major reading order is the dominant TEDS=0 + // failure mode on the bench: docs 083/120/130/etc.). + let table_rects = crate::markdown_layout::detect_table_rects( + &page.graphics, + page.page_width, + page.page_height, + ); + let mut obstacles: Vec = Vec::with_capacity(figures.len() + table_rects.len()); + obstacles.extend(figures.iter().cloned()); + obstacles.extend(table_rects.iter().cloned()); + let (projected_lines, regions) = build_projected_lines( + &projected_items, + page.page_width, + page.page_height, + &obstacles, + ); + ParsedPage { + page_number: page.page_number, + page_width: page.page_width, + page_height: page.page_height, + text, + markdown: String::new(), + text_items: projected_items + .into_iter() + .map(|proj| TextItem { + x: proj.orig_x, + y: proj.orig_y, + width: proj.orig_width, + height: proj.orig_height, + rotation: proj.orig_rotation, + ..proj.item + }) + .collect(), + projected_lines, + regions, + graphics: page.graphics, + figures, + struct_nodes: page.struct_nodes, + image_refs: page.image_refs, + } + }) + .collect() +} + +// ── ProjectedLine derivation ──────────────────────────────────────────────── +// +// `build_projected_lines` groups already-projected items (in reading order) +// into per-line structural metadata consumed by the markdown emitter. Lines +// are grouped by y-proximity; aggregates (dominant font, all-bold/italic/mono) +// are char-weighted across the line's items. The JSON/text outputs are +// unaffected — `ParsedPage.projected_lines` is `#[serde(skip)]`. + +/// PDF font descriptor flag bits we care about. +/// See PDF spec §9.8.2 (table 123). +const PDF_FONT_FLAG_FIXED_PITCH: i32 = 1; // bit 1 +const PDF_FONT_FLAG_ITALIC: i32 = 64; // bit 7 +const PDF_FONT_FLAG_FORCE_BOLD: i32 = 262144; // bit 19 + +/// Font-name substrings that indicate bold weight. +const BOLD_NAME_HINTS: &[&str] = &["Bold", "Black", "Heavy", "Semibold", "Demibold"]; +/// Font-name substrings that indicate italic / oblique slant. +const ITALIC_NAME_HINTS: &[&str] = &["Italic", "Oblique"]; +/// Font-name substrings that indicate a monospaced typeface. +const MONO_NAME_HINTS: &[&str] = &[ + "Courier", + "Mono", + "Consolas", + "Menlo", + "Source Code", + "Inconsolata", + "Hack", + "Fira Code", +]; + +fn name_contains_any(name: &str, hints: &[&str]) -> bool { + hints.iter().any(|h| name.contains(h)) +} + +pub(crate) fn is_bold_item(item: &TextItem) -> bool { + if let Some(flags) = item.font_flags + && flags & PDF_FONT_FLAG_FORCE_BOLD != 0 + { + return true; + } + if let Some(w) = item.font_weight + && w >= 600 + { + return true; + } + if let Some(n) = &item.font_name + && name_contains_any(n, BOLD_NAME_HINTS) + { + return true; + } + false +} + +pub(crate) fn is_italic_item(item: &TextItem) -> bool { + if let Some(flags) = item.font_flags + && flags & PDF_FONT_FLAG_ITALIC != 0 + { + return true; + } + if let Some(n) = &item.font_name + && name_contains_any(n, ITALIC_NAME_HINTS) + { + return true; + } + false +} + +pub(crate) fn is_strike_item(item: &TextItem) -> bool { + item.strike +} + +pub(crate) fn is_mono_item(item: &TextItem) -> bool { + if let Some(flags) = item.font_flags + && flags & PDF_FONT_FLAG_FIXED_PITCH != 0 + { + return true; + } + if let Some(n) = &item.font_name + && name_contains_any(n, MONO_NAME_HINTS) + { + return true; + } + false +} + +// ── XY-cut layout decomposition ───────────────────────────────────────────── +// +// Replaces the prior flat column detector. Recursively splits the page along H +// or V axes at "valleys" — runs of low projection density wider than a minimum +// threshold. Pre-order traversal of the resulting tree gives reading order +// (top→bottom, left→right) including for nested layouts (banded splits with +// sub-columns). +// +// Threshold is min-density relative to the local content's median: a cut is +// valid if the projection density in the valley stays below T_DENS × median +// for at least MIN_VALLEY_PT continuous units. Auto-scales to page content so +// dense vs. sparse pages need no per-doc tuning. Conservative defaults — we'd +// rather under-cut (merge columns) than shred a paragraph with wide +// inter-word gaps. + +/// Bucket size for the 1D density projection (points). +const XY_BUCKET_PT: f32 = 2.0; +/// Density threshold for a "valley", expressed as a fraction of the local +/// median non-zero bucket density. +const XY_T_DENS: f32 = 0.10; +/// Minimum vertical-cut (column gutter) valley width. +const XY_MIN_V_VALLEY_PT: f32 = 8.0; +/// Minimum horizontal-cut (row gap) valley width, expressed as a multiple of +/// the median line height. +const XY_MIN_H_VALLEY_FACTOR: f32 = 1.4; +/// Hard recursion cap. +// Backstop only — every cut still passes the banner/density/histogram gates. +// 6 starved pages with several stacked floats (tables + captions above a +// 2-col body): each banner peel burns a level, and the body band leafs out +// un-split at the cap. +const XY_MAX_DEPTH: u32 = 12; +/// Below this item count we stop trying to cut. +const XY_MIN_ITEMS_TO_CUT: usize = 4; +/// A horizontal cut must leave at least this many distinct text lines on each +/// side; otherwise we don't slice prose into sliver bands. +const XY_MIN_LINES_PER_H_SIDE: usize = 2; +/// Vertical-cut score must beat the horizontal score by this factor to be +/// chosen. Tie-breaks in favor of horizontal so pages are sliced into bands +/// before columns — matches natural reading order. +const XY_V_PREFERENCE_MARGIN: f32 = 1.1; + +/// Bucket width used by the column-start histogram fallback. 5pt is finer +/// than typical inter-column gutters (8–20pt) and coarser than baseline +/// noise / kerning drift, so column edges land in distinct buckets. +const XY_COLUMN_BUCKET_PT: f32 = 5.0; +/// Minimum lines stacked at the same left-edge cluster for the cluster to +/// count as a column peak. A real column has many lines at one x (15+); +/// indented paragraph-starts and footnote markers produce a handful (3–6). +/// We set this above the typical "indented paragraph" count so those drop +/// out and only true column-left edges remain. +const XY_COLUMN_MIN_LINES_PER_PEAK: usize = 10; +/// Minimum horizontal distance between two column peaks, as a fraction of +/// `bbox.width`. Sub-paragraph indents and list nesting produce +/// closely-spaced peaks; a real column gutter is much further out. +const XY_COLUMN_MIN_GAP_FRACTION: f32 = 0.25; +/// Minimum fraction of items that must start at one of the two detected +/// column peaks. Real 2-column layouts cluster nearly every line at one of +/// two left edges; tables, lists, and scattered prose don't. +const XY_COLUMN_PEAK_DOMINANCE: f32 = 0.55; +/// Minimum smaller/larger ratio between the two column peaks' line counts. +/// Balanced 2-column layouts have ~equal line counts in each column; a +/// 90/10 split is almost certainly a single column with one off-x bullet +/// stack, not a real two-column structure. +const XY_COLUMN_PEAK_BALANCE_RATIO: f32 = 0.4; +/// Minimum per-column horizontal fill for an N-column (3+ peak) split. +/// Genuine N-column prose fills each inter-peak slot nearly edge-to-edge +/// with text; a data table leaves wide intra-row whitespace, so its cells +/// cover only a fraction of each slot. Gate the N-column split on the +/// least-filled column so we keep splitting prose but bail (→ table +/// detector) on sparse tabular layouts. +const XY_COLUMN_MIN_FILL: f32 = 0.55; + +/// A peak whose band count is below this fraction of the most-banded column's +/// is treated as a figure-scatter "phantom" (axis tick labels, a point cloud +/// spread across x) rather than a real column. Real table columns have one +/// cell per row, so their band counts are comparable to their siblings; a +/// plot embedded in 2-column prose contributes a sparse, low-fill third peak +/// that would otherwise flip the whole region to `tabular` and suppress the +/// genuine column split. See the phantom-drop in `xy_find_column_cut`. +const XY_COLUMN_PHANTOM_BAND_FRACTION: f32 = 0.25; + +#[derive(Debug, Clone, Copy)] +struct CutCandidate { + axis: CutAxis, + /// Coordinate of the split (y for horizontal cut, x for vertical). + position: f32, + score: f32, +} + +/// Character-weighted contribution of an item to the projection. Headings get +/// roughly the same weight as the body text they're competing with, instead of +/// being amplified by their bbox area. +fn xy_item_weight(it: &TextItem) -> f32 { + it.text.chars().count().max(1) as f32 +} + +fn xy_root_bbox(items: &[ProjectedTextItem], page_width: f32, page_height: f32) -> Rect { + let mut min_x = f32::INFINITY; + let mut min_y = f32::INFINITY; + let mut max_x = f32::NEG_INFINITY; + let mut max_y = f32::NEG_INFINITY; + for it in items { + if it.item.text.is_empty() { + continue; + } + min_x = min_x.min(it.item.x); + min_y = min_y.min(it.item.y); + max_x = max_x.max(it.item.x + it.item.width.max(0.0)); + max_y = max_y.max(it.item.y + it.item.height.max(0.0)); + } + if !min_x.is_finite() { + return Rect { + x: 0.0, + y: 0.0, + width: page_width.max(1.0), + height: page_height.max(1.0), + }; + } + let x = min_x.max(0.0); + let y = min_y.max(0.0); + let w = (max_x - x).max(1.0); + let h = (max_y - y).max(1.0); + // Span the actual item extent. The page dimensions are only used as a + // fallback (no items). We must NOT clamp the bbox down to them: + // `handle_rotation_reading_order` lays rotated marginalia (e.g. an arXiv + // sidebar stamp) out on a virtual canvas taller/wider than the physical + // page and pushes the non-rotated body below it. Clamping to page_height + // would drop those displaced body items outside the density buckets, so no + // horizontal valley between a full-width header and the 2-column body is + // ever found and the column V-cut shreds the centered title/author band. + Rect { + x, + y, + width: w, + height: h, + } +} + +fn xy_median_line_height(items: &[ProjectedTextItem], idxs: &[usize]) -> f32 { + let mut heights: Vec = idxs + .iter() + .filter_map(|&i| { + let h = items[i].item.height; + if h > 0.5 { Some(h) } else { None } + }) + .collect(); + if heights.is_empty() { + return 10.0; + } + heights.sort_by(|a, b| a.total_cmp(b)); + heights[heights.len() / 2] +} + +/// Approximate distinct-line count by y-banding, used only to enforce +/// `XY_MIN_LINES_PER_H_SIDE`. +fn xy_distinct_lines(items: &[ProjectedTextItem], idxs: &[usize], median_h: f32) -> usize { + if idxs.is_empty() { + return 0; + } + let band = (median_h * 0.5).max(2.0); + let mut ys: Vec = idxs.iter().map(|&i| items[i].item.y).collect(); + ys.sort_by(|a, b| a.total_cmp(b)); + let mut lines = 1usize; + let mut last = ys[0]; + for &y in &ys[1..] { + if (y - last).abs() > band { + lines += 1; + last = y; + } + } + lines +} + +/// Find the best valley along `axis` inside `bbox` for the given items. The +/// returned position is in absolute page coordinates. +/// +/// `figures` is the set of figure-region rectangles on the page. Any figure +/// that intersects `bbox` is stamped into the density projection like a giant +/// opaque text block — its perpendicular extent (clipped to `bbox`) is added +/// to each bucket it covers along `axis`. This makes the recursion prefer +/// cuts *around* figures rather than slicing through them (which is the +/// motivating fix for academic-paper page-1 layouts where a figure straddles +/// both columns). +fn xy_find_best_cut( + items: &[ProjectedTextItem], + idxs: &[usize], + bbox: &Rect, + axis: CutAxis, + median_h: f32, + figures: &[Rect], +) -> Option { + let (origin, length) = match axis { + CutAxis::Horizontal => (bbox.y, bbox.height), + CutAxis::Vertical => (bbox.x, bbox.width), + }; + if length <= 0.0 { + return None; + } + let n_buckets = ((length / XY_BUCKET_PT).ceil() as usize).max(1); + let mut density = vec![0.0f32; n_buckets]; + + for &i in idxs { + let it = &items[i].item; + let (c0, c1) = match axis { + CutAxis::Horizontal => (it.y, it.y + it.height.max(0.0)), + CutAxis::Vertical => (it.x, it.x + it.width.max(0.0)), + }; + let weight = xy_item_weight(it); + let b0_f = ((c0 - origin) / XY_BUCKET_PT).floor(); + let b1_f = ((c1 - origin) / XY_BUCKET_PT).ceil(); + let b0 = b0_f.max(0.0) as usize; + let b1 = (b1_f.max(b0_f + 1.0) as usize).min(n_buckets); + for b in b0..b1 { + density[b] += weight; + } + } + + // Stamp figures into the projection as opaque obstacles. A figure that + // intersects `bbox` adds density to each axis-parallel bucket it spans; + // weight = its perpendicular extent (clipped to bbox) so figures behave + // like very wide/tall pseudo-items relative to text. The valley search + // naturally finds cuts adjacent to figures rather than through them. + for fig in figures { + let fx0 = fig.x.max(bbox.x); + let fy0 = fig.y.max(bbox.y); + let fx1 = (fig.x + fig.width).min(bbox.x + bbox.width); + let fy1 = (fig.y + fig.height).min(bbox.y + bbox.height); + if fx1 <= fx0 || fy1 <= fy0 { + continue; + } + let (c0, c1, perp) = match axis { + CutAxis::Horizontal => (fy0, fy1, fx1 - fx0), + CutAxis::Vertical => (fx0, fx1, fy1 - fy0), + }; + let weight = perp.max(1.0); + let b0_f = ((c0 - origin) / XY_BUCKET_PT).floor(); + let b1_f = ((c1 - origin) / XY_BUCKET_PT).ceil(); + let b0 = b0_f.max(0.0) as usize; + let b1 = (b1_f.max(b0_f + 1.0) as usize).min(n_buckets); + for b in b0..b1 { + density[b] += weight; + } + } + + let mut nonzero: Vec = density.iter().copied().filter(|d| *d > 0.0).collect(); + if nonzero.is_empty() { + return None; + } + nonzero.sort_by(|a, b| a.total_cmp(b)); + let median = nonzero[nonzero.len() / 2]; + if median <= 0.0 { + return None; + } + let threshold = median * XY_T_DENS; + + // Don't cut at leading/trailing whitespace — only consider valleys interior + // to the content's span on this axis. + let first_dense = density.iter().position(|d| *d > threshold)?; + let last_dense = density.iter().rposition(|d| *d > threshold)?; + if last_dense <= first_dense + 1 { + return None; + } + + let min_valley_pt = match axis { + CutAxis::Vertical => XY_MIN_V_VALLEY_PT, + CutAxis::Horizontal => median_h * XY_MIN_H_VALLEY_FACTOR, + }; + let min_valley_buckets = ((min_valley_pt / XY_BUCKET_PT).ceil() as usize).max(1); + + let mut best: Option = None; + let mut i = first_dense + 1; + while i <= last_dense { + if density[i] <= threshold { + let s = i; + while i <= last_dense && density[i] <= threshold { + i += 1; + } + let e = i; // exclusive + let width = e - s; + if width >= min_valley_buckets { + let mean: f32 = density[s..e].iter().sum::() / width as f32; + let depth = (1.0 - mean / median).max(0.0); + let score = width as f32 * depth; + let mid = (s as f32 + e as f32) * 0.5; + let position = origin + mid * XY_BUCKET_PT; + // Density-stamping alone isn't always enough to push the + // gutter inside a table region below threshold — the + // inter-cell gaps win on tables with sparsely-filled cells + // (docs 120/180/150). Hard-reject any cut line that passes + // strictly through an obstacle rect intersecting `bbox`. + if !cut_line_passes_through_obstacle(axis, position, bbox, figures) + && best.as_ref().is_none_or(|b| score > b.score) + { + best = Some(CutCandidate { + axis, + position, + score, + }); + } + } + } else { + i += 1; + } + } + best +} + +/// Hard-reject companion to the density-stamping in `xy_find_best_cut`. +/// Density-stamping makes the recursion *prefer* cuts around obstacles, but +/// sparsely-filled tables (docs 120/180/150) have inter-cell gaps that still +/// score better than the stamped obstacle band. Returns true iff the cut +/// line at `position` strictly passes through any obstacle that intersects +/// `bbox`. +fn cut_line_passes_through_obstacle( + axis: CutAxis, + position: f32, + bbox: &Rect, + obstacles: &[Rect], +) -> bool { + // Tolerance: a cut whose position lands within ~1pt of an obstacle's + // edge is allowed (the obstacle's edge is itself a natural cut line). + const EDGE_TOLERANCE_PT: f32 = 1.0; + for ob in obstacles { + // Skip obstacles that don't intersect bbox at all. + let ox0 = ob.x.max(bbox.x); + let oy0 = ob.y.max(bbox.y); + let ox1 = (ob.x + ob.width).min(bbox.x + bbox.width); + let oy1 = (ob.y + ob.height).min(bbox.y + bbox.height); + if ox1 <= ox0 || oy1 <= oy0 { + continue; + } + let through = match axis { + CutAxis::Vertical => { + position > ox0 + EDGE_TOLERANCE_PT && position < ox1 - EDGE_TOLERANCE_PT + } + CutAxis::Horizontal => { + position > oy0 + EDGE_TOLERANCE_PT && position < oy1 - EDGE_TOLERANCE_PT + } + }; + if through { + return true; + } + } + false +} + +fn xy_split_bbox(bbox: &Rect, cut: &CutCandidate) -> (Rect, Rect) { + match cut.axis { + CutAxis::Horizontal => { + let top_h = (cut.position - bbox.y).max(0.0); + let top = Rect { + x: bbox.x, + y: bbox.y, + width: bbox.width, + height: top_h, + }; + let bot = Rect { + x: bbox.x, + y: cut.position, + width: bbox.width, + height: (bbox.height - top_h).max(0.0), + }; + (top, bot) + } + CutAxis::Vertical => { + let left_w = (cut.position - bbox.x).max(0.0); + let left = Rect { + x: bbox.x, + y: bbox.y, + width: left_w, + height: bbox.height, + }; + let right = Rect { + x: cut.position, + y: bbox.y, + width: (bbox.width - left_w).max(0.0), + height: bbox.height, + }; + (left, right) + } + } +} + +/// Minimum fraction of region width a banner band must cover. +const XY_BANNER_WIDTH_FRACTION: f32 = 0.6; +/// Banner clearance gap above/below in multiples of median line height. +const XY_BANNER_CLEARANCE_FACTOR: f32 = 1.5; + +/// Detect a "banner" y-band — one or more vertically-adjacent wide text bands +/// (≥ `XY_BANNER_WIDTH_FRACTION` of bbox width) flanked by a clear vertical +/// gap from the rest of the content. Returns an H-cut that isolates the banner +/// from neighboring content. +/// +/// Motivating case: a full-width centered title + authors block above a 2- +/// column body where a figure straddles both columns. Density-based XY-cut +/// can't separate them because the figure prevents any clean V-cut and there's +/// no obvious H-valley between the banner and the body. The banner detector +/// runs first and force-cuts just below the banner stack, freeing the +/// recursion to find normal column gutters in the lower region. +/// +/// Also helps mid-document section headings that span the full content width: +/// a wide centered "5 Conclusion" line above a 2-column body would otherwise +/// get pulled into one column. +/// Fallback column-gutter detector for cases where the density-based V-cut +/// search finds no valley — typically because a column-spanning element +/// (wide heading, figure, table) fills the gutter at one y-band and inflates +/// the projection at the gutter's x. Detects 2+ column structure by clustering +/// the left edges of "narrow" lines (lines that don't span the full region +/// width). When two clusters with ≥`XY_COLUMN_MIN_LINES_PER_PEAK` lines each +/// are separated by ≥`XY_COLUMN_MIN_GAP_FRACTION × bbox.width`, returns a +/// V-cut at the midpoint between them with a high score so it beats any +/// density-based cut except banner. +fn xy_find_column_cut( + items: &[ProjectedTextItem], + idxs: &[usize], + bbox: &Rect, + median_h: f32, + tabular: &mut bool, +) -> Option { + let dbg = std::env::var("LITEPARSE_DEBUG_XY").is_ok(); + macro_rules! reject { + ($($t:tt)*) => {{ + if dbg { eprintln!("[xy col-reject] {}", format!($($t)*)); } + return None; + }}; + } + if bbox.width <= 1.0 || idxs.len() < 8 { + reject!("preflight: width={:.1} items={}", bbox.width, idxs.len()); + } + + // Identify "line-start" items: within each y-band, the first item of + // each horizontal run is a line-start. PDFium routinely emits mid-line + // word-cluster fragments as separate items; counting all of them in + // the histogram denominator dilutes the column-dominance ratio so the + // wide-side probe never trips after a footer-peel H-cut. A 2-column + // line at the same baseline contributes TWO line-starts (one per + // column) because the inter-column gutter exceeds `gutter_gap`, so we + // don't collapse a band to its single leftmost item. + let band_tol = (median_h * 0.5).max(2.0); + let gutter_gap = median_h.max(4.0); + + let mut sorted: Vec = idxs.to_vec(); + sorted.sort_by(|&a, &b| { + items[a] + .item + .y + .total_cmp(&items[b].item.y) + .then(items[a].item.x.total_cmp(&items[b].item.x)) + }); + + let mut line_starts: Vec = Vec::with_capacity(idxs.len() / 2); + // Items whose gap to the previous item falls just under `gutter_gap` but + // clearly above word-gap range. A tight column gutter (e.g. ~9pt at + // median_h=10) fails the strict test on justified lines whose left column + // runs right up to the gutter; these get a second chance below if their x + // aligns with a column peak that has strict evidence. + let mut near_starts: Vec = Vec::new(); + let near_gap_floor = gutter_gap * 0.6; + let mut k = 0; + while k < sorted.len() { + let band_y = items[sorted[k]].item.y; + let mut j = k; + while j < sorted.len() && (items[sorted[j]].item.y - band_y).abs() <= band_tol { + j += 1; + } + let mut band_items: Vec = sorted[k..j].to_vec(); + band_items.sort_by(|&a, &b| items[a].item.x.total_cmp(&items[b].item.x)); + let mut prev_right = f32::NEG_INFINITY; + for &idx in &band_items { + let it = &items[idx].item; + if it.x.is_finite() { + let gap = it.x - prev_right; + if gap > gutter_gap { + line_starts.push(it.x); + } else if gap > near_gap_floor { + near_starts.push(it.x); + } + } + let right = it.x + it.width.max(0.0); + if right > prev_right { + prev_right = right; + } + } + k = j; + } + + if line_starts.len() < 8 { + reject!( + "line_starts={} < 8 (items={})", + line_starts.len(), + idxs.len() + ); + } + + // Histogram extent uses full item bounds so `XY_COLUMN_MIN_GAP_FRACTION` + // calibration (fraction of layout width) is preserved. Only line-starts + // contribute to the bucket counts and the dominance denominator. + let mut min_x = f32::INFINITY; + let mut max_x = f32::NEG_INFINITY; + for &i in idxs { + let it = &items[i].item; + if it.x.is_finite() { + min_x = min_x.min(it.x); + max_x = max_x.max(it.x + it.width.max(0.0)); + } + } + if !min_x.is_finite() || max_x <= min_x + 1.0 { + reject!("degenerate extent min_x={min_x:.1} max_x={max_x:.1}"); + } + let total_w = max_x - min_x; + let bucket_pt = XY_COLUMN_BUCKET_PT; + let n_buckets = ((total_w / bucket_pt).ceil() as usize).max(1); + let mut hist = vec![0usize; n_buckets]; + for &x in &line_starts { + let b_f = ((x - min_x) / bucket_pt).floor(); + let b = b_f.max(0.0) as usize; + if b < n_buckets { + hist[b] += 1; + } + } + + // Peak-aligned rescue for tight gutters: a near-gap start only counts + // when it lands at (nearly) the same x as existing strict-evidence + // starts. A column left edge aligns to sub-point precision across + // bands; word-gap "rivers" in justified text jitter by several points — + // a looser bucket-width match here let rivers stack onto stray strict + // starts and manufacture a fake second column inside narrow justified + // prose. Require near-exact agreement with at least 2 strict starts. + let mut rescued = 0usize; + let mut counted_starts: Vec = line_starts.clone(); + if !near_starts.is_empty() { + const RESCUE_ALIGN_TOL_PT: f32 = 1.5; + for &x in &near_starts { + let support = line_starts + .iter() + .filter(|&&s| (s - x).abs() <= RESCUE_ALIGN_TOL_PT) + .count(); + if support >= 2 { + let b = (((x - min_x) / bucket_pt).floor()).max(0.0) as usize; + if b < n_buckets { + hist[b] += 1; + rescued += 1; + counted_starts.push(x); + } + } + } + if dbg && rescued > 0 { + eprintln!( + "[xy col-rescue] {rescued} near-gap starts (gap in [{near_gap_floor:.1},{gutter_gap:.1}]) exactly aligned to >=2 strict starts" + ); + } + } + let n_starts = line_starts.len() + rescued; + + // Cluster adjacent occupied buckets into peaks. A peak is a contiguous + // run of buckets with ≥1 count, merged through gaps of ≤2 zero buckets + // (~10pt) to absorb slight intra-column drift. + let mut peaks: Vec<(f32, usize)> = Vec::new(); // (x_center, item_count) + let mut i = 0; + while i < n_buckets { + if hist[i] == 0 { + i += 1; + continue; + } + let s = i; + let mut total = 0usize; + let mut last_nonzero = i; + let mut j = i; + while j < n_buckets { + if hist[j] > 0 { + total += hist[j]; + last_nonzero = j; + j += 1; + } else if j - last_nonzero <= 2 { + j += 1; + } else { + break; + } + } + let x_center = min_x + bucket_pt * (s as f32 + last_nonzero as f32 + 1.0) * 0.5; + peaks.push((x_center, total)); + i = j; + } + + // Keep only "strong" peaks. A real column edge is sat-stacked by many + // lines at the same left x; spurious peaks (a stray indented quote, a + // numbered marker, etc.) carry only a few lines. + let strong_before = peaks.len(); + let raw_counts: Vec<(f32, usize)> = peaks.clone(); + // Adaptive minimum: scale with how many line-starts the band actually has. + // The static `XY_COLUMN_MIN_LINES_PER_PEAK = 10` floor is meant to drop + // indented-paragraph false peaks (a handful of lines) on a large region, + // but on smaller regions it can drop legitimate column peaks too. For a + // body region with ~36 lines split across 2 columns, each peak only + // carries 5–6 line-starts after the histogram spreads them across the + // page width — well under 10. Use `max(line_starts / 6, 4)`: still rules + // out the tiny indent peaks, but a 2-column region with ~5 lines/peak + // qualifies. Capped by `XY_COLUMN_MIN_LINES_PER_PEAK` so large regions + // keep the stricter floor. + let adaptive_min = (n_starts / 6).clamp(4, XY_COLUMN_MIN_LINES_PER_PEAK); + peaks.retain(|(_, c)| *c >= adaptive_min); + if peaks.len() < 2 { + if dbg { + let preview: Vec = raw_counts + .iter() + .take(8) + .map(|(x, c)| format!("({x:.0}:{c})")) + .collect(); + eprintln!( + "[xy col-reject] peaks<2 after min_per_peak={adaptive_min} (raw={strong_before} strong={}): {}", + peaks.len(), + preview.join(",") + ); + } + return None; + } + peaks.sort_by(|a, b| a.0.total_cmp(&b.0)); + + // Tabular layouts produce 3+ strong peaks (one per cell column). Real + // page columns have exactly 2 dominant peaks (left edge of col 1, left + // edge of col 2) — even multi-paragraph indents are sub-peaks weak + // enough to drop out under the min_per_peak filter. Bail when we see + // more than 2 strong peaks so we don't slice tables. + // Tabular layouts produce 3+ strong peaks with comparable counts. But a + // real 2-column page can also produce 3+ peaks when a centered float + // (caption, sub-heading stack, pull-quote) sits between the columns and + // contributes line-starts at a mid x. Distinguish: if the top-2 peaks + // by count dominate the remainder, keep them and drop the rest; + // otherwise (top-3 are comparable → tabular) bail. + if peaks.len() > 2 { + let mut by_count = peaks.clone(); + by_count.sort_by(|a, b| b.1.cmp(&a.1)); + let third = by_count[2].1 as f32; + let second = by_count[1].1 as f32; + let largest = by_count[0].1 as f32; + if second <= 0.0 || third / second >= XY_COLUMN_PEAK_BALANCE_RATIO { + // 3+ comparable peaks. Two shapes produce this: a genuine + // N-column prose layout (newspaper / magazine / multi-col + // reference list) or a data table. We can't bail on both — + // bailing leaves N-column prose joined line-by-line, which the + // borderless table detector then shreds into a fake table. + // Keep every peak balanced against the largest and let the + // gap logic cut the leftmost gutter; recursion re-runs on each + // side and peels the remaining columns one at a time. The + // downstream dominance + min-gap guards still reject tables + // whose cells don't tightly left-cluster (numeric columns are + // typically right-aligned / centered, so they never form + // strong left-edge peaks and fail dominance here). + let before = peaks.len(); + peaks.retain(|(_, c)| { + largest <= 0.0 || (*c as f32) / largest >= XY_COLUMN_PEAK_BALANCE_RATIO + }); + if peaks.len() < 2 { + reject!("peaks>2 but <2 balanced (largest={largest})"); + } + if dbg { + eprintln!( + "[xy col-keep-N] kept {} of {} balanced peaks (N-column); xs=[{}]", + peaks.len(), + before, + peaks + .iter() + .map(|(x, c)| format!("{x:.0}:{c}")) + .collect::>() + .join(",") + ); + } + } else { + // Top-2 dominate → assume real columns + weak floats. Keep + // top-2 (re-sorted left→right) and continue. + let mut top2 = vec![by_count[0], by_count[1]]; + top2.sort_by(|a, b| a.0.total_cmp(&b.0)); + if dbg { + eprintln!( + "[xy col-keep-top2] dropped {} weak peaks; kept xs=[{:.0}:{},{:.0}:{}]", + peaks.len() - 2, + top2[0].0, + top2[0].1, + top2[1].0, + top2[1].1 + ); + } + peaks = top2; + } + } + // Fill-density gate: prose columns fill each inter-peak slot nearly + // edge-to-edge; table cells leave wide whitespace. Applies to ALL peak + // counts including 2 — a 2-col label/value table (description list) + // produces the same two left-edge peaks as 2-col prose, and slicing it + // strands each half in its own leaf where no table detector can see + // the rows whole. Per band, measure each column's covered span / slot + // width; if too sparse, treat as a table and bail. + { + let mut n = peaks.len(); + let mut covered_sum = vec![0.0f32; n]; + let mut band_count = vec![0usize; n]; + let mut kk = 0; + while kk < sorted.len() { + let by = items[sorted[kk]].item.y; + let mut jj = kk; + while jj < sorted.len() && (items[sorted[jj]].item.y - by).abs() <= band_tol { + jj += 1; + } + let mut lo_x = vec![f32::INFINITY; n]; + let mut hi_x = vec![f32::NEG_INFINITY; n]; + for &idx in &sorted[kk..jj] { + let it = &items[idx].item; + if !it.x.is_finite() { + continue; + } + // Peak centers are bucket-quantized, so a column's items can + // start up to a bucket left of its peak center; without the + // slack they all bin into the previous slot and that column + // reads as empty (its sparse cells never get fill-checked). + let mut c = 0; + while c + 1 < n && it.x >= peaks[c + 1].0 - bucket_pt { + c += 1; + } + lo_x[c] = lo_x[c].min(it.x); + hi_x[c] = hi_x[c].max(it.x + it.width.max(0.0)); + } + for c in 0..n { + if hi_x[c] > lo_x[c] { + let col_w = if c + 1 < n { + peaks[c + 1].0 - peaks[c].0 + } else { + max_x - peaks[c].0 + }; + if col_w > 1.0 { + covered_sum[c] += ((hi_x[c] - lo_x[c]) / col_w).min(1.0); + band_count[c] += 1; + } + } + } + kk = jj; + } + // Two-part gate that distinguishes prose columns from tables: + // + // 1. min_fill floor (MIN_FILL_HARD_FLOOR = 0.38) — rejects + // pages where any column has narrow text in many bands + // (e.g. table with numeric column). Real tables have + // at least one column with fill ≤ 0.36. + // 2. Band-count-weighted avg_fill ≥ XY_COLUMN_MIN_FILL — + // catches table layouts where ALL columns are uniformly + // narrow (no column dominates the weighted average). + // + // Both must pass. The min-only gate was too brittle: a real + // 3-column prose page with one page-bottom column having ~4 + // bands of short text dragged the min below 0.55 even though + // every meaningful column was clearly prose. The avg-only + // gate let through tables where the narrow column had enough + // bands to be informative but its weight was diluted by + // wider neighboring columns. + let mut min_fill = f32::INFINITY; + let mut weighted_sum = 0.0f32; + let mut weighted_n = 0.0f32; + let mut per_col_fill: Vec = Vec::with_capacity(n); + for c in 0..n { + if band_count[c] > 0 { + let f = covered_sum[c] / band_count[c] as f32; + per_col_fill.push(f); + min_fill = min_fill.min(f); + weighted_sum += f * band_count[c] as f32; + weighted_n += band_count[c] as f32; + } + } + let mut avg_fill = if weighted_n > 0.0 { + weighted_sum / weighted_n + } else { + f32::INFINITY + }; + if dbg { + eprintln!( + "[xy col-fill] avg_fill={avg_fill:.2} min_fill={min_fill:.2} (gate={XY_COLUMN_MIN_FILL}) per_col=[{}] band_counts=[{}]", + per_col_fill + .iter() + .map(|f| format!("{f:.2}")) + .collect::>() + .join(","), + band_count + .iter() + .map(|c| c.to_string()) + .collect::>() + .join(",") + ); + } + const MIN_FILL_HARD_FLOOR: f32 = 0.38; + // Figure-scatter phantom column: on a 2-column page with an embedded + // plot (scatter cloud, axis tick labels), the plot's x-spread items + // form a third peak that is BOTH sparsely-banded (far fewer text rows + // than the real columns) and low-fill. Left in, it drags min_fill + // under the floor and flips the whole region to `tabular`, suppressing + // the genuine column split. A real narrow table column instead has a + // band count comparable to its siblings (one cell per row), so the + // band-count ratio cleanly separates the two. Drop phantom peaks when + // ≥2 well-filled columns survive, then re-run the gate on them. + if n > 2 && std::env::var("LITEPARSE_DISABLE_PHANTOM_COL_DROP").is_err() { + let max_bands = band_count.iter().copied().max().unwrap_or(0); + if max_bands > 0 { + let band_floor = + (max_bands as f32 * XY_COLUMN_PHANTOM_BAND_FRACTION).ceil() as usize; + let col_fill = |c: usize| -> f32 { + if band_count[c] > 0 { + covered_sum[c] / band_count[c] as f32 + } else { + 0.0 + } + }; + let survivors: Vec = (0..n) + .filter(|&c| !(band_count[c] < band_floor && col_fill(c) < MIN_FILL_HARD_FLOOR)) + .collect(); + let survivors_filled = survivors + .iter() + .all(|&c| col_fill(c) >= MIN_FILL_HARD_FLOOR); + if survivors.len() >= 2 && survivors.len() < n && survivors_filled { + let kept: Vec<(f32, usize)> = survivors.iter().map(|&c| peaks[c]).collect(); + let mut new_min = f32::INFINITY; + let mut wsum = 0.0f32; + let mut wn = 0.0f32; + for &c in &survivors { + let f = col_fill(c); + new_min = new_min.min(f); + wsum += f * band_count[c] as f32; + wn += band_count[c] as f32; + } + if dbg { + eprintln!( + "[xy col-phantom] dropped {} sparse low-fill peak(s) (band_floor={band_floor}); kept xs=[{}]", + n - survivors.len(), + kept.iter() + .map(|(x, _)| format!("{x:.0}")) + .collect::>() + .join(",") + ); + } + peaks = kept; + n = peaks.len(); + min_fill = new_min; + avg_fill = if wn > 0.0 { wsum / wn } else { f32::INFINITY }; + } + } + } + // The min-floor catches ≥3-col tables whose narrow numeric column is + // diluted out of the weighted average by wide neighbors. With only 2 + // columns no dilution is possible — a sparse column of two drags the + // average itself — and a short second prose column (a page-bottom + // column with a few bands) would false-positive here, suppressing a + // needed column split AND the density V via the tabular flag. + if n > 2 && min_fill.is_finite() && min_fill < MIN_FILL_HARD_FLOOR { + *tabular = true; + reject!("N-column min fill {min_fill:.2} < {MIN_FILL_HARD_FLOOR} (tabular column)"); + } + if avg_fill.is_finite() && avg_fill < XY_COLUMN_MIN_FILL { + *tabular = true; + reject!( + "N-column avg fill {avg_fill:.2} < {} (tabular)", + XY_COLUMN_MIN_FILL + ); + } + } + // Require the two peaks to together dominate the histogram — a real + // 2-column layout has the great majority of items starting at one of + // the two column-left edges. Tables, lists, and prose with scattered + // indents distribute their item-starts across many x's and won't pass. + let peak_sum: usize = peaks.iter().map(|(_, c)| *c).sum(); + let dominance = (peak_sum as f32) / (n_starts as f32); + if dominance < XY_COLUMN_PEAK_DOMINANCE { + reject!( + "dominance {dominance:.2} < {} (peak_sum={peak_sum} line_starts={n_starts})", + XY_COLUMN_PEAK_DOMINANCE + ); + } + // Require the peaks to be roughly balanced in size. A real multi-column + // layout has comparable line counts in each column; a long-single-column + // page that happens to have one bullet stack at a different x produces + // a tall + tiny peak pair that should not trigger a column split. Check + // the global min/max across all kept peaks so an N-column layout with one + // short column is also rejected. + let smallest = peaks.iter().map(|(_, c)| *c).min().unwrap() as f32; + let largest = peaks.iter().map(|(_, c)| *c).max().unwrap() as f32; + let balance = if largest > 0.0 { + smallest / largest + } else { + 0.0 + }; + if balance < XY_COLUMN_PEAK_BALANCE_RATIO { + reject!( + "balance {balance:.2} < {} (min={smallest} max={largest} npeaks={})", + XY_COLUMN_PEAK_BALANCE_RATIO, + peaks.len() + ); + } + + // Cut at the leftmost gutter that clears `XY_COLUMN_MIN_GAP_FRACTION × + // total_w` (list-item / block-quote indents produce closely spaced peaks; + // a real column gutter is much further apart). For a 2-column layout this + // is the only gap. For N columns, peeling the leftmost column first and + // letting the recursion re-run on the remainder splits the rest one + // gutter at a time. + let min_gap = total_w * XY_COLUMN_MIN_GAP_FRACTION; + let mut best: Option<(f32, usize)> = None; + for w_idx in 0..peaks.len() - 1 { + let gap = peaks[w_idx + 1].0 - peaks[w_idx].0; + if gap >= min_gap { + best = Some((gap, w_idx)); + break; + } + } + let li = match best { + Some((_, li)) => li, + None => reject!( + "no gap >= {:.0} (min_gap_frac={} total_w={total_w:.0}); peak_xs=[{:.0},{:.0}] gap={:.0}", + min_gap, + XY_COLUMN_MIN_GAP_FRACTION, + peaks[0].0, + peaks[1].0, + peaks[1].0 - peaks[0].0 + ), + }; + // Place the cut at the actual gutter, not midway between peak centers. + // Peaks mark column LEFT edges, so the center midpoint lands inside the + // left column's text span and misroutes its mid-line items under + // partition_by_item. The gutter spans from the rightmost left-side item + // extent to the right column's leftmost counted start — NOT the peak + // center: a right-aligned numeric column scatters its starts left of + // the bucket-quantized center, and a cut placed at the center would + // route those values into the left column. + let mid_centers = (peaks[li].0 + peaks[li + 1].0) * 0.5; + let right_min_start = counted_starts + .iter() + .copied() + .filter(|&x| x > mid_centers) + .fold(f32::INFINITY, f32::min); + let boundary = if right_min_start.is_finite() { + right_min_start + } else { + peaks[li + 1].0 + }; + let mut gutter_left = f32::NEG_INFINITY; + for &i in idxs { + let it = &items[i].item; + if it.x.is_finite() { + let r = it.x + it.width.max(0.0); + if r <= boundary - 1.0 && r > gutter_left { + gutter_left = r; + } + } + } + let cut_x = if gutter_left.is_finite() && gutter_left > peaks[li].0 { + (gutter_left + boundary) * 0.5 + } else { + mid_centers + }; + // Validate cut lands inside the bbox; if bbox-clamping moved the bbox + // off the items (some PDFs have content with negative x), fall back to + // an item-relative midpoint check. + if cut_x <= min_x + 1.0 || cut_x >= max_x - 1.0 { + reject!("cut_x {cut_x:.0} outside extent [{min_x:.0},{max_x:.0}]"); + } + // High finite score so this beats every density cut but stays below + // banner's infinity. + Some(CutCandidate { + axis: CutAxis::Vertical, + position: cut_x, + score: 1.0e9, + }) +} + +/// X position of the vertical gutter that splits `idxs` (within `bbox`) into +/// columns — from the density V-cut or the column-start histogram fallback. +/// `None` when the region is single-column. Used to decide whether peeling a +/// wide spanning line above this region is productive. +fn column_cut_x( + items: &[ProjectedTextItem], + idxs: &[usize], + bbox: &Rect, + median_h: f32, + figures: &[Rect], +) -> Option { + xy_find_best_cut(items, idxs, bbox, CutAxis::Vertical, median_h, figures) + .or_else(|| { + let mut tabular = false; + xy_find_column_cut(items, idxs, bbox, median_h, &mut tabular) + }) + .map(|c| c.position) +} + +/// Union horizontal extent (min x, max x) of `idxs`. +fn x_extent(items: &[ProjectedTextItem], idxs: &[usize]) -> (f32, f32) { + let mut lo = f32::INFINITY; + let mut hi = f32::NEG_INFINITY; + for &i in idxs { + let it = &items[i].item; + lo = lo.min(it.x); + hi = hi.max(it.x + it.width.max(0.0)); + } + (lo, hi) +} + +fn xy_find_banner_cut( + items: &[ProjectedTextItem], + idxs: &[usize], + bbox: &Rect, + median_h: f32, +) -> Option { + if bbox.width <= 1.0 || idxs.len() < 2 { + return None; + } + let mut sorted: Vec = idxs.to_vec(); + sorted.sort_by(|&a, &b| items[a].item.y.total_cmp(&items[b].item.y)); + let band_tol = (median_h * 0.5).max(2.0); + let min_clearance = (median_h * XY_BANNER_CLEARANCE_FACTOR).max(8.0); + let width_threshold = XY_BANNER_WIDTH_FRACTION * bbox.width; + + // Group items into y-bands. A new item joins the current band when its top + // is within `band_tol` of either the band's center or its existing bottom + // (handles slight baseline variation). + let mut bands: Vec<(f32, f32, f32, f32)> = Vec::new(); // (y_min, y_max, x_min, x_max) + for &i in &sorted { + let it = &items[i].item; + let y0 = it.y; + let y1 = it.y + it.height.max(0.0); + let x0 = it.x; + let x1 = it.x + it.width.max(0.0); + let mut merged = false; + if let Some(last) = bands.last_mut() { + let center = (last.0 + last.1) * 0.5; + if (y0 - center).abs() <= band_tol || y0 <= last.1 + band_tol { + last.0 = last.0.min(y0); + last.1 = last.1.max(y1); + last.2 = last.2.min(x0); + last.3 = last.3.max(x1); + merged = true; + } + } + if !merged { + bands.push((y0, y1, x0, x1)); + } + } + if bands.is_empty() { + return None; + } + let is_wide = |b: &(f32, f32, f32, f32)| -> bool { (b.3 - b.2) >= width_threshold }; + + // Walk top→bottom. For each run of consecutive wide bands (close enough to + // each other to count as a single banner stack), check above/below clearance + // and emit a cut if isolated. + let mut i = 0; + while i < bands.len() { + if !is_wide(&bands[i]) { + i += 1; + continue; + } + let start = i; + let mut end = i; + // Extend through consecutive wide bands separated by less than the + // clearance threshold — multi-line title + authors counts as one + // banner stack. + while end + 1 < bands.len() + && is_wide(&bands[end + 1]) + && bands[end + 1].0 - bands[end].1 < min_clearance + { + end += 1; + } + let banner_top = bands[start].0; + let banner_bot = bands[end].1; + let above_y = if start > 0 { + Some(bands[start - 1].1) + } else { + None + }; + let below_y = if end + 1 < bands.len() { + Some(bands[end + 1].0) + } else { + None + }; + let above_clear = above_y.is_none_or(|ay| banner_top - ay >= min_clearance); + let below_clear = below_y.is_none_or(|by| by - banner_bot >= min_clearance); + + if above_clear && below_clear { + // Prefer cutting BELOW the banner so it becomes the top region of + // the split (matches natural reading order). Fall back to cutting + // above only when there's nothing below to cut against. + let cut_y = if let Some(by) = below_y { + (banner_bot + by) * 0.5 + } else if let Some(ay) = above_y { + (ay + banner_top) * 0.5 + } else { + // Banner spans the whole region — no productive cut. + i = end + 1; + continue; + }; + // Skip degenerate cuts that would leave one side empty. + if cut_y > bbox.y + 1.0 && cut_y < bbox.y + bbox.height - 1.0 { + return Some(CutCandidate { + axis: CutAxis::Horizontal, + position: cut_y, + score: f32::INFINITY, + }); + } + } + i = end + 1; + } + None +} + +fn xy_cut_rec( + items: &[ProjectedTextItem], + idxs: Vec, + bbox: Rect, + depth: u32, + median_h: f32, + figures: &[Rect], +) -> Region { + if idxs.len() <= XY_MIN_ITEMS_TO_CUT || depth >= XY_MAX_DEPTH { + return Region { + bbox, + kind: RegionKind::Leaf { item_indices: idxs }, + }; + } + // Banner cut wins over density-based valleys. Score is f32::INFINITY so + // the existing comparison logic naturally selects it. + let banner = xy_find_banner_cut(items, &idxs, &bbox, median_h); + let h = xy_find_best_cut(items, &idxs, &bbox, CutAxis::Horizontal, median_h, figures); + let v = xy_find_best_cut(items, &idxs, &bbox, CutAxis::Vertical, median_h, figures); + // Fallback column-start histogram. Only consulted when BOTH density + // cuts returned None — those are the cases (wide-heading filled gutter, + // figure-straddle layouts) where the column structure is real but the + // density projection can't see it. If either density cut found a + // valley, trust it instead of firing the histogram fallback. + // Run the histogram probe unconditionally: besides being a fallback cut + // source, its fill gate is the region-level table detector — when it + // rejects with "tabular", density V-cuts must not slice the table's + // inter-column gutters either (the dominant column-major-table failure). + let mut region_tabular = false; + let column_full = xy_find_column_cut(items, &idxs, &bbox, median_h, &mut region_tabular); + let column = if v.is_none() && h.is_none() { + column_full + } else { + None + }; + let debug_xy = std::env::var("LITEPARSE_DEBUG_XY").is_ok(); + let disable_vcut_minlines = std::env::var("LITEPARSE_DISABLE_VCUT_MINLINES").is_ok(); + if debug_xy && depth == 0 && std::env::var("LITEPARSE_DEBUG_ITEMS").is_ok() { + for &i in &idxs { + let it = &items[i].item; + eprintln!( + "[xy item] x={:.1} w={:.1} y={:.1} h={:.1} text={:?}", + it.x, + it.width, + it.y, + it.height, + &it.text.chars().take(80).collect::() + ); + } + } + if debug_xy { + // Also probe what the column histogram WOULD return if we ran it + // unconditionally — this is the diagnostic that tells us whether + // reordering V/H/column priority would unlock more column splits. + let column_probe = column_full; + let pad = " ".repeat(depth as usize); + let fmt = |c: &Option| { + c.as_ref() + .map(|c| format!("{:?}@{:.1}/{:.3}", c.axis, c.position, c.score)) + .unwrap_or_else(|| "-".to_string()) + }; + let fmt_pos = |p: &Option| { + p.as_ref() + .map(|p| format!("{p:.1}")) + .unwrap_or_else(|| "-".to_string()) + }; + let (ex0, ex1) = x_extent(items, &idxs); + let lines = xy_distinct_lines(items, &idxs, median_h); + eprintln!( + "[xy d={depth}]{pad} bbox=({:.0},{:.0} {:.0}x{:.0}) items={} lines={} x_ext=[{ex0:.1},{ex1:.1}] banner={} h={} v={} col={} col_probe={}", + bbox.x, + bbox.y, + bbox.width, + bbox.height, + idxs.len(), + lines, + fmt(&banner), + fmt(&h), + fmt(&v), + fmt_pos(&column.as_ref().map(|c| c.position)), + fmt_pos(&column_probe.as_ref().map(|c| c.position)), + ); + } + // Build a priority-ordered candidate list. + // + // **Primary** is the cut chosen by the main resolution order + // (banner → column → H/V resolved by score margin). **Fallback** is + // the column-histogram probe — but ONLY that, not density V or the + // other density axis. The column histogram has strong discriminators + // (2 strong dominant balanced peaks); a density V valley that lost to + // H by the score margin is often a layout coincidence (mid-line word + // clustering) we shouldn't trust as a fallback. Falling through to + // the histogram-only fallback — rather than collapsing to a leaf — + // is what unblocks 2-column pages whose density H-cut peels a + // footer/header but trips the min-lines guard. + let (h_score, v_score) = (h.as_ref().map(|c| c.score), v.as_ref().map(|c| c.score)); + let prefer_h_over_v = match (h_score, v_score) { + (Some(hs), Some(vs)) => vs <= hs * XY_V_PREFERENCE_MARGIN, + _ => true, + }; + let mut candidates: Vec = Vec::new(); + if let Some(bc) = banner { + candidates.push(bc); + } + if let Some(cc) = column { + candidates.push(cc); + } + // Primary density cut, then the other axis as fallback. Without the + // alternate-axis fallback, a low-impact H-cut that wins by the score + // margin (e.g. peeling a 1-line footer) trips the min-lines guard and + // collapses the whole region to a single leaf — masking the real + // column structure the V-cut would have revealed. + // When the histogram fill gate classified this region as tabular, a + // density V valley here is a table gutter, not a column boundary — + // suppress it so the table reaches the table detectors row-major. + let v = if region_tabular { + if debug_xy && v.is_some() { + let pad = " ".repeat(depth as usize); + eprintln!("[xy d={depth}]{pad} -> SUPPRESS density V (region tabular)"); + } + None + } else { + v + }; + if prefer_h_over_v { + if let Some(hc) = h { + candidates.push(hc); + } + if let Some(vc) = v { + candidates.push(vc); + } + } else { + if let Some(vc) = v { + candidates.push(vc); + } + if let Some(hc) = h { + candidates.push(hc); + } + } + // Final fallback: re-use the unconditional column-histogram probe. + // Skipped when an existing candidate is already a histogram-derived + // column cut (score = 1.0e9 from `xy_find_column_cut`). + if !candidates + .iter() + .any(|c| c.axis == CutAxis::Vertical && (c.score - 1.0e9).abs() < 1.0) + && let Some(cc) = column_full + { + candidates.push(cc); + } + + for cut in candidates { + if debug_xy { + let pad = " ".repeat(depth as usize); + eprintln!( + "[xy d={depth}]{pad} -> TRY {:?}@{:.1}/{:.3}", + cut.axis, cut.position, cut.score + ); + } + let mut left: Vec = Vec::new(); + let mut right: Vec = Vec::new(); + // Column-histogram V-cuts (score = 1.0e9) split between two column + // left-edge clusters. The cut sits at the midpoint between the two + // peak x's, which is typically far inside the left column. PDFium + // routinely emits a single visual line as multiple items (line-start + // + mid-line emphasis fragments + late-line clusters); each item has + // its own x, so an item-by-item partition by `it.x` will split one + // logical line across both sides whenever a continuation fragment + // starts past `cut.position`. Verified on `031a888e…page_1` where + // a histogram V-cut at x=360.8 inside column 2 routed the body-start + // fragments at x=312 LEFT but their same-baseline continuation + // "In Section 2 the" at x=474 RIGHT, orphaning the snippet from its + // paragraph and producing a stranded block downstream. + // + // Fix: for histogram V-cuts, first measure how many y-bands have items + // on *both* sides of the cut ("straddling rows"). A real column + // boundary has every body row straddling (text in left + right column + // on the same baseline) — partition by `it.x` so each column gets its + // items. An intra-column histogram split (031a888e case: a single line + // wrapped into multiple items at different x's) has almost no + // straddling rows — partition by row leftmost-x so a wrapped line + // stays whole. The threshold (>~25% of rows straddle) cleanly + // separates: 0145bc47 (every body row straddles, ~100%) gets per-item + // partition; 031a888e (1 of ~45 rows straddles, ~2%) gets per-row. + // + // Density V-cuts (white-space valleys) still use centroid: they only + // fire where a real text-free vertical band exists, so an item that + // crosses a density cut is genuinely wider than the column. + let is_histogram_v_cut = cut.axis == CutAxis::Vertical && (cut.score - 1.0e9).abs() < 1.0; + if is_histogram_v_cut { + // Sort by y then x so items on one baseline cluster together. + let mut sorted_idxs: Vec = idxs.clone(); + sorted_idxs.sort_by(|&a, &b| { + items[a] + .item + .y + .total_cmp(&items[b].item.y) + .then(items[a].item.x.total_cmp(&items[b].item.x)) + }); + let band_tol = (median_h * 0.5).max(2.0); + + // First pass: count rows with items entirely-left, entirely-right, + // or straddling the cut. Decides the partition strategy. + let (mut rows_total, mut rows_straddle) = (0usize, 0usize); + let mut k = 0; + while k < sorted_idxs.len() { + let band_y = items[sorted_idxs[k]].item.y; + let mut j = k; + let mut has_left = false; + let mut has_right = false; + while j < sorted_idxs.len() + && (items[sorted_idxs[j]].item.y - band_y).abs() <= band_tol + { + let ix = items[sorted_idxs[j]].item.x; + if ix.is_finite() { + if ix < cut.position { + has_left = true; + } else { + has_right = true; + } + } + j += 1; + } + if has_left || has_right { + rows_total += 1; + if has_left && has_right { + rows_straddle += 1; + } + } + k = j; + } + // 25% threshold: when a quarter or more of rows have content on + // both sides of the cut, it's a real column boundary (every body + // row has text in both columns). Below that, the cut sits inside + // a single column and the rare straddler is an intra-column + // wrapped line that must not be torn apart. + const STRADDLE_PARTITION_FRACTION: f32 = 0.25; + let partition_by_item = rows_total > 0 + && (rows_straddle as f32) / (rows_total as f32) >= STRADDLE_PARTITION_FRACTION; + if debug_xy { + let pad = " ".repeat(depth as usize); + eprintln!( + "[xy d={depth}]{pad} hist-v straddle={}/{} → partition_by_{}", + rows_straddle, + rows_total, + if partition_by_item { "item" } else { "row" } + ); + } + + if partition_by_item { + for &i in &idxs { + if items[i].item.x < cut.position { + left.push(i); + } else { + right.push(i); + } + } + } else { + let mut k = 0; + while k < sorted_idxs.len() { + let band_y = items[sorted_idxs[k]].item.y; + let mut j = k; + let mut min_x = f32::INFINITY; + while j < sorted_idxs.len() + && (items[sorted_idxs[j]].item.y - band_y).abs() <= band_tol + { + let ix = items[sorted_idxs[j]].item.x; + if ix.is_finite() && ix < min_x { + min_x = ix; + } + j += 1; + } + let side_left = min_x < cut.position; + for &i in &sorted_idxs[k..j] { + if side_left { + left.push(i); + } else { + right.push(i); + } + } + k = j; + } + } + } else { + for &i in &idxs { + let it = &items[i].item; + let split_at = match cut.axis { + CutAxis::Horizontal => it.y + it.height.max(0.0) * 0.5, + CutAxis::Vertical => it.x + it.width.max(0.0) * 0.5, + }; + if split_at < cut.position { + left.push(i); + } else { + right.push(i); + } + } + } + if debug_xy { + let pad = " ".repeat(depth as usize); + eprintln!( + "[xy d={depth}]{pad} partition: left={} right={}", + left.len(), + right.len() + ); + } + if left.is_empty() || right.is_empty() { + if debug_xy { + let pad = " ".repeat(depth as usize); + eprintln!("[xy d={depth}]{pad} -> degenerate, try next"); + } + continue; + } + let (left_bbox, right_bbox) = xy_split_bbox(&bbox, &cut); + + // Banner cuts (score = ∞) at the page root are explicitly meant to + // isolate single-line wide headers like titles, so skip the + // min-lines guard for them. At deeper recursion levels we keep the + // guard — an aggressive single-line banner cut mid-recursion + // fragments paragraphs around emphasized lines (e.g. a centered + // "Figure N caption" sandwiched between body paragraphs would + // otherwise carve out its own sliver region and break paragraph + // continuation). + let allow_single_line = + cut.axis == CutAxis::Horizontal && cut.score.is_infinite() && depth == 0; + if cut.axis == CutAxis::Horizontal && !allow_single_line { + let lc = xy_distinct_lines(items, &left, median_h); + let rc = xy_distinct_lines(items, &right, median_h); + // Rescue: a single wide spanning line (e.g. a centered + // authors / affiliation line above a two-column body) is + // worth isolating even mid-recursion when peeling it reveals + // the column gutter underneath. Otherwise the spanning line + // straddles the gutter, the V-cut can never fire, and the + // whole body interleaves column-by-column. The density H-cut + // that peels it has a finite score (the line is often just + // under the banner width threshold), so this is NOT gated on + // a banner cut. Discriminator vs. carving a single-column + // caption out of running prose: the thin side must be one + // line whose x-extent crosses the gutter revealed on the + // other (multi-line) side. + let spanning_rescue = { + let thin_wide = if lc < XY_MIN_LINES_PER_H_SIDE && rc >= XY_MIN_LINES_PER_H_SIDE { + Some(((&left, lc), (&right, &right_bbox))) + } else if rc < XY_MIN_LINES_PER_H_SIDE && lc >= XY_MIN_LINES_PER_H_SIDE { + Some(((&right, rc), (&left, &left_bbox))) + } else { + None + }; + thin_wide.is_some_and(|((thin, thin_lines), (wide, wide_bbox))| { + thin_lines <= 1 + && column_cut_x(items, wide, wide_bbox, median_h, figures).is_some_and( + |gx| { + let (tx0, tx1) = x_extent(items, thin); + tx0 < gx - 1.0 && tx1 > gx + 1.0 + }, + ) + }) + }; + if (lc < XY_MIN_LINES_PER_H_SIDE || rc < XY_MIN_LINES_PER_H_SIDE) && !spanning_rescue { + if debug_xy { + let pad = " ".repeat(depth as usize); + eprintln!( + "[xy d={depth}]{pad} -> min-lines fail (lc={lc} rc={rc}), try next" + ); + } + continue; + } + } + // Vertical (column) min-lines guard. A real column gutter is sustained + // across multiple stacked lines on both sides. A vertical cut that + // isolates a single-line "column" is a false gutter: in single-column + // justified prose, a short final line plus a wide inter-word gap in the + // full-width line above it align an empty vertical band that is not a + // column boundary. Without this, that band splits the physical line + // into two sibling regions and the markdown emitter renders them out of + // order (e.g. "…and its share repurchases are 100%" → the tail is + // sliced off into its own region). The initial true 2-column split has + // many lines per side, so this never blocks real columns. + if cut.axis == CutAxis::Vertical && !disable_vcut_minlines { + let lc = xy_distinct_lines(items, &left, median_h); + let rc = xy_distinct_lines(items, &right, median_h); + if lc < XY_MIN_LINES_PER_H_SIDE || rc < XY_MIN_LINES_PER_H_SIDE { + if debug_xy { + let pad = " ".repeat(depth as usize); + eprintln!( + "[xy d={depth}]{pad} -> v-cut min-lines fail (lc={lc} rc={rc}), try next" + ); + } + continue; + } + } + + if debug_xy { + let pad = " ".repeat(depth as usize); + eprintln!( + "[xy d={depth}]{pad} -> COMMIT {:?}@{:.1}", + cut.axis, cut.position + ); + } + let left_region = xy_cut_rec(items, left, left_bbox, depth + 1, median_h, figures); + let right_region = xy_cut_rec(items, right, right_bbox, depth + 1, median_h, figures); + return Region { + bbox, + kind: RegionKind::Split { + axis: cut.axis, + children: vec![left_region, right_region], + }, + }; + } + + if debug_xy { + let pad = " ".repeat(depth as usize); + eprintln!("[xy d={depth}]{pad} -> LEAF (no valid cut)"); + } + Region { + bbox, + kind: RegionKind::Leaf { item_indices: idxs }, + } +} + +/// Build the page's XY-cut region tree. +/// +/// `figures` is an optional set of figure-region bounding rectangles (from +/// `figure_cluster::detect_figure_rects`); pass `&[]` to disable obstacle +/// seeding. Figures cause the recursion to favor cuts around them. +pub(crate) fn xy_cut( + items: &[ProjectedTextItem], + page_width: f32, + page_height: f32, + figures: &[Rect], +) -> Region { + let all: Vec = items + .iter() + .enumerate() + .filter(|(_, it)| !it.item.text.is_empty()) + .map(|(i, _)| i) + .collect(); + if all.is_empty() { + return Region { + bbox: Rect { + x: 0.0, + y: 0.0, + width: page_width.max(1.0), + height: page_height.max(1.0), + }, + kind: RegionKind::Leaf { + item_indices: Vec::new(), + }, + }; + } + let bbox = xy_root_bbox(items, page_width, page_height); + let median_h = xy_median_line_height(items, &all); + xy_cut_rec(items, all, bbox, 0, median_h, figures) +} + +/// Pre-order walk of leaves. Each leaf yields its `region_path` (index trail +/// from the root) and item indices. Leaves with no items are skipped. +fn xy_walk_leaves(region: &Region, path: &mut Vec, out: &mut Vec<(Vec, Vec)>) { + match ®ion.kind { + RegionKind::Leaf { item_indices } => { + if !item_indices.is_empty() { + out.push((path.clone(), item_indices.clone())); + } + } + RegionKind::Split { children, .. } => { + for (i, child) in children.iter().enumerate() { + path.push(i as u16); + xy_walk_leaves(child, path, out); + path.pop(); + } + } + } +} + +/// Group `items` into per-leaf lines using the XY-cut region tree, then derive +/// per-line aggregates. Leaves are walked pre-order so reading order follows +/// the layout — top→bottom bands, left→right columns, recursively. Returns +/// the populated lines and the region tree so callers can persist it on +/// `ParsedPage`. +pub(crate) fn build_projected_lines( + items: &[ProjectedTextItem], + page_width: f32, + page_height: f32, + figures: &[Rect], +) -> (Vec, Region) { + if items.is_empty() { + return (Vec::new(), Region::default()); + } + + let region = xy_cut(items, page_width, page_height, figures); + let mut leaves: Vec<(Vec, Vec)> = Vec::new(); + xy_walk_leaves(®ion, &mut Vec::new(), &mut leaves); + + // Figures used for the per-line `in_figure` heading-exclusion flag. Drop + // page-dominating figures: on posters / slides / infographics the whole + // page is one large graphic and its big text *is* the heading content, so + // excluding it would demote real headings. A chart on an academic page + // covers only a fraction of the page — that's the pollution we want out of + // the heading histogram. + let page_area = (page_width.max(1.0)) * (page_height.max(1.0)); + let heading_excl_figures: Vec = figures + .iter() + .filter(|f| f.width * f.height < page_area * 0.55) + .cloned() + .collect(); + + let mut out: Vec = Vec::new(); + for (path, indices) in leaves { + // Sort within the leaf by y, tie-break by x. `build_one_line` re-sorts + // by x for left→right concatenation; the y-banding loop here only + // needs y order. + let mut sorted = indices; + sorted.sort_by(|&a, &b| { + items[a] + .item + .y + .total_cmp(&items[b].item.y) + .then(items[a].item.x.total_cmp(&items[b].item.x)) + }); + + let mut current: Vec = Vec::new(); + let mut current_y: f32 = 0.0; + let mut current_h: f32 = 0.0; + // PDFium occasionally reports anomalously large item heights (e.g. + // 56pt for a single-word run whose real glyph height is ~13pt) when + // the font's bounding box / line-height is baked into the text-matrix + // scale. Without a cap, the y-band tolerance `max(h) * 0.5` swallows + // multiple distinct baselines into one projected line (visible on + // docs 121, 122 — fill-in-the-blank lab worksheets). Clamp at 24pt + // for the banding decision; the actual stored line bbox is unchanged. + const Y_BAND_HEIGHT_CAP: f32 = 24.0; + for idx in sorted { + let y = items[idx].item.y; + let raw_h = items[idx].item.height; + let h = raw_h.clamp(1.0, Y_BAND_HEIGHT_CAP); + if current.is_empty() { + current.push(idx); + current_y = y; + current_h = h; + continue; + } + // Use the SMALLER of the two heights for the y-band tolerance — + // matching baselines (same row) differ by ≪ either glyph height, + // while the next-row-down baseline differs by ≈ the smaller + // glyph's full height. Using `.max()` here is fooled by + // matrix-inflated em-boxes: a 16pt heading row "swallows" a + // 50pt-em-box body item on the row below because the tolerance + // becomes 25pt. Reproducer: doc 122 ("III. Electrophorese" at + // y=286.2 vs "Reagents:" at y=295.9, diff=9.7pt). + // When the incoming item's raw bbox is wildly taller than the + // current line's anchor (>2× and >Y_BAND_HEIGHT_CAP), this is + // an em-box-inflated body item trying to crash into a heading + // row. Halve the tolerance further so a small y-offset (4–7pt) + // doesn't fuse them. Reproducer: doc 122 "Load the Gel" + // (h=14.9, y=435.6) vs next-row "Use" (h=55.6, y=442.0, + // diff=6.4pt vs 14.9*0.5=7.45 tolerance — too permissive). + let height_mismatch = raw_h > Y_BAND_HEIGHT_CAP && raw_h > current_h * 2.0; + let tol_factor = if height_mismatch { 0.3 } else { 0.5 }; + let same = (y - current_y).abs() < current_h.min(h) * tol_factor; + if same { + current.push(idx); + current_y = current_y.min(y); + current_h = current_h.max(h); + } else { + out.push(build_one_line( + items, + ¤t, + path.clone(), + &heading_excl_figures, + )); + current = vec![idx]; + current_y = y; + current_h = h; + } + } + if !current.is_empty() { + out.push(build_one_line( + items, + ¤t, + path.clone(), + &heading_excl_figures, + )); + } + } + + // Normalize `indent_x` to be leaf-relative: subtract each leaf's minimum + // line bbox.x from every line in that leaf. This way list-nesting and + // paragraph-indent comparisons in `markdown_layout.rs` use offsets from + // the column's left edge rather than absolute page x. Multi-column pages + // would otherwise see every column-2 line as "indented" relative to + // column-1 lines. + { + use std::collections::HashMap; + let mut leaf_min: HashMap, f32> = HashMap::new(); + for line in &out { + let e = leaf_min + .entry(line.region_path.clone()) + .or_insert(f32::INFINITY); + if line.indent_x < *e { + *e = line.indent_x; + } + } + for line in &mut out { + if let Some(min) = leaf_min.get(&line.region_path) + && min.is_finite() + { + line.indent_x -= *min; + if line.indent_x < 0.0 { + line.indent_x = 0.0; + } + } + } + } + + (out, region) +} + +fn build_one_line( + items: &[ProjectedTextItem], + idxs: &[usize], + region_path: Vec, + figures: &[Rect], +) -> ProjectedLine { + // Sort by x so concatenation reads left→right even if reading order had + // rotated insertions. + let mut sorted: Vec = idxs.to_vec(); + sorted.sort_by(|a, b| items[*a].item.x.total_cmp(&items[*b].item.x)); + + let mut text = String::new(); + let mut min_x = f32::INFINITY; + let mut min_y = f32::INFINITY; + let mut max_x = f32::NEG_INFINITY; + let mut max_y = f32::NEG_INFINITY; + // Original-coordinate bbox (pre-rotation-displacement). `figures` are in + // original page space, so figure-overlap must be tested here, not against + // the projected `bbox` below (which can be displaced onto a virtual canvas + // by `handle_rotation_reading_order`). + let mut omin_x = f32::INFINITY; + let mut omin_y = f32::INFINITY; + let mut omax_x = f32::NEG_INFINITY; + let mut omax_y = f32::NEG_INFINITY; + + let mut size_weights: HashMap = HashMap::new(); + let mut height_weights: HashMap = HashMap::new(); + // Matrix-derived true on-page size (`Tf_size × text_matrix_scale`, computed + // in extract.rs). For matrix-baked-size fonts this is the precise size the + // raw `font_size` (≈1.0) hides; jitter-free, unlike bbox height. + let mut font_height_weights: HashMap = HashMap::new(); + let mut name_weights: HashMap = HashMap::new(); + let mut bold_chars: usize = 0; + let mut italic_chars: usize = 0; + let mut mono_chars: usize = 0; + let mut total_chars: usize = 0; + let mut anchor_weights: HashMap = HashMap::new(); + let mut mcid: Option = None; + let mut spans: Vec = Vec::with_capacity(sorted.len()); + + for (pos, &i) in sorted.iter().enumerate() { + let proj = &items[i]; + let it = &proj.item; + // `handle_rotation_reading_order` zeroes `item.rotation` after it + // reorders cardinal-rotated text into reading order, so the span would + // otherwise look horizontal to `is_rotated_line`. Restore the original + // rotation onto the span clone (only consumed by markdown heading/TOC + // rotation guards) so a sideways margin stamp doesn't pollute the + // heading-size map. + let mut span = it.clone(); + span.rotation = proj.orig_rotation; + spans.push(span); + + // Concatenate item text. Use existing num_spaces from projection only as + // a hint — the markdown emitter re-collapses whitespace, so we just + // ensure there's *some* separation between adjacent items. + if pos > 0 && !text.ends_with(' ') { + text.push(' '); + } + text.push_str(&it.text); + + min_x = min_x.min(it.x); + min_y = min_y.min(it.y); + max_x = max_x.max(it.x + it.width); + max_y = max_y.max(it.y + it.height); + + omin_x = omin_x.min(proj.orig_x); + omin_y = omin_y.min(proj.orig_y); + omax_x = omax_x.max(proj.orig_x + proj.orig_width); + omax_y = omax_y.max(proj.orig_y + proj.orig_height); + + let n = it.text.chars().count().max(1); + total_chars += n; + + if let Some(size) = it.font_size + && size > 0.0 + { + let key = (size * 100.0).round() as u32; + let e = size_weights.entry(key).or_insert((size, 0)); + e.1 += n; + } + let h_key = (it.height.max(0.0) * 100.0).round() as u32; + let e = height_weights.entry(h_key).or_insert((it.height, 0)); + e.1 += n; + if let Some(fh) = it.font_height + && fh > 1.5 + { + let fh_key = (fh * 100.0).round() as u32; + let e = font_height_weights.entry(fh_key).or_insert((fh, 0)); + e.1 += n; + } + + if let Some(name) = &it.font_name { + *name_weights.entry(name.clone()).or_insert(0) += n; + } + + if is_bold_item(it) { + bold_chars += n; + } + if is_italic_item(it) { + italic_chars += n; + } + if is_mono_item(it) { + mono_chars += n; + } + + let akey = match proj.anchor { + Anchor::Left => 0u8, + Anchor::Right => 1, + Anchor::Center => 2, + Anchor::Floating => 3, + }; + *anchor_weights.entry(akey).or_insert(0) += n; + + if mcid.is_none() { + mcid = it.mcid; + } + } + + // NOTE on tie-breaks: `max_by_key` over a HashMap returns the *last* max it + // iterates, and HashMap iteration order is randomized per process. Ties on + // char-weight would therefore pick a different winner every run, making the + // line's font size / name / anchor non-deterministic (and with them, heading + // and emphasis classification downstream). We break every tie on the bucket + // key so the result is stable across runs. + let dominant_size_from_font = size_weights + .iter() + .max_by_key(|(k, (_, n))| (*n, **k)) + .map(|(_, (s, _))| *s) + .unwrap_or(0.0); + // Fallback: when PDFium reports font_size ≤ 1.5 (size baked into the text + // matrix), use char-weighted bbox height so the size-dependent grouping + // (tables, paragraphs) keeps its well-tuned behavior. + let (dominant_font_size, font_size_is_estimated) = if dominant_size_from_font > 1.5 { + (dominant_size_from_font, false) + } else { + let h = height_weights + .iter() + .max_by_key(|(k, (_, n))| (*n, **k)) + .map(|(_, (h, _))| *h) + .unwrap_or(0.0); + (h, true) + }; + + // Precise size for *heading detection only*: when the raw size is baked, + // the matrix-derived `font_height` (Tf_size × text_matrix_scale) is the + // jitter-free on-page size, far better than the bbox-height estimate above + // for separating headings from body. Routed only to body-size + heading-map + // (see `heading_font_size` doc on ProjectedLine); table/paragraph grouping + // deliberately keeps the bbox-height value to avoid regressing table + // structure. + let heading_font_size = if dominant_size_from_font > 1.5 { + None + } else { + font_height_weights + .iter() + .max_by_key(|(k, (_, n))| (*n, **k)) + .map(|(_, (h, _))| *h) + }; + + let dominant_font_name = name_weights + .iter() + .max_by_key(|(name, n)| (**n, *name)) + .map(|(name, _)| name.clone()); + + let majority = |count: usize| total_chars > 0 && count * 2 > total_chars; + + let dominant_anchor_key = anchor_weights + .iter() + .max_by_key(|(k, n)| (**n, **k)) + .map(|(k, _)| *k) + .unwrap_or(0); + let anchor = match dominant_anchor_key { + 1 => Anchor::Right, + 2 => Anchor::Center, + 3 => Anchor::Floating, + _ => Anchor::Left, + }; + + let bbox = Rect { + x: if min_x.is_finite() { min_x } else { 0.0 }, + y: if min_y.is_finite() { min_y } else { 0.0 }, + width: if max_x.is_finite() && min_x.is_finite() { + (max_x - min_x).max(0.0) + } else { + 0.0 + }, + height: if max_y.is_finite() && min_y.is_finite() { + (max_y - min_y).max(0.0) + } else { + 0.0 + }, + }; + + // Figure membership: ≥50% of the line's original-coordinate area inside a + // detected figure that is also clearly taller than the line. The height + // guard is what separates real chart pollution (axis labels, legends — set + // inside tall plot regions) from a section heading sitting on a thin + // colored background bar, which figure detection can mis-cluster as a + // figure ~1 line tall. Only the former should lose heading candidacy. + let line_h = (omax_y - omin_y).max(0.0); + let in_figure = if omax_x > omin_x && line_h > 0.0 { + let line_area = (omax_x - omin_x) * line_h; + figures.iter().any(|f| { + if f.height < line_h * 3.0 { + return false; + } + let ix = (omax_x.min(f.x + f.width) - omin_x.max(f.x)).max(0.0); + let iy = (omax_y.min(f.y + f.height) - omin_y.max(f.y)).max(0.0); + line_area > 0.0 && (ix * iy) / line_area >= 0.5 + }) + } else { + false + }; + + ProjectedLine { + text, + bbox: bbox.clone(), + anchor, + // Real column detection is deferred (carry-forward in MARKDOWN_PROGRESS). + // `indent_x` mirrors bbox.x for now; the markdown emitter relies on + // this for paragraph/list-indent comparisons within a single column. + indent_x: bbox.x, + dominant_font_size, + font_size_is_estimated, + heading_font_size, + dominant_font_name, + all_bold: majority(bold_chars), + all_italic: majority(italic_chars), + all_mono: majority(mono_chars), + all_strike: false, + spans, + region_path, + mcid, + in_figure, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn projected_item(text: &str, y: f32, width: f32, height: f32) -> ProjectedTextItem { + ProjectedTextItem { + item: TextItem { + text: text.to_string(), + x: 10.0, + y, + width, + height, + ..Default::default() + }, + snap: Snap::Left, + anchor: Anchor::Left, + is_dup: false, + rendered: false, + num_spaces: 0, + force_unsnapped: false, + is_margin_line_number: false, + rotated: false, + d: 0.0, + orig_x: 10.0, + orig_y: y, + orig_width: width, + orig_height: height, + orig_rotation: 0.0, + } + } + + #[test] + fn project_to_grid_handles_text_sparse_zero_width_items() { + let page = Page { + page_number: 1, + page_width: 612.0, + page_height: 792.0, + graphics: Vec::new(), + text_items: Vec::new(), + struct_nodes: Vec::new(), + image_refs: Vec::new(), + }; + let projection_boxes = vec![ + projected_item("", 10.0, 0.0, 10.0), + projected_item("", 30.0, 0.0, 20.0), + ]; + + let (_, text) = project_to_grid(&page, projection_boxes); + + assert!(text.is_empty()); + } + + fn item_at(text: &str, x: f32, y: f32, w: f32, h: f32) -> ProjectedTextItem { + ProjectedTextItem { + item: TextItem { + text: text.to_string(), + x, + y, + width: w, + height: h, + ..Default::default() + }, + snap: Snap::Left, + anchor: Anchor::Left, + is_dup: false, + rendered: false, + num_spaces: 0, + force_unsnapped: false, + is_margin_line_number: false, + rotated: false, + d: 0.0, + orig_x: x, + orig_y: y, + orig_width: w, + orig_height: h, + orig_rotation: 0.0, + } + } + + #[test] + fn xy_cut_finds_column_gutter_on_two_column_layout() { + // Two columns, 50pt-wide gutter centered at x=300. Each column has + // five rows of body text. Expect: a single vertical split at the + // gutter, both children leaves. + let mut items = Vec::new(); + for row in 0..5 { + let y = 100.0 + row as f32 * 14.0; + items.push(item_at("left side text", 50.0, y, 200.0, 10.0)); + items.push(item_at("right side text", 350.0, y, 200.0, 10.0)); + } + let region = xy_cut(&items, 612.0, 792.0, &[]); + match region.kind { + RegionKind::Split { axis, children } => { + assert_eq!(axis, CutAxis::Vertical); + assert_eq!(children.len(), 2); + let l = match &children[0].kind { + RegionKind::Leaf { item_indices } => item_indices.len(), + _ => 0, + }; + let r = match &children[1].kind { + RegionKind::Leaf { item_indices } => item_indices.len(), + _ => 0, + }; + assert_eq!(l, 5, "left col should hold 5 items"); + assert_eq!(r, 5, "right col should hold 5 items"); + } + _ => panic!("expected a vertical split, got {:?}", region.kind), + } + } + + #[test] + fn xy_cut_returns_leaf_on_single_column_prose() { + // Tight single-column text — no valley wide enough to cut. + let items: Vec<_> = (0..8) + .map(|row| { + let y = 100.0 + row as f32 * 12.0; + item_at("the quick brown fox", 60.0, y, 400.0, 10.0) + }) + .collect(); + let region = xy_cut(&items, 612.0, 792.0, &[]); + assert!(matches!(region.kind, RegionKind::Leaf { .. })); + } + + #[test] + fn xy_cut_walk_assigns_reading_order_paths() { + // 2-column page: pre-order walk visits left column before right. + let mut items = Vec::new(); + for row in 0..5 { + let y = 100.0 + row as f32 * 14.0; + items.push(item_at("L", 50.0, y, 200.0, 10.0)); + items.push(item_at("R", 350.0, y, 200.0, 10.0)); + } + let (lines, _region) = build_projected_lines(&items, 612.0, 792.0, &[]); + // Left col's 5 lines come first (path [0, ...]), then right col's 5 + // (path [1, ...]). + assert_eq!(lines.len(), 10); + for line in &lines[..5] { + assert_eq!(line.region_path.first().copied(), Some(0)); + } + for line in &lines[5..] { + assert_eq!(line.region_path.first().copied(), Some(1)); + } + } + + #[test] + fn banner_cut_isolates_full_width_title_above_two_columns() { + // Layout: a centered wide title at the top, clear gap, then 2-column + // body text. Density-based V-cut at the root would place the title + // (centroid at page center) inside one of the columns. The banner + // detector should H-cut just below the title so it ends up as the + // pre-order first leaf, separate from both columns. + let mut items = Vec::new(); + items.push(item_at( + "Wide Centered Title Spanning Full Page", + 100.0, + 50.0, + 412.0, + 14.0, + )); + // Body starts well below the title (clear vertical gap). + for row in 0..5 { + let y = 200.0 + row as f32 * 14.0; + items.push(item_at("left side text", 50.0, y, 200.0, 10.0)); + items.push(item_at("right side text", 350.0, y, 200.0, 10.0)); + } + let (lines, _region) = build_projected_lines(&items, 612.0, 792.0, &[]); + // First line (in pre-order) must be the title. + let first = lines.first().expect("at least one line"); + assert!( + first.text.contains("Title"), + "expected title first, got {:?}", + first.text + ); + // Title leaf should be different from any column leaf. + let body_paths: Vec<&Vec> = lines[1..].iter().map(|l| &l.region_path).collect(); + assert!( + body_paths.iter().all(|p| **p != first.region_path), + "title leaf should not share region_path with body lines" + ); + } + + #[test] + fn banner_cut_does_not_fire_on_plain_two_column_layout() { + // Same layout as `xy_cut_finds_column_gutter_on_two_column_layout` — + // no wide centered element to trigger a banner cut. Regression guard: + // the V-cut must still be the first cut at the root. + let mut items = Vec::new(); + for row in 0..5 { + let y = 100.0 + row as f32 * 14.0; + items.push(item_at("left side text", 50.0, y, 200.0, 10.0)); + items.push(item_at("right side text", 350.0, y, 200.0, 10.0)); + } + let region = xy_cut(&items, 612.0, 792.0, &[]); + match region.kind { + RegionKind::Split { axis, .. } => { + assert_eq!(axis, CutAxis::Vertical, "expected V-cut, not H"); + } + _ => panic!("expected a split at the root"), + } + } + + #[test] + fn figure_obstacle_forces_h_cut_around_straddling_figure() { + // Two text bands separated by a wide figure that spans both halves of + // the page. The text alone has no V-gutter and only a modest y-gap; + // without obstacle seeding the root would either V-cut (bad — the + // figure straddles both sides) or fail to cut at all. With the figure + // stamped into density, the H-valleys above/below the figure dominate + // and the recursion H-cuts into a top band → figure band → bottom band. + let mut items = Vec::new(); + // Top band: several wide lines of text. + for row in 0..4 { + let y = 100.0 + row as f32 * 14.0; + items.push(item_at( + "top band line spans full width", + 50.0, + y, + 500.0, + 12.0, + )); + } + // Bottom band: several wide lines, well below the figure. + for row in 0..4 { + let y = 400.0 + row as f32 * 14.0; + items.push(item_at( + "bottom band line after the figure", + 50.0, + y, + 500.0, + 12.0, + )); + } + // Figure: 500×200pt block centered in the page between the bands. + let figures = vec![Rect { + x: 60.0, + y: 160.0, + width: 490.0, + height: 200.0, + }]; + + let region_no_fig = xy_cut(&items, 612.0, 792.0, &[]); + let region_with_fig = xy_cut(&items, 612.0, 792.0, &figures); + + // With figures, the root cut must be horizontal (top vs. bottom band). + // Drilling through nested splits is fine — we just check the first one. + match region_with_fig.kind { + RegionKind::Split { axis, .. } => { + assert_eq!( + axis, + CutAxis::Horizontal, + "figure obstacle should force an H-cut between bands" + ); + } + RegionKind::Leaf { .. } => panic!("expected a split when figure is present"), + } + // Without figures, the same layout *might* still find an H-cut (the + // gap from y≈127 to y≈400 is real), but the cut score with figures + // should be at least as strong. We mostly just want to confirm both + // paths return without panicking and the figure path produces a split. + let _ = region_no_fig; + } + + fn has_vertical_split(region: &Region) -> bool { + match ®ion.kind { + RegionKind::Split { axis, children } => { + *axis == CutAxis::Vertical || children.iter().any(has_vertical_split) + } + RegionKind::Leaf { .. } => false, + } + } + + fn two_column_body(items: &mut Vec, y0: f32) { + // Left column at x≈50..280, right column at x≈320..550, sharing y-bands. + for row in 0..8 { + let y = y0 + row as f32 * 14.0; + items.push(item_at("left column body text here", 50.0, y, 230.0, 12.0)); + items.push(item_at( + "right column body text here", + 320.0, + y, + 230.0, + 12.0, + )); + } + } + + #[test] + fn spanning_line_above_columns_is_peeled_to_reveal_gutter() { + // A single centered line that crosses the gutter (x 160..450, gutter + // ≈300) sits above a clean two-column body. The spanning line straddles + // the gutter so the body can't V-cut until it's peeled. The rescue in + // the min-lines guard must allow the (finite-score) H-cut that isolates + // the spanning line, after which the body splits into columns. + let mut items = vec![item_at( + "Centered Spanning Author Line", + 160.0, + 100.0, + 290.0, + 12.0, + )]; + two_column_body(&mut items, 140.0); + let region = xy_cut(&items, 612.0, 792.0, &[]); + assert!( + has_vertical_split(®ion), + "spanning line should be peeled so the two-column body V-cuts" + ); + } + + #[test] + fn single_column_caption_is_not_peeled_into_columns() { + // A short centered line that sits entirely within the left column + // (x 60..200, never crossing the gutter) must NOT trigger the spanning + // rescue — it's a caption in running prose, not a column-straddling + // banner. The body below it is genuinely single-column. + let mut items = vec![item_at("Short caption", 60.0, 100.0, 140.0, 12.0)]; + for row in 0..8 { + let y = 140.0 + row as f32 * 14.0; + items.push(item_at( + "single column running body text", + 50.0, + y, + 230.0, + 12.0, + )); + } + let region = xy_cut(&items, 612.0, 792.0, &[]); + assert!( + !has_vertical_split(®ion), + "single-column caption must not be carved into columns" + ); + } + + #[test] + fn project_pages_to_grid_handles_page_with_no_text_items() { + let pages = vec![Page { + page_number: 1, + page_width: 612.0, + page_height: 792.0, + text_items: Vec::new(), + graphics: Vec::new(), + struct_nodes: Vec::new(), + image_refs: Vec::new(), + }]; + + let parsed = project_pages_to_grid(pages); + + assert_eq!(parsed.len(), 1); + assert!(parsed[0].text.is_empty()); + assert!(parsed[0].text_items.is_empty()); + } + + #[test] + fn project_pages_to_grid_unions_original_bbox_when_word_items_merge() { + let y = 50.25; + let pages = vec![Page { + page_number: 1, + page_width: 612.0, + page_height: 792.0, + text_items: vec![ + TextItem { + text: "A".to_string(), + x: 10.0, + y, + width: 10.0, + height: 8.0, + ..Default::default() + }, + TextItem { + text: "B".to_string(), + x: 24.0, + y: y + 0.01, + width: 8.0, + height: 7.5, + ..Default::default() + }, + ], + graphics: Vec::new(), + struct_nodes: Vec::new(), + image_refs: Vec::new(), + }]; + + let parsed = project_pages_to_grid(pages); + let item = parsed[0] + .text_items + .iter() + .find(|item| item.text == "A B") + .expect("merged text item"); + + assert!((item.x - 10.0).abs() < 0.001); + assert!((item.y - y).abs() < 0.001); + assert!((item.width - 22.0).abs() < 0.001); + assert!((item.height - 8.0).abs() < 0.001); + } + + #[test] + fn project_pages_to_grid_unions_original_bbox_when_continuous_items_merge() { + let pages = vec![Page { + page_number: 1, + page_width: 612.0, + page_height: 792.0, + text_items: vec![ + TextItem { + text: "ab".to_string(), + x: 40.0, + y: 100.0, + width: 10.2, + height: 9.0, + ..Default::default() + }, + TextItem { + text: "cd".to_string(), + x: 50.2, + y: 100.0, + width: 12.3, + height: 9.0, + ..Default::default() + }, + ], + graphics: Vec::new(), + struct_nodes: Vec::new(), + image_refs: Vec::new(), + }]; + + let parsed = project_pages_to_grid(pages); + let item = parsed[0] + .text_items + .iter() + .find(|item| item.text == "ab cd") + .expect("merged continuous text item"); + + assert!((item.x - 40.0).abs() < 0.001); + assert!((item.y - 100.0).abs() < 0.001); + assert!((item.width - 22.5).abs() < 0.01); + assert!((item.height - 9.0).abs() < 0.01); + } + + #[test] + fn canonical_rotation_snaps_cardinals_and_near_cardinals() { + // Exact cardinals are unchanged. + assert_eq!(canonical_rotation(0.0), 0); + assert_eq!(canonical_rotation(90.0), 90); + assert_eq!(canonical_rotation(180.0), 180); + assert_eq!(canonical_rotation(270.0), 270); + + // Small offsets within the 2° tolerance snap to the nearest cardinal. + assert_eq!(canonical_rotation(1.0), 0); + assert_eq!(canonical_rotation(88.5), 90); + assert_eq!(canonical_rotation(271.0), 270); + } + + #[test] + fn canonical_rotation_snaps_near_360_to_zero() { + // Rotations just under 360° are ~upright and must snap to 0, not be + // treated as ~270° (regression: linear distance picked 270 for 359°). + assert_eq!(canonical_rotation(358.0), 0); + assert_eq!(canonical_rotation(359.0), 0); + assert_eq!(canonical_rotation(359.5), 0); + // rem_euclid normalizes out-of-range / negative inputs first. + assert_eq!(canonical_rotation(360.0), 0); + assert_eq!(canonical_rotation(-1.0), 0); + } + + #[test] + fn canonical_rotation_passes_through_non_cardinal_angles() { + // Beyond the 2° snap tolerance the rounded raw angle is returned, + // including angles near (but not within tolerance of) 360°. + assert_eq!(canonical_rotation(45.0), 45); + assert_eq!(canonical_rotation(357.0), 357); + } +} diff --git a/crates/liteparse/src/render.rs b/crates/liteparse/src/render.rs new file mode 100644 index 0000000..b33680b --- /dev/null +++ b/crates/liteparse/src/render.rs @@ -0,0 +1,173 @@ +use crate::error::LiteParseError; +use crate::extract::{encode_png, load_document_from_input}; +use crate::types::PdfInput; +use pdfium::Library; +use serde::Serialize; + +/// A single rendered page as PNG bytes. +#[derive(Debug, Clone)] +pub struct RenderedPage { + pub page_num: u32, + pub width: u32, + pub height: u32, + pub png_bytes: Vec, +} + +/// Render selected pages from a PDF input to PNG bytes. +/// +/// Acquires the process-global PDFium lock for the entire render. The lock +/// is held until this function returns — PNG encoding happens inside the +/// critical section, which is fine because it is pure CPU work with no +/// `.await` points. +pub fn render_pages_to_png( + input: &PdfInput, + page_numbers: Option<&[u32]>, + dpi: f32, + password: Option<&str>, +) -> Result, LiteParseError> { + let lib = Library::init(); + let document = load_document_from_input(&lib, input, password)?; + render_document_pages(&document, page_numbers, dpi) +} + +fn render_document_pages( + document: &pdfium::Document, + page_numbers: Option<&[u32]>, + dpi: f32, +) -> Result, LiteParseError> { + let page_count = document.page_count() as u32; + let pages: Vec = match page_numbers { + Some(nums) => nums.to_vec(), + None => (1..=page_count).collect(), + }; + + let mut results = Vec::with_capacity(pages.len()); + for page_num in pages { + if page_num < 1 || page_num > page_count { + return Err(LiteParseError::Other(format!( + "page {page_num} out of range (document has {page_count} pages)" + ))); + } + + let page = document.page((page_num - 1) as i32)?; + let bitmap = page.render(dpi)?; + let width = bitmap.width() as u32; + let height = bitmap.height() as u32; + let rgba = bitmap.to_rgba(); + let png_bytes = encode_png(&rgba, width, height)?; + + results.push(RenderedPage { + page_num, + width, + height, + png_bytes, + }); + } + + Ok(results) +} + +/// Render a single page to a PNG file. +pub fn screenshot( + pdf_path: &str, + page_num: u32, + dpi: f32, + output_path: &str, + password: Option<&str>, +) -> Result<(), LiteParseError> { + let input = PdfInput::Path(pdf_path.to_string()); + let pages = render_pages_to_png(&input, Some(&[page_num]), dpi, password)?; + let page = pages + .into_iter() + .next() + .ok_or_else(|| LiteParseError::Other("no page rendered".into()))?; + + std::fs::write(output_path, &page.png_bytes)?; + + eprintln!( + "[rust-bin] rendered page {} at {dpi} DPI → {output_path} ({}×{})", + page_num, page.width, page.height + ); + + Ok(()) +} + +#[derive(Debug, Serialize)] +struct ImageBoundsOutput { + x: f32, + y: f32, + width: f32, + height: f32, +} + +/// Extract image bounding boxes and print as JSON to stdout. +pub fn image_bounds(pdf_path: &str, page_num: Option) -> Result<(), LiteParseError> { + let lib = Library::init(); + let document = load_document_from_input(&lib, &PdfInput::Path(pdf_path.to_string()), None)?; + let page_count = document.page_count(); + + for page_index in 0..page_count { + if let Some(target) = page_num + && page_index as u32 + 1 != target + { + continue; + } + + let page = document.page(page_index)?; + let bounds = page.image_bounds(25.0, 0.9); + + let output: Vec = bounds + .iter() + .map(|b| ImageBoundsOutput { + x: b.x, + y: b.y, + width: b.width, + height: b.height, + }) + .collect(); + + let json = serde_json::json!({ + "page_number": page_index + 1, + "images": output, + }); + println!("{}", serde_json::to_string(&json)?); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_image_bounds_output_serializes() { + let b = ImageBoundsOutput { + x: 1.0, + y: 2.0, + width: 3.0, + height: 4.0, + }; + let s = serde_json::to_string(&b).unwrap(); + assert!(s.contains("\"x\":1")); + assert!(s.contains("\"width\":3")); + } + + #[test] + fn test_screenshot_missing_file_errors() { + let r = screenshot( + "/nonexistent/path/does_not_exist.pdf", + 1, + 72.0, + "/tmp/out.png", + None, + ); + assert!(r.is_err()); + } + + #[test] + fn test_image_bounds_missing_file_errors() { + let r = image_bounds("/nonexistent/path/does_not_exist.pdf", None); + assert!(r.is_err()); + } +} diff --git a/crates/liteparse/src/search.rs b/crates/liteparse/src/search.rs new file mode 100644 index 0000000..7862f0c --- /dev/null +++ b/crates/liteparse/src/search.rs @@ -0,0 +1,222 @@ +use crate::types::TextItem; + +/// Options for searching text items. +pub struct SearchOptions { + pub phrase: String, + pub case_sensitive: bool, +} + +/// Search text items for phrase matches, returning synthetic merged items. +/// +/// Consecutive text items are concatenated and searched. When a phrase spans +/// multiple items, the result is a single merged item with a combined bounding +/// box and the matched text. Font metadata is taken from the first matched item. +pub fn search_items(items: &[TextItem], options: &SearchOptions) -> Vec { + let mut results = Vec::new(); + // An empty phrase matches nothing. Without this guard, `str::contains("")` + // is always true, so every item would emit a spurious empty-text match. + if options.phrase.is_empty() { + return results; + } + let normalize = |s: &str| -> String { + if options.case_sensitive { + s.to_string() + } else { + s.to_lowercase() + } + }; + let q = normalize(&options.phrase); + + // Pre-compute separator between each pair of adjacent items. + // If two items are on the same line and spatially adjacent, join without a space. + let mut seps: Vec<&str> = vec![""; items.len()]; + for i in 1..items.len() { + let prev = &items[i - 1]; + let cur = &items[i]; + let font_size = prev.font_size.or(cur.font_size).unwrap_or(12.0); + let same_line = (cur.y - prev.y).abs() < font_size * 0.5; + let gap = cur.x - (prev.x + prev.width); + seps[i] = if same_line && gap <= font_size * 0.3 { + "" + } else { + " " + }; + } + + let mut start = 0; + while start < items.len() { + let mut combined = String::new(); + let mut found = false; + + for end in start..items.len() { + if end > start { + combined.push_str(seps[end]); + } + combined.push_str(&items[end].text); + + if normalize(&combined).contains(&q) { + // Narrow from the left: drop leading items that aren't part of the match + let mut narrowed = combined.clone(); + let mut s = start; + while s < end { + let skip_len = items[s].text.len() + seps[s + 1].len(); + let without = &narrowed[skip_len..]; + if normalize(without).contains(&q) { + narrowed = without.to_string(); + s += 1; + } else { + break; + } + } + + // Merge bounding boxes of matched items + let matched = &items[s..=end]; + let x = matched.iter().map(|m| m.x).fold(f32::INFINITY, f32::min); + let y = matched.iter().map(|m| m.y).fold(f32::INFINITY, f32::min); + let x2 = matched + .iter() + .map(|m| m.x + m.width) + .fold(f32::NEG_INFINITY, f32::max); + let y2 = matched + .iter() + .map(|m| m.y + m.height) + .fold(f32::NEG_INFINITY, f32::max); + + results.push(TextItem { + text: options.phrase.clone(), + x, + y, + width: x2 - x, + height: y2 - y, + font_name: matched[0].font_name.clone(), + font_size: matched[0].font_size, + ..Default::default() + }); + + // Advance past the match to avoid duplicates + start = end + 1; + found = true; + break; + } + + // Stop expanding if combined text is already much longer than the query + if combined.len() > q.len() * 2 { + break; + } + } + + if !found { + start += 1; + } + } + + results +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_item(text: &str, x: f32, y: f32, width: f32) -> TextItem { + TextItem { + text: text.into(), + x, + y, + width, + height: 12.0, + font_name: Some("Arial".into()), + font_size: Some(12.0), + ..Default::default() + } + } + + #[test] + fn single_item_match() { + let items = vec![make_item("hello world", 0.0, 0.0, 80.0)]; + let opts = SearchOptions { + phrase: "hello world".into(), + case_sensitive: false, + }; + let results = search_items(&items, &opts); + assert_eq!(results.len(), 1); + assert_eq!(results[0].text, "hello world"); + } + + #[test] + fn multi_item_phrase() { + // Three items on the same line with gaps large enough to insert spaces + let items = vec![ + make_item("0°C", 0.0, 0.0, 20.0), + make_item("to", 30.0, 0.0, 12.0), + make_item("70°C", 52.0, 0.0, 24.0), + ]; + let opts = SearchOptions { + phrase: "0°C to 70°C".into(), + case_sensitive: false, + }; + let results = search_items(&items, &opts); + assert_eq!(results.len(), 1); + assert_eq!(results[0].text, "0°C to 70°C"); + assert_eq!(results[0].x, 0.0); + assert_eq!(results[0].width, 76.0); // 52 + 24 + } + + #[test] + fn case_insensitive() { + let items = vec![make_item("Hello World", 0.0, 0.0, 80.0)]; + let opts = SearchOptions { + phrase: "hello world".into(), + case_sensitive: false, + }; + assert_eq!(search_items(&items, &opts).len(), 1); + } + + #[test] + fn case_sensitive_no_match() { + let items = vec![make_item("Hello World", 0.0, 0.0, 80.0)]; + let opts = SearchOptions { + phrase: "hello world".into(), + case_sensitive: true, + }; + assert_eq!(search_items(&items, &opts).len(), 0); + } + + #[test] + fn no_match() { + let items = vec![make_item("foo bar", 0.0, 0.0, 40.0)]; + let opts = SearchOptions { + phrase: "baz".into(), + case_sensitive: false, + }; + assert_eq!(search_items(&items, &opts).len(), 0); + } + + #[test] + fn empty_phrase_matches_nothing() { + let items = vec![ + make_item("alpha", 0.0, 0.0, 30.0), + make_item("beta", 0.0, 20.0, 30.0), + make_item("gamma", 0.0, 40.0, 30.0), + ]; + let opts = SearchOptions { + phrase: String::new(), + case_sensitive: false, + }; + assert_eq!(search_items(&items, &opts).len(), 0); + } + + #[test] + fn multiple_matches() { + let items = vec![ + make_item("hello", 0.0, 0.0, 30.0), + make_item("world", 0.0, 20.0, 30.0), + make_item("hello", 0.0, 40.0, 30.0), + ]; + let opts = SearchOptions { + phrase: "hello".into(), + case_sensitive: false, + }; + let results = search_items(&items, &opts); + assert_eq!(results.len(), 2); + } +} diff --git a/crates/liteparse/src/types.rs b/crates/liteparse/src/types.rs new file mode 100644 index 0000000..6287b14 --- /dev/null +++ b/crates/liteparse/src/types.rs @@ -0,0 +1,444 @@ +use serde::Serialize; +use std::collections::HashMap; + +#[doc(hidden)] +#[derive(Debug, Clone)] +pub enum PdfInput { + /// Path to a PDF file on disk. + Path(String), + /// Raw PDF bytes (e.g. from a network response or in-memory buffer). + Bytes(Vec), +} + +/// Represents a single text item extracted from a PDF page, +/// including its content, position, size, rotation, and font metadata. +#[derive(Debug, Clone, Default, Serialize)] +pub struct TextItem { + pub text: String, + /// Viewport-space coordinates (top-left origin, 72 DPI). + pub x: f32, + pub y: f32, + pub width: f32, + pub height: f32, + /// Rotation in degrees (counter-clockwise, adjusted for page rotation). + pub rotation: f32, + pub font_name: Option, + pub font_size: Option, + /// Font size * scale_y from the text matrix — accounts for CTM scaling. + #[serde(skip_serializing_if = "Option::is_none")] + pub font_height: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub font_ascent: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub font_descent: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub font_weight: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub font_flags: Option, + /// Sum of glyph widths (using charcode-based lookup when possible). + #[serde(skip_serializing_if = "Option::is_none")] + pub text_width: Option, + /// Whether the font has buggy encoding (private-use codepoints, TT subset, etc.) + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub font_is_buggy: bool, + /// Whether most characters in this item could not be mapped to Unicode + /// (e.g. a Type3 font with no ToUnicode map). The text content is + /// PDFium's char-code fallback and does not reflect the rendered glyphs. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub has_unicode_map_error: bool, + /// Marked content ID from the PDF structure tree. + #[serde(skip_serializing_if = "Option::is_none")] + pub mcid: Option, + /// Fill color as ARGB hex string (e.g. "ff000000"). + #[serde(skip_serializing_if = "Option::is_none")] + pub fill_color: Option, + /// Stroke color as ARGB hex string. + #[serde(skip_serializing_if = "Option::is_none")] + pub stroke_color: Option, + /// OCR confidence score (0.0–1.0). None for native PDF text. + #[serde(skip_serializing_if = "Option::is_none")] + pub confidence: Option, + /// Target URI when this item falls inside a hyperlink annotation's + /// rectangle. Populated in `extract.rs`; consumed by the markdown emitter. + #[serde(skip_serializing_if = "Option::is_none")] + pub link: Option, + /// Whether a thin horizontal stroke/rect crosses this item's vertical middle + /// band (a strikethrough line). Populated in `extract.rs`; consumed by the + /// markdown emitter to wrap the text in `~~…~~`. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub strike: bool, + /// Per-word sub-boxes within this item, split on the inter-word spaces seen + /// during segment building. A segment groups several words together (it only + /// breaks at line/column boundaries), so this exposes the finer word-level + /// geometry needed for bbox attribution. Empty for items that produced no + /// word split (e.g. OCR-sourced or single-token items). Internal/attribution + /// use only — `#[serde(skip)]` keeps it out of the JSON output but it is + /// marshalled across the napi boundary. + #[serde(skip)] + pub words: Vec, +} + +/// One word's bounding box within a `TextItem`, in the same viewport space +/// (top-left origin, 72 DPI) as the parent item. `text` is the word's content +/// with inter-word spaces excluded. +#[derive(Debug, Clone, Default, Serialize)] +pub struct WordBox { + pub text: String, + pub x: f32, + pub y: f32, + pub width: f32, + pub height: f32, +} + +#[doc(hidden)] +#[derive(Debug, Serialize)] +pub struct Page { + pub page_number: usize, + pub page_width: f32, + pub page_height: f32, + pub text_items: Vec, + /// Vector graphics on the page, distilled from PDFium path objects. + /// Not emitted in JSON/text outputs — consumed by the markdown layout pass. + #[serde(skip)] + pub graphics: Vec, + /// Structure-tree nodes for this page when the PDF is tagged. Each node + /// carries its role, marked-content ids, and the union bbox of its tagged + /// content. Empty for untagged PDFs. + #[serde(skip)] + pub struct_nodes: Vec, + /// Raster image objects detected on the page. Empty when the page has no + /// images. Threaded through to `ParsedPage.image_refs`. + #[serde(skip)] + pub image_refs: Vec, +} + +/// One entry in the document outline (bookmarks). Coordinates are in PDF +/// user space (origin bottom-left) — convert to viewport with +/// `page_height - y` once you know the page. +#[doc(hidden)] +#[derive(Debug, Clone)] +pub struct OutlineTarget { + /// Hierarchy depth, 1-based. + pub level: u8, + pub title: String, + /// Zero-based page index of the destination. + pub page_index: i32, + /// Y in PDF user space, top of the target location. `None` when the + /// destination doesn't specify a Y. + pub y_pdf: Option, +} + +/// One node from the structure tree of a page. Pre-flattened in pre-order +/// (parent before children). +#[doc(hidden)] +#[derive(Debug, Clone)] +pub struct StructNode { + pub role: String, + pub mcids: Vec, + /// Union bbox of page objects tagged with `mcids`, in viewport coords. + /// `None` when none of the mcids resolved to a bbox. + pub bbox: Option, + pub alt_text: Option, +} + +/// Represents a fully parsed page with projected text layout. +#[derive(Debug, Serialize)] +pub struct ParsedPage { + pub page_number: usize, + pub page_width: f32, + pub page_height: f32, + pub text: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub markdown: String, + pub text_items: Vec, + /// Per-line structural metadata used by the markdown emitter. Not part of + /// the JSON/text outputs (consumed internally) so it is `#[serde(skip)]`. + #[serde(skip)] + pub projected_lines: Vec, + /// Root of the XY-cut region tree for this page. Leaves correspond to the + /// `region_path` on each `ProjectedLine`. Internal-only. + #[serde(skip)] + pub regions: Region, + /// Vector graphics on the page (decomposed paths) used by the markdown + /// emitter for ruled-table / HR / figure-cluster detection. Not part of + /// the JSON/text output. + #[serde(skip)] + pub graphics: Vec, + /// Figure-region bounding rectangles derived from `graphics`. Pre-computed + /// in `to_parsed_pages` so the XY-cut layout pass can treat them as + /// obstacles, and reused downstream for figure classification. + #[serde(skip)] + pub figures: Vec, + /// Structure-tree nodes for this page (tagged PDFs only). Pre-flattened in + /// pre-order. Consumed by the markdown classifier for highest-priority + /// heading / figure / table detection. + #[serde(skip)] + pub struct_nodes: Vec, + /// Raster image objects on the page. Bbox in viewport coords. Populated + /// during extraction; consumed by the markdown emitter to interleave + /// `Block::Figure` references at the right y position. Empty when the + /// page has no embedded images. Not part of JSON/text output. + #[serde(skip)] + pub image_refs: Vec, +} + +/// One embedded raster image on a page. `id` is a stable, page-scoped slug +/// used as the markdown link target (e.g. `image_p1_0.png`). `obj_index` is +/// the image's position among image page-objects, so a later embed pass can +/// re-open the document and pull pixel bytes with `render_image_object`. +#[doc(hidden)] +#[derive(Debug, Clone)] +pub struct ImageRef { + pub id: String, + pub bbox: Rect, + pub obj_index: usize, +} + +/// A raster image extracted from a page along with its pixel bytes. Surfaced +/// on `ParseResult.images` only when `ImageMode::Embed` is configured — +/// otherwise the extraction step skips the render and only `ImageRef`s are +/// produced. `format` is currently always `"png"` (encoded from the +/// FPDFImageObj_GetRenderedBitmap output via the same path used for page +/// screenshots). +#[derive(Debug, Clone, Serialize)] +pub struct ExtractedImage { + pub id: String, + pub page: u32, + pub bbox: Rect, + pub format: String, + #[serde(skip)] + pub bytes: Vec, +} + +#[doc(hidden)] +#[derive(Debug, Clone, Default, Serialize)] +pub struct Rect { + pub x: f32, + pub y: f32, + pub width: f32, + pub height: f32, +} + +/// Lightweight vector-graphic primitive derived from PDFium path objects. +/// Only the shapes useful to the markdown emitter (ruled tables, HRs, figure +/// clusters) are kept — bezier curves and complex paths are decomposed into +/// straight strokes, or dropped. +#[doc(hidden)] +#[derive(Debug, Clone)] +pub enum GraphicPrimitive { + /// A single straight line segment in viewport coords. Used for HR/table + /// border detection. + Stroke { + x1: f32, + y1: f32, + x2: f32, + y2: f32, + color: Option, + width: f32, + }, + /// An axis-aligned rectangle — typically a filled cell background, banner, + /// or fully-stroked table border drawn as a single path. + Rect { + bbox: Rect, + fill: Option, + stroke: Option, + }, +} + +impl GraphicPrimitive { + /// Bbox of the primitive in viewport coords. + pub fn bbox(&self) -> Rect { + match self { + GraphicPrimitive::Stroke { x1, y1, x2, y2, .. } => { + let x = x1.min(*x2); + let y = y1.min(*y2); + Rect { + x, + y, + width: (x2 - x1).abs(), + height: (y2 - y1).abs(), + } + } + GraphicPrimitive::Rect { bbox, .. } => bbox.clone(), + } + } +} + +/// Per-line structural metadata derived during grid projection. Used by the +/// markdown emitter; not surfaced in JSON/text output. +#[doc(hidden)] +#[derive(Debug, Clone, Serialize)] +pub struct ProjectedLine { + pub text: String, + pub bbox: Rect, + pub anchor: Anchor, + pub indent_x: f32, + pub dominant_font_size: f32, + /// True when `dominant_font_size` was derived from bbox height (PDFium + /// reported the font size baked into the text matrix, ~1.0) rather than a + /// real font-size value. Height-derived sizes jitter ±1pt line-to-line + /// based on glyph content (descenders, parens, capitals), so heading + /// detection must use a wider margin over body for these lines. + pub font_size_is_estimated: bool, + /// Precise matrix-derived size (`Tf_size × text_matrix_scale`) for + /// matrix-baked-size lines, when a glyph exposed a text matrix. Used + /// *only* by heading detection (body-size + heading map), where the + /// jitter-free value beats the bbox-height estimate in `dominant_font_size`. + /// Deliberately NOT consumed by table/paragraph grouping, which stay on + /// `dominant_font_size` to avoid perturbing well-tuned line grouping. + pub heading_font_size: Option, + pub dominant_font_name: Option, + pub all_bold: bool, + pub all_italic: bool, + pub all_mono: bool, + pub all_strike: bool, + pub spans: Vec, + /// Path from the page's region-tree root to the leaf containing this line. + /// Equality means "same leaf"; prefix relationship means "one contains the + /// other". Replaces the prior flat `column_id` scheme so nested layouts + /// (banded splits with sub-columns) survive paragraph/table grouping. + pub region_path: Vec, + pub mcid: Option, + /// True when the line's original-coordinate bbox falls inside a detected + /// figure/chart region. Chart text (axis labels, legends, category titles) + /// is often set in a font larger than real headings; if it reached the + /// heading-size histogram it would hijack the top heading levels and get + /// promoted itself. Heading detection skips these lines. + pub in_figure: bool, +} + +/// XY-cut region tree node. A page's root region recursively splits along H or +/// V axes until each leaf holds a coherent block of items. +#[doc(hidden)] +#[derive(Debug, Clone, Default)] +pub struct Region { + pub bbox: Rect, + pub kind: RegionKind, +} + +#[doc(hidden)] +#[derive(Debug, Clone)] +pub enum RegionKind { + Leaf { + item_indices: Vec, + }, + Split { + axis: CutAxis, + children: Vec, + }, +} + +impl Default for RegionKind { + fn default() -> Self { + RegionKind::Leaf { + item_indices: Vec::new(), + } + } +} + +#[doc(hidden)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CutAxis { + Horizontal, + Vertical, +} + +#[doc(hidden)] +#[derive(Debug, Serialize)] +pub enum Snap { + Left, + Right, + Center, +} + +#[doc(hidden)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub enum Anchor { + Left, + Right, + Center, + /// Inline span that does not snap to a column edge — used by lines whose + /// dominant items couldn't be classified as Left/Right/Center. + Floating, +} + +#[doc(hidden)] +#[derive(Debug, Serialize)] +pub struct ProjectedTextItem { + pub item: TextItem, + pub snap: Snap, + pub anchor: Anchor, + pub is_dup: bool, + pub rendered: bool, + pub num_spaces: usize, + pub force_unsnapped: bool, + pub is_margin_line_number: bool, + pub rotated: bool, + pub d: f32, + pub orig_x: f32, + pub orig_y: f32, + pub orig_width: f32, + pub orig_height: f32, + pub orig_rotation: f32, +} + +#[doc(hidden)] +pub type AnchorMap = HashMap>; + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_item() -> TextItem { + TextItem { + text: "hi".into(), + x: 1.0, + y: 2.0, + width: 10.0, + height: 4.0, + font_name: Some("Arial".into()), + font_size: Some(12.0), + ..Default::default() + } + } + + #[test] + fn text_item_skips_none_fields() { + let item = sample_item(); + let s = serde_json::to_string(&item).unwrap(); + assert!(!s.contains("font_height")); + assert!(!s.contains("confidence")); + assert!(!s.contains("font_is_buggy")); + assert!(s.contains("\"text\":\"hi\"")); + } + + #[test] + fn text_item_includes_buggy_flag_when_true() { + let mut item = sample_item(); + item.font_is_buggy = true; + let s = serde_json::to_string(&item).unwrap(); + assert!(s.contains("font_is_buggy")); + } + + #[test] + fn page_serializes() { + let p = Page { + page_number: 1, + page_width: 100.0, + page_height: 200.0, + text_items: vec![sample_item()], + graphics: vec![], + struct_nodes: vec![], + image_refs: vec![], + }; + let s = serde_json::to_string(&p).unwrap(); + assert!(s.contains("\"page_number\":1")); + } + + #[test] + fn anchor_map_basic() { + let mut m: AnchorMap = HashMap::new(); + m.entry(5).or_default().push((1, 2)); + assert_eq!(m.get(&5).unwrap()[0], (1, 2)); + } +} diff --git a/crates/liteparse/tests/integration_test.rs b/crates/liteparse/tests/integration_test.rs new file mode 100644 index 0000000..186f2bd --- /dev/null +++ b/crates/liteparse/tests/integration_test.rs @@ -0,0 +1,227 @@ +use std::path::Path; + +use liteparse::conversion::convert_data_to_pdf; +use liteparse::types::PdfInput; +use liteparse::{LiteParse, LiteParseConfig}; +use serial_test::serial; + +#[tokio::test] +#[serial] +async fn test_screenshot_image_integration() { + let env_var = std::env::var("SKIP_INTEGRATION_TESTS"); + if let Ok(v) = env_var + && v == "yes" + { + return; + } + let lit = LiteParse::new(LiteParseConfig::default()); + let results = lit + .screenshot("../../integration_tests_data/receipt.png", None) + .await + .expect("Should be able to screenshot converted image"); + assert_eq!(results.len(), 1); + assert!(results[0].width > 0); + assert!(results[0].height > 0); + assert!(!results[0].image_bytes.is_empty()); +} + +#[tokio::test] +#[serial] +async fn test_screenshot_pdf_integration() { + let lit = LiteParse::new(LiteParseConfig::default()); + let results = lit + .screenshot("../../integration_tests_data/sample.pdf", None) + .await + .expect("Should be able to screenshot PDF"); + assert_eq!(results.len(), 1); + assert!(!results[0].image_bytes.is_empty()); +} + +#[tokio::test] +async fn test_screenshot_rejects_text_file() { + let dir = tempfile::tempdir().unwrap(); + let txt_path = dir.path().join("notes.txt"); + std::fs::write(&txt_path, "hello").unwrap(); + let lit = LiteParse::new(LiteParseConfig::default()); + let err = lit + .screenshot(txt_path.to_str().unwrap(), None) + .await + .unwrap_err() + .to_string(); + assert!(err.contains("Cannot screenshot text-based format")); +} + +#[tokio::test] +#[serial] +async fn test_convert_data_to_pdf_integration() { + let env_var = std::env::var("SKIP_INTEGRATION_TESTS"); + if let Ok(v) = env_var + && v == "yes" + { + return; + } + let fixture_path = "../../integration_tests_data/receipt.png"; + let data = tokio::fs::read(fixture_path) + .await + .expect("Should be able to read file"); + let (converted, _temps) = convert_data_to_pdf(data, None) + .await + .expect("Should be able to convert data to PDF"); + assert!(Path::new(&converted.pdf_path).exists()); +} + +#[tokio::test] +#[serial] +async fn test_parse_bytes_image_integration() { + let env_var = std::env::var("SKIP_INTEGRATION_TESTS"); + if let Ok(v) = env_var + && v == "yes" + { + return; + } + let fixture_path = "../../integration_tests_data/receipt.png"; + let lit = LiteParse::new(LiteParseConfig::default()); + let data = tokio::fs::read(fixture_path) + .await + .expect("Should be able to read file"); + let input = PdfInput::Bytes(data); + let parsed = lit + .parse_input(input) + .await + .expect("Should be able to parse"); + assert_eq!(parsed.pages.len(), 1); +} + +#[tokio::test] +#[serial] +async fn test_parse_bytes_office_integration() { + let env_var = std::env::var("SKIP_INTEGRATION_TESTS"); + if let Ok(v) = env_var + && v == "yes" + { + return; + } + let fixture_path = "../../integration_tests_data/sample3.doc"; + let lit = LiteParse::new(LiteParseConfig::default()); + let data = tokio::fs::read(fixture_path) + .await + .expect("Should be able to read file"); + let input = PdfInput::Bytes(data); + let parsed = lit + .parse_input(input) + .await + .expect("Should be able to parse"); + assert_eq!(parsed.pages.len(), 2); +} + +#[tokio::test] +#[serial] +async fn test_parse_image_integration() { + let env_var = std::env::var("SKIP_INTEGRATION_TESTS"); + if let Ok(v) = env_var + && v == "yes" + { + return; + } + let lit = LiteParse::new(LiteParseConfig::default()); + let parsed = lit + .parse("../../integration_tests_data/receipt.png") + .await + .expect("Should be able to parse"); + assert_eq!(parsed.pages.len(), 1); +} + +#[tokio::test] +#[serial] +async fn test_parse_office_doc_integration() { + let env_var = std::env::var("SKIP_INTEGRATION_TESTS"); + if let Ok(v) = env_var + && v == "yes" + { + return; + } + let lit = LiteParse::new(LiteParseConfig::default()); + let parsed = lit + .parse("../../integration_tests_data/sample3.doc") + .await + .expect("Should be able to parse"); + assert_eq!(parsed.pages.len(), 2); +} + +#[tokio::test] +#[serial] +async fn test_parse_pdf_integration() { + let lit = LiteParse::new(LiteParseConfig::default()); + let parsed = lit + .parse("../../integration_tests_data/sample.pdf") + .await + .expect("Should be able to parse"); + assert_eq!(parsed.pages.len(), 1); +} + +#[tokio::test] +#[serial] +async fn test_parse_bytes_pdf_integration() { + let fixture_path = "../../integration_tests_data/sample.pdf"; + let lit = LiteParse::new(LiteParseConfig::default()); + let data = tokio::fs::read(fixture_path) + .await + .expect("Should be able to read file"); + let input = PdfInput::Bytes(data); + let parsed = lit + .parse_input(input) + .await + .expect("Should be able to parse"); + assert_eq!(parsed.pages.len(), 1); +} + +/// Stress test: many concurrent `parse_input` calls on a multi-threaded +/// tokio runtime through a single `Arc`. Before the PDFium +/// process-global lock was introduced, this scenario caused malloc +/// double-free / heap corruption because PDFium FFI is not thread-safe. +/// +/// We intentionally do **not** use `#[serial]` here — this test must run +/// concurrently with itself (across tasks within the test) to exercise the +/// lock. Other tests in this file are `#[serial]` so they won't race. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn test_concurrent_parse_does_not_crash() { + use std::sync::Arc; + use tokio::task::JoinSet; + + let env_var = std::env::var("SKIP_INTEGRATION_TESTS"); + if let Ok(v) = env_var + && v == "yes" + { + return; + } + + let lit = Arc::new(LiteParse::new(LiteParseConfig { + ocr_enabled: false, + quiet: true, + ..LiteParseConfig::default() + })); + + let bytes = tokio::fs::read("../../integration_tests_data/sample.pdf") + .await + .expect("fixture exists"); + + let mut set: JoinSet = JoinSet::new(); + for _ in 0..16 { + let lit = lit.clone(); + let bytes = bytes.clone(); + set.spawn(async move { + let parsed = lit + .parse_input(PdfInput::Bytes(bytes)) + .await + .expect("parse should succeed"); + parsed.pages.len() + }); + } + + let mut total = 0; + while let Some(joined) = set.join_next().await { + total += joined.expect("task panicked"); + } + // 16 tasks × 1 page each + assert_eq!(total, 16); +} diff --git a/crates/pdfium-sys/Cargo.toml b/crates/pdfium-sys/Cargo.toml new file mode 100644 index 0000000..251a28e --- /dev/null +++ b/crates/pdfium-sys/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "liteparse-pdfium-sys" +version = "1.3.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Raw FFI bindings to PDFium for liteparse" +links = "pdfium" + +[features] +default = [] +bindgen = ["dep:bindgen"] + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +libloading = "0.8" + +[build-dependencies] +bindgen = { version = "0.71", optional = true } +flate2 = "1" +tar = "0.4" +ureq = "3" diff --git a/crates/pdfium-sys/bindings.rs b/crates/pdfium-sys/bindings.rs new file mode 100644 index 0000000..8ff4e3e --- /dev/null +++ b/crates/pdfium-sys/bindings.rs @@ -0,0 +1,2972 @@ +/* automatically generated by rust-bindgen 0.71.1 */ + +pub const FPDF_OBJECT_UNKNOWN: u32 = 0; +pub const FPDF_OBJECT_BOOLEAN: u32 = 1; +pub const FPDF_OBJECT_NUMBER: u32 = 2; +pub const FPDF_OBJECT_STRING: u32 = 3; +pub const FPDF_OBJECT_NAME: u32 = 4; +pub const FPDF_OBJECT_ARRAY: u32 = 5; +pub const FPDF_OBJECT_DICTIONARY: u32 = 6; +pub const FPDF_OBJECT_STREAM: u32 = 7; +pub const FPDF_OBJECT_NULLOBJ: u32 = 8; +pub const FPDF_OBJECT_REFERENCE: u32 = 9; +pub const FPDF_POLICY_MACHINETIME_ACCESS: u32 = 0; +pub const FPDF_ERR_SUCCESS: u32 = 0; +pub const FPDF_ERR_UNKNOWN: u32 = 1; +pub const FPDF_ERR_FILE: u32 = 2; +pub const FPDF_ERR_FORMAT: u32 = 3; +pub const FPDF_ERR_PASSWORD: u32 = 4; +pub const FPDF_ERR_SECURITY: u32 = 5; +pub const FPDF_ERR_PAGE: u32 = 6; +pub const FPDF_ANNOT: u32 = 1; +pub const FPDF_LCD_TEXT: u32 = 2; +pub const FPDF_NO_NATIVETEXT: u32 = 4; +pub const FPDF_GRAYSCALE: u32 = 8; +pub const FPDF_DEBUG_INFO: u32 = 128; +pub const FPDF_NO_CATCH: u32 = 256; +pub const FPDF_RENDER_LIMITEDIMAGECACHE: u32 = 512; +pub const FPDF_RENDER_FORCEHALFTONE: u32 = 1024; +pub const FPDF_PRINTING: u32 = 2048; +pub const FPDF_RENDER_NO_SMOOTHTEXT: u32 = 4096; +pub const FPDF_RENDER_NO_SMOOTHIMAGE: u32 = 8192; +pub const FPDF_RENDER_NO_SMOOTHPATH: u32 = 16384; +pub const FPDF_REVERSE_BYTE_ORDER: u32 = 16; +pub const FPDF_CONVERT_FILL_TO_STROKE: u32 = 32; +pub const FPDFBitmap_Unknown: u32 = 0; +pub const FPDFBitmap_Gray: u32 = 1; +pub const FPDFBitmap_BGR: u32 = 2; +pub const FPDFBitmap_BGRx: u32 = 3; +pub const FPDFBitmap_BGRA: u32 = 4; +pub const FPDFBitmap_BGRA_Premul: u32 = 5; +pub const FPDF_MATCHCASE: u32 = 1; +pub const FPDF_MATCHWHOLEWORD: u32 = 2; +pub const FPDF_CONSECUTIVE: u32 = 4; +pub const FPDF_COLORSPACE_UNKNOWN: u32 = 0; +pub const FPDF_COLORSPACE_DEVICEGRAY: u32 = 1; +pub const FPDF_COLORSPACE_DEVICERGB: u32 = 2; +pub const FPDF_COLORSPACE_DEVICECMYK: u32 = 3; +pub const FPDF_COLORSPACE_CALGRAY: u32 = 4; +pub const FPDF_COLORSPACE_CALRGB: u32 = 5; +pub const FPDF_COLORSPACE_LAB: u32 = 6; +pub const FPDF_COLORSPACE_ICCBASED: u32 = 7; +pub const FPDF_COLORSPACE_SEPARATION: u32 = 8; +pub const FPDF_COLORSPACE_DEVICEN: u32 = 9; +pub const FPDF_COLORSPACE_INDEXED: u32 = 10; +pub const FPDF_COLORSPACE_PATTERN: u32 = 11; +pub const FPDF_PAGEOBJ_UNKNOWN: u32 = 0; +pub const FPDF_PAGEOBJ_TEXT: u32 = 1; +pub const FPDF_PAGEOBJ_PATH: u32 = 2; +pub const FPDF_PAGEOBJ_IMAGE: u32 = 3; +pub const FPDF_PAGEOBJ_SHADING: u32 = 4; +pub const FPDF_PAGEOBJ_FORM: u32 = 5; +pub const FPDF_SEGMENT_UNKNOWN: i32 = -1; +pub const FPDF_SEGMENT_LINETO: u32 = 0; +pub const FPDF_SEGMENT_BEZIERTO: u32 = 1; +pub const FPDF_SEGMENT_MOVETO: u32 = 2; +pub const FPDF_FILLMODE_NONE: u32 = 0; +pub const FPDF_FILLMODE_ALTERNATE: u32 = 1; +pub const FPDF_FILLMODE_WINDING: u32 = 2; +pub const FPDF_FONT_TYPE1: u32 = 1; +pub const FPDF_FONT_TRUETYPE: u32 = 2; +pub const FPDF_LINECAP_BUTT: u32 = 0; +pub const FPDF_LINECAP_ROUND: u32 = 1; +pub const FPDF_LINECAP_PROJECTING_SQUARE: u32 = 2; +pub const FPDF_LINEJOIN_MITER: u32 = 0; +pub const FPDF_LINEJOIN_ROUND: u32 = 1; +pub const FPDF_LINEJOIN_BEVEL: u32 = 2; +pub const FPDF_PRINTMODE_EMF: u32 = 0; +pub const FPDF_PRINTMODE_TEXTONLY: u32 = 1; +pub const FPDF_PRINTMODE_POSTSCRIPT2: u32 = 2; +pub const FPDF_PRINTMODE_POSTSCRIPT3: u32 = 3; +pub const FPDF_PRINTMODE_POSTSCRIPT2_PASSTHROUGH: u32 = 4; +pub const FPDF_PRINTMODE_POSTSCRIPT3_PASSTHROUGH: u32 = 5; +pub const FPDF_PRINTMODE_EMF_IMAGE_MASKS: u32 = 6; +pub const FPDF_PRINTMODE_POSTSCRIPT3_TYPE42: u32 = 7; +pub const FPDF_PRINTMODE_POSTSCRIPT3_TYPE42_PASSTHROUGH: u32 = 8; +pub const FPDFDOC_AACTION_WC: u32 = 16; +pub const FPDFDOC_AACTION_WS: u32 = 17; +pub const FPDFDOC_AACTION_DS: u32 = 18; +pub const FPDFDOC_AACTION_WP: u32 = 19; +pub const FPDFDOC_AACTION_DP: u32 = 20; +pub const FPDFPAGE_AACTION_OPEN: u32 = 0; +pub const FPDFPAGE_AACTION_CLOSE: u32 = 1; +pub const FPDF_FORMFIELD_UNKNOWN: u32 = 0; +pub const FPDF_FORMFIELD_PUSHBUTTON: u32 = 1; +pub const FPDF_FORMFIELD_CHECKBOX: u32 = 2; +pub const FPDF_FORMFIELD_RADIOBUTTON: u32 = 3; +pub const FPDF_FORMFIELD_COMBOBOX: u32 = 4; +pub const FPDF_FORMFIELD_LISTBOX: u32 = 5; +pub const FPDF_FORMFIELD_TEXTFIELD: u32 = 6; +pub const FPDF_FORMFIELD_SIGNATURE: u32 = 7; +pub const FPDF_FORMFIELD_COUNT: u32 = 8; +pub const FPDF_ANNOT_UNKNOWN: u32 = 0; +pub const FPDF_ANNOT_TEXT: u32 = 1; +pub const FPDF_ANNOT_LINK: u32 = 2; +pub const FPDF_ANNOT_FREETEXT: u32 = 3; +pub const FPDF_ANNOT_LINE: u32 = 4; +pub const FPDF_ANNOT_SQUARE: u32 = 5; +pub const FPDF_ANNOT_CIRCLE: u32 = 6; +pub const FPDF_ANNOT_POLYGON: u32 = 7; +pub const FPDF_ANNOT_POLYLINE: u32 = 8; +pub const FPDF_ANNOT_HIGHLIGHT: u32 = 9; +pub const FPDF_ANNOT_UNDERLINE: u32 = 10; +pub const FPDF_ANNOT_SQUIGGLY: u32 = 11; +pub const FPDF_ANNOT_STRIKEOUT: u32 = 12; +pub const FPDF_ANNOT_STAMP: u32 = 13; +pub const FPDF_ANNOT_CARET: u32 = 14; +pub const FPDF_ANNOT_INK: u32 = 15; +pub const FPDF_ANNOT_POPUP: u32 = 16; +pub const FPDF_ANNOT_FILEATTACHMENT: u32 = 17; +pub const FPDF_ANNOT_SOUND: u32 = 18; +pub const FPDF_ANNOT_MOVIE: u32 = 19; +pub const FPDF_ANNOT_WIDGET: u32 = 20; +pub const FPDF_ANNOT_SCREEN: u32 = 21; +pub const FPDF_ANNOT_PRINTERMARK: u32 = 22; +pub const FPDF_ANNOT_TRAPNET: u32 = 23; +pub const FPDF_ANNOT_WATERMARK: u32 = 24; +pub const FPDF_ANNOT_THREED: u32 = 25; +pub const FPDF_ANNOT_RICHMEDIA: u32 = 26; +pub const FPDF_ANNOT_XFAWIDGET: u32 = 27; +pub const FPDF_ANNOT_REDACT: u32 = 28; +pub const FPDF_ANNOT_FLAG_NONE: u32 = 0; +pub const FPDF_ANNOT_FLAG_INVISIBLE: u32 = 1; +pub const FPDF_ANNOT_FLAG_HIDDEN: u32 = 2; +pub const FPDF_ANNOT_FLAG_PRINT: u32 = 4; +pub const FPDF_ANNOT_FLAG_NOZOOM: u32 = 8; +pub const FPDF_ANNOT_FLAG_NOROTATE: u32 = 16; +pub const FPDF_ANNOT_FLAG_NOVIEW: u32 = 32; +pub const FPDF_ANNOT_FLAG_READONLY: u32 = 64; +pub const FPDF_ANNOT_FLAG_LOCKED: u32 = 128; +pub const FPDF_ANNOT_FLAG_TOGGLENOVIEW: u32 = 256; +pub const FPDF_ANNOT_APPEARANCEMODE_NORMAL: u32 = 0; +pub const FPDF_ANNOT_APPEARANCEMODE_ROLLOVER: u32 = 1; +pub const FPDF_ANNOT_APPEARANCEMODE_DOWN: u32 = 2; +pub const FPDF_ANNOT_APPEARANCEMODE_COUNT: u32 = 3; +pub const FPDF_FORMFLAG_NONE: u32 = 0; +pub const FPDF_FORMFLAG_READONLY: u32 = 1; +pub const FPDF_FORMFLAG_REQUIRED: u32 = 2; +pub const FPDF_FORMFLAG_NOEXPORT: u32 = 4; +pub const FPDF_FORMFLAG_TEXT_MULTILINE: u32 = 4096; +pub const FPDF_FORMFLAG_TEXT_PASSWORD: u32 = 8192; +pub const FPDF_FORMFLAG_CHOICE_COMBO: u32 = 131072; +pub const FPDF_FORMFLAG_CHOICE_EDIT: u32 = 262144; +pub const FPDF_FORMFLAG_CHOICE_MULTI_SELECT: u32 = 2097152; +pub const FPDF_ANNOT_AACTION_KEY_STROKE: u32 = 12; +pub const FPDF_ANNOT_AACTION_FORMAT: u32 = 13; +pub const FPDF_ANNOT_AACTION_VALIDATE: u32 = 14; +pub const FPDF_ANNOT_AACTION_CALCULATE: u32 = 15; +pub const FPDF_TEXT_RENDERMODE_FPDF_TEXTRENDERMODE_UNKNOWN: FPDF_TEXT_RENDERMODE = -1; +pub const FPDF_TEXT_RENDERMODE_FPDF_TEXTRENDERMODE_FILL: FPDF_TEXT_RENDERMODE = 0; +pub const FPDF_TEXT_RENDERMODE_FPDF_TEXTRENDERMODE_STROKE: FPDF_TEXT_RENDERMODE = 1; +pub const FPDF_TEXT_RENDERMODE_FPDF_TEXTRENDERMODE_FILL_STROKE: FPDF_TEXT_RENDERMODE = 2; +pub const FPDF_TEXT_RENDERMODE_FPDF_TEXTRENDERMODE_INVISIBLE: FPDF_TEXT_RENDERMODE = 3; +pub const FPDF_TEXT_RENDERMODE_FPDF_TEXTRENDERMODE_FILL_CLIP: FPDF_TEXT_RENDERMODE = 4; +pub const FPDF_TEXT_RENDERMODE_FPDF_TEXTRENDERMODE_STROKE_CLIP: FPDF_TEXT_RENDERMODE = 5; +pub const FPDF_TEXT_RENDERMODE_FPDF_TEXTRENDERMODE_FILL_STROKE_CLIP: FPDF_TEXT_RENDERMODE = 6; +pub const FPDF_TEXT_RENDERMODE_FPDF_TEXTRENDERMODE_CLIP: FPDF_TEXT_RENDERMODE = 7; +pub const FPDF_TEXT_RENDERMODE_FPDF_TEXTRENDERMODE_LAST: FPDF_TEXT_RENDERMODE = 7; +pub type FPDF_TEXT_RENDERMODE = ::std::os::raw::c_int; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct fpdf_action_t__ { + _unused: [u8; 0], +} +pub type FPDF_ACTION = *mut fpdf_action_t__; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct fpdf_annotation_t__ { + _unused: [u8; 0], +} +pub type FPDF_ANNOTATION = *mut fpdf_annotation_t__; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct fpdf_attachment_t__ { + _unused: [u8; 0], +} +pub type FPDF_ATTACHMENT = *mut fpdf_attachment_t__; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct fpdf_avail_t__ { + _unused: [u8; 0], +} +pub type FPDF_AVAIL = *mut fpdf_avail_t__; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct fpdf_bitmap_t__ { + _unused: [u8; 0], +} +pub type FPDF_BITMAP = *mut fpdf_bitmap_t__; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct fpdf_bookmark_t__ { + _unused: [u8; 0], +} +pub type FPDF_BOOKMARK = *mut fpdf_bookmark_t__; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct fpdf_clippath_t__ { + _unused: [u8; 0], +} +pub type FPDF_CLIPPATH = *mut fpdf_clippath_t__; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct fpdf_dest_t__ { + _unused: [u8; 0], +} +pub type FPDF_DEST = *mut fpdf_dest_t__; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct fpdf_document_t__ { + _unused: [u8; 0], +} +pub type FPDF_DOCUMENT = *mut fpdf_document_t__; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct fpdf_font_t__ { + _unused: [u8; 0], +} +pub type FPDF_FONT = *mut fpdf_font_t__; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct fpdf_form_handle_t__ { + _unused: [u8; 0], +} +pub type FPDF_FORMHANDLE = *mut fpdf_form_handle_t__; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct fpdf_glyphpath_t__ { + _unused: [u8; 0], +} +pub type FPDF_GLYPHPATH = *const fpdf_glyphpath_t__; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct fpdf_javascript_action_t { + _unused: [u8; 0], +} +pub type FPDF_JAVASCRIPT_ACTION = *mut fpdf_javascript_action_t; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct fpdf_link_t__ { + _unused: [u8; 0], +} +pub type FPDF_LINK = *mut fpdf_link_t__; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct fpdf_page_t__ { + _unused: [u8; 0], +} +pub type FPDF_PAGE = *mut fpdf_page_t__; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct fpdf_pagelink_t__ { + _unused: [u8; 0], +} +pub type FPDF_PAGELINK = *mut fpdf_pagelink_t__; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct fpdf_pageobject_t__ { + _unused: [u8; 0], +} +pub type FPDF_PAGEOBJECT = *mut fpdf_pageobject_t__; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct fpdf_pageobjectmark_t__ { + _unused: [u8; 0], +} +pub type FPDF_PAGEOBJECTMARK = *mut fpdf_pageobjectmark_t__; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct fpdf_pagerange_t__ { + _unused: [u8; 0], +} +pub type FPDF_PAGERANGE = *const fpdf_pagerange_t__; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct fpdf_pathsegment_t { + _unused: [u8; 0], +} +pub type FPDF_PATHSEGMENT = *const fpdf_pathsegment_t; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct fpdf_schhandle_t__ { + _unused: [u8; 0], +} +pub type FPDF_SCHHANDLE = *mut fpdf_schhandle_t__; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct fpdf_signature_t__ { + _unused: [u8; 0], +} +pub type FPDF_SIGNATURE = *const fpdf_signature_t__; +pub type FPDF_SKIA_CANVAS = *mut ::std::os::raw::c_void; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct fpdf_structelement_t__ { + _unused: [u8; 0], +} +pub type FPDF_STRUCTELEMENT = *mut fpdf_structelement_t__; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct fpdf_structelement_attr_t__ { + _unused: [u8; 0], +} +pub type FPDF_STRUCTELEMENT_ATTR = *const fpdf_structelement_attr_t__; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct fpdf_structelement_attr_value_t__ { + _unused: [u8; 0], +} +pub type FPDF_STRUCTELEMENT_ATTR_VALUE = *const fpdf_structelement_attr_value_t__; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct fpdf_structtree_t__ { + _unused: [u8; 0], +} +pub type FPDF_STRUCTTREE = *mut fpdf_structtree_t__; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct fpdf_textpage_t__ { + _unused: [u8; 0], +} +pub type FPDF_TEXTPAGE = *mut fpdf_textpage_t__; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct fpdf_widget_t__ { + _unused: [u8; 0], +} +pub type FPDF_WIDGET = *mut fpdf_widget_t__; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct fpdf_xobject_t__ { + _unused: [u8; 0], +} +pub type FPDF_XOBJECT = *mut fpdf_xobject_t__; +pub type FPDF_BOOL = ::std::os::raw::c_int; +pub type FPDF_RESULT = ::std::os::raw::c_int; +pub type FPDF_DWORD = ::std::os::raw::c_ulong; +pub type FS_FLOAT = f32; +pub const _FPDF_DUPLEXTYPE__DuplexUndefined: _FPDF_DUPLEXTYPE_ = 0; +pub const _FPDF_DUPLEXTYPE__Simplex: _FPDF_DUPLEXTYPE_ = 1; +pub const _FPDF_DUPLEXTYPE__DuplexFlipShortEdge: _FPDF_DUPLEXTYPE_ = 2; +pub const _FPDF_DUPLEXTYPE__DuplexFlipLongEdge: _FPDF_DUPLEXTYPE_ = 3; +pub type _FPDF_DUPLEXTYPE_ = ::std::os::raw::c_uint; +pub use self::_FPDF_DUPLEXTYPE_ as FPDF_DUPLEXTYPE; +pub type FPDF_WCHAR = ::std::os::raw::c_ushort; +pub type FPDF_BYTESTRING = *const ::std::os::raw::c_char; +pub type FPDF_WIDESTRING = *const FPDF_WCHAR; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct FPDF_BSTR_ { + pub str_: *mut ::std::os::raw::c_char, + pub len: ::std::os::raw::c_int, +} +impl Default for FPDF_BSTR_ { + fn default() -> Self { + let mut s = ::std::mem::MaybeUninit::::uninit(); + unsafe { + ::std::ptr::write_bytes(s.as_mut_ptr(), 0, 1); + s.assume_init() + } + } +} +pub type FPDF_BSTR = FPDF_BSTR_; +pub type FPDF_STRING = *const ::std::os::raw::c_char; +#[repr(C)] +#[derive(Debug, Default, Copy, Clone)] +pub struct _FS_MATRIX_ { + pub a: f32, + pub b: f32, + pub c: f32, + pub d: f32, + pub e: f32, + pub f: f32, +} +pub type FS_MATRIX = _FS_MATRIX_; +#[repr(C)] +#[derive(Debug, Default, Copy, Clone)] +pub struct _FS_RECTF_ { + pub left: f32, + pub top: f32, + pub right: f32, + pub bottom: f32, +} +pub type FS_LPRECTF = *mut _FS_RECTF_; +pub type FS_RECTF = _FS_RECTF_; +pub type FS_LPCRECTF = *const FS_RECTF; +#[repr(C)] +#[derive(Debug, Default, Copy, Clone)] +pub struct FS_SIZEF_ { + pub width: f32, + pub height: f32, +} +pub type FS_LPSIZEF = *mut FS_SIZEF_; +pub type FS_SIZEF = FS_SIZEF_; +pub type FS_LPCSIZEF = *const FS_SIZEF; +#[repr(C)] +#[derive(Debug, Default, Copy, Clone)] +pub struct FS_POINTF_ { + pub x: f32, + pub y: f32, +} +pub type FS_LPPOINTF = *mut FS_POINTF_; +pub type FS_POINTF = FS_POINTF_; +pub type FS_LPCPOINTF = *const FS_POINTF; +#[repr(C)] +#[derive(Debug, Default, Copy, Clone)] +pub struct _FS_QUADPOINTSF { + pub x1: FS_FLOAT, + pub y1: FS_FLOAT, + pub x2: FS_FLOAT, + pub y2: FS_FLOAT, + pub x3: FS_FLOAT, + pub y3: FS_FLOAT, + pub x4: FS_FLOAT, + pub y4: FS_FLOAT, +} +pub type FS_QUADPOINTSF = _FS_QUADPOINTSF; +pub type FPDF_ANNOTATION_SUBTYPE = ::std::os::raw::c_int; +pub type FPDF_ANNOT_APPEARANCEMODE = ::std::os::raw::c_int; +pub type FPDF_OBJECT_TYPE = ::std::os::raw::c_int; +pub const FPDF_RENDERER_TYPE_FPDF_RENDERERTYPE_AGG: FPDF_RENDERER_TYPE = 0; +pub const FPDF_RENDERER_TYPE_FPDF_RENDERERTYPE_SKIA: FPDF_RENDERER_TYPE = 1; +pub type FPDF_RENDERER_TYPE = ::std::os::raw::c_uint; +pub const FPDF_FONT_BACKEND_TYPE_FPDF_FONTBACKENDTYPE_FREETYPE: FPDF_FONT_BACKEND_TYPE = 0; +pub const FPDF_FONT_BACKEND_TYPE_FPDF_FONTBACKENDTYPE_FONTATIONS: FPDF_FONT_BACKEND_TYPE = 1; +pub type FPDF_FONT_BACKEND_TYPE = ::std::os::raw::c_uint; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct FPDF_LIBRARY_CONFIG_ { + pub version: ::std::os::raw::c_int, + pub m_pUserFontPaths: *mut *const ::std::os::raw::c_char, + pub m_pIsolate: *mut ::std::os::raw::c_void, + pub m_v8EmbedderSlot: ::std::os::raw::c_uint, + pub m_pPlatform: *mut ::std::os::raw::c_void, + pub m_RendererType: FPDF_RENDERER_TYPE, + pub m_FontLibraryType: FPDF_FONT_BACKEND_TYPE, +} +impl Default for FPDF_LIBRARY_CONFIG_ { + fn default() -> Self { + let mut s = ::std::mem::MaybeUninit::::uninit(); + unsafe { + ::std::ptr::write_bytes(s.as_mut_ptr(), 0, 1); + s.assume_init() + } + } +} +pub type FPDF_LIBRARY_CONFIG = FPDF_LIBRARY_CONFIG_; +unsafe extern "C" { + pub fn FPDF_InitLibraryWithConfig(config: *const FPDF_LIBRARY_CONFIG); +} +unsafe extern "C" { + pub fn FPDF_InitLibrary(); +} +unsafe extern "C" { + pub fn FPDF_DestroyLibrary(); +} +unsafe extern "C" { + pub fn FPDF_SetSandBoxPolicy(policy: FPDF_DWORD, enable: FPDF_BOOL); +} +unsafe extern "C" { + pub fn FPDF_LoadDocument(file_path: FPDF_STRING, password: FPDF_BYTESTRING) -> FPDF_DOCUMENT; +} +unsafe extern "C" { + pub fn FPDF_LoadMemDocument( + data_buf: *const ::std::os::raw::c_void, + size: ::std::os::raw::c_int, + password: FPDF_BYTESTRING, + ) -> FPDF_DOCUMENT; +} +unsafe extern "C" { + pub fn FPDF_LoadMemDocument64( + data_buf: *const ::std::os::raw::c_void, + size: usize, + password: FPDF_BYTESTRING, + ) -> FPDF_DOCUMENT; +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct FPDF_FILEACCESS { + pub m_FileLen: ::std::os::raw::c_ulong, + pub m_GetBlock: ::std::option::Option< + unsafe extern "C" fn( + param: *mut ::std::os::raw::c_void, + position: ::std::os::raw::c_ulong, + pBuf: *mut ::std::os::raw::c_uchar, + size: ::std::os::raw::c_ulong, + ) -> ::std::os::raw::c_int, + >, + pub m_Param: *mut ::std::os::raw::c_void, +} +impl Default for FPDF_FILEACCESS { + fn default() -> Self { + let mut s = ::std::mem::MaybeUninit::::uninit(); + unsafe { + ::std::ptr::write_bytes(s.as_mut_ptr(), 0, 1); + s.assume_init() + } + } +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct FPDF_FILEHANDLER_ { + pub clientData: *mut ::std::os::raw::c_void, + pub Release: + ::std::option::Option, + pub GetSize: ::std::option::Option< + unsafe extern "C" fn(clientData: *mut ::std::os::raw::c_void) -> FPDF_DWORD, + >, + pub ReadBlock: ::std::option::Option< + unsafe extern "C" fn( + clientData: *mut ::std::os::raw::c_void, + offset: FPDF_DWORD, + buffer: *mut ::std::os::raw::c_void, + size: FPDF_DWORD, + ) -> FPDF_RESULT, + >, + pub WriteBlock: ::std::option::Option< + unsafe extern "C" fn( + clientData: *mut ::std::os::raw::c_void, + offset: FPDF_DWORD, + buffer: *const ::std::os::raw::c_void, + size: FPDF_DWORD, + ) -> FPDF_RESULT, + >, + pub Flush: ::std::option::Option< + unsafe extern "C" fn(clientData: *mut ::std::os::raw::c_void) -> FPDF_RESULT, + >, + pub Truncate: ::std::option::Option< + unsafe extern "C" fn( + clientData: *mut ::std::os::raw::c_void, + size: FPDF_DWORD, + ) -> FPDF_RESULT, + >, +} +impl Default for FPDF_FILEHANDLER_ { + fn default() -> Self { + let mut s = ::std::mem::MaybeUninit::::uninit(); + unsafe { + ::std::ptr::write_bytes(s.as_mut_ptr(), 0, 1); + s.assume_init() + } + } +} +pub type FPDF_FILEHANDLER = FPDF_FILEHANDLER_; +unsafe extern "C" { + pub fn FPDF_LoadCustomDocument( + pFileAccess: *mut FPDF_FILEACCESS, + password: FPDF_BYTESTRING, + ) -> FPDF_DOCUMENT; +} +unsafe extern "C" { + pub fn FPDF_GetFileVersion( + doc: FPDF_DOCUMENT, + fileVersion: *mut ::std::os::raw::c_int, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDF_GetLastError() -> ::std::os::raw::c_ulong; +} +unsafe extern "C" { + pub fn FPDF_DocumentHasValidCrossReferenceTable(document: FPDF_DOCUMENT) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDF_GetTrailerEnds( + document: FPDF_DOCUMENT, + buffer: *mut ::std::os::raw::c_uint, + length: ::std::os::raw::c_ulong, + ) -> ::std::os::raw::c_ulong; +} +unsafe extern "C" { + pub fn FPDF_GetDocPermissions(document: FPDF_DOCUMENT) -> ::std::os::raw::c_ulong; +} +unsafe extern "C" { + pub fn FPDF_GetDocUserPermissions(document: FPDF_DOCUMENT) -> ::std::os::raw::c_ulong; +} +unsafe extern "C" { + pub fn FPDF_GetSecurityHandlerRevision(document: FPDF_DOCUMENT) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDF_GetPageCount(document: FPDF_DOCUMENT) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDF_LoadPage(document: FPDF_DOCUMENT, page_index: ::std::os::raw::c_int) -> FPDF_PAGE; +} +unsafe extern "C" { + pub fn FPDF_GetPageWidthF(page: FPDF_PAGE) -> f32; +} +unsafe extern "C" { + pub fn FPDF_GetPageWidth(page: FPDF_PAGE) -> f64; +} +unsafe extern "C" { + pub fn FPDF_GetPageHeightF(page: FPDF_PAGE) -> f32; +} +unsafe extern "C" { + pub fn FPDF_GetPageHeight(page: FPDF_PAGE) -> f64; +} +unsafe extern "C" { + pub fn FPDF_GetPageBoundingBox(page: FPDF_PAGE, rect: *mut FS_RECTF) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDF_GetPageSizeByIndexF( + document: FPDF_DOCUMENT, + page_index: ::std::os::raw::c_int, + size: *mut FS_SIZEF, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDF_GetPageSizeByIndex( + document: FPDF_DOCUMENT, + page_index: ::std::os::raw::c_int, + width: *mut f64, + height: *mut f64, + ) -> ::std::os::raw::c_int; +} +#[repr(C)] +#[derive(Debug, Default, Copy, Clone)] +pub struct FPDF_COLORSCHEME_ { + pub path_fill_color: FPDF_DWORD, + pub path_stroke_color: FPDF_DWORD, + pub text_fill_color: FPDF_DWORD, + pub text_stroke_color: FPDF_DWORD, +} +pub type FPDF_COLORSCHEME = FPDF_COLORSCHEME_; +unsafe extern "C" { + pub fn FPDF_RenderPageBitmap( + bitmap: FPDF_BITMAP, + page: FPDF_PAGE, + start_x: ::std::os::raw::c_int, + start_y: ::std::os::raw::c_int, + size_x: ::std::os::raw::c_int, + size_y: ::std::os::raw::c_int, + rotate: ::std::os::raw::c_int, + flags: ::std::os::raw::c_int, + ); +} +unsafe extern "C" { + pub fn FPDF_RenderPageBitmapWithMatrix( + bitmap: FPDF_BITMAP, + page: FPDF_PAGE, + matrix: *const FS_MATRIX, + clipping: *const FS_RECTF, + flags: ::std::os::raw::c_int, + ); +} +unsafe extern "C" { + pub fn FPDF_ClosePage(page: FPDF_PAGE); +} +unsafe extern "C" { + pub fn FPDF_CloseDocument(document: FPDF_DOCUMENT); +} +unsafe extern "C" { + pub fn FPDF_DeviceToPage( + page: FPDF_PAGE, + start_x: ::std::os::raw::c_int, + start_y: ::std::os::raw::c_int, + size_x: ::std::os::raw::c_int, + size_y: ::std::os::raw::c_int, + rotate: ::std::os::raw::c_int, + device_x: ::std::os::raw::c_int, + device_y: ::std::os::raw::c_int, + page_x: *mut f64, + page_y: *mut f64, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDF_PageToDevice( + page: FPDF_PAGE, + start_x: ::std::os::raw::c_int, + start_y: ::std::os::raw::c_int, + size_x: ::std::os::raw::c_int, + size_y: ::std::os::raw::c_int, + rotate: ::std::os::raw::c_int, + page_x: f64, + page_y: f64, + device_x: *mut ::std::os::raw::c_int, + device_y: *mut ::std::os::raw::c_int, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFBitmap_Create( + width: ::std::os::raw::c_int, + height: ::std::os::raw::c_int, + alpha: ::std::os::raw::c_int, + ) -> FPDF_BITMAP; +} +unsafe extern "C" { + pub fn FPDFBitmap_CreateEx( + width: ::std::os::raw::c_int, + height: ::std::os::raw::c_int, + format: ::std::os::raw::c_int, + first_scan: *mut ::std::os::raw::c_void, + stride: ::std::os::raw::c_int, + ) -> FPDF_BITMAP; +} +unsafe extern "C" { + pub fn FPDFBitmap_GetFormat(bitmap: FPDF_BITMAP) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFBitmap_FillRect( + bitmap: FPDF_BITMAP, + left: ::std::os::raw::c_int, + top: ::std::os::raw::c_int, + width: ::std::os::raw::c_int, + height: ::std::os::raw::c_int, + color: FPDF_DWORD, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFBitmap_GetBuffer(bitmap: FPDF_BITMAP) -> *mut ::std::os::raw::c_void; +} +unsafe extern "C" { + pub fn FPDFBitmap_GetWidth(bitmap: FPDF_BITMAP) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFBitmap_GetHeight(bitmap: FPDF_BITMAP) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFBitmap_GetStride(bitmap: FPDF_BITMAP) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFBitmap_Destroy(bitmap: FPDF_BITMAP); +} +unsafe extern "C" { + pub fn FPDF_VIEWERREF_GetPrintScaling(document: FPDF_DOCUMENT) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDF_VIEWERREF_GetNumCopies(document: FPDF_DOCUMENT) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDF_VIEWERREF_GetPrintPageRange(document: FPDF_DOCUMENT) -> FPDF_PAGERANGE; +} +unsafe extern "C" { + pub fn FPDF_VIEWERREF_GetPrintPageRangeCount(pagerange: FPDF_PAGERANGE) -> usize; +} +unsafe extern "C" { + pub fn FPDF_VIEWERREF_GetPrintPageRangeElement( + pagerange: FPDF_PAGERANGE, + index: usize, + ) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDF_VIEWERREF_GetDuplex(document: FPDF_DOCUMENT) -> FPDF_DUPLEXTYPE; +} +unsafe extern "C" { + pub fn FPDF_VIEWERREF_GetName( + document: FPDF_DOCUMENT, + key: FPDF_BYTESTRING, + buffer: *mut ::std::os::raw::c_char, + length: ::std::os::raw::c_ulong, + ) -> ::std::os::raw::c_ulong; +} +unsafe extern "C" { + pub fn FPDF_CountNamedDests(document: FPDF_DOCUMENT) -> FPDF_DWORD; +} +unsafe extern "C" { + pub fn FPDF_GetNamedDestByName(document: FPDF_DOCUMENT, name: FPDF_BYTESTRING) -> FPDF_DEST; +} +unsafe extern "C" { + pub fn FPDF_GetNamedDest( + document: FPDF_DOCUMENT, + index: ::std::os::raw::c_int, + buffer: *mut ::std::os::raw::c_void, + buflen: *mut ::std::os::raw::c_long, + ) -> FPDF_DEST; +} +unsafe extern "C" { + pub fn FPDF_GetXFAPacketCount(document: FPDF_DOCUMENT) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDF_GetXFAPacketName( + document: FPDF_DOCUMENT, + index: ::std::os::raw::c_int, + buffer: *mut ::std::os::raw::c_void, + buflen: ::std::os::raw::c_ulong, + ) -> ::std::os::raw::c_ulong; +} +unsafe extern "C" { + pub fn FPDF_GetXFAPacketContent( + document: FPDF_DOCUMENT, + index: ::std::os::raw::c_int, + buffer: *mut ::std::os::raw::c_void, + buflen: ::std::os::raw::c_ulong, + out_buflen: *mut ::std::os::raw::c_ulong, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFText_LoadPage(page: FPDF_PAGE) -> FPDF_TEXTPAGE; +} +unsafe extern "C" { + pub fn FPDFText_ClosePage(text_page: FPDF_TEXTPAGE); +} +unsafe extern "C" { + pub fn FPDFText_CountChars(text_page: FPDF_TEXTPAGE) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFText_GetUnicode( + text_page: FPDF_TEXTPAGE, + index: ::std::os::raw::c_int, + ) -> ::std::os::raw::c_uint; +} +unsafe extern "C" { + pub fn FPDFText_GetCharCode( + text_page: FPDF_TEXTPAGE, + index: ::std::os::raw::c_int, + ) -> ::std::os::raw::c_uint; +} +unsafe extern "C" { + pub fn FPDFText_GetTextObject( + text_page: FPDF_TEXTPAGE, + index: ::std::os::raw::c_int, + ) -> FPDF_PAGEOBJECT; +} +unsafe extern "C" { + pub fn FPDFText_IsGenerated( + text_page: FPDF_TEXTPAGE, + index: ::std::os::raw::c_int, + ) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFText_IsHyphen( + text_page: FPDF_TEXTPAGE, + index: ::std::os::raw::c_int, + ) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFText_HasUnicodeMapError( + text_page: FPDF_TEXTPAGE, + index: ::std::os::raw::c_int, + ) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFText_GetFontSize(text_page: FPDF_TEXTPAGE, index: ::std::os::raw::c_int) -> f64; +} +unsafe extern "C" { + pub fn FPDFText_GetFontInfo( + text_page: FPDF_TEXTPAGE, + index: ::std::os::raw::c_int, + buffer: *mut ::std::os::raw::c_void, + buflen: ::std::os::raw::c_ulong, + flags: *mut ::std::os::raw::c_int, + ) -> ::std::os::raw::c_ulong; +} +unsafe extern "C" { + pub fn FPDFText_GetFontWeight( + text_page: FPDF_TEXTPAGE, + index: ::std::os::raw::c_int, + ) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFText_GetFillColor( + text_page: FPDF_TEXTPAGE, + index: ::std::os::raw::c_int, + R: *mut ::std::os::raw::c_uint, + G: *mut ::std::os::raw::c_uint, + B: *mut ::std::os::raw::c_uint, + A: *mut ::std::os::raw::c_uint, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFText_GetStrokeColor( + text_page: FPDF_TEXTPAGE, + index: ::std::os::raw::c_int, + R: *mut ::std::os::raw::c_uint, + G: *mut ::std::os::raw::c_uint, + B: *mut ::std::os::raw::c_uint, + A: *mut ::std::os::raw::c_uint, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFText_GetCharAngle(text_page: FPDF_TEXTPAGE, index: ::std::os::raw::c_int) -> f32; +} +unsafe extern "C" { + pub fn FPDFText_GetCharBox( + text_page: FPDF_TEXTPAGE, + index: ::std::os::raw::c_int, + left: *mut f64, + right: *mut f64, + bottom: *mut f64, + top: *mut f64, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFText_GetLooseCharBox( + text_page: FPDF_TEXTPAGE, + index: ::std::os::raw::c_int, + rect: *mut FS_RECTF, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFText_GetMatrix( + text_page: FPDF_TEXTPAGE, + index: ::std::os::raw::c_int, + matrix: *mut FS_MATRIX, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFText_GetCharOrigin( + text_page: FPDF_TEXTPAGE, + index: ::std::os::raw::c_int, + x: *mut f64, + y: *mut f64, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFText_GetCharIndexAtPos( + text_page: FPDF_TEXTPAGE, + x: f64, + y: f64, + xTolerance: f64, + yTolerance: f64, + ) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFText_GetText( + text_page: FPDF_TEXTPAGE, + start_index: ::std::os::raw::c_int, + count: ::std::os::raw::c_int, + result: *mut ::std::os::raw::c_ushort, + ) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFText_CountRects( + text_page: FPDF_TEXTPAGE, + start_index: ::std::os::raw::c_int, + count: ::std::os::raw::c_int, + ) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFText_GetRect( + text_page: FPDF_TEXTPAGE, + rect_index: ::std::os::raw::c_int, + left: *mut f64, + top: *mut f64, + right: *mut f64, + bottom: *mut f64, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFText_GetBoundedText( + text_page: FPDF_TEXTPAGE, + left: f64, + top: f64, + right: f64, + bottom: f64, + buffer: *mut ::std::os::raw::c_ushort, + buflen: ::std::os::raw::c_int, + ) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFText_FindStart( + text_page: FPDF_TEXTPAGE, + findwhat: FPDF_WIDESTRING, + flags: ::std::os::raw::c_ulong, + start_index: ::std::os::raw::c_int, + ) -> FPDF_SCHHANDLE; +} +unsafe extern "C" { + pub fn FPDFText_FindNext(handle: FPDF_SCHHANDLE) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFText_FindPrev(handle: FPDF_SCHHANDLE) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFText_GetSchResultIndex(handle: FPDF_SCHHANDLE) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFText_GetSchCount(handle: FPDF_SCHHANDLE) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFText_FindClose(handle: FPDF_SCHHANDLE); +} +unsafe extern "C" { + pub fn FPDFLink_LoadWebLinks(text_page: FPDF_TEXTPAGE) -> FPDF_PAGELINK; +} +unsafe extern "C" { + pub fn FPDFLink_CountWebLinks(link_page: FPDF_PAGELINK) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFLink_GetURL( + link_page: FPDF_PAGELINK, + link_index: ::std::os::raw::c_int, + buffer: *mut ::std::os::raw::c_ushort, + buflen: ::std::os::raw::c_int, + ) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFLink_CountRects( + link_page: FPDF_PAGELINK, + link_index: ::std::os::raw::c_int, + ) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFLink_GetRect( + link_page: FPDF_PAGELINK, + link_index: ::std::os::raw::c_int, + rect_index: ::std::os::raw::c_int, + left: *mut f64, + top: *mut f64, + right: *mut f64, + bottom: *mut f64, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFLink_GetTextRange( + link_page: FPDF_PAGELINK, + link_index: ::std::os::raw::c_int, + start_char_index: *mut ::std::os::raw::c_int, + char_count: *mut ::std::os::raw::c_int, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFLink_CloseWebLinks(link_page: FPDF_PAGELINK); +} +pub const FPDF_FONT_TYPE_FPDF_FONTTYPE_UNKNOWN: FPDF_FONT_TYPE = 0; +pub const FPDF_FONT_TYPE_FPDF_FONTTYPE_TYPE1: FPDF_FONT_TYPE = 1; +pub const FPDF_FONT_TYPE_FPDF_FONTTYPE_TRUETYPE: FPDF_FONT_TYPE = 2; +pub const FPDF_FONT_TYPE_FPDF_FONTTYPE_TYPE0: FPDF_FONT_TYPE = 3; +pub const FPDF_FONT_TYPE_FPDF_FONTTYPE_TYPE3: FPDF_FONT_TYPE = 4; +pub const FPDF_FONT_TYPE_FPDF_FONTTYPE_CID_TYPE0: FPDF_FONT_TYPE = 5; +pub const FPDF_FONT_TYPE_FPDF_FONTTYPE_CID_TYPE2: FPDF_FONT_TYPE = 6; +pub type FPDF_FONT_TYPE = ::std::os::raw::c_uint; +#[repr(C)] +#[derive(Debug, Default, Copy, Clone)] +pub struct FPDF_IMAGEOBJ_METADATA { + pub width: ::std::os::raw::c_uint, + pub height: ::std::os::raw::c_uint, + pub horizontal_dpi: f32, + pub vertical_dpi: f32, + pub bits_per_pixel: ::std::os::raw::c_uint, + pub colorspace: ::std::os::raw::c_int, + pub marked_content_id: ::std::os::raw::c_int, +} +unsafe extern "C" { + pub fn FPDF_CreateNewDocument() -> FPDF_DOCUMENT; +} +unsafe extern "C" { + pub fn FPDFPage_New( + document: FPDF_DOCUMENT, + page_index: ::std::os::raw::c_int, + width: f64, + height: f64, + ) -> FPDF_PAGE; +} +unsafe extern "C" { + pub fn FPDFPage_Delete(document: FPDF_DOCUMENT, page_index: ::std::os::raw::c_int); +} +unsafe extern "C" { + pub fn FPDF_MovePages( + document: FPDF_DOCUMENT, + page_indices: *const ::std::os::raw::c_int, + page_indices_len: ::std::os::raw::c_ulong, + dest_page_index: ::std::os::raw::c_int, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPage_GetRotation(page: FPDF_PAGE) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFPage_SetRotation(page: FPDF_PAGE, rotate: ::std::os::raw::c_int); +} +unsafe extern "C" { + pub fn FPDFPage_InsertObject(page: FPDF_PAGE, page_object: FPDF_PAGEOBJECT) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPage_InsertObjectAtIndex( + page: FPDF_PAGE, + page_object: FPDF_PAGEOBJECT, + index: usize, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPage_RemoveObject(page: FPDF_PAGE, page_object: FPDF_PAGEOBJECT) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPage_CountObjects(page: FPDF_PAGE) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFPage_GetObject(page: FPDF_PAGE, index: ::std::os::raw::c_int) -> FPDF_PAGEOBJECT; +} +unsafe extern "C" { + pub fn FPDFPage_HasTransparency(page: FPDF_PAGE) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPage_GenerateContent(page: FPDF_PAGE) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPageObj_Destroy(page_object: FPDF_PAGEOBJECT); +} +unsafe extern "C" { + pub fn FPDFPageObj_HasTransparency(page_object: FPDF_PAGEOBJECT) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPageObj_GetType(page_object: FPDF_PAGEOBJECT) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFPageObj_GetIsActive( + page_object: FPDF_PAGEOBJECT, + active: *mut FPDF_BOOL, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPageObj_SetIsActive(page_object: FPDF_PAGEOBJECT, active: FPDF_BOOL) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPageObj_Transform( + page_object: FPDF_PAGEOBJECT, + a: f64, + b: f64, + c: f64, + d: f64, + e: f64, + f: f64, + ); +} +unsafe extern "C" { + pub fn FPDFPageObj_TransformF( + page_object: FPDF_PAGEOBJECT, + matrix: *const FS_MATRIX, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPageObj_GetMatrix(page_object: FPDF_PAGEOBJECT, matrix: *mut FS_MATRIX) + -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPageObj_SetMatrix( + page_object: FPDF_PAGEOBJECT, + matrix: *const FS_MATRIX, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPage_TransformAnnots( + page: FPDF_PAGE, + a: f64, + b: f64, + c: f64, + d: f64, + e: f64, + f: f64, + ); +} +unsafe extern "C" { + pub fn FPDFPageObj_NewImageObj(document: FPDF_DOCUMENT) -> FPDF_PAGEOBJECT; +} +unsafe extern "C" { + pub fn FPDFPageObj_GetMarkedContentID(page_object: FPDF_PAGEOBJECT) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFPageObj_CountMarks(page_object: FPDF_PAGEOBJECT) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFPageObj_GetMark( + page_object: FPDF_PAGEOBJECT, + index: ::std::os::raw::c_ulong, + ) -> FPDF_PAGEOBJECTMARK; +} +unsafe extern "C" { + pub fn FPDFPageObj_AddMark( + page_object: FPDF_PAGEOBJECT, + name: FPDF_BYTESTRING, + ) -> FPDF_PAGEOBJECTMARK; +} +unsafe extern "C" { + pub fn FPDFPageObj_AddExistingMark( + page_object: FPDF_PAGEOBJECT, + mark: FPDF_PAGEOBJECTMARK, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPageObj_RemoveMark( + page_object: FPDF_PAGEOBJECT, + mark: FPDF_PAGEOBJECTMARK, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPageObjMark_GetName( + mark: FPDF_PAGEOBJECTMARK, + buffer: *mut FPDF_WCHAR, + buflen: ::std::os::raw::c_ulong, + out_buflen: *mut ::std::os::raw::c_ulong, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPageObjMark_CountParams(mark: FPDF_PAGEOBJECTMARK) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFPageObjMark_GetParamKey( + mark: FPDF_PAGEOBJECTMARK, + index: ::std::os::raw::c_ulong, + buffer: *mut FPDF_WCHAR, + buflen: ::std::os::raw::c_ulong, + out_buflen: *mut ::std::os::raw::c_ulong, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPageObjMark_GetParamValueType( + mark: FPDF_PAGEOBJECTMARK, + key: FPDF_BYTESTRING, + ) -> FPDF_OBJECT_TYPE; +} +unsafe extern "C" { + pub fn FPDFPageObjMark_GetParamIntValue( + mark: FPDF_PAGEOBJECTMARK, + key: FPDF_BYTESTRING, + out_value: *mut ::std::os::raw::c_int, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPageObjMark_GetParamFloatValue( + mark: FPDF_PAGEOBJECTMARK, + key: FPDF_BYTESTRING, + out_value: *mut f32, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPageObjMark_GetParamStringValue( + mark: FPDF_PAGEOBJECTMARK, + key: FPDF_BYTESTRING, + buffer: *mut FPDF_WCHAR, + buflen: ::std::os::raw::c_ulong, + out_buflen: *mut ::std::os::raw::c_ulong, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPageObjMark_GetParamBlobValue( + mark: FPDF_PAGEOBJECTMARK, + key: FPDF_BYTESTRING, + buffer: *mut ::std::os::raw::c_uchar, + buflen: ::std::os::raw::c_ulong, + out_buflen: *mut ::std::os::raw::c_ulong, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPageObjMark_SetIntParam( + document: FPDF_DOCUMENT, + page_object: FPDF_PAGEOBJECT, + mark: FPDF_PAGEOBJECTMARK, + key: FPDF_BYTESTRING, + value: ::std::os::raw::c_int, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPageObjMark_SetFloatParam( + document: FPDF_DOCUMENT, + page_object: FPDF_PAGEOBJECT, + mark: FPDF_PAGEOBJECTMARK, + key: FPDF_BYTESTRING, + value: f32, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPageObjMark_SetStringParam( + document: FPDF_DOCUMENT, + page_object: FPDF_PAGEOBJECT, + mark: FPDF_PAGEOBJECTMARK, + key: FPDF_BYTESTRING, + value: FPDF_BYTESTRING, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPageObjMark_SetBlobParam( + document: FPDF_DOCUMENT, + page_object: FPDF_PAGEOBJECT, + mark: FPDF_PAGEOBJECTMARK, + key: FPDF_BYTESTRING, + value: *const ::std::os::raw::c_uchar, + value_len: ::std::os::raw::c_ulong, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPageObjMark_RemoveParam( + page_object: FPDF_PAGEOBJECT, + mark: FPDF_PAGEOBJECTMARK, + key: FPDF_BYTESTRING, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFImageObj_LoadJpegFile( + pages: *mut FPDF_PAGE, + count: ::std::os::raw::c_int, + image_object: FPDF_PAGEOBJECT, + file_access: *mut FPDF_FILEACCESS, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFImageObj_LoadJpegFileInline( + pages: *mut FPDF_PAGE, + count: ::std::os::raw::c_int, + image_object: FPDF_PAGEOBJECT, + file_access: *mut FPDF_FILEACCESS, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFImageObj_SetMatrix( + image_object: FPDF_PAGEOBJECT, + a: f64, + b: f64, + c: f64, + d: f64, + e: f64, + f: f64, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFImageObj_SetBitmap( + pages: *mut FPDF_PAGE, + count: ::std::os::raw::c_int, + image_object: FPDF_PAGEOBJECT, + bitmap: FPDF_BITMAP, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFImageObj_GetBitmap(image_object: FPDF_PAGEOBJECT) -> FPDF_BITMAP; +} +unsafe extern "C" { + pub fn FPDFImageObj_GetRenderedBitmap( + document: FPDF_DOCUMENT, + page: FPDF_PAGE, + image_object: FPDF_PAGEOBJECT, + ) -> FPDF_BITMAP; +} +unsafe extern "C" { + pub fn FPDFImageObj_GetImageDataDecoded( + image_object: FPDF_PAGEOBJECT, + buffer: *mut ::std::os::raw::c_void, + buflen: ::std::os::raw::c_ulong, + ) -> ::std::os::raw::c_ulong; +} +unsafe extern "C" { + pub fn FPDFImageObj_GetImageDataRaw( + image_object: FPDF_PAGEOBJECT, + buffer: *mut ::std::os::raw::c_void, + buflen: ::std::os::raw::c_ulong, + ) -> ::std::os::raw::c_ulong; +} +unsafe extern "C" { + pub fn FPDFImageObj_GetImageFilterCount(image_object: FPDF_PAGEOBJECT) + -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFImageObj_GetImageFilter( + image_object: FPDF_PAGEOBJECT, + index: ::std::os::raw::c_int, + buffer: *mut ::std::os::raw::c_void, + buflen: ::std::os::raw::c_ulong, + ) -> ::std::os::raw::c_ulong; +} +unsafe extern "C" { + pub fn FPDFImageObj_GetImageMetadata( + image_object: FPDF_PAGEOBJECT, + page: FPDF_PAGE, + metadata: *mut FPDF_IMAGEOBJ_METADATA, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFImageObj_GetImagePixelSize( + image_object: FPDF_PAGEOBJECT, + width: *mut ::std::os::raw::c_uint, + height: *mut ::std::os::raw::c_uint, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFImageObj_GetIccProfileDataDecoded( + image_object: FPDF_PAGEOBJECT, + page: FPDF_PAGE, + buffer: *mut u8, + buflen: usize, + out_buflen: *mut usize, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPageObj_CreateNewPath(x: f32, y: f32) -> FPDF_PAGEOBJECT; +} +unsafe extern "C" { + pub fn FPDFPageObj_CreateNewRect(x: f32, y: f32, w: f32, h: f32) -> FPDF_PAGEOBJECT; +} +unsafe extern "C" { + pub fn FPDFPageObj_GetBounds( + page_object: FPDF_PAGEOBJECT, + left: *mut f32, + bottom: *mut f32, + right: *mut f32, + top: *mut f32, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPageObj_GetRotatedBounds( + page_object: FPDF_PAGEOBJECT, + quad_points: *mut FS_QUADPOINTSF, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPageObj_SetBlendMode(page_object: FPDF_PAGEOBJECT, blend_mode: FPDF_BYTESTRING); +} +unsafe extern "C" { + pub fn FPDFPageObj_SetStrokeColor( + page_object: FPDF_PAGEOBJECT, + R: ::std::os::raw::c_uint, + G: ::std::os::raw::c_uint, + B: ::std::os::raw::c_uint, + A: ::std::os::raw::c_uint, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPageObj_GetStrokeColor( + page_object: FPDF_PAGEOBJECT, + R: *mut ::std::os::raw::c_uint, + G: *mut ::std::os::raw::c_uint, + B: *mut ::std::os::raw::c_uint, + A: *mut ::std::os::raw::c_uint, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPageObj_SetStrokeWidth(page_object: FPDF_PAGEOBJECT, width: f32) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPageObj_GetStrokeWidth(page_object: FPDF_PAGEOBJECT, width: *mut f32) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPageObj_GetLineJoin(page_object: FPDF_PAGEOBJECT) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFPageObj_SetLineJoin( + page_object: FPDF_PAGEOBJECT, + line_join: ::std::os::raw::c_int, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPageObj_GetLineCap(page_object: FPDF_PAGEOBJECT) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFPageObj_SetLineCap( + page_object: FPDF_PAGEOBJECT, + line_cap: ::std::os::raw::c_int, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPageObj_SetFillColor( + page_object: FPDF_PAGEOBJECT, + R: ::std::os::raw::c_uint, + G: ::std::os::raw::c_uint, + B: ::std::os::raw::c_uint, + A: ::std::os::raw::c_uint, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPageObj_GetFillColor( + page_object: FPDF_PAGEOBJECT, + R: *mut ::std::os::raw::c_uint, + G: *mut ::std::os::raw::c_uint, + B: *mut ::std::os::raw::c_uint, + A: *mut ::std::os::raw::c_uint, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPageObj_GetDashPhase(page_object: FPDF_PAGEOBJECT, phase: *mut f32) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPageObj_SetDashPhase(page_object: FPDF_PAGEOBJECT, phase: f32) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPageObj_GetDashCount(page_object: FPDF_PAGEOBJECT) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFPageObj_GetDashArray( + page_object: FPDF_PAGEOBJECT, + dash_array: *mut f32, + dash_count: usize, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPageObj_SetDashArray( + page_object: FPDF_PAGEOBJECT, + dash_array: *const f32, + dash_count: usize, + phase: f32, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPath_CountSegments(path: FPDF_PAGEOBJECT) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFPath_GetPathSegment( + path: FPDF_PAGEOBJECT, + index: ::std::os::raw::c_int, + ) -> FPDF_PATHSEGMENT; +} +unsafe extern "C" { + pub fn FPDFPathSegment_GetPoint( + segment: FPDF_PATHSEGMENT, + x: *mut f32, + y: *mut f32, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPathSegment_GetType(segment: FPDF_PATHSEGMENT) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFPathSegment_GetClose(segment: FPDF_PATHSEGMENT) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPath_MoveTo(path: FPDF_PAGEOBJECT, x: f32, y: f32) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPath_LineTo(path: FPDF_PAGEOBJECT, x: f32, y: f32) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPath_BezierTo( + path: FPDF_PAGEOBJECT, + x1: f32, + y1: f32, + x2: f32, + y2: f32, + x3: f32, + y3: f32, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPath_Close(path: FPDF_PAGEOBJECT) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPath_SetDrawMode( + path: FPDF_PAGEOBJECT, + fillmode: ::std::os::raw::c_int, + stroke: FPDF_BOOL, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPath_GetDrawMode( + path: FPDF_PAGEOBJECT, + fillmode: *mut ::std::os::raw::c_int, + stroke: *mut FPDF_BOOL, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPageObj_NewTextObj( + document: FPDF_DOCUMENT, + font: FPDF_BYTESTRING, + font_size: f32, + ) -> FPDF_PAGEOBJECT; +} +unsafe extern "C" { + pub fn FPDFText_SetText(text_object: FPDF_PAGEOBJECT, text: FPDF_WIDESTRING) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFText_SetCharcodes( + text_object: FPDF_PAGEOBJECT, + charcodes: *const u32, + count: usize, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFText_SetPositions( + text_object: FPDF_PAGEOBJECT, + positions: *const f32, + count: usize, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFText_LoadFont( + document: FPDF_DOCUMENT, + data: *const u8, + size: u32, + font_type: ::std::os::raw::c_int, + cid: FPDF_BOOL, + ) -> FPDF_FONT; +} +unsafe extern "C" { + pub fn FPDFText_LoadStandardFont(document: FPDF_DOCUMENT, font: FPDF_BYTESTRING) -> FPDF_FONT; +} +unsafe extern "C" { + pub fn FPDFText_LoadCidType2Font( + document: FPDF_DOCUMENT, + font_data: *const u8, + font_data_size: u32, + to_unicode_cmap: FPDF_BYTESTRING, + cid_to_gid_map_data: *const u8, + cid_to_gid_map_data_size: u32, + ) -> FPDF_FONT; +} +unsafe extern "C" { + pub fn FPDFTextObj_GetFontSize(text: FPDF_PAGEOBJECT, size: *mut f32) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFTextObj_SetFontSize(text: FPDF_PAGEOBJECT, size: f32) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFFont_Close(font: FPDF_FONT); +} +unsafe extern "C" { + pub fn FPDFPageObj_CreateTextObj( + document: FPDF_DOCUMENT, + font: FPDF_FONT, + font_size: f32, + ) -> FPDF_PAGEOBJECT; +} +unsafe extern "C" { + pub fn FPDFTextObj_GetTextRenderMode(text: FPDF_PAGEOBJECT) -> FPDF_TEXT_RENDERMODE; +} +unsafe extern "C" { + pub fn FPDFTextObj_SetTextRenderMode( + text: FPDF_PAGEOBJECT, + render_mode: FPDF_TEXT_RENDERMODE, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFTextObj_GetText( + text_object: FPDF_PAGEOBJECT, + text_page: FPDF_TEXTPAGE, + buffer: *mut FPDF_WCHAR, + length: ::std::os::raw::c_ulong, + ) -> ::std::os::raw::c_ulong; +} +unsafe extern "C" { + pub fn FPDFTextObj_GetRenderedBitmap( + document: FPDF_DOCUMENT, + page: FPDF_PAGE, + text_object: FPDF_PAGEOBJECT, + scale: f32, + ) -> FPDF_BITMAP; +} +unsafe extern "C" { + pub fn FPDFTextObj_GetFont(text: FPDF_PAGEOBJECT) -> FPDF_FONT; +} +unsafe extern "C" { + pub fn FPDFFont_GetBaseFontName( + font: FPDF_FONT, + buffer: *mut ::std::os::raw::c_char, + length: usize, + ) -> usize; +} +unsafe extern "C" { + pub fn FPDFFont_GetFamilyName( + font: FPDF_FONT, + buffer: *mut ::std::os::raw::c_char, + length: usize, + ) -> usize; +} +unsafe extern "C" { + pub fn FPDFFont_GetFontData( + font: FPDF_FONT, + buffer: *mut u8, + buflen: usize, + out_buflen: *mut usize, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFFont_GetIsEmbedded(font: FPDF_FONT) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFFont_GetFlags(font: FPDF_FONT) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFFont_GetType(font: FPDF_FONT) -> FPDF_FONT_TYPE; +} +unsafe extern "C" { + pub fn FPDFFont_GetWeight(font: FPDF_FONT) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFFont_GetItalicAngle(font: FPDF_FONT, angle: *mut ::std::os::raw::c_int) + -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFFont_GetAscent(font: FPDF_FONT, font_size: f32, ascent: *mut f32) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFFont_GetDescent(font: FPDF_FONT, font_size: f32, descent: *mut f32) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFFont_GetGlyphWidth( + font: FPDF_FONT, + glyph: u32, + font_size: f32, + width: *mut f32, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFFont_GetGlyphWidthFromCharCode( + font: FPDF_FONT, + char_code: u32, + font_size: f32, + width: *mut f32, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFFont_GetGlyphPath(font: FPDF_FONT, glyph: u32, font_size: f32) -> FPDF_GLYPHPATH; +} +unsafe extern "C" { + pub fn FPDFFont_GetGlyphPathFromCharCode( + font: FPDF_FONT, + char_code: u32, + font_size: f32, + ) -> FPDF_GLYPHPATH; +} +unsafe extern "C" { + pub fn FPDFFont_HasToUnicode(font: FPDF_FONT) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFFont_GetCharGlyphName( + font: FPDF_FONT, + char_code: u32, + buffer: *mut ::std::os::raw::c_char, + length: ::std::os::raw::c_ulong, + ) -> ::std::os::raw::c_ulong; +} +unsafe extern "C" { + pub fn FPDFFont_GetEncoding( + font: FPDF_FONT, + buffer: *mut ::std::os::raw::c_char, + length: ::std::os::raw::c_ulong, + ) -> ::std::os::raw::c_ulong; +} +unsafe extern "C" { + pub fn FPDFFont_GetCharGlyphIndex(font: FPDF_FONT, char_code: u32) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFGlyphPath_CountGlyphSegments(glyphpath: FPDF_GLYPHPATH) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFGlyphPath_GetGlyphPathSegment( + glyphpath: FPDF_GLYPHPATH, + index: ::std::os::raw::c_int, + ) -> FPDF_PATHSEGMENT; +} +unsafe extern "C" { + pub fn FPDFFormObj_CountObjects(form_object: FPDF_PAGEOBJECT) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFFormObj_GetObject( + form_object: FPDF_PAGEOBJECT, + index: ::std::os::raw::c_ulong, + ) -> FPDF_PAGEOBJECT; +} +unsafe extern "C" { + pub fn FPDFFormObj_RemoveObject( + form_object: FPDF_PAGEOBJECT, + page_object: FPDF_PAGEOBJECT, + ) -> FPDF_BOOL; +} +pub const FPDF_FILEIDTYPE_FILEIDTYPE_PERMANENT: FPDF_FILEIDTYPE = 0; +pub const FPDF_FILEIDTYPE_FILEIDTYPE_CHANGING: FPDF_FILEIDTYPE = 1; +pub type FPDF_FILEIDTYPE = ::std::os::raw::c_uint; +unsafe extern "C" { + pub fn FPDFBookmark_GetFirstChild( + document: FPDF_DOCUMENT, + bookmark: FPDF_BOOKMARK, + ) -> FPDF_BOOKMARK; +} +unsafe extern "C" { + pub fn FPDFBookmark_GetNextSibling( + document: FPDF_DOCUMENT, + bookmark: FPDF_BOOKMARK, + ) -> FPDF_BOOKMARK; +} +unsafe extern "C" { + pub fn FPDFBookmark_GetTitle( + bookmark: FPDF_BOOKMARK, + buffer: *mut ::std::os::raw::c_void, + buflen: ::std::os::raw::c_ulong, + ) -> ::std::os::raw::c_ulong; +} +unsafe extern "C" { + pub fn FPDFBookmark_GetCount(bookmark: FPDF_BOOKMARK) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFBookmark_Find(document: FPDF_DOCUMENT, title: FPDF_WIDESTRING) -> FPDF_BOOKMARK; +} +unsafe extern "C" { + pub fn FPDFBookmark_GetDest(document: FPDF_DOCUMENT, bookmark: FPDF_BOOKMARK) -> FPDF_DEST; +} +unsafe extern "C" { + pub fn FPDFBookmark_GetAction(bookmark: FPDF_BOOKMARK) -> FPDF_ACTION; +} +unsafe extern "C" { + pub fn FPDFAction_GetType(action: FPDF_ACTION) -> ::std::os::raw::c_ulong; +} +unsafe extern "C" { + pub fn FPDFAction_GetDest(document: FPDF_DOCUMENT, action: FPDF_ACTION) -> FPDF_DEST; +} +unsafe extern "C" { + pub fn FPDFAction_GetFilePath( + action: FPDF_ACTION, + buffer: *mut ::std::os::raw::c_void, + buflen: ::std::os::raw::c_ulong, + ) -> ::std::os::raw::c_ulong; +} +unsafe extern "C" { + pub fn FPDFAction_GetURIPath( + document: FPDF_DOCUMENT, + action: FPDF_ACTION, + buffer: *mut ::std::os::raw::c_void, + buflen: ::std::os::raw::c_ulong, + ) -> ::std::os::raw::c_ulong; +} +unsafe extern "C" { + pub fn FPDFDest_GetDestPageIndex( + document: FPDF_DOCUMENT, + dest: FPDF_DEST, + ) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFDest_GetView( + dest: FPDF_DEST, + pNumParams: *mut ::std::os::raw::c_ulong, + pParams: *mut FS_FLOAT, + ) -> ::std::os::raw::c_ulong; +} +unsafe extern "C" { + pub fn FPDFDest_GetLocationInPage( + dest: FPDF_DEST, + hasXVal: *mut FPDF_BOOL, + hasYVal: *mut FPDF_BOOL, + hasZoomVal: *mut FPDF_BOOL, + x: *mut FS_FLOAT, + y: *mut FS_FLOAT, + zoom: *mut FS_FLOAT, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFLink_GetLinkAtPoint(page: FPDF_PAGE, x: f64, y: f64) -> FPDF_LINK; +} +unsafe extern "C" { + pub fn FPDFLink_GetLinkZOrderAtPoint(page: FPDF_PAGE, x: f64, y: f64) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFLink_GetDest(document: FPDF_DOCUMENT, link: FPDF_LINK) -> FPDF_DEST; +} +unsafe extern "C" { + pub fn FPDFLink_GetAction(link: FPDF_LINK) -> FPDF_ACTION; +} +unsafe extern "C" { + pub fn FPDFLink_Enumerate( + page: FPDF_PAGE, + start_pos: *mut ::std::os::raw::c_int, + link_annot: *mut FPDF_LINK, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFLink_GetAnnot(page: FPDF_PAGE, link_annot: FPDF_LINK) -> FPDF_ANNOTATION; +} +unsafe extern "C" { + pub fn FPDFLink_GetAnnotRect(link_annot: FPDF_LINK, rect: *mut FS_RECTF) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFLink_CountQuadPoints(link_annot: FPDF_LINK) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFLink_GetQuadPoints( + link_annot: FPDF_LINK, + quad_index: ::std::os::raw::c_int, + quad_points: *mut FS_QUADPOINTSF, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDF_GetPageAAction(page: FPDF_PAGE, aa_type: ::std::os::raw::c_int) -> FPDF_ACTION; +} +unsafe extern "C" { + pub fn FPDF_GetFileIdentifier( + document: FPDF_DOCUMENT, + id_type: FPDF_FILEIDTYPE, + buffer: *mut ::std::os::raw::c_void, + buflen: ::std::os::raw::c_ulong, + ) -> ::std::os::raw::c_ulong; +} +unsafe extern "C" { + pub fn FPDF_GetMetaText( + document: FPDF_DOCUMENT, + tag: FPDF_BYTESTRING, + buffer: *mut ::std::os::raw::c_void, + buflen: ::std::os::raw::c_ulong, + ) -> ::std::os::raw::c_ulong; +} +unsafe extern "C" { + pub fn FPDF_GetPageLabel( + document: FPDF_DOCUMENT, + page_index: ::std::os::raw::c_int, + buffer: *mut ::std::os::raw::c_void, + buflen: ::std::os::raw::c_ulong, + ) -> ::std::os::raw::c_ulong; +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct _IPDF_JsPlatform { + pub version: ::std::os::raw::c_int, + pub app_alert: ::std::option::Option< + unsafe extern "C" fn( + pThis: *mut _IPDF_JsPlatform, + Msg: FPDF_WIDESTRING, + Title: FPDF_WIDESTRING, + Type: ::std::os::raw::c_int, + Icon: ::std::os::raw::c_int, + ) -> ::std::os::raw::c_int, + >, + pub app_beep: ::std::option::Option< + unsafe extern "C" fn(pThis: *mut _IPDF_JsPlatform, nType: ::std::os::raw::c_int), + >, + pub app_response: ::std::option::Option< + unsafe extern "C" fn( + pThis: *mut _IPDF_JsPlatform, + Question: FPDF_WIDESTRING, + Title: FPDF_WIDESTRING, + Default: FPDF_WIDESTRING, + cLabel: FPDF_WIDESTRING, + bPassword: FPDF_BOOL, + response: *mut ::std::os::raw::c_void, + length: ::std::os::raw::c_int, + ) -> ::std::os::raw::c_int, + >, + pub Doc_getFilePath: ::std::option::Option< + unsafe extern "C" fn( + pThis: *mut _IPDF_JsPlatform, + filePath: *mut ::std::os::raw::c_void, + length: ::std::os::raw::c_int, + ) -> ::std::os::raw::c_int, + >, + pub Doc_mail: ::std::option::Option< + unsafe extern "C" fn( + pThis: *mut _IPDF_JsPlatform, + mailData: *mut ::std::os::raw::c_void, + length: ::std::os::raw::c_int, + bUI: FPDF_BOOL, + To: FPDF_WIDESTRING, + Subject: FPDF_WIDESTRING, + CC: FPDF_WIDESTRING, + BCC: FPDF_WIDESTRING, + Msg: FPDF_WIDESTRING, + ), + >, + pub Doc_print: ::std::option::Option< + unsafe extern "C" fn( + pThis: *mut _IPDF_JsPlatform, + bUI: FPDF_BOOL, + nStart: ::std::os::raw::c_int, + nEnd: ::std::os::raw::c_int, + bSilent: FPDF_BOOL, + bShrinkToFit: FPDF_BOOL, + bPrintAsImage: FPDF_BOOL, + bReverse: FPDF_BOOL, + bAnnotations: FPDF_BOOL, + ), + >, + pub Doc_submitForm: ::std::option::Option< + unsafe extern "C" fn( + pThis: *mut _IPDF_JsPlatform, + formData: *mut ::std::os::raw::c_void, + length: ::std::os::raw::c_int, + URL: FPDF_WIDESTRING, + ), + >, + pub Doc_gotoPage: ::std::option::Option< + unsafe extern "C" fn(pThis: *mut _IPDF_JsPlatform, nPageNum: ::std::os::raw::c_int), + >, + pub Field_browse: ::std::option::Option< + unsafe extern "C" fn( + pThis: *mut _IPDF_JsPlatform, + filePath: *mut ::std::os::raw::c_void, + length: ::std::os::raw::c_int, + ) -> ::std::os::raw::c_int, + >, + pub m_pFormfillinfo: *mut ::std::os::raw::c_void, + pub m_isolate: *mut ::std::os::raw::c_void, + pub m_v8EmbedderSlot: ::std::os::raw::c_uint, +} +impl Default for _IPDF_JsPlatform { + fn default() -> Self { + let mut s = ::std::mem::MaybeUninit::::uninit(); + unsafe { + ::std::ptr::write_bytes(s.as_mut_ptr(), 0, 1); + s.assume_init() + } + } +} +pub type IPDF_JSPLATFORM = _IPDF_JsPlatform; +pub type TimerCallback = + ::std::option::Option; +#[repr(C)] +#[derive(Debug, Default, Copy, Clone)] +pub struct _FPDF_SYSTEMTIME { + pub wYear: ::std::os::raw::c_ushort, + pub wMonth: ::std::os::raw::c_ushort, + pub wDayOfWeek: ::std::os::raw::c_ushort, + pub wDay: ::std::os::raw::c_ushort, + pub wHour: ::std::os::raw::c_ushort, + pub wMinute: ::std::os::raw::c_ushort, + pub wSecond: ::std::os::raw::c_ushort, + pub wMilliseconds: ::std::os::raw::c_ushort, +} +pub type FPDF_SYSTEMTIME = _FPDF_SYSTEMTIME; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct _FPDF_FORMFILLINFO { + pub version: ::std::os::raw::c_int, + pub Release: ::std::option::Option, + pub FFI_Invalidate: ::std::option::Option< + unsafe extern "C" fn( + pThis: *mut _FPDF_FORMFILLINFO, + page: FPDF_PAGE, + left: f64, + top: f64, + right: f64, + bottom: f64, + ), + >, + pub FFI_OutputSelectedRect: ::std::option::Option< + unsafe extern "C" fn( + pThis: *mut _FPDF_FORMFILLINFO, + page: FPDF_PAGE, + left: f64, + top: f64, + right: f64, + bottom: f64, + ), + >, + pub FFI_SetCursor: ::std::option::Option< + unsafe extern "C" fn(pThis: *mut _FPDF_FORMFILLINFO, nCursorType: ::std::os::raw::c_int), + >, + pub FFI_SetTimer: ::std::option::Option< + unsafe extern "C" fn( + pThis: *mut _FPDF_FORMFILLINFO, + uElapse: ::std::os::raw::c_int, + lpTimerFunc: TimerCallback, + ) -> ::std::os::raw::c_int, + >, + pub FFI_KillTimer: ::std::option::Option< + unsafe extern "C" fn(pThis: *mut _FPDF_FORMFILLINFO, nTimerID: ::std::os::raw::c_int), + >, + pub FFI_GetLocalTime: ::std::option::Option< + unsafe extern "C" fn(pThis: *mut _FPDF_FORMFILLINFO) -> FPDF_SYSTEMTIME, + >, + pub FFI_OnChange: ::std::option::Option, + pub FFI_GetPage: ::std::option::Option< + unsafe extern "C" fn( + pThis: *mut _FPDF_FORMFILLINFO, + document: FPDF_DOCUMENT, + nPageIndex: ::std::os::raw::c_int, + ) -> FPDF_PAGE, + >, + pub FFI_GetCurrentPage: ::std::option::Option< + unsafe extern "C" fn(pThis: *mut _FPDF_FORMFILLINFO, document: FPDF_DOCUMENT) -> FPDF_PAGE, + >, + pub FFI_GetRotation: ::std::option::Option< + unsafe extern "C" fn( + pThis: *mut _FPDF_FORMFILLINFO, + page: FPDF_PAGE, + ) -> ::std::os::raw::c_int, + >, + pub FFI_ExecuteNamedAction: ::std::option::Option< + unsafe extern "C" fn(pThis: *mut _FPDF_FORMFILLINFO, namedAction: FPDF_BYTESTRING), + >, + pub FFI_SetTextFieldFocus: ::std::option::Option< + unsafe extern "C" fn( + pThis: *mut _FPDF_FORMFILLINFO, + value: FPDF_WIDESTRING, + valueLen: FPDF_DWORD, + is_focus: FPDF_BOOL, + ), + >, + pub FFI_DoURIAction: ::std::option::Option< + unsafe extern "C" fn(pThis: *mut _FPDF_FORMFILLINFO, bsURI: FPDF_BYTESTRING), + >, + pub FFI_DoGoToAction: ::std::option::Option< + unsafe extern "C" fn( + pThis: *mut _FPDF_FORMFILLINFO, + nPageIndex: ::std::os::raw::c_int, + zoomMode: ::std::os::raw::c_int, + fPosArray: *mut f32, + sizeofArray: ::std::os::raw::c_int, + ), + >, + pub m_pJsPlatform: *mut IPDF_JSPLATFORM, + pub xfa_disabled: FPDF_BOOL, + pub FFI_DisplayCaret: ::std::option::Option< + unsafe extern "C" fn( + pThis: *mut _FPDF_FORMFILLINFO, + page: FPDF_PAGE, + bVisible: FPDF_BOOL, + left: f64, + top: f64, + right: f64, + bottom: f64, + ), + >, + pub FFI_GetCurrentPageIndex: ::std::option::Option< + unsafe extern "C" fn( + pThis: *mut _FPDF_FORMFILLINFO, + document: FPDF_DOCUMENT, + ) -> ::std::os::raw::c_int, + >, + pub FFI_SetCurrentPage: ::std::option::Option< + unsafe extern "C" fn( + pThis: *mut _FPDF_FORMFILLINFO, + document: FPDF_DOCUMENT, + iCurPage: ::std::os::raw::c_int, + ), + >, + pub FFI_GotoURL: ::std::option::Option< + unsafe extern "C" fn( + pThis: *mut _FPDF_FORMFILLINFO, + document: FPDF_DOCUMENT, + wsURL: FPDF_WIDESTRING, + ), + >, + pub FFI_GetPageViewRect: ::std::option::Option< + unsafe extern "C" fn( + pThis: *mut _FPDF_FORMFILLINFO, + page: FPDF_PAGE, + left: *mut f64, + top: *mut f64, + right: *mut f64, + bottom: *mut f64, + ), + >, + pub FFI_PageEvent: ::std::option::Option< + unsafe extern "C" fn( + pThis: *mut _FPDF_FORMFILLINFO, + page_count: ::std::os::raw::c_int, + event_type: FPDF_DWORD, + ), + >, + pub FFI_PopupMenu: ::std::option::Option< + unsafe extern "C" fn( + pThis: *mut _FPDF_FORMFILLINFO, + page: FPDF_PAGE, + hWidget: FPDF_WIDGET, + menuFlag: ::std::os::raw::c_int, + x: f32, + y: f32, + ) -> FPDF_BOOL, + >, + pub FFI_OpenFile: ::std::option::Option< + unsafe extern "C" fn( + pThis: *mut _FPDF_FORMFILLINFO, + fileFlag: ::std::os::raw::c_int, + wsURL: FPDF_WIDESTRING, + mode: *const ::std::os::raw::c_char, + ) -> *mut FPDF_FILEHANDLER, + >, + pub FFI_EmailTo: ::std::option::Option< + unsafe extern "C" fn( + pThis: *mut _FPDF_FORMFILLINFO, + fileHandler: *mut FPDF_FILEHANDLER, + pTo: FPDF_WIDESTRING, + pSubject: FPDF_WIDESTRING, + pCC: FPDF_WIDESTRING, + pBcc: FPDF_WIDESTRING, + pMsg: FPDF_WIDESTRING, + ), + >, + pub FFI_UploadTo: ::std::option::Option< + unsafe extern "C" fn( + pThis: *mut _FPDF_FORMFILLINFO, + fileHandler: *mut FPDF_FILEHANDLER, + fileFlag: ::std::os::raw::c_int, + uploadTo: FPDF_WIDESTRING, + ), + >, + pub FFI_GetPlatform: ::std::option::Option< + unsafe extern "C" fn( + pThis: *mut _FPDF_FORMFILLINFO, + platform: *mut ::std::os::raw::c_void, + length: ::std::os::raw::c_int, + ) -> ::std::os::raw::c_int, + >, + pub FFI_GetLanguage: ::std::option::Option< + unsafe extern "C" fn( + pThis: *mut _FPDF_FORMFILLINFO, + language: *mut ::std::os::raw::c_void, + length: ::std::os::raw::c_int, + ) -> ::std::os::raw::c_int, + >, + pub FFI_DownloadFromURL: ::std::option::Option< + unsafe extern "C" fn( + pThis: *mut _FPDF_FORMFILLINFO, + URL: FPDF_WIDESTRING, + ) -> *mut FPDF_FILEHANDLER, + >, + pub FFI_PostRequestURL: ::std::option::Option< + unsafe extern "C" fn( + pThis: *mut _FPDF_FORMFILLINFO, + wsURL: FPDF_WIDESTRING, + wsData: FPDF_WIDESTRING, + wsContentType: FPDF_WIDESTRING, + wsEncode: FPDF_WIDESTRING, + wsHeader: FPDF_WIDESTRING, + response: *mut FPDF_BSTR, + ) -> FPDF_BOOL, + >, + pub FFI_PutRequestURL: ::std::option::Option< + unsafe extern "C" fn( + pThis: *mut _FPDF_FORMFILLINFO, + wsURL: FPDF_WIDESTRING, + wsData: FPDF_WIDESTRING, + wsEncode: FPDF_WIDESTRING, + ) -> FPDF_BOOL, + >, + pub FFI_OnFocusChange: ::std::option::Option< + unsafe extern "C" fn( + param: *mut _FPDF_FORMFILLINFO, + annot: FPDF_ANNOTATION, + page_index: ::std::os::raw::c_int, + ), + >, + pub FFI_DoURIActionWithKeyboardModifier: ::std::option::Option< + unsafe extern "C" fn( + param: *mut _FPDF_FORMFILLINFO, + uri: FPDF_BYTESTRING, + modifiers: ::std::os::raw::c_int, + ), + >, +} +impl Default for _FPDF_FORMFILLINFO { + fn default() -> Self { + let mut s = ::std::mem::MaybeUninit::::uninit(); + unsafe { + ::std::ptr::write_bytes(s.as_mut_ptr(), 0, 1); + s.assume_init() + } + } +} +pub type FPDF_FORMFILLINFO = _FPDF_FORMFILLINFO; +unsafe extern "C" { + pub fn FPDFDOC_InitFormFillEnvironment( + document: FPDF_DOCUMENT, + formInfo: *mut FPDF_FORMFILLINFO, + ) -> FPDF_FORMHANDLE; +} +unsafe extern "C" { + pub fn FPDFDOC_ExitFormFillEnvironment(hHandle: FPDF_FORMHANDLE); +} +unsafe extern "C" { + pub fn FPDFPage_HasFormFieldAtPoint( + hHandle: FPDF_FORMHANDLE, + page: FPDF_PAGE, + page_x: f64, + page_y: f64, + ) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFPage_FormFieldZOrderAtPoint( + hHandle: FPDF_FORMHANDLE, + page: FPDF_PAGE, + page_x: f64, + page_y: f64, + ) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDF_SetFormFieldHighlightColor( + hHandle: FPDF_FORMHANDLE, + fieldType: ::std::os::raw::c_int, + color: ::std::os::raw::c_ulong, + ); +} +unsafe extern "C" { + pub fn FPDF_SetFormFieldHighlightAlpha( + hHandle: FPDF_FORMHANDLE, + alpha: ::std::os::raw::c_uchar, + ); +} +unsafe extern "C" { + pub fn FPDF_RemoveFormFieldHighlight(hHandle: FPDF_FORMHANDLE); +} +unsafe extern "C" { + pub fn FPDF_FFLDraw( + hHandle: FPDF_FORMHANDLE, + bitmap: FPDF_BITMAP, + page: FPDF_PAGE, + start_x: ::std::os::raw::c_int, + start_y: ::std::os::raw::c_int, + size_x: ::std::os::raw::c_int, + size_y: ::std::os::raw::c_int, + rotate: ::std::os::raw::c_int, + flags: ::std::os::raw::c_int, + ); +} +unsafe extern "C" { + pub fn FPDF_GetFormType(document: FPDF_DOCUMENT) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDF_LoadXFA(document: FPDF_DOCUMENT) -> FPDF_BOOL; +} +pub const FPDFANNOT_COLORTYPE_FPDFANNOT_COLORTYPE_Color: FPDFANNOT_COLORTYPE = 0; +pub const FPDFANNOT_COLORTYPE_FPDFANNOT_COLORTYPE_InteriorColor: FPDFANNOT_COLORTYPE = 1; +pub type FPDFANNOT_COLORTYPE = ::std::os::raw::c_uint; +unsafe extern "C" { + pub fn FPDFAnnot_IsSupportedSubtype(subtype: FPDF_ANNOTATION_SUBTYPE) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPage_CreateAnnot( + page: FPDF_PAGE, + subtype: FPDF_ANNOTATION_SUBTYPE, + ) -> FPDF_ANNOTATION; +} +unsafe extern "C" { + pub fn FPDFPage_GetAnnotCount(page: FPDF_PAGE) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFPage_GetAnnot(page: FPDF_PAGE, index: ::std::os::raw::c_int) -> FPDF_ANNOTATION; +} +unsafe extern "C" { + pub fn FPDFPage_GetAnnotIndex(page: FPDF_PAGE, annot: FPDF_ANNOTATION) + -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFPage_CloseAnnot(annot: FPDF_ANNOTATION); +} +unsafe extern "C" { + pub fn FPDFPage_RemoveAnnot(page: FPDF_PAGE, index: ::std::os::raw::c_int) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFAnnot_GetSubtype(annot: FPDF_ANNOTATION) -> FPDF_ANNOTATION_SUBTYPE; +} +unsafe extern "C" { + pub fn FPDFAnnot_IsObjectSupportedSubtype(subtype: FPDF_ANNOTATION_SUBTYPE) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFAnnot_UpdateObject(annot: FPDF_ANNOTATION, obj: FPDF_PAGEOBJECT) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFAnnot_AddInkStroke( + annot: FPDF_ANNOTATION, + points: *const FS_POINTF, + point_count: usize, + ) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFAnnot_RemoveInkList(annot: FPDF_ANNOTATION) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFAnnot_AppendObject(annot: FPDF_ANNOTATION, obj: FPDF_PAGEOBJECT) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFAnnot_GetObjectCount(annot: FPDF_ANNOTATION) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFAnnot_GetObject( + annot: FPDF_ANNOTATION, + index: ::std::os::raw::c_int, + ) -> FPDF_PAGEOBJECT; +} +unsafe extern "C" { + pub fn FPDFAnnot_RemoveObject( + annot: FPDF_ANNOTATION, + index: ::std::os::raw::c_int, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFAnnot_SetColor( + annot: FPDF_ANNOTATION, + type_: FPDFANNOT_COLORTYPE, + R: ::std::os::raw::c_uint, + G: ::std::os::raw::c_uint, + B: ::std::os::raw::c_uint, + A: ::std::os::raw::c_uint, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFAnnot_GetColor( + annot: FPDF_ANNOTATION, + type_: FPDFANNOT_COLORTYPE, + R: *mut ::std::os::raw::c_uint, + G: *mut ::std::os::raw::c_uint, + B: *mut ::std::os::raw::c_uint, + A: *mut ::std::os::raw::c_uint, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFAnnot_HasAttachmentPoints(annot: FPDF_ANNOTATION) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFAnnot_SetAttachmentPoints( + annot: FPDF_ANNOTATION, + quad_index: usize, + quad_points: *const FS_QUADPOINTSF, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFAnnot_AppendAttachmentPoints( + annot: FPDF_ANNOTATION, + quad_points: *const FS_QUADPOINTSF, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFAnnot_CountAttachmentPoints(annot: FPDF_ANNOTATION) -> usize; +} +unsafe extern "C" { + pub fn FPDFAnnot_GetAttachmentPoints( + annot: FPDF_ANNOTATION, + quad_index: usize, + quad_points: *mut FS_QUADPOINTSF, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFAnnot_SetRect(annot: FPDF_ANNOTATION, rect: *const FS_RECTF) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFAnnot_GetRect(annot: FPDF_ANNOTATION, rect: *mut FS_RECTF) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFAnnot_GetVertices( + annot: FPDF_ANNOTATION, + buffer: *mut FS_POINTF, + length: ::std::os::raw::c_ulong, + ) -> ::std::os::raw::c_ulong; +} +unsafe extern "C" { + pub fn FPDFAnnot_GetInkListCount(annot: FPDF_ANNOTATION) -> ::std::os::raw::c_ulong; +} +unsafe extern "C" { + pub fn FPDFAnnot_GetInkListPath( + annot: FPDF_ANNOTATION, + path_index: ::std::os::raw::c_ulong, + buffer: *mut FS_POINTF, + length: ::std::os::raw::c_ulong, + ) -> ::std::os::raw::c_ulong; +} +unsafe extern "C" { + pub fn FPDFAnnot_GetLine( + annot: FPDF_ANNOTATION, + start: *mut FS_POINTF, + end: *mut FS_POINTF, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFAnnot_SetBorder( + annot: FPDF_ANNOTATION, + horizontal_radius: f32, + vertical_radius: f32, + border_width: f32, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFAnnot_GetBorder( + annot: FPDF_ANNOTATION, + horizontal_radius: *mut f32, + vertical_radius: *mut f32, + border_width: *mut f32, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFAnnot_GetFormAdditionalActionJavaScript( + hHandle: FPDF_FORMHANDLE, + annot: FPDF_ANNOTATION, + event: ::std::os::raw::c_int, + buffer: *mut FPDF_WCHAR, + buflen: ::std::os::raw::c_ulong, + ) -> ::std::os::raw::c_ulong; +} +unsafe extern "C" { + pub fn FPDFAnnot_HasKey(annot: FPDF_ANNOTATION, key: FPDF_BYTESTRING) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFAnnot_GetValueType(annot: FPDF_ANNOTATION, key: FPDF_BYTESTRING) + -> FPDF_OBJECT_TYPE; +} +unsafe extern "C" { + pub fn FPDFAnnot_SetStringValue( + annot: FPDF_ANNOTATION, + key: FPDF_BYTESTRING, + value: FPDF_WIDESTRING, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFAnnot_GetStringValue( + annot: FPDF_ANNOTATION, + key: FPDF_BYTESTRING, + buffer: *mut FPDF_WCHAR, + buflen: ::std::os::raw::c_ulong, + ) -> ::std::os::raw::c_ulong; +} +unsafe extern "C" { + pub fn FPDFAnnot_GetNumberValue( + annot: FPDF_ANNOTATION, + key: FPDF_BYTESTRING, + value: *mut f32, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFAnnot_SetAP( + annot: FPDF_ANNOTATION, + appearanceMode: FPDF_ANNOT_APPEARANCEMODE, + value: FPDF_WIDESTRING, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFAnnot_GetAP( + annot: FPDF_ANNOTATION, + appearanceMode: FPDF_ANNOT_APPEARANCEMODE, + buffer: *mut FPDF_WCHAR, + buflen: ::std::os::raw::c_ulong, + ) -> ::std::os::raw::c_ulong; +} +unsafe extern "C" { + pub fn FPDFAnnot_GetLinkedAnnot( + annot: FPDF_ANNOTATION, + key: FPDF_BYTESTRING, + ) -> FPDF_ANNOTATION; +} +unsafe extern "C" { + pub fn FPDFAnnot_GetFlags(annot: FPDF_ANNOTATION) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFAnnot_SetFlags(annot: FPDF_ANNOTATION, flags: ::std::os::raw::c_int) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFAnnot_GetFormFieldFlags( + handle: FPDF_FORMHANDLE, + annot: FPDF_ANNOTATION, + ) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFAnnot_SetFormFieldFlags( + handle: FPDF_FORMHANDLE, + annot: FPDF_ANNOTATION, + flags: ::std::os::raw::c_int, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFAnnot_GetFormFieldAtPoint( + hHandle: FPDF_FORMHANDLE, + page: FPDF_PAGE, + point: *const FS_POINTF, + ) -> FPDF_ANNOTATION; +} +unsafe extern "C" { + pub fn FPDFAnnot_GetFormFieldName( + hHandle: FPDF_FORMHANDLE, + annot: FPDF_ANNOTATION, + buffer: *mut FPDF_WCHAR, + buflen: ::std::os::raw::c_ulong, + ) -> ::std::os::raw::c_ulong; +} +unsafe extern "C" { + pub fn FPDFAnnot_GetFormFieldAlternateName( + hHandle: FPDF_FORMHANDLE, + annot: FPDF_ANNOTATION, + buffer: *mut FPDF_WCHAR, + buflen: ::std::os::raw::c_ulong, + ) -> ::std::os::raw::c_ulong; +} +unsafe extern "C" { + pub fn FPDFAnnot_GetFormFieldType( + hHandle: FPDF_FORMHANDLE, + annot: FPDF_ANNOTATION, + ) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFAnnot_GetFormFieldValue( + hHandle: FPDF_FORMHANDLE, + annot: FPDF_ANNOTATION, + buffer: *mut FPDF_WCHAR, + buflen: ::std::os::raw::c_ulong, + ) -> ::std::os::raw::c_ulong; +} +unsafe extern "C" { + pub fn FPDFAnnot_GetOptionCount( + hHandle: FPDF_FORMHANDLE, + annot: FPDF_ANNOTATION, + ) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFAnnot_GetOptionLabel( + hHandle: FPDF_FORMHANDLE, + annot: FPDF_ANNOTATION, + index: ::std::os::raw::c_int, + buffer: *mut FPDF_WCHAR, + buflen: ::std::os::raw::c_ulong, + ) -> ::std::os::raw::c_ulong; +} +unsafe extern "C" { + pub fn FPDFAnnot_IsOptionSelected( + handle: FPDF_FORMHANDLE, + annot: FPDF_ANNOTATION, + index: ::std::os::raw::c_int, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFAnnot_GetFontSize( + hHandle: FPDF_FORMHANDLE, + annot: FPDF_ANNOTATION, + value: *mut f32, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFAnnot_SetFontColor( + handle: FPDF_FORMHANDLE, + annot: FPDF_ANNOTATION, + R: ::std::os::raw::c_uint, + G: ::std::os::raw::c_uint, + B: ::std::os::raw::c_uint, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFAnnot_GetFontColor( + hHandle: FPDF_FORMHANDLE, + annot: FPDF_ANNOTATION, + R: *mut ::std::os::raw::c_uint, + G: *mut ::std::os::raw::c_uint, + B: *mut ::std::os::raw::c_uint, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFAnnot_IsChecked(hHandle: FPDF_FORMHANDLE, annot: FPDF_ANNOTATION) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFAnnot_SetFocusableSubtypes( + hHandle: FPDF_FORMHANDLE, + subtypes: *const FPDF_ANNOTATION_SUBTYPE, + count: usize, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFAnnot_GetFocusableSubtypesCount(hHandle: FPDF_FORMHANDLE) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFAnnot_GetFocusableSubtypes( + hHandle: FPDF_FORMHANDLE, + subtypes: *mut FPDF_ANNOTATION_SUBTYPE, + count: usize, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFAnnot_GetLink(annot: FPDF_ANNOTATION) -> FPDF_LINK; +} +unsafe extern "C" { + pub fn FPDFAnnot_GetFormControlCount( + hHandle: FPDF_FORMHANDLE, + annot: FPDF_ANNOTATION, + ) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFAnnot_GetFormControlIndex( + hHandle: FPDF_FORMHANDLE, + annot: FPDF_ANNOTATION, + ) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFAnnot_GetFormFieldExportValue( + hHandle: FPDF_FORMHANDLE, + annot: FPDF_ANNOTATION, + buffer: *mut FPDF_WCHAR, + buflen: ::std::os::raw::c_ulong, + ) -> ::std::os::raw::c_ulong; +} +unsafe extern "C" { + pub fn FPDFAnnot_SetURI( + annot: FPDF_ANNOTATION, + uri: *const ::std::os::raw::c_char, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFAnnot_GetFileAttachment(annot: FPDF_ANNOTATION) -> FPDF_ATTACHMENT; +} +unsafe extern "C" { + pub fn FPDFAnnot_AddFileAttachment( + annot: FPDF_ANNOTATION, + name: FPDF_WIDESTRING, + ) -> FPDF_ATTACHMENT; +} +unsafe extern "C" { + pub fn FPDFAnnot_GetObjNum(annot: FPDF_ANNOTATION) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDF_StructTree_GetForPage(page: FPDF_PAGE) -> FPDF_STRUCTTREE; +} +unsafe extern "C" { + pub fn FPDF_StructTree_Close(struct_tree: FPDF_STRUCTTREE); +} +unsafe extern "C" { + pub fn FPDF_StructTree_CountChildren(struct_tree: FPDF_STRUCTTREE) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDF_StructTree_GetChildAtIndex( + struct_tree: FPDF_STRUCTTREE, + index: ::std::os::raw::c_int, + ) -> FPDF_STRUCTELEMENT; +} +unsafe extern "C" { + pub fn FPDF_StructElement_GetAltText( + struct_element: FPDF_STRUCTELEMENT, + buffer: *mut ::std::os::raw::c_void, + buflen: ::std::os::raw::c_ulong, + ) -> ::std::os::raw::c_ulong; +} +unsafe extern "C" { + pub fn FPDF_StructElement_GetActualText( + struct_element: FPDF_STRUCTELEMENT, + buffer: *mut ::std::os::raw::c_void, + buflen: ::std::os::raw::c_ulong, + ) -> ::std::os::raw::c_ulong; +} +unsafe extern "C" { + pub fn FPDF_StructElement_GetExpansion( + struct_element: FPDF_STRUCTELEMENT, + buffer: *mut ::std::os::raw::c_void, + buflen: ::std::os::raw::c_ulong, + ) -> ::std::os::raw::c_ulong; +} +unsafe extern "C" { + pub fn FPDF_StructElement_GetID( + struct_element: FPDF_STRUCTELEMENT, + buffer: *mut ::std::os::raw::c_void, + buflen: ::std::os::raw::c_ulong, + ) -> ::std::os::raw::c_ulong; +} +unsafe extern "C" { + pub fn FPDF_StructElement_GetLang( + struct_element: FPDF_STRUCTELEMENT, + buffer: *mut ::std::os::raw::c_void, + buflen: ::std::os::raw::c_ulong, + ) -> ::std::os::raw::c_ulong; +} +unsafe extern "C" { + pub fn FPDF_StructElement_GetStringAttribute( + struct_element: FPDF_STRUCTELEMENT, + attr_name: FPDF_BYTESTRING, + buffer: *mut ::std::os::raw::c_void, + buflen: ::std::os::raw::c_ulong, + ) -> ::std::os::raw::c_ulong; +} +unsafe extern "C" { + pub fn FPDF_StructElement_GetMarkedContentID( + struct_element: FPDF_STRUCTELEMENT, + ) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDF_StructElement_GetType( + struct_element: FPDF_STRUCTELEMENT, + buffer: *mut ::std::os::raw::c_void, + buflen: ::std::os::raw::c_ulong, + ) -> ::std::os::raw::c_ulong; +} +unsafe extern "C" { + pub fn FPDF_StructElement_GetObjType( + struct_element: FPDF_STRUCTELEMENT, + buffer: *mut ::std::os::raw::c_void, + buflen: ::std::os::raw::c_ulong, + ) -> ::std::os::raw::c_ulong; +} +unsafe extern "C" { + pub fn FPDF_StructElement_GetTitle( + struct_element: FPDF_STRUCTELEMENT, + buffer: *mut ::std::os::raw::c_void, + buflen: ::std::os::raw::c_ulong, + ) -> ::std::os::raw::c_ulong; +} +unsafe extern "C" { + pub fn FPDF_StructElement_CountChildren( + struct_element: FPDF_STRUCTELEMENT, + ) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDF_StructElement_GetChildAtIndex( + struct_element: FPDF_STRUCTELEMENT, + index: ::std::os::raw::c_int, + ) -> FPDF_STRUCTELEMENT; +} +unsafe extern "C" { + pub fn FPDF_StructElement_GetChildMarkedContentID( + struct_element: FPDF_STRUCTELEMENT, + index: ::std::os::raw::c_int, + ) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDF_StructElement_GetChildObjNum( + struct_element: FPDF_STRUCTELEMENT, + index: ::std::os::raw::c_int, + ) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDF_StructElement_GetParent(struct_element: FPDF_STRUCTELEMENT) -> FPDF_STRUCTELEMENT; +} +unsafe extern "C" { + pub fn FPDF_StructElement_GetAttributeCount( + struct_element: FPDF_STRUCTELEMENT, + ) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDF_StructElement_GetAttributeAtIndex( + struct_element: FPDF_STRUCTELEMENT, + index: ::std::os::raw::c_int, + ) -> FPDF_STRUCTELEMENT_ATTR; +} +unsafe extern "C" { + pub fn FPDF_StructElement_Attr_GetCount( + struct_attribute: FPDF_STRUCTELEMENT_ATTR, + ) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDF_StructElement_Attr_GetName( + struct_attribute: FPDF_STRUCTELEMENT_ATTR, + index: ::std::os::raw::c_int, + buffer: *mut ::std::os::raw::c_void, + buflen: ::std::os::raw::c_ulong, + out_buflen: *mut ::std::os::raw::c_ulong, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDF_StructElement_Attr_GetValue( + struct_attribute: FPDF_STRUCTELEMENT_ATTR, + name: FPDF_BYTESTRING, + ) -> FPDF_STRUCTELEMENT_ATTR_VALUE; +} +unsafe extern "C" { + pub fn FPDF_StructElement_Attr_GetType( + value: FPDF_STRUCTELEMENT_ATTR_VALUE, + ) -> FPDF_OBJECT_TYPE; +} +unsafe extern "C" { + pub fn FPDF_StructElement_Attr_GetBooleanValue( + value: FPDF_STRUCTELEMENT_ATTR_VALUE, + out_value: *mut FPDF_BOOL, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDF_StructElement_Attr_GetNumberValue( + value: FPDF_STRUCTELEMENT_ATTR_VALUE, + out_value: *mut f32, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDF_StructElement_Attr_GetStringValue( + value: FPDF_STRUCTELEMENT_ATTR_VALUE, + buffer: *mut ::std::os::raw::c_void, + buflen: ::std::os::raw::c_ulong, + out_buflen: *mut ::std::os::raw::c_ulong, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDF_StructElement_Attr_GetBlobValue( + value: FPDF_STRUCTELEMENT_ATTR_VALUE, + buffer: *mut ::std::os::raw::c_void, + buflen: ::std::os::raw::c_ulong, + out_buflen: *mut ::std::os::raw::c_ulong, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDF_StructElement_Attr_CountChildren( + value: FPDF_STRUCTELEMENT_ATTR_VALUE, + ) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDF_StructElement_Attr_GetChildAtIndex( + value: FPDF_STRUCTELEMENT_ATTR_VALUE, + index: ::std::os::raw::c_int, + ) -> FPDF_STRUCTELEMENT_ATTR_VALUE; +} +unsafe extern "C" { + pub fn FPDF_StructElement_GetMarkedContentIdCount( + struct_element: FPDF_STRUCTELEMENT, + ) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDF_StructElement_GetMarkedContentIdAtIndex( + struct_element: FPDF_STRUCTELEMENT, + index: ::std::os::raw::c_int, + ) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFPage_SetMediaBox(page: FPDF_PAGE, left: f32, bottom: f32, right: f32, top: f32); +} +unsafe extern "C" { + pub fn FPDFPage_SetCropBox(page: FPDF_PAGE, left: f32, bottom: f32, right: f32, top: f32); +} +unsafe extern "C" { + pub fn FPDFPage_SetBleedBox(page: FPDF_PAGE, left: f32, bottom: f32, right: f32, top: f32); +} +unsafe extern "C" { + pub fn FPDFPage_SetTrimBox(page: FPDF_PAGE, left: f32, bottom: f32, right: f32, top: f32); +} +unsafe extern "C" { + pub fn FPDFPage_SetArtBox(page: FPDF_PAGE, left: f32, bottom: f32, right: f32, top: f32); +} +unsafe extern "C" { + pub fn FPDFPage_GetMediaBox( + page: FPDF_PAGE, + left: *mut f32, + bottom: *mut f32, + right: *mut f32, + top: *mut f32, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPage_GetCropBox( + page: FPDF_PAGE, + left: *mut f32, + bottom: *mut f32, + right: *mut f32, + top: *mut f32, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPage_GetBleedBox( + page: FPDF_PAGE, + left: *mut f32, + bottom: *mut f32, + right: *mut f32, + top: *mut f32, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPage_GetTrimBox( + page: FPDF_PAGE, + left: *mut f32, + bottom: *mut f32, + right: *mut f32, + top: *mut f32, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPage_GetArtBox( + page: FPDF_PAGE, + left: *mut f32, + bottom: *mut f32, + right: *mut f32, + top: *mut f32, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPage_TransFormWithClip( + page: FPDF_PAGE, + matrix: *const FS_MATRIX, + clipRect: *const FS_RECTF, + ) -> FPDF_BOOL; +} +unsafe extern "C" { + pub fn FPDFPageObj_TransformClipPath( + page_object: FPDF_PAGEOBJECT, + a: f64, + b: f64, + c: f64, + d: f64, + e: f64, + f: f64, + ); +} +unsafe extern "C" { + pub fn FPDFPageObj_GetClipPath(page_object: FPDF_PAGEOBJECT) -> FPDF_CLIPPATH; +} +unsafe extern "C" { + pub fn FPDFClipPath_CountPaths(clip_path: FPDF_CLIPPATH) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFClipPath_CountPathSegments( + clip_path: FPDF_CLIPPATH, + path_index: ::std::os::raw::c_int, + ) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn FPDFClipPath_GetPathSegment( + clip_path: FPDF_CLIPPATH, + path_index: ::std::os::raw::c_int, + segment_index: ::std::os::raw::c_int, + ) -> FPDF_PATHSEGMENT; +} +unsafe extern "C" { + pub fn FPDF_CreateClipPath(left: f32, bottom: f32, right: f32, top: f32) -> FPDF_CLIPPATH; +} +unsafe extern "C" { + pub fn FPDF_DestroyClipPath(clipPath: FPDF_CLIPPATH); +} +unsafe extern "C" { + pub fn FPDFPage_InsertClipPath(page: FPDF_PAGE, clipPath: FPDF_CLIPPATH); +} diff --git a/crates/pdfium-sys/build.rs b/crates/pdfium-sys/build.rs new file mode 100644 index 0000000..69f1ed1 --- /dev/null +++ b/crates/pdfium-sys/build.rs @@ -0,0 +1,302 @@ +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +const PDFIUM_RELEASE_TAG: &str = "chromium/7897"; +const PDFIUM_RELEASE_URL: &str = "https://github.com/run-llama/pdfium-binaries/releases/download"; + +fn main() { + let (lib_dir, include_dir) = resolve_pdfium_dirs(); + + let lib_dir = lib_dir.canonicalize().unwrap_or_else(|e| { + panic!( + "pdfium lib dir does not exist at {}: {e}", + lib_dir.display() + ) + }); + let include_dir = include_dir.canonicalize().unwrap_or_else(|e| { + panic!( + "pdfium include dir does not exist at {}: {e}", + include_dir.display() + ) + }); + + let target_arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default(); + + println!("cargo:rustc-link-search=native={}", lib_dir.display()); + + if target_arch == "wasm32" { + // For wasm targets, pdfium is shipped as a static archive (libpdfium.a) + // and linked statically into the final .wasm module. There is no + // dynamic loading and no need to copy any shared library. + // + // The WASI sysroot libraries (libc, libc++, libc++abi, etc.) must also + // be linked so that all C/C++ runtime symbols are resolved. Without + // them, those symbols appear as unresolved "env::" imports in the + // browser, making the .wasm unusable. + println!("cargo:rustc-link-lib=static=pdfium"); + println!("cargo:rustc-link-lib=static=c"); + println!("cargo:rustc-link-lib=static=c++"); + println!("cargo:rustc-link-lib=static=c++abi"); + println!("cargo:rustc-link-lib=static=wasi-emulated-mman"); + println!("cargo:rustc-link-lib=static=wasi-emulated-signal"); + // Newer pdfium-binaries releases ship the wasm setjmp/longjmp runtime + // in a standalone libsetjmp.a, which pdfium's bundled libjpeg/freetype + // reference for their __c_longjmp error handling. Older releases folded + // these into libc.a, so only link it when the archive is present. + // + // NOTE: libsetjmp.a's single object also redefines the __wasm_setjmp/ + // __wasm_longjmp helpers that Rust's own codegen emits. The + // --allow-multiple-definition link-arg needed to tolerate that lives in + // the final cdylib's build script (crates/liteparse-wasm/build.rs), + // because rustc-link-arg does not propagate to dependent crates. + if lib_dir.join("libsetjmp.a").exists() { + println!("cargo:rustc-link-lib=static=setjmp"); + } + println!("cargo:lib_path={}", lib_dir.display()); + } else { + // Non-wasm: pdfium is loaded at runtime via libloading (no link-time + // dynamic dependency). We bake the lib directory path into the binary + // so the runtime loader knows where to find the shared library. + println!("cargo:lib_path={}", lib_dir.display()); + println!("cargo:rustc-env=PDFIUM_LIB_DIR={}", lib_dir.display()); + + // Copy the dylib into target//deps/ so that CI scripts and + // packaging tools (copy-pdfium.sh, maturin wheel bundling) can find it + // in the build tree. This is NOT for linking — just discoverability. + let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + let dll_dir = if target_os == "windows" { + lib_dir + .parent() + .map(|p| p.join("bin")) + .unwrap_or(lib_dir.clone()) + } else { + lib_dir.clone() + }; + copy_dylib_to_target_deps(&dll_dir); + } + + run_bindgen(&include_dir); +} + +/// Determine where pdfium lib and include dirs are. +/// Priority: env vars > vendor/ dir > auto-download to cache. +fn resolve_pdfium_dirs() -> (PathBuf, PathBuf) { + // 1. Explicit env var override + if let (Ok(lib), Ok(inc)) = (env::var("PDFIUM_LIB_PATH"), env::var("PDFIUM_INCLUDE_PATH")) { + return (PathBuf::from(lib), PathBuf::from(inc)); + } + + // 2. Vendor directory relative to workspace root + let manifest = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); + let vendor_lib = manifest.join("../../vendor/pdfium/release/lib"); + let vendor_include = manifest.join("../../vendor/pdfium/release/include"); + if vendor_lib.exists() && vendor_include.exists() { + return (vendor_lib, vendor_include); + } + + // 3. Auto-download to cache + let cache_dir = pdfium_cache_dir(); + let lib_dir = cache_dir.join("lib"); + let include_dir = cache_dir.join("include"); + + if lib_dir.exists() && include_dir.exists() { + return (lib_dir, include_dir); + } + + eprintln!("pdfium-sys: downloading pdfium from GitHub releases..."); + download_pdfium(&cache_dir); + + (lib_dir, include_dir) +} + +/// ~/.cache/pdfium-rs/// +fn pdfium_cache_dir() -> PathBuf { + let base = dirs_cache().join("pdfium-rs"); + let tag_safe = PDFIUM_RELEASE_TAG.replace('/', "_"); + let asset = pdfium_asset_stem(); + base.join(tag_safe).join(asset) +} + +fn dirs_cache() -> PathBuf { + if let Ok(xdg) = env::var("XDG_CACHE_HOME") { + return PathBuf::from(xdg); + } + + let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + + // Windows -> USERPROFILE + if target_os == "windows" { + if let Ok(local_app_data) = env::var("LOCALAPPDATA") { + return PathBuf::from(local_app_data); + } + let home = env::var("USERPROFILE").expect("USERPROFILE env var not set"); + return PathBuf::from(home).join("AppData\\Local"); + } + + let home = env::var("HOME").expect("HOME env var not set"); + if target_os == "macos" { + PathBuf::from(&home).join("Library/Caches") + } else { + PathBuf::from(&home).join(".cache") + } +} + +/// Map target triple to the pdfium-binaries asset name (without .tgz). +fn pdfium_asset_stem() -> &'static str { + let target = env::var("TARGET").unwrap(); + match target.as_str() { + "aarch64-apple-darwin" => "pdfium-mac-arm64", + "x86_64-apple-darwin" => "pdfium-mac-x64", + // Universal macOS binary works for both, but we prefer arch-specific + "x86_64-unknown-linux-gnu" => "pdfium-linux-x64", + "x86_64-unknown-linux-musl" => "pdfium-linux-musl-x64", + "aarch64-unknown-linux-gnu" | "aarch64-unknown-linux-musl" => "pdfium-linux-arm64", + "armv7-unknown-linux-gnueabihf" => "pdfium-linux-arm", + "x86_64-pc-windows-msvc" | "x86_64-pc-windows-gnu" => "pdfium-win-x64", + "aarch64-pc-windows-msvc" => "pdfium-win-arm64", + "i686-pc-windows-msvc" | "i686-pc-windows-gnu" => "pdfium-win-x86", + "wasm32-unknown-unknown" | "wasm32-wasip1" => "pdfium-wasi-wasm", + other => panic!("unsupported target for pdfium auto-download: {other}"), + } +} + +fn download_pdfium(dest: &Path) { + let asset = format!("{}.tgz", pdfium_asset_stem()); + let tag_encoded = PDFIUM_RELEASE_TAG.replace('/', "%2F"); + let url = format!("{PDFIUM_RELEASE_URL}/{tag_encoded}/{asset}"); + + eprintln!("pdfium-sys: GET {url}"); + + let response = ureq::get(&url).call().unwrap_or_else(|e| { + panic!("failed to download pdfium from {url}: {e}"); + }); + + let reader = response.into_body().into_reader(); + let gz = flate2::read::GzDecoder::new(reader); + let mut archive = tar::Archive::new(gz); + + // Extract to a temp dir first, then rename atomically + let tmp = dest.with_extension("tmp"); + if tmp.exists() { + fs::remove_dir_all(&tmp).ok(); + } + fs::create_dir_all(&tmp).expect("failed to create temp dir"); + archive + .unpack(&tmp) + .expect("failed to extract pdfium archive"); + + // Fix dylib install name on macOS so @rpath resolution works + fix_dylib_install_name(&tmp); + + // Atomic rename into place + if dest.exists() { + fs::remove_dir_all(dest).ok(); + } + fs::rename(&tmp, dest).expect("failed to move pdfium to cache dir"); + + eprintln!("pdfium-sys: cached pdfium at {}", dest.display()); +} + +/// On macOS, pdfium-binaries ships dylibs with install name `./libpdfium.dylib`. +/// We need `@rpath/libpdfium.dylib` for rpath resolution to work. +fn fix_dylib_install_name(dir: &Path) { + let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + if target_os != "macos" { + return; + } + + let dylib = dir.join("lib/libpdfium.dylib"); + if !dylib.exists() { + return; + } + + let status = Command::new("install_name_tool") + .args(["-id", "@rpath/libpdfium.dylib"]) + .arg(&dylib) + .status(); + + match status { + Ok(s) if s.success() => {} + Ok(s) => eprintln!("pdfium-sys: install_name_tool exited with {s}"), + Err(e) => eprintln!("pdfium-sys: failed to run install_name_tool: {e}"), + } +} + +/// Copy the pdfium shared library into `target//deps/` so that +/// CI scripts and packaging tools can find it in the build tree. +/// This is NOT for linking — pdfium is loaded at runtime via libloading. +fn copy_dylib_to_target_deps(lib_dir: &Path) { + let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + + let dylib_name = match target_os.as_str() { + "macos" => "libpdfium.dylib", + "windows" => "pdfium.dll", + _ => "libpdfium.so", + }; + + let src = lib_dir.join(dylib_name); + if !src.exists() { + return; + } + + // OUT_DIR is typically target//build/-/out + // We want target//deps which is 3 levels up then into deps + let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); + if let Some(build_dir) = out_dir + .parent() + .and_then(|p| p.parent()) + .and_then(|p| p.parent()) + { + let deps_dir = build_dir.join("deps"); + if deps_dir.is_dir() { + let dst = deps_dir.join(dylib_name); + fs::copy(&src, &dst).unwrap_or_else(|e| { + panic!("failed to copy {} to {}: {e}", src.display(), dst.display()) + }); + } + } +} + +fn run_bindgen(include_dir: &Path) { + let out_path = PathBuf::from(env::var("OUT_DIR").unwrap()); + let out_file = out_path.join("bindings.rs"); + + #[cfg(feature = "bindgen")] + { + let bindings = bindgen::Builder::default() + .header("wrapper.h") + .clang_arg(format!("-I{}", include_dir.display())) + .allowlist_function("FPDF.*") + .allowlist_function("FPDFText_.*") + .allowlist_function("FPDFPage.*") + .allowlist_function("FPDFLink_.*") + .allowlist_function("FPDFFont_.*") + .allowlist_type("FPDF.*") + .allowlist_type("FS_.*") + .allowlist_var("FPDF.*") + .derive_debug(true) + .derive_default(true) + .layout_tests(false) + .parse_callbacks(Box::new(bindgen::CargoCallbacks::new())) + .generate() + .expect("Unable to generate bindings"); + + bindings + .write_to_file(&out_file) + .expect("Couldn't write bindings!"); + } + + #[cfg(not(feature = "bindgen"))] + { + let _ = include_dir; + let pregenerated = Path::new(env!("CARGO_MANIFEST_DIR")).join("bindings.rs"); + fs::copy(&pregenerated, &out_file).unwrap_or_else(|e| { + panic!( + "Failed to copy pre-generated bindings from {}: {e}", + pregenerated.display() + ) + }); + } +} diff --git a/crates/pdfium-sys/src/dynamic.rs b/crates/pdfium-sys/src/dynamic.rs new file mode 100644 index 0000000..5581f3a --- /dev/null +++ b/crates/pdfium-sys/src/dynamic.rs @@ -0,0 +1,659 @@ +//! Runtime loading of the pdfium shared library via `libloading`. +//! +//! On non-wasm targets, pdfium is loaded at runtime instead of being linked +//! at compile time. This avoids rpath issues when `liteparse` is used as a +//! library dependency in other Rust projects. + +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +use libloading::Library; + +use crate::*; + +static BINDINGS: OnceLock = OnceLock::new(); + +/// The compile-time lib directory baked in by pdfium-sys's build.rs. +const PDFIUM_LIB_DIR: &str = env!("PDFIUM_LIB_DIR"); + +macro_rules! load_fn { + ($lib:expr, $name:literal) => {{ + let sym = unsafe { $lib.get::<*const ()>($name.as_bytes())? }; + unsafe { std::mem::transmute(*sym) } + }}; +} + +/// Holds all pdfium function pointers loaded at runtime. +pub struct PdfiumBindings { + // Keep the library handle alive — dropping it would unload the symbols. + _lib: Library, + + // -- Library lifecycle -- + pub FPDF_InitLibrary: unsafe extern "C" fn(), + pub FPDF_GetLastError: unsafe extern "C" fn() -> std::os::raw::c_ulong, + + // -- Document -- + pub FPDF_LoadDocument: unsafe extern "C" fn(FPDF_STRING, FPDF_BYTESTRING) -> FPDF_DOCUMENT, + pub FPDF_LoadMemDocument: unsafe extern "C" fn( + *const std::os::raw::c_void, + std::os::raw::c_int, + FPDF_BYTESTRING, + ) -> FPDF_DOCUMENT, + pub FPDF_CloseDocument: unsafe extern "C" fn(FPDF_DOCUMENT), + pub FPDF_GetPageCount: unsafe extern "C" fn(FPDF_DOCUMENT) -> std::os::raw::c_int, + + // -- Page -- + pub FPDF_LoadPage: unsafe extern "C" fn(FPDF_DOCUMENT, std::os::raw::c_int) -> FPDF_PAGE, + pub FPDF_ClosePage: unsafe extern "C" fn(FPDF_PAGE), + pub FPDF_GetPageWidthF: unsafe extern "C" fn(FPDF_PAGE) -> f32, + pub FPDF_GetPageHeightF: unsafe extern "C" fn(FPDF_PAGE) -> f32, + pub FPDF_GetPageBoundingBox: unsafe extern "C" fn(FPDF_PAGE, *mut FS_RECTF) -> FPDF_BOOL, + pub FPDFPage_GetRotation: unsafe extern "C" fn(FPDF_PAGE) -> std::os::raw::c_int, + pub FPDF_PageToDevice: unsafe extern "C" fn( + FPDF_PAGE, + std::os::raw::c_int, + std::os::raw::c_int, + std::os::raw::c_int, + std::os::raw::c_int, + std::os::raw::c_int, + f64, + f64, + *mut std::os::raw::c_int, + *mut std::os::raw::c_int, + ) -> FPDF_BOOL, + + // -- Page objects -- + pub FPDFPage_CountObjects: unsafe extern "C" fn(FPDF_PAGE) -> std::os::raw::c_int, + pub FPDFPage_GetObject: unsafe extern "C" fn(FPDF_PAGE, std::os::raw::c_int) -> FPDF_PAGEOBJECT, + pub FPDFPageObj_GetType: unsafe extern "C" fn(FPDF_PAGEOBJECT) -> std::os::raw::c_int, + pub FPDFPageObj_GetBounds: + unsafe extern "C" fn(FPDF_PAGEOBJECT, *mut f32, *mut f32, *mut f32, *mut f32) -> FPDF_BOOL, + pub FPDFPageObj_GetMarkedContentID: + unsafe extern "C" fn(FPDF_PAGEOBJECT) -> std::os::raw::c_int, + pub FPDFImageObj_GetRenderedBitmap: + unsafe extern "C" fn(FPDF_DOCUMENT, FPDF_PAGE, FPDF_PAGEOBJECT) -> FPDF_BITMAP, + pub FPDFPageObj_GetMatrix: unsafe extern "C" fn(FPDF_PAGEOBJECT, *mut FS_MATRIX) -> FPDF_BOOL, + pub FPDFPageObj_GetStrokeColor: unsafe extern "C" fn( + FPDF_PAGEOBJECT, + *mut std::os::raw::c_uint, + *mut std::os::raw::c_uint, + *mut std::os::raw::c_uint, + *mut std::os::raw::c_uint, + ) -> FPDF_BOOL, + pub FPDFPageObj_GetFillColor: unsafe extern "C" fn( + FPDF_PAGEOBJECT, + *mut std::os::raw::c_uint, + *mut std::os::raw::c_uint, + *mut std::os::raw::c_uint, + *mut std::os::raw::c_uint, + ) -> FPDF_BOOL, + pub FPDFPageObj_GetStrokeWidth: unsafe extern "C" fn(FPDF_PAGEOBJECT, *mut f32) -> FPDF_BOOL, + pub FPDFPath_GetDrawMode: unsafe extern "C" fn( + FPDF_PAGEOBJECT, + *mut std::os::raw::c_int, + *mut FPDF_BOOL, + ) -> FPDF_BOOL, + pub FPDFFormObj_CountObjects: unsafe extern "C" fn(FPDF_PAGEOBJECT) -> std::os::raw::c_int, + pub FPDFFormObj_GetObject: + unsafe extern "C" fn(FPDF_PAGEOBJECT, std::os::raw::c_ulong) -> FPDF_PAGEOBJECT, + pub FPDFPath_CountSegments: unsafe extern "C" fn(FPDF_PAGEOBJECT) -> std::os::raw::c_int, + pub FPDFPath_GetPathSegment: + unsafe extern "C" fn(FPDF_PAGEOBJECT, std::os::raw::c_int) -> FPDF_PATHSEGMENT, + pub FPDFPathSegment_GetPoint: + unsafe extern "C" fn(FPDF_PATHSEGMENT, *mut f32, *mut f32) -> FPDF_BOOL, + pub FPDFPathSegment_GetType: unsafe extern "C" fn(FPDF_PATHSEGMENT) -> std::os::raw::c_int, + pub FPDFPathSegment_GetClose: unsafe extern "C" fn(FPDF_PATHSEGMENT) -> FPDF_BOOL, + + // -- TextPage -- + pub FPDFText_LoadPage: unsafe extern "C" fn(FPDF_PAGE) -> FPDF_TEXTPAGE, + pub FPDFText_ClosePage: unsafe extern "C" fn(FPDF_TEXTPAGE), + pub FPDFText_CountChars: unsafe extern "C" fn(FPDF_TEXTPAGE) -> std::os::raw::c_int, + pub FPDFText_GetUnicode: + unsafe extern "C" fn(FPDF_TEXTPAGE, std::os::raw::c_int) -> std::os::raw::c_uint, + pub FPDFText_GetCharCode: + unsafe extern "C" fn(FPDF_TEXTPAGE, std::os::raw::c_int) -> std::os::raw::c_uint, + pub FPDFText_GetFontSize: unsafe extern "C" fn(FPDF_TEXTPAGE, std::os::raw::c_int) -> f64, + pub FPDFText_GetFontWeight: + unsafe extern "C" fn(FPDF_TEXTPAGE, std::os::raw::c_int) -> std::os::raw::c_int, + pub FPDFText_GetFontInfo: unsafe extern "C" fn( + FPDF_TEXTPAGE, + std::os::raw::c_int, + *mut std::os::raw::c_void, + std::os::raw::c_ulong, + *mut std::os::raw::c_int, + ) -> std::os::raw::c_ulong, + pub FPDFText_GetCharAngle: unsafe extern "C" fn(FPDF_TEXTPAGE, std::os::raw::c_int) -> f32, + pub FPDFText_GetCharBox: unsafe extern "C" fn( + FPDF_TEXTPAGE, + std::os::raw::c_int, + *mut f64, + *mut f64, + *mut f64, + *mut f64, + ) -> FPDF_BOOL, + pub FPDFText_GetLooseCharBox: + unsafe extern "C" fn(FPDF_TEXTPAGE, std::os::raw::c_int, *mut FS_RECTF) -> FPDF_BOOL, + pub FPDFText_GetMatrix: + unsafe extern "C" fn(FPDF_TEXTPAGE, std::os::raw::c_int, *mut FS_MATRIX) -> FPDF_BOOL, + pub FPDFText_IsGenerated: + unsafe extern "C" fn(FPDF_TEXTPAGE, std::os::raw::c_int) -> std::os::raw::c_int, + pub FPDFText_HasUnicodeMapError: + unsafe extern "C" fn(FPDF_TEXTPAGE, std::os::raw::c_int) -> std::os::raw::c_int, + pub FPDFText_GetTextObject: + unsafe extern "C" fn(FPDF_TEXTPAGE, std::os::raw::c_int) -> FPDF_PAGEOBJECT, + pub FPDFText_GetStrokeColor: unsafe extern "C" fn( + FPDF_TEXTPAGE, + std::os::raw::c_int, + *mut std::os::raw::c_uint, + *mut std::os::raw::c_uint, + *mut std::os::raw::c_uint, + *mut std::os::raw::c_uint, + ) -> FPDF_BOOL, + pub FPDFText_GetFillColor: unsafe extern "C" fn( + FPDF_TEXTPAGE, + std::os::raw::c_int, + *mut std::os::raw::c_uint, + *mut std::os::raw::c_uint, + *mut std::os::raw::c_uint, + *mut std::os::raw::c_uint, + ) -> FPDF_BOOL, + pub FPDFText_CountRects: unsafe extern "C" fn( + FPDF_TEXTPAGE, + std::os::raw::c_int, + std::os::raw::c_int, + ) -> std::os::raw::c_int, + pub FPDFText_GetRect: unsafe extern "C" fn( + FPDF_TEXTPAGE, + std::os::raw::c_int, + *mut f64, + *mut f64, + *mut f64, + *mut f64, + ) -> FPDF_BOOL, + pub FPDFText_GetBoundedText: unsafe extern "C" fn( + FPDF_TEXTPAGE, + f64, + f64, + f64, + f64, + *mut std::os::raw::c_ushort, + std::os::raw::c_int, + ) -> std::os::raw::c_int, + pub FPDFText_GetText: unsafe extern "C" fn( + FPDF_TEXTPAGE, + std::os::raw::c_int, + std::os::raw::c_int, + *mut std::os::raw::c_ushort, + ) -> std::os::raw::c_int, + pub FPDFTextObj_GetTextRenderMode: + unsafe extern "C" fn(FPDF_PAGEOBJECT) -> FPDF_TEXT_RENDERMODE, + + // -- Bitmap -- + pub FPDFBitmap_CreateEx: unsafe extern "C" fn( + std::os::raw::c_int, + std::os::raw::c_int, + std::os::raw::c_int, + *mut std::os::raw::c_void, + std::os::raw::c_int, + ) -> FPDF_BITMAP, + pub FPDFBitmap_Destroy: unsafe extern "C" fn(FPDF_BITMAP), + pub FPDFBitmap_GetWidth: unsafe extern "C" fn(FPDF_BITMAP) -> std::os::raw::c_int, + pub FPDFBitmap_GetHeight: unsafe extern "C" fn(FPDF_BITMAP) -> std::os::raw::c_int, + pub FPDFBitmap_GetStride: unsafe extern "C" fn(FPDF_BITMAP) -> std::os::raw::c_int, + pub FPDFBitmap_GetBuffer: unsafe extern "C" fn(FPDF_BITMAP) -> *mut std::os::raw::c_void, + pub FPDFBitmap_FillRect: unsafe extern "C" fn( + FPDF_BITMAP, + std::os::raw::c_int, + std::os::raw::c_int, + std::os::raw::c_int, + std::os::raw::c_int, + FPDF_DWORD, + ) -> FPDF_BOOL, + + // -- Rendering -- + pub FPDF_RenderPageBitmap: unsafe extern "C" fn( + FPDF_BITMAP, + FPDF_PAGE, + std::os::raw::c_int, + std::os::raw::c_int, + std::os::raw::c_int, + std::os::raw::c_int, + std::os::raw::c_int, + std::os::raw::c_int, + ), + + // -- Font -- + pub FPDFTextObj_GetFont: unsafe extern "C" fn(FPDF_PAGEOBJECT) -> FPDF_FONT, + pub FPDFFont_GetBaseFontName: + unsafe extern "C" fn(FPDF_FONT, *mut std::os::raw::c_char, usize) -> usize, + pub FPDFFont_GetType: unsafe extern "C" fn(FPDF_FONT) -> FPDF_FONT_TYPE, + pub FPDFFont_GetIsEmbedded: unsafe extern "C" fn(FPDF_FONT) -> std::os::raw::c_int, + pub FPDFFont_GetAscent: unsafe extern "C" fn(FPDF_FONT, f32, *mut f32) -> FPDF_BOOL, + pub FPDFFont_GetDescent: unsafe extern "C" fn(FPDF_FONT, f32, *mut f32) -> FPDF_BOOL, + pub FPDFFont_GetGlyphWidth: unsafe extern "C" fn(FPDF_FONT, u32, f32, *mut f32) -> FPDF_BOOL, + pub FPDFFont_GetGlyphWidthFromCharCode: + unsafe extern "C" fn(FPDF_FONT, u32, f32, *mut f32) -> FPDF_BOOL, + pub FPDFFont_GetGlyphPathFromCharCode: + unsafe extern "C" fn(FPDF_FONT, u32, f32) -> FPDF_GLYPHPATH, + pub FPDFGlyphPath_CountGlyphSegments: + unsafe extern "C" fn(FPDF_GLYPHPATH) -> std::os::raw::c_int, + pub FPDFGlyphPath_GetGlyphPathSegment: + unsafe extern "C" fn(FPDF_GLYPHPATH, std::os::raw::c_int) -> FPDF_PATHSEGMENT, + pub FPDFFont_HasToUnicode: unsafe extern "C" fn(FPDF_FONT) -> FPDF_BOOL, + pub FPDFFont_GetCharGlyphName: unsafe extern "C" fn( + FPDF_FONT, + u32, + *mut std::os::raw::c_char, + std::os::raw::c_ulong, + ) -> std::os::raw::c_ulong, + pub FPDFFont_GetEncoding: unsafe extern "C" fn( + FPDF_FONT, + *mut std::os::raw::c_char, + std::os::raw::c_ulong, + ) -> std::os::raw::c_ulong, + pub FPDFFont_GetCharGlyphIndex: unsafe extern "C" fn(FPDF_FONT, u32) -> std::os::raw::c_int, + pub FPDFFont_GetFontData: + unsafe extern "C" fn(FPDF_FONT, *mut u8, usize, *mut usize) -> FPDF_BOOL, + + // -- Outline (bookmarks) -- + pub FPDFBookmark_GetFirstChild: + unsafe extern "C" fn(FPDF_DOCUMENT, FPDF_BOOKMARK) -> FPDF_BOOKMARK, + pub FPDFBookmark_GetNextSibling: + unsafe extern "C" fn(FPDF_DOCUMENT, FPDF_BOOKMARK) -> FPDF_BOOKMARK, + pub FPDFBookmark_GetTitle: unsafe extern "C" fn( + FPDF_BOOKMARK, + *mut std::os::raw::c_void, + std::os::raw::c_ulong, + ) -> std::os::raw::c_ulong, + pub FPDFBookmark_GetDest: unsafe extern "C" fn(FPDF_DOCUMENT, FPDF_BOOKMARK) -> FPDF_DEST, + pub FPDFBookmark_GetAction: unsafe extern "C" fn(FPDF_BOOKMARK) -> FPDF_ACTION, + pub FPDFAction_GetDest: unsafe extern "C" fn(FPDF_DOCUMENT, FPDF_ACTION) -> FPDF_DEST, + pub FPDFAction_GetURIPath: unsafe extern "C" fn( + FPDF_DOCUMENT, + FPDF_ACTION, + *mut std::os::raw::c_void, + std::os::raw::c_ulong, + ) -> std::os::raw::c_ulong, + pub FPDFLink_Enumerate: + unsafe extern "C" fn(FPDF_PAGE, *mut std::os::raw::c_int, *mut FPDF_LINK) -> FPDF_BOOL, + pub FPDFLink_GetAction: unsafe extern "C" fn(FPDF_LINK) -> FPDF_ACTION, + pub FPDFLink_GetAnnotRect: unsafe extern "C" fn(FPDF_LINK, *mut FS_RECTF) -> FPDF_BOOL, + pub FPDFLink_CountQuadPoints: unsafe extern "C" fn(FPDF_LINK) -> std::os::raw::c_int, + pub FPDFLink_GetQuadPoints: + unsafe extern "C" fn(FPDF_LINK, std::os::raw::c_int, *mut FS_QUADPOINTSF) -> FPDF_BOOL, + pub FPDFDest_GetDestPageIndex: + unsafe extern "C" fn(FPDF_DOCUMENT, FPDF_DEST) -> std::os::raw::c_int, + pub FPDFDest_GetLocationInPage: unsafe extern "C" fn( + FPDF_DEST, + *mut FPDF_BOOL, + *mut FPDF_BOOL, + *mut FPDF_BOOL, + *mut FS_FLOAT, + *mut FS_FLOAT, + *mut FS_FLOAT, + ) -> FPDF_BOOL, + + // -- Structure tree -- + pub FPDF_StructTree_GetForPage: unsafe extern "C" fn(FPDF_PAGE) -> FPDF_STRUCTTREE, + pub FPDF_StructTree_Close: unsafe extern "C" fn(FPDF_STRUCTTREE), + pub FPDF_StructTree_CountChildren: unsafe extern "C" fn(FPDF_STRUCTTREE) -> std::os::raw::c_int, + pub FPDF_StructTree_GetChildAtIndex: + unsafe extern "C" fn(FPDF_STRUCTTREE, std::os::raw::c_int) -> FPDF_STRUCTELEMENT, + pub FPDF_StructElement_GetType: unsafe extern "C" fn( + FPDF_STRUCTELEMENT, + *mut std::os::raw::c_void, + std::os::raw::c_ulong, + ) -> std::os::raw::c_ulong, + pub FPDF_StructElement_GetAltText: unsafe extern "C" fn( + FPDF_STRUCTELEMENT, + *mut std::os::raw::c_void, + std::os::raw::c_ulong, + ) -> std::os::raw::c_ulong, + pub FPDF_StructElement_GetTitle: unsafe extern "C" fn( + FPDF_STRUCTELEMENT, + *mut std::os::raw::c_void, + std::os::raw::c_ulong, + ) -> std::os::raw::c_ulong, + pub FPDF_StructElement_CountChildren: + unsafe extern "C" fn(FPDF_STRUCTELEMENT) -> std::os::raw::c_int, + pub FPDF_StructElement_GetChildAtIndex: + unsafe extern "C" fn(FPDF_STRUCTELEMENT, std::os::raw::c_int) -> FPDF_STRUCTELEMENT, + pub FPDF_StructElement_GetChildMarkedContentID: + unsafe extern "C" fn(FPDF_STRUCTELEMENT, std::os::raw::c_int) -> std::os::raw::c_int, + pub FPDF_StructElement_GetMarkedContentID: + unsafe extern "C" fn(FPDF_STRUCTELEMENT) -> std::os::raw::c_int, + pub FPDF_StructElement_GetMarkedContentIdCount: + unsafe extern "C" fn(FPDF_STRUCTELEMENT) -> std::os::raw::c_int, + pub FPDF_StructElement_GetMarkedContentIdAtIndex: + unsafe extern "C" fn(FPDF_STRUCTELEMENT, std::os::raw::c_int) -> std::os::raw::c_int, +} + +// SAFETY: PdfiumBindings contains only function pointers and a Library handle. +// The Library handle is thread-safe (it just holds a dlopen handle). +// Function pointers are inherently Send+Sync. +unsafe impl Send for PdfiumBindings {} +unsafe impl Sync for PdfiumBindings {} + +impl PdfiumBindings { + fn load(lib: Library) -> Result { + Ok(Self { + FPDF_InitLibrary: load_fn!(lib, "FPDF_InitLibrary"), + FPDF_GetLastError: load_fn!(lib, "FPDF_GetLastError"), + FPDF_LoadDocument: load_fn!(lib, "FPDF_LoadDocument"), + FPDF_LoadMemDocument: load_fn!(lib, "FPDF_LoadMemDocument"), + FPDF_CloseDocument: load_fn!(lib, "FPDF_CloseDocument"), + FPDF_GetPageCount: load_fn!(lib, "FPDF_GetPageCount"), + FPDF_LoadPage: load_fn!(lib, "FPDF_LoadPage"), + FPDF_ClosePage: load_fn!(lib, "FPDF_ClosePage"), + FPDF_GetPageWidthF: load_fn!(lib, "FPDF_GetPageWidthF"), + FPDF_GetPageHeightF: load_fn!(lib, "FPDF_GetPageHeightF"), + FPDF_GetPageBoundingBox: load_fn!(lib, "FPDF_GetPageBoundingBox"), + FPDFPage_GetRotation: load_fn!(lib, "FPDFPage_GetRotation"), + FPDF_PageToDevice: load_fn!(lib, "FPDF_PageToDevice"), + FPDFPage_CountObjects: load_fn!(lib, "FPDFPage_CountObjects"), + FPDFPage_GetObject: load_fn!(lib, "FPDFPage_GetObject"), + FPDFPageObj_GetType: load_fn!(lib, "FPDFPageObj_GetType"), + FPDFPageObj_GetBounds: load_fn!(lib, "FPDFPageObj_GetBounds"), + FPDFPageObj_GetMarkedContentID: load_fn!(lib, "FPDFPageObj_GetMarkedContentID"), + FPDFImageObj_GetRenderedBitmap: load_fn!(lib, "FPDFImageObj_GetRenderedBitmap"), + FPDFPageObj_GetMatrix: load_fn!(lib, "FPDFPageObj_GetMatrix"), + FPDFPageObj_GetStrokeColor: load_fn!(lib, "FPDFPageObj_GetStrokeColor"), + FPDFPageObj_GetFillColor: load_fn!(lib, "FPDFPageObj_GetFillColor"), + FPDFPageObj_GetStrokeWidth: load_fn!(lib, "FPDFPageObj_GetStrokeWidth"), + FPDFPath_GetDrawMode: load_fn!(lib, "FPDFPath_GetDrawMode"), + FPDFFormObj_CountObjects: load_fn!(lib, "FPDFFormObj_CountObjects"), + FPDFFormObj_GetObject: load_fn!(lib, "FPDFFormObj_GetObject"), + FPDFPath_CountSegments: load_fn!(lib, "FPDFPath_CountSegments"), + FPDFPath_GetPathSegment: load_fn!(lib, "FPDFPath_GetPathSegment"), + FPDFPathSegment_GetPoint: load_fn!(lib, "FPDFPathSegment_GetPoint"), + FPDFPathSegment_GetType: load_fn!(lib, "FPDFPathSegment_GetType"), + FPDFPathSegment_GetClose: load_fn!(lib, "FPDFPathSegment_GetClose"), + FPDFText_LoadPage: load_fn!(lib, "FPDFText_LoadPage"), + FPDFText_ClosePage: load_fn!(lib, "FPDFText_ClosePage"), + FPDFText_CountChars: load_fn!(lib, "FPDFText_CountChars"), + FPDFText_GetUnicode: load_fn!(lib, "FPDFText_GetUnicode"), + FPDFText_GetCharCode: load_fn!(lib, "FPDFText_GetCharCode"), + FPDFText_GetFontSize: load_fn!(lib, "FPDFText_GetFontSize"), + FPDFText_GetFontWeight: load_fn!(lib, "FPDFText_GetFontWeight"), + FPDFText_GetFontInfo: load_fn!(lib, "FPDFText_GetFontInfo"), + FPDFText_GetCharAngle: load_fn!(lib, "FPDFText_GetCharAngle"), + FPDFText_GetCharBox: load_fn!(lib, "FPDFText_GetCharBox"), + FPDFText_GetLooseCharBox: load_fn!(lib, "FPDFText_GetLooseCharBox"), + FPDFText_GetMatrix: load_fn!(lib, "FPDFText_GetMatrix"), + FPDFText_IsGenerated: load_fn!(lib, "FPDFText_IsGenerated"), + FPDFText_HasUnicodeMapError: load_fn!(lib, "FPDFText_HasUnicodeMapError"), + FPDFText_GetTextObject: load_fn!(lib, "FPDFText_GetTextObject"), + FPDFText_GetStrokeColor: load_fn!(lib, "FPDFText_GetStrokeColor"), + FPDFText_GetFillColor: load_fn!(lib, "FPDFText_GetFillColor"), + FPDFText_CountRects: load_fn!(lib, "FPDFText_CountRects"), + FPDFText_GetRect: load_fn!(lib, "FPDFText_GetRect"), + FPDFText_GetBoundedText: load_fn!(lib, "FPDFText_GetBoundedText"), + FPDFText_GetText: load_fn!(lib, "FPDFText_GetText"), + FPDFTextObj_GetTextRenderMode: load_fn!(lib, "FPDFTextObj_GetTextRenderMode"), + FPDFBitmap_CreateEx: load_fn!(lib, "FPDFBitmap_CreateEx"), + FPDFBitmap_Destroy: load_fn!(lib, "FPDFBitmap_Destroy"), + FPDFBitmap_GetWidth: load_fn!(lib, "FPDFBitmap_GetWidth"), + FPDFBitmap_GetHeight: load_fn!(lib, "FPDFBitmap_GetHeight"), + FPDFBitmap_GetStride: load_fn!(lib, "FPDFBitmap_GetStride"), + FPDFBitmap_GetBuffer: load_fn!(lib, "FPDFBitmap_GetBuffer"), + FPDFBitmap_FillRect: load_fn!(lib, "FPDFBitmap_FillRect"), + FPDF_RenderPageBitmap: load_fn!(lib, "FPDF_RenderPageBitmap"), + FPDFTextObj_GetFont: load_fn!(lib, "FPDFTextObj_GetFont"), + FPDFFont_GetBaseFontName: load_fn!(lib, "FPDFFont_GetBaseFontName"), + FPDFFont_GetType: load_fn!(lib, "FPDFFont_GetType"), + FPDFFont_GetIsEmbedded: load_fn!(lib, "FPDFFont_GetIsEmbedded"), + FPDFFont_GetAscent: load_fn!(lib, "FPDFFont_GetAscent"), + FPDFFont_GetDescent: load_fn!(lib, "FPDFFont_GetDescent"), + FPDFFont_GetGlyphWidth: load_fn!(lib, "FPDFFont_GetGlyphWidth"), + FPDFFont_GetGlyphWidthFromCharCode: load_fn!(lib, "FPDFFont_GetGlyphWidthFromCharCode"), + FPDFFont_GetGlyphPathFromCharCode: load_fn!(lib, "FPDFFont_GetGlyphPathFromCharCode"), + FPDFGlyphPath_CountGlyphSegments: load_fn!(lib, "FPDFGlyphPath_CountGlyphSegments"), + FPDFGlyphPath_GetGlyphPathSegment: load_fn!(lib, "FPDFGlyphPath_GetGlyphPathSegment"), + FPDFFont_HasToUnicode: load_fn!(lib, "FPDFFont_HasToUnicode"), + FPDFFont_GetCharGlyphName: load_fn!(lib, "FPDFFont_GetCharGlyphName"), + FPDFFont_GetEncoding: load_fn!(lib, "FPDFFont_GetEncoding"), + FPDFFont_GetCharGlyphIndex: load_fn!(lib, "FPDFFont_GetCharGlyphIndex"), + FPDFFont_GetFontData: load_fn!(lib, "FPDFFont_GetFontData"), + + FPDFBookmark_GetFirstChild: load_fn!(lib, "FPDFBookmark_GetFirstChild"), + FPDFBookmark_GetNextSibling: load_fn!(lib, "FPDFBookmark_GetNextSibling"), + FPDFBookmark_GetTitle: load_fn!(lib, "FPDFBookmark_GetTitle"), + FPDFBookmark_GetDest: load_fn!(lib, "FPDFBookmark_GetDest"), + FPDFBookmark_GetAction: load_fn!(lib, "FPDFBookmark_GetAction"), + FPDFAction_GetDest: load_fn!(lib, "FPDFAction_GetDest"), + FPDFAction_GetURIPath: load_fn!(lib, "FPDFAction_GetURIPath"), + FPDFLink_Enumerate: load_fn!(lib, "FPDFLink_Enumerate"), + FPDFLink_GetAction: load_fn!(lib, "FPDFLink_GetAction"), + FPDFLink_GetAnnotRect: load_fn!(lib, "FPDFLink_GetAnnotRect"), + FPDFLink_CountQuadPoints: load_fn!(lib, "FPDFLink_CountQuadPoints"), + FPDFLink_GetQuadPoints: load_fn!(lib, "FPDFLink_GetQuadPoints"), + FPDFDest_GetDestPageIndex: load_fn!(lib, "FPDFDest_GetDestPageIndex"), + FPDFDest_GetLocationInPage: load_fn!(lib, "FPDFDest_GetLocationInPage"), + + FPDF_StructTree_GetForPage: load_fn!(lib, "FPDF_StructTree_GetForPage"), + FPDF_StructTree_Close: load_fn!(lib, "FPDF_StructTree_Close"), + FPDF_StructTree_CountChildren: load_fn!(lib, "FPDF_StructTree_CountChildren"), + FPDF_StructTree_GetChildAtIndex: load_fn!(lib, "FPDF_StructTree_GetChildAtIndex"), + FPDF_StructElement_GetType: load_fn!(lib, "FPDF_StructElement_GetType"), + FPDF_StructElement_GetAltText: load_fn!(lib, "FPDF_StructElement_GetAltText"), + FPDF_StructElement_GetTitle: load_fn!(lib, "FPDF_StructElement_GetTitle"), + FPDF_StructElement_CountChildren: load_fn!(lib, "FPDF_StructElement_CountChildren"), + FPDF_StructElement_GetChildAtIndex: load_fn!(lib, "FPDF_StructElement_GetChildAtIndex"), + FPDF_StructElement_GetChildMarkedContentID: load_fn!( + lib, + "FPDF_StructElement_GetChildMarkedContentID" + ), + FPDF_StructElement_GetMarkedContentID: load_fn!( + lib, + "FPDF_StructElement_GetMarkedContentID" + ), + FPDF_StructElement_GetMarkedContentIdCount: load_fn!( + lib, + "FPDF_StructElement_GetMarkedContentIdCount" + ), + FPDF_StructElement_GetMarkedContentIdAtIndex: load_fn!( + lib, + "FPDF_StructElement_GetMarkedContentIdAtIndex" + ), + + _lib: lib, + }) + } +} + +/// Shared library file name for the current platform. +fn dylib_name() -> &'static str { + #[cfg(target_os = "macos")] + { + "libpdfium.dylib" + } + #[cfg(target_os = "windows")] + { + "pdfium.dll" + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + "libpdfium.so" + } +} + +/// Get the directory containing the current shared library (the .pyd/.so/.node/.dll +/// that this code is compiled into). This lets us find sibling files like pdfium.dll +/// that are bundled next to the native extension in Python wheels, Node packages, etc. +fn self_dir() -> Option { + // Use a static function in this module as the probe address. + let addr = self_dir as *const (); + + #[cfg(target_os = "windows")] + { + use std::ffi::OsString; + use std::os::windows::ffi::OsStringExt; + + #[link(name = "kernel32")] + unsafe extern "system" { + fn GetModuleHandleExW( + dwFlags: u32, + lpModuleName: *const u8, + phModule: *mut *mut std::ffi::c_void, + ) -> i32; + fn GetModuleFileNameW( + hModule: *mut std::ffi::c_void, + lpFilename: *mut u16, + nSize: u32, + ) -> u32; + } + + const FROM_ADDRESS: u32 = 0x00000004; + const UNCHANGED_REFCOUNT: u32 = 0x00000002; + + unsafe { + let mut module = std::ptr::null_mut(); + if GetModuleHandleExW( + FROM_ADDRESS | UNCHANGED_REFCOUNT, + addr as *const u8, + &mut module, + ) == 0 + { + return None; + } + let mut buf = vec![0u16; 1024]; + let len = GetModuleFileNameW(module, buf.as_mut_ptr(), buf.len() as u32); + if len == 0 || len >= buf.len() as u32 { + return None; + } + let path = PathBuf::from(OsString::from_wide(&buf[..len as usize])); + path.parent().map(|p| p.to_path_buf()) + } + } + + #[cfg(not(target_os = "windows"))] + { + #[repr(C)] + struct DlInfo { + dli_fname: *const std::os::raw::c_char, + dli_fbase: *mut std::ffi::c_void, + dli_sname: *const std::os::raw::c_char, + dli_saddr: *mut std::ffi::c_void, + } + + unsafe extern "C" { + fn dladdr(addr: *const std::ffi::c_void, info: *mut DlInfo) -> i32; + } + + unsafe { + let mut info: DlInfo = std::mem::zeroed(); + if dladdr(addr as *const std::ffi::c_void, &mut info) != 0 && !info.dli_fname.is_null() + { + let cstr = std::ffi::CStr::from_ptr(info.dli_fname); + let path = PathBuf::from(cstr.to_string_lossy().as_ref()); + return path.parent().map(|p| p.to_path_buf()); + } + None + } + } +} + +/// Search paths for the pdfium shared library, in priority order. +fn search_paths() -> Vec { + let mut paths = Vec::new(); + let name = dylib_name(); + + // 1. Runtime env var override (directory containing the shared library) + if let Ok(dir) = std::env::var("PDFIUM_LIB_PATH") { + paths.push(PathBuf::from(&dir).join(name)); + } + + // 2. Compile-time baked path from build.rs + if !PDFIUM_LIB_DIR.is_empty() { + let lib_dir = PathBuf::from(PDFIUM_LIB_DIR); + paths.push(lib_dir.join(name)); + + // On Windows, pdfium-binaries puts the DLL in bin/, not lib/ + #[cfg(target_os = "windows")] + if let Some(parent) = lib_dir.parent() { + paths.push(parent.join("bin").join(name)); + } + } + + // 3. Next to the native extension (Python .pyd/.so, Node .node, etc.) + // Uses dladdr (Unix) / GetModuleHandleExW (Windows) to find our own module path. + if let Some(dir) = self_dir() { + paths.push(dir.join(name)); + } + + // 4. Next to the current executable + if let Ok(exe) = std::env::current_exe() + && let Some(exe_dir) = exe.parent() + { + paths.push(exe_dir.join(name)); + } + + // 5. Bare library name (system search paths / LD_LIBRARY_PATH / DYLD_LIBRARY_PATH / PATH) + paths.push(PathBuf::from(name)); + + paths +} + +/// Load the pdfium shared library from a specific path. +pub fn load(lib_path: &Path) -> Result<(), String> { + if BINDINGS.get().is_some() { + return Ok(()); + } + let lib = unsafe { Library::new(lib_path) } + .map_err(|e| format!("failed to load pdfium from {}: {e}", lib_path.display()))?; + let bindings = + PdfiumBindings::load(lib).map_err(|e| format!("failed to resolve pdfium symbols: {e}"))?; + let _ = BINDINGS.set(bindings); + Ok(()) +} + +/// Load the pdfium shared library from default search paths. +/// +/// Search order: +/// 1. `PDFIUM_LIB_PATH` env var (directory containing the shared library) +/// 2. Compile-time cached download path +/// 3. System library search paths +pub fn load_default() -> Result<(), String> { + if BINDINGS.get().is_some() { + return Ok(()); + } + + let paths = search_paths(); + let mut last_err = String::from("no search paths configured"); + + for path in &paths { + match unsafe { Library::new(path) } { + Ok(lib) => match PdfiumBindings::load(lib) { + Ok(bindings) => { + let _ = BINDINGS.set(bindings); + return Ok(()); + } + Err(e) => { + last_err = format!( + "failed to resolve pdfium symbols from {}: {e}", + path.display() + ); + } + }, + Err(e) => { + last_err = format!("{}: {e}", path.display()); + } + } + } + + Err(format!( + "could not find pdfium shared library. Last error: {last_err}. \ + Set PDFIUM_LIB_PATH to the directory containing {}", + dylib_name() + )) +} + +/// Get a reference to the loaded pdfium bindings. +/// +/// # Panics +/// Panics if `load()` or `load_default()` has not been called successfully. +pub fn pdfium() -> &'static PdfiumBindings { + BINDINGS + .get() + .expect("pdfium not loaded — call pdfium_sys::dynamic::load_default() first") +} diff --git a/crates/pdfium-sys/src/lib.rs b/crates/pdfium-sys/src/lib.rs new file mode 100644 index 0000000..918eaee --- /dev/null +++ b/crates/pdfium-sys/src/lib.rs @@ -0,0 +1,9 @@ +#![allow(non_upper_case_globals)] +#![allow(non_camel_case_types)] +#![allow(non_snake_case)] +#![allow(dead_code)] + +include!(concat!(env!("OUT_DIR"), "/bindings.rs")); + +#[cfg(not(target_arch = "wasm32"))] +pub mod dynamic; diff --git a/crates/pdfium-sys/wrapper.h b/crates/pdfium-sys/wrapper.h new file mode 100644 index 0000000..e35d841 --- /dev/null +++ b/crates/pdfium-sys/wrapper.h @@ -0,0 +1,7 @@ +#include "fpdfview.h" +#include "fpdf_text.h" +#include "fpdf_edit.h" +#include "fpdf_doc.h" +#include "fpdf_annot.h" +#include "fpdf_structtree.h" +#include "fpdf_transformpage.h" diff --git a/crates/pdfium/Cargo.toml b/crates/pdfium/Cargo.toml new file mode 100644 index 0000000..8efd675 --- /dev/null +++ b/crates/pdfium/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "liteparse-pdfium" +version = "1.3.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Safe Rust wrapper around PDFium for liteparse" + +[dependencies] +pdfium-sys = { package = "liteparse-pdfium-sys", version = "1.3.0", path = "../pdfium-sys" } + +[dev-dependencies] +blake3 = "1" diff --git a/crates/pdfium/src/bitmap.rs b/crates/pdfium/src/bitmap.rs new file mode 100644 index 0000000..e96b466 --- /dev/null +++ b/crates/pdfium/src/bitmap.rs @@ -0,0 +1,166 @@ +use std::marker::PhantomData; + +use crate::error::PdfiumError; +use crate::ffi; +use crate::library::Library; + +/// A BGRA pixel buffer owned by PDFium. +/// +/// The `'lib` lifetime ties the bitmap to a held [`Library`] lock, so it +/// cannot be created (or destroyed via `Drop`) outside the PDFium critical +/// section. +pub struct Bitmap<'lib> { + handle: pdfium_sys::FPDF_BITMAP, + _lib: PhantomData<&'lib Library>, +} + +impl<'lib> Bitmap<'lib> { + /// Wrap an existing FPDF_BITMAP handle (takes ownership, will destroy on drop). + /// + /// # Safety + /// The handle must be a valid, non-null bitmap that the caller owns, + /// and the caller must hold a [`Library`] for at least `'lib`. + pub unsafe fn from_handle(handle: pdfium_sys::FPDF_BITMAP) -> Self { + Bitmap { + handle, + _lib: PhantomData, + } + } + + /// Create a new BGRA bitmap with the given dimensions. + /// + /// # Safety + /// The caller must hold a [`Library`] for at least `'lib` (PDFium FFI is + /// not thread-safe). `'lib` is not constrained by an argument, so callers + /// must ensure it cannot outlive the held lock — usually by inferring it + /// from the call site (e.g. returning a `Bitmap<'lib>` from a method on + /// `Page<'_, 'lib>`, whose existence already proves the lock is held). + pub unsafe fn new(width: i32, height: i32) -> Result { + let handle = unsafe { + ffi!(FPDFBitmap_CreateEx( + width, + height, + pdfium_sys::FPDFBitmap_BGRA as i32, + std::ptr::null_mut(), + 0, // stride=0 lets pdfium choose + )) + }; + if handle.is_null() { + return Err(PdfiumError::OperationFailed); + } + Ok(Bitmap { + handle, + _lib: PhantomData, + }) + } + + pub fn handle(&self) -> pdfium_sys::FPDF_BITMAP { + self.handle + } + + pub fn width(&self) -> i32 { + unsafe { ffi!(FPDFBitmap_GetWidth(self.handle)) } + } + + pub fn height(&self) -> i32 { + unsafe { ffi!(FPDFBitmap_GetHeight(self.handle)) } + } + + pub fn stride(&self) -> i32 { + unsafe { ffi!(FPDFBitmap_GetStride(self.handle)) } + } + + /// Fill a rectangle with an ARGB color (0xAARRGGBB). + pub fn fill_rect(&self, left: i32, top: i32, width: i32, height: i32, color: u64) { + unsafe { + ffi!(FPDFBitmap_FillRect( + self.handle, + left, + top, + width, + height, + // necessary for windows -> expected `u32`, found `u64` + #[allow(clippy::useless_conversion)] + color.try_into().unwrap(), + )); + } + } + + /// Get the raw pixel buffer as a byte slice. + /// Format is BGRA, row-major, with `stride()` bytes per row. + pub fn buffer(&self) -> &[u8] { + let ptr = unsafe { ffi!(FPDFBitmap_GetBuffer(self.handle)) }; + let len = (self.stride() * self.height()) as usize; + unsafe { std::slice::from_raw_parts(ptr as *const u8, len) } + } + + /// Convert the BGRA buffer to RGBA in a new Vec. + pub fn to_rgba(&self) -> Vec { + let width = self.width() as usize; + let height = self.height() as usize; + let stride = self.stride() as usize; + let src = self.buffer(); + let mut rgba = Vec::with_capacity(width * height * 4); + + for y in 0..height { + let row = &src[y * stride..y * stride + width * 4]; + for pixel in row.chunks_exact(4) { + // BGRA -> RGBA + rgba.push(pixel[2]); // R + rgba.push(pixel[1]); // G + rgba.push(pixel[0]); // B + rgba.push(pixel[3]); // A + } + } + + rgba + } + + /// Convert the BGRA buffer to tightly-packed RGB in a new Vec, dropping the + /// alpha channel (pages render onto opaque white, so alpha is constant 255). + pub fn to_rgb(&self) -> Vec { + let width = self.width() as usize; + let height = self.height() as usize; + let stride = self.stride() as usize; + let src = self.buffer(); + let mut rgb = Vec::with_capacity(width * height * 3); + + for y in 0..height { + let row = &src[y * stride..y * stride + width * 4]; + for pixel in row.chunks_exact(4) { + // BGRA -> RGB (drop A) + rgb.push(pixel[2]); // R + rgb.push(pixel[1]); // G + rgb.push(pixel[0]); // B + } + } + + rgb + } + + /// Convert the BGRA buffer to tightly-packed 8-bit grayscale (1 byte/px) + /// using Rec. 601 luma weights. + pub fn to_luma(&self) -> Vec { + let width = self.width() as usize; + let height = self.height() as usize; + let stride = self.stride() as usize; + let src = self.buffer(); + let mut luma = Vec::with_capacity(width * height); + + for y in 0..height { + let row = &src[y * stride..y * stride + width * 4]; + for pixel in row.chunks_exact(4) { + let (b, g, r) = (pixel[0] as u32, pixel[1] as u32, pixel[2] as u32); + luma.push(((77 * r + 150 * g + 29 * b) >> 8) as u8); + } + } + + luma + } +} + +impl Drop for Bitmap<'_> { + fn drop(&mut self) { + unsafe { ffi!(FPDFBitmap_Destroy(self.handle)) }; + } +} diff --git a/crates/pdfium/src/document.rs b/crates/pdfium/src/document.rs new file mode 100644 index 0000000..c47bdc0 --- /dev/null +++ b/crates/pdfium/src/document.rs @@ -0,0 +1,160 @@ +use crate::error::PdfiumError; +use crate::ffi; +use crate::library::Library; +use crate::page::Page; + +/// An open PDF document. +/// +/// The `'lib` lifetime ties this `Document` to the [`Library`] that opened +/// it, statically guaranteeing that no PDFium calls happen after the +/// process-wide PDFium lock has been released. +pub struct Document<'lib> { + pub(crate) handle: pdfium_sys::FPDF_DOCUMENT, + pub(crate) _lib: std::marker::PhantomData<&'lib Library>, +} + +/// One entry in the document's outline (bookmarks tree). +#[derive(Debug, Clone)] +pub struct OutlineEntry { + /// Hierarchy depth, 1-based (top-level entries are level 1). + pub level: u8, + /// Bookmark title. + pub title: String, + /// Zero-based page index of the destination, or `None` if the destination + /// isn't a page in this document (external link, missing dest, etc). + pub page_index: Option, + /// Y coordinate of the destination on the page in PDF user space (origin + /// bottom-left), or `None` if the destination doesn't specify one. To + /// compare against viewport-space line bboxes (origin top-left) use + /// `page_height - y`. + pub y: Option, +} + +impl<'lib> Document<'lib> { + pub fn page_count(&self) -> i32 { + unsafe { ffi!(FPDF_GetPageCount(self.handle)) } + } + + pub fn page(&self, index: i32) -> Result, PdfiumError> { + let handle = unsafe { ffi!(FPDF_LoadPage(self.handle, index)) }; + if handle.is_null() { + return Err(PdfiumError::PageNotFound); + } + Ok(Page { + handle, + doc_handle: self.handle, + _doc: std::marker::PhantomData, + }) + } + + /// Walk the document outline (bookmarks). Returns entries in pre-order + /// (depth-first), so parents precede their children. Empty when the + /// document has no outline. + pub fn outline(&self) -> Vec { + let mut out = Vec::new(); + let root = unsafe { + ffi!(FPDFBookmark_GetFirstChild( + self.handle, + std::ptr::null_mut() + )) + }; + if !root.is_null() { + self.walk_bookmark(root, 1, &mut out); + } + out + } + + fn walk_bookmark( + &self, + bookmark: pdfium_sys::FPDF_BOOKMARK, + level: u8, + out: &mut Vec, + ) { + let mut cur = bookmark; + while !cur.is_null() { + let title = read_bookmark_title(cur); + let (page_index, y) = resolve_dest(self.handle, cur); + out.push(OutlineEntry { + level, + title, + page_index, + y, + }); + + let child = unsafe { ffi!(FPDFBookmark_GetFirstChild(self.handle, cur)) }; + if !child.is_null() { + self.walk_bookmark(child, level.saturating_add(1), out); + } + + cur = unsafe { ffi!(FPDFBookmark_GetNextSibling(self.handle, cur)) }; + } + } +} + +fn read_bookmark_title(bookmark: pdfium_sys::FPDF_BOOKMARK) -> String { + let needed = unsafe { ffi!(FPDFBookmark_GetTitle(bookmark, std::ptr::null_mut(), 0)) } as usize; + if needed < 2 { + return String::new(); + } + // `needed` is byte length including a trailing UTF-16 NUL terminator. + let mut buf: Vec = vec![0; needed / 2]; + let written = unsafe { + ffi!(FPDFBookmark_GetTitle( + bookmark, + buf.as_mut_ptr() as *mut std::os::raw::c_void, + needed as std::os::raw::c_ulong, + )) + } as usize; + if written < 2 { + return String::new(); + } + let chars = written / 2; + let end = if buf.get(chars - 1) == Some(&0) { + chars - 1 + } else { + chars + }; + String::from_utf16_lossy(&buf[..end]) +} + +fn resolve_dest( + doc: pdfium_sys::FPDF_DOCUMENT, + bookmark: pdfium_sys::FPDF_BOOKMARK, +) -> (Option, Option) { + let mut dest = unsafe { ffi!(FPDFBookmark_GetDest(doc, bookmark)) }; + if dest.is_null() { + let action = unsafe { ffi!(FPDFBookmark_GetAction(bookmark)) }; + if !action.is_null() { + dest = unsafe { ffi!(FPDFAction_GetDest(doc, action)) }; + } + } + if dest.is_null() { + return (None, None); + } + let page_index = unsafe { ffi!(FPDFDest_GetDestPageIndex(doc, dest)) }; + let page_index = if page_index >= 0 { + Some(page_index) + } else { + None + }; + + let mut has_x: pdfium_sys::FPDF_BOOL = 0; + let mut has_y: pdfium_sys::FPDF_BOOL = 0; + let mut has_z: pdfium_sys::FPDF_BOOL = 0; + let mut x: f32 = 0.0; + let mut y: f32 = 0.0; + let mut z: f32 = 0.0; + let ok = unsafe { + ffi!(FPDFDest_GetLocationInPage( + dest, &mut has_x, &mut has_y, &mut has_z, &mut x, &mut y, &mut z + )) + }; + let y_out = if ok != 0 && has_y != 0 { Some(y) } else { None }; + (page_index, y_out) +} + +impl Drop for Document<'_> { + fn drop(&mut self) { + unsafe { ffi!(FPDF_CloseDocument(self.handle)) }; + } +} diff --git a/crates/pdfium/src/error.rs b/crates/pdfium/src/error.rs new file mode 100644 index 0000000..e8082cf --- /dev/null +++ b/crates/pdfium/src/error.rs @@ -0,0 +1,44 @@ +use std::fmt; + +use crate::ffi; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PdfiumError { + Unknown, + FileNotFound, + InvalidFormat, + PasswordRequired, + UnsupportedSecurity, + PageNotFound, + OperationFailed, +} + +impl PdfiumError { + pub(crate) fn from_last_error() -> Self { + let code = unsafe { ffi!(FPDF_GetLastError()) }; + match code as u32 { + pdfium_sys::FPDF_ERR_FILE => PdfiumError::FileNotFound, + pdfium_sys::FPDF_ERR_FORMAT => PdfiumError::InvalidFormat, + pdfium_sys::FPDF_ERR_PASSWORD => PdfiumError::PasswordRequired, + pdfium_sys::FPDF_ERR_SECURITY => PdfiumError::UnsupportedSecurity, + pdfium_sys::FPDF_ERR_PAGE => PdfiumError::PageNotFound, + _ => PdfiumError::Unknown, + } + } +} + +impl fmt::Display for PdfiumError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + PdfiumError::Unknown => write!(f, "unknown pdfium error"), + PdfiumError::FileNotFound => write!(f, "file not found"), + PdfiumError::InvalidFormat => write!(f, "invalid PDF format"), + PdfiumError::PasswordRequired => write!(f, "password required"), + PdfiumError::UnsupportedSecurity => write!(f, "unsupported security handler"), + PdfiumError::PageNotFound => write!(f, "page not found"), + PdfiumError::OperationFailed => write!(f, "operation failed"), + } + } +} + +impl std::error::Error for PdfiumError {} diff --git a/crates/pdfium/src/font.rs b/crates/pdfium/src/font.rs new file mode 100644 index 0000000..0f44d01 --- /dev/null +++ b/crates/pdfium/src/font.rs @@ -0,0 +1,291 @@ +use crate::ffi; + +/// Wrapper around FPDF_FONT obtained from a text object. +/// This is a borrowed handle — it does not own the font and must not outlive +/// the page object it was obtained from. +pub struct Font { + handle: pdfium_sys::FPDF_FONT, +} + +/// Font type enum matching PDFium's FPDF_FONT_TYPE values. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FontType { + Unknown, + Type1, + TrueType, + Type0, + Type3, + CidType0, + CidType2, +} + +impl Font { + /// Create a Font from a text page object handle. + /// Returns None if the object has no font. + /// + /// # Safety + /// `obj` must be a valid `FPDF_PAGEOBJECT` handle obtained from PDFium. + pub unsafe fn from_text_object(obj: pdfium_sys::FPDF_PAGEOBJECT) -> Option { + let handle = unsafe { ffi!(FPDFTextObj_GetFont(obj)) }; + if handle.is_null() { + None + } else { + Some(Font { handle }) + } + } + + pub fn handle(&self) -> pdfium_sys::FPDF_FONT { + self.handle + } + + /// Get the base font name (PostScript name, subset prefix stripped by PDFium). + pub fn base_name(&self) -> Option { + let len = unsafe { + ffi!(FPDFFont_GetBaseFontName( + self.handle, + std::ptr::null_mut(), + 0 + )) + }; + if len == 0 { + return None; + } + let mut buf: Vec = vec![0; len]; + let written = unsafe { + ffi!(FPDFFont_GetBaseFontName( + self.handle, + buf.as_mut_ptr() as *mut std::ffi::c_char, + len, + )) + }; + if written == 0 { + return None; + } + // Strip trailing NUL + let str_len = if written > 0 && buf[written - 1] == 0 { + written - 1 + } else { + written + }; + Some(String::from_utf8_lossy(&buf[..str_len]).into_owned()) + } + + /// Get font type. + pub fn font_type(&self) -> FontType { + let t = unsafe { ffi!(FPDFFont_GetType(self.handle)) }; + match t { + pdfium_sys::FPDF_FONT_TYPE_FPDF_FONTTYPE_TYPE1 => FontType::Type1, + pdfium_sys::FPDF_FONT_TYPE_FPDF_FONTTYPE_TRUETYPE => FontType::TrueType, + pdfium_sys::FPDF_FONT_TYPE_FPDF_FONTTYPE_TYPE0 => FontType::Type0, + pdfium_sys::FPDF_FONT_TYPE_FPDF_FONTTYPE_TYPE3 => FontType::Type3, + pdfium_sys::FPDF_FONT_TYPE_FPDF_FONTTYPE_CID_TYPE0 => FontType::CidType0, + pdfium_sys::FPDF_FONT_TYPE_FPDF_FONTTYPE_CID_TYPE2 => FontType::CidType2, + _ => FontType::Unknown, + } + } + + /// Whether the font is embedded in the PDF. + pub fn is_embedded(&self) -> bool { + unsafe { ffi!(FPDFFont_GetIsEmbedded(self.handle)) != 0 } + } + + /// Get font ascent for a given em size. + pub fn ascent(&self, font_size: f32) -> Option { + let mut val: f32 = 0.0; + let ok = unsafe { ffi!(FPDFFont_GetAscent(self.handle, font_size, &mut val)) }; + if ok != 0 { Some(val) } else { None } + } + + /// Get font descent for a given em size (typically negative). + pub fn descent(&self, font_size: f32) -> Option { + let mut val: f32 = 0.0; + let ok = unsafe { ffi!(FPDFFont_GetDescent(self.handle, font_size, &mut val)) }; + if ok != 0 { Some(val) } else { None } + } + + /// Get glyph width using the raw character code. + pub fn glyph_width_from_char_code(&self, char_code: u32, font_size: f32) -> Option { + let mut width: f32 = 0.0; + let ok = unsafe { + ffi!(FPDFFont_GetGlyphWidthFromCharCode( + self.handle, + char_code, + font_size, + &mut width, + )) + }; + if ok != 0 { Some(width) } else { None } + } + + /// Walk the vector outline of the glyph for `char_code`, returning one + /// entry per path segment as `(segment_type, x, y)`. `segment_type` is the + /// raw PDFium `FPDF_SEGMENT_*` value (LINETO=0, BEZIERTO=1, MOVETO=2). + /// Segments with type `FPDF_SEGMENT_UNKNOWN` (-1) are skipped, and a point + /// that cannot be read is reported as `(type, 0.0, 0.0)` — matching the + /// platform's `hashGlyphPath` packing convention so a downstream hash of + /// these segments reproduces the platform font DB's `pathHash` key. + /// + /// Returns `None` when the font has no outline for this char code + /// (e.g. whitespace / non-rendered glyph), distinct from `Some(vec![])`. + pub fn glyph_path_segments( + &self, + char_code: u32, + font_size: f32, + ) -> Option> { + let glyph_path = unsafe { + ffi!(FPDFFont_GetGlyphPathFromCharCode( + self.handle, + char_code, + font_size + )) + }; + if glyph_path.is_null() { + return None; + } + let count = unsafe { ffi!(FPDFGlyphPath_CountGlyphSegments(glyph_path)) }; + let mut segments = Vec::new(); + for i in 0..count { + let segment = unsafe { ffi!(FPDFGlyphPath_GetGlyphPathSegment(glyph_path, i)) }; + if segment.is_null() { + break; + } + let seg_type = unsafe { ffi!(FPDFPathSegment_GetType(segment)) }; + if seg_type == pdfium_sys::FPDF_SEGMENT_UNKNOWN { + continue; + } + let mut x: f32 = 0.0; + let mut y: f32 = 0.0; + let ok = unsafe { ffi!(FPDFPathSegment_GetPoint(segment, &mut x, &mut y)) }; + if ok == 0 { + x = 0.0; + y = 0.0; + } + segments.push((seg_type, x, y)); + } + Some(segments) + } + + /// Get glyph width using a Unicode codepoint. + pub fn glyph_width(&self, unicode: u32, font_size: f32) -> Option { + let mut width: f32 = 0.0; + let ok = unsafe { + ffi!(FPDFFont_GetGlyphWidth( + self.handle, + unicode, + font_size, + &mut width + )) + }; + if ok != 0 { Some(width) } else { None } + } + + /// Whether the font dictionary defines a /ToUnicode CMap. When false, the + /// unicode values PDFium reports for this font's chars are derived from + /// the encoding alone and may be garbage for custom/Identity encodings. + pub fn has_to_unicode(&self) -> bool { + unsafe { ffi!(FPDFFont_HasToUnicode(self.handle)) != 0 } + } + + /// Get the font's /Encoding name ("WinAnsiEncoding", "Identity-H", ...), + /// the /BaseEncoding name when /Encoding is a dict, or "Custom" for + /// font-private encodings. + pub fn encoding(&self) -> Option { + let len = + unsafe { ffi!(FPDFFont_GetEncoding(self.handle, std::ptr::null_mut(), 0)) } as usize; + if len == 0 { + return None; + } + let mut buf: Vec = vec![0; len]; + let written = unsafe { + ffi!(FPDFFont_GetEncoding( + self.handle, + buf.as_mut_ptr() as *mut std::ffi::c_char, + len as u64 as _, + )) + } as usize; + if written == 0 { + return None; + } + let str_len = if buf[written - 1] == 0 { + written - 1 + } else { + written + }; + Some(String::from_utf8_lossy(&buf[..str_len]).into_owned()) + } + + /// Get the PostScript glyph name the font assigns to a raw char code + /// (from /Encoding /Differences, falling back to the embedded font + /// program's glyph name table). Resolve against the Adobe Glyph List to + /// recover unicode when /ToUnicode is missing. + pub fn char_glyph_name(&self, char_code: u32) -> Option { + let len = unsafe { + ffi!(FPDFFont_GetCharGlyphName( + self.handle, + char_code, + std::ptr::null_mut(), + 0 + )) + } as usize; + if len == 0 { + return None; + } + let mut buf: Vec = vec![0; len]; + let written = unsafe { + ffi!(FPDFFont_GetCharGlyphName( + self.handle, + char_code, + buf.as_mut_ptr() as *mut std::ffi::c_char, + len as u64 as _, + )) + } as usize; + if written == 0 { + return None; + } + let str_len = if buf[written - 1] == 0 { + written - 1 + } else { + written + }; + Some(String::from_utf8_lossy(&buf[..str_len]).into_owned()) + } + + /// Get the glyph index in the embedded font program for a raw char code. + /// Pair with glyph-path rendering for a per-glyph OCR fallback. + pub fn char_glyph_index(&self, char_code: u32) -> Option { + let idx = unsafe { ffi!(FPDFFont_GetCharGlyphIndex(self.handle, char_code)) }; + if idx >= 0 { Some(idx as u32) } else { None } + } + + /// Get the embedded font program bytes (decompressed FontFile/2/3 stream, + /// or the substitute font data for non-embedded fonts). + pub fn font_data(&self) -> Option> { + let mut size: usize = 0; + let ok = unsafe { + ffi!(FPDFFont_GetFontData( + self.handle, + std::ptr::null_mut(), + 0, + &mut size + )) + }; + if ok == 0 || size == 0 { + return None; + } + let mut buf: Vec = vec![0; size]; + let mut written: usize = 0; + let ok = unsafe { + ffi!(FPDFFont_GetFontData( + self.handle, + buf.as_mut_ptr(), + buf.len(), + &mut written + )) + }; + if ok == 0 || written == 0 { + return None; + } + buf.truncate(written); + Some(buf) + } +} diff --git a/crates/pdfium/src/lib.rs b/crates/pdfium/src/lib.rs new file mode 100644 index 0000000..f357b3c --- /dev/null +++ b/crates/pdfium/src/lib.rs @@ -0,0 +1,39 @@ +mod bitmap; +mod document; +mod error; +mod font; +mod library; +mod page; +mod struct_tree; +mod text_page; +mod types; + +pub use bitmap::Bitmap; +pub use document::{Document, OutlineEntry}; +pub use error::PdfiumError; +pub use font::{Font, FontType}; +pub use library::Library; +pub use page::{ + ImageBounds, Page, PathObject, PathSegment, PdfLink, SegmentKind, ViewportTransform, +}; +pub use struct_tree::StructNode; +pub use text_page::{TextChar, TextCharIter, TextPage}; +pub use types::*; + +/// Unified FFI call macro. On wasm, calls pdfium_sys extern functions directly. +/// On non-wasm, calls through the runtime-loaded function pointers. +#[cfg(not(target_arch = "wasm32"))] +macro_rules! ffi { + ($fn_name:ident($($args:expr),* $(,)?)) => { + (pdfium_sys::dynamic::pdfium().$fn_name)($($args),*) + } +} + +#[cfg(target_arch = "wasm32")] +macro_rules! ffi { + ($fn_name:ident($($args:expr),* $(,)?)) => { + pdfium_sys::$fn_name($($args),*) + } +} + +pub(crate) use ffi; diff --git a/crates/pdfium/src/library.rs b/crates/pdfium/src/library.rs new file mode 100644 index 0000000..b0dbf8f --- /dev/null +++ b/crates/pdfium/src/library.rs @@ -0,0 +1,145 @@ +use std::ffi::CString; +use std::sync::Once; +#[cfg(not(target_arch = "wasm32"))] +use std::sync::{Mutex, MutexGuard, OnceLock}; + +use crate::document::Document; +use crate::error::PdfiumError; +use crate::ffi; + +static INIT: Once = Once::new(); + +/// Process-global PDFium serialization lock. +/// +/// PDFium's FFI is **not thread-safe**: concurrent calls (even across distinct +/// documents) corrupt internal state and cause heap UB (double-free / heap +/// corruption). Every [`Library`] handle holds this mutex for its entire +/// lifetime, and the owning PDFium resources ([`Document`], `Page`, +/// `TextPage`, `Bitmap`) borrow from a [`Library`] via their `'lib` lifetime, +/// so the borrow checker statically prevents PDFium work outside the lock. +/// (`Font` is a borrowed, non-owning handle constructed through an `unsafe` +/// fn; its lock discipline is the caller's responsibility, not statically +/// enforced.) +#[cfg(not(target_arch = "wasm32"))] +fn pdfium_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) +} + +/// A live, locked PDFium session. +/// +/// Holding a `Library` proves the current thread has exclusive, +/// process-wide access to PDFium. All PDFium resources ([`Document`] etc.) +/// borrow from this handle, which makes it impossible to call into PDFium +/// without first acquiring the lock. +/// +/// `Library` is intentionally **not `Clone`**. To use PDFium from a +/// different scope, call [`Library::init`] again — this will block until +/// any other in-flight PDFium work has finished. +/// +/// On `wasm32` there is no threading, so the lock is elided. +/// +/// The snippet below must fail to compile — a `Document` cannot outlive +/// the `Library` that opened it: +/// +/// ```compile_fail +/// use liteparse_pdfium::{Library, Document}; +/// let doc: Document<'static> = { +/// let lib = Library::init(); +/// lib.load_document("x.pdf", None).unwrap() +/// }; +/// // `lib` was dropped above — using `doc` here is a use-after-unlock. +/// let _ = doc.page_count(); +/// ``` +pub struct Library { + #[cfg(not(target_arch = "wasm32"))] + _guard: MutexGuard<'static, ()>, + #[cfg(target_arch = "wasm32")] + _private: (), +} + +impl Library { + /// Acquire the process-wide PDFium lock, blocking the current thread + /// until any other in-flight PDFium work has finished. Initializes the + /// library on first call. + /// + /// Multiple concurrent callers are serialized; only one `Library` + /// instance exists at a time. + pub fn init() -> Library { + #[cfg(not(target_arch = "wasm32"))] + { + pdfium_sys::dynamic::load_default().expect("failed to load pdfium shared library"); + // Recover from poisoning: a panic mid-FFI may leave PDFium in + // an odd state, but subsequent calls should still be allowed + // (the worst case is that the next parse also fails cleanly). + let guard = pdfium_lock() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + INIT.call_once(|| unsafe { ffi!(FPDF_InitLibrary()) }); + Library { _guard: guard } + } + #[cfg(target_arch = "wasm32")] + { + INIT.call_once(|| unsafe { ffi!(FPDF_InitLibrary()) }); + Library { _private: () } + } + } + + pub fn load_document( + &self, + path: &str, + password: Option<&str>, + ) -> Result, PdfiumError> { + let c_path = CString::new(path).map_err(|_| PdfiumError::FileNotFound)?; + let c_password = password + .map(|p| CString::new(p).map_err(|_| PdfiumError::OperationFailed)) + .transpose()?; + + let handle = unsafe { + ffi!(FPDF_LoadDocument( + c_path.as_ptr(), + c_password.as_ref().map_or(std::ptr::null(), |p| p.as_ptr()), + )) + }; + + if handle.is_null() { + return Err(PdfiumError::from_last_error()); + } + + Ok(Document { + handle, + _lib: std::marker::PhantomData, + }) + } + + pub fn load_document_from_bytes( + &self, + data: &[u8], + password: Option<&str>, + ) -> Result, PdfiumError> { + let c_password = password + .map(|p| CString::new(p).map_err(|_| PdfiumError::OperationFailed)) + .transpose()?; + + let handle = unsafe { + ffi!(FPDF_LoadMemDocument( + data.as_ptr() as *const std::ffi::c_void, + data.len() as i32, + c_password.as_ref().map_or(std::ptr::null(), |p| p.as_ptr()), + )) + }; + + if handle.is_null() { + return Err(PdfiumError::from_last_error()); + } + + // SAFETY: pdfium requires the data buffer to outlive the document. + // The caller must ensure `data` lives long enough. For owned data, + // consider passing a Vec and having the Document hold it. + // For now, this is the caller's responsibility. + Ok(Document { + handle, + _lib: std::marker::PhantomData, + }) + } +} diff --git a/crates/pdfium/src/page.rs b/crates/pdfium/src/page.rs new file mode 100644 index 0000000..f6db71b --- /dev/null +++ b/crates/pdfium/src/page.rs @@ -0,0 +1,894 @@ +use std::marker::PhantomData; + +use crate::bitmap::Bitmap; +use crate::document::Document; +use crate::error::PdfiumError; +use crate::ffi; +use crate::text_page::TextPage; +use crate::types::{Color, RectF}; + +/// Bounding box of an embedded image object on a page. +/// Coordinates are in PDF points with top-left origin (Y-down). +#[derive(Debug, Clone, Copy)] +pub struct ImageBounds { + pub x: f32, + pub y: f32, + pub width: f32, + pub height: f32, +} + +/// One segment of a vector path. Coordinates are in viewport space +/// (top-left origin, 72 DPI) after the object's matrix has been applied. +#[derive(Debug, Clone, Copy)] +pub struct PathSegment { + pub kind: SegmentKind, + pub x: f32, + pub y: f32, + /// Whether this segment closes the current subpath back to its MoveTo. + pub close: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SegmentKind { + MoveTo, + LineTo, + BezierTo, +} + +/// A vector path object extracted from a page. Used by the markdown emitter +/// for ruled-table, horizontal-rule, and figure-cluster detection. +#[derive(Debug, Clone)] +pub struct PathObject { + /// Object bbox in viewport space (after matrix; from FPDFPageObj_GetBounds). + pub bbox: RectF, + pub stroke_color: Option, + pub fill_color: Option, + pub stroke_width: f32, + /// True when the path is stroked per its draw mode. + pub is_stroked: bool, + /// True when the path is filled (draw-mode fill ≠ NONE). + pub is_filled: bool, + pub segments: Vec, +} + +/// A URI hyperlink annotation on a page. `rect` is in viewport space +/// (top-left origin, 72 DPI), matching `TextItem` coordinates so the URI can +/// be assigned to overlapping text. Only external URI links are represented; +/// internal GoTo/named destinations are excluded. +#[derive(Debug, Clone)] +pub struct PdfLink { + pub rect: RectF, + pub uri: String, +} + +/// A loaded page within a [`Document`]. +/// +/// The `'doc` lifetime ties the page to its owning document; `'lib` carries +/// the PDFium-lock lifetime through, ensuring no PDFium calls can occur +/// after the lock is released. +pub struct Page<'doc, 'lib: 'doc> { + pub(crate) handle: pdfium_sys::FPDF_PAGE, + pub(crate) doc_handle: pdfium_sys::FPDF_DOCUMENT, + pub(crate) _doc: PhantomData<&'doc Document<'lib>>, +} + +impl<'doc, 'lib: 'doc> Page<'doc, 'lib> { + pub fn width(&self) -> f32 { + unsafe { ffi!(FPDF_GetPageWidthF(self.handle)) } + } + + pub fn height(&self) -> f32 { + unsafe { ffi!(FPDF_GetPageHeightF(self.handle)) } + } + + pub fn rotation(&self) -> i32 { + unsafe { ffi!(FPDFPage_GetRotation(self.handle)) } + } + + /// Get the page bounding box (CropBox, falls back to MediaBox). + /// Coordinates in PDF page space. + pub fn view_box(&self) -> Option { + let mut rect = pdfium_sys::FS_RECTF { + left: 0.0, + top: 0.0, + right: 0.0, + bottom: 0.0, + }; + let ok = unsafe { ffi!(FPDF_GetPageBoundingBox(self.handle, &mut rect)) }; + if ok != 0 { + Some(RectF { + left: rect.left, + top: rect.top, + right: rect.right, + bottom: rect.bottom, + }) + } else { + None + } + } + + /// Convert a point from PDF page space to viewport space (top-left origin, 72 DPI). + /// Mirrors the platform's Parse_pageToViewport using FPDF_PageToDevice at 1000x scale. + pub fn page_to_viewport(&self, view_box: &RectF, page_x: f32, page_y: f32) -> (f32, f32) { + let mut vw = view_box.right - view_box.left; + let mut vh = view_box.top - view_box.bottom; + + let rotation = self.rotation(); + if rotation == 1 || rotation == 3 { + // 90° or 270° — swap viewport dimensions + std::mem::swap(&mut vw, &mut vh); + } + + let device_w = (vw * 1000.0).round() as i32; + let device_h = (vh * 1000.0).round() as i32; + let mut dx: i32 = 0; + let mut dy: i32 = 0; + + unsafe { + ffi!(FPDF_PageToDevice( + self.handle, + 0, + 0, + device_w, + device_h, + 0, // rotation 0 — PDFium applies page rotation internally + page_x as f64, + page_y as f64, + &mut dx, + &mut dy, + )); + } + + (dx as f32 / 1000.0, dy as f32 / 1000.0) + } + + /// Convert bounds from PDF page space to viewport space (top-left origin). + /// Returns RectF with left/top/right/bottom in viewport coordinates. + pub fn bounds_to_viewport(&self, view_box: &RectF, page_bounds: &RectF) -> RectF { + let (ll_x, ll_y) = self.page_to_viewport(view_box, page_bounds.left, page_bounds.bottom); + let (ur_x, ur_y) = self.page_to_viewport(view_box, page_bounds.right, page_bounds.top); + + RectF { + left: ll_x.min(ur_x), + top: ll_y.min(ur_y), + right: ll_x.max(ur_x), + bottom: ll_y.max(ur_y), + } + } + + pub fn text(&self) -> Result, PdfiumError> { + let handle = unsafe { ffi!(FPDFText_LoadPage(self.handle)) }; + if handle.is_null() { + return Err(PdfiumError::OperationFailed); + } + Ok(TextPage { + handle, + _page: PhantomData, + }) + } + + /// Render the page to a BGRA bitmap at the given DPI. + pub fn render(&self, dpi: f32) -> Result, PdfiumError> { + let scale = dpi / 72.0; + let width = (self.width() * scale).round() as i32; + let height = (self.height() * scale).round() as i32; + + // SAFETY: this method is on `Page<'_, 'lib>`, whose existence proves + // the PDFium lock is held for `'lib`; the returned `Bitmap<'lib>` is + // tied to that same lock lifetime. + let bitmap = unsafe { Bitmap::new(width, height) }?; + + // Fill with white (ARGB: 0xFFFFFFFF) + bitmap.fill_rect(0, 0, width, height, 0xFFFFFFFF); + + let flags = (pdfium_sys::FPDF_ANNOT | pdfium_sys::FPDF_PRINTING) as i32; + + unsafe { + ffi!(FPDF_RenderPageBitmap( + bitmap.handle(), + self.handle, + 0, // start_x + 0, // start_y + width, // size_x + height, // size_y + 0, // rotation + flags, + )); + } + + Ok(bitmap) + } + + /// Extract bounding boxes of embedded image objects on this page. + /// Returns coordinates in viewport space (Y-down, top-left origin) in PDF points. + /// Filters out images smaller than `min_size_pt` and images covering more than + /// `max_page_coverage` fraction of the page. + pub fn image_bounds(&self, min_size_pt: f32, max_page_coverage: f32) -> Vec { + let page_width = self.width(); + let page_height = self.height(); + let obj_count = unsafe { ffi!(FPDFPage_CountObjects(self.handle)) }; + let mut results = Vec::new(); + + for i in 0..obj_count { + let obj = unsafe { ffi!(FPDFPage_GetObject(self.handle, i)) }; + if obj.is_null() { + continue; + } + + let obj_type = unsafe { ffi!(FPDFPageObj_GetType(obj)) }; + if obj_type != pdfium_sys::FPDF_PAGEOBJ_IMAGE as i32 { + continue; + } + + let mut left: f32 = 0.0; + let mut bottom: f32 = 0.0; + let mut right: f32 = 0.0; + let mut top: f32 = 0.0; + let ok = unsafe { + ffi!(FPDFPageObj_GetBounds( + obj, + &mut left, + &mut bottom, + &mut right, + &mut top + )) + }; + if ok == 0 { + continue; + } + + let w = right - left; + let h = top - bottom; + + if w < min_size_pt || h < min_size_pt { + continue; + } + if w > page_width * max_page_coverage && h > page_height * max_page_coverage { + continue; + } + + // Convert from PDF coords (bottom-left origin) to viewport (top-left origin) + results.push(ImageBounds { + x: left, + y: page_height - top, + width: w, + height: h, + }); + } + + results + } + + /// Extract bounding boxes of filled vector path objects on this page, + /// recursing into form XObjects (with each form's transform applied). + /// Returns coordinates in viewport space (Y-down, top-left origin) in PDF + /// points. Stroke-only paths (rules, borders) are skipped, as are paths + /// smaller than `min_size_pt` in either dimension and paths covering more + /// than `max_page_coverage` fraction of the page in both dimensions + /// (full-page background rects). + pub fn filled_path_bounds(&self, min_size_pt: f32, max_page_coverage: f32) -> Vec { + let page_width = self.width(); + let page_height = self.height(); + let obj_count = unsafe { ffi!(FPDFPage_CountObjects(self.handle)) }; + let mut results = Vec::new(); + + for i in 0..obj_count { + let obj = unsafe { ffi!(FPDFPage_GetObject(self.handle, i)) }; + if obj.is_null() { + continue; + } + collect_filled_paths( + obj, + None, + page_width, + page_height, + min_size_pt, + max_page_coverage, + 0, + &mut results, + ); + } + + results + } + + /// Get the rendered bitmap of a specific embedded image object by index. + /// The index corresponds to the order from iterating page objects (image objects only). + pub fn render_image_object(&self, image_obj_index: usize) -> Result, PdfiumError> { + let obj_count = unsafe { ffi!(FPDFPage_CountObjects(self.handle)) }; + let mut image_idx = 0usize; + + for i in 0..obj_count { + let obj = unsafe { ffi!(FPDFPage_GetObject(self.handle, i)) }; + if obj.is_null() { + continue; + } + let obj_type = unsafe { ffi!(FPDFPageObj_GetType(obj)) }; + if obj_type != pdfium_sys::FPDF_PAGEOBJ_IMAGE as i32 { + continue; + } + + if image_idx == image_obj_index { + let bmp_handle = unsafe { + ffi!(FPDFImageObj_GetRenderedBitmap( + self.doc_handle, + self.handle, + obj + )) + }; + if bmp_handle.is_null() { + return Err(PdfiumError::OperationFailed); + } + // Wrap in our Bitmap (which will call Destroy on drop) + return Ok(unsafe { Bitmap::from_handle(bmp_handle) }); + } + image_idx += 1; + } + + Err(PdfiumError::OperationFailed) + } + + /// Enumerate vector path objects on this page. Segment points are + /// transformed into viewport space (top-left origin, 72 DPI) by composing + /// the object's matrix with the page→viewport transform. Recurses into + /// Form XObjects (composing each form's matrix) — table rules and other + /// vector art are frequently wrapped in a form container, invisible to a + /// top-level-only walk. + pub fn path_objects(&self, view_box: &RectF) -> Vec { + let vp = self.viewport_transform(view_box); + let obj_count = unsafe { ffi!(FPDFPage_CountObjects(self.handle)) }; + let mut out = Vec::new(); + let identity = pdfium_sys::FS_MATRIX { + a: 1.0, + b: 0.0, + c: 0.0, + d: 1.0, + e: 0.0, + f: 0.0, + }; + + for i in 0..obj_count { + let obj = unsafe { ffi!(FPDFPage_GetObject(self.handle, i)) }; + if obj.is_null() { + continue; + } + collect_path_objects(obj, &identity, &vp, 0, &mut out); + } + out + } + + /// Enumerate URI hyperlink annotations on this page. Each link's clickable + /// rectangle is mapped into viewport space (matching `TextItem`); the URI + /// is read from the link's URI action. Annotations whose action is not a + /// URI (internal GoTo / named destinations) are skipped. + pub fn links(&self, view_box: &RectF) -> Vec { + let mut out = Vec::new(); + let mut start_pos: std::os::raw::c_int = 0; + let mut link_annot: pdfium_sys::FPDF_LINK = std::ptr::null_mut(); + loop { + let ok = unsafe { + ffi!(FPDFLink_Enumerate( + self.handle, + &mut start_pos, + &mut link_annot + )) + }; + if ok == 0 { + break; + } + if link_annot.is_null() { + continue; + } + let action = unsafe { ffi!(FPDFLink_GetAction(link_annot)) }; + if action.is_null() { + continue; + } + let Some(uri) = read_uri_path(self.doc_handle, action) else { + continue; + }; + + // Prefer per-line quad points: a link that wraps across lines has + // one quad per line, each tight around the anchor text. The single + // annotation rect is their *union* — a tall box that would swallow + // the unlinked words sitting between the lines. Fall back to the + // annot rect only when no quads are present. + let quad_count = unsafe { ffi!(FPDFLink_CountQuadPoints(link_annot)) }; + let mut emitted = false; + for q in 0..quad_count { + let mut quad = pdfium_sys::FS_QUADPOINTSF::default(); + let ok = unsafe { ffi!(FPDFLink_GetQuadPoints(link_annot, q, &mut quad)) }; + if ok == 0 { + continue; + } + let page_bounds = RectF { + left: quad.x1.min(quad.x2).min(quad.x3).min(quad.x4), + bottom: quad.y1.min(quad.y2).min(quad.y3).min(quad.y4), + right: quad.x1.max(quad.x2).max(quad.x3).max(quad.x4), + top: quad.y1.max(quad.y2).max(quad.y3).max(quad.y4), + }; + out.push(PdfLink { + rect: self.bounds_to_viewport(view_box, &page_bounds), + uri: uri.clone(), + }); + emitted = true; + } + if emitted { + continue; + } + + let mut rect = pdfium_sys::FS_RECTF { + left: 0.0, + top: 0.0, + right: 0.0, + bottom: 0.0, + }; + let got = unsafe { ffi!(FPDFLink_GetAnnotRect(link_annot, &mut rect)) }; + if got == 0 { + continue; + } + let page_bounds = RectF { + left: rect.left, + top: rect.top, + right: rect.right, + bottom: rect.bottom, + }; + out.push(PdfLink { + rect: self.bounds_to_viewport(view_box, &page_bounds), + uri, + }); + } + out + } +} + +/// Read a link action's URI path. PDFium returns the URI as a NUL-terminated +/// 7-bit-ASCII byte string; the two-call protocol queries the length first. +/// Returns `None` for non-URI actions (length 0) or empty URIs. +fn read_uri_path( + doc: pdfium_sys::FPDF_DOCUMENT, + action: pdfium_sys::FPDF_ACTION, +) -> Option { + let needed = + unsafe { ffi!(FPDFAction_GetURIPath(doc, action, std::ptr::null_mut(), 0)) } as usize; + if needed < 2 { + return None; + } + let mut buf: Vec = vec![0; needed]; + let written = unsafe { + ffi!(FPDFAction_GetURIPath( + doc, + action, + buf.as_mut_ptr() as *mut std::os::raw::c_void, + needed as std::os::raw::c_ulong, + )) + } as usize; + if written < 2 { + return None; + } + // `written` includes the trailing NUL. + let end = written.saturating_sub(1).min(buf.len()); + let uri = String::from_utf8_lossy(&buf[..end]) + .trim_matches(char::from(0)) + .to_string(); + if uri.is_empty() { None } else { Some(uri) } +} + +const FS_IDENTITY: pdfium_sys::FS_MATRIX = pdfium_sys::FS_MATRIX { + a: 1.0, + b: 0.0, + c: 0.0, + d: 1.0, + e: 0.0, + f: 0.0, +}; + +/// Compose two affine matrices: `result(p) = outer(inner(p))`. +fn compose_matrix( + outer: &pdfium_sys::FS_MATRIX, + inner: &pdfium_sys::FS_MATRIX, +) -> pdfium_sys::FS_MATRIX { + pdfium_sys::FS_MATRIX { + a: outer.a * inner.a + outer.c * inner.b, + b: outer.b * inner.a + outer.d * inner.b, + c: outer.a * inner.c + outer.c * inner.d, + d: outer.b * inner.c + outer.d * inner.d, + e: outer.a * inner.e + outer.c * inner.f + outer.e, + f: outer.b * inner.e + outer.d * inner.f + outer.f, + } +} + +/// Recursively collect path objects, descending into Form XObjects. `parent` +/// is the accumulated form matrix mapping this object's content space into +/// page space (identity at the top level). +fn collect_path_objects( + obj: pdfium_sys::FPDF_PAGEOBJECT, + parent: &pdfium_sys::FS_MATRIX, + vp: &ViewportTransform, + depth: usize, + out: &mut Vec, +) { + const MAX_FORM_DEPTH: usize = 6; + let obj_type = unsafe { ffi!(FPDFPageObj_GetType(obj)) }; + + if obj_type == pdfium_sys::FPDF_PAGEOBJ_FORM as i32 { + if depth >= MAX_FORM_DEPTH { + return; + } + let mut fm = FS_IDENTITY; + unsafe { ffi!(FPDFPageObj_GetMatrix(obj, &mut fm)) }; + let combined = compose_matrix(parent, &fm); + let n = unsafe { ffi!(FPDFFormObj_CountObjects(obj)) }; + for i in 0..n { + let child = unsafe { ffi!(FPDFFormObj_GetObject(obj, i as std::os::raw::c_ulong)) }; + if child.is_null() { + continue; + } + collect_path_objects(child, &combined, vp, depth + 1, out); + } + return; + } + + if obj_type != pdfium_sys::FPDF_PAGEOBJ_PATH as i32 { + return; + } + + // Object → content-space matrix, composed with the accumulated form + // matrix to reach page space. + let mut m = FS_IDENTITY; + unsafe { ffi!(FPDFPageObj_GetMatrix(obj, &mut m)) }; + let m = compose_matrix(parent, &m); + + // GetBounds reports bounds in the object's content-stream space (its own + // matrix applied, ancestor form matrices not). Lift the corners through + // the parent matrix, then to viewport. + let mut left = 0.0f32; + let mut bottom = 0.0f32; + let mut right = 0.0f32; + let mut top = 0.0f32; + let ok = unsafe { + ffi!(FPDFPageObj_GetBounds( + obj, + &mut left, + &mut bottom, + &mut right, + &mut top + )) + }; + if ok == 0 { + return; + } + let corners = [(left, bottom), (left, top), (right, bottom), (right, top)]; + let mut min_x = f32::INFINITY; + let mut min_y = f32::INFINITY; + let mut max_x = f32::NEG_INFINITY; + let mut max_y = f32::NEG_INFINITY; + for (x, y) in corners { + let px = parent.a * x + parent.c * y + parent.e; + let py = parent.b * x + parent.d * y + parent.f; + min_x = min_x.min(px); + max_x = max_x.max(px); + min_y = min_y.min(py); + max_y = max_y.max(py); + } + let bbox = vp.transform_bounds(&RectF { + left: min_x, + top: max_y, + right: max_x, + bottom: min_y, + }); + + // Draw mode → is_filled / is_stroked. + let mut fill_mode = 0i32; + let mut stroke_bool = 0i32; + let dm_ok = unsafe { ffi!(FPDFPath_GetDrawMode(obj, &mut fill_mode, &mut stroke_bool)) }; + let (is_filled, is_stroked) = if dm_ok != 0 { + ( + fill_mode != pdfium_sys::FPDF_FILLMODE_NONE as i32, + stroke_bool != 0, + ) + } else { + (false, false) + }; + + // Colors are reported as RGBA channels in 0..=255 cuint. + let stroke_color = + read_color(|r, g, b, a| unsafe { ffi!(FPDFPageObj_GetStrokeColor(obj, r, g, b, a)) }); + let fill_color = + read_color(|r, g, b, a| unsafe { ffi!(FPDFPageObj_GetFillColor(obj, r, g, b, a)) }); + + let mut stroke_width = 0.0f32; + unsafe { ffi!(FPDFPageObj_GetStrokeWidth(obj, &mut stroke_width)) }; + + // Walk segments. Points are in the object's local coords; apply the + // composed matrix → page, then viewport transform. + let n_segs = unsafe { ffi!(FPDFPath_CountSegments(obj)) }; + let mut segments = Vec::with_capacity(n_segs.max(0) as usize); + for si in 0..n_segs { + let seg = unsafe { ffi!(FPDFPath_GetPathSegment(obj, si)) }; + if seg.is_null() { + continue; + } + let mut sx = 0.0f32; + let mut sy = 0.0f32; + let pt_ok = unsafe { ffi!(FPDFPathSegment_GetPoint(seg, &mut sx, &mut sy)) }; + if pt_ok == 0 { + continue; + } + let ty = unsafe { ffi!(FPDFPathSegment_GetType(seg)) }; + let close = unsafe { ffi!(FPDFPathSegment_GetClose(seg)) } != 0; + let kind = match ty as u32 { + pdfium_sys::FPDF_SEGMENT_MOVETO => SegmentKind::MoveTo, + pdfium_sys::FPDF_SEGMENT_LINETO => SegmentKind::LineTo, + pdfium_sys::FPDF_SEGMENT_BEZIERTO => SegmentKind::BezierTo, + _ => continue, + }; + + // Apply the composed matrix (FS_MATRIX is column-major a/b/c/d/e/f + // matching the PDF text-matrix convention used elsewhere). + let page_x = m.a * sx + m.c * sy + m.e; + let page_y = m.b * sx + m.d * sy + m.f; + let (x, y) = vp.transform_point(page_x, page_y); + segments.push(PathSegment { kind, x, y, close }); + } + + out.push(PathObject { + bbox, + stroke_color, + fill_color, + stroke_width, + is_stroked, + is_filled, + segments, + }); +} + +/// Helper: call a PDFium getter for RGBA color channels and pack into our `Color`. +/// Returns None when the FFI call reports failure. +fn read_color(getter: F) -> Option +where + F: FnOnce(*mut u32, *mut u32, *mut u32, *mut u32) -> i32, +{ + let mut r = 0u32; + let mut g = 0u32; + let mut b = 0u32; + let mut a = 0u32; + let ok = getter(&mut r, &mut g, &mut b, &mut a); + if ok == 0 { + return None; + } + Some(Color { + r: r as u8, + g: g as u8, + b: b as u8, + a: a as u8, + }) +} + +/// Recursion limit for nested form XObjects in `filled_path_bounds`. +const MAX_FORM_DEPTH: u32 = 4; + +/// Compose two FS_MATRIX transforms: the result applies `inner` first, +/// then `outer` (i.e. `outer ∘ inner`). +fn compose_matrices( + outer: &pdfium_sys::FS_MATRIX, + inner: &pdfium_sys::FS_MATRIX, +) -> pdfium_sys::FS_MATRIX { + pdfium_sys::FS_MATRIX { + a: outer.a * inner.a + outer.c * inner.b, + b: outer.b * inner.a + outer.d * inner.b, + c: outer.a * inner.c + outer.c * inner.d, + d: outer.b * inner.c + outer.d * inner.d, + e: outer.a * inner.e + outer.c * inner.f + outer.e, + f: outer.b * inner.e + outer.d * inner.f + outer.f, + } +} + +#[allow(clippy::too_many_arguments)] +fn collect_filled_paths( + obj: pdfium_sys::FPDF_PAGEOBJECT, + transform: Option<&pdfium_sys::FS_MATRIX>, + page_width: f32, + page_height: f32, + min_size_pt: f32, + max_page_coverage: f32, + depth: u32, + out: &mut Vec, +) { + let obj_type = unsafe { ffi!(FPDFPageObj_GetType(obj)) }; + + if obj_type == pdfium_sys::FPDF_PAGEOBJ_FORM as i32 { + if depth >= MAX_FORM_DEPTH { + return; + } + // Child bounds are reported in the form's coordinate space, so the + // form matrix (composed with any outer form transforms) must be + // applied to map them into page space. + let mut m = pdfium_sys::FS_MATRIX { + a: 1.0, + b: 0.0, + c: 0.0, + d: 1.0, + e: 0.0, + f: 0.0, + }; + let has_m = unsafe { ffi!(FPDFPageObj_GetMatrix(obj, &mut m)) } != 0; + let combined = match (transform, has_m) { + (Some(outer), true) => Some(compose_matrices(outer, &m)), + (Some(outer), false) => Some(*outer), + (None, true) => Some(m), + (None, false) => None, + }; + + let child_count = unsafe { ffi!(FPDFFormObj_CountObjects(obj)) }; + for i in 0..child_count { + let child = unsafe { ffi!(FPDFFormObj_GetObject(obj, i as std::os::raw::c_ulong)) }; + if child.is_null() { + continue; + } + collect_filled_paths( + child, + combined.as_ref(), + page_width, + page_height, + min_size_pt, + max_page_coverage, + depth + 1, + out, + ); + } + return; + } + + if obj_type != pdfium_sys::FPDF_PAGEOBJ_PATH as i32 { + return; + } + + // Only filled paths can be glyph outlines; skip stroke-only paths + // (table borders, rules, underlines). + let mut fill_mode: std::os::raw::c_int = 0; + let mut stroke: pdfium_sys::FPDF_BOOL = 0; + let ok = unsafe { ffi!(FPDFPath_GetDrawMode(obj, &mut fill_mode, &mut stroke)) }; + if ok == 0 || fill_mode == pdfium_sys::FPDF_FILLMODE_NONE as i32 { + return; + } + + // Skip light or transparent fills: glyph outlines are drawn in ink-like + // (dark, opaque) colors, while table zebra striping and section shading + // use light pastels. Light-on-dark text still gets caught because the + // dark background rect itself is a dark filled path. Paths whose fill + // color can't be read (pattern/shading fills) are kept conservatively. + let mut r: std::os::raw::c_uint = 0; + let mut g: std::os::raw::c_uint = 0; + let mut b: std::os::raw::c_uint = 0; + let mut a: std::os::raw::c_uint = 0; + let ok = unsafe { + ffi!(FPDFPageObj_GetFillColor( + obj, &mut r, &mut g, &mut b, &mut a + )) + }; + if ok != 0 { + if a < 128 { + return; + } + let luminance = 0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32; + if luminance > 140.0 { + return; + } + } + + let mut left: f32 = 0.0; + let mut bottom: f32 = 0.0; + let mut right: f32 = 0.0; + let mut top: f32 = 0.0; + let ok = unsafe { + ffi!(FPDFPageObj_GetBounds( + obj, + &mut left, + &mut bottom, + &mut right, + &mut top + )) + }; + if ok == 0 { + return; + } + + if let Some(m) = transform { + let corners = [(left, bottom), (right, bottom), (left, top), (right, top)]; + let mut min_x = f32::INFINITY; + let mut max_x = f32::NEG_INFINITY; + let mut min_y = f32::INFINITY; + let mut max_y = f32::NEG_INFINITY; + for (x, y) in corners { + let tx = m.a * x + m.c * y + m.e; + let ty = m.b * x + m.d * y + m.f; + min_x = min_x.min(tx); + max_x = max_x.max(tx); + min_y = min_y.min(ty); + max_y = max_y.max(ty); + } + left = min_x; + right = max_x; + bottom = min_y; + top = max_y; + } + + let w = right - left; + let h = top - bottom; + + if w < min_size_pt || h < min_size_pt { + return; + } + if w > page_width * max_page_coverage && h > page_height * max_page_coverage { + return; + } + + out.push(ImageBounds { + x: left, + y: page_height - top, + width: w, + height: h, + }); +} + +/// Pre-computed affine transform from PDF page space to viewport space. +/// Avoids repeated FFI calls to `FPDF_PageToDevice` by probing 3 points +/// once and deriving the 6 affine coefficients. +#[derive(Debug, Clone, Copy)] +pub struct ViewportTransform { + a: f32, + b: f32, + c: f32, + d: f32, + e: f32, + f: f32, +} + +impl ViewportTransform { + /// Transform a single point from page space to viewport space. + #[inline] + pub fn transform_point(&self, page_x: f32, page_y: f32) -> (f32, f32) { + ( + self.a * page_x + self.b * page_y + self.e, + self.c * page_x + self.d * page_y + self.f, + ) + } + + /// Transform a bounding rect from page space to viewport space. + #[inline] + pub fn transform_bounds(&self, page_bounds: &RectF) -> RectF { + let (ll_x, ll_y) = self.transform_point(page_bounds.left, page_bounds.bottom); + let (ur_x, ur_y) = self.transform_point(page_bounds.right, page_bounds.top); + RectF { + left: ll_x.min(ur_x), + top: ll_y.min(ur_y), + right: ll_x.max(ur_x), + bottom: ll_y.max(ur_y), + } + } +} + +impl<'doc, 'lib: 'doc> Page<'doc, 'lib> { + /// Build a `ViewportTransform` by probing 3 points through PDFium. + /// This makes 3 FFI calls total, after which all transforms are pure math. + pub fn viewport_transform(&self, view_box: &RectF) -> ViewportTransform { + let (e, f) = self.page_to_viewport(view_box, 0.0, 0.0); + let (ax_e, cx_f) = self.page_to_viewport(view_box, 1.0, 0.0); + let (by_e, dy_f) = self.page_to_viewport(view_box, 0.0, 1.0); + + ViewportTransform { + a: ax_e - e, + b: by_e - e, + c: cx_f - f, + d: dy_f - f, + e, + f, + } + } +} + +impl Drop for Page<'_, '_> { + fn drop(&mut self) { + unsafe { ffi!(FPDF_ClosePage(self.handle)) }; + } +} diff --git a/crates/pdfium/src/struct_tree.rs b/crates/pdfium/src/struct_tree.rs new file mode 100644 index 0000000..6767fe5 --- /dev/null +++ b/crates/pdfium/src/struct_tree.rs @@ -0,0 +1,225 @@ +//! Walk the PDF structure tree (tagged-PDF tree) for a page. +//! +//! Each struct element has: +//! - a role (string: "H1", "Figure", "Table", ...) +//! - zero or more marked content ids (mcids) that tie the element to +//! page-content stream blocks +//! - zero or more child elements +//! +//! For LiteParse's purposes we want a flat list of nodes with: +//! - the role string +//! - viewport-space bbox derived from joining page objects whose +//! `FPDFPageObj_GetMarkedContentID` matches one of this node's mcids +//! - the mcid list itself (so the layout pass can map text runs to nodes) + +use crate::ffi; +use crate::page::{Page, ViewportTransform}; +use crate::types::RectF; + +/// One node in the structure tree, flattened for downstream use. +#[derive(Debug, Clone)] +pub struct StructNode { + /// Role string from `FPDF_StructElement_GetType` (e.g. "H1", "P", "Figure"). + pub role: String, + /// Marked content ids attached to this element (and its non-struct child markers). + pub mcids: Vec, + /// Union bbox of page objects tagged with any of `mcids`, in viewport + /// coordinates (top-left origin, 72 DPI). `None` when none of the mcids + /// resolved to a bbox on the page. + pub bbox: Option, + /// Optional alt text (set for Figure / Formula elements when present). + pub alt_text: Option, +} + +impl Page<'_, '_> { + /// Walk this page's structure tree (tagged-PDF tree). Returns an empty + /// vec when the page is untagged or the document has no struct tree. + /// Nodes are returned in pre-order (parent before children). + pub fn struct_tree(&self, view_box: &RectF) -> Vec { + let tree = unsafe { ffi!(FPDF_StructTree_GetForPage(self.handle)) }; + if tree.is_null() { + return Vec::new(); + } + + let mcid_bboxes = collect_mcid_bboxes(self, view_box); + let mut out = Vec::new(); + + let count = unsafe { ffi!(FPDF_StructTree_CountChildren(tree)) }; + for i in 0..count { + let elem = unsafe { ffi!(FPDF_StructTree_GetChildAtIndex(tree, i)) }; + if !elem.is_null() { + walk_element(elem, &mcid_bboxes, &mut out); + } + } + + unsafe { ffi!(FPDF_StructTree_Close(tree)) }; + + out + } +} + +/// Pre-scan all page objects on the page, building `mcid → union(bbox)` in +/// viewport space. Each struct node then unions the bboxes for its own mcids. +fn collect_mcid_bboxes( + page: &Page<'_, '_>, + view_box: &RectF, +) -> std::collections::HashMap { + let vp = page.viewport_transform(view_box); + let obj_count = unsafe { ffi!(FPDFPage_CountObjects(page.handle)) }; + let mut map: std::collections::HashMap = std::collections::HashMap::new(); + + for i in 0..obj_count { + let obj = unsafe { ffi!(FPDFPage_GetObject(page.handle, i)) }; + if obj.is_null() { + continue; + } + let mcid = unsafe { ffi!(FPDFPageObj_GetMarkedContentID(obj)) }; + if mcid < 0 { + continue; + } + let bbox = page_object_bbox(obj, &vp); + if let Some(b) = bbox { + map.entry(mcid) + .and_modify(|cur| *cur = union_rect(cur, &b)) + .or_insert(b); + } + } + + map +} + +fn page_object_bbox(obj: pdfium_sys::FPDF_PAGEOBJECT, vp: &ViewportTransform) -> Option { + let mut left = 0.0f32; + let mut bottom = 0.0f32; + let mut right = 0.0f32; + let mut top = 0.0f32; + let ok = unsafe { + ffi!(FPDFPageObj_GetBounds( + obj, + &mut left, + &mut bottom, + &mut right, + &mut top + )) + }; + if ok == 0 { + return None; + } + Some(vp.transform_bounds(&RectF { + left, + top, + right, + bottom, + })) +} + +fn union_rect(a: &RectF, b: &RectF) -> RectF { + RectF { + left: a.left.min(b.left), + top: a.top.min(b.top), + right: a.right.max(b.right), + bottom: a.bottom.max(b.bottom), + } +} + +fn walk_element( + elem: pdfium_sys::FPDF_STRUCTELEMENT, + mcid_bboxes: &std::collections::HashMap, + out: &mut Vec, +) { + let role = read_element_type(elem); + let alt_text = read_alt_text(elem); + + // Collect mcids: the multi-mcid getters + per-child marked-content-only children. + let mut mcids: Vec = Vec::new(); + let n_mcids = unsafe { ffi!(FPDF_StructElement_GetMarkedContentIdCount(elem)) }; + for i in 0..n_mcids { + let m = unsafe { ffi!(FPDF_StructElement_GetMarkedContentIdAtIndex(elem, i)) }; + if m >= 0 { + mcids.push(m); + } + } + // Also legacy: single direct mcid getter (older tag-trees expose it here). + let single = unsafe { ffi!(FPDF_StructElement_GetMarkedContentID(elem)) }; + if single >= 0 && !mcids.contains(&single) { + mcids.push(single); + } + + let n_children = unsafe { ffi!(FPDF_StructElement_CountChildren(elem)) }; + for i in 0..n_children { + // Non-struct children expose their mcid via GetChildMarkedContentID + // (returns -1 when the child is itself a struct element). + let child_mcid = unsafe { ffi!(FPDF_StructElement_GetChildMarkedContentID(elem, i)) }; + if child_mcid >= 0 && !mcids.contains(&child_mcid) { + mcids.push(child_mcid); + } + } + + let bbox = union_mcid_bboxes(&mcids, mcid_bboxes); + out.push(StructNode { + role, + mcids, + bbox, + alt_text, + }); + + for i in 0..n_children { + let child_elem = unsafe { ffi!(FPDF_StructElement_GetChildAtIndex(elem, i)) }; + if !child_elem.is_null() { + walk_element(child_elem, mcid_bboxes, out); + } + } +} + +fn union_mcid_bboxes( + mcids: &[i32], + mcid_bboxes: &std::collections::HashMap, +) -> Option { + let mut acc: Option = None; + for m in mcids { + if let Some(b) = mcid_bboxes.get(m) { + acc = Some(match acc { + Some(a) => union_rect(&a, b), + None => *b, + }); + } + } + acc +} + +fn read_element_type(elem: pdfium_sys::FPDF_STRUCTELEMENT) -> String { + read_widestring(|buf, len| unsafe { ffi!(FPDF_StructElement_GetType(elem, buf, len)) }) +} + +fn read_alt_text(elem: pdfium_sys::FPDF_STRUCTELEMENT) -> Option { + let s = + read_widestring(|buf, len| unsafe { ffi!(FPDF_StructElement_GetAltText(elem, buf, len)) }); + if s.is_empty() { None } else { Some(s) } +} + +/// Read a PDFium UTF-16LE widestring out-param via the "call once for size, +/// allocate, call again" pattern. `getter` is `(buf, buflen) -> bytes_written`. +fn read_widestring(getter: F) -> String +where + F: Fn(*mut std::os::raw::c_void, std::os::raw::c_ulong) -> std::os::raw::c_ulong, +{ + let needed = getter(std::ptr::null_mut(), 0) as usize; + if needed < 2 { + return String::new(); + } + let mut buf: Vec = vec![0; needed / 2]; + let written = getter( + buf.as_mut_ptr() as *mut std::os::raw::c_void, + needed as std::os::raw::c_ulong, + ) as usize; + if written < 2 { + return String::new(); + } + let chars = written / 2; + let end = if buf.get(chars - 1) == Some(&0) { + chars - 1 + } else { + chars + }; + String::from_utf16_lossy(&buf[..end]) +} diff --git a/crates/pdfium/src/text_page.rs b/crates/pdfium/src/text_page.rs new file mode 100644 index 0000000..1e619f0 --- /dev/null +++ b/crates/pdfium/src/text_page.rs @@ -0,0 +1,456 @@ +use std::marker::PhantomData; + +use crate::ffi; +use crate::page::Page; +use crate::types::{CharBox, Color, Matrix, RectF, TextRect}; + +/// Extracted text content of a [`Page`]. +/// +/// `'page` is the borrow of the parent page; `'lib` carries the PDFium-lock +/// lifetime through so that no FFI call can occur after the lock is released. +pub struct TextPage<'page, 'lib: 'page> { + pub(crate) handle: pdfium_sys::FPDF_TEXTPAGE, + pub(crate) _page: PhantomData<&'page Page<'page, 'lib>>, +} + +impl<'page, 'lib: 'page> TextPage<'page, 'lib> { + pub fn char_count(&self) -> i32 { + unsafe { ffi!(FPDFText_CountChars(self.handle)) } + } + + pub fn chars(&self) -> TextCharIter<'_> { + TextCharIter { + text_page: self, + index: 0, + count: self.char_count(), + } + } + + pub fn char_at(&self, index: i32) -> Option> { + if index >= 0 && index < self.char_count() { + Some(TextChar { + text_page: self, + index, + }) + } else { + None + } + } + + /// Like `char_at`, but skips the `char_count()` FFI bounds check. + /// The caller must ensure `index` is in `0..char_count`. + pub fn char_at_unchecked(&self, index: i32) -> TextChar<'_> { + TextChar { + text_page: self, + index, + } + } + + /// Count rectangular areas occupied by a text segment. + /// Must be called before `rect()`. + pub fn count_rects(&self, start: i32, count: i32) -> i32 { + unsafe { ffi!(FPDFText_CountRects(self.handle, start, count)) } + } + + /// Get a rectangle from the last `count_rects()` call. + pub fn rect(&self, index: i32) -> Option { + let mut left = 0.0; + let mut top = 0.0; + let mut right = 0.0; + let mut bottom = 0.0; + let ok = unsafe { + ffi!(FPDFText_GetRect( + self.handle, + index, + &mut left, + &mut top, + &mut right, + &mut bottom, + )) + }; + if ok != 0 { + Some(TextRect { + left, + top, + right, + bottom, + }) + } else { + None + } + } + + /// Extract text within a rectangular boundary. + pub fn bounded_text(&self, left: f64, top: f64, right: f64, bottom: f64) -> String { + // First call to get required buffer length (in UTF-16 code units, excluding terminator) + let len = unsafe { + ffi!(FPDFText_GetBoundedText( + self.handle, + left, + top, + right, + bottom, + std::ptr::null_mut(), + 0, + )) + }; + if len <= 0 { + return String::new(); + } + + // Allocate buffer with space for terminator + let buf_len = len + 1; + let mut buf: Vec = vec![0; buf_len as usize]; + let written = unsafe { + ffi!(FPDFText_GetBoundedText( + self.handle, + left, + top, + right, + bottom, + buf.as_mut_ptr(), + buf_len, + )) + }; + + if written <= 0 { + return String::new(); + } + + // Strip trailing NUL if present + let text_len = if written > 0 && buf[(written - 1) as usize] == 0 { + (written - 1) as usize + } else { + written as usize + }; + + String::from_utf16_lossy(&buf[..text_len]) + } + + /// Extract text for a range of characters. + pub fn get_text(&self, start: i32, count: i32) -> String { + if count <= 0 { + return String::new(); + } + let buf_len = count + 1; // +1 for terminator + let mut buf: Vec = vec![0; buf_len as usize]; + let written = unsafe { + ffi!(FPDFText_GetText( + self.handle, + start, + count, + buf.as_mut_ptr() + )) + }; + if written <= 0 { + return String::new(); + } + let text_len = if written > 0 && buf[(written - 1) as usize] == 0 { + (written - 1) as usize + } else { + written as usize + }; + String::from_utf16_lossy(&buf[..text_len]) + } +} + +impl Drop for TextPage<'_, '_> { + fn drop(&mut self) { + unsafe { ffi!(FPDFText_ClosePage(self.handle)) }; + } +} + +// -- TextChar: zero-cost view into a TextPage -- + +pub struct TextChar<'tp> { + // We don't care about the inner lifetimes here — we only need to know + // that we're borrowing the text page (and transitively the library lock) + // for at least `'tp`. Using `'tp` for the inner params makes the type + // covariant in `'tp` and lets borrow-checking flow naturally from the + // outer borrow. + text_page: &'tp TextPage<'tp, 'tp>, + pub(crate) index: i32, +} + +impl TextChar<'_> { + pub fn unicode(&self) -> u32 { + unsafe { ffi!(FPDFText_GetUnicode(self.text_page.handle, self.index)) } + } + + /// Raw character code from the PDF content stream (not Unicode). + /// Only meaningful for non-generated characters. + pub fn char_code(&self) -> u32 { + unsafe { ffi!(FPDFText_GetCharCode(self.text_page.handle, self.index)) } + } + + pub fn font_size(&self) -> f64 { + unsafe { ffi!(FPDFText_GetFontSize(self.text_page.handle, self.index)) } + } + + pub fn font_weight(&self) -> i32 { + unsafe { ffi!(FPDFText_GetFontWeight(self.text_page.handle, self.index)) } + } + + /// Get font info name and flags. Returns (name, flags) or None. + pub fn font_info(&self) -> Option<(String, i32)> { + let mut flags: i32 = 0; + let len = unsafe { + ffi!(FPDFText_GetFontInfo( + self.text_page.handle, + self.index, + std::ptr::null_mut(), + 0, + &mut flags, + )) + }; + if len == 0 { + return None; + } + let mut buf: Vec = vec![0; len as usize]; + let written = unsafe { + ffi!(FPDFText_GetFontInfo( + self.text_page.handle, + self.index, + buf.as_mut_ptr() as *mut std::ffi::c_void, + len, + &mut flags, + )) + }; + if written == 0 { + return None; + } + let str_len = if written > 0 && buf[(written - 1) as usize] == 0 { + (written - 1) as usize + } else { + written as usize + }; + Some((String::from_utf8_lossy(&buf[..str_len]).into_owned(), flags)) + } + + pub fn font_name(&self) -> Option { + self.font_info().map(|(name, _)| name) + } + + /// Angle in radians. Returns -1 on error. + pub fn angle(&self) -> f32 { + unsafe { ffi!(FPDFText_GetCharAngle(self.text_page.handle, self.index)) } + } + + /// Get the FPDF_PAGEOBJECT for this character (for font/color extraction). + pub fn text_object(&self) -> Option { + let obj = unsafe { ffi!(FPDFText_GetTextObject(self.text_page.handle, self.index)) }; + if obj.is_null() { None } else { Some(obj) } + } + + /// Advance width of the ASCII space (char code 0x20) in this character's + /// font, expressed per em (i.e. for `font_size = 1.0`). Multiply by the + /// actual font size to get the space width in text-space points. Returns + /// `None` when the font or glyph is unavailable. Used to set a font-aware + /// threshold for detecting word boundaries when PDFium omits space glyphs. + pub fn font_space_width(&self) -> Option { + let obj = self.text_object()?; + let font = unsafe { crate::font::Font::from_text_object(obj)? }; + font.glyph_width_from_char_code(0x20, 1.0) + .filter(|w| *w > 0.0) + .or_else(|| font.glyph_width(0x20, 1.0).filter(|w| *w > 0.0)) + } + + /// Get stroke color (r, g, b, a). + pub fn stroke_color(&self) -> Option { + let mut r = 0u32; + let mut g = 0u32; + let mut b = 0u32; + let mut a = 0u32; + let ok = unsafe { + ffi!(FPDFText_GetStrokeColor( + self.text_page.handle, + self.index, + &mut r, + &mut g, + &mut b, + &mut a, + )) + }; + if ok != 0 { + Some(Color { + r: r as u8, + g: g as u8, + b: b as u8, + a: a as u8, + }) + } else { + None + } + } + + /// Get fill color (r, g, b, a). + pub fn fill_color(&self) -> Option { + let mut r = 0u32; + let mut g = 0u32; + let mut b = 0u32; + let mut a = 0u32; + let ok = unsafe { + ffi!(FPDFText_GetFillColor( + self.text_page.handle, + self.index, + &mut r, + &mut g, + &mut b, + &mut a, + )) + }; + if ok != 0 { + Some(Color { + r: r as u8, + g: g as u8, + b: b as u8, + a: a as u8, + }) + } else { + None + } + } + + /// Get text render mode from the page object. + /// Returns the raw FPDF_TEXT_RENDERMODE value (0=fill, 1=stroke, 2=fill+stroke, 3=invisible, etc.) + pub fn text_render_mode(&self) -> Option { + let obj = self.text_object()?; + let mode = unsafe { ffi!(FPDFTextObj_GetTextRenderMode(obj)) }; + if mode >= 0 { Some(mode) } else { None } + } + + /// Get marked content ID from the page object (-1 if none). + pub fn marked_content_id(&self) -> Option { + let obj = self.text_object()?; + let mcid = unsafe { ffi!(FPDFPageObj_GetMarkedContentID(obj)) }; + if mcid >= 0 { Some(mcid) } else { None } + } + + pub fn char_box(&self) -> Option { + let mut left = 0.0; + let mut right = 0.0; + let mut bottom = 0.0; + let mut top = 0.0; + let ok = unsafe { + ffi!(FPDFText_GetCharBox( + self.text_page.handle, + self.index, + &mut left, + &mut right, + &mut bottom, + &mut top, + )) + }; + if ok != 0 { + Some(CharBox { + left, + right, + bottom, + top, + }) + } else { + None + } + } + + pub fn loose_char_box(&self) -> Option { + let mut rect: pdfium_sys::FS_RECTF = pdfium_sys::FS_RECTF { + left: 0.0, + top: 0.0, + right: 0.0, + bottom: 0.0, + }; + let ok = unsafe { + ffi!(FPDFText_GetLooseCharBox( + self.text_page.handle, + self.index, + &mut rect + )) + }; + if ok != 0 { + Some(RectF { + left: rect.left, + top: rect.top, + right: rect.right, + bottom: rect.bottom, + }) + } else { + None + } + } + + pub fn matrix(&self) -> Option { + let mut m: pdfium_sys::FS_MATRIX = pdfium_sys::FS_MATRIX { + a: 0.0, + b: 0.0, + c: 0.0, + d: 0.0, + e: 0.0, + f: 0.0, + }; + let ok = unsafe { + ffi!(FPDFText_GetMatrix( + self.text_page.handle, + self.index, + &mut m + )) + }; + if ok != 0 { + Some(Matrix { + a: m.a, + b: m.b, + c: m.c, + d: m.d, + e: m.e, + f: m.f, + }) + } else { + None + } + } + + pub fn is_generated(&self) -> bool { + unsafe { ffi!(FPDFText_IsGenerated(self.text_page.handle, self.index)) == 1 } + } + + pub fn has_unicode_map_error(&self) -> bool { + unsafe { + ffi!(FPDFText_HasUnicodeMapError( + self.text_page.handle, + self.index + )) == 1 + } + } +} + +// -- TextCharIter -- + +pub struct TextCharIter<'tp> { + text_page: &'tp TextPage<'tp, 'tp>, + index: i32, + count: i32, +} + +impl<'tp> Iterator for TextCharIter<'tp> { + type Item = TextChar<'tp>; + + fn next(&mut self) -> Option { + if self.index < self.count { + let ch = TextChar { + text_page: self.text_page, + index: self.index, + }; + self.index += 1; + Some(ch) + } else { + None + } + } + + fn size_hint(&self) -> (usize, Option) { + let remaining = (self.count - self.index) as usize; + (remaining, Some(remaining)) + } +} + +impl ExactSizeIterator for TextCharIter<'_> {} diff --git a/crates/pdfium/src/types.rs b/crates/pdfium/src/types.rs new file mode 100644 index 0000000..33a9e3b --- /dev/null +++ b/crates/pdfium/src/types.rs @@ -0,0 +1,41 @@ +#[derive(Debug, Clone, Copy, Default)] +pub struct RectF { + pub left: f32, + pub top: f32, + pub right: f32, + pub bottom: f32, +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct CharBox { + pub left: f64, + pub right: f64, + pub bottom: f64, + pub top: f64, +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct Matrix { + pub a: f32, + pub b: f32, + pub c: f32, + pub d: f32, + pub e: f32, + pub f: f32, +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct Color { + pub r: u8, + pub g: u8, + pub b: u8, + pub a: u8, +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct TextRect { + pub left: f64, + pub top: f64, + pub right: f64, + pub bottom: f64, +} diff --git a/dataset_eval_utils/README.md b/dataset_eval_utils/README.md new file mode 100644 index 0000000..d876412 --- /dev/null +++ b/dataset_eval_utils/README.md @@ -0,0 +1,95 @@ +# LiteParse Eval Utils + +Utilities for generating and evaluating datasets for PDF parsing performance. Compares text extraction quality across multiple PDF parsers using LLM-based QA evaluation. + +## Setup + +Requires Python 3.12+. + +```bash +# Install the package (from the dataset_eval_utils directory) +pip install -e . +``` + +You'll need an `ANTHROPIC_API_KEY` environment variable set for the LLM-based evaluation and dataset processing tools. + +## Dataset + +An existing dataset that was generated and evaluated using this framework can be found on [huggingface](). + +You can download the dataset using the Hugging Face CLI: + +```bash +hf download run-llama/liteparse-eval-dataset --repo-type dataset --local-dir ./liteparse-eval-dataset +``` + +## CLI Tools + +### `lp-process` — Generate Ground Truth Datasets + +Processes PDF and image files using Claude's vision capabilities to generate structured QA ground truth data. + +```bash +lp-process /path/to/documents --output-dir ./ground_truth +``` + +Options: +- `--output-dir` — Directory to save output JSON files (default: `./output`) +- `--model` — Claude model to use (default: `claude-sonnet-4-5-20250929`) +- `--api-key` — Anthropic API key (or set `ANTHROPIC_API_KEY` env var) + +Each output JSON file contains document metadata and QA pairs extracted from the document pages. + +### `lp-evaluate` — Run QA Evaluation + +Evaluates parser text extraction quality by having an LLM answer questions from extracted text and judging correctness against ground truth answers. + +```bash +lp-evaluate \ + --data-dir ./documents \ + --ground-truth-dir ./ground_truth \ + --parse-provider liteparse \ + --output ./results/run1 +``` + +Options: +- `--data-dir` — Directory containing source PDF documents (required) +- `--ground-truth-dir` — Directory containing ground truth JSON files (required) +- `--output` — Path to save results (JSON + HTML report) +- `--parse-provider` — Parser to evaluate: `liteparse`, `pymupdf`, `pypdf`, `markitdown` (default: `liteparse`) +- `--llm-provider` — LLM for answering questions: `anthropic` (default: `anthropic`) + +Outputs: +- `.json` — Aggregate results with pass rates +- `_detailed.json` — Per-document results with extracted text and individual QA results +- `_report.html` — Interactive HTML report with PDF previews and QA breakdowns + +### `lp-benchmark` — Performance Benchmarking + +Measures parse latency and memory usage across providers. + +```bash +lp-benchmark document.pdf --providers pymupdf liteparse --runs 20 +``` + +Options: +- `--providers` — Providers to benchmark (default: all local providers) +- `--runs` — Number of benchmark runs per provider (default: 10) +- `--warmup` — Number of warmup runs (default: 1) +- `--output` — Path to save JSON results + +## Parser Providers + +| Provider | Library | Notes | +|----------|---------|-------| +| `liteparse` | [liteparse](https://github.com/run-llama/liteparse) | Spatial text extraction with OCR support | +| `pymupdf` | [PyMuPDF](https://pymupdf.readthedocs.io/) | Fast, mature PDF library | +| `pypdf` | [pypdf](https://pypdf.readthedocs.io/) | Pure-Python PDF library | +| `markitdown` | [MarkItDown](https://github.com/microsoft/markitdown) | Microsoft's document-to-markdown converter | + +## Evaluation Pipeline + +1. **Extract text** from PDF using the selected parser provider +2. **Answer questions** — LLM reads the extracted text and answers ground truth questions +3. **Judge answers** — A separate LLM judge evaluates whether predicted answers are semantically equivalent to expected answers +4. **Aggregate** — Pass rates are computed per-document and overall diff --git a/dataset_eval_utils/pyproject.toml b/dataset_eval_utils/pyproject.toml new file mode 100644 index 0000000..3a5c82c --- /dev/null +++ b/dataset_eval_utils/pyproject.toml @@ -0,0 +1,29 @@ +[project] +name = "liteparse-eval" +version = "0.1.0" +description = "Utilities for generating and evaluating datasets for PDF parsing performance." +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "anthropic>=0.104.1", + "pillow>=12.2.0", + "pymupdf>=1.27.2", + "pymupdf4llm>=1.27.2", + "pypdf>=6.13.3", + "pdftotext>=3.0.0", + "markitdown[all]>=0.1.5", + "opendataloader-pdf>=2.4.1", + "rapidfuzz>=3.14.5", + "liteparse>=2.0.0", +] + +[project.scripts] +lp-evaluate = "liteparse_eval.evaluation:main" +lp-process = "liteparse_eval.processing:main" +lp-benchmark = "liteparse_eval.benchmark:main" + +[tool.setuptools] +package-dir = {"" = "src"} + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/dataset_eval_utils/src/liteparse_eval/__init__.py b/dataset_eval_utils/src/liteparse_eval/__init__.py new file mode 100644 index 0000000..e8817a7 --- /dev/null +++ b/dataset_eval_utils/src/liteparse_eval/__init__.py @@ -0,0 +1,22 @@ +"""LiteParse Eval - Document parsing evaluation and benchmarking toolkit.""" + +from liteparse_eval.providers import ( + LLMProvider, + AnthropicProvider, + ParserProvider, + LiteparseProvider, + MarkItDownProvider, + PyMuPDFProvider, + PyPDFProvider, +) + +__version__ = "0.1.0" +__all__ = [ + "LLMProvider", + "AnthropicProvider", + "ParserProvider", + "LiteparseProvider", + "MarkItDownProvider", + "PyMuPDFProvider", + "PyPDFProvider", +] diff --git a/dataset_eval_utils/src/liteparse_eval/benchmark.py b/dataset_eval_utils/src/liteparse_eval/benchmark.py new file mode 100644 index 0000000..392d3d8 --- /dev/null +++ b/dataset_eval_utils/src/liteparse_eval/benchmark.py @@ -0,0 +1,328 @@ +""" +Performance benchmarking tool for parser providers. + +Benchmarks each provider across a folder of documents, reporting per-document +latency and an aggregate summary table. +""" + +import argparse +import json +import time +from pathlib import Path +from typing import Optional + +from liteparse_eval.providers import ( + ParserProvider, + LiteparseProvider, + MarkItDownProvider, + OpenDataLoaderProvider, + PdfInspectorProvider, + PdfToTextProvider, + PyMuPDFProvider, + PyMuPDF4LLMMarkdownProvider, + PyMuPDF4LLMTextProvider, + PyPDFProvider, +) + +ALL_PROVIDERS = [ + "liteparse", + "pymupdf", + "pypdf", + "markitdown", + "pdftotext", + "pymupdf4llm-text", + "pymupdf4llm-md", + "opendataloader", + "pdf-inspector", +] + +PROVIDER_MAP = { + "liteparse": LiteparseProvider, + "pymupdf": PyMuPDFProvider, + "pypdf": PyPDFProvider, + "markitdown": MarkItDownProvider, + "pdftotext": PdfToTextProvider, + "pymupdf4llm-text": PyMuPDF4LLMTextProvider, + "pymupdf4llm-md": PyMuPDF4LLMMarkdownProvider, + "opendataloader": OpenDataLoaderProvider, + "pdf-inspector": PdfInspectorProvider, +} + + +def find_pdfs(directory: Path) -> list[Path]: + """Find all PDF files in a directory (non-recursive).""" + return sorted(directory.glob("*.pdf")) + + +def count_pages(file_path: Path) -> Optional[int]: + """Return the page count of a PDF, or None if it can't be read.""" + try: + import pypdf + + return len(pypdf.PdfReader(str(file_path)).pages) + except Exception: + return None + + +def time_extraction(provider: ParserProvider, file_path: Path) -> tuple[float, int]: + """Time a single extraction, returning (seconds, text_length).""" + start = time.perf_counter() + text = provider.extract_text(file_path) + elapsed = time.perf_counter() - start + return elapsed, len(text) + + +def format_table( + providers: list[str], + doc_names: list[str], + # results[provider_name][doc_name] = (seconds, chars) | None (error) + results: dict[str, dict[str, tuple[float, int] | None]], + page_counts: dict[str, Optional[int]] | None = None, +) -> str: + """Build a formatted table string.""" + page_counts = page_counts or {} + + # Annotate each document with its page count, e.g. "457_pages.pdf (457p)". + def doc_label(doc: str) -> str: + pages = page_counts.get(doc) + return f"{doc} ({pages}p)" if pages else doc + + labels = {doc: doc_label(doc) for doc in doc_names} + + # Column widths + doc_col_w = max(len("Document"), *(len(labels[d]) for d in doc_names)) + 2 + prov_col_w = 14 + + # Header + header = f"{'Document':<{doc_col_w}}" + for p in providers: + header += f" {p:>{prov_col_w}}" + sep = "-" * len(header) + + lines = [sep, header, sep] + + # Per-document rows + for doc in doc_names: + row = f"{labels[doc]:<{doc_col_w}}" + for p in providers: + entry = results[p].get(doc) + if entry is None: + cell = "ERROR" + else: + cell = f"{entry[0]:.3f}s" + row += f" {cell:>{prov_col_w}}" + lines.append(row) + + lines.append(sep) + + # Totals row + row = f"{'TOTAL':<{doc_col_w}}" + for p in providers: + total = 0.0 + has_error = False + for doc in doc_names: + entry = results[p].get(doc) + if entry is None: + has_error = True + else: + total += entry[0] + cell = f"{total:.3f}s" + ("*" if has_error else "") + row += f" {cell:>{prov_col_w}}" + lines.append(row) + + # Average row + row = f"{'AVG/doc':<{doc_col_w}}" + for p in providers: + times = [results[p][d][0] for d in doc_names if results[p].get(d) is not None] + if times: + avg = sum(times) / len(times) + cell = f"{avg:.3f}s" + else: + cell = "N/A" + row += f" {cell:>{prov_col_w}}" + lines.append(row) + + # Per-page row (aggregate: total time over successfully-parsed pages). + total_known_pages = sum(p for p in page_counts.values() if p) + if total_known_pages: + row = f"{'MS/PAGE':<{doc_col_w}}" + for p in providers: + secs = 0.0 + pages = 0 + for doc in doc_names: + entry = results[p].get(doc) + pc = page_counts.get(doc) + if entry is not None and pc: + secs += entry[0] + pages += pc + cell = f"{secs / pages * 1000:.2f}ms" if pages else "N/A" + row += f" {cell:>{prov_col_w}}" + lines.append(row) + + lines.append(sep) + return "\n".join(lines) + + +def run_benchmark( + input_dir: Path, + providers: list[str], + output_path: Optional[Path] = None, + warmup_runs: int = 10, +) -> dict: + """ + Benchmark providers across all PDFs in a directory. + + Returns a dict suitable for JSON serialization. + """ + pdf_files = find_pdfs(input_dir) + if not pdf_files: + print(f"No PDF files found in {input_dir}") + return {} + + doc_names = [f.name for f in pdf_files] + page_counts = {f.name: count_pages(f) for f in pdf_files} + total_pages = sum(p for p in page_counts.values() if p) + + print(f"Found {len(pdf_files)} documents ({total_pages} pages) in {input_dir}") + print(f"Providers: {', '.join(providers)}") + print() + + # results[provider][doc_name] = (seconds, chars) | None + results: dict[str, dict[str, tuple[float, int] | None]] = { + p: {} for p in providers + } + + for provider_name in providers: + print(f"[{provider_name}]") + try: + provider = PROVIDER_MAP[provider_name]() + except Exception as e: + print(f" Failed to initialize: {e}\n") + for f in pdf_files: + results[provider_name][f.name] = None + continue + + # Warmup runs + if warmup_runs > 0: + print(f" Warming up ({warmup_runs} runs)...") + for _ in range(warmup_runs): + for pdf_path in pdf_files: + try: + provider.extract_text(pdf_path) + except Exception: + pass + + for pdf_path in pdf_files: + try: + elapsed, text_len = time_extraction(provider, pdf_path) + results[provider_name][pdf_path.name] = (elapsed, text_len) + print(f" {pdf_path.name}: {elapsed:.3f}s ({text_len:,} chars)") + except Exception as e: + results[provider_name][pdf_path.name] = None + print(f" {pdf_path.name}: ERROR - {e}") + print() + + # Print table + table = format_table(providers, doc_names, results, page_counts) + print(table) + + # Build JSON output + output = { + "input_dir": str(input_dir), + "documents": doc_names, + "page_counts": page_counts, + "total_pages": total_pages, + "providers": {}, + } + for p in providers: + provider_results = {} + for doc in doc_names: + entry = results[p].get(doc) + if entry is None: + provider_results[doc] = {"error": True} + else: + pc = page_counts.get(doc) + provider_results[doc] = { + "seconds": round(entry[0], 4), + "text_length": entry[1], + "ms_per_page": round(entry[0] / pc * 1000, 3) if pc else None, + } + times = [results[p][d][0] for d in doc_names if results[p].get(d) is not None] + # Aggregate ms/page over successfully-parsed pages only. + parsed_secs = sum( + results[p][d][0] + for d in doc_names + if results[p].get(d) is not None and page_counts.get(d) + ) + parsed_pages = sum( + page_counts[d] + for d in doc_names + if results[p].get(d) is not None and page_counts.get(d) + ) + output["providers"][p] = { + "per_document": provider_results, + "total_seconds": round(sum(times), 4) if times else None, + "avg_seconds": round(sum(times) / len(times), 4) if times else None, + "ms_per_page": round(parsed_secs / parsed_pages * 1000, 3) + if parsed_pages + else None, + "num_success": len(times), + "num_error": len(doc_names) - len(times), + } + + if output_path: + with open(output_path, "w") as f: + json.dump(output, f, indent=2) + print(f"\nResults saved to: {output_path}") + + return output + + +def main(): + """CLI entry point for the benchmark tool.""" + parser = argparse.ArgumentParser( + description="Benchmark parse providers across a folder of PDF documents" + ) + parser.add_argument( + "input_dir", + type=Path, + help="Directory containing PDF documents to benchmark" + ) + parser.add_argument( + "--providers", + type=str, + nargs="+", + choices=ALL_PROVIDERS, + default=ALL_PROVIDERS, + help="Parse providers to benchmark (default: all providers)" + ) + parser.add_argument( + "--output", + type=Path, + help="Path to save JSON results" + ) + parser.add_argument( + "--warmup-runs", + type=int, + default=5, + help="Number of warmup runs per provider before timing (default: 5)" + ) + + args = parser.parse_args() + + if not args.input_dir.is_dir(): + print(f"Error: Not a directory: {args.input_dir}") + return 1 + + run_benchmark( + input_dir=args.input_dir, + providers=args.providers, + output_path=args.output, + warmup_runs=args.warmup_runs, + ) + + return 0 + + +if __name__ == "__main__": + exit(main()) diff --git a/dataset_eval_utils/src/liteparse_eval/evaluation.py b/dataset_eval_utils/src/liteparse_eval/evaluation.py new file mode 100644 index 0000000..d2533ff --- /dev/null +++ b/dataset_eval_utils/src/liteparse_eval/evaluation.py @@ -0,0 +1,491 @@ +""" +Evaluation and benchmarking script for text extraction and LLM-based document understanding. + +This script provides: +1. LLM QA evaluation using an LLM judge for pass/fail evaluation +2. Latency tracking for LLM and parse operations +""" + +import argparse +import json +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import List, Optional + +from liteparse_eval.providers import ( + ParserProvider, + LLMProvider, + AnthropicProvider, + LiteparseProvider, + MarkItDownProvider, + OpenDataLoaderProvider, + PdfToTextProvider, + PyMuPDFProvider, + PyMuPDF4LLMMarkdownProvider, + PyMuPDF4LLMTextProvider, + PyPDFProvider, +) + + +@dataclass +class LatencyMetrics: + """Latency metrics for provider calls.""" + latencies: List[float] = field(default_factory=list) # Individual call latencies in seconds + + @property + def count(self) -> int: + """Number of calls.""" + return len(self.latencies) + + @property + def average(self) -> float: + """Average latency in seconds.""" + return sum(self.latencies) / len(self.latencies) if self.latencies else 0.0 + + @property + def min(self) -> float: + """Minimum latency in seconds.""" + return min(self.latencies) if self.latencies else 0.0 + + @property + def max(self) -> float: + """Maximum latency in seconds.""" + return max(self.latencies) if self.latencies else 0.0 + + @property + def stddev(self) -> float: + """Standard deviation of latency in seconds.""" + if not self.latencies or len(self.latencies) < 2: + return 0.0 + mean = self.average + variance = sum((x - mean) ** 2 for x in self.latencies) / len(self.latencies) + return variance ** 0.5 + + @property + def total(self) -> float: + """Total latency in seconds.""" + return sum(self.latencies) if self.latencies else 0.0 + + def to_dict(self) -> dict: + """Convert to dictionary for JSON serialization.""" + return { + "count": self.count, + "total_seconds": round(self.total, 3), + "average_seconds": round(self.average, 3), + "min_seconds": round(self.min, 3), + "max_seconds": round(self.max, 3), + "stddev_seconds": round(self.stddev, 3), + "individual_latencies": [round(lat, 3) for lat in self.latencies] + } + + +@dataclass +class QAResult: + """Result for a single QA pair evaluation.""" + question: str + expected_answer: str + predicted_answer: str + llm_judge_pass: bool + + +@dataclass +class QAEvalResult: + """Results for QA evaluation on a single document.""" + file_path: Path + total_questions: int + llm_judge_pass_rate: float + qa_results: List[QAResult] + llm_latency_metrics: Optional[LatencyMetrics] = None + parse_latency_seconds: Optional[float] = None + + +class Benchmark: + """Main benchmark runner for text extraction and QA evaluation.""" + + def __init__( + self, + parser_provider: Optional[ParserProvider] = None, + llm_provider: Optional[LLMProvider] = None, + llm_judge_provider: Optional[LLMProvider] = None, + ): + """ + Initialize the benchmark. + + Args: + parser_provider: Parser provider to use for text extraction + llm_provider: LLM provider to use for answering questions + llm_judge_provider: LLM provider for judge-based evaluation + """ + self.parser_provider = parser_provider + self.llm_provider = llm_provider + self.llm_judge_provider = llm_judge_provider + + def run_qa_eval( + self, + extracted_text: str, + doc_path: Path, + ground_truth_path: Path, + parse_latency: Optional[float] = None + ) -> QAEvalResult: + """ + Run QA evaluation on a single document. + + Args: + extracted_text: Extracted text from the document + doc_path: Path to the source document + ground_truth_path: Path to the ground truth JSON file + parse_latency: Time taken for text extraction in seconds + + Returns: + QAEvalResult with evaluation metrics + """ + if not self.llm_provider or not self.llm_judge_provider: + raise ValueError("LLM provider and judge provider must be configured") + + # Load ground truth + with open(ground_truth_path, "r") as f: + ground_truth = json.load(f) + + # Get predicted answers with latency tracking + predicted_answers = [] + llm_latency_metrics = LatencyMetrics() + for qa_pair in ground_truth["qa_pairs"]: + start_time = time.perf_counter() + answer = self.llm_provider.answer_question(extracted_text, qa_pair["question"]) + latency = time.perf_counter() - start_time + llm_latency_metrics.latencies.append(latency) + predicted_answers.append(answer) + + # Evaluate with LLM judge + qa_results = [] + judge_passes = 0 + + for predicted, gt_pair in zip(predicted_answers, ground_truth["qa_pairs"]): + question = gt_pair["question"] + expected = gt_pair["answer"] + + try: + llm_judge_pass = self.llm_judge_provider.evaluate_answer(question, expected, predicted) + except Exception as e: + print(f" Warning: LLM judge evaluation failed: {e}") + llm_judge_pass = False + + if llm_judge_pass: + judge_passes += 1 + + qa_results.append(QAResult( + question=question, + expected_answer=expected, + predicted_answer=predicted, + llm_judge_pass=llm_judge_pass, + )) + + total = len(ground_truth["qa_pairs"]) + llm_judge_pass_rate = judge_passes / total if total > 0 else 0.0 + + result = QAEvalResult( + file_path=doc_path, + total_questions=total, + llm_judge_pass_rate=llm_judge_pass_rate, + qa_results=qa_results, + llm_latency_metrics=llm_latency_metrics, + parse_latency_seconds=parse_latency, + ) + return result + + def run_full_benchmark( + self, + data_dir: Path, + ground_truth_dir: Path, + output_path: Optional[Path] = None + ) -> dict: + """ + Run full benchmark across all documents using batch extraction. + + Args: + data_dir: Directory containing input documents + ground_truth_dir: Directory containing ground truth JSON files + output_path: Optional path to save detailed results + + Returns: + Dictionary with aggregated benchmark results + """ + qa_results: list[QAEvalResult] = [] + extracted_texts: dict[str, str] = {} # Store extracted text for each document + + # Find all ground truth files + gt_files = sorted(ground_truth_dir.glob("*.json")) + + print(f"Running benchmark on {len(gt_files)} documents...") + + # Find all source documents and match them to ground truth files + source_docs = sorted(data_dir.glob("*.pdf")) + doc_gt_pairs: list[tuple[Path, Path]] = [] + + for gt_path in gt_files: + source_doc = next(( + doc for doc in source_docs if doc.stem == gt_path.stem + ), None) + + if not source_doc: + print(f" Warning: Could not find source document for {gt_path.name}") + continue + + doc_gt_pairs.append((source_doc, gt_path)) + + if not doc_gt_pairs: + print("No documents found to process.") + return {} + + # Extract text + parse_latency_per_doc: dict[Path, float] = {} + if self.parser_provider: + docs_to_extract = [doc for doc, _ in doc_gt_pairs] + + for doc_path in docs_to_extract: + start_time = time.perf_counter() + try: + parse_result = self.parser_provider.extract_text(doc_path) + total_time = time.perf_counter() - start_time + + extracted_texts[str(doc_path)] = parse_result + parse_latency_per_doc[doc_path] = total_time + except Exception as e: + print(f" Error: extraction failed: {e}") + parse_result = "" + + # Run QA evaluation for each document + for i, (source_doc, gt_path) in enumerate(doc_gt_pairs, 1): + print(f"\n[{i}/{len(doc_gt_pairs)}] Evaluating: {gt_path.name}") + + extracted_text = extracted_texts.get(str(source_doc), "") + parse_latency = parse_latency_per_doc.get(source_doc) + + # Run QA evaluation + try: + qa_result = self.run_qa_eval(extracted_text, source_doc, gt_path, parse_latency) + qa_results.append(qa_result) + latency_str = "" + if qa_result.llm_latency_metrics: + avg_lat = qa_result.llm_latency_metrics.average + latency_str = f" [avg LLM: {avg_lat:.2f}s]" + + print(f" QA: LLM judge pass: {qa_result.llm_judge_pass_rate:.1%}{latency_str}") + except Exception as e: + print(f" Error: QA evaluation failed: {e}") + + # Aggregate results + aggregate = {} + + if qa_results: + total_questions = sum(r.total_questions for r in qa_results) + total_llm_judge_passes = sum( + r.llm_judge_pass_rate * r.total_questions for r in qa_results + ) + + # Aggregate parse latency metrics + parse_latencies = [r.parse_latency_seconds for r in qa_results if r.parse_latency_seconds is not None] + parse_latency_metrics = LatencyMetrics(latencies=parse_latencies) if parse_latencies else None + + # Aggregate LLM latency metrics across all documents + all_llm_latencies = [] + for r in qa_results: + if r.llm_latency_metrics: + all_llm_latencies.extend(r.llm_latency_metrics.latencies) + llm_latency_metrics = LatencyMetrics(latencies=all_llm_latencies) if all_llm_latencies else None + + aggregate["qa"] = { + "total_documents": len(qa_results), + "total_questions": total_questions, + "overall_llm_judge_pass_rate": total_llm_judge_passes / total_questions if total_questions > 0 else 0.0, + "per_document_results": [ + { + "file": str(r.file_path), + "llm_judge_pass_rate": r.llm_judge_pass_rate, + "total_questions": r.total_questions, + "parse_latency_seconds": r.parse_latency_seconds, + "llm_latency_metrics": r.llm_latency_metrics.to_dict() if r.llm_latency_metrics else None + } + for r in qa_results + ] + } + + if parse_latency_metrics: + aggregate["qa"]["parse_latency_metrics"] = parse_latency_metrics.to_dict() + + if llm_latency_metrics: + aggregate["qa"]["llm_latency_metrics"] = llm_latency_metrics.to_dict() + + # Save results if requested + if output_path: + # Save aggregate results + with open(f"{output_path}.json", "w") as f: + json.dump(aggregate, f, indent=2) + print(f"\nAggregate results saved to: {output_path}") + + # Save detailed results with extracted text for debugging + detailed_output_path = output_path.parent / f"{output_path.stem}_detailed{output_path.suffix}" + detailed_results = self._build_detailed_results(qa_results, extracted_texts) + with open(f"{detailed_output_path}.json", "w") as f: + json.dump(detailed_results, f, indent=2) + print(f"Detailed results saved to: {detailed_output_path}") + + # Generate HTML report + try: + from liteparse_eval.report import HTMLReportGenerator + + html_report_path = output_path.parent / f"{output_path.stem}_report.html" + generator = HTMLReportGenerator( + detailed_results=detailed_results, + ground_truth_dir=ground_truth_dir + ) + generator.generate_report(html_report_path) + print(f"HTML report saved to: {html_report_path}") + except Exception as e: + print(f"Warning: HTML report generation failed: {e}") + # Don't fail the entire benchmark if HTML generation fails + + return aggregate + + def _build_detailed_results( + self, + qa_results: list[QAEvalResult], + extracted_texts: dict[str, str] + ) -> dict: + """ + Build detailed results including extracted text and individual test results. + + Args: + qa_results: List of QA evaluation results + extracted_texts: Dictionary mapping file paths to extracted text + + Returns: + Dictionary with detailed results for debugging + """ + detailed = {"documents": []} + + # Create a mapping of file paths to results + qa_map = {str(r.file_path): r for r in qa_results} + + # Combine results for each document + all_files = set(qa_map.keys()) + + for file_path in sorted(all_files): + doc_result = { + "file": file_path, + "extracted_text": extracted_texts.get(file_path, "") + } + + # Add QA evaluation details + if file_path in qa_map: + qa_result = qa_map[file_path] + doc_result["qa_evaluation"] = { + "llm_judge_pass_rate": qa_result.llm_judge_pass_rate, + "total_questions": qa_result.total_questions, + "parse_latency_seconds": qa_result.parse_latency_seconds, + "llm_latency_metrics": qa_result.llm_latency_metrics.to_dict() if qa_result.llm_latency_metrics else None, + "qa_pairs": [ + { + "question": qa.question, + "expected_answer": qa.expected_answer, + "predicted_answer": qa.predicted_answer, + "llm_judge_pass": qa.llm_judge_pass, + } + for qa in qa_result.qa_results + ] + } + + detailed["documents"].append(doc_result) + + return detailed + + +def main(): + """Entry point of the benchmark framework.""" + + parser = argparse.ArgumentParser( + description="Benchmark text extraction and LLM providers on document understanding tasks" + ) + parser.add_argument( + "--data-dir", + type=Path, + required=True, + help="Directory containing source documents" + ) + parser.add_argument( + "--ground-truth-dir", + type=Path, + required=True, + help="Directory containing ground truth JSON files" + ) + parser.add_argument( + "--output", + type=Path, + help="Path to save detailed benchmark results" + ) + parser.add_argument( + "--parse-provider", + type=str, + choices=["pymupdf", "pypdf", "markitdown", "liteparse", "pdftotext", "pymupdf4llm-text", "pymupdf4llm-md", "opendataloader"], + default="liteparse", + help="Parse provider to use for text extraction. (default: liteparse)" + ) + parser.add_argument( + "--llm-provider", + type=str, + choices=["anthropic"], + default="anthropic", + help="LLM provider to use. (default: anthropic)" + ) + + args = parser.parse_args() + + # Initialize parser provider + provider_map = { + "pymupdf": PyMuPDFProvider, + "pypdf": PyPDFProvider, + "markitdown": MarkItDownProvider, + "liteparse": LiteparseProvider, + "pdftotext": PdfToTextProvider, + "pymupdf4llm-text": PyMuPDF4LLMTextProvider, + "pymupdf4llm-md": PyMuPDF4LLMMarkdownProvider, + "opendataloader": OpenDataLoaderProvider, + } + if args.parse_provider not in provider_map: + raise ValueError("Please specify a valid parser provider using --parse-provider") + parser_provider = provider_map[args.parse_provider]() + + # Initialize LLM provider + if args.llm_provider == "anthropic": + llm_provider = AnthropicProvider() + else: + raise ValueError("Please specify a valid LLM provider using --llm-provider") + + # Use separate LLM judge provider + llm_judge_provider = AnthropicProvider(model="claude-haiku-4-5-20251001") + + benchmark = Benchmark( + parser_provider=parser_provider, + llm_provider=llm_provider, + llm_judge_provider=llm_judge_provider, + ) + + results = benchmark.run_full_benchmark( + data_dir=args.data_dir, + ground_truth_dir=args.ground_truth_dir, + output_path=args.output + ) + + print("\n" + "="*60) + print("BENCHMARK RESULTS") + print("="*60) + + if "qa" in results: + print(f"\nQA Evaluation:") + print(f" Overall LLM Judge Pass Rate: {results['qa']['overall_llm_judge_pass_rate']:.1%}") + print(f" Total Questions: {results['qa']['total_questions']}") + + +if __name__ == "__main__": + exit(main()) diff --git a/dataset_eval_utils/src/liteparse_eval/processing.py b/dataset_eval_utils/src/liteparse_eval/processing.py new file mode 100644 index 0000000..f723749 --- /dev/null +++ b/dataset_eval_utils/src/liteparse_eval/processing.py @@ -0,0 +1,280 @@ +""" +Process PDFs and images to create a structured dataset using Anthropic's Claude with vision. +""" + +import base64 +import json +import random +from pathlib import Path +from typing import List, Literal + +from anthropic import Anthropic +from liteparse import LiteParse +from pydantic import BaseModel, Field + + +# Define the output schema using Pydantic-like structure +class QAPair(BaseModel): + question: str = Field(..., description="A question that can only be answered using information from the page.") + answer: str = Field() + +class PageAnnotation(BaseModel): + has_text: bool = Field(..., description="Whether the document contains readable text") + document_type: Literal["academic_paper", "form", "invoice", "newspaper", "other"] = Field( + ..., description="The type of document" + ) + layout_complexity: Literal["simple", "multi_column", "complex"] = Field( + ..., description="The complexity of the document layout" + ) + qa_pairs: List[QAPair] = Field( + ..., + description="Question-answer pairs about the document", + example=[{"question": "What is the main topic?", "answer": "Sample Answer"}] + ) + + +def pdf_to_images(pdf_path: Path, dpi: int = 150) -> List[Path]: + """ + Convert a PDF to a list of image paths (one per page) using liteparse. + + Args: + pdf_path: Path to the PDF file + dpi: DPI for rendering (default: 150) + + Returns: + List of paths to generated images (one per page) + """ + parser = LiteParse() + result = parser.screenshot(pdf_path, dpi=dpi) + + return [Path(s.image_path) for s in result.screenshots] + + +def encode_image(image_path: Path) -> tuple[str, str]: + """ + Encode an image to base64 and determine its media type. + + Args: + image_path: Path to the image file + + Returns: + Tuple of (base64_encoded_data, media_type) + """ + with open(image_path, "rb") as image_file: + image_data = base64.standard_b64encode(image_file.read()).decode("utf-8") + + # Determine media type from extension + extension = image_path.suffix.lower() + media_type_map = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + ".webp": "image/webp" + } + media_type = media_type_map.get(extension, "image/jpeg") + + return image_data, media_type + + +def analyze_image_with_claude( + client: Anthropic, + image_path: Path, + model: str = "claude-sonnet-4-5-20250929" +) -> PageAnnotation | None: + """ + Analyze an image using Claude with structured outputs. + + Args: + client: Anthropic client + image_path: Path to the image to analyze + model: Model to use for analysis + + Returns: + Structured analysis result as a dictionary + """ + image_data, media_type = encode_image(image_path) + + prompt = """Analyze this document image and provide a structured analysis. + +Extract: +1. Whether it contains readable text +2. The document type (academic_paper, form, invoice, newspaper, or other) +3. Layout complexity (simple, multi_column, or complex) +4. Generate 3-5 question-answer pairs about the document content + +Be thorough and accurate in your analysis. This data will be used in a document parsing benchmark (LLM-as-a-judge on QA responses), so extracted data should be interesting, diverse, and sometimes challenging.""" + + # With .parse() - can pass Pydantic model directly + response = client.beta.messages.parse( + model=model, + max_tokens=8192, + betas=["structured-outputs-2025-11-13"], + messages=[ + { + "role": "user", + "content": [ + { + "type": "image", + "source": { + "type": "base64", + "media_type": media_type, + "data": image_data, + }, + }, + { + "type": "text", + "text": prompt + } + ], + } + ], + output_format=PageAnnotation, + ) + + return response.parsed_output + + +def process_file( + client: Anthropic, + file_path: Path, + output_dir: Path, + model: str = "claude-sonnet-4-5-20250929" +) -> None: + """ + Process a single file (PDF or image) and save results. + + Args: + client: Anthropic client + file_path: Path to the file to process + output_dir: Directory to save output JSON files + model: Model to use for analysis + """ + print(f"Processing: {file_path}") + + # Handle PDFs vs images + if file_path.suffix.lower() == ".pdf": + try: + image_paths = pdf_to_images(file_path) + except NotImplementedError: + print(f" Skipping PDF (conversion not implemented): {file_path}") + return + else: + # Single image file + image_paths = [file_path] + + # Process each page/image + for page_num, image_path in enumerate(image_paths, start=1): + try: + print(f" Analyzing page {page_num}/{len(image_paths)}...") + result = analyze_image_with_claude(client, image_path, model) + + if result is None: + print(f" ✗ No results for page {page_num} in {file_path}") + continue + + # Create output filename + if len(image_paths) > 1: + # Multi-page PDF + output_filename = f"{file_path.stem}_page_{page_num:03d}.json" + else: + # Single image + output_filename = f"{file_path.stem}.json" + + output_path = output_dir / output_filename + + # Save result + with open(output_path, "w", encoding="utf-8") as f: + json.dump(result.model_dump(), f, indent=2, ensure_ascii=False) + + print(f" ✓ Saved: {output_path}") + + except Exception as e: + print(f" ✗ Error processing page {page_num}: {e}") + + +def find_documents(input_dir: Path) -> List[Path]: + """ + Find all PDF and image files in a directory (recursively). + + Args: + input_dir: Directory to search + + Returns: + List of paths to document files + """ + supported_extensions = {".pdf", ".jpg", ".jpeg", ".png", ".gif", ".webp"} + documents = [] + + for ext in supported_extensions: + documents.extend(input_dir.rglob(f"*{ext}")) + documents.extend(input_dir.rglob(f"*{ext.upper()}")) + + return sorted(set(documents)) + + +def main(): + """Main entry point.""" + import argparse + + parser = argparse.ArgumentParser( + description="Process PDFs and images to create a structured dataset" + ) + parser.add_argument( + "input_dir", + type=Path, + help="Directory containing PDFs and/or images to process" + ) + parser.add_argument( + "--output-dir", + type=Path, + default=Path("output"), + help="Directory to save output JSON files (default: ./output)" + ) + parser.add_argument( + "--model", + type=str, + default="claude-sonnet-4-5-20250929", + help="Claude model to use (default: claude-sonnet-4-5-20250929)" + ) + parser.add_argument( + "--api-key", + type=str, + help="Anthropic API key (or set ANTHROPIC_API_KEY environment variable)" + ) + + args = parser.parse_args() + + # Validate input directory + if not args.input_dir.exists(): + print(f"Error: Input directory does not exist: {args.input_dir}") + return 1 + + # Create output directory + args.output_dir.mkdir(parents=True, exist_ok=True) + + # Initialize Anthropic client + client = Anthropic(api_key=args.api_key) if args.api_key else Anthropic() + + # Find all documents + documents = find_documents(args.input_dir) + + if not documents: + print(f"No documents found in {args.input_dir}") + return 1 + + + documents = random.sample(documents, 50) + print(f"Found {len(documents)} document(s) to process\n") + + # Process each document + for i, doc_path in enumerate(documents, start=1): + print(f"\n[{i}/{len(documents)}]") + process_file(client, doc_path, args.output_dir, args.model) + + print(f"\n✓ Complete! Results saved to: {args.output_dir}") + return 0 + + +if __name__ == "__main__": + exit(main()) diff --git a/dataset_eval_utils/src/liteparse_eval/providers/__init__.py b/dataset_eval_utils/src/liteparse_eval/providers/__init__.py new file mode 100644 index 0000000..420f4b3 --- /dev/null +++ b/dataset_eval_utils/src/liteparse_eval/providers/__init__.py @@ -0,0 +1,29 @@ +from .llm import LLMProvider, AnthropicProvider, QA_PROMPT +from .parsers import ( + ParserProvider, + LiteparseProvider, + MarkItDownProvider, + OpenDataLoaderProvider, + PdfInspectorProvider, + PdfToTextProvider, + PyMuPDFProvider, + PyMuPDF4LLMMarkdownProvider, + PyMuPDF4LLMTextProvider, + PyPDFProvider, +) + +__all__ = [ + "LLMProvider", + "AnthropicProvider", + "QA_PROMPT", + "ParserProvider", + "LiteparseProvider", + "MarkItDownProvider", + "OpenDataLoaderProvider", + "PdfInspectorProvider", + "PdfToTextProvider", + "PyMuPDFProvider", + "PyMuPDF4LLMMarkdownProvider", + "PyMuPDF4LLMTextProvider", + "PyPDFProvider", +] diff --git a/dataset_eval_utils/src/liteparse_eval/providers/llm/__init__.py b/dataset_eval_utils/src/liteparse_eval/providers/llm/__init__.py new file mode 100644 index 0000000..a01bd4a --- /dev/null +++ b/dataset_eval_utils/src/liteparse_eval/providers/llm/__init__.py @@ -0,0 +1,4 @@ +from .base import LLMProvider, QA_PROMPT +from .anthropic import AnthropicProvider + +__all__ = ["LLMProvider", "AnthropicProvider", "QA_PROMPT"] diff --git a/dataset_eval_utils/src/liteparse_eval/providers/llm/anthropic.py b/dataset_eval_utils/src/liteparse_eval/providers/llm/anthropic.py new file mode 100644 index 0000000..4ba4878 --- /dev/null +++ b/dataset_eval_utils/src/liteparse_eval/providers/llm/anthropic.py @@ -0,0 +1,74 @@ +from anthropic import Anthropic + +from .base import LLMProvider, QA_PROMPT, JUDGE_PROMPT + + +class AnthropicProvider(LLMProvider): + """ + LLM provider using Anthropic for QA. + """ + + def __init__(self, api_key: str = None, model: str = "claude-sonnet-4-5-20250929"): + """ + Initialize Anthropic QA provider. + + Args: + api_key: Anthropic API key (or use ANTHROPIC_API_KEY env var) + model: Claude model to use + """ + + self.client = Anthropic(api_key=api_key, max_retries=100, timeout=10000) + self.model = model + + def answer_question(self, ocr_text: str, question: str) -> str: + """Answer a question about an image using Claude.""" + + # Call Claude + response = self.client.messages.create( + model=self.model, + max_tokens=1024, + messages=[ + { + "role": "user", + "content": QA_PROMPT.format(ocr_text=ocr_text, question=question), + } + ], + timeout=10000, + ) + if not response.content or len(response.content) == 0: + raise ValueError("No content returned from Anthropic response") + + return response.content[0].text + + def evaluate_answer(self, question: str, expected_answer: str, predicted_answer: str) -> bool: + """ + Evaluate whether the predicted answer is correct compared to the expected answer using an LLM judge. + """ + + # Call the LLM judge + judge_prompt = JUDGE_PROMPT.format( + question=question, + expected_answer=expected_answer, + predicted_answer=predicted_answer, + ) + + response = self.client.messages.create( + model=self.model, + max_tokens=256, + messages=[ + { + "role": "user", + "content": judge_prompt, + } + ], + timeout=10000, + ) + if not response.content or len(response.content) == 0: + # If the judge fails to respond, we can choose to be lenient and pass the answer + return True + + # Check if the response is "" or "" + resp_text = response.content[0].text.strip() + + resp_text_lower = resp_text.lower() + return "{question} + +{expected_answer} + +{predicted_answer} + +Do these answers convey the same information? Consider: +1. Are the answers semantically equivalent, even if wording differs? +2. Is the meaning preserved even if wording differs? +3. Some answers rely on the question for context, so ensure that the predicted answer is correct in the context of the question, even if it is not verbatim the same as the expected answer. +4. If the predicted answer says "not found" or similar, it should only pass if the expected answer also indicates the information is not present. + +Respond with "{{short explanation}}" if the answers are semantically equivalent, or "{{short explanation}}" if they are not. The explanation should be concise, ideally one sentence, and should justify the pass/fail decision based on the criteria above.""" + + +class LLMProvider(ABC): + """Abstract base class for LLM providers.""" + + @abstractmethod + def answer_question(self, image_path: Path, question: str) -> str: + """ + Answer a question about an image. + + Args: + image_path: Path to the image file + question: Question to answer + + Returns: + Answer as a string + """ + pass + + @abstractmethod + def evaluate_answer(self, question: str, expected_answer: str, predicted_answer: str) -> bool: + """ + Evaluate whether the predicted answer is correct compared to the expected answer. + + Args: + expected_answer: The ground truth answer + predicted_answer: The answer generated by the LLM + + Returns: + True if the predicted answer is correct, False otherwise + """ + pass diff --git a/dataset_eval_utils/src/liteparse_eval/providers/parsers/__init__.py b/dataset_eval_utils/src/liteparse_eval/providers/parsers/__init__.py new file mode 100644 index 0000000..b6b23d7 --- /dev/null +++ b/dataset_eval_utils/src/liteparse_eval/providers/parsers/__init__.py @@ -0,0 +1,23 @@ +from .base import ParserProvider +from .liteparse import LiteparseProvider +from .markitdown import MarkItDownProvider +from .opendataloader import OpenDataLoaderProvider +from .pdf_inspector import PdfInspectorProvider +from .pdftotext import PdfToTextProvider +from .pymupdf import PyMuPDFProvider +from .pymupdf4llm_md import PyMuPDF4LLMMarkdownProvider +from .pymupdf4llm_text import PyMuPDF4LLMTextProvider +from .pypdf import PyPDFProvider + +__all__ = [ + "ParserProvider", + "LiteparseProvider", + "MarkItDownProvider", + "OpenDataLoaderProvider", + "PdfInspectorProvider", + "PdfToTextProvider", + "PyMuPDFProvider", + "PyMuPDF4LLMMarkdownProvider", + "PyMuPDF4LLMTextProvider", + "PyPDFProvider", +] diff --git a/dataset_eval_utils/src/liteparse_eval/providers/parsers/base.py b/dataset_eval_utils/src/liteparse_eval/providers/parsers/base.py new file mode 100644 index 0000000..c6153ab --- /dev/null +++ b/dataset_eval_utils/src/liteparse_eval/providers/parsers/base.py @@ -0,0 +1,37 @@ +from abc import ABC, abstractmethod +from pathlib import Path + + +class ParserProvider(ABC): + """Abstract base class for text extraction/parsing providers.""" + + @abstractmethod + def extract_text(self, file_path: Path) -> str: + """ + Extract text from a document. + + Args: + file_path: Path to the document file + + Returns: + Extracted text as a single string + """ + pass + + def extract_text_batch(self, file_paths: list[Path]) -> dict[Path, str]: + """ + Extract text from multiple documents in batch. + + Override this method for providers that support native batch processing. + The default implementation processes files sequentially. + + Args: + file_paths: List of paths to document files + + Returns: + Dictionary mapping file paths to extracted text + """ + results = {} + for file_path in file_paths: + results[file_path] = self.extract_text(file_path) + return results diff --git a/dataset_eval_utils/src/liteparse_eval/providers/parsers/liteparse.py b/dataset_eval_utils/src/liteparse_eval/providers/parsers/liteparse.py new file mode 100644 index 0000000..89c1557 --- /dev/null +++ b/dataset_eval_utils/src/liteparse_eval/providers/parsers/liteparse.py @@ -0,0 +1,51 @@ +from pathlib import Path +from typing import Optional + +from liteparse import LiteParse + +from .base import ParserProvider + + +class LiteparseProvider(ParserProvider): + """ + Parser provider using the liteparse Python wrapper. + + This provider uses the liteparse library for PDF text extraction. + """ + + def __init__( + self, + ocr_enabled: bool = False, + ocr_server_url: Optional[str] = None, + ocr_language: str = "en", + max_pages: int = 1000, + dpi: int = 150, + preserve_very_small_text: bool = False, + ): + """ + Initialize the liteparse provider. + + Args: + ocr_enabled: Whether to enable OCR for scanned documents + ocr_server_url: URL of HTTP OCR server (uses Tesseract if not provided) + ocr_language: Language code for OCR (e.g., "en", "fr", "de") + max_pages: Maximum number of pages to parse + dpi: DPI for rendering (affects OCR quality) + preserve_very_small_text: Whether to preserve very small text + cli_path: Custom path to liteparse CLI (auto-detected if not provided) + """ + self.parser = LiteParse( + ocr_enabled=ocr_enabled, + ocr_server_url=ocr_server_url, + ocr_language=ocr_language, + max_pages=max_pages, + dpi=dpi, + preserve_very_small_text=preserve_very_small_text, + output_format="markdown", + quiet=True, + ) + + def extract_text(self, file_path: Path) -> str: + """Extract markdown from a document using liteparse.""" + result = self.parser.parse(file_path) + return result.text diff --git a/dataset_eval_utils/src/liteparse_eval/providers/parsers/markitdown.py b/dataset_eval_utils/src/liteparse_eval/providers/parsers/markitdown.py new file mode 100644 index 0000000..8bf94c8 --- /dev/null +++ b/dataset_eval_utils/src/liteparse_eval/providers/parsers/markitdown.py @@ -0,0 +1,28 @@ +from pathlib import Path + +from markitdown import MarkItDown + +from .base import ParserProvider + + +class MarkItDownProvider(ParserProvider): + """ + Parse provider using MarkItDown. + + Install with: pip install markitdown + """ + + def __init__(self, config: dict | None = None): + """ + Initialize the parse provider. + + Args: + config: Configuration dict parameters for MarkItDown + """ + self.config = config or {} + self.markitdown = MarkItDown(**self.config) + + def extract_text(self, file_path: Path) -> str: + """Extract text from a document using MarkItDown.""" + result = self.markitdown.convert(str(file_path)) + return result.text_content diff --git a/dataset_eval_utils/src/liteparse_eval/providers/parsers/opendataloader.py b/dataset_eval_utils/src/liteparse_eval/providers/parsers/opendataloader.py new file mode 100644 index 0000000..cf47c51 --- /dev/null +++ b/dataset_eval_utils/src/liteparse_eval/providers/parsers/opendataloader.py @@ -0,0 +1,38 @@ +import tempfile +from pathlib import Path + +import opendataloader_pdf + +from .base import ParserProvider + + +class OpenDataLoaderProvider(ParserProvider): + """ + Parse provider using OpenDataLoader PDF. + + Install with: pip install opendataloader-pdf + Requires: Java 11+ + """ + + def __init__(self): + """Initialize the parse provider.""" + pass + + def extract_text(self, file_path: Path) -> str: + """Extract markdown from a document using OpenDataLoader PDF.""" + with tempfile.TemporaryDirectory() as tmp_dir: + opendataloader_pdf.convert( + input_path=[str(file_path)], + output_dir=tmp_dir, + format="markdown", + quiet=True, + ) + # Read the output markdown file + output_file = Path(tmp_dir) / f"{file_path.stem}.md" + if output_file.exists(): + return output_file.read_text(encoding="utf-8") + # Fallback: find any .md file in the output dir + md_files = list(Path(tmp_dir).glob("*.md")) + if md_files: + return md_files[0].read_text(encoding="utf-8") + return "" diff --git a/dataset_eval_utils/src/liteparse_eval/providers/parsers/pdftotext.py b/dataset_eval_utils/src/liteparse_eval/providers/parsers/pdftotext.py new file mode 100644 index 0000000..97c3896 --- /dev/null +++ b/dataset_eval_utils/src/liteparse_eval/providers/parsers/pdftotext.py @@ -0,0 +1,24 @@ +from pathlib import Path + +import pdftotext + +from .base import ParserProvider + + +class PdfToTextProvider(ParserProvider): + """ + Parse provider using pdftotext (poppler-based). + + Install with: pip install pdftotext + Requires system dependency: libpoppler-cpp-dev (Linux) or poppler (macOS via brew) + """ + + def __init__(self): + """Initialize the parse provider.""" + pass + + def extract_text(self, file_path: Path) -> str: + """Extract text from a document using pdftotext.""" + with open(file_path, "rb") as f: + pdf = pdftotext.PDF(f, physical=True) + return "\n\n".join(pdf) diff --git a/dataset_eval_utils/src/liteparse_eval/providers/parsers/pymupdf.py b/dataset_eval_utils/src/liteparse_eval/providers/parsers/pymupdf.py new file mode 100644 index 0000000..36ed2eb --- /dev/null +++ b/dataset_eval_utils/src/liteparse_eval/providers/parsers/pymupdf.py @@ -0,0 +1,23 @@ +from pathlib import Path + +import fitz # PyMuPDF + +from .base import ParserProvider + + +class PyMuPDFProvider(ParserProvider): + """ + Parse provider using PyMuPDF. + + Install with: pip install pymupdf + """ + + def __init__(self): + """Initialize the parse provider.""" + pass + + def extract_text(self, file_path: Path) -> str: + """Extract text from a document using PyMuPDF.""" + doc = fitz.open(str(file_path)) + text = "\n\n".join(page.get_text() for page in doc) + return text diff --git a/dataset_eval_utils/src/liteparse_eval/providers/parsers/pymupdf4llm_md.py b/dataset_eval_utils/src/liteparse_eval/providers/parsers/pymupdf4llm_md.py new file mode 100644 index 0000000..493b770 --- /dev/null +++ b/dataset_eval_utils/src/liteparse_eval/providers/parsers/pymupdf4llm_md.py @@ -0,0 +1,21 @@ +from pathlib import Path + +import pymupdf4llm + +from .base import ParserProvider + + +class PyMuPDF4LLMMarkdownProvider(ParserProvider): + """ + Parse provider using PyMuPDF4LLM in markdown mode. + + Install with: pip install pymupdf4llm + """ + + def __init__(self): + """Initialize the parse provider.""" + pass + + def extract_text(self, file_path: Path) -> str: + """Extract text from a document using pymupdf4llm (markdown).""" + return pymupdf4llm.to_markdown(str(file_path), use_ocr=False) diff --git a/dataset_eval_utils/src/liteparse_eval/providers/parsers/pymupdf4llm_text.py b/dataset_eval_utils/src/liteparse_eval/providers/parsers/pymupdf4llm_text.py new file mode 100644 index 0000000..a6c0207 --- /dev/null +++ b/dataset_eval_utils/src/liteparse_eval/providers/parsers/pymupdf4llm_text.py @@ -0,0 +1,21 @@ +from pathlib import Path + +import pymupdf4llm + +from .base import ParserProvider + + +class PyMuPDF4LLMTextProvider(ParserProvider): + """ + Parse provider using PyMuPDF4LLM in plain text mode. + + Install with: pip install pymupdf4llm + """ + + def __init__(self): + """Initialize the parse provider.""" + pass + + def extract_text(self, file_path: Path) -> str: + """Extract text from a document using pymupdf4llm (plain text).""" + return pymupdf4llm.to_text(str(file_path), use_ocr=False) diff --git a/dataset_eval_utils/src/liteparse_eval/providers/parsers/pypdf.py b/dataset_eval_utils/src/liteparse_eval/providers/parsers/pypdf.py new file mode 100644 index 0000000..dc71a7b --- /dev/null +++ b/dataset_eval_utils/src/liteparse_eval/providers/parsers/pypdf.py @@ -0,0 +1,28 @@ +from pathlib import Path + +import pypdf + +from .base import ParserProvider + + +class PyPDFProvider(ParserProvider): + """ + Parse provider using PyPDF. + + Install with: pip install pypdf + """ + + def __init__(self, config: dict | None = None): + """ + Initialize the parse provider. + + Args: + config: Configuration dict parameters for PyPDF + """ + self.config = config or {} + + def extract_text(self, file_path: Path) -> str: + """Extract text from a document using PyPDF.""" + result = pypdf.PdfReader(str(file_path), **self.config) + text = "\n\n".join(page.extract_text() for page in result.pages) + return text diff --git a/dataset_eval_utils/src/liteparse_eval/report.py b/dataset_eval_utils/src/liteparse_eval/report.py new file mode 100644 index 0000000..528c1f0 --- /dev/null +++ b/dataset_eval_utils/src/liteparse_eval/report.py @@ -0,0 +1,626 @@ +"""HTML report generation for text extraction evaluation results.""" + +import base64 +import html +import io +from datetime import datetime +from pathlib import Path + +import fitz # PyMuPDF + +try: + from PIL import Image + HAS_PIL = True +except ImportError: + HAS_PIL = False + + +class HTMLReportGenerator: + """Generate HTML reports from text extraction evaluation results.""" + + def __init__(self, detailed_results: dict, ground_truth_dir: Path): + """ + Initialize the HTML report generator. + + Args: + detailed_results: Dictionary containing detailed evaluation results + ground_truth_dir: Path to the directory containing ground truth JSON files + """ + self.detailed_results = detailed_results + self.ground_truth_dir = ground_truth_dir + self.documents = detailed_results.get("documents", []) + + def generate_report(self, output_path: Path) -> None: + """ + Generate complete HTML report and save to file. + + Args: + output_path: Path where the HTML report will be saved + """ + html_content = self._build_html() + output_path.write_text(html_content, encoding="utf-8") + + def _build_html(self) -> str: + """Build the complete HTML document.""" + css = self._generate_css() + summary = self._generate_summary_html() + navigation = self._generate_navigation_html() + documents_html = self._generate_all_documents_html() + + return f""" + + + + + Parsing Evaluation Report - {datetime.now().strftime('%Y-%m-%d %H:%M')} + + + + {navigation} + {summary} + {documents_html} + +""" + + def _generate_css(self) -> str: + """Generate CSS styles for the report.""" + return """ body { + font-family: system-ui, -apple-system, sans-serif; + margin: 0; + padding: 0; + background: #f5f5f5; + } + .container { + max-width: 1400px; + margin: 0 auto; + padding: 20px; + } + nav { + position: sticky; + top: 0; + background: #fff; + padding: 15px 20px; + border-bottom: 2px solid #ddd; + z-index: 100; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + } + nav h2 { + margin: 0 0 10px 0; + font-size: 1.25em; + } + .nav-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 10px; + max-height: 0; + overflow: hidden; + transition: max-height 0.3s ease; + } + .nav-list.expanded { + max-height: 500px; + overflow-y: auto; + } + .toggle-nav { + background: #3b82f6; + color: white; + border: none; + padding: 8px 16px; + border-radius: 4px; + cursor: pointer; + font-size: 0.9em; + } + .toggle-nav:hover { + background: #2563eb; + } + .nav-item { + padding: 8px 12px; + background: #f9fafb; + border-radius: 4px; + text-decoration: none; + color: #1f2937; + display: flex; + justify-content: space-between; + align-items: center; + border-left: 4px solid #d1d5db; + } + .nav-item:hover { + background: #e5e7eb; + } + .nav-item.good { + border-left-color: #22c55e; + } + .nav-item.warning { + border-left-color: #f59e0b; + } + .nav-item.poor { + border-left-color: #ef4444; + } + .nav-item-metrics { + font-size: 0.85em; + color: #6b7280; + } + .summary { + background: #fff; + padding: 30px; + margin: 20px 0; + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); + } + .summary h1 { + margin: 0 0 20px 0; + color: #1f2937; + } + .metrics { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 20px; + margin: 20px 0; + } + .metric { + background: #f9fafb; + border: 1px solid #e5e7eb; + padding: 20px; + border-radius: 8px; + } + .metric-label { + font-size: 0.9em; + color: #6b7280; + margin-bottom: 8px; + } + .metric-value { + font-size: 2em; + font-weight: bold; + margin-bottom: 5px; + } + .metric-value.good { + color: #22c55e; + } + .metric-value.warning { + color: #f59e0b; + } + .metric-value.poor { + color: #ef4444; + } + .document { + background: #fff; + border: 1px solid #e5e7eb; + margin: 20px 0; + padding: 30px; + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); + } + .document-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 20px; + padding-bottom: 15px; + border-bottom: 2px solid #e5e7eb; + } + .document-title { + font-size: 1.5em; + font-weight: bold; + color: #1f2937; + word-break: break-all; + } + .edit-link { + display: inline-block; + padding: 8px 16px; + background: #3b82f6; + color: white; + text-decoration: none; + border-radius: 4px; + font-size: 0.9em; + white-space: nowrap; + } + .edit-link:hover { + background: #2563eb; + } + .section { + margin: 30px 0; + } + .section-title { + font-size: 1.25em; + font-weight: bold; + margin: 0 0 15px 0; + color: #374151; + } + .pdf-preview { + max-width: 100%; + border: 1px solid #d1d5db; + border-radius: 4px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + } + .pdf-error { + background: #fee2e2; + border: 1px solid #fecaca; + padding: 20px; + border-radius: 4px; + color: #991b1b; + } + .doc-text { + background: #f9fafb; + border: 1px solid #e5e7eb; + padding: 20px; + border-radius: 4px; + white-space: pre-wrap; + font-family: 'Courier New', monospace; + font-size: 0.9em; + max-height: 400px; + overflow-y: auto; + line-height: 1.5; + } + .qa-pairs { + display: flex; + flex-direction: column; + gap: 15px; + } + .qa-item { + padding: 20px; + background: #f9fafb; + border-radius: 4px; + border-left: 4px solid; + } + .qa-item.pass { + border-left-color: #22c55e; + } + .qa-item.fail { + border-left-color: #ef4444; + } + .qa-question { + font-weight: bold; + margin-bottom: 12px; + color: #1f2937; + } + .qa-answer { + margin: 8px 0; + padding: 10px; + background: white; + border-radius: 4px; + } + .qa-label { + font-size: 0.85em; + color: #6b7280; + margin-bottom: 4px; + } + .qa-score { + font-weight: bold; + font-size: 1.1em; + margin-top: 10px; + } + .score-badge { + display: inline-block; + padding: 4px 8px; + border-radius: 4px; + font-size: 0.85em; + font-weight: bold; + } + .score-badge.good { + background: #dcfce7; + color: #166534; + } + .score-badge.warning { + background: #fef3c7; + color: #92400e; + } + .score-badge.poor { + background: #fee2e2; + color: #991b1b; + }""" + + def _generate_summary_html(self) -> str: + """Generate aggregate metrics summary section.""" + total_docs = len(self.documents) + + # Calculate aggregate QA metrics + total_llm_judge_pass = 0 + qa_count = 0 + parse_latencies = [] + all_llm_latencies = [] + + for doc in self.documents: + qa_eval = doc.get("qa_evaluation", {}) + if qa_eval: + llm_judge_rate = qa_eval.get("llm_judge_pass_rate", 0) + total_llm_judge_pass += llm_judge_rate + qa_count += 1 + + parse_lat = qa_eval.get("parse_latency_seconds") + if parse_lat is not None: + parse_latencies.append(parse_lat) + + llm_metrics = qa_eval.get("llm_latency_metrics") + if llm_metrics: + all_llm_latencies.extend(llm_metrics.get("individual_latencies", [])) + + avg_llm_judge_pass = total_llm_judge_pass / qa_count if qa_count > 0 else 0 + judge_class = self._get_metric_class(avg_llm_judge_pass) + + # Generate latency metrics HTML + latency_metrics_html = "" + if parse_latencies: + avg_parse_lat = sum(parse_latencies) / len(parse_latencies) + latency_metrics_html += f"""
+
Parse Latency (Avg)
+
{avg_parse_lat:.2f}s
+
min: {min(parse_latencies):.2f}s, max: {max(parse_latencies):.2f}s
+
""" + + if all_llm_latencies: + avg_llm_lat = sum(all_llm_latencies) / len(all_llm_latencies) + latency_metrics_html += f"""
+
LLM Latency (Avg per call)
+
{avg_llm_lat:.2f}s
+
min: {min(all_llm_latencies):.2f}s, max: {max(all_llm_latencies):.2f}s
+
""" + + return f"""
+
+

QA Evaluation Report

+

Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}

+
+
+
Total Documents
+
{total_docs}
+
+
+
LLM Judge Pass Rate
+
{avg_llm_judge_pass:.1%}
+
Across {qa_count} documents
+
+{latency_metrics_html} +
+
+
""" + + def _generate_navigation_html(self) -> str: + """Generate navigation with anchors to each document.""" + nav_items = [] + for idx, doc in enumerate(self.documents): + filename = Path(doc["file"]).name + qa_eval = doc.get("qa_evaluation", {}) + + judge_pass = qa_eval.get("llm_judge_pass_rate", 0) + nav_class = self._get_metric_class(judge_pass) + + metrics_text = f"Judge Pass: {judge_pass:.0%}" + + nav_items.append( + f' ' + f'{html.escape(filename)}' + f'{metrics_text}' + f'' + ) + + nav_list = "\n".join(nav_items) + + return f""" """ + + def _generate_all_documents_html(self) -> str: + """Generate HTML for all documents.""" + docs_html = [] + for idx, doc in enumerate(self.documents): + docs_html.append(self._generate_document_html(doc, idx)) + + return f"""
+{chr(10).join(docs_html)} +
""" + + def _generate_document_html(self, doc: dict, index: int) -> str: + """Generate HTML for single document with all evaluations.""" + filename = Path(doc["file"]).name + pdf_path = Path(doc["file"]) + + # Generate VS Code link + vscode_link = self._generate_vscode_link(doc["file"]) + edit_button = f'Edit Ground Truth' if vscode_link else '' + + # Generate PDF preview + pdf_preview_html = self._generate_pdf_preview_html(pdf_path) + + extracted_text = doc.get("extracted_text") + extracted_text_html = f'
{html.escape(extracted_text)}
' + + # Generate QA results + qa_html = self._generate_qa_html(doc.get("qa_evaluation", {})) + + return f"""
+
+
{html.escape(filename)}
+ {edit_button} +
+ +
+

PDF Preview

+ {pdf_preview_html} +
+ +
+

Extracted Text

+ {extracted_text_html} +
+ +
+

QA Evaluation

+ {qa_html} +
+
""" + + def _generate_pdf_preview_html(self, pdf_path: Path) -> str: + """Generate PDF preview HTML with base64 encoded image.""" + try: + base64_image = self._pdf_to_base64_image(pdf_path) + return f'PDF Preview' + except Exception as e: + return f'
Failed to load PDF preview: {html.escape(str(e))}
' + + def _generate_qa_html(self, qa_eval: dict) -> str: + """Generate HTML for QA evaluation results.""" + if not qa_eval: + return '

No QA evaluation data available.

' + + llm_judge_pass_rate = qa_eval.get("llm_judge_pass_rate", 0) + total_questions = qa_eval.get("total_questions", 0) + parse_latency = qa_eval.get("parse_latency_seconds") + llm_metrics = qa_eval.get("llm_latency_metrics") + + judge_class = self._get_metric_class(llm_judge_pass_rate) + + latency_text = "" + if parse_latency is not None: + latency_text += f'

Parse Latency: {parse_latency:.2f}s

' + + if llm_metrics: + avg_lat = llm_metrics.get("average_seconds", 0) + min_lat = llm_metrics.get("min_seconds", 0) + max_lat = llm_metrics.get("max_seconds", 0) + stddev_lat = llm_metrics.get("stddev_seconds", 0) + latency_text += f'

LLM Latency: avg: {avg_lat:.2f}s, min: {min_lat:.2f}s, max: {max_lat:.2f}s, stddev: {stddev_lat:.2f}s

' + + summary = f'

LLM Judge Pass Rate: {llm_judge_pass_rate:.1%} (across {total_questions} questions)

{latency_text}' + + qa_pairs = qa_eval.get("qa_pairs", []) + if not qa_pairs: + return summary + '

No QA pairs to display.

' + + qa_items = [] + for qa in qa_pairs: + llm_judge_pass = qa.get("llm_judge_pass", False) + + item_class = "pass" if llm_judge_pass else "fail" + badge_class = "good" if llm_judge_pass else "poor" + badge_text = "PASS" if llm_judge_pass else "FAIL" + + question = html.escape(qa.get("question", "")) + expected = html.escape(qa.get("expected_answer", "")) + predicted = html.escape(qa.get("predicted_answer", "")) + + qa_items.append( + f'
' + f'
Q: {question}
' + f'
' + f'
Expected Answer:
' + f'{expected}' + f'
' + f'
' + f'
Predicted Answer:
' + f'{predicted}' + f'
' + f'
' + f'LLM Judge: {badge_text}' + f'
' + f'
' + ) + + qa_html = "\n".join(qa_items) + + return f"""{summary} +
+{qa_html} +
""" + + def _pdf_to_base64_image(self, pdf_path: Path, dpi: int = 72) -> str: + """ + Convert first page of PDF to base64-encoded image. + + Uses JPEG compression if PIL is available, otherwise PNG. + + Args: + pdf_path: Path to the PDF file + dpi: Resolution for rendering (default 72) + + Returns: + Base64-encoded image as data URL + + Raises: + Exception: If PDF cannot be opened or converted + """ + doc = fitz.open(str(pdf_path)) + try: + page = doc[0] # First page only + + # Render to pixmap at specified DPI + mat = fitz.Matrix(dpi / 72, dpi / 72) + pix = page.get_pixmap(matrix=mat) + + # Try to compress to JPEG if PIL is available + if HAS_PIL: + # Convert pixmap to PIL Image + img_data = pix.tobytes("png") + img = Image.open(io.BytesIO(img_data)) + + # Convert to RGB (JPEG doesn't support alpha channel) + if img.mode in ('RGBA', 'LA', 'P'): + rgb_img = Image.new('RGB', img.size, (255, 255, 255)) + if img.mode == 'P': + img = img.convert('RGBA') + rgb_img.paste(img, mask=img.split()[-1] if img.mode in ('RGBA', 'LA') else None) + img = rgb_img + + # Save as JPEG with compression + buffer = io.BytesIO() + img.save(buffer, format='JPEG', quality=85, optimize=True) + img_bytes = buffer.getvalue() + + # Encode to base64 + base64_str = base64.b64encode(img_bytes).decode("utf-8") + return f"data:image/jpeg;base64,{base64_str}" + else: + # Fallback to PNG + img_bytes = pix.tobytes("png") + base64_str = base64.b64encode(img_bytes).decode("utf-8") + return f"data:image/png;base64,{base64_str}" + finally: + doc.close() + + def _generate_vscode_link(self, pdf_path: str) -> str: + """ + Generate vscode:// link to ground truth JSON file. + + Args: + pdf_path: Path to the PDF file + + Returns: + vscode:// URL or empty string if ground truth file doesn't exist + """ + # Get the stem of the PDF filename + pdf_stem = Path(pdf_path).stem + + # Find corresponding ground truth JSON file + gt_file = self.ground_truth_dir / f"{pdf_stem}.json" + + if gt_file.exists(): + # Convert to absolute path for VS Code link + abs_path = gt_file.resolve() + return f"vscode://file/{abs_path}" + + return "" + + def _get_metric_class(self, score: float) -> str: + """ + Get CSS class based on metric score. + + Args: + score: Score value between 0 and 1 + + Returns: + CSS class name: 'good', 'warning', or 'poor' + """ + if score >= 0.9: + return "good" + elif score >= 0.7: + return "warning" + else: + return "poor" diff --git a/dataset_eval_utils/uv.lock b/dataset_eval_utils/uv.lock new file mode 100644 index 0000000..eb6ca44 --- /dev/null +++ b/dataset_eval_utils/uv.lock @@ -0,0 +1,1655 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform != 'win32'", + "python_full_version < '3.13' and sys_platform == 'win32'", + "python_full_version < '3.13' and sys_platform != 'win32'", +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anthropic" +version = "0.104.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "docstring-parser" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/c7/7a655b948916f777354648ce979f68b94d5b8dbdb5f61fed1f37fad9378c/anthropic-0.104.1.tar.gz", hash = "sha256:17362b6c45f527afcc9b0fdf62011ffd359726ab2ebcb1978ea0cc41bd8d8d40", size = 850081, upload-time = "2026-05-22T15:36:57.432Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/12/d9ab42790494d7c428391a46cd28492395566a6a8ccb138d681978594455/anthropic-0.104.1-py3-none-any.whl", hash = "sha256:35c8cb456f5a4405aafe1f10f03f6fcc54fa51fa8ec01d655cc4b437d120e9b7", size = 832996, upload-time = "2026-05-22T15:36:59.519Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "audioop-lts" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/53/946db57842a50b2da2e0c1e34bd37f36f5aadba1a929a3971c5d7841dbca/audioop_lts-0.2.2.tar.gz", hash = "sha256:64d0c62d88e67b98a1a5e71987b7aa7b5bcffc7dcee65b635823dbdd0a8dbbd0", size = 30686, upload-time = "2025-08-05T16:43:17.409Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/d4/94d277ca941de5a507b07f0b592f199c22454eeaec8f008a286b3fbbacd6/audioop_lts-0.2.2-cp313-abi3-macosx_10_13_universal2.whl", hash = "sha256:fd3d4602dc64914d462924a08c1a9816435a2155d74f325853c1f1ac3b2d9800", size = 46523, upload-time = "2025-08-05T16:42:20.836Z" }, + { url = "https://files.pythonhosted.org/packages/f8/5a/656d1c2da4b555920ce4177167bfeb8623d98765594af59702c8873f60ec/audioop_lts-0.2.2-cp313-abi3-macosx_10_13_x86_64.whl", hash = "sha256:550c114a8df0aafe9a05442a1162dfc8fec37e9af1d625ae6060fed6e756f303", size = 27455, upload-time = "2025-08-05T16:42:22.283Z" }, + { url = "https://files.pythonhosted.org/packages/1b/83/ea581e364ce7b0d41456fb79d6ee0ad482beda61faf0cab20cbd4c63a541/audioop_lts-0.2.2-cp313-abi3-macosx_11_0_arm64.whl", hash = "sha256:9a13dc409f2564de15dd68be65b462ba0dde01b19663720c68c1140c782d1d75", size = 26997, upload-time = "2025-08-05T16:42:23.849Z" }, + { url = "https://files.pythonhosted.org/packages/b8/3b/e8964210b5e216e5041593b7d33e97ee65967f17c282e8510d19c666dab4/audioop_lts-0.2.2-cp313-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:51c916108c56aa6e426ce611946f901badac950ee2ddaf302b7ed35d9958970d", size = 85844, upload-time = "2025-08-05T16:42:25.208Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2e/0a1c52faf10d51def20531a59ce4c706cb7952323b11709e10de324d6493/audioop_lts-0.2.2-cp313-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47eba38322370347b1c47024defbd36374a211e8dd5b0dcbce7b34fdb6f8847b", size = 85056, upload-time = "2025-08-05T16:42:26.559Z" }, + { url = "https://files.pythonhosted.org/packages/75/e8/cd95eef479656cb75ab05dfece8c1f8c395d17a7c651d88f8e6e291a63ab/audioop_lts-0.2.2-cp313-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba7c3a7e5f23e215cb271516197030c32aef2e754252c4c70a50aaff7031a2c8", size = 93892, upload-time = "2025-08-05T16:42:27.902Z" }, + { url = "https://files.pythonhosted.org/packages/5c/1e/a0c42570b74f83efa5cca34905b3eef03f7ab09fe5637015df538a7f3345/audioop_lts-0.2.2-cp313-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def246fe9e180626731b26e89816e79aae2276f825420a07b4a647abaa84becc", size = 96660, upload-time = "2025-08-05T16:42:28.9Z" }, + { url = "https://files.pythonhosted.org/packages/50/d5/8a0ae607ca07dbb34027bac8db805498ee7bfecc05fd2c148cc1ed7646e7/audioop_lts-0.2.2-cp313-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e160bf9df356d841bb6c180eeeea1834085464626dc1b68fa4e1d59070affdc3", size = 79143, upload-time = "2025-08-05T16:42:29.929Z" }, + { url = "https://files.pythonhosted.org/packages/12/17/0d28c46179e7910bfb0bb62760ccb33edb5de973052cb2230b662c14ca2e/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4b4cd51a57b698b2d06cb9993b7ac8dfe89a3b2878e96bc7948e9f19ff51dba6", size = 84313, upload-time = "2025-08-05T16:42:30.949Z" }, + { url = "https://files.pythonhosted.org/packages/84/ba/bd5d3806641564f2024e97ca98ea8f8811d4e01d9b9f9831474bc9e14f9e/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4a53aa7c16a60a6857e6b0b165261436396ef7293f8b5c9c828a3a203147ed4a", size = 93044, upload-time = "2025-08-05T16:42:31.959Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5e/435ce8d5642f1f7679540d1e73c1c42d933331c0976eb397d1717d7f01a3/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:3fc38008969796f0f689f1453722a0f463da1b8a6fbee11987830bfbb664f623", size = 78766, upload-time = "2025-08-05T16:42:33.302Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3b/b909e76b606cbfd53875693ec8c156e93e15a1366a012f0b7e4fb52d3c34/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_s390x.whl", hash = "sha256:15ab25dd3e620790f40e9ead897f91e79c0d3ce65fe193c8ed6c26cffdd24be7", size = 87640, upload-time = "2025-08-05T16:42:34.854Z" }, + { url = "https://files.pythonhosted.org/packages/30/e7/8f1603b4572d79b775f2140d7952f200f5e6c62904585d08a01f0a70393a/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:03f061a1915538fd96272bac9551841859dbb2e3bf73ebe4a23ef043766f5449", size = 86052, upload-time = "2025-08-05T16:42:35.839Z" }, + { url = "https://files.pythonhosted.org/packages/b5/96/c37846df657ccdda62ba1ae2b6534fa90e2e1b1742ca8dcf8ebd38c53801/audioop_lts-0.2.2-cp313-abi3-win32.whl", hash = "sha256:3bcddaaf6cc5935a300a8387c99f7a7fbbe212a11568ec6cf6e4bc458c048636", size = 26185, upload-time = "2025-08-05T16:42:37.04Z" }, + { url = "https://files.pythonhosted.org/packages/34/a5/9d78fdb5b844a83da8a71226c7bdae7cc638861085fff7a1d707cb4823fa/audioop_lts-0.2.2-cp313-abi3-win_amd64.whl", hash = "sha256:a2c2a947fae7d1062ef08c4e369e0ba2086049a5e598fda41122535557012e9e", size = 30503, upload-time = "2025-08-05T16:42:38.427Z" }, + { url = "https://files.pythonhosted.org/packages/34/25/20d8fde083123e90c61b51afb547bb0ea7e77bab50d98c0ab243d02a0e43/audioop_lts-0.2.2-cp313-abi3-win_arm64.whl", hash = "sha256:5f93a5db13927a37d2d09637ccca4b2b6b48c19cd9eda7b17a2e9f77edee6a6f", size = 24173, upload-time = "2025-08-05T16:42:39.704Z" }, + { url = "https://files.pythonhosted.org/packages/58/a7/0a764f77b5c4ac58dc13c01a580f5d32ae8c74c92020b961556a43e26d02/audioop_lts-0.2.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:73f80bf4cd5d2ca7814da30a120de1f9408ee0619cc75da87d0641273d202a09", size = 47096, upload-time = "2025-08-05T16:42:40.684Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ed/ebebedde1a18848b085ad0fa54b66ceb95f1f94a3fc04f1cd1b5ccb0ed42/audioop_lts-0.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:106753a83a25ee4d6f473f2be6b0966fc1c9af7e0017192f5531a3e7463dce58", size = 27748, upload-time = "2025-08-05T16:42:41.992Z" }, + { url = "https://files.pythonhosted.org/packages/cb/6e/11ca8c21af79f15dbb1c7f8017952ee8c810c438ce4e2b25638dfef2b02c/audioop_lts-0.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fbdd522624141e40948ab3e8cdae6e04c748d78710e9f0f8d4dae2750831de19", size = 27329, upload-time = "2025-08-05T16:42:42.987Z" }, + { url = "https://files.pythonhosted.org/packages/84/52/0022f93d56d85eec5da6b9da6a958a1ef09e80c39f2cc0a590c6af81dcbb/audioop_lts-0.2.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:143fad0311e8209ece30a8dbddab3b65ab419cbe8c0dde6e8828da25999be911", size = 92407, upload-time = "2025-08-05T16:42:44.336Z" }, + { url = "https://files.pythonhosted.org/packages/87/1d/48a889855e67be8718adbc7a01f3c01d5743c325453a5e81cf3717664aad/audioop_lts-0.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfbbc74ec68a0fd08cfec1f4b5e8cca3d3cd7de5501b01c4b5d209995033cde9", size = 91811, upload-time = "2025-08-05T16:42:45.325Z" }, + { url = "https://files.pythonhosted.org/packages/98/a6/94b7213190e8077547ffae75e13ed05edc488653c85aa5c41472c297d295/audioop_lts-0.2.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cfcac6aa6f42397471e4943e0feb2244549db5c5d01efcd02725b96af417f3fe", size = 100470, upload-time = "2025-08-05T16:42:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/e9/e9/78450d7cb921ede0cfc33426d3a8023a3bda755883c95c868ee36db8d48d/audioop_lts-0.2.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:752d76472d9804ac60f0078c79cdae8b956f293177acd2316cd1e15149aee132", size = 103878, upload-time = "2025-08-05T16:42:47.576Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e2/cd5439aad4f3e34ae1ee852025dc6aa8f67a82b97641e390bf7bd9891d3e/audioop_lts-0.2.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:83c381767e2cc10e93e40281a04852facc4cd9334550e0f392f72d1c0a9c5753", size = 84867, upload-time = "2025-08-05T16:42:49.003Z" }, + { url = "https://files.pythonhosted.org/packages/68/4b/9d853e9076c43ebba0d411e8d2aa19061083349ac695a7d082540bad64d0/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c0022283e9556e0f3643b7c3c03f05063ca72b3063291834cca43234f20c60bb", size = 90001, upload-time = "2025-08-05T16:42:50.038Z" }, + { url = "https://files.pythonhosted.org/packages/58/26/4bae7f9d2f116ed5593989d0e521d679b0d583973d203384679323d8fa85/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a2d4f1513d63c795e82948e1305f31a6d530626e5f9f2605408b300ae6095093", size = 99046, upload-time = "2025-08-05T16:42:51.111Z" }, + { url = "https://files.pythonhosted.org/packages/b2/67/a9f4fb3e250dda9e9046f8866e9fa7d52664f8985e445c6b4ad6dfb55641/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:c9c8e68d8b4a56fda8c025e538e639f8c5953f5073886b596c93ec9b620055e7", size = 84788, upload-time = "2025-08-05T16:42:52.198Z" }, + { url = "https://files.pythonhosted.org/packages/70/f7/3de86562db0121956148bcb0fe5b506615e3bcf6e63c4357a612b910765a/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:96f19de485a2925314f5020e85911fb447ff5fbef56e8c7c6927851b95533a1c", size = 94472, upload-time = "2025-08-05T16:42:53.59Z" }, + { url = "https://files.pythonhosted.org/packages/f1/32/fd772bf9078ae1001207d2df1eef3da05bea611a87dd0e8217989b2848fa/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e541c3ef484852ef36545f66209444c48b28661e864ccadb29daddb6a4b8e5f5", size = 92279, upload-time = "2025-08-05T16:42:54.632Z" }, + { url = "https://files.pythonhosted.org/packages/4f/41/affea7181592ab0ab560044632571a38edaf9130b84928177823fbf3176a/audioop_lts-0.2.2-cp313-cp313t-win32.whl", hash = "sha256:d5e73fa573e273e4f2e5ff96f9043858a5e9311e94ffefd88a3186a910c70917", size = 26568, upload-time = "2025-08-05T16:42:55.627Z" }, + { url = "https://files.pythonhosted.org/packages/28/2b/0372842877016641db8fc54d5c88596b542eec2f8f6c20a36fb6612bf9ee/audioop_lts-0.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9191d68659eda01e448188f60364c7763a7ca6653ed3f87ebb165822153a8547", size = 30942, upload-time = "2025-08-05T16:42:56.674Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/baf2b9cc7e96c179bb4a54f30fcd83e6ecb340031bde68f486403f943768/audioop_lts-0.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:c174e322bb5783c099aaf87faeb240c8d210686b04bd61dfd05a8e5a83d88969", size = 24603, upload-time = "2025-08-05T16:42:57.571Z" }, + { url = "https://files.pythonhosted.org/packages/5c/73/413b5a2804091e2c7d5def1d618e4837f1cb82464e230f827226278556b7/audioop_lts-0.2.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:f9ee9b52f5f857fbaf9d605a360884f034c92c1c23021fb90b2e39b8e64bede6", size = 47104, upload-time = "2025-08-05T16:42:58.518Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8c/daa3308dc6593944410c2c68306a5e217f5c05b70a12e70228e7dd42dc5c/audioop_lts-0.2.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:49ee1a41738a23e98d98b937a0638357a2477bc99e61b0f768a8f654f45d9b7a", size = 27754, upload-time = "2025-08-05T16:43:00.132Z" }, + { url = "https://files.pythonhosted.org/packages/4e/86/c2e0f627168fcf61781a8f72cab06b228fe1da4b9fa4ab39cfb791b5836b/audioop_lts-0.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5b00be98ccd0fc123dcfad31d50030d25fcf31488cde9e61692029cd7394733b", size = 27332, upload-time = "2025-08-05T16:43:01.666Z" }, + { url = "https://files.pythonhosted.org/packages/c7/bd/35dce665255434f54e5307de39e31912a6f902d4572da7c37582809de14f/audioop_lts-0.2.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6d2e0f9f7a69403e388894d4ca5ada5c47230716a03f2847cfc7bd1ecb589d6", size = 92396, upload-time = "2025-08-05T16:43:02.991Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d2/deeb9f51def1437b3afa35aeb729d577c04bcd89394cb56f9239a9f50b6f/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9b0b8a03ef474f56d1a842af1a2e01398b8f7654009823c6d9e0ecff4d5cfbf", size = 91811, upload-time = "2025-08-05T16:43:04.096Z" }, + { url = "https://files.pythonhosted.org/packages/76/3b/09f8b35b227cee28cc8231e296a82759ed80c1a08e349811d69773c48426/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b267b70747d82125f1a021506565bdc5609a2b24bcb4773c16d79d2bb260bbd", size = 100483, upload-time = "2025-08-05T16:43:05.085Z" }, + { url = "https://files.pythonhosted.org/packages/0b/15/05b48a935cf3b130c248bfdbdea71ce6437f5394ee8533e0edd7cfd93d5e/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0337d658f9b81f4cd0fdb1f47635070cc084871a3d4646d9de74fdf4e7c3d24a", size = 103885, upload-time = "2025-08-05T16:43:06.197Z" }, + { url = "https://files.pythonhosted.org/packages/83/80/186b7fce6d35b68d3d739f228dc31d60b3412105854edb975aa155a58339/audioop_lts-0.2.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:167d3b62586faef8b6b2275c3218796b12621a60e43f7e9d5845d627b9c9b80e", size = 84899, upload-time = "2025-08-05T16:43:07.291Z" }, + { url = "https://files.pythonhosted.org/packages/49/89/c78cc5ac6cb5828f17514fb12966e299c850bc885e80f8ad94e38d450886/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0d9385e96f9f6da847f4d571ce3cb15b5091140edf3db97276872647ce37efd7", size = 89998, upload-time = "2025-08-05T16:43:08.335Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4b/6401888d0c010e586c2ca50fce4c903d70a6bb55928b16cfbdfd957a13da/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:48159d96962674eccdca9a3df280e864e8ac75e40a577cc97c5c42667ffabfc5", size = 99046, upload-time = "2025-08-05T16:43:09.367Z" }, + { url = "https://files.pythonhosted.org/packages/de/f8/c874ca9bb447dae0e2ef2e231f6c4c2b0c39e31ae684d2420b0f9e97ee68/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8fefe5868cd082db1186f2837d64cfbfa78b548ea0d0543e9b28935ccce81ce9", size = 84843, upload-time = "2025-08-05T16:43:10.749Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c0/0323e66f3daebc13fd46b36b30c3be47e3fc4257eae44f1e77eb828c703f/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:58cf54380c3884fb49fdd37dfb7a772632b6701d28edd3e2904743c5e1773602", size = 94490, upload-time = "2025-08-05T16:43:12.131Z" }, + { url = "https://files.pythonhosted.org/packages/98/6b/acc7734ac02d95ab791c10c3f17ffa3584ccb9ac5c18fd771c638ed6d1f5/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:088327f00488cdeed296edd9215ca159f3a5a5034741465789cad403fcf4bec0", size = 92297, upload-time = "2025-08-05T16:43:13.139Z" }, + { url = "https://files.pythonhosted.org/packages/13/c3/c3dc3f564ce6877ecd2a05f8d751b9b27a8c320c2533a98b0c86349778d0/audioop_lts-0.2.2-cp314-cp314t-win32.whl", hash = "sha256:068aa17a38b4e0e7de771c62c60bbca2455924b67a8814f3b0dee92b5820c0b3", size = 27331, upload-time = "2025-08-05T16:43:14.19Z" }, + { url = "https://files.pythonhosted.org/packages/72/bb/b4608537e9ffcb86449091939d52d24a055216a36a8bf66b936af8c3e7ac/audioop_lts-0.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a5bf613e96f49712073de86f20dbdd4014ca18efd4d34ed18c75bd808337851b", size = 31697, upload-time = "2025-08-05T16:43:15.193Z" }, + { url = "https://files.pythonhosted.org/packages/f6/22/91616fe707a5c5510de2cac9b046a30defe7007ba8a0c04f9c08f27df312/audioop_lts-0.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:b492c3b040153e68b9fdaff5913305aaaba5bb433d8a7f73d5cf6a64ed3cc1dd", size = 25206, upload-time = "2025-08-05T16:43:16.444Z" }, +] + +[[package]] +name = "azure-ai-documentintelligence" +version = "1.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core" }, + { name = "isodate" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/7b/8115cd713e2caa5e44def85f2b7ebd02a74ae74d7113ba20bdd41fd6dd80/azure_ai_documentintelligence-1.0.2.tar.gz", hash = "sha256:4d75a2513f2839365ebabc0e0e1772f5601b3a8c9a71e75da12440da13b63484", size = 170940, upload-time = "2025-03-27T02:46:20.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/75/c9ec040f23082f54ffb1977ff8f364c2d21c79a640a13d1c1809e7fd6b1a/azure_ai_documentintelligence-1.0.2-py3-none-any.whl", hash = "sha256:e1fb446abbdeccc9759d897898a0fe13141ed29f9ad11fc705f951925822ed59", size = 106005, upload-time = "2025-03-27T02:46:22.356Z" }, +] + +[[package]] +name = "azure-core" +version = "1.39.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/83/bbde3faa84ddcb8eb0eca4b3ffb3221252281db4ce351300fe248c5c70b1/azure_core-1.39.0.tar.gz", hash = "sha256:8a90a562998dd44ce84597590fff6249701b98c0e8797c95fcdd695b54c35d74", size = 367531, upload-time = "2026-03-19T01:31:29.461Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/d6/8ebcd05b01a580f086ac9a97fb9fac65c09a4b012161cc97c21a336e880b/azure_core-1.39.0-py3-none-any.whl", hash = "sha256:4ac7b70fab5438c3f68770649a78daf97833caa83827f91df9c14e0e0ea7d34f", size = 218318, upload-time = "2026-03-19T01:31:31.25Z" }, +] + +[[package]] +name = "azure-identity" +version = "1.25.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core" }, + { name = "cryptography" }, + { name = "msal" }, + { name = "msal-extensions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/0e/3a63efb48aa4a5ae2cfca61ee152fbcb668092134d3eb8bfda472dd5c617/azure_identity-1.25.3.tar.gz", hash = "sha256:ab23c0d63015f50b630ef6c6cf395e7262f439ce06e5d07a64e874c724f8d9e6", size = 286304, upload-time = "2026-03-13T01:12:20.892Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/9a/417b3a533e01953a7c618884df2cb05a71e7b68bdbce4fbdb62349d2a2e8/azure_identity-1.25.3-py3-none-any.whl", hash = "sha256:f4d0b956a8146f30333e071374171f3cfa7bdb8073adb8c3814b65567aa7447c", size = 192138, upload-time = "2026-03-13T01:12:22.951Z" }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/62/c0815c992c9545347aeea7859b50dc9044d147e2e7278329c6e02ac9a616/charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab", size = 295154, upload-time = "2026-03-15T18:50:50.88Z" }, + { url = "https://files.pythonhosted.org/packages/a8/37/bdca6613c2e3c58c7421891d80cc3efa1d32e882f7c4a7ee6039c3fc951a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21", size = 199191, upload-time = "2026-03-15T18:50:52.658Z" }, + { url = "https://files.pythonhosted.org/packages/6c/92/9934d1bbd69f7f398b38c5dae1cbf9cc672e7c34a4adf7b17c0a9c17d15d/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2", size = 218674, upload-time = "2026-03-15T18:50:54.102Z" }, + { url = "https://files.pythonhosted.org/packages/af/90/25f6ab406659286be929fd89ab0e78e38aa183fc374e03aa3c12d730af8a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff", size = 215259, upload-time = "2026-03-15T18:50:55.616Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5", size = 207276, upload-time = "2026-03-15T18:50:57.054Z" }, + { url = "https://files.pythonhosted.org/packages/f7/72/d0426afec4b71dc159fa6b4e68f868cd5a3ecd918fec5813a15d292a7d10/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0", size = 195161, upload-time = "2026-03-15T18:50:58.686Z" }, + { url = "https://files.pythonhosted.org/packages/bf/18/c82b06a68bfcb6ce55e508225d210c7e6a4ea122bfc0748892f3dc4e8e11/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a", size = 203452, upload-time = "2026-03-15T18:51:00.196Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/0c25979b92f8adafdbb946160348d8d44aa60ce99afdc27df524379875cb/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2", size = 202272, upload-time = "2026-03-15T18:51:01.703Z" }, + { url = "https://files.pythonhosted.org/packages/2e/3d/7fea3e8fe84136bebbac715dd1221cc25c173c57a699c030ab9b8900cbb7/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5", size = 195622, upload-time = "2026-03-15T18:51:03.526Z" }, + { url = "https://files.pythonhosted.org/packages/57/8a/d6f7fd5cb96c58ef2f681424fbca01264461336d2a7fc875e4446b1f1346/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6", size = 220056, upload-time = "2026-03-15T18:51:05.269Z" }, + { url = "https://files.pythonhosted.org/packages/16/50/478cdda782c8c9c3fb5da3cc72dd7f331f031e7f1363a893cdd6ca0f8de0/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d", size = 203751, upload-time = "2026-03-15T18:51:06.858Z" }, + { url = "https://files.pythonhosted.org/packages/75/fc/cc2fcac943939c8e4d8791abfa139f685e5150cae9f94b60f12520feaa9b/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2", size = 216563, upload-time = "2026-03-15T18:51:08.564Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b7/a4add1d9a5f68f3d037261aecca83abdb0ab15960a3591d340e829b37298/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923", size = 209265, upload-time = "2026-03-15T18:51:10.312Z" }, + { url = "https://files.pythonhosted.org/packages/6c/18/c094561b5d64a24277707698e54b7f67bd17a4f857bbfbb1072bba07c8bf/charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4", size = 144229, upload-time = "2026-03-15T18:51:11.694Z" }, + { url = "https://files.pythonhosted.org/packages/ab/20/0567efb3a8fd481b8f34f739ebddc098ed062a59fed41a8d193a61939e8f/charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb", size = 154277, upload-time = "2026-03-15T18:51:13.004Z" }, + { url = "https://files.pythonhosted.org/packages/15/57/28d79b44b51933119e21f65479d0864a8d5893e494cf5daab15df0247c17/charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4", size = 142817, upload-time = "2026-03-15T18:51:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/1e/1d/4fdabeef4e231153b6ed7567602f3b68265ec4e5b76d6024cf647d43d981/charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", size = 294823, upload-time = "2026-03-15T18:51:15.755Z" }, + { url = "https://files.pythonhosted.org/packages/47/7b/20e809b89c69d37be748d98e84dce6820bf663cf19cf6b942c951a3e8f41/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", size = 198527, upload-time = "2026-03-15T18:51:17.177Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/4f8d27527d59c039dce6f7622593cdcd3d70a8504d87d09eb11e9fdc6062/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", size = 218388, upload-time = "2026-03-15T18:51:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/f6/9b/4770ccb3e491a9bacf1c46cc8b812214fe367c86a96353ccc6daf87b01ec/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", size = 214563, upload-time = "2026-03-15T18:51:20.374Z" }, + { url = "https://files.pythonhosted.org/packages/2b/58/a199d245894b12db0b957d627516c78e055adc3a0d978bc7f65ddaf7c399/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", size = 206587, upload-time = "2026-03-15T18:51:21.807Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/3def227f1ec56f5c69dfc8392b8bd63b11a18ca8178d9211d7cc5e5e4f27/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", size = 194724, upload-time = "2026-03-15T18:51:23.508Z" }, + { url = "https://files.pythonhosted.org/packages/58/ab/9318352e220c05efd31c2779a23b50969dc94b985a2efa643ed9077bfca5/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", size = 202956, upload-time = "2026-03-15T18:51:25.239Z" }, + { url = "https://files.pythonhosted.org/packages/75/13/f3550a3ac25b70f87ac98c40d3199a8503676c2f1620efbf8d42095cfc40/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", size = 201923, upload-time = "2026-03-15T18:51:26.682Z" }, + { url = "https://files.pythonhosted.org/packages/1b/db/c5c643b912740b45e8eec21de1bbab8e7fc085944d37e1e709d3dcd9d72f/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", size = 195366, upload-time = "2026-03-15T18:51:28.129Z" }, + { url = "https://files.pythonhosted.org/packages/5a/67/3b1c62744f9b2448443e0eb160d8b001c849ec3fef591e012eda6484787c/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", size = 219752, upload-time = "2026-03-15T18:51:29.556Z" }, + { url = "https://files.pythonhosted.org/packages/f6/98/32ffbaf7f0366ffb0445930b87d103f6b406bc2c271563644bde8a2b1093/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", size = 203296, upload-time = "2026-03-15T18:51:30.921Z" }, + { url = "https://files.pythonhosted.org/packages/41/12/5d308c1bbe60cabb0c5ef511574a647067e2a1f631bc8634fcafaccd8293/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", size = 215956, upload-time = "2026-03-15T18:51:32.399Z" }, + { url = "https://files.pythonhosted.org/packages/53/e9/5f85f6c5e20669dbe56b165c67b0260547dea97dba7e187938833d791687/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", size = 208652, upload-time = "2026-03-15T18:51:34.214Z" }, + { url = "https://files.pythonhosted.org/packages/f1/11/897052ea6af56df3eef3ca94edafee410ca699ca0c7b87960ad19932c55e/charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", size = 143940, upload-time = "2026-03-15T18:51:36.15Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5c/724b6b363603e419829f561c854b87ed7c7e31231a7908708ac086cdf3e2/charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", size = 154101, upload-time = "2026-03-15T18:51:37.876Z" }, + { url = "https://files.pythonhosted.org/packages/01/a5/7abf15b4c0968e47020f9ca0935fb3274deb87cb288cd187cad92e8cdffd/charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", size = 143109, upload-time = "2026-03-15T18:51:39.565Z" }, + { url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" }, + { url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" }, + { url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299, upload-time = "2026-03-15T18:51:45.871Z" }, + { url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811, upload-time = "2026-03-15T18:51:47.308Z" }, + { url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706, upload-time = "2026-03-15T18:51:48.849Z" }, + { url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706, upload-time = "2026-03-15T18:51:50.257Z" }, + { url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497, upload-time = "2026-03-15T18:51:52.012Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511, upload-time = "2026-03-15T18:51:53.723Z" }, + { url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133, upload-time = "2026-03-15T18:51:55.333Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035, upload-time = "2026-03-15T18:51:56.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321, upload-time = "2026-03-15T18:51:58.17Z" }, + { url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973, upload-time = "2026-03-15T18:51:59.998Z" }, + { url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610, upload-time = "2026-03-15T18:52:02.213Z" }, + { url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962, upload-time = "2026-03-15T18:52:03.658Z" }, + { url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595, upload-time = "2026-03-15T18:52:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828, upload-time = "2026-03-15T18:52:06.831Z" }, + { url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138, upload-time = "2026-03-15T18:52:08.239Z" }, + { url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679, upload-time = "2026-03-15T18:52:10.043Z" }, + { url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475, upload-time = "2026-03-15T18:52:11.854Z" }, + { url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230, upload-time = "2026-03-15T18:52:13.325Z" }, + { url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045, upload-time = "2026-03-15T18:52:14.752Z" }, + { url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658, upload-time = "2026-03-15T18:52:16.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769, upload-time = "2026-03-15T18:52:17.782Z" }, + { url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328, upload-time = "2026-03-15T18:52:19.553Z" }, + { url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302, upload-time = "2026-03-15T18:52:21.043Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127, upload-time = "2026-03-15T18:52:22.491Z" }, + { url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840, upload-time = "2026-03-15T18:52:24.113Z" }, + { url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890, upload-time = "2026-03-15T18:52:25.541Z" }, + { url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379, upload-time = "2026-03-15T18:52:27.05Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043, upload-time = "2026-03-15T18:52:28.502Z" }, + { url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523, upload-time = "2026-03-15T18:52:29.956Z" }, + { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "cobble" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/7a/a507c709be2c96e1bb6102eb7b7f4026c5e5e223ef7d745a17d239e9d844/cobble-0.1.4.tar.gz", hash = "sha256:de38be1539992c8a06e569630717c485a5f91be2192c461ea2b220607dfa78aa", size = 3805, upload-time = "2024-06-01T18:11:09.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/e1/3714a2f371985215c219c2a70953d38e3eed81ef165aed061d21de0e998b/cobble-0.1.4-py3-none-any.whl", hash = "sha256:36c91b1655e599fd428e2b95fdd5f0da1ca2e9f1abb0bc871dec21a0e78a2b44", size = 3984, upload-time = "2024-06-01T18:11:07.911Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coloredlogs" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "humanfriendly" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0", size = 278520, upload-time = "2021-06-11T10:22:45.202Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", size = 46018, upload-time = "2021-06-11T10:22:42.561Z" }, +] + +[[package]] +name = "cryptography" +version = "48.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/bc/ee4137cbbe105652c0ee4252792b78fc8e7afa4b8e61d9d5dc05a7f45731/cryptography-48.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1", size = 8008324, upload-time = "2026-06-09T22:31:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/d5/85/6379d42181bfc713094f081360fc5784d6c816b599d45e7f082502d173ce/cryptography-48.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225", size = 4696243, upload-time = "2026-06-09T22:32:33.446Z" }, + { url = "https://files.pythonhosted.org/packages/9c/87/c85d147b53323c7eb4d850920c8901377323c2a0ff8d79c262d4fee89aa2/cryptography-48.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691", size = 4713235, upload-time = "2026-06-09T22:31:40.141Z" }, + { url = "https://files.pythonhosted.org/packages/79/58/67cbf8cf1ee7c54b439ca07bbecf8362c07afc11a3724fea70f745784add/cryptography-48.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242", size = 4702323, upload-time = "2026-06-09T22:31:42.191Z" }, + { url = "https://files.pythonhosted.org/packages/89/c6/24266ac10c47f6cd2a865f4446062b466da1d1f10b27189eac00e61bf0c9/cryptography-48.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08", size = 5300085, upload-time = "2026-06-09T22:31:58.703Z" }, + { url = "https://files.pythonhosted.org/packages/d2/bb/cc4b78784f97efc8c5874c2a9743708d172be6663024b34a0467885ae0c8/cryptography-48.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6", size = 4746137, upload-time = "2026-06-09T22:31:31.01Z" }, + { url = "https://files.pythonhosted.org/packages/1f/52/0c44de3f5267f8fbe8e835138017522a333436166e406f0db9b9e6e3033f/cryptography-48.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8", size = 4333867, upload-time = "2026-06-09T22:32:28.096Z" }, + { url = "https://files.pythonhosted.org/packages/9a/2e/772d7adbfa931537bc401640b7cac9976bff689bda187833e5d63b428e49/cryptography-48.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429", size = 4701805, upload-time = "2026-06-09T22:31:38.284Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a3/b06844f303873493c963caf581c04df31c7035e0c1b0f02c4814d319ec80/cryptography-48.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f", size = 5258461, upload-time = "2026-06-09T22:31:04.187Z" }, + { url = "https://files.pythonhosted.org/packages/9f/13/8b765e2e12b07c74941caadb9d1c8fdc006c4dfbf2b8f2d610519758954d/cryptography-48.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f", size = 4745488, upload-time = "2026-06-09T22:32:30.07Z" }, + { url = "https://files.pythonhosted.org/packages/2e/aa/48972bce55049b32a94f4907eda4d75fa385aad8a39506cc2fc72196ecf0/cryptography-48.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41", size = 4830256, upload-time = "2026-06-09T22:31:43.868Z" }, + { url = "https://files.pythonhosted.org/packages/47/a2/e5079a032fb85cf6005046ca92bbd78b0c82dad2b5751ab8c311659da06f/cryptography-48.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6", size = 4979117, upload-time = "2026-06-09T22:31:05.845Z" }, + { url = "https://files.pythonhosted.org/packages/b7/a0/8f50cae9c74e718ed769d63ed5c74bd0ea830c9550a74629cebd1b9c7bc7/cryptography-48.0.1-cp311-abi3-win32.whl", hash = "sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158", size = 3304154, upload-time = "2026-06-09T22:32:16.845Z" }, + { url = "https://files.pythonhosted.org/packages/c5/69/0572c77dbace6fef72f33755bd52ea399c71367250d366237f8691826b9e/cryptography-48.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24", size = 3817138, upload-time = "2026-06-09T22:32:00.388Z" }, + { url = "https://files.pythonhosted.org/packages/42/06/3e768b4c3bc78201583fa35a0e18f640dd782ff41afba88f8545481a8874/cryptography-48.0.1-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:f817adc181390bd54f2f700107a7419040fb7c1bdf2fc26f36551a06a68c3345", size = 7989830, upload-time = "2026-06-09T22:31:07.8Z" }, + { url = "https://files.pythonhosted.org/packages/8a/13/6476736484b94041110c8340a3eb63962fea4975baea8cb4a512adb44d4d/cryptography-48.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d5d30989c6917b478b5817902e85fddaea2261efa8648383d965381ccb9e1ac4", size = 4689201, upload-time = "2026-06-09T22:31:09.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/62/65a87f34d2a431546e2509b85d55e8c90df86d668f6731da64d538512ac2/cryptography-48.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:df637c05205ea7c1d7fbcbe54bbfea648a52951155f997af13d895d0ecc96991", size = 4702822, upload-time = "2026-06-09T22:32:24.409Z" }, + { url = "https://files.pythonhosted.org/packages/7f/59/810b5204b0a9b10f4b6bc06bd551a8b609803cd931806bc3b71884b225e5/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:869c3b8a53bfe27147832df48b32adadf558249d50e76cb3769d40e986b13265", size = 4694875, upload-time = "2026-06-09T22:32:08.737Z" }, + { url = "https://files.pythonhosted.org/packages/24/dc/d8ca05ffea724eec6d232ea6f18e74c269eb6bdfdcc9bfba689790d1325f/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:e361afba8918070d376df76f408a4f67fec0ee9cff81a99e48fe9a233ef59e17", size = 5290385, upload-time = "2026-06-09T22:31:15.212Z" }, + { url = "https://files.pythonhosted.org/packages/03/8c/3be6cb4da181f5bb6c19cf560c2359d60644a6b5fc5b57854e528f47b296/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d069066deead00ac7f090be101be875a06855908f7ec004c27b8fefb4acfb411", size = 4737082, upload-time = "2026-06-09T22:32:22.66Z" }, + { url = "https://files.pythonhosted.org/packages/aa/f6/d5f60a5a1434dbfd949e227fd0065d194c7e6b6ac526b17f5c06152b8231/cryptography-48.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:09f73a725d582cef64b91281a322cd798d14a33b2b6f2b7ad9531dc336d84c02", size = 4325328, upload-time = "2026-06-09T22:32:10.777Z" }, + { url = "https://files.pythonhosted.org/packages/17/b7/ba75dd947a14b6ad907b01ae8f6b5b348cdd1b48142f0063dee9e20c1d9d/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:15254441469dd6bf027039453288e2072124f8b6603563f5d759e1c9b69273fa", size = 4694530, upload-time = "2026-06-09T22:31:53.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/29/50d6b9e8aff12d8b67afaeb3569335e32dc83a5723e3bbded24fdac9f809/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:8ace4507d1e6533c125f4fac754f8bb8b6a74c08e92179dabd7e16571a3efbf3", size = 5245046, upload-time = "2026-06-09T22:31:25.774Z" }, + { url = "https://files.pythonhosted.org/packages/9f/04/618f4115cfc0add0838c82507aa18a346089428da8653ad38b3ff36f5cb3/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b4e391975f038e66432328639620a4aff2d307513b004f1ca06d6225bced815c", size = 4736660, upload-time = "2026-06-09T22:32:12.676Z" }, + { url = "https://files.pythonhosted.org/packages/24/9c/06e062462a0de28a3b3911322eded4c16deb9f441b1b7575d3dc59488ab5/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42fcd8e26fe555d9b3577a135f5091fefa0aa4e99129c23fb56787a1bd4ada72", size = 4822229, upload-time = "2026-06-09T22:31:17.062Z" }, + { url = "https://files.pythonhosted.org/packages/f4/be/0561971eaaee4b8a0e7d5113c536921063ab91aaf23278ac374eaf881e11/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c1400da5e32a43253392277eac7490a60e497d810a63dd5608d71bbd7af507c9", size = 4966364, upload-time = "2026-06-09T22:31:32.842Z" }, + { url = "https://files.pythonhosted.org/packages/a4/27/728c77876f12b000820b69ae490f3c4083775e79e07827e9e60be07ad209/cryptography-48.0.1-cp314-cp314t-win32.whl", hash = "sha256:0df56b056bc17c1b7d6821dfa65216e62bd232d8ab05eb3db44e71d235651471", size = 3278498, upload-time = "2026-06-09T22:31:29.154Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/79a612c6d7b1e6ee0edd43633d53035bec2cfb78c82b76f7864f39e36f34/cryptography-48.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:9de21387aa95e2a895823d0745b430bed4f33503ba9ab5e0b5311f33e37d66d2", size = 3798790, upload-time = "2026-06-09T22:31:56.697Z" }, + { url = "https://files.pythonhosted.org/packages/ca/6c/00fa2a95997164c8b2072ce327c23d4ab20809ccc323ea5fab91e53a4bba/cryptography-48.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67", size = 7987408, upload-time = "2026-06-09T22:32:20.777Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d9/45f309a7e4e5f3f8f121d6d3be9e94024a7726ec598d6e08ae04edb2f04d/cryptography-48.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8", size = 4690196, upload-time = "2026-06-09T22:31:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9f/a1bc8bcc798811b8527eb374bbccf30a3f3e806829d967118222bf1125eb/cryptography-48.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a", size = 4696782, upload-time = "2026-06-09T22:31:45.615Z" }, + { url = "https://files.pythonhosted.org/packages/66/c2/81a4fb4e4373c500bb526bc337ac5719dd31dd15b970b84a238168c6aa08/cryptography-48.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577", size = 4696618, upload-time = "2026-06-09T22:31:11.564Z" }, + { url = "https://files.pythonhosted.org/packages/e5/0b/aa68b221dde92d09cb29a024ede17550ee21e77a404e59fc093c82bb51e1/cryptography-48.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1", size = 5289970, upload-time = "2026-06-09T22:31:20.368Z" }, + { url = "https://files.pythonhosted.org/packages/78/13/fba657f958d2af66ea959a4ba01212632089249d34af1ae48054136344d7/cryptography-48.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d", size = 4731873, upload-time = "2026-06-09T22:31:22.253Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4c/9a964756d24a26b3e34dfcb16f961b89838786e6700b635b0d1e3adff4b6/cryptography-48.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6", size = 4330804, upload-time = "2026-06-09T22:31:36.56Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0f/a10f3a6eb12950a10e3a874070283aa2dd5875b2bfd15fad8a3e17b3f13e/cryptography-48.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46", size = 4696217, upload-time = "2026-06-09T22:31:13.351Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6f/5cd12f951165ea73ef85266775d97e4c763b2474ccfd816dd69d3a18d6f8/cryptography-48.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401", size = 5245252, upload-time = "2026-06-09T22:32:02.193Z" }, + { url = "https://files.pythonhosted.org/packages/68/ab/8aaa12e4516ec4464033ab79b6f3b592bd5a92102467c4ace8a0d970203f/cryptography-48.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b", size = 4731388, upload-time = "2026-06-09T22:32:04.019Z" }, + { url = "https://files.pythonhosted.org/packages/1b/24/50027ea4dca85ec1f40688f3c24fb32ccacd520583c9592c3cc95628e6fb/cryptography-48.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1", size = 4824186, upload-time = "2026-06-09T22:32:18.707Z" }, + { url = "https://files.pythonhosted.org/packages/52/41/04cb5eb17085ade6f50cc611fb657df6a0f5885350de8764ece89c050197/cryptography-48.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475", size = 4964539, upload-time = "2026-06-09T22:31:18.793Z" }, + { url = "https://files.pythonhosted.org/packages/36/bf/ed70785c496e89d7e73b7cda2d21f2447fd6d4e821714b8d04ff217fed92/cryptography-48.0.1-cp39-abi3-win32.whl", hash = "sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1", size = 3282307, upload-time = "2026-06-09T22:30:53.162Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ff/371ea7d252656ee1eb6d83eeeef3d1d0c6baf1d6497687d081ea03814670/cryptography-48.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac", size = 3793408, upload-time = "2026-06-09T22:32:15.191Z" }, +] + +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, +] + +[[package]] +name = "et-xmlfile" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, +] + +[[package]] +name = "flatbuffers" +version = "25.12.19" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "humanfriendly" +version = "10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyreadline3", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702, upload-time = "2021-09-17T21:40:43.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794, upload-time = "2021-09-17T21:40:39.897Z" }, +] + +[[package]] +name = "idna" +version = "3.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, +] + +[[package]] +name = "isodate" +version = "0.7.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/4d/e940025e2ce31a8ce1202635910747e5a87cc3a6a6bb2d00973375014749/isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6", size = 29705, upload-time = "2024-10-08T23:04:11.5Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320, upload-time = "2024-10-08T23:04:09.501Z" }, +] + +[[package]] +name = "jiter" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/5e/4ec91646aee381d01cdb9974e30882c9cd3b8c5d1079d6b5ff4af522439a/jiter-0.13.0.tar.gz", hash = "sha256:f2839f9c2c7e2dffc1bc5929a510e14ce0a946be9365fd1219e7ef342dae14f4", size = 164847, upload-time = "2026-02-02T12:37:56.441Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/30/7687e4f87086829955013ca12a9233523349767f69653ebc27036313def9/jiter-0.13.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0a2bd69fc1d902e89925fc34d1da51b2128019423d7b339a45d9e99c894e0663", size = 307958, upload-time = "2026-02-02T12:35:57.165Z" }, + { url = "https://files.pythonhosted.org/packages/c3/27/e57f9a783246ed95481e6749cc5002a8a767a73177a83c63ea71f0528b90/jiter-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f917a04240ef31898182f76a332f508f2cc4b57d2b4d7ad2dbfebbfe167eb505", size = 318597, upload-time = "2026-02-02T12:35:58.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/52/e5719a60ac5d4d7c5995461a94ad5ef962a37c8bf5b088390e6fad59b2ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e2b199f446d3e82246b4fd9236d7cb502dc2222b18698ba0d986d2fecc6152", size = 348821, upload-time = "2026-02-02T12:36:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/61/db/c1efc32b8ba4c740ab3fc2d037d8753f67685f475e26b9d6536a4322bcdd/jiter-0.13.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04670992b576fa65bd056dbac0c39fe8bd67681c380cb2b48efa885711d9d726", size = 364163, upload-time = "2026-02-02T12:36:01.937Z" }, + { url = "https://files.pythonhosted.org/packages/55/8a/fb75556236047c8806995671a18e4a0ad646ed255276f51a20f32dceaeec/jiter-0.13.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a1aff1fbdb803a376d4d22a8f63f8e7ccbce0b4890c26cc7af9e501ab339ef0", size = 483709, upload-time = "2026-02-02T12:36:03.41Z" }, + { url = "https://files.pythonhosted.org/packages/7e/16/43512e6ee863875693a8e6f6d532e19d650779d6ba9a81593ae40a9088ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b3fb8c2053acaef8580809ac1d1f7481a0a0bdc012fd7f5d8b18fb696a5a089", size = 370480, upload-time = "2026-02-02T12:36:04.791Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4c/09b93e30e984a187bc8aaa3510e1ec8dcbdcd71ca05d2f56aac0492453aa/jiter-0.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdaba7d87e66f26a2c45d8cbadcbfc4bf7884182317907baf39cfe9775bb4d93", size = 360735, upload-time = "2026-02-02T12:36:06.994Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1b/46c5e349019874ec5dfa508c14c37e29864ea108d376ae26d90bee238cd7/jiter-0.13.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b88d649135aca526da172e48083da915ec086b54e8e73a425ba50999468cc08", size = 391814, upload-time = "2026-02-02T12:36:08.368Z" }, + { url = "https://files.pythonhosted.org/packages/15/9e/26184760e85baee7162ad37b7912797d2077718476bf91517641c92b3639/jiter-0.13.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e404ea551d35438013c64b4f357b0474c7abf9f781c06d44fcaf7a14c69ff9e2", size = 513990, upload-time = "2026-02-02T12:36:09.993Z" }, + { url = "https://files.pythonhosted.org/packages/e9/34/2c9355247d6debad57a0a15e76ab1566ab799388042743656e566b3b7de1/jiter-0.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1f4748aad1b4a93c8bdd70f604d0f748cdc0e8744c5547798acfa52f10e79228", size = 548021, upload-time = "2026-02-02T12:36:11.376Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4a/9f2c23255d04a834398b9c2e0e665382116911dc4d06b795710503cdad25/jiter-0.13.0-cp312-cp312-win32.whl", hash = "sha256:0bf670e3b1445fc4d31612199f1744f67f889ee1bbae703c4b54dc097e5dd394", size = 203024, upload-time = "2026-02-02T12:36:12.682Z" }, + { url = "https://files.pythonhosted.org/packages/09/ee/f0ae675a957ae5a8f160be3e87acea6b11dc7b89f6b7ab057e77b2d2b13a/jiter-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:15db60e121e11fe186c0b15236bd5d18381b9ddacdcf4e659feb96fc6c969c92", size = 205424, upload-time = "2026-02-02T12:36:13.93Z" }, + { url = "https://files.pythonhosted.org/packages/1b/02/ae611edf913d3cbf02c97cdb90374af2082c48d7190d74c1111dde08bcdd/jiter-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:41f92313d17989102f3cb5dd533a02787cdb99454d494344b0361355da52fcb9", size = 186818, upload-time = "2026-02-02T12:36:15.308Z" }, + { url = "https://files.pythonhosted.org/packages/91/9c/7ee5a6ff4b9991e1a45263bfc46731634c4a2bde27dfda6c8251df2d958c/jiter-0.13.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1f8a55b848cbabf97d861495cd65f1e5c590246fabca8b48e1747c4dfc8f85bf", size = 306897, upload-time = "2026-02-02T12:36:16.748Z" }, + { url = "https://files.pythonhosted.org/packages/7c/02/be5b870d1d2be5dd6a91bdfb90f248fbb7dcbd21338f092c6b89817c3dbf/jiter-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f556aa591c00f2c45eb1b89f68f52441a016034d18b65da60e2d2875bbbf344a", size = 317507, upload-time = "2026-02-02T12:36:18.351Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/b25d2ec333615f5f284f3a4024f7ce68cfa0604c322c6808b2344c7f5d2b/jiter-0.13.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7e1d61da332ec412350463891923f960c3073cf1aae93b538f0bb4c8cd46efb", size = 350560, upload-time = "2026-02-02T12:36:19.746Z" }, + { url = "https://files.pythonhosted.org/packages/be/ec/74dcb99fef0aca9fbe56b303bf79f6bd839010cb18ad41000bf6cc71eec0/jiter-0.13.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3097d665a27bc96fd9bbf7f86178037db139f319f785e4757ce7ccbf390db6c2", size = 363232, upload-time = "2026-02-02T12:36:21.243Z" }, + { url = "https://files.pythonhosted.org/packages/1b/37/f17375e0bb2f6a812d4dd92d7616e41917f740f3e71343627da9db2824ce/jiter-0.13.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d01ecc3a8cbdb6f25a37bd500510550b64ddf9f7d64a107d92f3ccb25035d0f", size = 483727, upload-time = "2026-02-02T12:36:22.688Z" }, + { url = "https://files.pythonhosted.org/packages/77/d2/a71160a5ae1a1e66c1395b37ef77da67513b0adba73b993a27fbe47eb048/jiter-0.13.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed9bbc30f5d60a3bdf63ae76beb3f9db280d7f195dfcfa61af792d6ce912d159", size = 370799, upload-time = "2026-02-02T12:36:24.106Z" }, + { url = "https://files.pythonhosted.org/packages/01/99/ed5e478ff0eb4e8aa5fd998f9d69603c9fd3f32de3bd16c2b1194f68361c/jiter-0.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98fbafb6e88256f4454de33c1f40203d09fc33ed19162a68b3b257b29ca7f663", size = 359120, upload-time = "2026-02-02T12:36:25.519Z" }, + { url = "https://files.pythonhosted.org/packages/16/be/7ffd08203277a813f732ba897352797fa9493faf8dc7995b31f3d9cb9488/jiter-0.13.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5467696f6b827f1116556cb0db620440380434591e93ecee7fd14d1a491b6daa", size = 390664, upload-time = "2026-02-02T12:36:26.866Z" }, + { url = "https://files.pythonhosted.org/packages/d1/84/e0787856196d6d346264d6dcccb01f741e5f0bd014c1d9a2ebe149caf4f3/jiter-0.13.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2d08c9475d48b92892583df9da592a0e2ac49bcd41fae1fec4f39ba6cf107820", size = 513543, upload-time = "2026-02-02T12:36:28.217Z" }, + { url = "https://files.pythonhosted.org/packages/65/50/ecbd258181c4313cf79bca6c88fb63207d04d5bf5e4f65174114d072aa55/jiter-0.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:aed40e099404721d7fcaf5b89bd3b4568a4666358bcac7b6b15c09fb6252ab68", size = 547262, upload-time = "2026-02-02T12:36:29.678Z" }, + { url = "https://files.pythonhosted.org/packages/27/da/68f38d12e7111d2016cd198161b36e1f042bd115c169255bcb7ec823a3bf/jiter-0.13.0-cp313-cp313-win32.whl", hash = "sha256:36ebfbcffafb146d0e6ffb3e74d51e03d9c35ce7c625c8066cdbfc7b953bdc72", size = 200630, upload-time = "2026-02-02T12:36:31.808Z" }, + { url = "https://files.pythonhosted.org/packages/25/65/3bd1a972c9a08ecd22eb3b08a95d1941ebe6938aea620c246cf426ae09c2/jiter-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:8d76029f077379374cf0dbc78dbe45b38dec4a2eb78b08b5194ce836b2517afc", size = 202602, upload-time = "2026-02-02T12:36:33.679Z" }, + { url = "https://files.pythonhosted.org/packages/15/fe/13bd3678a311aa67686bb303654792c48206a112068f8b0b21426eb6851e/jiter-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:bb7613e1a427cfcb6ea4544f9ac566b93d5bf67e0d48c787eca673ff9c9dff2b", size = 185939, upload-time = "2026-02-02T12:36:35.065Z" }, + { url = "https://files.pythonhosted.org/packages/49/19/a929ec002ad3228bc97ca01dbb14f7632fffdc84a95ec92ceaf4145688ae/jiter-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fa476ab5dd49f3bf3a168e05f89358c75a17608dbabb080ef65f96b27c19ab10", size = 316616, upload-time = "2026-02-02T12:36:36.579Z" }, + { url = "https://files.pythonhosted.org/packages/52/56/d19a9a194afa37c1728831e5fb81b7722c3de18a3109e8f282bfc23e587a/jiter-0.13.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ade8cb6ff5632a62b7dbd4757d8c5573f7a2e9ae285d6b5b841707d8363205ef", size = 346850, upload-time = "2026-02-02T12:36:38.058Z" }, + { url = "https://files.pythonhosted.org/packages/36/4a/94e831c6bf287754a8a019cb966ed39ff8be6ab78cadecf08df3bb02d505/jiter-0.13.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9950290340acc1adaded363edd94baebcee7dabdfa8bee4790794cd5cfad2af6", size = 358551, upload-time = "2026-02-02T12:36:39.417Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ec/a4c72c822695fa80e55d2b4142b73f0012035d9fcf90eccc56bc060db37c/jiter-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2b4972c6df33731aac0742b64fd0d18e0a69bc7d6e03108ce7d40c85fd9e3e6d", size = 201950, upload-time = "2026-02-02T12:36:40.791Z" }, + { url = "https://files.pythonhosted.org/packages/b6/00/393553ec27b824fbc29047e9c7cd4a3951d7fbe4a76743f17e44034fa4e4/jiter-0.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:701a1e77d1e593c1b435315ff625fd071f0998c5f02792038a5ca98899261b7d", size = 185852, upload-time = "2026-02-02T12:36:42.077Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f5/f1997e987211f6f9bd71b8083047b316208b4aca0b529bb5f8c96c89ef3e/jiter-0.13.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:cc5223ab19fe25e2f0bf2643204ad7318896fe3729bf12fde41b77bfc4fafff0", size = 308804, upload-time = "2026-02-02T12:36:43.496Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8f/5482a7677731fd44881f0204981ce2d7175db271f82cba2085dd2212e095/jiter-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9776ebe51713acf438fd9b4405fcd86893ae5d03487546dae7f34993217f8a91", size = 318787, upload-time = "2026-02-02T12:36:45.071Z" }, + { url = "https://files.pythonhosted.org/packages/f3/b9/7257ac59778f1cd025b26a23c5520a36a424f7f1b068f2442a5b499b7464/jiter-0.13.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:879e768938e7b49b5e90b7e3fecc0dbec01b8cb89595861fb39a8967c5220d09", size = 353880, upload-time = "2026-02-02T12:36:47.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/87/719eec4a3f0841dad99e3d3604ee4cba36af4419a76f3cb0b8e2e691ad67/jiter-0.13.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:682161a67adea11e3aae9038c06c8b4a9a71023228767477d683f69903ebc607", size = 366702, upload-time = "2026-02-02T12:36:48.871Z" }, + { url = "https://files.pythonhosted.org/packages/d2/65/415f0a75cf6921e43365a1bc227c565cb949caca8b7532776e430cbaa530/jiter-0.13.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a13b68cd1cd8cc9de8f244ebae18ccb3e4067ad205220ef324c39181e23bbf66", size = 486319, upload-time = "2026-02-02T12:36:53.006Z" }, + { url = "https://files.pythonhosted.org/packages/54/a2/9e12b48e82c6bbc6081fd81abf915e1443add1b13d8fc586e1d90bb02bb8/jiter-0.13.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87ce0f14c6c08892b610686ae8be350bf368467b6acd5085a5b65441e2bf36d2", size = 372289, upload-time = "2026-02-02T12:36:54.593Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c1/e4693f107a1789a239c759a432e9afc592366f04e901470c2af89cfd28e1/jiter-0.13.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c365005b05505a90d1c47856420980d0237adf82f70c4aff7aebd3c1cc143ad", size = 360165, upload-time = "2026-02-02T12:36:56.112Z" }, + { url = "https://files.pythonhosted.org/packages/17/08/91b9ea976c1c758240614bd88442681a87672eebc3d9a6dde476874e706b/jiter-0.13.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1317fdffd16f5873e46ce27d0e0f7f4f90f0cdf1d86bf6abeaea9f63ca2c401d", size = 389634, upload-time = "2026-02-02T12:36:57.495Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/58325ef99390d6d40427ed6005bf1ad54f2577866594bcf13ce55675f87d/jiter-0.13.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c05b450d37ba0c9e21c77fef1f205f56bcee2330bddca68d344baebfc55ae0df", size = 514933, upload-time = "2026-02-02T12:36:58.909Z" }, + { url = "https://files.pythonhosted.org/packages/5b/25/69f1120c7c395fd276c3996bb8adefa9c6b84c12bb7111e5c6ccdcd8526d/jiter-0.13.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:775e10de3849d0631a97c603f996f518159272db00fdda0a780f81752255ee9d", size = 548842, upload-time = "2026-02-02T12:37:00.433Z" }, + { url = "https://files.pythonhosted.org/packages/18/05/981c9669d86850c5fbb0d9e62bba144787f9fba84546ba43d624ee27ef29/jiter-0.13.0-cp314-cp314-win32.whl", hash = "sha256:632bf7c1d28421c00dd8bbb8a3bac5663e1f57d5cd5ed962bce3c73bf62608e6", size = 202108, upload-time = "2026-02-02T12:37:01.718Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/cdcf54dd0b0341db7d25413229888a346c7130bd20820530905fdb65727b/jiter-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:f22ef501c3f87ede88f23f9b11e608581c14f04db59b6a801f354397ae13739f", size = 204027, upload-time = "2026-02-02T12:37:03.075Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f9/724bcaaab7a3cd727031fe4f6995cb86c4bd344909177c186699c8dec51a/jiter-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:07b75fe09a4ee8e0c606200622e571e44943f47254f95e2436c8bdcaceb36d7d", size = 187199, upload-time = "2026-02-02T12:37:04.414Z" }, + { url = "https://files.pythonhosted.org/packages/62/92/1661d8b9fd6a3d7a2d89831db26fe3c1509a287d83ad7838831c7b7a5c7e/jiter-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:964538479359059a35fb400e769295d4b315ae61e4105396d355a12f7fef09f0", size = 318423, upload-time = "2026-02-02T12:37:05.806Z" }, + { url = "https://files.pythonhosted.org/packages/4f/3b/f77d342a54d4ebcd128e520fc58ec2f5b30a423b0fd26acdfc0c6fef8e26/jiter-0.13.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e104da1db1c0991b3eaed391ccd650ae8d947eab1480c733e5a3fb28d4313e40", size = 351438, upload-time = "2026-02-02T12:37:07.189Z" }, + { url = "https://files.pythonhosted.org/packages/76/b3/ba9a69f0e4209bd3331470c723c2f5509e6f0482e416b612431a5061ed71/jiter-0.13.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e3a5f0cde8ff433b8e88e41aa40131455420fb3649a3c7abdda6145f8cb7202", size = 364774, upload-time = "2026-02-02T12:37:08.579Z" }, + { url = "https://files.pythonhosted.org/packages/b3/16/6cdb31fa342932602458dbb631bfbd47f601e03d2e4950740e0b2100b570/jiter-0.13.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:57aab48f40be1db920a582b30b116fe2435d184f77f0e4226f546794cedd9cf0", size = 487238, upload-time = "2026-02-02T12:37:10.066Z" }, + { url = "https://files.pythonhosted.org/packages/ed/b1/956cc7abaca8d95c13aa8d6c9b3f3797241c246cd6e792934cc4c8b250d2/jiter-0.13.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7772115877c53f62beeb8fd853cab692dbc04374ef623b30f997959a4c0e7e95", size = 372892, upload-time = "2026-02-02T12:37:11.656Z" }, + { url = "https://files.pythonhosted.org/packages/26/c4/97ecde8b1e74f67b8598c57c6fccf6df86ea7861ed29da84629cdbba76c4/jiter-0.13.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1211427574b17b633cfceba5040de8081e5abf114f7a7602f73d2e16f9fdaa59", size = 360309, upload-time = "2026-02-02T12:37:13.244Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d7/eabe3cf46715854ccc80be2cd78dd4c36aedeb30751dbf85a1d08c14373c/jiter-0.13.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7beae3a3d3b5212d3a55d2961db3c292e02e302feb43fce6a3f7a31b90ea6dfe", size = 389607, upload-time = "2026-02-02T12:37:14.881Z" }, + { url = "https://files.pythonhosted.org/packages/df/2d/03963fc0804e6109b82decfb9974eb92df3797fe7222428cae12f8ccaa0c/jiter-0.13.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:e5562a0f0e90a6223b704163ea28e831bd3a9faa3512a711f031611e6b06c939", size = 514986, upload-time = "2026-02-02T12:37:16.326Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/8c83b45eb3eb1c1e18d841fe30b4b5bc5619d781267ca9bc03e005d8fd0a/jiter-0.13.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:6c26a424569a59140fb51160a56df13f438a2b0967365e987889186d5fc2f6f9", size = 548756, upload-time = "2026-02-02T12:37:17.736Z" }, + { url = "https://files.pythonhosted.org/packages/47/66/eea81dfff765ed66c68fd2ed8c96245109e13c896c2a5015c7839c92367e/jiter-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:24dc96eca9f84da4131cdf87a95e6ce36765c3b156fc9ae33280873b1c32d5f6", size = 201196, upload-time = "2026-02-02T12:37:19.101Z" }, + { url = "https://files.pythonhosted.org/packages/ff/32/4ac9c7a76402f8f00d00842a7f6b83b284d0cf7c1e9d4227bc95aa6d17fa/jiter-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0a8d76c7524087272c8ae913f5d9d608bd839154b62c4322ef65723d2e5bb0b8", size = 204215, upload-time = "2026-02-02T12:37:20.495Z" }, + { url = "https://files.pythonhosted.org/packages/f9/8e/7def204fea9f9be8b3c21a6f2dd6c020cf56c7d5ff753e0e23ed7f9ea57e/jiter-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2c26cf47e2cad140fa23b6d58d435a7c0161f5c514284802f25e87fddfe11024", size = 187152, upload-time = "2026-02-02T12:37:22.124Z" }, + { url = "https://files.pythonhosted.org/packages/80/60/e50fa45dd7e2eae049f0ce964663849e897300433921198aef94b6ffa23a/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:3d744a6061afba08dd7ae375dcde870cffb14429b7477e10f67e9e6d68772a0a", size = 305169, upload-time = "2026-02-02T12:37:50.376Z" }, + { url = "https://files.pythonhosted.org/packages/d2/73/a009f41c5eed71c49bec53036c4b33555afcdee70682a18c6f66e396c039/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:ff732bd0a0e778f43d5009840f20b935e79087b4dc65bd36f1cd0f9b04b8ff7f", size = 303808, upload-time = "2026-02-02T12:37:52.092Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/528b439290763bff3d939268085d03382471b442f212dca4ff5f12802d43/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab44b178f7981fcaea7e0a5df20e773c663d06ffda0198f1a524e91b2fde7e59", size = 337384, upload-time = "2026-02-02T12:37:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/67/8a/a342b2f0251f3dac4ca17618265d93bf244a2a4d089126e81e4c1056ac50/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19", size = 343768, upload-time = "2026-02-02T12:37:55.055Z" }, +] + +[[package]] +name = "liteparse" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/d5/ccaa4f8bb00103ca43e8c26b0e73fb8cde40d59da96a1c99113ba83d4899/liteparse-2.0.0.tar.gz", hash = "sha256:a723ca76ade9625831840cf248f26b699cc6acfb3aa0b30f5b0ffa56a4bdbb61", size = 109638, upload-time = "2026-05-25T20:23:45.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/9a/fa43059fdce7ecacf3bba70d181b23fede07292ffa1f6f89f3bf1ae38c61/liteparse-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c6e6bf6c22b585f30e16766d3eae0f5b8e21393a9cd21026909877a6528c0b81", size = 11014571, upload-time = "2026-05-25T20:22:58.394Z" }, + { url = "https://files.pythonhosted.org/packages/9a/59/3f7c79b2af0324f63a9ba37d3a3bdca6b706d7bdecdd851d972d42224ef0/liteparse-2.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:8d17ea3a072b89f5f8e7f28ba598588c3eeba796648e95500cbe9c4f302d0f45", size = 16482610, upload-time = "2026-05-25T20:23:01.341Z" }, + { url = "https://files.pythonhosted.org/packages/be/76/8ca7b5e8d34514d21cfa1f5bea58bcb1a444727f2f8a1c025f9a32f560f1/liteparse-2.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b0832a799152ffd80957fe4b3b338cfea085dc18163518da245c6f2029870e4b", size = 16640748, upload-time = "2026-05-25T20:23:04.557Z" }, + { url = "https://files.pythonhosted.org/packages/5d/c3/d7f0764c627cff107cfc624736d6773628da5eeccfb595eb514ac603eca2/liteparse-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:d71c6b68a66ac7b480ccc089e9336c3a740b4d5058c178c08cb0b3e1337a8630", size = 11104653, upload-time = "2026-05-25T20:23:07.445Z" }, + { url = "https://files.pythonhosted.org/packages/30/5c/2ed890ed11b1552e2465618976ba7f513c208bec3c1099cbc6a92712b017/liteparse-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b0e5e9e2b9c1df71280cf6e0d10a4f56ea5f83792adcba795e26d0927603b620", size = 11013916, upload-time = "2026-05-25T20:23:10.439Z" }, + { url = "https://files.pythonhosted.org/packages/78/9d/c703e73f47eb1bf5927e7c5efb31038dd2248c645071b7389be312596d2b/liteparse-2.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:15154c95c4991e832d89d832679c273a723502b3e2d5fb53fdf2a3d12f5a58f2", size = 16482681, upload-time = "2026-05-25T20:23:13.373Z" }, + { url = "https://files.pythonhosted.org/packages/c7/bd/9e0e58e3005d781d567fc892595a52404814f0ccd6196a19f86b6f0bf241/liteparse-2.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:1a10002ca0759bdea2ff4afeda5effdcc7e6fd658119b852a4ed06b5bce7f6ed", size = 16639144, upload-time = "2026-05-25T20:23:16.611Z" }, + { url = "https://files.pythonhosted.org/packages/71/36/90e37fe63689d05728ff064580696da44d117432f8c3dbfe0747a46594df/liteparse-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:cf11d305859a3c7a1b54947af6cdef5fbc913247b14aa76652f6cd0dbcee9eac", size = 11104426, upload-time = "2026-05-25T20:23:19.437Z" }, + { url = "https://files.pythonhosted.org/packages/dc/2a/51398b321017b5519b7b94fee38f73d393198189158ea3831c2bfd1df9fd/liteparse-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:276d234b0338ef9f50bc7554021027537663e3090093727944525506bf5e2208", size = 11015234, upload-time = "2026-05-25T20:23:22.318Z" }, + { url = "https://files.pythonhosted.org/packages/b6/9a/513636c787267335e32f0af684e8159681029628699b023072e4ba4cb945/liteparse-2.0.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:1adde028264ccb21725fd4ae01aa9ef925921022acff539bdc39eefb4b03c3c5", size = 16484720, upload-time = "2026-05-25T20:23:25.199Z" }, + { url = "https://files.pythonhosted.org/packages/d2/20/617e166450e2629a3683ac445fe88f0c907bf76cabd42b996ba34eac66b6/liteparse-2.0.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:cf699201e1696b4dad2fe107f91e05c5bb60d1ee4c622dc96e7efb7fdb1c247b", size = 16642251, upload-time = "2026-05-25T20:23:28.483Z" }, + { url = "https://files.pythonhosted.org/packages/a0/93/c4ba6d5c5513890ae550dc39034b637675484ca5bc81e0e32d1322ea02a0/liteparse-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:dbe3bd6e650566bd6d4a187977b22641674388accb7ced4fb74cd0386528989e", size = 11106982, upload-time = "2026-05-25T20:23:31.5Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3c/bb5b9540fdea6edbca2a9a21aa04631c443f1e01b64d82435c37fe81d913/liteparse-2.0.0-cp315-cp315-manylinux_2_28_aarch64.whl", hash = "sha256:c93eab1d635edce51e2a380ef7dfb0f166b7dece925409850588273aba900fe8", size = 16484904, upload-time = "2026-05-25T20:23:34.41Z" }, + { url = "https://files.pythonhosted.org/packages/30/f2/671457f7e24fbe5278bbc62826caee7dea9015e2065fe121d0a1958bb8d9/liteparse-2.0.0-cp315-cp315-manylinux_2_28_x86_64.whl", hash = "sha256:e9e2aadfa4e02ca25c0a5cefba0fbd54037983db701c3f64bfd4a04b15fe2436", size = 16642310, upload-time = "2026-05-25T20:23:37.507Z" }, +] + +[[package]] +name = "liteparse-eval" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "anthropic" }, + { name = "liteparse" }, + { name = "markitdown", extra = ["all"] }, + { name = "opendataloader-pdf" }, + { name = "pdftotext" }, + { name = "pillow" }, + { name = "pymupdf" }, + { name = "pymupdf4llm" }, + { name = "pypdf" }, + { name = "rapidfuzz" }, +] + +[package.metadata] +requires-dist = [ + { name = "anthropic", specifier = ">=0.104.1" }, + { name = "liteparse", specifier = ">=2.0.0" }, + { name = "markitdown", extras = ["all"], specifier = ">=0.1.5" }, + { name = "opendataloader-pdf", specifier = ">=2.4.1" }, + { name = "pdftotext", specifier = ">=3.0.0" }, + { name = "pillow", specifier = ">=12.2.0" }, + { name = "pymupdf", specifier = ">=1.27.2" }, + { name = "pymupdf4llm", specifier = ">=1.27.2" }, + { name = "pypdf", specifier = ">=6.13.3" }, + { name = "rapidfuzz", specifier = ">=3.14.5" }, +] + +[[package]] +name = "lxml" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/28/30/9abc9e34c657c33834eaf6cd02124c61bdf5944d802aa48e69be8da3585d/lxml-6.1.0.tar.gz", hash = "sha256:bfd57d8008c4965709a919c3e9a98f76c2c7cb319086b3d26858250620023b13", size = 4197006, upload-time = "2026-04-18T04:32:51.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/d4/9326838b59dc36dfae42eec9656b97520f9997eee1de47b8316aaeed169c/lxml-6.1.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d2f17a16cd8751e8eb233a7e41aecdf8e511712e00088bf9be455f604cd0d28d", size = 8570663, upload-time = "2026-04-18T04:27:48.253Z" }, + { url = "https://files.pythonhosted.org/packages/d8/a4/053745ce1f8303ccbb788b86c0db3a91b973675cefc42566a188637b7c40/lxml-6.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f0cea5b1d3e6e77d71bd2b9972eb2446221a69dc52bb0b9c3c6f6e5700592d93", size = 4624024, upload-time = "2026-04-18T04:27:52.594Z" }, + { url = "https://files.pythonhosted.org/packages/90/97/a517944b20f8fd0932ad2109482bee4e29fe721416387a363306667941f6/lxml-6.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fc46da94826188ed45cb53bd8e3fc076ae22675aea2087843d4735627f867c6d", size = 4930895, upload-time = "2026-04-18T04:32:56.29Z" }, + { url = "https://files.pythonhosted.org/packages/94/7c/e08a970727d556caa040a44773c7b7e3ad0f0d73dedc863543e9a8b931f2/lxml-6.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9147d8e386ec3b82c3b15d88927f734f565b0aaadef7def562b853adca45784a", size = 5093820, upload-time = "2026-04-18T04:32:58.94Z" }, + { url = "https://files.pythonhosted.org/packages/88/ee/2a5c2aa2c32016a226ca25d3e1056a8102ea6e1fe308bf50213586635400/lxml-6.1.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5715e0e28736a070f3f34a7ccc09e2fdcba0e3060abbcf61a1a5718ff6d6b105", size = 5005790, upload-time = "2026-04-18T04:33:01.272Z" }, + { url = "https://files.pythonhosted.org/packages/e3/38/a0db9be8f38ad6043ab9429487c128dd1d30f07956ef43040402f8da49e8/lxml-6.1.0-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4937460dc5df0cdd2f06a86c285c28afda06aefa3af949f9477d3e8df430c485", size = 5630827, upload-time = "2026-04-18T04:33:04.036Z" }, + { url = "https://files.pythonhosted.org/packages/31/ba/3c13d3fc24b7cacf675f808a3a1baabf43a30d0cd24c98f94548e9aa58eb/lxml-6.1.0-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bc783ee3147e60a25aa0445ea82b3e8aabb83b240f2b95d32cb75587ff781814", size = 5240445, upload-time = "2026-04-18T04:33:06.87Z" }, + { url = "https://files.pythonhosted.org/packages/55/ba/eeef4ccba09b2212fe239f46c1692a98db1878e0872ae320756488878a94/lxml-6.1.0-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:40d9189f80075f2e1f88db21ef815a2b17b28adf8e50aaf5c789bfe737027f32", size = 5350121, upload-time = "2026-04-18T04:33:09.365Z" }, + { url = "https://files.pythonhosted.org/packages/7e/01/1da87c7b587c38d0cbe77a01aae3b9c1c49ed47d76918ef3db8fc151b1ca/lxml-6.1.0-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:05b9b8787e35bec69e68daf4952b2e6dfcfb0db7ecf1a06f8cdfbbac4eb71aad", size = 4694949, upload-time = "2026-04-18T04:33:11.628Z" }, + { url = "https://files.pythonhosted.org/packages/a1/88/7db0fe66d5aaf128443ee1623dec3db1576f3e4c17751ec0ef5866468590/lxml-6.1.0-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f0f08beb0182e3e9a86fae124b3c47a7b41b7b69b225e1377db983802404e54", size = 5243901, upload-time = "2026-04-18T04:33:13.95Z" }, + { url = "https://files.pythonhosted.org/packages/00/a8/1346726af7d1f6fca1f11223ba34001462b0a3660416986d37641708d57c/lxml-6.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73becf6d8c81d4c76b1014dbd3584cb26d904492dcf73ca85dc8bff08dcd6d2d", size = 5048054, upload-time = "2026-04-18T04:33:16.965Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b7/85057012f035d1a0c87e02f8c723ca3c3e6e0728bcf4cb62080b21b1c1e3/lxml-6.1.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1ae225f66e5938f4fa29d37e009a3bb3b13032ac57eb4eb42afa44f6e4054e69", size = 4777324, upload-time = "2026-04-18T04:33:19.832Z" }, + { url = "https://files.pythonhosted.org/packages/75/6c/ad2f94a91073ef570f33718040e8e160d5fb93331cf1ab3ca1323f939e2d/lxml-6.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:690022c7fae793b0489aa68a658822cea83e0d5933781811cabbf5ea3bcfe73d", size = 5645702, upload-time = "2026-04-18T04:33:22.436Z" }, + { url = "https://files.pythonhosted.org/packages/3b/89/0bb6c0bd549c19004c60eea9dc554dd78fd647b72314ef25d460e0d208c6/lxml-6.1.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:63aeafc26aac0be8aff14af7871249e87ea1319be92090bfd632ec68e03b16a5", size = 5232901, upload-time = "2026-04-18T04:33:26.21Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d9/d609a11fb567da9399f525193e2b49847b5a409cdebe737f06a8b7126bdc/lxml-6.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:264c605ab9c0e4aa1a679636f4582c4d3313700009fac3ec9c3412ed0d8f3e1d", size = 5261333, upload-time = "2026-04-18T04:33:28.984Z" }, + { url = "https://files.pythonhosted.org/packages/a6/3a/ac3f99ec8ac93089e7dd556f279e0d14c24de0a74a507e143a2e4b496e7c/lxml-6.1.0-cp312-cp312-win32.whl", hash = "sha256:56971379bc5ee8037c5a0f09fa88f66cdb7d37c3e38af3e45cf539f41131ac1f", size = 3596289, upload-time = "2026-04-18T04:27:42.819Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a7/0a915557538593cb1bbeedcd40e13c7a261822c26fecbbdb71dad0c2f540/lxml-6.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:bba078de0031c219e5dd06cf3e6bf8fb8e6e64a77819b358f53bb132e3e03366", size = 3997059, upload-time = "2026-04-18T04:27:46.764Z" }, + { url = "https://files.pythonhosted.org/packages/92/96/a5dc078cf0126fbfbc35611d77ecd5da80054b5893e28fb213a5613b9e1d/lxml-6.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:c3592631e652afa34999a088f98ba7dfc7d6aff0d535c410bea77a71743f3819", size = 3659552, upload-time = "2026-04-18T04:27:51.133Z" }, + { url = "https://files.pythonhosted.org/packages/08/03/69347590f1cf4a6d5a4944bb6099e6d37f334784f16062234e1f892fdb1d/lxml-6.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a0092f2b107b69601adf562a57c956fbb596e05e3e6651cabd3054113b007e45", size = 8559689, upload-time = "2026-04-18T04:31:57.785Z" }, + { url = "https://files.pythonhosted.org/packages/3f/58/25e00bb40b185c974cfe156c110474d9a8a8390d5f7c92a4e328189bb60e/lxml-6.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fc7140d7a7386e6b545d41b7358f4d02b656d4053f5fa6859f92f4b9c2572c4d", size = 4617892, upload-time = "2026-04-18T04:32:01.78Z" }, + { url = "https://files.pythonhosted.org/packages/f5/54/92ad98a94ac318dc4f97aaac22ff8d1b94212b2ae8af5b6e9b354bf825f7/lxml-6.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:419c58fc92cc3a2c3fa5f78c63dbf5da70c1fa9c1b25f25727ecee89a96c7de2", size = 4923489, upload-time = "2026-04-18T04:33:31.401Z" }, + { url = "https://files.pythonhosted.org/packages/15/3b/a20aecfab42bdf4f9b390590d345857ad3ffd7c51988d1c89c53a0c73faf/lxml-6.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:37fabd1452852636cf38ecdcc9dd5ca4bba7a35d6c53fa09725deeb894a87491", size = 5082162, upload-time = "2026-04-18T04:33:34.262Z" }, + { url = "https://files.pythonhosted.org/packages/45/26/2cdb3d281ac1bd175603e290cbe4bad6eff127c0f8de90bafd6f8548f0fd/lxml-6.1.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2853c8b2170cc6cd54a6b4d50d2c1a8a7aeca201f23804b4898525c7a152cfc", size = 4993247, upload-time = "2026-04-18T04:33:36.674Z" }, + { url = "https://files.pythonhosted.org/packages/f6/05/d735aef963740022a08185c84821f689fc903acb3d50326e6b1e9886cc22/lxml-6.1.0-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e369cbd690e788c8d15e56222d91a09c6a417f49cbc543040cba0fe2e25a79e", size = 5613042, upload-time = "2026-04-18T04:33:39.205Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b8/ead7c10efff731738c72e59ed6eb5791854879fbed7ae98781a12006263a/lxml-6.1.0-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e69aa6805905807186eb00e66c6d97a935c928275182eb02ee40ba00da9623b2", size = 5228304, upload-time = "2026-04-18T04:33:41.647Z" }, + { url = "https://files.pythonhosted.org/packages/6b/10/e9842d2ec322ea65f0a7270aa0315a53abed06058b88ef1b027f620e7a5f/lxml-6.1.0-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:4bd1bdb8a9e0e2dd229de19b5f8aebac80e916921b4b2c6ef8a52bc131d0c1f9", size = 5341578, upload-time = "2026-04-18T04:33:44.596Z" }, + { url = "https://files.pythonhosted.org/packages/89/54/40d9403d7c2775fa7301d3ddd3464689bfe9ba71acc17dfff777071b4fdc/lxml-6.1.0-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:cbd7b79cdcb4986ad78a2662625882747f09db5e4cd7b2ae178a88c9c51b3dfe", size = 4700209, upload-time = "2026-04-18T04:33:47.552Z" }, + { url = "https://files.pythonhosted.org/packages/85/b2/bbdcc2cf45dfc7dfffef4fd97e5c47b15919b6a365247d95d6f684ef5e82/lxml-6.1.0-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:43e4d297f11080ec9d64a4b1ad7ac02b4484c9f0e2179d9c4ef78e886e747b88", size = 5232365, upload-time = "2026-04-18T04:33:50.249Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/b06875665e53aaba7127611a7bed3b7b9658e20b22bc2dd217a0b7ab0091/lxml-6.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cc16682cc987a3da00aa56a3aa3075b08edb10d9b1e476938cfdbee8f3b67181", size = 5043654, upload-time = "2026-04-18T04:33:52.71Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9c/e71a069d09641c1a7abeb30e693f828c7c90a41cbe3d650b2d734d876f85/lxml-6.1.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:d6d8efe71429635f0559579092bb5e60560d7b9115ee38c4adbea35632e7fa24", size = 4769326, upload-time = "2026-04-18T04:33:55.244Z" }, + { url = "https://files.pythonhosted.org/packages/cc/06/7a9cd84b3d4ed79adf35f874750abb697dec0b4a81a836037b36e47c091a/lxml-6.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7e39ab3a28af7784e206d8606ec0e4bcad0190f63a492bca95e94e5a4aef7f6e", size = 5635879, upload-time = "2026-04-18T04:33:58.509Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f0/9d57916befc1e54c451712c7ee48e9e74e80ae4d03bdce49914e0aee42cd/lxml-6.1.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9eb667bf50856c4a58145f8ca2d5e5be160191e79eb9e30855a476191b3c3495", size = 5224048, upload-time = "2026-04-18T04:34:00.943Z" }, + { url = "https://files.pythonhosted.org/packages/99/75/90c4eefda0c08c92221fe0753db2d6699a4c628f76ff4465ec20dea84cc1/lxml-6.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7f4a77d6f7edf9230cee3e1f7f6764722a41604ee5681844f18db9a81ea0ec33", size = 5250241, upload-time = "2026-04-18T04:34:03.365Z" }, + { url = "https://files.pythonhosted.org/packages/5e/73/16596f7e4e38fa33084b9ccbccc22a15f82a290a055126f2c1541236d2ff/lxml-6.1.0-cp313-cp313-win32.whl", hash = "sha256:28902146ffbe5222df411c5d19e5352490122e14447e98cd118907ee3fd6ee62", size = 3596938, upload-time = "2026-04-18T04:31:56.206Z" }, + { url = "https://files.pythonhosted.org/packages/8e/63/981401c5680c1eb30893f00a19641ac80db5d1e7086c62cb4b13ed813038/lxml-6.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:4a1503c56e4e2b38dc76f2f2da7bae69670c0f1933e27cfa34b2fa5876410b16", size = 3995728, upload-time = "2026-04-18T04:31:58.763Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e8/c358a38ac3e541d16a1b527e4e9cb78c0419b0506a070ace11777e5e8404/lxml-6.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:e0af85773850417d994d019741239b901b22c6680206f46a34766926e466141d", size = 3658372, upload-time = "2026-04-18T04:32:03.629Z" }, + { url = "https://files.pythonhosted.org/packages/eb/45/cee4cf203ef0bab5c52afc118da61d6b460c928f2893d40023cfa27e0b80/lxml-6.1.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ab863fd37458fed6456525f297d21239d987800c46e67da5ef04fc6b3dd93ac8", size = 8576713, upload-time = "2026-04-18T04:32:06.831Z" }, + { url = "https://files.pythonhosted.org/packages/8a/a7/eda05babeb7e046839204eaf254cd4d7c9130ce2bbf0d9e90ea41af5654d/lxml-6.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6fd8b1df8254ff4fd93fd31da1fc15770bde23ac045be9bb1f87425702f61cc9", size = 4623874, upload-time = "2026-04-18T04:32:10.755Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e9/db5846de9b436b91890a62f29d80cd849ea17948a49bf532d5278ee69a9e/lxml-6.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:47024feaae386a92a146af0d2aeed65229bf6fff738e6a11dda6b0015fb8fd03", size = 4949535, upload-time = "2026-04-18T04:34:06.657Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ba/0d3593373dcae1d68f40dc3c41a5a92f2544e68115eb2f62319a4c2a6500/lxml-6.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3f00972f84450204cd5d93a5395965e348956aaceaadec693a22ec743f8ae3eb", size = 5086881, upload-time = "2026-04-18T04:34:09.556Z" }, + { url = "https://files.pythonhosted.org/packages/43/76/759a7484539ad1af0d125a9afe9c3fb5f82a8779fd1f5f56319d9e4ea2fd/lxml-6.1.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97faa0860e13b05b15a51fb4986421ef7a30f0b3334061c416e0981e9450ca4c", size = 5031305, upload-time = "2026-04-18T04:34:12.336Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b9/c1f0daf981a11e47636126901fd4ab82429e18c57aeb0fc3ad2940b42d8b/lxml-6.1.0-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:972a6451204798675407beaad97b868d0c733d9a74dafefc63120b81b8c2de28", size = 5647522, upload-time = "2026-04-18T04:34:14.89Z" }, + { url = "https://files.pythonhosted.org/packages/31/e6/1f533dcd205275363d9ba3511bcec52fa2df86abf8abe6a5f2c599f0dc31/lxml-6.1.0-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fe022f20bc4569ec66b63b3fb275a3d628d9d32da6326b2982584104db6d3086", size = 5239310, upload-time = "2026-04-18T04:34:17.652Z" }, + { url = "https://files.pythonhosted.org/packages/c3/8c/4175fb709c78a6e315ed814ed33be3defd8b8721067e70419a6cf6f971da/lxml-6.1.0-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:75c4c7c619a744f972f4451bf5adf6d0fb00992a1ffc9fd78e13b0bc817cc99f", size = 5350799, upload-time = "2026-04-18T04:34:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/fd/77/6ffdebc5994975f0dde4acb59761902bd9d9bb84422b9a0bd239a7da9ca8/lxml-6.1.0-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3648f20d25102a22b6061c688beb3a805099ea4beb0a01ce62975d926944d292", size = 4697693, upload-time = "2026-04-18T04:34:23.541Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f1/565f36bd5c73294602d48e04d23f81ff4c8736be6ba5e1d1ec670ac9be80/lxml-6.1.0-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:77b9f99b17cbf14026d1e618035077060fc7195dd940d025149f3e2e830fbfcb", size = 5250708, upload-time = "2026-04-18T04:34:26.001Z" }, + { url = "https://files.pythonhosted.org/packages/5a/11/a68ab9dd18c5c499404deb4005f4bc4e0e88e5b72cd755ad96efec81d18d/lxml-6.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32662519149fd7a9db354175aa5e417d83485a8039b8aaa62f873ceee7ea4cad", size = 5084737, upload-time = "2026-04-18T04:34:28.32Z" }, + { url = "https://files.pythonhosted.org/packages/ab/78/e8f41e2c74f4af564e6a0348aea69fb6daaefa64bc071ef469823d22cc18/lxml-6.1.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:73d658216fc173cf2c939e90e07b941c5e12736b0bf6a99e7af95459cfe8eabb", size = 4737817, upload-time = "2026-04-18T04:34:30.784Z" }, + { url = "https://files.pythonhosted.org/packages/06/2d/aa4e117aa2ce2f3b35d9ff246be74a2f8e853baba5d2a92c64744474603a/lxml-6.1.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ac4db068889f8772a4a698c5980ec302771bb545e10c4b095d4c8be26749616f", size = 5670753, upload-time = "2026-04-18T04:34:33.675Z" }, + { url = "https://files.pythonhosted.org/packages/08/f5/dd745d50c0409031dbfcc4881740542a01e54d6f0110bd420fa7782110b8/lxml-6.1.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:45e9dfbd1b661eb64ba0d4dbe762bd210c42d86dd1e5bd2bdf89d634231beb43", size = 5238071, upload-time = "2026-04-18T04:34:36.12Z" }, + { url = "https://files.pythonhosted.org/packages/3e/74/ad424f36d0340a904665867dab310a3f1f4c96ff4039698de83b77f44c1f/lxml-6.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:89e8d73d09ac696a5ba42ec69787913d53284f12092f651506779314f10ba585", size = 5264319, upload-time = "2026-04-18T04:34:39.035Z" }, + { url = "https://files.pythonhosted.org/packages/53/36/a15d8b3514ec889bfd6aa3609107fcb6c9189f8dc347f1c0b81eded8d87c/lxml-6.1.0-cp314-cp314-win32.whl", hash = "sha256:ebe33f4ec1b2de38ceb225a1749a2965855bffeef435ba93cd2d5d540783bf2f", size = 3657139, upload-time = "2026-04-18T04:32:20.006Z" }, + { url = "https://files.pythonhosted.org/packages/1a/a4/263ebb0710851a3c6c937180a9a86df1206fdfe53cc43005aa2237fd7736/lxml-6.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:398443df51c538bd578529aa7e5f7afc6c292644174b47961f3bf87fe5741120", size = 4064195, upload-time = "2026-04-18T04:32:23.876Z" }, + { url = "https://files.pythonhosted.org/packages/80/68/2000f29d323b6c286de077ad20b429fc52272e44eae6d295467043e56012/lxml-6.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:8c8984e1d8c4b3949e419158fda14d921ff703a9ed8a47236c6eb7a2b6cb4946", size = 3741870, upload-time = "2026-04-18T04:32:27.922Z" }, + { url = "https://files.pythonhosted.org/packages/30/e9/21383c7c8d43799f0da90224c0d7c921870d476ec9b3e01e1b2c0b8237c5/lxml-6.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1081dd10bc6fa437db2500e13993abf7cc30716d0a2f40e65abb935f02ec559c", size = 8827548, upload-time = "2026-04-18T04:32:15.094Z" }, + { url = "https://files.pythonhosted.org/packages/a5/01/c6bc11cd587030dd4f719f65c5657960649fe3e19196c844c75bf32cd0d6/lxml-6.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:dabecc48db5f42ba348d1f5d5afdc54c6c4cc758e676926c7cd327045749517d", size = 4735866, upload-time = "2026-04-18T04:32:18.924Z" }, + { url = "https://files.pythonhosted.org/packages/f3/01/757132fff5f4acf25463b5298f1a46099f3a94480b806547b29ce5e385de/lxml-6.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e3dd5fe19c9e0ac818a9c7f132a5e43c1339ec1cbbfecb1a938bd3a47875b7c9", size = 4969476, upload-time = "2026-04-18T04:34:41.889Z" }, + { url = "https://files.pythonhosted.org/packages/fd/fb/1bc8b9d27ed64be7c8903db6c89e74dc8c2cd9ec630a7462e4654316dc5b/lxml-6.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9e7b0a4ca6dcc007a4cef00a761bba2dea959de4bd2df98f926b33c92ca5dfb9", size = 5103719, upload-time = "2026-04-18T04:34:44.797Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e7/5bf82fa28133536a54601aae633b14988e89ed61d4c1eb6b899b023233aa/lxml-6.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d27bbe326c6b539c64b42638b18bc6003a8d88f76213a97ac9ed4f885efeab7", size = 5027890, upload-time = "2026-04-18T04:34:47.634Z" }, + { url = "https://files.pythonhosted.org/packages/2d/20/e048db5d4b4ea0366648aa595f26bb764b2670903fc585b87436d0a5032c/lxml-6.1.0-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4e425db0c5445ef0ad56b0eec54f89b88b2d884656e536a90b2f52aecb4ca86", size = 5596008, upload-time = "2026-04-18T04:34:51.503Z" }, + { url = "https://files.pythonhosted.org/packages/9a/c2/d10807bc8da4824b39e5bd01b5d05c077b6fd01bd91584167edf6b269d22/lxml-6.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b89b098105b8599dc57adac95d1813409ac476d3c948a498775d3d0c6124bfb", size = 5224451, upload-time = "2026-04-18T04:34:54.263Z" }, + { url = "https://files.pythonhosted.org/packages/3c/15/2ebea45bea427e7f0057e9ce7b2d62c5aba20c6b001cca89ed0aadb3ad41/lxml-6.1.0-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:c4a699432846df86cc3de502ee85f445ebad748a1c6021d445f3e514d2cd4b1c", size = 5312135, upload-time = "2026-04-18T04:34:56.818Z" }, + { url = "https://files.pythonhosted.org/packages/31/e2/87eeae151b0be2a308d49a7ec444ff3eb192b14251e62addb29d0bf3778f/lxml-6.1.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:30e7b2ed63b6c8e97cca8af048589a788ab5c9c905f36d9cf1c2bb549f450d2f", size = 4639126, upload-time = "2026-04-18T04:34:59.704Z" }, + { url = "https://files.pythonhosted.org/packages/a3/51/8a3f6a20902ad604dd746ec7b4000311b240d389dac5e9d95adefd349e0c/lxml-6.1.0-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:022981127642fe19866d2907d76241bb07ed21749601f727d5d5dd1ce5d1b773", size = 5232579, upload-time = "2026-04-18T04:35:02.658Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d2/650d619bdbe048d2c3f2c31edb00e35670a5e2d65b4fe3b61bce37b19121/lxml-6.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:23cad0cc86046d4222f7f418910e46b89971c5a45d3c8abfad0f64b7b05e4a9b", size = 5084206, upload-time = "2026-04-18T04:35:05.175Z" }, + { url = "https://files.pythonhosted.org/packages/dd/8a/672ca1a3cbeabd1f511ca275a916c0514b747f4b85bdaae103b8fa92f307/lxml-6.1.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:21c3302068f50d1e8728c67c87ba92aa87043abee517aa2576cca1855326b405", size = 4758906, upload-time = "2026-04-18T04:35:08.098Z" }, + { url = "https://files.pythonhosted.org/packages/be/f1/ef4b691da85c916cb2feb1eec7414f678162798ac85e042fa164419ac05c/lxml-6.1.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:be10838781cb3be19251e276910cd508fe127e27c3242e50521521a0f3781690", size = 5620553, upload-time = "2026-04-18T04:35:11.23Z" }, + { url = "https://files.pythonhosted.org/packages/59/17/94e81def74107809755ac2782fdad4404420f1c92ca83433d117a6d5acf0/lxml-6.1.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2173a7bffe97667bbf0767f8a99e587740a8c56fdf3befac4b09cb29a80276fd", size = 5229458, upload-time = "2026-04-18T04:35:14.254Z" }, + { url = "https://files.pythonhosted.org/packages/21/55/c4be91b0f830a871fc1b0d730943d56013b683d4671d5198260e2eae722b/lxml-6.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c6854e9cf99c84beb004eecd7d3a3868ef1109bf2b1df92d7bc11e96a36c2180", size = 5247861, upload-time = "2026-04-18T04:35:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ca/77123e4d77df3cb1e968ade7b1f808f5d3a5c1c96b18a33895397de292c1/lxml-6.1.0-cp314-cp314t-win32.whl", hash = "sha256:00750d63ef0031a05331b9223463b1c7c02b9004cef2346a5b2877f0f9494dd2", size = 3897377, upload-time = "2026-04-18T04:32:07.656Z" }, + { url = "https://files.pythonhosted.org/packages/64/ce/3554833989d074267c063209bae8b09815e5656456a2d332b947806b05ff/lxml-6.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:80410c3a7e3c617af04de17caa9f9f20adaa817093293d69eae7d7d0522836f5", size = 4392701, upload-time = "2026-04-18T04:32:12.113Z" }, + { url = "https://files.pythonhosted.org/packages/2b/a0/9b916c68c0e57752c07f8f64b30138d9d4059dbeb27b90274dedbea128ff/lxml-6.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:26dd9f57ee3bd41e7d35b4c98a2ffd89ed11591649f421f0ec19f67d50ec67ac", size = 3817120, upload-time = "2026-04-18T04:32:15.803Z" }, +] + +[[package]] +name = "magika" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "numpy" }, + { name = "onnxruntime" }, + { name = "python-dotenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/f3/3d1dcdd7b9c41d589f5cff252d32ed91cdf86ba84391cfc81d9d8773571d/magika-0.6.3.tar.gz", hash = "sha256:7cc52aa7359af861957043e2bf7265ed4741067251c104532765cd668c0c0cb1", size = 3042784, upload-time = "2025-10-30T15:22:34.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/e4/35c323beb3280482c94299d61626116856ac2d4ec16ecef50afc4fdd4291/magika-0.6.3-py3-none-any.whl", hash = "sha256:eda443d08006ee495e02083b32e51b98cb3696ab595a7d13900d8e2ef506ec9d", size = 2969474, upload-time = "2025-10-30T15:22:25.298Z" }, + { url = "https://files.pythonhosted.org/packages/25/8f/132b0d7cd51c02c39fd52658a5896276c30c8cc2fd453270b19db8c40f7e/magika-0.6.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:86901e64b05dde5faff408c9b8245495b2e1fd4c226e3393d3d2a3fee65c504b", size = 13358841, upload-time = "2025-10-30T15:22:27.413Z" }, + { url = "https://files.pythonhosted.org/packages/c4/03/5ed859be502903a68b7b393b17ae0283bf34195cfcca79ce2dc25b9290e7/magika-0.6.3-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:3d9661eedbdf445ac9567e97e7ceefb93545d77a6a32858139ea966b5806fb64", size = 15367335, upload-time = "2025-10-30T15:22:29.907Z" }, + { url = "https://files.pythonhosted.org/packages/7b/9e/f8ee7d644affa3b80efdd623a3d75865c8f058f3950cb87fb0c48e3559bc/magika-0.6.3-py3-none-win_amd64.whl", hash = "sha256:e57f75674447b20cab4db928ae58ab264d7d8582b55183a0b876711c2b2787f3", size = 12692831, upload-time = "2025-10-30T15:22:32.063Z" }, +] + +[[package]] +name = "mammoth" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cobble" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/3c/a58418d2af00f2da60d4a51e18cd0311307b72d48d2fffec36a97b4a5e44/mammoth-1.11.0.tar.gz", hash = "sha256:a0f59e442f34d5b6447f4b0999306cbf3e67aaabfa8cb516f878fb1456744637", size = 53142, upload-time = "2025-09-19T10:35:20.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/54/2e39566a131b13f6d8d193f974cb6a34e81bb7cc2fa6f7e03de067b36588/mammoth-1.11.0-py2.py3-none-any.whl", hash = "sha256:c077ab0d450bd7c0c6ecd529a23bf7e0fa8190c929e28998308ff4eada3f063b", size = 54752, upload-time = "2025-09-19T10:35:18.699Z" }, +] + +[[package]] +name = "markdownify" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3f/bc/c8c8eea5335341306b0fa7e1cb33c5e1c8d24ef70ddd684da65f41c49c92/markdownify-1.2.2.tar.gz", hash = "sha256:b274f1b5943180b031b699b199cbaeb1e2ac938b75851849a31fd0c3d6603d09", size = 18816, upload-time = "2025-11-16T19:21:18.565Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ce/f1e3e9d959db134cedf06825fae8d5b294bd368aacdd0831a3975b7c4d55/markdownify-1.2.2-py3-none-any.whl", hash = "sha256:3f02d3cc52714084d6e589f70397b6fc9f2f3a8531481bf35e8cc39f975e186a", size = 15724, upload-time = "2025-11-16T19:21:17.622Z" }, +] + +[[package]] +name = "markitdown" +version = "0.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "charset-normalizer" }, + { name = "defusedxml" }, + { name = "magika" }, + { name = "markdownify" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/93/3b93c291c99d09f64f7535ba74c1c6a3507cf49cffd38983a55de6f834b6/markitdown-0.1.5.tar.gz", hash = "sha256:4c956ff1528bf15e1814542035ec96e989206d19d311bb799f4df973ecafc31a", size = 45099, upload-time = "2026-02-20T19:45:23.886Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/8b/fd7e042455a829a1ede0bc8e9e3061aa6c7c4cf745385526ef62ff1b5a5b/markitdown-0.1.5-py3-none-any.whl", hash = "sha256:5180a9a841e20fc01c2c09dbc5d039638429bbebcdc2af1b2615c3c427840434", size = 63402, upload-time = "2026-02-20T19:45:27.195Z" }, +] + +[package.optional-dependencies] +all = [ + { name = "azure-ai-documentintelligence" }, + { name = "azure-identity" }, + { name = "lxml" }, + { name = "mammoth" }, + { name = "olefile" }, + { name = "openpyxl" }, + { name = "pandas" }, + { name = "pdfminer-six" }, + { name = "pdfplumber" }, + { name = "pydub" }, + { name = "python-pptx" }, + { name = "speechrecognition" }, + { name = "xlrd" }, + { name = "youtube-transcript-api" }, +] + +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, +] + +[[package]] +name = "msal" +version = "1.35.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/aa/5a646093ac218e4a329391d5a31e5092a89db7d2ef1637a90b82cd0b6f94/msal-1.35.1.tar.gz", hash = "sha256:70cac18ab80a053bff86219ba64cfe3da1f307c74b009e2da57ef040eb1b5656", size = 165658, upload-time = "2026-03-04T23:38:51.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/86/16815fddf056ca998853c6dc525397edf0b43559bb4073a80d2bc7fe8009/msal-1.35.1-py3-none-any.whl", hash = "sha256:8f4e82f34b10c19e326ec69f44dc6b30171f2f7098f3720ea8a9f0c11832caa3", size = 119909, upload-time = "2026-03-04T23:38:50.452Z" }, +] + +[[package]] +name = "msal-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "msal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/99/5d239b6156eddf761a636bded1118414d161bd6b7b37a9335549ed159396/msal_extensions-1.3.1.tar.gz", hash = "sha256:c5b0fd10f65ef62b5f1d62f4251d51cbcaf003fcedae8c91b040a488614be1a4", size = 23315, upload-time = "2025-03-14T23:51:03.902Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/75/bd9b7bb966668920f06b200e84454c8f3566b102183bc55c5473d96cb2b9/msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca", size = 20583, upload-time = "2025-03-14T23:51:03.016Z" }, +] + +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/10/8b/c265f4823726ab832de836cdd184d0986dcf94480f81e8739692a7ac7af2/numpy-2.4.3.tar.gz", hash = "sha256:483a201202b73495f00dbc83796c6ae63137a9bdade074f7648b3e32613412dd", size = 20727743, upload-time = "2026-03-09T07:58:53.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/ed/6388632536f9788cea23a3a1b629f25b43eaacd7d7377e5d6bc7b9deb69b/numpy-2.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:61b0cbabbb6126c8df63b9a3a0c4b1f44ebca5e12ff6997b80fcf267fb3150ef", size = 16669628, upload-time = "2026-03-09T07:56:24.252Z" }, + { url = "https://files.pythonhosted.org/packages/74/1b/ee2abfc68e1ce728b2958b6ba831d65c62e1b13ce3017c13943f8f9b5b2e/numpy-2.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7395e69ff32526710748f92cd8c9849b361830968ea3e24a676f272653e8983e", size = 14696872, upload-time = "2026-03-09T07:56:26.991Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d1/780400e915ff5638166f11ca9dc2c5815189f3d7cf6f8759a1685e586413/numpy-2.4.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:abdce0f71dcb4a00e4e77f3faf05e4616ceccfe72ccaa07f47ee79cda3b7b0f4", size = 5203489, upload-time = "2026-03-09T07:56:29.414Z" }, + { url = "https://files.pythonhosted.org/packages/0b/bb/baffa907e9da4cc34a6e556d6d90e032f6d7a75ea47968ea92b4858826c4/numpy-2.4.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:48da3a4ee1336454b07497ff7ec83903efa5505792c4e6d9bf83d99dc07a1e18", size = 6550814, upload-time = "2026-03-09T07:56:32.225Z" }, + { url = "https://files.pythonhosted.org/packages/7b/12/8c9f0c6c95f76aeb20fc4a699c33e9f827fa0d0f857747c73bb7b17af945/numpy-2.4.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32e3bef222ad6b052280311d1d60db8e259e4947052c3ae7dd6817451fc8a4c5", size = 15666601, upload-time = "2026-03-09T07:56:34.461Z" }, + { url = "https://files.pythonhosted.org/packages/bd/79/cc665495e4d57d0aa6fbcc0aa57aa82671dfc78fbf95fe733ed86d98f52a/numpy-2.4.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7dd01a46700b1967487141a66ac1a3cf0dd8ebf1f08db37d46389401512ca97", size = 16621358, upload-time = "2026-03-09T07:56:36.852Z" }, + { url = "https://files.pythonhosted.org/packages/a8/40/b4ecb7224af1065c3539f5ecfff879d090de09608ad1008f02c05c770cb3/numpy-2.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:76f0f283506c28b12bba319c0fab98217e9f9b54e6160e9c79e9f7348ba32e9c", size = 17016135, upload-time = "2026-03-09T07:56:39.337Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b1/6a88e888052eed951afed7a142dcdf3b149a030ca59b4c71eef085858e43/numpy-2.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:737f630a337364665aba3b5a77e56a68cc42d350edd010c345d65a3efa3addcc", size = 18345816, upload-time = "2026-03-09T07:56:42.31Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8f/103a60c5f8c3d7fc678c19cd7b2476110da689ccb80bc18050efbaeae183/numpy-2.4.3-cp312-cp312-win32.whl", hash = "sha256:26952e18d82a1dbbc2f008d402021baa8d6fc8e84347a2072a25e08b46d698b9", size = 5960132, upload-time = "2026-03-09T07:56:44.851Z" }, + { url = "https://files.pythonhosted.org/packages/d7/7c/f5ee1bf6ed888494978046a809df2882aad35d414b622893322df7286879/numpy-2.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:65f3c2455188f09678355f5cae1f959a06b778bc66d535da07bf2ef20cd319d5", size = 12316144, upload-time = "2026-03-09T07:56:47.057Z" }, + { url = "https://files.pythonhosted.org/packages/71/46/8d1cb3f7a00f2fb6394140e7e6623696e54c6318a9d9691bb4904672cf42/numpy-2.4.3-cp312-cp312-win_arm64.whl", hash = "sha256:2abad5c7fef172b3377502bde47892439bae394a71bc329f31df0fd829b41a9e", size = 10220364, upload-time = "2026-03-09T07:56:49.849Z" }, + { url = "https://files.pythonhosted.org/packages/b6/d0/1fe47a98ce0df229238b77611340aff92d52691bcbc10583303181abf7fc/numpy-2.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b346845443716c8e542d54112966383b448f4a3ba5c66409771b8c0889485dd3", size = 16665297, upload-time = "2026-03-09T07:56:52.296Z" }, + { url = "https://files.pythonhosted.org/packages/27/d9/4e7c3f0e68dfa91f21c6fb6cf839bc829ec920688b1ce7ec722b1a6202fb/numpy-2.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2629289168f4897a3c4e23dc98d6f1731f0fc0fe52fb9db19f974041e4cc12b9", size = 14691853, upload-time = "2026-03-09T07:56:54.992Z" }, + { url = "https://files.pythonhosted.org/packages/3a/66/bd096b13a87549683812b53ab211e6d413497f84e794fb3c39191948da97/numpy-2.4.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:bb2e3cf95854233799013779216c57e153c1ee67a0bf92138acca0e429aefaee", size = 5198435, upload-time = "2026-03-09T07:56:57.184Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/687722910b5a5601de2135c891108f51dfc873d8e43c8ed9f4ebb440b4a2/numpy-2.4.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:7f3408ff897f8ab07a07fbe2823d7aee6ff644c097cc1f90382511fe982f647f", size = 6546347, upload-time = "2026-03-09T07:56:59.531Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ec/7971c4e98d86c564750393fab8d7d83d0a9432a9d78bb8a163a6dc59967a/numpy-2.4.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:decb0eb8a53c3b009b0962378065589685d66b23467ef5dac16cbe818afde27f", size = 15664626, upload-time = "2026-03-09T07:57:01.385Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/7daecbea84ec935b7fc732e18f532073064a3816f0932a40a17f3349185f/numpy-2.4.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5f51900414fc9204a0e0da158ba2ac52b75656e7dce7e77fb9f84bfa343b4cc", size = 16608916, upload-time = "2026-03-09T07:57:04.008Z" }, + { url = "https://files.pythonhosted.org/packages/df/58/2a2b4a817ffd7472dca4421d9f0776898b364154e30c95f42195041dc03b/numpy-2.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6bd06731541f89cdc01b261ba2c9e037f1543df7472517836b78dfb15bd6e476", size = 17015824, upload-time = "2026-03-09T07:57:06.347Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ca/627a828d44e78a418c55f82dd4caea8ea4a8ef24e5144d9e71016e52fb40/numpy-2.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22654fe6be0e5206f553a9250762c653d3698e46686eee53b399ab90da59bd92", size = 18334581, upload-time = "2026-03-09T07:57:09.114Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c0/76f93962fc79955fcba30a429b62304332345f22d4daec1cb33653425643/numpy-2.4.3-cp313-cp313-win32.whl", hash = "sha256:d71e379452a2f670ccb689ec801b1218cd3983e253105d6e83780967e899d687", size = 5958618, upload-time = "2026-03-09T07:57:11.432Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3c/88af0040119209b9b5cb59485fa48b76f372c73068dbf9254784b975ac53/numpy-2.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:0a60e17a14d640f49146cb38e3f105f571318db7826d9b6fef7e4dce758faecd", size = 12312824, upload-time = "2026-03-09T07:57:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/58/ce/3d07743aced3d173f877c3ef6a454c2174ba42b584ab0b7e6d99374f51ed/numpy-2.4.3-cp313-cp313-win_arm64.whl", hash = "sha256:c9619741e9da2059cd9c3f206110b97583c7152c1dc9f8aafd4beb450ac1c89d", size = 10221218, upload-time = "2026-03-09T07:57:16.183Z" }, + { url = "https://files.pythonhosted.org/packages/62/09/d96b02a91d09e9d97862f4fc8bfebf5400f567d8eb1fe4b0cc4795679c15/numpy-2.4.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7aa4e54f6469300ebca1d9eb80acd5253cdfa36f2c03d79a35883687da430875", size = 14819570, upload-time = "2026-03-09T07:57:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/b5/ca/0b1aba3905fdfa3373d523b2b15b19029f4f3031c87f4066bd9d20ef6c6b/numpy-2.4.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d1b90d840b25874cf5cd20c219af10bac3667db3876d9a495609273ebe679070", size = 5326113, upload-time = "2026-03-09T07:57:21.052Z" }, + { url = "https://files.pythonhosted.org/packages/c0/63/406e0fd32fcaeb94180fd6a4c41e55736d676c54346b7efbce548b94a914/numpy-2.4.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:a749547700de0a20a6718293396ec237bb38218049cfce788e08fcb716e8cf73", size = 6646370, upload-time = "2026-03-09T07:57:22.804Z" }, + { url = "https://files.pythonhosted.org/packages/b6/d0/10f7dc157d4b37af92720a196be6f54f889e90dcd30dce9dc657ed92c257/numpy-2.4.3-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94f3c4a151a2e529adf49c1d54f0f57ff8f9b233ee4d44af623a81553ab86368", size = 15723499, upload-time = "2026-03-09T07:57:24.693Z" }, + { url = "https://files.pythonhosted.org/packages/66/f1/d1c2bf1161396629701bc284d958dc1efa3a5a542aab83cf11ee6eb4cba5/numpy-2.4.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22c31dc07025123aedf7f2db9e91783df13f1776dc52c6b22c620870dc0fab22", size = 16657164, upload-time = "2026-03-09T07:57:27.676Z" }, + { url = "https://files.pythonhosted.org/packages/1a/be/cca19230b740af199ac47331a21c71e7a3d0ba59661350483c1600d28c37/numpy-2.4.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:148d59127ac95979d6f07e4d460f934ebdd6eed641db9c0db6c73026f2b2101a", size = 17081544, upload-time = "2026-03-09T07:57:30.664Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c5/9602b0cbb703a0936fb40f8a95407e8171935b15846de2f0776e08af04c7/numpy-2.4.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a97cbf7e905c435865c2d939af3d93f99d18eaaa3cabe4256f4304fb51604349", size = 18380290, upload-time = "2026-03-09T07:57:33.763Z" }, + { url = "https://files.pythonhosted.org/packages/ed/81/9f24708953cd30be9ee36ec4778f4b112b45165812f2ada4cc5ea1c1f254/numpy-2.4.3-cp313-cp313t-win32.whl", hash = "sha256:be3b8487d725a77acccc9924f65fd8bce9af7fac8c9820df1049424a2115af6c", size = 6082814, upload-time = "2026-03-09T07:57:36.491Z" }, + { url = "https://files.pythonhosted.org/packages/e2/9e/52f6eaa13e1a799f0ab79066c17f7016a4a8ae0c1aefa58c82b4dab690b4/numpy-2.4.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1ec84fd7c8e652b0f4aaaf2e6e9cc8eaa9b1b80a537e06b2e3a2fb176eedcb26", size = 12452673, upload-time = "2026-03-09T07:57:38.281Z" }, + { url = "https://files.pythonhosted.org/packages/c4/04/b8cece6ead0b30c9fbd99bb835ad7ea0112ac5f39f069788c5558e3b1ab2/numpy-2.4.3-cp313-cp313t-win_arm64.whl", hash = "sha256:120df8c0a81ebbf5b9020c91439fccd85f5e018a927a39f624845be194a2be02", size = 10290907, upload-time = "2026-03-09T07:57:40.747Z" }, + { url = "https://files.pythonhosted.org/packages/70/ae/3936f79adebf8caf81bd7a599b90a561334a658be4dcc7b6329ebf4ee8de/numpy-2.4.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5884ce5c7acfae1e4e1b6fde43797d10aa506074d25b531b4f54bde33c0c31d4", size = 16664563, upload-time = "2026-03-09T07:57:43.817Z" }, + { url = "https://files.pythonhosted.org/packages/9b/62/760f2b55866b496bb1fa7da2a6db076bef908110e568b02fcfc1422e2a3a/numpy-2.4.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:297837823f5bc572c5f9379b0c9f3a3365f08492cbdc33bcc3af174372ebb168", size = 14702161, upload-time = "2026-03-09T07:57:46.169Z" }, + { url = "https://files.pythonhosted.org/packages/32/af/a7a39464e2c0a21526fb4fb76e346fb172ebc92f6d1c7a07c2c139cc17b1/numpy-2.4.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:a111698b4a3f8dcbe54c64a7708f049355abd603e619013c346553c1fd4ca90b", size = 5208738, upload-time = "2026-03-09T07:57:48.506Z" }, + { url = "https://files.pythonhosted.org/packages/29/8c/2a0cf86a59558fa078d83805589c2de490f29ed4fb336c14313a161d358a/numpy-2.4.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:4bd4741a6a676770e0e97fe9ab2e51de01183df3dcbcec591d26d331a40de950", size = 6543618, upload-time = "2026-03-09T07:57:50.591Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b8/612ce010c0728b1c363fa4ea3aa4c22fe1c5da1de008486f8c2f5cb92fae/numpy-2.4.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54f29b877279d51e210e0c80709ee14ccbbad647810e8f3d375561c45ef613dd", size = 15680676, upload-time = "2026-03-09T07:57:52.34Z" }, + { url = "https://files.pythonhosted.org/packages/a9/7e/4f120ecc54ba26ddf3dc348eeb9eb063f421de65c05fc961941798feea18/numpy-2.4.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:679f2a834bae9020f81534671c56fd0cc76dd7e5182f57131478e23d0dc59e24", size = 16613492, upload-time = "2026-03-09T07:57:54.91Z" }, + { url = "https://files.pythonhosted.org/packages/2c/86/1b6020db73be330c4b45d5c6ee4295d59cfeef0e3ea323959d053e5a6909/numpy-2.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d84f0f881cb2225c2dfd7f78a10a5645d487a496c6668d6cc39f0f114164f3d0", size = 17031789, upload-time = "2026-03-09T07:57:57.641Z" }, + { url = "https://files.pythonhosted.org/packages/07/3a/3b90463bf41ebc21d1b7e06079f03070334374208c0f9a1f05e4ae8455e7/numpy-2.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d213c7e6e8d211888cc359bab7199670a00f5b82c0978b9d1c75baf1eddbeac0", size = 18339941, upload-time = "2026-03-09T07:58:00.577Z" }, + { url = "https://files.pythonhosted.org/packages/a8/74/6d736c4cd962259fd8bae9be27363eb4883a2f9069763747347544c2a487/numpy-2.4.3-cp314-cp314-win32.whl", hash = "sha256:52077feedeff7c76ed7c9f1a0428558e50825347b7545bbb8523da2cd55c547a", size = 6007503, upload-time = "2026-03-09T07:58:03.331Z" }, + { url = "https://files.pythonhosted.org/packages/48/39/c56ef87af669364356bb011922ef0734fc49dad51964568634c72a009488/numpy-2.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:0448e7f9caefb34b4b7dd2b77f21e8906e5d6f0365ad525f9f4f530b13df2afc", size = 12444915, upload-time = "2026-03-09T07:58:06.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1f/ab8528e38d295fd349310807496fabb7cf9fe2e1f70b97bc20a483ea9d4a/numpy-2.4.3-cp314-cp314-win_arm64.whl", hash = "sha256:b44fd60341c4d9783039598efadd03617fa28d041fc37d22b62d08f2027fa0e7", size = 10494875, upload-time = "2026-03-09T07:58:08.734Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ef/b7c35e4d5ef141b836658ab21a66d1a573e15b335b1d111d31f26c8ef80f/numpy-2.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0a195f4216be9305a73c0e91c9b026a35f2161237cf1c6de9b681637772ea657", size = 14822225, upload-time = "2026-03-09T07:58:11.034Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8d/7730fa9278cf6648639946cc816e7cc89f0d891602584697923375f801ed/numpy-2.4.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:cd32fbacb9fd1bf041bf8e89e4576b6f00b895f06d00914820ae06a616bdfef7", size = 5328769, upload-time = "2026-03-09T07:58:13.67Z" }, + { url = "https://files.pythonhosted.org/packages/47/01/d2a137317c958b074d338807c1b6a383406cdf8b8e53b075d804cc3d211d/numpy-2.4.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:2e03c05abaee1f672e9d67bc858f300b5ccba1c21397211e8d77d98350972093", size = 6649461, upload-time = "2026-03-09T07:58:15.912Z" }, + { url = "https://files.pythonhosted.org/packages/5c/34/812ce12bc0f00272a4b0ec0d713cd237cb390666eb6206323d1cc9cedbb2/numpy-2.4.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d1ce23cce91fcea443320a9d0ece9b9305d4368875bab09538f7a5b4131938a", size = 15725809, upload-time = "2026-03-09T07:58:17.787Z" }, + { url = "https://files.pythonhosted.org/packages/25/c0/2aed473a4823e905e765fee3dc2cbf504bd3e68ccb1150fbdabd5c39f527/numpy-2.4.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c59020932feb24ed49ffd03704fbab89f22aa9c0d4b180ff45542fe8918f5611", size = 16655242, upload-time = "2026-03-09T07:58:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/f2/c8/7e052b2fc87aa0e86de23f20e2c42bd261c624748aa8efd2c78f7bb8d8c6/numpy-2.4.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9684823a78a6cd6ad7511fc5e25b07947d1d5b5e2812c93fe99d7d4195130720", size = 17080660, upload-time = "2026-03-09T07:58:23.067Z" }, + { url = "https://files.pythonhosted.org/packages/f3/3d/0876746044db2adcb11549f214d104f2e1be00f07a67edbb4e2812094847/numpy-2.4.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0200b25c687033316fb39f0ff4e3e690e8957a2c3c8d22499891ec58c37a3eb5", size = 18380384, upload-time = "2026-03-09T07:58:25.839Z" }, + { url = "https://files.pythonhosted.org/packages/07/12/8160bea39da3335737b10308df4f484235fd297f556745f13092aa039d3b/numpy-2.4.3-cp314-cp314t-win32.whl", hash = "sha256:5e10da9e93247e554bb1d22f8edc51847ddd7dde52d85ce31024c1b4312bfba0", size = 6154547, upload-time = "2026-03-09T07:58:28.289Z" }, + { url = "https://files.pythonhosted.org/packages/42/f3/76534f61f80d74cc9cdf2e570d3d4eeb92c2280a27c39b0aaf471eda7b48/numpy-2.4.3-cp314-cp314t-win_amd64.whl", hash = "sha256:45f003dbdffb997a03da2d1d0cb41fbd24a87507fb41605c0420a3db5bd4667b", size = 12633645, upload-time = "2026-03-09T07:58:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/1f/b6/7c0d4334c15983cec7f92a69e8ce9b1e6f31857e5ee3a413ac424e6bd63d/numpy-2.4.3-cp314-cp314t-win_arm64.whl", hash = "sha256:4d382735cecd7bcf090172489a525cd7d4087bc331f7df9f60ddc9a296cf208e", size = 10565454, upload-time = "2026-03-09T07:58:33.031Z" }, +] + +[[package]] +name = "olefile" +version = "0.47" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/1b/077b508e3e500e1629d366249c3ccb32f95e50258b231705c09e3c7a4366/olefile-0.47.zip", hash = "sha256:599383381a0bf3dfbd932ca0ca6515acd174ed48870cbf7fee123d698c192c1c", size = 112240, upload-time = "2023-12-01T16:22:53.025Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/d3/b64c356a907242d719fc668b71befd73324e47ab46c8ebbbede252c154b2/olefile-0.47-py2.py3-none-any.whl", hash = "sha256:543c7da2a7adadf21214938bb79c83ea12b473a4b6ee4ad4bf854e7715e13d1f", size = 114565, upload-time = "2023-12-01T16:22:51.518Z" }, +] + +[[package]] +name = "onnxruntime" +version = "1.20.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coloredlogs" }, + { name = "flatbuffers" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "protobuf" }, + { name = "sympy" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/39/9335e0874f68f7d27103cbffc0e235e32e26759202df6085716375c078bb/onnxruntime-1.20.1-cp312-cp312-macosx_13_0_universal2.whl", hash = "sha256:22b0655e2bf4f2161d52706e31f517a0e54939dc393e92577df51808a7edc8c9", size = 31007580, upload-time = "2024-11-21T00:49:07.029Z" }, + { url = "https://files.pythonhosted.org/packages/c5/9d/a42a84e10f1744dd27c6f2f9280cc3fb98f869dd19b7cd042e391ee2ab61/onnxruntime-1.20.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1f56e898815963d6dc4ee1c35fc6c36506466eff6d16f3cb9848cea4e8c8172", size = 11952833, upload-time = "2024-11-21T00:49:10.563Z" }, + { url = "https://files.pythonhosted.org/packages/47/42/2f71f5680834688a9c81becbe5c5bb996fd33eaed5c66ae0606c3b1d6a02/onnxruntime-1.20.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb71a814f66517a65628c9e4a2bb530a6edd2cd5d87ffa0af0f6f773a027d99e", size = 13333903, upload-time = "2024-11-21T00:49:12.984Z" }, + { url = "https://files.pythonhosted.org/packages/c8/f1/aabfdf91d013320aa2fc46cf43c88ca0182860ff15df872b4552254a9680/onnxruntime-1.20.1-cp312-cp312-win32.whl", hash = "sha256:bd386cc9ee5f686ee8a75ba74037750aca55183085bf1941da8efcfe12d5b120", size = 9814562, upload-time = "2024-11-21T00:49:15.453Z" }, + { url = "https://files.pythonhosted.org/packages/dd/80/76979e0b744307d488c79e41051117634b956612cc731f1028eb17ee7294/onnxruntime-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:19c2d843eb074f385e8bbb753a40df780511061a63f9def1b216bf53860223fb", size = 11331482, upload-time = "2024-11-21T00:49:19.412Z" }, + { url = "https://files.pythonhosted.org/packages/f7/71/c5d980ac4189589267a06f758bd6c5667d07e55656bed6c6c0580733ad07/onnxruntime-1.20.1-cp313-cp313-macosx_13_0_universal2.whl", hash = "sha256:cc01437a32d0042b606f462245c8bbae269e5442797f6213e36ce61d5abdd8cc", size = 31007574, upload-time = "2024-11-21T00:49:23.225Z" }, + { url = "https://files.pythonhosted.org/packages/81/0d/13bbd9489be2a6944f4a940084bfe388f1100472f38c07080a46fbd4ab96/onnxruntime-1.20.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb44b08e017a648924dbe91b82d89b0c105b1adcfe31e90d1dc06b8677ad37be", size = 11951459, upload-time = "2024-11-21T00:49:26.269Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ea/4454ae122874fd52bbb8a961262de81c5f932edeb1b72217f594c700d6ef/onnxruntime-1.20.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bda6aebdf7917c1d811f21d41633df00c58aff2bef2f598f69289c1f1dabc4b3", size = 13331620, upload-time = "2024-11-21T00:49:28.875Z" }, + { url = "https://files.pythonhosted.org/packages/d8/e0/50db43188ca1c945decaa8fc2a024c33446d31afed40149897d4f9de505f/onnxruntime-1.20.1-cp313-cp313-win_amd64.whl", hash = "sha256:d30367df7e70f1d9fc5a6a68106f5961686d39b54d3221f760085524e8d38e16", size = 11331758, upload-time = "2024-11-21T00:49:31.417Z" }, + { url = "https://files.pythonhosted.org/packages/d8/55/3821c5fd60b52a6c82a00bba18531793c93c4addfe64fbf061e235c5617a/onnxruntime-1.20.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9158465745423b2b5d97ed25aa7740c7d38d2993ee2e5c3bfacb0c4145c49d8", size = 11950342, upload-time = "2024-11-21T00:49:34.164Z" }, + { url = "https://files.pythonhosted.org/packages/14/56/fd990ca222cef4f9f4a9400567b9a15b220dee2eafffb16b2adbc55c8281/onnxruntime-1.20.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0df6f2df83d61f46e842dbcde610ede27218947c33e994545a22333491e72a3b", size = 13337040, upload-time = "2024-11-21T00:49:37.271Z" }, +] + +[[package]] +name = "opendataloader-pdf" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/1d/b70e392a4ce1b6e03c432c42457a9fd49b984be43a75681400544ed306ed/opendataloader_pdf-2.4.6.tar.gz", hash = "sha256:f3accb230a2eb609e1b1c9593e6650f4e41dc94435919ef2e348b301722366cb", size = 22549250, upload-time = "2026-05-21T02:39:51.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/b1/925606b247a060b44e751f5aba876ed0d19cb32414c85bdd1fbb4cf2cf5b/opendataloader_pdf-2.4.6-py3-none-any.whl", hash = "sha256:70f3ac3aa37f7e07a5a05640eed071fbdf0428040af33f432bd43f8a31df460e", size = 22563333, upload-time = "2026-05-21T02:39:47.944Z" }, +] + +[[package]] +name = "openpyxl" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "et-xmlfile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pandas" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/0c/b28ed414f080ee0ad153f848586d61d1878f91689950f037f976ce15f6c8/pandas-3.0.1.tar.gz", hash = "sha256:4186a699674af418f655dbd420ed87f50d56b4cd6603784279d9eef6627823c8", size = 4641901, upload-time = "2026-02-17T22:20:16.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/51/b467209c08dae2c624873d7491ea47d2b47336e5403309d433ea79c38571/pandas-3.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:476f84f8c20c9f5bc47252b66b4bb25e1a9fc2fa98cead96744d8116cb85771d", size = 10344357, upload-time = "2026-02-17T22:18:38.262Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f1/e2567ffc8951ab371db2e40b2fe068e36b81d8cf3260f06ae508700e5504/pandas-3.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0ab749dfba921edf641d4036c4c21c0b3ea70fea478165cb98a998fb2a261955", size = 9884543, upload-time = "2026-02-17T22:18:41.476Z" }, + { url = "https://files.pythonhosted.org/packages/d7/39/327802e0b6d693182403c144edacbc27eb82907b57062f23ef5a4c4a5ea7/pandas-3.0.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8e36891080b87823aff3640c78649b91b8ff6eea3c0d70aeabd72ea43ab069b", size = 10396030, upload-time = "2026-02-17T22:18:43.822Z" }, + { url = "https://files.pythonhosted.org/packages/3d/fe/89d77e424365280b79d99b3e1e7d606f5165af2f2ecfaf0c6d24c799d607/pandas-3.0.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:532527a701281b9dd371e2f582ed9094f4c12dd9ffb82c0c54ee28d8ac9520c4", size = 10876435, upload-time = "2026-02-17T22:18:45.954Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a6/2a75320849dd154a793f69c951db759aedb8d1dd3939eeacda9bdcfa1629/pandas-3.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:356e5c055ed9b0da1580d465657bc7d00635af4fd47f30afb23025352ba764d1", size = 11405133, upload-time = "2026-02-17T22:18:48.533Z" }, + { url = "https://files.pythonhosted.org/packages/58/53/1d68fafb2e02d7881df66aa53be4cd748d25cbe311f3b3c85c93ea5d30ca/pandas-3.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9d810036895f9ad6345b8f2a338dd6998a74e8483847403582cab67745bff821", size = 11932065, upload-time = "2026-02-17T22:18:50.837Z" }, + { url = "https://files.pythonhosted.org/packages/75/08/67cc404b3a966b6df27b38370ddd96b3b023030b572283d035181854aac5/pandas-3.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:536232a5fe26dd989bd633e7a0c450705fdc86a207fec7254a55e9a22950fe43", size = 9741627, upload-time = "2026-02-17T22:18:53.905Z" }, + { url = "https://files.pythonhosted.org/packages/86/4f/caf9952948fb00d23795f09b893d11f1cacb384e666854d87249530f7cbe/pandas-3.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f463ebfd8de7f326d38037c7363c6dacb857c5881ab8961fb387804d6daf2f7", size = 9052483, upload-time = "2026-02-17T22:18:57.31Z" }, + { url = "https://files.pythonhosted.org/packages/0b/48/aad6ec4f8d007534c091e9a7172b3ec1b1ee6d99a9cbb936b5eab6c6cf58/pandas-3.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5272627187b5d9c20e55d27caf5f2cd23e286aba25cadf73c8590e432e2b7262", size = 10317509, upload-time = "2026-02-17T22:18:59.498Z" }, + { url = "https://files.pythonhosted.org/packages/a8/14/5990826f779f79148ae9d3a2c39593dc04d61d5d90541e71b5749f35af95/pandas-3.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:661e0f665932af88c7877f31da0dc743fe9c8f2524bdffe23d24fdcb67ef9d56", size = 9860561, upload-time = "2026-02-17T22:19:02.265Z" }, + { url = "https://files.pythonhosted.org/packages/fa/80/f01ff54664b6d70fed71475543d108a9b7c888e923ad210795bef04ffb7d/pandas-3.0.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75e6e292ff898679e47a2199172593d9f6107fd2dd3617c22c2946e97d5df46e", size = 10365506, upload-time = "2026-02-17T22:19:05.017Z" }, + { url = "https://files.pythonhosted.org/packages/f2/85/ab6d04733a7d6ff32bfc8382bf1b07078228f5d6ebec5266b91bfc5c4ff7/pandas-3.0.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ff8cf1d2896e34343197685f432450ec99a85ba8d90cce2030c5eee2ef98791", size = 10873196, upload-time = "2026-02-17T22:19:07.204Z" }, + { url = "https://files.pythonhosted.org/packages/48/a9/9301c83d0b47c23ac5deab91c6b39fd98d5b5db4d93b25df8d381451828f/pandas-3.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eca8b4510f6763f3d37359c2105df03a7a221a508f30e396a51d0713d462e68a", size = 11370859, upload-time = "2026-02-17T22:19:09.436Z" }, + { url = "https://files.pythonhosted.org/packages/59/fe/0c1fc5bd2d29c7db2ab372330063ad555fb83e08422829c785f5ec2176ca/pandas-3.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:06aff2ad6f0b94a17822cf8b83bbb563b090ed82ff4fe7712db2ce57cd50d9b8", size = 11924584, upload-time = "2026-02-17T22:19:11.562Z" }, + { url = "https://files.pythonhosted.org/packages/d6/7d/216a1588b65a7aa5f4535570418a599d943c85afb1d95b0876fc00aa1468/pandas-3.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:9fea306c783e28884c29057a1d9baa11a349bbf99538ec1da44c8476563d1b25", size = 9742769, upload-time = "2026-02-17T22:19:13.926Z" }, + { url = "https://files.pythonhosted.org/packages/c4/cb/810a22a6af9a4e97c8ab1c946b47f3489c5bca5adc483ce0ffc84c9cc768/pandas-3.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:a8d37a43c52917427e897cb2e429f67a449327394396a81034a4449b99afda59", size = 9043855, upload-time = "2026-02-17T22:19:16.09Z" }, + { url = "https://files.pythonhosted.org/packages/92/fa/423c89086cca1f039cf1253c3ff5b90f157b5b3757314aa635f6bf3e30aa/pandas-3.0.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d54855f04f8246ed7b6fc96b05d4871591143c46c0b6f4af874764ed0d2d6f06", size = 10752673, upload-time = "2026-02-17T22:19:18.304Z" }, + { url = "https://files.pythonhosted.org/packages/22/23/b5a08ec1f40020397f0faba72f1e2c11f7596a6169c7b3e800abff0e433f/pandas-3.0.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e1b677accee34a09e0dc2ce5624e4a58a1870ffe56fc021e9caf7f23cd7668f", size = 10404967, upload-time = "2026-02-17T22:19:20.726Z" }, + { url = "https://files.pythonhosted.org/packages/5c/81/94841f1bb4afdc2b52a99daa895ac2c61600bb72e26525ecc9543d453ebc/pandas-3.0.1-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a9cabbdcd03f1b6cd254d6dda8ae09b0252524be1592594c00b7895916cb1324", size = 10320575, upload-time = "2026-02-17T22:19:24.919Z" }, + { url = "https://files.pythonhosted.org/packages/0a/8b/2ae37d66a5342a83adadfd0cb0b4bf9c3c7925424dd5f40d15d6cfaa35ee/pandas-3.0.1-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ae2ab1f166668b41e770650101e7090824fd34d17915dd9cd479f5c5e0065e9", size = 10710921, upload-time = "2026-02-17T22:19:27.181Z" }, + { url = "https://files.pythonhosted.org/packages/a2/61/772b2e2757855e232b7ccf7cb8079a5711becb3a97f291c953def15a833f/pandas-3.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6bf0603c2e30e2cafac32807b06435f28741135cb8697eae8b28c7d492fc7d76", size = 11334191, upload-time = "2026-02-17T22:19:29.411Z" }, + { url = "https://files.pythonhosted.org/packages/1b/08/b16c6df3ef555d8495d1d265a7963b65be166785d28f06a350913a4fac78/pandas-3.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6c426422973973cae1f4a23e51d4ae85974f44871b24844e4f7de752dd877098", size = 11782256, upload-time = "2026-02-17T22:19:32.34Z" }, + { url = "https://files.pythonhosted.org/packages/55/80/178af0594890dee17e239fca96d3d8670ba0f5ff59b7d0439850924a9c09/pandas-3.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b03f91ae8c10a85c1613102c7bef5229b5379f343030a3ccefeca8a33414cf35", size = 10485047, upload-time = "2026-02-17T22:19:34.605Z" }, + { url = "https://files.pythonhosted.org/packages/bb/8b/4bb774a998b97e6c2fd62a9e6cfdaae133b636fd1c468f92afb4ae9a447a/pandas-3.0.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:99d0f92ed92d3083d140bf6b97774f9f13863924cf3f52a70711f4e7588f9d0a", size = 10322465, upload-time = "2026-02-17T22:19:36.803Z" }, + { url = "https://files.pythonhosted.org/packages/72/3a/5b39b51c64159f470f1ca3b1c2a87da290657ca022f7cd11442606f607d1/pandas-3.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3b66857e983208654294bb6477b8a63dee26b37bdd0eb34d010556e91261784f", size = 9910632, upload-time = "2026-02-17T22:19:39.001Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f7/b449ffb3f68c11da12fc06fbf6d2fa3a41c41e17d0284d23a79e1c13a7e4/pandas-3.0.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56cf59638bf24dc9bdf2154c81e248b3289f9a09a6d04e63608c159022352749", size = 10440535, upload-time = "2026-02-17T22:19:41.157Z" }, + { url = "https://files.pythonhosted.org/packages/55/77/6ea82043db22cb0f2bbfe7198da3544000ddaadb12d26be36e19b03a2dc5/pandas-3.0.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1a9f55e0f46951874b863d1f3906dcb57df2d9be5c5847ba4dfb55b2c815249", size = 10893940, upload-time = "2026-02-17T22:19:43.493Z" }, + { url = "https://files.pythonhosted.org/packages/03/30/f1b502a72468c89412c1b882a08f6eed8a4ee9dc033f35f65d0663df6081/pandas-3.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1849f0bba9c8a2fb0f691d492b834cc8dadf617e29015c66e989448d58d011ee", size = 11442711, upload-time = "2026-02-17T22:19:46.074Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f0/ebb6ddd8fc049e98cabac5c2924d14d1dda26a20adb70d41ea2e428d3ec4/pandas-3.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3d288439e11b5325b02ae6e9cc83e6805a62c40c5a6220bea9beb899c073b1c", size = 11963918, upload-time = "2026-02-17T22:19:48.838Z" }, + { url = "https://files.pythonhosted.org/packages/09/f8/8ce132104074f977f907442790eaae24e27bce3b3b454e82faa3237ff098/pandas-3.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:93325b0fe372d192965f4cca88d97667f49557398bbf94abdda3bf1b591dbe66", size = 9862099, upload-time = "2026-02-17T22:19:51.081Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b7/6af9aac41ef2456b768ef0ae60acf8abcebb450a52043d030a65b4b7c9bd/pandas-3.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:97ca08674e3287c7148f4858b01136f8bdfe7202ad25ad04fec602dd1d29d132", size = 9185333, upload-time = "2026-02-17T22:19:53.266Z" }, + { url = "https://files.pythonhosted.org/packages/66/fc/848bb6710bc6061cb0c5badd65b92ff75c81302e0e31e496d00029fe4953/pandas-3.0.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:58eeb1b2e0fb322befcf2bbc9ba0af41e616abadb3d3414a6bc7167f6cbfce32", size = 10772664, upload-time = "2026-02-17T22:19:55.806Z" }, + { url = "https://files.pythonhosted.org/packages/69/5c/866a9bbd0f79263b4b0db6ec1a341be13a1473323f05c122388e0f15b21d/pandas-3.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cd9af1276b5ca9e298bd79a26bda32fa9cc87ed095b2a9a60978d2ca058eaf87", size = 10421286, upload-time = "2026-02-17T22:19:58.091Z" }, + { url = "https://files.pythonhosted.org/packages/51/a4/2058fb84fb1cfbfb2d4a6d485e1940bb4ad5716e539d779852494479c580/pandas-3.0.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94f87a04984d6b63788327cd9f79dda62b7f9043909d2440ceccf709249ca988", size = 10342050, upload-time = "2026-02-17T22:20:01.376Z" }, + { url = "https://files.pythonhosted.org/packages/22/1b/674e89996cc4be74db3c4eb09240c4bb549865c9c3f5d9b086ff8fcfbf00/pandas-3.0.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85fe4c4df62e1e20f9db6ebfb88c844b092c22cd5324bdcf94bfa2fc1b391221", size = 10740055, upload-time = "2026-02-17T22:20:04.328Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f8/e954b750764298c22fa4614376531fe63c521ef517e7059a51f062b87dca/pandas-3.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:331ca75a2f8672c365ae25c0b29e46f5ac0c6551fdace8eec4cd65e4fac271ff", size = 11357632, upload-time = "2026-02-17T22:20:06.647Z" }, + { url = "https://files.pythonhosted.org/packages/6d/02/c6e04b694ffd68568297abd03588b6d30295265176a5c01b7459d3bc35a3/pandas-3.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:15860b1fdb1973fffade772fdb931ccf9b2f400a3f5665aef94a00445d7d8dd5", size = 11810974, upload-time = "2026-02-17T22:20:08.946Z" }, + { url = "https://files.pythonhosted.org/packages/89/41/d7dfb63d2407f12055215070c42fc6ac41b66e90a2946cdc5e759058398b/pandas-3.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:44f1364411d5670efa692b146c748f4ed013df91ee91e9bec5677fb1fd58b937", size = 10884622, upload-time = "2026-02-17T22:20:11.711Z" }, + { url = "https://files.pythonhosted.org/packages/68/b0/34937815889fa982613775e4b97fddd13250f11012d769949c5465af2150/pandas-3.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:108dd1790337a494aa80e38def654ca3f0968cf4f362c85f44c15e471667102d", size = 9452085, upload-time = "2026-02-17T22:20:14.331Z" }, +] + +[[package]] +name = "pdfminer-six" +version = "20251230" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "charset-normalizer" }, + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/9a/d79d8fa6d47a0338846bb558b39b9963b8eb2dfedec61867c138c1b17eeb/pdfminer_six-20251230.tar.gz", hash = "sha256:e8f68a14c57e00c2d7276d26519ea64be1b48f91db1cdc776faa80528ca06c1e", size = 8511285, upload-time = "2025-12-30T15:49:13.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/d7/b288ea32deb752a09aab73c75e1e7572ab2a2b56c3124a5d1eb24c62ceb3/pdfminer_six-20251230-py3-none-any.whl", hash = "sha256:9ff2e3466a7dfc6de6fd779478850b6b7c2d9e9405aa2a5869376a822771f485", size = 6591909, upload-time = "2025-12-30T15:49:10.76Z" }, +] + +[[package]] +name = "pdfplumber" +version = "0.11.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pdfminer-six" }, + { name = "pillow" }, + { name = "pypdfium2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/37/9ca3519e92a8434eb93be570b131476cc0a4e840bb39c62ddb7813a39d53/pdfplumber-0.11.9.tar.gz", hash = "sha256:481224b678b2bbdbf376e2c39bf914144eef7c3d301b4a28eebf0f7f6109d6dc", size = 102768, upload-time = "2026-01-05T08:10:29.072Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/c8/cdbc975f5b634e249cfa6597e37c50f3078412474f21c015e508bfbfe3c3/pdfplumber-0.11.9-py3-none-any.whl", hash = "sha256:33ec5580959ba524e9100138746e090879504c42955df1b8a997604dd326c443", size = 60045, upload-time = "2026-01-05T08:10:27.512Z" }, +] + +[[package]] +name = "pdftotext" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/99/b6c910344eb95f3dddf8e27cddcdebb51765174b0624632db4cd62d3f172/pdftotext-3.0.0.tar.gz", hash = "sha256:c3652b19154e145515a8bfa953fc6136338ed92337bdccbb53fe0d88013b1aaa", size = 113565, upload-time = "2024-12-06T23:16:31.22Z" } + +[[package]] +name = "pillow" +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, + { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, + { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, + { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, + { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, + { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, + { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, + { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, + { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, + { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, + { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, + { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, + { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, + { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, + { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, + { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, + { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, + { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, + { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, + { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, + { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, + { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, +] + +[[package]] +name = "protobuf" +version = "7.34.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/6b/a0e95cad1ad7cc3f2c6821fcab91671bd5b78bd42afb357bb4765f29bc41/protobuf-7.34.1.tar.gz", hash = "sha256:9ce42245e704cc5027be797c1db1eb93184d44d1cdd71811fb2d9b25ad541280", size = 454708, upload-time = "2026-03-20T17:34:47.036Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/11/3325d41e6ee15bf1125654301211247b042563bcc898784351252549a8ad/protobuf-7.34.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:d8b2cc79c4d8f62b293ad9b11ec3aebce9af481fa73e64556969f7345ebf9fc7", size = 429247, upload-time = "2026-03-20T17:34:37.024Z" }, + { url = "https://files.pythonhosted.org/packages/eb/9d/aa69df2724ff63efa6f72307b483ce0827f4347cc6d6df24b59e26659fef/protobuf-7.34.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:5185e0e948d07abe94bb76ec9b8416b604cfe5da6f871d67aad30cbf24c3110b", size = 325753, upload-time = "2026-03-20T17:34:38.751Z" }, + { url = "https://files.pythonhosted.org/packages/92/e8/d174c91fd48e50101943f042b09af9029064810b734e4160bbe282fa1caa/protobuf-7.34.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:403b093a6e28a960372b44e5eb081775c9b056e816a8029c61231743d63f881a", size = 340198, upload-time = "2026-03-20T17:34:39.871Z" }, + { url = "https://files.pythonhosted.org/packages/53/1b/3b431694a4dc6d37b9f653f0c64b0a0d9ec074ee810710c0c3da21d67ba7/protobuf-7.34.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:8ff40ce8cd688f7265326b38d5a1bed9bfdf5e6723d49961432f83e21d5713e4", size = 324267, upload-time = "2026-03-20T17:34:41.1Z" }, + { url = "https://files.pythonhosted.org/packages/85/29/64de04a0ac142fb685fd09999bc3d337943fb386f3a0ec57f92fd8203f97/protobuf-7.34.1-cp310-abi3-win32.whl", hash = "sha256:34b84ce27680df7cca9f231043ada0daa55d0c44a2ddfaa58ec1d0d89d8bf60a", size = 426628, upload-time = "2026-03-20T17:34:42.536Z" }, + { url = "https://files.pythonhosted.org/packages/4d/87/cb5e585192a22b8bd457df5a2c16a75ea0db9674c3a0a39fc9347d84e075/protobuf-7.34.1-cp310-abi3-win_amd64.whl", hash = "sha256:e97b55646e6ce5cbb0954a8c28cd39a5869b59090dfaa7df4598a7fba869468c", size = 437901, upload-time = "2026-03-20T17:34:44.112Z" }, + { url = "https://files.pythonhosted.org/packages/88/95/608f665226bca68b736b79e457fded9a2a38c4f4379a4a7614303d9db3bc/protobuf-7.34.1-py3-none-any.whl", hash = "sha256:bb3812cd53aefea2b028ef42bd780f5b96407247f20c6ef7c679807e9d188f11", size = 170715, upload-time = "2026-03-20T17:34:45.384Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, +] + +[[package]] +name = "pydub" +version = "0.25.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/9a/e6bca0eed82db26562c73b5076539a4a08d3cffd19c3cc5913a3e61145fd/pydub-0.25.1.tar.gz", hash = "sha256:980a33ce9949cab2a569606b65674d748ecbca4f0796887fd6f46173a7b0d30f", size = 38326, upload-time = "2021-03-10T02:09:54.659Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/53/d78dc063216e62fc55f6b2eebb447f6a4b0a59f55c8406376f76bf959b08/pydub-0.25.1-py2.py3-none-any.whl", hash = "sha256:65617e33033874b59d87db603aa1ed450633288aefead953b30bded59cb599a6", size = 32327, upload-time = "2021-03-10T02:09:53.503Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pymupdf" +version = "1.27.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/32/f6b645c51d79a188a4844140c5dabca7b487ad56c4be69c4bc782d0d11a9/pymupdf-1.27.2.2.tar.gz", hash = "sha256:ea8fdc3ab6671ca98f629d5ec3032d662c8cf1796b146996b7ad306ac7ed3335", size = 85354380, upload-time = "2026-03-20T09:47:58.386Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/88/d01992a50165e22dec057a1129826846c547feb4ba07f42720ac030ce438/pymupdf-1.27.2.2-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:800f43e60a6f01f644343c2213b8613db02eaf4f4ba235b417b3351fa99e01c0", size = 23987563, upload-time = "2026-03-19T12:35:42.989Z" }, + { url = "https://files.pythonhosted.org/packages/6d/0e/9f526bc1d49d8082eff0d1547a69d541a0c5a052e71da625559efaba46a6/pymupdf-1.27.2.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:8e2e4299ef1ac0c9dff9be096cbd22783699673abecfa7c3f73173ae06421d73", size = 23263089, upload-time = "2026-03-20T09:44:16.982Z" }, + { url = "https://files.pythonhosted.org/packages/42/be/984f0d6343935b5dd30afaed6be04fc753146bf55709e63ef28bf9ef7497/pymupdf-1.27.2.2-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c5e3d54922db1c7da844f1208ac1db05704770988752311f81dd36694ae0a07b", size = 24318817, upload-time = "2026-03-20T09:44:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/22/8e/85e9d9f11dbf34036eb1df283805ef6b885f2005a56d6533bb58ab0b8a11/pymupdf-1.27.2.2-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:892698c9768457eb0991c102c96a856c0a7062539371df5e6bee0816f3ef498e", size = 24948135, upload-time = "2026-03-20T09:44:51.012Z" }, + { url = "https://files.pythonhosted.org/packages/db/e6/386edb017e5b93f1ab0bf6653ae32f3dd8dfc834ed770212e10ca62f4af9/pymupdf-1.27.2.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b4bbfa6ef347fade678771a93f6364971c51a2cdc44cd2400dc4eeed1ddb4e6", size = 25169585, upload-time = "2026-03-20T09:45:05.393Z" }, + { url = "https://files.pythonhosted.org/packages/ba/fd/f1ebe24fcd31aaea8b85b3a7ac4c3fc96e20388be5466ace27c9a3c546d9/pymupdf-1.27.2.2-cp310-abi3-win32.whl", hash = "sha256:0b8e924433b7e0bd46be820899300259235997d5a747638471fb2762baa8ee30", size = 18008861, upload-time = "2026-03-20T09:45:21.353Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b6/2a9a8556000199bbf80a5915dcd15d550d1e5288894316445c54726aaf53/pymupdf-1.27.2.2-cp310-abi3-win_amd64.whl", hash = "sha256:09bb53f9486ccb5297030cbc2dbdae845ba1c3c5126e96eb2d16c4f118de0b5b", size = 19238032, upload-time = "2026-03-20T09:45:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c6/e3e11c42f09b9c34ec332c0f37b817671b59ef4001895b854f0494092105/pymupdf-1.27.2.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6cebfbbdfd219ebdebf4d8e3914624b2e3d3a844c43f4f76935822dd9b13cc12", size = 24985299, upload-time = "2026-03-20T09:45:53.26Z" }, +] + +[[package]] +name = "pymupdf-layout" +version = "1.27.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "networkx" }, + { name = "numpy" }, + { name = "onnxruntime" }, + { name = "pymupdf" }, + { name = "pyyaml" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/dd/4a9769b17661c1ee1b5bdeac28c832c9c7cc1ef425eb2088b5b5bd982bcc/pymupdf_layout-1.27.2.2-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:7b8f0d94d5675802c67e4af321214dcfce2de3d963926459dc6fc138607366cd", size = 15799842, upload-time = "2026-03-20T09:46:04.194Z" }, + { url = "https://files.pythonhosted.org/packages/ce/14/3ed13138449a002ab6957789019da5951fc8ba07ab8f1faf27a14c274717/pymupdf_layout-1.27.2.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:bef82a3ff5c05212c806333153cece2b9d972eed173d2352f0c514bb3f1faf54", size = 15795217, upload-time = "2026-03-20T09:46:14.142Z" }, + { url = "https://files.pythonhosted.org/packages/f7/20/487a2b1422999113ecc8b117cf50e72915992d0a7ef247164989396cf8db/pymupdf_layout-1.27.2.2-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d610359e1eb8013124531431f3b8c77818070e7869500b92c9b25bd78ea7ef7f", size = 15805238, upload-time = "2026-03-20T09:46:23.676Z" }, + { url = "https://files.pythonhosted.org/packages/02/45/35c67a1b1956618f69674b9823cc78e96787de37fe22a2b217581a1770a9/pymupdf_layout-1.27.2.2-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:df503eab9c28cfaadb847970f39093958e7a2ebf79fc47426dbd91b9f9064d6c", size = 15806267, upload-time = "2026-03-20T09:46:33.089Z" }, + { url = "https://files.pythonhosted.org/packages/82/56/97fad0cd00869e934f7a130f251b21e3534ec0fcffaa3459286fbf3daf32/pymupdf_layout-1.27.2.2-cp310-abi3-win_amd64.whl", hash = "sha256:efc66387833f085b9e9a77089c748c88c4c96485772d7dfe0139eaa6efc2f444", size = 15809705, upload-time = "2026-03-20T09:46:43.009Z" }, +] + +[[package]] +name = "pymupdf4llm" +version = "1.27.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pymupdf" }, + { name = "pymupdf-layout" }, + { name = "tabulate" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f0/e7/8b97bf223ea2fd72efd862af3210ae3aa2fb15b39b55767de9e0a2fd0985/pymupdf4llm-1.27.2.2.tar.gz", hash = "sha256:f95e113d434958f8c63393c836fe965ad398d1fc07e7807c0a627c9ec1946e9f", size = 72877, upload-time = "2026-03-20T09:48:01.485Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/fc/a4977b84f9a7e70aac4c9beed55d4693b985cef89fab7d49c896335bf158/pymupdf4llm-1.27.2.2-py3-none-any.whl", hash = "sha256:ec3bbceed21c6f86289155f29c557aa54ae1c8282c4a45d6de984f16fb4c90cb", size = 84294, upload-time = "2026-03-20T09:45:55.365Z" }, +] + +[[package]] +name = "pypdf" +version = "6.13.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/17/18/9947cc201af9ccf76720fd3347bf4f70eb882ce3fcf4cb05f7443e4cf871/pypdf-6.13.3.tar.gz", hash = "sha256:f3cb822769725f1bac658c406cfc9460399043f3750c2d3e4650e0a85eacabd7", size = 6484063, upload-time = "2026-06-17T15:22:00.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/56/2967e621598987905fb8cdfadd8f8de6b5c68c9351f0523c4df8409f28f1/pypdf-6.13.3-py3-none-any.whl", hash = "sha256:c6e3f86afb625791510b02ad5480e94b63970bb957df75d44657c282ecc52224", size = 347288, upload-time = "2026-06-17T15:21:59.512Z" }, +] + +[[package]] +name = "pypdfium2" +version = "5.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/01/be763b9081c7eb823196e7d13d9c145bf75ac43f3c1466de81c21c24b381/pypdfium2-5.6.0.tar.gz", hash = "sha256:bcb9368acfe3547054698abbdae68ba0cbd2d3bda8e8ee437e061deef061976d", size = 270714, upload-time = "2026-03-08T01:05:06.5Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/b1/129ed0177521a93a892f8a6a215dd3260093e30e77ef7035004bb8af7b6c/pypdfium2-5.6.0-py3-none-android_23_arm64_v8a.whl", hash = "sha256:fb7858c9707708555b4a719b5548a6e7f5d26bc82aef55ae4eb085d7a2190b11", size = 3346059, upload-time = "2026-03-08T01:04:21.37Z" }, + { url = "https://files.pythonhosted.org/packages/86/34/cbdece6886012180a7f2c7b2c360c415cf5e1f83f1973d2c9201dae3506a/pypdfium2-5.6.0-py3-none-android_23_armeabi_v7a.whl", hash = "sha256:6a7e1f4597317786f994bfb947eef480e53933f804a990193ab89eef8243f805", size = 2804418, upload-time = "2026-03-08T01:04:23.384Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f6/9f9e190fe0e5a6b86b82f83bd8b5d3490348766062381140ca5cad8e00b1/pypdfium2-5.6.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e468c38997573f0e86f03273c2c1fbdea999de52ba43fee96acaa2f6b2ad35f7", size = 3412541, upload-time = "2026-03-08T01:04:25.45Z" }, + { url = "https://files.pythonhosted.org/packages/ee/8d/e57492cb2228ba56ed57de1ff044c8ac114b46905f8b1445c33299ba0488/pypdfium2-5.6.0-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:ad3abddc5805424f962e383253ccad6a0d1d2ebd86afa9a9e1b9ca659773cd0d", size = 3592320, upload-time = "2026-03-08T01:04:27.509Z" }, + { url = "https://files.pythonhosted.org/packages/f9/8a/8ab82e33e9c551494cbe1526ea250ca8cc4e9e98d6a4fc6b6f8d959aa1d1/pypdfium2-5.6.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f6b5eb9eae5c45076395454522ca26add72ba8bd1fe473e1e4721aa58521470c", size = 3596450, upload-time = "2026-03-08T01:04:29.183Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b5/602a792282312ccb158cc63849528079d94b0a11efdc61f2a359edfb41e9/pypdfium2-5.6.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:258624da8ef45cdc426e11b33e9d83f9fb723c1c201c6e0f4ab5a85966c6b876", size = 3325442, upload-time = "2026-03-08T01:04:30.886Z" }, + { url = "https://files.pythonhosted.org/packages/81/1f/9e48ec05ed8d19d736c2d1f23c1bd0f20673f02ef846a2576c69e237f15d/pypdfium2-5.6.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e9367451c8a00931d6612db0822525a18c06f649d562cd323a719e46ac19c9bb", size = 3727434, upload-time = "2026-03-08T01:04:33.619Z" }, + { url = "https://files.pythonhosted.org/packages/33/90/0efd020928b4edbd65f4f3c2af0c84e20b43a3ada8fa6d04f999a97afe7a/pypdfium2-5.6.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a757869f891eac1cc1372e38a4aa01adac8abc8fe2a8a4e2ebf50595e3bf5937", size = 4139029, upload-time = "2026-03-08T01:04:36.08Z" }, + { url = "https://files.pythonhosted.org/packages/ff/49/a640b288a48dab1752281dd9b72c0679fccea107874e80a65a606b00efa9/pypdfium2-5.6.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:515be355222cc57ae9e62cd5c7c350b8e0c863efc539f80c7d75e2811ba45cb6", size = 3646387, upload-time = "2026-03-08T01:04:38.151Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/a344c19c01021eeb5d830c102e4fc9b1602f19c04aa7d11abbe2d188fd8e/pypdfium2-5.6.0-py3-none-manylinux_2_27_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1c4753c7caf7d004211d7f57a21f10d127f5e0e5510a14d24bc073e7220a3ea", size = 3097212, upload-time = "2026-03-08T01:04:40.776Z" }, + { url = "https://files.pythonhosted.org/packages/50/96/e48e13789ace22aeb9b7510904a1b1493ec588196e11bbacc122da330b3d/pypdfium2-5.6.0-py3-none-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c49729090281fdd85775fb8912c10bd19e99178efaa98f145ab06e7ce68554d2", size = 2965026, upload-time = "2026-03-08T01:04:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/cb/06/3100e44d4935f73af8f5d633d3bd40f0d36d606027085a0ef1f0566a6320/pypdfium2-5.6.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a4a1749a8d4afd62924a8d95cfa4f2e26fc32957ce34ac3b674be6f127ed252e", size = 4131431, upload-time = "2026-03-08T01:04:44.982Z" }, + { url = "https://files.pythonhosted.org/packages/64/ef/d8df63569ce9a66c8496057782eb8af78e0d28667922d62ec958434e3d4b/pypdfium2-5.6.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:36469ebd0fdffb7130ce45ed9c44f8232d91571c89eb851bd1633c64b6f6114f", size = 3747469, upload-time = "2026-03-08T01:04:46.702Z" }, + { url = "https://files.pythonhosted.org/packages/a6/47/fd2c6a67a49fade1acd719fbd11f7c375e7219912923ef2de0ea0ac1544e/pypdfium2-5.6.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9da900df09be3cf546b637a127a7b6428fb22d705951d731269e25fd3adef457", size = 4337578, upload-time = "2026-03-08T01:04:49.007Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f5/836c83e54b01e09478c4d6bf4912651d6053c932250fcee953f5c72d8e4a/pypdfium2-5.6.0-py3-none-musllinux_1_2_ppc64le.whl", hash = "sha256:45fccd5622233c5ec91a885770ae7dd4004d4320ac05a4ad8fa03a66dea40244", size = 4376104, upload-time = "2026-03-08T01:04:51.04Z" }, + { url = "https://files.pythonhosted.org/packages/6e/7f/b940b6a1664daf8f9bad87c6c99b84effa3611615b8708d10392dc33036c/pypdfium2-5.6.0-py3-none-musllinux_1_2_riscv64.whl", hash = "sha256:282dc030e767cd61bd0299f9d581052b91188e2b87561489057a8e7963e7e0cb", size = 3929824, upload-time = "2026-03-08T01:04:53.544Z" }, + { url = "https://files.pythonhosted.org/packages/88/79/00267d92a6a58c229e364d474f5698efe446e0c7f4f152f58d0138715e99/pypdfium2-5.6.0-py3-none-musllinux_1_2_s390x.whl", hash = "sha256:a1c1dfe950382c76a7bba1ba160ec5e40df8dd26b04a1124ae268fda55bc4cbe", size = 4270201, upload-time = "2026-03-08T01:04:55.81Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ab/b127f38aba41746bdf9ace15ba08411d7ef6ecba1326d529ba414eb1ed50/pypdfium2-5.6.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:43b0341ca6feb6c92e4b7a9eb4813e5466f5f5e8b6baeb14df0a94d5f312c00b", size = 4180793, upload-time = "2026-03-08T01:04:57.961Z" }, + { url = "https://files.pythonhosted.org/packages/0e/8c/a01c8e4302448b614d25a85c08298b0d3e9dfbdac5bd1b2f32c9b02e83d9/pypdfium2-5.6.0-py3-none-win32.whl", hash = "sha256:9dfcd4ff49a2b9260d00e38539ab28190d59e785e83030b30ffaf7a29c42155d", size = 3596753, upload-time = "2026-03-08T01:05:00.566Z" }, + { url = "https://files.pythonhosted.org/packages/9b/5f/2d871adf46761bb002a62686545da6348afe838d19af03df65d1ece786a2/pypdfium2-5.6.0-py3-none-win_amd64.whl", hash = "sha256:c6bc8dd63d0568f4b592f3e03de756afafc0e44aa1fe8878cc4aba1b11ae7374", size = 3716526, upload-time = "2026-03-08T01:05:02.433Z" }, + { url = "https://files.pythonhosted.org/packages/3a/80/0d9b162098597fbe3ac2b269b1682c0c3e8db9ba87679603fdd9b19afaa6/pypdfium2-5.6.0-py3-none-win_arm64.whl", hash = "sha256:5538417b199bdcb3207370c88df61f2ba3dac7a3253f82e1aa2708e6376b6f90", size = 3515049, upload-time = "2026-03-08T01:05:04.587Z" }, +] + +[[package]] +name = "pyreadline3" +version = "3.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/49/4cea918a08f02817aabae639e3d0ac046fef9f9180518a3ad394e22da148/pyreadline3-3.5.4.tar.gz", hash = "sha256:8d57d53039a1c75adba8e50dd3d992b28143480816187ea5efbd5c78e6c885b7", size = 99839, upload-time = "2024-09-19T02:40:10.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178, upload-time = "2024-09-19T02:40:08.598Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-pptx" +version = "1.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lxml" }, + { name = "pillow" }, + { name = "typing-extensions" }, + { name = "xlsxwriter" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/a9/0c0db8d37b2b8a645666f7fd8accea4c6224e013c42b1d5c17c93590cd06/python_pptx-1.0.2.tar.gz", hash = "sha256:479a8af0eaf0f0d76b6f00b0887732874ad2e3188230315290cd1f9dd9cc7095", size = 10109297, upload-time = "2024-08-07T17:33:37.772Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/4f/00be2196329ebbff56ce564aa94efb0fbc828d00de250b1980de1a34ab49/python_pptx-1.0.2-py3-none-any.whl", hash = "sha256:160838e0b8565a8b1f67947675886e9fea18aa5e795db7ae531606d68e785cba", size = 472788, upload-time = "2024-08-07T17:33:28.192Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "rapidfuzz" +version = "3.14.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/21/ef6157213316e85790041254259907eb722e00b03480256c0545d98acd33/rapidfuzz-3.14.5.tar.gz", hash = "sha256:ba10ac57884ce82112f7ed910b67e7fb6072d8ef2c06e30dc63c0f604a112e0e", size = 57901753, upload-time = "2026-04-07T11:16:31.931Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/e3/574435c6aafb80254c191ef40d7aca2cb2bb97a095ec9395e9fa59ac307a/rapidfuzz-3.14.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0d3378f471ef440473a396ce2f8e97ee12f89a78b495540e0a5617bbfe895638", size = 1944601, upload-time = "2026-04-07T11:14:18.771Z" }, + { url = "https://files.pythonhosted.org/packages/d0/1f/fbad3102a255ecc112ce9a7e779bacab7fd14398217be8868dc9082ba363/rapidfuzz-3.14.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e910eebca9fd0eba245c0555e764597e8a0cccb673a92da2dc2397050725f48", size = 1164293, upload-time = "2026-04-07T11:14:20.534Z" }, + { url = "https://files.pythonhosted.org/packages/88/37/a3eb7ff6121ed3a5f199a8c38cc86c8e481816f879cb0e0b738b078c9a7e/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01550fe5f60fd176aa66b7611289d46dc4aa4b1b904874c7b6d1d54e581c5ec1", size = 1371999, upload-time = "2026-04-07T11:14:22.63Z" }, + { url = "https://files.pythonhosted.org/packages/79/72/97a9728c711c7c1b06e107d3f0623880fb4ef90e147ed13c551a1730e7cc/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48bee0b91bebfaec41e1081e351000659ab7570cc4598d617aa04d5bf827f9e6", size = 3145715, upload-time = "2026-04-07T11:14:24.508Z" }, + { url = "https://files.pythonhosted.org/packages/ed/54/d5caabbea233ac90c286c87c260e49d7641467e87438a18d858e41c82e91/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:7e580cb04ad849ae9b786fa21383c6b994b6e6c1444ad1cb9f22392759d72741", size = 1456304, upload-time = "2026-04-07T11:14:26.515Z" }, + { url = "https://files.pythonhosted.org/packages/fc/a7/2d1a81250ac8c01a0100c026018e76f0e7a097ff63e4c553e02a6938c6fb/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:09d6c9ba091854f07817055d795d604179c12a8f308ba4c7d56f3719dfea1646", size = 2389089, upload-time = "2026-04-07T11:14:28.635Z" }, + { url = "https://files.pythonhosted.org/packages/65/0d/c47c3872203ae88e6506997c0b576ad731f5261daa25d559be09c9756658/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1e989f86113be66574113b9c7bdf4793f3f863d248e47d911b355e05ca6b6b10", size = 2493404, upload-time = "2026-04-07T11:14:30.577Z" }, + { url = "https://files.pythonhosted.org/packages/8f/2f/71e0a5a3130792146c8a200a2dd1e52aa16f7c1074012e17f2601eea9a90/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ebd1a18e2e47bc0b292a07e6ed9c3642f8aaa672d12253885f599b50807a4f9", size = 4251709, upload-time = "2026-04-07T11:14:32.451Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/d39874901abacef325adb5b34ae416817c8486dfb4fb87c7a9b74ec5b072/rapidfuzz-3.14.5-cp312-cp312-win32.whl", hash = "sha256:9981d38a703b86f0e315a3cd229fd1906fe1d91c989ed121fb975b3c849f89f5", size = 1710069, upload-time = "2026-04-07T11:14:34.37Z" }, + { url = "https://files.pythonhosted.org/packages/85/0b/f65572c53de8a1c704bda707f63a447b67bdbe95d7cdc70d18885e191df5/rapidfuzz-3.14.5-cp312-cp312-win_amd64.whl", hash = "sha256:d8375e3da319593389727c3187ccaf3e0e84199accc530866b8e0f2b79af05e9", size = 1540630, upload-time = "2026-04-07T11:14:36.287Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c3/143be3a578f989758cae516f3270d5cbb49783a7bfdf57cc27a670e00456/rapidfuzz-3.14.5-cp312-cp312-win_arm64.whl", hash = "sha256:478b59bb018a6780d73f33e38d0b3ec5e968a6c1ed42876b993dd456b7aa20e8", size = 813137, upload-time = "2026-04-07T11:14:38.289Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/252803f2010ba699618cdc048b6e1f7cc1f433c08b4a9a17579b92ab0142/rapidfuzz-3.14.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ebd8fd343bf8492a1e60bcb6dc99f90f74f65d98d8241a6b3e1fed225b76ecd6", size = 1940205, upload-time = "2026-04-07T11:14:40.319Z" }, + { url = "https://files.pythonhosted.org/packages/ea/59/b2afd98e41af9cd54554a4c1c423d84cdd60e6b1c0a09496f033b55f60ec/rapidfuzz-3.14.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6737b35d5af7479c5bf9710f7b17edd9d2c43128d974d25fb4ea653e42c64609", size = 1159639, upload-time = "2026-04-07T11:14:42.52Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/7aa7e62c4c516a7af322ed0c4f0774208b72d457d0cfec808bad0df12f4a/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b002c7994cc9f2bc9d9856f0fbaee6e8072c983873846c92f25cefba5b2a925f", size = 1367194, upload-time = "2026-04-07T11:14:44.25Z" }, + { url = "https://files.pythonhosted.org/packages/90/79/2fc252a63bc91d3c3b234d0a3a6ad4ebc460037a23cdcdaf9285f986e6c9/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17a34330cd2a538c1ce5d400b61ba358c5b72c654b928ff87b362e88f8b864c7", size = 3151805, upload-time = "2026-04-07T11:14:46.21Z" }, + { url = "https://files.pythonhosted.org/packages/17/54/0c83508f2683ea70e2d05f8527eb07328acf7bb1e9d97a3bece5702378e7/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:95d937e74c1a7a1287dfb03b62a827be08ede10a155cf1af73bbf47f2b73ee6e", size = 1455667, upload-time = "2026-04-07T11:14:47.991Z" }, + { url = "https://files.pythonhosted.org/packages/71/1b/070175e873177814d58850a01ebe80e20ae11e93eb4da894d563988660fa/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:46b92a9970dcc34f0096901c792644094cab49554ac3547f35e3aebbdf0a3610", size = 2388246, upload-time = "2026-04-07T11:14:50.098Z" }, + { url = "https://files.pythonhosted.org/packages/c9/dd/77caf7aaf9c2be050ad1f128d7c24ff0f59079aa62c5f62f9df41c0af45e/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e012177c8e8a8a0754ae0d6027d63042aa5ff036d9f40f07cb3466a6082e21b8", size = 2494333, upload-time = "2026-04-07T11:14:52.303Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/dd7e1f2aa31a8fbbfc16b0610af1d770ffaf1287490f3c8c5b1c52da264f/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a2ae6f53f99c9a0eca7a0afc5b4e45fc73bc1dd4ac74c00509031d76df80ed98", size = 4258579, upload-time = "2026-04-07T11:14:54.538Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0a/ac99e1ba347ba0e85e0bb60b74231d55fb93c0eff43f2920ccb413d0be08/rapidfuzz-3.14.5-cp313-cp313-win32.whl", hash = "sha256:4a60f0057231188e3bd30216f7b4e0f279b11fa4ec818bb6c1d9f014d1562fbc", size = 1709231, upload-time = "2026-04-07T11:14:56.524Z" }, + { url = "https://files.pythonhosted.org/packages/cf/cb/0e251d731b3166378644238e8f0cf9e89858c024e19f75ca9f7e3ae83fd5/rapidfuzz-3.14.5-cp313-cp313-win_amd64.whl", hash = "sha256:11bfc2ed8fbe4ab86bd516fadefab126f90e6dcadffa761739fcb304707dfd35", size = 1538519, upload-time = "2026-04-07T11:14:58.635Z" }, + { url = "https://files.pythonhosted.org/packages/30/6f/4548132acc947db6d5346a248e44a8b3a22d608ef30e770fb578caaf2d00/rapidfuzz-3.14.5-cp313-cp313-win_arm64.whl", hash = "sha256:b486b5218808f6f4dc471b114b1054e63553db69705c97da0271f47bd706aedd", size = 812628, upload-time = "2026-04-07T11:15:00.552Z" }, + { url = "https://files.pythonhosted.org/packages/00/60/69b177577290c5eab892c6f75fe89c3aff3f9ae80298a78d9372b1cecb9a/rapidfuzz-3.14.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:39ef8658aaf67d51667e7bdaf7096f432333377d8302ac43c70b5df8a4cf89b8", size = 1970231, upload-time = "2026-04-07T11:15:02.603Z" }, + { url = "https://files.pythonhosted.org/packages/48/38/2fd790052659cc4e2907b63c25433f0987864b445c1aeec1a302ef5ad948/rapidfuzz-3.14.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9ad37a0be705b544af6296da8edddc260d10a8ae5462530fc9991f66498bb1f9", size = 1194394, upload-time = "2026-04-07T11:15:04.572Z" }, + { url = "https://files.pythonhosted.org/packages/80/f4/28430ad8472fc3536e8ebd51a864a226e979cfe924c6e3f83d111373aa74/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d45e06f60729e07d9b20c205f7e5cff90b6ef2584e852eecf46e045aea69627d", size = 1377051, upload-time = "2026-04-07T11:15:06.728Z" }, + { url = "https://files.pythonhosted.org/packages/77/7e/9aeacabcfd1e77397968362e5b98fe14248b8307011136b17daf99752a8e/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e52da10236aa6212de71b9e170bace65b64b129c0dea7fc243d6c9ce976f5074", size = 3160565, upload-time = "2026-04-07T11:15:08.667Z" }, + { url = "https://files.pythonhosted.org/packages/56/f4/db4dd7be0cd2f2022117ac5407d905f435d60e48baaea313a567ad27e865/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:440d30faaf682ca496170a7f0cc5453ec942e3e079f0fd802c9a7f938dfb50a3", size = 1442113, upload-time = "2026-04-07T11:15:11.138Z" }, + { url = "https://files.pythonhosted.org/packages/a4/99/0e9f6aa57f3e32a767216f797e56dc96b720fcecfb9d8ee907ecc82f8d66/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:56227a61fd3d17b0cd9793132431f3a3d07c8654be96794ba9f89fe0fc8b2d09", size = 2396618, upload-time = "2026-04-07T11:15:13.154Z" }, + { url = "https://files.pythonhosted.org/packages/60/94/44a78e39ffce17cbdd3e2b53b696acc751d5d153be0f499d052b07a4d904/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:2e83cd2e25bb4edd97b689d9979d9c3acccdaaf26ceac08212ceece202febcfa", size = 2478220, upload-time = "2026-04-07T11:15:15.193Z" }, + { url = "https://files.pythonhosted.org/packages/dd/df/454311469a09a507e9d784a35796742bec22e4cebe75551e2da4e0e290fd/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:af3b859726cd3374287e405e14b9634563c078c5531a4f62375508addebddad1", size = 4265027, upload-time = "2026-04-07T11:15:17.28Z" }, + { url = "https://files.pythonhosted.org/packages/fc/01/175465a9ab3e3b70ba669058372f009d1d49c1746e2dcd56b69df188d3a5/rapidfuzz-3.14.5-cp313-cp313t-win32.whl", hash = "sha256:8ce1d850b3c0178440efde9e884d98421b5e87ff925f364d6d79e23910d7593f", size = 1766814, upload-time = "2026-04-07T11:15:19.687Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a0/a9b84a47af06ebed94a1439eb2f02adebfb8628bcd30af1fe3e02f5ef56c/rapidfuzz-3.14.5-cp313-cp313t-win_amd64.whl", hash = "sha256:c84af70bcf34e99aee894e46a0f1ac77f17d0ef828179c387407642e2466d28a", size = 1582448, upload-time = "2026-04-07T11:15:21.98Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f1/5937800238b3f8248e70860d79f69ba8f73e764fff47e36bc9e2f26dbcc6/rapidfuzz-3.14.5-cp313-cp313t-win_arm64.whl", hash = "sha256:aac0ad28c686a5e72b81668b906c030ee28050b244544b8af68e12fb32543895", size = 832932, upload-time = "2026-04-07T11:15:24.358Z" }, + { url = "https://files.pythonhosted.org/packages/81/41/aa3ffb3355e62e1bf91f6599b3092e866bc88487a07c524004943c7676df/rapidfuzz-3.14.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1a31cc6d7d03e7318a0974c038959c59e19c752b81115f2e9138b3331cd64d45", size = 1943327, upload-time = "2026-04-07T11:15:26.266Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e1/c2141f1840a41e07ad2db6f724945f8f8ff3065463899a22939152dd6e09/rapidfuzz-3.14.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0298d357e2bc59d572da4db0bc631009b6f8f6c9bc8c11e99a12b833f16b6575", size = 1161755, upload-time = "2026-04-07T11:15:28.659Z" }, + { url = "https://files.pythonhosted.org/packages/ca/07/66e753eeaa353161d1d331b7dd517bb349b0bacfebe8496d7b26be26f81f/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:59b3dba758661a318995655435c6ab20a04ade79fa51e75bc8dc107cac8df280", size = 1376571, upload-time = "2026-04-07T11:15:31.225Z" }, + { url = "https://files.pythonhosted.org/packages/c8/85/9535df0b78ba51f478c9ce7eb6d1f85535cc31fe356773b48fd9d3e563ca/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4900143d82071bdda533b00300c40b14b963ff826b3642cc463b6dd0f036585e", size = 3156468, upload-time = "2026-04-07T11:15:33.428Z" }, + { url = "https://files.pythonhosted.org/packages/81/ee/b667eb93bba6dc4e0de658edd778e1619dc4d6aab68fa5e5c7f075152735/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:feedf219672eef83ea6be6f3bb093bba396a8560fc75be85ba225f082903df0a", size = 1458311, upload-time = "2026-04-07T11:15:35.557Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ce/479074f5624364a48df3403c538797ef22d3ac49c19dc76c3f79fcdcc70c/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:419e4397a36e2665ec992d8d64c20ba4b2a42500c76ecadeca78a4f19cb9cc32", size = 2398228, upload-time = "2026-04-07T11:15:37.669Z" }, + { url = "https://files.pythonhosted.org/packages/0b/15/a8982f649150fffbdcd6f17565974501f6ab33b2795267bffbd4a7ba905b/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:97131ab2be39043054ee28d99e09efe316e6d53449b7e962dfcf3c2de8b2b246", size = 2497226, upload-time = "2026-04-07T11:15:39.857Z" }, + { url = "https://files.pythonhosted.org/packages/19/52/5267c03ef6759831b7d4625a0c9c06e87baa2fae084b61ac9c388858317b/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:593c00dac4e30231c35bf3b4f1da8ec0998762e9e94425586a5d636fcd57f9d0", size = 4262283, upload-time = "2026-04-07T11:15:42.279Z" }, + { url = "https://files.pythonhosted.org/packages/71/c0/2579f343a97f5254c43bb5853baccc01488357dcb64a27bcb869b7888a4a/rapidfuzz-3.14.5-cp314-cp314-win32.whl", hash = "sha256:0084b687b02b4e569b46d8d6d4ad25659528e6081cd6d067ca453a69035f07e4", size = 1744614, upload-time = "2026-04-07T11:15:44.498Z" }, + { url = "https://files.pythonhosted.org/packages/17/eb/8edfed1e80119dc9c35b11df4bc701eea85622ad681fff0263b6961d3224/rapidfuzz-3.14.5-cp314-cp314-win_amd64.whl", hash = "sha256:5dfa89d78f22cd773054caff44827b846161a29f2dcf7e78b8f90d086621e502", size = 1588971, upload-time = "2026-04-07T11:15:46.86Z" }, + { url = "https://files.pythonhosted.org/packages/f6/04/5676df93c85cfa57a3045d8047318df9f3cd58c7b8a99340dd95f874795e/rapidfuzz-3.14.5-cp314-cp314-win_arm64.whl", hash = "sha256:67f3f9d2b444268ab53e47d31bab89954888d23c04c6789f2c727e51fe4b1d13", size = 834985, upload-time = "2026-04-07T11:15:49.411Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0d/4a8988cea658fe335048ddef8c876addff1b6daa3c9ca8ad65a5a2196e69/rapidfuzz-3.14.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:77eac0526899b3c3ad1454bb2b03cdb491d67358ec8ef0c9c48bd61b632b431d", size = 1972517, upload-time = "2026-04-07T11:15:51.819Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a3/f5cfd9965a9d9a9e32249159797c47b5d6299ea6d1629f9126b25f1c10a3/rapidfuzz-3.14.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b9c6bd754d11f6e78ac54e3d86b4b11dc1ba2f13e5fc958899574532897f5a99", size = 1196056, upload-time = "2026-04-07T11:15:54.292Z" }, + { url = "https://files.pythonhosted.org/packages/64/07/561c2e40cfd10e6630a7b0ac5a2a813aef50d944bcd1f3d260319d659d5b/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:738c96944d076deeaff70e92b65696ab4f7ecb8081d7791c5403a3257dfaf8ff", size = 1374732, upload-time = "2026-04-07T11:15:56.584Z" }, + { url = "https://files.pythonhosted.org/packages/c2/39/123bb94fee40e2fb3b7c49b80827c7ef42d838e18def3fc2fef5a3cf817a/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4c1bca487a17fe4226b4ffb2d30e799d2b274d692cffa76bd0746f56235fca3", size = 3166902, upload-time = "2026-04-07T11:15:58.768Z" }, + { url = "https://files.pythonhosted.org/packages/75/0a/45716fafc9fd2e028cf20b5ac5bc704887081cd312f84edb0e325599414b/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:af6a90a4ed2a48fa1a2d17e9d824e6c7c950bea5bad0b707c77fd55751e6bfef", size = 1452130, upload-time = "2026-04-07T11:16:01.453Z" }, + { url = "https://files.pythonhosted.org/packages/ca/49/4e96c413114398481c0a5b0086af32c364a18613c9a2ea578d17c4bea4ee/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bf5018938208d4597b2e679a4f8cff9fd252f1df53583130ae56281a21801b64", size = 2396308, upload-time = "2026-04-07T11:16:03.588Z" }, + { url = "https://files.pythonhosted.org/packages/89/b7/49fea9fc6878d59bd259d01dd1972d9b86117992b1c66d9b16f0a65273c3/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c0919d1f89ddf91129906705723118ea09754171e4116f5a5dbc667c7bc9b261", size = 2488210, upload-time = "2026-04-07T11:16:05.871Z" }, + { url = "https://files.pythonhosted.org/packages/0c/44/a1f732b93ffacbdad077b7c801149549b2938e1bece6addb5ad85ed74df8/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:93d8da883a35116d6813432177f35e570db5b0a5e30ecb0cbd7cb39c815735df", size = 4270621, upload-time = "2026-04-07T11:16:08.483Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ce/ff942d19fce5385054650bb71a58495ddda299d94661ccc4e6e7fa44868b/rapidfuzz-3.14.5-cp314-cp314t-win32.whl", hash = "sha256:0f23e37019ec07712d58976b1ab2b889f8649a7f7c2f626a2f34ea9139e79279", size = 1803950, upload-time = "2026-04-07T11:16:10.873Z" }, + { url = "https://files.pythonhosted.org/packages/5c/0f/9aafc63f9661222b819b391c187eed29fc90ad5935f9690e5ecc2d2047a4/rapidfuzz-3.14.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7d5ca9c7832e6879a707296d1463685f7c243a27846227044504741640caec66", size = 1632357, upload-time = "2026-04-07T11:16:13.1Z" }, + { url = "https://files.pythonhosted.org/packages/70/a6/51fc1b0e61e3326e1c68a61cfd0c6b3c34c843681c4b1eefbf0596f59162/rapidfuzz-3.14.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3e91dcd2549b8f8d843f98ba03a17e01f3d8b72ce942adbbb6761bc58ffce813", size = 855409, upload-time = "2026-04-07T11:16:15.787Z" }, +] + +[[package]] +name = "requests" +version = "2.33.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, +] + +[[package]] +name = "speechrecognition" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "audioop-lts", marker = "python_full_version >= '3.13'" }, + { name = "standard-aifc", marker = "python_full_version >= '3.13'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/70/30b861a00aab91433dadcf827a0420319d71e319decdeb2f721d217c3db3/speechrecognition-3.15.1.tar.gz", hash = "sha256:cc5c8e040639a277c7586505c92b8d0d02b871daca57f3d175f8f678e82c3850", size = 32861196, upload-time = "2026-03-11T14:26:09.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/46/a7b177f6051dd6a572fe51774bac302c64ec0520199fd7532becc28bdba8/speechrecognition-3.15.1-py3-none-any.whl", hash = "sha256:b2b046170e1dda3e921ae3e993c77dace6d3610025ce91773cfd0debf1675c2d", size = 32853213, upload-time = "2026-03-11T14:26:04.196Z" }, +] + +[[package]] +name = "standard-aifc" +version = "3.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "audioop-lts", marker = "python_full_version >= '3.13'" }, + { name = "standard-chunk", marker = "python_full_version >= '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/53/6050dc3dde1671eb3db592c13b55a8005e5040131f7509cef0215212cb84/standard_aifc-3.13.0.tar.gz", hash = "sha256:64e249c7cb4b3daf2fdba4e95721f811bde8bdfc43ad9f936589b7bb2fae2e43", size = 15240, upload-time = "2024-10-30T16:01:31.772Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/52/5fbb203394cc852334d1575cc020f6bcec768d2265355984dfd361968f36/standard_aifc-3.13.0-py3-none-any.whl", hash = "sha256:f7ae09cc57de1224a0dd8e3eb8f73830be7c3d0bc485de4c1f82b4a7f645ac66", size = 10492, upload-time = "2024-10-30T16:01:07.071Z" }, +] + +[[package]] +name = "standard-chunk" +version = "3.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/06/ce1bb165c1f111c7d23a1ad17204d67224baa69725bb6857a264db61beaf/standard_chunk-3.13.0.tar.gz", hash = "sha256:4ac345d37d7e686d2755e01836b8d98eda0d1a3ee90375e597ae43aaf064d654", size = 4672, upload-time = "2024-10-30T16:18:28.326Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/90/a5c1084d87767d787a6caba615aa50dc587229646308d9420c960cb5e4c0/standard_chunk-3.13.0-py3-none-any.whl", hash = "sha256:17880a26c285189c644bd5bd8f8ed2bdb795d216e3293e6dbe55bbd848e2982c", size = 4944, upload-time = "2024-10-30T16:18:26.694Z" }, +] + +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, +] + +[[package]] +name = "tabulate" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/58/8c37dea7bbf769b20d58e7ace7e5edfe65b849442b00ffcdd56be88697c6/tabulate-0.10.0.tar.gz", hash = "sha256:e2cfde8f79420f6deeffdeda9aaec3b6bc5abce947655d17ac662b126e48a60d", size = 91754, upload-time = "2026-03-04T18:55:34.402Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl", hash = "sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3", size = 39814, upload-time = "2026-03-04T18:55:31.284Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "xlrd" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/07/5a/377161c2d3538d1990d7af382c79f3b2372e880b65de21b01b1a2b78691e/xlrd-2.0.2.tar.gz", hash = "sha256:08b5e25de58f21ce71dc7db3b3b8106c1fa776f3024c54e45b45b374e89234c9", size = 100167, upload-time = "2025-06-14T08:46:39.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/62/c8d562e7766786ba6587d09c5a8ba9f718ed3fa8af7f4553e8f91c36f302/xlrd-2.0.2-py2.py3-none-any.whl", hash = "sha256:ea762c3d29f4cca48d82df517b6d89fbce4db3107f9d78713e48cd321d5c9aa9", size = 96555, upload-time = "2025-06-14T08:46:37.766Z" }, +] + +[[package]] +name = "xlsxwriter" +version = "3.2.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/2c/c06ef49dc36e7954e55b802a8b231770d286a9758b3d936bd1e04ce5ba88/xlsxwriter-3.2.9.tar.gz", hash = "sha256:254b1c37a368c444eac6e2f867405cc9e461b0ed97a3233b2ac1e574efb4140c", size = 215940, upload-time = "2025-09-16T00:16:21.63Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/0c/3662f4a66880196a590b202f0db82d919dd2f89e99a27fadef91c4a33d41/xlsxwriter-3.2.9-py3-none-any.whl", hash = "sha256:9a5db42bc5dff014806c58a20b9eae7322a134abb6fce3c92c181bfb275ec5b3", size = 175315, upload-time = "2025-09-16T00:16:20.108Z" }, +] + +[[package]] +name = "youtube-transcript-api" +version = "1.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "defusedxml" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b0/32/f60d87a99c05a53604c58f20f670c7ea6262b55e0bbeb836ffe4550b248b/youtube_transcript_api-1.0.3.tar.gz", hash = "sha256:902baf90e7840a42e1e148335e09fe5575dbff64c81414957aea7038e8a4db46", size = 2153252, upload-time = "2025-03-25T18:14:21.119Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/44/40c03bb0f8bddfb9d2beff2ed31641f52d96c287ba881d20e0c074784ac2/youtube_transcript_api-1.0.3-py3-none-any.whl", hash = "sha256:d1874e57de65cf14c9d7d09b2b37c814d6287fa0e770d4922c4cd32a5b3f6c47", size = 2169911, upload-time = "2025-03-25T18:14:19.416Z" }, +] diff --git a/docs.config.mjs b/docs.config.mjs new file mode 100644 index 0000000..b573b83 --- /dev/null +++ b/docs.config.mjs @@ -0,0 +1,11 @@ +export default { + section: "liteparse", + label: "LiteParse", + content: [ + { src: "./docs/src/content/docs/liteparse", dest: "liteparse" }, + ], + sidebar: [{ + label: "LiteParse", + content: { type: "autogenerate", directory: "liteparse", collapsed: true }, + }], +}; diff --git a/docs/src/content/docs/liteparse/_meta.yml b/docs/src/content/docs/liteparse/_meta.yml new file mode 100644 index 0000000..7d25541 --- /dev/null +++ b/docs/src/content/docs/liteparse/_meta.yml @@ -0,0 +1,3 @@ +label: LiteParse +order: 1 +collapsed: false diff --git a/docs/src/content/docs/liteparse/cli-reference.md b/docs/src/content/docs/liteparse/cli-reference.md new file mode 100644 index 0000000..6649207 --- /dev/null +++ b/docs/src/content/docs/liteparse/cli-reference.md @@ -0,0 +1,215 @@ +--- +title: CLI Reference +description: Complete reference for all LiteParse CLI commands and options. +sidebar: + order: 5 +--- + +LiteParse provides the `lit` CLI with four commands: `parse`, `batch-parse`, `screenshot`, and `is-complex`. The CLI is the same whether installed via `npm`, `pip`, or built from Rust source. + +## `lit parse` + +Parse a single document. + +``` +lit parse [options] +``` + +### Arguments + +| Argument | Description | +|----------|-------------| +| `file` | Path to the document file, or `-` to read from stdin | + +### Options + +| Option | Description | Default | +|--------|-------------|---------| +| `-o, --output ` | Write output to a file instead of stdout | — | +| `--format ` | Output format: `json`, `text`, or `markdown` | `text` | +| `--image-mode ` | Markdown image handling: `off`, `placeholder`, or `embed` | `placeholder` | +| `--image-output-dir ` | Directory to write images to when `--image-mode embed` | — | +| `--no-links` | Emit link anchor text as plain text (no `[text](url)`) in markdown | — | +| `--no-ocr` | Disable OCR entirely | — | +| `--ocr-language ` | OCR language code (Tesseract format) | `eng` | +| `--ocr-server-url ` | HTTP OCR server URL | — (uses Tesseract) | +| `--tessdata-path ` | Path to tessdata directory | — (uses `TESSDATA_PREFIX` env var) | +| `--num-workers ` | Pages to OCR in parallel | CPU cores - 1 | +| `--max-pages ` | Maximum pages to parse | `1000` | +| `--target-pages ` | Pages to parse (e.g., `"1-5,10"`) | — (all pages) | +| `--dpi ` | Rendering DPI | `150` | +| `--preserve-small-text` | Keep very small text | — | +| `--password ` | Password for encrypted/protected documents | — | +| `-q, --quiet` | Suppress progress output | — | + +### Examples + +```bash +# Basic text parsing +lit parse report.pdf + +# JSON output with bounding boxes +lit parse report.pdf --format json -o report.json + +# Markdown output (headings, tables, lists, images, links) +lit parse report.pdf --format markdown -o report.md + +# Markdown with embedded images written to disk +lit parse report.pdf --format markdown --image-mode embed --image-output-dir ./images + +# Parse pages 1-5 only, no OCR +lit parse report.pdf --target-pages "1-5" --no-ocr + +# High-DPI rendering with French OCR +lit parse report.pdf --dpi 300 --ocr-language fra + +# Use an external OCR server +lit parse report.pdf --ocr-server-url http://localhost:8828/ocr + +# Pipe output to another tool +lit parse report.pdf -q | wc -l + +# Parse a remote file via stdin +curl -sL https://example.com/report.pdf | lit parse --no-ocr - +``` + +--- + +## `lit batch-parse` + +Parse multiple documents in a directory. + +``` +lit batch-parse [options] +``` + +### Arguments + +| Argument | Description | +|----------|-------------| +| `input-dir` | Directory containing documents to parse | +| `output-dir` | Directory for output files | + +### Options + +| Option | Description | Default | +|--------|-------------|---------| +| `--format ` | Output format: `json`, `text`, or `markdown` | `text` | +| `--no-ocr` | Disable OCR entirely | — | +| `--ocr-language ` | OCR language code | `eng` | +| `--ocr-server-url ` | HTTP OCR server URL | — (uses Tesseract) | +| `--tessdata-path ` | Path to tessdata directory | — | +| `--num-workers ` | Pages to OCR in parallel | CPU cores - 1 | +| `--max-pages ` | Maximum pages per file | `1000` | +| `--dpi ` | Rendering DPI | `150` | +| `--recursive` | Search subdirectories | — | +| `--extension ` | Only process this extension (e.g., `".pdf"`) | — (all supported) | +| `--password ` | Password for encrypted/protected documents (applied to all files) | — | +| `-q, --quiet` | Suppress progress output | — | + +### Examples + +```bash +# Parse all supported files in a directory +lit batch-parse ./documents ./output + +# Recursively parse only PDFs +lit batch-parse ./documents ./output --recursive --extension ".pdf" + +# Batch parse with JSON output and no OCR +lit batch-parse ./documents ./output --format json --no-ocr +``` + +--- + +## `lit screenshot` + +Generate page images from a document (PDF, DOCX, XLSX, images, etc.). + +``` +lit screenshot [options] +``` + +### Arguments + +| Argument | Description | +|----------|-------------| +| `file` | Path to the document file | + +### Options + +| Option | Description | Default | +|--------|-------------|---------| +| `-o, --output-dir ` | Output directory | `./screenshots` | +| `--target-pages ` | Pages to screenshot (e.g., `"1,3,5"` or `"1-5"`) | — (all pages) | +| `--dpi ` | Rendering DPI | `150` | +| `--password ` | Password for encrypted/protected documents | — | +| `-q, --quiet` | Suppress progress output | — | + +### Examples + +```bash +# Screenshot all pages of a PDF +lit screenshot document.pdf -o ./pages + +# Screenshot a Word document (requires LibreOffice) +lit screenshot report.docx -o ./pages + +# First 5 pages at high DPI +lit screenshot document.pdf --target-pages "1-5" --dpi 300 -o ./pages + +# Specific pages only +lit screenshot document.pdf --target-pages "1,5,10" -o ./pages +``` + +--- + +## `lit is-complex` + +Check whether a document needs OCR or heavier parsing — a cheap pre-parse pass over the text layer only (no rasterization, no OCR). See the [Document Complexity guide](/liteparse/guides/complexity/) for details. + +``` +lit is-complex [options] +``` + +The command prints per-page JSON to **stdout**, a human-readable verdict to **stderr**, and exits **non-zero when any page needs OCR** — so it works as a shell predicate or a `jq` source. + +### Arguments + +| Argument | Description | +|----------|-------------| +| `file` | Path to the document file | + +### Options + +| Option | Description | Default | +|--------|-------------|---------| +| `--compact` | Emit dense, whitespace-free JSON instead of pretty-printed | — | +| `--max-pages ` | Maximum pages to check | `1000` | +| `--target-pages ` | Pages to check (e.g., `"1-5,10,15-20"`) | — (all pages) | +| `--password ` | Password for encrypted/protected documents | — | +| `-q, --quiet` | Suppress the stderr verdict | — | + +### Examples + +```bash +# Print the complexity verdict and per-page JSON +lit is-complex document.pdf + +# Use as a shell predicate: only parse with --no-ocr when simple +lit is-complex document.pdf --quiet && lit parse document.pdf --no-ocr + +# List the page numbers that need OCR +lit is-complex document.pdf --compact | jq '[.[] | select(.needs_ocr) | .page_number]' +``` + +--- + +## Global options + +These options are available on all commands: + +| Option | Description | +|--------|-------------| +| `-h, --help` | Show help for a command | +| `-V, --version` | Show version number | diff --git a/docs/src/content/docs/liteparse/getting_started.mdx b/docs/src/content/docs/liteparse/getting_started.mdx new file mode 100644 index 0000000..74c5ec1 --- /dev/null +++ b/docs/src/content/docs/liteparse/getting_started.mdx @@ -0,0 +1,93 @@ +--- +title: Getting Started +description: Install LiteParse and parse your first document in under a minute. +sidebar: + order: 1 +--- + +LiteParse is available for Node.js, Python, and as a standalone Rust binary. + +All versions (except WASM) ship the same CLI and core library capabilities. + +## Installation + + + + + +```bash +npm i -g @llamaindex/liteparse +``` + + + +```bash +pip install liteparse +``` + + + +```bash +cargo install liteparse +``` + + + + +```bash +npm i @llamaindex/liteparse-wasm +``` + +Since the WASM package is designed for browser usage, it does not include the `lit` CLI command. Instead, you can use the exported functions directly from JavaScript to parse documents in the browser environment. + +For full browser usage and limitations, see the [WASM package guide](/liteparse/guides/browser-usage/). + + + +## Quick start + +Once installed, parse from the command line: + +```bash +# Parse a PDF and print text to stdout +lit parse document.pdf + +# Save output to a file +lit parse document.pdf -o output.txt + +# Get structured JSON with bounding boxes +lit parse document.pdf --format json -o output.json + +# Render to Markdown (headings, tables, lists, images, links) +lit parse document.pdf --format markdown -o output.md + +# Parse only specific pages +lit parse document.pdf --target-pages "1-5,10,15-20" +``` + +### Batch parsing + +Parse an entire directory of documents at once: + +```bash +lit batch-parse ./pdfs ./outputs +``` + +### Screenshots + +Generate page images from a PDF for LLM agents or visual workflows: + +```bash +lit screenshot document.pdf -o ./screenshots +``` + +## Next steps + +- [Markdown output](/liteparse/guides/markdown/): Render documents to clean, structured Markdown. +- [Library usage](/liteparse/guides/library-usage/): Use LiteParse programmatically from TypeScript or Python. +- [OCR configuration](/liteparse/guides/ocr/): Configure Tesseract, use an external OCR server, or bring your own. +- [Multi-format support](/liteparse/guides/multi-format/): Parse DOCX, XLSX, PPTX, images, and more. +- [Document complexity](/liteparse/guides/complexity/): Check whether a document needs OCR / heavy processing before parsing, and route accordingly. +- [Browser usage (WASM)](/liteparse/guides/browser-usage/): Run LiteParse entirely in the browser. +- [Agent skill](/liteparse/guides/agent-skill/): Add LiteParse as a skill for coding agents. +- [CLI reference](/liteparse/cli-reference/): Complete command and option reference. diff --git a/docs/src/content/docs/liteparse/guides/_meta.yml b/docs/src/content/docs/liteparse/guides/_meta.yml new file mode 100644 index 0000000..06e2c20 --- /dev/null +++ b/docs/src/content/docs/liteparse/guides/_meta.yml @@ -0,0 +1,3 @@ +label: Guides +order: 2 +collapsed: false diff --git a/docs/src/content/docs/liteparse/guides/agent-skill.md b/docs/src/content/docs/liteparse/guides/agent-skill.md new file mode 100644 index 0000000..8f63f05 --- /dev/null +++ b/docs/src/content/docs/liteparse/guides/agent-skill.md @@ -0,0 +1,30 @@ +--- +title: Agent Skill +description: Add LiteParse as a skill for coding agents like Claude Code, Cursor, and others. +sidebar: + order: 7 +--- + +LiteParse can be installed as a **coding agent skill** using Vercel's [skills](https://github.com/vercel-labs/skills) utility. This gives your coding agent the ability to process documents, generate screenshots, and parse text from files, all locally. + +## Installation + +Add the LiteParse skill to your project: + +```bash +npx skills add run-llama/llamaparse-agent-skills --skill liteparse +``` + +This downloads a skill file that compatible coding agents (Claude Code, Cursor, etc.) will automatically pick up. + +Once configured, your agent will be able to call the LiteParse CLI commands directly from its code execution environment. This means you can have your agent parse PDFs, pull out the text, and generate screenshots on the fly as part of its reasoning process. + +## Example prompts + +Once the skill is installed, you can ask your coding agent things like: + +- "Parse this PDF and extract the text as JSON" +- "Extract text from all the DOCX files in the `./contracts` folder" +- "Screenshot pages 1-5 of this PDF at 300 DPI" +- "Parse this scanned document using the PaddleOCR server on localhost:8828" +- "Get the bounding boxes for all text on page 3" diff --git a/docs/src/content/docs/liteparse/guides/browser-usage.md b/docs/src/content/docs/liteparse/guides/browser-usage.md new file mode 100644 index 0000000..fa32459 --- /dev/null +++ b/docs/src/content/docs/liteparse/guides/browser-usage.md @@ -0,0 +1,93 @@ +--- +title: Browser Usage (WASM) +description: Run LiteParse entirely in the browser with the WASM package. +sidebar: + order: 7 +--- + +LiteParse ships a WebAssembly package that runs entirely in the browser — no server, no cloud calls. It supports PDF parsing and custom OCR engines implemented in JavaScript. + +## Install + +```bash +npm install @llamaindex/liteparse-wasm +``` + +## Quick start + +```typescript +import init, { LiteParse } from "@llamaindex/liteparse-wasm"; + +// Load the WASM module +await init(); + +const parser = new LiteParse({ + ocrEnabled: false, + outputFormat: "json", +}); + +// data is a Uint8Array (e.g. from or fetch) +const bytes = new Uint8Array(await file.arrayBuffer()); +const result = await parser.parse(bytes); + +console.log(result.text); +console.log(result.pages[0]); +``` + +## What works in the browser + +- **PDF parsing** from `Uint8Array` input (use `file.arrayBuffer()` to get bytes from a file picker for example) +- **Custom OCR** via the `ocrEngine` callback interface (see below) +- **Text and JSON output formats** + +## What doesn't work + +- **File path input** — pass `Uint8Array` instead +- **DOCX/XLSX/PPTX/image conversion** — requires LibreOffice/ImageMagick which aren't available in the browser +- **Built-in Tesseract or HTTP OCR** — use the custom `ocrEngine` interface instead +- **Screenshots** — not available in the WASM build + +## OCR in the browser + +The native Tesseract and HTTP OCR backends are not available in WASM. To use OCR, pass a custom `ocrEngine` object with a `recognize` method: + +```typescript +const parser = new LiteParse({ + ocrEnabled: true, + ocrLanguage: "eng", + ocrEngine: { + /** + * @param imageData PNG-encoded image bytes + * @param width rendered page width in pixels + * @param height rendered page height in pixels + * @param language e.g. "eng" + * @returns array of { text, bbox: [x1, y1, x2, y2], confidence } + */ + async recognize(imageData, width, height, language) { + // e.g. call a Web Worker wrapping tesseract.js, or a remote OCR service + return [ + { text: "Hello", bbox: [10, 20, 80, 40], confidence: 0.98 }, + ]; + }, + }, +}); +``` + +This lets you plug in any OCR implementation — a Web Worker running tesseract.js, a cloud OCR API, or anything else that returns text with bounding boxes. + +## Config options + +All optional, camelCase: + +| Option | Type | Default | Description | +|---|---|---|---| +| `ocrLanguage` | `string` | `"eng"` | Language code passed to the OCR engine | +| `ocrEnabled` | `boolean` | `true` | Run OCR on text-sparse pages | +| `maxPages` | `number` | `1000` | Stop after this many pages | +| `targetPages` | `string` | — | e.g. `"1-5,10,15-20"` | +| `dpi` | `number` | `150` | Render DPI for OCR | +| `outputFormat` | `"json" \| "text"` | `"json"` | Format used by `parser.format(...)` | +| `preserveVerySmallText` | `boolean` | `false` | Keep tiny text that's normally filtered | +| `password` | `string` | — | Password for protected PDFs | +| `quiet` | `boolean` | `false` | Suppress progress logging | +| `ocrEngine` | `object` | — | Custom JS-side OCR engine (see above) | diff --git a/docs/src/content/docs/liteparse/guides/complexity.mdx b/docs/src/content/docs/liteparse/guides/complexity.mdx new file mode 100644 index 0000000..1e6d345 --- /dev/null +++ b/docs/src/content/docs/liteparse/guides/complexity.mdx @@ -0,0 +1,181 @@ +--- +title: Document Complexity +description: Use is_complex to decide whether a document needs OCR or heavier parsing before you commit to it. +sidebar: + order: 9 +--- + +The `is_complex` command in LiteParse checks whether a document is "complex". This means looking for images, broken/garbled text, large amounts of vector graphics, and sparse text. + +Use it to: + +- **Route documents** to cheaper or more expensive parsing/OCR backends. +- **Reject or flag** documents you can't handle — e.g. when running with `--no-ocr`, find the pages that would come back empty. +- **Estimate cost** up front by counting how many pages actually need OCR. + +## How it works + +Complexity is computed **per page**. Each page gets a `needs_ocr` verdict and a list of `reasons` explaining why it was flagged. A document is "complex" if any of its pages needs OCR. + +The reasons are derived from a few signals: + +| Reason | Meaning | +|--------|---------| +| `scanned` | A single raster covers ~the whole page with little or no text behind it — a scanned/photographed page. | +| `no-text` | Almost no extractable text and no full-page image — a blank page, cover, or divider. | +| `sparse-text` | Some real text, but it covers very little of the page — typically a figure with a thin caption. | +| `embedded-images` | Substantial embedded raster figures sit alongside the native text. | +| `garbled` | The native text decodes to garbage and text is likely unreadable. | +| `vector-text` | Text is painted as filled vector outlines outside the text layer, so no native text items represent it. | + +> The set of reasons may grow over time as the router learns to recommend heavier pipelines. Treat `reasons` as an open-ended list and route on the values you care about rather than assuming it's exhaustive. + +## CLI + +```bash +lit is-complex document.pdf +``` + +The command always prints per-page JSON to **stdout**, a human-readable verdict to **stderr**, and sets its **exit code** to reflect the result — so you can consume it however fits your workflow. + +```jsonc +// stdout +[ + { + "page_number": 1, + "text_length": 0, + "text_coverage": 0.0, + "has_substantial_images": false, + "image_block_count": 0, + "image_coverage": 0.0, + "largest_image_coverage": 0.0, + "full_page_image": true, + "uncovered_vector_area": null, + "is_garbled": false, + "page_area": 482400.0, + "needs_ocr": true, + "reasons": ["scanned"] + } +] +``` + +``` +# stderr +COMPLEX — 1/1 page(s) need OCR +``` + +The exit code is **non-zero when any page needs OCR**, so the command works as a shell predicate: + +```bash +# Only parse with --no-ocr when the document is simple +lit is-complex document.pdf --quiet && lit parse document.pdf --no-ocr +``` + +Pipe the JSON into `jq` to act on individual pages. The example below will list the page numbers that need OCR: + +```bash +lit is-complex document.pdf --compact | jq '[.[] | select(.needs_ocr) | .page_number]' +``` + +### Options + +| Flag | Description | +|------|-------------| +| `--compact` | Emit dense, whitespace-free JSON instead of pretty-printed. | +| `--max-pages ` | Maximum number of pages to check (default: 1000). | +| `--target-pages ` | Check only specific pages, e.g. `"1-5,10,15-20"`. | +| `--password ` | Password for encrypted/protected documents. | +| `-q`, `--quiet` | Suppress the stderr logging output. | + +## Library + +The same check is available programmatically. It returns one entry per page. + + + + +```typescript +import { LiteParse } from "@llamaindex/liteparse"; + +const parser = new LiteParse({ ocrEnabled: false }); +const pages = await parser.isComplex("document.pdf"); + +const complex = pages.some((p) => p.needsOcr); +if (complex) { + // Route to a heavweight pipeline, like LlamaParse + ... +} else { + // Cheap path — skip OCR entirely + const result = await parser.parse("document.pdf"); +} + +// Inspect why specific pages were flagged +for (const page of pages.filter((p) => p.needsOcr)) { + console.log(`Page ${page.pageNumber}: ${page.reasons.join(", ")}`); +} +``` + + + + +```python +from liteparse import LiteParse + +parser = LiteParse(ocr_enabled=False) +pages = parser.is_complex("document.pdf") + +if any(p.needs_ocr for p in pages): + # Route to a heavweight pipeline, like LlamaParse + ... +else: + # Cheap path — skip OCR entirely + result = parser.parse("document.pdf") + +# Inspect why specific pages were flagged +for page in pages: + if page.needs_ocr: + print(f"Page {page.page_number}: {', '.join(page.reasons)}") +``` + + + + +```javascript +import { LiteParse } from "@llamaindex/liteparse-wasm"; + +const parser = new LiteParse(); +const pages = await parser.isComplex(pdfBytes); // Uint8Array + +const complex = pages.some((p) => p.needsOcr); +``` + + + + +Both the library and CLI accept raw bytes as well as file paths, so you can run the check on documents you've already loaded into memory. + +## Per-page fields + +Every entry includes the raw signals behind the verdict, so you can apply your own thresholds instead of relying solely on `needs_ocr`: + +| Field | Description | +|-------|-------------| +| `page_number` | 1-indexed page number. | +| `needs_ocr` | Verdict: the page needs more than the cheap text-only path. Equivalent to `reasons` being non-empty. | +| `reasons` | Every reason the page was flagged (see the table above). Empty exactly when `needs_ocr` is false. | +| `text_length` | Length of usable native text (garbled/unmappable text excluded). | +| `text_coverage` | Fraction of the page area covered by native text (0–1). | +| `has_substantial_images` | Whether any counted inline raster figures are present. | +| `image_block_count` | Number of counted raster image objects (full-page backgrounds excluded). | +| `image_coverage` | Summed image-bbox area over page area, clamped to 1. | +| `largest_image_coverage` | Largest single counted image's area over page area, clamped to 1. | +| `full_page_image` | A single raster covers ≥90% of the page — the signal that tells a scan apart from a blank page. | +| `uncovered_vector_area` | Filled vector-outline area not covered by native text (pt²). `null`/`undefined` when a cheaper signal already decided the page. | +| `is_garbled` | Whether the native text decodes to garbage. | +| `page_area` | Page area in pt². | + +## Next steps + +- [OCR configuration](/liteparse/guides/ocr/): Set up the OCR backend you route complex documents to. +- [Library usage](/liteparse/guides/library-usage/): Full programmatic API for TypeScript and Python. +- [CLI reference](/liteparse/cli-reference/): Complete command and option reference. diff --git a/docs/src/content/docs/liteparse/guides/library-usage.mdx b/docs/src/content/docs/liteparse/guides/library-usage.mdx new file mode 100644 index 0000000..e4aca4c --- /dev/null +++ b/docs/src/content/docs/liteparse/guides/library-usage.mdx @@ -0,0 +1,419 @@ +--- +title: Library Usage +description: Use LiteParse programmatically from TypeScript or Python. +sidebar: + order: 1 +--- + +LiteParse can be used as a library in your own code, not just from the CLI. There are native packages for both TypeScript and Python. + + + + + +## TypeScript / Node.js usage + +Install as a project dependency: + +```bash +npm install @llamaindex/liteparse +# or +pnpm add @llamaindex/liteparse +``` + +### Parsing a document + +```typescript +import { LiteParse } from "@llamaindex/liteparse"; + +const parser = new LiteParse({ ocrEnabled: true }); +const result = await parser.parse("document.pdf"); + +// Full document text with layout preserved +console.log(result.text); + +// Per-page data +for (const page of result.pages) { + console.log(`Page ${page.pageNum}: ${page.textItems.length} text items`); +} +``` + +### JSON output with bounding boxes + +Text items include spatial coordinates (`x`, `y`, `width`, `height`) in PDF points: + +```typescript +const parser = new LiteParse({ outputFormat: "json" }); +const result = await parser.parse("document.pdf"); + +for (const page of result.pages) { + for (const item of page.textItems) { + console.log(`[${item.x}, ${item.y}] ${item.width}×${item.height} "${item.text}"`); + } +} +``` + +### Markdown output + +Render the document to Markdown — headings, tables, lists, images, and links. The rendered Markdown comes back on `result.text`: + +```typescript +const parser = new LiteParse({ + outputFormat: "markdown", // "json" | "text" | "markdown" + imageMode: "placeholder", // "placeholder" | "off" | "embed" + extractLinks: true, // render [text](url) link syntax (default: true) +}); +const result = await parser.parse("document.pdf"); +console.log(result.text); // rendered Markdown +``` + +### Configuration + +Pass any config options to the constructor. You only need to specify what you want to override: + +```typescript +const parser = new LiteParse({ + ocrEnabled: true, + ocrServerUrl: "http://localhost:8828/ocr", + ocrLanguage: "fra", + dpi: 300, + outputFormat: "json", + targetPages: "1-10", + password: "secret", +}); +``` + +### Buffer / Uint8Array input + +You can pass raw bytes directly instead of a file path: + +```typescript +import { readFile } from "fs/promises"; + +const parser = new LiteParse(); + +// From a file read +const pdfBytes = await readFile("document.pdf"); +const result = await parser.parse(pdfBytes); + +// From an HTTP response +const response = await fetch("https://example.com/document.pdf"); +const buffer = Buffer.from(await response.arrayBuffer()); +const result2 = await parser.parse(buffer); +``` + +### Screenshots + +Generate page images as buffers — useful for sending to LLMs or saving to disk: + +```typescript +const parser = new LiteParse(); +const screenshots = parser.screenshot("document.pdf"); + +for (const shot of screenshots) { + console.log(`Page ${shot.pageNum}: ${shot.width}x${shot.height}`); + // shot.imageBuffer contains the raw PNG data +} + +// Screenshot specific pages +const shots = parser.screenshot("document.pdf", [1, 2, 3]); +``` + +### Environment variables + +| Variable | Description | +|----------|-------------| +| `TESSDATA_PREFIX` | Path to a directory containing Tesseract `.traineddata` files. For offline/air-gapped environments. Also available as the `tessdataPath` config option. | + + + + +## Python usage + +Install the package from PyPI: + +```bash +pip install liteparse +``` + +### Parsing a document + +```python +from liteparse import LiteParse + +parser = LiteParse() +result = parser.parse("document.pdf") + +# Full document text +print(result.text) + +# Per-page data +for page in result.pages: + print(f"Page {page.page_num}: {len(page.text_items)} text items") +``` + +### Configuration + +All options are passed to the constructor: + +```python +parser = LiteParse( + ocr_enabled=True, + ocr_server_url="http://localhost:8828/ocr", + ocr_language="fra", + dpi=300, + target_pages="1-5", + password="secret", +) +result = parser.parse("document.pdf") +``` + +### Markdown output + +Render the document to Markdown — headings, tables, lists, images, and links. The rendered Markdown comes back on `result.text`: + +```python +parser = LiteParse( + output_format="markdown", # "json" | "text" | "markdown" + image_mode="placeholder", # "placeholder" | "off" | "embed" + extract_links=True, # render [text](url) link syntax (default: True) +) +result = parser.parse("document.pdf") +print(result.text) # rendered Markdown +``` + +### Parsing from bytes + +If you already have file contents in memory (e.g. from a web upload), pass them directly to `parse()`: + +```python +with open("document.pdf", "rb") as f: + pdf_bytes = f.read() + +result = parser.parse(pdf_bytes) +print(result.text) +``` + +### Screenshots + +```python +screenshots = parser.screenshot("document.pdf", page_numbers=[1, 2, 3]) +for s in screenshots: + print(f"Page {s.page_num}: {s.width}x{s.height}") + # s.image_bytes contains PNG data +``` + +### Environment variables + +| Variable | Description | +|----------|-------------| +| `TESSDATA_PREFIX` | Path to a directory containing Tesseract `.traineddata` files. For offline/air-gapped environments. Also available as the `tessdata_path` constructor option. | + + + +## Rust usage + +Add `liteparse` to your `Cargo.toml`: + +```toml +[dependencies] +liteparse = "2" +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } +``` + +### Parsing a document + +```rust +use liteparse::{LiteParse, LiteParseConfig}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let parser = LiteParse::new(LiteParseConfig::default()); + let result = parser.parse("document.pdf").await?; + + // Full document text + println!("{}", result.text); + + // Per-page data + for page in &result.pages { + println!("Page {}: {} text items", page.page_number, page.text_items.len()); + } + Ok(()) +} +``` + +### Parsing from bytes + +Use `PdfInput::Bytes` when you have the PDF in memory: + +```rust +use liteparse::{LiteParse, LiteParseConfig}; +use liteparse::types::PdfInput; + +let parser = LiteParse::new(LiteParseConfig::default()); +let pdf_bytes = std::fs::read("document.pdf")?; +let result = parser.parse_input(PdfInput::Bytes(pdf_bytes)).await?; +``` + +### Configuration + +Override only the fields you need: + +```rust +use liteparse::{LiteParse, LiteParseConfig, OutputFormat}; + +let config = LiteParseConfig { + ocr_enabled: true, + ocr_language: "fra".to_string(), + dpi: 300.0, + target_pages: Some("1-10".to_string()), + output_format: OutputFormat::Json, + password: Some("secret".to_string()), + ..Default::default() +}; +let parser = LiteParse::new(config); +``` + +### Markdown output + +Render the document to Markdown — headings, tables, lists, images, and links. The rendered Markdown comes back on `result.text`: + +```rust +use liteparse::config::{ImageMode, LiteParseConfig, OutputFormat}; +use liteparse::LiteParse; + +let config = LiteParseConfig { + output_format: OutputFormat::Markdown, + image_mode: ImageMode::Placeholder, + extract_links: true, + ..Default::default() +}; +let result = LiteParse::new(config).parse("document.pdf").await?; +println!("{}", result.text); // rendered Markdown +``` + +### Screenshots + +Generate page images as PNG bytes: + +```rust +let parser = LiteParse::new(LiteParseConfig::default()); +let screenshots = parser.screenshot("document.pdf", None).await?; + +for shot in &screenshots { + println!("Page {}: {}x{}", shot.page_num, shot.width, shot.height); + // shot.image_bytes contains the raw PNG data +} + +// Screenshot specific pages +let shots = parser.screenshot("document.pdf", Some(vec![1, 2, 3])).await?; +``` + +### Custom OCR engine + +Implement the `OcrEngine` trait to plug in your own OCR backend: + +```rust +use liteparse::LiteParse; +use liteparse::ocr::OcrEngine; +use std::sync::Arc; + +let parser = LiteParse::new(Default::default()) + .with_ocr_engine(Arc::new(my_engine)); +``` + +### Features + +| Feature | Default | Description | +|---------|---------|-------------| +| `tesseract` | Yes | Built-in Tesseract OCR via `tesseract-rs`. Disable with `default-features = false` if you only use HTTP OCR or no OCR. | + + + + +## Browser (WASM) usage + +Install the WASM package: + +```bash +npm install @llamaindex/liteparse-wasm +``` + +### Parsing a document + +```typescript +import init, { LiteParse } from "@llamaindex/liteparse-wasm"; + +// Initialize the WASM module (required once before use) +await init(); + +const parser = new LiteParse({ ocrEnabled: false }); + +// Pass a Uint8Array (e.g. from fetch, File input, or drag-drop) +const bytes = new Uint8Array(await file.arrayBuffer()); +const result = await parser.parse(bytes); + +console.log(result.text); // full document text +console.log(result.pages[0]); // per-page items with bboxes +``` + +### Configuration + +All options are optional and use camelCase: + +```typescript +const parser = new LiteParse({ + ocrEnabled: true, + ocrLanguage: "fra", + dpi: 300, + outputFormat: "json", + targetPages: "1-10", + maxPages: 100, + password: "secret", + quiet: false, +}); +``` + +### Markdown output + +The browser build supports Markdown output too — set `outputFormat: "markdown"` and read the rendered Markdown from `result.text`. See the [Markdown output guide](/liteparse/guides/markdown/) for the `imageMode` and `extractLinks` options. + +```typescript +const parser = new LiteParse({ outputFormat: "markdown" }); +const result = await parser.parse(bytes); +console.log(result.text); // rendered Markdown +``` + +### OCR in the browser + +The native Tesseract and HTTP OCR backends are not available in the browser. To use OCR, provide a JS-side engine with a `recognize` method: + +```typescript +const parser = new LiteParse({ + ocrEnabled: true, + ocrLanguage: "eng", + ocrEngine: { + /** + * @param imageData - PNG-encoded image bytes + * @param width - rendered page width in pixels + * @param height - rendered page height in pixels + * @param language - e.g. "eng" + * @returns array of recognized text regions + */ + async recognize(imageData, width, height, language) { + // e.g. call tesseract.js in a worker, or a remote OCR service + return [ + { text: "Hello", bbox: [10, 20, 80, 40], confidence: 0.98 }, + ]; + }, + }, +}); +``` + +### Notes + +- Input must be a `Uint8Array` — file paths are not supported in the browser. +- The WASM module must be initialized with `await init()` before creating a `LiteParse` instance. + + + \ No newline at end of file diff --git a/docs/src/content/docs/liteparse/guides/markdown.md b/docs/src/content/docs/liteparse/guides/markdown.md new file mode 100644 index 0000000..e5f889a --- /dev/null +++ b/docs/src/content/docs/liteparse/guides/markdown.md @@ -0,0 +1,113 @@ +--- +title: Markdown Output +description: Render documents to clean, structured Markdown for LLMs and RAG pipelines. +sidebar: + order: 2 +--- + +LiteParse can render documents directly to Markdown, reconstructing headings, +tables, lists, images, and links from the spatial layout. This is ideal for +feeding documents to LLMs and RAG pipelines, where clean, structured text +matters more than exact visual fidelity. + +Markdown is a first-class output format alongside `text` and `json`. + +## CLI + +```bash +# Render a document to Markdown +lit parse document.pdf --format markdown -o output.md + +# Print Markdown to stdout +lit parse document.pdf --format markdown +``` + +### Images + +By default, raster images are emitted as Markdown placeholders +(`![](image_pN_K.png)`) in reading order. Control this with `--image-mode`: + +| Mode | Behavior | +|------|----------| +| `placeholder` (default) | Emit `![](image_pN_K.png)` references in reading order | +| `off` | Strip images entirely | +| `embed` | Write each image's PNG bytes to `--image-output-dir` and reference them | + +```bash +# Strip images +lit parse document.pdf --format markdown --image-mode off + +# Extract embedded images to disk and reference them from the markdown +lit parse document.pdf --format markdown --image-mode embed --image-output-dir ./images +``` + +### Links + +Hyperlink annotations are rendered as `[text](url)` by default. Pass +`--no-links` to emit the anchor text as plain text instead: + +```bash +lit parse document.pdf --format markdown --no-links +``` + +## Library + +The rendered Markdown is returned on `result.text`. + + + + +```typescript +import { LiteParse } from "@llamaindex/liteparse"; + +const parser = new LiteParse({ + outputFormat: "markdown", // "json" | "text" | "markdown" + imageMode: "placeholder", // "placeholder" | "off" | "embed" (default: "placeholder") + extractLinks: true, // render [text](url) link syntax (default: true) +}); +const result = await parser.parse("document.pdf"); +console.log(result.text); // rendered Markdown +``` + + + + +```python +from liteparse import LiteParse + +parser = LiteParse( + output_format="markdown", # "json" | "text" | "markdown" + image_mode="placeholder", # "placeholder" | "off" | "embed" + extract_links=True, # render [text](url) link syntax (default: True) +) +result = parser.parse("document.pdf") +print(result.text) # rendered Markdown +``` + + + +```rust +use liteparse::config::{ImageMode, LiteParseConfig, OutputFormat}; +use liteparse::LiteParse; + +let config = LiteParseConfig { + output_format: OutputFormat::Markdown, + image_mode: ImageMode::Placeholder, + extract_links: true, + ..Default::default() +}; +let result = LiteParse::new(config).parse("document.pdf").await?; +println!("{}", result.text); // rendered Markdown +``` + + + + +## Quality notes + +Markdown reconstruction quality varies with document complexity. LiteParse does +a strong job on typical documents, handling prose, headings, simple-to-moderate tables, +and lists. This runs entirely locally with no models using rule-based heuristics. For the hardest documents (dense or +multi-level tables, complex multi-column layouts, charts, and scans), +[LlamaParse](https://developers.llamaindex.ai/python/cloud/llamaparse/?utm_source=github&utm_medium=liteparse) +remains the most accurate option. diff --git a/docs/src/content/docs/liteparse/guides/multi-format.md b/docs/src/content/docs/liteparse/guides/multi-format.md new file mode 100644 index 0000000..acdd058 --- /dev/null +++ b/docs/src/content/docs/liteparse/guides/multi-format.md @@ -0,0 +1,82 @@ +--- +title: Multi-Format Support +description: Parse Word documents, spreadsheets, presentations, and images with LiteParse. +sidebar: + order: 4 +--- + +LiteParse automatically converts non-PDF formats to PDF before parsing. This lets you use the same parsing pipeline for Office documents, images, and more. + +## Supported formats + +### Office documents (via LibreOffice) + +| Category | Extensions | +|----------|-----------| +| Word | `.doc`, `.docx`, `.docm`, `.odt`, `.rtf`, `.pages` | +| PowerPoint | `.ppt`, `.pptx`, `.pptm`, `.odp`, `.key` | +| Spreadsheets | `.xls`, `.xlsx`, `.xlsm`, `.ods`, `.csv`, `.tsv`, `.numbers` | + +### Images (via ImageMagick) + +`.jpg`, `.jpeg`, `.png`, `.gif`, `.bmp`, `.tiff`, `.webp`, `.svg` + +Images are converted to PDF and then parsed with OCR to extract text. + +## Installing dependencies + +Format conversion uses standard system tools. Install the ones you need: + +### LibreOffice (for Office documents) + +```bash +# macOS +brew install --cask libreoffice + +# Ubuntu/Debian +apt-get install libreoffice + +# Windows +choco install libreoffice-fresh +``` + +> On Windows, you may need to add the LibreOffice CLI directory (typically `C:\Program Files\LibreOffice\program`) to your PATH and restart. + +### ImageMagick (for images) + +```bash +# macOS +brew install imagemagick + +# Ubuntu/Debian +apt-get install imagemagick + +# Windows +choco install imagemagick.app +``` + +## Usage + +Once the dependencies are installed, just pass any supported file to `lit parse`: + +```bash +lit parse report.docx +lit parse slides.pptx --format json +lit parse spreadsheet.xlsx -o output.txt +lit parse scan.png +``` + +Batch mode also handles mixed formats: + +```bash +lit batch-parse ./documents ./output --recursive +``` + +## How it works + +1. LiteParse detects the file extension +2. If it's not a PDF, it converts to PDF using the appropriate tool (LibreOffice or ImageMagick) +3. The resulting PDF is parsed normally +4. Temporary conversion files are cleaned up automatically + +If the required conversion tool isn't installed, LiteParse will return an error explaining which dependency is needed. diff --git a/docs/src/content/docs/liteparse/guides/ocr.md b/docs/src/content/docs/liteparse/guides/ocr.md new file mode 100644 index 0000000..53d6045 --- /dev/null +++ b/docs/src/content/docs/liteparse/guides/ocr.md @@ -0,0 +1,182 @@ +--- +title: OCR Configuration +description: Configure OCR in LiteParse — built-in Tesseract, or bring your own via HTTP servers. +sidebar: + order: 3 +--- + +LiteParse uses OCR selectively — only on embedded images or pages where native text extraction didn't find text. This keeps parsing fast while still capturing text from scanned pages and embedded images. + +## Built-in Tesseract (default) + +Tesseract is bundled with LiteParse and works out of the box. Just run: + +```bash +lit parse document.pdf +``` + +### Language support + +Specify the OCR language for better accuracy on non-English documents: + +```bash +lit parse document.pdf --ocr-language fra # French +lit parse document.pdf --ocr-language deu # German +lit parse document.pdf --ocr-language jpn # Japanese +``` + +Tesseract uses [ISO 639-3](https://tesseract-ocr.github.io/tessdoc/Data-Files-in-different-versions.html) language codes (`eng`, `fra`, `deu`, etc.). + +### Offline / air-gapped environments + +For environments without internet access, point Tesseract at a local directory containing pre-downloaded `.traineddata` files: + +```bash +# Via environment variable +export TESSDATA_PREFIX=/path/to/tessdata +lit parse document.pdf --ocr-language eng + +# Or via CLI flag +lit parse document.pdf --tessdata-path /path/to/tessdata +``` + +The `tessdata_path` / `tessdataPath` option is also available in the library APIs. + +### Troubleshooting: missing language data + +If Tesseract can't find its language data, you'll see an error like: + +``` +Error opening data file tessdata/eng.traineddata +Please make sure the TESSDATA_PREFIX environment variable is set to your "tessdata" directory. +Failed loading language 'eng' +``` + +The bundled Tesseract still needs the `.traineddata` file for your language. It is normally downloaded and cached automatically on first use (under `~/.tesseract-rs/tessdata` on Linux, `~/Library/Application Support/tesseract-rs/tessdata` on macOS); if that download didn't happen — e.g. offline, restricted network, or a sandboxed install — OCR cannot run. + +To resolve it, do any one of the following: + +- Download the language file (e.g. [`eng.traineddata`](https://github.com/tesseract-ocr/tessdata)) and point LiteParse at it with `TESSDATA_PREFIX` or `--tessdata-path` (see [above](#offline--air-gapped-environments)). +- Use an [HTTP OCR server](#http-ocr-servers) instead of the built-in engine. +- Disable OCR with `--no-ocr` if you don't need it. + +When language data is missing for **every** page, LiteParse now fails with a clear `OCR failed for all N page(s)` error instead of returning a document with silently-empty OCR text — so an unresolved setup surfaces immediately rather than producing partial results that look complete. + +### Disabling OCR + +If you don't need OCR (pure native-text PDFs, or you don't care about images), disable it for faster parsing: + +```bash +lit parse document.pdf --no-ocr +``` + +## HTTP OCR servers + +For higher accuracy or GPU-accelerated OCR, you can point LiteParse at an HTTP OCR server. LiteParse ships with ready-to-use examples for popular OCR engines. + +### EasyOCR + +```bash +# Start the EasyOCR server (requires Python) +git clone https://github.com/run-llama/liteparse.git +cd liteparse/ocr/easyocr +pip install -r requirements.txt +python server.py + +# Parse with EasyOCR in another terminal +lit parse document.pdf --ocr-server-url http://localhost:8828/ocr +``` + +### PaddleOCR + +```bash +# Start the PaddleOCR server (requires Python) +git clone https://github.com/run-llama/liteparse.git +cd liteparse/ocr/paddleocr +pip install -r requirements.txt +python server.py + +# Parse with PaddleOCR in another terminal +lit parse document.pdf --ocr-server-url http://localhost:8828/ocr +``` + +### Parallel OCR workers + +LiteParse OCRs multiple pages in parallel. By default, it uses one fewer worker than your CPU core count. Override this with: + +```bash +lit parse document.pdf --num-workers 8 +``` + +This is useful if you need to slow down OCR requests to an external server or if your OCR engine is GPU-accelerated and can handle more concurrency. + +## Custom OCR servers + +You can integrate any OCR engine by implementing the LiteParse OCR API. Your server needs a single endpoint: + +``` +POST /ocr +Content-Type: multipart/form-data +``` + +**Request fields:** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `file` | binary | Yes | Image file (PNG, JPG, etc.) | +| `language` | string | No | ISO 639-1 language code (default: `en`) | + +**Response format:** + +```json +{ + "results": [ + { + "text": "recognized text", + "bbox": [x1, y1, x2, y2], + "confidence": 0.95 + } + ] +} +``` + +Each result contains: + +| Field | Type | Description | +|-------|------|-------------| +| `text` | string | Recognized text | +| `bbox` | `[x1, y1, x2, y2]` | Bounding box in pixels. Origin is top-left, x goes right, y goes down | +| `confidence` | number | Score from 0.0 to 1.0 | + +### Testing your server + +```bash +# Quick test with curl +curl -X POST http://localhost:8080/ocr \ + -F "file=@test.png" \ + -F "language=en" | jq . + +# Use with LiteParse +lit parse document.pdf --ocr-server-url http://localhost:8080/ocr +``` + +### Common Gotchas + +- Return `{"results": []}` if no text is detected +- Bounding boxes must be axis-aligned (`[x1, y1, x2, y2]` where top-left to bottom-right) +- If your engine returns rotated boxes, convert to axis-aligned by taking min/max coordinates +- If your engine doesn't provide confidence scores, return `1.0` +- Results should be in reading order (top-to-bottom, left-to-right) +- Cache OCR models in memory rather than reloading per request + +## OCR in the browser (WASM) + +The built-in Tesseract and HTTP OCR backends are not available in the WASM build. Instead, you can pass a custom `ocrEngine` object with a `recognize` method. See the [browser usage guide](/liteparse/guides/browser-usage/) for details. + +### A note on OCR approaches + +These days, its common to apply the term "OCR" to both traditional approaches and newer LLM-based document understanding models. + +The LiteParse OCR API is designed specifically for approaches that return text with bounding boxes. + +If you are trying to integrate a method that doesn't return bounding boxes, you will have to generate dummy bounding boxes. diff --git a/docs/src/content/docs/liteparse/guides/parsing-urls.md b/docs/src/content/docs/liteparse/guides/parsing-urls.md new file mode 100644 index 0000000..14a2f8c --- /dev/null +++ b/docs/src/content/docs/liteparse/guides/parsing-urls.md @@ -0,0 +1,40 @@ +--- +title: Parsing URLs +description: Parse remote documents by reading URLs. +sidebar: + order: 6 +--- + +To parse remote files, LiteParse supports both CLI and library usage for reading bytes and streams. The CLI can download them with any tool you like and pipe the bytes to `lit parse` using `-` as the file argument, while the libraries can fetch the bytes directly and pass them to the parser. + +## CLI usage + +```bash +# Parse a remote PDF +curl -sL https://example.com/report.pdf | lit parse - + +# With options +curl -sL https://example.com/report.pdf | lit parse --no-ocr --format json - + +# Save to a file +curl -sL https://example.com/report.pdf | lit parse -o report.txt - +``` + +The `-` argument tells LiteParse to read from stdin instead of a file path. Any tool that writes to stdout works — `curl`, `wget`, `aws s3 cp - -`, etc. + +## Library usage + +The TypeScript library accepts `Buffer`/`Uint8Array` directly, so you can handle the download however you like. + +For example, using `fetch` in a Node.js environment: + +```typescript +import { LiteParse } from "@llamaindex/liteparse"; + +const response = await fetch("https://example.com/report.pdf"); +const buffer = Buffer.from(await response.arrayBuffer()); + +const parser = new LiteParse({ ocrEnabled: false }); +const result = await parser.parse(buffer); +console.log(result.text); +``` diff --git a/docs/src/content/docs/liteparse/guides/server-usage.md b/docs/src/content/docs/liteparse/guides/server-usage.md new file mode 100644 index 0000000..ba97215 --- /dev/null +++ b/docs/src/content/docs/liteparse/guides/server-usage.md @@ -0,0 +1,166 @@ +--- +title: Server Usage +description: Run LiteParse as an HTTP service with liteparse-server. +sidebar: + order: 8 +--- + +[`liteparse-server`](https://github.com/run-llama/liteparse-server) is an Express server that exposes [`@llamaindex/liteparse`](https://www.npmjs.com/package/@llamaindex/liteparse) as an HTTP parsing backend. It lets you run LiteParse as a standalone service, which is useful when your application isn't Node.js, when you want to share one parsing instance across multiple clients, or when you need a parsing endpoint behind your own infrastructure. + +The server ships in two flavours: + +- **Slim** — a minimal build with no external dependencies, ideal for getting started or embedding in your own stack. +- **Full** — an all-in-one setup with built-in **Redis caching and rate limiting**, **OpenTelemetry tracing** (Jaeger), and **metrics** (Prometheus + Grafana), wired together with Docker Compose. + +Both expose the same two endpoints: `POST /parse` and `POST /screenshots`. + +## Requirements + +- [Bun](https://bun.sh) ≥ 1.0 (or Node.js) +- [Docker](https://docs.docker.com/get-docker/) and Docker Compose (only required for the full server setup) + +## Slim server + +[`src/slim.ts`](https://github.com/run-llama/liteparse-server/tree/main/src/slim.ts) is a minimal version of the server that removes caching and observability while keeping rate limiting and logging. It needs no Redis, no OpenTelemetry collector, and no supporting services. + +### Running locally + +After cloning the GitHub repository: + +```bash +git clone https://github.com/run-llama/liteparse-server +cd liteparse-server +``` + +You can run the API server with either Bun or Node: + +```bash +bun run start-slim:bun +# or with Node +npm run start-slim:node +``` + +The server listens on **port 5000**. + +### Running with Docker + +**Pre-built image** + +You can pull the pre-built LiteParse (slim) server image from the GitHub Container Registry: + +```bash +docker pull ghcr.io/run-llama/liteparse-server:main +``` + +You can then run it exposing port 5000: + +```bash +docker run -p 5000:5000 ghcr.io/run-llama/liteparse-server:main +``` + +**Build locally** + +If you clone the repository, the provided [`slim.Dockerfile`](https://github.com/run-llama/liteparse-server/tree/main/slim.Dockerfile) produces a self-contained image: + +```bash +# Build the image +docker build -f slim.Dockerfile -t liteparse-server-slim . + +# Run exposing port 5000 +docker run -p 5000:5000 liteparse-server-slim +``` + +The API is then available at **http://localhost:5000**. + +## Full server + +For a production-style deployment with caching, rate limiting, distributed tracing, and metrics, follow the Docker Compose example in [`examples/docker-compose`](https://github.com/run-llama/liteparse-server/tree/main/examples/docker-compose). It brings up the server alongside Redis, the OpenTelemetry Collector, Jaeger, Prometheus, and Grafana. + +## API specification + +Base URL: `http://localhost:5000` + +### `POST /parse` — parse a single file + +Parses a single document and returns either structured page data or plain text. + +**Form fields:** + +| Field | Type | Required | Description | +| -------- | ------ | -------- | ----------------------------------------- | +| `file` | file | Yes | The document to parse | +| `config` | string | No | JSON-serialized `LiteParseConfig` options | + +**Query parameters:** + +| Parameter | Type | Default | Description | +| --------- | ------- | ------- | ---------------------------------------------------------------------------------- | +| `text` | boolean | `false` | If `true`, returns `text/plain`; otherwise `application/json` with a `pages` array | + +**Responses:** + +- `200 text/plain` — extracted text (when `text=true`) +- `200 application/json` — `{ "pages": [...] }` (when `text=false`) +- `400` — missing `file` +- `429` — rate limit exceeded (only if rate limiting is configured) + +### `POST /screenshots` — screenshot pages of a document + +Renders document pages as PNG images and streams them back as newline-delimited JSON (NDJSON). + +**Form fields:** + +| Field | Type | Required | Description | +| -------- | ------ | -------- | ----------------------------------------- | +| `file` | file | Yes | The document to screenshot | +| `config` | string | No | JSON-serialized `LiteParseConfig` options | + +**Query parameters:** + +| Parameter | Type | Default | Description | +| --------- | ------ | ------- | ----------------------------------------------------------------- | +| `pages` | string | all | Comma-separated 1-based page numbers to screenshot (e.g. `1,2,3`) | + +**Response `200 application/x-ndjson`** — one JSON object per line: + +```json +{ + "index": 0, + "mimetype": "image/png", + "data": "", + "page_number": 1, + "height": 1056, + "width": 816 +} +``` + +## Example usage with `curl` + +Parse a file and get JSON pages: + +```bash +curl -X POST http://localhost:5000/parse \ + -F "file=@path/to/document.pdf" +``` + +Parse a file and get plain text: + +```bash +curl -X POST "http://localhost:5000/parse?text=true" \ + -F "file=@path/to/document.pdf" +``` + +Pass a `LiteParseConfig` (e.g. enable OCR): + +```bash +curl -X POST http://localhost:5000/parse \ + -F "file=@path/to/document.pdf" \ + -F 'config={"ocrEnabled":true}' +``` + +Screenshot specific pages and stream NDJSON: + +```bash +curl -X POST "http://localhost:5000/screenshots?pages=1,2,3" \ + -F "file=@path/to/document.pdf" +``` diff --git a/docs/src/content/docs/liteparse/guides/visual-citations.mdx b/docs/src/content/docs/liteparse/guides/visual-citations.mdx new file mode 100644 index 0000000..506dff7 --- /dev/null +++ b/docs/src/content/docs/liteparse/guides/visual-citations.mdx @@ -0,0 +1,186 @@ +--- +title: Visual Citations with Bounding Boxes +description: Use bounding boxes and screenshots to show exactly where information was found in a document. +sidebar: + order: 5 +--- + +When building agents or RAG workflows, it is often not enough to parse text and call it done. Frequently, users and applications will require you to show _where_ that text came from. + +LiteParse gives you spatial coordinates for every text item, plus page screenshots, so you can highlight exact regions on the rendered page. + +## How bounding boxes work + +When you parse a document with JSON output, each page includes **`textItems`** — every extracted text element with its position (`x`, `y`, `width`, `height`) and content. + +```json +$ lit parse document.pdf --format json +{ + "pages": [{ + "page": 1, + "width": 612, + "height": 792, + "text": "...", + "textItems": [ + { "text": "Revenue grew 15%", "x": 72, "y": 200, "width": 150, "height": 12, ... } + ], + }] +} +``` + +Coordinates are in **PDF points** (1 point = 1/72 inch). Origin is the top-left corner of the page, with X increasing right and Y increasing down. + +## Library usage + +The library lets you parse for bboxes and generate screenshots in a single script. For example, searching for "0°C to 70°C" and showing where it appears: + + + +### TypeScript + +```typescript +import { LiteParse, searchItems } from "@llamaindex/liteparse"; + +const parser = new LiteParse({ outputFormat: "json" }); +const result = await parser.parse("report.pdf"); + +for (const page of result.pages) { + const matches = searchItems(page.textItems, { phrase: "0°C to 70°C" }); + for (const match of matches) { + console.log(`Found "${match.text}" at (${match.x}, ${match.y}) ${match.width}×${match.height}`); + } +} +``` + + + + +### Python + +```python +from liteparse import LiteParse, search_items + +parser = LiteParse() +result = parser.parse("report.pdf") + +for page in result.pages: + matches = search_items(page.text_items, "0°C to 70°C") + for match in matches: + print(f'Found "{match.text}" at ({match.x}, {match.y}) {match.width}×{match.height}') +``` + + + +## Converting coordinates to image pixels + +Text item coordinates are in PDF points, but screenshots are in pixels. To draw highlights on a screenshot, you need to scale the coordinates: + +```typescript +const scaleFactor = dpi / 72; // PDF points -> pixels at your chosen DPI + +function itemToPixels(item, dpi = 150) { + const scale = dpi / 72; + return { + x: item.x * scale, + y: item.y * scale, + width: item.width * scale, + height: item.height * scale, + }; +} +``` + +For example, at the default 150 DPI the scale factor is `150 / 72 ~ 2.08`, so a text item at `(72, 200)` maps to pixel `(150, 416)`. + +## Full example: highlighting citations with sharp + +Here's a complete workflow that parses a PDF, searches for a phrase, and draws yellow highlight boxes on the page screenshot: + +```typescript +import { LiteParse, searchItems } from "@llamaindex/liteparse"; +import sharp from "sharp"; + +const DPI = 150; +const SCALE = DPI / 72; + +async function main() { + const parser = new LiteParse({ outputFormat: "json", dpi: DPI }); + + const result = await parser.parse("manual.pdf"); + const screenshots = await parser.screenshot("manual.pdf"); + + // Search for a phrase, grouped by page + const query = "0°C to 70°C"; + const hitsByPage = new Map>(); + + for (const page of result.json?.pages || []) { + const matches = searchItems(page.textItems, { phrase: query }); + if (matches.length) hitsByPage.set(page.page, matches); + } + + // Draw all highlights per page into a single image + for (const [pageNum, rects] of hitsByPage) { + const shot = screenshots.find((s) => s.pageNum === pageNum); + if (!shot) continue; + + const composites = await Promise.all( + rects.map(async (rect) => { + const pixel = { + left: Math.round(rect.x * SCALE), + top: Math.round(rect.y * SCALE), + width: Math.round(rect.width * SCALE), + height: Math.round(rect.height * SCALE), + }; + + const overlay = await sharp({ + create: { + width: pixel.width, + height: pixel.height, + channels: 4, + background: { r: 255, g: 255, b: 0, alpha: 0.3 }, + }, + }) + .png() + .toBuffer(); + + return { input: overlay, left: pixel.left, top: pixel.top }; + }) + ); + + const highlighted = await sharp(shot.imageBuffer) + .composite(composites) + .png() + .toBuffer(); + + await sharp(highlighted).toFile(`citation_page${pageNum}.png`); + console.log(`Saved citation_page${pageNum}.png (${rects.length} highlights)`); + } +} + +main().catch(console.error); +``` + +Running this script on a PDF will produce new images with the search phrase highlighted, showing exactly where the information was found on the page. + +![Example output showing highlighted search results on a PDF page](visual_citation.png) + +## CLI usage + +Parse to JSON to get bounding boxes: + +```bash +lit parse document.pdf --format json -o result.json +``` + +Generate page screenshots alongside: + +```bash +lit screenshot document.pdf -o ./screenshots +``` + +From there, you (or an agent) can process the resulting JSON and screenshots as needed using any tools available. + +## Tips + +- Use the same `dpi` value for both `parse()` and `screenshot()`. The default is `150` for both. +- Page `width` and `height` in the JSON are in PDF points, matching the coordinate space. Use these if you need to normalize coordinates to percentages. +- For multi-word phrase search, iterate `textItems` and check adjacent items — a phrase may span multiple text items. diff --git a/docs/src/content/docs/liteparse/index.md b/docs/src/content/docs/liteparse/index.md new file mode 100644 index 0000000..a466481 --- /dev/null +++ b/docs/src/content/docs/liteparse/index.md @@ -0,0 +1,31 @@ +--- +title: What is LiteParse? +description: Fast, local PDF parsing with spatial text parsing, OCR, and bounding boxes. +sidebar: + order: 0 +--- + +LiteParse is an open-source document parsing library that parses text with spatial layout information and bounding boxes. Written in Rust for speed and reliability, it runs entirely on your machine with no cloud dependencies, no LLMs, and no API keys. + +LiteParse is designed specifically for use cases that require fast, accurate text parsing: real-time applications, coding agents, and local workflows. It provides a simple CLI and library API for parsing PDFs, Office documents, and images, with built-in OCR support. + +out + +## What can LiteParse do? + +- **Parse PDFs** with precise spatial layout. Text comes back positioned where it appears on the page +- **Render to Markdown** with headings, tables, lists, images, and links — clean structured output for LLMs and RAG pipelines +- **Extract bounding boxes** for every text line, ready for downstream processing or visualization +- **OCR scanned documents** using built-in Tesseract or plug in your own OCR server +- **Parse Office files and images** with support for DOCX, XLSX, PPTX, PNG, JPG, and more via automatic conversion +- **Screenshot PDF pages** as high-quality images for LLM-based workflows +- **Use from Node.js/TypeScript, Python, Rust, or the browser (WASM)** — whatever fits your stack + +## Get started + +- [Getting started](/liteparse/getting_started/): Install LiteParse and parse your first document. +- [Markdown output](/liteparse/guides/markdown/): Render documents to clean, structured Markdown. +- [Library usage](/liteparse/guides/library-usage/): Use LiteParse from TypeScript or Python code. +- [Browser usage (WASM)](/liteparse/guides/browser-usage/): Run LiteParse in the browser with zero server dependencies. +- [CLI reference](/liteparse/cli-reference/): Complete command and option reference. +- [API reference](/liteparse/api/): Detailed API documentation (rust) for all public types and functions. The same types apply across all language bindings. diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..2017178 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,34 @@ +import eslint from "@eslint/js"; +import tseslint from "typescript-eslint"; +import eslintConfigPrettier from "eslint-config-prettier"; + +export default tseslint.config( + eslint.configs.recommended, + ...tseslint.configs.recommended, + eslintConfigPrettier, + { + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + }, + { + ignores: ["dist/**", "node_modules/**", "src/vendor/**", "examples/**"], + }, + { + rules: { + // Allow unused vars that start with underscore + "@typescript-eslint/no-unused-vars": [ + "error", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + }, + ], + // Allow explicit any in some cases (can be tightened later) + "@typescript-eslint/no-explicit-any": "warn", + }, + } +); diff --git a/full.Dockerfile b/full.Dockerfile new file mode 100644 index 0000000..878ca26 --- /dev/null +++ b/full.Dockerfile @@ -0,0 +1,39 @@ +# Stage 1: Build from source +FROM rust:1-bookworm AS builder + +RUN apt-get update && apt-get install -y \ + libclang-dev \ + libtesseract-dev \ + libleptonica-dev \ + cmake \ + g++ \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /build +COPY Cargo.toml Cargo.lock ./ +COPY crates/ ./crates/ + +RUN cargo build --release + +# Stage 2: Runtime image with conversion tools +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + libtesseract5 \ + liblept5 \ + tesseract-ocr-eng \ + ca-certificates \ + libreoffice \ + imagemagick \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /build/target/release/lit /usr/local/bin/lit +# pdfium shared library +COPY --from=builder /root/.cache/pdfium-rs/ /usr/local/lib/pdfium-rs/ + +# Ensure pdfium is discoverable at runtime +ENV LD_LIBRARY_PATH="/usr/local/lib/pdfium-rs" + +RUN ln -s /usr/local/bin/lit /usr/local/bin/liteparse + +CMD ["/bin/sh"] diff --git a/integration_tests_data/receipt.png b/integration_tests_data/receipt.png new file mode 100644 index 0000000..426800f Binary files /dev/null and b/integration_tests_data/receipt.png differ diff --git a/integration_tests_data/sample.pdf b/integration_tests_data/sample.pdf new file mode 100644 index 0000000..c01805e Binary files /dev/null and b/integration_tests_data/sample.pdf differ diff --git a/integration_tests_data/sample3.doc b/integration_tests_data/sample3.doc new file mode 100644 index 0000000..1bac61d Binary files /dev/null and b/integration_tests_data/sample3.doc differ diff --git a/musl_build_cargozig.md b/musl_build_cargozig.md new file mode 100644 index 0000000..0c573c9 --- /dev/null +++ b/musl_build_cargozig.md @@ -0,0 +1,62 @@ +## Maturin + +Maturin has **first-class support** for `cargo-zigbuild` via the `--zig` flag: + +```bash +maturin build --release --target x86_64-unknown-linux-musl --zig +maturin build --release --target aarch64-unknown-linux-musl --zig +``` + +That's it. Maturin detects `cargo-zigbuild` is installed and routes through it automatically when `--zig` is passed. You just need: + +```bash +pip install maturin +cargo install cargo-zigbuild +# and the targets: +rustup target add x86_64-unknown-linux-musl aarch64-unknown-linux-musl +``` + +In a CI matrix (GitHub Actions example): +```yaml +- name: Build musl wheels + run: | + maturin build --release --target ${{ matrix.target }} --zig + strategy: + matrix: + target: [x86_64-unknown-linux-musl, aarch64-unknown-linux-musl] +``` + +## napi-rs + +Actually, the **real pattern** for napi is to override the cargo command via the `CARGO` env var or use napi's `--cargo-cwd` + a wrapper script. The cleanest approach in practice: + +```bash +# Tell napi-rs to use cargo-zigbuild instead of cargo +CARGO=cargo-zigbuild npx @napi-rs/cli build \ + --platform \ + --release \ + --target x86_64-unknown-linux-musl +``` + +`napi-rs` internally shells out to whatever `$CARGO` points to, and `cargo-zigbuild` accepts the same interface as `cargo build`, so this works cleanly. + +## The even cleaner napi path: use their Docker images + +The `@napi-rs/cli` team maintains cross-compilation Docker images that have Zig + musl toolchains pre-configured: + +```bash +docker run --rm -v $(pwd):/build \ + ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-alpine \ + sh -c "cd /build && npx @napi-rs/cli build --release --target x86_64-unknown-linux-musl" +``` + +Their [CI template](https://github.com/napi-rs/package-template) scaffolds all of this out of the box if you use `napi new`. + +## Summary + +| Tool | musl + Zig support | +|---|---| +| maturin | Native `--zig` flag, trivial | +| napi-rs | `CARGO=cargo-zigbuild` env var, or use their Docker images | + +For maturin, `--zig` is the blessed path. For napi-rs, the `CARGO` env var override or their Docker-based CI template is the practical answer. diff --git a/ocr/README.md b/ocr/README.md new file mode 100644 index 0000000..e2ef24d --- /dev/null +++ b/ocr/README.md @@ -0,0 +1,128 @@ +# ocr/ + +Example OCR server implementations that conform to the LiteParse OCR API specification. + +These servers allow you to use alternative OCR engines instead of the built-in Tesseract.js. + +## Why Use an External OCR Server? + +| Feature | Tesseract.js (built-in) | EasyOCR | PaddleOCR | Surya | +|---------|-------------------------|---------|-----------|-------| +| Setup | Zero (included) | uv | uv | uv | +| Speed | Moderate | Moderate | Fast (2-3x) | Moderate (GPU recommended) | +| Accuracy (Latin) | Good | Good | Good | Excellent | +| Accuracy (CJK) | Fair | Good | Excellent | Excellent | +| Languages | 100+ | 80+ | 80+ | 91 | +| Memory | In-process | Separate | Separate | Separate | + +**Recommendations:** +- **Quick start**: Use built-in Tesseract (no setup) +- **Asian languages**: Use PaddleOCR (best CJK support) +- **Multilingual / broad scripts**: Use Surya (strong multilingual accuracy) +- **General use**: EasyOCR (good balance) + +## Available Servers + +### [easyocr/](./easyocr/) +Flask server wrapping EasyOCR library. +- Port: **8828** +- Good general-purpose OCR +- 80+ languages + +### [paddleocr/](./paddleocr/) +Flask server wrapping PaddleOCR library. +- Port: **8829** +- Excellent for Chinese, Japanese, Korean +- 2-3x faster than EasyOCR + +### [suryaocr/](./suryaocr/) +FastAPI server wrapping Surya OCR 2. +- Port: **8830** +- Multilingual foundation model (90+ languages) +- Block-level output; GPU recommended + +## Quick Start + +```bash +# Start EasyOCR server +cd ocr/easyocr +uv run server.py + +# OR start PaddleOCR server +cd ocr/paddleocr +uv run server.py + +# OR start Surya OCR server +cd ocr/suryaocr +uv run server.py +``` + +Then use with LiteParse: + +```bash +# CLI +lit parse document.pdf --ocr-server-url http://localhost:8828/ocr + +# Code +const parser = new LiteParse({ + ocrServerUrl: 'http://localhost:8828/ocr', + ocrLanguage: 'en', +}); +``` + +## API Specification + +All servers implement the same API (defined in `OCR_API_SPEC.md`): + +**Endpoint:** `POST /ocr` + +**Request:** +- Content-Type: `multipart/form-data` +- Fields: + - `file` - Image file + - `language` - Language code (e.g., 'en', 'zh', 'ja') + +**Response:** +```json +{ + "results": [ + { + "text": "recognized text", + "bbox": [x1, y1, x2, y2], + "confidence": 0.95 + } + ] +} +``` + +## Creating a Custom OCR Server + +To implement your own OCR server: + +1. Create a Flask/FastAPI/Express server +2. Accept `POST /ocr` with multipart form data +3. Return JSON with `results` array containing: + - `text` - Recognized text string + - `bbox` - Bounding box as `[x1, y1, x2, y2]` + - `confidence` - Confidence score (0-1) + +4. (Optional) Implement `GET /health` endpoint + +See the existing servers as reference implementations. + +## Language Codes + +Most servers accept ISO 639-1 codes (e.g., 'en', 'zh', 'ja') and map them internally: + +| ISO Code | Language | Notes | +|----------|----------|-------| +| en | English | | +| zh | Chinese (Simplified) | | +| zh-tw | Chinese (Traditional) | | +| ja | Japanese | | +| ko | Korean | | +| fr | French | | +| de | German | | +| es | Spanish | | +| ar | Arabic | | +| hi | Hindi | | diff --git a/ocr/easyocr/Dockerfile b/ocr/easyocr/Dockerfile new file mode 100644 index 0000000..45b6234 --- /dev/null +++ b/ocr/easyocr/Dockerfile @@ -0,0 +1,26 @@ +FROM ghcr.io/astral-sh/uv:python3.12-trixie + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + libgomp1 \ + libglib2.0-0 \ + libsm6 \ + libxext6 \ + libxrender-dev \ + libgl1 \ + && rm -rf /var/lib/apt/lists/* + +# Set working directory +WORKDIR /app + +# Copy pyproject.toml and server code +COPY ./*py* . + +# install necessary dependencies +RUN uv sync + +# Expose port +EXPOSE 8828 + +# Run server +CMD ["uv", "run", "python3", "server.py"] diff --git a/ocr/easyocr/README.md b/ocr/easyocr/README.md new file mode 100644 index 0000000..2736c51 --- /dev/null +++ b/ocr/easyocr/README.md @@ -0,0 +1,91 @@ +# EasyOCR Service + +This is a simple Flask server that wraps EasyOCR to conform to the LiteParse OCR API specification (see `../../OCR_API_SPEC.md`). + +## Build and Run + +```bash +# install and run (in one command) +uv run server.py +``` + +## Usage + +The service exposes a single endpoint: + +- `POST /ocr` - Perform OCR on an uploaded image + +### Parameters + +- `file` - Image file (multipart/form-data) +- `language` - Language code (e.g., 'en', 'fr', 'de') + +### Example + +```bash +curl -X POST -F "file=@image.png" -F "language=en" http://localhost:8828/ocr +``` + +### Response Format + +```json +{ + "results": [ + { + "text": "recognized text", + "bbox": [x1, y1, x2, y2], + "confidence": 0.95 + } + ] +} +``` + +This conforms to the LiteParse OCR API specification. + +## Supported Languages + +EasyOCR supports 80+ languages. Common language codes: + +- `en` - English +- `fr` - French +- `de` - German +- `es` - Spanish +- `zh` - Chinese +- `ja` - Japanese +- `ko` - Korean +- `ar` - Arabic + +Full list: https://www.jaided.ai/easyocr/ + +## Use with LiteParse + +Once the server is running, use it with LiteParse OSS: + +```bash +# Parse with EasyOCR +lit parse document.pdf --ocr-server-url http://localhost:8828/ocr + +# With specific language +lit parse document.pdf --ocr-server-url http://localhost:8828/ocr --ocr-language zh +``` + +Or in code: + +```typescript +import { LiteParse } from 'liteparse'; + +const parser = new LiteParse({ + ocrServerUrl: 'http://localhost:8828/ocr', + ocrLanguage: 'en', +}); + +const result = await parser.parse('document.pdf'); +``` + +## Testing + +If you make changes to the server, make sure to adapt and run tests: + +```bash +uv run pytest test_server.py +``` diff --git a/ocr/easyocr/pyproject.toml b/ocr/easyocr/pyproject.toml new file mode 100644 index 0000000..31ed707 --- /dev/null +++ b/ocr/easyocr/pyproject.toml @@ -0,0 +1,20 @@ +[project] +name = "easyocr-liteparse" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "easyocr>=1.7.2", + "fastapi>=0.132.0", + "numpy>=2.4.2", + "pillow>=12.2.0", + "python-multipart>=0.0.31", + "uvicorn>=0.41.0", +] + +[dependency-groups] +dev = [ + "httpx>=0.28.1", + "pytest>=9.0.3", +] diff --git a/ocr/easyocr/server.py b/ocr/easyocr/server.py new file mode 100644 index 0000000..a8385ba --- /dev/null +++ b/ocr/easyocr/server.py @@ -0,0 +1,98 @@ +import io +import logging +from typing import Any + +import easyocr +import numpy as np +import uvicorn +from fastapi import FastAPI +from fastapi.datastructures import UploadFile +from fastapi.param_functions import File, Form +from PIL import Image +from pydantic import BaseModel + +LANG_MAP = {"eng": "en"} + +class OcrResponse(BaseModel): + results: list[Any] + + +class StatusResponse(BaseModel): + status: str + + +class EasyOCRServer: + def __init__(self) -> None: + self.reader: easyocr.Reader | None = None + self.current_language: str | None = None + + def _create_ocr_server(self) -> FastAPI: + app = FastAPI() + + @app.post("/ocr") + async def ocr_endpoint( + file: UploadFile = File(...), language: str = Form(default="en") + ) -> OcrResponse: + print( + f"Language: {language}. Received file {file.filename or 'no name'} with size {file.size or 'unknown size'} and type {file.content_type or 'unknown type'}", + flush=True, + ) + # Get language from request + language = language.lower() + + # Normalize language + if language in LANG_MAP: + language = LANG_MAP[language] + + if self.reader is None or self.current_language != language: + print(f"Initializing EasyOCR reader for language: {language}") + self.reader = easyocr.Reader([language], gpu=False) + self.current_language = language + + image_data = await file.read() + image = Image.open(io.BytesIO(image_data)) + + # Convert to numpy array + image_array = np.array(image) + + # Run OCR + results = self.reader.readtext(image_array) # type: ignore + + # Format results according to LiteParse OCR API spec + # Convert from EasyOCR format: [[[x1,y1], [x2,y2], [x3,y3], [x4,y4]], text, confidence] + # To standard format: { text, bbox: [x1, y1, x2, y2], confidence } + formatted = [] + for coords, text, confidence in results: + # Convert polygon to axis-aligned bounding box + # coords is [[x1,y1], [x2,y2], [x3,y3], [x4,y4]] + if isinstance(coords, np.ndarray): + coords = coords.tolist() + + # int casting is necessary for pydantic serialization (np.Int32 are not serializable) + xs = [int(point[0]) for point in coords] + ys = [int(point[1]) for point in coords] + bbox = [min(xs), min(ys), max(xs), max(ys)] + + formatted.append( + {"text": text, "bbox": bbox, "confidence": float(confidence)} + ) + return OcrResponse(results=formatted) + + @app.get("/health") + def health() -> StatusResponse: + return StatusResponse(status="healthy") + + return app + + def serve(self) -> None: + app = self._create_ocr_server() + uvicorn.run(app, host="0.0.0.0", port=8828) + + +if __name__ == "__main__": + logging.basicConfig( + level=logging.DEBUG, + ) + logging.info("Starting server on port 8828") + server = EasyOCRServer() + server.serve() diff --git a/ocr/easyocr/test_server.py b/ocr/easyocr/test_server.py new file mode 100644 index 0000000..6f3d64c --- /dev/null +++ b/ocr/easyocr/test_server.py @@ -0,0 +1,65 @@ +import io +from typing import Any + +import pytest +from fastapi.testclient import TestClient +from PIL import Image + +from server import EasyOCRServer + + +@pytest.fixture(scope="module") +def server() -> EasyOCRServer: + return EasyOCRServer() + + +class MockEasyOCRReader: + def __init__(self, *args, **kwargs) -> None: + self.results = [ + ([[10, 20], [200, 40]], "Hello World", 0.98), + ([[10, 50], [250, 70]], "Total: $42.00", 0.95), + ([[10, 80], [180, 100]], "Thank you!", 0.87), + ] + self.transformed_results = [ + {"text": "Hello World", "bbox": [10, 20, 200, 40], "confidence": 0.98}, + {"text": "Total: $42.00", "bbox": [10, 50, 250, 70], "confidence": 0.95}, + {"text": "Thank you!", "bbox": [10, 80, 180, 100], "confidence": 0.87}, + ] + + def readtext(self, *args, **kwargs) -> list[Any]: + return self.results + + +def test_server_init(server: EasyOCRServer) -> None: + assert server.current_language is None + assert server.reader is None + + +def test_server_health_endpoint(server: EasyOCRServer) -> None: + app = server._create_ocr_server() + client = TestClient(app) + response = client.get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "healthy"} + + +def test_server_ocr_endpoint(server: EasyOCRServer) -> None: + image = Image.new("RGB", (1, 1), color=(255, 255, 255)) + + # Save to bytes (to simulate a file upload) + buffer = io.BytesIO() + image.save(buffer, format="PNG") + buffer.seek(0) + app = server._create_ocr_server() + mock_ocr = MockEasyOCRReader() + server.reader = mock_ocr # type: ignore + server.current_language = "en" + client = TestClient(app) + + response = client.post( + "/ocr", + files={"file": ("test.png", buffer, "image/png")}, + data={"language": "en"}, + ) + assert response.status_code == 200 + assert response.json().get("results", []) == mock_ocr.transformed_results diff --git a/ocr/easyocr/uv.lock b/ocr/easyocr/uv.lock new file mode 100644 index 0000000..e9346e3 --- /dev/null +++ b/ocr/easyocr/uv.lock @@ -0,0 +1,1271 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cuda-bindings" +version = "12.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cuda-pathfinder" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/c1/dabe88f52c3e3760d861401bb994df08f672ec893b8f7592dc91626adcf3/cuda_bindings-12.9.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fda147a344e8eaeca0c6ff113d2851ffca8f7dfc0a6c932374ee5c47caa649c8", size = 12151019, upload-time = "2025-10-21T14:51:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/56/e465c31dc9111be3441a9ba7df1941fe98f4aa6e71e8788a3fb4534ce24d/cuda_bindings-12.9.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:32bdc5a76906be4c61eb98f546a6786c5773a881f3b166486449b5d141e4a39f", size = 11906628, upload-time = "2025-10-21T14:51:49.905Z" }, + { url = "https://files.pythonhosted.org/packages/a3/84/1e6be415e37478070aeeee5884c2022713c1ecc735e6d82d744de0252eee/cuda_bindings-12.9.4-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56e0043c457a99ac473ddc926fe0dc4046694d99caef633e92601ab52cbe17eb", size = 11925991, upload-time = "2025-10-21T14:51:56.535Z" }, + { url = "https://files.pythonhosted.org/packages/d1/af/6dfd8f2ed90b1d4719bc053ff8940e494640fe4212dc3dd72f383e4992da/cuda_bindings-12.9.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8b72ee72a9cc1b531db31eebaaee5c69a8ec3500e32c6933f2d3b15297b53686", size = 11922703, upload-time = "2025-10-21T14:52:03.585Z" }, + { url = "https://files.pythonhosted.org/packages/6c/19/90ac264acc00f6df8a49378eedec9fd2db3061bf9263bf9f39fd3d8377c3/cuda_bindings-12.9.4-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d80bffc357df9988dca279734bc9674c3934a654cab10cadeed27ce17d8635ee", size = 11924658, upload-time = "2025-10-21T14:52:10.411Z" }, +] + +[[package]] +name = "cuda-pathfinder" +version = "1.3.5" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/b5/e4056e4058fb56519fcddf1face6fe3ff2398953b41615fafe9fb1540bf2/cuda_pathfinder-1.3.5-py3-none-any.whl", hash = "sha256:6c88220f8637cb35d2a75c620d72efebf683b248b923713d8fbe235844c1a4b9", size = 33711, upload-time = "2026-02-23T18:34:27.253Z" }, +] + +[[package]] +name = "easyocr" +version = "1.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ninja" }, + { name = "numpy" }, + { name = "opencv-python-headless" }, + { name = "pillow" }, + { name = "pyclipper" }, + { name = "python-bidi" }, + { name = "pyyaml" }, + { name = "scikit-image" }, + { name = "scipy" }, + { name = "shapely" }, + { name = "torch" }, + { name = "torchvision" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/84/4a2cab0e6adde6a85e7ba543862e5fc0250c51f3ac721a078a55cdcff250/easyocr-1.7.2-py3-none-any.whl", hash = "sha256:5be12f9b0e595d443c9c3d10b0542074b50f0ec2d98b141a109cd961fd1c177c", size = 2870178, upload-time = "2024-09-24T11:34:43.554Z" }, +] + +[[package]] +name = "easyocr-liteparse" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "easyocr" }, + { name = "fastapi" }, + { name = "numpy" }, + { name = "pillow" }, + { name = "python-multipart" }, + { name = "uvicorn" }, +] + +[package.dev-dependencies] +dev = [ + { name = "httpx" }, + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "easyocr", specifier = ">=1.7.2" }, + { name = "fastapi", specifier = ">=0.132.0" }, + { name = "numpy", specifier = ">=2.4.2" }, + { name = "pillow", specifier = ">=12.2.0" }, + { name = "python-multipart", specifier = ">=0.0.31" }, + { name = "uvicorn", specifier = ">=0.41.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "httpx", specifier = ">=0.28.1" }, + { name = "pytest", specifier = ">=9.0.3" }, +] + +[[package]] +name = "fastapi" +version = "0.136.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" }, +] + +[[package]] +name = "filelock" +version = "3.24.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/73/92/a8e2479937ff39185d20dd6a851c1a63e55849e447a55e798cc2e1f49c65/filelock-3.24.3.tar.gz", hash = "sha256:011a5644dc937c22699943ebbfc46e969cdde3e171470a6e40b9533e5a72affa", size = 37935, upload-time = "2026-02-19T00:48:20.543Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/0f/5d0c71a1aefeb08efff26272149e07ab922b64f46c63363756224bd6872e/filelock-3.24.3-py3-none-any.whl", hash = "sha256:426e9a4660391f7f8a810d71b0555bce9008b0a1cc342ab1f6947d37639e002d", size = 24331, upload-time = "2026-02-19T00:48:18.465Z" }, +] + +[[package]] +name = "fsspec" +version = "2026.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/7c/f60c259dcbf4f0c47cc4ddb8f7720d2dcdc8888c8e5ad84c73ea4531cc5b/fsspec-2026.2.0.tar.gz", hash = "sha256:6544e34b16869f5aacd5b90bdf1a71acb37792ea3ddf6125ee69a22a53fb8bff", size = 313441, upload-time = "2026-02-05T21:50:53.743Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437", size = 202505, upload-time = "2026-02-05T21:50:51.819Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, +] + +[[package]] +name = "imageio" +version = "2.37.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/6f/606be632e37bf8d05b253e8626c2291d74c691ddc7bcdf7d6aaf33b32f6a/imageio-2.37.2.tar.gz", hash = "sha256:0212ef2727ac9caa5ca4b2c75ae89454312f440a756fcfc8ef1993e718f50f8a", size = 389600, upload-time = "2025-11-04T14:29:39.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/fe/301e0936b79bcab4cacc7548bf2853fc28dced0a578bab1f7ef53c9aa75b/imageio-2.37.2-py3-none-any.whl", hash = "sha256:ad9adfb20335d718c03de457358ed69f141021a333c40a53e57273d8a5bd0b9b", size = 317646, upload-time = "2025-11-04T14:29:37.948Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "lazy-loader" +version = "0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/6b/c875b30a1ba490860c93da4cabf479e03f584eba06fe5963f6f6644653d8/lazy_loader-0.4.tar.gz", hash = "sha256:47c75182589b91a4e1a85a136c074285a5ad4d9f39c63e0d7fb76391c4574cd1", size = 15431, upload-time = "2024-04-05T13:03:12.261Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/60/d497a310bde3f01cb805196ac61b7ad6dc5dcf8dce66634dc34364b20b4f/lazy_loader-0.4-py3-none-any.whl", hash = "sha256:342aa8e14d543a154047afb4ba8ef17f5563baad3fc610d7b15b213b0f119efc", size = 12097, upload-time = "2024-04-05T13:03:10.514Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, +] + +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + +[[package]] +name = "ninja" +version = "1.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/73/79a0b22fc731989c708068427579e840a6cf4e937fe7ae5c5d0b7356ac22/ninja-1.13.0.tar.gz", hash = "sha256:4a40ce995ded54d9dc24f8ea37ff3bf62ad192b547f6c7126e7e25045e76f978", size = 242558, upload-time = "2025-08-11T15:10:19.421Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/74/d02409ed2aa865e051b7edda22ad416a39d81a84980f544f8de717cab133/ninja-1.13.0-py3-none-macosx_10_9_universal2.whl", hash = "sha256:fa2a8bfc62e31b08f83127d1613d10821775a0eb334197154c4d6067b7068ff1", size = 310125, upload-time = "2025-08-11T15:09:50.971Z" }, + { url = "https://files.pythonhosted.org/packages/8e/de/6e1cd6b84b412ac1ef327b76f0641aeb5dcc01e9d3f9eee0286d0c34fd93/ninja-1.13.0-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3d00c692fb717fd511abeb44b8c5d00340c36938c12d6538ba989fe764e79630", size = 177467, upload-time = "2025-08-11T15:09:52.767Z" }, + { url = "https://files.pythonhosted.org/packages/c8/83/49320fb6e58ae3c079381e333575fdbcf1cca3506ee160a2dcce775046fa/ninja-1.13.0-py3-none-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:be7f478ff9f96a128b599a964fc60a6a87b9fa332ee1bd44fa243ac88d50291c", size = 187834, upload-time = "2025-08-11T15:09:54.115Z" }, + { url = "https://files.pythonhosted.org/packages/56/c7/ba22748fb59f7f896b609cd3e568d28a0a367a6d953c24c461fe04fc4433/ninja-1.13.0-py3-none-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:60056592cf495e9a6a4bea3cd178903056ecb0943e4de45a2ea825edb6dc8d3e", size = 202736, upload-time = "2025-08-11T15:09:55.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/22/d1de07632b78ac8e6b785f41fa9aad7a978ec8c0a1bf15772def36d77aac/ninja-1.13.0-py3-none-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:1c97223cdda0417f414bf864cfb73b72d8777e57ebb279c5f6de368de0062988", size = 179034, upload-time = "2025-08-11T15:09:57.394Z" }, + { url = "https://files.pythonhosted.org/packages/ed/de/0e6edf44d6a04dabd0318a519125ed0415ce437ad5a1ec9b9be03d9048cf/ninja-1.13.0-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fb46acf6b93b8dd0322adc3a4945452a4e774b75b91293bafcc7b7f8e6517dfa", size = 180716, upload-time = "2025-08-11T15:09:58.696Z" }, + { url = "https://files.pythonhosted.org/packages/54/28/938b562f9057aaa4d6bfbeaa05e81899a47aebb3ba6751e36c027a7f5ff7/ninja-1.13.0-py3-none-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4be9c1b082d244b1ad7ef41eb8ab088aae8c109a9f3f0b3e56a252d3e00f42c1", size = 146843, upload-time = "2025-08-11T15:10:00.046Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fb/d06a3838de4f8ab866e44ee52a797b5491df823901c54943b2adb0389fbb/ninja-1.13.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:6739d3352073341ad284246f81339a384eec091d9851a886dfa5b00a6d48b3e2", size = 154402, upload-time = "2025-08-11T15:10:01.657Z" }, + { url = "https://files.pythonhosted.org/packages/31/bf/0d7808af695ceddc763cf251b84a9892cd7f51622dc8b4c89d5012779f06/ninja-1.13.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:11be2d22027bde06f14c343f01d31446747dbb51e72d00decca2eb99be911e2f", size = 552388, upload-time = "2025-08-11T15:10:03.349Z" }, + { url = "https://files.pythonhosted.org/packages/9d/70/c99d0c2c809f992752453cce312848abb3b1607e56d4cd1b6cded317351a/ninja-1.13.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:aa45b4037b313c2f698bc13306239b8b93b4680eb47e287773156ac9e9304714", size = 472501, upload-time = "2025-08-11T15:10:04.735Z" }, + { url = "https://files.pythonhosted.org/packages/9f/43/c217b1153f0e499652f5e0766da8523ce3480f0a951039c7af115e224d55/ninja-1.13.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5f8e1e8a1a30835eeb51db05cf5a67151ad37542f5a4af2a438e9490915e5b72", size = 638280, upload-time = "2025-08-11T15:10:06.512Z" }, + { url = "https://files.pythonhosted.org/packages/8c/45/9151bba2c8d0ae2b6260f71696330590de5850e5574b7b5694dce6023e20/ninja-1.13.0-py3-none-musllinux_1_2_ppc64le.whl", hash = "sha256:3d7d7779d12cb20c6d054c61b702139fd23a7a964ec8f2c823f1ab1b084150db", size = 642420, upload-time = "2025-08-11T15:10:08.35Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/95752eb635bb8ad27d101d71bef15bc63049de23f299e312878fc21cb2da/ninja-1.13.0-py3-none-musllinux_1_2_riscv64.whl", hash = "sha256:d741a5e6754e0bda767e3274a0f0deeef4807f1fec6c0d7921a0244018926ae5", size = 585106, upload-time = "2025-08-11T15:10:09.818Z" }, + { url = "https://files.pythonhosted.org/packages/c1/31/aa56a1a286703800c0cbe39fb4e82811c277772dc8cd084f442dd8e2938a/ninja-1.13.0-py3-none-musllinux_1_2_s390x.whl", hash = "sha256:e8bad11f8a00b64137e9b315b137d8bb6cbf3086fbdc43bf1f90fd33324d2e96", size = 707138, upload-time = "2025-08-11T15:10:11.366Z" }, + { url = "https://files.pythonhosted.org/packages/34/6f/5f5a54a1041af945130abdb2b8529cbef0cdcbbf9bcf3f4195378319d29a/ninja-1.13.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b4f2a072db3c0f944c32793e91532d8948d20d9ab83da9c0c7c15b5768072200", size = 581758, upload-time = "2025-08-11T15:10:13.295Z" }, + { url = "https://files.pythonhosted.org/packages/95/97/51359c77527d45943fe7a94d00a3843b81162e6c4244b3579fe8fc54cb9c/ninja-1.13.0-py3-none-win32.whl", hash = "sha256:8cfbb80b4a53456ae8a39f90ae3d7a2129f45ea164f43fadfa15dc38c4aef1c9", size = 267201, upload-time = "2025-08-11T15:10:15.158Z" }, + { url = "https://files.pythonhosted.org/packages/29/45/c0adfbfb0b5895aa18cec400c535b4f7ff3e52536e0403602fc1a23f7de9/ninja-1.13.0-py3-none-win_amd64.whl", hash = "sha256:fb8ee8719f8af47fed145cced4a85f0755dd55d45b2bddaf7431fa89803c5f3e", size = 309975, upload-time = "2025-08-11T15:10:16.697Z" }, + { url = "https://files.pythonhosted.org/packages/df/93/a7b983643d1253bb223234b5b226e69de6cda02b76cdca7770f684b795f5/ninja-1.13.0-py3-none-win_arm64.whl", hash = "sha256:3c0b40b1f0bba764644385319028650087b4c1b18cdfa6f45cb39a3669b81aa9", size = 290806, upload-time = "2025-08-11T15:10:18.018Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/fd/0005efbd0af48e55eb3c7208af93f2862d4b1a56cd78e84309a2d959208d/numpy-2.4.2.tar.gz", hash = "sha256:659a6107e31a83c4e33f763942275fd278b21d095094044eb35569e86a21ddae", size = 20723651, upload-time = "2026-01-31T23:13:10.135Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/6e/6f394c9c77668153e14d4da83bcc247beb5952f6ead7699a1a2992613bea/numpy-2.4.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:21982668592194c609de53ba4933a7471880ccbaadcc52352694a59ecc860b3a", size = 16667963, upload-time = "2026-01-31T23:10:52.147Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f8/55483431f2b2fd015ae6ed4fe62288823ce908437ed49db5a03d15151678/numpy-2.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40397bda92382fcec844066efb11f13e1c9a3e2a8e8f318fb72ed8b6db9f60f1", size = 14693571, upload-time = "2026-01-31T23:10:54.789Z" }, + { url = "https://files.pythonhosted.org/packages/2f/20/18026832b1845cdc82248208dd929ca14c9d8f2bac391f67440707fff27c/numpy-2.4.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:b3a24467af63c67829bfaa61eecf18d5432d4f11992688537be59ecd6ad32f5e", size = 5203469, upload-time = "2026-01-31T23:10:57.343Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/2eb97c8a77daaba34eaa3fa7241a14ac5f51c46a6bd5911361b644c4a1e2/numpy-2.4.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:805cc8de9fd6e7a22da5aed858e0ab16be5a4db6c873dde1d7451c541553aa27", size = 6550820, upload-time = "2026-01-31T23:10:59.429Z" }, + { url = "https://files.pythonhosted.org/packages/b1/91/b97fdfd12dc75b02c44e26c6638241cc004d4079a0321a69c62f51470c4c/numpy-2.4.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d82351358ffbcdcd7b686b90742a9b86632d6c1c051016484fa0b326a0a1548", size = 15663067, upload-time = "2026-01-31T23:11:01.291Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c6/a18e59f3f0b8071cc85cbc8d80cd02d68aa9710170b2553a117203d46936/numpy-2.4.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e35d3e0144137d9fdae62912e869136164534d64a169f86438bc9561b6ad49f", size = 16619782, upload-time = "2026-01-31T23:11:03.669Z" }, + { url = "https://files.pythonhosted.org/packages/b7/83/9751502164601a79e18847309f5ceec0b1446d7b6aa12305759b72cf98b2/numpy-2.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adb6ed2ad29b9e15321d167d152ee909ec73395901b70936f029c3bc6d7f4460", size = 17013128, upload-time = "2026-01-31T23:11:05.913Z" }, + { url = "https://files.pythonhosted.org/packages/61/c4/c4066322256ec740acc1c8923a10047818691d2f8aec254798f3dd90f5f2/numpy-2.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8906e71fd8afcb76580404e2a950caef2685df3d2a57fe82a86ac8d33cc007ba", size = 18345324, upload-time = "2026-01-31T23:11:08.248Z" }, + { url = "https://files.pythonhosted.org/packages/ab/af/6157aa6da728fa4525a755bfad486ae7e3f76d4c1864138003eb84328497/numpy-2.4.2-cp312-cp312-win32.whl", hash = "sha256:ec055f6dae239a6299cace477b479cca2fc125c5675482daf1dd886933a1076f", size = 5960282, upload-time = "2026-01-31T23:11:10.497Z" }, + { url = "https://files.pythonhosted.org/packages/92/0f/7ceaaeaacb40567071e94dbf2c9480c0ae453d5bb4f52bea3892c39dc83c/numpy-2.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:209fae046e62d0ce6435fcfe3b1a10537e858249b3d9b05829e2a05218296a85", size = 12314210, upload-time = "2026-01-31T23:11:12.176Z" }, + { url = "https://files.pythonhosted.org/packages/2f/a3/56c5c604fae6dd40fa2ed3040d005fca97e91bd320d232ac9931d77ba13c/numpy-2.4.2-cp312-cp312-win_arm64.whl", hash = "sha256:fbde1b0c6e81d56f5dccd95dd4a711d9b95df1ae4009a60887e56b27e8d903fa", size = 10220171, upload-time = "2026-01-31T23:11:14.684Z" }, + { url = "https://files.pythonhosted.org/packages/a1/22/815b9fe25d1d7ae7d492152adbc7226d3eff731dffc38fe970589fcaaa38/numpy-2.4.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:25f2059807faea4b077a2b6837391b5d830864b3543627f381821c646f31a63c", size = 16663696, upload-time = "2026-01-31T23:11:17.516Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/817d03a03f93ba9c6c8993de509277d84e69f9453601915e4a69554102a1/numpy-2.4.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bd3a7a9f5847d2fb8c2c6d1c862fa109c31a9abeca1a3c2bd5a64572955b2979", size = 14688322, upload-time = "2026-01-31T23:11:19.883Z" }, + { url = "https://files.pythonhosted.org/packages/da/b4/f805ab79293c728b9a99438775ce51885fd4f31b76178767cfc718701a39/numpy-2.4.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8e4549f8a3c6d13d55041925e912bfd834285ef1dd64d6bc7d542583355e2e98", size = 5198157, upload-time = "2026-01-31T23:11:22.375Z" }, + { url = "https://files.pythonhosted.org/packages/74/09/826e4289844eccdcd64aac27d13b0fd3f32039915dd5b9ba01baae1f436c/numpy-2.4.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:aea4f66ff44dfddf8c2cffd66ba6538c5ec67d389285292fe428cb2c738c8aef", size = 6546330, upload-time = "2026-01-31T23:11:23.958Z" }, + { url = "https://files.pythonhosted.org/packages/19/fb/cbfdbfa3057a10aea5422c558ac57538e6acc87ec1669e666d32ac198da7/numpy-2.4.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3cd545784805de05aafe1dde61752ea49a359ccba9760c1e5d1c88a93bbf2b7", size = 15660968, upload-time = "2026-01-31T23:11:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/04/dc/46066ce18d01645541f0186877377b9371b8fa8017fa8262002b4ef22612/numpy-2.4.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0d9b7c93578baafcbc5f0b83eaf17b79d345c6f36917ba0c67f45226911d499", size = 16607311, upload-time = "2026-01-31T23:11:28.117Z" }, + { url = "https://files.pythonhosted.org/packages/14/d9/4b5adfc39a43fa6bf918c6d544bc60c05236cc2f6339847fc5b35e6cb5b0/numpy-2.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f74f0f7779cc7ae07d1810aab8ac6b1464c3eafb9e283a40da7309d5e6e48fbb", size = 17012850, upload-time = "2026-01-31T23:11:30.888Z" }, + { url = "https://files.pythonhosted.org/packages/b7/20/adb6e6adde6d0130046e6fdfb7675cc62bc2f6b7b02239a09eb58435753d/numpy-2.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7ac672d699bf36275c035e16b65539931347d68b70667d28984c9fb34e07fa7", size = 18334210, upload-time = "2026-01-31T23:11:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/78/0e/0a73b3dff26803a8c02baa76398015ea2a5434d9b8265a7898a6028c1591/numpy-2.4.2-cp313-cp313-win32.whl", hash = "sha256:8e9afaeb0beff068b4d9cd20d322ba0ee1cecfb0b08db145e4ab4dd44a6b5110", size = 5958199, upload-time = "2026-01-31T23:11:35.385Z" }, + { url = "https://files.pythonhosted.org/packages/43/bc/6352f343522fcb2c04dbaf94cb30cca6fd32c1a750c06ad6231b4293708c/numpy-2.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:7df2de1e4fba69a51c06c28f5a3de36731eb9639feb8e1cf7e4a7b0daf4cf622", size = 12310848, upload-time = "2026-01-31T23:11:38.001Z" }, + { url = "https://files.pythonhosted.org/packages/6e/8d/6da186483e308da5da1cc6918ce913dcfe14ffde98e710bfeff2a6158d4e/numpy-2.4.2-cp313-cp313-win_arm64.whl", hash = "sha256:0fece1d1f0a89c16b03442eae5c56dc0be0c7883b5d388e0c03f53019a4bfd71", size = 10221082, upload-time = "2026-01-31T23:11:40.392Z" }, + { url = "https://files.pythonhosted.org/packages/25/a1/9510aa43555b44781968935c7548a8926274f815de42ad3997e9e83680dd/numpy-2.4.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5633c0da313330fd20c484c78cdd3f9b175b55e1a766c4a174230c6b70ad8262", size = 14815866, upload-time = "2026-01-31T23:11:42.495Z" }, + { url = "https://files.pythonhosted.org/packages/36/30/6bbb5e76631a5ae46e7923dd16ca9d3f1c93cfa8d4ed79a129814a9d8db3/numpy-2.4.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d9f64d786b3b1dd742c946c42d15b07497ed14af1a1f3ce840cce27daa0ce913", size = 5325631, upload-time = "2026-01-31T23:11:44.7Z" }, + { url = "https://files.pythonhosted.org/packages/46/00/3a490938800c1923b567b3a15cd17896e68052e2145d8662aaf3e1ffc58f/numpy-2.4.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:b21041e8cb6a1eb5312dd1d2f80a94d91efffb7a06b70597d44f1bd2dfc315ab", size = 6646254, upload-time = "2026-01-31T23:11:46.341Z" }, + { url = "https://files.pythonhosted.org/packages/d3/e9/fac0890149898a9b609caa5af7455a948b544746e4b8fe7c212c8edd71f8/numpy-2.4.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00ab83c56211a1d7c07c25e3217ea6695e50a3e2f255053686b081dc0b091a82", size = 15720138, upload-time = "2026-01-31T23:11:48.082Z" }, + { url = "https://files.pythonhosted.org/packages/ea/5c/08887c54e68e1e28df53709f1893ce92932cc6f01f7c3d4dc952f61ffd4e/numpy-2.4.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fb882da679409066b4603579619341c6d6898fc83a8995199d5249f986e8e8f", size = 16655398, upload-time = "2026-01-31T23:11:50.293Z" }, + { url = "https://files.pythonhosted.org/packages/4d/89/253db0fa0e66e9129c745e4ef25631dc37d5f1314dad2b53e907b8538e6d/numpy-2.4.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:66cb9422236317f9d44b67b4d18f44efe6e9c7f8794ac0462978513359461554", size = 17079064, upload-time = "2026-01-31T23:11:52.927Z" }, + { url = "https://files.pythonhosted.org/packages/2a/d5/cbade46ce97c59c6c3da525e8d95b7abe8a42974a1dc5c1d489c10433e88/numpy-2.4.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0f01dcf33e73d80bd8dc0f20a71303abbafa26a19e23f6b68d1aa9990af90257", size = 18379680, upload-time = "2026-01-31T23:11:55.22Z" }, + { url = "https://files.pythonhosted.org/packages/40/62/48f99ae172a4b63d981babe683685030e8a3df4f246c893ea5c6ef99f018/numpy-2.4.2-cp313-cp313t-win32.whl", hash = "sha256:52b913ec40ff7ae845687b0b34d8d93b60cb66dcee06996dd5c99f2fc9328657", size = 6082433, upload-time = "2026-01-31T23:11:58.096Z" }, + { url = "https://files.pythonhosted.org/packages/07/38/e054a61cfe48ad9f1ed0d188e78b7e26859d0b60ef21cd9de4897cdb5326/numpy-2.4.2-cp313-cp313t-win_amd64.whl", hash = "sha256:5eea80d908b2c1f91486eb95b3fb6fab187e569ec9752ab7d9333d2e66bf2d6b", size = 12451181, upload-time = "2026-01-31T23:11:59.782Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a4/a05c3a6418575e185dd84d0b9680b6bb2e2dc3e4202f036b7b4e22d6e9dc/numpy-2.4.2-cp313-cp313t-win_arm64.whl", hash = "sha256:fd49860271d52127d61197bb50b64f58454e9f578cb4b2c001a6de8b1f50b0b1", size = 10290756, upload-time = "2026-01-31T23:12:02.438Z" }, + { url = "https://files.pythonhosted.org/packages/18/88/b7df6050bf18fdcfb7046286c6535cabbdd2064a3440fca3f069d319c16e/numpy-2.4.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:444be170853f1f9d528428eceb55f12918e4fda5d8805480f36a002f1415e09b", size = 16663092, upload-time = "2026-01-31T23:12:04.521Z" }, + { url = "https://files.pythonhosted.org/packages/25/7a/1fee4329abc705a469a4afe6e69b1ef7e915117747886327104a8493a955/numpy-2.4.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d1240d50adff70c2a88217698ca844723068533f3f5c5fa6ee2e3220e3bdb000", size = 14698770, upload-time = "2026-01-31T23:12:06.96Z" }, + { url = "https://files.pythonhosted.org/packages/fb/0b/f9e49ba6c923678ad5bc38181c08ac5e53b7a5754dbca8e581aa1a56b1ff/numpy-2.4.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:7cdde6de52fb6664b00b056341265441192d1291c130e99183ec0d4b110ff8b1", size = 5208562, upload-time = "2026-01-31T23:12:09.632Z" }, + { url = "https://files.pythonhosted.org/packages/7d/12/d7de8f6f53f9bb76997e5e4c069eda2051e3fe134e9181671c4391677bb2/numpy-2.4.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:cda077c2e5b780200b6b3e09d0b42205a3d1c68f30c6dceb90401c13bff8fe74", size = 6543710, upload-time = "2026-01-31T23:12:11.969Z" }, + { url = "https://files.pythonhosted.org/packages/09/63/c66418c2e0268a31a4cf8a8b512685748200f8e8e8ec6c507ce14e773529/numpy-2.4.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d30291931c915b2ab5717c2974bb95ee891a1cf22ebc16a8006bd59cd210d40a", size = 15677205, upload-time = "2026-01-31T23:12:14.33Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6c/7f237821c9642fb2a04d2f1e88b4295677144ca93285fd76eff3bcba858d/numpy-2.4.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bba37bc29d4d85761deed3954a1bc62be7cf462b9510b51d367b769a8c8df325", size = 16611738, upload-time = "2026-01-31T23:12:16.525Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a7/39c4cdda9f019b609b5c473899d87abff092fc908cfe4d1ecb2fcff453b0/numpy-2.4.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b2f0073ed0868db1dcd86e052d37279eef185b9c8db5bf61f30f46adac63c909", size = 17028888, upload-time = "2026-01-31T23:12:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/da/b3/e84bb64bdfea967cc10950d71090ec2d84b49bc691df0025dddb7c26e8e3/numpy-2.4.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7f54844851cdb630ceb623dcec4db3240d1ac13d4990532446761baede94996a", size = 18339556, upload-time = "2026-01-31T23:12:21.816Z" }, + { url = "https://files.pythonhosted.org/packages/88/f5/954a291bc1192a27081706862ac62bb5920fbecfbaa302f64682aa90beed/numpy-2.4.2-cp314-cp314-win32.whl", hash = "sha256:12e26134a0331d8dbd9351620f037ec470b7c75929cb8a1537f6bfe411152a1a", size = 6006899, upload-time = "2026-01-31T23:12:24.14Z" }, + { url = "https://files.pythonhosted.org/packages/05/cb/eff72a91b2efdd1bc98b3b8759f6a1654aa87612fc86e3d87d6fe4f948c4/numpy-2.4.2-cp314-cp314-win_amd64.whl", hash = "sha256:068cdb2d0d644cdb45670810894f6a0600797a69c05f1ac478e8d31670b8ee75", size = 12443072, upload-time = "2026-01-31T23:12:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/37/75/62726948db36a56428fce4ba80a115716dc4fad6a3a4352487f8bb950966/numpy-2.4.2-cp314-cp314-win_arm64.whl", hash = "sha256:6ed0be1ee58eef41231a5c943d7d1375f093142702d5723ca2eb07db9b934b05", size = 10494886, upload-time = "2026-01-31T23:12:28.488Z" }, + { url = "https://files.pythonhosted.org/packages/36/2f/ee93744f1e0661dc267e4b21940870cabfae187c092e1433b77b09b50ac4/numpy-2.4.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:98f16a80e917003a12c0580f97b5f875853ebc33e2eaa4bccfc8201ac6869308", size = 14818567, upload-time = "2026-01-31T23:12:30.709Z" }, + { url = "https://files.pythonhosted.org/packages/a7/24/6535212add7d76ff938d8bdc654f53f88d35cddedf807a599e180dcb8e66/numpy-2.4.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:20abd069b9cda45874498b245c8015b18ace6de8546bf50dfa8cea1696ed06ef", size = 5328372, upload-time = "2026-01-31T23:12:32.962Z" }, + { url = "https://files.pythonhosted.org/packages/5e/9d/c48f0a035725f925634bf6b8994253b43f2047f6778a54147d7e213bc5a7/numpy-2.4.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:e98c97502435b53741540a5717a6749ac2ada901056c7db951d33e11c885cc7d", size = 6649306, upload-time = "2026-01-31T23:12:34.797Z" }, + { url = "https://files.pythonhosted.org/packages/81/05/7c73a9574cd4a53a25907bad38b59ac83919c0ddc8234ec157f344d57d9a/numpy-2.4.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da6cad4e82cb893db4b69105c604d805e0c3ce11501a55b5e9f9083b47d2ffe8", size = 15722394, upload-time = "2026-01-31T23:12:36.565Z" }, + { url = "https://files.pythonhosted.org/packages/35/fa/4de10089f21fc7d18442c4a767ab156b25c2a6eaf187c0db6d9ecdaeb43f/numpy-2.4.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e4424677ce4b47fe73c8b5556d876571f7c6945d264201180db2dc34f676ab5", size = 16653343, upload-time = "2026-01-31T23:12:39.188Z" }, + { url = "https://files.pythonhosted.org/packages/b8/f9/d33e4ffc857f3763a57aa85650f2e82486832d7492280ac21ba9efda80da/numpy-2.4.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2b8f157c8a6f20eb657e240f8985cc135598b2b46985c5bccbde7616dc9c6b1e", size = 17078045, upload-time = "2026-01-31T23:12:42.041Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b8/54bdb43b6225badbea6389fa038c4ef868c44f5890f95dd530a218706da3/numpy-2.4.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5daf6f3914a733336dab21a05cdec343144600e964d2fcdabaac0c0269874b2a", size = 18380024, upload-time = "2026-01-31T23:12:44.331Z" }, + { url = "https://files.pythonhosted.org/packages/a5/55/6e1a61ded7af8df04016d81b5b02daa59f2ea9252ee0397cb9f631efe9e5/numpy-2.4.2-cp314-cp314t-win32.whl", hash = "sha256:8c50dd1fc8826f5b26a5ee4d77ca55d88a895f4e4819c7ecc2a9f5905047a443", size = 6153937, upload-time = "2026-01-31T23:12:47.229Z" }, + { url = "https://files.pythonhosted.org/packages/45/aa/fa6118d1ed6d776b0983f3ceac9b1a5558e80df9365b1c3aa6d42bf9eee4/numpy-2.4.2-cp314-cp314t-win_amd64.whl", hash = "sha256:fcf92bee92742edd401ba41135185866f7026c502617f422eb432cfeca4fe236", size = 12631844, upload-time = "2026-01-31T23:12:48.997Z" }, + { url = "https://files.pythonhosted.org/packages/32/0a/2ec5deea6dcd158f254a7b372fb09cfba5719419c8d66343bab35237b3fb/numpy-2.4.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1f92f53998a17265194018d1cc321b2e96e900ca52d54c7c77837b71b9465181", size = 10565379, upload-time = "2026-01-31T23:12:51.345Z" }, +] + +[[package]] +name = "nvidia-cublas-cu12" +version = "12.8.4.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/61/e24b560ab2e2eaeb3c839129175fb330dfcfc29e5203196e5541a4c44682/nvidia_cublas_cu12-12.8.4.1-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:8ac4e771d5a348c551b2a426eda6193c19aa630236b418086020df5ba9667142", size = 594346921, upload-time = "2025-03-07T01:44:31.254Z" }, +] + +[[package]] +name = "nvidia-cuda-cupti-cu12" +version = "12.8.90" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/02/2adcaa145158bf1a8295d83591d22e4103dbfd821bcaf6f3f53151ca4ffa/nvidia_cuda_cupti_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ea0cb07ebda26bb9b29ba82cda34849e73c166c18162d3913575b0c9db9a6182", size = 10248621, upload-time = "2025-03-07T01:40:21.213Z" }, +] + +[[package]] +name = "nvidia-cuda-nvrtc-cu12" +version = "12.8.93" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/6b/32f747947df2da6994e999492ab306a903659555dddc0fbdeb9d71f75e52/nvidia_cuda_nvrtc_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:a7756528852ef889772a84c6cd89d41dfa74667e24cca16bb31f8f061e3e9994", size = 88040029, upload-time = "2025-03-07T01:42:13.562Z" }, +] + +[[package]] +name = "nvidia-cuda-runtime-cu12" +version = "12.8.90" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/9b/a997b638fcd068ad6e4d53b8551a7d30fe8b404d6f1804abf1df69838932/nvidia_cuda_runtime_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:adade8dcbd0edf427b7204d480d6066d33902cab2a4707dcfc48a2d0fd44ab90", size = 954765, upload-time = "2025-03-07T01:40:01.615Z" }, +] + +[[package]] +name = "nvidia-cudnn-cu12" +version = "9.10.2.21" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas-cu12" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/51/e123d997aa098c61d029f76663dedbfb9bc8dcf8c60cbd6adbe42f76d049/nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:949452be657fa16687d0930933f032835951ef0892b37d2d53824d1a84dc97a8", size = 706758467, upload-time = "2025-06-06T21:54:08.597Z" }, +] + +[[package]] +name = "nvidia-cufft-cu12" +version = "11.3.3.83" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink-cu12" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/13/ee4e00f30e676b66ae65b4f08cb5bcbb8392c03f54f2d5413ea99a5d1c80/nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d2dd21ec0b88cf61b62e6b43564355e5222e4a3fb394cac0db101f2dd0d4f74", size = 193118695, upload-time = "2025-03-07T01:45:27.821Z" }, +] + +[[package]] +name = "nvidia-cufile-cu12" +version = "1.13.1.3" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/fe/1bcba1dfbfb8d01be8d93f07bfc502c93fa23afa6fd5ab3fc7c1df71038a/nvidia_cufile_cu12-1.13.1.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1d069003be650e131b21c932ec3d8969c1715379251f8d23a1860554b1cb24fc", size = 1197834, upload-time = "2025-03-07T01:45:50.723Z" }, +] + +[[package]] +name = "nvidia-curand-cu12" +version = "10.3.9.90" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/aa/6584b56dc84ebe9cf93226a5cde4d99080c8e90ab40f0c27bda7a0f29aa1/nvidia_curand_cu12-10.3.9.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:b32331d4f4df5d6eefa0554c565b626c7216f87a06a4f56fab27c3b68a830ec9", size = 63619976, upload-time = "2025-03-07T01:46:23.323Z" }, +] + +[[package]] +name = "nvidia-cusolver-cu12" +version = "11.7.3.90" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas-cu12" }, + { name = "nvidia-cusparse-cu12" }, + { name = "nvidia-nvjitlink-cu12" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/48/9a13d2975803e8cf2777d5ed57b87a0b6ca2cc795f9a4f59796a910bfb80/nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:4376c11ad263152bd50ea295c05370360776f8c3427b30991df774f9fb26c450", size = 267506905, upload-time = "2025-03-07T01:47:16.273Z" }, +] + +[[package]] +name = "nvidia-cusparse-cu12" +version = "12.5.8.93" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink-cu12" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/f5/e1854cb2f2bcd4280c44736c93550cc300ff4b8c95ebe370d0aa7d2b473d/nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ec05d76bbbd8b61b06a80e1eaf8cf4959c3d4ce8e711b65ebd0443bb0ebb13b", size = 288216466, upload-time = "2025-03-07T01:48:13.779Z" }, +] + +[[package]] +name = "nvidia-cusparselt-cu12" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/79/12978b96bd44274fe38b5dde5cfb660b1d114f70a65ef962bcbbed99b549/nvidia_cusparselt_cu12-0.7.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:f1bb701d6b930d5a7cea44c19ceb973311500847f81b634d802b7b539dc55623", size = 287193691, upload-time = "2025-02-26T00:15:44.104Z" }, +] + +[[package]] +name = "nvidia-nccl-cu12" +version = "2.27.5" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/89/f7a07dc961b60645dbbf42e80f2bc85ade7feb9a491b11a1e973aa00071f/nvidia_nccl_cu12-2.27.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ad730cf15cb5d25fe849c6e6ca9eb5b76db16a80f13f425ac68d8e2e55624457", size = 322348229, upload-time = "2025-06-26T04:11:28.385Z" }, +] + +[[package]] +name = "nvidia-nvjitlink-cu12" +version = "12.8.93" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/74/86a07f1d0f42998ca31312f998bd3b9a7eff7f52378f4f270c8679c77fb9/nvidia_nvjitlink_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:81ff63371a7ebd6e6451970684f916be2eab07321b73c9d244dc2b4da7f73b88", size = 39254836, upload-time = "2025-03-07T01:49:55.661Z" }, +] + +[[package]] +name = "nvidia-nvshmem-cu12" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/09/6ea3ea725f82e1e76684f0708bbedd871fc96da89945adeba65c3835a64c/nvidia_nvshmem_cu12-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:042f2500f24c021db8a06c5eec2539027d57460e1c1a762055a6554f72c369bd", size = 139103095, upload-time = "2025-09-06T00:32:31.266Z" }, +] + +[[package]] +name = "nvidia-nvtx-cu12" +version = "12.8.90" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/eb/86626c1bbc2edb86323022371c39aa48df6fd8b0a1647bc274577f72e90b/nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5b17e2001cc0d751a5bc2c6ec6d26ad95913324a4adb86788c944f8ce9ba441f", size = 89954, upload-time = "2025-03-07T01:42:44.131Z" }, +] + +[[package]] +name = "opencv-python-headless" +version = "4.13.0.92" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/42/2310883be3b8826ac58c3f2787b9358a2d46923d61f88fedf930bc59c60c/opencv_python_headless-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:1a7d040ac656c11b8c38677cc8cccdc149f98535089dbe5b081e80a4e5903209", size = 46247192, upload-time = "2026-02-05T07:01:35.187Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1e/6f9e38005a6f7f22af785df42a43139d0e20f169eb5787ce8be37ee7fcc9/opencv_python_headless-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:3e0a6f0a37994ec6ce5f59e936be21d5d6384a4556f2d2da9c2f9c5dc948394c", size = 32568914, upload-time = "2026-02-05T07:01:51.989Z" }, + { url = "https://files.pythonhosted.org/packages/21/76/9417a6aef9def70e467a5bf560579f816148a4c658b7d525581b356eda9e/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c8cfc8e87ed452b5cecb9419473ee5560a989859fe1d10d1ce11ae87b09a2cb", size = 33703709, upload-time = "2026-02-05T10:24:46.469Z" }, + { url = "https://files.pythonhosted.org/packages/92/ce/bd17ff5772938267fd49716e94ca24f616ff4cb1ff4c6be13085108037be/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0525a3d2c0b46c611e2130b5fdebc94cf404845d8fa64d2f3a3b679572a5bd22", size = 56016764, upload-time = "2026-02-05T10:26:48.904Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b4/b7bcbf7c874665825a8c8e1097e93ea25d1f1d210a3e20d4451d01da30aa/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb60e36b237b1ebd40a912da5384b348df8ed534f6f644d8e0b4f103e272ba7d", size = 35010236, upload-time = "2026-02-05T10:28:11.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/33/b5db29a6c00eb8f50708110d8d453747ca125c8b805bc437b289dbdcc057/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0bd48544f77c68b2941392fcdf9bcd2b9cdf00e98cb8c29b2455d194763cf99e", size = 60391106, upload-time = "2026-02-05T10:30:14.236Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c3/52cfea47cd33e53e8c0fbd6e7c800b457245c1fda7d61660b4ffe9596a7f/opencv_python_headless-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:a7cf08e5b191f4ebb530791acc0825a7986e0d0dee2a3c491184bd8599848a4b", size = 30812232, upload-time = "2026-02-05T07:02:29.594Z" }, + { url = "https://files.pythonhosted.org/packages/4a/90/b338326131ccb2aaa3c2c85d00f41822c0050139a4bfe723cfd95455bd2d/opencv_python_headless-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:77a82fe35ddcec0f62c15f2ba8a12ecc2ed4207c17b0902c7a3151ae29f37fb6", size = 40070414, upload-time = "2026-02-05T07:02:26.448Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pillow" +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, + { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, + { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, + { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, + { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, + { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, + { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, + { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, + { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, + { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, + { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, + { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, + { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, + { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, + { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, + { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, + { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, + { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, + { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, + { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, + { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, + { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pyclipper" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/21/3c06205bb407e1f79b73b7b4dfb3950bd9537c4f625a68ab5cc41177f5bc/pyclipper-1.4.0.tar.gz", hash = "sha256:9882bd889f27da78add4dd6f881d25697efc740bf840274e749988d25496c8e1", size = 54489, upload-time = "2025-12-01T13:15:35.015Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/1b/7a07b68e0842324d46c03e512d8eefa9cb92ba2a792b3b4ebf939dafcac3/pyclipper-1.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:222ac96c8b8281b53d695b9c4fedc674f56d6d4320ad23f1bdbd168f4e316140", size = 265676, upload-time = "2025-12-01T13:15:04.15Z" }, + { url = "https://files.pythonhosted.org/packages/6b/dd/8bd622521c05d04963420ae6664093f154343ed044c53ea260a310c8bb4d/pyclipper-1.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f3672dbafbb458f1b96e1ee3e610d174acb5ace5bd2ed5d1252603bb797f2fc6", size = 140458, upload-time = "2025-12-01T13:15:05.76Z" }, + { url = "https://files.pythonhosted.org/packages/7a/06/6e3e241882bf7d6ab23d9c69ba4e85f1ec47397cbbeee948a16cf75e21ed/pyclipper-1.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d1f807e2b4760a8e5c6d6b4e8c1d71ef52b7fe1946ff088f4fa41e16a881a5ca", size = 978235, upload-time = "2025-12-01T13:15:06.993Z" }, + { url = "https://files.pythonhosted.org/packages/cf/f4/3418c1cd5eea640a9fa2501d4bc0b3655fa8d40145d1a4f484b987990a75/pyclipper-1.4.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce1f83c9a4e10ea3de1959f0ae79e9a5bd41346dff648fee6228ba9eaf8b3872", size = 961388, upload-time = "2025-12-01T13:15:08.467Z" }, + { url = "https://files.pythonhosted.org/packages/ac/94/c85401d24be634af529c962dd5d781f3cb62a67cd769534df2cb3feee97a/pyclipper-1.4.0-cp312-cp312-win32.whl", hash = "sha256:3ef44b64666ebf1cb521a08a60c3e639d21b8c50bfbe846ba7c52a0415e936f4", size = 95169, upload-time = "2025-12-01T13:15:10.098Z" }, + { url = "https://files.pythonhosted.org/packages/97/77/dfea08e3b230b82ee22543c30c35d33d42f846a77f96caf7c504dd54fab1/pyclipper-1.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:d1e5498d883b706a4ce636247f0d830c6eb34a25b843a1b78e2c969754ca9037", size = 104619, upload-time = "2025-12-01T13:15:11.592Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/cbce7d47de1e6458f66a4d999b091640134deb8f2c7351eab993b70d2e10/pyclipper-1.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d49df13cbb2627ccb13a1046f3ea6ebf7177b5504ec61bdef87d6a704046fd6e", size = 264342, upload-time = "2025-12-01T13:15:12.697Z" }, + { url = "https://files.pythonhosted.org/packages/ce/cc/742b9d69d96c58ac156947e1b56d0f81cbacbccf869e2ac7229f2f86dc4e/pyclipper-1.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:37bfec361e174110cdddffd5ecd070a8064015c99383d95eb692c253951eee8a", size = 139839, upload-time = "2025-12-01T13:15:13.911Z" }, + { url = "https://files.pythonhosted.org/packages/db/48/dd301d62c1529efdd721b47b9e5fb52120fcdac5f4d3405cfc0d2f391414/pyclipper-1.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:14c8bdb5a72004b721c4e6f448d2c2262d74a7f0c9e3076aeff41e564a92389f", size = 972142, upload-time = "2025-12-01T13:15:15.477Z" }, + { url = "https://files.pythonhosted.org/packages/07/bf/d493fd1b33bb090fa64e28c1009374d5d72fa705f9331cd56517c35e381e/pyclipper-1.4.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f2a50c22c3a78cb4e48347ecf06930f61ce98cf9252f2e292aa025471e9d75b1", size = 952789, upload-time = "2025-12-01T13:15:17.042Z" }, + { url = "https://files.pythonhosted.org/packages/cf/88/b95ea8ea21ddca34aa14b123226a81526dd2faaa993f9aabd3ed21231604/pyclipper-1.4.0-cp313-cp313-win32.whl", hash = "sha256:c9a3faa416ff536cee93417a72bfb690d9dea136dc39a39dbbe1e5dadf108c9c", size = 94817, upload-time = "2025-12-01T13:15:18.724Z" }, + { url = "https://files.pythonhosted.org/packages/ba/42/0a1920d276a0e1ca21dc0d13ee9e3ba10a9a8aa3abac76cd5e5a9f503306/pyclipper-1.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:d4b2d7c41086f1927d14947c563dfc7beed2f6c0d9af13c42fe3dcdc20d35832", size = 104007, upload-time = "2025-12-01T13:15:19.763Z" }, + { url = "https://files.pythonhosted.org/packages/1a/20/04d58c70f3ccd404f179f8dd81d16722a05a3bf1ab61445ee64e8218c1f8/pyclipper-1.4.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:7c87480fc91a5af4c1ba310bdb7de2f089a3eeef5fe351a3cedc37da1fcced1c", size = 265167, upload-time = "2025-12-01T13:15:20.844Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2e/a570c1abe69b7260ca0caab4236ce6ea3661193ebf8d1bd7f78ccce537a5/pyclipper-1.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:81d8bb2d1fb9d66dc7ea4373b176bb4b02443a7e328b3b603a73faec088b952e", size = 139966, upload-time = "2025-12-01T13:15:22.036Z" }, + { url = "https://files.pythonhosted.org/packages/e8/3b/e0859e54adabdde8a24a29d3f525ebb31c71ddf2e8d93edce83a3c212ffc/pyclipper-1.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:773c0e06b683214dcfc6711be230c83b03cddebe8a57eae053d4603dd63582f9", size = 968216, upload-time = "2025-12-01T13:15:23.18Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6b/e3c4febf0a35ae643ee579b09988dd931602b5bf311020535fd9e5b7e715/pyclipper-1.4.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9bc45f2463d997848450dbed91c950ca37c6cf27f84a49a5cad4affc0b469e39", size = 954198, upload-time = "2025-12-01T13:15:24.522Z" }, + { url = "https://files.pythonhosted.org/packages/fc/74/728efcee02e12acb486ce9d56fa037120c9bf5b77c54bbdbaa441c14a9d9/pyclipper-1.4.0-cp314-cp314-win32.whl", hash = "sha256:0b8c2105b3b3c44dbe1a266f64309407fe30bf372cf39a94dc8aaa97df00da5b", size = 96951, upload-time = "2025-12-01T13:15:25.79Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d7/7f4354e69f10a917e5c7d5d72a499ef2e10945312f5e72c414a0a08d2ae4/pyclipper-1.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:6c317e182590c88ec0194149995e3d71a979cfef3b246383f4e035f9d4a11826", size = 106782, upload-time = "2025-12-01T13:15:26.945Z" }, + { url = "https://files.pythonhosted.org/packages/63/60/fc32c7a3d7f61a970511ec2857ecd09693d8ac80d560ee7b8e67a6d268c9/pyclipper-1.4.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:f160a2c6ba036f7eaf09f1f10f4fbfa734234af9112fb5187877efed78df9303", size = 269880, upload-time = "2025-12-01T13:15:28.117Z" }, + { url = "https://files.pythonhosted.org/packages/49/df/c4a72d3f62f0ba03ec440c4fff56cd2d674a4334d23c5064cbf41c9583f6/pyclipper-1.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a9f11ad133257c52c40d50de7a0ca3370a0cdd8e3d11eec0604ad3c34ba549e9", size = 141706, upload-time = "2025-12-01T13:15:30.134Z" }, + { url = "https://files.pythonhosted.org/packages/c5/0b/cf55df03e2175e1e2da9db585241401e0bc98f76bee3791bed39d0313449/pyclipper-1.4.0-cp314-cp314t-win32.whl", hash = "sha256:bbc827b77442c99deaeee26e0e7f172355ddb097a5e126aea206d447d3b26286", size = 105308, upload-time = "2025-12-01T13:15:31.225Z" }, + { url = "https://files.pythonhosted.org/packages/8f/dc/53df8b6931d47080b4fe4ee8450d42e660ee1c5c1556c7ab73359182b769/pyclipper-1.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29dae3e0296dff8502eeb7639fcfee794b0eec8590ba3563aee28db269da6b04", size = 117608, upload-time = "2025-12-01T13:15:32.69Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "python-bidi" +version = "0.6.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/e3/c0c8bf6fca79ac946a28d57f116e3b9e5b10a4469b6f70bf73f3744c49bf/python_bidi-0.6.7.tar.gz", hash = "sha256:c10065081c0e137975de5d9ba2ff2306286dbf5e0c586d4d5aec87c856239b41", size = 45503, upload-time = "2025-10-22T09:52:49.624Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/03/5b2f3e73501d0f41ebc2b075b49473047c6cdfc3465cf890263fc69e3915/python_bidi-0.6.7-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:11c51579e01f768446a7e13a0059fea1530936a707abcbeaad9467a55cb16073", size = 272536, upload-time = "2025-10-22T09:51:59.721Z" }, + { url = "https://files.pythonhosted.org/packages/31/77/c6048e938a73e5a7c6fa3d5e3627a5961109daa728c2e7d050567cecdc26/python_bidi-0.6.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:47deaada8949af3a790f2cd73b613f9bfa153b4c9450f91c44a60c3109a81f73", size = 263258, upload-time = "2025-10-22T09:51:50.328Z" }, + { url = "https://files.pythonhosted.org/packages/57/56/ed4dc501cab7de70ce35cd435c86278e4eb1caf238c80bc72297767c9219/python_bidi-0.6.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b38ddfab41d10e780edb431edc30aec89bee4ce43d718e3896e99f33dae5c1d3", size = 292700, upload-time = "2025-10-22T09:50:59.628Z" }, + { url = "https://files.pythonhosted.org/packages/77/6a/1bf06d7544c940ffddd97cd0e02c55348a92163c5495fa18e34217dfbebe/python_bidi-0.6.7-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2a93b0394cc684d64356b0475858c116f1e335ffbaba388db93bf47307deadfa", size = 300881, upload-time = "2025-10-22T09:51:07.507Z" }, + { url = "https://files.pythonhosted.org/packages/22/1d/ce7577a8f50291c06e94f651ac5de0d1678fc2642af26a5dad9901a0244f/python_bidi-0.6.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ec1694134961b71ac05241ac989b49ccf08e232b5834d5fc46f8a7c3bb1c13a9", size = 439125, upload-time = "2025-10-22T09:51:16.559Z" }, + { url = "https://files.pythonhosted.org/packages/a3/87/4cf6dcd58e22f0fd904e7a161c6b73a5f9d17d4d49073fcb089ba62f1469/python_bidi-0.6.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8047c33b85f7790474a1f488bef95689f049976a4e1c6f213a8d075d180a93e4", size = 325816, upload-time = "2025-10-22T09:51:25.12Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0a/4028a088e29ce8f1673e85ec9f64204fc368355c3207e6a71619c2b4579a/python_bidi-0.6.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d9de35eb5987da27dd81e371c52142dd8e924bd61c1006003071ea05a735587", size = 300550, upload-time = "2025-10-22T09:51:42.739Z" }, + { url = "https://files.pythonhosted.org/packages/1f/05/cac15eba462d5a2407ac4ef1c792c45a948652b00c6bd81eaab3834a62d2/python_bidi-0.6.7-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a99d898ad1a399d9c8cab5561b3667fd24f4385820ac90c3340aa637aa5adfc9", size = 313017, upload-time = "2025-10-22T09:51:34.905Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b1/3ba91b9ea60fa54a9aa730a5fe432bd73095d55be371244584fc6818eae1/python_bidi-0.6.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5debaab33562fdfc79ffdbd8d9c51cf07b8529de0e889d8cd145d78137aab21e", size = 472798, upload-time = "2025-10-22T09:52:09.079Z" }, + { url = "https://files.pythonhosted.org/packages/50/40/4bf5fb7255e35c218174f322a4d4c80b63b2604d73adc6e32f843e700824/python_bidi-0.6.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c11c62a3cdb9d1426b1536de9e3446cb09c7d025bd4df125275cae221f214899", size = 565234, upload-time = "2025-10-22T09:52:19.703Z" }, + { url = "https://files.pythonhosted.org/packages/bd/81/ad23fb85bff69d0a25729cd3834254b87c3c7caa93d657c8f8edcbed08f6/python_bidi-0.6.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6c051f2d28ca542092d01da8b5fe110fb6191ff58d298a54a93dc183bece63bf", size = 491844, upload-time = "2025-10-22T09:52:31.216Z" }, + { url = "https://files.pythonhosted.org/packages/65/85/103baaf142b2838f583b71904a2454fa31bd2a912ff505c25874f45d6c3e/python_bidi-0.6.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:95867a07c5dee0ea2340fe1d0e4f6d9f5c5687d473193b6ee6f86fa44aac45d1", size = 463753, upload-time = "2025-10-22T09:52:41.943Z" }, + { url = "https://files.pythonhosted.org/packages/54/c3/6a5c3b9f42a6b188430c83a7e70a76bc7c0db3354302fce7c8ed94a0c062/python_bidi-0.6.7-cp312-cp312-win32.whl", hash = "sha256:4c73cd980d45bb967799c7f0fc98ea93ae3d65b21ef2ba6abef6a057720bf483", size = 155820, upload-time = "2025-10-22T09:53:00.254Z" }, + { url = "https://files.pythonhosted.org/packages/45/c4/683216398ee3abf6b9bb0f26ae15c696fabbe36468ba26d5271f0c11b343/python_bidi-0.6.7-cp312-cp312-win_amd64.whl", hash = "sha256:d524a4ba765bae9b950706472a77a887a525ed21144fe4b41f6190f6e57caa2c", size = 159966, upload-time = "2025-10-22T09:52:52.547Z" }, + { url = "https://files.pythonhosted.org/packages/25/a5/8ad0a448d42fd5d01dd127c1dc5ab974a8ea6e20305ac89a3356dacd3bdf/python_bidi-0.6.7-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1c061207212cd1db27bf6140b96dcd0536246f1e13e99bb5d03f4632f8e2ad7f", size = 272129, upload-time = "2025-10-22T09:52:00.761Z" }, + { url = "https://files.pythonhosted.org/packages/e6/c0/a13981fc0427a0d35e96fc4e31fbb0f981b28d0ce08416f98f42d51ea3bc/python_bidi-0.6.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a2eb8fca918c7381531035c3aae31c29a1c1300ab8a63cad1ec3a71331096c78", size = 263174, upload-time = "2025-10-22T09:51:51.401Z" }, + { url = "https://files.pythonhosted.org/packages/9c/32/74034239d0bca32c315cac5c3ec07ef8eb44fa0e8cea1585cad85f5b8651/python_bidi-0.6.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:414004fe9cba33d288ff4a04e1c9afe6a737f440595d01b5bbed00d750296bbd", size = 292496, upload-time = "2025-10-22T09:51:00.708Z" }, + { url = "https://files.pythonhosted.org/packages/83/fa/d6c853ed2668b1c12d66e71d4f843d0710d1ccaecc17ce09b35d2b1382a7/python_bidi-0.6.7-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5013ba963e9da606c4c03958cc737ebd5f8b9b8404bd71ab0d580048c746f875", size = 300727, upload-time = "2025-10-22T09:51:09.152Z" }, + { url = "https://files.pythonhosted.org/packages/9c/8d/55685bddfc1fbfa6e28e1c0be7df4023e504de7d2ac1355a3fa610836bc1/python_bidi-0.6.7-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad5f0847da00687f52d2b81828e8d887bdea9eb8686a9841024ea7a0e153028e", size = 438823, upload-time = "2025-10-22T09:51:17.844Z" }, + { url = "https://files.pythonhosted.org/packages/9f/54/db9e70443f89e3ec6fa70dcd16809c3656d1efe7946076dcd59832f722df/python_bidi-0.6.7-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26a8fe0d532b966708fc5f8aea0602107fde4745a8a5ae961edd3cf02e807d07", size = 325721, upload-time = "2025-10-22T09:51:26.132Z" }, + { url = "https://files.pythonhosted.org/packages/55/c5/98ac9c00f17240f9114c756791f0cd9ba59a5d4b5d84fd1a6d0d50604e82/python_bidi-0.6.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6323e943c7672b271ad9575a2232508f17e87e81a78d7d10d6e93040e210eddf", size = 300493, upload-time = "2025-10-22T09:51:43.783Z" }, + { url = "https://files.pythonhosted.org/packages/0b/cb/382538dd7c656eb50408802b9a9466dbd3432bea059410e65a6c14bc79f9/python_bidi-0.6.7-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:349b89c3110bd25aa56d79418239ca4785d4bcc7a596e63bb996a9696fc6a907", size = 312889, upload-time = "2025-10-22T09:51:36.011Z" }, + { url = "https://files.pythonhosted.org/packages/50/8d/dbc784cecd9b2950ba99c8fef0387ae588837e4e2bfd543be191d18bf9f6/python_bidi-0.6.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e7cad66317f12f0fd755fe41ee7c6b06531d2189a9048a8f37addb5109f7e3e3", size = 472798, upload-time = "2025-10-22T09:52:10.446Z" }, + { url = "https://files.pythonhosted.org/packages/83/e6/398d59075265717d2950622ede1d366aff88ffcaa67a30b85709dea72206/python_bidi-0.6.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49639743f1230648fd4fb47547f8a48ada9c5ca1426b17ac08e3be607c65394c", size = 564974, upload-time = "2025-10-22T09:52:22.416Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8e/2b939be0651bc2b69c234dc700723a26b93611d5bdd06b253d67d9da3557/python_bidi-0.6.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4636d572b357ab9f313c5340915c1cf51e3e54dd069351e02b6b76577fd1a854", size = 491711, upload-time = "2025-10-22T09:52:32.322Z" }, + { url = "https://files.pythonhosted.org/packages/8f/05/f53739ab2ce2eee0c855479a31b64933f6ff6164f3ddc611d04e4b79d922/python_bidi-0.6.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d7310312a68fdb1a8249cf114acb5435aa6b6a958b15810f053c1df5f98476e4", size = 463536, upload-time = "2025-10-22T09:52:43.142Z" }, + { url = "https://files.pythonhosted.org/packages/77/c6/800899e2764f723c2ea9172eabcc1a31ffb8b4bb71ea5869158fd83bd437/python_bidi-0.6.7-cp313-cp313-win32.whl", hash = "sha256:ec985386bc3cd54155f2ef0434fccbfd743617ed6fc1a84dae2ab1de6062e0c6", size = 155786, upload-time = "2025-10-22T09:53:01.357Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/a811c12c1a4b8fa7c0c0963d92c042284c2049b1586615af6b1774b786d9/python_bidi-0.6.7-cp313-cp313-win_amd64.whl", hash = "sha256:f57726b5a90d818625e6996f5116971b7a4ceb888832337d0e2cf43d1c362a90", size = 159863, upload-time = "2025-10-22T09:52:53.537Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/cda302126e878be162bf183eb0bd6dc47ca3e680fb52111e49c62a8ea1eb/python_bidi-0.6.7-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b0bee27fb596a0f518369c275a965d0448c39a0730e53a030b311bb10562d4d5", size = 271899, upload-time = "2025-10-22T09:52:01.758Z" }, + { url = "https://files.pythonhosted.org/packages/4d/4b/9c15ca0fe795a5c55a39daa391524ac74e26d9187493632d455257771023/python_bidi-0.6.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c19ab378fefb1f09623f583fcfa12ed42369a998ddfbd39c40908397243c56b", size = 262235, upload-time = "2025-10-22T09:51:52.379Z" }, + { url = "https://files.pythonhosted.org/packages/0f/5e/25b25be64bff05272aa28d8bef2fbbad8415db3159a41703eb2e63dc9824/python_bidi-0.6.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:630cee960ba9e3016f95a8e6f725a621ddeff6fd287839f5693ccfab3f3a9b5c", size = 471983, upload-time = "2025-10-22T09:52:12.182Z" }, + { url = "https://files.pythonhosted.org/packages/4d/78/a9363f5da1b10d9211514b96ea47ecc95c797ed5ac566684bfece0666082/python_bidi-0.6.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:0dbb4bbae212cca5bcf6e522fe8f572aff7d62544557734c2f810ded844d9eea", size = 565016, upload-time = "2025-10-22T09:52:23.515Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ed/37dcb7d3dc250ecdff8120b026c37fcdbeada4111e4d7148c053180bcf54/python_bidi-0.6.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:1dd0a5ec0d8710905cebb4c9e5018aa8464395a33cb32a3a6c2a951bf1984fe5", size = 491180, upload-time = "2025-10-22T09:52:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/40/a3/50d1f6060a7a500768768f5f8735cb68deba36391248dbf13d5d2c9c0885/python_bidi-0.6.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4ea928c31c7364098f853f122868f6f2155d6840661f7ea8b2ccfdf6084eb9f4", size = 463126, upload-time = "2025-10-22T09:52:44.28Z" }, + { url = "https://files.pythonhosted.org/packages/d2/47/712cd7d1068795c57fdf6c4acca00716688aa8b4e353b30de2ed8f599fd6/python_bidi-0.6.7-cp314-cp314-win32.whl", hash = "sha256:f7c055a50d068b3a924bd33a327646346839f55bcb762a26ec3fde8ea5d40564", size = 155793, upload-time = "2025-10-22T09:53:02.7Z" }, + { url = "https://files.pythonhosted.org/packages/c3/e8/1f86bf699b20220578351f9b7b635ed8b6e84dd51ad3cca08b89513ae971/python_bidi-0.6.7-cp314-cp314-win_amd64.whl", hash = "sha256:8a17631e3e691eec4ae6a370f7b035cf0a5767f4457bd615d11728c23df72e43", size = 159821, upload-time = "2025-10-22T09:52:54.95Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.31" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/64/7e/9b35ad8f3d9ca680f7c87a88f19612fdd8da9796c4d3b46e560ac79dcc4a/python_multipart-0.0.31.tar.gz", hash = "sha256:fc631183bb13e56db3158a4909908dfb2e23565286744e798241e63750e5d680", size = 46689, upload-time = "2026-06-04T08:27:49.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/1e/7f7f299527a5a8ad90acd5f2f78dfa6c8495c6301a3205106ea68a84de96/python_multipart-0.0.31-py3-none-any.whl", hash = "sha256:8408153d68a9773291fc1da39a8b85a50044bddbabd2dd72e9229776b7b15e28", size = 29996, upload-time = "2026-06-04T08:27:47.804Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "scikit-image" +version = "0.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "imageio" }, + { name = "lazy-loader" }, + { name = "networkx" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "scipy" }, + { name = "tifffile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/b4/2528bb43c67d48053a7a649a9666432dc307d66ba02e3a6d5c40f46655df/scikit_image-0.26.0.tar.gz", hash = "sha256:f5f970ab04efad85c24714321fcc91613fcb64ef2a892a13167df2f3e59199fa", size = 22729739, upload-time = "2025-12-20T17:12:21.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/e8/e13757982264b33a1621628f86b587e9a73a13f5256dad49b19ba7dc9083/scikit_image-0.26.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d454b93a6fa770ac5ae2d33570f8e7a321bb80d29511ce4b6b78058ebe176e8c", size = 12376452, upload-time = "2025-12-20T17:10:52.796Z" }, + { url = "https://files.pythonhosted.org/packages/e3/be/f8dd17d0510f9911f9f17ba301f7455328bf13dae416560126d428de9568/scikit_image-0.26.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3409e89d66eff5734cd2b672d1c48d2759360057e714e1d92a11df82c87cba37", size = 12061567, upload-time = "2025-12-20T17:10:55.207Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/c70120a6880579fb42b91567ad79feb4772f7be72e8d52fec403a3dde0c6/scikit_image-0.26.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c717490cec9e276afb0438dd165b7c3072d6c416709cc0f9f5a4c1070d23a44", size = 13084214, upload-time = "2025-12-20T17:10:57.468Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a2/70401a107d6d7466d64b466927e6b96fcefa99d57494b972608e2f8be50f/scikit_image-0.26.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7df650e79031634ac90b11e64a9eedaf5a5e06fcd09bcd03a34be01745744466", size = 13561683, upload-time = "2025-12-20T17:10:59.49Z" }, + { url = "https://files.pythonhosted.org/packages/13/a5/48bdfd92794c5002d664e0910a349d0a1504671ef5ad358150f21643c79a/scikit_image-0.26.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cefd85033e66d4ea35b525bb0937d7f42d4cdcfed2d1888e1570d5ce450d3932", size = 14112147, upload-time = "2025-12-20T17:11:02.083Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b5/ac71694da92f5def5953ca99f18a10fe98eac2dd0a34079389b70b4d0394/scikit_image-0.26.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3f5bf622d7c0435884e1e141ebbe4b2804e16b2dd23ae4c6183e2ea99233be70", size = 14661625, upload-time = "2025-12-20T17:11:04.528Z" }, + { url = "https://files.pythonhosted.org/packages/23/4d/a3cc1e96f080e253dad2251bfae7587cf2b7912bcd76fd43fd366ff35a87/scikit_image-0.26.0-cp312-cp312-win_amd64.whl", hash = "sha256:abed017474593cd3056ae0fe948d07d0747b27a085e92df5474f4955dd65aec0", size = 11911059, upload-time = "2025-12-20T17:11:06.61Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/d1b8055f584acc937478abf4550d122936f420352422a1a625eef2c605d8/scikit_image-0.26.0-cp312-cp312-win_arm64.whl", hash = "sha256:4d57e39ef67a95d26860c8caf9b14b8fb130f83b34c6656a77f191fa6d1d04d8", size = 11348740, upload-time = "2025-12-20T17:11:09.118Z" }, + { url = "https://files.pythonhosted.org/packages/4f/48/02357ffb2cca35640f33f2cfe054a4d6d5d7a229b88880a64f1e45c11f4e/scikit_image-0.26.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a2e852eccf41d2d322b8e60144e124802873a92b8d43a6f96331aa42888491c7", size = 12346329, upload-time = "2025-12-20T17:11:11.599Z" }, + { url = "https://files.pythonhosted.org/packages/67/b9/b792c577cea2c1e94cda83b135a656924fc57c428e8a6d302cd69aac1b60/scikit_image-0.26.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:98329aab3bc87db352b9887f64ce8cdb8e75f7c2daa19927f2e121b797b678d5", size = 12031726, upload-time = "2025-12-20T17:11:13.871Z" }, + { url = "https://files.pythonhosted.org/packages/07/a9/9564250dfd65cb20404a611016db52afc6268b2b371cd19c7538ea47580f/scikit_image-0.26.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:915bb3ba66455cf8adac00dc8fdf18a4cd29656aec7ddd38cb4dda90289a6f21", size = 13094910, upload-time = "2025-12-20T17:11:16.2Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b8/0d8eeb5a9fd7d34ba84f8a55753a0a3e2b5b51b2a5a0ade648a8db4a62f7/scikit_image-0.26.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b36ab5e778bf50af5ff386c3ac508027dc3aaeccf2161bdf96bde6848f44d21b", size = 13660939, upload-time = "2025-12-20T17:11:18.464Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d6/91d8973584d4793d4c1a847d388e34ef1218d835eeddecfc9108d735b467/scikit_image-0.26.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:09bad6a5d5949c7896c8347424c4cca899f1d11668030e5548813ab9c2865dcb", size = 14138938, upload-time = "2025-12-20T17:11:20.919Z" }, + { url = "https://files.pythonhosted.org/packages/39/9a/7e15d8dc10d6bbf212195fb39bdeb7f226c46dd53f9c63c312e111e2e175/scikit_image-0.26.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:aeb14db1ed09ad4bee4ceb9e635547a8d5f3549be67fc6c768c7f923e027e6cd", size = 14752243, upload-time = "2025-12-20T17:11:23.347Z" }, + { url = "https://files.pythonhosted.org/packages/8f/58/2b11b933097bc427e42b4a8b15f7de8f24f2bac1fd2779d2aea1431b2c31/scikit_image-0.26.0-cp313-cp313-win_amd64.whl", hash = "sha256:ac529eb9dbd5954f9aaa2e3fe9a3fd9661bfe24e134c688587d811a0233127f1", size = 11906770, upload-time = "2025-12-20T17:11:25.297Z" }, + { url = "https://files.pythonhosted.org/packages/ad/ec/96941474a18a04b69b6f6562a5bd79bd68049fa3728d3b350976eccb8b93/scikit_image-0.26.0-cp313-cp313-win_arm64.whl", hash = "sha256:a2d211bc355f59725efdcae699b93b30348a19416cc9e017f7b2fb599faf7219", size = 11342506, upload-time = "2025-12-20T17:11:27.399Z" }, + { url = "https://files.pythonhosted.org/packages/03/e5/c1a9962b0cf1952f42d32b4a2e48eed520320dbc4d2ff0b981c6fa508b6b/scikit_image-0.26.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:9eefb4adad066da408a7601c4c24b07af3b472d90e08c3e7483d4e9e829d8c49", size = 12663278, upload-time = "2025-12-20T17:11:29.358Z" }, + { url = "https://files.pythonhosted.org/packages/ae/97/c1a276a59ce8e4e24482d65c1a3940d69c6b3873279193b7ebd04e5ee56b/scikit_image-0.26.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6caec76e16c970c528d15d1c757363334d5cb3069f9cea93d2bead31820511f3", size = 12405142, upload-time = "2025-12-20T17:11:31.282Z" }, + { url = "https://files.pythonhosted.org/packages/d4/4a/f1cbd1357caef6c7993f7efd514d6e53d8fd6f7fe01c4714d51614c53289/scikit_image-0.26.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a07200fe09b9d99fcdab959859fe0f7db8df6333d6204344425d476850ce3604", size = 12942086, upload-time = "2025-12-20T17:11:33.683Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6f/74d9fb87c5655bd64cf00b0c44dc3d6206d9002e5f6ba1c9aeb13236f6bf/scikit_image-0.26.0-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92242351bccf391fc5df2d1529d15470019496d2498d615beb68da85fe7fdf37", size = 13265667, upload-time = "2025-12-20T17:11:36.11Z" }, + { url = "https://files.pythonhosted.org/packages/a7/73/faddc2413ae98d863f6fa2e3e14da4467dd38e788e1c23346cf1a2b06b97/scikit_image-0.26.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:52c496f75a7e45844d951557f13c08c81487c6a1da2e3c9c8a39fcde958e02cc", size = 14001966, upload-time = "2025-12-20T17:11:38.55Z" }, + { url = "https://files.pythonhosted.org/packages/02/94/9f46966fa042b5d57c8cd641045372b4e0df0047dd400e77ea9952674110/scikit_image-0.26.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:20ef4a155e2e78b8ab973998e04d8a361d49d719e65412405f4dadd9155a61d9", size = 14359526, upload-time = "2025-12-20T17:11:41.087Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b4/2840fe38f10057f40b1c9f8fb98a187a370936bf144a4ac23452c5ef1baf/scikit_image-0.26.0-cp313-cp313t-win_amd64.whl", hash = "sha256:c9087cf7d0e7f33ab5c46d2068d86d785e70b05400a891f73a13400f1e1faf6a", size = 12287629, upload-time = "2025-12-20T17:11:43.11Z" }, + { url = "https://files.pythonhosted.org/packages/22/ba/73b6ca70796e71f83ab222690e35a79612f0117e5aaf167151b7d46f5f2c/scikit_image-0.26.0-cp313-cp313t-win_arm64.whl", hash = "sha256:27d58bc8b2acd351f972c6508c1b557cfed80299826080a4d803dd29c51b707e", size = 11647755, upload-time = "2025-12-20T17:11:45.279Z" }, + { url = "https://files.pythonhosted.org/packages/51/44/6b744f92b37ae2833fd423cce8f806d2368859ec325a699dc30389e090b9/scikit_image-0.26.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:63af3d3a26125f796f01052052f86806da5b5e54c6abef152edb752683075a9c", size = 12365810, upload-time = "2025-12-20T17:11:47.357Z" }, + { url = "https://files.pythonhosted.org/packages/40/f5/83590d9355191f86ac663420fec741b82cc547a4afe7c4c1d986bf46e4db/scikit_image-0.26.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ce00600cd70d4562ed59f80523e18cdcc1fae0e10676498a01f73c255774aefd", size = 12075717, upload-time = "2025-12-20T17:11:49.483Z" }, + { url = "https://files.pythonhosted.org/packages/72/48/253e7cf5aee6190459fe136c614e2cbccc562deceb4af96e0863f1b8ee29/scikit_image-0.26.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6381edf972b32e4f54085449afde64365a57316637496c1325a736987083e2ab", size = 13161520, upload-time = "2025-12-20T17:11:51.58Z" }, + { url = "https://files.pythonhosted.org/packages/73/c3/cec6a3cbaadfdcc02bd6ff02f3abfe09eaa7f4d4e0a525a1e3a3f4bce49c/scikit_image-0.26.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6624a76c6085218248154cc7e1500e6b488edcd9499004dd0d35040607d7505", size = 13684340, upload-time = "2025-12-20T17:11:53.708Z" }, + { url = "https://files.pythonhosted.org/packages/d4/0d/39a776f675d24164b3a267aa0db9f677a4cb20127660d8bf4fd7fef66817/scikit_image-0.26.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f775f0e420faac9c2aa6757135f4eb468fb7b70e0b67fa77a5e79be3c30ee331", size = 14203839, upload-time = "2025-12-20T17:11:55.89Z" }, + { url = "https://files.pythonhosted.org/packages/ee/25/2514df226bbcedfe9b2caafa1ba7bc87231a0c339066981b182b08340e06/scikit_image-0.26.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede4d6d255cc5da9faeb2f9ba7fedbc990abbc652db429f40a16b22e770bb578", size = 14770021, upload-time = "2025-12-20T17:11:58.014Z" }, + { url = "https://files.pythonhosted.org/packages/8d/5b/0671dc91c0c79340c3fe202f0549c7d3681eb7640fe34ab68a5f090a7c7f/scikit_image-0.26.0-cp314-cp314-win_amd64.whl", hash = "sha256:0660b83968c15293fd9135e8d860053ee19500d52bf55ca4fb09de595a1af650", size = 12023490, upload-time = "2025-12-20T17:12:00.013Z" }, + { url = "https://files.pythonhosted.org/packages/65/08/7c4cb59f91721f3de07719085212a0b3962e3e3f2d1818cbac4eeb1ea53e/scikit_image-0.26.0-cp314-cp314-win_arm64.whl", hash = "sha256:b8d14d3181c21c11170477a42542c1addc7072a90b986675a71266ad17abc37f", size = 11473782, upload-time = "2025-12-20T17:12:01.983Z" }, + { url = "https://files.pythonhosted.org/packages/49/41/65c4258137acef3d73cb561ac55512eacd7b30bb4f4a11474cad526bc5db/scikit_image-0.26.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:cde0bbd57e6795eba83cb10f71a677f7239271121dc950bc060482834a668ad1", size = 12686060, upload-time = "2025-12-20T17:12:03.886Z" }, + { url = "https://files.pythonhosted.org/packages/e7/32/76971f8727b87f1420a962406388a50e26667c31756126444baf6668f559/scikit_image-0.26.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:163e9afb5b879562b9aeda0dd45208a35316f26cc7a3aed54fd601604e5cf46f", size = 12422628, upload-time = "2025-12-20T17:12:05.921Z" }, + { url = "https://files.pythonhosted.org/packages/37/0d/996febd39f757c40ee7b01cdb861867327e5c8e5f595a634e8201462d958/scikit_image-0.26.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724f79fd9b6cb6f4a37864fe09f81f9f5d5b9646b6868109e1b100d1a7019e59", size = 12962369, upload-time = "2025-12-20T17:12:07.912Z" }, + { url = "https://files.pythonhosted.org/packages/48/b4/612d354f946c9600e7dea012723c11d47e8d455384e530f6daaaeb9bf62c/scikit_image-0.26.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3268f13310e6857508bd87202620df996199a016a1d281b309441d227c822394", size = 13272431, upload-time = "2025-12-20T17:12:10.255Z" }, + { url = "https://files.pythonhosted.org/packages/0a/6e/26c00b466e06055a086de2c6e2145fe189ccdc9a1d11ccc7de020f2591ad/scikit_image-0.26.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fac96a1f9b06cd771cbbb3cd96c5332f36d4efd839b1d8b053f79e5887acde62", size = 14016362, upload-time = "2025-12-20T17:12:12.793Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/00a90402e1775634043c2a0af8a3c76ad450866d9fa444efcc43b553ba2d/scikit_image-0.26.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c1e7bd342f43e7a97e571b3f03ba4c1293ea1a35c3f13f41efdc8a81c1dc8f2", size = 14364151, upload-time = "2025-12-20T17:12:14.909Z" }, + { url = "https://files.pythonhosted.org/packages/da/ca/918d8d306bd43beacff3b835c6d96fac0ae64c0857092f068b88db531a7c/scikit_image-0.26.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b702c3bb115e1dcf4abf5297429b5c90f2189655888cbed14921f3d26f81d3a4", size = 12413484, upload-time = "2025-12-20T17:12:17.046Z" }, + { url = "https://files.pythonhosted.org/packages/dc/cd/4da01329b5a8d47ff7ec3c99a2b02465a8017b186027590dc7425cee0b56/scikit_image-0.26.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0608aa4a9ec39e0843de10d60edb2785a30c1c47819b67866dd223ebd149acaf", size = 11769501, upload-time = "2025-12-20T17:12:19.339Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, + { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, + { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, + { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, + { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, + { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, + { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, + { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, + { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, + { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, + { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, +] + +[[package]] +name = "setuptools" +version = "82.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/f3/748f4d6f65d1756b9ae577f329c951cda23fb900e4de9f70900ced962085/setuptools-82.0.0.tar.gz", hash = "sha256:22e0a2d69474c6ae4feb01951cb69d515ed23728cf96d05513d36e42b62b37cb", size = 1144893, upload-time = "2026-02-08T15:08:40.206Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/c6/76dc613121b793286a3f91621d7b75a2b493e0390ddca50f11993eadf192/setuptools-82.0.0-py3-none-any.whl", hash = "sha256:70b18734b607bd1da571d097d236cfcfacaf01de45717d59e6e04b96877532e0", size = 1003468, upload-time = "2026-02-08T15:08:38.723Z" }, +] + +[[package]] +name = "shapely" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/bc/0989043118a27cccb4e906a46b7565ce36ca7b57f5a18b78f4f1b0f72d9d/shapely-2.1.2.tar.gz", hash = "sha256:2ed4ecb28320a433db18a5bf029986aa8afcfd740745e78847e330d5d94922a9", size = 315489, upload-time = "2025-09-24T13:51:41.432Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/c0/f3b6453cf2dfa99adc0ba6675f9aaff9e526d2224cbd7ff9c1a879238693/shapely-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe2533caae6a91a543dec62e8360fe86ffcdc42a7c55f9dfd0128a977a896b94", size = 1833550, upload-time = "2025-09-24T13:50:30.019Z" }, + { url = "https://files.pythonhosted.org/packages/86/07/59dee0bc4b913b7ab59ab1086225baca5b8f19865e6101db9ebb7243e132/shapely-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ba4d1333cc0bc94381d6d4308d2e4e008e0bd128bdcff5573199742ee3634359", size = 1643556, upload-time = "2025-09-24T13:50:32.291Z" }, + { url = "https://files.pythonhosted.org/packages/26/29/a5397e75b435b9895cd53e165083faed5d12fd9626eadec15a83a2411f0f/shapely-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bd308103340030feef6c111d3eb98d50dc13feea33affc8a6f9fa549e9458a3", size = 2988308, upload-time = "2025-09-24T13:50:33.862Z" }, + { url = "https://files.pythonhosted.org/packages/b9/37/e781683abac55dde9771e086b790e554811a71ed0b2b8a1e789b7430dd44/shapely-2.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e7d4d7ad262a48bb44277ca12c7c78cb1b0f56b32c10734ec9a1d30c0b0c54b", size = 3099844, upload-time = "2025-09-24T13:50:35.459Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f3/9876b64d4a5a321b9dc482c92bb6f061f2fa42131cba643c699f39317cb9/shapely-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e9eddfe513096a71896441a7c37db72da0687b34752c4e193577a145c71736fc", size = 3988842, upload-time = "2025-09-24T13:50:37.478Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a0/704c7292f7014c7e74ec84eddb7b109e1fbae74a16deae9c1504b1d15565/shapely-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:980c777c612514c0cf99bc8a9de6d286f5e186dcaf9091252fcd444e5638193d", size = 4152714, upload-time = "2025-09-24T13:50:39.9Z" }, + { url = "https://files.pythonhosted.org/packages/53/46/319c9dc788884ad0785242543cdffac0e6530e4d0deb6c4862bc4143dcf3/shapely-2.1.2-cp312-cp312-win32.whl", hash = "sha256:9111274b88e4d7b54a95218e243282709b330ef52b7b86bc6aaf4f805306f454", size = 1542745, upload-time = "2025-09-24T13:50:41.414Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bf/cb6c1c505cb31e818e900b9312d514f381fbfa5c4363edfce0fcc4f8c1a4/shapely-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:743044b4cfb34f9a67205cee9279feaf60ba7d02e69febc2afc609047cb49179", size = 1722861, upload-time = "2025-09-24T13:50:43.35Z" }, + { url = "https://files.pythonhosted.org/packages/c3/90/98ef257c23c46425dc4d1d31005ad7c8d649fe423a38b917db02c30f1f5a/shapely-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b510dda1a3672d6879beb319bc7c5fd302c6c354584690973c838f46ec3e0fa8", size = 1832644, upload-time = "2025-09-24T13:50:44.886Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ab/0bee5a830d209adcd3a01f2d4b70e587cdd9fd7380d5198c064091005af8/shapely-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8cff473e81017594d20ec55d86b54bc635544897e13a7cfc12e36909c5309a2a", size = 1642887, upload-time = "2025-09-24T13:50:46.735Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5e/7d7f54ba960c13302584c73704d8c4d15404a51024631adb60b126a4ae88/shapely-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe7b77dc63d707c09726b7908f575fc04ff1d1ad0f3fb92aec212396bc6cfe5e", size = 2970931, upload-time = "2025-09-24T13:50:48.374Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a2/83fc37e2a58090e3d2ff79175a95493c664bcd0b653dd75cb9134645a4e5/shapely-2.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ed1a5bbfb386ee8332713bf7508bc24e32d24b74fc9a7b9f8529a55db9f4ee6", size = 3082855, upload-time = "2025-09-24T13:50:50.037Z" }, + { url = "https://files.pythonhosted.org/packages/44/2b/578faf235a5b09f16b5f02833c53822294d7f21b242f8e2d0cf03fb64321/shapely-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a84e0582858d841d54355246ddfcbd1fce3179f185da7470f41ce39d001ee1af", size = 3979960, upload-time = "2025-09-24T13:50:51.74Z" }, + { url = "https://files.pythonhosted.org/packages/4d/04/167f096386120f692cc4ca02f75a17b961858997a95e67a3cb6a7bbd6b53/shapely-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc3487447a43d42adcdf52d7ac73804f2312cbfa5d433a7d2c506dcab0033dfd", size = 4142851, upload-time = "2025-09-24T13:50:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/48/74/fb402c5a6235d1c65a97348b48cdedb75fb19eca2b1d66d04969fc1c6091/shapely-2.1.2-cp313-cp313-win32.whl", hash = "sha256:9c3a3c648aedc9f99c09263b39f2d8252f199cb3ac154fadc173283d7d111350", size = 1541890, upload-time = "2025-09-24T13:50:55.337Z" }, + { url = "https://files.pythonhosted.org/packages/41/47/3647fe7ad990af60ad98b889657a976042c9988c2807cf322a9d6685f462/shapely-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:ca2591bff6645c216695bdf1614fca9c82ea1144d4a7591a466fef64f28f0715", size = 1722151, upload-time = "2025-09-24T13:50:57.153Z" }, + { url = "https://files.pythonhosted.org/packages/3c/49/63953754faa51ffe7d8189bfbe9ca34def29f8c0e34c67cbe2a2795f269d/shapely-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2d93d23bdd2ed9dc157b46bc2f19b7da143ca8714464249bef6771c679d5ff40", size = 1834130, upload-time = "2025-09-24T13:50:58.49Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ee/dce001c1984052970ff60eb4727164892fb2d08052c575042a47f5a9e88f/shapely-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01d0d304b25634d60bd7cf291828119ab55a3bab87dc4af1e44b07fb225f188b", size = 1642802, upload-time = "2025-09-24T13:50:59.871Z" }, + { url = "https://files.pythonhosted.org/packages/da/e7/fc4e9a19929522877fa602f705706b96e78376afb7fad09cad5b9af1553c/shapely-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8d8382dd120d64b03698b7298b89611a6ea6f55ada9d39942838b79c9bc89801", size = 3018460, upload-time = "2025-09-24T13:51:02.08Z" }, + { url = "https://files.pythonhosted.org/packages/a1/18/7519a25db21847b525696883ddc8e6a0ecaa36159ea88e0fef11466384d0/shapely-2.1.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:19efa3611eef966e776183e338b2d7ea43569ae99ab34f8d17c2c054d3205cc0", size = 3095223, upload-time = "2025-09-24T13:51:04.472Z" }, + { url = "https://files.pythonhosted.org/packages/48/de/b59a620b1f3a129c3fecc2737104a0a7e04e79335bd3b0a1f1609744cf17/shapely-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:346ec0c1a0fcd32f57f00e4134d1200e14bf3f5ae12af87ba83ca275c502498c", size = 4030760, upload-time = "2025-09-24T13:51:06.455Z" }, + { url = "https://files.pythonhosted.org/packages/96/b3/c6655ee7232b417562bae192ae0d3ceaadb1cc0ffc2088a2ddf415456cc2/shapely-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6305993a35989391bd3476ee538a5c9a845861462327efe00dd11a5c8c709a99", size = 4170078, upload-time = "2025-09-24T13:51:08.584Z" }, + { url = "https://files.pythonhosted.org/packages/a0/8e/605c76808d73503c9333af8f6cbe7e1354d2d238bda5f88eea36bfe0f42a/shapely-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:c8876673449f3401f278c86eb33224c5764582f72b653a415d0e6672fde887bf", size = 1559178, upload-time = "2025-09-24T13:51:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/36/f7/d317eb232352a1f1444d11002d477e54514a4a6045536d49d0c59783c0da/shapely-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:4a44bc62a10d84c11a7a3d7c1c4fe857f7477c3506e24c9062da0db0ae0c449c", size = 1739756, upload-time = "2025-09-24T13:51:12.105Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c4/3ce4c2d9b6aabd27d26ec988f08cb877ba9e6e96086eff81bfea93e688c7/shapely-2.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:9a522f460d28e2bf4e12396240a5fc1518788b2fcd73535166d748399ef0c223", size = 1831290, upload-time = "2025-09-24T13:51:13.56Z" }, + { url = "https://files.pythonhosted.org/packages/17/b9/f6ab8918fc15429f79cb04afa9f9913546212d7fb5e5196132a2af46676b/shapely-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ff629e00818033b8d71139565527ced7d776c269a49bd78c9df84e8f852190c", size = 1641463, upload-time = "2025-09-24T13:51:14.972Z" }, + { url = "https://files.pythonhosted.org/packages/a5/57/91d59ae525ca641e7ac5551c04c9503aee6f29b92b392f31790fcb1a4358/shapely-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f67b34271dedc3c653eba4e3d7111aa421d5be9b4c4c7d38d30907f796cb30df", size = 2970145, upload-time = "2025-09-24T13:51:16.961Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cb/4948be52ee1da6927831ab59e10d4c29baa2a714f599f1f0d1bc747f5777/shapely-2.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:21952dc00df38a2c28375659b07a3979d22641aeb104751e769c3ee825aadecf", size = 3073806, upload-time = "2025-09-24T13:51:18.712Z" }, + { url = "https://files.pythonhosted.org/packages/03/83/f768a54af775eb41ef2e7bec8a0a0dbe7d2431c3e78c0a8bdba7ab17e446/shapely-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1f2f33f486777456586948e333a56ae21f35ae273be99255a191f5c1fa302eb4", size = 3980803, upload-time = "2025-09-24T13:51:20.37Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/559c7c195807c91c79d38a1f6901384a2878a76fbdf3f1048893a9b7534d/shapely-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cf831a13e0d5a7eb519e96f58ec26e049b1fad411fc6fc23b162a7ce04d9cffc", size = 4133301, upload-time = "2025-09-24T13:51:21.887Z" }, + { url = "https://files.pythonhosted.org/packages/80/cd/60d5ae203241c53ef3abd2ef27c6800e21afd6c94e39db5315ea0cbafb4a/shapely-2.1.2-cp314-cp314-win32.whl", hash = "sha256:61edcd8d0d17dd99075d320a1dd39c0cb9616f7572f10ef91b4b5b00c4aeb566", size = 1583247, upload-time = "2025-09-24T13:51:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/74/d4/135684f342e909330e50d31d441ace06bf83c7dc0777e11043f99167b123/shapely-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:a444e7afccdb0999e203b976adb37ea633725333e5b119ad40b1ca291ecf311c", size = 1773019, upload-time = "2025-09-24T13:51:24.873Z" }, + { url = "https://files.pythonhosted.org/packages/a3/05/a44f3f9f695fa3ada22786dc9da33c933da1cbc4bfe876fe3a100bafe263/shapely-2.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:5ebe3f84c6112ad3d4632b1fd2290665aa75d4cef5f6c5d77c4c95b324527c6a", size = 1834137, upload-time = "2025-09-24T13:51:26.665Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/4d57db45bf314573427b0a70dfca15d912d108e6023f623947fa69f39b72/shapely-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5860eb9f00a1d49ebb14e881f5caf6c2cf472c7fd38bd7f253bbd34f934eb076", size = 1642884, upload-time = "2025-09-24T13:51:28.029Z" }, + { url = "https://files.pythonhosted.org/packages/5a/27/4e29c0a55d6d14ad7422bf86995d7ff3f54af0eba59617eb95caf84b9680/shapely-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b705c99c76695702656327b819c9660768ec33f5ce01fa32b2af62b56ba400a1", size = 3018320, upload-time = "2025-09-24T13:51:29.903Z" }, + { url = "https://files.pythonhosted.org/packages/9f/bb/992e6a3c463f4d29d4cd6ab8963b75b1b1040199edbd72beada4af46bde5/shapely-2.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a1fd0ea855b2cf7c9cddaf25543e914dd75af9de08785f20ca3085f2c9ca60b0", size = 3094931, upload-time = "2025-09-24T13:51:32.699Z" }, + { url = "https://files.pythonhosted.org/packages/9c/16/82e65e21070e473f0ed6451224ed9fa0be85033d17e0c6e7213a12f59d12/shapely-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:df90e2db118c3671a0754f38e36802db75fe0920d211a27481daf50a711fdf26", size = 4030406, upload-time = "2025-09-24T13:51:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/7c/75/c24ed871c576d7e2b64b04b1fe3d075157f6eb54e59670d3f5ffb36e25c7/shapely-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:361b6d45030b4ac64ddd0a26046906c8202eb60d0f9f53085f5179f1d23021a0", size = 4169511, upload-time = "2025-09-24T13:51:36.297Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f7/b3d1d6d18ebf55236eec1c681ce5e665742aab3c0b7b232720a7d43df7b6/shapely-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:b54df60f1fbdecc8ebc2c5b11870461a6417b3d617f555e5033f1505d36e5735", size = 1602607, upload-time = "2025-09-24T13:51:37.757Z" }, + { url = "https://files.pythonhosted.org/packages/9a/f6/f09272a71976dfc138129b8faf435d064a811ae2f708cb147dccdf7aacdb/shapely-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:0036ac886e0923417932c2e6369b6c52e38e0ff5d9120b90eef5cd9a5fc5cae9", size = 1796682, upload-time = "2025-09-24T13:51:39.233Z" }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, +] + +[[package]] +name = "tifffile" +version = "2026.2.20" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/80/0ddd8dc74c22e1e5efcfb152303b025f8f4a5010ae9936f1e57f7d7f9256/tifffile-2026.2.20.tar.gz", hash = "sha256:b98a7fc6ea4fa0e9919734857eebc6e2cb2c3a95468a930d4a948a9a49646ab7", size = 377196, upload-time = "2026-02-20T20:09:34.608Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/07/0cd5cad2fdb7d32515561bc26da041654f3b3c0abc299f4730f30b89271d/tifffile-2026.2.20-py3-none-any.whl", hash = "sha256:a83e0e991647e39d5912369998ef02d858f89effe30064403a1a123b5daef8fb", size = 234528, upload-time = "2026-02-20T20:09:33.278Z" }, +] + +[[package]] +name = "torch" +version = "2.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cuda-bindings", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "jinja2" }, + { name = "networkx" }, + { name = "nvidia-cublas-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cuda-cupti-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cuda-nvrtc-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cuda-runtime-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cudnn-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cufft-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cufile-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-curand-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cusolver-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cusparse-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cusparselt-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-nccl-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-nvjitlink-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-nvshmem-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-nvtx-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "setuptools" }, + { name = "sympy" }, + { name = "triton", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/54/a2ba279afcca44bbd320d4e73675b282fcee3d81400ea1b53934efca6462/torch-2.10.0-2-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:13ec4add8c3faaed8d13e0574f5cd4a323c11655546f91fbe6afa77b57423574", size = 79498202, upload-time = "2026-02-10T21:44:52.603Z" }, + { url = "https://files.pythonhosted.org/packages/ec/23/2c9fe0c9c27f7f6cb865abcea8a4568f29f00acaeadfc6a37f6801f84cb4/torch-2.10.0-2-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:e521c9f030a3774ed770a9c011751fb47c4d12029a3d6522116e48431f2ff89e", size = 79498254, upload-time = "2026-02-10T21:44:44.095Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7a/abada41517ce0011775f0f4eacc79659bc9bc6c361e6bfe6f7052a6b9363/torch-2.10.0-3-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:98c01b8bb5e3240426dcde1446eed6f40c778091c8544767ef1168fc663a05a6", size = 915622781, upload-time = "2026-03-11T14:17:11.354Z" }, + { url = "https://files.pythonhosted.org/packages/ab/c6/4dfe238342ffdcec5aef1c96c457548762d33c40b45a1ab7033bb26d2ff2/torch-2.10.0-3-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:80b1b5bfe38eb0e9f5ff09f206dcac0a87aadd084230d4a36eea5ec5232c115b", size = 915627275, upload-time = "2026-03-11T14:16:11.325Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f0/72bf18847f58f877a6a8acf60614b14935e2f156d942483af1ffc081aea0/torch-2.10.0-3-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:46b3574d93a2a8134b3f5475cfb98e2eb46771794c57015f6ad1fb795ec25e49", size = 915523474, upload-time = "2026-03-11T14:17:44.422Z" }, + { url = "https://files.pythonhosted.org/packages/f4/39/590742415c3030551944edc2ddc273ea1fdfe8ffb2780992e824f1ebee98/torch-2.10.0-3-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:b1d5e2aba4eb7f8e87fbe04f86442887f9167a35f092afe4c237dfcaaef6e328", size = 915632474, upload-time = "2026-03-11T14:15:13.666Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8e/34949484f764dde5b222b7fe3fede43e4a6f0da9d7f8c370bb617d629ee2/torch-2.10.0-3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:0228d20b06701c05a8f978357f657817a4a63984b0c90745def81c18aedfa591", size = 915523882, upload-time = "2026-03-11T14:14:46.311Z" }, + { url = "https://files.pythonhosted.org/packages/cc/af/758e242e9102e9988969b5e621d41f36b8f258bb4a099109b7a4b4b50ea4/torch-2.10.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:5fd4117d89ffd47e3dcc71e71a22efac24828ad781c7e46aaaf56bf7f2796acf", size = 145996088, upload-time = "2026-01-21T16:24:44.171Z" }, + { url = "https://files.pythonhosted.org/packages/23/8e/3c74db5e53bff7ed9e34c8123e6a8bfef718b2450c35eefab85bb4a7e270/torch-2.10.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:787124e7db3b379d4f1ed54dd12ae7c741c16a4d29b49c0226a89bea50923ffb", size = 915711952, upload-time = "2026-01-21T16:23:53.503Z" }, + { url = "https://files.pythonhosted.org/packages/6e/01/624c4324ca01f66ae4c7cd1b74eb16fb52596dce66dbe51eff95ef9e7a4c/torch-2.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:2c66c61f44c5f903046cc696d088e21062644cbe541c7f1c4eaae88b2ad23547", size = 113757972, upload-time = "2026-01-21T16:24:39.516Z" }, + { url = "https://files.pythonhosted.org/packages/c9/5c/dee910b87c4d5c0fcb41b50839ae04df87c1cfc663cf1b5fca7ea565eeaa/torch-2.10.0-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:6d3707a61863d1c4d6ebba7be4ca320f42b869ee657e9b2c21c736bf17000294", size = 79498198, upload-time = "2026-01-21T16:24:34.704Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6f/f2e91e34e3fcba2e3fc8d8f74e7d6c22e74e480bbd1db7bc8900fdf3e95c/torch-2.10.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:5c4d217b14741e40776dd7074d9006fd28b8a97ef5654db959d8635b2fe5f29b", size = 146004247, upload-time = "2026-01-21T16:24:29.335Z" }, + { url = "https://files.pythonhosted.org/packages/98/fb/5160261aeb5e1ee12ee95fe599d0541f7c976c3701d607d8fc29e623229f/torch-2.10.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6b71486353fce0f9714ca0c9ef1c850a2ae766b409808acd58e9678a3edb7738", size = 915716445, upload-time = "2026-01-21T16:22:45.353Z" }, + { url = "https://files.pythonhosted.org/packages/6a/16/502fb1b41e6d868e8deb5b0e3ae926bbb36dab8ceb0d1b769b266ad7b0c3/torch-2.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:c2ee399c644dc92ef7bc0d4f7e74b5360c37cdbe7c5ba11318dda49ffac2bc57", size = 113757050, upload-time = "2026-01-21T16:24:19.204Z" }, + { url = "https://files.pythonhosted.org/packages/1a/0b/39929b148f4824bc3ad6f9f72a29d4ad865bcf7ebfc2fa67584773e083d2/torch-2.10.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:3202429f58309b9fa96a614885eace4b7995729f44beb54d3e4a47773649d382", size = 79851305, upload-time = "2026-01-21T16:24:09.209Z" }, + { url = "https://files.pythonhosted.org/packages/d8/14/21fbce63bc452381ba5f74a2c0a959fdf5ad5803ccc0c654e752e0dbe91a/torch-2.10.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:aae1b29cd68e50a9397f5ee897b9c24742e9e306f88a807a27d617f07adb3bd8", size = 146005472, upload-time = "2026-01-21T16:22:29.022Z" }, + { url = "https://files.pythonhosted.org/packages/54/fd/b207d1c525cb570ef47f3e9f836b154685011fce11a2f444ba8a4084d042/torch-2.10.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:6021db85958db2f07ec94e1bc77212721ba4920c12a18dc552d2ae36a3eb163f", size = 915612644, upload-time = "2026-01-21T16:21:47.019Z" }, + { url = "https://files.pythonhosted.org/packages/36/53/0197f868c75f1050b199fe58f9bf3bf3aecac9b4e85cc9c964383d745403/torch-2.10.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff43db38af76fda183156153983c9a096fc4c78d0cd1e07b14a2314c7f01c2c8", size = 113997015, upload-time = "2026-01-21T16:23:00.767Z" }, + { url = "https://files.pythonhosted.org/packages/0e/13/e76b4d9c160e89fff48bf16b449ea324bda84745d2ab30294c37c2434c0d/torch-2.10.0-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:cdf2a523d699b70d613243211ecaac14fe9c5df8a0b0a9c02add60fb2a413e0f", size = 79498248, upload-time = "2026-01-21T16:23:09.315Z" }, + { url = "https://files.pythonhosted.org/packages/4f/93/716b5ac0155f1be70ed81bacc21269c3ece8dba0c249b9994094110bfc51/torch-2.10.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:bf0d9ff448b0218e0433aeb198805192346c4fd659c852370d5cc245f602a06a", size = 79464992, upload-time = "2026-01-21T16:23:05.162Z" }, + { url = "https://files.pythonhosted.org/packages/69/2b/51e663ff190c9d16d4a8271203b71bc73a16aa7619b9f271a69b9d4a936b/torch-2.10.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:233aed0659a2503b831d8a67e9da66a62c996204c0bba4f4c442ccc0c68a3f60", size = 146018567, upload-time = "2026-01-21T16:22:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/5e/cd/4b95ef7f293b927c283db0b136c42be91c8ec6845c44de0238c8c23bdc80/torch-2.10.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:682497e16bdfa6efeec8cde66531bc8d1fbbbb4d8788ec6173c089ed3cc2bfe5", size = 915721646, upload-time = "2026-01-21T16:21:16.983Z" }, + { url = "https://files.pythonhosted.org/packages/56/97/078a007208f8056d88ae43198833469e61a0a355abc0b070edd2c085eb9a/torch-2.10.0-cp314-cp314-win_amd64.whl", hash = "sha256:6528f13d2a8593a1a412ea07a99812495bec07e9224c28b2a25c0a30c7da025c", size = 113752373, upload-time = "2026-01-21T16:22:13.471Z" }, + { url = "https://files.pythonhosted.org/packages/d8/94/71994e7d0d5238393df9732fdab607e37e2b56d26a746cb59fdb415f8966/torch-2.10.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f5ab4ba32383061be0fb74bda772d470140a12c1c3b58a0cfbf3dae94d164c28", size = 79850324, upload-time = "2026-01-21T16:22:09.494Z" }, + { url = "https://files.pythonhosted.org/packages/e2/65/1a05346b418ea8ccd10360eef4b3e0ce688fba544e76edec26913a8d0ee0/torch-2.10.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:716b01a176c2a5659c98f6b01bf868244abdd896526f1c692712ab36dbaf9b63", size = 146006482, upload-time = "2026-01-21T16:22:18.42Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b9/5f6f9d9e859fc3235f60578fa64f52c9c6e9b4327f0fe0defb6de5c0de31/torch-2.10.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d8f5912ba938233f86361e891789595ff35ca4b4e2ac8fe3670895e5976731d6", size = 915613050, upload-time = "2026-01-21T16:20:49.035Z" }, + { url = "https://files.pythonhosted.org/packages/66/4d/35352043ee0eaffdeff154fad67cd4a31dbed7ff8e3be1cc4549717d6d51/torch-2.10.0-cp314-cp314t-win_amd64.whl", hash = "sha256:71283a373f0ee2c89e0f0d5f446039bdabe8dbc3c9ccf35f0f784908b0acd185", size = 113995816, upload-time = "2026-01-21T16:22:05.312Z" }, +] + +[[package]] +name = "torchvision" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "pillow" }, + { name = "torch" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/3a/6ea0d73f49a9bef38a1b3a92e8dd455cea58470985d25635beab93841748/torchvision-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c2abe430c90b1d5e552680037d68da4eb80a5852ebb1c811b2b89d299b10573b", size = 1874920, upload-time = "2026-01-21T16:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/51/f8/c0e1ef27c66e15406fece94930e7d6feee4cb6374bbc02d945a630d6426e/torchvision-0.25.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:b75deafa2dfea3e2c2a525559b04783515e3463f6e830cb71de0fb7ea36fe233", size = 2344556, upload-time = "2026-01-21T16:27:40.125Z" }, + { url = "https://files.pythonhosted.org/packages/68/2f/f24b039169db474e8688f649377de082a965fbf85daf4e46c44412f1d15a/torchvision-0.25.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:f25aa9e380865b11ea6e9d99d84df86b9cc959f1a007cd966fc6f1ab2ed0e248", size = 8072351, upload-time = "2026-01-21T16:27:21.074Z" }, + { url = "https://files.pythonhosted.org/packages/ad/16/8f650c2e288977cf0f8f85184b90ee56ed170a4919347fc74ee99286ed6f/torchvision-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:f9c55ae8d673ab493325d1267cbd285bb94d56f99626c00ac4644de32a59ede3", size = 4303059, upload-time = "2026-01-21T16:27:11.08Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5b/1562a04a6a5a4cf8cf40016a0cdeda91ede75d6962cff7f809a85ae966a5/torchvision-0.25.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:24e11199e4d84ba9c5ee7825ebdf1cd37ce8deec225117f10243cae984ced3ec", size = 1874918, upload-time = "2026-01-21T16:27:39.02Z" }, + { url = "https://files.pythonhosted.org/packages/36/b1/3d6c42f62c272ce34fcce609bb8939bdf873dab5f1b798fd4e880255f129/torchvision-0.25.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:5f271136d2d2c0b7a24c5671795c6e4fd8da4e0ea98aeb1041f62bc04c4370ef", size = 2309106, upload-time = "2026-01-21T16:27:30.624Z" }, + { url = "https://files.pythonhosted.org/packages/c7/60/59bb9c8b67cce356daeed4cb96a717caa4f69c9822f72e223a0eae7a9bd9/torchvision-0.25.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:855c0dc6d37f462482da7531c6788518baedca1e0847f3df42a911713acdfe52", size = 8071522, upload-time = "2026-01-21T16:27:29.392Z" }, + { url = "https://files.pythonhosted.org/packages/32/a5/9a9b1de0720f884ea50dbf9acb22cbe5312e51d7b8c4ac6ba9b51efd9bba/torchvision-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:cef0196be31be421f6f462d1e9da1101be7332d91984caa6f8022e6c78a5877f", size = 4321911, upload-time = "2026-01-21T16:27:35.195Z" }, + { url = "https://files.pythonhosted.org/packages/52/99/dca81ed21ebaeff2b67cc9f815a20fdaa418b69f5f9ea4c6ed71721470db/torchvision-0.25.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a8f8061284395ce31bcd460f2169013382ccf411148ceb2ee38e718e9860f5a7", size = 1896209, upload-time = "2026-01-21T16:27:32.159Z" }, + { url = "https://files.pythonhosted.org/packages/28/cc/2103149761fdb4eaed58a53e8437b2d716d48f05174fab1d9fcf1e2a2244/torchvision-0.25.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:146d02c9876858420adf41f3189fe90e3d6a409cbfa65454c09f25fb33bf7266", size = 2310735, upload-time = "2026-01-21T16:27:22.327Z" }, + { url = "https://files.pythonhosted.org/packages/76/ad/f4c985ad52ddd3b22711c588501be1b330adaeaf6850317f66751711b78c/torchvision-0.25.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:c4d395cb2c4a2712f6eb93a34476cdf7aae74bb6ea2ea1917f858e96344b00aa", size = 8089557, upload-time = "2026-01-21T16:27:27.666Z" }, + { url = "https://files.pythonhosted.org/packages/63/cc/0ea68b5802e5e3c31f44b307e74947bad5a38cc655231d845534ed50ddb8/torchvision-0.25.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5e6b449e9fa7d642142c0e27c41e5a43b508d57ed8e79b7c0a0c28652da8678c", size = 4344260, upload-time = "2026-01-21T16:27:17.018Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1f/fa839532660e2602b7e704d65010787c5bb296258b44fa8b9c1cd6175e7d/torchvision-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:620a236288d594dcec7634c754484542dc0a5c1b0e0b83a34bda5e91e9b7c3a1", size = 1896193, upload-time = "2026-01-21T16:27:24.785Z" }, + { url = "https://files.pythonhosted.org/packages/80/ed/d51889da7ceaf5ff7a0574fb28f9b6b223df19667265395891f81b364ab3/torchvision-0.25.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:0b5e7f50002a8145a98c5694a018e738c50e2972608310c7e88e1bd4c058f6ce", size = 2309331, upload-time = "2026-01-21T16:27:19.97Z" }, + { url = "https://files.pythonhosted.org/packages/90/a5/f93fcffaddd8f12f9e812256830ec9c9ca65abbf1bc369379f9c364d1ff4/torchvision-0.25.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:632db02300e83793812eee4f61ae6a2686dab10b4cfd628b620dc47747aa9d03", size = 8088713, upload-time = "2026-01-21T16:27:15.281Z" }, + { url = "https://files.pythonhosted.org/packages/1f/eb/d0096eed5690d962853213f2ee00d91478dfcb586b62dbbb449fb8abc3a6/torchvision-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:d1abd5ed030c708f5dbf4812ad5f6fbe9384b63c40d6bd79f8df41a4a759a917", size = 4325058, upload-time = "2026-01-21T16:27:26.165Z" }, + { url = "https://files.pythonhosted.org/packages/97/36/96374a4c7ab50dea9787ce987815614ccfe988a42e10ac1a2e3e5b60319a/torchvision-0.25.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad9a8a5877782944d99186e4502a614770fe906626d76e9cd32446a0ac3075f2", size = 1896207, upload-time = "2026-01-21T16:27:23.383Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e2/7abb10a867db79b226b41da419b63b69c0bd5b82438c4a4ed50e084c552f/torchvision-0.25.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:40a122c3cf4d14b651f095e0f672b688dde78632783fc5cd3d4d5e4f6a828563", size = 2310741, upload-time = "2026-01-21T16:27:18.712Z" }, + { url = "https://files.pythonhosted.org/packages/08/e6/0927784e6ffc340b6676befde1c60260bd51641c9c574b9298d791a9cda4/torchvision-0.25.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:846890161b825b38aa85fc37fb3ba5eea74e7091ff28bab378287111483b6443", size = 8089772, upload-time = "2026-01-21T16:27:14.048Z" }, + { url = "https://files.pythonhosted.org/packages/b6/37/e7ca4ec820d434c0f23f824eb29f0676a0c3e7a118f1514f5b949c3356da/torchvision-0.25.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f07f01d27375ad89d72aa2b3f2180f07da95dd9d2e4c758e015c0acb2da72977", size = 4425879, upload-time = "2026-01-21T16:27:12.579Z" }, +] + +[[package]] +name = "triton" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/a8/cdf8b3e4c98132f965f88c2313a4b493266832ad47fb52f23d14d4f86bb5/triton-3.6.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74caf5e34b66d9f3a429af689c1c7128daba1d8208df60e81106b115c00d6fca", size = 188266850, upload-time = "2026-01-20T16:00:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/f9/0b/37d991d8c130ce81a8728ae3c25b6e60935838e9be1b58791f5997b24a54/triton-3.6.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10c7f76c6e72d2ef08df639e3d0d30729112f47a56b0c81672edc05ee5116ac9", size = 188289450, upload-time = "2026-01-20T16:00:49.136Z" }, + { url = "https://files.pythonhosted.org/packages/35/f8/9c66bfc55361ec6d0e4040a0337fb5924ceb23de4648b8a81ae9d33b2b38/triton-3.6.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d002e07d7180fd65e622134fbd980c9a3d4211fb85224b56a0a0efbd422ab72f", size = 188400296, upload-time = "2026-01-20T16:00:56.042Z" }, + { url = "https://files.pythonhosted.org/packages/df/3d/9e7eee57b37c80cec63322c0231bb6da3cfe535a91d7a4d64896fcb89357/triton-3.6.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a17a5d5985f0ac494ed8a8e54568f092f7057ef60e1b0fa09d3fd1512064e803", size = 188273063, upload-time = "2026-01-20T16:01:07.278Z" }, + { url = "https://files.pythonhosted.org/packages/f6/56/6113c23ff46c00aae423333eb58b3e60bdfe9179d542781955a5e1514cb3/triton-3.6.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:46bd1c1af4b6704e554cad2eeb3b0a6513a980d470ccfa63189737340c7746a7", size = 188397994, upload-time = "2026-01-20T16:01:14.236Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/32/ce/eeb58ae4ac36fe09e3842eb02e0eb676bf2c53ae062b98f1b2531673efdd/uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a", size = 82633, upload-time = "2026-02-16T23:07:24.1Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" }, +] diff --git a/ocr/paddleocr/Dockerfile b/ocr/paddleocr/Dockerfile new file mode 100644 index 0000000..5b08e4a --- /dev/null +++ b/ocr/paddleocr/Dockerfile @@ -0,0 +1,26 @@ +FROM ghcr.io/astral-sh/uv:python3.12-trixie + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + libgomp1 \ + libglib2.0-0 \ + libsm6 \ + libxext6 \ + libxrender-dev \ + libgl1 \ + && rm -rf /var/lib/apt/lists/* + +# Set working directory +WORKDIR /app + +# Copy pyproject.toml and server code +COPY ./*py* . + +# install necessary dependencies +RUN uv sync + +# Expose port +EXPOSE 8829 + +# Run server +CMD ["uv", "run", "python3", "server.py"] diff --git a/ocr/paddleocr/README.md b/ocr/paddleocr/README.md new file mode 100644 index 0000000..bf9ecbe --- /dev/null +++ b/ocr/paddleocr/README.md @@ -0,0 +1,115 @@ +# PaddleOCR Service + +This is a simple Flask server that wraps PaddleOCR to conform to the LiteParse OCR API specification (see `../../OCR_API_SPEC.md`). + +PaddleOCR is especially fast and accurate for Chinese, Japanese, and Korean languages. + +## Build and Run + +```bash +# install and run (in one command) +uv run server.py +``` + +## Usage + +The service exposes a single endpoint: + +- `POST /ocr` - Perform OCR on an uploaded image + +### Parameters + +- `file` - Image file (multipart/form-data) +- `language` - Language code (e.g., 'en', 'zh', 'ja', 'ko') + +### Example + +```bash +curl -X POST -F "file=@image.png" -F "language=zh" http://localhost:8829/ocr +``` + +### Response Format + +```json +{ + "results": [ + { + "text": "recognized text", + "bbox": [x1, y1, x2, y2], + "confidence": 0.95 + } + ] +} +``` + +This conforms to the LiteParse OCR API specification. + +## Supported Languages + +PaddleOCR supports 80+ languages with excellent support for CJK: + +- `en` - English +- `zh` / `zh-cn` - Chinese (Simplified) +- `zh-tw` / `zh-hant` - Chinese (Traditional) +- `ja` - Japanese +- `ko` - Korean +- `fr` - French +- `de` - German +- `es` - Spanish +- `pt` - Portuguese +- `ru` - Russian +- `ar` - Arabic +- `hi` - Hindi/Devanagari + +Full list: https://github.com/PaddlePaddle/PaddleOCR + +## Performance + +PaddleOCR is optimized for speed and accuracy: + +- **Fast**: 2-3x faster than EasyOCR +- **Accurate**: Especially for Asian languages (Chinese, Japanese, Korean) +- **Lightweight**: Smaller model sizes + +## Use with LiteParse + +Once the server is running, use it with LiteParse: + +```bash +# Parse with PaddleOCR +lit parse document.pdf --ocr-server-url http://localhost:8829/ocr + +# With specific language +lit parse document.pdf --ocr-server-url http://localhost:8829/ocr --ocr-language zh +``` + +Or in code: + +```typescript +import { LiteParse } from 'liteparse'; + +const parser = new LiteParse({ + ocrServerUrl: 'http://localhost:8829/ocr', + ocrLanguage: 'zh', +}); + +const result = await parser.parse('document.pdf'); +``` + +## Testing + +If you make changes to the server, make sure to adapt and run tests: + +```bash +uv run pytest test_server.py +``` + +## GPU Support + +For GPU acceleration, set `use_gpu=True` in `server.py`. + +## Notes + +- First request may be slow as PaddleOCR downloads models +- Models are cached after first use +- Default port is 8829 (different from EasyOCR's 8828) diff --git a/ocr/paddleocr/pyproject.toml b/ocr/paddleocr/pyproject.toml new file mode 100644 index 0000000..20d5b62 --- /dev/null +++ b/ocr/paddleocr/pyproject.toml @@ -0,0 +1,29 @@ +[project] +name = "paddleocr-liteparse" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.12,<3.14" +dependencies = [ + "fastapi>=0.131.0", + "numpy>=2.4.2", + "paddleocr>=3.4.0", + "paddlepaddle>=3.3.0", + "pillow>=12.1.1", + "python-multipart>=0.0.22", + "uvicorn>=0.41.0", +] + +[tool.uv.sources] +paddlepaddle = { index = "paddle" } + +[[tool.uv.index]] +name = "paddle" +url = "https://www.paddlepaddle.org.cn/packages/stable/cpu/" +explicit = true + +[dependency-groups] +dev = [ + "httpx>=0.28.1", + "pytest>=9.0.2", +] diff --git a/ocr/paddleocr/server.py b/ocr/paddleocr/server.py new file mode 100644 index 0000000..3c0ecda --- /dev/null +++ b/ocr/paddleocr/server.py @@ -0,0 +1,196 @@ +import io +import logging +import traceback +from typing import Any + +import numpy as np +import uvicorn +from fastapi import FastAPI, HTTPException +from fastapi.datastructures import UploadFile +from fastapi.param_functions import File, Form +from paddleocr import PaddleOCR +from PIL import Image +from pydantic import BaseModel + + +class OcrResponse(BaseModel): + results: list[Any] + + +class StatusResponse(BaseModel): + status: str + + +class PaddleOCRServer: + def __init__(self) -> None: + self.ocr: PaddleOCR = PaddleOCR( + lang="en", + use_doc_orientation_classify=False, + use_doc_unwarping=False, + use_textline_orientation=True, + ) + self.current_language: str = "en" + + @staticmethod + def normalize_language(language: str) -> str: + normalized = language.lower() + aliases = { + "eng": "en", + "zh": "ch", + "zh-cn": "ch", + "zh-hans": "ch", + "zh-tw": "chinese_cht", + "zh-hant": "chinese_cht", + "ja": "japan", + "ko": "korean", + } + return aliases.get(normalized, normalized) + + def _create_ocr_server( + self, + ) -> FastAPI: + app = FastAPI() + + @app.post("/ocr") + async def ocr_endpoint( + file: UploadFile = File(...), language: str = Form(default="en") + ) -> OcrResponse: + # Get language from request + language = self.normalize_language(language) + + try: + # Initialize OCR if needed or language changed + if self.current_language != language: + # PaddleOCR 3.x parameters + self.ocr = PaddleOCR( + lang=language, + use_doc_orientation_classify=False, + use_doc_unwarping=False, + use_textline_orientation=True, + ) + self.current_language = language + + # Load image + + image_data = await file.read() + image = Image.open(io.BytesIO(image_data)) + + # Convert to numpy array (RGB) + if image.mode != "RGB": + image = image.convert("RGB") + image_array = np.array(image) + + # Run OCR + # PaddleOCR 3.x returns: list of result dicts + # Each result has: res['rec_texts'], res['rec_scores'], res['rec_boxes'] + results = self.ocr.predict(image_array) + except ValueError as ve: + if "No models are available for the language" in str(ve): + raise HTTPException(status_code=400, detail=str(ve)) + raise HTTPException(status_code=500, detail=str(ve)) + except Exception as e: + logging.error("OCR failed:\n%s", traceback.format_exc()) + raise HTTPException(status_code=500, detail=str(e)) + + # Format results according to LiteParse OCR API spec + # Convert to: { text, bbox: [x1, y1, x2, y2], confidence } + formatted = [] + + if results and len(results) > 0: + # Get the first result + result = results[0] + + res_data = ( + result.get("res", result) if isinstance(result, dict) else result + ) + # Extract texts, scores, axis-aligned boxes, and quad polygons. + # PaddleOCR exposes the 4-point detection polygons under + # ``rec_polys`` (or ``dt_polys`` upstream) in TL→TR→BR→BL order + # relative to each detection's upright reading frame. We forward + # them so LiteParse can recover the rotation of vertical + # sidebar text instead of treating it as a horizontal line. + if isinstance(res_data, dict): + texts = res_data.get("rec_texts", []) + scores = res_data.get("rec_scores", []) + boxes = res_data.get("rec_boxes", []) + polys = res_data.get("rec_polys", res_data.get("dt_polys", [])) + else: + # Fallback for result object with attributes + texts = getattr(res_data, "rec_texts", []) or [] + scores = getattr(res_data, "rec_scores", []) or [] + boxes = getattr(res_data, "rec_boxes", []) or [] + polys = ( + getattr(res_data, "rec_polys", None) + or getattr(res_data, "dt_polys", None) + or [] + ) + + # Convert numpy arrays to lists if needed + if hasattr(texts, "tolist"): + texts = texts.tolist() + if hasattr(scores, "tolist"): + scores = scores.tolist() + if hasattr(boxes, "tolist"): + boxes = boxes.tolist() + if hasattr(polys, "tolist"): + polys = polys.tolist() + + # Combine them - they should be parallel arrays + for i in range(len(texts)): + text = texts[i] + confidence = float(scores[i]) if i < len(scores) else 0.0 + + # Get bounding box coordinates + # rec_boxes format is typically [x_min, y_min, x_max, y_max] + if i < len(boxes): + box = boxes[i] + # Convert to list and ensure 4 coordinates + if hasattr(box, "tolist"): + bbox = box.tolist() + else: + bbox = list(box) + else: + bbox = [0, 0, 0, 0] + + polygon = None + if i < len(polys): + poly = polys[i] + if hasattr(poly, "tolist"): + poly = poly.tolist() + # Expect a 4×2 sequence; coerce to floats and validate + # shape before forwarding. + if len(poly) == 4 and all(len(pt) == 2 for pt in poly): + polygon = [[float(pt[0]), float(pt[1])] for pt in poly] + # When rec_boxes is missing/zero (rotated detections in + # some PaddleOCR builds only populate rec_polys), derive + # an axis-aligned fallback from the polygon. + if polygon is not None and bbox == [0, 0, 0, 0]: + xs = [pt[0] for pt in polygon] + ys = [pt[1] for pt in polygon] + bbox = [min(xs), min(ys), max(xs), max(ys)] + + item = {"text": text, "bbox": bbox, "confidence": confidence} + if polygon is not None: + item["polygon"] = polygon + formatted.append(item) + + return OcrResponse(results=formatted) + + @app.get("/health") + def health() -> StatusResponse: + return StatusResponse(status="healthy") + + return app + + def serve(self) -> None: + app = self._create_ocr_server() + uvicorn.run(app, host="0.0.0.0", port=8829) + + +if __name__ == "__main__": + logging.basicConfig( + level=logging.DEBUG, + ) + logging.info("Starting server on port 8829") + server = PaddleOCRServer() + server.serve() diff --git a/ocr/paddleocr/test_server.py b/ocr/paddleocr/test_server.py new file mode 100644 index 0000000..9b66794 --- /dev/null +++ b/ocr/paddleocr/test_server.py @@ -0,0 +1,106 @@ +import io +from typing import Any + +import pytest +from fastapi.testclient import TestClient +from paddleocr import PaddleOCR +from PIL import Image + +import server as paddle_server_module +from server import PaddleOCRServer + + +@pytest.fixture(scope="module") +def server() -> PaddleOCRServer: + return PaddleOCRServer() + + +class MockPaddleOcr: + def __init__(self, *args, **kwargs) -> None: + self.results = [ + { + "res": { + "rec_texts": ["Hello World", "Total: $42.00", "Thank you!"], + "rec_scores": [0.98, 0.95, 0.87], + "rec_boxes": [ + [10, 20, 200, 40], + [10, 50, 250, 70], + [10, 80, 180, 100], + ], + } + } + ] + self.transformed_results = [ + {"text": "Hello World", "bbox": [10, 20, 200, 40], "confidence": 0.98}, + {"text": "Total: $42.00", "bbox": [10, 50, 250, 70], "confidence": 0.95}, + {"text": "Thank you!", "bbox": [10, 80, 180, 100], "confidence": 0.87}, + ] + + def predict(self, *args, **kwargs) -> list[Any]: + return self.results + + +def test_server_init(server: PaddleOCRServer) -> None: + assert server.current_language == "en" + assert isinstance(server.ocr, PaddleOCR) + + +def test_server_health_endpoint(server: PaddleOCRServer) -> None: + app = server._create_ocr_server() + client = TestClient(app) + response = client.get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "healthy"} + + +def test_server_ocr_endpoint(server: PaddleOCRServer) -> None: + image = Image.new("RGB", (1, 1), color=(255, 255, 255)) + + # Save to bytes (to simulate a file upload) + buffer = io.BytesIO() + image.save(buffer, format="PNG") + buffer.seek(0) + app = server._create_ocr_server() + mock_ocr = MockPaddleOcr() + server.ocr = mock_ocr # type: ignore + client = TestClient(app) + + response = client.post( + "/ocr", + files={"file": ("test.png", buffer, "image/png")}, + data={"language": "en"}, + ) + assert response.status_code == 200 + assert response.json().get("results", []) == mock_ocr.transformed_results + + +def test_server_normalizes_documented_language_aliases( + monkeypatch: pytest.MonkeyPatch, +) -> None: + image = Image.new("RGB", (1, 1), color=(255, 255, 255)) + buffer = io.BytesIO() + image.save(buffer, format="PNG") + buffer.seek(0) + + captured_langs: list[str] = [] + + class CapturingPaddleOcr(MockPaddleOcr): + def __init__(self, *args, **kwargs) -> None: + captured_langs.append(kwargs.get("lang", "")) + super().__init__(*args, **kwargs) + + monkeypatch.setattr(paddle_server_module, "PaddleOCR", CapturingPaddleOcr) + + server = PaddleOCRServer() + app = server._create_ocr_server() + client = TestClient(app) + + response = client.post( + "/ocr", + files={"file": ("test.png", buffer, "image/png")}, + data={"language": "zh"}, + ) + + assert response.status_code == 200 + assert captured_langs == ["en", "ch"] + assert server.current_language == "ch" diff --git a/ocr/suryaocr/.dockerignore b/ocr/suryaocr/.dockerignore new file mode 100644 index 0000000..213b2cb --- /dev/null +++ b/ocr/suryaocr/.dockerignore @@ -0,0 +1,3 @@ +.venv +__pycache__ +.pytest_cache diff --git a/ocr/suryaocr/Dockerfile b/ocr/suryaocr/Dockerfile new file mode 100644 index 0000000..ddadccf --- /dev/null +++ b/ocr/suryaocr/Dockerfile @@ -0,0 +1,51 @@ +# Single image: GPU-enabled llama.cpp `llama-server` + the Surya OCR API. +# +# Based on the official CUDA llama.cpp server image, which already provides a +# `qwen35`-capable `llama-server` (with libggml-cuda), the CUDA runtime, and +# Python 3.12. Surya's llamacpp backend finds `llama-server` on PATH and spawns +# it INSIDE this same container, so one image runs both the API and the model +# server. Run with `--gpus all`. +# +# (Stock Ollama's bundled llama.cpp cannot load Surya 2's custom qwen35 GGUF; +# this upstream build can.) +FROM ghcr.io/ggml-org/llama.cpp:server-cuda + +# Bring uv in from its official image (no external install script needed). +COPY --from=ghcr.io/astral-sh/uv:0.11.21 /uv /usr/local/bin/uv + +# Python/runtime libs for Surya, torch, and Pillow. +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3-venv \ + libgl1 \ + libglib2.0-0 \ + libgomp1 \ + libcurl4 \ + && rm -rf /var/lib/apt/lists/* + +# llama-server and its shared libraries live in /app. +ENV PATH="/app:${PATH}" +ENV LD_LIBRARY_PATH="/app:${LD_LIBRARY_PATH}" + +# Surya: llama.cpp backend, all layers on GPU. Surya spawns llama-server and +# downloads the GGUF weights from Hugging Face on the first OCR request. +ENV SURYA_INFERENCE_BACKEND=llamacpp +ENV LLAMA_CPP_NGL=99 +# Surya's layout step is the only request that sends a grammar; its regex +# `pattern` breaks the upstream llama.cpp grammar parser. Run layout +# unconstrained so the OCR pipeline returns results on this build. +ENV SURYA_GUIDED_LAYOUT=false +# Cache model weights (Surya torch models + the GGUF) under /models. +ENV HF_HOME=/models + +WORKDIR /opt/app + +# Copy project metadata, lockfile, and server code +COPY pyproject.toml uv.lock README.md server.py test_server.py ./ + +# Install dependencies (uses the committed lockfile) +RUN uv sync --frozen + +# The base image's entrypoint is llama-server; clear it and run the API. +ENTRYPOINT [] +EXPOSE 8830 +CMD ["uv", "run", "python3", "server.py"] diff --git a/ocr/suryaocr/README.md b/ocr/suryaocr/README.md new file mode 100644 index 0000000..965b717 --- /dev/null +++ b/ocr/suryaocr/README.md @@ -0,0 +1,178 @@ +# Surya OCR Service + +A FastAPI server wrapping [Surya OCR 2](https://github.com/datalab-to/surya) to +conform to the LiteParse OCR API specification (see `../../OCR_API_SPEC.md`). + +Surya 2 is a multilingual OCR foundation model with strong accuracy across many +languages — a single model handles all languages with no per-language setup. + +## Build and Run + +```bash +# install and run (in one command) +uv run server.py +``` + +The first run downloads Surya model weights from Hugging Face and may take a few +minutes; weights are cached afterward. + +## Inference backend (required) + +Surya 2 is a VLM-backed model: text recognition runs through a separate +inference backend, which you must provide. Surya does **not** bundle it. + +- **CPU / local (llama.cpp):** install the `llama-server` binary so Surya's + `llamacpp` backend can spawn it: + - macOS: `brew install llama.cpp` + - Linux: `brew install llama.cpp`, or download a release from + https://github.com/ggml-org/llama.cpp/releases and put `llama-server` on + your `PATH` (or set `LLAMA_CPP_BINARY=/path/to/llama-server`). +- **GPU (vllm):** set `SURYA_INFERENCE_BACKEND=vllm` (requires a CUDA GPU). +- **External server:** point `SURYA_INFERENCE_URL` at an already-running Surya + inference server to attach without spawning one locally. + +Without a backend, startup succeeds and `GET /health` works, but `POST /ocr` +returns a 500 with "llama-server binary not found". + +## Docker (GPU, single image) + +The provided `Dockerfile` builds **one image that runs both the API and the +model server on a GPU**. It is based on the official CUDA llama.cpp image +(`ghcr.io/ggml-org/llama.cpp:server-cuda`), which ships a `qwen35`-capable +`llama-server` (Surya 2's custom GGUF architecture) with `libggml-cuda` and the +CUDA runtime. Surya spawns `llama-server` from `PATH` inside the same container, +so no sidecar or separate inference service is needed. + +```bash +# Build +docker build -t suryaocr-liteparse:cuda . + +# Run (requires the NVIDIA container runtime) +docker run --rm --gpus all -p 8830:8830 \ + -v "$HOME/.cache/suryaocr-models:/models" \ + suryaocr-liteparse:cuda +``` + +- `--gpus all` exposes the host GPUs; all model layers are offloaded + (`LLAMA_CPP_NGL=99`). +- The `-v …:/models` mount caches weights (`HF_HOME=/models`). The **first** + `POST /ocr` downloads the GGUF weights from Hugging Face and spawns + `llama-server` (a few minutes); subsequent requests reuse the cache and the + running server. + +The image bakes in the backend configuration: `SURYA_INFERENCE_BACKEND=llamacpp`, +`LLAMA_CPP_NGL=99`, and `SURYA_GUIDED_LAYOUT=false`. The last one is required on +the upstream `llama-server` build: Surya's layout step is the only request that +sends a grammar, and its regex `pattern` breaks llama.cpp's +json-schema→GBNF converter (`failed to parse grammar`), which otherwise yields +empty results. With guided layout off, layout runs as free generation (Surya +parses the output itself) and the rest of the pipeline never uses a grammar. + +To use a CUDA GPU via vLLM instead of bundled llama.cpp, set +`SURYA_INFERENCE_BACKEND=vllm` (see the backend section above). + +Verify it is up: + +```bash +curl http://localhost:8830/health +curl -X POST -F "file=@image.png" http://localhost:8830/ocr +``` + +## Usage + +The service exposes: + +- `POST /ocr` — Perform OCR on an uploaded image +- `GET /health` — Health check + +### Parameters + +- `file` — Image file (multipart/form-data) +- `language` — Language code (accepted for API compatibility; **ignored**, since + Surya 2 is multilingual) + +### Example + +```bash +curl -X POST -F "file=@image.png" http://localhost:8830/ocr +``` + +### Response Format + +```json +{ + "results": [ + { + "text": "recognized text", + "bbox": [x1, y1, x2, y2], + "confidence": 0.95, + "polygon": [[x1, y1], [x2, y2], [x3, y3], [x4, y4]] + } + ] +} +``` + +Results are **block-level** (one entry per detected layout block), with each +block's HTML stripped to plain text. This conforms to the LiteParse OCR API spec. + +## Supported Languages + +Surya 2 is a single multilingual model — no `language` parameter is required +(the `language` field is accepted but ignored). + +Per Surya's own benchmark, it scores an **87.2% overall pass rate across 91 +languages**, with 38 of the 91 languages scoring ≥ 90% and 76 scoring ≥ 80%, +covering text accuracy, layout, tables, math, and reading order. It has strong +performance across both Latin and non-Latin scripts. + +- Full 91-language results: https://github.com/datalab-to/surya/blob/master/static/docs/multilingual.md +- Project overview: https://github.com/datalab-to/surya + +## Device / GPU + +There are two device knobs, for two different parts of the pipeline: + +- `LLAMA_CPP_NGL` controls how many layers of the **VLM** (the model that reads + text) the `llamacpp` backend offloads to the GPU. `99` offloads everything; + `0` keeps it on CPU. The Docker image sets `99`. +- `TORCH_DEVICE` controls the device for Surya's **helper torch models** + (e.g. layout). Surya auto-detects, or you can force it: + + ```bash + TORCH_DEVICE=cuda uv run server.py # GPU + TORCH_DEVICE=cpu uv run server.py # CPU + ``` + +For a turnkey GPU deployment, prefer the Docker image above — it wires both up. + +## Use with LiteParse + +```bash +lit parse document.pdf --ocr-server-url http://localhost:8830/ocr +``` + +Or in code: + +```typescript +import { LiteParse } from 'liteparse'; + +const parser = new LiteParse({ + ocrServerUrl: 'http://localhost:8830/ocr', +}); + +const result = await parser.parse('document.pdf'); +``` + +## Testing + +```bash +uv run pytest test_server.py +``` + +Tests mock the Surya predictor, so they run without downloading any models. + +## Notes + +- First request/startup may be slow while models download. +- Default port is 8830 (easyocr 8828, paddleocr 8829). +- Output is block-granular; Surya 2 has no per-line text API. diff --git a/ocr/suryaocr/pyproject.toml b/ocr/suryaocr/pyproject.toml new file mode 100644 index 0000000..6485a4c --- /dev/null +++ b/ocr/suryaocr/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "suryaocr-liteparse" +version = "0.1.0" +description = "Surya OCR 2 server conforming to the LiteParse OCR API" +readme = "README.md" +requires-python = ">=3.12,<3.14" +dependencies = [ + "surya-ocr>=0.20.0,<0.21.0", + "fastapi>=0.131.0", + "uvicorn>=0.41.0", + "pillow>=10.2.0,<11", + "python-multipart>=0.0.22", +] + +[dependency-groups] +dev = [ + "httpx>=0.27.0,<0.28", + "pytest>=9.0.2", +] diff --git a/ocr/suryaocr/server.py b/ocr/suryaocr/server.py new file mode 100644 index 0000000..95defd2 --- /dev/null +++ b/ocr/suryaocr/server.py @@ -0,0 +1,186 @@ +import io +import logging +import os +import re +import traceback +from html import unescape +from html.parser import HTMLParser +from typing import Any + +# Surya 2 is a VLM-backed model: text recognition runs through an inference +# backend. Default to the CPU-friendly llama.cpp backend, which spawns a +# `llama-server` binary (bundled in the Docker image; install locally with +# `brew install llama.cpp` or from github.com/ggml-org/llama.cpp/releases) and +# downloads the GGUF weights on first use. Override for GPU with +# SURYA_INFERENCE_BACKEND=vllm, or attach to an already-running inference +# server with SURYA_INFERENCE_URL. These must be set before importing surya. +os.environ.setdefault("SURYA_INFERENCE_BACKEND", "llamacpp") +os.environ.setdefault("LLAMA_CPP_NGL", "0") # 0 = CPU; set 99 for full GPU offload +# Surya only sends a grammar for the layout step (LAYOUT_JSON_SCHEMA), whose +# regex `pattern` the upstream llama.cpp json-schema→GBNF converter cannot +# parse ("failed to parse grammar"). Disable guided layout so layout runs as +# free generation (Surya parses the output itself); block/full-page OCR never +# use a grammar. Required for the llama.cpp backend to return results. +os.environ.setdefault("SURYA_GUIDED_LAYOUT", "false") + +import uvicorn +from fastapi import FastAPI, HTTPException +from fastapi.datastructures import UploadFile +from fastapi.param_functions import File, Form +from PIL import Image +from pydantic import BaseModel +from surya.inference import SuryaInferenceManager +from surya.recognition import RecognitionPredictor + +_BLOCK_OR_BREAK = { + "br", "p", "div", "li", "tr", "td", "th", + "h1", "h2", "h3", "h4", "h5", "h6", +} + + +class _TextExtractor(HTMLParser): + def __init__(self) -> None: + super().__init__() + self._parts: list[str] = [] + + def handle_data(self, data: str) -> None: + self._parts.append(data) + + def handle_starttag(self, tag: str, attrs: object) -> None: + if tag in _BLOCK_OR_BREAK: + self._parts.append(" ") + + def handle_endtag(self, tag: str) -> None: + if tag in _BLOCK_OR_BREAK: + self._parts.append(" ") + + def text(self) -> str: + return "".join(self._parts) + + +def _html_to_text(html: str) -> str: + """Strip block HTML to collapsed plain text (stdlib only).""" + if not html: + return "" + parser = _TextExtractor() + parser.feed(html) + parser.close() + return re.sub(r"\s+", " ", unescape(parser.text())).strip() + + +class OcrResponse(BaseModel): + results: list[Any] + + +class StatusResponse(BaseModel): + status: str + + +def _coerce_polygon(polygon: Any) -> list[list[float]] | None: + """Return a 4x2 float polygon, or None if the shape is invalid.""" + if polygon is None: + return None + if hasattr(polygon, "tolist"): + polygon = polygon.tolist() + if len(polygon) == 4 and all(len(pt) == 2 for pt in polygon): + return [[float(pt[0]), float(pt[1])] for pt in polygon] + return None + + +def _block_to_result(block: Any) -> dict[str, Any] | None: + """Map a Surya block to the LiteParse OCR shape, or None to skip it.""" + get = ( + block.get if isinstance(block, dict) else lambda k, d=None: getattr(block, k, d) + ) + + if get("skipped", False): + return None + + text = _html_to_text(get("html", "") or "") + if not text: + return None + + polygon = _coerce_polygon(get("polygon")) + + bbox = get("bbox") + if hasattr(bbox, "tolist"): + bbox = bbox.tolist() + if bbox is not None: + bbox = [int(round(float(v))) for v in bbox] + elif polygon is not None: + xs = [pt[0] for pt in polygon] + ys = [pt[1] for pt in polygon] + bbox = [int(min(xs)), int(min(ys)), int(max(xs)), int(max(ys))] + else: + bbox = [0, 0, 0, 0] + + confidence = get("confidence") + confidence = float(confidence) if confidence is not None else 1.0 + + result: dict[str, Any] = {"text": text, "bbox": bbox, "confidence": confidence} + if polygon is not None: + result["polygon"] = polygon + return result + + +class SuryaOCRServer: + def __init__(self) -> None: + # Surya 2 is multilingual; one model handles all languages. The + # inference manager selects the device automatically (override with + # the TORCH_DEVICE env var) and may download models on first run. + self.manager = SuryaInferenceManager() + self.recognition_predictor = RecognitionPredictor(self.manager) + + def _create_ocr_server(self) -> FastAPI: + app = FastAPI() + + @app.post("/ocr") + async def ocr_endpoint( + file: UploadFile = File(...), language: str = Form(default="en") + ) -> OcrResponse: + # `language` is accepted for API compatibility but unused: Surya 2 + # is multilingual and needs no per-language model reload. + try: + image_data = await file.read() + image = Image.open(io.BytesIO(image_data)) + if image.mode != "RGB": + image = image.convert("RGB") + except Exception as e: + raise HTTPException(status_code=400, detail=f"Invalid image: {e}") + + try: + predictions = self.recognition_predictor([image]) + except Exception as e: + logging.error("OCR failed:\n%s", traceback.format_exc()) + raise HTTPException(status_code=500, detail=str(e)) + + formatted: list[dict[str, Any]] = [] + if predictions: + page = predictions[0] + blocks = ( + page.get("blocks", []) + if isinstance(page, dict) + else getattr(page, "blocks", []) + ) + for block in blocks: + mapped = _block_to_result(block) + if mapped is not None: + formatted.append(mapped) + + return OcrResponse(results=formatted) + + @app.get("/health") + def health() -> StatusResponse: + return StatusResponse(status="healthy") + + return app + + def serve(self) -> None: + app = self._create_ocr_server() + uvicorn.run(app, host="0.0.0.0", port=8830) + + +if __name__ == "__main__": + logging.basicConfig(level=logging.DEBUG) + logging.info("Starting server on port 8830") + SuryaOCRServer().serve() diff --git a/ocr/suryaocr/test_server.py b/ocr/suryaocr/test_server.py new file mode 100644 index 0000000..e63a3b3 --- /dev/null +++ b/ocr/suryaocr/test_server.py @@ -0,0 +1,162 @@ +import io +from types import SimpleNamespace + +import pytest +from fastapi.testclient import TestClient +from PIL import Image + +from server import _html_to_text +from server import SuryaOCRServer + + +def test_html_to_text_strips_tags_and_unescapes() -> None: + assert _html_to_text("

Hello World

") == "Hello World" + assert _html_to_text("Line one
Line two") == "Line one Line two" + assert _html_to_text("

a

b

") == "a b" + assert _html_to_text("Total: & $42") == "Total: & $42" + + +def test_html_to_text_empty_for_markup_only() -> None: + assert _html_to_text("") == "" + assert _html_to_text("
") == "" + assert _html_to_text(" ") == "" + + +class MockRecognitionPredictor: + def __init__(self, blocks: list) -> None: + self._blocks = blocks + + def __call__(self, images, *args, **kwargs) -> list: + return [SimpleNamespace(blocks=self._blocks, image_bbox=[0, 0, 100, 100])] + + +def _make_server(blocks: list) -> SuryaOCRServer: + server = SuryaOCRServer.__new__(SuryaOCRServer) # skip model load + server.recognition_predictor = MockRecognitionPredictor(blocks) # type: ignore + return server + + +def _png_bytes() -> io.BytesIO: + image = Image.new("RGB", (4, 4), color=(255, 255, 255)) + buffer = io.BytesIO() + image.save(buffer, format="PNG") + buffer.seek(0) + return buffer + + +def test_server_health_endpoint() -> None: + server = _make_server([]) + client = TestClient(server._create_ocr_server()) + response = client.get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "healthy"} + + +def test_server_ocr_endpoint_maps_blocks() -> None: + blocks = [ + SimpleNamespace( + html="

Hello World

", + bbox=[10.0, 20.0, 200.0, 40.0], + polygon=[[10, 20], [200, 20], [200, 40], [10, 40]], + confidence=0.97, + skipped=False, + ), + ] + server = _make_server(blocks) + client = TestClient(server._create_ocr_server()) + response = client.post( + "/ocr", + files={"file": ("test.png", _png_bytes(), "image/png")}, + data={"language": "en"}, + ) + assert response.status_code == 200 + results = response.json()["results"] + assert len(results) == 1 + assert results[0]["text"] == "Hello World" + assert results[0]["bbox"] == [10, 20, 200, 40] + assert results[0]["confidence"] == pytest.approx(0.97) + assert results[0]["polygon"] == [ + [10.0, 20.0], [200.0, 20.0], [200.0, 40.0], [10.0, 40.0] + ] + + +def test_server_defaults_missing_confidence() -> None: + blocks = [ + SimpleNamespace( + html="

x

", + bbox=[0.0, 0.0, 5.0, 5.0], + polygon=[[0, 0], [5, 0], [5, 5], [0, 5]], + confidence=None, + skipped=False, + ), + ] + server = _make_server(blocks) + client = TestClient(server._create_ocr_server()) + response = client.post( + "/ocr", files={"file": ("t.png", _png_bytes(), "image/png")} + ) + assert response.json()["results"][0]["confidence"] == pytest.approx(1.0) + + +def test_server_skips_empty_and_skipped_blocks() -> None: + blocks = [ + SimpleNamespace(html="", bbox=[0.0, 0.0, 5.0, 5.0], + polygon=[[0, 0], [5, 0], [5, 5], [0, 5]], + confidence=0.9, skipped=False), + SimpleNamespace(html="

image

", bbox=[0.0, 0.0, 5.0, 5.0], + polygon=[[0, 0], [5, 0], [5, 5], [0, 5]], + confidence=0.9, skipped=True), + SimpleNamespace(html="

keep

", bbox=[1.0, 1.0, 9.0, 9.0], + polygon=[[1, 1], [9, 1], [9, 9], [1, 9]], + confidence=0.9, skipped=False), + ] + server = _make_server(blocks) + client = TestClient(server._create_ocr_server()) + response = client.post( + "/ocr", files={"file": ("t.png", _png_bytes(), "image/png")} + ) + results = response.json()["results"] + assert len(results) == 1 + assert results[0]["text"] == "keep" + + +def test_server_derives_bbox_from_polygon_when_bbox_missing() -> None: + blocks = [ + SimpleNamespace(html="

z

", bbox=None, + polygon=[[3, 4], [30, 4], [30, 40], [3, 40]], + confidence=0.8, skipped=False), + ] + server = _make_server(blocks) + client = TestClient(server._create_ocr_server()) + response = client.post( + "/ocr", files={"file": ("t.png", _png_bytes(), "image/png")} + ) + assert response.json()["results"][0]["bbox"] == [3, 4, 30, 40] + + +def test_server_keeps_zero_bbox_without_deriving_from_polygon() -> None: + blocks = [ + SimpleNamespace(html="

q

", bbox=[0, 0, 0, 0], + polygon=[[3, 4], [30, 4], [30, 40], [3, 40]], + confidence=0.9, skipped=False), + ] + server = _make_server(blocks) + client = TestClient(server._create_ocr_server()) + response = client.post( + "/ocr", files={"file": ("t.png", _png_bytes(), "image/png")} + ) + assert response.json()["results"][0]["bbox"] == [0, 0, 0, 0] + + +def test_server_skips_whitespace_only_html() -> None: + blocks = [ + SimpleNamespace(html="

", bbox=[1, 1, 9, 9], + polygon=[[1, 1], [9, 1], [9, 9], [1, 9]], + confidence=0.9, skipped=False), + ] + server = _make_server(blocks) + client = TestClient(server._create_ocr_server()) + response = client.post( + "/ocr", files={"file": ("t.png", _png_bytes(), "image/png")} + ) + assert response.json()["results"] == [] diff --git a/ocr/suryaocr/uv.lock b/ocr/suryaocr/uv.lock new file mode 100644 index 0000000..5a9e35b --- /dev/null +++ b/ocr/suryaocr/uv.lock @@ -0,0 +1,1244 @@ +version = 1 +revision = 3 +requires-python = ">=3.12, <3.14" +resolution-markers = [ + "sys_platform == 'darwin'", + "platform_machine == 'aarch64' and sys_platform == 'linux'", + "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')", +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cuda-bindings" +version = "13.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cuda-pathfinder", marker = "sys_platform != 'darwin'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/67/5e7dba1ba576dd73da5dee894ca076ca5e959450dfff66d6d510a255d1f7/cuda_bindings-13.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7855c4868aabc0cfae28abbe83d56734bdfbd08f08fc234ac1912a12858bf49", size = 6025351, upload-time = "2026-05-29T23:11:49.685Z" }, + { url = "https://files.pythonhosted.org/packages/39/2a/6d2e9047d1fb243dbaa364b01e0297534b9ed7fd27dba1c9f361519cf69b/cuda_bindings-13.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e32d08f71ebcdf00f0f41eab2eb37e8da94c8ed411cc9f7f7a019ce6b34abe3a", size = 6657965, upload-time = "2026-05-29T23:11:52.227Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6e/2394f8163360f8391f8f1b7e72d300a82724edb81a7b7084c799fbd4c91f/cuda_bindings-13.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9efb21c1ee64981e184b9e0ba5eb3179e5ba3d4b51665a6cb52b8ef3d01a7cbf", size = 5920504, upload-time = "2026-05-29T23:11:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/34/c2/ef9b6a63f7dc432712a462c816662e662e00d38caa9b861c8c2588195d03/cuda_bindings-13.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2732904099e0a4d4db774a5fc6d91ee95fae065b4d2ecabb4968c5fe2406c9d7", size = 6476660, upload-time = "2026-05-29T23:11:59.188Z" }, +] + +[[package]] +name = "cuda-pathfinder" +version = "1.5.5" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/c8/26f2e4aae92f11522a96043892ba39a90eac610d5242523aa863212bc1c7/cuda_pathfinder-1.5.5-py3-none-any.whl", hash = "sha256:0228c023f95d1480f143ef5c8922d27a2ab052087a942e81dc289c9eb8f91689", size = 51671, upload-time = "2026-05-27T01:21:25.413Z" }, +] + +[[package]] +name = "cuda-toolkit" +version = "13.0.2" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b2/453099f5f3b698d7d0eab38916aac44c7f76229f451709e2eb9db6615dcd/cuda_toolkit-13.0.2-py2.py3-none-any.whl", hash = "sha256:b198824cf2f54003f50d64ada3a0f184b42ca0846c1c94192fa269ecd97a66eb", size = 2364, upload-time = "2025-12-19T23:24:07.328Z" }, +] + +[package.optional-dependencies] +cudart = [ + { name = "nvidia-cuda-runtime", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, +] +cufft = [ + { name = "nvidia-cufft", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, +] +cufile = [ + { name = "nvidia-cufile", marker = "sys_platform == 'linux'" }, +] +cupti = [ + { name = "nvidia-cuda-cupti", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, +] +curand = [ + { name = "nvidia-curand", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, +] +cusolver = [ + { name = "nvidia-cusolver", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, +] +cusparse = [ + { name = "nvidia-cusparse", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, +] +nvjitlink = [ + { name = "nvidia-nvjitlink", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, +] +nvrtc = [ + { name = "nvidia-cuda-nvrtc", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, +] +nvtx = [ + { name = "nvidia-nvtx", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "fastapi" +version = "0.138.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/58/ff455d9fe47c60abadb34b9e05a304b1f05f5ab8000ac01565156b6f5e43/fastapi-0.138.0.tar.gz", hash = "sha256:d445a4877636ad191e7053e08c9bf98cb921a6756776848400bb773d1740c061", size = 419240, upload-time = "2026-06-20T01:18:05.259Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/ff/8496d9847a5fedae775eb49460722d3efaa80487854273e9647ae876218c/fastapi-0.138.0-py3-none-any.whl", hash = "sha256:b6f54fd1bd72c80b0f899f172c61a600f6f7af9b43d4d772a018f35624048cb0", size = 126779, upload-time = "2026-06-20T01:18:03.483Z" }, +] + +[[package]] +name = "filelock" +version = "3.29.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/dc/be6cbe99670cd6e4ad387123647cb08e0c32975e223f82551e914c5568a6/filelock-3.29.4.tar.gz", hash = "sha256:10cdb3656fc44541cdf30652a93fb10ec6b05325620eb316bd26893e4201538a", size = 63028, upload-time = "2026-06-13T16:12:00.744Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-py3-none-any.whl", hash = "sha256:dac1648087d5115554850d113e7dd8c83ab2d38e3435dde2d4f163847e57b767", size = 42757, upload-time = "2026-06-13T16:11:59.582Z" }, +] + +[[package]] +name = "filetype" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/29/745f7d30d47fe0f251d3ad3dc2978a23141917661998763bebb6da007eb1/filetype-1.2.0.tar.gz", hash = "sha256:66b56cd6474bf41d8c54660347d37afcc3f7d1970648de365c102ef77548aadb", size = 998020, upload-time = "2022-11-02T17:34:04.141Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/79/1b8fa1bb3568781e84c9200f951c735f3f157429f44be0495da55894d620/filetype-1.2.0-py2.py3-none-any.whl", hash = "sha256:7ce71b6880181241cf7ac8697a2f1eb6a8bd9b429f7ad6d27b8db9ba5f1c2d25", size = 19970, upload-time = "2022-11-02T17:34:01.425Z" }, +] + +[[package]] +name = "fsspec" +version = "2026.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/10/a1/ae4e3e5003468d6391d2c77b6fa1cd73bd5d13511d81c642d7b28ac90ed4/fsspec-2026.6.0.tar.gz", hash = "sha256:f5bac145310fe30e16e1471bd6840b2d990d609e872251d7e674241822abf01a", size = 313646, upload-time = "2026-06-16T01:57:28.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/22/4222d7ddf3da30f363edaa98e329c2bce6c65497c9cb2810931c8b2c0fbc/fsspec-2026.6.0-py3-none-any.whl", hash = "sha256:02e0b71817df9b2169dc30a16832045764def1191b43dcff5bb85bdee212d2a1", size = 203949, upload-time = "2026-06-16T01:57:26.358Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "hf-xet" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/2d/57fd21d84d93efb4bd0b962383790e19dd1bc053501b4264c97903b4e83e/hf_xet-1.5.1.tar.gz", hash = "sha256:51ef4500dab3764b41135ee1381a4b62ce56fc54d4c92b719b59e597d6df5bf6", size = 876636, upload-time = "2026-06-08T23:02:53.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/ee/dd9ba7beae1005e54131b7d45263cc74c8a066d47d354e6d58ae9445a388/hf_xet-1.5.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:dbf48c0d02cf0b2e568944330c60d9120c272dabe013bd892d48e25bc6797577", size = 4069485, upload-time = "2026-06-08T23:02:13.193Z" }, + { url = "https://files.pythonhosted.org/packages/b6/bc/9cae6cfeb4e03070874e73e5c97c66eb90369d3206b6a2b1ef5f96520888/hf_xet-1.5.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78e4e5192ad2b674c2e1160b651cb9134db974f8ae1835bdfbfb0166b894a43", size = 3838493, upload-time = "2026-06-08T23:02:15.282Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b4/d5c01e0eb6d9f2ca2dacd84d0d1b71e6cfbb2ef3208c968528e010e9b3d7/hf_xet-1.5.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6f7a04a8ad962422e225bc49fbbac99dc1806764b1f3e54dbd154bffa7593947", size = 4505658, upload-time = "2026-06-08T23:02:17.196Z" }, + { url = "https://files.pythonhosted.org/packages/76/c5/29a7598c0c6383c523dc22186d577f4e04267a626cd95ae60f67c00bfe66/hf_xet-1.5.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:d48199c2bf4f8df0adc55d31d1368b6ec0e4d4f45bc86b08038089c23db0bed8", size = 4292822, upload-time = "2026-06-08T23:02:18.608Z" }, + { url = "https://files.pythonhosted.org/packages/04/9a/dceaf6ca69390126b86ea825fb354b93d01163199070b7bd849225de9468/hf_xet-1.5.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:97f212a88d14bbf573619a74b7fecb238de77d08fc702e54dec6f78276ca3283", size = 4491255, upload-time = "2026-06-08T23:02:20.124Z" }, + { url = "https://files.pythonhosted.org/packages/48/a7/e5a7afaacf6c1791fdbeeac42951fb81c3d2bc482992b115dedcc86d963e/hf_xet-1.5.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f61e3665892a6c8c5e765395838b8ddf36185da835253d4bc4509a81e49fb342", size = 4711062, upload-time = "2026-06-08T23:02:21.863Z" }, + { url = "https://files.pythonhosted.org/packages/53/49/2802f8433c9742ce281bddc1e65c02c32268ca3098d66828b05e12e45ee2/hf_xet-1.5.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f4ad3ebd4c32dd2b27099d69dc7b2df821e30767e46fb6ee6a0713778243b8ff", size = 4017205, upload-time = "2026-06-08T23:02:23.495Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5a/50c71195b9fb883659f596e7252faf4c18c58e753a9013bdbf9bac5d2250/hf_xet-1.5.1-cp313-cp313t-win_arm64.whl", hash = "sha256:8298485c1e36e7e67cbd01eeb1376619b7af43d4f1ec245caae306f890a8a32d", size = 3845426, upload-time = "2026-06-08T23:02:25.124Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d8/5e54cf37434759d1f4f2ba9b66077ff9d4c4e1f37b6bd7975da5c40d94ab/hf_xet-1.5.1-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:6abd35c3221eff63836618ddfb954dcf84798603f71d8e33e3ed7b04acfdbe6e", size = 4077794, upload-time = "2026-06-08T23:02:40.656Z" }, + { url = "https://files.pythonhosted.org/packages/35/94/4b2ecfbad8f8b04701a23aefb62f540b9137d058b7e1dbef16a32676f0e9/hf_xet-1.5.1-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:94e761bbd266bf4c03cee73753916062665ce8365aa40ed321f45afcb934b41e", size = 3845354, upload-time = "2026-06-08T23:02:42.702Z" }, + { url = "https://files.pythonhosted.org/packages/de/cc/f99f4bc7295023d7bd9ebbfd51f75cc530ca262c1227666268b8208f4b77/hf_xet-1.5.1-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:892e3a3a3aecc12aded8b93cf4f9cd059282c7de0732f7d55026f3abdf474350", size = 4514864, upload-time = "2026-06-08T23:02:44.497Z" }, + { url = "https://files.pythonhosted.org/packages/cd/6e/21f7e5a2381278bd3b7b7a5a4d90038518bb6308a0c1daf5d9f8268bb178/hf_xet-1.5.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a93df2039190502835b1db8cd7e178b0b7b889fe9ab51299d5ced26e0dd879a4", size = 4303784, upload-time = "2026-06-08T23:02:46.203Z" }, + { url = "https://files.pythonhosted.org/packages/35/0e/f992bb6927ac1cb30ef74e62268f551f338bc32b2191f7c96a44c6f7283e/hf_xet-1.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0c97106032ef70467b4f6bc2d0ccc266d7613ee076afc56516c502f87ce1c4a6", size = 4500703, upload-time = "2026-06-08T23:02:47.628Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d1/90a498d05447980b977b1669246eeeeae4cfb0ea3e7a286eaba627f91bf9/hf_xet-1.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6208adb15d192b90e4c2ad2a27ed864359b2cb0f2494eb6d7c7f3699ac02e2bf", size = 4719498, upload-time = "2026-06-08T23:02:49.268Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b6/20f99cfe97cc663a711f7b33cc21d4793e51968e9a26125b4afcd77315ba/hf_xet-1.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:f7b3002f95d1c13e24bcb4537baa8f0eb3838957067c91bb4959bc004a6435f5", size = 4026419, upload-time = "2026-06-08T23:02:50.829Z" }, + { url = "https://files.pythonhosted.org/packages/f9/fa/77453694888f03e5a8c8852d1514a0894d8e81c622d39edbaf308ea0dcf4/hf_xet-1.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:93d090b57b211133f6c0dab0205ef5cb6d89162979ba75a74845045cc3063b8e", size = 3855178, upload-time = "2026-06-08T23:02:52.452Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.27.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/82/08f8c936781f67d9e6b9eeb8a0c8b4e406136ea4c3d1f89a5db71d42e0e6/httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2", size = 144189, upload-time = "2024-08-27T12:54:01.334Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/95/9377bcb415797e44274b51d46e3249eba641711cf3348050f76ee7b15ffc/httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0", size = 76395, upload-time = "2024-08-27T12:53:59.653Z" }, +] + +[[package]] +name = "huggingface-hub" +version = "0.36.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/b7/8cb61d2eece5fb05a83271da168186721c450eb74e3c31f7ef3169fa475b/huggingface_hub-0.36.2.tar.gz", hash = "sha256:1934304d2fb224f8afa3b87007d58501acfda9215b334eed53072dd5e815ff7a", size = 649782, upload-time = "2026-02-06T09:24:13.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/af/48ac8483240de756d2438c380746e7130d1c6f75802ef22f3c6d49982787/huggingface_hub-0.36.2-py3-none-any.whl", hash = "sha256:48f0c8eac16145dfce371e9d2d7772854a4f591bcb56c9cf548accf531d54270", size = 566395, upload-time = "2026-02-06T09:24:11.133Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jiter" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/b5/55f06bb281d92fb3cc86d14e1def2bd908bb77693183e7cb1f5a3c388b0c/jiter-0.15.0.tar.gz", hash = "sha256:4251acc80e2b7c9b7b8823456ea0fceeb0734dac2df7636d3c711b38476b5a76", size = 166640, upload-time = "2026-05-19T10:09:48.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/53/4f6bddbcde3c71e56d0aa1337ec95950f3d27dd4153e25aadf0feac71751/jiter-0.15.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0e90a1c315a0226ec822d973817967f9223b7701546c8c2a7913e7ab0926294d", size = 308793, upload-time = "2026-05-19T10:07:35.25Z" }, + { url = "https://files.pythonhosted.org/packages/01/84/c01099b59a285a1ebba64ae93f62bfa036675340fd1b0045ae65890a0442/jiter-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8c9004af7c8d67cce7f1aae1026fb55607f4aa600710d08ede3a3ce4aeefe7e0", size = 309570, upload-time = "2026-05-19T10:07:36.919Z" }, + { url = "https://files.pythonhosted.org/packages/58/64/8fb7f9d45bb98190355454cd04dad8d8f27223d6bd52f83af07f637168a6/jiter-0.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c210f8b35dc6f30aafd4b4365ca89b9d1189f21ab49b8e68fa6322a847aef138", size = 336783, upload-time = "2026-05-19T10:07:38.694Z" }, + { url = "https://files.pythonhosted.org/packages/c3/b6/f5739011d009b3a30f6a53c5240979030ba29ae46a8c67e3a15759f7c37d/jiter-0.15.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f30bae8bc1c2d613e28e5af3e8cceb09b742f1c8a8a5f839fb67afaffc03b61", size = 363555, upload-time = "2026-05-19T10:07:40.832Z" }, + { url = "https://files.pythonhosted.org/packages/e5/12/98a9d9f766665e8a3b6252454e17cb0c464606a28cf2fa09399b003345fa/jiter-0.15.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c60e71b6d10cfc284c9bf36bd885e8d44c46f688ce50aa91b5edd90181dea687", size = 452255, upload-time = "2026-05-19T10:07:42.62Z" }, + { url = "https://files.pythonhosted.org/packages/e8/d5/60f972840f79c5e7544fce567c56f1e4e50468f996baba3e78d823dd62a6/jiter-0.15.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ab068bce62a45aa3e7367eceaffb5dde60b7eb853be8dece45132e3d0ff4879", size = 373559, upload-time = "2026-05-19T10:07:44.201Z" }, + { url = "https://files.pythonhosted.org/packages/ee/cf/d46ef1234ba335aabc2f013210db8e0821a22f5e644a2e9449df199ecc23/jiter-0.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa248c9eb220197d363f688818dac2fd4b2f0cd7d843ca7105d652034823427d", size = 346055, upload-time = "2026-05-19T10:07:46.005Z" }, + { url = "https://files.pythonhosted.org/packages/f0/63/4d2749d8d54d230bad9b3a6b0d00cc28c6ff6b2fdffc26a8ccf76cc5a974/jiter-0.15.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2a77aadd57cac1682e4401a72724d2796d89a4ba129b1a5812aa94ee480826eb", size = 351406, upload-time = "2026-05-19T10:07:47.855Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b9/9965b990035d8773328e0a8c8b457a87bf2b19f6c4126d9d99296be5d16a/jiter-0.15.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2ae901f3a55bfafdde31d289590fa25e3245735a2b1e8c7cc15871710a002871", size = 389357, upload-time = "2026-05-19T10:07:49.665Z" }, + { url = "https://files.pythonhosted.org/packages/2d/55/9ddf903deda1413e87fed792f416b7123daee5b8efbad6a202a7421c36a5/jiter-0.15.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f0b271b462769543716f92d3a4f90527df6ef5ed05ee95ec4137f513e21e1b77", size = 517263, upload-time = "2026-05-19T10:07:51.537Z" }, + { url = "https://files.pythonhosted.org/packages/e8/76/a0c40ad064d3a20a4fde231e35d56e9a01ce82164278180e82d5daf85469/jiter-0.15.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2fb6a5d26af81fc0f00f9360a891e05cf755e149bba391c4d563adc54812973d", size = 548646, upload-time = "2026-05-19T10:07:53.196Z" }, + { url = "https://files.pythonhosted.org/packages/23/4f/eca9b954942916ba2f453891b8593ab444cd872396fe66a3936616f236f3/jiter-0.15.0-cp312-cp312-win32.whl", hash = "sha256:c2f6bb8b5216ab9e7873bc08b5d7bef2b8abbb578a3069bf1cd14a45d71d771d", size = 206427, upload-time = "2026-05-19T10:07:55.307Z" }, + { url = "https://files.pythonhosted.org/packages/95/bf/8ead82a87495149542748e828d153fd232a512a22c83b02c4815c1a9c7d8/jiter-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:40b2c7e92c44a84d748d21706c68dc6ff8161d80b59c99d774721a0d2317d7c7", size = 197300, upload-time = "2026-05-19T10:07:56.651Z" }, + { url = "https://files.pythonhosted.org/packages/f4/e4/9b8a78fb2d894471bc344e37f1949bdd784bd914d031dba0ba3a40c71dd7/jiter-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:cc0bc345cf2df9d1c00ac443f50d543c1ccfa8b0422cb85b1ab70d681c0b255b", size = 192702, upload-time = "2026-05-19T10:07:58.307Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f4/f708c900ecee41b2025ef8413d5351e5649eb2125c506f6720cc69b06f5c/jiter-0.15.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1c11465f97e2abf45a014b83b730222f8f1c5335e802c7055a67d50de6f1f4e3", size = 307829, upload-time = "2026-05-19T10:07:59.704Z" }, + { url = "https://files.pythonhosted.org/packages/86/59/db537c0949e83668c38481d426b9f2fd5ab758c4ee53a811dd0a510626a0/jiter-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e7b1776f0797956c509e123d0952d10d293a9492dea9f288ab9570ec01d1a5", size = 308445, upload-time = "2026-05-19T10:08:01.184Z" }, + { url = "https://files.pythonhosted.org/packages/37/38/ea0e13b18c30ef951da0d47d39e7fa9edb82a93a62990ffbd7cea9b622d4/jiter-0.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:351a341c2105aa430b7047e30f1bf7975f6313b00165d3fc07be2edaf741f279", size = 336181, upload-time = "2026-05-19T10:08:02.688Z" }, + { url = "https://files.pythonhosted.org/packages/58/fc/2303901b16c4ba05865588990a420c0b4156270b44379c20931544a1d962/jiter-0.15.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4ab395feec8d249ec4044e228e98a7033f043426a265df439dc3698823f0a4e4", size = 362985, upload-time = "2026-05-19T10:08:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6f/11bace093c52e7d4d26c8e606ccd7ae8c972189622469ec0d9e28161e28b/jiter-0.15.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2a438005b6f22d0273413484d6094d7c2c5d10ec1b3a3bf128e0d1d3ba53258", size = 453292, upload-time = "2026-05-19T10:08:05.967Z" }, + { url = "https://files.pythonhosted.org/packages/22/db/987f2f086ca4d7a6582eb4ccd513f9b26b42d9e4243a087609a3137a8fc7/jiter-0.15.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f18f85e4218d1b40f000f42a92239a7a61a902cd42c65e6c360dbd17dcb20894", size = 373501, upload-time = "2026-05-19T10:08:07.857Z" }, + { url = "https://files.pythonhosted.org/packages/8f/7c/89fbcabb2739b7a5b8dc959a1b6c5761f6484f5fed3486854b3c789bb1de/jiter-0.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1aa62e277fc1cbd80e6deacae6f4d983b41b3d7728e0645c5d741a6149bba45", size = 344683, upload-time = "2026-05-19T10:08:09.431Z" }, + { url = "https://files.pythonhosted.org/packages/30/6f/6cca7692e7dddfec6d8d76c54dc97f2af2a41df4ac0674b999df1f09a5f3/jiter-0.15.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:6550fa135c7deb8ead6af49ed7ff648532ea8334a1447fe34a36315ef79c5c29", size = 350892, upload-time = "2026-05-19T10:08:11.352Z" }, + { url = "https://files.pythonhosted.org/packages/39/14/0338d6190cb8e6d22e677ab1d4eabd4117f67cca70c54cd04b82ff64e068/jiter-0.15.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:066f8f33f18b2419cd8213b2436fa7fbc9c499f315971cfa3ce1f9820c001b1b", size = 388723, upload-time = "2026-05-19T10:08:12.912Z" }, + { url = "https://files.pythonhosted.org/packages/90/31/cc19f4a1bdb6afb09ce6a2f2615aa8d44d994eba0d8e6105ed1af920e736/jiter-0.15.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:75e8a04e91432dde9f1838373cf93d23726c79d3e908d319acf0e796f85592e7", size = 516648, upload-time = "2026-05-19T10:08:14.808Z" }, + { url = "https://files.pythonhosted.org/packages/49/9f/833c541512cd091b63c10c0381973dfe11bc7a503a818c16384417e0c81e/jiter-0.15.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:a97261f1fccb8e50ecd2890a96e46efdc3f57c80a197324c6777827231eca712", size = 547382, upload-time = "2026-05-19T10:08:16.927Z" }, + { url = "https://files.pythonhosted.org/packages/d2/11/e7b70e91f90bc4477e8eee9e8a5f7cf3cb41b4525d6394dc98a714eb8f7f/jiter-0.15.0-cp313-cp313-win32.whl", hash = "sha256:c77496cb10bd7549690fbbab3e5ec05857b83e49276f4a9423a766ddd2afcd4c", size = 205845, upload-time = "2026-05-19T10:08:18.401Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/5c20d9ad6f02c493e4023e5d2d09e1c1f15fe2753c9102c544aff068a88e/jiter-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b15741f501469009ae0ae90b7147958a664a7dede40aa7ff174a8a4645f546d0", size = 196842, upload-time = "2026-05-19T10:08:20.131Z" }, + { url = "https://files.pythonhosted.org/packages/6b/11/1eb400ef248e8c925fd883fbe325daf5e42cd1b0d308539dd332bd4f7ffc/jiter-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d6a60072b44c3c2b797a7ddcbcbbf2b34ea3cfd4721580fbfd2a09d9d9b84ba", size = 192212, upload-time = "2026-05-19T10:08:21.807Z" }, + { url = "https://files.pythonhosted.org/packages/8a/60/2fd8d7c79da8acf9b7b277c7616847773779356b92acfc9bb158452174da/jiter-0.15.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ef1fd24d9413f6209e00d3d5a453e67acfe004a25cc6c8e8484faed4311ab9e8", size = 315065, upload-time = "2026-05-19T10:08:23.218Z" }, + { url = "https://files.pythonhosted.org/packages/46/f4/008fb7d65e8ac2abf00811651a661e025c4ba80bbc6f378450384ddd3aed/jiter-0.15.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:144f8e72cb53dab146347b91cceac01f5481237f2b93b4a339a1ee8f8878b67c", size = 339444, upload-time = "2026-05-19T10:08:24.701Z" }, + { url = "https://files.pythonhosted.org/packages/00/55/90b0c7b9c6896c0f2a591dd36d36b71d22e09674bfef178fa03ba3f81499/jiter-0.15.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:553fcac2ef2cb990877f9fc0833b8b629a3e6a5670b6b5fd58219b41a653ddc4", size = 347779, upload-time = "2026-05-19T10:08:26.408Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/69666cec5000fd57734c118437394516c749ae8dbeea9fb66d6fef9c4775/jiter-0.15.0-cp313-cp313t-win_amd64.whl", hash = "sha256:774f93f65031856bf14ad9f59bdcab8b8cad501e5ceabd51ba3525f76937a25b", size = 200395, upload-time = "2026-05-19T10:08:28.055Z" }, + { url = "https://files.pythonhosted.org/packages/39/04/a6aa62cd27e8149b0d28df5561f10f6cceaf7935a9ccf3f1c5a05f9a0cd8/jiter-0.15.0-cp313-cp313t-win_arm64.whl", hash = "sha256:f1e1754960f38ec40613a07e5e372df67acb3b890fb383b6fb3de3e49ddbf3c7", size = 190516, upload-time = "2026-05-19T10:08:29.35Z" }, + { url = "https://files.pythonhosted.org/packages/73/38/505941b2b092fd5bbbd60a52a880db1173f1690ae6751bed3af1c9ddcb4e/jiter-0.15.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:631f13a3d04e97d4e083993b10f4b99530e3a10d953e2eb5e196b7dc7f812ce0", size = 303769, upload-time = "2026-05-19T10:09:42.203Z" }, + { url = "https://files.pythonhosted.org/packages/e7/95/a06692b29e77473f286e1ec1f426d3ca44d7b5843be8ad21d7a5f3fcdcc0/jiter-0.15.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:b6c0ffae686c39bf3737be60793783267628783ea42545632c10b291105aee45", size = 305128, upload-time = "2026-05-19T10:09:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/23/85/7270d7ad41d6061a25b950c6bf91d638bd9aacb113200a8c8d57a055fd67/jiter-0.15.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d54fb5b31dea401a41af3f8a7d2512e9b6a6a005491e6166c7e4ffab9639a9c", size = 340459, upload-time = "2026-05-19T10:09:45.452Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8d/302cb2057b7513327b4d575cff6b1d066ee6431a5357fc3f8867cd684406/jiter-0.15.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d5d6090cdc1b7c9e780dfb04949a990adb1e301a2fc0bbcee7de4638d33f9a", size = 344469, upload-time = "2026-05-19T10:09:46.864Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, +] + +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, +] + +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/05/3d27272d30698dc0ecb7fdfaa41ad70303b444f81722bb99bce1d818638a/numpy-2.5.0.tar.gz", hash = "sha256:5a129578019311b6e56bdd714250f19b518f7dceeeb8d1af5490f4942d3f891c", size = 20652461, upload-time = "2026-06-21T20:57:51.95Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/0a/11486d02add7b1384dff7374d124b1cfbb0ee864dcc9f6a2c0380638cf84/numpy-2.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:489780423903667933b4ed6197b6ec3b75ea5dd17d1d8f0f38d798feb6921561", size = 16789987, upload-time = "2026-06-21T20:56:16.657Z" }, + { url = "https://files.pythonhosted.org/packages/55/b2/285f48640a181947b4587a3766d21ec1eaa7fea833d4b49957e09da467a2/numpy-2.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ece55976ced6bca95a03ae2839e2e5ccffe8eb6a3e7022415645eb154a81e4e6", size = 11760322, upload-time = "2026-06-21T20:56:19.813Z" }, + { url = "https://files.pythonhosted.org/packages/dd/67/b032db1eb03ca30d16eda3b0c22aaa615338b9263c2fd559d0f29451aca4/numpy-2.5.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:c83b664b0e6eee9594fa920cf0639d8af796606d3fad6cc70180c87e4b97c7be", size = 5319605, upload-time = "2026-06-21T20:56:22.173Z" }, + { url = "https://files.pythonhosted.org/packages/b9/83/03fc7300c7c6b6c84c487b1dc80d322817b95fbd1f4dd57a85e23b7198de/numpy-2.5.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:bf80333980bf37f523341ddd72c783f39d6829ec7736b9eb99086388a2d52cc2", size = 6653628, upload-time = "2026-06-21T20:56:23.914Z" }, + { url = "https://files.pythonhosted.org/packages/82/49/2ec21730bc63ccfda829323f7040a8ed4715b3852ce658689cf74ee96a8c/numpy-2.5.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1a4874217b36d5ac8fc876f52e39df56f8182c88463e9e2dceabf7ca8b7efb8", size = 15153691, upload-time = "2026-06-21T20:56:25.631Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6b/f4a3d0637692c49da8ef99d72d52526f92e0a8d6ac4f0ca9f31441b9d9ea/numpy-2.5.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aaa760137137e8d3c920d27927748215b56014f92667dc9b6c27dfc61249255a", size = 16660066, upload-time = "2026-06-21T20:56:28.009Z" }, + { url = "https://files.pythonhosted.org/packages/3a/2f/c354ec86d1f3f5c19649463b0d39652e160736e5b0a4cd18dff0576715c4/numpy-2.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7174ce8265fc7f7417d171c9ea8fe905220748893ea67a2a7abe726ec331c4b0", size = 16514638, upload-time = "2026-06-21T20:56:30.26Z" }, + { url = "https://files.pythonhosted.org/packages/06/34/43efdcb319988648580f93c11f1ae82cf7e2faa74925e98e454ae3aa95f8/numpy-2.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b8c3daaf99de52415d20b42f8e8155c78642cb04207d02f9d317a0dcf1b3fb54", size = 18419647, upload-time = "2026-06-21T20:56:32.41Z" }, + { url = "https://files.pythonhosted.org/packages/71/e2/f5d1676b1d7fb682eb5e9a1641e7ebd2414b3216c370661d1029778908b4/numpy-2.5.0-cp312-cp312-win32.whl", hash = "sha256:6206db0af545d73d068add6d992279145f158428d1da6cc49adc4b630c5d6ee5", size = 6056688, upload-time = "2026-06-21T20:56:34.657Z" }, + { url = "https://files.pythonhosted.org/packages/8f/7c/48f115d1c58a34032facebcd51fdf2d02df2c51d4a46a81dd1197bb2ea6b/numpy-2.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:6f2d6873e2940c860a309d21e25b1e69af6aaffdd80aa056b04c16380db1c4f2", size = 12419237, upload-time = "2026-06-21T20:56:36.24Z" }, + { url = "https://files.pythonhosted.org/packages/86/26/2e0882f4044d1b1a1b63e875151fb2393389032022a8b7f5657a7996d3b2/numpy-2.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:a55e1eb2bca2cfd17a16b213c99dfc8502d47b0d494224d2122277d0400935ca", size = 10339912, upload-time = "2026-06-21T20:56:38.733Z" }, + { url = "https://files.pythonhosted.org/packages/8a/33/07675aaad7f26ea013d5e884d9a0d784b79c6bd7566c333f5a52fa3c610b/numpy-2.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:520e6b8be0a4b65840ac8090d4f51cef4bed66e2b0894d5a520f099adc24a9b2", size = 16784890, upload-time = "2026-06-21T20:56:40.799Z" }, + { url = "https://files.pythonhosted.org/packages/85/4b/953118a730ee3b35e28645e0eb4cf9beec5bdbb954e1ac2f5fcefba6bbc3/numpy-2.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:146b81cdd3967fdb6beca8ba25f00c58741d8f3cbd797f55af0fbe0bfec3469c", size = 11754584, upload-time = "2026-06-21T20:56:43.094Z" }, + { url = "https://files.pythonhosted.org/packages/44/9b/56dd530c367c74ae17411027cea4135ca57e1e0583bf5594cee18bd83217/numpy-2.5.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:126b88d95e8ff9b00c9e717aa540469f21d6180162f84c0caec51b16215d49cd", size = 5313904, upload-time = "2026-06-21T20:56:45.503Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b0/bcd672edad27ecca7da1f7bb0ce72cd1706a4f2d79ae94990afc97c13e1c/numpy-2.5.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d4313cef1594c5ce46c31b6e54e918338f63f16ee9322304e8c9114d6d81c8bd", size = 6648504, upload-time = "2026-06-21T20:56:47.567Z" }, + { url = "https://files.pythonhosted.org/packages/80/9e/15cdfcbd30a1544a46c9e487a00df331c4672450216538705a9e51fa6710/numpy-2.5.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:750fb097caf26fa878746d9d119f6f9da12dedcbff1eea966c3e3447647c4a9e", size = 15150086, upload-time = "2026-06-21T20:56:49.352Z" }, + { url = "https://files.pythonhosted.org/packages/32/4e/8d7656ccaab3e81e97258b8a9bc5f0c8502513a92fb4ceb0a2cbfebc17bf/numpy-2.5.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3893adc2dc7c0412ba76777db55a049215d99c9aa3113003be8f49f4f1290ab9", size = 16647250, upload-time = "2026-06-21T20:56:51.542Z" }, + { url = "https://files.pythonhosted.org/packages/3c/81/97060281b602ed07f21b12f4ec409eac1f75a2f91fbc829ed8b2becf3ad4/numpy-2.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:835e454dd99b238cdc5a3f63bce2371296f5ebc53ca1e0f8e6ddbb6d92a29aab", size = 16512864, upload-time = "2026-06-21T20:56:55.401Z" }, + { url = "https://files.pythonhosted.org/packages/33/ab/4496208146911f8d8ddb54f68a972aafa6c8d44babcb2ea03b0e5cc87c9d/numpy-2.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f9836778081a0a3c02a6a21493f3e9f5b311f8d2541934f31f05583dc999ea4", size = 18408407, upload-time = "2026-06-21T20:56:57.75Z" }, + { url = "https://files.pythonhosted.org/packages/d4/9f/a4df67c181e4ee8b467aa3332dc2db10fd5c515136831302f3ca48bc0a01/numpy-2.5.0-cp313-cp313-win32.whl", hash = "sha256:0b525be4744b60bb0557ac872d53ef07d085b5f39622bc579c98d3809d05b988", size = 6054431, upload-time = "2026-06-21T20:57:00.016Z" }, + { url = "https://files.pythonhosted.org/packages/30/53/491e1c47c55b62ccc6a63c1c5b8635c73fc2258dddeb9bda27cae4a0ae96/numpy-2.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:44353e2878930039db472b99dc353d749826e4010bd4d2a7f835e94a97a5c748", size = 12414420, upload-time = "2026-06-21T20:57:01.815Z" }, + { url = "https://files.pythonhosted.org/packages/eb/4a/25c2906f541e9d9f4c5769764db732e6627be91a13f4724fa10634d77db4/numpy-2.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:48f54b00711f83a5f796b70c518e8c2b3c5848dda03a54911f23eb68519b9b60", size = 10339533, upload-time = "2026-06-21T20:57:03.961Z" }, +] + +[[package]] +name = "nvidia-cublas" +version = "13.1.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cuda-nvrtc", marker = "sys_platform != 'darwin'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" }, + { url = "https://files.pythonhosted.org/packages/3b/cd/154ca20c38269e05eff77c1464e6c1da89f50a6390b565e9d82e06bc11e1/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:37936a16db8fe4ac1f065c2139360608a543a09275cb1a1af612e08cfa065436", size = 423138758, upload-time = "2026-04-08T18:46:58.655Z" }, +] + +[[package]] +name = "nvidia-cuda-cupti" +version = "13.0.85" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/2a/80353b103fc20ce05ef51e928daed4b6015db4aaa9162ed0997090fe2250/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_aarch64.whl", hash = "sha256:796bd679890ee55fb14a94629b698b6db54bcfd833d391d5e94017dd9d7d3151", size = 10310827, upload-time = "2025-09-04T08:26:42.012Z" }, + { url = "https://files.pythonhosted.org/packages/33/6d/737d164b4837a9bbd202f5ae3078975f0525a55730fe871d8ed4e3b952b0/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_x86_64.whl", hash = "sha256:4eb01c08e859bf924d222250d2e8f8b8ff6d3db4721288cf35d14252a4d933c8", size = 10715597, upload-time = "2025-09-04T08:26:51.312Z" }, +] + +[[package]] +name = "nvidia-cuda-nvrtc" +version = "13.0.88" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/68/483a78f5e8f31b08fb1bb671559968c0ca3a065ac7acabfc7cee55214fd6/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:ad9b6d2ead2435f11cbb6868809d2adeeee302e9bb94bcf0539c7a40d80e8575", size = 90215200, upload-time = "2025-09-04T08:28:44.204Z" }, + { url = "https://files.pythonhosted.org/packages/b7/dc/6bb80850e0b7edd6588d560758f17e0550893a1feaf436807d64d2da040f/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d27f20a0ca67a4bb34268a5e951033496c5b74870b868bacd046b1b8e0c3267b", size = 43015449, upload-time = "2025-09-04T08:28:20.239Z" }, +] + +[[package]] +name = "nvidia-cuda-runtime" +version = "13.0.96" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/4f/17d7b9b8e285199c58ce28e31b5c5bbaa4d8271af06a89b6405258245de2/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ef9bcbe90493a2b9d810e43d249adb3d02e98dd30200d86607d8d02687c43f55", size = 2261060, upload-time = "2025-10-09T08:55:15.78Z" }, + { url = "https://files.pythonhosted.org/packages/2e/24/d1558f3b68b1d26e706813b1d10aa1d785e4698c425af8db8edc3dced472/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f82250d7782aa23b6cfe765ecc7db554bd3c2870c43f3d1821f1d18aebf0548", size = 2243632, upload-time = "2025-10-09T08:55:36.117Z" }, +] + +[[package]] +name = "nvidia-cudnn-cu13" +version = "9.20.0.48" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas", marker = "sys_platform != 'darwin'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" }, + { url = "https://files.pythonhosted.org/packages/6e/5e/edb9c0ae051602c3ccaffe424256463636d639e27d7f302dde9975ef9e7a/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:0c45dd8eeb50b603f07995b1b300c62ffe6a1980482b82b3bcf94a4ca9d49304", size = 366173588, upload-time = "2026-03-09T19:29:34.474Z" }, +] + +[[package]] +name = "nvidia-cufft" +version = "12.0.0.61" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink", marker = "sys_platform != 'darwin'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2f/7b57e29836ea8714f81e9898409196f47d772d5ddedddf1592eadb8ab743/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6c44f692dce8fd5ffd3e3df134b6cdb9c2f72d99cf40b62c32dde45eea9ddad3", size = 214085489, upload-time = "2025-09-04T08:31:56.044Z" }, +] + +[[package]] +name = "nvidia-cufile" +version = "1.15.1.6" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/70/4f193de89a48b71714e74602ee14d04e4019ad36a5a9f20c425776e72cd6/nvidia_cufile-1.15.1.6-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:08a3ecefae5a01c7f5117351c64f17c7c62efa5fffdbe24fc7d298da19cd0b44", size = 1223672, upload-time = "2025-09-04T08:32:22.779Z" }, + { url = "https://files.pythonhosted.org/packages/ab/73/cc4a14c9813a8a0d509417cf5f4bdaba76e924d58beb9864f5a7baceefbf/nvidia_cufile-1.15.1.6-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:bdc0deedc61f548bddf7733bdc216456c2fdb101d020e1ab4b88d232d5e2f6d1", size = 1136992, upload-time = "2025-09-04T08:32:14.119Z" }, +] + +[[package]] +name = "nvidia-curand" +version = "10.4.0.35" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/72/7c2ae24fb6b63a32e6ae5d241cc65263ea18d08802aaae087d9f013335a2/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:133df5a7509c3e292aaa2b477afd0194f06ce4ea24d714d616ff36439cee349a", size = 61962106, upload-time = "2025-08-04T10:21:41.128Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9f/be0a41ca4a4917abf5cb9ae0daff1a6060cc5de950aec0396de9f3b52bc5/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:1aee33a5da6e1db083fe2b90082def8915f30f3248d5896bcec36a579d941bfc", size = 59544258, upload-time = "2025-08-04T10:22:03.992Z" }, +] + +[[package]] +name = "nvidia-cusolver" +version = "12.0.4.66" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas", marker = "sys_platform != 'darwin'" }, + { name = "nvidia-cusparse", marker = "sys_platform != 'darwin'" }, + { name = "nvidia-nvjitlink", marker = "sys_platform != 'darwin'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, + { url = "https://files.pythonhosted.org/packages/5f/67/cba3777620cdacb99102da4042883709c41c709f4b6323c10781a9c3aa34/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:0a759da5dea5c0ea10fd307de75cdeb59e7ea4fcb8add0924859b944babf1112", size = 200941980, upload-time = "2025-09-04T08:33:22.767Z" }, +] + +[[package]] +name = "nvidia-cusparse" +version = "12.6.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink", marker = "sys_platform != 'darwin'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, + { url = "https://files.pythonhosted.org/packages/fa/18/623c77619c31d62efd55302939756966f3ecc8d724a14dab2b75f1508850/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b3c89c88d01ee0e477cb7f82ef60a11a4bcd57b6b87c33f789350b59759360b", size = 145942937, upload-time = "2025-09-04T08:33:58.029Z" }, +] + +[[package]] +name = "nvidia-cusparselt-cu13" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/e1/cdc1797eadf82d3a9a575a19b33fdc871a97edbec42c00b5b5e914f4aff4/nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:4dca476c50bf4780d46cd0bfbd82e2bc10a08e4fef7950917ce8d7578d22a23f", size = 221051344, upload-time = "2025-09-05T18:49:51.289Z" }, + { url = "https://files.pythonhosted.org/packages/34/7d/2661f2fb3ac4302f3a246f5fc030213ac60c1fe0bce84f9783dbd831dbb7/nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:786ce87568c303fadb5afcc7102d454cd3040d75f6f8626f5db460d1871f4dd0", size = 170148586, upload-time = "2025-09-05T18:50:50.248Z" }, +] + +[[package]] +name = "nvidia-nccl-cu13" +version = "2.29.7" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/0d/daf50d44177ee0cbc7ff0a0c91eb5ff676c82be42f9a970bc7597f440c3a/nvidia_nccl_cu13-2.29.7-py3-none-manylinux_2_18_aarch64.whl", hash = "sha256:674a12383e3c38a1bcccae7d4f3633b37852230b6047883cb2f4c2d1b36d9bf5", size = 206014712, upload-time = "2026-03-03T05:34:20.843Z" }, + { url = "https://files.pythonhosted.org/packages/67/f4/58e4e91b6919367c7aafb8e36fce9aad1a3047e536bf7e2fd560927d3a4c/nvidia_nccl_cu13-2.29.7-py3-none-manylinux_2_18_x86_64.whl", hash = "sha256:edd81538446786ec3b73972543e53bb43bcaf0bfc8ef76cb679fcc390ffe136d", size = 205976000, upload-time = "2026-03-03T05:36:24.472Z" }, +] + +[[package]] +name = "nvidia-nvjitlink" +version = "13.0.88" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/7a/123e033aaff487c77107195fa5a2b8686795ca537935a24efae476c41f05/nvidia_nvjitlink-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:13a74f429e23b921c1109976abefacc69835f2f433ebd323d3946e11d804e47b", size = 40713933, upload-time = "2025-09-04T08:35:43.553Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2c/93c5250e64df4f894f1cbb397c6fd71f79813f9fd79d7cd61de3f97b3c2d/nvidia_nvjitlink-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e931536ccc7d467a98ba1d8b89ff7fa7f1fa3b13f2b0069118cd7f47bff07d0c", size = 38768748, upload-time = "2025-09-04T08:35:20.008Z" }, +] + +[[package]] +name = "nvidia-nvshmem-cu13" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/0f/05cc9c720236dcd2db9c1ab97fff629e96821be2e63103569da0c9b72f19/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dc2a197f38e5d0376ad52cd1a2a3617d3cdc150fd5966f4aee9bcebb1d68fe9", size = 60215947, upload-time = "2025-09-06T00:32:20.022Z" }, + { url = "https://files.pythonhosted.org/packages/3c/35/a9bf80a609e74e3b000fef598933235c908fcefcef9026042b8e6dfde2a9/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:290f0a2ee94c9f3687a02502f3b9299a9f9fe826e6d0287ee18482e78d495b80", size = 60412546, upload-time = "2025-09-06T00:32:41.564Z" }, +] + +[[package]] +name = "nvidia-nvtx" +version = "13.0.85" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/f3/d86c845465a2723ad7e1e5c36dcd75ddb82898b3f53be47ebd429fb2fa5d/nvidia_nvtx-13.0.85-py3-none-manylinux1_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4936d1d6780fbe68db454f5e72a42ff64d1fd6397df9f363ae786930fd5c1cd4", size = 148047, upload-time = "2025-09-04T08:29:01.761Z" }, + { url = "https://files.pythonhosted.org/packages/a8/64/3708a90d1ebe202ffdeb7185f878a3c84d15c2b2c31858da2ce0583e2def/nvidia_nvtx-13.0.85-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb7780edb6b14107373c835bf8b72e7a178bac7367e23da7acb108f973f157a6", size = 148878, upload-time = "2025-09-04T08:28:53.627Z" }, +] + +[[package]] +name = "openai" +version = "1.109.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/a1/a303104dc55fc546a3f6914c842d3da471c64eec92043aef8f652eb6c524/openai-1.109.1.tar.gz", hash = "sha256:d173ed8dbca665892a6db099b4a2dfac624f94d20a93f46eb0b56aae940ed869", size = 564133, upload-time = "2025-09-24T13:00:53.075Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/2a/7dd3d207ec669cacc1f186fd856a0f61dbc255d24f6fdc1a6715d6051b0f/openai-1.109.1-py3-none-any.whl", hash = "sha256:6bcaf57086cf59159b8e27447e4e7dd019db5d29a438072fbd49c290c7e65315", size = 948627, upload-time = "2025-09-24T13:00:50.754Z" }, +] + +[[package]] +name = "opencv-python-headless" +version = "4.11.0.86" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/2f/5b2b3ba52c864848885ba988f24b7f105052f68da9ab0e693cc7c25b0b30/opencv-python-headless-4.11.0.86.tar.gz", hash = "sha256:996eb282ca4b43ec6a3972414de0e2331f5d9cda2b41091a49739c19fb843798", size = 95177929, upload-time = "2025-01-16T13:53:40.22Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/53/2c50afa0b1e05ecdb4603818e85f7d174e683d874ef63a6abe3ac92220c8/opencv_python_headless-4.11.0.86-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:48128188ade4a7e517237c8e1e11a9cdf5c282761473383e77beb875bb1e61ca", size = 37326460, upload-time = "2025-01-16T13:52:57.015Z" }, + { url = "https://files.pythonhosted.org/packages/3b/43/68555327df94bb9b59a1fd645f63fafb0762515344d2046698762fc19d58/opencv_python_headless-4.11.0.86-cp37-abi3-macosx_13_0_x86_64.whl", hash = "sha256:a66c1b286a9de872c343ee7c3553b084244299714ebb50fbdcd76f07ebbe6c81", size = 56723330, upload-time = "2025-01-16T13:55:45.731Z" }, + { url = "https://files.pythonhosted.org/packages/45/be/1438ce43ebe65317344a87e4b150865c5585f4c0db880a34cdae5ac46881/opencv_python_headless-4.11.0.86-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6efabcaa9df731f29e5ea9051776715b1bdd1845d7c9530065c7951d2a2899eb", size = 29487060, upload-time = "2025-01-16T13:51:59.625Z" }, + { url = "https://files.pythonhosted.org/packages/dd/5c/c139a7876099916879609372bfa513b7f1257f7f1a908b0bdc1c2328241b/opencv_python_headless-4.11.0.86-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e0a27c19dd1f40ddff94976cfe43066fbbe9dfbb2ec1907d66c19caef42a57b", size = 49969856, upload-time = "2025-01-16T13:53:29.654Z" }, + { url = "https://files.pythonhosted.org/packages/95/dd/ed1191c9dc91abcc9f752b499b7928aacabf10567bb2c2535944d848af18/opencv_python_headless-4.11.0.86-cp37-abi3-win32.whl", hash = "sha256:f447d8acbb0b6f2808da71fddd29c1cdd448d2bc98f72d9bb78a7a898fc9621b", size = 29324425, upload-time = "2025-01-16T13:52:49.048Z" }, + { url = "https://files.pythonhosted.org/packages/86/8a/69176a64335aed183529207ba8bc3d329c2999d852b4f3818027203f50e6/opencv_python_headless-4.11.0.86-cp37-abi3-win_amd64.whl", hash = "sha256:6c304df9caa7a6a5710b91709dd4786bf20a74d57672b3c31f7033cc638174ca", size = 39402386, upload-time = "2025-01-16T13:52:56.418Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pillow" +version = "10.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/74/ad3d526f3bf7b6d3f408b73fde271ec69dfac8b81341a318ce825f2b3812/pillow-10.4.0.tar.gz", hash = "sha256:166c1cd4d24309b30d61f79f4a9114b7b2313d7450912277855ff5dfd7cd4a06", size = 46555059, upload-time = "2024-07-01T09:48:43.583Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/cb/0353013dc30c02a8be34eb91d25e4e4cf594b59e5a55ea1128fde1e5f8ea/pillow-10.4.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:673655af3eadf4df6b5457033f086e90299fdd7a47983a13827acf7459c15d94", size = 3509350, upload-time = "2024-07-01T09:46:17.177Z" }, + { url = "https://files.pythonhosted.org/packages/e7/cf/5c558a0f247e0bf9cec92bff9b46ae6474dd736f6d906315e60e4075f737/pillow-10.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:866b6942a92f56300012f5fbac71f2d610312ee65e22f1aa2609e491284e5597", size = 3374980, upload-time = "2024-07-01T09:46:19.169Z" }, + { url = "https://files.pythonhosted.org/packages/84/48/6e394b86369a4eb68b8a1382c78dc092245af517385c086c5094e3b34428/pillow-10.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29dbdc4207642ea6aad70fbde1a9338753d33fb23ed6956e706936706f52dd80", size = 4343799, upload-time = "2024-07-01T09:46:21.883Z" }, + { url = "https://files.pythonhosted.org/packages/3b/f3/a8c6c11fa84b59b9df0cd5694492da8c039a24cd159f0f6918690105c3be/pillow-10.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf2342ac639c4cf38799a44950bbc2dfcb685f052b9e262f446482afaf4bffca", size = 4459973, upload-time = "2024-07-01T09:46:24.321Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1b/c14b4197b80150fb64453585247e6fb2e1d93761fa0fa9cf63b102fde822/pillow-10.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f5b92f4d70791b4a67157321c4e8225d60b119c5cc9aee8ecf153aace4aad4ef", size = 4370054, upload-time = "2024-07-01T09:46:26.825Z" }, + { url = "https://files.pythonhosted.org/packages/55/77/40daddf677897a923d5d33329acd52a2144d54a9644f2a5422c028c6bf2d/pillow-10.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:86dcb5a1eb778d8b25659d5e4341269e8590ad6b4e8b44d9f4b07f8d136c414a", size = 4539484, upload-time = "2024-07-01T09:46:29.355Z" }, + { url = "https://files.pythonhosted.org/packages/40/54/90de3e4256b1207300fb2b1d7168dd912a2fb4b2401e439ba23c2b2cabde/pillow-10.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:780c072c2e11c9b2c7ca37f9a2ee8ba66f44367ac3e5c7832afcfe5104fd6d1b", size = 4477375, upload-time = "2024-07-01T09:46:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/13/24/1bfba52f44193860918ff7c93d03d95e3f8748ca1de3ceaf11157a14cf16/pillow-10.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37fb69d905be665f68f28a8bba3c6d3223c8efe1edf14cc4cfa06c241f8c81d9", size = 4608773, upload-time = "2024-07-01T09:46:33.73Z" }, + { url = "https://files.pythonhosted.org/packages/55/04/5e6de6e6120451ec0c24516c41dbaf80cce1b6451f96561235ef2429da2e/pillow-10.4.0-cp312-cp312-win32.whl", hash = "sha256:7dfecdbad5c301d7b5bde160150b4db4c659cee2b69589705b6f8a0c509d9f42", size = 2235690, upload-time = "2024-07-01T09:46:36.587Z" }, + { url = "https://files.pythonhosted.org/packages/74/0a/d4ce3c44bca8635bd29a2eab5aa181b654a734a29b263ca8efe013beea98/pillow-10.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1d846aea995ad352d4bdcc847535bd56e0fd88d36829d2c90be880ef1ee4668a", size = 2554951, upload-time = "2024-07-01T09:46:38.777Z" }, + { url = "https://files.pythonhosted.org/packages/b5/ca/184349ee40f2e92439be9b3502ae6cfc43ac4b50bc4fc6b3de7957563894/pillow-10.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:e553cad5179a66ba15bb18b353a19020e73a7921296a7979c4a2b7f6a5cd57f9", size = 2243427, upload-time = "2024-07-01T09:46:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/c3/00/706cebe7c2c12a6318aabe5d354836f54adff7156fd9e1bd6c89f4ba0e98/pillow-10.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8bc1a764ed8c957a2e9cacf97c8b2b053b70307cf2996aafd70e91a082e70df3", size = 3525685, upload-time = "2024-07-01T09:46:45.194Z" }, + { url = "https://files.pythonhosted.org/packages/cf/76/f658cbfa49405e5ecbfb9ba42d07074ad9792031267e782d409fd8fe7c69/pillow-10.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6209bb41dc692ddfee4942517c19ee81b86c864b626dbfca272ec0f7cff5d9fb", size = 3374883, upload-time = "2024-07-01T09:46:47.331Z" }, + { url = "https://files.pythonhosted.org/packages/46/2b/99c28c4379a85e65378211971c0b430d9c7234b1ec4d59b2668f6299e011/pillow-10.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bee197b30783295d2eb680b311af15a20a8b24024a19c3a26431ff83eb8d1f70", size = 4339837, upload-time = "2024-07-01T09:46:49.647Z" }, + { url = "https://files.pythonhosted.org/packages/f1/74/b1ec314f624c0c43711fdf0d8076f82d9d802afd58f1d62c2a86878e8615/pillow-10.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ef61f5dd14c300786318482456481463b9d6b91ebe5ef12f405afbba77ed0be", size = 4455562, upload-time = "2024-07-01T09:46:51.811Z" }, + { url = "https://files.pythonhosted.org/packages/4a/2a/4b04157cb7b9c74372fa867096a1607e6fedad93a44deeff553ccd307868/pillow-10.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:297e388da6e248c98bc4a02e018966af0c5f92dfacf5a5ca22fa01cb3179bca0", size = 4366761, upload-time = "2024-07-01T09:46:53.961Z" }, + { url = "https://files.pythonhosted.org/packages/ac/7b/8f1d815c1a6a268fe90481232c98dd0e5fa8c75e341a75f060037bd5ceae/pillow-10.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:e4db64794ccdf6cb83a59d73405f63adbe2a1887012e308828596100a0b2f6cc", size = 4536767, upload-time = "2024-07-01T09:46:56.664Z" }, + { url = "https://files.pythonhosted.org/packages/e5/77/05fa64d1f45d12c22c314e7b97398ffb28ef2813a485465017b7978b3ce7/pillow-10.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd2880a07482090a3bcb01f4265f1936a903d70bc740bfcb1fd4e8a2ffe5cf5a", size = 4477989, upload-time = "2024-07-01T09:46:58.977Z" }, + { url = "https://files.pythonhosted.org/packages/12/63/b0397cfc2caae05c3fb2f4ed1b4fc4fc878f0243510a7a6034ca59726494/pillow-10.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b35b21b819ac1dbd1233317adeecd63495f6babf21b7b2512d244ff6c6ce309", size = 4610255, upload-time = "2024-07-01T09:47:01.189Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f9/cfaa5082ca9bc4a6de66ffe1c12c2d90bf09c309a5f52b27759a596900e7/pillow-10.4.0-cp313-cp313-win32.whl", hash = "sha256:551d3fd6e9dc15e4c1eb6fc4ba2b39c0c7933fa113b220057a34f4bb3268a060", size = 2235603, upload-time = "2024-07-01T09:47:03.918Z" }, + { url = "https://files.pythonhosted.org/packages/01/6a/30ff0eef6e0c0e71e55ded56a38d4859bf9d3634a94a88743897b5f96936/pillow-10.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:030abdbe43ee02e0de642aee345efa443740aa4d828bfe8e2eb11922ea6a21ea", size = 2554972, upload-time = "2024-07-01T09:47:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/48/2c/2e0a52890f269435eee38b21c8218e102c621fe8d8df8b9dd06fabf879ba/pillow-10.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:5b001114dd152cfd6b23befeb28d7aee43553e2402c9f159807bf55f33af8a8d", size = 2243375, upload-time = "2024-07-01T09:47:09.065Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pypdfium2" +version = "4.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/14/838b3ba247a0ba92e4df5d23f2bea9478edcfd72b78a39d6ca36ccd84ad2/pypdfium2-4.30.0.tar.gz", hash = "sha256:48b5b7e5566665bc1015b9d69c1ebabe21f6aee468b509531c3c8318eeee2e16", size = 140239, upload-time = "2024-05-09T18:33:17.552Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/9a/c8ff5cc352c1b60b0b97642ae734f51edbab6e28b45b4fcdfe5306ee3c83/pypdfium2-4.30.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:b33ceded0b6ff5b2b93bc1fe0ad4b71aa6b7e7bd5875f1ca0cdfb6ba6ac01aab", size = 2837254, upload-time = "2024-05-09T18:32:48.653Z" }, + { url = "https://files.pythonhosted.org/packages/21/8b/27d4d5409f3c76b985f4ee4afe147b606594411e15ac4dc1c3363c9a9810/pypdfium2-4.30.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:4e55689f4b06e2d2406203e771f78789bd4f190731b5d57383d05cf611d829de", size = 2707624, upload-time = "2024-05-09T18:32:51.458Z" }, + { url = "https://files.pythonhosted.org/packages/11/63/28a73ca17c24b41a205d658e177d68e198d7dde65a8c99c821d231b6ee3d/pypdfium2-4.30.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e6e50f5ce7f65a40a33d7c9edc39f23140c57e37144c2d6d9e9262a2a854854", size = 2793126, upload-time = "2024-05-09T18:32:53.581Z" }, + { url = "https://files.pythonhosted.org/packages/d1/96/53b3ebf0955edbd02ac6da16a818ecc65c939e98fdeb4e0958362bd385c8/pypdfium2-4.30.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3d0dd3ecaffd0b6dbda3da663220e705cb563918249bda26058c6036752ba3a2", size = 2591077, upload-time = "2024-05-09T18:32:55.99Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ee/0394e56e7cab8b5b21f744d988400948ef71a9a892cbeb0b200d324ab2c7/pypdfium2-4.30.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cc3bf29b0db8c76cdfaac1ec1cde8edf211a7de7390fbf8934ad2aa9b4d6dfad", size = 2864431, upload-time = "2024-05-09T18:32:57.911Z" }, + { url = "https://files.pythonhosted.org/packages/65/cd/3f1edf20a0ef4a212a5e20a5900e64942c5a374473671ac0780eaa08ea80/pypdfium2-4.30.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f1f78d2189e0ddf9ac2b7a9b9bd4f0c66f54d1389ff6c17e9fd9dc034d06eb3f", size = 2812008, upload-time = "2024-05-09T18:32:59.886Z" }, + { url = "https://files.pythonhosted.org/packages/c8/91/2d517db61845698f41a2a974de90762e50faeb529201c6b3574935969045/pypdfium2-4.30.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:5eda3641a2da7a7a0b2f4dbd71d706401a656fea521b6b6faa0675b15d31a163", size = 6181543, upload-time = "2024-05-09T18:33:02.597Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c4/ed1315143a7a84b2c7616569dfb472473968d628f17c231c39e29ae9d780/pypdfium2-4.30.0-py3-none-musllinux_1_1_i686.whl", hash = "sha256:0dfa61421b5eb68e1188b0b2231e7ba35735aef2d867d86e48ee6cab6975195e", size = 6175911, upload-time = "2024-05-09T18:33:05.376Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c4/9e62d03f414e0e3051c56d5943c3bf42aa9608ede4e19dc96438364e9e03/pypdfium2-4.30.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:f33bd79e7a09d5f7acca3b0b69ff6c8a488869a7fab48fdf400fec6e20b9c8be", size = 6267430, upload-time = "2024-05-09T18:33:08.067Z" }, + { url = "https://files.pythonhosted.org/packages/90/47/eda4904f715fb98561e34012826e883816945934a851745570521ec89520/pypdfium2-4.30.0-py3-none-win32.whl", hash = "sha256:ee2410f15d576d976c2ab2558c93d392a25fb9f6635e8dd0a8a3a5241b275e0e", size = 2775951, upload-time = "2024-05-09T18:33:10.567Z" }, + { url = "https://files.pythonhosted.org/packages/25/bd/56d9ec6b9f0fc4e0d95288759f3179f0fcd34b1a1526b75673d2f6d5196f/pypdfium2-4.30.0-py3-none-win_amd64.whl", hash = "sha256:90dbb2ac07be53219f56be09961eb95cf2473f834d01a42d901d13ccfad64b4c", size = 2892098, upload-time = "2024-05-09T18:33:13.107Z" }, + { url = "https://files.pythonhosted.org/packages/be/7a/097801205b991bc3115e8af1edb850d30aeaf0118520b016354cf5ccd3f6/pypdfium2-4.30.0-py3-none-win_arm64.whl", hash = "sha256:119b2969a6d6b1e8d55e99caaf05290294f2d0fe49c12a3f17102d01c441bd29", size = 2752118, upload-time = "2024-05-09T18:33:15.489Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, +] + +[[package]] +name = "regex" +version = "2026.5.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/0e/49aee608ad09480e7fd276898c99ec6192985fa331abe4eb3a986094490b/regex-2026.5.9.tar.gz", hash = "sha256:a8234aa23ec39894bfe4a3f1b85616a7032481964a13ac6fc9f10de4f6fca270", size = 416074, upload-time = "2026-05-09T23:15:19.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/9b/6550044bc44e17c84d312c031c2ec42fbdb6a4ec4e29093be3a172d08772/regex-2026.5.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:57eeeb05db7979413dec5438f2db21d7ecbba787cde7a711df1a6f6df672aa06", size = 490451, upload-time = "2026-05-09T23:12:34.72Z" }, + { url = "https://files.pythonhosted.org/packages/1e/95/fc7ba4303b5a0f92446a12ee6778ef2c6c799233f5060042a31bf390cfe9/regex-2026.5.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:398c521292f4c7fb807001dcd54694d3a1fcafc179a36ad9cc56f98df85930b6", size = 292112, upload-time = "2026-05-09T23:12:36.285Z" }, + { url = "https://files.pythonhosted.org/packages/54/4b/ee27938d1b2c443e89a9a10e00d2d19aa5ee300cd3d61140644e93bb083e/regex-2026.5.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f7a7c26137296beba7784de6eba69c6a93a63ccebc385e4962fe67e267a91225", size = 289599, upload-time = "2026-05-09T23:12:38.089Z" }, + { url = "https://files.pythonhosted.org/packages/d8/dd/ba103dc19614e25f3880800ca67ce093d6e21b325d72b8383c7bf906e9fa/regex-2026.5.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6441cc660d76107934a09c22167200839a0e89604a6297f78a974e66e931d2c0", size = 796732, upload-time = "2026-05-09T23:12:40.062Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e7/f035b4fd858b050b0080bf302968dc0f59ba34e391872d54936758e6844e/regex-2026.5.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:91328f1c23d47595ca3ef0a7557fa129c5a23404b775c770697d2f35b33e0107", size = 865440, upload-time = "2026-05-09T23:12:42.059Z" }, + { url = "https://files.pythonhosted.org/packages/0a/51/8cd301ecc899aea28124357f729f4272f44de7806fc7ca02490bfbe253e8/regex-2026.5.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:93a7860539414dddaefba2b40f8771765ae17949d4c7182b876ce429e11a8309", size = 912329, upload-time = "2026-05-09T23:12:44.373Z" }, + { url = "https://files.pythonhosted.org/packages/cc/1e/3fbe2fa1e8cebd62f3bb7d3321cff1640aca2e240b51d9bd624aad949260/regex-2026.5.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd2810d22146b6d838acc5ec15602cb6b47920aa4e33015df3868eedfd20bab8", size = 801239, upload-time = "2026-05-09T23:12:46.268Z" }, + { url = "https://files.pythonhosted.org/packages/17/2f/6f6008682bf2cf98040a0d3153a8e557b6ab728d7713d045cee4ce544ab8/regex-2026.5.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daff2bdbaf1d23e52fdff7c0b7bc2048b68f978df6a4d107ac981f94caef2e66", size = 777054, upload-time = "2026-05-09T23:12:48.051Z" }, + { url = "https://files.pythonhosted.org/packages/19/2b/eee0d20a6842ba04df4b8847a920b57ef56853f14ef85405473e586b605a/regex-2026.5.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4eeb011098fcb77af513dcef521a3dbecbf8849b1e38940759d293b7a93f5026", size = 785098, upload-time = "2026-05-09T23:12:49.851Z" }, + { url = "https://files.pythonhosted.org/packages/4a/98/6fc1e6410feefb92159edaed5041992bfe390e8d26c721865434acbca558/regex-2026.5.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ea9c8ecfa1b73c73b626534d6626e5340d429630943672b8480724f44e84b962", size = 860095, upload-time = "2026-05-09T23:12:51.666Z" }, + { url = "https://files.pythonhosted.org/packages/18/a3/bd855e0f2cb1a978ecf6fa6bb69632dd9c3f6ea3b81cde62fde14c9daec7/regex-2026.5.9-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cd2846168eb9ee3c513902bc8225409cb1caab31d04728b145171fa1625d9621", size = 765762, upload-time = "2026-05-09T23:12:53.413Z" }, + { url = "https://files.pythonhosted.org/packages/dc/66/0ae8c092e60b14c79d24f8e0b7f0aea5bfbffdcab00b5483d13404d3c3a5/regex-2026.5.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:39617fb0cde9c0e6306dc70e3bfc096f3da793219879f7ae7aa341a69fbdcf6d", size = 852100, upload-time = "2026-05-09T23:12:55.256Z" }, + { url = "https://files.pythonhosted.org/packages/21/de/8dfde60fc1b21c946a893ba273403b72617edb261370cb1087099a83f088/regex-2026.5.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd03c4f0e33280d15cae17159b899245d6b7c53d21def19b263b39655061f5ce", size = 789479, upload-time = "2026-05-09T23:12:57.573Z" }, + { url = "https://files.pythonhosted.org/packages/c3/1c/bdcc98f9a4af4fdd166c74941174619ccff4726d3ce32faa8e9a2ecd38dd/regex-2026.5.9-cp312-cp312-win32.whl", hash = "sha256:164eba9b755ea6f244b0d881196fbc1fac09714e9782c9e2732b813142033c8e", size = 266699, upload-time = "2026-05-09T23:12:59.14Z" }, + { url = "https://files.pythonhosted.org/packages/78/87/240d36864f9e48ace85f72e79ced97ceb7f27ce87739a947dcb834b4e6bc/regex-2026.5.9-cp312-cp312-win_amd64.whl", hash = "sha256:86f40a5d6444db30a125c9c9177e6b25dad981cbc37451fd838f145e6edac92e", size = 277783, upload-time = "2026-05-09T23:13:00.789Z" }, + { url = "https://files.pythonhosted.org/packages/4f/b5/7b30f312b0669dff5beebe5b0989dc2d1a312b1a44fab852199c387a5b96/regex-2026.5.9-cp312-cp312-win_arm64.whl", hash = "sha256:96f5f58b54a063d7ea9dca08e1cf57bfe10499c4d579ee672da284f57f5f0070", size = 270513, upload-time = "2026-05-09T23:13:02.426Z" }, + { url = "https://files.pythonhosted.org/packages/aa/da/797e91ecec6f84135da778ddce78c20e0af5d2a15c26f87a81bc3eadb6db/regex-2026.5.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d626b84406444b165fc0ba981604edea39f0588ff1f92baa23fe50799ea9afdb", size = 490303, upload-time = "2026-05-09T23:13:04.382Z" }, + { url = "https://files.pythonhosted.org/packages/44/da/bf30abaaa737b58f4a4b8c4a03659e02fd92092c822e0197ed9e0daab917/regex-2026.5.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d7bdc0ab8f3dd7e1b4f9ab88634e13374669db86bb3c72e8292f07ae313f539f", size = 292019, upload-time = "2026-05-09T23:13:06.022Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e7/d0eaf5713828417b9e5648cf81fa9bacd4961f6ab98c380c2034f8716e35/regex-2026.5.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a8820737949116ffff55fe18f9fc644530063ba6ebfcb8314239416e78f1347c", size = 289468, upload-time = "2026-05-09T23:13:08.214Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9b/b3fdd62b003baa1a9b593cd8c8699c9651c2e80cc21a5c715707983c42d7/regex-2026.5.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0fbdbac82cb3e4450d0ccde7d7a35607f4cb2dd9fba4b8b69bfaf8c9fa6aed", size = 796749, upload-time = "2026-05-09T23:13:10.573Z" }, + { url = "https://files.pythonhosted.org/packages/d4/30/66ab84588765f5b4b271a9ca09ef7ce2b87caa95176ec3d2ad65d7bc4902/regex-2026.5.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:57e8915c7986aa33d25e4d3629cef711cd2863f2961b10409f0c04cb8b7d9020", size = 865445, upload-time = "2026-05-09T23:13:12.523Z" }, + { url = "https://files.pythonhosted.org/packages/1a/89/f05169e8588aac365f35ffc7f3bc3184f095ef4cfded7cfaa3c7fd5dbd89/regex-2026.5.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:508f56a89ba9cb26e4168cbc37dbd60a28d82430a9e18ad1d25fe0883c314ca2", size = 912322, upload-time = "2026-05-09T23:13:14.281Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/c93444052cf41581f3c884ab3fb5823daf0992f11cd4388d4275ca610558/regex-2026.5.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6d189041f15691cfa2b6c4290448ec221244d225b3f5fe9e7771b34ffcdf6e2", size = 801269, upload-time = "2026-05-09T23:13:16.569Z" }, + { url = "https://files.pythonhosted.org/packages/50/fe/0cf96b882f540e62e8b9956599798203d599c44cf4c77917ca27400ff69b/regex-2026.5.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e82db382b44d0111b22601c509c89f64434816c9e0eef9d1989cda8cc6ff1c04", size = 777085, upload-time = "2026-05-09T23:13:18.675Z" }, + { url = "https://files.pythonhosted.org/packages/23/5c/d78d4924e7fc875557b9e9b768423925fdfaac5549d06da7810019a9bd26/regex-2026.5.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2acfb48634f64996b57f90f39afa692ff362162722581921fe92239a59960f3c", size = 785153, upload-time = "2026-05-09T23:13:20.525Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e0/5214774090e7b4524dcea3e3c4aa74141d43043f8beb49c1599db1c8b53a/regex-2026.5.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d29eebfc9525db68cad3c97eedd7f754fa265aa5cd0cf4f863b2421e1b48fc9f", size = 860164, upload-time = "2026-05-09T23:13:22.263Z" }, + { url = "https://files.pythonhosted.org/packages/6e/e1/4a57a83350319b1271f0d7a249b8672513ed928b237a741631270de6caea/regex-2026.5.9-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:debb893095e944091c16e641a6e33c1b0f4cb61ab945ec5afbf53ce7068834d8", size = 765731, upload-time = "2026-05-09T23:13:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/12/f4/499e74a20c156fc75836ee04a72a38d1a063978f600937f9760467beb1b0/regex-2026.5.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d659eee77986549c9ea45b861c7567e44d6287c3dc9a4565478853f7b9fe2ff6", size = 852062, upload-time = "2026-05-09T23:13:26.125Z" }, + { url = "https://files.pythonhosted.org/packages/5b/92/7eebc0d0a01e78629695f342ba17e0deaff8fb45e79cc0d7b98287da6e3e/regex-2026.5.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2efa205e6d98b24d1f3ab395c11aa15cdf10935bca283d0285e0499c284fba21", size = 789577, upload-time = "2026-05-09T23:13:27.814Z" }, + { url = "https://files.pythonhosted.org/packages/05/a4/018e71f7d2ad48c1ebe6d3ae0026f9b7cb4802fd15c7cc02fdf724355102/regex-2026.5.9-cp313-cp313-win32.whl", hash = "sha256:f3844f134e834076677dd369976e9f5068679fcb8e50102fdf6b7ac96a3ec127", size = 266691, upload-time = "2026-05-09T23:13:29.549Z" }, + { url = "https://files.pythonhosted.org/packages/e6/1d/861a93719fb9ee7dbfc3761b3797b7a3e112a5d42c6129459d2d741be9b5/regex-2026.5.9-cp313-cp313-win_amd64.whl", hash = "sha256:3527bb4942d2c14552155406cdedd906567456821848aed1cb4933a391bf5eca", size = 277747, upload-time = "2026-05-09T23:13:31.859Z" }, + { url = "https://files.pythonhosted.org/packages/d9/c6/0a2436ae4da1ba76e51cb98943c6838a9a721faa40ebe2dce07694ae34e3/regex-2026.5.9-cp313-cp313-win_arm64.whl", hash = "sha256:56a33f191f17d8c417f99945ebdc1e691d3af9605d86ec68c7e54a57e3e17af6", size = 270500, upload-time = "2026-05-09T23:13:33.525Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e9/d21346f7b60ed58789371358ed66b09d00f832e1bd7c06e55d9da5679882/regex-2026.5.9-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:01f28d868834624c934b8d2e0aa1c8341337e37831f4a012f18a5afcba4cbaf3", size = 494172, upload-time = "2026-05-09T23:13:35.935Z" }, + { url = "https://files.pythonhosted.org/packages/c4/43/fd1177a2032037c681baecdb3422ee4e1424aec4e4f470ef47793d325274/regex-2026.5.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:48036f6374aaa79eb3b754ec29c61d1c6b1606749d705a13f8854fa2539671f6", size = 293952, upload-time = "2026-05-09T23:13:38.307Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7d/9fbf919768368d3f8a4f6c692cf2aa61e482b2b81ec6a298ace4cbf02480/regex-2026.5.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b96350aa424e79d4fd6b567b344dcbe2b2d6bfc48dfe7717587e1fa6d43da6ff", size = 292314, upload-time = "2026-05-09T23:13:40.353Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6c/e41bfeecb589716843e7c4df09ba46ff2a42961457afece19059d85caeef/regex-2026.5.9-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f3af7a4903c5c04a11a196a5aa75cdd7dd3f8508132f9fb3259d9f5908e3b88", size = 811681, upload-time = "2026-05-09T23:13:42.543Z" }, + { url = "https://files.pythonhosted.org/packages/87/83/a5c1c525fba0aa656e88ad0face0b1829788ef4c2fb6b26df58aa1151b84/regex-2026.5.9-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e87577720152d2caae19fe2baaf1f8d5ca12091e9e229f03915c37d1e4b9178", size = 871135, upload-time = "2026-05-09T23:13:44.326Z" }, + { url = "https://files.pythonhosted.org/packages/18/d4/80882e799e440dd878b0979cbebf8fa4d54624a332c83037c7a701649e3f/regex-2026.5.9-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c8b9b9d294cfea3cd19c718ade7cc93492b2c4991abd9a68d0b3477ae6d8e100", size = 917265, upload-time = "2026-05-09T23:13:47.295Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ff/8db60211e2286e396aad7dc7725356c502bff0901ea05bd6cdc2e1a042b9/regex-2026.5.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:728d8bfd28a8845c8b6bc5dc7ce010453d206396786c0765c2740cb65f37791e", size = 816311, upload-time = "2026-05-09T23:13:49.885Z" }, + { url = "https://files.pythonhosted.org/packages/4c/47/742ef579c61730f8d268e5cf1f9ce0e37e2ea041ad0f5644724f2378e463/regex-2026.5.9-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7e30b874d341fac767d7df5a0870540541c2c054b80cfaac116e8d367a8a7ff2", size = 785498, upload-time = "2026-05-09T23:13:52.25Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ab/cb0999802dcb0fb95b1ab005e8d4163d8afdd67efc2cb6b6630ac13f8cb1/regex-2026.5.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fd190e88a895a8901325fad284a3f74ea52b1da8525b76cc811fa9b1edf0ce2b", size = 801348, upload-time = "2026-05-09T23:13:54.127Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/8ca59a24c55bc34d166eefaf3717bd77772f329fdbf984d86581e0a3571c/regex-2026.5.9-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:8e76e8161ad00694cfce6767d5dea860c6391ac5b83e5c3a39661e696f11fc7e", size = 866493, upload-time = "2026-05-09T23:13:56.067Z" }, + { url = "https://files.pythonhosted.org/packages/8d/3d/30f2ae62cef3278bb5bb821f467277a55fb73f01032cf85997e15e8289a8/regex-2026.5.9-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ddda5340e6c01a293027dd46232fa79eaff1b48058ce7a98f572b6445b088041", size = 772811, upload-time = "2026-05-09T23:13:57.867Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ae/7d2089bcd78ad0c0161bc684339df50032acb438a7bd3305e7ddb1193cec/regex-2026.5.9-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:205109e96b3cf5adf8f4cd62bedde9487feb282b9497a3535451e5a24cd706a0", size = 856584, upload-time = "2026-05-09T23:13:59.679Z" }, + { url = "https://files.pythonhosted.org/packages/a9/29/92ff47f75990131ea4f24ba17819e5a9d141e10819807e09addd73409af6/regex-2026.5.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dfbe4579b9f08036aa7d101d1835437a20783574ac66327e6b29b4018a138081", size = 803453, upload-time = "2026-05-09T23:14:01.978Z" }, + { url = "https://files.pythonhosted.org/packages/04/99/eff29f1037dcab36702c9ee5d6858cf1ce2336ea8ea2987f64245b99ea5e/regex-2026.5.9-cp313-cp313t-win32.whl", hash = "sha256:ed2c9e8068b614c574d8d30e543d617cf5379b0535d46f97ef00e904745a08b5", size = 269951, upload-time = "2026-05-09T23:14:03.661Z" }, + { url = "https://files.pythonhosted.org/packages/0e/9d/8870b8981d27b22cda77bb26a5ac7ebfa9c7d9e0dea195a834a82380e748/regex-2026.5.9-cp313-cp313t-win_amd64.whl", hash = "sha256:b46b0f094dc1d3b90356c85a0bd2c9bafc4a6a190b9d6f8ddd5a033b6e088ed4", size = 281240, upload-time = "2026-05-09T23:14:05.56Z" }, + { url = "https://files.pythonhosted.org/packages/72/b1/3379415e8f135c13ac551353397cc4fe97b4978f3cac73c5fcbcded548b8/regex-2026.5.9-cp313-cp313t-win_arm64.whl", hash = "sha256:872acc074bd29ffc9913ecdfedf6ea77502312ca44a4aa0d3779089c6069d8de", size = 272383, upload-time = "2026-05-09T23:14:07.843Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "safetensors" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/06/f955dbbb1859e3bd23c8ac6141af5106e7ad5fedec4a3a6e3d60f94b7001/safetensors-0.8.0.tar.gz", hash = "sha256:fabaf3e0f18a6618d9b36560682562157f77c2b71fcffc7b432be2baed9d753d", size = 325846, upload-time = "2026-06-09T07:52:25.563Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/a0/f718cda65b05407d228f97602cf60dca269c979867aa5beb25410de26cd3/safetensors-0.8.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c554f85858e05226d3c2828e32395e677434685d6d94594a41643361c5e837f0", size = 473568, upload-time = "2026-06-09T07:52:18.829Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b1/fa7c600e7dceae12e9606c7578cbc9ff1e1ed55844883ee5c92205e86226/safetensors-0.8.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c80201d22cbf405b80647a60ada77bba06c8fba2da2743ba1e89cdcc39a81f25", size = 484562, upload-time = "2026-06-09T07:52:17.518Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/65a7de0af421317bb36a067241e4235fff194eed60b961ed6d3f59a3fc60/safetensors-0.8.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a46e5ff292c356d6991e60942ba7f79817682d3a2cef0702136448cb9c4d235", size = 502844, upload-time = "2026-06-09T07:52:07.624Z" }, + { url = "https://files.pythonhosted.org/packages/91/4f/3175c9d75634e0e0dda0082794193521035edd7c70a6f212bf33ca06ddf4/safetensors-0.8.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4124502b78f03534117c848f87a39b8f31e577b15eff423bf8bfb95f2a8c30d0", size = 511823, upload-time = "2026-06-09T07:52:09.565Z" }, + { url = "https://files.pythonhosted.org/packages/20/87/846c289e7aa2299eff406335717cf43ce8777194ece8aad75772e0411615/safetensors-0.8.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7bc0a787ba8a35be368ee3574edfa2b1ad389eebd0a72e482ae275490e3f6c98", size = 633461, upload-time = "2026-06-09T07:52:11.128Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/8d64d9df2c45d5ded401df889d0ad90882804ca172d79ec4f0df8f727fe0/safetensors-0.8.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:040070828e36dc8e122178bbbd5830ff9e97920affb84cbe0f46442497bed358", size = 545148, upload-time = "2026-06-09T07:52:13.603Z" }, + { url = "https://files.pythonhosted.org/packages/28/50/f203ff3a3ddfe19308efc83c5a3a29ed02bf786732ec35e68bf9162f3365/safetensors-0.8.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd6f3f93c9a0a7cc2788ee63fb763353d4bd2e89b0751bc78fcf7dda00bea774", size = 516040, upload-time = "2026-06-09T07:52:16.29Z" }, + { url = "https://files.pythonhosted.org/packages/46/fb/cdaed17ceb2948784fd9c36b6fd3e951b608547cea81a48e8ee6f8cfdfcb/safetensors-0.8.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:fcdd41ec4628fee5799f807c73c353629130fbd942aa23d83c623dd6c9d52d78", size = 513832, upload-time = "2026-06-09T07:52:12.37Z" }, + { url = "https://files.pythonhosted.org/packages/0d/49/1e15de264dcc3b77943d2d0c56a95809956883b1c2d6d585c792523f180b/safetensors-0.8.0-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8e9f537aa183a38ace122d27303dcd986b26bd2a7591f9181d7f0c396f4677ca", size = 559930, upload-time = "2026-06-09T07:52:14.743Z" }, + { url = "https://files.pythonhosted.org/packages/2a/43/bf38443278eab4b1be1fce2931e2b012ad9cb7df52ada751d0aab8f7659a/safetensors-0.8.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:87eec7ffed2b809f05a398a8becb7d013f19f7837cd15d9748580d6cf30dbaf4", size = 678670, upload-time = "2026-06-09T07:52:20.032Z" }, + { url = "https://files.pythonhosted.org/packages/72/e3/68cd3fa5b48488e84add63e04cb12f3bc28ae4638c06d4508c6e88823d0e/safetensors-0.8.0-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:4a95ae2b05d7726d751da4ebf626a2ca782b706e101bd894c95bc2450b1cffcc", size = 786679, upload-time = "2026-06-09T07:52:21.322Z" }, + { url = "https://files.pythonhosted.org/packages/29/4b/1c19c509d56e01f4fbb3d0a2e597450f6cc04d1d56cf52defb0a62dfd715/safetensors-0.8.0-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae091f16662658bdc019a4ff6cb4c085bb7d725eb5978b183ffd265863b6d2d", size = 765683, upload-time = "2026-06-09T07:52:22.594Z" }, + { url = "https://files.pythonhosted.org/packages/27/43/41c1621732edd934d868a00d1b891584c892a7b62a9aab82ea5a0a5623ee/safetensors-0.8.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8e080062fcde23be189565e1c3305d16751a218ecf9412c8601e64204eb6f846", size = 722361, upload-time = "2026-06-09T07:52:23.924Z" }, + { url = "https://files.pythonhosted.org/packages/8e/3f/73ccf82579412b4a71c4ca673f10b5f1f888d7cf5af7fe24f27d30307be4/safetensors-0.8.0-cp310-abi3-win32.whl", hash = "sha256:2ddf52eac562eda224f99acfa7889d02968c1fd59a5b011ae7d8137c37e9c02d", size = 342401, upload-time = "2026-06-09T07:52:28.895Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6d/3fba214c1e5e0f69991677ec3bc17023f0421776975e1de0c682dca475e2/safetensors-0.8.0-cp310-abi3-win_amd64.whl", hash = "sha256:096ec1a98435df7beb08853bb5aa9081a84f23d0adc67ed1a0a10550f608373f", size = 355540, upload-time = "2026-06-09T07:52:27.832Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fc/7eedc3510d97878876e32774eebbeb61c43f148a96e915c84229a3e967aa/safetensors-0.8.0-cp310-abi3-win_arm64.whl", hash = "sha256:f7838e5135a406ad3e02efdcb8cf2e5397d368b0154537c4fec682dbc544d452", size = 340500, upload-time = "2026-06-09T07:52:26.745Z" }, +] + +[[package]] +name = "setuptools" +version = "81.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/1c/73e719955c59b8e424d015ab450f51c0af856ae46ea2da83eba51cc88de1/setuptools-81.0.0.tar.gz", hash = "sha256:487b53915f52501f0a79ccfd0c02c165ffe06631443a886740b91af4b7a5845a", size = 1198299, upload-time = "2026-02-06T21:10:39.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/e3/c164c88b2e5ce7b24d667b9bd83589cf4f3520d97cad01534cd3c4f55fdb/setuptools-81.0.0-py3-none-any.whl", hash = "sha256:fdd925d5c5d9f62e4b74b30d6dd7828ce236fd6ed998a08d81de62ce5a6310d6", size = 1062021, upload-time = "2026-02-06T21:10:37.175Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.8.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + +[[package]] +name = "surya-ocr" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "click" }, + { name = "filelock" }, + { name = "filetype" }, + { name = "httpx" }, + { name = "huggingface-hub" }, + { name = "openai" }, + { name = "opencv-python-headless" }, + { name = "pillow" }, + { name = "platformdirs" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pypdfium2" }, + { name = "python-dotenv" }, + { name = "torch" }, + { name = "transformers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/6f/5843f3aff907056bd1f8afb6ff127fe62bc0df9b7873de787296092950b6/surya_ocr-0.20.0.tar.gz", hash = "sha256:07eaee1109baf43094bd4d151dfa7628a6a416d6b0ac103c6610cb6e7ac90239", size = 25939689, upload-time = "2026-05-27T15:19:10.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/b9/4821fd7e24764def3a94ad73e94499b1dcd2a22bd3f12ba0d470b3346949/surya_ocr-0.20.0-py3-none-any.whl", hash = "sha256:fe080a3d820ec5d569cb93c4c6994c47d1489d2d3d7d069e763863aac04ce498", size = 115358, upload-time = "2026-05-27T15:19:08.212Z" }, +] + +[[package]] +name = "suryaocr-liteparse" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "fastapi" }, + { name = "pillow" }, + { name = "python-multipart" }, + { name = "surya-ocr" }, + { name = "uvicorn" }, +] + +[package.dev-dependencies] +dev = [ + { name = "httpx" }, + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", specifier = ">=0.131.0" }, + { name = "pillow", specifier = ">=10.2.0,<11" }, + { name = "python-multipart", specifier = ">=0.0.22" }, + { name = "surya-ocr", specifier = ">=0.20.0,<0.21.0" }, + { name = "uvicorn", specifier = ">=0.41.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "httpx", specifier = ">=0.27.0,<0.28" }, + { name = "pytest", specifier = ">=9.0.2" }, +] + +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, +] + +[[package]] +name = "tokenizers" +version = "0.22.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115, upload-time = "2026-01-05T10:45:15.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275, upload-time = "2026-01-05T10:41:02.158Z" }, + { url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472, upload-time = "2026-01-05T10:41:00.276Z" }, + { url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736, upload-time = "2026-01-05T10:40:32.165Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835, upload-time = "2026-01-05T10:40:38.847Z" }, + { url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673, upload-time = "2026-01-05T10:40:56.614Z" }, + { url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818, upload-time = "2026-01-05T10:40:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195, upload-time = "2026-01-05T10:40:51.139Z" }, + { url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982, upload-time = "2026-01-05T10:40:58.331Z" }, + { url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245, upload-time = "2026-01-05T10:41:04.053Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069, upload-time = "2026-01-05T10:45:10.673Z" }, + { url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263, upload-time = "2026-01-05T10:45:12.559Z" }, + { url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429, upload-time = "2026-01-05T10:45:14.333Z" }, + { url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363, upload-time = "2026-01-05T10:45:20.593Z" }, + { url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786, upload-time = "2026-01-05T10:45:18.411Z" }, + { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" }, +] + +[[package]] +name = "torch" +version = "2.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cuda-bindings", marker = "sys_platform == 'linux'" }, + { name = "cuda-toolkit", extra = ["cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "sys_platform == 'linux'" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "jinja2" }, + { name = "networkx" }, + { name = "nvidia-cublas", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cudnn-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cusparselt-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nccl-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nvshmem-cu13", marker = "sys_platform == 'linux'" }, + { name = "setuptools" }, + { name = "sympy" }, + { name = "triton", marker = "sys_platform == 'linux'" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/54/efb7ebca77970012b0cc21687a55d70eb2ba514b2c2b8e18d9fb1222f3be/torch-2.12.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:d2dd0f2c5f7ccbddaf34cade0deaf476808368f902b9cdb7f36a2ab42301bc0e", size = 87991951, upload-time = "2026-06-17T21:07:49.309Z" }, + { url = "https://files.pythonhosted.org/packages/1e/00/4210d76ca7424981f04033ebe7e48816ab83287a62538747a58825db770c/torch-2.12.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:2de4e19b88a481482c6c75291f2d6a52eda3ce51f311b29aa9b68499c830c07c", size = 426382721, upload-time = "2026-06-17T21:06:41.842Z" }, + { url = "https://files.pythonhosted.org/packages/76/1f/bc9f5a5aa569307076365f25afcebacb22e9c754b1bcfbaaa146627c7fda/torch-2.12.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:649e4ced014ba646f76f8cb9c9726735a6323eb321b7919f942790a923f90921", size = 532261322, upload-time = "2026-06-17T21:06:06.673Z" }, + { url = "https://files.pythonhosted.org/packages/9e/49/c549461daa008159d006a76a991fbc2f26fa8bac27a4030c858463dcb20f/torch-2.12.1-cp312-cp312-win_amd64.whl", hash = "sha256:e86550597877fb272ddc52db2f85b82cb601ea7bd932576a0340152cae2200b3", size = 122988095, upload-time = "2026-06-17T21:07:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/ff/4a/0300261818e1560d72cc160ac826005507e8b7ca0a35788b591436d05b4a/torch-2.12.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:c75e93173c700bccd6bfcc4a9d19ce242ab6dacd1f1781483027a16239b9e650", size = 87992358, upload-time = "2026-06-17T21:07:40.299Z" }, + { url = "https://files.pythonhosted.org/packages/30/a7/874a5ca05e8f159211dca7921060f7057acc1adb26431e119fd150623efc/torch-2.12.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:fcb61ccd20784b62bdd78ec84238a5cfb383b4994902e03bac95505ab360884c", size = 426386134, upload-time = "2026-06-17T21:07:31.481Z" }, + { url = "https://files.pythonhosted.org/packages/e1/75/20bb8fe9c1ad6538cce8cd0391b51927ae5af0b17ed1eab44b8824465dc1/torch-2.12.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:f4afc8083dff08719edbea346644476e3cec0cf40ebe256be0ee5d5b7c7e8c0d", size = 532268019, upload-time = "2026-06-17T21:05:37.925Z" }, + { url = "https://files.pythonhosted.org/packages/d1/fa/824ddb662af55b2eabc0dbb7b57c7c0b1bcd93693754a2b8509ec4d16490/torch-2.12.1-cp313-cp313-win_amd64.whl", hash = "sha256:f92609e3b3ce72f25e2eb780d043ced2480c1a86c47c852604fc7a9108648386", size = 122987777, upload-time = "2026-06-17T21:07:09.49Z" }, +] + +[[package]] +name = "tqdm" +version = "4.68.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/d7/0535a28b1f5f24f6612fb3ff1e89fb1a8d160fee0f976e0aa6803862134b/tqdm-4.68.3.tar.gz", hash = "sha256:00dfa48452b6b6cfae3dd9885636c23d3422d1ec97c66d96818cbd5e0821d482", size = 170596, upload-time = "2026-06-17T07:36:52.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/8e/bb97bb0c71802080bfc8952937d174e49cfc50de5c951dd47b2496f0dcdb/tqdm-4.68.3-py3-none-any.whl", hash = "sha256:39832cc2def2789a6f29df83f172db7416cea70052c0907a57801c5f2fdccb03", size = 78337, upload-time = "2026-06-17T07:36:50.132Z" }, +] + +[[package]] +name = "transformers" +version = "4.57.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "huggingface-hub" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "regex" }, + { name = "requests" }, + { name = "safetensors" }, + { name = "tokenizers" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/35/67252acc1b929dc88b6602e8c4a982e64f31e733b804c14bc24b47da35e6/transformers-4.57.6.tar.gz", hash = "sha256:55e44126ece9dc0a291521b7e5492b572e6ef2766338a610b9ab5afbb70689d3", size = 10134912, upload-time = "2026-01-16T10:38:39.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/b8/e484ef633af3887baeeb4b6ad12743363af7cce68ae51e938e00aaa0529d/transformers-4.57.6-py3-none-any.whl", hash = "sha256:4c9e9de11333ddfe5114bc872c9f370509198acf0b87a832a0ab9458e2bd0550", size = 11993498, upload-time = "2026-01-16T10:38:31.289Z" }, +] + +[[package]] +name = "triton" +version = "3.7.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/fa/f856e24deb462d5f18bd4b5a746957862ab9b6ee5834bda60605ec348366/triton-3.7.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9497f2e696ee368862a181a90b2dcc03ca978cc4f602abd67c7d81022a6988e1", size = 184692359, upload-time = "2026-06-17T20:03:48.288Z" }, + { url = "https://files.pythonhosted.org/packages/c4/6f/fb96d15db6f36d6eae4cafb998c2e0353bf59d7c4ea1662d7497f269134a/triton-3.7.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e40869937a68206ec70d7f25bb7ec6433cb083f9135e1f36dbd318dc449a728", size = 197719725, upload-time = "2026-06-17T19:53:20.419Z" }, + { url = "https://files.pythonhosted.org/packages/00/42/c5089d4d9327fcd1e862c599cc2927f39418f84dd11a84cb2ccff9d4787a/triton-3.7.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cdbfc09d9ec58bc5e68321525653220de7515c199e7a8097a97c85e62b52cd0a", size = 184694629, upload-time = "2026-06-17T20:03:53.444Z" }, + { url = "https://files.pythonhosted.org/packages/07/42/2c3ac59253ae8892b6f307875263dd23dc875cdf732d3aea40d6d41fb7cb/triton-3.7.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58c0e131da05134a2a4788ccbcc0c1105cf0f54c8e98f19e34cd465396dc15eb", size = 197729241, upload-time = "2026-06-17T19:53:27.801Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.49.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" }, +] diff --git a/packages/node/.gitignore b/packages/node/.gitignore new file mode 100644 index 0000000..ac3c62e --- /dev/null +++ b/packages/node/.gitignore @@ -0,0 +1,7 @@ +dist/ +node_modules/ +*.node +*.dylib +*.so +*.dll +npm/ diff --git a/packages/node/README.md b/packages/node/README.md new file mode 100644 index 0000000..c30a02f --- /dev/null +++ b/packages/node/README.md @@ -0,0 +1,139 @@ +# LiteParse Node.js + +Node.js/TypeScript bindings for [LiteParse](https://github.com/run-llama/liteparse) — fast, lightweight PDF and document parsing with spatial text extraction. + +## Installation + +```bash +npm i @llamaindex/liteparse +``` + +This also installs the `lit` CLI command (use `npm i -g` for global access). + +## Quick Start + +```typescript +import { LiteParse } from '@llamaindex/liteparse'; + +const parser = new LiteParse(); +const result = await parser.parse('document.pdf'); +console.log(result.text); + +// Access structured data +for (const page of result.pages) { + console.log(`Page ${page.pageNum}: ${page.textItems.length} text items`); +} +``` + +## Markdown Output + +LiteParse can render documents directly to Markdown including headings, tables, lists, +images, and links reconstructed from the spatial layout. Great for feeding LLMs +and RAG pipelines. The rendered Markdown is returned on `result.text`: + +```typescript +const parser = new LiteParse({ + outputFormat: 'markdown', // "json" | "text" | "markdown" + imageMode: 'placeholder', // "placeholder" | "off" | "embed" + extractLinks: true, // render [text](url) link syntax (default: true) +}); +const result = await parser.parse('document.pdf'); +console.log(result.text); // rendered Markdown +``` + +> Reconstruction quality varies with document complexity. + +## Configuration + +All options are passed to the constructor: + +```typescript +const parser = new LiteParse({ + ocrEnabled: true, // Enable OCR (default: true) + ocrLanguage: 'eng', // Tesseract language code + ocrServerUrl: undefined, // HTTP OCR server URL (optional) + tessdataPath: undefined, // Path to tessdata directory (optional) + maxPages: 1000, // Max pages to parse + targetPages: '1-5,10', // Specific pages (optional) + dpi: 150, // Rendering DPI + outputFormat: 'json', // "json" | "text" | "markdown" + imageMode: 'placeholder', // Markdown image handling: "placeholder" | "off" | "embed" + extractLinks: true, // Render [text](url) links in markdown output + preserveVerySmallText: false, // Keep tiny text + password: undefined, // Password for protected documents + quiet: false, // Suppress progress output + numWorkers: 4, // Concurrent OCR workers +}); +``` + +## Parsing from Bytes + +Pass a `Buffer` or `Uint8Array` directly — useful for HTTP responses or in-memory data: + +```typescript +import { readFile } from 'fs/promises'; + +const pdfBytes = await readFile('document.pdf'); +const result = await parser.parse(pdfBytes); +console.log(result.text); +``` + +## Screenshots + +Generate PNG screenshots of document pages: + +```typescript +const screenshots = parser.screenshot('document.pdf', [1, 2, 3]); +for (const s of screenshots) { + console.log(`Page ${s.pageNum}: ${s.width}x${s.height}`); + // s.imageBuffer contains PNG bytes +} +``` + +## Document Complexity + +Before committing to a full parse, check whether a document needs OCR or heavier +processing. `isComplex` is a cheap, text-layer-only pass that returns one entry per page +with a `needsOcr` verdict and the signals behind it — useful for routing documents to +different pipelines, rejecting ones you can't handle, or estimating cost. + +```typescript +const parser = new LiteParse(); +const pages = await parser.isComplex('document.pdf'); + +if (pages.some((p) => p.needsOcr)) { + // Route to the OCR-enabled pipeline + const result = await parser.parse('document.pdf'); +} else { + // Cheap path — skip OCR entirely + const result = await new LiteParse({ ocrEnabled: false }).parse('document.pdf'); +} + +// Inspect why specific pages were flagged +for (const page of pages.filter((p) => p.needsOcr)) { + console.log(`Page ${page.pageNumber}: ${page.reasons.join(', ')}`); +} +``` + +`reasons` is one of `"scanned"`, `"no-text"`, `"sparse-text"`, `"embedded-images"`, +`"garbled"`, or `"vector-text"`. Raw bytes work here too. + +## Supported Formats + +- PDF (`.pdf`) +- Microsoft Office (`.docx`, `.xlsx`, `.pptx`, etc.) — requires LibreOffice +- OpenDocument (`.odt`, `.ods`, `.odp`) — requires LibreOffice +- Images (`.png`, `.jpg`, `.tiff`, etc.) — requires ImageMagick +- And more! + +## CLI + +The npm package includes the `lit` CLI: + +```bash +lit parse document.pdf +lit parse document.pdf --format json -o output.json +lit screenshot document.pdf -o ./screenshots +lit batch-parse ./input ./output +lit is-complex document.pdf +``` diff --git a/packages/node/native.d.ts b/packages/node/native.d.ts new file mode 100644 index 0000000..7ba78b7 --- /dev/null +++ b/packages/node/native.d.ts @@ -0,0 +1,232 @@ +/* tslint:disable */ +/* eslint-disable */ + +/* auto-generated by NAPI-RS */ + +export interface JsLiteParseConfig { + /** OCR language code (e.g., "eng", "fra"). */ + ocrLanguage?: string + /** Whether OCR is enabled. */ + ocrEnabled?: boolean + /** HTTP OCR server URL. If set, uses HTTP OCR instead of Tesseract. */ + ocrServerUrl?: string + /** + * Extra HTTP headers sent with every request to `ocrServerUrl` + * (e.g. `{ Authorization: "Bearer " }`). + */ + ocrServerHeaders?: Record + /** Path to tessdata directory for Tesseract. */ + tessdataPath?: string + /** Maximum number of pages to parse. */ + maxPages?: number + /** Specific pages to parse (e.g., "1-5,10,15-20"). */ + targetPages?: string + /** DPI for rendering pages (used for OCR and screenshots). */ + dpi?: number + /** Output format: "json", "text", or "markdown". */ + outputFormat?: string + /** Keep very small text that would normally be filtered out. */ + preserveVerySmallText?: boolean + /** Password for encrypted/protected documents. */ + password?: string + /** Suppress progress output. */ + quiet?: boolean + /** Number of concurrent OCR workers (default: CPU cores - 1). */ + numWorkers?: number + /** + * How to surface raster images in markdown output: "off", "placeholder" + * (default — emits `![](image_pN_K.png)` references with no bytes), or + * "embed" (also returns each image's PNG bytes on `images`). + */ + imageMode?: string + /** + * Render hyperlink annotations as `[text](url)` in markdown output + * (default true). Set false for plain anchor text. + */ + extractLinks?: boolean + /** + * Whether a systemic OCR failure aborts the whole parse (default true). + * Set false to keep already-recovered native text and return partial + * results when OCR is unavailable, instead of rejecting. + */ + ocrFailureFatal?: boolean + /** + * OCR request-hedging schedule (ms). Empty/unset = no hedging. Multiple + * delays (e.g. `[0, 5000, 10000]`) fire duplicate requests per attempt and + * take the first success — lower tail latency at the cost of extra load. + */ + ocrHedgeDelaysMs?: Array + /** + * Emit per-word sub-boxes on each text item (`TextItem.words`). Default + * false. Word boxes roughly double the text-item payload, so enable only + * for word-level bbox attribution. + */ + emitWordBoxes?: boolean + /** + * Restrict output to a page sub-region. Each field is the fraction of the + * page cropped from that side; a text item survives only if it lies + * entirely inside the remaining rectangle. Unset keeps the whole page. + */ + cropBox?: JsCropBox + /** + * Drop diagonal text (rotation >2° off the nearest right angle). Default + * false. Use to exclude rotated watermarks/stamps from the output. + */ + skipDiagonalText?: boolean +} +/** + * A page sub-region as the fraction cropped from each side (top-left origin, + * each in `[0, 1]`). + */ +export interface JsCropBox { + top: number + right: number + bottom: number + left: number +} +/** One word's sub-box within a `JsTextItem`, in the same viewport space. */ +export interface JsWordBox { + text: string + x: number + y: number + width: number + height: number +} +export interface JsTextItem { + text: string + x: number + y: number + width: number + height: number + fontName?: string + fontSize?: number + confidence?: number + /** Rotation in degrees (viewport space). Defaults to 0 when omitted. */ + rotation?: number + /** + * Per-word sub-boxes for attribution. Empty for items with no word split + * (e.g. OCR-sourced or single-token items). + */ + words: Array +} +/** + * A vector-graphic primitive supplied by an external extractor. `kind` selects + * the variant: `"stroke"` (uses `x1/y1/x2/y2`) or `"rect"` (uses + * `x/y/width/height`). Coordinates are viewport space (top-left origin, 72 + * DPI), matching the text items. `has_fill`/`has_stroke` carry the paint + * intent even when no color is known, so ruled-table edge detection still + * treats a colorless stroked rect as stroked. + */ +export interface JsGraphic { + /** "stroke" or "rect". Anything else is dropped. */ + kind: string + x1?: number + y1?: number + x2?: number + y2?: number + x?: number + y?: number + width?: number + height?: number + /** Whether the path is filled. Drives Rect `fill` presence. */ + hasFill?: boolean + /** Whether the path is stroked. Drives Rect `stroke` presence. */ + hasStroke?: boolean + /** Fill color as ARGB hex (e.g. "ff000000"). May be absent even when filled. */ + fillColor?: string + /** Stroke color as ARGB hex. May be absent even when stroked. */ + strokeColor?: string + /** Stroke line width in points. */ + lineWidth?: number +} +/** + * A page of pre-extracted text supplied by an external extractor. Coordinates + * are viewport space (top-left origin, 72 DPI). `graphics` enables ruled-table + * and horizontal-rule detection; struct nodes are still unsupported on this + * path, so tagged-heading detection remains unavailable until they are added. + */ +export interface JsPageInput { + pageNumber: number + pageWidth: number + pageHeight: number + textItems: Array + graphics?: Array +} +export interface JsParsedPage { + pageNum: number + width: number + height: number + text: string + markdown: string + textItems: Array +} +export interface JsParseResult { + pages: Array + text: string + images: Array +} +export interface JsExtractedImage { + id: string + page: number + format: string + bytes: Buffer +} +export interface JsScreenshotResult { + pageNum: number + width: number + height: number + imageBuffer: Buffer +} +export interface JsPageComplexityStats { + pageNumber: number + textLength: number + textCoverage: number + hasSubstantialImages: boolean + imageBlockCount: number + imageCoverage: number + largestImageCoverage: number + fullPageImage: boolean + uncoveredVectorArea?: number + isGarbled: boolean + pageArea: number + needsOcr: boolean + reasons: Array +} +/** Search text items for phrase matches, returning merged items with combined bounding boxes. */ +export declare function searchItems(items: Array, phrase: string, caseSensitive?: boolean | undefined | null): Array +/** Main LiteParse parser class. */ +export declare class LiteParse { + /** + * Create a new LiteParse instance with optional configuration. + * Any fields not provided will use defaults. + */ + constructor(config?: JsLiteParseConfig | undefined | null) + /** Parse a document. Accepts a file path (string) or raw PDF bytes (Buffer). */ + parse(input: string | Buffer): Promise + /** + * Parse from pre-extracted pages, skipping PDFium text extraction. + * + * The caller supplies pages already populated with text items in viewport + * space (top-left origin, 72 DPI). Runs only grid projection + the + * configured output formatter, so it never loads PDFium. Use when an + * external extractor owns text extraction (e.g. to keep its own + * font-recovery pipeline). + */ + parsePages(pages: Array): JsParseResult + /** + * Determine per-page complexity. Returns one entry per parsed page with + * signals (text coverage, images, garbled text, vector area) and a + * `needsOcr` verdict — a cheap pre-OCR check to decide whether a document + * needs advanced parsing. Accepts a file path (string) or raw PDF bytes. + */ + isComplex(input: string | Buffer): Promise> + /** + * Take screenshots of document pages. Returns PNG image buffers. + * + * Non-PDF files are automatically converted to PDF before rendering when + * LibreOffice/ImageMagick are available. + */ + screenshot(input: string | Buffer, pageNumbers?: Array | undefined | null): Promise> + /** Get the current configuration. */ + get config(): JsLiteParseConfig +} diff --git a/packages/node/package-lock.json b/packages/node/package-lock.json new file mode 100644 index 0000000..21e5daf --- /dev/null +++ b/packages/node/package-lock.json @@ -0,0 +1,86 @@ +{ + "name": "@llamaindex/liteparse", + "version": "2.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@llamaindex/liteparse", + "version": "2.0.0", + "license": "Apache-2.0", + "dependencies": { + "commander": "^12.0.0" + }, + "bin": { + "lit": "dist/cli.js", + "liteparse": "dist/cli.js" + }, + "devDependencies": { + "@napi-rs/cli": "^2.18.4", + "@types/node": "^22.0.0", + "typescript": "~5.9.2" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "@llamaindex/liteparse-darwin-arm64": "2.0.0", + "@llamaindex/liteparse-linux-arm64-gnu": "2.0.0", + "@llamaindex/liteparse-linux-x64-gnu": "2.0.0", + "@llamaindex/liteparse-win32-x64-msvc": "2.0.0" + } + }, + "node_modules/@napi-rs/cli": { + "version": "2.18.4", + "resolved": "https://registry.npmjs.org/@napi-rs/cli/-/cli-2.18.4.tgz", + "integrity": "sha512-SgJeA4df9DE2iAEpr3M2H0OKl/yjtg1BnRI5/JyowS71tUWhrfSu2LT0V3vlHET+g1hBVlrO60PmEXwUEKp8Mg==", + "dev": true, + "bin": { + "napi": "scripts/index.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "dev": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "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, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true + } + } +} diff --git a/packages/node/package.json b/packages/node/package.json new file mode 100644 index 0000000..f24152b --- /dev/null +++ b/packages/node/package.json @@ -0,0 +1,82 @@ +{ + "name": "@llamaindex/liteparse", + "version": "2.5.1", + "description": "Fast, lightweight PDF and document parsing with spatial text extraction", + "type": "module", + "main": "./dist/lib.js", + "types": "./dist/lib.d.ts", + "bin": { + "lit": "./dist/cli.js", + "liteparse": "./dist/cli.js" + }, + "exports": { + ".": { + "types": "./dist/lib.d.ts", + "import": "./dist/lib.js" + }, + "./package.json": "./package.json" + }, + "napi": { + "name": "liteparse", + "triples": { + "defaults": true, + "additional": [ + "aarch64-apple-darwin", + "aarch64-unknown-linux-gnu", + "aarch64-pc-windows-msvc", + "x86_64-unknown-linux-musl" + ] + } + }, + "files": [ + "dist", + "*.node", + "*.dylib", + "*.so", + "*.so.*", + "*.dll", + "README.md", + "LICENSE" + ], + "scripts": { + "build:rs": "napi build --cargo-cwd ../../crates/liteparse-napi --platform --release --js false --dts native.d.ts .", + "build:pdfium": "bash scripts/copy-pdfium.sh .", + "build:ts": "tsc", + "build": "npm run build:rs && npm run build:pdfium && npm run build:ts", + "prepublishOnly": "npm run build" + }, + "dependencies": { + "commander": "^12.0.0" + }, + "devDependencies": { + "@napi-rs/cli": "^2.18.4", + "@types/node": "^22.0.0", + "typescript": "~5.9.2" + }, + "optionalDependencies": { + "@llamaindex/liteparse-darwin-arm64": "2.5.1", + "@llamaindex/liteparse-darwin-x64": "2.5.1", + "@llamaindex/liteparse-linux-arm64-gnu": "2.5.1", + "@llamaindex/liteparse-linux-x64-gnu": "2.5.1", + "@llamaindex/liteparse-win32-arm64-msvc": "2.5.1", + "@llamaindex/liteparse-win32-x64-msvc": "2.5.1", + "@llamaindex/liteparse-linux-x64-musl": "2.5.1" + }, + "engines": { + "node": ">=18.0.0" + }, + "keywords": [ + "pdf", + "parser", + "ocr", + "text-extraction", + "pdf-to-text", + "document-parsing" + ], + "repository": { + "type": "git", + "url": "https://github.com/run-llama/liteparse.git" + }, + "author": "LlamaIndex", + "license": "Apache-2.0" +} \ No newline at end of file diff --git a/packages/node/scripts/copy-pdfium.sh b/packages/node/scripts/copy-pdfium.sh new file mode 100755 index 0000000..8fe1149 --- /dev/null +++ b/packages/node/scripts/copy-pdfium.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# +# Copy the pdfium shared library next to the .node file so it can be found +# at runtime via @loader_path (macOS) or $ORIGIN (Linux). +# +# Usage: ./scripts/copy-pdfium.sh [output-dir] +# output-dir: directory containing the .node file (default: .) +# +# The script auto-detects the pdfium library location from: +# 1. PDFIUM_LIB_PATH env var (set by CI or user) +# 2. The pdfium-sys build cache (~/.cache/pdfium-rs or ~/Library/Caches/pdfium-rs) + +set -euo pipefail + +OUTPUT_DIR="${1:-.}" + +# Determine OS and library filename +case "$(uname -s)" in + Darwin*) DYLIB="libpdfium.dylib" ;; + Linux*) DYLIB="libpdfium.so" ;; + MINGW*|MSYS*|CYGWIN*) DYLIB="pdfium.dll" ;; + *) echo "Unsupported OS: $(uname -s)" >&2; exit 1 ;; +esac + +# Find the pdfium library +find_pdfium() { + # 1. Explicit env var + if [ -n "${PDFIUM_LIB_PATH:-}" ] && [ -f "${PDFIUM_LIB_PATH}/${DYLIB}" ]; then + echo "${PDFIUM_LIB_PATH}/${DYLIB}" + return + fi + + # 2. Vendor directory + local vendor="$(dirname "$0")/../../vendor/pdfium/release/lib/${DYLIB}" + if [ -f "$vendor" ]; then + echo "$vendor" + return + fi + + # 3. Build cache + local cache_base + case "$(uname -s)" in + Darwin*) cache_base="$HOME/Library/Caches/pdfium-rs" ;; + *) cache_base="${XDG_CACHE_HOME:-$HOME/.cache}/pdfium-rs" ;; + esac + + # Find most recent cached pdfium + if [ -d "$cache_base" ]; then + local found + found=$(find "$cache_base" -name "$DYLIB" -type f 2>/dev/null | head -1) + if [ -n "$found" ]; then + echo "$found" + return + fi + fi + + echo "" +} + +PDFIUM_PATH=$(find_pdfium) + +if [ -z "$PDFIUM_PATH" ]; then + echo "Error: Could not find ${DYLIB}. Set PDFIUM_LIB_PATH to the directory containing it." >&2 + exit 1 +fi + +cp "$PDFIUM_PATH" "${OUTPUT_DIR}/${DYLIB}" +echo "Copied ${PDFIUM_PATH} -> ${OUTPUT_DIR}/${DYLIB}" diff --git a/packages/node/src/cli.ts b/packages/node/src/cli.ts new file mode 100644 index 0000000..3673811 --- /dev/null +++ b/packages/node/src/cli.ts @@ -0,0 +1,440 @@ +#!/usr/bin/env node + +import { program } from "commander"; +import { LiteParse, type LiteParseConfig } from "./lib.js"; +import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from "node:fs"; +import { join, relative, parse as parsePath } from "node:path"; + +program + .name("liteparse") + .description("Fast, lightweight PDF and document parsing") + .version("2.0.0"); + +/** Collect repeated `--ocr-server-header "Name: Value"` flags into an object. */ +function collectHeader( + value: string, + previous: Record = {}, +): Record { + const idx = value.indexOf(":"); + if (idx === -1) { + throw new Error(`invalid header '${value}', expected 'Name: Value'`); + } + const name = value.slice(0, idx).trim(); + if (name === "") { + throw new Error(`invalid header '${value}', empty header name`); + } + previous[name] = value.slice(idx + 1).trim(); + return previous; +} + +program + .command("parse") + .description("Parse a document and extract text") + .argument("", "Path to the document file") + .option("-o, --output ", "Output file path") + .option("--format ", 'Output format: json|text|markdown (default: "text")') + .option( + "--image-mode ", + "How to surface raster images in markdown: off|placeholder|embed (default: placeholder)", + ) + .option( + "--image-output-dir ", + "Directory to write embedded images to when --image-mode embed is set", + ) + .option("--no-links", "Disable hyperlink extraction (emit plain anchor text)") + .option("--ocr-server-url ", "HTTP OCR server URL") + .option( + "--ocr-server-header
", + 'Extra header for OCR server requests, "Name: Value" (repeatable)', + collectHeader, + ) + .option("--no-ocr", "Disable OCR") + .option("--ocr-language ", "OCR language (default: eng)") + .option("--max-pages ", "Max pages to parse", parseInt) + .option( + "--target-pages ", + 'Pages to parse (e.g., "1-5,10,15-20")', + ) + .option("--dpi ", "Rendering DPI", parseFloat) + .option("--preserve-small-text", "Keep very small text") + .option("--password ", "Password for encrypted documents") + .option("--config ", "JSON config file path") + .option("-q, --quiet", "Suppress progress output") + .option("--num-workers ", "Number of concurrent OCR workers", parseInt) + .action(async (file: string, opts: Record) => { + try { + const config: Partial = {}; + + // Load config file if provided + if (opts.config) { + const fileConfig = JSON.parse( + readFileSync(opts.config as string, "utf-8"), + ); + Object.assign(config, fileConfig); + } + + // CLI options override config file + if (opts.format) config.outputFormat = opts.format as "json" | "text" | "markdown"; + if (opts.imageMode) + config.imageMode = opts.imageMode as "off" | "placeholder" | "embed"; + if (opts.links === false) config.extractLinks = false; + if (opts.ocrServerUrl) + config.ocrServerUrl = opts.ocrServerUrl as string; + if (opts.ocrServerHeader) + config.ocrServerHeaders = opts.ocrServerHeader as Record; + if (opts.ocr === false) config.ocrEnabled = false; + if (opts.ocrLanguage) config.ocrLanguage = opts.ocrLanguage as string; + if (opts.maxPages) config.maxPages = opts.maxPages as number; + if (opts.targetPages) config.targetPages = opts.targetPages as string; + if (opts.dpi) config.dpi = opts.dpi as number; + if (opts.preserveSmallText) config.preserveVerySmallText = true; + if (opts.password) config.password = opts.password as string; + if (opts.quiet) config.quiet = true; + if (opts.numWorkers) config.numWorkers = opts.numWorkers as number; + + // Default CLI output to text (library defaults to json) + if (!config.outputFormat) config.outputFormat = "text"; + + const parser = new LiteParse(config); + const result = await parser.parse(file); + + const output = + config.outputFormat === "json" + ? JSON.stringify( + { + pages: result.pages.map((p) => ({ + page: p.pageNum, + width: p.width, + height: p.height, + text: p.text, + textItems: p.textItems, + })), + }, + null, + 2, + ) + : result.text; + + if (opts.imageOutputDir && result.images.length > 0) { + const dir = opts.imageOutputDir as string; + mkdirSync(dir, { recursive: true }); + for (const img of result.images) { + writeFileSync(join(dir, `image_${img.id}.${img.format}`), img.bytes); + } + if (!opts.quiet) { + console.error( + `[liteparse] wrote ${result.images.length} image(s) to ${dir}`, + ); + } + } + + if (opts.output) { + writeFileSync(opts.output as string, output, "utf-8"); + } else { + process.stdout.write(output); + } + } catch (err) { + console.error( + `Error: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(1); + } + }); + +program + .command("is-complex") + .description( + "Check if a document is 'complex' enough to require OCR or other advanced parsing", + ) + .argument("", "Path to the document file") + .option("--compact", "Emit dense, whitespace-free JSON instead of pretty") + .option("--max-pages ", "Max pages to parse", parseInt) + .option( + "--target-pages ", + 'Pages to check (e.g., "1-5,10,15-20")', + ) + .option("--password ", "Password for encrypted documents") + .option("-q, --quiet", "Suppress progress output") + .action(async (file: string, opts: Record) => { + try { + const config: Partial = {}; + if (opts.maxPages) config.maxPages = opts.maxPages as number; + if (opts.targetPages) config.targetPages = opts.targetPages as string; + if (opts.password) config.password = opts.password as string; + if (opts.quiet) config.quiet = true; + + const parser = new LiteParse(config); + const stats = await parser.isComplex(file); + + const complexPages = stats.filter((s) => s.needsOcr).length; + + // Always emit JSON on stdout so the command composes with `jq` without a + // flag. Pretty by default; `--compact` drops the whitespace. Both parse + // identically for any reader. + process.stdout.write( + opts.compact + ? JSON.stringify(stats) + : JSON.stringify(stats, null, 2), + ); + process.stdout.write("\n"); + + // Human verdict goes to stderr so it never pollutes the JSON on stdout; + // the exit code below carries the same signal for scripts. + if (!opts.quiet) { + const verdict = complexPages > 0 ? "COMPLEX" : "SIMPLE"; + console.error( + `${verdict} — ${complexPages}/${stats.length} page(s) need OCR`, + ); + } + + // Exit non-zero when any page needs OCR, so the command is usable as a + // shell predicate (e.g. `is-complex doc.pdf && parse --no-ocr`). + if (complexPages > 0) process.exit(1); + } catch (err) { + console.error( + `Error: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(1); + } + }); + +program + .command("screenshot") + .description("Generate screenshots of document pages") + .argument("", "Path to the document file") + .option( + "-o, --output-dir ", + "Output directory for screenshots", + "./screenshots", + ) + .option( + "--target-pages ", + 'Pages to screenshot (e.g., "1,3,5" or "1-5")', + ) + .option("--dpi ", "Rendering DPI", parseFloat) + .option("--password ", "Password for encrypted documents") + .option("-q, --quiet", "Suppress progress output") + .action(async (file: string, opts: Record) => { + try { + const config: Partial = {}; + if (opts.dpi) config.dpi = opts.dpi as number; + if (opts.password) config.password = opts.password as string; + if (opts.quiet) config.quiet = true; + if (opts.targetPages) config.targetPages = opts.targetPages as string; + + const parser = new LiteParse(config); + + // Parse target pages into number array + let pageNumbers: number[] | undefined; + if (opts.targetPages) { + pageNumbers = []; + for (const part of (opts.targetPages as string).split(",")) { + const trimmed = part.trim(); + if (trimmed.includes("-")) { + const [start, end] = trimmed.split("-").map(Number); + for (let i = start; i <= end; i++) pageNumbers.push(i); + } else { + pageNumbers.push(Number(trimmed)); + } + } + } + + const outputDir = opts.outputDir as string; + mkdirSync(outputDir, { recursive: true }); + + const results = await parser.screenshot(file, pageNumbers); + + for (const result of results) { + const outputPath = join(outputDir, `page_${result.pageNum}.png`); + writeFileSync(outputPath, result.imageBuffer); + if (!opts.quiet) { + console.error( + `[liteparse] screenshot page ${result.pageNum} → ${outputPath}`, + ); + } + } + } catch (err) { + console.error( + `Error: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(1); + } + }); + +program + .command("batch-parse") + .description("Parse multiple documents in batch mode") + .argument("", "Input directory") + .argument("", "Output directory") + .option("--format ", 'Output format: json|text|markdown (default: "text")') + .option("--no-ocr", "Disable OCR") + .option("--ocr-language ", "OCR language (default: eng)") + .option("--ocr-server-url ", "HTTP OCR server URL") + .option( + "--ocr-server-header
", + 'Extra header for OCR server requests, "Name: Value" (repeatable)', + collectHeader, + ) + .option("--max-pages ", "Max pages to parse per file", parseInt) + .option("--dpi ", "Rendering DPI", parseFloat) + .option("--recursive", "Recursively search input directory") + .option("--extension ", "Only process files with this extension") + .option("--password ", "Password for encrypted documents") + .option("-q, --quiet", "Suppress progress output") + .option("--num-workers ", "Number of concurrent OCR workers", parseInt) + .action( + async ( + inputDir: string, + outputDir: string, + opts: Record, + ) => { + try { + const config: Partial = {}; + const format = (opts.format as string) ?? "text"; + config.outputFormat = format as "json" | "text" | "markdown"; + if (opts.ocr === false) config.ocrEnabled = false; + if (opts.ocrLanguage) config.ocrLanguage = opts.ocrLanguage as string; + if (opts.ocrServerUrl) + config.ocrServerUrl = opts.ocrServerUrl as string; + if (opts.ocrServerHeader) + config.ocrServerHeaders = opts.ocrServerHeader as Record; + if (opts.maxPages) config.maxPages = opts.maxPages as number; + if (opts.dpi) config.dpi = opts.dpi as number; + if (opts.password) config.password = opts.password as string; + if (opts.quiet) config.quiet = true; + if (opts.numWorkers) config.numWorkers = opts.numWorkers as number; + + const parser = new LiteParse(config); + const outExt = format === "json" ? ".json" : format === "markdown" ? ".md" : ".txt"; + + mkdirSync(outputDir, { recursive: true }); + + const extFilter = opts.extension + ? (opts.extension as string).startsWith(".") + ? (opts.extension as string).toLowerCase() + : `.${(opts.extension as string).toLowerCase()}` + : undefined; + + const files = collectFiles( + inputDir, + !!opts.recursive, + extFilter, + ); + + if (files.length === 0) { + console.error( + `[liteparse] no matching files found in ${inputDir}`, + ); + return; + } + if (!opts.quiet) { + console.error( + `[liteparse] found ${files.length} files to process`, + ); + } + + let success = 0; + let errors = 0; + + for (const filePath of files) { + const t0 = Date.now(); + const rel = relative(inputDir, filePath); + const parsed = parsePath(rel); + const outPath = join( + outputDir, + parsed.dir, + parsed.name + outExt, + ); + mkdirSync(join(outputDir, parsed.dir), { recursive: true }); + + try { + const result = await parser.parse(filePath); + const output = + format === "json" + ? JSON.stringify( + { + pages: result.pages.map((p) => ({ + page: p.pageNum, + width: p.width, + height: p.height, + text: p.text, + textItems: p.textItems, + })), + }, + null, + 2, + ) + : result.text; + writeFileSync(outPath, output, "utf-8"); + success++; + if (!opts.quiet) { + const elapsed = Date.now() - t0; + console.error( + `[liteparse] ${filePath} → ${outPath} (${elapsed}ms)`, + ); + } + } catch (err) { + console.error( + `[liteparse] error parsing ${filePath}: ${err instanceof Error ? err.message : String(err)}`, + ); + errors++; + } + } + + console.error( + `[liteparse] batch complete: ${success} succeeded, ${errors} failed`, + ); + if (errors > 0) process.exit(1); + } catch (err) { + console.error( + `Error: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(1); + } + }, + ); + +const SUPPORTED_EXTENSIONS = new Set([ + ".pdf", + ".doc", ".docx", ".docm", ".dot", ".dotm", ".dotx", ".odt", ".ott", ".rtf", ".pages", + ".ppt", ".pptx", ".pptm", ".pot", ".potm", ".potx", ".odp", ".otp", ".key", + ".xls", ".xlsx", ".xlsm", ".xlsb", ".ods", ".ots", ".csv", ".tsv", ".numbers", + ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".tif", ".webp", ".svg", + ".txt", ".md", ".markdown", ".log", +]); + +function collectFiles( + dir: string, + recursive: boolean, + extFilter?: string, +): string[] { + const files: string[] = []; + collectFilesInner(dir, recursive, extFilter, files); + files.sort(); + return files; +} + +function collectFilesInner( + dir: string, + recursive: boolean, + extFilter: string | undefined, + files: string[], +): void { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + if (recursive) collectFilesInner(fullPath, recursive, extFilter, files); + continue; + } + const lower = entry.name.toLowerCase(); + if (extFilter) { + if (!lower.endsWith(extFilter)) continue; + } else { + const ext = lower.lastIndexOf(".") >= 0 ? lower.slice(lower.lastIndexOf(".")) : ""; + if (!SUPPORTED_EXTENSIONS.has(ext)) continue; + } + files.push(fullPath); + } +} + +program.parse(process.argv); diff --git a/packages/node/src/lib.ts b/packages/node/src/lib.ts new file mode 100644 index 0000000..25d85c2 --- /dev/null +++ b/packages/node/src/lib.ts @@ -0,0 +1,426 @@ +import { + native, + type LiteParseNative, + type LiteParseNativeConfig, + type NativeParseResult, + type NativeParsedPage, + type NativePageInput, + type NativeTextItem, + type NativeExtractedImage, + type NativePageComplexityStats, +} from "./native.js"; + +// --------------------------------------------------------------------------- +// Public types — match the existing TypeScript API +// --------------------------------------------------------------------------- + +export type LiteParseInput = string | Buffer | Uint8Array; +export type OutputFormat = "json" | "text" | "markdown"; +export type ImageMode = "off" | "placeholder" | "embed"; + +export interface LiteParseConfig { + ocrLanguage: string; + ocrEnabled: boolean; + ocrServerUrl?: string; + /** Extra HTTP headers sent with every request to `ocrServerUrl`. */ + ocrServerHeaders?: Record; + tessdataPath?: string; + maxPages: number; + targetPages?: string; + dpi: number; + outputFormat: OutputFormat; + /** How to surface raster images in markdown output (default: "placeholder"). */ + imageMode: ImageMode; + /** Render hyperlink annotations as `[text](url)` in markdown output (default: true). */ + extractLinks: boolean; + preserveVerySmallText: boolean; + password?: string; + quiet: boolean; + numWorkers: number; + /** + * Whether a systemic OCR failure (every OCR task failed and at least one was + * a text-sparse page) aborts the whole parse (default: true). Set false to + * keep already-recovered native text and return partial results instead of + * rejecting — for callers that prefer a degraded document over a hard failure. + */ + ocrFailureFatal: boolean; + /** + * OCR request-hedging schedule (ms). Empty (default) = no hedging. Multiple + * delays (e.g. `[0, 5000, 10000, 15000, 20000]`) fire duplicate requests per + * OCR attempt and take the first success — lower tail latency on a slow/stuck + * OCR pod, at the cost of extra OCR-server load. HTTP OCR engine only. + */ + ocrHedgeDelaysMs: number[]; + /** + * Emit per-word sub-boxes on each text item ({@link TextItem.words}). + * Default false. Word boxes roughly double the text-item payload (size + napi + * marshalling), so enable only when doing word-level bbox attribution. + */ + emitWordBoxes: boolean; + /** + * Restrict output to a page sub-region. Each field is the fraction of the + * page cropped away from that side (top-left origin), so `{ left: 0.5 }` + * discards the left half. A text item survives only when it lies entirely + * inside the remaining rectangle. Undefined (default) keeps the whole page. + * Applied after OCR merge, so OCR text outside the region is dropped too. + */ + cropBox?: CropBox; + /** + * Drop diagonal text — items whose rotation is more than 2° off the nearest + * right angle (0/90/180/270). Default false. Use to exclude rotated + * watermarks/stamps from the output. + */ + skipDiagonalText: boolean; +} + +/** + * A page sub-region expressed as the fraction cropped from each side + * (top-left origin, each value in `[0, 1]`). + */ +export interface CropBox { + top: number; + right: number; + bottom: number; + left: number; +} + +/** + * One word's bounding box within a {@link TextItem}, in the same viewport space + * (top-left origin, 72 DPI). `text` excludes inter-word spaces. + */ +export interface WordBox { + text: string; + x: number; + y: number; + width: number; + height: number; +} + +export interface TextItem { + text: string; + x: number; + y: number; + width: number; + height: number; + fontName?: string; + fontSize?: number; + confidence?: number; + /** Rotation in degrees (viewport space). Defaults to 0 when omitted. */ + rotation?: number; + /** + * Per-word sub-boxes within this item. A text item groups several words + * together (breaking only at line/column boundaries), so this carries the + * finer word-level geometry for bbox attribution. Empty/undefined for items + * with no word split (e.g. OCR-sourced or single-token items). + */ + words?: WordBox[]; +} + +/** + * A vector-graphic primitive supplied to {@link LiteParse.parsePages}. `kind` + * selects the variant: `"stroke"` (uses `x1/y1/x2/y2`) or `"rect"` (uses + * `x/y/width/height`, top-left origin). Coordinates are viewport space (72 DPI), + * matching the text items. `hasFill`/`hasStroke` carry the paint intent even + * when the color is unknown, so ruled-table edge detection still treats a + * colorless stroked rect as stroked. + */ +export interface Graphic { + kind: "stroke" | "rect"; + x1?: number; + y1?: number; + x2?: number; + y2?: number; + x?: number; + y?: number; + width?: number; + height?: number; + hasFill?: boolean; + hasStroke?: boolean; + fillColor?: string; + strokeColor?: string; + lineWidth?: number; +} + +/** + * A page of pre-extracted text supplied to {@link LiteParse.parsePages}. + * Coordinates are viewport space (top-left origin, 72 DPI). `graphics` is + * optional; when supplied it enables ruled-table and horizontal-rule detection. + */ +export interface PageInput { + pageNumber: number; + pageWidth: number; + pageHeight: number; + textItems: TextItem[]; + graphics?: Graphic[]; +} + +export interface ParsedPage { + pageNum: number; + width: number; + height: number; + text: string; + markdown: string; + textItems: TextItem[]; +} + +export interface ExtractedImage { + /** Reference id used in the markdown output (e.g. `![](image_p1_0.png)` → `"p1_0"`). */ + id: string; + page: number; + format: string; + bytes: Buffer; +} + +export interface ParseResult { + pages: ParsedPage[]; + text: string; + /** Populated only when configured with `imageMode: "embed"`. */ + images: ExtractedImage[]; +} + +export interface ScreenshotResult { + pageNum: number; + width: number; + height: number; + imageBuffer: Buffer; +} + +/** + * Per-page complexity signals from {@link LiteParse.isComplex}, used to decide + * whether a document needs OCR or other advanced parsing. + */ +export interface PageComplexityStats { + pageNumber: number; + textLength: number; + /** Fraction of the page area covered by native text (0–1). */ + textCoverage: number; + hasSubstantialImages: boolean; + imageBlockCount: number; + /** Summed image-bbox area over page area, clamped to 1. */ + imageCoverage: number; + /** Largest single *counted* image's area over page area, clamped to 1. */ + largestImageCoverage: number; + /** + * A single raster covers ≥90% of the page. Full-page backgrounds are excluded + * from the image coverage fields, so this is the only signal that tells a scan + * apart from a blank page — both otherwise report no text and no images. + */ + fullPageImage: boolean; + /** + * Filled vector-outline area not covered by native text, in pt². `undefined` + * when a cheaper signal already decided the page, so this walk was skipped. + */ + uncoveredVectorArea?: number; + isGarbled: boolean; + pageArea: number; + /** Verdict: whether this page needs more than the cheap text-only path. */ + needsOcr: boolean; + /** + * Every reason the page was flagged (e.g. `"scanned"`, `"sparse-text"`, + * `"garbled"`). Empty exactly when `needsOcr` is false. This is the value to + * route on; new reasons may be added over time. + */ + reasons: string[]; +} + +// --------------------------------------------------------------------------- +// LiteParse class +// --------------------------------------------------------------------------- + +export class LiteParse { + private _native: LiteParseNative; + private _config: LiteParseConfig; + + constructor(userConfig: Partial = {}) { + const nativeConfig: LiteParseNativeConfig = { + ocrLanguage: userConfig.ocrLanguage, + ocrEnabled: userConfig.ocrEnabled, + ocrServerUrl: userConfig.ocrServerUrl, + ocrServerHeaders: userConfig.ocrServerHeaders, + tessdataPath: userConfig.tessdataPath, + maxPages: userConfig.maxPages, + targetPages: userConfig.targetPages, + dpi: userConfig.dpi, + outputFormat: userConfig.outputFormat, + imageMode: userConfig.imageMode, + extractLinks: userConfig.extractLinks, + preserveVerySmallText: userConfig.preserveVerySmallText, + password: userConfig.password, + quiet: userConfig.quiet, + numWorkers: userConfig.numWorkers, + ocrFailureFatal: userConfig.ocrFailureFatal, + ocrHedgeDelaysMs: userConfig.ocrHedgeDelaysMs, + emitWordBoxes: userConfig.emitWordBoxes, + cropBox: userConfig.cropBox, + skipDiagonalText: userConfig.skipDiagonalText, + }; + + this._native = new native.LiteParse(nativeConfig); + + // Read back the resolved config from the native side + const resolved = this._native.config; + this._config = { + ocrLanguage: resolved.ocrLanguage ?? "eng", + ocrEnabled: resolved.ocrEnabled ?? true, + ocrServerUrl: resolved.ocrServerUrl ?? undefined, + ocrServerHeaders: resolved.ocrServerHeaders ?? undefined, + tessdataPath: resolved.tessdataPath ?? undefined, + maxPages: resolved.maxPages ?? 1000, + targetPages: resolved.targetPages ?? undefined, + dpi: resolved.dpi ?? 150, + outputFormat: (resolved.outputFormat as OutputFormat) ?? "json", + imageMode: (resolved.imageMode as ImageMode) ?? "placeholder", + extractLinks: resolved.extractLinks ?? true, + preserveVerySmallText: resolved.preserveVerySmallText ?? false, + password: resolved.password ?? undefined, + quiet: resolved.quiet ?? false, + numWorkers: resolved.numWorkers ?? 1, + ocrFailureFatal: resolved.ocrFailureFatal ?? true, + ocrHedgeDelaysMs: resolved.ocrHedgeDelaysMs ?? [], + emitWordBoxes: resolved.emitWordBoxes ?? false, + cropBox: resolved.cropBox ?? undefined, + skipDiagonalText: resolved.skipDiagonalText ?? false, + }; + } + + async parse(input: LiteParseInput): Promise { + // Convert Uint8Array to Buffer for the native side + const nativeInput = + typeof input === "string" ? input : Buffer.from(input); + const result: NativeParseResult = await this._native.parse(nativeInput); + return { + pages: result.pages.map(toPage), + text: result.text, + images: (result.images ?? []).map(toImage), + }; + } + + /** + * Parse from pre-extracted pages, skipping PDFium text extraction. Runs only + * grid projection + the configured output formatter, so the caller's own + * text-extraction / font-recovery owns the text content. Synchronous: no + * PDFium load and no OCR on this path. + */ + parsePages(pages: PageInput[]): ParseResult { + const nativePages: NativePageInput[] = pages.map((p) => ({ + pageNumber: p.pageNumber, + pageWidth: p.pageWidth, + pageHeight: p.pageHeight, + textItems: p.textItems, + graphics: p.graphics, + })); + const result = this._native.parsePages(nativePages); + return { + pages: result.pages.map(toPage), + text: result.text, + images: (result.images ?? []).map(toImage), + }; + } + + /** + * Determine per-page complexity without running a full parse. Returns one + * entry per page with signals and a `needsOcr` verdict — a cheap pre-OCR + * check to decide whether a document needs advanced parsing. + */ + async isComplex(input: LiteParseInput): Promise { + const nativeInput = + typeof input === "string" ? input : Buffer.from(input); + const stats: NativePageComplexityStats[] = + await this._native.isComplex(nativeInput); + return stats.map((s) => ({ + pageNumber: s.pageNumber, + textLength: s.textLength, + textCoverage: s.textCoverage, + hasSubstantialImages: s.hasSubstantialImages, + imageBlockCount: s.imageBlockCount, + imageCoverage: s.imageCoverage, + largestImageCoverage: s.largestImageCoverage, + fullPageImage: s.fullPageImage, + uncoveredVectorArea: s.uncoveredVectorArea ?? undefined, + isGarbled: s.isGarbled, + pageArea: s.pageArea, + needsOcr: s.needsOcr, + reasons: s.reasons, + })); + } + + async screenshot( + input: LiteParseInput, + pageNumbers?: number[], + ): Promise { + const nativeInput = + typeof input === "string" ? input : Buffer.from(input); + const results = await this._native.screenshot( + nativeInput, + pageNumbers ?? null, + ); + return results.map((r) => ({ + pageNum: r.pageNum, + width: r.width, + height: r.height, + imageBuffer: r.imageBuffer, + })); + } + + getConfig(): LiteParseConfig { + return { ...this._config }; + } +} + +function toPage(p: NativeParsedPage): ParsedPage { + return { + pageNum: p.pageNum, + width: p.width, + height: p.height, + text: p.text, + markdown: p.markdown, + textItems: p.textItems.map(toTextItem), + }; +} + +function toImage(img: NativeExtractedImage): ExtractedImage { + return { + id: img.id, + page: img.page, + format: img.format, + bytes: img.bytes, + }; +} + +function toTextItem(item: NativeTextItem): TextItem { + return { + text: item.text, + x: item.x, + y: item.y, + width: item.width, + height: item.height, + fontName: item.fontName, + fontSize: item.fontSize, + confidence: item.confidence, + rotation: item.rotation, + words: item.words, + }; +} + +// --------------------------------------------------------------------------- +// searchItems — standalone utility +// --------------------------------------------------------------------------- + +export interface SearchItemsOptions { + phrase: string; + caseSensitive?: boolean; +} + +export function searchItems( + items: TextItem[], + options: SearchItemsOptions, +): TextItem[] { + const nativeResults = native.searchItems( + items, + options.phrase, + options.caseSensitive ?? false, + ); + return nativeResults.map(toTextItem); +} + +export default LiteParse; diff --git a/packages/node/src/native.ts b/packages/node/src/native.ts new file mode 100644 index 0000000..03942dc --- /dev/null +++ b/packages/node/src/native.ts @@ -0,0 +1,219 @@ +// Native binary loader - tries platform-specific packages, falls back to local .node file. +// +// In production: the correct @llamaindex/liteparse- optional dependency +// provides the .node binary. During development: `napi build` places it alongside package.json. + +import { createRequire } from "node:module"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const require = createRequire(import.meta.url); +const __dirname = dirname(fileURLToPath(import.meta.url)); + +interface NativeBindings { + LiteParse: new (config?: LiteParseNativeConfig) => LiteParseNative; + searchItems( + items: NativeTextItem[], + phrase: string, + caseSensitive?: boolean | null, + ): NativeTextItem[]; +} + +export interface LiteParseNativeConfig { + ocrLanguage?: string; + ocrEnabled?: boolean; + ocrServerUrl?: string; + ocrServerHeaders?: Record; + tessdataPath?: string; + maxPages?: number; + targetPages?: string; + dpi?: number; + outputFormat?: string; + imageMode?: string; + extractLinks?: boolean; + preserveVerySmallText?: boolean; + password?: string; + quiet?: boolean; + numWorkers?: number; + ocrFailureFatal?: boolean; + ocrHedgeDelaysMs?: number[]; + emitWordBoxes?: boolean; + cropBox?: NativeCropBox; + skipDiagonalText?: boolean; +} + +export interface NativeCropBox { + top: number; + right: number; + bottom: number; + left: number; +} + +export interface NativeWordBox { + text: string; + x: number; + y: number; + width: number; + height: number; +} + +export interface NativeTextItem { + text: string; + x: number; + y: number; + width: number; + height: number; + fontName?: string; + fontSize?: number; + confidence?: number; + rotation?: number; + words?: NativeWordBox[]; +} + +export interface NativeGraphic { + kind: string; + x1?: number; + y1?: number; + x2?: number; + y2?: number; + x?: number; + y?: number; + width?: number; + height?: number; + hasFill?: boolean; + hasStroke?: boolean; + fillColor?: string; + strokeColor?: string; + lineWidth?: number; +} + +export interface NativePageInput { + pageNumber: number; + pageWidth: number; + pageHeight: number; + textItems: NativeTextItem[]; + graphics?: NativeGraphic[]; +} + +export interface NativeParsedPage { + pageNum: number; + width: number; + height: number; + text: string; + markdown: string; + textItems: NativeTextItem[]; +} + +export interface NativeExtractedImage { + id: string; + page: number; + format: string; + bytes: Buffer; +} + +export interface NativeParseResult { + pages: NativeParsedPage[]; + text: string; + images: NativeExtractedImage[]; +} + +export interface NativeScreenshotResult { + pageNum: number; + width: number; + height: number; + imageBuffer: Buffer; +} + +export interface NativePageComplexityStats { + pageNumber: number; + textLength: number; + textCoverage: number; + hasSubstantialImages: boolean; + imageBlockCount: number; + imageCoverage: number; + largestImageCoverage: number; + fullPageImage: boolean; + uncoveredVectorArea?: number; + isGarbled: boolean; + pageArea: number; + needsOcr: boolean; + reasons: string[]; +} + +export interface LiteParseNative { + parse(input: string | Buffer): Promise; + parsePages(pages: NativePageInput[]): NativeParseResult; + isComplex(input: string | Buffer): Promise; + screenshot( + input: string | Buffer, + pageNumbers?: number[] | null, + ): Promise; + format(result: NativeParseResult): string; + readonly config: LiteParseNativeConfig; +} + +function loadNative(): NativeBindings { + // Platform-specific package names generated by napi-rs + const triples: Record = { + "darwin-x64": "@llamaindex/liteparse-darwin-x64", + "darwin-arm64": "@llamaindex/liteparse-darwin-arm64", + "linux-x64-gnu": "@llamaindex/liteparse-linux-x64-gnu", + "linux-x64-musl": "@llamaindex/liteparse-linux-x64-musl", + "linux-arm64-gnu": "@llamaindex/liteparse-linux-arm64-gnu", + "linux-arm64-musl": "@llamaindex/liteparse-linux-arm64-musl", + "win32-x64-msvc": "@llamaindex/liteparse-win32-x64-msvc", + "win32-arm64-msvc": "@llamaindex/liteparse-win32-arm64-msvc", + }; + + // Try platform-specific package first + const platform = process.platform; + const arch = process.arch; + + const candidates: string[] = []; + if (platform === "linux") { + // Try gnu first, then musl + candidates.push(`${platform}-${arch}-gnu`); + candidates.push(`${platform}-${arch}-musl`); + } else if (platform === "win32") { + candidates.push(`${platform}-${arch}-msvc`); + } else { + candidates.push(`${platform}-${arch}`); + } + + for (const key of candidates) { + const pkg = triples[key]; + if (pkg) { + try { + return require(pkg); + } catch { + // Not installed, try next + } + } + } + + // Fallback: local .node file (development builds) + // Try several paths since __dirname may be dist/ or dist/src/ + const searchDirs = [__dirname, join(__dirname, ".."), join(__dirname, "..", "..")]; + // Try full triple names (e.g. liteparse.linux-x64-gnu.node) and simple name + const fileNames = [ + ...candidates.map((c) => `liteparse.${c}.node`), + `liteparse.${platform}-${arch}.node`, + "liteparse.node", + ]; + for (const dir of searchDirs) { + for (const fileName of fileNames) { + try { + return require(join(dir, fileName)); + } catch { + // try next + } + } + } + + throw new Error( + `Failed to load native module for ${platform}-${arch}. ` + + `Ensure the correct optional dependency is installed.`, + ); +} + +export const native = loadNative(); diff --git a/packages/node/tsconfig.json b/packages/node/tsconfig.json new file mode 100644 index 0000000..028f6d6 --- /dev/null +++ b/packages/node/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "skipLibCheck": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/python/.gitignore b/packages/python/.gitignore new file mode 100644 index 0000000..857b617 --- /dev/null +++ b/packages/python/.gitignore @@ -0,0 +1,5 @@ +# Native extension and bundled dylib (built by maturin) +*.so +*.pyd +*.dylib +*.dll diff --git a/packages/python/README.md b/packages/python/README.md new file mode 100644 index 0000000..a624501 --- /dev/null +++ b/packages/python/README.md @@ -0,0 +1,135 @@ +# LiteParse Python + +Python bindings for [LiteParse](https://github.com/run-llama/liteparse) — fast, lightweight PDF and document parsing with spatial text extraction. + +## Installation + +```bash +pip install liteparse +``` + +This also installs the `lit` CLI command. + +## Quick Start + +```python +from liteparse import LiteParse + +parser = LiteParse() +result = parser.parse("document.pdf") +print(result.text) + +# Access structured data +for page in result.pages: + print(f"Page {page.page_num}: {len(page.text_items)} text items") +``` + +## Markdown Output + +LiteParse can render documents directly to Markdown including headings, tables, lists, +images, and links reconstructed from the spatial layout. Great for feeding LLMs +and RAG pipelines. The rendered Markdown is returned on `result.text`: + +```python +parser = LiteParse( + output_format="markdown", # "json" | "text" | "markdown" + image_mode="placeholder", # "placeholder" | "off" | "embed" + extract_links=True, # render [text](url) link syntax (default: True) +) +result = parser.parse("document.pdf") +print(result.text) # rendered Markdown +``` + +> Reconstruction quality varies with document complexity. + +## Configuration + +All options are passed to the constructor: + +```python +parser = LiteParse( + ocr_enabled=True, # Enable OCR (default: True) + ocr_language="eng", # Tesseract language code + ocr_server_url=None, # HTTP OCR server URL (optional) + tessdata_path=None, # Path to tessdata directory (optional) + max_pages=1000, # Max pages to parse + target_pages="1-5,10", # Specific pages (optional) + dpi=150, # Rendering DPI + output_format="json", # "json" | "text" | "markdown" + image_mode="placeholder", # Markdown image handling: "placeholder" | "off" | "embed" + extract_links=True, # Render [text](url) links in markdown output + preserve_very_small_text=False, # Keep tiny text + password=None, # Password for protected documents + quiet=False, # Suppress progress output + num_workers=4, # Concurrent OCR workers +) +``` + +## Parsing from Bytes + +Pass raw PDF bytes directly — useful for web uploads or downloaded files: + +```python +with open("document.pdf", "rb") as f: + result = parser.parse(f.read()) +print(result.text) +``` + +## Screenshots + +Generate PNG screenshots of document pages: + +```python +screenshots = parser.screenshot("document.pdf", page_numbers=[1, 2, 3]) +for s in screenshots: + print(f"Page {s.page_num}: {s.width}x{s.height}") + with open(f"page_{s.page_num}.png", "wb") as f: + f.write(s.image_bytes) +``` + +## Document Complexity + +Before committing to a full parse, check whether a document needs OCR or heavier +processing. `is_complex` is a cheap, text-layer-only pass that returns one entry per page +with a `needs_ocr` verdict and the signals behind it — useful for routing documents to +different pipelines, rejecting ones you can't handle, or estimating cost. + +```python +parser = LiteParse() +pages = parser.is_complex("document.pdf") + +if any(p.needs_ocr for p in pages): + # Route to the OCR-enabled pipeline + result = parser.parse("document.pdf") +else: + # Cheap path — skip OCR entirely + result = LiteParse(ocr_enabled=False).parse("document.pdf") + +# Inspect why specific pages were flagged +for page in pages: + if page.needs_ocr: + print(f"Page {page.page_number}: {', '.join(page.reasons)}") +``` + +`reasons` is one of `"scanned"`, `"no-text"`, `"sparse-text"`, `"embedded-images"`, +`"garbled"`, or `"vector-text"`. Raw bytes work here too. + +## Supported Formats + +- PDF (`.pdf`) +- Microsoft Office (`.docx`, `.xlsx`, `.pptx`, etc.) — requires LibreOffice +- OpenDocument (`.odt`, `.ods`, `.odp`) — requires LibreOffice +- Images (`.png`, `.jpg`, `.tiff`, etc.) — requires ImageMagick +- And more! + +## CLI + +The Python package includes the `lit` CLI: + +```bash +lit parse document.pdf +lit parse document.pdf --format json -o output.json +lit screenshot document.pdf -o ./screenshots +lit batch-parse ./input ./output +lit is-complex document.pdf +``` diff --git a/packages/python/liteparse/__init__.py b/packages/python/liteparse/__init__.py new file mode 100644 index 0000000..3ccef61 --- /dev/null +++ b/packages/python/liteparse/__init__.py @@ -0,0 +1,27 @@ +from .parser import LiteParse, search_items +from .types import ( + ExtractedImage, + LiteParseConfig, + PageComplexityStats, + ParseResult, + ParsedPage, + TextItem, + WordBox, + ScreenshotResult, + ParseError, +) + +__version__ = "2.0.0" +__all__ = [ + "LiteParse", + "LiteParseConfig", + "ParseResult", + "ParsedPage", + "TextItem", + "WordBox", + "ScreenshotResult", + "PageComplexityStats", + "ExtractedImage", + "ParseError", + "search_items", +] diff --git a/packages/python/liteparse/cli.py b/packages/python/liteparse/cli.py new file mode 100644 index 0000000..d92e700 --- /dev/null +++ b/packages/python/liteparse/cli.py @@ -0,0 +1,19 @@ +"""CLI entry point for the `lit` command.""" + +import sys + +from liteparse._liteparse import run_cli + + +def main() -> None: + try: + run_cli(sys.argv) + except SystemExit: + raise + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/packages/python/liteparse/parser.py b/packages/python/liteparse/parser.py new file mode 100644 index 0000000..3d07fdc --- /dev/null +++ b/packages/python/liteparse/parser.py @@ -0,0 +1,388 @@ +"""LiteParse Python wrapper - native Rust bindings via PyO3.""" + +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple, Union + +from liteparse._liteparse import LiteParse as _NativeLiteParse +from liteparse._liteparse import search_items as _native_search_items + +from .types import ( + ExtractedImage, + LiteParseConfig, + PageComplexityStats, + ParsedPage, + ParseError, + ParseResult, + ScreenshotResult, + TextItem, + WordBox, +) + + +def _convert_native_result(native_result: Any) -> ParseResult: + """Convert a native PyParseResult to our Python ParseResult.""" + pages: List[ParsedPage] = [] + for native_page in native_result.pages: + text_items = [ + TextItem( + text=item.text, + x=item.x, + y=item.y, + width=item.width, + height=item.height, + font_name=item.font_name, + font_size=item.font_size, + confidence=item.confidence, + rotation=getattr(item, "rotation", 0.0), + words=[ + WordBox( + text=w.text, + x=w.x, + y=w.y, + width=w.width, + height=w.height, + ) + for w in getattr(item, "words", []) + ], + ) + for item in native_page.text_items + ] + pages.append( + ParsedPage( + page_num=native_page.page_num, + width=native_page.width, + height=native_page.height, + text=native_page.text, + markdown=native_page.markdown, + text_items=text_items, + ) + ) + images = [ + ExtractedImage( + id=img.id, + page=img.page, + format=img.format, + bytes=img.bytes, + ) + for img in getattr(native_result, "images", []) + ] + return ParseResult( + pages=pages, + text=native_result.text, + images=images, + ) + + +class LiteParse: + """ + Python wrapper for the LiteParse document parser. + + Uses native Rust bindings for fast, in-process PDF parsing. + + Example: + >>> from liteparse import LiteParse + >>> parser = LiteParse() + >>> result = parser.parse("document.pdf") + >>> print(result.text) + """ + + def __init__( + self, + *, + ocr_enabled: Optional[bool] = None, + ocr_server_url: Optional[str] = None, + ocr_server_headers: Optional[Dict[str, str]] = None, + ocr_language: Optional[str] = None, + tessdata_path: Optional[str] = None, + max_pages: Optional[int] = None, + target_pages: Optional[str] = None, + dpi: Optional[float] = None, + output_format: Optional[str] = None, + preserve_very_small_text: Optional[bool] = None, + password: Optional[str] = None, + quiet: Optional[bool] = None, + num_workers: Optional[int] = None, + image_mode: Optional[str] = None, + extract_links: Optional[bool] = None, + ocr_failure_fatal: Optional[bool] = None, + ocr_hedge_delays_ms: Optional[List[int]] = None, + emit_word_boxes: Optional[bool] = None, + crop_box: Optional[Tuple[float, float, float, float]] = None, + skip_diagonal_text: Optional[bool] = None, + ): + """ + Initialize LiteParse parser. + + Args: + ocr_enabled: Whether to enable OCR for scanned documents (default: True) + ocr_server_url: URL of HTTP OCR server (uses Tesseract if not provided) + ocr_server_headers: Extra HTTP headers sent with every request to + ``ocr_server_url`` (e.g. ``{"Authorization": "Bearer "}``) + ocr_language: Language code for OCR (e.g., "eng", "fra") + tessdata_path: Path to tessdata directory for Tesseract + max_pages: Maximum number of pages to parse + target_pages: Specific pages to parse (e.g., "1-5,10,15-20") + dpi: DPI for rendering (affects OCR quality) + output_format: Output format: "json", "text", or "markdown" (default: "json") + preserve_very_small_text: Whether to preserve very small text + password: Password for encrypted/protected documents + quiet: Suppress progress output + num_workers: Number of concurrent OCR workers (default: CPU cores - 1) + extract_links: Render hyperlink annotations as ``[text](url)`` in + markdown output (default: True). Set False for plain anchor text. + ocr_failure_fatal: Whether a systemic OCR failure (every OCR task + failed and at least one was a text-sparse page) aborts the whole + parse (default: True). Set False to keep already-recovered native + text and return partial results instead of raising — for callers + that prefer a degraded document over a hard failure. + ocr_hedge_delays_ms: Request-hedging schedule for HTTP OCR, in + milliseconds. Empty or single-element means no hedging (one + request per attempt — the default). With multiple delays (e.g. + ``[0, 5000, 10000]``) each attempt fires a duplicate request at + every delay and takes the first to succeed, cancelling the rest + — trades extra OCR-server load for lower tail latency. + emit_word_boxes: Emit per-word sub-boxes on each text item + (``TextItem.words``). Default False. Word boxes roughly double + the text-item payload, so enable only for word-level bbox + attribution. + crop_box: Restrict output to a page sub-region, as a + ``(top, right, bottom, left)`` tuple where each value is the + fraction cropped from that side (top-left origin, each in + ``[0, 1]``); e.g. ``(0, 0, 0, 0.5)`` keeps the right half. A + text item survives only if it lies entirely inside the + remaining rectangle. None (default) keeps the whole page. + skip_diagonal_text: Drop diagonal text — items whose rotation is + more than 2° off the nearest right angle (0/90/180/270). + Default False. Use to exclude rotated watermarks/stamps. + """ + kwargs = {} + if ocr_enabled is not None: + kwargs["ocr_enabled"] = ocr_enabled + if ocr_server_url is not None: + kwargs["ocr_server_url"] = ocr_server_url + if ocr_server_headers is not None: + kwargs["ocr_server_headers"] = ocr_server_headers + if ocr_language is not None: + kwargs["ocr_language"] = ocr_language + if tessdata_path is not None: + kwargs["tessdata_path"] = tessdata_path + if max_pages is not None: + kwargs["max_pages"] = max_pages + if target_pages is not None: + kwargs["target_pages"] = target_pages + if dpi is not None: + kwargs["dpi"] = dpi + if output_format is not None: + kwargs["output_format"] = output_format + if preserve_very_small_text is not None: + kwargs["preserve_very_small_text"] = preserve_very_small_text + if password is not None: + kwargs["password"] = password + if quiet is not None: + kwargs["quiet"] = quiet + if num_workers is not None: + kwargs["num_workers"] = num_workers + if image_mode is not None: + kwargs["image_mode"] = image_mode + if extract_links is not None: + kwargs["extract_links"] = extract_links + if ocr_failure_fatal is not None: + kwargs["ocr_failure_fatal"] = ocr_failure_fatal + if ocr_hedge_delays_ms is not None: + kwargs["ocr_hedge_delays_ms"] = ocr_hedge_delays_ms + if emit_word_boxes is not None: + kwargs["emit_word_boxes"] = emit_word_boxes + if crop_box is not None: + kwargs["crop_box"] = crop_box + if skip_diagonal_text is not None: + kwargs["skip_diagonal_text"] = skip_diagonal_text + + self._native = _NativeLiteParse(**kwargs) + + def parse( + self, + file_data: Union[str, Path, bytes], + ) -> ParseResult: + """ + Parse a document file. + + Args: + file_data: Path to the document file, or raw PDF bytes. + + Returns: + ParseResult containing the parsed document data. + + Raises: + ParseError: If parsing fails. + FileNotFoundError: If the file doesn't exist. + """ + try: + if isinstance(file_data, bytes): + native_result = self._native.parse_bytes(file_data) + else: + file_path = Path(file_data) + if not file_path.exists(): + raise FileNotFoundError(f"File not found: {file_path}") + native_result = self._native.parse(str(file_path.absolute())) + return _convert_native_result(native_result) + except FileNotFoundError: + raise + except Exception as e: + raise ParseError(str(e)) from e + + def is_complex( + self, + file_data: Union[str, Path, bytes], + ) -> List[PageComplexityStats]: + """ + Determine per-page complexity without running a full parse. + + Returns one entry per page with signals (text coverage, images, garbled + text, vector area) and a ``needs_ocr`` verdict — a cheap pre-OCR check to + decide whether a document needs advanced parsing. + + Args: + file_data: Path to the document file, or raw PDF bytes. + + Returns: + List of PageComplexityStats, one per page. + + Raises: + ParseError: If the check fails. + FileNotFoundError: If the file doesn't exist. + """ + try: + if isinstance(file_data, bytes): + native_stats = self._native.is_complex_bytes(file_data) + else: + file_path = Path(file_data) + if not file_path.exists(): + raise FileNotFoundError(f"File not found: {file_path}") + native_stats = self._native.is_complex(str(file_path.absolute())) + return [ + PageComplexityStats( + page_number=s.page_number, + text_length=s.text_length, + text_coverage=s.text_coverage, + has_substantial_images=s.has_substantial_images, + image_block_count=s.image_block_count, + image_coverage=s.image_coverage, + largest_image_coverage=s.largest_image_coverage, + full_page_image=s.full_page_image, + uncovered_vector_area=s.uncovered_vector_area, + is_garbled=s.is_garbled, + page_area=s.page_area, + needs_ocr=s.needs_ocr, + reasons=list(s.reasons), + ) + for s in native_stats + ] + except FileNotFoundError: + raise + except Exception as e: + raise ParseError(str(e)) from e + + def screenshot( + self, + file_path: Union[str, Path], + *, + page_numbers: Optional[List[int]] = None, + ) -> List[ScreenshotResult]: + """ + Generate screenshots of document pages. + + Supports PDFs natively. Non-PDF formats (DOCX, XLSX, images, etc.) are + automatically converted to PDF before rendering when the required system + tools are installed. Plain-text formats cannot be rendered. + + Args: + file_path: Path to the document file (PDF, DOCX, images, etc.). + page_numbers: Specific page numbers to screenshot (1-indexed). + If None, screenshots all pages. + + Returns: + List of ScreenshotResult with PNG image bytes. + + Raises: + FileNotFoundError: If the file doesn't exist. + ParseError: If screenshot generation fails. + """ + file_path = Path(file_path) + if not file_path.exists(): + raise FileNotFoundError(f"File not found: {file_path}") + + try: + native_results = self._native.screenshot( + str(file_path.absolute()), + page_numbers, + ) + return [ + ScreenshotResult( + page_num=r.page_num, + width=r.width, + height=r.height, + image_bytes=r.image_bytes, + ) + for r in native_results + ] + except Exception as e: + raise ParseError(str(e)) from e + + def get_config(self) -> LiteParseConfig: + """Return the resolved configuration.""" + cfg = self._native.config + return LiteParseConfig( + ocr_language=cfg.ocr_language, + ocr_enabled=cfg.ocr_enabled, + ocr_server_url=cfg.ocr_server_url, + ocr_server_headers=cfg.ocr_server_headers, + tessdata_path=cfg.tessdata_path, + max_pages=cfg.max_pages, + target_pages=cfg.target_pages, + dpi=cfg.dpi, + output_format=cfg.output_format, + preserve_very_small_text=cfg.preserve_very_small_text, + password=cfg.password, + quiet=cfg.quiet, + num_workers=cfg.num_workers, + ) + + def __repr__(self) -> str: + return "LiteParse()" + + +def search_items( + items: List[TextItem], + phrase: str, + *, + case_sensitive: bool = False, +) -> List[TextItem]: + """ + Search text items for phrase matches, returning merged items with combined bounding boxes. + + A phrase may span multiple adjacent text items. This function concatenates + consecutive items, finds matches, and returns synthetic merged TextItem + objects with combined bounding boxes. + + Args: + items: List of TextItem objects to search through. + phrase: The phrase to search for. + case_sensitive: Whether the search should be case-sensitive (default: False). + + Returns: + List of TextItem objects representing the matched regions. + """ + native_results = _native_search_items(items, phrase, case_sensitive=case_sensitive) + return [ + TextItem( + text=item.text, + x=item.x, + y=item.y, + width=item.width, + height=item.height, + font_name=item.font_name, + font_size=item.font_size, + confidence=item.confidence, + ) + for item in native_results + ] diff --git a/packages/python/liteparse/py.typed b/packages/python/liteparse/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/packages/python/liteparse/types.py b/packages/python/liteparse/types.py new file mode 100644 index 0000000..c9ca0d3 --- /dev/null +++ b/packages/python/liteparse/types.py @@ -0,0 +1,128 @@ +"""Python-friendly type wrappers around the native Rust bindings.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Dict, Iterator, List, Optional + + +@dataclass +class WordBox: + """One word's bounding box within a :class:`TextItem`, in the same viewport + space (top-left origin, 72 DPI). ``text`` excludes inter-word spaces.""" + text: str + x: float + y: float + width: float + height: float + + +@dataclass +class TextItem: + """Individual text item extracted from a document.""" + text: str + x: float + y: float + width: float + height: float + font_name: Optional[str] = None + font_size: Optional[float] = None + confidence: Optional[float] = None + rotation: float = 0.0 + #: Per-word sub-boxes. Empty unless the parser was configured with + #: ``emit_word_boxes=True``. + words: List[WordBox] = field(default_factory=list) + + +@dataclass +class ParsedPage: + """A parsed page from a document.""" + page_num: int + width: float + height: float + text: str + markdown: str = "" + text_items: List[TextItem] = field(default_factory=list) + + +@dataclass +class ExtractedImage: + """An embedded raster image extracted from a page. + + Populated only when the parser was configured with ``image_mode="embed"``. + The ``id`` matches the reference used in the markdown output + (e.g. ``![](image_p1_0.png)`` → ``id="p1_0"``). + """ + id: str + page: int + format: str + bytes: bytes + + +@dataclass +class ParseResult: + """Result of parsing a document.""" + pages: List[ParsedPage] + text: str + images: List[ExtractedImage] = field(default_factory=list) + + @property + def num_pages(self) -> int: + return len(self.pages) + + def get_page(self, page_num: int) -> Optional[ParsedPage]: + """Get a specific page by number (1-indexed).""" + for page in self.pages: + if page.page_num == page_num: + return page + return None + + +@dataclass +class ScreenshotResult: + """Result of a single page screenshot.""" + page_num: int + width: int + height: int + image_bytes: bytes + + +@dataclass +class PageComplexityStats: + """Per-page complexity signals used to decide whether a document needs OCR.""" + page_number: int + text_length: int + text_coverage: float + has_substantial_images: bool + image_block_count: int + image_coverage: float + largest_image_coverage: float + full_page_image: bool + uncovered_vector_area: Optional[float] + is_garbled: bool + page_area: float + needs_ocr: bool + reasons: list[str] + + +@dataclass +class LiteParseConfig: + """Resolved parser configuration.""" + ocr_language: str + ocr_enabled: bool + ocr_server_url: Optional[str] + ocr_server_headers: Optional[Dict[str, str]] + tessdata_path: Optional[str] + max_pages: int + target_pages: Optional[str] + dpi: float + output_format: str + preserve_very_small_text: bool + password: Optional[str] + quiet: bool + num_workers: int + + +class ParseError(Exception): + """Exception raised when parsing fails.""" + pass diff --git a/packages/python/pyproject.toml b/packages/python/pyproject.toml new file mode 100644 index 0000000..d92e88b --- /dev/null +++ b/packages/python/pyproject.toml @@ -0,0 +1,62 @@ +[build-system] +requires = ["maturin>=1.0,<2.0"] +build-backend = "maturin" + +[project] +name = "liteparse" +version = "2.5.1" +description = "Python bindings for LiteParse - fast, lightweight PDF and document parsing" +readme = "README.md" +license = "Apache-2.0" +requires-python = ">=3.10" +authors = [ + { name = "Logan Markewich", email = "logan@runllama.ai" }, +] +keywords = ["pdf", "parsing", "ocr", "document", "text-extraction"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Rust", + "Topic :: Text Processing", + "Topic :: Scientific/Engineering :: Information Analysis", + "Typing :: Typed", +] + +[project.urls] +Homepage = "https://github.com/run-llama/liteparse" +Documentation = "https://github.com/run-llama/liteparse#readme" +Repository = "https://github.com/run-llama/liteparse" + +[project.scripts] +lit = "liteparse.cli:main" + +[project.optional-dependencies] +dev = [ + "pytest>=7.0", + "pytest-asyncio>=0.21", + "mypy>=1.0", +] + +[tool.maturin] +manifest-path = "../../crates/liteparse-python/Cargo.toml" +python-source = "." +module-name = "liteparse._liteparse" +# Bundle libpdfium alongside the extension module +include = [ + { path = "liteparse/libpdfium.dylib", format = "wheel" }, + { path = "liteparse/libpdfium.so", format = "wheel" }, + { path = "liteparse/pdfium.dll", format = "wheel" }, +] + +[tool.mypy] +python_version = "3.10" +strict = true +warn_return_any = true +warn_unused_ignores = true diff --git a/packages/python/scripts/copy-pdfium.sh b/packages/python/scripts/copy-pdfium.sh new file mode 100755 index 0000000..4a94c87 --- /dev/null +++ b/packages/python/scripts/copy-pdfium.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# +# Copy the pdfium shared library into the liteparse package directory +# so it can be found at runtime via @loader_path (macOS) or $ORIGIN (Linux). +# +# Usage: ./scripts/copy-pdfium.sh +# +# The script auto-detects the pdfium library location from: +# 1. PDFIUM_LIB_PATH env var (set by CI or user) +# 2. The pdfium-sys build cache (~/.cache/pdfium-rs or ~/Library/Caches/pdfium-rs) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +OUTPUT_DIR="${SCRIPT_DIR}/../liteparse" + +# Determine OS and library filename +case "$(uname -s)" in + Darwin*) DYLIB="libpdfium.dylib" ;; + Linux*) DYLIB="libpdfium.so" ;; + MINGW*|MSYS*|CYGWIN*) DYLIB="pdfium.dll" ;; + *) echo "Unsupported OS: $(uname -s)" >&2; exit 1 ;; +esac + +# Find the pdfium library +find_pdfium() { + # 1. Explicit env var + if [ -n "${PDFIUM_LIB_PATH:-}" ] && [ -f "${PDFIUM_LIB_PATH}/${DYLIB}" ]; then + echo "${PDFIUM_LIB_PATH}/${DYLIB}" + return + fi + + # 2. Vendor directory + local vendor="${SCRIPT_DIR}/../../vendor/pdfium/release/lib/${DYLIB}" + if [ -f "$vendor" ]; then + echo "$vendor" + return + fi + + # 3. Cargo build output (debug and release) + local workspace_root="${SCRIPT_DIR}/../../../.." + for profile in release debug; do + local deps="${workspace_root}/target/${profile}/deps/${DYLIB}" + if [ -f "$deps" ]; then + echo "$deps" + return + fi + done + + # 4. Build cache + local cache_base + case "$(uname -s)" in + Darwin*) cache_base="$HOME/Library/Caches/pdfium-rs" ;; + *) cache_base="${XDG_CACHE_HOME:-$HOME/.cache}/pdfium-rs" ;; + esac + + if [ -d "$cache_base" ]; then + local found + found=$(find "$cache_base" -name "$DYLIB" -type f 2>/dev/null | head -1) + if [ -n "$found" ]; then + echo "$found" + return + fi + fi + + echo "" +} + +PDFIUM_PATH=$(find_pdfium) + +if [ -z "$PDFIUM_PATH" ]; then + echo "Error: Could not find ${DYLIB}. Set PDFIUM_LIB_PATH to the directory containing it." >&2 + exit 1 +fi + +cp "$PDFIUM_PATH" "${OUTPUT_DIR}/${DYLIB}" +echo "Copied ${PDFIUM_PATH} -> ${OUTPUT_DIR}/${DYLIB}" diff --git a/packages/python/scripts/download-pdfium.sh b/packages/python/scripts/download-pdfium.sh new file mode 100755 index 0000000..b134da2 --- /dev/null +++ b/packages/python/scripts/download-pdfium.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# +# Download the prebuilt pdfium binary for a given target triple and stage it +# at `packages/python/liteparse/` so maturin's `[tool.maturin] include` +# rule packs it into the wheel (and records it in RECORD) at build time. +# +# Usage: ./scripts/download-pdfium.sh +# +# The release tag is parsed from `crates/pdfium-sys/build.rs` so we only have +# one source of truth for the pdfium version. + +set -euo pipefail + +TARGET="${1:?usage: download-pdfium.sh }" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PKG_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +REPO_ROOT="$(cd "$PKG_DIR/../.." && pwd)" + +BUILD_RS="$REPO_ROOT/crates/pdfium-sys/build.rs" +TAG=$(grep -E 'const PDFIUM_RELEASE_TAG' "$BUILD_RS" | sed -E 's/.*"([^"]+)".*/\1/') +[ -n "$TAG" ] || { echo "Could not parse PDFIUM_RELEASE_TAG from $BUILD_RS" >&2; exit 1; } + +case "$TARGET" in + aarch64-apple-darwin) ASSET="pdfium-mac-arm64"; DYLIB="libpdfium.dylib" ;; + x86_64-apple-darwin) ASSET="pdfium-mac-x64"; DYLIB="libpdfium.dylib" ;; + x86_64-unknown-linux-gnu) ASSET="pdfium-linux-x64"; DYLIB="libpdfium.so" ;; + aarch64-unknown-linux-gnu) ASSET="pdfium-linux-arm64"; DYLIB="libpdfium.so" ;; + x86_64-unknown-linux-musl) ASSET="pdfium-linux-musl-x64"; DYLIB="libpdfium.so" ;; + aarch64-unknown-linux-musl) ASSET="pdfium-linux-arm64"; DYLIB="libpdfium.so" ;; + x86_64-pc-windows-msvc) ASSET="pdfium-win-x64"; DYLIB="pdfium.dll" ;; + aarch64-pc-windows-msvc) ASSET="pdfium-win-arm64"; DYLIB="pdfium.dll" ;; + *) echo "Unsupported target: $TARGET" >&2; exit 1 ;; +esac + +URL_TAG="${TAG//\//%2F}" +URL="https://github.com/run-llama/pdfium-binaries/releases/download/${URL_TAG}/${ASSET}.tgz" + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +echo "Downloading $URL" +curl -fsSL "$URL" -o "$TMP/${ASSET}.tgz" +tar -xzf "$TMP/${ASSET}.tgz" -C "$TMP" + +# pdfium-binaries layout: lib/ on unix, bin/pdfium.dll on windows +SRC="" +for candidate in "$TMP/lib/$DYLIB" "$TMP/bin/$DYLIB"; do + if [ -f "$candidate" ]; then + SRC="$candidate" + break + fi +done +[ -n "$SRC" ] || { echo "Could not find $DYLIB in archive" >&2; ls -R "$TMP" >&2; exit 1; } + +# Mirror the install-name fix in crates/pdfium-sys/build.rs so the dylib resolves +# via @rpath at runtime. pdfium-binaries ships macOS dylibs with install name +# `./libpdfium.dylib` which won't be found via our rpath. +if [ "$DYLIB" = "libpdfium.dylib" ] && command -v install_name_tool >/dev/null 2>&1; then + install_name_tool -id "@rpath/libpdfium.dylib" "$SRC" +fi + +DEST_DIR="$PKG_DIR/liteparse" +mkdir -p "$DEST_DIR" +cp "$SRC" "$DEST_DIR/$DYLIB" +echo "Staged $DEST_DIR/$DYLIB" diff --git a/packages/python/tests/__init__.py b/packages/python/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/python/tests/conftest.py b/packages/python/tests/conftest.py new file mode 100644 index 0000000..a5e8932 --- /dev/null +++ b/packages/python/tests/conftest.py @@ -0,0 +1,45 @@ +"""Shared fixtures for e2e tests.""" + +from pathlib import Path + +import pytest + +from liteparse import LiteParse + +# Resolve test-docs relative to the repo root +REPO_ROOT = Path(__file__).resolve().parents[3] +TEST_DOCS = REPO_ROOT / "test-docs" +EXPECTED_DATASET = REPO_ROOT / "expected-dataset" / "data" + + +@pytest.fixture +def parser() -> LiteParse: + """LiteParse instance (auto-detects CLI).""" + return LiteParse() + + +@pytest.fixture +def invoice_pdf() -> Path: + """Path to a small single-page invoice PDF.""" + p = TEST_DOCS / "invoice.pdf" + if not p.exists(): + pytest.skip(f"Test document not found: {p}") + return p + + +@pytest.fixture +def two_page_pdf() -> Path: + """Path to a 2-page PDF.""" + p = EXPECTED_DATASET / "2pages.pdf" + if not p.exists(): + pytest.skip(f"Test document not found: {p}") + return p + + +@pytest.fixture +def empty_pdf() -> Path: + """Path to an empty PDF.""" + p = EXPECTED_DATASET / "empty.pdf" + if not p.exists(): + pytest.skip(f"Test document not found: {p}") + return p diff --git a/packages/python/tests/test_batch_e2e.py b/packages/python/tests/test_batch_e2e.py new file mode 100644 index 0000000..88f72b2 --- /dev/null +++ b/packages/python/tests/test_batch_e2e.py @@ -0,0 +1,88 @@ +"""E2E tests for LiteParse.batch_parse() — validates Python types match CLI output.""" + +import tempfile +from pathlib import Path + +import pytest + +from liteparse import ( + BatchResult, + LiteParse, + OutputFormat, +) + + +class TestBatchParseBasic: + """Basic batch parsing functionality.""" + + def test_batch_parse_returns_batch_result( + self, parser: LiteParse, invoice_pdf: Path + ): + input_dir = invoice_pdf.parent + with tempfile.TemporaryDirectory() as tmpdir: + result = parser.batch_parse( + input_dir, + tmpdir, + ocr_enabled=False, + extension_filter=".pdf", + ) + assert isinstance(result, BatchResult) + assert result.output_dir == tmpdir + + def test_batch_parse_creates_output_files( + self, parser: LiteParse, invoice_pdf: Path + ): + input_dir = invoice_pdf.parent + with tempfile.TemporaryDirectory() as tmpdir: + parser.batch_parse( + input_dir, + tmpdir, + output_format=OutputFormat.TEXT, + ocr_enabled=False, + extension_filter=".pdf", + ) + output_files = list(Path(tmpdir).rglob("*")) + # Should have produced at least one output file + assert len(output_files) > 0 + + def test_batch_parse_json_format(self, parser: LiteParse, invoice_pdf: Path): + input_dir = invoice_pdf.parent + with tempfile.TemporaryDirectory() as tmpdir: + parser.batch_parse( + input_dir, + tmpdir, + output_format="json", + ocr_enabled=False, + extension_filter=".pdf", + ) + json_files = list(Path(tmpdir).rglob("*.json")) + assert len(json_files) > 0 + + @pytest.mark.asyncio + async def test_batch_parse_async(self, parser: LiteParse, invoice_pdf: Path): + input_dir = invoice_pdf.parent + with tempfile.TemporaryDirectory() as tmpdir: + result = await parser.batch_parse_async( + input_dir, + tmpdir, + output_format="json", + ocr_enabled=False, + extension_filter=".pdf", + ) + assert isinstance(result, BatchResult) + assert result.output_dir == tmpdir + + +class TestBatchParseErrors: + """Test error handling.""" + + def test_input_dir_not_found(self, parser: LiteParse): + with tempfile.TemporaryDirectory() as tmpdir: + with pytest.raises(FileNotFoundError): + parser.batch_parse("/nonexistent/dir", tmpdir) + + @pytest.mark.asyncio + async def test_input_dir_not_found_async(self, parser: LiteParse): + with tempfile.TemporaryDirectory() as tmpdir: + with pytest.raises(FileNotFoundError): + await parser.batch_parse_async("/nonexistent/dir", tmpdir) diff --git a/packages/python/tests/test_parse_e2e.py b/packages/python/tests/test_parse_e2e.py new file mode 100644 index 0000000..11b9334 --- /dev/null +++ b/packages/python/tests/test_parse_e2e.py @@ -0,0 +1,176 @@ +"""E2E tests for LiteParse.parse() — validates Python types match CLI output.""" + +from pathlib import Path + +import pytest + +from liteparse import ( + BoundingBox, + LiteParse, + ParsedPage, + ParseError, + ParseResult, + TextItem, +) + + +class TestParseBasic: + """Basic parse functionality and output structure.""" + + def test_parse_returns_parse_result(self, parser: LiteParse, invoice_pdf: Path): + result = parser.parse(invoice_pdf, ocr_enabled=False) + assert isinstance(result, ParseResult) + + def test_parse_result_has_pages(self, parser: LiteParse, invoice_pdf: Path): + result = parser.parse(invoice_pdf, ocr_enabled=False) + assert len(result.pages) > 0 + assert result.num_pages == len(result.pages) + + def test_parse_result_has_text(self, parser: LiteParse, invoice_pdf: Path): + result = parser.parse(invoice_pdf, ocr_enabled=False) + assert isinstance(result.text, str) + assert len(result.text) > 0 + + def test_parse_result_has_json(self, parser: LiteParse, invoice_pdf: Path): + result = parser.parse(invoice_pdf, ocr_enabled=False) + assert result.json is not None + assert "pages" in result.json + + def test_parse_bytest_input(self, parser: LiteParse, invoice_pdf: Path): + file_bytes = invoice_pdf.read_bytes() + result = parser.parse(file_bytes) + assert result.json is not None + assert "pages" in result.json + + @pytest.mark.asyncio + async def test_parse_async(self, parser: LiteParse, invoice_pdf: Path): + result = await parser.parse_async(invoice_pdf) + assert result.json is not None + assert "pages" in result.json + + @pytest.mark.asyncio + async def test_parse_async_bytes_input(self, parser: LiteParse, invoice_pdf: Path): + file_bytes = invoice_pdf.read_bytes() + result = await parser.parse_async(file_bytes) + assert result.json is not None + assert "pages" in result.json + + +class TestParsedPageStructure: + """Validate ParsedPage fields match CLI output.""" + + def test_page_fields(self, parser: LiteParse, invoice_pdf: Path): + result = parser.parse(invoice_pdf, ocr_enabled=False) + page = result.pages[0] + assert isinstance(page, ParsedPage) + assert isinstance(page.pageNum, int) + assert page.pageNum >= 1 + assert isinstance(page.width, (int, float)) + assert isinstance(page.height, (int, float)) + assert page.width > 0 + assert page.height > 0 + assert isinstance(page.text, str) + + def test_page_has_text_items(self, parser: LiteParse, invoice_pdf: Path): + result = parser.parse(invoice_pdf, ocr_enabled=False) + page = result.pages[0] + assert len(page.textItems) > 0 + + def test_text_item_fields(self, parser: LiteParse, invoice_pdf: Path): + result = parser.parse(invoice_pdf, ocr_enabled=False) + item = result.pages[0].textItems[0] + assert isinstance(item, TextItem) + assert isinstance(item.text, str) + assert isinstance(item.x, (int, float)) + assert isinstance(item.y, (int, float)) + assert isinstance(item.width, (int, float)) + assert isinstance(item.height, (int, float)) + # fontName and fontSize may be None or present + if item.fontName is not None: + assert isinstance(item.fontName, str) + if item.fontSize is not None: + assert isinstance(item.fontSize, (int, float)) + + def test_bounding_box_fields(self, parser: LiteParse, invoice_pdf: Path): + result = parser.parse(invoice_pdf, ocr_enabled=False, precise_bounding_box=True) + page = result.pages[0] + assert len(page.boundingBoxes) > 0 + bbox = page.boundingBoxes[0] + assert isinstance(bbox, BoundingBox) + assert isinstance(bbox.x1, (int, float)) + assert isinstance(bbox.y1, (int, float)) + assert isinstance(bbox.x2, (int, float)) + assert isinstance(bbox.y2, (int, float)) + + +class TestParseOptions: + """Test that CLI options are correctly forwarded.""" + + def test_target_pages(self, parser: LiteParse, invoice_pdf: Path): + # invoice.pdf has 2 pages — parse only page 1 + result = parser.parse(invoice_pdf, ocr_enabled=False, target_pages="1") + assert result.num_pages == 1 + assert result.pages[0].pageNum == 1 + + def test_max_pages(self, parser: LiteParse, invoice_pdf: Path): + result = parser.parse(invoice_pdf, ocr_enabled=False, max_pages=1) + assert result.num_pages == 1 + + def test_no_precise_bbox(self, parser: LiteParse, invoice_pdf: Path): + result = parser.parse( + invoice_pdf, ocr_enabled=False, precise_bounding_box=False + ) + assert isinstance(result, ParseResult) + # With no precise bbox, boundingBoxes should be empty + for page in result.pages: + assert len(page.boundingBoxes) == 0 + + def test_get_page_helper(self, parser: LiteParse, invoice_pdf: Path): + result = parser.parse(invoice_pdf, ocr_enabled=False) + page1 = result.get_page(1) + assert page1 is not None + assert page1.pageNum == 1 + assert result.get_page(999) is None + + def test_multi_page_text_joined(self, parser: LiteParse, invoice_pdf: Path): + result = parser.parse(invoice_pdf, ocr_enabled=False) + assert result.num_pages == 2 + # result.text should be the join of all page texts + expected = "\n\n".join(p.text for p in result.pages) + assert result.text == expected + + +class TestParseErrors: + """Test error handling.""" + + def test_file_not_found(self, parser: LiteParse): + with pytest.raises(FileNotFoundError): + parser.parse("/nonexistent/file.pdf") + + def test_cli_not_found(self): + parser = LiteParse(cli_path="/nonexistent/liteparse") + # subprocess raises FileNotFoundError when the binary doesn't exist + with pytest.raises((ParseError, FileNotFoundError, OSError)): + parser.parse(Path(__file__)) # any existing file + + def test_timeout(self, parser: LiteParse, invoice_pdf: Path): + # Extremely short timeout should fail + with pytest.raises(TimeoutError): + parser.parse(invoice_pdf, timeout=0.001) + + @pytest.mark.asyncio + async def test_file_not_found_async(self, parser: LiteParse): + with pytest.raises(FileNotFoundError): + await parser.parse_async("/nonexistent/file.pdf") + + @pytest.mark.asyncio + async def test_cli_not_found_async(self): + parser = LiteParse(cli_path="/nonexistent/liteparse") + # subprocess raises FileNotFoundError when the binary doesn't exist + with pytest.raises((ParseError, FileNotFoundError, OSError)): + parser.parse(Path(__file__)) # any existing file + + @pytest.mark.asyncio + async def test_timeout_async(self, parser: LiteParse, invoice_pdf: Path): + with pytest.raises(TimeoutError): + await parser.parse_async(invoice_pdf, timeout=0.001) diff --git a/packages/python/tests/test_screenshot_e2e.py b/packages/python/tests/test_screenshot_e2e.py new file mode 100644 index 0000000..18f8898 --- /dev/null +++ b/packages/python/tests/test_screenshot_e2e.py @@ -0,0 +1,102 @@ +"""E2E tests for LiteParse.screenshot() — validates Python types match CLI output.""" + +import tempfile +from pathlib import Path + +import pytest + +from liteparse import ( + ImageFormat, + LiteParse, + ScreenshotBatchResult, + ScreenshotResult, +) + + +class TestScreenshotBasic: + """Basic screenshot functionality.""" + + def test_screenshot_returns_batch_result( + self, parser: LiteParse, invoice_pdf: Path + ): + result = parser.screenshot(invoice_pdf) + assert isinstance(result, ScreenshotBatchResult) + + def test_screenshot_has_screenshots(self, parser: LiteParse, invoice_pdf: Path): + result = parser.screenshot(invoice_pdf) + assert len(result) > 0 + assert len(result.screenshots) > 0 + + def test_screenshot_result_fields(self, parser: LiteParse, invoice_pdf: Path): + result = parser.screenshot(invoice_pdf) + ss = result.screenshots[0] + assert isinstance(ss, ScreenshotResult) + assert isinstance(ss.page_num, int) + assert ss.page_num >= 1 + assert isinstance(ss.image_path, str) + assert Path(ss.image_path).exists() + + def test_screenshot_output_dir(self, parser: LiteParse, invoice_pdf: Path): + with tempfile.TemporaryDirectory() as tmpdir: + result = parser.screenshot(invoice_pdf, output_dir=tmpdir) + assert result.output_dir == tmpdir + # Files should be in the specified directory + for ss in result.screenshots: + assert ss.image_path.startswith(tmpdir) + + def test_screenshot_png_format(self, parser: LiteParse, invoice_pdf: Path): + result = parser.screenshot(invoice_pdf, image_format=ImageFormat.PNG) + for ss in result.screenshots: + assert ss.image_path.endswith(".png") + + def test_screenshot_jpg_format(self, parser: LiteParse, invoice_pdf: Path): + result = parser.screenshot(invoice_pdf, image_format="jpg") + for ss in result.screenshots: + assert ss.image_path.endswith(".jpg") + + @pytest.mark.asyncio + async def test_screensho_async_basic(self, parser: LiteParse, invoice_pdf: Path): + result = await parser.screenshot_async(invoice_pdf, image_format="png") + assert isinstance(result, ScreenshotBatchResult) + assert len(result.screenshots) > 0 + + +class TestScreenshotOptions: + """Test screenshot options are forwarded correctly.""" + + def test_target_pages(self, parser: LiteParse, invoice_pdf: Path): + # invoice.pdf has 2 pages — screenshot only page 1 + result = parser.screenshot(invoice_pdf, target_pages="1") + assert len(result) == 1 + assert result.screenshots[0].page_num == 1 + + def test_load_bytes(self, parser: LiteParse, invoice_pdf: Path): + result = parser.screenshot(invoice_pdf, load_bytes=True) + for ss in result.screenshots: + assert ss.image_bytes is not None + assert len(ss.image_bytes) > 0 + + def test_no_load_bytes(self, parser: LiteParse, invoice_pdf: Path): + result = parser.screenshot(invoice_pdf, load_bytes=False) + for ss in result.screenshots: + assert ss.image_bytes is None + + def test_get_page_helper(self, parser: LiteParse, invoice_pdf: Path): + result = parser.screenshot(invoice_pdf) + page1 = result.get_page(1) + assert page1 is not None + assert page1.page_num == 1 + assert result.get_page(999) is None + + def test_iterable(self, parser: LiteParse, invoice_pdf: Path): + result = parser.screenshot(invoice_pdf) + pages = list(result) + assert len(pages) == len(result) + + +class TestScreenshotErrors: + """Test error handling.""" + + def test_file_not_found(self, parser: LiteParse): + with pytest.raises(FileNotFoundError): + parser.screenshot("/nonexistent/file.pdf") diff --git a/packages/python/uv.lock b/packages/python/uv.lock new file mode 100644 index 0000000..b5308fa --- /dev/null +++ b/packages/python/uv.lock @@ -0,0 +1,333 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "librt" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/5f/63f5fa395c7a8a93558c0904ba8f1c8d1b997ca6a3de61bc7659970d66bf/librt-0.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:81fd938344fecb9373ba1b155968c8a329491d2ce38e7ddb76f30ffb938f12dc", size = 65697, upload-time = "2026-02-17T16:11:06.903Z" }, + { url = "https://files.pythonhosted.org/packages/ff/e0/0472cf37267b5920eff2f292ccfaede1886288ce35b7f3203d8de00abfe6/librt-0.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5db05697c82b3a2ec53f6e72b2ed373132b0c2e05135f0696784e97d7f5d48e7", size = 68376, upload-time = "2026-02-17T16:11:08.395Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8bd1359fdcd27ab897cd5963294fa4a7c83b20a8564678e4fd12157e56a5/librt-0.8.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d56bc4011975f7460bea7b33e1ff425d2f1adf419935ff6707273c77f8a4ada6", size = 197084, upload-time = "2026-02-17T16:11:09.774Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fe/163e33fdd091d0c2b102f8a60cc0a61fd730ad44e32617cd161e7cd67a01/librt-0.8.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdc0f588ff4b663ea96c26d2a230c525c6fc62b28314edaaaca8ed5af931ad0", size = 207337, upload-time = "2026-02-17T16:11:11.311Z" }, + { url = "https://files.pythonhosted.org/packages/01/99/f85130582f05dcf0c8902f3d629270231d2f4afdfc567f8305a952ac7f14/librt-0.8.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c2b54ff6717a7a563b72627990bec60d8029df17df423f0ed37d56a17a176b", size = 219980, upload-time = "2026-02-17T16:11:12.499Z" }, + { url = "https://files.pythonhosted.org/packages/6f/54/cb5e4d03659e043a26c74e08206412ac9a3742f0477d96f9761a55313b5f/librt-0.8.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f1125e6bbf2f1657d9a2f3ccc4a2c9b0c8b176965bb565dd4d86be67eddb4b6", size = 212921, upload-time = "2026-02-17T16:11:14.484Z" }, + { url = "https://files.pythonhosted.org/packages/b1/81/a3a01e4240579c30f3487f6fed01eb4bc8ef0616da5b4ebac27ca19775f3/librt-0.8.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8f4bb453f408137d7581be309b2fbc6868a80e7ef60c88e689078ee3a296ae71", size = 221381, upload-time = "2026-02-17T16:11:17.459Z" }, + { url = "https://files.pythonhosted.org/packages/08/b0/fc2d54b4b1c6fb81e77288ff31ff25a2c1e62eaef4424a984f228839717b/librt-0.8.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c336d61d2fe74a3195edc1646d53ff1cddd3a9600b09fa6ab75e5514ba4862a7", size = 216714, upload-time = "2026-02-17T16:11:19.197Z" }, + { url = "https://files.pythonhosted.org/packages/96/96/85daa73ffbd87e1fb287d7af6553ada66bf25a2a6b0de4764344a05469f6/librt-0.8.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eb5656019db7c4deacf0c1a55a898c5bb8f989be904597fcb5232a2f4828fa05", size = 214777, upload-time = "2026-02-17T16:11:20.443Z" }, + { url = "https://files.pythonhosted.org/packages/12/9c/c3aa7a2360383f4bf4f04d98195f2739a579128720c603f4807f006a4225/librt-0.8.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c25d9e338d5bed46c1632f851babf3d13c78f49a225462017cf5e11e845c5891", size = 237398, upload-time = "2026-02-17T16:11:22.083Z" }, + { url = "https://files.pythonhosted.org/packages/61/19/d350ea89e5274665185dabc4bbb9c3536c3411f862881d316c8b8e00eb66/librt-0.8.1-cp310-cp310-win32.whl", hash = "sha256:aaab0e307e344cb28d800957ef3ec16605146ef0e59e059a60a176d19543d1b7", size = 54285, upload-time = "2026-02-17T16:11:23.27Z" }, + { url = "https://files.pythonhosted.org/packages/4f/d6/45d587d3d41c112e9543a0093d883eb57a24a03e41561c127818aa2a6bcc/librt-0.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:56e04c14b696300d47b3bc5f1d10a00e86ae978886d0cee14e5714fafb5df5d2", size = 61352, upload-time = "2026-02-17T16:11:24.207Z" }, + { url = "https://files.pythonhosted.org/packages/1d/01/0e748af5e4fee180cf7cd12bd12b0513ad23b045dccb2a83191bde82d168/librt-0.8.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:681dc2451d6d846794a828c16c22dc452d924e9f700a485b7ecb887a30aad1fd", size = 65315, upload-time = "2026-02-17T16:11:25.152Z" }, + { url = "https://files.pythonhosted.org/packages/9d/4d/7184806efda571887c798d573ca4134c80ac8642dcdd32f12c31b939c595/librt-0.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3b4350b13cc0e6f5bec8fa7caf29a8fb8cdc051a3bae45cfbfd7ce64f009965", size = 68021, upload-time = "2026-02-17T16:11:26.129Z" }, + { url = "https://files.pythonhosted.org/packages/ae/88/c3c52d2a5d5101f28d3dc89298444626e7874aa904eed498464c2af17627/librt-0.8.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ac1e7817fd0ed3d14fd7c5df91daed84c48e4c2a11ee99c0547f9f62fdae13da", size = 194500, upload-time = "2026-02-17T16:11:27.177Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5d/6fb0a25b6a8906e85b2c3b87bee1d6ed31510be7605b06772f9374ca5cb3/librt-0.8.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:747328be0c5b7075cde86a0e09d7a9196029800ba75a1689332348e998fb85c0", size = 205622, upload-time = "2026-02-17T16:11:28.242Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a6/8006ae81227105476a45691f5831499e4d936b1c049b0c1feb17c11b02d1/librt-0.8.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0af2bd2bc204fa27f3d6711d0f360e6b8c684a035206257a81673ab924aa11e", size = 218304, upload-time = "2026-02-17T16:11:29.344Z" }, + { url = "https://files.pythonhosted.org/packages/ee/19/60e07886ad16670aae57ef44dada41912c90906a6fe9f2b9abac21374748/librt-0.8.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d480de377f5b687b6b1bc0c0407426da556e2a757633cc7e4d2e1a057aa688f3", size = 211493, upload-time = "2026-02-17T16:11:30.445Z" }, + { url = "https://files.pythonhosted.org/packages/9c/cf/f666c89d0e861d05600438213feeb818c7514d3315bae3648b1fc145d2b6/librt-0.8.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d0ee06b5b5291f609ddb37b9750985b27bc567791bc87c76a569b3feed8481ac", size = 219129, upload-time = "2026-02-17T16:11:32.021Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ef/f1bea01e40b4a879364c031476c82a0dc69ce068daad67ab96302fed2d45/librt-0.8.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9e2c6f77b9ad48ce5603b83b7da9ee3e36b3ab425353f695cba13200c5d96596", size = 213113, upload-time = "2026-02-17T16:11:33.192Z" }, + { url = "https://files.pythonhosted.org/packages/9b/80/cdab544370cc6bc1b72ea369525f547a59e6938ef6863a11ab3cd24759af/librt-0.8.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:439352ba9373f11cb8e1933da194dcc6206daf779ff8df0ed69c5e39113e6a99", size = 212269, upload-time = "2026-02-17T16:11:34.373Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9c/48d6ed8dac595654f15eceab2035131c136d1ae9a1e3548e777bb6dbb95d/librt-0.8.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:82210adabbc331dbb65d7868b105185464ef13f56f7f76688565ad79f648b0fe", size = 234673, upload-time = "2026-02-17T16:11:36.063Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/35b68b1db517f27a01be4467593292eb5315def8900afad29fabf56304ba/librt-0.8.1-cp311-cp311-win32.whl", hash = "sha256:52c224e14614b750c0a6d97368e16804a98c684657c7518752c356834fff83bb", size = 54597, upload-time = "2026-02-17T16:11:37.544Z" }, + { url = "https://files.pythonhosted.org/packages/71/02/796fe8f02822235966693f257bf2c79f40e11337337a657a8cfebba5febc/librt-0.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:c00e5c884f528c9932d278d5c9cbbea38a6b81eb62c02e06ae53751a83a4d52b", size = 61733, upload-time = "2026-02-17T16:11:38.691Z" }, + { url = "https://files.pythonhosted.org/packages/28/ad/232e13d61f879a42a4e7117d65e4984bb28371a34bb6fb9ca54ec2c8f54e/librt-0.8.1-cp311-cp311-win_arm64.whl", hash = "sha256:f7cdf7f26c2286ffb02e46d7bac56c94655540b26347673bea15fa52a6af17e9", size = 52273, upload-time = "2026-02-17T16:11:40.308Z" }, + { url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516, upload-time = "2026-02-17T16:11:41.604Z" }, + { url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634, upload-time = "2026-02-17T16:11:43.268Z" }, + { url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941, upload-time = "2026-02-17T16:11:44.28Z" }, + { url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991, upload-time = "2026-02-17T16:11:45.462Z" }, + { url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476, upload-time = "2026-02-17T16:11:46.542Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518, upload-time = "2026-02-17T16:11:47.746Z" }, + { url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116, upload-time = "2026-02-17T16:11:49.298Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751, upload-time = "2026-02-17T16:11:50.49Z" }, + { url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378, upload-time = "2026-02-17T16:11:51.783Z" }, + { url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199, upload-time = "2026-02-17T16:11:53.561Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917, upload-time = "2026-02-17T16:11:54.758Z" }, + { url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017, upload-time = "2026-02-17T16:11:55.748Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441, upload-time = "2026-02-17T16:11:56.801Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z" }, + { url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279, upload-time = "2026-02-17T16:11:59.862Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288, upload-time = "2026-02-17T16:12:00.954Z" }, + { url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075, upload-time = "2026-02-17T16:12:03.631Z" }, + { url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486, upload-time = "2026-02-17T16:12:04.725Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219, upload-time = "2026-02-17T16:12:05.828Z" }, + { url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750, upload-time = "2026-02-17T16:12:07.09Z" }, + { url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969, upload-time = "2026-02-17T16:12:09.633Z" }, + { url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000, upload-time = "2026-02-17T16:12:10.632Z" }, + { url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495, upload-time = "2026-02-17T16:12:11.633Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081, upload-time = "2026-02-17T16:12:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309, upload-time = "2026-02-17T16:12:13.756Z" }, + { url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804, upload-time = "2026-02-17T16:12:14.818Z" }, + { url = "https://files.pythonhosted.org/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c", size = 206907, upload-time = "2026-02-17T16:12:16.513Z" }, + { url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217, upload-time = "2026-02-17T16:12:17.906Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14", size = 214622, upload-time = "2026-02-17T16:12:19.108Z" }, + { url = "https://files.pythonhosted.org/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7", size = 221987, upload-time = "2026-02-17T16:12:20.331Z" }, + { url = "https://files.pythonhosted.org/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6", size = 215132, upload-time = "2026-02-17T16:12:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071", size = 215195, upload-time = "2026-02-17T16:12:23.073Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946, upload-time = "2026-02-17T16:12:24.275Z" }, + { url = "https://files.pythonhosted.org/packages/e9/cc/5f2a34fbc8aeb35314a3641f9956fa9051a947424652fad9882be7a97949/librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023", size = 50689, upload-time = "2026-02-17T16:12:25.766Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/cd4d010ab2147339ca2b93e959c3686e964edc6de66ddacc935c325883d7/librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730", size = 57875, upload-time = "2026-02-17T16:12:27.465Z" }, + { url = "https://files.pythonhosted.org/packages/84/0f/2143cb3c3ca48bd3379dcd11817163ca50781927c4537345d608b5045998/librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3", size = 48058, upload-time = "2026-02-17T16:12:28.556Z" }, + { url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313, upload-time = "2026-02-17T16:12:29.659Z" }, + { url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994, upload-time = "2026-02-17T16:12:31.516Z" }, + { url = "https://files.pythonhosted.org/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7", size = 220770, upload-time = "2026-02-17T16:12:33.294Z" }, + { url = "https://files.pythonhosted.org/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040", size = 235409, upload-time = "2026-02-17T16:12:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473, upload-time = "2026-02-17T16:12:36.656Z" }, + { url = "https://files.pythonhosted.org/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732", size = 238866, upload-time = "2026-02-17T16:12:37.849Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624", size = 250248, upload-time = "2026-02-17T16:12:39.445Z" }, + { url = "https://files.pythonhosted.org/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4", size = 240629, upload-time = "2026-02-17T16:12:40.889Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382", size = 239615, upload-time = "2026-02-17T16:12:42.446Z" }, + { url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001, upload-time = "2026-02-17T16:12:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/b9/eb/7697f60fbe7042ab4e88f4ee6af496b7f222fffb0a4e3593ef1f29f81652/librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a", size = 51328, upload-time = "2026-02-17T16:12:45.148Z" }, + { url = "https://files.pythonhosted.org/packages/7c/72/34bf2eb7a15414a23e5e70ecb9440c1d3179f393d9349338a91e2781c0fb/librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4", size = 58722, upload-time = "2026-02-17T16:12:46.85Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" }, +] + +[[package]] +name = "liteparse" +version = "2.0.5" +source = { editable = "." } + +[package.optional-dependencies] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[package.metadata] +requires-dist = [ + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21" }, +] +provides-extras = ["dev"] + +[[package]] +name = "mypy" +version = "1.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/63/e499890d8e39b1ff2df4c0c6ce5d371b6844ee22b8250687a99fd2f657a8/mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec", size = 13101333, upload-time = "2025-12-15T05:03:03.28Z" }, + { url = "https://files.pythonhosted.org/packages/72/4b/095626fc136fba96effc4fd4a82b41d688ab92124f8c4f7564bffe5cf1b0/mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b", size = 12164102, upload-time = "2025-12-15T05:02:33.611Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/952928dd081bf88a83a5ccd49aaecfcd18fd0d2710c7ff07b8fb6f7032b9/mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6", size = 12765799, upload-time = "2025-12-15T05:03:28.44Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/93c2e4a287f74ef11a66fb6d49c7a9f05e47b0a4399040e6719b57f500d2/mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74", size = 13522149, upload-time = "2025-12-15T05:02:36.011Z" }, + { url = "https://files.pythonhosted.org/packages/7b/0e/33a294b56aaad2b338d203e3a1d8b453637ac36cb278b45005e0901cf148/mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1", size = 13810105, upload-time = "2025-12-15T05:02:40.327Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fd/3e82603a0cb66b67c5e7abababce6bf1a929ddf67bf445e652684af5c5a0/mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac", size = 10057200, upload-time = "2025-12-15T05:02:51.012Z" }, + { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, + { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, + { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, + { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, + { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, + { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, + { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, + { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, + { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, + { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pathspec" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] diff --git a/packages/wasm/README.md b/packages/wasm/README.md new file mode 100644 index 0000000..14db051 --- /dev/null +++ b/packages/wasm/README.md @@ -0,0 +1,117 @@ +# @llamaindex/liteparse-wasm + +Browser/WebAssembly build of [LiteParse](https://github.com/run-llama/liteparse) — a fast, lightweight PDF parser with spatial text extraction. + +This package runs entirely in the browser. No server, no cloud calls. + +## Install + +```sh +npm install @llamaindex/liteparse-wasm +``` + +## Quick start + +```ts +import init, { LiteParse } from "@llamaindex/liteparse-wasm"; + +// Load the wasm module (point at the file shipped with the package). +await init(); + +const parser = new LiteParse({ + ocrEnabled: false, // OCR requires a JS-side engine (see below) + outputFormat: "json", +}); + +// `data` is a Uint8Array (e.g. from fetch / File / drag-drop). +const bytes = new Uint8Array(await file.arrayBuffer()); +const result = await parser.parse(bytes); + +console.log(result.text); // full document text +console.log(result.pages[0]); // per-page items with bboxes +``` + +## Document complexity + +Before committing to a full parse, check whether a document needs OCR or heavier +processing. `isComplex` is a cheap, text-layer-only pass that returns one entry per page +with a `needsOcr` verdict and the signals behind it — useful for routing documents or +deciding whether the JS-side OCR engine is worth wiring up. + +```ts +const parser = new LiteParse({ ocrEnabled: false }); +const bytes = new Uint8Array(await file.arrayBuffer()); +const pages = await parser.isComplex(bytes); + +if (pages.some((p) => p.needsOcr)) { + // This document would benefit from OCR — see "OCR in the browser" below + for (const page of pages.filter((p) => p.needsOcr)) { + console.log(`Page ${page.pageNumber}: ${page.reasons.join(", ")}`); + } +} +``` + +`reasons` is one of `"scanned"`, `"no-text"`, `"sparse-text"`, `"embedded-images"`, +`"garbled"`, or `"vector-text"`. + +## Config options + +All optional, camelCase: + +| Option | Type | Default | Description | +|---|---|---|---| +| `ocrLanguage` | `string` | `"eng"` | Language code passed to the OCR engine | +| `ocrEnabled` | `boolean` | `true` | Run OCR on text-sparse pages | +| `maxPages` | `number` | `1000` | Stop after this many pages | +| `targetPages` | `string` | — | e.g. `"1-5,10,15-20"` | +| `dpi` | `number` | `150` | Render DPI for OCR / screenshots | +| `outputFormat` | `"json" \| "text" \| "markdown"` | `"json"` | Output format; `"markdown"` returns rendered Markdown on `result.text` | +| `imageMode` | `"off" \| "placeholder" \| "embed"` | `"placeholder"` | How raster images are surfaced in markdown output | +| `extractLinks` | `boolean` | `true` | Render hyperlink annotations as `[text](url)` in markdown output | +| `preserveVerySmallText` | `boolean` | `false` | Keep tiny text that's normally filtered | +| `password` | `string` | — | Password for protected PDFs | +| `quiet` | `boolean` | `false` | Suppress progress logging | +| `ocrEngine` | `object` | — | JS-side OCR engine (see below) | + +## OCR in the browser + +The native HTTP-OCR and Tesseract backends are not available in the browser. To use OCR, pass an object with a `recognize` method: + +```ts +const parser = new LiteParse({ + ocrEnabled: true, + ocrLanguage: "eng", + ocrEngine: { + /** + * @param imageData PNG-encoded image bytes + * @param width rendered page width in pixels + * @param height rendered page height in pixels + * @param language e.g. "eng" + * @returns array of { text, bbox: [x1,y1,x2,y2], confidence } + */ + async recognize(imageData, width, height, language) { + // e.g. call a worker that wraps tesseract.js, or a remote OCR service + return [ + { text: "Hello", bbox: [10, 20, 80, 40], confidence: 0.98 }, + ]; + }, + }, +}); +``` + +## Building from source + +Requires Rust + [`wasm-pack`](https://rustwasm.github.io/wasm-pack/): + +```sh +# from packages/wasm +npm run build # web target (default) +npm run build:bundler # for webpack/rollup/vite +npm run build:nodejs # for node.js +``` + +Output goes to `pkg/`. + +## License + +Apache-2.0 diff --git a/packages/wasm/package.json b/packages/wasm/package.json new file mode 100644 index 0000000..ebecf1a --- /dev/null +++ b/packages/wasm/package.json @@ -0,0 +1,48 @@ +{ + "name": "@llamaindex/liteparse-wasm", + "version": "2.5.1", + "description": "Fast, lightweight PDF parsing with spatial text extraction — WebAssembly build for browsers", + "type": "module", + "main": "./pkg/liteparse_wasm.js", + "types": "./pkg/liteparse_wasm.d.ts", + "exports": { + ".": { + "types": "./pkg/liteparse_wasm.d.ts", + "import": "./pkg/liteparse_wasm.js" + }, + "./liteparse_wasm_bg.wasm": "./pkg/liteparse_wasm_bg.wasm", + "./package.json": "./package.json" + }, + "files": [ + "pkg", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "wasm-pack build ../../crates/liteparse-wasm --release --target web --out-dir ../../packages/wasm/pkg --out-name liteparse_wasm && node scripts/patch-wasi-imports.js", + "build:bundler": "wasm-pack build ../../crates/liteparse-wasm --release --target bundler --out-dir ../../packages/wasm/pkg --out-name liteparse_wasm && node scripts/patch-wasi-imports.js", + "build:nodejs": "wasm-pack build ../../crates/liteparse-wasm --release --target nodejs --out-dir ../../packages/wasm/pkg --out-name liteparse_wasm && node scripts/patch-wasi-imports.js", + "clean": "rm -rf pkg", + "prepublishOnly": "npm run build" + }, + "keywords": [ + "pdf", + "parser", + "ocr", + "text-extraction", + "pdf-to-text", + "wasm", + "webassembly", + "browser", + "document-parsing" + ], + "repository": { + "type": "git", + "url": "https://github.com/run-llama/liteparse.git" + }, + "author": "LlamaIndex", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } +} diff --git a/packages/wasm/scripts/patch-wasi-imports.js b/packages/wasm/scripts/patch-wasi-imports.js new file mode 100644 index 0000000..60781a8 --- /dev/null +++ b/packages/wasm/scripts/patch-wasi-imports.js @@ -0,0 +1,138 @@ +#!/usr/bin/env node +/** + * Post-build script that patches the wasm-bindgen generated JS glue to + * provide stub implementations of WASI preview1 syscalls and any remaining + * "env" imports. + * + * The WASI syscalls (wasi_snapshot_preview1::*) are baked into the WASM binary + * by wasi-libc and cannot be resolved at Rust link time. Instead we inject + * no-op / error-returning stubs at the JS level so the WASM module can + * instantiate in the browser. + */ + +import { readFileSync, writeFileSync } from "fs"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const GLUE_PATH = join(__dirname, "..", "pkg", "liteparse_wasm.js"); + +let source = readFileSync(GLUE_PATH, "utf-8"); + +// WASI errno constants +const ERRNO_SUCCESS = 0; +const ERRNO_BADF = 8; +const ERRNO_NOENT = 44; +const ERRNO_NOSYS = 52; + +// The stubs object we'll inject into the JS source +const STUBS_CODE = ` +// --- WASI / env stubs (injected by patch-wasi-imports.js) --- +const __wasi_stubs = { + // wasi_snapshot_preview1 + environ_sizes_get(count, buf_size) { + const view = new DataView(wasm.memory.buffer); + view.setUint32(count, 0, true); + view.setUint32(buf_size, 0, true); + return ${ERRNO_SUCCESS}; + }, + environ_get() { return ${ERRNO_SUCCESS}; }, + clock_time_get(clock_id, precision, time) { + const view = new DataView(wasm.memory.buffer); + view.setBigUint64(time, BigInt(0), true); + return ${ERRNO_SUCCESS}; + }, + fd_close(fd) { return fd <= 2 ? ${ERRNO_SUCCESS} : ${ERRNO_BADF}; }, + fd_fdstat_get(fd, stat) { + if (fd > 2) return ${ERRNO_BADF}; + // Report stdio fds as character devices (filetype=2, no special flags) + const view = new DataView(wasm.memory.buffer); + view.setUint8(stat, 2); // filetype = CHARACTER_DEVICE + view.setUint16(stat + 2, 0, true); // fdflags = 0 + view.setBigUint64(stat + 8, BigInt(0), true); // rights_base + view.setBigUint64(stat + 16, BigInt(0), true); // rights_inheriting + return ${ERRNO_SUCCESS}; + }, + fd_fdstat_set_flags() { return ${ERRNO_NOSYS}; }, + fd_filestat_get() { return ${ERRNO_BADF}; }, + fd_filestat_set_size() { return ${ERRNO_BADF}; }, + fd_prestat_get() { return ${ERRNO_BADF}; }, + fd_prestat_dir_name() { return ${ERRNO_BADF}; }, + fd_read() { return ${ERRNO_BADF}; }, + fd_readdir() { return ${ERRNO_BADF}; }, + fd_seek() { return ${ERRNO_NOSYS}; }, + fd_sync() { return ${ERRNO_NOSYS}; }, + fd_write(fd, iovs, iovs_len, nwritten) { + // Support stdout (1) and stderr (2) — compute total bytes and discard + if (fd !== 1 && fd !== 2) return ${ERRNO_BADF}; + const view = new DataView(wasm.memory.buffer); + const mem = new Uint8Array(wasm.memory.buffer); + let total = 0; + for (let i = 0; i < iovs_len; i++) { + const ptr = view.getUint32(iovs + i * 8, true); + const len = view.getUint32(iovs + i * 8 + 4, true); + // Optionally log stderr to console + if (fd === 2 && len > 0) { + try { + const text = new TextDecoder().decode(mem.subarray(ptr, ptr + len)); + console.warn("[pdfium]", text); + } catch (_) {} + } + total += len; + } + view.setUint32(nwritten, total, true); + return ${ERRNO_SUCCESS}; + }, + path_filestat_get() { return ${ERRNO_NOENT}; }, + path_open() { return ${ERRNO_NOENT}; }, + path_remove_directory() { return ${ERRNO_NOSYS}; }, + path_unlink_file() { return ${ERRNO_NOSYS}; }, + proc_exit(code) { throw new Error("WASI proc_exit called with code " + code); }, +}; + +const __env_stubs = { + // __c_longjmp is a WASM exception handling tag used for setjmp/longjmp. + // It must be a WebAssembly.Tag, not a function. + __c_longjmp: new WebAssembly.Tag({ parameters: ["i32"] }), +}; +// --- end stubs --- +`; + +// 1. Remove the top-level `import ... from "env"` and `import ... from "wasi_snapshot_preview1"` +// These are ES module imports that can't resolve in a browser. +source = source.replace(/^import \* as import\d+ from "(?:env|wasi_snapshot_preview1)";?\n/gm, ""); + +// 2. Inject stubs before the __wbg_get_imports function +source = source.replace( + "function __wbg_get_imports() {", + STUBS_CODE + "\nfunction __wbg_get_imports() {" +); + +// 3. In the return object of __wbg_get_imports, replace the env/wasi entries +// with our stubs objects. The generated code emits a run of duplicate +// "wasi_snapshot_preview1": importN keys (and, depending on the pdfium build, +// an "env": importN key). The order and presence of these vary between +// wasm-bindgen versions, so collapse each independently rather than assuming +// a fixed env-then-wasi layout. +const wasiBefore = source; +source = source.replace( + /(?:[ \t]*"wasi_snapshot_preview1": import\d+,\n)+/, + ` "wasi_snapshot_preview1": __wasi_stubs,\n` +); +if (source === wasiBefore) { + throw new Error( + "patch-wasi-imports: failed to find wasi_snapshot_preview1 import entries — " + + "the wasm-bindgen glue layout changed, update this script." + ); +} + +// The "env" import only exists when a symbol (e.g. __c_longjmp) is left +// unresolved at link time. When pdfium's libsetjmp.a is linked it disappears, +// so this replacement is best-effort. +source = source.replace( + /[ \t]*"env": import\d+,\n/, + ` "env": __env_stubs,\n` +); + +writeFileSync(GLUE_PATH, source, "utf-8"); +console.log("Patched WASI/env stubs into", GLUE_PATH); diff --git a/scripts/browser-compat/wasm-test.html b/scripts/browser-compat/wasm-test.html new file mode 100644 index 0000000..88445f5 --- /dev/null +++ b/scripts/browser-compat/wasm-test.html @@ -0,0 +1,53 @@ + + + + + LiteParse WASM Browser Test + + +
loading
+ + + + + + diff --git a/scripts/browser-compat/wasm-test.mjs b/scripts/browser-compat/wasm-test.mjs new file mode 100644 index 0000000..01128df --- /dev/null +++ b/scripts/browser-compat/wasm-test.mjs @@ -0,0 +1,112 @@ +/** + * Playwright test that loads the LiteParse WASM module in a real browser + * and verifies it can parse a PDF end-to-end. + * + * Usage: node scripts/browser-compat/wasm-test.mjs + * + * Requires: playwright (npx playwright install chromium) + * Expects: packages/wasm/pkg/ to contain the built WASM files + * demo/docs/apple-10k-2024.pdf to exist + */ + +import { createServer } from "node:http"; +import { readFile } from "node:fs/promises"; +import { resolve, extname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { chromium } from "playwright"; + +const ROOT = resolve(fileURLToPath(import.meta.url), "../../.."); + +const MIME_TYPES = { + ".html": "text/html", + ".js": "application/javascript", + ".wasm": "application/wasm", + ".pdf": "application/pdf", + ".json": "application/json", +}; + +function startServer() { + return new Promise((resolvePromise) => { + const server = createServer(async (req, res) => { + const url = new URL(req.url, "http://localhost"); + const filePath = resolve(ROOT, "." + url.pathname); + + // Basic security: don't serve outside ROOT + if (!filePath.startsWith(ROOT)) { + res.writeHead(403); + res.end("Forbidden"); + return; + } + + try { + const data = await readFile(filePath); + const ext = extname(filePath); + res.writeHead(200, { + "Content-Type": MIME_TYPES[ext] || "application/octet-stream", + "Cross-Origin-Opener-Policy": "same-origin", + "Cross-Origin-Embedder-Policy": "require-corp", + }); + res.end(data); + } catch { + res.writeHead(404); + res.end("Not found"); + } + }); + + server.listen(0, "127.0.0.1", () => { + const port = server.address().port; + resolvePromise({ server, port }); + }); + }); +} + +async function main() { + const { server, port } = await startServer(); + const baseUrl = `http://127.0.0.1:${port}`; + console.log(`Static server listening on ${baseUrl}`); + + let browser; + try { + browser = await chromium.launch(); + const page = await browser.newPage(); + + // Collect console errors + const errors = []; + page.on("pageerror", (err) => errors.push(err.message)); + + console.log("Navigating to test page..."); + await page.goto(`${baseUrl}/scripts/browser-compat/wasm-test.html`); + + // Wait for either #result or #error to appear (up to 120s for large PDFs) + await page.waitForSelector("#result[style*='block'], #error[style*='block']", { + timeout: 120_000, + }); + + const errorText = await page.locator("#error").textContent(); + if (errorText) { + console.error(`FAIL: ${errorText}`); + if (errors.length) console.error("Console errors:", errors); + process.exit(1); + } + + const resultText = await page.locator("#result").textContent(); + const pages = await page.locator("#result").getAttribute("data-pages"); + const textLength = await page.locator("#result").getAttribute("data-text-length"); + + console.log(`PASS: ${resultText}`); + console.log(` Pages: ${pages}`); + console.log(` Text length: ${textLength}`); + + if (errors.length) { + console.warn("Browser console errors (non-fatal):", errors); + } + } finally { + if (browser) await browser.close(); + server.close(); + } +} + +main().catch((err) => { + console.error("Test runner failed:", err); + process.exit(1); +}); diff --git a/scripts/build-musl-node.sh b/scripts/build-musl-node.sh new file mode 100644 index 0000000..361ae77 --- /dev/null +++ b/scripts/build-musl-node.sh @@ -0,0 +1,61 @@ +#!/bin/sh +set -eux + +# tesseract-rs's build.rs hard-codes -DCMAKE_CXX_COMPILER=clang++ and -stdlib=libc++, +# so we need real clang + libc++ in the image (gcc/g++ from build-base is not enough). +# Alpine's libc++ links against llvm-libunwind (NOT the GNU libunwind, which conflicts). +# The libc++ runtime depends on libc++abi.so.1 too; that .so is shipped in the +# `libc++` package itself (no separate apk needed at build time, but we DO need +# to bundle the .so next to the .node for runtime — see the ldd loop below). +# Static libs (openssl-libs-static, zlib-static) are required because musl rust defaults +# to crt-static for build scripts. +apk add --no-cache \ + build-base cmake git curl pkgconf perl \ + clang libc++-dev llvm-libunwind-dev \ + tesseract-ocr-dev leptonica-dev \ + openssl-dev openssl-libs-static zlib-static + +curl --proto "=https" --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable -t x86_64-unknown-linux-musl +. /root/.cargo/env + +# napi produces a cdylib (.node) which MUST be dynamically linked against libc. +export RUSTFLAGS="-C target-feature=-crt-static" +npx napi build --cargo-cwd ../../crates/liteparse-napi --platform --release --js false --dts native.d.ts --target x86_64-unknown-linux-musl . + +# The resulting .node has DT_NEEDED entries for the clang+libc++ runtime +# (libc++.so.1, libc++abi.so.1, libunwind.so.1) which are not present on stock +# node:20-alpine. Bundle them next to the .node so the $ORIGIN rpath +# (set by liteparse-napi/build.rs) finds them at dlopen time. Same pattern we +# use for libpdfium.so. +# +# We use `ldd` to discover the exact DT_NEEDED list rather than hard-coding it, +# so future toolchain changes (e.g. clang adding a new runtime dep) don't +# silently break the smoke test. Anything resolved under /usr/lib that isn't a +# core musl/libc/loader file gets copied. +NODE_FILE=$(ls liteparse.*.node | head -n1) +echo "DT_NEEDED for $NODE_FILE:" +ldd "$NODE_FILE" || true +# ldd on musl exits 0 even for unresolved deps and prints them; parse names. +for needed in $(ldd "$NODE_FILE" 2>/dev/null | awk '{print $1}' | grep -E '^lib'); do + case "$needed" in + # Stock musl / loader libs that are always present in any Alpine image. + libc.musl-*.so.* | ld-musl-*.so.* | libc.so | libdl.so* | libm.so* | libpthread.so* | librt.so*) + continue + ;; + # Already bundled. + libpdfium.so) + continue + ;; + esac + src=$(find /usr/lib /usr/lib64 -maxdepth 3 -name "$needed" 2>/dev/null | head -n1) + if [ -z "$src" ]; then + echo "WARN: could not locate $needed under /usr/lib, skipping" + continue + fi + src=$(readlink -f "$src") + [ -f "$src" ] || { echo "ERROR: $src does not resolve to a file"; exit 1; } + cp -v "$src" "./$needed" +done + +echo "Bundled libs in $(pwd):" +ls -la *.so* *.node 2>/dev/null diff --git a/scripts/build-musl-py.sh b/scripts/build-musl-py.sh new file mode 100644 index 0000000..97551a1 --- /dev/null +++ b/scripts/build-musl-py.sh @@ -0,0 +1,122 @@ +#!/bin/sh +set -eux + +# Mirrors scripts/build-musl-node.sh but for the Python wheel. +# +# Why a hand-rolled Alpine container instead of maturin-action's +# musllinux_1_2 image: +# - musllinux_1_2 is Alpine-based but ships a musl-cross GCC whose default +# specs define _FORTIFY_SOURCE=2. tesseract's C++ then references glibc +# fortify wrappers (__printf_chk, __fprintf_chk, ...) that don't exist in +# musl libc, so linking fails. +# - tesseract-rs hard-codes clang++ + libc++ for its CMake build, so we want +# the real Alpine clang/libc++ toolchain, not a musl-cross-gcc. +# - tesseract-rs's build-deps (reqwest → native-tls → openssl-sys) get +# compiled for the host. In the musllinux_1_2 cross container the host +# openssl install is half-broken; native Alpine clang against system +# openssl-dev just works. +# +# So we replicate the node build container pattern: a vanilla python:3-alpine, +# install clang/libc++/tesseract dev libs from apk, then run maturin against +# a self-contained venv. + +apk add --no-cache \ + build-base cmake git curl pkgconf perl \ + clang libc++-dev llvm-libunwind-dev \ + tesseract-ocr-dev leptonica-dev \ + openssl-dev openssl-libs-static zlib-static \ + patchelf + +curl --proto "=https" --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable -t x86_64-unknown-linux-musl +. /root/.cargo/env + +python3 -m venv /tmp/venv +. /tmp/venv/bin/activate +pip install --upgrade pip maturin + +# cdylib MUST be dynamically linked against libc. +export RUSTFLAGS="-C target-feature=-crt-static" + +cd packages/python +rm -rf dist + +# pyproject.toml's [tool.maturin] include rule expects liteparse/libpdfium.so +# to exist at build time so it gets packed into the wheel. pdfium-sys's build +# script downloads pdfium into ~/.cache/pdfium-rs; we need to compile once +# (which triggers the download) and then copy the .so into place before the +# real maturin build picks it up via include. Easiest: just run +# scripts/copy-pdfium.sh after a probe build, OR run copy-pdfium.sh directly +# pointing at the cache. The script auto-detects the cache path. +# +# Order: trigger the pdfium download via a cargo metadata-only compile of +# pdfium-sys (cheap), then copy, then real maturin build. +( cd ../../crates/pdfium-sys && cargo build --release --target x86_64-unknown-linux-musl ) +sh scripts/copy-pdfium.sh + +# --find-interpreter would try to build for every python on PATH; we only +# have the container's python (3.12 for python:3.12-alpine, etc.). Letting +# maturin auto-detect produces a single wheel for that interpreter, which is +# what we want for the musl matrix entry. +maturin build --release --out dist --target x86_64-unknown-linux-musl + +WHEEL=$(ls dist/*.whl | head -n1) +echo "Built wheel: $WHEEL" + +# The extension module lives at liteparse/_liteparse*.so inside the wheel. +# Inspect its DT_NEEDED entries (clang + libc++ runtime) and bundle anything +# non-system next to it, same logic as build-musl-node.sh. We use a scratch +# dir to unpack/repack the wheel. +WORK=$(mktemp -d) +unzip -q "$WHEEL" -d "$WORK" + +EXT=$(find "$WORK/liteparse" -maxdepth 1 -name '_liteparse*.so' | head -n1) +[ -n "$EXT" ] || { echo "ERROR: could not find _liteparse*.so in wheel"; exit 1; } +echo "Extension module: $EXT" +echo "DT_NEEDED for extension:" +ldd "$EXT" || true + +DEST=$(dirname "$EXT") +for needed in $(ldd "$EXT" 2>/dev/null | awk '{print $1}' | grep -E '^lib'); do + case "$needed" in + libc.musl-*.so.* | ld-musl-*.so.* | libc.so | libdl.so* | libm.so* | libpthread.so* | librt.so*) + continue + ;; + # libpdfium is already bundled by maturin via pyproject.toml include rules. + libpdfium.so) + continue + ;; + esac + if [ -f "$DEST/$needed" ]; then + echo "Already bundled: $needed" + continue + fi + src=$(find /usr/lib /usr/lib64 -maxdepth 3 -name "$needed" 2>/dev/null | head -n1) + if [ -z "$src" ]; then + echo "WARN: could not locate $needed under /usr/lib, skipping" + continue + fi + src=$(readlink -f "$src") + [ -f "$src" ] || { echo "ERROR: $src does not resolve to a file"; exit 1; } + cp -v "$src" "$DEST/$needed" +done + +echo "Bundled libs next to extension:" +ls -la "$DEST" + +# Repack the wheel. Use python's zipfile to preserve wheel layout / metadata. +python3 - < # update all packages + scripts/bump-version.py --package # update only one + where is one of: core, node, python, wasm + +Examples: + scripts/bump-version.py 2.0.1 + scripts/bump-version.py 2.0.1-beta.1 + scripts/bump-version.py 2.1.0-rc.0 --package python +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent + +SEMVER_RE = re.compile( + r"^(?P\d+)\.(?P\d+)\.(?P\d+)" + r"(?:-(?P
alpha|beta|rc)\.(?P\d+))?$"
+)
+
+PRE_TO_PEP440 = {"alpha": "a", "beta": "b", "rc": "rc"}
+
+
+def parse_version(v: str) -> tuple[str, str | None, int | None]:
+    """Return (base "X.Y.Z", pre_label or None, pre_num or None)."""
+    m = SEMVER_RE.match(v)
+    if not m:
+        raise SystemExit(
+            f"Invalid version: {v!r}. Expected X.Y.Z or X.Y.Z-(alpha|beta|rc).N"
+        )
+    base = f"{m['major']}.{m['minor']}.{m['patch']}"
+    pre = m["pre"]
+    num = int(m["num"]) if m["num"] is not None else None
+    return base, pre, num
+
+
+def to_semver(base: str, pre: str | None, num: int | None) -> str:
+    if pre is None:
+        return base
+    return f"{base}-{pre}.{num}"
+
+
+def to_pep440(base: str, pre: str | None, num: int | None) -> str:
+    if pre is None:
+        return base
+    return f"{base}{PRE_TO_PEP440[pre]}{num}"
+
+
+def replace_in_file(
+    path: Path, pattern: str, replacement: str, *, expect: int = 1
+) -> None:
+    text = path.read_text()
+    new_text, n = re.subn(pattern, replacement, text, count=expect if expect else 0)
+    if expect and n != expect:
+        raise SystemExit(
+            f"{path}: expected {expect} replacement(s) for /{pattern}/, made {n}"
+        )
+    if n == 0:
+        print(f"  (no changes in {path.relative_to(REPO_ROOT)})")
+        return
+    path.write_text(new_text)
+    print(
+        f"  updated {path.relative_to(REPO_ROOT)} ({n} change{'s' if n != 1 else ''})"
+    )
+
+
+def update_cargo_version(path: Path, new_version: str) -> None:
+    # Update the first top-level `version = "..."` line (the [package] version).
+    replace_in_file(
+        path,
+        r'(?m)^version\s*=\s*"[^"]+"',
+        f'version = "{new_version}"',
+        expect=1,
+    )
+
+
+def update_liteparse_dep(path: Path, new_version: str) -> None:
+    # Update the inline `liteparse = { package = "liteparse", version = "..." }` line.
+    replace_in_file(
+        path,
+        r'(liteparse\s*=\s*\{\s*package\s*=\s*"liteparse"\s*,\s*version\s*=\s*)"[^"]+"',
+        rf'\1"{new_version}"',
+        expect=1,
+    )
+
+
+def update_json_version(path: Path, new_version: str) -> None:
+    # Update the top-level "version": "..." field in a package.json.
+    replace_in_file(
+        path,
+        r'("version"\s*:\s*)"[^"]+"',
+        rf'\1"{new_version}"',
+        expect=1,
+    )
+
+
+def update_pyproject_version(path: Path, new_version: str) -> None:
+    # Update the `version = "..."` field in pyproject.toml's [project] table.
+    replace_in_file(
+        path,
+        r'(?m)^version\s*=\s*"[^"]+"',
+        f'version = "{new_version}"',
+        expect=1,
+    )
+
+
+def bump_core(semver: str) -> None:
+    print("• core (crates/liteparse)")
+    update_cargo_version(REPO_ROOT / "crates/liteparse/Cargo.toml", semver)
+    # Also update the path-dep version pin used by napi/python crates so it
+    # stays in sync with the core crate's own version.
+    print("  syncing liteparse dep version in dependent crates")
+    update_liteparse_dep(REPO_ROOT / "crates/liteparse-napi/Cargo.toml", semver)
+    update_liteparse_dep(REPO_ROOT / "crates/liteparse-python/Cargo.toml", semver)
+
+
+def bump_node(semver: str) -> None:
+    print("• node (crates/liteparse-napi + packages/node)")
+    update_cargo_version(REPO_ROOT / "crates/liteparse-napi/Cargo.toml", semver)
+    update_json_version(REPO_ROOT / "packages/node/package.json", semver)
+
+
+def bump_python(semver: str, pep440: str) -> None:
+    print("• python (crates/liteparse-python + packages/python)")
+    update_cargo_version(REPO_ROOT / "crates/liteparse-python/Cargo.toml", semver)
+    update_pyproject_version(REPO_ROOT / "packages/python/pyproject.toml", pep440)
+
+
+def bump_wasm(semver: str) -> None:
+    print("• wasm (crates/liteparse-wasm + packages/wasm)")
+    update_cargo_version(REPO_ROOT / "crates/liteparse-wasm/Cargo.toml", semver)
+    update_json_version(REPO_ROOT / "packages/wasm/package.json", semver)
+
+
+PACKAGES = ("core", "node", "python", "wasm")
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(description="Bump LiteParse package versions.")
+    parser.add_argument("version", help="Target version (e.g. 2.0.1 or 2.0.1-beta.1)")
+    parser.add_argument(
+        "--package",
+        choices=PACKAGES,
+        help="Update only one package group (default: all)",
+    )
+    args = parser.parse_args()
+
+    base, pre, num = parse_version(args.version)
+    semver = to_semver(base, pre, num)
+    pep440 = to_pep440(base, pre, num)
+
+    print(f"Target semver:  {semver}")
+    print(f"Target PEP 440: {pep440}")
+    print()
+
+    targets = (args.package,) if args.package else PACKAGES
+    for pkg in targets:
+        if pkg == "core":
+            bump_core(semver)
+        elif pkg == "node":
+            bump_node(semver)
+        elif pkg == "python":
+            bump_python(semver, pep440)
+        elif pkg == "wasm":
+            bump_wasm(semver)
+
+    print("\nDone.")
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/scripts/compare-dataset.sh b/scripts/compare-dataset.sh
new file mode 100755
index 0000000..1dd5701
--- /dev/null
+++ b/scripts/compare-dataset.sh
@@ -0,0 +1,179 @@
+#!/usr/bin/env bash
+# Compares current liteparse output against a baseline dataset
+#
+# Usage:
+#   ./scripts/compare-dataset.sh [dataset-dir]
+#
+# Exit codes:
+#   0 - No changes detected
+#   1 - Changes detected (requires approval)
+#   2 - Error occurred
+set -uo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+
+DATASET_DIR="${1:-$REPO_ROOT/dataset}"
+DOCUMENTS_DIR="$DATASET_DIR/data"
+METADATA_FILE="$DATASET_DIR/metadata.jsonl"
+
+LIT="$REPO_ROOT/target/release/lit"
+if [ ! -x "$LIT" ]; then
+  echo "ERROR: lit binary not found at $LIT. Run 'cargo build --release' first."
+  exit 2
+fi
+
+echo "LiteParse Dataset Comparison"
+echo "============================"
+echo "Dataset: $DATASET_DIR"
+echo "Documents: $DOCUMENTS_DIR"
+echo
+
+if [ ! -f "$METADATA_FILE" ]; then
+  echo "ERROR: metadata.jsonl not found at $METADATA_FILE"
+  exit 2
+fi
+
+ENTRY_COUNT=$(wc -l < "$METADATA_FILE" | tr -d ' ')
+echo "Loaded $ENTRY_COUNT expected entries"
+echo
+
+if [ "$ENTRY_COUNT" -eq 0 ]; then
+  echo "ERROR: metadata.jsonl is empty — dataset has no entries to compare against."
+  echo "The dataset may need to be regenerated with: ./scripts/create-dataset.sh"
+  exit 2
+fi
+
+DIFF_COUNT=0
+ADDED_COUNT=0
+REMOVED_COUNT=0
+CHANGED_COUNT=0
+DIFF_OUTPUT=""
+
+# Collect unique documents from metadata
+DOCUMENTS=$(jq -r '.document' "$METADATA_FILE" | sort -u)
+
+while IFS= read -r document; do
+  [ -z "$document" ] && continue
+  FILE_PATH="$DOCUMENTS_DIR/$document"
+
+  # Check if file exists
+  if [ ! -f "$FILE_PATH" ]; then
+    # All pages for this document are removed
+    while IFS= read -r line; do
+      PAGE=$(echo "$line" | jq -r '.page')
+      DIFF_OUTPUT+="[REMOVED] $document (page $PAGE)"$'\n\n'
+      REMOVED_COUNT=$((REMOVED_COUNT + 1))
+      DIFF_COUNT=$((DIFF_COUNT + 1))
+    done < <(jq -c "select(.document == \"$document\")" "$METADATA_FILE")
+    continue
+  fi
+
+  echo "Checking: $document"
+
+  # Parse current output
+  CURRENT_JSON=""
+  PARSE_ERROR=""
+  if CURRENT_JSON=$("$LIT" parse --format json --no-ocr -q "$FILE_PATH" 2>&1); then
+    # Compare each expected page
+    while IFS= read -r line; do
+      EXPECTED_PAGE=$(echo "$line" | jq -r '.page')
+      EXPECTED_TEXT=$(echo "$line" | jq -r '.output_text')
+      IS_PDF=false
+      if [[ "$document" == *.pdf ]]; then
+        IS_PDF=true
+      fi
+
+      # Get actual text for this page
+      PAGE_EXISTS=$(echo "$CURRENT_JSON" | jq --argjson page "$EXPECTED_PAGE" \
+        '[.pages[] | select(.page == $page)] | length' 2>/dev/null)
+      ACTUAL_TEXT=$(echo "$CURRENT_JSON" | jq -r --argjson page "$EXPECTED_PAGE" \
+        '.pages[] | select(.page == $page) | .text // ""' 2>/dev/null)
+
+      if [ "$PAGE_EXISTS" = "0" ] && [ "$EXPECTED_PAGE" -ne 0 ]; then
+        # Page not found in current output
+        DIFF_OUTPUT+="[REMOVED] $document (page $EXPECTED_PAGE)"$'\n\n'
+        REMOVED_COUNT=$((REMOVED_COUNT + 1))
+        DIFF_COUNT=$((DIFF_COUNT + 1))
+        continue
+      fi
+
+      # Normalize for comparison
+      if [ "$IS_PDF" = true ]; then
+        EXPECTED_CMP=$(echo "$EXPECTED_TEXT" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
+        ACTUAL_CMP=$(echo "$ACTUAL_TEXT" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
+      else
+        # For non-PDF: collapse whitespace runs, trim lines, remove empty lines
+        EXPECTED_CMP=$(echo "$EXPECTED_TEXT" | sed 's/[[:space:]]\{1,\}/ /g' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | sed '/^$/d')
+        ACTUAL_CMP=$(echo "$ACTUAL_TEXT" | sed 's/[[:space:]]\{1,\}/ /g' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | sed '/^$/d')
+      fi
+
+      if [ "$EXPECTED_CMP" != "$ACTUAL_CMP" ]; then
+        DIFF_OUTPUT+="[CHANGED] $document (page $EXPECTED_PAGE)"$'\n'
+        # Show a unified-style diff
+        DIFF_DETAIL=$(diff <(echo "$EXPECTED_TEXT") <(echo "$ACTUAL_TEXT") --unified=1 2>/dev/null || true)
+        if [ -n "$DIFF_DETAIL" ]; then
+          # Show first 50 lines of diff to avoid massive output
+          DIFF_OUTPUT+="$(echo "$DIFF_DETAIL" | head -50)"$'\n'
+          REMAINING=$(echo "$DIFF_DETAIL" | wc -l | tr -d ' ')
+          if [ "$REMAINING" -gt 50 ]; then
+            DIFF_OUTPUT+="... ($((REMAINING - 50)) more lines)"$'\n'
+          fi
+        fi
+        DIFF_OUTPUT+=$'\n'
+        CHANGED_COUNT=$((CHANGED_COUNT + 1))
+        DIFF_COUNT=$((DIFF_COUNT + 1))
+      fi
+    done < <(jq -c "select(.document == \"$document\")" "$METADATA_FILE")
+
+    # Check for new pages not in expected dataset
+    EXPECTED_PAGES=$(jq -r "select(.document == \"$document\") | .page" "$METADATA_FILE" | sort -n)
+    ACTUAL_PAGES=$(echo "$CURRENT_JSON" | jq -r '.pages[].page' | sort -n)
+
+    while IFS= read -r page; do
+      [ -z "$page" ] && continue
+      if ! echo "$EXPECTED_PAGES" | grep -q "^${page}$"; then
+        DIFF_OUTPUT+="[ADDED] $document (page $page)"$'\n\n'
+        ADDED_COUNT=$((ADDED_COUNT + 1))
+        DIFF_COUNT=$((DIFF_COUNT + 1))
+      fi
+    done <<< "$ACTUAL_PAGES"
+
+  else
+    PARSE_ERROR="$CURRENT_JSON"
+    echo "  ERROR: $PARSE_ERROR"
+
+    # Check if error was expected
+    HAS_ERROR_ENTRY=$(jq -r "select(.document == \"$document\") | .output_json.error // false" "$METADATA_FILE" | head -1)
+    if [ "$HAS_ERROR_ENTRY" != "true" ]; then
+      DIFF_OUTPUT+="[CHANGED] $document (page 0)"$'\n'
+      DIFF_OUTPUT+="  Expected: successful parse"$'\n'
+      DIFF_OUTPUT+="  Actual: error: $PARSE_ERROR"$'\n\n'
+      CHANGED_COUNT=$((CHANGED_COUNT + 1))
+      DIFF_COUNT=$((DIFF_COUNT + 1))
+    fi
+  fi
+done <<< "$DOCUMENTS"
+
+echo
+echo "Results"
+echo "-------"
+
+if [ "$DIFF_COUNT" -eq 0 ]; then
+  echo "✓ No changes detected"
+  exit 0
+fi
+
+echo "✗ $DIFF_COUNT change(s) detected:"
+echo
+echo "$DIFF_OUTPUT"
+
+echo "---"
+echo "SUMMARY:"
+echo "  Added: $ADDED_COUNT"
+echo "  Removed: $REMOVED_COUNT"
+echo "  Changed: $CHANGED_COUNT"
+echo
+echo "This PR changes liteparse output and requires manual approval."
+
+exit 1
diff --git a/scripts/compare-outputs.sh b/scripts/compare-outputs.sh
new file mode 100755
index 0000000..bf0e8ab
--- /dev/null
+++ b/scripts/compare-outputs.sh
@@ -0,0 +1,28 @@
+#!/bin/bash
+# Compare dataset outputs and set GitHub Actions output variables
+# Usage: ./compare-outputs.sh 
+
+set -u
+
+EXPECTED_DATASET="${1:-expected-dataset}"
+OUTPUT_FILE="comparison-output.txt"
+
+# Run comparison script
+set +e
+./scripts/compare-dataset.sh "$EXPECTED_DATASET" > "$OUTPUT_FILE" 2>&1
+EXIT_CODE=$?
+set -e
+
+cat "$OUTPUT_FILE"
+
+if [ $EXIT_CODE -eq 0 ]; then
+  echo "has_changes=false" >> "$GITHUB_OUTPUT"
+  echo "✓ No output changes detected"
+elif [ $EXIT_CODE -eq 1 ]; then
+  echo "has_changes=true" >> "$GITHUB_OUTPUT"
+  echo "⚠ Output changes detected - requires approval"
+else
+  echo "has_changes=error" >> "$GITHUB_OUTPUT"
+  echo "✗ Error running comparison"
+  exit 1
+fi
diff --git a/scripts/create-dataset.sh b/scripts/create-dataset.sh
new file mode 100755
index 0000000..a9b64fc
--- /dev/null
+++ b/scripts/create-dataset.sh
@@ -0,0 +1,134 @@
+#!/usr/bin/env bash
+# Creates a dataset for regression testing
+#
+# Output structure:
+#   dataset/
+#     data/
+#       doc1.pdf
+#       doc2.docx
+#       ...
+#     metadata.jsonl  (each line: {"file_name":"data/doc1.pdf","document":"doc1.pdf","page":1,"output_text":"...","output_json":{...}})
+#
+# Usage:
+#   ./scripts/create-dataset.sh [output-dir] [source-docs-dir]
+#
+# Arguments:
+#   output-dir      - Where to write the dataset (default: ./dataset)
+#   source-docs-dir - Where to read source documents from (default: ./e2e-test-docs)
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+
+OUTPUT_DIR="${1:-$REPO_ROOT/dataset}"
+SOURCE_DOCS_DIR="${2:-$REPO_ROOT/e2e-test-docs}"
+DOCUMENTS_DIR="$OUTPUT_DIR/data"
+
+# Resolve paths for comparison
+RESOLVED_SOURCE="$(cd "$SOURCE_DOCS_DIR" 2>/dev/null && pwd)"
+RESOLVED_DOCUMENTS="$(mkdir -p "$DOCUMENTS_DIR" && cd "$DOCUMENTS_DIR" && pwd)"
+SKIP_COPY=false
+if [ "$RESOLVED_SOURCE" = "$RESOLVED_DOCUMENTS" ]; then
+  SKIP_COPY=true
+fi
+
+LIT="$REPO_ROOT/target/release/lit"
+if [ ! -x "$LIT" ]; then
+  echo "ERROR: lit binary not found at $LIT. Run 'cargo build --release' first."
+  exit 1
+fi
+
+echo "LiteParse Dataset Generator"
+echo "==========================="
+echo "Source: $SOURCE_DOCS_DIR"
+echo "Output: $OUTPUT_DIR"
+if [ "$SKIP_COPY" = true ]; then
+  echo "(Source is output documents dir - skipping copy)"
+fi
+echo
+
+mkdir -p "$DOCUMENTS_DIR"
+
+METADATA_FILE="$OUTPUT_DIR/metadata.jsonl"
+: > "$METADATA_FILE"
+
+TOTAL_ENTRIES=0
+TOTAL_FILES=0
+
+# Find all files in source directory
+while IFS= read -r -d '' file; do
+  REL_PATH="${file#"$SOURCE_DOCS_DIR/"}"
+  TOTAL_FILES=$((TOTAL_FILES + 1))
+  echo "Processing: $REL_PATH"
+
+  # Copy file to dataset if needed
+  if [ "$SKIP_COPY" = false ]; then
+    DEST_PATH="$DOCUMENTS_DIR/$REL_PATH"
+    mkdir -p "$(dirname "$DEST_PATH")"
+    cp "$file" "$DEST_PATH"
+  fi
+
+  # Parse with lit and capture JSON output to a temp file (avoids argument list too long)
+  JSON_TMPFILE=$(mktemp)
+  trap "rm -f '$JSON_TMPFILE'" EXIT
+  PARSE_ERROR=""
+  if "$LIT" parse --format json --no-ocr -q "$file" > "$JSON_TMPFILE" 2>&1; then
+    # Extract per-page data from JSON
+    PAGE_COUNT=$(jq '.pages | length' "$JSON_TMPFILE")
+
+    if [ "$PAGE_COUNT" -eq 0 ]; then
+      # No pages - single text entry
+      TEXT=$(jq -r '.text // ""' "$JSON_TMPFILE")
+      jq -nc \
+        --arg fn "data/$REL_PATH" \
+        --arg doc "$REL_PATH" \
+        --arg text "$TEXT" \
+        '{file_name: $fn, document: $doc, page: 1, output_text: $text, output_json: {text: $text}}' \
+        >> "$METADATA_FILE"
+      TOTAL_ENTRIES=$((TOTAL_ENTRIES + 1))
+      echo "  -> 1 text entry"
+    else
+      for i in $(seq 0 $((PAGE_COUNT - 1))); do
+        PAGE_NUM=$(jq ".pages[$i].page" "$JSON_TMPFILE")
+        PAGE_TEXT=$(jq -r ".pages[$i].text // \"\"" "$JSON_TMPFILE")
+
+        jq -nc \
+          --arg fn "data/$REL_PATH" \
+          --arg doc "$REL_PATH" \
+          --argjson page "$PAGE_NUM" \
+          --arg text "$PAGE_TEXT" \
+          --slurpfile json <(jq ".pages[$i]" "$JSON_TMPFILE") \
+          '{file_name: $fn, document: $doc, page: $page, output_text: $text, output_json: $json[0]}' \
+          >> "$METADATA_FILE"
+        TOTAL_ENTRIES=$((TOTAL_ENTRIES + 1))
+      done
+      echo "  -> $PAGE_COUNT pages"
+    fi
+  else
+    PARSE_ERROR=$(cat "$JSON_TMPFILE")
+    echo "  ERROR: $PARSE_ERROR"
+    jq -nc \
+      --arg fn "data/$REL_PATH" \
+      --arg doc "$REL_PATH" \
+      --arg msg "$PARSE_ERROR" \
+      '{file_name: $fn, document: $doc, page: 0, output_text: "", output_json: {error: true, message: $msg}}' \
+      >> "$METADATA_FILE"
+    TOTAL_ENTRIES=$((TOTAL_ENTRIES + 1))
+  fi
+  rm -f "$JSON_TMPFILE"
+done < <(find "$SOURCE_DOCS_DIR" -type f -print0 | sort -z)
+
+if [ "$TOTAL_ENTRIES" -eq 0 ]; then
+  echo
+  echo "ERROR: No dataset entries were generated. Check that source documents exist and are parseable."
+  exit 1
+fi
+
+echo
+echo "Dataset generation complete!"
+echo "  Total entries: $TOTAL_ENTRIES"
+echo "  Documents: $TOTAL_FILES"
+echo "  Metadata: $METADATA_FILE"
+echo "  Documents dir: $DOCUMENTS_DIR"
+echo
+echo "Use compare-dataset.sh to compare future output against this baseline."
diff --git a/scripts/edge-compat/wasm-test.mjs b/scripts/edge-compat/wasm-test.mjs
new file mode 100644
index 0000000..2c3e49b
--- /dev/null
+++ b/scripts/edge-compat/wasm-test.mjs
@@ -0,0 +1,126 @@
+/**
+ * Edge runtime compatibility test for LiteParse WASM module.
+ *
+ * Spins up a Miniflare (Cloudflare Workers) instance that loads the WASM
+ * module and parses a PDF, verifying it works in an edge runtime environment.
+ *
+ * Usage: node scripts/edge-compat/wasm-test.mjs
+ *
+ * Requires: miniflare (npm i -D miniflare)
+ * Expects:  packages/wasm/pkg/ to contain the built WASM files
+ *           demo/docs/apple-10k-2024.pdf to exist
+ */
+
+import { Miniflare } from "miniflare";
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const ROOT = resolve(fileURLToPath(import.meta.url), "../../..");
+
+const wasmPath = resolve(ROOT, "packages/wasm/pkg/liteparse_wasm_bg.wasm");
+const gluePath = resolve(ROOT, "packages/wasm/pkg/liteparse_wasm.js");
+const pdfPath = resolve(ROOT, "demo/docs/apple-10k-2024.pdf");
+
+// Build a self-contained worker script.
+// The generated glue exports an async `init()` that fetches the .wasm via URL.
+// In edge runtimes, we import the WASM as a module and use `initSync` instead.
+let glueSource = readFileSync(gluePath, "utf-8");
+
+// Strip default export (the async init / fetch-based loader)
+glueSource = glueSource.replace(/export\s*\{[^}]*__wbg_init\s+as\s+default[^}]*\};?/g, "");
+glueSource = glueSource.replace(/export\s+default\s+__wbg_init\s*;?/g, "");
+glueSource = glueSource.replace(/export\s*\{\s*initSync\s*(?:,\s*__wbg_init\s+as\s+default\s*)?\}\s*;?/g, "");
+
+const workerScript = `
+// Import WASM as a module (standard edge runtime pattern)
+import __liteparse_wasm_mod from "liteparse.wasm";
+
+// --- LiteParse WASM glue (patched) ---
+${glueSource}
+
+// --- Edge worker handler ---
+export default {
+  async fetch(request) {
+    try {
+      initSync({ module: __liteparse_wasm_mod });
+
+      const parser = new LiteParse({ ocrEnabled: false, outputFormat: "text", quiet: true });
+
+      const pdfBytes = new Uint8Array(await request.arrayBuffer());
+      const result = await parser.parse(pdfBytes);
+
+      const pageCount = result.pages.length;
+      const textLength = result.text.length;
+
+      return new Response(JSON.stringify({
+        ok: true,
+        pages: pageCount,
+        textLength: textLength,
+      }), {
+        headers: { "Content-Type": "application/json" },
+      });
+    } catch (err) {
+      return new Response(JSON.stringify({
+        ok: false,
+        error: err.message || String(err),
+        stack: err.stack,
+      }), {
+        status: 500,
+        headers: { "Content-Type": "application/json" },
+      });
+    }
+  }
+};
+`;
+
+async function main() {
+  console.log("Starting Miniflare (Cloudflare Workers runtime)...");
+
+  const wasmBytes = readFileSync(wasmPath);
+
+  const mf = new Miniflare({
+    modules: [
+      { type: "ESModule", path: "worker.mjs", contents: workerScript },
+      { type: "CompiledWasm", path: "liteparse.wasm", contents: wasmBytes },
+    ],
+    compatibilityDate: "2024-01-01",
+  });
+
+  try {
+    const pdfBytes = readFileSync(pdfPath);
+    console.log(`Sending ${(pdfBytes.length / 1024 / 1024).toFixed(1)}MB PDF to edge worker...`);
+
+    const response = await mf.dispatchFetch("http://localhost/parse", {
+      method: "POST",
+      body: pdfBytes,
+    });
+
+    const result = await response.json();
+
+    if (!result.ok) {
+      console.error(`FAIL: ${result.error}`);
+      if (result.stack) console.error(result.stack);
+      process.exit(1);
+    }
+
+    if (result.pages === 0) {
+      console.error("FAIL: No pages parsed");
+      process.exit(1);
+    }
+
+    if (result.textLength < 100) {
+      console.error(`FAIL: Text too short: ${result.textLength} chars`);
+      process.exit(1);
+    }
+
+    console.log(`PASS: ${result.pages} pages, ${result.textLength} chars`);
+  } finally {
+    await mf.dispose();
+  }
+}
+
+main().catch((err) => {
+  console.error("Test runner failed:", err);
+  process.exit(1);
+});
diff --git a/scripts/generate-api-docs.sh b/scripts/generate-api-docs.sh
new file mode 100755
index 0000000..7fc443d
--- /dev/null
+++ b/scripts/generate-api-docs.sh
@@ -0,0 +1,33 @@
+#!/usr/bin/env bash
+# Generates API reference docs from rustdoc JSON via rustdoc-md and outputs
+# a single Starlight-compatible markdown file with frontmatter.
+set -euo pipefail
+
+DOCS_DIR="./docs/src/content/docs/liteparse"
+TMP_DIR="${DOCS_DIR}/.api-tmp"
+OUT_FILE="${DOCS_DIR}/api.md"
+
+# Generate rustdoc JSON and convert to markdown
+mkdir -p $TMP_DIR
+cd crates/liteparse/
+cargo +nightly rustdoc --lib -- -Z unstable-options --output-format json
+cd ../../
+rustdoc-md --path ./target/doc/liteparse.json --output ${TMP_DIR}/README.md
+./scripts/strip-impls-from-api-docs.py
+
+# Prepend Starlight frontmatter to the generated README
+cat > "${OUT_FILE}" <<'FRONTMATTER'
+---
+title: API Reference
+description: API reference for the liteparse Rust crate. Types are shared across Node.js, Python, and WASM bindings.
+sidebar:
+  order: 6
+---
+FRONTMATTER
+
+cat "${TMP_DIR}/README.md" >> "${OUT_FILE}"
+
+# Clean up temp directory
+rm -rf "${TMP_DIR}"
+
+echo "Generated ${OUT_FILE}"
diff --git a/scripts/smoke-test-musl-node.sh b/scripts/smoke-test-musl-node.sh
new file mode 100755
index 0000000..d63b480
--- /dev/null
+++ b/scripts/smoke-test-musl-node.sh
@@ -0,0 +1,30 @@
+#!/bin/sh
+# Smoke test for the musl Node binding, run inside node:20-alpine.
+# Assumes the build artifact has been downloaded into /work/packages/node and
+# `npx tsc` has produced dist/.
+set -eux
+
+cd /work/packages/node
+
+ls -la *.node *.so 2>/dev/null || true
+
+# Load the .node file directly first, so any dlopen / relocation error surfaces
+# verbatim. The native.ts loader silently swallows require() failures and
+# reports a generic "Failed to load native module" message, which hides the
+# real cause (missing shared lib, unresolved symbol, etc.).
+node -e '
+  const path = require("node:path");
+  const f = path.resolve("./liteparse.linux-x64-musl.node");
+  console.log("loading", f);
+  require(f);
+  console.log("raw .node loaded ok");
+'
+
+# Then run the actual smoke check through the public lib entry point.
+node -e '
+  import("./dist/lib.js").then(async ({ LiteParse }) => {
+    const p = new LiteParse({ ocrEnabled: false, quiet: true });
+    console.log("Config:", JSON.stringify(p.getConfig()));
+    console.log("Native module loaded successfully");
+  }).catch(e => { console.error(e); process.exit(1); });
+'
diff --git a/scripts/strip-impls-from-api-docs.py b/scripts/strip-impls-from-api-docs.py
new file mode 100755
index 0000000..96a9bdf
--- /dev/null
+++ b/scripts/strip-impls-from-api-docs.py
@@ -0,0 +1,68 @@
+#!/usr/bin/env -S uv run --script
+# /// script
+# requires-python = ">=3.10"
+# dependencies = []
+# ///
+import re
+
+DOCS_DIR = "./docs/src/content/docs/liteparse"
+TMP_DIR = f"{DOCS_DIR}/.api-tmp"
+
+path = f"{TMP_DIR}/README.md"
+text = open(path).read()
+
+# Strip implementation and trait implementation sections
+text = re.sub(r"#{2,} Implementations\n.*?(?=\n#{2,} )", "", text, flags=re.DOTALL)
+text = re.sub(
+    r"#{2,} Trait Implementations\n.*?(?=\n#{2,} )", "", text, flags=re.DOTALL
+)
+
+# Strip the crate header/version boilerplate
+text = re.sub(r"# Crate Documentation\n\n\*\*Version:.*?\*\*Format Version:.*?\n", "", text, flags=re.DOTALL)
+
+# Strip the redundant "Re-exports" section at the end (types are already documented inline)
+text = re.sub(r"\n## Re-exports\n.*", "", text, flags=re.DOTALL)
+
+# Strip the top-level "## Modules" line (it's just a bare heading before the actual modules)
+text = text.replace("\n## Modules\n", "\n")
+
+# Clean up the "# Module `liteparse`" header to just be a plain intro
+text = text.replace("# Module `liteparse`\n", "")
+
+# Remove module grouping headings and their code blocks entirely
+text = re.sub(
+    r'## Module `\w+`\n\n```rust\npub mod \w+ \{ /\* \.\.\. \*/ \}\n```\n*',
+    "",
+    text,
+)
+
+# Remove bare "### Types" and "### Functions" category headings
+text = re.sub(r"### Types\n+", "", text)
+text = re.sub(r"### Functions\n+", "", text)
+
+# Promote struct/enum/function headings (####) to ## so they're top-level nav items
+# But first, flatten ###### (variant names) to #### so they stay OUT of nav
+text = re.sub(r"^######", "####", text, flags=re.MULTILINE)
+
+# Promote #### (Struct/Enum/Function) -> ##
+# Promote ##### (Fields/Variants/Methods) -> ###
+text = re.sub(r"^#####", "###", text, flags=re.MULTILINE)
+text = re.sub(r"^#### (Struct |Enum |Function )", r"## \1", text, flags=re.MULTILINE)
+
+# Remove the "private fields" placeholder table (it's noise for opaque structs)
+text = re.sub(
+    r"### Fields\n\n\| Name \| Type \| Documentation \|\n\|[-| ]+\|\n\| \*private fields\* \| \.\.\. \| \*Some fields have been omitted\* \|\n+",
+    "",
+    text,
+)
+
+# Collapse verbose enum variant field tables into simple inline type annotations
+# Matches pattern: #### `VariantName`\n\nFields:\n\n| Index | Type | ... |\n|---...|\n| 0 | `Type` | |
+text = re.sub(
+    r"^(#### `\w+`)\n\nFields:\n\n\| Index \| Type \| Documentation \|\n\|[-| ]+\|\n\| 0 \| `([^`]+)` \|  \|",
+    r"\1(`\2`)",
+    text,
+    flags=re.MULTILINE,
+)
+
+open(path, "w").write(text)
diff --git a/scripts/sync-docs-to-developer-hub.sh b/scripts/sync-docs-to-developer-hub.sh
new file mode 100755
index 0000000..9eec0a4
--- /dev/null
+++ b/scripts/sync-docs-to-developer-hub.sh
@@ -0,0 +1,27 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+DOCS_REPO="${1:?Usage: $0 /path/to/developer-hub-repo}"
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+
+# --- Markdown docs ---
+SOURCE_DIR="$REPO_ROOT/docs/src/content/docs/liteparse"
+DEST_DIR="$DOCS_REPO/src/content/docs/liteparse"
+
+echo "=== Syncing markdown docs ==="
+mkdir -p "$DEST_DIR"
+
+rsync -av --delete \
+  --include='*/' \
+  --include='*.md' \
+  --include='*.mdx' \
+  --include='*.yml' \
+  --include='*.png' \
+  --include='*.jpg' \
+  --include='*.jpeg' \
+  --include='*.svg' \
+  --exclude='*' \
+  "$SOURCE_DIR/" "$DEST_DIR/"
+
+echo "Docs sync complete."
diff --git a/scripts/upload-dataset.sh b/scripts/upload-dataset.sh
new file mode 100755
index 0000000..511b6ef
--- /dev/null
+++ b/scripts/upload-dataset.sh
@@ -0,0 +1,49 @@
+#!/usr/bin/env bash
+# Regenerates and uploads the dataset to HuggingFace
+#
+# Usage:
+#   HF_TOKEN=xxx ./scripts/upload-dataset.sh [dataset-dir] [repo-name]
+#
+# Arguments:
+#   dataset-dir - Directory containing the dataset with data/ subfolder (default: ./dataset)
+#   repo-name   - HuggingFace repository name (default: llamaindex/liteparse_cicd_data)
+#
+# Environment variables:
+#   HF_TOKEN - HuggingFace API token with write access
+#
+# Requires: hf cli (pip install huggingface_hub)
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+
+DATASET_DIR="${1:-$REPO_ROOT/dataset}"
+REPO_NAME="${2:-llamaindex/liteparse_cicd_data}"
+
+if [ -z "${HF_TOKEN:-}" ]; then
+  echo "Error: HF_TOKEN environment variable is required"
+  echo "Get a token from https://huggingface.co/settings/tokens"
+  exit 1
+fi
+
+DOCUMENTS_DIR="$DATASET_DIR/data"
+
+echo "LiteParse Dataset Upload"
+echo "========================"
+echo "Dataset: $DATASET_DIR"
+echo "Documents: $DOCUMENTS_DIR"
+echo "Repo: $REPO_NAME"
+echo
+
+# Step 1: Regenerate dataset from documents in the dataset directory
+echo "Step 1: Regenerating dataset from existing documents..."
+"$SCRIPT_DIR/create-dataset.sh" "$DATASET_DIR" "$DOCUMENTS_DIR"
+
+# Step 2: Upload to HuggingFace
+echo
+echo "Step 2: Uploading to HuggingFace..."
+hf upload "$REPO_NAME" "$DATASET_DIR" --repo-type dataset --token "$HF_TOKEN"
+
+echo
+echo "✓ Dataset uploaded successfully!"
+echo "  View at: https://huggingface.co/datasets/$REPO_NAME"
diff --git a/wasm-demo-site/index.html b/wasm-demo-site/index.html
new file mode 100644
index 0000000..40a0616
--- /dev/null
+++ b/wasm-demo-site/index.html
@@ -0,0 +1,549 @@
+
+
+
+  
+  
+  LiteParse - Browser PDF Parser
+  
+  
+  
+  
+
+
+  
+ + + + + + + + + + + + + +
+

LiteParse

+ WASM +
+
+ +
+
+

PDF Parsing in the Browser

+

Fast, lightweight document parsing powered by WebAssembly. No server required — everything runs locally in your browser.

+
+ +
+ $ npm install @llamaindex/liteparse-wasm + +
+ +
+ + 📄 +
+ Drop a PDF here or click to browse +
+
+ +
+ +
+
+ + +
+ + +
+

+      
+
+ +
+

Usage

+
import init, { LiteParse } from "@llamaindex/liteparse-wasm";
+
+// Initialize the WASM module
+await init();
+
+// Create a parser instance (markdown output)
+const parser = new LiteParse({ outputFormat: "markdown" });
+
+// Parse PDF bytes (Uint8Array from File, fetch, etc.)
+const bytes = new Uint8Array(await file.arrayBuffer());
+const result = await parser.parse(bytes);
+
+console.log(result.text);           // rendered markdown
+console.log(result.pages[0].textItems); // items with bounding boxes
+
+
+ + + + + +