From 502d7d5949a3e75571eaf707416f52006353eb51 Mon Sep 17 00:00:00 2001 From: Wei Lin Date: Thu, 3 Sep 2026 13:32:59 +0800 Subject: [PATCH 1/2] feat(python): add initial PyPI package --- .github/workflows/pypi-publish.yml | 65 ++++++++ .github/workflows/python-ci.yml | 56 +++++++ .gitignore | 7 + CONTRIBUTING.md | 14 ++ minipdf-python/LICENSE | 200 +++++++++++++++++++++++++ minipdf-python/README.md | 39 +++++ minipdf-python/pyproject.toml | 58 +++++++ minipdf-python/src/minipdf/__init__.py | 28 ++++ minipdf-python/src/minipdf/__main__.py | 3 + minipdf-python/src/minipdf/api.py | 54 +++++++ minipdf-python/src/minipdf/cli.py | 50 +++++++ minipdf-python/src/minipdf/docx.py | 146 ++++++++++++++++++ minipdf-python/src/minipdf/errors.py | 14 ++ minipdf-python/src/minipdf/office.py | 65 ++++++++ minipdf-python/src/minipdf/options.py | 38 +++++ minipdf-python/src/minipdf/pdf.py | 128 ++++++++++++++++ minipdf-python/tests/helpers.py | 33 ++++ minipdf-python/tests/test_api.py | 57 +++++++ minipdf-python/tests/test_cli.py | 54 +++++++ minipdf-python/tests/test_docx.py | 42 ++++++ minipdf-python/tests/test_options.py | 24 +++ 21 files changed, 1175 insertions(+) create mode 100644 .github/workflows/pypi-publish.yml create mode 100644 .github/workflows/python-ci.yml create mode 100644 minipdf-python/LICENSE create mode 100644 minipdf-python/README.md create mode 100644 minipdf-python/pyproject.toml create mode 100644 minipdf-python/src/minipdf/__init__.py create mode 100644 minipdf-python/src/minipdf/__main__.py create mode 100644 minipdf-python/src/minipdf/api.py create mode 100644 minipdf-python/src/minipdf/cli.py create mode 100644 minipdf-python/src/minipdf/docx.py create mode 100644 minipdf-python/src/minipdf/errors.py create mode 100644 minipdf-python/src/minipdf/office.py create mode 100644 minipdf-python/src/minipdf/options.py create mode 100644 minipdf-python/src/minipdf/pdf.py create mode 100644 minipdf-python/tests/helpers.py create mode 100644 minipdf-python/tests/test_api.py create mode 100644 minipdf-python/tests/test_cli.py create mode 100644 minipdf-python/tests/test_docx.py create mode 100644 minipdf-python/tests/test_options.py diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml new file mode 100644 index 00000000..225eeeb2 --- /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-python/ + 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..5d95bfcf --- /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 +``` + +## 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..5f6c5241 --- /dev/null +++ b/minipdf-python/pyproject.toml @@ -0,0 +1,58 @@ +[build-system] +requires = ["setuptools>=77"] +build-backend = "setuptools.build_meta" + +[project] +name = "minipdf-python" +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 From 27531755937cdbbc0722e792883ddfb51142910f Mon Sep 17 00:00:00 2001 From: Wei Lin Date: Thu, 3 Sep 2026 13:45:00 +0800 Subject: [PATCH 2/2] chore(python): align PyPI project name --- .github/workflows/pypi-publish.yml | 2 +- minipdf-python/README.md | 2 +- minipdf-python/pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index 225eeeb2..43ebabc9 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest environment: name: pypi - url: https://pypi.org/project/minipdf-python/ + url: https://pypi.org/project/minipdf/ permissions: contents: read id-token: write diff --git a/minipdf-python/README.md b/minipdf-python/README.md index 5d95bfcf..7b939559 100644 --- a/minipdf-python/README.md +++ b/minipdf-python/README.md @@ -10,7 +10,7 @@ currently supported. ## Install ```bash -pip install minipdf-python +pip install minipdf ``` ## Python API diff --git a/minipdf-python/pyproject.toml b/minipdf-python/pyproject.toml index 5f6c5241..00fb0649 100644 --- a/minipdf-python/pyproject.toml +++ b/minipdf-python/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools>=77"] build-backend = "setuptools.build_meta" [project] -name = "minipdf-python" +name = "minipdf" version = "0.1.0" description = "Pure Python Office-to-PDF conversion engine" readme = "README.md"