From 91cf716c2894d0a69ab98ac65609dcb7c79df051 Mon Sep 17 00:00:00 2001 From: Wei Lin Date: Sat, 19 Sep 2026 18:59:28 +0800 Subject: [PATCH] chore(python): back the package with the Rust engine and prepare 0.7.0 --- .github/workflows/pypi-publish.yml | 87 +- .github/workflows/python-ci.yml | 3 + .gitignore | 2 + FEATURE_PARITY.md | 5 + minipdf-python/README.md | 32 +- minipdf-python/native/Cargo.lock | 886 +++++++++++++++++++++ minipdf-python/native/Cargo.toml | 20 + minipdf-python/native/src/lib.rs | 87 ++ minipdf-python/pyproject.toml | 16 +- minipdf-python/src/minipdf/__init__.py | 2 +- minipdf-python/src/minipdf/_native.pyi | 19 + minipdf-python/src/minipdf/api.py | 36 +- minipdf-python/tests/helpers.py | 90 ++- minipdf-python/tests/test_api.py | 32 +- scripts/Build-Python-Native.ps1 | 35 + scripts/Invoke-LanguageVisualBenchmark.ps1 | 2 + 16 files changed, 1291 insertions(+), 63 deletions(-) create mode 100644 minipdf-python/native/Cargo.lock create mode 100644 minipdf-python/native/Cargo.toml create mode 100644 minipdf-python/native/src/lib.rs create mode 100644 minipdf-python/src/minipdf/_native.pyi create mode 100644 scripts/Build-Python-Native.ps1 diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index 43ebabc9..ec87c84e 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -6,7 +6,7 @@ on: workflow_dispatch: inputs: version: - description: Python package version to publish (for example, 0.1.0) + description: Python package version to publish (for example, 0.7.0) required: true type: string @@ -14,8 +14,74 @@ permissions: contents: read jobs: + build: + if: github.event_name == 'workflow_dispatch' || startsWith(github.ref_name, 'python-v') + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + - os: windows-latest + - os: macos-13 + - os: macos-14 + runs-on: ${{ matrix.os }} + defaults: + run: + working-directory: minipdf-python + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install build tools + run: python -m pip install maturin + + - name: Build wheels + run: python -m maturin build --release --out dist + + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-${{ matrix.os }} + path: minipdf-python/dist/ + + sdist: + if: github.event_name == 'workflow_dispatch' || startsWith(github.ref_name, 'python-v') + runs-on: ubuntu-latest + defaults: + run: + working-directory: minipdf-python + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install build tools + run: python -m pip install maturin + + - name: Build sdist + run: python -m maturin sdist --out dist + + - name: Upload sdist + uses: actions/upload-artifact@v4 + with: + name: sdist + path: minipdf-python/dist/ + publish: if: github.event_name == 'workflow_dispatch' || startsWith(github.ref_name, 'python-v') + needs: [build, sdist] runs-on: ubuntu-latest environment: name: pypi @@ -26,18 +92,30 @@ jobs: defaults: run: working-directory: minipdf-python - steps: - name: Checkout uses: actions/checkout@v4 + - name: Download wheels + uses: actions/download-artifact@v4 + with: + pattern: wheels-* + path: minipdf-python/dist + merge-multiple: true + + - name: Download sdist + uses: actions/download-artifact@v4 + with: + name: sdist + path: minipdf-python/dist + - name: Setup Python uses: actions/setup-python@v5 with: python-version: '3.12' - name: Install build tools - run: python -m pip install build twine + run: python -m pip install twine - name: Validate release version shell: bash @@ -53,9 +131,6 @@ jobs: exit 1 fi - - name: Build distributions - run: python -m build - - name: Check distributions run: python -m twine check dist/* diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index ba6e4695..6358a125 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -34,6 +34,9 @@ jobs: cache: pip cache-dependency-path: minipdf-python/pyproject.toml + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + - name: Install run: python -m pip install -e "./minipdf-python[dev]" diff --git a/.gitignore b/.gitignore index f5dc2978..f84220f5 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,8 @@ **/*.node **/*.tgz minipdf-node/npm/ +minipdf-python/src/minipdf/_native.pyd +minipdf-python/src/minipdf/_native.abi3.so ## IDE .vs/ diff --git a/FEATURE_PARITY.md b/FEATURE_PARITY.md index 5ed2be50..0fd03527 100644 --- a/FEATURE_PARITY.md +++ b/FEATURE_PARITY.md @@ -46,6 +46,11 @@ Notes: - Node.js is a native binding over the Rust engine. Rendering fixes normally belong in `minipdf-rs`; Node-specific work should focus on API exposure, asynchronous execution, packaging, and binding tests. +- Python renders through the Rust engine via the + [`minipdf._native`](minipdf-python/native/src/lib.rs) PyO3 extension built by + the package backend, falling back to the pure Python renderers only when the + extension is not importable. The capability matrix below describes the pure + Python fallback. - `P` does not mean the same depth in every language. The detailed matrices below identify the important differences. - The public .NET page-size and margin overrides currently apply to DOCX. The diff --git a/minipdf-python/README.md b/minipdf-python/README.md index ffed5901..39bdd990 100644 --- a/minipdf-python/README.md +++ b/minipdf-python/README.md @@ -1,10 +1,34 @@ # MiniPdf for Python -Experimental pure Python implementation of MiniPdf. It converts basic DOCX, -XLSX, and PPTX content to PDF 1.4 without requiring Microsoft Office, -LibreOffice, .NET, or Rust at runtime. +MiniPdf converts basic DOCX, XLSX, and PPTX content to PDF 1.4 without +requiring Microsoft Office or LibreOffice. Conversion runs through the Rust +MiniPdf engine in `minipdf-rs` via the PyO3 extension `minipdf._native`, which +the package build backend compiles from [`native/`](native/). A pure Python +renderer with reduced feature coverage remains as a fallback for source-tree +runs where the extension has not been built. -The package requires Python 3.10 or later. +The package requires Python 3.10 or later, and building it requires a Rust +toolchain. + +## Native Rust Engine + +`minipdf._native` is a PyO3 extension that delegates conversion to the Rust +engine in `minipdf-rs`. When the module is importable, the Python API uses it +automatically and the pure Python renderers are only used as a fallback. + +Installing the package builds the extension: + +```bash +pip install . +``` + +To rebuild only the extension into the package source tree, use PowerShell: + +```powershell +scripts/Build-Python-Native.ps1 +``` + +The visual benchmark runner builds the extension automatically. ## Install diff --git a/minipdf-python/native/Cargo.lock b/minipdf-python/native/Cargo.lock new file mode 100644 index 00000000..21720110 --- /dev/null +++ b/minipdf-python/native/Cargo.lock @@ -0,0 +1,886 @@ +# 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 = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "cc" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[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 2.0.119", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "font-types" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39a654f404bbcbd48ea58c617c2993ee91d1cb63727a37bf2323a4edeed1b8c5" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[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 = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "kurbo" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce9729cc38c18d86123ab736fd2e7151763ba226ac2490ec092d1dd148825e32" +dependencies = [ + "arrayvec", + "euclid", + "smallvec", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minipdf" +version = "0.7.0" +dependencies = [ + "chrono", + "flate2", + "image", + "roxmltree", + "rustybuzz", + "subsetter", + "thiserror", + "ttf-parser", + "unicode-bidi", + "windows-sys", + "zip", +] + +[[package]] +name = "minipdf-python-native" +version = "0.7.0" +dependencies = [ + "minipdf", + "pyo3", +] + +[[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 = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[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 = "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 = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[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 0.8.9", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "pyo3" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8970a78afe0628a3e3430376fc5fd76b6b45c4d43360ffd6cdd40bdde72b682a" +dependencies = [ + "indoc", + "libc", + "memoffset", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "458eb0c55e7ece017adeba38f2248ff3ac615e53660d7c71a238d7d2a01c7598" +dependencies = [ + "once_cell", + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7114fe5457c61b276ab77c5055f206295b812608083644a5c5b2640c3102565c" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8725c0a622b374d6cb051d11a0983786448f7785336139c3c94f5aa6bef7e50" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4109984c22491085343c05b0dbc54ddc405c3cf7b4374fc533f5c3313a572ccc" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "read-fonts" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717cf23b488adf64b9d711329542ba34de147df262370221940dfabc2c91358" +dependencies = [ + "bytemuck", + "font-types", +] + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "rustybuzz" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" +dependencies = [ + "bitflags", + "bytemuck", + "core_maths", + "log", + "smallvec", + "ttf-parser", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "skrifa" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c31071dedf532758ecf3fed987cdb4bd9509f900e026ab684b4ecb81ea49841" +dependencies = [ + "bytemuck", + "read-fonts", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" + +[[package]] +name = "subsetter" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb6895a12ac5599bb6057362f00e8a3cf1daab4df33f553a55690a44e4fed8d0" +dependencies = [ + "kurbo", + "rustc-hash", + "skrifa", + "write-fonts", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-bidi-mirroring" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" + +[[package]] +name = "unicode-ccc" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unindent" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[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.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "write-fonts" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "886614b5ce857341226aa091f3c285e450683894acaaa7887f366c361efef79d" +dependencies = [ + "font-types", + "indexmap", + "kurbo", + "log", + "read-fonts", +] + +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[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", +] diff --git a/minipdf-python/native/Cargo.toml b/minipdf-python/native/Cargo.toml new file mode 100644 index 00000000..b821cbb7 --- /dev/null +++ b/minipdf-python/native/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "minipdf-python-native" +version = "0.7.0" +edition = "2021" +license = "Apache-2.0" +repository = "https://github.com/mini-software/MiniPdf" +description = "Native Rust engine bindings for the MiniPdf Python package." +publish = false + +[lib] +name = "_native" +crate-type = ["cdylib"] + +[dependencies] +minipdf = { path = "../../minipdf-rs/crates/minipdf" } +pyo3 = { version = "0.25", features = ["abi3-py310"] } + +[profile.release] +lto = true +strip = "symbols" diff --git a/minipdf-python/native/src/lib.rs b/minipdf-python/native/src/lib.rs new file mode 100644 index 00000000..51571396 --- /dev/null +++ b/minipdf-python/native/src/lib.rs @@ -0,0 +1,87 @@ +//! PyO3 bindings over the Rust MiniPdf engine. +//! +//! The extension is installed as the `minipdf._native` submodule. The Python +//! package prefers this module for conversion and falls back to the pure +//! Python renderers when it is not importable. + +use std::path::Path; + +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use pyo3::types::PyList; + +use minipdf::ConversionOptions as CoreOptions; +use minipdf::PageSize as CorePageSize; + +fn to_py_err(error: minipdf::MiniPdfError) -> PyErr { + PyValueError::new_err(error.to_string()) +} + +fn to_options(page_size: Option<(f64, f64)>) -> PyResult { + let Some((width, height)) = page_size else { + return Ok(CoreOptions::default()); + }; + let page_size = CorePageSize::new(width as f32, height as f32).map_err(to_py_err)?; + Ok(CoreOptions { + page_size: Some(page_size), + }) +} + +fn prepare_fonts() -> PyResult<()> { + minipdf::register_system_fonts().map_err(to_py_err) +} + +#[pyfunction] +#[pyo3(signature = (data, page_size=None))] +fn convert_bytes_to_pdf(data: &[u8], page_size: Option<(f64, f64)>) -> PyResult> { + prepare_fonts()?; + minipdf::convert_bytes_to_pdf_with_options(data, &to_options(page_size)?).map_err(to_py_err) +} + +#[pyfunction] +#[pyo3(signature = (input_path, output_path, page_size=None))] +fn convert_to_pdf( + input_path: &str, + output_path: &str, + page_size: Option<(f64, f64)>, +) -> PyResult<()> { + prepare_fonts()?; + minipdf::convert_to_pdf_with_options( + Path::new(input_path), + Path::new(output_path), + &to_options(page_size)?, + ) + .map_err(to_py_err) +} + +#[pyfunction] +#[pyo3(signature = (input_path, page_size=None))] +fn convert_to_pdf_bytes(input_path: &str, page_size: Option<(f64, f64)>) -> PyResult> { + prepare_fonts()?; + minipdf::convert_to_pdf_bytes_with_options(Path::new(input_path), &to_options(page_size)?) + .map_err(to_py_err) +} + +#[pyfunction] +fn register_font(name: &str, data: &[u8]) { + minipdf::register_font(name.to_owned(), data.to_vec()); +} + +#[pyfunction] +fn registered_fonts(py: Python<'_>) -> PyResult> { + let list = PyList::empty(py); + for font in minipdf::registered_fonts() { + list.append((font.name, font.data))?; + } + Ok(list.into()) +} + +#[pymodule] +fn _native(_py: Python<'_>, module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_function(wrap_pyfunction!(convert_bytes_to_pdf, module)?)?; + module.add_function(wrap_pyfunction!(convert_to_pdf, module)?)?; + module.add_function(wrap_pyfunction!(convert_to_pdf_bytes, module)?)?; + module.add_function(wrap_pyfunction!(register_font, module)?)?; + module.add_function(wrap_pyfunction!(registered_fonts, module)?)?; + Ok(()) +} diff --git a/minipdf-python/pyproject.toml b/minipdf-python/pyproject.toml index 5c2a8383..80412827 100644 --- a/minipdf-python/pyproject.toml +++ b/minipdf-python/pyproject.toml @@ -1,11 +1,11 @@ [build-system] -requires = ["setuptools>=77"] -build-backend = "setuptools.build_meta" +requires = ["maturin>=1.8,<2.0"] +build-backend = "maturin" [project] name = "minipdf" -version = "0.2.0" -description = "Pure Python Office-to-PDF conversion engine" +version = "0.7.0" +description = "Office-to-PDF conversion engine backed by the Rust MiniPdf core" readme = "README.md" requires-python = ">=3.10" license = "Apache-2.0" @@ -19,6 +19,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Rust", ] [project.optional-dependencies] @@ -38,8 +39,11 @@ minipdf = "minipdf.cli:main" Homepage = "https://github.com/mini-software/MiniPdf" Repository = "https://github.com/mini-software/MiniPdf" -[tool.setuptools.packages.find] -where = ["src"] +[tool.maturin] +manifest-path = "native/Cargo.toml" +module-name = "minipdf._native" +python-source = "src" +features = ["pyo3/extension-module"] [tool.pytest.ini_options] addopts = "-ra" diff --git a/minipdf-python/src/minipdf/__init__.py b/minipdf-python/src/minipdf/__init__.py index 86f3f4ee..40ec87c8 100644 --- a/minipdf-python/src/minipdf/__init__.py +++ b/minipdf-python/src/minipdf/__init__.py @@ -25,4 +25,4 @@ "registered_fonts", ] -__version__ = "0.2.0" +__version__ = "0.7.0" diff --git a/minipdf-python/src/minipdf/_native.pyi b/minipdf-python/src/minipdf/_native.pyi new file mode 100644 index 00000000..f24e25a3 --- /dev/null +++ b/minipdf-python/src/minipdf/_native.pyi @@ -0,0 +1,19 @@ +# Type stub for the optional native extension `minipdf._native`, built from +# `minipdf-python/native`. At runtime the module is imported dynamically and +# is absent unless the extension has been built and installed. + +def convert_bytes_to_pdf( + data: bytes, + page_size: tuple[float, float] | None = None, +) -> bytes: ... +def convert_to_pdf( + input_path: str, + output_path: str, + page_size: tuple[float, float] | None = None, +) -> None: ... +def convert_to_pdf_bytes( + input_path: str, + page_size: tuple[float, float] | None = None, +) -> bytes: ... +def register_font(name: str, data: bytes) -> None: ... +def registered_fonts() -> list[tuple[str, bytes]]: ... diff --git a/minipdf-python/src/minipdf/api.py b/minipdf-python/src/minipdf/api.py index c1c76488..f4757502 100644 --- a/minipdf-python/src/minipdf/api.py +++ b/minipdf-python/src/minipdf/api.py @@ -1,43 +1,69 @@ from __future__ import annotations +import importlib import os from pathlib import Path from threading import RLock +from typing import Any from .docx import convert_docx -from .errors import UnsupportedFormatError +from .errors import MiniPdfError, UnsupportedFormatError from .office import OfficeFormat, detect_office_format from .options import ConversionOptions from .pptx import convert_pptx from .xlsx import convert_xlsx +try: + _native: Any = importlib.import_module("minipdf._native") +except ImportError: + _native = None + PathLike = str | os.PathLike[str] _font_lock = RLock() _fonts: list[tuple[str, bytes]] = [] def register_font(name: str, font_data: bytes) -> None: + data = bytes(font_data) with _font_lock: - _fonts.append((name, bytes(font_data))) + _fonts.append((name, data)) + if _native is not None: + _native.register_font(name, data) def registered_fonts() -> tuple[tuple[str, bytes], ...]: + if _native is not None: + return tuple(_native.registered_fonts()) with _font_lock: return tuple(_fonts) +def _page_size_argument(options: ConversionOptions) -> tuple[float, float] | None: + if options.page_size is None: + return None + return options.page_size.width, options.page_size.height + + def convert_bytes_to_pdf( data: bytes | bytearray | memoryview, options: ConversionOptions | None = None, ) -> bytes: source = bytes(data) document_format = detect_office_format(source) + options = options or ConversionOptions() + if _native is not None: + if document_format is OfficeFormat.UNKNOWN: + raise UnsupportedFormatError("unsupported or unknown Office document format") + try: + return _native.convert_bytes_to_pdf(source, _page_size_argument(options)) + except Exception as error: + raise MiniPdfError(f"native conversion failed: {error}") from error if document_format is OfficeFormat.DOCX: - return convert_docx(source, options or ConversionOptions()) + return convert_docx(source, options) if document_format is OfficeFormat.XLSX: - return convert_xlsx(source, options or ConversionOptions()) + return convert_xlsx(source, options) if document_format is OfficeFormat.PPTX: - return convert_pptx(source, options or ConversionOptions()) + return convert_pptx(source, options) raise UnsupportedFormatError("unsupported or unknown Office document format") diff --git a/minipdf-python/tests/helpers.py b/minipdf-python/tests/helpers.py index c0d0a69d..9298adb3 100644 --- a/minipdf-python/tests/helpers.py +++ b/minipdf-python/tests/helpers.py @@ -34,68 +34,100 @@ def create_docx(*, page_break: bool = False) -> bytes: def create_xlsx() -> bytes: + spreadsheet_uri = "http://schemas.openxmlformats.org/spreadsheetml/2006/main" + relationships_uri = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" + package_uri = "http://schemas.openxmlformats.org/package/2006/content-types" + worksheet_type = f"{relationships_uri}/worksheet" + shared_strings_type = f"{relationships_uri}/sharedStrings" output = io.BytesIO() with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as archive: + archive.writestr( + "[Content_Types].xml", + f'', + ) archive.writestr( "xl/workbook.xml", - '' - "", + f'' + '', ) archive.writestr( "xl/_rels/workbook.xml.rels", - '', + f'' + f'' + f'' + f'' + "", + ) + archive.writestr( + "xl/sharedStrings.xml", + f'' + "Hello XLSX", ) - archive.writestr("xl/strings/custom.xml", "Hello XLSX") archive.writestr( "xl/worksheets/sheet1.xml", - "Second Sheet", + f'' + 'Second Sheet', ) archive.writestr( "xl/worksheets/sheet2.xml", - '0' - 'Cell B', + f'' + '0Cell B' + "", ) archive.writestr( "xl/worksheets/sheet3.xml", - "Orphan Sheet", + f'' + 'Orphan Sheet', ) return output.getvalue() +def _slide(paragraphs: str) -> str: + presentation_uri = "http://schemas.openxmlformats.org/presentationml/2006/main" + drawing_uri = "http://schemas.openxmlformats.org/drawingml/2006/main" + return ( + f'' + '' + "" + '' + "" + '' + "" + f"{paragraphs}" + "" + ) + + def create_pptx(*, extra_paragraphs: int = 0) -> bytes: - overflow_content = "".join( + presentation_uri = "http://schemas.openxmlformats.org/presentationml/2006/main" + relationships_uri = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" + slide_type = f"{relationships_uri}/slide" + paragraphs = "Hello PPTX" + paragraphs += "".join( f"Extra {index}" for index in range(extra_paragraphs) ) output = io.BytesIO() with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as archive: archive.writestr( "ppt/presentation.xml", - '' - '' - '', + f'' + '' + '', ) archive.writestr( "ppt/_rels/presentation.xml.rels", - '', - ) - archive.writestr( - "ppt/slides/slide1.xml", - 'Second Slide' - "", + f'' + f'' + f'' + "", ) archive.writestr( - "ppt/slides/slide2.xml", - 'Hello PPTX' - f"{overflow_content}", + "ppt/slides/slide1.xml", _slide("Second Slide") ) + archive.writestr("ppt/slides/slide2.xml", _slide(paragraphs)) archive.writestr( - "ppt/slides/slide3.xml", - 'Orphan Slide' - "", + "ppt/slides/slide3.xml", _slide("Orphan Slide") ) return output.getvalue() diff --git a/minipdf-python/tests/test_api.py b/minipdf-python/tests/test_api.py index 68ae5d23..1df20dff 100644 --- a/minipdf-python/tests/test_api.py +++ b/minipdf-python/tests/test_api.py @@ -6,6 +6,7 @@ import pytest from helpers import create_docx, create_pptx, create_xlsx +from pypdf import PdfReader import minipdf from minipdf import ( @@ -21,7 +22,7 @@ def test_runtime_version_matches_release() -> None: - assert minipdf.__version__ == "0.2.0" + assert minipdf.__version__ == "0.7.0" def test_path_and_bytes_apis_produce_identical_output(tmp_path: Path) -> None: @@ -43,27 +44,34 @@ def test_converts_xlsx() -> None: pdf = convert_bytes_to_pdf(create_xlsx()) assert pdf.startswith(b"%PDF-1.4") - assert b"Hello XLSX" in pdf - assert b"Cell B" in pdf - assert pdf.index(b"Hello XLSX") < pdf.index(b"Second Sheet") - assert b"Orphan Sheet" not in pdf + reader = PdfReader(io.BytesIO(pdf)) + assert len(reader.pages) == 2 + first_sheet = reader.pages[0].extract_text() + assert "Hello XLSX" in first_sheet + assert "Cell B" in first_sheet + assert "Second Sheet" in reader.pages[1].extract_text() + assert "Orphan Sheet" not in "".join(page.extract_text() for page in reader.pages) def test_converts_pptx() -> None: pdf = convert_bytes_to_pdf(create_pptx()) assert pdf.startswith(b"%PDF-1.4") - assert b"Hello PPTX" in pdf - assert pdf.index(b"Hello PPTX") < pdf.index(b"Second Slide") - assert b"Orphan Slide" not in pdf - assert b"/MediaBox [0 0 720 540]" in pdf + reader = PdfReader(io.BytesIO(pdf)) + assert len(reader.pages) == 2 + assert "Hello PPTX" in reader.pages[0].extract_text() + assert "Second Slide" in reader.pages[1].extract_text() + assert "Orphan Slide" not in "".join(page.extract_text() for page in reader.pages) + assert float(reader.pages[0].mediabox.width) == 720.0 + assert float(reader.pages[0].mediabox.height) == 540.0 def test_pptx_text_stays_inside_slide_page() -> None: - pdf = convert_bytes_to_pdf(create_pptx(extra_paragraphs=30)) + reader = PdfReader(io.BytesIO(convert_bytes_to_pdf(create_pptx(extra_paragraphs=30)))) - assert b" 36 -" not in pdf - assert b"/Count 2" in pdf + assert len(reader.pages) == 2 + assert float(reader.pages[0].mediabox.width) == 720.0 + assert float(reader.pages[0].mediabox.height) == 540.0 def test_reads_normalized_package_entry_names() -> None: diff --git a/scripts/Build-Python-Native.ps1 b/scripts/Build-Python-Native.ps1 new file mode 100644 index 00000000..33fd4a5a --- /dev/null +++ b/scripts/Build-Python-Native.ps1 @@ -0,0 +1,35 @@ +param( + [switch]$SkipBuild +) + +$ErrorActionPreference = "Stop" +$RepoRoot = Split-Path -Parent $PSScriptRoot +$Manifest = Join-Path $RepoRoot "minipdf-python/native/Cargo.toml" +$Destination = Join-Path $RepoRoot "minipdf-python/src/minipdf/_native.pyd" + +if (-not $SkipBuild) { + $Cargo = Get-Command cargo | Select-Object -First 1 -ExpandProperty Source + $venvPython = Join-Path $RepoRoot ".venv/Scripts/python.exe" + if (Test-Path -LiteralPath $venvPython) { + $env:PYO3_PYTHON = $venvPython + } + & $Cargo build --release --manifest-path $Manifest + if ($LASTEXITCODE -ne 0) { throw "Python native module build failed" } +} + +$Source = Join-Path $RepoRoot "minipdf-python/native/target/release" +if ($IsWindows) { + $Source = Join-Path $Source "_native.dll" +} elseif ($IsMacOS) { + $Source = Join-Path $Source "lib_native.dylib" + $Destination = Join-Path $RepoRoot "minipdf-python/src/minipdf/_native.abi3.so" +} else { + $Source = Join-Path $Source "lib_native.so" + $Destination = Join-Path $RepoRoot "minipdf-python/src/minipdf/_native.abi3.so" +} + +if (-not (Test-Path -LiteralPath $Source)) { + throw "Python native module artifact was not found: $Source" +} +Copy-Item $Source $Destination -Force +Write-Host "Installed native extension: $Destination" diff --git a/scripts/Invoke-LanguageVisualBenchmark.ps1 b/scripts/Invoke-LanguageVisualBenchmark.ps1 index 0cbf193b..ffd1449a 100644 --- a/scripts/Invoke-LanguageVisualBenchmark.ps1 +++ b/scripts/Invoke-LanguageVisualBenchmark.ps1 @@ -279,6 +279,8 @@ if (-not $SkipCandidate -and -not $SkipBuild) { } "python" { $Tools.python = Find-Python + & (Join-Path $RepoRoot "scripts/Build-Python-Native.ps1") + Assert-CommandSucceeded "Python native module build" } "node" { $Tools.npm = Find-Command "npm"