diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml new file mode 100644 index 000000000..538dca833 --- /dev/null +++ b/.github/workflows/python.yml @@ -0,0 +1,276 @@ +name: python + +on: + push: + release: + types: + - published + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +env: + CCACHE_DIR: ${{ github.workspace }}/.ccache + CCACHE_MAXSIZE: 1G + CCACHE_KEY_SUFFIX: r1 + CONAN_HOME: ${{ github.workspace }}/.conan2 + CONAN_KEY_SUFFIX: r1 + +jobs: + format: + runs-on: ubuntu-24.04 + steps: + - name: checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: setup python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: 3.14 + - name: install black + run: pip install black + + - name: check python formatting + run: black --check --diff python + + build-test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - { os: ubuntu-24.04, build_profile: ubuntu-24.04-clang-18, host_profile: ubuntu-24.04-clang-18 } + - { os: macos-26, build_profile: macos-26-armv8-clang-14, host_profile: macos-26-armv8-clang-14 } + steps: + - name: checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - name: checkout conan-odr-index + run: git submodule update --init --depth 1 conan-odr-index + + - name: ubuntu install ccache + if: runner.os == 'Linux' + run: | + sudo apt install ccache + ccache -V + - name: macos install ccache + if: runner.os == 'macOS' + run: | + brew install ccache + ccache -V + + - name: setup python 3.14 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: 3.14 + - name: install python dependencies + run: pip install conan pytest + + - name: cache conan + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + with: + path: ${{ env.CONAN_HOME }} + key: conan-python-${{ matrix.host_profile }}-${{ env.CONAN_KEY_SUFFIX }} + restore-keys: | + conan-python-${{ matrix.host_profile }}- + conan-${{ matrix.host_profile }}- + + - name: export conan-odr-index + run: python conan-odr-index/scripts/conan_export_all_packages.py --selection-config conan-odr-index/defaults.yaml + - name: conan config + run: conan config install .github/config/conan + + - name: cache ccache + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + with: + path: ${{ env.CCACHE_DIR }} + key: ccache-python-${{ matrix.host_profile }}-${{ env.CCACHE_KEY_SUFFIX }} + restore-keys: | + ccache-python-${{ matrix.host_profile }}- + ccache-${{ matrix.host_profile }}- + + - name: conan install + run: > + conan install . + -o '&:with_python=True' + --profile:host '${{ matrix.host_profile }}' + --profile:build '${{ matrix.build_profile }}' + --build missing + + - name: cmake + run: > + cmake -B build -S . + -DCMAKE_TOOLCHAIN_FILE="conan_toolchain.cmake" + -DCMAKE_CXX_COMPILER_LAUNCHER=ccache + -DCMAKE_BUILD_TYPE=Release + -DCMAKE_CXX_FLAGS="-Werror" + -DODR_PYTHON=ON + -DODR_CLI=OFF + -DODR_TEST=OFF + + - name: build + run: cmake --build build --target pyodr_core --config Release + + - name: pytest + run: PYTHONPATH=build/python python -m pytest python/tests -v + + wheels: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - { os: ubuntu-24.04, build_profile: ubuntu-24.04-clang-18, host_profile: ubuntu-24.04-clang-18 } + - { os: macos-26, build_profile: macos-26-armv8-clang-14, host_profile: macos-26-armv8-clang-14 } + steps: + - name: checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + # setuptools-scm derives the package version from git tags. + fetch-depth: 0 + - name: checkout conan-odr-index + run: git submodule update --init --depth 1 conan-odr-index + + # The conan profiles use ccache as compiler launcher, so it must exist + # even for `--build missing` source builds. + - name: ubuntu install ccache + if: runner.os == 'Linux' + run: | + sudo apt install ccache + ccache -V + - name: macos install ccache + if: runner.os == 'macOS' + run: | + brew install ccache + ccache -V + + - name: setup python 3.14 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: 3.14 + - name: install python dependencies + run: pip install conan build pytest + + - name: cache conan + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + with: + path: ${{ env.CONAN_HOME }} + key: conan-wheel-${{ matrix.host_profile }}-${{ env.CONAN_KEY_SUFFIX }} + restore-keys: | + conan-wheel-${{ matrix.host_profile }}- + + - name: export conan-odr-index + run: python conan-odr-index/scripts/conan_export_all_packages.py --selection-config conan-odr-index/defaults.yaml + - name: conan config + run: conan config install .github/config/conan + + - name: cache ccache + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + with: + path: ${{ env.CCACHE_DIR }} + key: ccache-wheel-${{ matrix.host_profile }}-${{ env.CCACHE_KEY_SUFFIX }} + restore-keys: | + ccache-wheel-${{ matrix.host_profile }}- + ccache-${{ matrix.host_profile }}- + + # Match the dependency set that pyproject.toml enables (no pdf2htmlEX, + # wvWare, libmagic — their runtime data cannot ship inside the wheel). + - name: conan install + run: > + conan install . + -o '&:with_python=True' + -o '&:with_pdf2htmlEX=False' + -o '&:with_wvWare=False' + -o '&:with_libmagic=False' + --profile:host '${{ matrix.host_profile }}' + --profile:build '${{ matrix.build_profile }}' + --build missing + + - name: build wheel + run: python -m build --wheel + env: + CMAKE_ARGS: -DCMAKE_TOOLCHAIN_FILE=${{ github.workspace }}/conan_toolchain.cmake + + # PyPI rejects plain `linux_*` platform tags; auditwheel grafts the wheel + # onto the matching manylinux tag (the conan deps are linked statically, + # so there should be no external libraries to vendor). + - name: audit wheel + if: runner.os == 'Linux' + run: | + sudo apt install patchelf + pipx run auditwheel repair --wheel-dir dist-audited dist/*.whl + rm dist/*.whl + mv dist-audited/*.whl dist/ + + - name: test wheel + run: | + pip install dist/*.whl + python -m pytest python/tests -v + + - name: upload wheels + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: wheel-${{ matrix.host_profile }} + path: dist/*.whl + if-no-files-found: error + + sdist: + runs-on: ubuntu-24.04 + steps: + - name: checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + # setuptools-scm derives the package version from git tags. + fetch-depth: 0 + + - name: build sdist + run: pipx run build --sdist + - name: check metadata + run: pipx run twine check dist/* + + - name: upload sdist + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: sdist + path: dist/*.tar.gz + if-no-files-found: error + + publish: + needs: [build-test, wheels, sdist] + runs-on: ubuntu-24.04 + if: github.event_name == 'release' && github.event.action == 'published' + permissions: + contents: write + steps: + - name: download artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + path: dist + merge-multiple: true + + - name: upload release assets + env: + GH_TOKEN: ${{ github.token }} + run: gh release upload '${{ github.event.release.tag_name }}' dist/* --repo '${{ github.repository }}' + + # Uses PyPI trusted publishing (OIDC, no token): the `pyodr` project on PyPI + # must list this repo + workflow + the `pypi` environment as a publisher. + # Note pip cannot build the published sdist on its own (the build needs a + # conan-generated toolchain plus the conan-odr-index recipes, see + # python/README.md); it is published for completeness. + pypi: + needs: [build-test, wheels, sdist] + runs-on: ubuntu-24.04 + if: github.event_name == 'release' && github.event.action == 'published' + environment: pypi + permissions: + id-token: write + steps: + - name: download artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + path: dist + merge-multiple: true + + - name: publish to PyPI + uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1 diff --git a/.gitignore b/.gitignore index 50a05bb03..c61b656e1 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,9 @@ tools/pdf/afm/ __pycache__/ *.py[cod] *$py.class + +# Packaging / testing +dist/ +wheelhouse/ +*.egg-info/ +.pytest_cache/ diff --git a/AGENTS.md b/AGENTS.md index 9e04f3e86..48f97a7be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,6 +63,7 @@ bytes ─▶ magic/open_strategy ─▶ DecodedFile ─▶ Document ─▶ Eleme | `src/odr/internal/pdf/`, `pdf_poppler/` | PDF (own parser + poppler/pdf2htmlEX path). | | `src/odr/internal/{csv,json,text,svm}/` | Smaller formats. | | `cli/src/` | CLI tools: `translate`, `back_translate`, `meta`, `server`. | +| `python/` | Python bindings (`pyodr`, pybind11); see [`python/AGENTS.md`](python/AGENTS.md). | | `tools/pdf/` | Dev tooling (not built): PDF encoding-data generators, see `tools/pdf/README.md`. | | `test/src/` | GoogleTest suites; data in `test/data` (git submodules). | | `offline/documentation/MS-*/` | Vendored Microsoft spec text (see [Specs](#specs)). | @@ -85,8 +86,8 @@ cmake --build cmake-build-relwithdebinfo --target translate # CLI: file → HTM - **Run the test binary from the build dir** so output stays out of the repo tree. - **For debugging, prefer the `translate` CLI** on a single file over the suite. - CMake options (`CMakeLists.txt`): `ODR_TEST`, `ODR_CLI`, `ODR_WITH_PDF2HTMLEX`, - `ODR_WITH_WVWARE`, `ODR_WITH_LIBMAGIC`, `ODR_CLANG_TIDY`. A new `.cpp` must be - added to `ODR_SOURCE_FILES`. + `ODR_WITH_WVWARE`, `ODR_WITH_LIBMAGIC`, `ODR_PYTHON`, `ODR_CLANG_TIDY`. A new + `.cpp` must be added to `ODR_SOURCE_FILES`. - **Test data lives in git submodules** under `test/data/`. ## Conventions diff --git a/CMakeLists.txt b/CMakeLists.txt index ef17b348a..dde9fb2ea 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -22,6 +22,7 @@ option(ODR_WITH_PDF2HTMLEX "Build with pdf2htmlEX" "${WITH_PDF2HTMLEX}") option(ODR_WITH_WVWARE "Build with wvWare" "${WITH_WVWARE}") option(ODR_WITH_LIBMAGIC "Build with libmagic" "${WITH_LIBMAGIC}") option(ODR_BUNDLE_ASSETS "Bundle assets during build and install" OFF) +option(ODR_PYTHON "Build Python bindings" OFF) include(GNUInstallDirs) @@ -405,6 +406,10 @@ if (ODR_CLI) add_subdirectory("cli") endif () +if (ODR_PYTHON) + add_subdirectory("python") +endif () + if (ODR_TEST) add_subdirectory("test") endif () @@ -423,8 +428,12 @@ install( FILES_MATCHING PATTERN "*.hpp" ${ODR_HEADER_EXCLUDE} ) +set(ODR_INSTALL_TARGETS odr) +if (ODR_CLI) + list(APPEND ODR_INSTALL_TARGETS meta translate back_translate) +endif () install( - TARGETS odr meta translate back_translate + TARGETS ${ODR_INSTALL_TARGETS} RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}" BUNDLE DESTINATION "${CMAKE_INSTALL_BINDIR}" LIBRARY DESTINATION "${CMAKE_INSTALL_LIBDIR}" diff --git a/conan.lock b/conan.lock index e827c43fc..d4fd9138c 100644 --- a/conan.lock +++ b/conan.lock @@ -4,16 +4,17 @@ "zstd/1.5.7#b68ca8e3de04ba5957761751d1d661f4%1760955092.069", "zlib/1.3.2#1cb806da49011867778ffb6ac7190fcb%1777558780.503", "xz_utils/5.8.3#a8432fead347c69d8b2737c35f936132%1775752656.4", - "wvware/1.2.9-odr#ecdcc87eeee4ba3171fc52b885ffcd37%1780762545.0901508", + "wvware/1.2.9-odr#ecdcc87eeee4ba3171fc52b885ffcd37%1784987064.234467", "vincentlaucsb-csv-parser/2.3.0#ac67e368e82c9e3da4a663c35e3a1b2f%1718528275.177", "util-linux-libuuid/2.41.2#ee8e33ba15c17c0140643e0bd93c0b6f%1765881086.29", "utfcpp/4.0.9#b5eb56fe6b829b6f3eb8da167c4f41b2%1773905531.586", "uchardet/0.0.8#6ab25e452021fcdb560f4e37f4a27bc1%1759735438.978", + "pybind11/2.13.6#42746850cd4c68d1b1ea42de456c2182%1755673714.548", "pugixml/1.15#979e88f4fafbfe3585d2c0510a071cc7%1739435725.483", - "poppler-data/0.4.12-odr#06cdb12e4cab52261a5eb6c7d7dad273%1780762544.93178", - "poppler/26.05.0-odr#1bea719ddff7adcea402ab5cc230cddb%1780762544.781552", + "poppler-data/0.4.12-odr#06cdb12e4cab52261a5eb6c7d7dad273%1784987064.081555", + "poppler/26.05.0-odr#1bea719ddff7adcea402ab5cc230cddb%1784987063.93038", "pixman/0.46.2#88b157b4faa6474a6028c2ba2a987924%1752742515.414", - "pdf2htmlex/0.18.8.rc1-odr-git-732fd68#3740316d902d99246da134b0d5f97276%1780762544.3294039", + "pdf2htmlex/0.18.8.rc1-odr-git-732fd68#3740316d902d99246da134b0d5f97276%1784987063.4659111", "pcre2/10.42#9a35f5089feb875ec61a38eca364ce77%1743524593.693", "openjpeg/2.5.4#372fbc2b4348d45ab0c0a62a8475dc2f%1760446899.685", "nlohmann_json/3.12.0#2d634ab0ec8d9f56353e5ccef6d6612c%1744735883.94", @@ -25,25 +26,25 @@ "libmagic/5.45#791d5bad38d33272bb120994a198b1ac%1727273086.09", "libjpeg/9f#8b1b89da851ad9172565c35f14e23c4c%1780476337.068", "libiconv/1.17#9923bc6dc6f106646d6967e0039a5ada%1774021608.288", - "libgsf/1.14.52#5dbc15a9ac6f146fa648766f8691256e%1780762540.360806", + "libgsf/1.14.52#5dbc15a9ac6f146fa648766f8691256e%1784987058.932805", "libgettext/0.22#b09eea019e19b9b9c46d8f1da7d75444%1765809130.834", "libffi/3.4.8#a045c00fb26779635e3bed40e80c5254%1753360042.396", "libelf/0.8.13#ba59bbc89757ed62cfd7690a73bf81be%1741781951.327", "lcms/2.17#3feb06eea368c52c82f50107cd7694cd%1753693316.094", "gtest/1.14.0#f8f0757a574a8dd747d16af62d6eb1b7%1743410807.169", - "glib/2.81.0-odr#0436d4fbe7682722ede5964a63953c1d%1780762540.211057", + "glib/2.81.0-odr#0436d4fbe7682722ede5964a63953c1d%1784987058.779003", "giflib/5.2.2#b445ec67bae61a96ddf0fa5614afde18%1773315754.794", "freetype/2.14.1#40f1e4af5db7d8155f9ecabd09973280%1762374429.281", - "fontforge/20251009#846770e19fc4ee3234e0a39344a71211%1780762540.059381", - "fontconfig/2.15.0-odr#5681bcb942193569ee95215b93ed9fb0%1780762539.746542", + "fontforge/20251009#846770e19fc4ee3234e0a39344a71211%1784987058.624506", + "fontconfig/2.15.0-odr#5681bcb942193569ee95215b93ed9fb0%1784987058.316003", "expat/2.8.1#e2fa0a1a12da326521a3d622a475047a%1780484982.662", "cryptopp/8.9.0#7a51e0038756b21bc3a6b82d681d5906%1758206597.119", "cpp-httplib/0.16.3#7aa89fbb81ffd19539a49fc132502966%1748426320.106", - "cairo/1.18.0-odr#e2979af10b4da6ece999e32d272ffabc%1780762539.5928671", + "cairo/1.18.0-odr#e2979af10b4da6ece999e32d272ffabc%1784987058.1596658", "bzip2/1.0.8#c470882369c2d95c5c77e970c0c7e321%1762886692.465", "brotli/1.1.0#3f631ef77008f7b5eb388780116371a3%1764862343.045", "boost/1.90.0#ddd0d973741acbebe7c3b8dfbfd005eb%1778050994.937", - "argon2/20190702-odr#965901884bc82ec8a7c0a1305d42c127%1780762539.437624" + "argon2/20190702-odr#965901884bc82ec8a7c0a1305d42c127%1784987057.981858" ], "build_requires": [ "zstd/1.5.7#b68ca8e3de04ba5957761751d1d661f4%1760955092.069", @@ -67,7 +68,7 @@ "gtk-doc-stub/cci.20181216#09072d684ce1458596b44a30a747494c%1687277608.37", "gperf/3.1#a7afdf8f7cccdc2dcd4d962370c33d4f%1755780571.156", "gnu-config/cci.20210814#466e9d4d7779e1c142443f7ea44b4284%1762363589.329", - "glib/2.81.0-odr#0436d4fbe7682722ede5964a63953c1d%1780762540.211057", + "glib/2.81.0-odr#0436d4fbe7682722ede5964a63953c1d%1784987058.779003", "gettext/0.26#28c867efd4914f03c6c05da08a23c35b%1765299118.633", "flex/2.6.4#efa781fc5088b47c895bd4eef6911f2e%1761560242.855", "cmake/4.3.2#5bae0b181463ca4dc3e924a981367ee6%1779785720.872", diff --git a/conanfile.py b/conanfile.py index 85c02b290..5285ab1d4 100644 --- a/conanfile.py +++ b/conanfile.py @@ -21,6 +21,7 @@ class OpenDocumentCoreConan(ConanFile): "with_wvWare": [True, False], "with_libmagic": [True, False], "with_http_server": [True, False], + "with_python": [True, False], "bundle_assets": [True, False], } default_options = { @@ -30,10 +31,11 @@ class OpenDocumentCoreConan(ConanFile): "with_wvWare": True, "with_libmagic": True, "with_http_server": True, + "with_python": False, "bundle_assets": False, } - exports_sources = ["cli/*", "cmake/*", "resources/dist/*", "src/*", "CMakeLists.txt"] + exports_sources = ["cli/*", "cmake/*", "python/*", "resources/dist/*", "src/*", "CMakeLists.txt"] def config_options(self): if self.settings.os == "Windows": @@ -59,6 +61,8 @@ def requirements(self): self.requires("argon2/20190702-odr") if self.options.get_safe("with_libmagic", False): self.requires("libmagic/5.45") + if self.options.get_safe("with_python", False): + self.requires("pybind11/2.13.6") def build_requirements(self): self.test_requires("gtest/1.14.0") @@ -79,6 +83,7 @@ def generate(self): tc.variables["ODR_WITH_WVWARE"] = self.options.get_safe("with_wvWare", False) tc.variables["ODR_WITH_LIBMAGIC"] = self.options.get_safe("with_libmagic", False) tc.variables["ODR_WITH_HTTP_SERVER"] = self.options.get_safe("with_http_server", False) + tc.variables["ODR_PYTHON"] = self.options.get_safe("with_python", False) tc.variables["ODR_BUNDLE_ASSETS"] = self.options.get_safe("bundle_assets", False) # Get runenv info, exported by package_info() of dependencies diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..65f510e63 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,77 @@ +# Python package build (pyodr). The C++ dependencies are resolved with conan; +# run `conan install . -o '&:with_python=True'` first and point CMAKE_ARGS at +# the generated conan_toolchain.cmake — see python/README.md. + +[build-system] +requires = ["scikit-build-core>=1.0", "setuptools-scm>=8"] +build-backend = "scikit_build_core.build" + +[project] +name = "pyodr" +# Derived from the git tag (v5.5.0 -> 5.5.0), like the conan package version. +dynamic = ["version"] +description = "Python bindings for OpenDocument.core: render office documents to HTML" +readme = "python/README.md" +license = { file = "LICENSE" } +authors = [ + { name = "Andreas Stefl", email = "stefl.andreas@gmail.com" }, +] +requires-python = ">=3.9" +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Programming Language :: C++", + "Programming Language :: Python :: 3", + "Topic :: Office/Business :: Office Suites", + "Topic :: Text Processing :: Markup :: HTML", +] + +[project.scripts] +pyodr = "pyodr.cli:main" + +[project.optional-dependencies] +test = ["pytest"] + +[project.urls] +homepage = "https://opendocument.app/" +source = "https://github.com/opendocument-app/OpenDocument.core" +tracker = "https://github.com/opendocument-app/OpenDocument.core/issues" + +[[tool.dynamic-metadata]] +provider = "scikit_build_core.metadata.setuptools_scm" + +[tool.scikit-build] +cmake.version = ">=3.18" +# Everything that goes into the wheel is installed by CMake (component +# `python`, see python/CMakeLists.txt). +wheel.packages = [] +install.components = ["python"] +sdist.exclude = [ + "cmake-build-*", + "conan-odr-index", + "docs", + "experiments", + "offline", + "test", + "tools", +] + +[tool.scikit-build.cmake.define] +ODR_PYTHON = "ON" +ODR_CLI = "OFF" +ODR_TEST = "OFF" +BUILD_SHARED_LIBS = "OFF" +# Keep wheels self-contained: skip the components whose runtime data cannot be +# bundled into the package (PDF rendering falls back to the built-in parser). +ODR_WITH_PDF2HTMLEX = "OFF" +ODR_WITH_WVWARE = "OFF" +ODR_WITH_LIBMAGIC = "OFF" + +# Section must exist for setuptools-scm to pick up its pyproject config. +[tool.setuptools_scm] + +[tool.pytest.ini_options] +testpaths = ["python/tests"] + +[tool.black] +target-version = ["py39"] diff --git a/python/AGENTS.md b/python/AGENTS.md new file mode 100644 index 000000000..f81e6ab76 --- /dev/null +++ b/python/AGENTS.md @@ -0,0 +1,31 @@ +# AGENTS.md — python bindings + +pybind11 bindings for the public C++ API (`src/odr/*.hpp`), packaged as +`pyodr`. + +## Layout + +| Path | What | +|------|------| +| `CMakeLists.txt` | Builds `pyodr/_core` extension; included from the root build via `ODR_PYTHON`, or standalone against an installed `odrcore`. | +| `src/` | Binding sources, one `bind_*` unit per public-API area (`bindings.hpp` declares them). | +| `pyodr/` | Pure-python package: `__init__.py` re-exports `_core`, `cli.py` is the `pyodr` console script. | +| `tests/` | pytest suite; inputs are generated inline (tmp files, zip-built minimal ODT) — no fixture files. | + +## Rules + +- **Bind the public API only** — never include `odr/internal/...` headers. +- Mirror the C++ names; drop the `Logger` parameters (bindings use the default + null logger). +- Anything returning an `Element` (or subtype/iterator) must carry + `py::keep_alive<0, 1>()` so handles keep the originating `Document` alive + (see `keep_self_alive` in `bind_document.cpp`). +- Stream-based C++ APIs (`write`, `save`, `pipe`) are bound as functions + returning `bytes`/`str` via `std::ostringstream`. +- New public C++ API? Extend the matching `bind_*.cpp` and add a pytest. +- C++ sources follow the repo clang-format; python is formatted with `black`. +- Tests must stay hermetic: build inputs inline in `tests/conftest.py`; + HTML-rendering tests take the `core_data_path` fixture (skips when assets are + missing). +- Build/test loop: see `python/README.md`; CI lives in + `.github/workflows/python.yml`. diff --git a/python/CMakeLists.txt b/python/CMakeLists.txt new file mode 100644 index 000000000..8514c1c13 --- /dev/null +++ b/python/CMakeLists.txt @@ -0,0 +1,79 @@ +# Python bindings for OpenDocument.core (package `pyodr`). +# +# Included from the top-level CMakeLists.txt when `ODR_PYTHON` is ON. Can also +# be configured standalone against an installed `odrcore` package. + +if (CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) + cmake_minimum_required(VERSION 3.18) + project(pyodr LANGUAGES CXX) + set(CMAKE_CXX_STANDARD 20) + set(CMAKE_CXX_STANDARD_REQUIRED ON) + set(CMAKE_CXX_EXTENSIONS OFF) + + find_package(odrcore REQUIRED) + set(ODR_PYTHON_ODR_TARGET odrcore::odrcore) + + # Same option name as the root build; an installed odrcore ships + # `http_server.hpp` only when it was built with the HTTP server. + option(ODR_WITH_HTTP_SERVER "Build with cpp-httplib HTTP server" ON) +else () + set(ODR_PYTHON_ODR_TARGET odr) +endif () + +find_package(Python 3.9 REQUIRED COMPONENTS Interpreter Development.Module) +find_package(pybind11 CONFIG REQUIRED) + +if (ODR_WITH_HTTP_SERVER) + set(ODR_PYTHON_HTTP_SERVER_SOURCE "src/bind_http_server.cpp") +else () + set(ODR_PYTHON_HTTP_SERVER_SOURCE "src/bind_http_server_stub.cpp") +endif () + +pybind11_add_module(pyodr_core + "src/module.cpp" + "src/bind_core.cpp" + "src/bind_document.cpp" + "src/bind_file.cpp" + "src/bind_html.cpp" + "${ODR_PYTHON_HTTP_SERVER_SOURCE}" + "src/bind_style.cpp" +) +target_link_libraries(pyodr_core PRIVATE ${ODR_PYTHON_ODR_TARGET}) +# The `$<1:...>` genex keeps multi-config generators from appending a +# per-config subdirectory; the module must sit inside the `pyodr` package. +set_target_properties(pyodr_core PROPERTIES + OUTPUT_NAME "_core" + LIBRARY_OUTPUT_DIRECTORY "$<1:${CMAKE_CURRENT_BINARY_DIR}/pyodr>" +) + +# Mirror the pure-python package next to the built extension so the build tree +# is directly importable: PYTHONPATH=/python +add_custom_target(pyodr_package + COMMAND "${CMAKE_COMMAND}" -E copy_directory + "${CMAKE_CURRENT_SOURCE_DIR}/pyodr" "${CMAKE_CURRENT_BINARY_DIR}/pyodr" +) +if (DEFINED ODR_BUILD_ODR_DATA_PATH) + add_custom_command(TARGET pyodr_package POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E copy_directory + "${ODR_BUILD_ODR_DATA_PATH}" "${CMAKE_CURRENT_BINARY_DIR}/pyodr/data" + ) +endif () +add_dependencies(pyodr_core pyodr_package) + +install(TARGETS pyodr_core LIBRARY DESTINATION pyodr COMPONENT python) +install(DIRECTORY pyodr/ DESTINATION pyodr COMPONENT python + FILES_MATCHING PATTERN "*.py" PATTERN "py.typed") +# Bundle the odr.js/css assets so the wheel is self-contained; +# `pyodr/__init__.py` points `GlobalParams` at them. +if (DEFINED ODR_BUILD_ODR_DATA_PATH) + install(DIRECTORY "${ODR_BUILD_ODR_DATA_PATH}/" DESTINATION pyodr/data + COMPONENT python) +endif () + +if (ODR_TEST) + enable_testing() + add_test(NAME pyodr_pytest + COMMAND Python::Interpreter -m pytest "${CMAKE_CURRENT_SOURCE_DIR}/tests" -v) + set_tests_properties(pyodr_pytest PROPERTIES + ENVIRONMENT "PYTHONPATH=${CMAKE_CURRENT_BINARY_DIR};ODR_CORE_DATA_PATH=${ODR_BUILD_ODR_DATA_PATH}") +endif () diff --git a/python/README.md b/python/README.md new file mode 100644 index 000000000..9cfd6ee2b --- /dev/null +++ b/python/README.md @@ -0,0 +1,50 @@ +# pyodr — Python bindings for OpenDocument.core + +Decode office documents (ODF, OOXML, legacy MS binary, PDF, CSV, ...) and +render them to HTML from Python. + +```python +import pyodr + +file = pyodr.open("document.odt") +print(pyodr.file_type_to_string(file.file_type())) + +service = pyodr.html.translate(file, "cache-dir", pyodr.HtmlConfig()) +html = service.bring_offline("output-dir") +for page in html.pages(): + print(page.name, page.path) +``` + +A small CLI is included: `pyodr ` renders a document and opens it in the +browser; `pyodr --serve` hosts it over HTTP (via the core HTTP server, +available when built with `ODR_WITH_HTTP_SERVER`). + +## Building + +The bindings are part of the main CMake build, toggled by `ODR_PYTHON`: + +```bash +conan install . -o '&:with_python=True' --build missing +cmake -B build -S . -DCMAKE_TOOLCHAIN_FILE=conan_toolchain.cmake -DODR_PYTHON=ON +cmake --build build --target pyodr_core +PYTHONPATH=build/python ODR_CORE_DATA_PATH=build/data python -m pytest python/tests +``` + +`pip install .` from the repository root builds a wheel via scikit-build-core +(see the root `pyproject.toml`); run `conan install` first and point +`CMAKE_ARGS` at the generated `conan_toolchain.cmake` so the C++ dependencies +resolve. Wheels build without pdf2htmlEX/wvWare/libmagic (their runtime data +cannot ship inside the wheel), so match those options: + +```bash +conan install . -o '&:with_python=True' -o '&:with_pdf2htmlEX=False' \ + -o '&:with_wvWare=False' -o '&:with_libmagic=False' --build missing +CMAKE_ARGS="-DCMAKE_TOOLCHAIN_FILE=$PWD/conan_toolchain.cmake" pip install . +``` + +## Runtime data + +Rendering uses shipped assets (CSS/JS). Wheels bundle them under `pyodr/data` +and pick them up automatically; for in-tree builds set the environment variable +`ODR_CORE_DATA_PATH` (the tests read it) or call +`pyodr.GlobalParams.set_odr_core_data_path(...)`. diff --git a/python/pyodr/__init__.py b/python/pyodr/__init__.py new file mode 100644 index 000000000..06bd58335 --- /dev/null +++ b/python/pyodr/__init__.py @@ -0,0 +1,42 @@ +"""Python bindings for OpenDocument.core. + +Decode office documents (ODF, OOXML, legacy MS binary, PDF, CSV, ...) and +render them to HTML. + +Example: + >>> import pyodr + >>> file = pyodr.open("document.odt") + >>> service = pyodr.html.translate(file, cache_path, pyodr.HtmlConfig()) + >>> html = service.bring_offline(output_path) + >>> [page.path for page in html.pages()] +""" + +import os +from pathlib import Path + +from pyodr import _core +from pyodr._core import * # noqa: F401,F403 +from pyodr._core import html # noqa: F401 + +__version__ = _core.version() + + +def _init_data_paths() -> None: + # Rendering needs the shipped odr.js/css assets. Resolution order: an + # already-configured valid path wins, then the ODR_CORE_DATA_PATH + # environment variable, then the assets bundled with the package. The + # built-in default is an install-relative guess ("share"), so only an + # existing directory counts as configured. + current = _core.GlobalParams.odr_core_data_path() + if current and os.path.isdir(current): + return + env_path = os.environ.get("ODR_CORE_DATA_PATH") + if env_path: + _core.GlobalParams.set_odr_core_data_path(env_path) + return + data_path = Path(__file__).resolve().parent / "data" + if data_path.is_dir(): + _core.GlobalParams.set_odr_core_data_path(str(data_path)) + + +_init_data_paths() diff --git a/python/pyodr/cli.py b/python/pyodr/cli.py new file mode 100644 index 000000000..a578b8146 --- /dev/null +++ b/python/pyodr/cli.py @@ -0,0 +1,133 @@ +"""Command line interface: render a document to HTML and open or serve it.""" + +import argparse +import signal +import sys +import tempfile +import webbrowser +from pathlib import Path +from typing import Optional, Sequence + +import pyodr + + +def _open_decoded_file(path: Path, password: Optional[str]): + try: + file = pyodr.open(str(path.resolve())) + except FileNotFoundError: + print(f"file not found: {path}", file=sys.stderr) + return None + except pyodr.UnknownFileTypeError: + print(f"unknown file type: {path}", file=sys.stderr) + return None + + print(f"file type: {pyodr.file_type_to_string(file.file_type())}") + + if file.password_encrypted(): + if password is None: + print("file is encrypted, use --password", file=sys.stderr) + return None + try: + file = file.decrypt(password) + except pyodr.WrongPasswordError: + print("wrong password", file=sys.stderr) + return None + + return file + + +def _translate(args, file) -> int: + if args.output is not None: + output = args.output.resolve() + output.mkdir(parents=True, exist_ok=True) + else: + output = Path(tempfile.mkdtemp(prefix="pyodr-")) + cache = tempfile.mkdtemp(prefix="pyodr-cache-") + + service = pyodr.html.translate(file, cache, pyodr.HtmlConfig()) + html = service.bring_offline(str(output)) + + for page in html.pages(): + print(f"{page.name}: {page.path}") + if not args.no_open: + webbrowser.open(f"file://{page.path}") + + return 0 + + +def _serve(args, file) -> int: + if not pyodr.has_http_server: + print("pyodr was built without the HTTP server", file=sys.stderr) + return 1 + + server_config = pyodr.HttpServer.Config() + server_config.cache_path = tempfile.mkdtemp(prefix="pyodr-server-") + server = pyodr.HttpServer(server_config) + + html_config = pyodr.HtmlConfig() + html_config.embed_images = False + html_config.relative_resource_paths = False + + prefix = "file" + views = server.serve_file(file, prefix, html_config) + urls = [ + f"http://{args.host}:{args.port}/file/{prefix}/{view.path()}" for view in views + ] + for url in urls: + print(url) + if not args.no_open and urls: + webbrowser.open(urls[0]) + + # `listen` blocks in C++; restore the default SIGINT handler so Ctrl+C + # terminates the server. + signal.signal(signal.SIGINT, signal.SIG_DFL) + server.listen(args.host, args.port) + + return 0 + + +def main(argv: Optional[Sequence[str]] = None) -> int: + parser = argparse.ArgumentParser( + prog="pyodr", description="OpenDocument Reader: render documents to HTML" + ) + parser.add_argument("file", type=Path, help="the file to open") + parser.add_argument( + "--output", + "-o", + type=Path, + default=None, + help="output directory (default: a temporary directory)", + ) + parser.add_argument( + "--password", "-p", default=None, help="password for encrypted files" + ) + parser.add_argument( + "--no-open", + action="store_true", + help="do not open the result in a web browser", + ) + parser.add_argument( + "--serve", + "-s", + action="store_true", + help="serve the file over HTTP instead of writing files", + ) + parser.add_argument( + "--host", default="localhost", help="HTTP server host (with --serve)" + ) + parser.add_argument( + "--port", type=int, default=8080, help="HTTP server port (with --serve)" + ) + args = parser.parse_args(argv) + + file = _open_decoded_file(args.file, args.password) + if file is None: + return 1 + + if args.serve: + return _serve(args, file) + return _translate(args, file) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/python/src/bind_core.cpp b/python/src/bind_core.cpp new file mode 100644 index 000000000..b9a94cef5 --- /dev/null +++ b/python/src/bind_core.cpp @@ -0,0 +1,132 @@ +#include "bindings.hpp" + +#include +#include +#include +#include + +#include + +#include + +namespace py = pybind11; + +void odr_python::bind_core(py::module_ &m) { + m.def("version", &odr::version, "Version of the underlying odrcore library."); + m.def("commit_hash", &odr::commit_hash, + "Git commit hash of the underlying odrcore library."); + m.def("is_dirty", &odr::is_dirty); + m.def("is_debug", &odr::is_debug); + m.def("identify", &odr::identify, + "Identification string of the underlying odrcore library."); + + py::class_(m, "GlobalParams", + "Global resource paths of the library.") + .def_static("odr_core_data_path", &odr::GlobalParams::odr_core_data_path) + .def_static("fontconfig_data_path", + &odr::GlobalParams::fontconfig_data_path) + .def_static("poppler_data_path", &odr::GlobalParams::poppler_data_path) + .def_static("pdf2htmlex_data_path", + &odr::GlobalParams::pdf2htmlex_data_path) + .def_static("libmagic_database_path", + &odr::GlobalParams::libmagic_database_path) + .def_static("set_odr_core_data_path", + &odr::GlobalParams::set_odr_core_data_path, py::arg("path")) + .def_static("set_fontconfig_data_path", + &odr::GlobalParams::set_fontconfig_data_path, py::arg("path")) + .def_static("set_poppler_data_path", + &odr::GlobalParams::set_poppler_data_path, py::arg("path")) + .def_static("set_pdf2htmlex_data_path", + &odr::GlobalParams::set_pdf2htmlex_data_path, py::arg("path")) + .def_static("set_libmagic_database_path", + &odr::GlobalParams::set_libmagic_database_path, + py::arg("path")); + + py::register_exception(m, "UnsupportedOperation"); + py::register_exception(m, "FileNotFound", + PyExc_FileNotFoundError); + py::register_exception(m, "UnknownFileTypeError"); + py::register_exception(m, + "UnsupportedFileTypeError"); + py::register_exception( + m, "UnknownDecoderEngineError"); + py::register_exception( + m, "UnsupportedDecoderEngineError"); + py::register_exception(m, "FileReadError"); + py::register_exception(m, "FileWriteError"); + py::register_exception(m, "NoDocumentFileError"); + py::register_exception(m, + "UnknownDocumentTypeError"); + py::register_exception( + m, "UnsupportedCryptoAlgorithmError"); + py::register_exception(m, "WrongPasswordError"); + py::register_exception(m, "DecryptionFailedError"); + py::register_exception(m, "NotEncryptedError"); + py::register_exception(m, "FileEncryptedError"); + py::register_exception( + m, "DocumentCopyProtectedError"); +} + +void odr_python::bind_functions(py::module_ &m) { + m.def( + "file_type_by_file_extension", + [](const std::string &extension) { + return odr::file_type_by_file_extension(extension); + }, + py::arg("extension")); + m.def("file_category_by_file_type", &odr::file_category_by_file_type, + py::arg("type")); + m.def("document_type_by_file_type", &odr::document_type_by_file_type, + py::arg("type")); + m.def("file_type_to_string", &odr::file_type_to_string, py::arg("type")); + m.def("file_category_to_string", &odr::file_category_to_string, + py::arg("category")); + m.def("document_type_to_string", &odr::document_type_to_string, + py::arg("type")); + m.def( + "file_type_by_mimetype", + [](const std::string &mimetype) { + return odr::file_type_by_mimetype(mimetype); + }, + py::arg("mimetype")); + m.def( + "mimetype_by_file_type", + [](const odr::FileType type) { + return std::string(odr::mimetype_by_file_type(type)); + }, + py::arg("type")); + m.def("decoder_engine_to_string", &odr::decoder_engine_to_string, + py::arg("engine")); + m.def( + "decoder_engine_by_name", + [](const std::string &name) { return odr::decoder_engine_by_name(name); }, + py::arg("name")); + + m.def( + "list_file_types", + [](const std::string &path) { return odr::list_file_types(path); }, + py::arg("path"), "Determine the possible file types of a file."); + m.def("list_decoder_engines", &odr::list_decoder_engines, py::arg("as_type")); + m.def( + "mimetype", + [](const std::string &path) { return std::string(odr::mimetype(path)); }, + py::arg("path"), "Determine the MIME type of a file."); + + m.def( + "open", [](const std::string &path) { return odr::open(path); }, + py::arg("path"), "Open and decode a file."); + m.def( + "open", + [](const std::string &path, const odr::FileType as) { + return odr::open(path, as); + }, + py::arg("path"), py::arg("as_type"), + "Open and decode a file as a specific file type."); + m.def( + "open", + [](const std::string &path, const odr::DecodePreference &preference) { + return odr::open(path, preference); + }, + py::arg("path"), py::arg("preference"), + "Open and decode a file with a decode preference."); +} diff --git a/python/src/bind_document.cpp b/python/src/bind_document.cpp new file mode 100644 index 000000000..481b44b87 --- /dev/null +++ b/python/src/bind_document.cpp @@ -0,0 +1,318 @@ +#include "bindings.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +namespace py = pybind11; + +namespace { + +// Ties the returned object to `self` so navigation handles keep the +// originating `Document` alive transitively. +constexpr auto keep_self_alive = py::keep_alive<0, 1>(); + +py::object make_element_iterator(const odr::ElementRange &range) { + return py::make_iterator(range.begin(), range.end()); +} + +py::object make_children_iterator(const odr::Element &element) { + return make_element_iterator(element.children()); +} + +} // namespace + +void odr_python::bind_document(py::module_ &m) { + py::enum_(m, "ElementType") + .value("none", odr::ElementType::none) + .value("root", odr::ElementType::root) + .value("slide", odr::ElementType::slide) + .value("sheet", odr::ElementType::sheet) + .value("page", odr::ElementType::page) + .value("master_page", odr::ElementType::master_page) + .value("sheet_cell", odr::ElementType::sheet_cell) + .value("text", odr::ElementType::text) + .value("line_break", odr::ElementType::line_break) + .value("page_break", odr::ElementType::page_break) + .value("paragraph", odr::ElementType::paragraph) + .value("span", odr::ElementType::span) + .value("link", odr::ElementType::link) + .value("bookmark", odr::ElementType::bookmark) + .value("list", odr::ElementType::list) + .value("list_item", odr::ElementType::list_item) + .value("table", odr::ElementType::table) + .value("table_column", odr::ElementType::table_column) + .value("table_row", odr::ElementType::table_row) + .value("table_cell", odr::ElementType::table_cell) + .value("frame", odr::ElementType::frame) + .value("image", odr::ElementType::image) + .value("rect", odr::ElementType::rect) + .value("line", odr::ElementType::line) + .value("circle", odr::ElementType::circle) + .value("custom_shape", odr::ElementType::custom_shape) + .value("group", odr::ElementType::group); + + py::enum_(m, "AnchorType") + .value("as_char", odr::AnchorType::as_char) + .value("at_char", odr::AnchorType::at_char) + .value("at_frame", odr::AnchorType::at_frame) + .value("at_page", odr::AnchorType::at_page) + .value("at_paragraph", odr::AnchorType::at_paragraph); + + py::enum_(m, "ValueType") + .value("unknown", odr::ValueType::unknown) + .value("string", odr::ValueType::string) + .value("float_number", odr::ValueType::float_number); + + py::class_(m, "TableDimensions") + .def(py::init<>()) + .def(py::init(), py::arg("rows"), + py::arg("columns")) + .def_readwrite("rows", &odr::TableDimensions::rows) + .def_readwrite("columns", &odr::TableDimensions::columns) + .def("__repr__", [](const odr::TableDimensions &dimensions) { + return "TableDimensions(rows=" + std::to_string(dimensions.rows) + + ", columns=" + std::to_string(dimensions.columns) + ")"; + }); + + py::class_(m, "TablePosition") + .def(py::init<>()) + .def(py::init(), py::arg("column"), + py::arg("row")) + .def_readwrite("column", &odr::TablePosition::column) + .def_readwrite("row", &odr::TablePosition::row) + .def_static("to_column_num", &odr::TablePosition::to_column_num, + py::arg("string")) + .def_static("to_row_num", &odr::TablePosition::to_row_num, + py::arg("string")) + .def_static("to_column_string", &odr::TablePosition::to_column_string, + py::arg("column")) + .def_static("to_row_string", &odr::TablePosition::to_row_string, + py::arg("row")); + + py::class_(m, "DocumentPath") + .def(py::init<>()) + .def(py::init(), py::arg("string")) + .def("empty", &odr::DocumentPath::empty) + .def("parent", &odr::DocumentPath::parent) + .def("join", &odr::DocumentPath::join, py::arg("other")) + .def( + "__eq__", + [](const odr::DocumentPath &lhs, const odr::DocumentPath &rhs) { + return lhs == rhs; + }, + py::is_operator()) + .def("__str__", &odr::DocumentPath::to_string) + .def("__repr__", [](const odr::DocumentPath &path) { + return "DocumentPath('" + path.to_string() + "')"; + }); + + py::class_(m, "Element") + .def(py::init<>()) + .def("__bool__", &odr::Element::operator bool) + .def( + "__eq__", + [](const odr::Element &lhs, const odr::Element &rhs) { + return lhs == rhs; + }, + py::is_operator()) + .def("type", &odr::Element::type) + .def("parent", &odr::Element::parent, keep_self_alive) + .def("first_child", &odr::Element::first_child, keep_self_alive) + .def("previous_sibling", &odr::Element::previous_sibling, keep_self_alive) + .def("next_sibling", &odr::Element::next_sibling, keep_self_alive) + .def("is_unique", &odr::Element::is_unique) + .def("is_self_locatable", &odr::Element::is_self_locatable) + .def("is_editable", &odr::Element::is_editable) + .def("document_path", &odr::Element::document_path) + .def("navigate_path", &odr::Element::navigate_path, py::arg("path"), + keep_self_alive) + .def("children", &make_children_iterator, keep_self_alive) + .def("__iter__", &make_children_iterator, keep_self_alive) + .def("as_text_root", &odr::Element::as_text_root, keep_self_alive) + .def("as_slide", &odr::Element::as_slide, keep_self_alive) + .def("as_sheet", &odr::Element::as_sheet, keep_self_alive) + .def("as_page", &odr::Element::as_page, keep_self_alive) + .def("as_sheet_cell", &odr::Element::as_sheet_cell, keep_self_alive) + .def("as_master_page", &odr::Element::as_master_page, keep_self_alive) + .def("as_line_break", &odr::Element::as_line_break, keep_self_alive) + .def("as_paragraph", &odr::Element::as_paragraph, keep_self_alive) + .def("as_span", &odr::Element::as_span, keep_self_alive) + .def("as_text", &odr::Element::as_text, keep_self_alive) + .def("as_link", &odr::Element::as_link, keep_self_alive) + .def("as_bookmark", &odr::Element::as_bookmark, keep_self_alive) + .def("as_list_item", &odr::Element::as_list_item, keep_self_alive) + .def("as_table", &odr::Element::as_table, keep_self_alive) + .def("as_table_column", &odr::Element::as_table_column, keep_self_alive) + .def("as_table_row", &odr::Element::as_table_row, keep_self_alive) + .def("as_table_cell", &odr::Element::as_table_cell, keep_self_alive) + .def("as_frame", &odr::Element::as_frame, keep_self_alive) + .def("as_rect", &odr::Element::as_rect, keep_self_alive) + .def("as_line", &odr::Element::as_line, keep_self_alive) + .def("as_circle", &odr::Element::as_circle, keep_self_alive) + .def("as_custom_shape", &odr::Element::as_custom_shape, keep_self_alive) + .def("as_image", &odr::Element::as_image, keep_self_alive); + + py::class_(m, "TextRoot") + .def("page_layout", &odr::TextRoot::page_layout) + .def("first_master_page", &odr::TextRoot::first_master_page, + keep_self_alive); + + py::class_(m, "Slide") + .def("name", &odr::Slide::name) + .def("page_layout", &odr::Slide::page_layout) + .def("master_page", &odr::Slide::master_page, keep_self_alive); + + py::class_(m, "Sheet") + .def("name", &odr::Sheet::name) + .def("dimensions", &odr::Sheet::dimensions) + .def("content", &odr::Sheet::content, py::arg("range")) + .def("cell", &odr::Sheet::cell, py::arg("column"), py::arg("row"), + keep_self_alive) + .def( + "shapes", + [](const odr::Sheet &sheet) { + return make_element_iterator(sheet.shapes()); + }, + keep_self_alive) + .def("style", &odr::Sheet::style) + .def("column_style", &odr::Sheet::column_style, py::arg("column")) + .def("row_style", &odr::Sheet::row_style, py::arg("row")) + .def("cell_style", &odr::Sheet::cell_style, py::arg("column"), + py::arg("row")); + + py::class_(m, "SheetCell") + .def("position", &odr::SheetCell::position) + .def("is_covered", &odr::SheetCell::is_covered) + .def("span", &odr::SheetCell::span) + .def("value_type", &odr::SheetCell::value_type); + + py::class_(m, "Page") + .def("name", &odr::Page::name) + .def("page_layout", &odr::Page::page_layout) + .def("master_page", &odr::Page::master_page, keep_self_alive); + + py::class_(m, "MasterPage") + .def("page_layout", &odr::MasterPage::page_layout); + + py::class_(m, "LineBreak") + .def("style", &odr::LineBreak::style); + + py::class_(m, "Paragraph") + .def("style", &odr::Paragraph::style) + .def("text_style", &odr::Paragraph::text_style); + + py::class_(m, "Span").def("style", + &odr::Span::style); + + py::class_(m, "Text") + .def("content", &odr::Text::content) + .def("set_content", &odr::Text::set_content, py::arg("text")) + .def("style", &odr::Text::style); + + py::class_(m, "Link").def("href", &odr::Link::href); + + py::class_(m, "Bookmark") + .def("name", &odr::Bookmark::name); + + py::class_(m, "ListItem") + .def("style", &odr::ListItem::style); + + py::class_(m, "Table") + .def("first_row", &odr::Table::first_row, keep_self_alive) + .def("first_column", &odr::Table::first_column, keep_self_alive) + .def( + "columns", + [](const odr::Table &table) { + return make_element_iterator(table.columns()); + }, + keep_self_alive) + .def( + "rows", + [](const odr::Table &table) { + return make_element_iterator(table.rows()); + }, + keep_self_alive) + .def("dimensions", &odr::Table::dimensions) + .def("style", &odr::Table::style); + + py::class_(m, "TableColumn") + .def("style", &odr::TableColumn::style); + + py::class_(m, "TableRow") + .def("style", &odr::TableRow::style); + + py::class_(m, "TableCell") + .def("is_covered", &odr::TableCell::is_covered) + .def("span", &odr::TableCell::span) + .def("value_type", &odr::TableCell::value_type) + .def("style", &odr::TableCell::style); + + py::class_(m, "Frame") + .def("anchor_type", &odr::Frame::anchor_type) + .def("x", &odr::Frame::x) + .def("y", &odr::Frame::y) + .def("width", &odr::Frame::width) + .def("height", &odr::Frame::height) + .def("z_index", &odr::Frame::z_index) + .def("style", &odr::Frame::style); + + py::class_(m, "Rect") + .def("x", &odr::Rect::x) + .def("y", &odr::Rect::y) + .def("width", &odr::Rect::width) + .def("height", &odr::Rect::height) + .def("style", &odr::Rect::style); + + py::class_(m, "Line") + .def("x1", &odr::Line::x1) + .def("y1", &odr::Line::y1) + .def("x2", &odr::Line::x2) + .def("y2", &odr::Line::y2) + .def("style", &odr::Line::style); + + py::class_(m, "Circle") + .def("x", &odr::Circle::x) + .def("y", &odr::Circle::y) + .def("width", &odr::Circle::width) + .def("height", &odr::Circle::height) + .def("style", &odr::Circle::style); + + py::class_(m, "CustomShape") + .def("x", &odr::CustomShape::x) + .def("y", &odr::CustomShape::y) + .def("width", &odr::CustomShape::width) + .def("height", &odr::CustomShape::height) + .def("style", &odr::CustomShape::style); + + py::class_(m, "Image") + .def("is_internal", &odr::Image::is_internal) + .def("file", &odr::Image::file) + .def("href", &odr::Image::href); + + py::class_(m, "Document") + .def("is_editable", &odr::Document::is_editable) + .def("is_savable", &odr::Document::is_savable, + py::arg("encrypted") = false) + .def("save", + py::overload_cast(&odr::Document::save, + py::const_), + py::arg("path")) + .def("save", + py::overload_cast( + &odr::Document::save, py::const_), + py::arg("path"), py::arg("password")) + .def("file_type", &odr::Document::file_type) + .def("document_type", &odr::Document::document_type) + .def("root_element", &odr::Document::root_element, keep_self_alive) + .def("as_filesystem", &odr::Document::as_filesystem); +} diff --git a/python/src/bind_file.cpp b/python/src/bind_file.cpp new file mode 100644 index 000000000..781c70612 --- /dev/null +++ b/python/src/bind_file.cpp @@ -0,0 +1,222 @@ +#include "bindings.hpp" + +#include +#include +#include +#include + +#include + +#include +#include + +namespace py = pybind11; + +void odr_python::bind_file(py::module_ &m) { + py::enum_(m, "FileType") + .value("unknown", odr::FileType::unknown) + .value("opendocument_text", odr::FileType::opendocument_text) + .value("opendocument_presentation", + odr::FileType::opendocument_presentation) + .value("opendocument_spreadsheet", + odr::FileType::opendocument_spreadsheet) + .value("opendocument_graphics", odr::FileType::opendocument_graphics) + .value("office_open_xml_document", + odr::FileType::office_open_xml_document) + .value("office_open_xml_presentation", + odr::FileType::office_open_xml_presentation) + .value("office_open_xml_workbook", + odr::FileType::office_open_xml_workbook) + .value("office_open_xml_encrypted", + odr::FileType::office_open_xml_encrypted) + .value("legacy_word_document", odr::FileType::legacy_word_document) + .value("legacy_powerpoint_presentation", + odr::FileType::legacy_powerpoint_presentation) + .value("legacy_excel_worksheets", odr::FileType::legacy_excel_worksheets) + .value("word_perfect", odr::FileType::word_perfect) + .value("rich_text_format", odr::FileType::rich_text_format) + .value("portable_document_format", + odr::FileType::portable_document_format) + .value("text_file", odr::FileType::text_file) + .value("comma_separated_values", odr::FileType::comma_separated_values) + .value("javascript_object_notation", + odr::FileType::javascript_object_notation) + .value("markdown", odr::FileType::markdown) + .value("zip", odr::FileType::zip) + .value("compound_file_binary_format", + odr::FileType::compound_file_binary_format) + .value("portable_network_graphics", + odr::FileType::portable_network_graphics) + .value("graphics_interchange_format", + odr::FileType::graphics_interchange_format) + .value("jpeg", odr::FileType::jpeg) + .value("bitmap_image_file", odr::FileType::bitmap_image_file) + .value("starview_metafile", odr::FileType::starview_metafile) + .value("truetype_font", odr::FileType::truetype_font) + .value("opentype_font", odr::FileType::opentype_font); + + py::enum_(m, "FileCategory") + .value("unknown", odr::FileCategory::unknown) + .value("text", odr::FileCategory::text) + .value("image", odr::FileCategory::image) + .value("archive", odr::FileCategory::archive) + .value("document", odr::FileCategory::document) + .value("font", odr::FileCategory::font); + + py::enum_(m, "FileLocation") + .value("memory", odr::FileLocation::memory) + .value("disk", odr::FileLocation::disk); + + py::enum_(m, "DecoderEngine") + .value("odr", odr::DecoderEngine::odr) + .value("poppler", odr::DecoderEngine::poppler) + .value("wvware", odr::DecoderEngine::wvware); + + py::enum_(m, "EncryptionState") + .value("unknown", odr::EncryptionState::unknown) + .value("not_encrypted", odr::EncryptionState::not_encrypted) + .value("encrypted", odr::EncryptionState::encrypted) + .value("decrypted", odr::EncryptionState::decrypted); + + py::enum_(m, "DocumentType") + .value("unknown", odr::DocumentType::unknown) + .value("text", odr::DocumentType::text) + .value("presentation", odr::DocumentType::presentation) + .value("spreadsheet", odr::DocumentType::spreadsheet) + .value("drawing", odr::DocumentType::drawing); + + py::class_(m, "DecodePreference") + .def(py::init<>()) + .def_readwrite("as_file_type", &odr::DecodePreference::as_file_type) + .def_readwrite("with_engine", &odr::DecodePreference::with_engine) + .def_readwrite("file_type_priority", + &odr::DecodePreference::file_type_priority) + .def_readwrite("engine_priority", + &odr::DecodePreference::engine_priority); + + py::class_(m, "DocumentMeta") + .def(py::init<>()) + .def_readwrite("document_type", &odr::DocumentMeta::document_type) + .def_readwrite("entry_count", &odr::DocumentMeta::entry_count) + .def_readwrite("title", &odr::DocumentMeta::title) + .def_readwrite("author", &odr::DocumentMeta::author) + .def_readwrite("subject", &odr::DocumentMeta::subject) + .def_readwrite("keywords", &odr::DocumentMeta::keywords) + .def_readwrite("creator", &odr::DocumentMeta::creator) + .def_readwrite("producer", &odr::DocumentMeta::producer) + .def_readwrite("creation_date", &odr::DocumentMeta::creation_date) + .def_readwrite("modification_date", + &odr::DocumentMeta::modification_date); + + py::class_(m, "FileMeta") + .def(py::init<>()) + .def_readwrite("type", &odr::FileMeta::type) + .def_property_readonly( + "mimetype", + [](const odr::FileMeta &meta) { return std::string(meta.mimetype); }) + .def_readwrite("password_encrypted", &odr::FileMeta::password_encrypted) + .def_readwrite("document_meta", &odr::FileMeta::document_meta); + + py::class_(m, "File") + .def(py::init<>()) + .def(py::init(), py::arg("path")) + .def("__bool__", + [](const odr::File &file) { return file.impl() != nullptr; }) + .def("location", &odr::File::location) + .def("size", &odr::File::size) + .def("disk_path", &odr::File::disk_path) + .def( + "read", + [](const odr::File &file) { + std::ostringstream out; + file.pipe(out); + return py::bytes(out.str()); + }, + "Read the whole file into bytes.") + .def("copy", &odr::File::copy, py::arg("path")); + + py::class_(m, "DecodedFile") + .def(py::init(), py::arg("file")) + .def(py::init(), py::arg("path")) + .def(py::init(), py::arg("path"), + py::arg("as_type")) + .def("file", &odr::DecodedFile::file) + .def("file_type", &odr::DecodedFile::file_type) + .def("file_category", &odr::DecodedFile::file_category) + .def("file_meta", &odr::DecodedFile::file_meta) + .def("decoder_engine", &odr::DecodedFile::decoder_engine) + .def("password_encrypted", &odr::DecodedFile::password_encrypted) + .def("encryption_state", &odr::DecodedFile::encryption_state) + .def("decrypt", &odr::DecodedFile::decrypt, py::arg("password")) + .def("is_decodable", &odr::DecodedFile::is_decodable) + .def("is_text_file", &odr::DecodedFile::is_text_file) + .def("is_image_file", &odr::DecodedFile::is_image_file) + .def("is_archive_file", &odr::DecodedFile::is_archive_file) + .def("is_document_file", &odr::DecodedFile::is_document_file) + .def("is_pdf_file", &odr::DecodedFile::is_pdf_file) + .def("is_font_file", &odr::DecodedFile::is_font_file) + .def("as_text_file", &odr::DecodedFile::as_text_file) + .def("as_image_file", &odr::DecodedFile::as_image_file) + .def("as_archive_file", &odr::DecodedFile::as_archive_file) + .def("as_document_file", &odr::DecodedFile::as_document_file) + .def("as_pdf_file", &odr::DecodedFile::as_pdf_file) + .def("as_font_file", &odr::DecodedFile::as_font_file); + + py::class_(m, "TextFile") + .def("charset", &odr::TextFile::charset) + .def("text", &odr::TextFile::text); + + py::class_(m, "ImageFile") + .def("read", [](const odr::ImageFile &file) { + std::ostringstream out; + out << file.stream()->rdbuf(); + return py::bytes(out.str()); + }); + + py::class_(m, "ArchiveFile") + .def("archive", &odr::ArchiveFile::archive); + + py::class_(m, "DocumentFile") + .def(py::init(), py::arg("path")) + .def_static("type_by_path", &odr::DocumentFile::type, py::arg("path")) + .def_static("meta_by_path", &odr::DocumentFile::meta, py::arg("path")) + .def("document_type", &odr::DocumentFile::document_type) + .def("document_meta", &odr::DocumentFile::document_meta) + .def("decrypt", &odr::DocumentFile::decrypt, py::arg("password")) + .def("document", &odr::DocumentFile::document); + + py::class_(m, "PdfFile") + .def("decrypt", &odr::PdfFile::decrypt, py::arg("password")); + + py::class_(m, "FontFile") + .def("read", [](const odr::FontFile &file) { + std::ostringstream out; + out << file.stream()->rdbuf(); + return py::bytes(out.str()); + }); + + py::class_(m, "FileWalker") + .def("end", &odr::FileWalker::end) + .def("depth", &odr::FileWalker::depth) + .def("path", &odr::FileWalker::path) + .def("is_file", &odr::FileWalker::is_file) + .def("is_directory", &odr::FileWalker::is_directory) + .def("pop", &odr::FileWalker::pop) + .def("next", &odr::FileWalker::next) + .def("flat_next", &odr::FileWalker::flat_next); + + py::class_(m, "Filesystem") + .def("exists", &odr::Filesystem::exists, py::arg("path")) + .def("is_file", &odr::Filesystem::is_file, py::arg("path")) + .def("is_directory", &odr::Filesystem::is_directory, py::arg("path")) + .def("file_walker", &odr::Filesystem::file_walker, py::arg("path")) + .def("open", &odr::Filesystem::open, py::arg("path")); + + py::class_(m, "Archive") + .def("as_filesystem", &odr::Archive::as_filesystem) + .def("save", [](const odr::Archive &archive) { + std::ostringstream out; + archive.save(out); + return py::bytes(out.str()); + }); +} diff --git a/python/src/bind_html.cpp b/python/src/bind_html.cpp new file mode 100644 index 000000000..0de384ac5 --- /dev/null +++ b/python/src/bind_html.cpp @@ -0,0 +1,202 @@ +#include "bindings.hpp" + +#include +#include +#include +#include + +#include +#include + +#include +#include +#include + +namespace py = pybind11; + +void odr_python::bind_html(py::module_ &m) { + py::enum_(m, "HtmlResourceType") + .value("html_fragment", odr::HtmlResourceType::html_fragment) + .value("css", odr::HtmlResourceType::css) + .value("js", odr::HtmlResourceType::js) + .value("image", odr::HtmlResourceType::image) + .value("font", odr::HtmlResourceType::font); + + py::enum_(m, "HtmlTableGridlines") + .value("none", odr::HtmlTableGridlines::none) + .value("soft", odr::HtmlTableGridlines::soft) + .value("hard", odr::HtmlTableGridlines::hard); + + py::enum_(m, "PdfTextMode") + .value("dual_layer", odr::PdfTextMode::dual_layer) + .value("single_layer", odr::PdfTextMode::single_layer); + + py::class_(m, "HtmlResource") + .def("type", &odr::HtmlResource::type) + .def("mime_type", &odr::HtmlResource::mime_type) + .def("name", &odr::HtmlResource::name) + .def("path", &odr::HtmlResource::path) + .def("file", &odr::HtmlResource::file) + .def("is_shipped", &odr::HtmlResource::is_shipped) + .def("is_external", &odr::HtmlResource::is_external) + .def("is_accessible", &odr::HtmlResource::is_accessible) + .def("read", [](const odr::HtmlResource &resource) { + std::ostringstream out; + resource.write_resource(out); + return py::bytes(out.str()); + }); + + py::class_(m, "HtmlConfig") + .def(py::init<>()) + .def(py::init(), py::arg("output_path")) + .def_readwrite("document_output_file_name", + &odr::HtmlConfig::document_output_file_name) + .def_readwrite("slide_output_file_name", + &odr::HtmlConfig::slide_output_file_name) + .def_readwrite("sheet_output_file_name", + &odr::HtmlConfig::sheet_output_file_name) + .def_readwrite("page_output_file_name", + &odr::HtmlConfig::page_output_file_name) + .def_readwrite("embed_images", &odr::HtmlConfig::embed_images) + .def_readwrite("embed_shipped_resources", + &odr::HtmlConfig::embed_shipped_resources) + .def_readwrite("resource_path", &odr::HtmlConfig::resource_path) + .def_readwrite("relative_resource_paths", + &odr::HtmlConfig::relative_resource_paths) + .def_readwrite("editable", &odr::HtmlConfig::editable) + .def_readwrite("text_document_margin", + &odr::HtmlConfig::text_document_margin) + .def_readwrite("spreadsheet_limit", &odr::HtmlConfig::spreadsheet_limit) + .def_readwrite("spreadsheet_limit_by_content", + &odr::HtmlConfig::spreadsheet_limit_by_content) + .def_readwrite("spreadsheet_gridlines", + &odr::HtmlConfig::spreadsheet_gridlines) + .def_readwrite("format_html", &odr::HtmlConfig::format_html) + .def_readwrite("html_indent", &odr::HtmlConfig::html_indent) + .def_readwrite("html_indent_string", &odr::HtmlConfig::html_indent_string) + .def_readwrite("background_image_format", + &odr::HtmlConfig::background_image_format) + .def_readwrite("background_image_dpi", + &odr::HtmlConfig::background_image_dpi) + .def_readwrite("page_range_begin", &odr::HtmlConfig::page_range_begin) + .def_readwrite("page_range_end", &odr::HtmlConfig::page_range_end) + .def_readwrite("pdf_text_mode", &odr::HtmlConfig::pdf_text_mode) + .def_readwrite("pdf_dual_layer_fallback_fonts", + &odr::HtmlConfig::pdf_dual_layer_fallback_fonts) + .def_readwrite("pdf_dual_layer_fallback_font_size_adjust", + &odr::HtmlConfig::pdf_dual_layer_fallback_font_size_adjust) + .def_readwrite("no_drm", &odr::HtmlConfig::no_drm) + .def_readwrite("embed_outline", &odr::HtmlConfig::embed_outline) + .def_readwrite("output_path", &odr::HtmlConfig::output_path) + .def_readwrite("resource_locator", &odr::HtmlConfig::resource_locator); + + py::class_(m, "HtmlPage") + .def_readonly("name", &odr::HtmlPage::name) + .def_readonly("path", &odr::HtmlPage::path) + .def("__repr__", [](const odr::HtmlPage &page) { + return "HtmlPage(name='" + page.name + "', path='" + page.path + "')"; + }); + + py::class_(m, "Html") + .def("config", &odr::Html::config) + .def("pages", &odr::Html::pages); + + // `py::dynamic_attr` so `HtmlService::list_views` can pin the service onto + // each view; the underlying view references the service without owning it. + py::class_(m, "HtmlView", py::dynamic_attr()) + .def("name", &odr::HtmlView::name) + .def("index", &odr::HtmlView::index) + .def("path", &odr::HtmlView::path) + .def("config", &odr::HtmlView::config) + .def( + "write_html", + [](const odr::HtmlView &view) { + std::ostringstream out; + auto resources = view.write_html(out); + return std::make_pair(out.str(), std::move(resources)); + }, + "Render this view; returns (html, resources).") + .def("bring_offline", &odr::HtmlView::bring_offline, + py::arg("output_path")); + + py::class_(m, "HtmlService") + .def("config", &odr::HtmlService::config) + .def("list_views", + [](const py::object &self) { + const auto &service = self.cast(); + py::list views; + for (const odr::HtmlView &view : service.list_views()) { + py::object obj = py::cast(view); + // Keep the service alive as long as the view handle exists; + // `translate(...).list_views()[0]` must not dangle. + obj.attr("_service") = self; + views.append(std::move(obj)); + } + return views; + }) + .def("warmup", &odr::HtmlService::warmup) + .def("exists", &odr::HtmlService::exists, py::arg("path")) + .def("mimetype", &odr::HtmlService::mimetype, py::arg("path")) + .def( + "write", + [](const odr::HtmlService &service, const std::string &path) { + std::ostringstream out; + service.write(path, out); + return py::bytes(out.str()); + }, + py::arg("path")) + .def( + "write_html", + [](const odr::HtmlService &service, const std::string &path) { + std::ostringstream out; + auto resources = service.write_html(path, out); + return std::make_pair(out.str(), std::move(resources)); + }, + py::arg("path"), "Render one view path; returns (html, resources).") + .def("bring_offline", + py::overload_cast( + &odr::HtmlService::bring_offline, py::const_), + py::arg("output_path")) + .def("bring_offline", + py::overload_cast &>( + &odr::HtmlService::bring_offline, py::const_), + py::arg("output_path"), py::arg("views")); + + auto html = m.def_submodule("html", "Translate decoded files to HTML."); + + html.def("standard_resource_locator", &odr::html::standard_resource_locator); + + html.def( + "translate", + [](const odr::DecodedFile &file, const std::string &cache_path, + const odr::HtmlConfig &config) { + return odr::html::translate(file, cache_path, config); + }, + py::arg("file"), py::arg("cache_path"), py::arg("config"), + "Translate a decoded file to HTML."); + html.def( + "translate", + [](const odr::Document &document, const std::string &cache_path, + const odr::HtmlConfig &config) { + return odr::html::translate(document, cache_path, config); + }, + py::arg("document"), py::arg("cache_path"), py::arg("config"), + "Translate a document to HTML."); + html.def( + "translate", + [](const odr::Filesystem &filesystem, const std::string &cache_path, + const odr::HtmlConfig &config) { + return odr::html::translate(filesystem, cache_path, config); + }, + py::arg("filesystem"), py::arg("cache_path"), py::arg("config"), + "Translate a filesystem to HTML."); + + html.def( + "edit", + [](const odr::Document &document, const std::string &diff) { + odr::html::edit(document, diff.c_str()); + }, + py::arg("document"), py::arg("diff"), + "Apply a diff (produced by the browser-side editor) to a document."); +} diff --git a/python/src/bind_http_server.cpp b/python/src/bind_http_server.cpp new file mode 100644 index 000000000..2eadcd117 --- /dev/null +++ b/python/src/bind_http_server.cpp @@ -0,0 +1,40 @@ +#include "bindings.hpp" + +#include +#include +#include + +#include + +namespace py = pybind11; + +void odr_python::bind_http_server(py::module_ &m) { + m.attr("has_http_server") = true; + + py::class_ server(m, "HttpServer", + "Serves translated files over HTTP."); + + py::class_(server, "Config") + .def(py::init<>()) + .def_readwrite("cache_path", &odr::HttpServer::Config::cache_path); + + server + .def(py::init([](const odr::HttpServer::Config &config) { + return odr::HttpServer(config); + }), + py::arg("config")) + .def("config", &odr::HttpServer::config) + .def("connect_service", &odr::HttpServer::connect_service, + py::arg("service"), py::arg("prefix")) + .def("serve_file", &odr::HttpServer::serve_file, py::arg("file"), + py::arg("prefix"), py::arg("config"), + "Translate a decoded file and host it under " + "`/file//`; returns the views.") + // Release the GIL: `listen` blocks and `stop` must remain callable from + // other Python threads. + .def("listen", &odr::HttpServer::listen, py::arg("host"), py::arg("port"), + py::call_guard()) + .def("clear", &odr::HttpServer::clear) + .def("stop", &odr::HttpServer::stop, + py::call_guard()); +} diff --git a/python/src/bind_http_server_stub.cpp b/python/src/bind_http_server_stub.cpp new file mode 100644 index 000000000..e033f9e21 --- /dev/null +++ b/python/src/bind_http_server_stub.cpp @@ -0,0 +1,9 @@ +#include "bindings.hpp" + +namespace py = pybind11; + +// Compiled instead of `bind_http_server.cpp` when `ODR_WITH_HTTP_SERVER` is +// OFF; see `python/CMakeLists.txt`. +void odr_python::bind_http_server(py::module_ &m) { + m.attr("has_http_server") = false; +} diff --git a/python/src/bind_style.cpp b/python/src/bind_style.cpp new file mode 100644 index 000000000..b2dc25469 --- /dev/null +++ b/python/src/bind_style.cpp @@ -0,0 +1,185 @@ +#include "bindings.hpp" + +#include +#include + +#include + +#include +#include + +namespace py = pybind11; + +namespace { + +template +void bind_directional_style(py::module_ &m, const char *name) { + py::class_>(m, name) + .def(py::init<>()) + .def_readwrite("right", &odr::DirectionalStyle::right) + .def_readwrite("top", &odr::DirectionalStyle::top) + .def_readwrite("left", &odr::DirectionalStyle::left) + .def_readwrite("bottom", &odr::DirectionalStyle::bottom); +} + +} // namespace + +void odr_python::bind_style(py::module_ &m) { + py::enum_(m, "FontWeight") + .value("normal", odr::FontWeight::normal) + .value("bold", odr::FontWeight::bold); + + py::enum_(m, "FontStyle") + .value("normal", odr::FontStyle::normal) + .value("italic", odr::FontStyle::italic); + + py::enum_(m, "FontPosition") + .value("normal", odr::FontPosition::normal) + .value("super", odr::FontPosition::super) + .value("sub", odr::FontPosition::sub); + + py::enum_(m, "TextAlign") + .value("left", odr::TextAlign::left) + .value("right", odr::TextAlign::right) + .value("center", odr::TextAlign::center) + .value("justify", odr::TextAlign::justify); + + py::enum_(m, "HorizontalAlign") + .value("left", odr::HorizontalAlign::left) + .value("center", odr::HorizontalAlign::center) + .value("right", odr::HorizontalAlign::right); + + py::enum_(m, "VerticalAlign") + .value("top", odr::VerticalAlign::top) + .value("middle", odr::VerticalAlign::middle) + .value("bottom", odr::VerticalAlign::bottom); + + py::enum_(m, "PrintOrientation") + .value("portrait", odr::PrintOrientation::portrait) + .value("landscape", odr::PrintOrientation::landscape); + + py::enum_(m, "TextWrap") + .value("none", odr::TextWrap::none) + .value("before", odr::TextWrap::before) + .value("after", odr::TextWrap::after) + .value("run_through", odr::TextWrap::run_through); + + py::class_(m, "DynamicUnit") + .def(py::init<>()) + .def(py::init([](const std::string &name) { + return odr::DynamicUnit(name.c_str()); + }), + py::arg("name")) + .def("name", &odr::DynamicUnit::name) + .def( + "__eq__", + [](const odr::DynamicUnit &lhs, const odr::DynamicUnit &rhs) { + return lhs == rhs; + }, + py::is_operator()) + .def("__str__", &odr::DynamicUnit::to_string) + .def("__repr__", [](const odr::DynamicUnit &unit) { + return "DynamicUnit('" + unit.to_string() + "')"; + }); + + py::class_(m, "Measure") + .def(py::init([](const std::string &string) { + return odr::Measure(string.c_str()); + }), + py::arg("string")) + .def(py::init(), py::arg("magnitude"), + py::arg("unit")) + .def("magnitude", &odr::Measure::magnitude) + .def("unit", &odr::Measure::unit) + .def("__float__", [](const odr::Measure &q) { return q.magnitude(); }) + .def( + "__eq__", + [](const odr::Measure &lhs, const odr::Measure &rhs) { + return lhs == rhs; + }, + py::is_operator()) + .def("__str__", &odr::Measure::to_string) + .def("__repr__", [](const odr::Measure &q) { + return "Measure('" + q.to_string() + "')"; + }); + + py::class_(m, "Color") + .def(py::init<>()) + .def(py::init(), py::arg("rgb")) + .def(py::init(), py::arg("red"), + py::arg("green"), py::arg("blue")) + .def(py::init(), + py::arg("red"), py::arg("green"), py::arg("blue"), py::arg("alpha")) + .def_readwrite("red", &odr::Color::red) + .def_readwrite("green", &odr::Color::green) + .def_readwrite("blue", &odr::Color::blue) + .def_readwrite("alpha", &odr::Color::alpha) + .def("rgb", &odr::Color::rgb) + .def("argb", &odr::Color::argb); + + bind_directional_style(m, "DirectionalMeasure"); + bind_directional_style(m, "DirectionalString"); + + py::class_(m, "TextStyle") + .def(py::init<>()) + .def_property_readonly( + "font_name", + [](const odr::TextStyle &style) -> std::optional { + if (style.font_name == nullptr) { + return std::nullopt; + } + return std::string(style.font_name); + }) + .def_readwrite("font_size", &odr::TextStyle::font_size) + .def_readwrite("font_weight", &odr::TextStyle::font_weight) + .def_readwrite("font_style", &odr::TextStyle::font_style) + .def_readwrite("font_underline", &odr::TextStyle::font_underline) + .def_readwrite("font_line_through", &odr::TextStyle::font_line_through) + .def_readwrite("font_shadow", &odr::TextStyle::font_shadow) + .def_readwrite("font_color", &odr::TextStyle::font_color) + .def_readwrite("background_color", &odr::TextStyle::background_color) + .def_readwrite("font_position", &odr::TextStyle::font_position); + + py::class_(m, "ParagraphStyle") + .def(py::init<>()) + .def_readwrite("text_align", &odr::ParagraphStyle::text_align) + .def_readwrite("margin", &odr::ParagraphStyle::margin) + .def_readwrite("line_height", &odr::ParagraphStyle::line_height) + .def_readwrite("text_indent", &odr::ParagraphStyle::text_indent); + + py::class_(m, "TableStyle") + .def(py::init<>()) + .def_readwrite("width", &odr::TableStyle::width); + + py::class_(m, "TableColumnStyle") + .def(py::init<>()) + .def_readwrite("width", &odr::TableColumnStyle::width); + + py::class_(m, "TableRowStyle") + .def(py::init<>()) + .def_readwrite("height", &odr::TableRowStyle::height); + + py::class_(m, "TableCellStyle") + .def(py::init<>()) + .def_readwrite("horizontal_align", &odr::TableCellStyle::horizontal_align) + .def_readwrite("vertical_align", &odr::TableCellStyle::vertical_align) + .def_readwrite("background_color", &odr::TableCellStyle::background_color) + .def_readwrite("padding", &odr::TableCellStyle::padding) + .def_readwrite("border", &odr::TableCellStyle::border) + .def_readwrite("text_rotation", &odr::TableCellStyle::text_rotation); + + py::class_(m, "GraphicStyle") + .def(py::init<>()) + .def_readwrite("stroke_width", &odr::GraphicStyle::stroke_width) + .def_readwrite("stroke_color", &odr::GraphicStyle::stroke_color) + .def_readwrite("fill_color", &odr::GraphicStyle::fill_color) + .def_readwrite("vertical_align", &odr::GraphicStyle::vertical_align) + .def_readwrite("text_wrap", &odr::GraphicStyle::text_wrap); + + py::class_(m, "PageLayout") + .def(py::init<>()) + .def_readwrite("width", &odr::PageLayout::width) + .def_readwrite("height", &odr::PageLayout::height) + .def_readwrite("print_orientation", &odr::PageLayout::print_orientation) + .def_readwrite("margin", &odr::PageLayout::margin); +} diff --git a/python/src/bindings.hpp b/python/src/bindings.hpp new file mode 100644 index 000000000..7f523f425 --- /dev/null +++ b/python/src/bindings.hpp @@ -0,0 +1,15 @@ +#pragma once + +#include + +namespace odr_python { + +void bind_core(pybind11::module_ &m); +void bind_style(pybind11::module_ &m); +void bind_file(pybind11::module_ &m); +void bind_document(pybind11::module_ &m); +void bind_html(pybind11::module_ &m); +void bind_http_server(pybind11::module_ &m); +void bind_functions(pybind11::module_ &m); + +} // namespace odr_python diff --git a/python/src/module.cpp b/python/src/module.cpp new file mode 100644 index 000000000..f8aa45d5b --- /dev/null +++ b/python/src/module.cpp @@ -0,0 +1,15 @@ +#include "bindings.hpp" + +namespace py = pybind11; + +PYBIND11_MODULE(_core, m) { + m.doc() = "Python bindings for OpenDocument.core"; + + odr_python::bind_core(m); + odr_python::bind_style(m); + odr_python::bind_file(m); + odr_python::bind_document(m); + odr_python::bind_html(m); + odr_python::bind_http_server(m); + odr_python::bind_functions(m); +} diff --git a/python/tests/conftest.py b/python/tests/conftest.py new file mode 100644 index 000000000..f0d672628 --- /dev/null +++ b/python/tests/conftest.py @@ -0,0 +1,96 @@ +import os +import zipfile + +import pytest + +import pyodr + + +def pytest_configure(config): + data_path = os.environ.get("ODR_CORE_DATA_PATH") + if data_path: + pyodr.GlobalParams.set_odr_core_data_path(data_path) + + +@pytest.fixture +def core_data_path(): + """Skip tests that render HTML when the shipped assets are unavailable.""" + path = pyodr.GlobalParams.odr_core_data_path() + if not path or not os.path.isdir(path): + pytest.skip("odr core data path not available (set ODR_CORE_DATA_PATH)") + return path + + +ODT_CONTENT_XML = """ + + + + Hello from pyodr! + Second paragraph + + + +""" + +ODT_STYLES_XML = """ + + + + + +""" + +ODT_MANIFEST_XML = """ + + + + + +""" + + +@pytest.fixture +def odt_path(tmp_path): + """A minimal OpenDocument text file built from inline XML.""" + path = tmp_path / "minimal.odt" + with zipfile.ZipFile(path, "w") as archive: + archive.writestr( + "mimetype", + "application/vnd.oasis.opendocument.text", + compress_type=zipfile.ZIP_STORED, + ) + archive.writestr("content.xml", ODT_CONTENT_XML) + archive.writestr("styles.xml", ODT_STYLES_XML) + archive.writestr("META-INF/manifest.xml", ODT_MANIFEST_XML) + return path + + +@pytest.fixture +def csv_path(tmp_path): + path = tmp_path / "table.csv" + path.write_text("name,value\nalpha,1\nbeta,2\n") + return path + + +@pytest.fixture +def txt_path(tmp_path): + path = tmp_path / "note.txt" + path.write_text("hello text file\nsecond line\n") + return path + + +@pytest.fixture +def json_path(tmp_path): + path = tmp_path / "data.json" + path.write_text('{\n "name": "pyodr",\n "values": [1, 2, 3]\n}\n') + return path diff --git a/python/tests/test_document.py b/python/tests/test_document.py new file mode 100644 index 000000000..adf0377df --- /dev/null +++ b/python/tests/test_document.py @@ -0,0 +1,73 @@ +import pyodr + + +def walk_text(element): + """Collect the text content of an element subtree.""" + parts = [] + if element.type() == pyodr.ElementType.text: + parts.append(element.as_text().content()) + for child in element.children(): + parts.extend(walk_text(child)) + return parts + + +def test_open_odt(odt_path): + file = pyodr.open(str(odt_path)) + assert file.file_type() == pyodr.FileType.opendocument_text + assert file.file_category() == pyodr.FileCategory.document + assert file.is_document_file() + + document_file = file.as_document_file() + assert document_file.document_type() == pyodr.DocumentType.text + assert not document_file.password_encrypted() + + +def test_document_meta(odt_path): + meta = pyodr.open(str(odt_path)).as_document_file().document_meta() + assert meta.document_type == pyodr.DocumentType.text + + +def test_element_tree(odt_path): + document = pyodr.open(str(odt_path)).as_document_file().document() + assert document.document_type() == pyodr.DocumentType.text + assert document.file_type() == pyodr.FileType.opendocument_text + + root = document.root_element() + assert root + assert root.type() == pyodr.ElementType.root + + children = list(root.children()) + paragraphs = [ + child for child in children if child.type() == pyodr.ElementType.paragraph + ] + assert len(paragraphs) == 2 + + text = walk_text(root) + assert "Hello from pyodr!" in text + assert "Second paragraph" in text + + +def test_element_navigation(odt_path): + document = pyodr.open(str(odt_path)).as_document_file().document() + root = document.root_element() + + first = root.first_child() + assert first + assert first.parent() == root + second = first.next_sibling() + assert second + assert second.previous_sibling() == first + + +def test_text_root(odt_path): + document = pyodr.open(str(odt_path)).as_document_file().document() + root = document.root_element().as_text_root() + assert root + layout = root.page_layout() + assert isinstance(layout, pyodr.PageLayout) + + +def test_document_filesystem(odt_path): + document = pyodr.open(str(odt_path)).as_document_file().document() + filesystem = document.as_filesystem() + assert filesystem.is_file("/content.xml") diff --git a/python/tests/test_file.py b/python/tests/test_file.py new file mode 100644 index 000000000..5ab89d6ce --- /dev/null +++ b/python/tests/test_file.py @@ -0,0 +1,73 @@ +import pytest + +import pyodr + + +def test_file(txt_path): + file = pyodr.File(str(txt_path)) + assert file + assert file.location() == pyodr.FileLocation.disk + assert file.size() == txt_path.stat().st_size + assert file.disk_path() == str(txt_path) + assert file.read() == txt_path.read_bytes() + + +def test_open_missing_file(tmp_path): + with pytest.raises(FileNotFoundError): + pyodr.open(str(tmp_path / "missing.txt")) + + +def test_open_text_file(txt_path): + file = pyodr.open(str(txt_path)) + assert file.file_type() == pyodr.FileType.text_file + assert file.file_category() == pyodr.FileCategory.text + assert file.is_text_file() + assert not file.is_document_file() + + text_file = file.as_text_file() + assert "hello text file" in text_file.text() + + +def test_open_csv_file(csv_path): + file = pyodr.open(str(csv_path)) + assert file.file_type() == pyodr.FileType.comma_separated_values + + file_types = pyodr.list_file_types(str(csv_path)) + assert pyodr.FileType.comma_separated_values in file_types + + +def test_open_json_file(json_path): + file = pyodr.open(str(json_path)) + assert file.file_type() == pyodr.FileType.javascript_object_notation + + +def test_open_as_type(txt_path): + file = pyodr.open(str(txt_path), pyodr.FileType.text_file) + assert file.file_type() == pyodr.FileType.text_file + + +def test_open_with_preference(txt_path): + preference = pyodr.DecodePreference() + preference.as_file_type = pyodr.FileType.text_file + file = pyodr.open(str(txt_path), preference) + assert file.file_type() == pyodr.FileType.text_file + + +def test_file_meta(csv_path): + file = pyodr.open(str(csv_path)) + meta = file.file_meta() + assert meta.type == pyodr.FileType.comma_separated_values + assert not meta.password_encrypted + + +def test_open_zip_archive(odt_path): + file = pyodr.open(str(odt_path), pyodr.FileType.zip) + assert file.is_archive_file() + + filesystem = file.as_archive_file().archive().as_filesystem() + assert filesystem.is_file("/mimetype") + assert filesystem.is_file("/content.xml") + assert not filesystem.exists("/nonexistent") + + mimetype = filesystem.open("/mimetype").read() + assert mimetype == b"application/vnd.oasis.opendocument.text" diff --git a/python/tests/test_html.py b/python/tests/test_html.py new file mode 100644 index 000000000..89bf42649 --- /dev/null +++ b/python/tests/test_html.py @@ -0,0 +1,77 @@ +from pathlib import Path + +import pyodr + + +def translate_offline(path, tmp_path): + file = pyodr.open(str(path)) + cache = tmp_path / "cache" + output = tmp_path / "output" + cache.mkdir() + output.mkdir() + service = pyodr.html.translate(file, str(cache), pyodr.HtmlConfig()) + return service.bring_offline(str(output)) + + +def test_html_config_defaults(): + config = pyodr.HtmlConfig() + assert config.embed_images + assert not config.editable + assert config.spreadsheet_gridlines == pyodr.HtmlTableGridlines.soft + + config.editable = True + config.format_html = True + config.spreadsheet_limit = pyodr.TableDimensions(100, 100) + assert config.editable + assert config.spreadsheet_limit.rows == 100 + + +def test_translate_text(core_data_path, txt_path, tmp_path): + html = translate_offline(txt_path, tmp_path) + pages = html.pages() + assert len(pages) == 1 + content = Path(pages[0].path).read_text() + assert "hello text file" in content + + +def test_translate_csv(core_data_path, csv_path, tmp_path): + html = translate_offline(csv_path, tmp_path) + pages = html.pages() + assert len(pages) == 1 + content = Path(pages[0].path).read_text() + assert "alpha" in content + + +def test_translate_document(core_data_path, odt_path, tmp_path): + html = translate_offline(odt_path, tmp_path) + pages = html.pages() + assert len(pages) == 1 + content = Path(pages[0].path).read_text() + assert "Hello from pyodr!" in content + + +def test_html_service_views(core_data_path, odt_path, tmp_path): + file = pyodr.open(str(odt_path)) + cache = tmp_path / "cache" + cache.mkdir() + service = pyodr.html.translate(file, str(cache), pyodr.HtmlConfig()) + + views = service.list_views() + assert len(views) == 1 + + content, resources = views[0].write_html() + assert "Hello from pyodr!" in content + assert isinstance(resources, list) + + +def test_html_view_outlives_service(core_data_path, odt_path, tmp_path): + file = pyodr.open(str(odt_path)) + cache = tmp_path / "cache" + cache.mkdir() + + # The service temporary is dropped immediately; the view must keep it + # alive. + view = pyodr.html.translate(file, str(cache), pyodr.HtmlConfig()).list_views()[0] + + content, _ = view.write_html() + assert "Hello from pyodr!" in content diff --git a/python/tests/test_http_server.py b/python/tests/test_http_server.py new file mode 100644 index 000000000..0eda97fb5 --- /dev/null +++ b/python/tests/test_http_server.py @@ -0,0 +1,54 @@ +import socket +import threading +import time +import urllib.request + +import pytest + +import pyodr + + +def free_port(): + with socket.socket() as sock: + sock.bind(("localhost", 0)) + return sock.getsockname()[1] + + +def fetch(url, timeout=5.0): + deadline = time.monotonic() + timeout + while True: + try: + with urllib.request.urlopen(url, timeout=1.0) as response: + return response.status, response.read() + except OSError: + if time.monotonic() > deadline: + raise + time.sleep(0.05) + + +@pytest.mark.skipif(not pyodr.has_http_server, reason="built without the HTTP server") +def test_serve_file(core_data_path, odt_path, tmp_path): + config = pyodr.HttpServer.Config() + config.cache_path = str(tmp_path / "server-cache") + server = pyodr.HttpServer(config) + + file = pyodr.open(str(odt_path)) + html_config = pyodr.HtmlConfig() + html_config.embed_images = False + html_config.relative_resource_paths = False + views = server.serve_file(file, "doc", html_config) + assert len(views) == 1 + + port = free_port() + thread = threading.Thread( + target=server.listen, args=("localhost", port), daemon=True + ) + thread.start() + try: + status, body = fetch(f"http://localhost:{port}/file/doc/{views[0].path()}") + assert status == 200 + assert b"Hello from pyodr!" in body + finally: + server.stop() + thread.join(timeout=5.0) + assert not thread.is_alive() diff --git a/python/tests/test_meta.py b/python/tests/test_meta.py new file mode 100644 index 000000000..d2722f7a3 --- /dev/null +++ b/python/tests/test_meta.py @@ -0,0 +1,69 @@ +import pyodr + + +def test_version(): + # A dev build carries no project version; only assert consistency. + assert isinstance(pyodr.version(), str) + assert pyodr.__version__ == pyodr.version() + + +def test_commit_hash(): + assert isinstance(pyodr.commit_hash(), str) + + +def test_identify(): + assert pyodr.identify() + + +def test_file_type_by_file_extension(): + assert pyodr.file_type_by_file_extension("odt") == pyodr.FileType.opendocument_text + assert ( + pyodr.file_type_by_file_extension("docx") + == pyodr.FileType.office_open_xml_document + ) + assert ( + pyodr.file_type_by_file_extension("pdf") + == pyodr.FileType.portable_document_format + ) + assert pyodr.file_type_by_file_extension("nope") == pyodr.FileType.unknown + + +def test_file_category_by_file_type(): + assert ( + pyodr.file_category_by_file_type(pyodr.FileType.opendocument_text) + == pyodr.FileCategory.document + ) + assert ( + pyodr.file_category_by_file_type(pyodr.FileType.zip) + == pyodr.FileCategory.archive + ) + + +def test_document_type_by_file_type(): + assert ( + pyodr.document_type_by_file_type(pyodr.FileType.opendocument_spreadsheet) + == pyodr.DocumentType.spreadsheet + ) + + +def test_type_to_string(): + assert pyodr.file_type_to_string(pyodr.FileType.opendocument_text) + assert pyodr.file_category_to_string(pyodr.FileCategory.document) + assert pyodr.document_type_to_string(pyodr.DocumentType.text) + + +def test_mimetype_roundtrip(): + mimetype = pyodr.mimetype_by_file_type(pyodr.FileType.portable_document_format) + assert mimetype == "application/pdf" + assert ( + pyodr.file_type_by_mimetype(mimetype) == pyodr.FileType.portable_document_format + ) + + +def test_decoder_engine(): + assert pyodr.decoder_engine_by_name("odr") == pyodr.DecoderEngine.odr + assert pyodr.decoder_engine_to_string(pyodr.DecoderEngine.odr) == "odr" + + +def test_global_params(): + assert isinstance(pyodr.GlobalParams.odr_core_data_path(), str) diff --git a/scripts/format b/scripts/format index 03d73ec46..5a60bd72e 100755 --- a/scripts/format +++ b/scripts/format @@ -20,3 +20,4 @@ function format_folder() { format_folder src format_folder cli format_folder test/src +format_folder python/src diff --git a/src/odr/file.cpp b/src/odr/file.cpp index fe7eecb53..84c863b06 100644 --- a/src/odr/file.cpp +++ b/src/odr/file.cpp @@ -237,7 +237,8 @@ std::unique_ptr TextFile::stream() const { } std::string TextFile::text() const { - return ""; // TODO + const auto stream = m_impl->file()->stream(); + return internal::util::stream::read(*stream); } ImageFile::ImageFile(std::shared_ptr impl)