Skip to content
Closed
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ rows are the enforced release-mode bounds plus one dated observation.

| Measurement | Value | Version | Platform | Build mode | Input | Command | Statistic | Measured on |
|---|---|---|---|---|---|---|---|---|
| Crates.io archive: rdocx | 1,092,256 compressed bytes, 6,498,484 member bytes, 36 members | 0.14.0 | macOS 26.6.2, Apple M5 Max, arm64 | `cargo package --locked --no-verify` | Tracked `rdocx` package inventory | `python3 scripts/readme_doctests.py --record-measurements` | gzip archive bytes, tar member bytes, tar member count | 2026-09-26 |
| Crates.io archive: rdocx | 1,096,411 compressed bytes, 6,516,717 member bytes, 36 members | 0.14.0 | macOS 26.6.2, Apple M5 Max, arm64 | `cargo package --locked --no-verify` | Tracked `rdocx` package inventory | `python3 scripts/readme_doctests.py --record-measurements` | gzip archive bytes, tar member bytes, tar member count | 2026-09-26 |
| Large-document layout throughput | minimum 250 pages/s, observed 31,019.1 pages/s | rdocx 0.14.0 | macOS 26.6.2, Apple M5 Max, arm64 | release, one test thread | 1,000 one-page paragraphs with deterministic fonts | `cargo test -p rdocx --test regression_test --release a_thousand_page_document_paginates_and_renders_within_the_declared_limits -- --ignored --exact --nocapture --test-threads=1` | pages per wall-clock second | 2026-09-19 |
| Large-document layout peak allocation | maximum 64 MiB, observed 29.03 MiB | rdocx 0.14.0 | macOS 26.6.2, Apple M5 Max, arm64 | release, one test thread | 1,000 one-page paragraphs with deterministic fonts | `cargo test -p rdocx --test regression_test --release a_thousand_page_document_paginates_and_renders_within_the_declared_limits -- --ignored --exact --nocapture --test-threads=1` | peak live allocation | 2026-09-19 |
| Large-document PDF throughput | minimum 1,000 pages/s, observed 60,058.0 pages/s | rdocx 0.14.0 | macOS 26.6.2, Apple M5 Max, arm64 | release, one test thread | 1,000 deterministic layout pages | `cargo test -p rdocx --test regression_test --release a_thousand_page_document_paginates_and_renders_within_the_declared_limits -- --ignored --exact --nocapture --test-threads=1` | pages per wall-clock second | 2026-09-19 |
Expand Down
12 changes: 7 additions & 5 deletions crates/rdocx-py/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,16 @@ with open("report.pdf", "wb") as output:

- File and byte-based DOCX input and output.
- Paragraphs, runs, fonts, tables, rows, cells, sections, and styles.
- Rich headers, footers, related stories, and resolved hyperlinks.
- Rich per-section headers and footers, related stories, and resolved
hyperlinks.
- Complete paragraph and run formatting, multilingual and vertical typography,
conditional and floating tables, section semantics, settings, fields, forms,
equations, drawings, comments, and metadata.
- Tracked comparison, main-body comment threads, revision resolution, and TOC
rebuilding.
- Deterministic layout fragments, page geometry, and PDF, PNG, JPEG, and TIFF
output through the native document engine.
- Tracked comparison, main-body comment threads, bookmarks, revision
resolution, and TOC insertion and rebuilding.
- Deterministic layout fragments, page geometry, and PDF, PDF/A, SVG, PNG,
JPEG, and TIFF output through the native document engine, with caller fonts
or a font directory for PDF.
- Python collections with negative indexes, slices, iteration, and explicit
stale-handle errors after structural changes.

Expand Down
26 changes: 26 additions & 0 deletions crates/rdocx-py/python/rdocx/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,27 @@ class LayoutError(RdocxError):
"""Document layout or rendering failed."""


class ReplacementCountError(RdocxError):
"""A counted replacement matched a different number of times than expected.

``index`` is the position of the failing pair in a ``Document.replace_all``
batch, and ``None`` for ``Document.try_replace_text``.
"""

def __init__(
self, message: str, expected: int, found: int, index: "int | None" = None
) -> None:
super().__init__(message, expected, found, index)
self.expected = expected
self.found = found
self.index = index

def __str__(self) -> str:
return str(self.args[0])


