diff --git a/README.md b/README.md index 479b462ef..973aa21af 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/crates/rdocx-py/README.md b/crates/rdocx-py/README.md index 35e317bde..9fe661ca3 100644 --- a/crates/rdocx-py/README.md +++ b/crates/rdocx-py/README.md @@ -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. diff --git a/crates/rdocx-py/python/rdocx/__init__.py b/crates/rdocx-py/python/rdocx/__init__.py index a04d7f32e..22864bb08 100644 --- a/crates/rdocx-py/python/rdocx/__init__.py +++ b/crates/rdocx-py/python/rdocx/__init__.py @@ -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, @@ -56,12 +76,15 @@ class LayoutError(RdocxError): StoryRunPosition, StoryRunRange, Style, + SvgDiagnostic, + SvgRenderResult, Table, TableCollection, TocRebuildReport, ) __all__ = [ + "Bookmark", "BoundingBox", "Cm", "Cell", @@ -89,6 +112,7 @@ class LayoutError(RdocxError): "Pt", "RGBColor", "RdocxError", + "ReplacementCountError", "Revision", "Row", "RowCollection", @@ -103,6 +127,8 @@ class LayoutError(RdocxError): "StoryRunPosition", "StoryRunRange", "Style", + "SvgDiagnostic", + "SvgRenderResult", "Table", "TableCollection", "TocRebuildReport", diff --git a/crates/rdocx-py/python/rdocx/_rdocx.pyi b/crates/rdocx-py/python/rdocx/_rdocx.pyi index f5e4ef18c..4dcf99263 100644 --- a/crates/rdocx-py/python/rdocx/_rdocx.pyi +++ b/crates/rdocx-py/python/rdocx/_rdocx.pyi @@ -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 @@ -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", ] @@ -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__( @@ -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__( @@ -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, @@ -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 @@ -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, ...]: ... @@ -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 @@ -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: ... @@ -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 diff --git a/crates/rdocx-py/src/document.rs b/crates/rdocx-py/src/document.rs index b81167c6f..0b2b2340a 100644 --- a/crates/rdocx-py/src/document.rs +++ b/crates/rdocx-py/src/document.rs @@ -2,12 +2,14 @@ use std::collections::BTreeMap; use std::path::PathBuf; use oxml_py_support::{PathSeg, RevisionCounter, StaleElementError}; -use pyo3::exceptions::{PyIndexError, PyTypeError, PyValueError}; +use pyo3::exceptions::{ + PyFileNotFoundError, PyIndexError, PyNotADirectoryError, PyTypeError, PyValueError, +}; use pyo3::prelude::*; use pyo3::types::{PyAny, PyBytes, PyList, PyTuple}; use smallvec::smallvec; -use crate::paragraph::{PyParagraph, PyParagraphCollection}; +use crate::paragraph::{ParagraphLocation, PyParagraph, PyParagraphCollection}; use crate::rdocx_to_pyerr; use crate::table::{PyTable, PyTableCollection}; @@ -77,6 +79,83 @@ impl From for rdocx::RunRange { } } +impl From for PyRunRange { + fn from(value: rdocx::RunRange) -> Self { + let position = |position: rdocx::RunPosition| PyRunPosition { + body_index: position.body_index, + run_index: position.run_index, + }; + Self { + start: position(value.start), + end: position(value.end), + } + } +} + +#[pyclass(name = "Bookmark", frozen, eq, skip_from_py_object)] +#[derive(Clone, PartialEq, Eq)] +pub struct PyBookmark { + id: Option, + name: Option, + range: Option, + direct_range: Option, + text: String, + issue: Option, +} + +#[pymethods] +impl PyBookmark { + #[new] + #[pyo3(signature = (*, id, name, range, direct_range, text, issue))] + fn new( + id: Option, + name: Option, + range: Option>, + direct_range: Option>, + text: String, + issue: Option, + ) -> Self { + Self { + id, + name, + range: range.map(|range| *range), + direct_range: direct_range.map(|range| *range), + text, + issue, + } + } + + #[getter] + fn id(&self) -> Option { + self.id + } + + #[getter] + fn name(&self) -> Option<&str> { + self.name.as_deref() + } + + #[getter] + fn range(&self) -> Option { + self.range + } + + #[getter] + fn direct_range(&self) -> Option { + self.direct_range + } + + #[getter] + fn text(&self) -> &str { + &self.text + } + + #[getter] + fn issue(&self) -> Option<&str> { + self.issue.as_deref() + } +} + #[pyclass(name = "Comment", frozen, get_all, eq, skip_from_py_object)] #[derive(Clone, PartialEq, Eq)] pub struct PyComment { @@ -136,6 +215,54 @@ impl PyComparisonDiagnostic { } } +#[pyclass(name = "SvgDiagnostic", frozen, get_all, eq, skip_from_py_object)] +#[derive(Clone, PartialEq, Eq)] +pub struct PySvgDiagnostic { + pub path: String, + pub message: String, +} + +#[pymethods] +impl PySvgDiagnostic { + #[new] + #[pyo3(signature = (*, path, message))] + fn new(path: String, message: String) -> Self { + Self { path, message } + } +} + +#[pyclass(name = "SvgRenderResult", frozen, eq, skip_from_py_object)] +#[derive(Clone, PartialEq, Eq)] +pub struct PySvgRenderResult { + svg: String, + diagnostics: Vec, +} + +#[pymethods] +impl PySvgRenderResult { + #[new] + #[pyo3(signature = (*, svg, diagnostics))] + fn new(svg: String, diagnostics: Vec>) -> Self { + Self { + svg, + diagnostics: diagnostics + .iter() + .map(|diagnostic| (**diagnostic).clone()) + .collect(), + } + } + + #[getter] + fn svg(&self) -> &str { + &self.svg + } + + #[getter] + fn diagnostics<'py>(&self, py: Python<'py>) -> PyResult> { + PyTuple::new(py, self.diagnostics.iter().cloned()) + } +} + #[pyclass(name = "BoundingBox", frozen, get_all, eq, skip_from_py_object)] #[derive(Clone, Copy, PartialEq)] pub struct PyBoundingBox { @@ -979,6 +1106,32 @@ impl PyDocument { ))) } + /// Split a run of the direct body paragraph at `body_index`. The revision + /// advances only when a continuation run is created. + fn split_body_run( + &mut self, + py: Python<'_>, + body_index: usize, + run_index: usize, + character_offset: usize, + ) -> PyResult { + let paragraph_index = self.inner.paragraph_index_of_content(body_index); + let run_count = |document: &rdocx::Document| { + paragraph_index + .and_then(|index| document.paragraph(index)) + .map(|paragraph| paragraph.run_count()) + }; + let before = run_count(&self.inner); + let boundary = self + .inner + .split_run(body_index, run_index, character_offset) + .map_err(|error| rdocx_to_pyerr(py, error))?; + if run_count(&self.inner) != before { + self.revisions.bump(); + } + Ok(boundary) + } + /// Run a native mutation that reports how many things it changed. /// /// The GIL is released while it runs, and live handles are staled only @@ -995,6 +1148,40 @@ impl PyDocument { } Ok(count) } + + /// Run an ordered replacement batch that publishes only when every + /// expected count holds, and stale live handles only when it replaced + /// something. + fn expected_replacements( + &mut self, + py: Python<'_>, + pairs: &[(&str, &str, Option)], + batch: bool, + ) -> PyResult> { + let counts = py + .detach(|| self.inner.try_replace_all_expected(pairs)) + .map_err(|error| rdocx_to_pyerr(py, error))? + .map_err(|mismatch| crate::replacement_count_to_pyerr(py, &mismatch, batch))?; + if counts.iter().any(|count| *count > 0) { + self.revisions.bump(); + } + Ok(counts) + } + + /// Check the names and the section of one header or footer variant. + fn section_story_target( + &self, + section_index: usize, + kind: &str, + variant: &str, + ) -> PyResult<(rdocx::HeaderFooterKind, rdocx::HdrFtrType)> { + let kind = header_footer_kind_from_name(kind)?; + let variant = header_footer_variant_from_name(variant)?; + if section_index >= self.inner.section_count() { + return Err(PyIndexError::new_err("section index out of range")); + } + Ok((kind, variant)) + } } fn story_snapshot(story: &rdocx::StoryId) -> PyStory { @@ -1042,6 +1229,39 @@ fn story_item_kind_from_name(name: &str) -> PyResult { } } +fn header_footer_kind_from_name(name: &str) -> PyResult { + match name { + "header" => Ok(rdocx::HeaderFooterKind::Header), + "footer" => Ok(rdocx::HeaderFooterKind::Footer), + _ => Err(PyValueError::new_err(format!( + "kind must be header or footer, not {name:?}" + ))), + } +} + +fn header_footer_variant_from_name(name: &str) -> PyResult { + // `HdrFtrType::from_str` reads every unknown name as the default variant. + match name { + "default" => Ok(rdocx::HdrFtrType::Default), + "first" => Ok(rdocx::HdrFtrType::First), + "even" => Ok(rdocx::HdrFtrType::Even), + _ => Err(PyValueError::new_err(format!( + "variant must be default, first or even, not {name:?}" + ))), + } +} + +/// Extract a direct body index, naming the accepted forms on a type error. +fn body_index_argument(value: &Bound<'_, PyAny>, message: &'static str) -> PyResult { + value.extract::().map_err(|error| { + if error.is_instance_of::(value.py()) { + PyTypeError::new_err(message) + } else { + error + } + }) +} + fn section_snapshot(section: rdocx::SectionRef<'_>) -> PySection { let (page_width, page_height) = section.page_size().map_or((None, None), |(width, height)| { (Some(width.to_emu()), Some(height.to_emu())) @@ -1193,32 +1413,129 @@ impl PyDocument { } fn split_run( - &mut self, + slf: Py, py: Python<'_>, - body_index: usize, + body_index: &Bound<'_, PyAny>, run_index: usize, character_offset: usize, ) -> PyResult { - let before = self - .inner - .paragraph(body_index) - .map(|paragraph| paragraph.run_count()); - let boundary = self + let Ok(paragraph) = body_index.cast::() else { + // An integer is the direct body index, as in `RunPosition`. + let body_index = body_index.extract::().map_err(|error| { + if error.is_instance_of::(py) { + PyTypeError::new_err("body_index must be an int or a Paragraph handle") + } else { + error + } + })?; + return slf + .borrow_mut(py) + .split_body_run(py, body_index, run_index, character_offset); + }; + let paragraph = paragraph.borrow(); + if !paragraph.belongs_to(py, &slf) { + return Err(PyValueError::new_err( + "paragraph handle belongs to a different document", + )); + } + // A cell handle counts paragraphs inside cell content controls, which + // `Cell::paragraph_mut` does not, so it could name another paragraph. + let ParagraphLocation::Body(paragraph_index) = paragraph.validate(py)? else { + return Err(PyValueError::new_err( + "split_run does not accept a table cell paragraph handle", + )); + }; + let mut document = slf.borrow_mut(py); + if let Some(body_index) = document.inner.content_index_of_paragraph(paragraph_index) { + return document.split_body_run(py, body_index, run_index, character_offset); + } + // A paragraph inside a block content control has no direct body index. + let run_count = |document: &rdocx::Document| { + document + .paragraph(paragraph_index) + .map(|paragraph| paragraph.run_count()) + }; + let before = run_count(&document.inner); + let boundary = document .inner - .split_run(body_index, run_index, character_offset) + .paragraph_mut(paragraph_index) + .ok_or_else(|| PyIndexError::new_err("paragraph index out of range"))? + .split_run(run_index, character_offset) .map_err(|error| rdocx_to_pyerr(py, error))?; - let after = self - .inner - .paragraph(body_index) - .map(|paragraph| paragraph.run_count()); - if before != after { - self.revisions.bump(); + if run_count(&document.inner) != before { + document.revisions.bump(); } Ok(boundary) } - fn to_pdf<'py>(&self, py: Python<'py>) -> PyResult> { - py.detach(|| self.inner.to_pdf()) + #[pyo3(signature = (*, fonts = None, font_dir = None))] + fn to_pdf<'py>( + &self, + py: Python<'py>, + fonts: Option)>>, + font_dir: Option, + ) -> PyResult> { + if fonts.is_none() && font_dir.is_none() { + return py + .detach(|| self.inner.to_pdf()) + .map(|bytes| PyBytes::new(py, &bytes)) + .map_err(|error| rdocx_to_pyerr(py, error)); + } + let mut font_files = fonts + .unwrap_or_default() + .into_iter() + .map(|(family, data)| (family, data.as_bytes().to_vec())) + .collect::>(); + // The native loader reads a missing directory as one without fonts. + if let Some(font_dir) = &font_dir + && !font_dir.is_dir() + { + return Err(if font_dir.exists() { + PyNotADirectoryError::new_err(format!( + "font directory {} is not a directory", + font_dir.display() + )) + } else { + PyFileNotFoundError::new_err(format!( + "font directory {} does not exist", + font_dir.display() + )) + }); + } + py.detach(|| { + if let Some(font_dir) = &font_dir { + font_files.extend( + rdocx::Document::load_fonts_from_dir(font_dir) + .into_iter() + .map(|font| (font.family, font.data)), + ); + } + let font_files = font_files + .iter() + .map(|(family, data)| (family.as_str(), data.as_slice())) + .collect::>(); + self.inner.to_pdf_with_fonts(&font_files) + }) + .map(|bytes| PyBytes::new(py, &bytes)) + .map_err(|error| rdocx_to_pyerr(py, error)) + } + + #[pyo3(signature = (profile = "pdfa-2b"))] + fn to_pdfa_deterministic<'py>( + &self, + py: Python<'py>, + profile: &str, + ) -> PyResult> { + let profile = match profile { + "pdfa-2b" => rdocx::PdfConformance::PdfA2b, + "pdfa-3b" => rdocx::PdfConformance::PdfA3b, + _ => { + return Err(PyValueError::new_err(format!( + "profile must be pdfa-2b or pdfa-3b, not {profile:?}" + ))); + } + }; + py.detach(|| self.inner.to_pdfa_deterministic(profile)) .map(|bytes| PyBytes::new(py, &bytes)) .map_err(|error| rdocx_to_pyerr(py, error)) } @@ -1235,6 +1552,27 @@ impl PyDocument { .map_err(|error| rdocx_to_pyerr(py, error)) } + fn render_page_to_svg( + &self, + py: Python<'_>, + page_index: usize, + ) -> PyResult> { + let rendered = py + .detach(|| self.inner.render_page_to_svg(page_index)) + .map_err(|error| rdocx_to_pyerr(py, error))?; + Ok(rendered.map(|result| PySvgRenderResult { + svg: result.svg, + diagnostics: result + .diagnostics + .into_iter() + .map(|diagnostic| PySvgDiagnostic { + path: diagnostic.path, + message: diagnostic.message, + }) + .collect(), + })) + } + #[pyo3(signature = (dpi = 150.0))] fn render_all_pages<'py>(&self, py: Python<'py>, dpi: f64) -> PyResult> { let pages = py @@ -1394,6 +1732,64 @@ impl PyDocument { PyTuple::new(py, snapshots) } + // The section story operations publish a reopened package, so each one + // advances the revision once, even when the variant already had a story. + fn create_section_story( + &mut self, + py: Python<'_>, + section_index: usize, + kind: &str, + variant: &str, + ) -> PyResult { + let (kind, variant) = self.section_story_target(section_index, kind, variant)?; + let story = py + .detach(|| { + self.inner + .create_section_story(section_index, kind, variant) + }) + .map_err(|error| rdocx_to_pyerr(py, error))?; + self.revisions.bump(); + Ok(story_snapshot(&story)) + } + + fn link_section_story( + &mut self, + py: Python<'_>, + section_index: usize, + kind: &str, + variant: &str, + story: PyRef<'_, PyStory>, + ) -> PyResult { + let (kind, variant) = self.section_story_target(section_index, kind, variant)?; + let story = self.native_story(py, &story)?; + let linked = py + .detach(|| { + self.inner + .link_section_story(section_index, kind, variant, &story) + }) + .map_err(|error| rdocx_to_pyerr(py, error))?; + self.revisions.bump(); + Ok(story_snapshot(&linked)) + } + + fn unlink_section_story( + &mut self, + py: Python<'_>, + section_index: usize, + kind: &str, + variant: &str, + ) -> PyResult { + let (kind, variant) = self.section_story_target(section_index, kind, variant)?; + let story = py + .detach(|| { + self.inner + .unlink_section_story(section_index, kind, variant) + }) + .map_err(|error| rdocx_to_pyerr(py, error))?; + self.revisions.bump(); + Ok(story_snapshot(&story)) + } + #[getter] fn hyperlinks<'py>(&self, py: Python<'py>) -> PyResult> { let links = self @@ -1565,6 +1961,37 @@ impl PyDocument { Ok(removed) } + #[getter] + fn bookmarks<'py>(&self, py: Python<'py>) -> PyResult> { + PyTuple::new( + py, + self.inner + .bookmarks() + .into_iter() + .map(|bookmark| PyBookmark { + id: bookmark.id(), + name: bookmark.name().map(str::to_owned), + range: bookmark.range().map(PyRunRange::from), + direct_range: bookmark.direct_range().map(PyRunRange::from), + text: bookmark.text().to_owned(), + issue: bookmark.issue().map(str::to_owned), + }), + ) + } + + fn add_bookmark( + &mut self, + py: Python<'_>, + name: &str, + range: PyRef<'_, PyRunRange>, + ) -> PyResult { + // Bookmark markers sit between runs, so no content moves and live + // handles stay valid. + self.inner + .add_bookmark(name, (*range).into()) + .map_err(|error| rdocx_to_pyerr(py, error)) + } + fn layout<'py>(&self, py: Python<'py>) -> PyResult> { let fragments = py .detach(|| { @@ -1692,15 +2119,53 @@ impl PyDocument { self.counted_mutation(py, |document| document.reject_revision_id(id)) } + #[pyo3(signature = (placeholder, replacement, *, expect = None))] fn try_replace_text( &mut self, py: Python<'_>, placeholder: &str, replacement: &str, + expect: Option, ) -> PyResult { - self.counted_mutation(py, |document| { - document.try_replace_text(placeholder, replacement) - }) + if expect.is_none() { + return self.counted_mutation(py, |document| { + document.try_replace_text(placeholder, replacement) + }); + } + let counts = + self.expected_replacements(py, &[(placeholder, replacement, expect)], false)?; + Ok(counts[0]) + } + + fn replace_all<'py>( + &mut self, + py: Python<'py>, + pairs: &Bound<'py, PyAny>, + ) -> PyResult> { + let mut owned = Vec::new(); + for (index, pair) in pairs.try_iter()?.enumerate() { + let pair = pair?; + let parsed = match pair.extract::<(String, String)>() { + Ok((placeholder, replacement)) => (placeholder, replacement, None), + Err(_) => pair + .extract::<(String, String, Option)>() + .map_err(|_| { + PyTypeError::new_err(format!( + "pair {index} must be an (old, new) or (old, new, expected) tuple, \ + where expected is a nonnegative int or None" + )) + })?, + }; + owned.push(parsed); + } + let pairs = owned + .iter() + .map(|(placeholder, replacement, expected)| { + (placeholder.as_str(), replacement.as_str(), *expected) + }) + .collect::>(); + let counts = self.expected_replacements(py, &pairs, true)?; + PyTuple::new(py, counts) } fn replace_all_regex( @@ -1787,6 +2252,32 @@ impl PyDocument { }) } + #[pyo3(signature = (index, max_level = 3))] + fn insert_toc(&mut self, py: Python<'_>, index: usize, max_level: u32) -> PyResult<()> { + let before = self.inner.content_count(); + if index > before { + return Err(PyIndexError::new_err("content index out of range")); + } + if !(1..=9).contains(&max_level) { + return Err(PyValueError::new_err("max_level must be between 1 and 9")); + } + self.inner.insert_toc(index, max_level); + // The native call always inserts its title paragraph, unless it + // cannot allocate unique heading bookmarks, and then changes nothing. + if self.inner.content_count() == before { + return Err(rdocx_to_pyerr( + py, + rdocx::Error::Other( + "table of contents was not inserted because its heading bookmarks \ + could not be allocated" + .to_owned(), + ), + )); + } + self.revisions.bump(); + Ok(()) + } + #[getter] fn paragraphs(slf: Py, py: Python<'_>) -> PyResult> { Py::new(py, PyParagraphCollection::new(slf)) @@ -1881,11 +2372,21 @@ impl PyDocument { Py::new(py, PyParagraph::new(slf, path)) } - fn pop_content(slf: Py, py: Python<'_>, index: usize) -> PyResult { - let location = slf.borrow(py).body_location(py, index)?; - if index == slf.borrow(py).inner.content_count() { - return Err(PyIndexError::new_err("content index out of range")); - } + fn pop_content( + slf: Py, + py: Python<'_>, + index: &Bound<'_, PyAny>, + ) -> PyResult { + let location = if let Ok(item) = index.cast::() { + slf.borrow(py).native_location(py, &item.borrow())? + } else { + let index = body_index_argument(index, "index must be an int or a StoryItem")?; + let location = slf.borrow(py).body_location(py, index)?; + if index == slf.borrow(py).inner.content_count() { + return Err(PyIndexError::new_err("content index out of range")); + } + location + }; let fragment = slf .borrow_mut(py) .inner @@ -1898,10 +2399,21 @@ impl PyDocument { fn insert_content( slf: Py, py: Python<'_>, - destination: usize, + destination: &Bound<'_, PyAny>, fragment: PyRef<'_, PyContentFragment>, ) -> PyResult<()> { - let location = slf.borrow(py).body_location(py, destination)?; + // A story item names the boundary before it, and a story its end. + let location = if let Ok(item) = destination.cast::() { + slf.borrow(py).native_location(py, &item.borrow())? + } else if let Ok(story) = destination.cast::() { + rdocx::ContentLocation::end(slf.borrow(py).native_story(py, &story.borrow())?) + } else { + let destination = body_index_argument( + destination, + "destination must be an int, a StoryItem or a Story", + )?; + slf.borrow(py).body_location(py, destination)? + }; let fragment = fragment.inner.clone(); slf.borrow_mut(py) .inner diff --git a/crates/rdocx-py/src/lib.rs b/crates/rdocx-py/src/lib.rs index 9cf232797..02904c00d 100644 --- a/crates/rdocx-py/src/lib.rs +++ b/crates/rdocx-py/src/lib.rs @@ -13,10 +13,11 @@ use pyo3::types::{PyAny, PyType}; use oxml_py_support::StaleElementError; use document::{ - PyBoundingBox, PyComment, PyComparisonDiagnostic, PyContentFragment, PyDocument, + PyBookmark, PyBoundingBox, PyComment, PyComparisonDiagnostic, PyContentFragment, PyDocument, PyHeaderFooterVariant, PyHyperlink, PyLayoutBackedFieldUpdateReport, PyLayoutFragment, PyLayoutPage, PyRevision, PyRunPosition, PyRunRange, PySection, PyStory, PyStoryItem, - PyStoryRunPosition, PyStoryRunRange, PyStyle, PyTocRebuildReport, + PyStoryRunPosition, PyStoryRunRange, PyStyle, PySvgDiagnostic, PySvgRenderResult, + PyTocRebuildReport, }; use formatting::{PyFont, PyParagraphFormat}; use paragraph::{PyParagraph, PyParagraphCollection}; @@ -52,18 +53,44 @@ pub(crate) fn enum_object(py: Python<'_>, name: &str, value: i32) -> PyResult, class_name: &str, message: String) -> PyErr { - let exception_type = py - .import("rdocx") +fn public_exception_type<'py>(py: Python<'py>, class_name: &str) -> PyResult> { + py.import("rdocx") .and_then(|module| module.getattr(class_name)) - .and_then(|class| class.cast_into::().map_err(Into::into)); + .and_then(|class| class.cast_into::().map_err(Into::into)) +} - match exception_type { +fn public_error(py: Python<'_>, class_name: &str, message: String) -> PyErr { + match public_exception_type(py, class_name) { Ok(class) => PyErr::from_type(class, (message,)), Err(_) => PyRuntimeError::new_err(message), } } +/// A counted replacement that matched a different number of times than the +/// caller expected. A single replacement uses the wording of +/// `rdocx replace --expect`, and a batch names the failing pair. +pub(crate) fn replacement_count_to_pyerr( + py: Python<'_>, + mismatch: &rdocx::ReplacementCountMismatch, + batch: bool, +) -> PyErr { + let (message, index) = if batch { + (mismatch.to_string(), Some(mismatch.index)) + } else { + ( + format!( + "expected {} replacement(s) of \"{}\", found {}", + mismatch.expected, mismatch.placeholder, mismatch.found + ), + None, + ) + }; + match public_exception_type(py, "ReplacementCountError") { + Ok(class) => PyErr::from_type(class, (message, mismatch.expected, mismatch.found, index)), + Err(_) => PyRuntimeError::new_err(message), + } +} + pub(crate) fn stale_to_pyerr(py: Python<'_>, error: StaleElementError) -> PyErr { public_error(py, "StaleElementError", error.to_string()) } @@ -92,10 +119,13 @@ fn _rdocx(module: &Bound<'_, PyModule>) -> PyResult<()> { module.add_class::()?; module.add_class::()?; module.add_class::()?; + module.add_class::()?; module.add_class::()?; module.add_class::()?; module.add_class::()?; module.add_class::()?; + module.add_class::()?; + module.add_class::()?; module.add_class::()?; module.add_class::()?; module.add_class::()?; diff --git a/crates/rdocx-py/src/run.rs b/crates/rdocx-py/src/run.rs index 303c7b444..35d8d5a4d 100644 --- a/crates/rdocx-py/src/run.rs +++ b/crates/rdocx-py/src/run.rs @@ -48,6 +48,37 @@ impl PyRun { .map_err(|error| stale_to_pyerr(py, error))?; path_indices(&self.path) } + + /// Apply one edit to this run where it lives, in the body or in a cell. + fn edit(&self, py: Python<'_>, edit: impl FnOnce(&mut rdocx::run::Run<'_>)) -> PyResult<()> { + let (location, _) = self.validate(py)?; + let mut document = self.document.borrow_mut(py); + match location { + ParagraphLocation::Body(index) => document + .inner + .paragraph_mut(index) + .ok_or_else(|| PyIndexError::new_err("paragraph index out of range"))? + .edit_run(&self.run_path, edit), + ParagraphLocation::Cell { + table, + row, + cell, + paragraph, + } => { + let mut table = document + .inner + .table_mut(table) + .ok_or_else(|| PyIndexError::new_err("table index out of range"))?; + let mut cell = table + .cell(row, cell) + .ok_or_else(|| PyIndexError::new_err("cell index out of range"))?; + cell.paragraph_mut(paragraph) + .ok_or_else(|| PyIndexError::new_err("paragraph index out of range"))? + .edit_run(&self.run_path, edit) + } + } + .map_err(|error| crate::rdocx_to_pyerr(py, error)) + } } #[pymethods] @@ -77,39 +108,23 @@ impl PyRun { #[setter] fn set_text(&self, py: Python<'_>, text: &str) -> PyResult<()> { - let (location, _) = self.validate(py)?; - let mut document = self.document.borrow_mut(py); - match location { - ParagraphLocation::Body(index) => { - let mut paragraph = document - .inner - .paragraph_mut(index) - .ok_or_else(|| PyIndexError::new_err("paragraph index out of range"))?; - paragraph - .edit_run(&self.run_path, |run| run.set_text(text)) - .map_err(|error| crate::rdocx_to_pyerr(py, error))?; - } - ParagraphLocation::Cell { - table, - row, - cell, - paragraph, - } => { - let mut table = document - .inner - .table_mut(table) - .ok_or_else(|| PyIndexError::new_err("table index out of range"))?; - let mut cell = table - .cell(row, cell) - .ok_or_else(|| PyIndexError::new_err("cell index out of range"))?; - let mut paragraph = cell - .paragraph_mut(paragraph) - .ok_or_else(|| PyIndexError::new_err("paragraph index out of range"))?; - paragraph - .edit_run(&self.run_path, |run| run.set_text(text)) - .map_err(|error| crate::rdocx_to_pyerr(py, error))?; - } - } + self.edit(py, |run| run.set_text(text)) + } + + // A tab is appended inside this run, so no run index moves and live + // handles stay valid. + fn add_tab(&self, py: Python<'_>) -> PyResult<()> { + self.edit(py, |run| run.add_tab()) + } + + #[pyo3(signature = (instruction, cached_result = ""))] + fn add_field(&self, py: Python<'_>, instruction: &str, cached_result: &str) -> PyResult<()> { + let mut added = Ok(()); + self.edit(py, |run| added = run.add_field(instruction, cached_result))?; + added.map_err(|error| crate::rdocx_to_pyerr(py, error))?; + // The field becomes a story item of its own, which moves the index + // path of every later story item. + self.document.borrow_mut(py).revisions.bump(); Ok(()) } diff --git a/crates/rdocx-py/tests/test_core.py b/crates/rdocx-py/tests/test_core.py index 0f8ce9271..49976c4fa 100644 --- a/crates/rdocx-py/tests/test_core.py +++ b/crates/rdocx-py/tests/test_core.py @@ -125,6 +125,66 @@ def test_counted_replacement_spans_runs_and_a_bad_regex_changes_nothing(): assert document.paragraphs[0].text == "Dear Ada, bye" +def test_counted_replacement_checks_expected_counts_before_publishing(): + import pickle + + import rdocx + + document = rdocx.Document() + document.add_paragraph("{{a}} {{b}} {{b}} {{c}}") + document.set_header("{{c}} header") + held = document.paragraphs[0] + before = document.to_bytes() + + with pytest.raises(rdocx.ReplacementCountError) as raised: + document.replace_all([("{{a}}", "A", 1), ("{{b}}", "B", 1), ("{{c}}", "C", 2)]) + error = raised.value + assert isinstance(error, rdocx.RdocxError) + assert (error.index, error.expected, error.found) == (1, 1, 2) + assert str(error) == 'pair 1: expected 1 replacement(s) of "{{b}}", found 2' + # Worker pools pickle exceptions to return them, so the error must survive. + copy = pickle.loads(pickle.dumps(error)) + assert (type(copy), str(copy), copy.index, copy.expected, copy.found) == ( + rdocx.ReplacementCountError, + str(error), + 1, + 1, + 2, + ) + assert document.to_bytes() == before + assert held.text == "{{a}} {{b}} {{b}} {{c}}" + + with pytest.raises( + rdocx.ReplacementCountError, + match=r'^expected 3 replacement\(s\) of "\{\{b\}\}", found 2$', + ) as raised: + document.try_replace_text("{{b}}", "B", expect=3) + assert (raised.value.index, raised.value.expected, raised.value.found) == (None, 3, 2) + assert document.to_bytes() == before + assert document.try_replace_text("{{missing}}", "x", expect=0) == 0 + assert document.replace_all([("{{missing}}", "x")]) == (0,) + assert document.replace_all([]) == () + assert held.text == "{{a}} {{b}} {{b}} {{c}}" + + # Pairs run in order, so the second one also replaces what the first wrote. + assert document.replace_all( + [("{{a}}", "{{b}}", 1), ("{{b}}", "B", 3), ("{{c}}", "C", None)] + ) == (1, 3, 2) + with pytest.raises(rdocx.StaleElementError): + held.text + assert document.paragraphs[0].text == "B B B C" + assert _story_paragraph_texts(document, "header") == ["C header"] + assert document.try_replace_text("B", "b", expect=3) == 3 + + for pairs, message in ( + ([("x",)], "pair 0 must be"), + ([("x", "y"), ("x", "y", -1)], "pair 1 must be"), + ("xy", "pair 0 must be"), + ): + with pytest.raises(TypeError, match=message): + document.replace_all(pairs) + + def test_update_fields_takes_a_keyword_context_and_counts_updates(): import datetime @@ -1000,6 +1060,143 @@ def test_update_layout_backed_fields_returns_owned_report(): assert b"stale target" not in xml +def test_pageref_to_a_bookmarked_run_range_is_filled_from_the_layout(): + import rdocx + + document = rdocx.Document() + document.add_paragraph("See page ") + document.add_table(2, 2) + target = document.add_paragraph("The target phrase ends here.") + target.paragraph_format.page_break_before = True + index = document.find_content_index("The target phrase") + assert index == 2 + assert document.split_run(index, 0, len("The target phrase")) == 1 + range_ = rdocx.RunRange( + start=rdocx.RunPosition(body_index=index, run_index=0), + end=rdocx.RunPosition(body_index=index, run_index=1), + ) + held = document.paragraphs[0].runs[0] + + bookmark_id = document.add_bookmark("target", range_) + assert document.bookmarks == ( + rdocx.Bookmark( + id=bookmark_id, + name="target", + # The recursive range counts the four cell paragraphs of the table. + range=rdocx.RunRange( + start=rdocx.RunPosition(body_index=5, run_index=0), + end=rdocx.RunPosition(body_index=5, run_index=1), + ), + direct_range=range_, + text="The target phrase", + issue=None, + ), + ) + later = next( + item + for item in document.story_items + if item.story.kind == "body" and item.text == "The target phrase ends here." + ) + held.add_field("PAGEREF target \\h", "?") + # The field is a story item of its own, so later items move and go stale. + for stale in (lambda: held.text, lambda: document.set_story_text(later, "x")): + with pytest.raises(rdocx.StaleElementError): + stale() + assert document.paragraphs[0].runs[0].text == "See page " + + report = document.update_layout_backed_fields() + assert report.page_reference_fields == 1 + field = re.search( + rb'w:instr="PAGEREF target \\h"[^>]*>\s*\s*([^<]*)', + _document_xml(document), + ) + assert field is not None and field.group(1) == b"2" + + before = document.to_bytes() + with pytest.raises(rdocx.RdocxError, match="already exists"): + document.add_bookmark("target", range_) + table_range = rdocx.RunRange( + start=rdocx.RunPosition(body_index=1, run_index=0), + end=rdocx.RunPosition(body_index=1, run_index=0), + ) + with pytest.raises(rdocx.RdocxError, match="is not a paragraph"): + document.add_bookmark("in_table", table_range) + run = document.paragraphs[0].runs[0] + with pytest.raises(rdocx.RdocxError, match="field name"): + run.add_field(" ") + assert run.text == "See page " + assert document.to_bytes() == before + + +def test_bookmarks_report_nested_ranges_without_a_direct_range(): + import rdocx + + source = rdocx.Document() + source.add_paragraph("placeholder") + document = _replace_document_body( + source, + """ + Inside + Open + """, + ) + inside, unmatched = document.bookmarks + assert inside.name == "inside" + assert inside.range == rdocx.RunRange( + start=rdocx.RunPosition(body_index=0, run_index=0), + end=rdocx.RunPosition(body_index=0, run_index=1), + ) + assert inside.direct_range is None + assert inside.text == "Inside" + assert unmatched.range is None and unmatched.direct_range is None + assert unmatched.issue == "bookmark id 2 has 1 start markers and 0 end markers" + + +def test_insert_toc_links_headings_and_tab_and_fields_extend_a_run(): + import rdocx + + document = rdocx.Document() + document.add_paragraph("Intro") + document.add_paragraph("Chapter one").style = "Heading1" + document.add_paragraph("Section one point one").style = "Heading2" + document.add_paragraph("Deep detail").style = "Heading3" + held = document.paragraphs[0] + + before = document.to_bytes() + with pytest.raises(IndexError): + document.insert_toc(5) + with pytest.raises(ValueError, match="max_level"): + document.insert_toc(0, max_level=0) + assert document.to_bytes() == before + assert held.text == "Intro" + + assert document.insert_toc(0, max_level=2) is None + with pytest.raises(rdocx.StaleElementError): + held.text + assert [paragraph.text for paragraph in document.paragraphs[:3]] == [ + "Table of Contents", + "Chapter one\t", + "Section one point one\t", + ] + assert [(link.text, link.anchor) for link in document.hyperlinks] == [ + ("Chapter one", "_Toc1"), + ("Section one point one", "_Toc2"), + ] + assert [(bookmark.name, bookmark.text) for bookmark in document.bookmarks] == [ + ("_Toc1", "Chapter one"), + ("_Toc2", "Section one point one"), + ] + + run = document.paragraphs[3].runs[0] + run.add_tab() + assert run.text == "Intro\t" + run.add_field("PAGE", "1") + with pytest.raises(rdocx.StaleElementError): + run.text + xml = _document_xml(document) + assert b'' + ) + source = rdocx.Document() + source.add_paragraph("placeholder") + return _replace_document_body( + source, + f"{section}First section." + f"Second section.{section}", + ) + + +def _default_footer(document, section_index): + return next( + variant.story + for variant in document.header_footer_variants + if variant.section_index == section_index + and variant.kind == "footer" + and variant.variant == "default" + ) + + +def _story_part(document, story): + with zipfile.ZipFile(io.BytesIO(document.to_bytes())) as archive: + return archive.read(story.part_name.lstrip("/")) + + +def test_footer_with_text_tab_and_page_fields_is_built_for_one_section(): + import rdocx + + document = _two_section_document() + held = document.paragraphs[0] + footer = document.create_section_story(1, "footer", "default") + assert footer.kind == "footer" + with pytest.raises(rdocx.StaleElementError): + held.text + assert _default_footer(document, 0) is None + assert _default_footer(document, 1) == footer + + # Author the paragraph in the body with the typed API, then move it. + paragraph = document.add_paragraph("Confidential") + paragraph.runs[0].add_tab() + document.paragraphs[2].add_run("Page ").add_field("PAGE", "1") + document.paragraphs[2].add_run(" of ").add_field("NUMPAGES", "1") + fragment = document.pop_content(document.find_content_index("Confidential")) + document.insert_content(footer, fragment) + assert [paragraph.text for paragraph in document.paragraphs] == [ + "First section.", + "Second section.", + ] + assert _story_paragraph_texts(document, "footer") == ["ConfidentialPage 1 of 1"] + + report = document.update_layout_backed_fields() + assert (report.page_fields, report.num_pages_fields) == (1, 1) + xml = _story_part(document, footer) + assert re.search(rb"Confidential\s*", xml) + for instruction in (b"PAGE", b"NUMPAGES"): + field = re.search( + rb'w:instr="' + instruction + rb'"[^>]*>\s*\s*([^<]*)', xml + ) + assert field is not None and field.group(1) == b"2" + body = _document_xml(document) + first_section = body[: body.index(b"First section.")] + assert b"footerReference" not in first_section + assert body.count(b"footerReference") == 1 + assert document.to_pdf().startswith(b"%PDF") + + +def test_section_stories_link_unlink_and_reject_bad_names_atomically(): + import rdocx + + document = _two_section_document() + footer = document.create_section_story(1, "footer", "default") + document.add_paragraph("Footer text") + fragment = document.pop_content(document.find_content_index("Footer text")) + document.insert_content(footer, fragment) + before = document.to_bytes() + for arguments, error in ( + ((1, "side", "default"), ValueError), + ((1, "footer", "odd"), ValueError), + ((2, "footer", "default"), IndexError), + ): + with pytest.raises(error): + document.create_section_story(*arguments) + with pytest.raises(error): + document.unlink_section_story(*arguments) + with pytest.raises(error): + document.link_section_story(*arguments, footer) + with pytest.raises(rdocx.RdocxError): + document.link_section_story(0, "header", "default", footer) + with pytest.raises(TypeError, match="destination must be an int, a StoryItem or a Story"): + document.insert_content("footer", fragment) + with pytest.raises(TypeError, match="index must be an int or a StoryItem"): + document.pop_content(footer) + assert document.to_bytes() == before + + assert document.link_section_story(0, "footer", "default", footer) == footer + assert _default_footer(document, 0) == footer + copy = document.unlink_section_story(0, "footer", "default") + assert copy.part_name != footer.part_name + assert _default_footer(document, 0) == copy + assert _story_paragraph_texts(document, "footer") == ["Footer text", "Footer text"] + + [item] = [item for item in document.story_items if item.story == copy] + popped = document.pop_content(item) + assert popped.kind == "paragraph" + assert [item for item in document.story_items if item.story == copy] == [] + assert _default_footer(document, 1) == footer + assert _story_paragraph_texts(document, "footer") == ["Footer text"] + + def test_hyperlinks_are_added_to_paragraphs_and_stories(): import rdocx @@ -1291,6 +1604,132 @@ def test_split_run_rejects_bad_coordinates_without_changing_the_document(): assert document.to_bytes() == before +def test_split_run_takes_the_direct_body_index_after_a_table(): + import rdocx + + # GitHub issue #163: the index find_content_index returns must address + # the same paragraph in split_run. + document = rdocx.Document() + document.add_paragraph("Alpha paragraph before the table.") + document.add_table(1, 1).cell(0, 0).text = "cell" + document.add_paragraph("Beta paragraph after the table.") + document.add_paragraph("Gamma paragraph at the end.") + target = next(p for p in document.paragraphs if p.text.startswith("Beta")) + body_index = document.find_content_index(target) + assert body_index == 2 + + assert document.split_run(body_index, 0, 4) == 1 + assert [[run.text for run in p.runs] for p in document.paragraphs] == [ + ["Alpha paragraph before the table."], + ["Beta", " paragraph after the table."], + ["Gamma paragraph at the end."], + ] + + before = document.to_bytes() + with pytest.raises(rdocx.RdocxError, match="is a table, not a paragraph"): + document.split_run(1, 0, 1) + with pytest.raises(TypeError, match="must be an int or a Paragraph handle"): + document.split_run("Beta", 0, 1) + with pytest.raises(OverflowError): + document.split_run(-1, 0, 1) + assert document.to_bytes() == before + + +def test_split_run_accepts_a_paragraph_handle_in_a_control_or_the_body(): + import rdocx + + document = _replace_document_body( + rdocx.Document(), + "Alpha." + "" + "Control one." + "Control two." + "" + '' + "" + "" + "In control." + "" + "Cell text." + "" + "" + "Beta.", + ) + control_two = document.paragraphs[2] + assert control_two.text == "Control two." + run = control_two.runs[0] + before_no_op = document.to_bytes() + assert document.split_run(control_two, 0, 0) == 0 + assert document.to_bytes() == before_no_op + assert run.text == "Control two." + + assert document.split_run(control_two, 0, 7) == 1 + with pytest.raises(rdocx.StaleElementError): + run.text + with pytest.raises(rdocx.StaleElementError): + document.split_run(control_two, 0, 1) + assert [r.text for r in document.paragraphs[2].runs] == ["Control", " two."] + + # A cell handle is refused, because its index counts the paragraph + # inside the cell's content control and the cell writer does not. + cell = document.tables[0].rows[0].cells[0] + assert [p.text for p in cell.paragraphs] == ["In control.", "Cell text."] + before_cell = document.to_bytes() + for cell_paragraph in cell.paragraphs: + with pytest.raises(ValueError, match="table cell paragraph handle"): + document.split_run(cell_paragraph, 0, 2) + assert document.to_bytes() == before_cell + + beta = document.paragraphs[3] + assert document.find_content_index(beta) == 3 + beta_run = beta.runs[0] + assert document.split_run(beta, 0, 5) == 1 + assert beta_run.text == "Beta." + assert document.split_run(beta, 0, 4) == 1 + assert [r.text for r in document.paragraphs[3].runs] == ["Beta", "."] + + other = rdocx.Document() + other.add_paragraph("elsewhere") + before = document.to_bytes() + with pytest.raises(ValueError, match="different document"): + document.split_run(other.paragraphs[0], 0, 1) + assert document.to_bytes() == before + + +def test_story_comment_after_a_block_content_control_anchors_on_its_paragraph(): + import rdocx + + document = _replace_document_body( + rdocx.Document(), + "Alpha." + "" + "Control one." + "" + "Beta paragraph.", + ) + item = next( + item + for item in document.story_items + if item.story.kind == "body" + and item.kind == "paragraph" + and item.text == "Beta paragraph." + ) + comment_id = document.add_comment( + rdocx.StoryRunRange( + start=rdocx.StoryRunPosition(item=item, run_index=0), + end=rdocx.StoryRunPosition(item=item, run_index=1), + ), + author="Ada", + text="Here", + ) + xml = _document_xml(document).decode() + start = xml.index(f'commentRangeStart w:id="{comment_id}"') + end = xml.index(f'commentRangeEnd w:id="{comment_id}"') + assert re.findall(r"]*>([^<]*)", xml[start:end]) == [ + "Beta paragraph." + ] + + def test_python_round_three_authoring_and_inspection_is_typed_and_lossless(): import rdocx diff --git a/crates/rdocx-py/tests/test_rendering_threads.py b/crates/rdocx-py/tests/test_rendering_threads.py index 1d11a2834..c86fbe4a1 100644 --- a/crates/rdocx-py/tests/test_rendering_threads.py +++ b/crates/rdocx-py/tests/test_rendering_threads.py @@ -1,6 +1,7 @@ import concurrent.futures import io import os +import re import shutil import statistics import struct @@ -245,6 +246,77 @@ def test_render_pages_accepts_keyword_options_and_zero_based_pages(): document.render_pages("jpeg") +def _caller_font_document(): + from rdocx import Document + + document = Document() + run = document.add_paragraph("Monospaced caller face").runs[0] + run.font.name = "Rdocx Test Face" + return document + + +def _pdf_base_fonts(pdf): + return set(re.findall(rb"/BaseFont\s*/([^\s/>]+)", pdf)) + + +def test_to_pdf_takes_caller_fonts_as_bytes_or_from_a_directory(tmp_path): + from rdocx import LayoutError + + mono = Path(__file__).resolve().parents[2] / "oxml-layout" / "fonts" + mono = mono / "LiberationMono-Regular.ttf" + document = _caller_font_document() + assert b"LiberationMono" not in _pdf_base_fonts(document.to_pdf()) + + pdf = document.to_pdf(fonts=[("Rdocx Test Face", mono.read_bytes())]) + assert b"LiberationMono" in _pdf_base_fonts(pdf) + + # A directory font takes its family name from the file name. + shutil.copy(mono, tmp_path / "Rdocx Test Face.ttf") + assert b"LiberationMono" in _pdf_base_fonts(document.to_pdf(font_dir=tmp_path)) + assert b"LiberationMono" in _pdf_base_fonts(document.to_pdf(font_dir=str(tmp_path))) + + # Caller fonts replace every other source, as `rdocx convert --font-dir` does. + empty = tmp_path / "empty" + empty.mkdir() + with pytest.raises(LayoutError, match="No font found for family 'Rdocx Test Face'"): + document.to_pdf(font_dir=empty) + with pytest.raises(FileNotFoundError, match="font directory .*missing does not exist"): + document.to_pdf(font_dir=tmp_path / "missing") + with pytest.raises(NotADirectoryError, match="is not a directory"): + document.to_pdf(font_dir=tmp_path / "Rdocx Test Face.ttf") + with pytest.raises(TypeError): + document.to_pdf(fonts=[("Rdocx Test Face", "not bytes")]) + + +def test_render_page_to_svg_returns_the_page_and_its_diagnostics(): + from rdocx import SvgDiagnostic, SvgRenderResult + + document = _caller_font_document() + + page = document.render_page_to_svg(0) + assert isinstance(page, SvgRenderResult) + assert page.svg.startswith("2" in part_two + assert document.to_pdfa_deterministic("pdfa-2b") == part_two + assert b"3" in document.to_pdfa_deterministic("pdfa-3b") + with pytest.raises(ValueError, match="profile must be pdfa-2b or pdfa-3b"): + document.to_pdfa_deterministic("pdfa-1b") + + def test_render_errors_reacquire_and_map_cleanly(): from rdocx import LayoutError, RdocxError @@ -331,6 +403,20 @@ def test_render_page_to_png_releases_gil_for_python_worker(): assert page.startswith(PNG_SIGNATURE) +def test_font_svg_and_pdfa_renders_release_gil_for_python_worker(tmp_path): + document = _nontrivial_document(13) + fonts = Path(__file__).resolve().parents[2] / "oxml-layout" / "fonts" + shutil.copy(fonts / "Carlito-Regular.ttf", tmp_path) + + pdf = _assert_releases_gil(lambda: document.to_pdf(font_dir=tmp_path)) + page = _assert_releases_gil(lambda: document.render_page_to_svg(0)) + archival = _assert_releases_gil(document.to_pdfa_deterministic) + + assert pdf.startswith(b"%PDF-") + assert page is not None and page.svg.startswith(" None: paragraph_format: ParagraphFormat = paragraph.paragraph_format paragraph_format.keep_together = None split_boundary: int = document.split_run(0, 0, 1) + handle_boundary: int = document.split_run(paragraph, 0, 1) paragraphs: ParagraphCollection = document.paragraphs first: Paragraph = paragraphs[0] sliced: list[Paragraph] = paragraphs[:] @@ -78,6 +83,12 @@ def exercise_rdocx_types(path: Path) -> None: pdf_bytes: bytes = opened.to_pdf() pages: list[bytes] = opened.render_all_pages() maybe_page: bytes | None = opened.render_page_to_png(0) + font_pdf: bytes = opened.to_pdf(fonts=[("Carlito", b"font")], font_dir=path) + archival_pdf: bytes = opened.to_pdfa_deterministic("pdfa-3b") + svg_page: SvgRenderResult | None = opened.render_page_to_svg(0) + if svg_page is not None: + assert_type(svg_page.svg, str) + svg_diagnostics: tuple[SvgDiagnostic, ...] = svg_page.diagnostics document.save(path) document.remove_content(0) position = RunPosition(body_index=0, run_index=0) @@ -95,6 +106,12 @@ def exercise_rdocx_types(path: Path) -> None: text="done", date="2026-09-16T11:00:00+01:00", ) + bookmark_id: int = document.add_bookmark("target", range_) + bookmarks: tuple[Bookmark, ...] = document.bookmarks + assert_type(bookmarks[0].direct_range, RunRange | None) + run.add_tab() + run.add_field("PAGEREF target \\h", "1") + document.insert_toc(0, max_level=2) comments: tuple[Comment, ...] = document.comments sections: tuple[Section, ...] = document.sections styles: tuple[Style, ...] = document.styles @@ -134,6 +151,11 @@ def exercise_rdocx_types(path: Path) -> None: document.move_content(table, content_index) replacement_count: int = document.try_replace_text("old", "new") regex_count: int = document.replace_all_regex([("old", "new")]) + expected_count: int = document.try_replace_text("old", "new", expect=1) + batch_counts: tuple[int, ...] = document.replace_all([("a", "b", 1), ("c", "d")]) + count_error = ReplacementCountError("message", 1, 2, index=0) + assert_type(count_error.index, int | None) + assert_type(count_error.found, int) revisions: tuple[Revision, ...] = document.revisions accepted: int = document.accept_all() dated: int = document.reject_revisions_in_date_range( @@ -149,6 +171,11 @@ def exercise_rdocx_types(path: Path) -> None: document.set_footer("Footer") document.set_story_text(story_items[0], "edited") document.add_hyperlink_to_story(stories[0], "home", "https://example.com/") + section_footer: Story = document.create_section_story(0, "footer", "default") + linked_footer: Story = document.link_section_story(0, "footer", "first", section_footer) + unlinked_footer: Story = document.unlink_section_story(0, "footer", "first") + document.insert_content(section_footer, document.pop_content(story_items[0])) + document.insert_content(story_items[0], fragment) link_run: Run = first.add_hyperlink("docs", "https://example.com/docs") assert_type(story_items[0].xml, bytes) compatible_story_item = StoryItem( diff --git a/crates/rdocx/src/comments.rs b/crates/rdocx/src/comments.rs index 1e85cd823..c067fb8f8 100644 --- a/crates/rdocx/src/comments.rs +++ b/crates/rdocx/src/comments.rs @@ -27,7 +27,8 @@ const DEFAULT_COMMENTS_EXTENDED_PART: &str = "/word/commentsExtended.xml"; /// A stable insertion point between runs in a body paragraph. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct RunPosition { - /// Index in the document body's paragraph and table sequence. + /// Direct body child index, where a table or a block content control + /// counts as one child. `Document::find_content_index` returns it. pub body_index: usize, /// Run insertion index in the selected paragraph. pub run_index: usize, @@ -60,6 +61,7 @@ pub struct BookmarkRef { id: Option, name: Option, range: Option, + direct_range: Option, text: String, issue: Option, } @@ -74,10 +76,28 @@ impl BookmarkRef { } /// Return the accepted-view half-open range reported by `Document::bookmarks`. + /// + /// Its body index is the paragraph ordinal counted recursively through + /// tables and block content controls, which field numbering reads. + /// [`Self::direct_range`] reports the direct body child index instead. pub fn range(&self) -> Option { self.range } + /// Return the same range with the direct body child index that + /// `RunPosition`, `Document::add_bookmark` and + /// `Document::find_content_index` use. + /// + /// It is `None` when either marker sits in a table cell or a block content + /// control, which have no direct body index. Run indexes are the same + /// accepted-view boundaries as [`Self::range`]. They equal the + /// `RunPosition` run index that `Document::add_bookmark` and + /// `Document::add_comment` take only in a paragraph without inline + /// content controls or tracked insertions. + pub fn direct_range(&self) -> Option { + self.direct_range + } + pub fn text(&self) -> &str { &self.text } @@ -256,10 +276,13 @@ impl Document { /// /// Reported body indexes count typed paragraphs recursively through tables and /// block content controls. Reported run indexes use accepted-view run boundaries. + /// `BookmarkRef::direct_range` also reports the direct body child index when + /// both markers sit in direct body paragraphs. pub fn bookmarks(&self) -> Vec { #[derive(Clone)] struct Marker { position: RunPosition, + direct_position: Option, start: bool, id: Option, name: Option, @@ -267,14 +290,28 @@ impl Document { let mut markers = Vec::new(); let mut paragraphs = Vec::new(); - collect_main_story_paragraphs(&self.document.body.content, &mut paragraphs); - for (body_index, paragraph) in paragraphs.into_iter().enumerate() { + for (direct_index, item) in self.document.body.content.iter().enumerate() { + let mut item_paragraphs = Vec::new(); + collect_main_story_paragraphs(std::slice::from_ref(item), &mut item_paragraphs); + let direct_index = matches!(item, BodyContent::Paragraph(_)).then_some(direct_index); + paragraphs.extend( + item_paragraphs + .into_iter() + .map(|paragraph| (paragraph, direct_index)), + ); + } + for (body_index, (paragraph, direct_index)) in paragraphs.into_iter().enumerate() { for marker in ¶graph.bookmark_markers { + let run_index = marker.projected_run_index(); markers.push(Marker { position: RunPosition { body_index, - run_index: marker.projected_run_index(), + run_index, }, + direct_position: direct_index.map(|body_index| RunPosition { + body_index, + run_index, + }), start: marker.is_start(), id: marker.id(), name: marker.name().map(str::to_owned), @@ -294,6 +331,7 @@ impl Document { id: None, name: marker.name.clone(), range: None, + direct_range: None, text: String::new(), issue: Some("bookmark marker has a malformed or missing id".to_owned()), }, @@ -343,6 +381,12 @@ impl Document { (Some(candidate), None) } }; + let direct_range = range.and_then(|_| { + Some(RunRange { + start: markers[starts[0]].direct_position?, + end: markers[ends[0]].direct_position?, + }) + }); let text = range .map(|_| { bookmark_range_text( @@ -360,6 +404,7 @@ impl Document { id: Some(id), name, range, + direct_range, text, issue, }, @@ -385,6 +430,7 @@ impl Document { bookmark.name.as_deref().unwrap_or("") )); bookmark.range = None; + bookmark.direct_range = None; bookmark.text.clear(); } } diff --git a/crates/rdocx/src/document.rs b/crates/rdocx/src/document.rs index 5c4b2b468..0ad71778e 100644 --- a/crates/rdocx/src/document.rs +++ b/crates/rdocx/src/document.rs @@ -853,6 +853,21 @@ pub struct ContentMeasurement { pub diagnostics: Vec, } +/// The pair of a [`Document::try_replace_all_expected`] batch whose count +/// differed from the count its caller expected. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[error("pair {index}: expected {expected} replacement(s) of \"{placeholder}\", found {found}")] +pub struct ReplacementCountMismatch { + /// Zero-based position of the pair in the batch. + pub index: usize, + /// The literal text the pair searched for. + pub placeholder: String, + /// The count the caller expected. + pub expected: usize, + /// The count the pair found once the pairs before it had run. + pub found: usize, +} + /// An owned main-body range and the package dependencies it can reach. #[derive(Debug, Clone)] pub struct DocumentFragment { @@ -13088,7 +13103,7 @@ impl Document { } pub(crate) fn story_paragraph_mut(&mut self, location: &ContentLocation) -> Result<&mut CT_P> { - let (part_name, mut paragraph_index, cell_route) = { + let (part_name, paragraph_slot, cell_route) = { let (source, owner) = self.story_source_and_owner(&location.story)?; let part_name = source.part_name.clone(); let source_xml = source.xml.into_owned(); @@ -13116,15 +13131,25 @@ impl Document { } .into()); } - let paragraph_index = items[..item_index] - .iter() - .filter(|item| item.kind == StoryItemKind::Paragraph) - .count(); + // A paragraph item is a direct child of its owner. In the body it + // resolves to its direct body slot, the count of direct items + // before it, and in a cell to its position among the cell's + // direct paragraphs. Neither counts the paragraphs inside a block + // content control. The body section properties are serialized + // last, so they never precede a paragraph. + let preceding = items[..item_index].iter(); + let paragraph_slot = if location.story.kind == StoryKind::Body { + preceding.filter(|item| item.direct_owner_child).count() + } else { + preceding + .filter(|item| item.kind == StoryItemKind::Paragraph) + .count() + }; let cell_route = (location.story.kind == StoryKind::TableCell && part_name == self.doc_part_name) .then(|| modeled_main_cell_route(&source_xml, &owner)) .transpose()?; - (part_name, paragraph_index, cell_route) + (part_name, paragraph_slot, cell_route) }; if part_name != self.doc_part_name { return Err(Error::Other( @@ -13132,10 +13157,10 @@ impl Document { )); } match location.story.kind { - StoryKind::Body => { - nth_paragraph_in_body(&mut self.document.body.content, &mut paragraph_index) - .ok_or_else(|| Error::Other("comment body paragraph is missing".to_owned())) - } + StoryKind::Body => match self.document.body.content.get_mut(paragraph_slot) { + Some(BodyContent::Paragraph(paragraph)) => Ok(paragraph), + _ => Err(Error::Other("comment body paragraph is missing".to_owned())), + }, StoryKind::TableCell => { let (content_index, mut cell_index) = cell_route.ok_or_else(|| { Error::Other("comment position has no modeled table-cell route".to_owned()) @@ -13154,7 +13179,13 @@ impl Document { BodyContent::Paragraph(_) | BodyContent::RawXml(_) => None, } .ok_or_else(|| Error::Other("comment table cell is missing".to_owned()))?; - nth_paragraph_in_cell(cell, &mut paragraph_index) + cell.content + .iter_mut() + .filter_map(|child| match child { + CellContent::Paragraph(paragraph) => Some(paragraph), + CellContent::Table(_) | CellContent::ContentControl(_) => None, + }) + .nth(paragraph_slot) .ok_or_else(|| Error::Other("comment cell paragraph is missing".to_owned())) } _ => Err(Error::Other( @@ -14529,7 +14560,9 @@ impl Document { .collect() } - /// Get an immutable reference to a paragraph by index (among paragraphs only). + /// Get an immutable reference to a paragraph by its index in + /// [`Self::paragraphs`]. That index skips body tables and enters block + /// content controls, so it is not the direct body index. pub fn paragraph(&self, index: usize) -> Option> { self.document .body @@ -14661,7 +14694,8 @@ impl Document { result } - /// Get a mutable reference to a paragraph by index (among paragraphs only). + /// Get a mutable reference to a paragraph by the same index as + /// [`Self::paragraph`]. pub fn paragraph_mut(&mut self, index: usize) -> Option> { self.invalidate_layout(); let mut remaining = index; @@ -14672,6 +14706,14 @@ impl Document { /// Split a direct body paragraph run at a Unicode scalar offset of its /// literal text and return the resulting run boundary. /// + /// `body_index` is the direct body child index that `RunPosition`, + /// [`Self::find_content_index`], [`Self::add_comment`] and + /// [`Self::insert_paragraph`] use, where a table or a block content + /// control counts as one child. An index that names a table, a block + /// content control or preserved XML is an error that names its kind. A + /// paragraph inside a block content control is split through + /// `paragraph_mut(i).split_run`. + /// /// Zero returns the boundary before the selected run and the literal text /// length returns the boundary after it without changing the document. /// Interior offsets create a continuation with cloned run properties while @@ -14683,23 +14725,40 @@ impl Document { run_index: usize, character_offset: usize, ) -> Result { - let original = self.paragraph(body_index).ok_or_else(|| { - Error::Other(format!("body paragraph index {body_index} is out of range")) - })?; - let mut candidate = original.inner.clone(); + let not_a_paragraph = |kind: &str| { + Error::Other(format!( + "body index {body_index} is {kind}, not a paragraph" + )) + }; + let original = match self.document.body.content.get(body_index) { + Some(BodyContent::Paragraph(paragraph)) => paragraph, + Some(BodyContent::Table(_)) => return Err(not_a_paragraph("a table")), + Some(BodyContent::ContentControl(_)) => { + return Err(not_a_paragraph("a block content control")); + } + Some(BodyContent::RawXml(_)) => return Err(not_a_paragraph("preserved XML")), + None => { + return Err(Error::Other(format!( + "body index {body_index} is out of range" + ))); + } + }; + let mut candidate = original.clone(); let boundary = Paragraph { inner: &mut candidate, } .split_run(run_index, character_offset)?; - if candidate == *original.inner { + if candidate == *original { return Ok(boundary); } - self.invalidate_layout(); - let mut remaining = body_index; - let paragraph = nth_paragraph_in_body(&mut self.document.body.content, &mut remaining) - .ok_or_else(|| Error::Other("body paragraph disappeared during split".to_owned()))?; + let Some(BodyContent::Paragraph(paragraph)) = + self.document.body.content.get_mut(body_index) + else { + unreachable!("body index {body_index} was checked to be a paragraph"); + }; *paragraph = candidate; + self.invalidate_layout(); Ok(boundary) } @@ -21248,6 +21307,40 @@ impl Document { count } + /// Replace literal pairs in order, all or nothing, and return the count of + /// each pair. + /// + /// Each pair searches what [`Self::replace_text`] searches and runs over + /// the whole document after the pairs before it, so a later pair sees the + /// text an earlier one wrote. A pair may give the count it expects. When a + /// pair finds another count, the document is left unchanged and the inner + /// error names that pair. A pair without an expected count may find + /// nothing. The outer error is a staging failure, which also leaves the + /// document unchanged. + pub fn try_replace_all_expected( + &mut self, + pairs: &[(&str, &str, Option)], + ) -> Result, ReplacementCountMismatch>> { + let mut candidate = self.clone_for_staging(); + let mut counts = Vec::with_capacity(pairs.len()); + for (index, &(placeholder, replacement, expected)) in pairs.iter().enumerate() { + let found = candidate.replace_batch(&[(placeholder, replacement)])?; + if let Some(expected) = expected + && expected != found + { + return Ok(Err(ReplacementCountMismatch { + index, + placeholder: placeholder.to_owned(), + expected, + found, + })); + } + counts.push(found); + } + self.commit_staged_mutation(candidate); + Ok(Ok(counts)) + } + /// Render scalar and structural template tags from structured JSON data. /// /// Tags use `{{ path.to.value }}` syntax and may cross ordinary Word run @@ -31309,6 +31402,55 @@ mod tests { assert_eq!(doc.paragraphs()[0].text(), "X and Y"); } + #[test] + fn expected_replacement_batch_runs_pairs_in_order_and_counts_each() { + let mut doc = Document::new(); + doc.add_paragraph("{{a}} and {{b}}"); + doc.set_header("{{b}} header"); + + let counts = doc + .try_replace_all_expected(&[ + ("{{a}}", "{{b}}", Some(1)), + ("{{b}}", "Y", Some(3)), + ("{{missing}}", "Z", None), + ]) + .unwrap() + .unwrap(); + assert_eq!(counts, [1, 3, 0]); + assert_eq!(doc.paragraphs()[0].text(), "Y and Y"); + assert_eq!(doc.header_text().as_deref(), Some("Y header")); + } + + #[test] + fn expected_replacement_batch_mismatch_names_the_pair_and_changes_nothing() { + let mut doc = Document::new(); + doc.add_paragraph("{{a}} {{b}} {{b}} {{c}}"); + let before = doc.to_bytes().unwrap(); + + let mismatch = doc + .try_replace_all_expected(&[ + ("{{a}}", "A", Some(1)), + ("{{b}}", "B", Some(1)), + ("{{c}}", "C", Some(1)), + ]) + .unwrap() + .unwrap_err(); + assert_eq!( + mismatch, + ReplacementCountMismatch { + index: 1, + placeholder: "{{b}}".to_owned(), + expected: 1, + found: 2, + } + ); + assert_eq!( + mismatch.to_string(), + "pair 1: expected 1 replacement(s) of \"{{b}}\", found 2" + ); + assert_eq!(doc.to_bytes().unwrap(), before); + } + #[test] fn replacement_flush_failures_leave_the_live_document_unchanged() { fn exhausted() -> Document { @@ -31338,6 +31480,18 @@ mod tests { assert!(result.is_err()); assert_eq!(literal.paragraphs()[0].text(), before); + let mut counted = exhausted(); + let before = counted.paragraphs()[0].text(); + let error = counted + .try_replace_all_expected(&[("{{value}}", "after", Some(1))]) + .unwrap_err(); + assert!( + error + .to_string() + .contains("styles relationship allocation failed") + ); + assert_eq!(counted.paragraphs()[0].text(), before); + let mut regex = exhausted(); let before = regex.paragraphs()[0].text(); let error = regex.replace_regex(r"\{\{value\}\}", "after").unwrap_err(); diff --git a/crates/rdocx/src/lib.rs b/crates/rdocx/src/lib.rs index 3e78e4db8..4773602e8 100644 --- a/crates/rdocx/src/lib.rs +++ b/crates/rdocx/src/lib.rs @@ -61,9 +61,9 @@ pub use document::{ ListLevelRestart, ListLevelSuffix, ListNumberFormat, NumberingDefinition, NumberingDefinitionLevel, NumberingFormat, NumberingInstance, NumberingLevel, NumberingLevelOverride, OutlineNode, PictureAnchor, PictureCrop, PictureOptions, RenderOptions, - Section, SectionRef, SectionStory, StoryError, StoryId, StoryItemKind, StoryItemRef, - StoryItemSnapshot, StoryKind, TextBoxDirection, TextBoxOptions, TextWatermarkOptions, - UnsupportedXmlRef, WordCreationProfile, WordPackageClass, + ReplacementCountMismatch, Section, SectionRef, SectionStory, StoryError, StoryId, + StoryItemKind, StoryItemRef, StoryItemSnapshot, StoryKind, TextBoxDirection, TextBoxOptions, + TextWatermarkOptions, UnsupportedXmlRef, WordCreationProfile, WordPackageClass, }; pub use embedded::{ EmbeddedContentInfo, EmbeddedContentKind, EmbeddedMutationPolicy, EmbeddedSignatureState, @@ -97,7 +97,7 @@ pub use oxml_opc::PackageReadLimits; pub use oxml_opc::{ CoveredRelationship, SignatureIssue, SignatureReport, SignerCertificateIdentity, }; -pub use oxml_pdf::{RasterFormat, RasterOptions, RasterOutput}; +pub use oxml_pdf::{PdfConformance, RasterFormat, RasterOptions, RasterOutput}; pub use paragraph::{ Alignment, BorderStyle, DropCap, FrameAnchor, FrameWrap, HyperlinkItemRef, HyperlinkRef, Paragraph, ParagraphBorderEdge, ParagraphBorderRef, ParagraphFrame, ParagraphItemRef, diff --git a/crates/rdocx/tests/regression_test.rs b/crates/rdocx/tests/regression_test.rs index d143b3c74..4b0a9fafe 100644 --- a/crates/rdocx/tests/regression_test.rs +++ b/crates/rdocx/tests/regression_test.rs @@ -1634,6 +1634,243 @@ fn checked_table_cell_comment_range_is_atomic_and_reopens() { assert_eq!(reopened.comments()[0].text(), "review"); } +/// GitHub issue #163: the index `find_content_index` returns is a direct body +/// child index, and every body API that takes an index must address the same +/// paragraph with it, whatever tables or block content controls precede it. +mod direct_body_index_coordinates { + use rdocx::{ + Document, RunPosition, RunRange, StoryItemKind, StoryKind, StoryRunPosition, StoryRunRange, + }; + + const TABLE: &str = r#"c00c01c10c11"#; + const CONTROL: &str = r#"Control one.Control two."#; + + fn paragraph(text: &str) -> String { + format!("{text}") + } + + fn document_from_body(body: &str) -> Document { + super::document_with_content_controls(&super::wrap_word_body(body)) + } + + fn run_texts(document: &Document) -> Vec> { + document + .paragraphs() + .iter() + .map(|paragraph| paragraph.runs().map(|run| run.text()).collect()) + .collect() + } + + fn one_run_range(body_index: usize) -> RunRange { + RunRange { + start: RunPosition { + body_index, + run_index: 0, + }, + end: RunPosition { + body_index, + run_index: 1, + }, + } + } + + /// Visible text between the range markers of comment `id` in the saved + /// main part. The tests keep each range inside one paragraph. + fn commented_text(document: &mut Document, id: i32) -> String { + let xml = super::document_xml(document); + let start_marker = format!(r#""#); + let end_marker = format!(r#""#); + let start = xml.find(&start_marker).expect("comment start marker") + start_marker.len(); + let end = start + xml[start..].find(&end_marker).expect("comment end marker"); + super::f_x093_visible_text(&xml[start..end]) + } + + #[test] + fn split_run_takes_the_direct_body_index_after_a_table() { + let mut document = Document::new(); + document.add_paragraph("Alpha paragraph before the table."); + document + .add_table(1, 1) + .row(0) + .unwrap() + .cell(0) + .unwrap() + .set_text("cell"); + document.add_paragraph("Beta paragraph after the table."); + document.add_paragraph("Gamma paragraph at the end."); + let beta = document.find_content_index("Beta").unwrap(); + assert_eq!(beta, 2); + + assert_eq!(document.split_run(beta, 0, 4).unwrap(), 1); + assert_eq!( + run_texts(&document), + [ + vec!["Alpha paragraph before the table."], + vec!["Beta", " paragraph after the table."], + vec!["Gamma paragraph at the end."], + ] + ); + + let id = document + .add_comment(one_run_range(beta), "Ada", None, "Which word?") + .unwrap(); + assert_eq!(commented_text(&mut document, id), "Beta"); + + document.insert_paragraph(beta, "Inserted."); + let texts: Vec = document.paragraphs().iter().map(|p| p.text()).collect(); + assert_eq!( + texts[1..3], + ["Inserted.", "Beta paragraph after the table."] + ); + } + + #[test] + fn split_run_takes_the_direct_body_index_after_a_block_content_control() { + let mut document = document_from_body(&format!( + "{}{CONTROL}{}{}", + paragraph("Alpha."), + paragraph("Beta paragraph."), + paragraph("Gamma.") + )); + let beta = document.find_content_index("Beta").unwrap(); + assert_eq!(beta, 2); + let mut expected = run_texts(&document); + expected[3] = vec!["Beta".to_owned(), " paragraph.".to_owned()]; + + assert_eq!(document.split_run(beta, 0, 4).unwrap(), 1); + assert_eq!(run_texts(&document), expected); + let id = document + .add_comment(one_run_range(beta), "Ada", None, "Which word?") + .unwrap(); + assert_eq!(commented_text(&mut document, id), "Beta"); + } + + #[test] + fn split_run_names_the_body_child_that_is_not_a_paragraph() { + let mut document = document_from_body(&format!( + r#"{}{TABLE}{CONTROL}{}{}"#, + paragraph("Alpha."), + paragraph("Custom."), + paragraph("Beta.") + )); + let before = document.to_bytes().unwrap(); + for (body_index, kind) in [(1, "table"), (2, "content control"), (3, "preserved XML")] { + let error = document.split_run(body_index, 0, 1).unwrap_err(); + assert!( + error.to_string().contains(kind), + "body index {body_index}: {error}" + ); + } + let error = document.split_run(5, 0, 1).unwrap_err(); + assert!(error.to_string().contains("out of range"), "{error}"); + assert_eq!(document.to_bytes().unwrap(), before); + } + + #[test] + fn bookmark_direct_range_reports_the_index_add_bookmark_took() { + let mut document = document_from_body(&format!( + "{}{TABLE}{CONTROL}{}", + paragraph("Alpha."), + paragraph("Beta paragraph.") + )); + let beta = document.find_content_index("Beta").unwrap(); + assert_eq!(beta, 3); + document.add_bookmark("beta", one_run_range(beta)).unwrap(); + + let bookmark = document.bookmarks().remove(0); + assert_eq!(bookmark.text(), "Beta paragraph."); + assert_eq!(bookmark.direct_range(), Some(one_run_range(beta))); + // range() keeps the recursive paragraph ordinal that REF numbering + // reads: Alpha, the four cells and the two control paragraphs. + assert_eq!(bookmark.range().unwrap().start.body_index, 7); + } + + #[test] + fn bookmark_direct_range_is_none_when_a_marker_is_nested() { + let marked = |id: u32, name: &str, text: &str| { + format!( + r#"{text}"# + ) + }; + let document = document_from_body(&format!( + r#"Alpha.{}{}Control end.{}"#, + marked(1, "cell", "In a cell."), + marked(2, "control", "In a control."), + marked(4, "direct", "Direct.") + )); + let bookmarks = document.bookmarks(); + let named = |name: &str| { + bookmarks + .iter() + .find(|bookmark| bookmark.name() == Some(name)) + .unwrap() + }; + for name in ["cell", "control", "mixed"] { + assert!(named(name).range().is_some(), "{name}: {:?}", named(name)); + assert_eq!(named(name).direct_range(), None, "{name}"); + } + assert_eq!(named("direct").direct_range(), Some(one_run_range(3))); + } + + fn comment_on_story_paragraph(document: &mut Document, kind: StoryKind, text: &str) -> i32 { + let story = super::f254_story(document, kind); + let location = document + .story_items(&story) + .unwrap() + .into_iter() + .find(|item| { + item.kind() == StoryItemKind::Paragraph + && item.text().unwrap().as_deref() == Some(text) + }) + .unwrap() + .location() + .clone(); + document + .add_story_comment( + StoryRunRange { + start: StoryRunPosition { + location: location.clone(), + run_index: 0, + }, + end: StoryRunPosition { + location, + run_index: 1, + }, + }, + "Ada", + None, + "Here", + ) + .unwrap() + } + + #[test] + fn story_comment_after_a_block_content_control_anchors_on_its_paragraph() { + let mut document = document_from_body(&format!( + "{}{CONTROL}{}", + paragraph("Alpha."), + paragraph("Beta paragraph.") + )); + let id = comment_on_story_paragraph(&mut document, StoryKind::Body, "Beta paragraph."); + assert_eq!(commented_text(&mut document, id), "Beta paragraph."); + } + + #[test] + fn story_comment_in_a_cell_after_a_block_content_control_anchors_on_its_paragraph() { + let mut document = document_from_body(&format!( + r#"{CONTROL}{}{}"#, + paragraph("Target cell paragraph."), + paragraph("After.") + )); + let id = comment_on_story_paragraph( + &mut document, + StoryKind::TableCell, + "Target cell paragraph.", + ); + assert_eq!(commented_text(&mut document, id), "Target cell paragraph."); + } +} + fn f_x090_cross_part_drawing_package() -> Vec { let mut document = f255_story_document(); document.add_picture(b"body image", "body.png", Length::pt(1.0), Length::pt(1.0)); diff --git a/docs/hld/03-architecture.md b/docs/hld/03-architecture.md index 97ce19c68..c726d119a 100644 --- a/docs/hld/03-architecture.md +++ b/docs/hld/03-architecture.md @@ -1366,8 +1366,9 @@ method and otherwise owns one complete `Document`. It never reaches into deterministic variants expose one zero-based page as self-contained searchable SVG. Out-of-range pages return `None`. `SvgRenderResult` carries the SVG and ordered `SvgDiagnostic` values, with layout diagnostics before recursive -lowering diagnostics. These additive methods are native Rust only. Python, -WASM, CLI, Presentation, and the public `oxml-pdf` surface remain unchanged. +lowering diagnostics. Python binds the normal-layout method as +`Document.render_page_to_svg`. WASM, CLI, Presentation, and the public +`oxml-pdf` surface remain unchanged. `Document::from_html` and `Document::open_html` are additive native facade constructors. They return the converted document with stable path-aware @@ -1484,20 +1485,27 @@ location only after the image part, relationship, drawing identity, and story content all validate together. `Document::split_run` creates an exact accepted-view run boundary without -changing `RunPosition`. It clones the selected paragraph, resolves the selected +changing `RunPosition`. Its paragraph argument is the same direct body child +index as `RunPosition` and `find_content_index`, and an index that names a +table, a block content control, or preserved XML fails with an error naming +that kind. It clones the selected paragraph, resolves the selected recursive source path, counts Unicode scalar values only in literal text, partitions ordered zero-width children at their source boundary, repairs hyperlink and marker coordinates, and publishes the clone only on success. Zero and end offsets select existing boundaries and leave typed state, layout, -and binding revisions unchanged. A structural edit makes an earlier path-backed -Python run handle stale. +and binding revisions unchanged. A Python `Paragraph` handle to a paragraph +inside a block content control splits through `Document::paragraph_mut`, which +clears the cached layout even for those offsets. A structural edit makes an +earlier path-backed Python run handle stale. Word bookmark mutation input reuses the same top-level `RunPosition` and half-open `RunRange` boundary as comments. `Document::bookmarks` returns immutable correlated summaries in typed main-story paragraph order through tables and block content controls. A reported body index is that recursive paragraph ordinal, and its run index is the accepted-view boundary used to -extract bookmark text. Marker encounter order resolves direction when start +extract bookmark text. `BookmarkRef::direct_range` reports the same range with +the direct body child index of `RunPosition` when both markers sit in direct +body paragraphs, and `None` otherwise. Marker encounter order resolves direction when start and end share one accepted boundary, so end before start remains reversed and start before end is a valid empty range. Isolated projection refresh after a run, comment, or bookmark edit carries the original Word namespace aliases. diff --git a/docs/hld/10-bindings-spec.md b/docs/hld/10-bindings-spec.md index a6a75eb7c..978aa59dd 100644 --- a/docs/hld/10-bindings-spec.md +++ b/docs/hld/10-bindings-spec.md @@ -584,7 +584,10 @@ unchanged owner scope. The Python `Document` binds direct-body sources must be live direct children of the same Python document. Coordinates are zero-based insertion boundaries, and a popped `ContentFragment` exposes only its typed kind and remains reusable because insertion clones the native -value. `try_replace_text` and `replace_all_regex` return exact native counts. +value. `pop_content` also accepts a `StoryItem` naming a direct child of any +story, and `insert_content` accepts a `StoryItem` for the boundary before that +item or a `Story` for the end of that story, so a paragraph authored in the +body can move into a header or footer. `try_replace_text` and `replace_all_regex` return exact native counts. Successful structural mutations stale handles once, successful replacements stale them only when their count is nonzero, and every rejected operation leaves package bytes and handle revisions unchanged. The native and Python @@ -650,9 +653,18 @@ section and inherited state. `create_section_story`, `link_section_story`, addressed by the returned `StoryId` through the common story API. The facade also exposes `even_and_odd_headers` and `set_even_and_odd_headers`, while first story creation enables section `titlePg`. These are additive pre-1.0 native -Rust APIs. Python exposes immutable inspection snapshots, and only the default -header and footer text setters `Document.set_header` and `Document.set_footer` -as mutation entry points. WASM and CLI gain no corresponding binding surface. +Rust APIs. Python exposes immutable inspection snapshots, the default header +and footer text setters `Document.set_header` and `Document.set_footer`, and +`Document.create_section_story`, `link_section_story`, and +`unlink_section_story`. These take a section index, a `header` or `footer` +kind, and a `default`, `first`, or `even` variant, the names +`HeaderFooterVariant` reports, and return the resulting `Story`. An unknown +name raises `ValueError` and a section index out of range raises `IndexError`, +both before any change. The native operations publish a reopened package, so +a successful call advances the revision once, even when the variant already +had its own story. Rich story content is authored with the typed body +API and moved with `pop_content` and `insert_content`, which accept story +coordinates. WASM and CLI gain no corresponding binding surface. `CT_SectPr` adds typed page-number start and raw child-position state, while `PageFrame` adds `displayed_page_number` beside its physical `page_number`. @@ -690,6 +702,16 @@ explicit output and reports the counts through a schema-1 main-story record. Python exposes the same operation and returns diagnostics as an immutable tuple with a derived `diagnostic_count` property. WASM does not expose this operation. +`Document::insert_toc(index, max_level)` writes a static table of contents at +a direct body index: a title paragraph and one entry per `Heading1` to +`HeadingN` paragraph, each linked to a new `_TocN` bookmark on its heading. It +writes no `TOC` field, so `rebuild_toc` does not refresh it. Python +`Document.insert_toc(index, max_level=3)` binds it. An index past the body end +raises `IndexError` and a level outside 1 to 9 raises `ValueError`, both before +any change. When the native call inserts nothing because it cannot allocate +unique heading bookmarks, the binding raises `RdocxError`. Otherwise it +advances the revision once. + The native facade re-exports the concrete OfficeMath tree from `rdocx-oxml`. `Paragraph::equations`, `Paragraph::equation`, and their read-only equivalents borrow inline and display equations in source order. Mutable paragraphs add @@ -865,8 +887,27 @@ The native Word facade provides additive `Document::try_replace_text` beside the legacy infallible `replace_text` method. The fallible method stages the replacement and publishes it only after namespace-safe serialization succeeds. The command-line `replace` operation uses this boundary, reports the stable -serialization error, and creates no partial output. Python and WASM bindings -gain no corresponding method. +serialization error, and creates no partial output. +`Document::try_replace_all_expected` takes ordered `(placeholder, replacement, +expected)` pairs and returns the count of each pair. Each pair runs over the +whole staged document after the pairs before it, so a later pair sees what an +earlier one wrote. When a pair gives an expected count and finds another, the +document stays unchanged and the inner result is a `ReplacementCountMismatch` +naming the pair index, placeholder, expected count, and found count. The outer +error is a staging failure. The legacy `replace_all` keeps its unordered map +and panicking preflight. + +Python `Document.try_replace_text(placeholder, replacement, *, expect=None)` +and `Document.replace_all(pairs)` share one contract with the rpptx binding. +`replace_all` takes `(old, new)` or `(old, new, expected)` tuples and returns a +tuple of counts. A count that differs from `expect` or from a pair's expected +count raises `ReplacementCountError`, a subclass of `RdocxError` with +`expected`, `found`, and `index` attributes, and leaves the package bytes and +live handles unchanged. `index` names the failing pair of a batch and is +`None` for `try_replace_text`, whose message is the one `rdocx replace +--expect` prints. The error pickles. Zero matches without an expected count +return zero rather than raising. A replacement advances the revision once when +it replaced something. WASM gains no corresponding method. The native presentation facade provides the same staged boundary through `Presentation::try_replace_text`, with exact counts across slides and speaker @@ -887,8 +928,12 @@ Native Rust callers can request `PdfA2b` or `PdfA3b` through `Document::to_pdfa_deterministic` and `Presentation::to_pdfa_deterministic`. Both methods return the PDF backend's typed conformance error through the facade error enum. These methods are -additive on the native pre-1.0 facades. Python, WASM, and CLI method names and -dependency selections remain unchanged. +additive on the native pre-1.0 facades. `rdocx` re-exports `PdfConformance`, so +a caller can name the profile without depending on `oxml-pdf`. Python +`Document.to_pdfa_deterministic(profile="pdfa-2b")` accepts `pdfa-2b` or +`pdfa-3b`, raises `ValueError` for any other name, releases the GIL, and maps a +conformance failure to `LayoutError`. WASM and CLI method names and dependency +selections remain unchanged. The pre-1.0 shared layout API carries semantic types, `MarkedContent`, and informative `Figure` variants through existing non-exhaustive enums. The image @@ -1006,12 +1051,17 @@ although third-party editors may renumber them. `RunPosition` and `RunRange` define top-level paragraph run boundaries with an inclusive start and exclusive end. `Document::split_run` splits one direct-body run at a Unicode scalar offset of its literal text, so -such a boundary can fall inside what was one run. The second part keeps the run +such a boundary can fall inside what was one run. Its first argument is the +same direct body child index as `RunPosition` and `find_content_index`. An +index that names a table, a block content control, or preserved XML is an +error naming that kind. The second part keeps the run properties and the enclosing hyperlink. Tabs, breaks, fields, drawings, references, and preserved raw children have zero width. Zero and the literal text length return the existing boundary without mutation. Interior success -returns the new continuation index. Python exposes the same method and advances -the binding revision only when a continuation is created. `CommentRef` exposes +returns the new continuation index. Python exposes the same method and also +accepts a `Paragraph` handle in place of the index, which reaches paragraphs +inside block content controls. A table cell paragraph handle is refused. The +binding revision advances only when a continuation is created. `CommentRef` exposes comment metadata, text, parent identity, and resolved state without permitting part-local mutation. `rdocx-cli comment` lists, adds, replies to, resolves, and removes comments. Add ranges use explicit zero-based, half-open body paragraph @@ -1029,13 +1079,24 @@ preserve a document already redacted through the native facade. Native Word callers use `Document::bookmarks` for immutable `BookmarkRef` summaries and `Document::add_bookmark` for atomic insertion over the existing top-level half-open `RunRange`. A summary exposes an optional id, name, range, -current text, and marker issue. Insertion validates the Word name and both +direct range, current text, and marker issue. The range counts paragraphs +recursively through tables and block content controls. The direct range uses +the `RunPosition` body index that `add_bookmark` takes and is present only when +both markers sit in direct body paragraphs. Its run indexes stay the +accepted-view boundaries of the range, which equal the run indexes +`add_bookmark` takes only in a paragraph without inline content controls or +tracked insertions. Insertion validates the Word name and both boundaries, rejects duplicate or producer-reserved names, and returns the allocated nonnegative id. The shared recursive `Field` model retains the complete `REF` and `PAGEREF` instruction, target argument, cached display, -dirty state, source form, and producer XML. These additions are native Rust -APIs only. Python, WASM, and CLI consumers keep their existing surface and -preserve the typed content when they save the owned document. +dirty state, source form, and producer XML. Python `Document.bookmarks` +returns a tuple of frozen `Bookmark` snapshots with the same fields, both +ranges as `RunRange` values or `None`. `Document.add_bookmark(name, range)` +returns the id. Its markers sit between runs, so it keeps live handles valid +and does not advance the revision. A rejected name or range raises +`RdocxError` and leaves the document unchanged. WASM and CLI consumers keep +their existing surface and preserve the typed content when they save the owned +document. Native Word callers evaluate fields with `Document::evaluate_fields` and an explicit `FieldEvaluationContext`. `FieldDateTime` supplies deterministic civil @@ -1182,8 +1243,16 @@ relationship already created by `Document::embed_image`. `add_field` rejects an instruction without a field name before mutation. `add_symbol` stores one Unicode scalar as text. `set_text` remains the explicit replacement operation, while formatting setters retain the complete ordered content sequence. These -methods are additive on the pre-1.0 native Rust facade. Python, WASM, and CLI -gain no implicit surface. +methods are additive on the pre-1.0 native Rust facade. Python `Run` binds +`add_tab()` and `add_field(instruction, cached_result="")`. Both append inside +the run through the same accepted run path as the text setter, so no run index +moves. A tab keeps live handles valid. A field becomes a story item of its +own, which moves the index path of every later `StoryItem`, so `add_field` +advances the revision once. An instruction without a field name raises +`RdocxError` and leaves the run and the revision unchanged. The field +serializes as a simple field after the run's earlier content, and +`update_layout_backed_fields` fills a `PAGE`, `NUMPAGES`, or `PAGEREF` cache. +WASM and CLI gain no implicit surface. `add_symbol` keeps that meaning. `add_symbol_char(font, char_code)` is the separate method that produces `w:sym`, and `add_special_character` produces @@ -1313,14 +1382,29 @@ keyword-only `render_pages` arguments, keeps zero-based page indices, releases the GIL for rendering, returns `list[bytes]` for PNG or JPEG, and returns one `bytes` value for TIFF. +Python `Document.to_pdf(*, fonts=None, font_dir=None)` keeps the plain call on +`Document::to_pdf`. With `fonts`, a sequence of `(family, bytes)` pairs, or +`font_dir`, a directory whose `.ttf`, `.otf`, and `.ttc` files +`Document::load_fonts_from_dir` labels by file name, it calls +`Document::to_pdf_with_fonts` with the given fonts first, as +`rdocx convert --font-dir` does. That call lays out with the caller fonts only, +so a family they do not provide, even through the automatic label aliases and +metric-compatible names, raises `LayoutError`. The native loader reads a +missing directory as an empty one, so the binding raises `FileNotFoundError` +for a missing `font_dir` and `NotADirectoryError` for a file, before any +layout. SVG and raster output take no caller fonts. + Native Word SVG adds `SvgDiagnostic`, `SvgRenderResult`, and four additive `Document` methods. `render_page_to_svg` and `render_page_to_svg_with_options` reuse normal layout. Their deterministic counterparts reuse bundled-font-only layout. Every method takes a zero-based page index and returns `None` beyond the laid-out document. The result contains self-contained searchable SVG plus layout-first, path-specific lowering -diagnostics. Python, WASM, CLI, Presentation, and public `oxml-pdf` APIs do not -gain SVG methods or values. +diagnostics. Python `Document.render_page_to_svg(page_index)` binds the normal +layout method, releases the GIL, and returns a frozen `SvgRenderResult` with the +SVG text and a tuple of frozen `SvgDiagnostic` values, or `None` beyond the +last page. WASM, CLI, Presentation, and public `oxml-pdf` APIs do not gain SVG +methods or values. Native renderers obtain the complete positioned output through `Document::layout` and `Document::layout_with_options`. Accepted calls return a diff --git a/scripts/readme_doctests.py b/scripts/readme_doctests.py index e24c643be..a3ef90c2d 100644 --- a/scripts/readme_doctests.py +++ b/scripts/readme_doctests.py @@ -383,7 +383,7 @@ class ReadmeCase: "oxml-opc": (92_122, 355_510, 12), "oxml-pdf": (66_015, 304_432, 14), "oxml-sml": (12_511, 49_803, 6), - "rdocx": (1_092_256, 6_498_484, 36), + "rdocx": (1_096_411, 6_516_717, 36), "rdocx-cli": (33_805, 145_256, 8), "rdocx-html": (15_486, 63_894, 11), "rdocx-layout": (255_752, 1_385_701, 15),