diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml
new file mode 100644
index 00000000..43ebabc9
--- /dev/null
+++ b/.github/workflows/pypi-publish.yml
@@ -0,0 +1,65 @@
+name: PyPI Publish
+
+on:
+ release:
+ types: [published]
+ workflow_dispatch:
+ inputs:
+ version:
+ description: Python package version to publish (for example, 0.1.0)
+ required: true
+ type: string
+
+permissions:
+ contents: read
+
+jobs:
+ publish:
+ if: github.event_name == 'workflow_dispatch' || startsWith(github.ref_name, 'python-v')
+ runs-on: ubuntu-latest
+ environment:
+ name: pypi
+ url: https://pypi.org/project/minipdf/
+ permissions:
+ contents: read
+ id-token: write
+ defaults:
+ run:
+ working-directory: minipdf-python
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+
+ - name: Install build tools
+ run: python -m pip install build twine
+
+ - name: Validate release version
+ shell: bash
+ run: |
+ if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
+ TAG_VERSION="${{ inputs.version }}"
+ else
+ TAG_VERSION="${GITHUB_REF_NAME#python-v}"
+ fi
+ PACKAGE_VERSION="$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')"
+ if [ -z "$TAG_VERSION" ] || [ "$TAG_VERSION" != "$PACKAGE_VERSION" ]; then
+ echo "::error::Release version '$TAG_VERSION' does not match package version '$PACKAGE_VERSION'."
+ exit 1
+ fi
+
+ - name: Build distributions
+ run: python -m build
+
+ - name: Check distributions
+ run: python -m twine check dist/*
+
+ - name: Publish to PyPI
+ uses: pypa/gh-action-pypi-publish@release/v1
+ with:
+ packages-dir: minipdf-python/dist/
\ No newline at end of file
diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml
new file mode 100644
index 00000000..ba6e4695
--- /dev/null
+++ b/.github/workflows/python-ci.yml
@@ -0,0 +1,56 @@
+name: Python CI
+
+on:
+ push:
+ branches: [main, master]
+ paths:
+ - 'minipdf-python/**'
+ - '.github/workflows/python-ci.yml'
+ pull_request:
+ branches: [main, master]
+ paths:
+ - 'minipdf-python/**'
+ - '.github/workflows/python-ci.yml'
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ strategy:
+ matrix:
+ os: [ubuntu-latest, windows-latest]
+ python-version: ['3.10', '3.12', '3.13']
+ runs-on: ${{ matrix.os }}
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+ cache: pip
+ cache-dependency-path: minipdf-python/pyproject.toml
+
+ - name: Install
+ run: python -m pip install -e "./minipdf-python[dev]"
+
+ - name: Test
+ run: python -m pytest minipdf-python/tests -q
+
+ - name: Lint
+ run: python -m ruff check minipdf-python
+
+ - name: Format
+ run: python -m ruff format --check minipdf-python
+
+ - name: Type check
+ run: python -m mypy minipdf-python/src minipdf-python/tests
+
+ - name: Build
+ run: python -m build minipdf-python
+
+ - name: Check distributions
+ run: python -m twine check minipdf-python/dist/*
diff --git a/.gitignore b/.gitignore
index 0b239355..f2e1c048 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,13 @@
**/bin/
**/obj/
**/target/
+**/__pycache__/
+**/.mypy_cache/
+**/.pytest_cache/
+**/.ruff_cache/
+**/*.egg-info/
+**/build/
+**/dist/
## IDE
.vs/
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 2a78c632..12b208a0 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -48,6 +48,20 @@ cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
```
+### Python
+
+Install Python 3.10 or later, then run:
+
+```powershell
+python -m pip install -e ".\minipdf-python[dev]"
+python -m pytest minipdf-python/tests
+python -m ruff check minipdf-python
+python -m ruff format --check minipdf-python
+python -m mypy minipdf-python/src minipdf-python/tests
+python -m build minipdf-python
+python -m twine check minipdf-python/dist/*
+```
+
### Documentation
Keep commands, APIs, benchmark results, and report links consistent. Changes to
diff --git a/minipdf-python/LICENSE b/minipdf-python/LICENSE
new file mode 100644
index 00000000..fea296d8
--- /dev/null
+++ b/minipdf-python/LICENSE
@@ -0,0 +1,200 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship made available under
+ the License, as indicated by a copyright notice that is included in
+ or attached to the work (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any form of the Work that is based on
+ (or derived from) the Work and for which the editorial revisions,
+ annotations, elaborations, or other modifications represent, as a whole,
+ an original work of authorship. For the purposes of this License,
+ Derivative Works shall not include works that remain separable from,
+ or merely link (or bind by name) to the interfaces of, the Work and
+ Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/minipdf-python/README.md b/minipdf-python/README.md
new file mode 100644
index 00000000..7b939559
--- /dev/null
+++ b/minipdf-python/README.md
@@ -0,0 +1,39 @@
+# MiniPdf for Python
+
+Experimental pure Python implementation of MiniPdf. The initial release
+converts basic DOCX paragraphs to PDF 1.4 without requiring Microsoft Office,
+LibreOffice, .NET, or Rust at runtime.
+
+The package requires Python 3.10 or later. XLSX support is planned; PPTX is not
+currently supported.
+
+## Install
+
+```bash
+pip install minipdf
+```
+
+## Python API
+
+```python
+import minipdf
+
+minipdf.convert_to_pdf("report.docx", "report.pdf")
+pdf_bytes = minipdf.convert_to_pdf_bytes("report.docx")
+```
+
+## Command Line
+
+```bash
+minipdf report.docx
+minipdf convert report.docx -o report.pdf
+minipdf report.docx --paper-size a4
+minipdf report.docx --page-width 400 --page-height 500
+```
+
+## Initial Scope
+
+Version 0.1 supports document page geometry, margins, paragraphs, explicit page
+breaks, and basic bold, italic, and font-size run formatting. Text currently
+uses PDF built-in Latin fonts. Tables, images, embedded fonts, CJK/RTL shaping,
+headers, footers, lists, XLSX, and PPTX are not yet supported.
diff --git a/minipdf-python/pyproject.toml b/minipdf-python/pyproject.toml
new file mode 100644
index 00000000..00fb0649
--- /dev/null
+++ b/minipdf-python/pyproject.toml
@@ -0,0 +1,58 @@
+[build-system]
+requires = ["setuptools>=77"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "minipdf"
+version = "0.1.0"
+description = "Pure Python Office-to-PDF conversion engine"
+readme = "README.md"
+requires-python = ">=3.10"
+license = "Apache-2.0"
+license-files = ["LICENSE"]
+authors = [{ name = "MiniPdf contributors" }]
+classifiers = [
+ "Development Status :: 2 - Pre-Alpha",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3 :: Only",
+ "Programming Language :: Python :: 3.10",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.13",
+]
+
+[project.optional-dependencies]
+dev = [
+ "build>=1.2",
+ "mypy>=1.11",
+ "pypdf>=5",
+ "pytest>=8",
+ "ruff>=0.9",
+ "twine>=6",
+]
+
+[project.scripts]
+minipdf = "minipdf.cli:main"
+
+[project.urls]
+Homepage = "https://github.com/mini-software/MiniPdf"
+Repository = "https://github.com/mini-software/MiniPdf"
+
+[tool.setuptools.packages.find]
+where = ["src"]
+
+[tool.pytest.ini_options]
+addopts = "-ra"
+pythonpath = ["src"]
+testpaths = ["tests"]
+
+[tool.ruff]
+line-length = 100
+target-version = "py310"
+
+[tool.ruff.lint]
+select = ["E", "F", "I", "UP", "B", "SIM"]
+
+[tool.mypy]
+python_version = "3.10"
+strict = true
diff --git a/minipdf-python/src/minipdf/__init__.py b/minipdf-python/src/minipdf/__init__.py
new file mode 100644
index 00000000..2dda4ca2
--- /dev/null
+++ b/minipdf-python/src/minipdf/__init__.py
@@ -0,0 +1,28 @@
+from .api import (
+ convert_bytes_to_pdf,
+ convert_to_pdf,
+ convert_to_pdf_bytes,
+ register_font,
+ registered_fonts,
+)
+from .errors import InvalidInputError, MiniPdfError, PackageError, UnsupportedFormatError
+from .office import OfficeFormat, detect_office_format
+from .options import ConversionOptions, PageSize
+
+__all__ = [
+ "ConversionOptions",
+ "InvalidInputError",
+ "MiniPdfError",
+ "OfficeFormat",
+ "PackageError",
+ "PageSize",
+ "UnsupportedFormatError",
+ "convert_bytes_to_pdf",
+ "convert_to_pdf",
+ "convert_to_pdf_bytes",
+ "detect_office_format",
+ "register_font",
+ "registered_fonts",
+]
+
+__version__ = "0.1.0"
diff --git a/minipdf-python/src/minipdf/__main__.py b/minipdf-python/src/minipdf/__main__.py
new file mode 100644
index 00000000..eb53e2f3
--- /dev/null
+++ b/minipdf-python/src/minipdf/__main__.py
@@ -0,0 +1,3 @@
+from .cli import main
+
+raise SystemExit(main())
diff --git a/minipdf-python/src/minipdf/api.py b/minipdf-python/src/minipdf/api.py
new file mode 100644
index 00000000..464973ee
--- /dev/null
+++ b/minipdf-python/src/minipdf/api.py
@@ -0,0 +1,54 @@
+from __future__ import annotations
+
+import os
+from pathlib import Path
+from threading import RLock
+
+from .docx import convert_docx
+from .errors import UnsupportedFormatError
+from .office import OfficeFormat, detect_office_format
+from .options import ConversionOptions
+
+PathLike = str | os.PathLike[str]
+_font_lock = RLock()
+_fonts: list[tuple[str, bytes]] = []
+
+
+def register_font(name: str, font_data: bytes) -> None:
+ with _font_lock:
+ _fonts.append((name, bytes(font_data)))
+
+
+def registered_fonts() -> tuple[tuple[str, bytes], ...]:
+ with _font_lock:
+ return tuple(_fonts)
+
+
+def convert_bytes_to_pdf(
+ data: bytes | bytearray | memoryview,
+ options: ConversionOptions | None = None,
+) -> bytes:
+ source = bytes(data)
+ document_format = detect_office_format(source)
+ if document_format is OfficeFormat.DOCX:
+ return convert_docx(source, options or ConversionOptions())
+ if document_format is OfficeFormat.XLSX:
+ raise UnsupportedFormatError("XLSX conversion is planned but not yet supported")
+ if document_format is OfficeFormat.PPTX:
+ raise UnsupportedFormatError("PPTX conversion is not supported")
+ raise UnsupportedFormatError("unsupported or unknown Office document format")
+
+
+def convert_to_pdf_bytes(
+ input_path: PathLike,
+ options: ConversionOptions | None = None,
+) -> bytes:
+ return convert_bytes_to_pdf(Path(input_path).read_bytes(), options)
+
+
+def convert_to_pdf(
+ input_path: PathLike,
+ output_path: PathLike,
+ options: ConversionOptions | None = None,
+) -> None:
+ Path(output_path).write_bytes(convert_to_pdf_bytes(input_path, options))
diff --git a/minipdf-python/src/minipdf/cli.py b/minipdf-python/src/minipdf/cli.py
new file mode 100644
index 00000000..65a0c12d
--- /dev/null
+++ b/minipdf-python/src/minipdf/cli.py
@@ -0,0 +1,50 @@
+from __future__ import annotations
+
+import argparse
+import sys
+from collections.abc import Sequence
+from pathlib import Path
+
+from .api import convert_to_pdf
+from .errors import MiniPdfError
+from .options import ConversionOptions, PageSize
+
+
+def _parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(prog="minipdf", description="Convert Office files to PDF.")
+ parser.add_argument("input", type=Path)
+ parser.add_argument("-o", "--output", type=Path)
+ parser.add_argument("--paper-size", choices=("a4", "letter"))
+ parser.add_argument("--page-width", type=float)
+ parser.add_argument("--page-height", type=float)
+ return parser
+
+
+def _options(args: argparse.Namespace, parser: argparse.ArgumentParser) -> ConversionOptions:
+ custom_supplied = args.page_width is not None or args.page_height is not None
+ if args.paper_size and custom_supplied:
+ parser.error("use either --paper-size or --page-width/--page-height, not both")
+ if (args.page_width is None) != (args.page_height is None):
+ parser.error("--page-width and --page-height must be specified together")
+ if args.paper_size == "a4":
+ return ConversionOptions(PageSize.A4)
+ if args.paper_size == "letter":
+ return ConversionOptions(PageSize.LETTER)
+ if custom_supplied:
+ return ConversionOptions(PageSize(args.page_width, args.page_height))
+ return ConversionOptions()
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ arguments = list(sys.argv[1:] if argv is None else argv)
+ if arguments[:1] == ["convert"]:
+ arguments.pop(0)
+ parser = _parser()
+ args = parser.parse_args(arguments)
+ output = args.output or args.input.with_suffix(".pdf")
+ try:
+ convert_to_pdf(args.input, output, _options(args, parser))
+ except (MiniPdfError, OSError, ValueError) as error:
+ parser.exit(1, f"Error: {error}\n")
+ print(output)
+ return 0
diff --git a/minipdf-python/src/minipdf/docx.py b/minipdf-python/src/minipdf/docx.py
new file mode 100644
index 00000000..1f9f6d8d
--- /dev/null
+++ b/minipdf-python/src/minipdf/docx.py
@@ -0,0 +1,146 @@
+from __future__ import annotations
+
+import textwrap
+import xml.etree.ElementTree as ET
+from dataclasses import dataclass
+
+from .errors import PackageError
+from .office import OfficePackage
+from .options import ConversionOptions, PageSize
+from .pdf import PdfDocument, TextStyle
+
+WORD_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
+W = f"{{{WORD_NS}}}"
+DEFAULT_MARGIN = 54.0
+
+
+@dataclass(frozen=True, slots=True)
+class Run:
+ text: str
+ style: TextStyle
+
+
+@dataclass(frozen=True, slots=True)
+class Paragraph:
+ runs: tuple[Run, ...]
+ page_break_before: bool = False
+
+
+@dataclass(frozen=True, slots=True)
+class Document:
+ paragraphs: tuple[Paragraph, ...]
+ page_size: PageSize
+ margins: tuple[float, float, float, float]
+
+
+def _points(value: str | None, default: float) -> float:
+ if value is None:
+ return default
+ try:
+ return float(value) / 20.0
+ except ValueError:
+ return default
+
+
+def read_document(package: OfficePackage) -> Document:
+ document_xml = package.read("word/document.xml")
+ if document_xml is None:
+ raise PackageError("DOCX package is missing word/document.xml")
+ try:
+ root = ET.fromstring(document_xml)
+ except ET.ParseError as error:
+ raise PackageError("word/document.xml is malformed") from error
+ body = root.find(f"{W}body")
+ if body is None:
+ raise PackageError("word/document.xml is missing the document body")
+
+ section = body.find(f"{W}sectPr")
+ size = section.find(f"{W}pgSz") if section is not None else None
+ margins = section.find(f"{W}pgMar") if section is not None else None
+ page_size = PageSize(
+ _points(size.get(f"{W}w") if size is not None else None, PageSize.A4.width),
+ _points(size.get(f"{W}h") if size is not None else None, PageSize.A4.height),
+ )
+ page_margins = tuple(
+ _points(margins.get(f"{W}{name}") if margins is not None else None, DEFAULT_MARGIN)
+ for name in ("top", "right", "bottom", "left")
+ )
+
+ paragraphs: list[Paragraph] = []
+ for paragraph_node in body.findall(f"{W}p"):
+ page_break = paragraph_node.find(f".//{W}pageBreakBefore") is not None
+ runs: list[Run] = []
+ for run_node in paragraph_node.findall(f"{W}r"):
+ properties = run_node.find(f"{W}rPr")
+ size_node = properties.find(f"{W}sz") if properties is not None else None
+ style = TextStyle(
+ size=_points(size_node.get(f"{W}val") if size_node is not None else None, 11.0),
+ bold=properties is not None and properties.find(f"{W}b") is not None,
+ italic=properties is not None and properties.find(f"{W}i") is not None,
+ )
+ chunks: list[str] = []
+ for child in run_node:
+ if child.tag == f"{W}t":
+ chunks.append(child.text or "")
+ elif child.tag == f"{W}tab":
+ chunks.append("\t")
+ elif child.tag == f"{W}br":
+ break_type = child.get(f"{W}type")
+ if break_type == "page":
+ if chunks:
+ runs.append(Run("".join(chunks), style))
+ chunks = []
+ if runs:
+ paragraphs.append(Paragraph(tuple(runs), page_break))
+ runs = []
+ page_break = True
+ else:
+ chunks.append("\n")
+ if chunks:
+ runs.append(Run("".join(chunks), style))
+ paragraphs.append(Paragraph(tuple(runs), page_break))
+
+ return Document(tuple(paragraphs), page_size, page_margins) # type: ignore[arg-type]
+
+
+def render_document(document: Document, options: ConversionOptions) -> bytes:
+ page_size = options.page_size or document.page_size
+ top, right, bottom, left = document.margins
+ pdf = PdfDocument()
+ page = pdf.add_page(page_size.width, page_size.height)
+ cursor_y = page_size.height - top
+ available_width = page_size.width - left - right
+
+ for paragraph in document.paragraphs:
+ if paragraph.page_break_before and page.operations:
+ page = pdf.add_page(page_size.width, page_size.height)
+ cursor_y = page_size.height - top
+ if not paragraph.runs:
+ cursor_y -= 16.0
+ continue
+ for run in paragraph.runs:
+ average_width = max(run.style.size * 0.5, 1.0)
+ columns = max(1, int(available_width / average_width))
+ lines = run.text.splitlines() or [""]
+ for source_line in lines:
+ wrapped = textwrap.wrap(
+ source_line,
+ width=columns,
+ replace_whitespace=False,
+ drop_whitespace=False,
+ break_long_words=True,
+ ) or [""]
+ for line in wrapped:
+ line_height = max(16.0, run.style.size * 1.2)
+ if cursor_y - line_height < bottom:
+ page = pdf.add_page(page_size.width, page_size.height)
+ cursor_y = page_size.height - top
+ cursor_y -= line_height
+ page.add_text(line, left, cursor_y, run.style)
+ cursor_y -= 5.6
+ return pdf.to_bytes()
+
+
+def convert_docx(data: bytes, options: ConversionOptions) -> bytes:
+ package = OfficePackage(data)
+ return render_document(read_document(package), options)
diff --git a/minipdf-python/src/minipdf/errors.py b/minipdf-python/src/minipdf/errors.py
new file mode 100644
index 00000000..0e4df9d9
--- /dev/null
+++ b/minipdf-python/src/minipdf/errors.py
@@ -0,0 +1,14 @@
+class MiniPdfError(Exception):
+ """Base exception for MiniPdf conversion failures."""
+
+
+class InvalidInputError(MiniPdfError, ValueError):
+ """Raised when an input or conversion option is invalid."""
+
+
+class UnsupportedFormatError(MiniPdfError):
+ """Raised when an Office package format is not supported."""
+
+
+class PackageError(MiniPdfError):
+ """Raised when an Office ZIP package is malformed or unsafe."""
diff --git a/minipdf-python/src/minipdf/office.py b/minipdf-python/src/minipdf/office.py
new file mode 100644
index 00000000..6b3349de
--- /dev/null
+++ b/minipdf-python/src/minipdf/office.py
@@ -0,0 +1,65 @@
+from __future__ import annotations
+
+import io
+import zipfile
+from enum import Enum
+
+from .errors import PackageError
+
+MAX_ENTRY_SIZE = 64 * 1024 * 1024
+MAX_PACKAGE_SIZE = 256 * 1024 * 1024
+
+
+class OfficeFormat(Enum):
+ UNKNOWN = "unknown"
+ XLSX = "xlsx"
+ DOCX = "docx"
+ PPTX = "pptx"
+
+
+class OfficePackage:
+ def __init__(self, data: bytes) -> None:
+ try:
+ self._archive = zipfile.ZipFile(io.BytesIO(data))
+ except (OSError, zipfile.BadZipFile) as error:
+ raise PackageError("input is not a valid Office ZIP package") from error
+ self._validate()
+
+ def _validate(self) -> None:
+ total_size = 0
+ for entry in self._archive.infolist():
+ normalized = entry.filename.replace("\\", "/")
+ parts = normalized.split("/")
+ if normalized.startswith("/") or ".." in parts:
+ raise PackageError(f"unsafe package path: {entry.filename}")
+ if entry.flag_bits & 0x1:
+ raise PackageError("encrypted Office packages are not supported")
+ if entry.file_size > MAX_ENTRY_SIZE:
+ raise PackageError(f"package entry is too large: {entry.filename}")
+ total_size += entry.file_size
+ if total_size > MAX_PACKAGE_SIZE:
+ raise PackageError("Office package expands beyond the safety limit")
+
+ @property
+ def format(self) -> OfficeFormat:
+ names = (entry.filename.replace("\\", "/") for entry in self._archive.infolist())
+ roots = {name.split("/", 1)[0] for name in names if "/" in name}
+ if "word" in roots:
+ return OfficeFormat.DOCX
+ if "xl" in roots:
+ return OfficeFormat.XLSX
+ if "ppt" in roots:
+ return OfficeFormat.PPTX
+ return OfficeFormat.UNKNOWN
+
+ def read(self, name: str) -> bytes | None:
+ try:
+ return self._archive.read(name)
+ except KeyError:
+ return None
+ except (OSError, RuntimeError, zipfile.BadZipFile) as error:
+ raise PackageError(f"cannot read package entry: {name}") from error
+
+
+def detect_office_format(data: bytes) -> OfficeFormat:
+ return OfficePackage(data).format
diff --git a/minipdf-python/src/minipdf/options.py b/minipdf-python/src/minipdf/options.py
new file mode 100644
index 00000000..b25f575d
--- /dev/null
+++ b/minipdf-python/src/minipdf/options.py
@@ -0,0 +1,38 @@
+from __future__ import annotations
+
+import math
+from dataclasses import dataclass
+from typing import ClassVar
+
+from .errors import InvalidInputError
+
+
+@dataclass(frozen=True, slots=True)
+class PageSize:
+ """PDF page dimensions measured in points."""
+
+ width: float
+ height: float
+
+ A4: ClassVar[PageSize]
+ LETTER: ClassVar[PageSize]
+
+ def __post_init__(self) -> None:
+ if (
+ not math.isfinite(self.width)
+ or not math.isfinite(self.height)
+ or self.width <= 0
+ or self.height <= 0
+ ):
+ raise InvalidInputError("page width and height must be positive finite values")
+
+
+PageSize.A4 = PageSize(595.28, 841.89)
+PageSize.LETTER = PageSize(612.0, 792.0)
+
+
+@dataclass(frozen=True, slots=True)
+class ConversionOptions:
+ """Options shared by all supported Office converters."""
+
+ page_size: PageSize | None = None
diff --git a/minipdf-python/src/minipdf/pdf.py b/minipdf-python/src/minipdf/pdf.py
new file mode 100644
index 00000000..1af5cf0f
--- /dev/null
+++ b/minipdf-python/src/minipdf/pdf.py
@@ -0,0 +1,128 @@
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+
+
+def _number(value: float) -> str:
+ return f"{value:.3f}".rstrip("0").rstrip(".") or "0"
+
+
+def _pdf_text(value: str) -> bytes:
+ encoded = value.encode("cp1252", errors="replace")
+ return encoded.replace(b"\\", b"\\\\").replace(b"(", b"\\(").replace(b")", b"\\)")
+
+
+@dataclass(frozen=True, slots=True)
+class TextStyle:
+ size: float = 11.0
+ bold: bool = False
+ italic: bool = False
+
+ @property
+ def resource(self) -> str:
+ if self.bold and self.italic:
+ return "F4"
+ if self.bold:
+ return "F2"
+ if self.italic:
+ return "F3"
+ return "F1"
+
+
+@dataclass(slots=True)
+class PdfPage:
+ width: float
+ height: float
+ operations: list[bytes] = field(default_factory=list)
+
+ def add_text(self, text: str, x: float, y: float, style: TextStyle) -> None:
+ self.operations.append(
+ b"BT /"
+ + style.resource.encode("ascii")
+ + b" "
+ + _number(style.size).encode("ascii")
+ + b" Tf 0 0 0 rg 1 0 0 1 "
+ + _number(x).encode("ascii")
+ + b" "
+ + _number(y).encode("ascii")
+ + b" Tm ("
+ + _pdf_text(text)
+ + b") Tj ET\n"
+ )
+
+ def content(self) -> bytes:
+ return b"".join(self.operations)
+
+
+@dataclass(slots=True)
+class PdfDocument:
+ pages: list[PdfPage] = field(default_factory=list)
+
+ def add_page(self, width: float, height: float) -> PdfPage:
+ page = PdfPage(width, height)
+ self.pages.append(page)
+ return page
+
+ def to_bytes(self) -> bytes:
+ if not self.pages:
+ self.add_page(595.28, 841.89)
+
+ page_count = len(self.pages)
+ font_start = 3 + page_count * 2
+ objects: list[bytes] = [
+ b"<< /Type /Catalog /Pages 2 0 R >>",
+ b"<< /Type /Pages /Kids ["
+ + b" ".join(f"{3 + index * 2} 0 R".encode("ascii") for index in range(page_count))
+ + f"] /Count {page_count} >>".encode("ascii"),
+ ]
+
+ for index, page in enumerate(self.pages):
+ page_id = 3 + index * 2
+ content_id = page_id + 1
+ resources = (
+ f"/Font << /F1 {font_start} 0 R /F2 {font_start + 1} 0 R "
+ f"/F3 {font_start + 2} 0 R /F4 {font_start + 3} 0 R >>"
+ )
+ objects.append(
+ (
+ f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {_number(page.width)} "
+ f"{_number(page.height)}] /Resources << {resources} >> "
+ f"/Contents {content_id} 0 R >>"
+ ).encode("ascii")
+ )
+ content = page.content()
+ objects.append(
+ f"<< /Length {len(content)} >>\nstream\n".encode("ascii") + content + b"endstream"
+ )
+
+ for base_font in (
+ "Helvetica",
+ "Helvetica-Bold",
+ "Helvetica-Oblique",
+ "Helvetica-BoldOblique",
+ ):
+ font_object = (
+ f"<< /Type /Font /Subtype /Type1 /BaseFont /{base_font} "
+ "/Encoding /WinAnsiEncoding >>"
+ )
+ objects.append(font_object.encode("ascii"))
+
+ output = bytearray(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n")
+ offsets = [0]
+ for object_id, content in enumerate(objects, start=1):
+ offsets.append(len(output))
+ output.extend(f"{object_id} 0 obj\n".encode("ascii"))
+ output.extend(content)
+ output.extend(b"\nendobj\n")
+ xref_offset = len(output)
+ output.extend(f"xref\n0 {len(objects) + 1}\n".encode("ascii"))
+ output.extend(b"0000000000 65535 f \n")
+ for offset in offsets[1:]:
+ output.extend(f"{offset:010d} 00000 n \n".encode("ascii"))
+ output.extend(
+ (
+ f"trailer\n<< /Size {len(objects) + 1} /Root 1 0 R >>\n"
+ f"startxref\n{xref_offset}\n%%EOF\n"
+ ).encode("ascii")
+ )
+ return bytes(output)
diff --git a/minipdf-python/tests/helpers.py b/minipdf-python/tests/helpers.py
new file mode 100644
index 00000000..4e0b72de
--- /dev/null
+++ b/minipdf-python/tests/helpers.py
@@ -0,0 +1,33 @@
+from __future__ import annotations
+
+import io
+import zipfile
+
+
+def create_docx(*, page_break: bool = False) -> bytes:
+ break_property = "" if page_break else ""
+ document = f"""
+
+
+
+ Hello from Python MiniPdf
+
+
+ {break_property}
+ Second paragraph
+
+
+
+
+
+
+"""
+ content_types = """
+
+
+"""
+ output = io.BytesIO()
+ with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as archive:
+ archive.writestr("[Content_Types].xml", content_types)
+ archive.writestr("word/document.xml", document)
+ return output.getvalue()
diff --git a/minipdf-python/tests/test_api.py b/minipdf-python/tests/test_api.py
new file mode 100644
index 00000000..6c6d6d17
--- /dev/null
+++ b/minipdf-python/tests/test_api.py
@@ -0,0 +1,57 @@
+from __future__ import annotations
+
+import io
+import zipfile
+from pathlib import Path
+
+import pytest
+from helpers import create_docx
+
+from minipdf import (
+ OfficeFormat,
+ PackageError,
+ UnsupportedFormatError,
+ convert_bytes_to_pdf,
+ convert_to_pdf,
+ convert_to_pdf_bytes,
+ detect_office_format,
+)
+
+
+def test_path_and_bytes_apis_produce_identical_output(tmp_path: Path) -> None:
+ input_path = tmp_path / "sample.docx"
+ output_path = tmp_path / "sample.pdf"
+ input_path.write_bytes(create_docx())
+
+ expected = convert_bytes_to_pdf(input_path.read_bytes())
+ assert convert_to_pdf_bytes(input_path) == expected
+ convert_to_pdf(input_path, output_path)
+ assert output_path.read_bytes() == expected
+
+
+def test_detects_docx_package() -> None:
+ assert detect_office_format(create_docx()) is OfficeFormat.DOCX
+
+
+def test_rejects_unknown_office_package() -> None:
+ package = io.BytesIO()
+ with zipfile.ZipFile(package, "w") as archive:
+ archive.writestr("custom/data.xml", "")
+
+ with pytest.raises(UnsupportedFormatError, match="unknown"):
+ convert_bytes_to_pdf(package.getvalue())
+
+
+def test_rejects_non_zip_input() -> None:
+ with pytest.raises(PackageError, match="valid Office ZIP"):
+ convert_bytes_to_pdf(b"not a zip")
+
+
+def test_rejects_unsafe_package_paths() -> None:
+ package = io.BytesIO()
+ with zipfile.ZipFile(package, "w") as archive:
+ archive.writestr("word/document.xml", "")
+ archive.writestr("../escape.xml", "")
+
+ with pytest.raises(PackageError, match="unsafe package path"):
+ detect_office_format(package.getvalue())
diff --git a/minipdf-python/tests/test_cli.py b/minipdf-python/tests/test_cli.py
new file mode 100644
index 00000000..0508ab45
--- /dev/null
+++ b/minipdf-python/tests/test_cli.py
@@ -0,0 +1,54 @@
+from __future__ import annotations
+
+import subprocess
+import sys
+from pathlib import Path
+
+from helpers import create_docx
+from pypdf import PdfReader
+
+
+def test_cli_converts_with_default_output_name(tmp_path: Path) -> None:
+ input_path = tmp_path / "sample.docx"
+ input_path.write_bytes(create_docx())
+
+ result = subprocess.run(
+ [sys.executable, "-m", "minipdf", str(input_path)],
+ check=False,
+ capture_output=True,
+ text=True,
+ )
+
+ output_path = input_path.with_suffix(".pdf")
+ assert result.returncode == 0
+ assert result.stdout.strip() == str(output_path)
+ assert result.stderr == ""
+ assert len(PdfReader(output_path).pages) == 1
+
+
+def test_cli_supports_convert_command_and_page_size(tmp_path: Path) -> None:
+ input_path = tmp_path / "sample.docx"
+ output_path = tmp_path / "custom.pdf"
+ input_path.write_bytes(create_docx())
+
+ result = subprocess.run(
+ [
+ sys.executable,
+ "-m",
+ "minipdf",
+ "convert",
+ str(input_path),
+ "-o",
+ str(output_path),
+ "--paper-size",
+ "a4",
+ ],
+ check=False,
+ capture_output=True,
+ text=True,
+ )
+
+ assert result.returncode == 0
+ page = PdfReader(output_path).pages[0]
+ assert float(page.mediabox.width) == 595.28
+ assert float(page.mediabox.height) == 841.89
diff --git a/minipdf-python/tests/test_docx.py b/minipdf-python/tests/test_docx.py
new file mode 100644
index 00000000..6f5e96fe
--- /dev/null
+++ b/minipdf-python/tests/test_docx.py
@@ -0,0 +1,42 @@
+from __future__ import annotations
+
+import io
+
+from helpers import create_docx
+from pypdf import PdfReader
+
+from minipdf import ConversionOptions, PageSize, convert_bytes_to_pdf
+
+
+def test_converts_basic_docx_to_parseable_pdf() -> None:
+ pdf = convert_bytes_to_pdf(create_docx())
+
+ assert pdf.startswith(b"%PDF-1.4")
+ assert pdf.endswith(b"%%EOF\n")
+ reader = PdfReader(io.BytesIO(pdf))
+ assert len(reader.pages) == 1
+ assert reader.pages[0].extract_text() == "Hello from Python MiniPdf\nSecond paragraph"
+ assert float(reader.pages[0].mediabox.width) == 612.0
+ assert float(reader.pages[0].mediabox.height) == 792.0
+
+
+def test_preserves_explicit_page_breaks() -> None:
+ reader = PdfReader(io.BytesIO(convert_bytes_to_pdf(create_docx(page_break=True))))
+
+ assert len(reader.pages) == 2
+ assert reader.pages[0].extract_text() == "Hello from Python MiniPdf"
+ assert reader.pages[1].extract_text() == "Second paragraph"
+
+
+def test_api_page_size_overrides_document_geometry() -> None:
+ options = ConversionOptions(PageSize(400, 500))
+ reader = PdfReader(io.BytesIO(convert_bytes_to_pdf(create_docx(), options)))
+
+ assert float(reader.pages[0].mediabox.width) == 400.0
+ assert float(reader.pages[0].mediabox.height) == 500.0
+
+
+def test_output_is_deterministic() -> None:
+ source = create_docx()
+
+ assert convert_bytes_to_pdf(source) == convert_bytes_to_pdf(source)
diff --git a/minipdf-python/tests/test_options.py b/minipdf-python/tests/test_options.py
new file mode 100644
index 00000000..724344bf
--- /dev/null
+++ b/minipdf-python/tests/test_options.py
@@ -0,0 +1,24 @@
+import math
+
+import pytest
+
+from minipdf import ConversionOptions, InvalidInputError, PageSize
+
+
+def test_exposes_standard_page_sizes() -> None:
+ assert PageSize(595.28, 841.89) == PageSize.A4
+ assert PageSize(612.0, 792.0) == PageSize.LETTER
+
+
+@pytest.mark.parametrize(
+ ("width", "height"),
+ [(0, 10), (-1, 10), (10, 0), (10, -1), (math.inf, 10), (10, math.nan)],
+)
+def test_rejects_invalid_page_dimensions(width: float, height: float) -> None:
+ with pytest.raises(InvalidInputError, match="positive finite"):
+ PageSize(width, height)
+
+
+def test_conversion_options_default_to_document_page_size() -> None:
+ assert ConversionOptions().page_size is None
+ assert ConversionOptions(page_size=PageSize.LETTER).page_size == PageSize.LETTER