from ._rdocx import (
Bookmark,
BoundingBox,
Cell,
CellCollection,
Expand Down Expand Up @@ -56,12 +76,15 @@ class LayoutError(RdocxError):
StoryRunPosition,
StoryRunRange,
Style,
SvgDiagnostic,
SvgRenderResult,
Table,
TableCollection,
TocRebuildReport,
)

__all__ = [
"Bookmark",
"BoundingBox",
"Cm",
"Cell",
Expand Down Expand Up @@ -89,6 +112,7 @@ class LayoutError(RdocxError):
"Pt",
"RGBColor",
"RdocxError",
"ReplacementCountError",
"Revision",
"Row",
"RowCollection",
Expand All @@ -103,6 +127,8 @@ class LayoutError(RdocxError):
"StoryRunPosition",
"StoryRunRange",
"Style",
"SvgDiagnostic",
"SvgRenderResult",
"Table",
"TableCollection",
"TocRebuildReport",
Expand Down
121 changes: 112 additions & 9 deletions crates/rdocx-py/python/rdocx/_rdocx.pyi
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import datetime as _datetime
import os as _os
from collections.abc import Iterator as _Iterator
from collections.abc import Iterator as _Iterator, Sequence as _Sequence
from typing import Literal as _Literal, NoReturn as _Never, final as _final, overload as _overload

from . import shared as _shared
Expand All @@ -9,12 +9,12 @@ from .enum import text as _text

_Path = str | _os.PathLike[str]
__all__ = [
"BoundingBox", "Cell", "CellCollection", "CellParagraphCollection",
"Bookmark", "BoundingBox", "Cell", "CellCollection", "CellParagraphCollection",
"Comment", "ComparisonDiagnostic", "ContentFragment", "Document", "Font", "HeaderFooterVariant",
"Hyperlink", "LayoutBackedFieldUpdateReport", "LayoutFragment", "LayoutPage", "Paragraph", "ParagraphCollection",
"ParagraphFormat", "Revision", "Row", "RowCollection", "Run", "RunCollection", "RunPosition",
"RunRange", "Section", "Story", "StoryItem", "StoryRunPosition", "StoryRunRange", "Style", "Table", "TableCollection",
"TocRebuildReport",
"RunRange", "Section", "Story", "StoryItem", "StoryRunPosition", "StoryRunRange", "Style",
"SvgDiagnostic", "SvgRenderResult", "Table", "TableCollection", "TocRebuildReport",
]


Expand All @@ -36,6 +36,39 @@ class RunRange:
def end(self) -> RunPosition: ...


@_final
class Bookmark:
"""A bookmark, or a marker problem, read by ``Document.bookmarks``.

``range`` counts paragraphs through tables and block content controls.
``direct_range`` uses the direct body index ``add_bookmark`` takes and is
``None`` when a marker sits in a table cell or a block content control.
"""

def __new__(
cls,
*,
id: int | None,
name: str | None,
range: RunRange | None,
direct_range: RunRange | None,
text: str,
issue: str | None,
) -> Bookmark: ...
@property
def id(self) -> int | None: ...
@property
def name(self) -> str | None: ...
@property
def range(self) -> RunRange | None: ...
@property
def direct_range(self) -> RunRange | None: ...
@property
def text(self) -> str: ...
@property
def issue(self) -> str | None: ...


@_final
class Comment:
def __new__(
Expand Down Expand Up @@ -74,6 +107,26 @@ class ComparisonDiagnostic:
def message(self) -> str: ...


@_final
class SvgDiagnostic:
def __new__(cls, *, path: str, message: str) -> SvgDiagnostic: ...
@property
def path(self) -> str: ...
@property
def message(self) -> str: ...


@_final
class SvgRenderResult:
def __new__(
cls, *, svg: str, diagnostics: _Sequence[SvgDiagnostic]
) -> SvgRenderResult: ...
@property
def svg(self) -> str: ...
@property
def diagnostics(self) -> tuple[SvgDiagnostic, ...]: ...


@_final
class BoundingBox:
def __new__(
Expand Down Expand Up @@ -428,10 +481,28 @@ class Document:
self, story: Story, relationship_id: str, data: bytes
) -> None: ...
def split_run(
self, body_index: int, run_index: int, character_offset: int
self, body_index: int | Paragraph, run_index: int, character_offset: int
) -> int: ...
def to_pdf(self) -> bytes: ...
def to_pdf(
self,
*,
fonts: _Sequence[tuple[str, bytes]] | None = None,
font_dir: _Path | None = None,
) -> bytes:
"""Render the document to PDF bytes.

``fonts`` gives ``(family, font bytes)`` pairs and ``font_dir`` a
directory whose ``.ttf``, ``.otf`` and ``.ttc`` files are named after
their family. When either is given, layout uses only those fonts, as
``rdocx convert --font-dir`` does, so a family they do not provide
raises ``LayoutError``. A missing ``font_dir`` raises
``FileNotFoundError``.
"""
def to_pdfa_deterministic(
self, profile: _Literal["pdfa-2b", "pdfa-3b"] = "pdfa-2b"
) -> bytes: ...
def render_page_to_png(self, page_index: int, dpi: float = 150.0) -> bytes | None: ...
def render_page_to_svg(self, page_index: int) -> SvgRenderResult | None: ...
def render_all_pages(self, dpi: float = 150.0) -> list[bytes]: ...
def render_pages(
self,
Expand All @@ -457,6 +528,25 @@ class Document:
def story_items(self) -> tuple[StoryItem, ...]: ...
@property
def header_footer_variants(self) -> tuple[HeaderFooterVariant, ...]: ...
def create_section_story(
self,
section_index: int,
kind: _Literal["header", "footer"],
variant: _Literal["default", "first", "even"],
) -> Story: ...
def link_section_story(
self,
section_index: int,
kind: _Literal["header", "footer"],
variant: _Literal["default", "first", "even"],
story: Story,
) -> Story: ...
def unlink_section_story(
self,
section_index: int,
kind: _Literal["header", "footer"],
variant: _Literal["default", "first", "even"],
) -> Story: ...
@property
def hyperlinks(self) -> tuple[Hyperlink, ...]: ...
@property
Expand All @@ -477,10 +567,18 @@ class Document:
) -> int: ...
def resolve_comment(self, id: int, *, resolved: bool = True) -> bool: ...
def remove_comment(self, id: int) -> bool: ...
@property
def bookmarks(self) -> tuple[Bookmark, ...]: ...
def add_bookmark(self, name: str, range: RunRange) -> int: ...
def layout(self) -> tuple[LayoutFragment, ...]: ...
def layout_page(self, page_index: int) -> LayoutPage | None: ...
def rebuild_toc(self) -> TocRebuildReport: ...
def try_replace_text(self, placeholder: str, replacement: str) -> int: ...
def try_replace_text(
self, placeholder: str, replacement: str, *, expect: int | None = None
) -> int: ...
def replace_all(
self, pairs: _Sequence[tuple[str, str] | tuple[str, str, int | None]]
) -> tuple[int, ...]: ...
def replace_all_regex(self, patterns: list[tuple[str, str]]) -> int: ...
@property
def revisions(self) -> tuple[Revision, ...]: ...
Expand Down Expand Up @@ -518,6 +616,7 @@ class Document:
) -> StoryItem: ...
def update_page_fields(self) -> int: ...
def update_layout_backed_fields(self) -> LayoutBackedFieldUpdateReport: ...
def insert_toc(self, index: int, max_level: int = 3) -> None: ...
@property
def paragraphs(self) -> ParagraphCollection: ...
@property
Expand All @@ -531,8 +630,10 @@ class Document:
def find_content_index(self, content: str) -> int: ...
def find_content_indices(self, text: str) -> tuple[int, ...]: ...
def insert_paragraph(self, index: int, text: str) -> Paragraph: ...
def pop_content(self, index: int) -> ContentFragment: ...
def insert_content(self, destination: int, fragment: ContentFragment) -> None: ...
def pop_content(self, index: int | StoryItem) -> ContentFragment: ...
def insert_content(
self, destination: int | StoryItem | Story, fragment: ContentFragment
) -> None: ...
def clone_content(self, source: Paragraph | Table, destination: int) -> None: ...
def move_content(self, source: Paragraph | Table, destination: int) -> None: ...

Expand Down Expand Up @@ -586,6 +687,8 @@ class Run:
def style_id(self) -> str | None: ...
@style_id.setter
def style_id(self, value: str | None) -> None: ...
def add_tab(self) -> None: ...
def add_field(self, instruction: str, cached_result: str = "") -> None: ...


@_final
Expand Down
Loading
Loading