From 9468cb1f2d150a92819dffe492bd5699da290eae Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sat, 25 Jul 2026 18:55:24 +0200 Subject: [PATCH 1/4] feat(python): integrate pyodr bindings from OpenDocument.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring the Python bindings into the main repo as a new root `python/` folder, replacing the separate OpenDocument.py repository: - pybind11 bindings rewritten against the current public API: odr free functions, the DecodedFile family, Document + full typed element tree (with keep_alive chains and Python iteration), styles, the HtmlService/HtmlView pipeline (views returned by list_views pin their service so they cannot dangle), Filesystem/Archive, GlobalParams, typed exceptions, and the HTTP server (gated by ODR_WITH_HTTP_SERVER) - `pyodr` package with a CLI (`pyodr `, `--serve` to host over HTTP) and automatic data-asset discovery - hermetic pytest suite (inline-generated txt/csv/json/minimal ODT), including a live HTTP server round-trip - CMake: new ODR_PYTHON option includes python/CMakeLists.txt; install of CLI targets now conditional on ODR_CLI - conan: new with_python option requires pybind11 and wires ODR_PYTHON - packaging: root pyproject.toml (scikit-build-core); wheels bundle the odr.js/css assets under pyodr/data - CI: python.yml with black check, conan build + pytest (ubuntu/macos), wheel + sdist builds; releases attach wheels and sdist as GitHub release assets (nothing goes to PyPI yet — pip cannot build the sdist in an isolated backend and Linux wheels are not manylinux-audited; cibuildwheel+conan wiring is a follow-up) - core fix: implement TextFile::text() (was a TODO stub) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Gffw8AurchbpS1rL3kWwBd --- .github/workflows/python.yml | 220 +++++++++++++++++++++ .gitignore | 6 + AGENTS.md | 5 +- CMakeLists.txt | 11 +- conanfile.py | 7 +- pyproject.toml | 70 +++++++ python/AGENTS.md | 31 +++ python/CMakeLists.txt | 77 ++++++++ python/README.md | 50 +++++ python/pyodr/__init__.py | 42 ++++ python/pyodr/cli.py | 133 +++++++++++++ python/src/bind_core.cpp | 132 +++++++++++++ python/src/bind_document.cpp | 318 +++++++++++++++++++++++++++++++ python/src/bind_file.cpp | 222 +++++++++++++++++++++ python/src/bind_html.cpp | 202 ++++++++++++++++++++ python/src/bind_http_server.cpp | 50 +++++ python/src/bind_style.cpp | 185 ++++++++++++++++++ python/src/bindings.hpp | 15 ++ python/src/module.cpp | 15 ++ python/tests/conftest.py | 96 ++++++++++ python/tests/test_document.py | 73 +++++++ python/tests/test_file.py | 73 +++++++ python/tests/test_html.py | 77 ++++++++ python/tests/test_http_server.py | 54 ++++++ python/tests/test_meta.py | 69 +++++++ scripts/format | 1 + src/odr/file.cpp | 3 +- 27 files changed, 2232 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/python.yml create mode 100644 pyproject.toml create mode 100644 python/AGENTS.md create mode 100644 python/CMakeLists.txt create mode 100644 python/README.md create mode 100644 python/pyodr/__init__.py create mode 100644 python/pyodr/cli.py create mode 100644 python/src/bind_core.cpp create mode 100644 python/src/bind_document.cpp create mode 100644 python/src/bind_file.cpp create mode 100644 python/src/bind_html.cpp create mode 100644 python/src/bind_http_server.cpp create mode 100644 python/src/bind_style.cpp create mode 100644 python/src/bindings.hpp create mode 100644 python/src/module.cpp create mode 100644 python/tests/conftest.py create mode 100644 python/tests/test_document.py create mode 100644 python/tests/test_file.py create mode 100644 python/tests/test_html.py create mode 100644 python/tests/test_http_server.py create mode 100644 python/tests/test_meta.py diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml new file mode 100644 index 000000000..d7b1af3b2 --- /dev/null +++ b/.github/workflows/python.yml @@ -0,0 +1,220 @@ +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 + - name: checkout conan-odr-index + run: git submodule update --init --depth 1 conan-odr-index + + - 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 + + # 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 + + - 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 + + - 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 + + # Nothing goes to PyPI yet: pip cannot build the sdist (the build needs a + # conan-generated toolchain plus the conan-odr-index recipes, see + # python/README.md) and the Linux wheels are not manylinux-audited. Releases + # get the wheels and sdist as assets; PyPI publishing via cibuildwheel+conan + # is a follow-up. + 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 }}' 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/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..141b1655b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,70 @@ +# 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>=0.10"] +build-backend = "scikit_build_core.build" + +[project] +name = "pyodr" +version = "0.1.0" +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.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" + +[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..c894d2d0c --- /dev/null +++ b/python/CMakeLists.txt @@ -0,0 +1,77 @@ +# 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) + +pybind11_add_module(pyodr_core + "src/module.cpp" + "src/bind_core.cpp" + "src/bind_document.cpp" + "src/bind_file.cpp" + "src/bind_html.cpp" + "src/bind_http_server.cpp" + "src/bind_style.cpp" +) +target_link_libraries(pyodr_core PRIVATE ${ODR_PYTHON_ODR_TARGET}) + +if (ODR_WITH_HTTP_SERVER) + target_compile_definitions(pyodr_core PRIVATE ODR_WITH_HTTP_SERVER) +endif () +# 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..7b929f0c3 --- /dev/null +++ b/python/src/bind_http_server.cpp @@ -0,0 +1,50 @@ +#include "bindings.hpp" + +namespace py = pybind11; + +#ifdef ODR_WITH_HTTP_SERVER + +#include +#include +#include + +#include + +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()); +} + +#else + +void odr_python::bind_http_server(py::module_ &m) { + m.attr("has_http_server") = false; +} + +#endif 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) From 5f4b4a68cce88733eb52a173d773c7a04c705c80 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sat, 25 Jul 2026 22:37:54 +0200 Subject: [PATCH 2/4] refactor(python): select http server binding source via cmake Replace the ODR_WITH_HTTP_SERVER ifdef in bind_http_server.cpp with a stub source file chosen in CMake, matching how the CLI handles the option; the compile definition is no longer needed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Gffw8AurchbpS1rL3kWwBd --- python/CMakeLists.txt | 12 +++++++----- python/src/bind_http_server.cpp | 14 ++------------ python/src/bind_http_server_stub.cpp | 9 +++++++++ 3 files changed, 18 insertions(+), 17 deletions(-) create mode 100644 python/src/bind_http_server_stub.cpp diff --git a/python/CMakeLists.txt b/python/CMakeLists.txt index c894d2d0c..8514c1c13 100644 --- a/python/CMakeLists.txt +++ b/python/CMakeLists.txt @@ -23,20 +23,22 @@ 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" - "src/bind_http_server.cpp" + "${ODR_PYTHON_HTTP_SERVER_SOURCE}" "src/bind_style.cpp" ) target_link_libraries(pyodr_core PRIVATE ${ODR_PYTHON_ODR_TARGET}) - -if (ODR_WITH_HTTP_SERVER) - target_compile_definitions(pyodr_core PRIVATE ODR_WITH_HTTP_SERVER) -endif () # 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 diff --git a/python/src/bind_http_server.cpp b/python/src/bind_http_server.cpp index 7b929f0c3..2eadcd117 100644 --- a/python/src/bind_http_server.cpp +++ b/python/src/bind_http_server.cpp @@ -1,15 +1,13 @@ #include "bindings.hpp" -namespace py = pybind11; - -#ifdef ODR_WITH_HTTP_SERVER - #include #include #include #include +namespace py = pybind11; + void odr_python::bind_http_server(py::module_ &m) { m.attr("has_http_server") = true; @@ -40,11 +38,3 @@ void odr_python::bind_http_server(py::module_ &m) { .def("stop", &odr::HttpServer::stop, py::call_guard()); } - -#else - -void odr_python::bind_http_server(py::module_ &m) { - m.attr("has_http_server") = false; -} - -#endif 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; +} From a1e6bbaf269753d634148d294ca98d597250da37 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sat, 25 Jul 2026 22:37:58 +0200 Subject: [PATCH 3/4] fix(python): add pybind11 to conan.lock CI's conan install rejects the new pybind11/2.13.6 requirement because the lockfile was not regenerated when the dependency was added. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Gffw8AurchbpS1rL3kWwBd --- conan.lock | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) 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", From 92f4d269b3ea74329908d70786a6345556e5c09e Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sat, 25 Jul 2026 22:48:25 +0200 Subject: [PATCH 4/4] feat(python): tag-derived package version and PyPI publishing Drop the hardcoded pyproject version: setuptools-scm derives it from git tags via scikit-build-core dynamic metadata, matching how the conan package version is stamped from the release tag. The wheels/sdist jobs fetch full history so the tags are available. Publish wheels and the sdist to PyPI via trusted publishing on release, like the original OpenDocument.py repo did. Linux wheels are grafted onto a manylinux tag with auditwheel first since PyPI rejects plain linux_* platform tags. Also install ccache in the wheels job: the conan profiles use it as compiler launcher, so conan source builds fail without it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Gffw8AurchbpS1rL3kWwBd --- .github/workflows/python.yml | 66 +++++++++++++++++++++++++++++++++--- pyproject.toml | 11 ++++-- 2 files changed, 70 insertions(+), 7 deletions(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index d7b1af3b2..538dca833 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -125,9 +125,25 @@ jobs: 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: @@ -148,6 +164,15 @@ jobs: - 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 @@ -166,6 +191,17 @@ jobs: 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 @@ -183,6 +219,9 @@ jobs: 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 @@ -196,11 +235,6 @@ jobs: path: dist/*.tar.gz if-no-files-found: error - # Nothing goes to PyPI yet: pip cannot build the sdist (the build needs a - # conan-generated toolchain plus the conan-odr-index recipes, see - # python/README.md) and the Linux wheels are not manylinux-audited. Releases - # get the wheels and sdist as assets; PyPI publishing via cibuildwheel+conan - # is a follow-up. publish: needs: [build-test, wheels, sdist] runs-on: ubuntu-24.04 @@ -218,3 +252,25 @@ jobs: 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/pyproject.toml b/pyproject.toml index 141b1655b..65f510e63 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,12 +3,13 @@ # the generated conan_toolchain.cmake — see python/README.md. [build-system] -requires = ["scikit-build-core>=0.10"] +requires = ["scikit-build-core>=1.0", "setuptools-scm>=8"] build-backend = "scikit_build_core.build" [project] name = "pyodr" -version = "0.1.0" +# 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" } @@ -36,6 +37,9 @@ 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 @@ -63,6 +67,9 @@ 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"]