diff --git a/Cargo.lock b/Cargo.lock index 739cf945a..cfee2d47c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1556,6 +1556,7 @@ dependencies = [ "oxml-py-support", "pyo3", "rdocx", + "rdocx-oxml", "smallvec", ] diff --git a/README.md b/README.md index 479b462ef..3011cf66d 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,095,229 compressed bytes, 6,510,120 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/Cargo.toml b/crates/rdocx-py/Cargo.toml index 65ef9dd65..742fc36f1 100644 --- a/crates/rdocx-py/Cargo.toml +++ b/crates/rdocx-py/Cargo.toml @@ -30,6 +30,7 @@ extension-module = ["pyo3/extension-module"] oxml-py-support = { workspace = true } pyo3 = { workspace = true } rdocx = { workspace = true, features = ["system-fonts"] } +rdocx-oxml = { workspace = true } smallvec = { workspace = true } [dev-dependencies] diff --git a/crates/rdocx-py/python/rdocx/__init__.py b/crates/rdocx-py/python/rdocx/__init__.py index a04d7f32e..05a078092 100644 --- a/crates/rdocx-py/python/rdocx/__init__.py +++ b/crates/rdocx-py/python/rdocx/__init__.py @@ -1,6 +1,6 @@ """Python bindings for rdocx.""" -from .enum.table import WD_CELL_VERTICAL_ALIGNMENT, WD_TABLE_ALIGNMENT +from .enum.table import WD_CELL_VERTICAL_ALIGNMENT, WD_ROW_HEIGHT_RULE, WD_TABLE_ALIGNMENT from .enum.text import WD_ALIGN_PARAGRAPH, WD_UNDERLINE from .shared import Cm, Emu, Inches, Length, Mm, Pt, RGBColor @@ -108,6 +108,7 @@ class LayoutError(RdocxError): "TocRebuildReport", "WD_ALIGN_PARAGRAPH", "WD_CELL_VERTICAL_ALIGNMENT", + "WD_ROW_HEIGHT_RULE", "WD_TABLE_ALIGNMENT", "WD_UNDERLINE", "XmlError", diff --git a/crates/rdocx-py/python/rdocx/_rdocx.pyi b/crates/rdocx-py/python/rdocx/_rdocx.pyi index f5e4ef18c..960d73f4a 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 @@ -8,6 +8,13 @@ from .enum import table as _table from .enum import text as _text _Path = str | _os.PathLike[str] +_BorderStyle = _Literal[ + "none", "single", "thick", "double", "dotted", "dashed", "dotDash", "wave" +] +_BorderEdge = _Literal["top", "bottom", "left", "right", "insideH", "insideV"] +_Margins = tuple[ + _shared.Length | None, _shared.Length | None, _shared.Length | None, _shared.Length | None +] __all__ = [ "BoundingBox", "Cell", "CellCollection", "CellParagraphCollection", "Comment", "ComparisonDiagnostic", "ContentFragment", "Document", "Font", "HeaderFooterVariant", @@ -449,6 +456,30 @@ class Document: def comments(self) -> tuple[Comment, ...]: ... @property def sections(self) -> tuple[Section, ...]: ... + def update_section( + self, + index: int, + *, + orientation: _Literal["portrait", "landscape"] | None = None, + page_width: int | None = None, + page_height: int | None = None, + margin_top: int | None = None, + margin_right: int | None = None, + margin_bottom: int | None = None, + margin_left: int | None = None, + gutter: int | None = None, + column_count: int | None = None, + column_spacing: int | None = None, + page_number_start: int | None = None, + header_distance: int | None = None, + footer_distance: int | None = None, + different_first_page: bool | None = None, + break_type: _Literal[ + "nextPage", "continuous", "evenPage", "oddPage", "nextColumn" + ] | None = None, + ) -> Section: ... + def insert_section(self, index: int) -> None: ... + def remove_section(self, index: int) -> None: ... @property def styles(self) -> tuple[Style, ...]: ... @property @@ -531,6 +562,7 @@ 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 insert_table(self, index: int, rows: int, cols: int) -> Table: ... def pop_content(self, index: int) -> ContentFragment: ... def insert_content(self, destination: int, fragment: ContentFragment) -> None: ... def clone_content(self, source: Paragraph | Table, destination: int) -> None: ... @@ -715,6 +747,23 @@ class Table: def width(self) -> _shared.Length | None: ... @width.setter def width(self, value: int) -> None: ... + def set_borders(self, style: _BorderStyle, *, size: int, color: str) -> None: ... + def set_border( + self, edge: _BorderEdge, style: _BorderStyle, *, size: int, color: str + ) -> None: ... + def border(self, edge: _BorderEdge) -> tuple[str, int | None, str | None] | None: ... + @property + def cell_margins(self) -> _Margins | None: ... + def set_cell_margins(self, *, top: int, right: int, bottom: int, left: int) -> None: ... + @property + def grid_widths(self) -> tuple[_shared.Length, ...]: ... + @grid_widths.setter + def grid_widths(self, value: _Sequence[int]) -> None: ... + def set_column_width(self, column: int, width: int) -> None: ... + def set_cell_grid_span(self, row: int, col: int, span: int | None) -> None: ... + def set_cell_vertical_merge( + self, row: int, col: int, merge: _Literal["restart", "continue"] | None + ) -> None: ... def clone_row(self, index: int, at: int | None = None) -> Row: ... def remove_row(self, index: int) -> None: ... @@ -735,6 +784,27 @@ class Row: def __new__(cls, *, _private: _Never) -> Row: ... @property def cells(self) -> CellCollection: ... + # height and height_rule read None for a w:trHeight with an auto rule, + # which python-docx reads as its value and AUTO, or with no value. + # Assigning a height keeps the rule of an exact height and writes a + # minimum otherwise, so an auto rule, or an exact rule without a value, + # becomes a minimum. + @property + def height(self) -> _shared.Length | None: ... + @height.setter + def height(self, value: int) -> None: ... + @property + def height_rule(self) -> _table.WD_ROW_HEIGHT_RULE | None: ... + @height_rule.setter + def height_rule(self, value: _table.WD_ROW_HEIGHT_RULE) -> None: ... + @property + def cant_split(self) -> bool | None: ... + @cant_split.setter + def cant_split(self, value: bool | None) -> None: ... + @property + def is_header(self) -> bool | None: ... + @is_header.setter + def is_header(self, value: bool | None) -> None: ... @_final @@ -766,6 +836,21 @@ class Cell: def vertical_alignment(self) -> _table.WD_CELL_VERTICAL_ALIGNMENT | None: ... @vertical_alignment.setter def vertical_alignment(self, value: _table.WD_CELL_VERTICAL_ALIGNMENT) -> None: ... + @property + def grid_span(self) -> int: ... + @property + def vertical_merge(self) -> _Literal["restart", "continue"] | None: ... + @property + def shading(self) -> str | None: ... + @shading.setter + def shading(self, value: str) -> None: ... + def border(self, edge: _BorderEdge) -> tuple[str, int | None, str | None] | None: ... + def set_border( + self, edge: _BorderEdge, style: _BorderStyle, *, size: int, color: str + ) -> None: ... + @property + def margins(self) -> _Margins | None: ... + def set_margins(self, *, top: int, right: int, bottom: int, left: int) -> None: ... @_final diff --git a/crates/rdocx-py/python/rdocx/enum/table.py b/crates/rdocx-py/python/rdocx/enum/table.py index 37d4b1151..eb625f6ed 100644 --- a/crates/rdocx-py/python/rdocx/enum/table.py +++ b/crates/rdocx-py/python/rdocx/enum/table.py @@ -19,4 +19,11 @@ class WD_CELL_VERTICAL_ALIGNMENT(IntEnum): BOTTOM = 3 -__all__ = ["WD_TABLE_ALIGNMENT", "WD_CELL_VERTICAL_ALIGNMENT"] +class WD_ROW_HEIGHT_RULE(IntEnum): + """How a table row height is applied.""" + + AT_LEAST = 1 + EXACTLY = 2 + + +__all__ = ["WD_TABLE_ALIGNMENT", "WD_CELL_VERTICAL_ALIGNMENT", "WD_ROW_HEIGHT_RULE"] diff --git a/crates/rdocx-py/src/document.rs b/crates/rdocx-py/src/document.rs index b81167c6f..64af5dfc7 100644 --- a/crates/rdocx-py/src/document.rs +++ b/crates/rdocx-py/src/document.rs @@ -7,6 +7,8 @@ use pyo3::prelude::*; use pyo3::types::{PyAny, PyBytes, PyList, PyTuple}; use smallvec::smallvec; +use rdocx_oxml::{ST_PageOrientation, ST_SectionType}; + use crate::paragraph::{PyParagraph, PyParagraphCollection}; use crate::rdocx_to_pyerr; use crate::table::{PyTable, PyTableCollection}; @@ -1087,6 +1089,25 @@ fn section_snapshot(section: rdocx::SectionRef<'_>) -> PySection { } } +/// A section length the caller gave, or the section's current explicit one. +/// +/// The native setters write paired values together, so a value given alone +/// keeps its partner as the section has it. A partner the section never set +/// is refused rather than invented. +fn given_or_current_length( + given: Option, + current: Option, + name: &str, +) -> PyResult { + match (given, current) { + (Some(emu), _) => Ok(rdocx::Length::emu(emu)), + (None, Some(twips)) => Ok(rdocx::Length::twips(twips.0)), + (None, None) => Err(PyValueError::new_err(format!( + "the section has no {name} to keep, so give {name} too" + ))), + } +} + fn style_snapshot(style: rdocx::style::Style<'_>) -> PyStyle { PyStyle { style_id: style.style_id().to_owned(), @@ -1328,6 +1349,193 @@ impl PyDocument { PyTuple::new(py, sections) } + #[pyo3(signature = ( + index, + *, + orientation = None, + page_width = None, + page_height = None, + margin_top = None, + margin_right = None, + margin_bottom = None, + margin_left = None, + gutter = None, + column_count = None, + column_spacing = None, + page_number_start = None, + header_distance = None, + footer_distance = None, + different_first_page = None, + break_type = None, + ))] + #[allow(clippy::too_many_arguments)] + fn update_section( + &mut self, + py: Python<'_>, + index: usize, + orientation: Option<&str>, + page_width: Option, + page_height: Option, + margin_top: Option, + margin_right: Option, + margin_bottom: Option, + margin_left: Option, + gutter: Option, + column_count: Option, + column_spacing: Option, + page_number_start: Option, + header_distance: Option, + footer_distance: Option, + different_first_page: Option, + break_type: Option<&str>, + ) -> PyResult { + let orientation = orientation + .map(|value| { + ST_PageOrientation::from_str(value) + .map_err(|_| PyValueError::new_err("orientation must be portrait or landscape")) + }) + .transpose()?; + let break_type = break_type + .map(|value| { + ST_SectionType::from_str(value).map_err(|_| { + PyValueError::new_err( + "break type must be nextPage, continuous, evenPage, oddPage or nextColumn", + ) + }) + }) + .transpose()?; + let mut section = self + .inner + .section_mut(index) + .ok_or_else(|| PyIndexError::new_err("section index out of range"))?; + + let current = section.properties(); + let page_size = if page_width.is_some() || page_height.is_some() { + Some(( + given_or_current_length(page_width, current.page_width, "page_width")?, + given_or_current_length(page_height, current.page_height, "page_height")?, + )) + } else { + None + }; + let margins = if [margin_top, margin_right, margin_bottom, margin_left] + .iter() + .any(Option::is_some) + { + Some(( + given_or_current_length(margin_top, current.margin_top, "margin_top")?, + given_or_current_length(margin_right, current.margin_right, "margin_right")?, + given_or_current_length(margin_bottom, current.margin_bottom, "margin_bottom")?, + given_or_current_length(margin_left, current.margin_left, "margin_left")?, + )) + } else { + None + }; + let columns = if column_count.is_some() || column_spacing.is_some() { + // Partners come from the equal-width view the snapshot reports, so + // a section in unequal-width tracks has none to keep, and one value + // given alone never rewrites its tracks. + let current = section.columns(); + let count = column_count + .or(current.map(|(count, _)| count)) + .ok_or_else(|| { + PyValueError::new_err( + "the section has no column_count to keep, so give column_count too", + ) + })?; + let spacing = given_or_current_length( + column_spacing, + current.map(|(_, spacing)| spacing.as_twips()), + "column_spacing", + )?; + Some((count, spacing)) + } else { + None + }; + let distances = if header_distance.is_some() || footer_distance.is_some() { + Some(( + given_or_current_length( + header_distance, + current.header_distance, + "header_distance", + )?, + given_or_current_length( + footer_distance, + current.footer_distance, + "footer_distance", + )?, + )) + } else { + None + }; + + // The native setters check one value at a time, so restore the + // section if a later value is rejected after an earlier one applied. + let saved = section.properties().clone(); + let applied = (|| -> rdocx::Result<()> { + if let Some((width, height)) = page_size { + section.set_page_size(width, height)?; + } + if let Some(orientation) = orientation { + section.set_orientation(orientation); + } + if let Some((top, right, bottom, left)) = margins { + section.set_margins(top, right, bottom, left)?; + } + if let Some(gutter) = gutter { + section.set_gutter(rdocx::Length::emu(gutter))?; + } + if let Some((count, spacing)) = columns { + section.set_columns(count, spacing)?; + } + if let Some(start) = page_number_start { + section.set_page_number_start(start)?; + } + if let Some((header, footer)) = distances { + section.set_header_footer_distance(header, footer)?; + } + if let Some(enabled) = different_first_page { + section.set_different_first_page(enabled); + } + if let Some(break_type) = break_type { + section.set_break_type(break_type); + } + Ok(()) + })(); + if let Err(error) = applied { + *section.properties_mut() = saved; + return Err(rdocx_to_pyerr(py, error)); + } + Ok(self + .inner + .sections() + .nth(index) + .map(section_snapshot) + .expect("the updated section exists")) + } + + fn insert_section(&mut self, py: Python<'_>, index: usize) -> PyResult<()> { + if index > self.inner.section_count() { + return Err(PyIndexError::new_err("section index out of range")); + } + self.inner + .insert_section(index) + .map_err(|error| rdocx_to_pyerr(py, error))?; + self.revisions.bump(); + Ok(()) + } + + fn remove_section(&mut self, py: Python<'_>, index: usize) -> PyResult<()> { + if index >= self.inner.section_count() { + return Err(PyIndexError::new_err("section index out of range")); + } + self.inner + .remove_section(index) + .map_err(|error| rdocx_to_pyerr(py, error))?; + self.revisions.bump(); + Ok(()) + } + #[getter] fn styles<'py>(&self, py: Python<'py>) -> PyResult> { PyTuple::new(py, self.inner.styles().into_iter().map(style_snapshot)) @@ -1881,6 +2089,31 @@ impl PyDocument { Py::new(py, PyParagraph::new(slf, path)) } + #[pyo3(signature = (index, rows, cols))] + fn insert_table( + slf: Py, + py: Python<'_>, + index: usize, + rows: usize, + cols: usize, + ) -> PyResult> { + let path = { + let mut document = slf.borrow_mut(py); + if index > document.inner.content_count() { + return Err(PyIndexError::new_err("content index out of range")); + } + document.inner.insert_table(index, rows, cols); + // Table handles count every table in document order, including + // those inside block content controls, so find the new one. + let table = (0..document.inner.table_count()) + .find(|table| document.inner.content_index_of_table(*table) == Some(index)) + .expect("an inserted body table has a table index"); + document.revisions.bump(); + document.revisions.capture(smallvec![PathSeg::Body(table)]) + }; + Py::new(py, PyTable::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() { diff --git a/crates/rdocx-py/src/table.rs b/crates/rdocx-py/src/table.rs index e16c4c251..d6d9f7e34 100644 --- a/crates/rdocx-py/src/table.rs +++ b/crates/rdocx-py/src/table.rs @@ -1,7 +1,7 @@ use oxml_py_support::{ContentPath, PathSeg}; use pyo3::exceptions::{PyIndexError, PyTypeError, PyValueError}; use pyo3::prelude::*; -use pyo3::types::{PyAny, PyList, PySlice}; +use pyo3::types::{PyAny, PyList, PySlice, PyTuple}; use smallvec::smallvec; use crate::document::PyDocument; @@ -88,6 +88,122 @@ fn vertical_to_int(value: rdocx::VerticalAlignment) -> i32 { } } +fn border_style_from_name(value: &str) -> PyResult { + match value { + "none" => Ok(rdocx::BorderStyle::None), + "single" => Ok(rdocx::BorderStyle::Single), + "thick" => Ok(rdocx::BorderStyle::Thick), + "double" => Ok(rdocx::BorderStyle::Double), + "dotted" => Ok(rdocx::BorderStyle::Dotted), + "dashed" => Ok(rdocx::BorderStyle::Dashed), + "dotDash" => Ok(rdocx::BorderStyle::DotDash), + "wave" => Ok(rdocx::BorderStyle::Wave), + _ => Err(PyValueError::new_err( + "border style must be none, single, thick, double, dotted, dashed, dotDash or wave", + )), + } +} + +fn table_border_edge(value: &str) -> PyResult { + match value { + "top" => Ok(rdocx::TableBorderEdge::Top), + "bottom" => Ok(rdocx::TableBorderEdge::Bottom), + "left" => Ok(rdocx::TableBorderEdge::Left), + "right" => Ok(rdocx::TableBorderEdge::Right), + "insideH" => Ok(rdocx::TableBorderEdge::InsideHorizontal), + "insideV" => Ok(rdocx::TableBorderEdge::InsideVertical), + _ => Err(PyValueError::new_err( + "border edge must be top, bottom, left, right, insideH or insideV", + )), + } +} + +fn cell_border_edge(value: &str) -> PyResult { + match value { + "top" => Ok(rdocx::CellBorderEdge::Top), + "bottom" => Ok(rdocx::CellBorderEdge::Bottom), + "left" => Ok(rdocx::CellBorderEdge::Left), + "right" => Ok(rdocx::CellBorderEdge::Right), + "insideH" => Ok(rdocx::CellBorderEdge::InsideHorizontal), + "insideV" => Ok(rdocx::CellBorderEdge::InsideVertical), + _ => Err(PyValueError::new_err( + "border edge must be top, bottom, left, right, insideH or insideV", + )), + } +} + +/// A border edge as `(style, size in eighths of a point, color)`. +type BorderSnapshot = (String, Option, Option); + +fn border_snapshot(border: rdocx::TableBorderRef<'_>) -> BorderSnapshot { + ( + border.style().to_owned(), + border.size_eighths_pt(), + border.color().map(str::to_owned), + ) +} + +/// Cell margins as `(top, right, bottom, left)`, each a `Length` or `None`. +type MarginSnapshot = ( + Option>, + Option>, + Option>, + Option>, +); + +fn margin_snapshot(py: Python<'_>, margins: rdocx::TableCellMargins) -> PyResult { + let length = + |value: Option| value.map(|value| length_object(py, value)).transpose(); + Ok(( + length(margins.top)?, + length(margins.right)?, + length(margins.bottom)?, + length(margins.left)?, + )) +} + +/// Read a `WD_ROW_HEIGHT_RULE` value, where `AT_LEAST` is 1 and `EXACTLY` is 2. +fn row_height_rule_is_exact(value: i32) -> PyResult { + match value { + 1 => Ok(false), + 2 => Ok(true), + _ => Err(PyValueError::new_err("unsupported row height rule")), + } +} + +fn row_height(length: rdocx::Length, exact: bool) -> rdocx::RowHeight { + if exact { + rdocx::RowHeight::Exact(length) + } else { + rdocx::RowHeight::AtLeast(length) + } +} + +fn row_height_parts(value: rdocx::RowHeight) -> (rdocx::Length, bool) { + match value { + rdocx::RowHeight::AtLeast(length) => (length, false), + rdocx::RowHeight::Exact(length) => (length, true), + } +} + +fn vertical_merge_from_name(value: Option<&str>) -> PyResult> { + match value { + None => Ok(None), + Some("restart") => Ok(Some(rdocx::VMerge::Restart)), + Some("continue") => Ok(Some(rdocx::VMerge::Continue)), + Some(_) => Err(PyValueError::new_err( + "vertical merge must be restart, continue or None", + )), + } +} + +fn vertical_merge_name(value: rdocx::VMerge) -> &'static str { + match value { + rdocx::VMerge::Restart => "restart", + rdocx::VMerge::Continue => "continue", + } +} + #[pyclass(name = "TableCollection")] pub struct PyTableCollection { document: Py, @@ -197,20 +313,24 @@ impl PyTable { pub(crate) fn belongs_to(&self, py: Python<'_>, document: &Py) -> bool { self.document.bind(py).is(document.bind(py)) } -} -#[pymethods] -impl PyTable { - #[getter] - fn rows(&self, py: Python<'_>) -> PyResult> { - self.validate(py)?; - Py::new( - py, - PyRowCollection::new(self.document.clone_ref(py), self.path.clone()), - ) + /// Apply one checked native table edit without advancing the revision. + fn edit( + &self, + py: Python<'_>, + edit: impl FnOnce(&mut rdocx::Table<'_>) -> rdocx::Result, + ) -> PyResult { + let index = self.validate(py)?; + let mut document = self.document.borrow_mut(py); + let mut table = document + .inner + .table_mut(index) + .ok_or_else(|| PyIndexError::new_err("table index out of range"))?; + edit(&mut table).map_err(|error| rdocx_to_pyerr(py, error)) } - fn cell(&self, py: Python<'_>, row: isize, col: isize) -> PyResult> { + /// Resolve possibly negative row and cell indexes against this table. + fn cell_coordinates(&self, py: Python<'_>, row: isize, col: isize) -> PyResult<(usize, usize)> { let table_index = self.validate(py)?; let document = self.document.borrow(py); let table = document @@ -223,7 +343,25 @@ impl PyTable { table.row(row).map(|row| row.cell_count()).unwrap_or(0), "cell", )?; - let path = document.revisions.capture(smallvec![ + Ok((row, col)) + } +} + +#[pymethods] +impl PyTable { + #[getter] + fn rows(&self, py: Python<'_>) -> PyResult> { + self.validate(py)?; + Py::new( + py, + PyRowCollection::new(self.document.clone_ref(py), self.path.clone()), + ) + } + + fn cell(&self, py: Python<'_>, row: isize, col: isize) -> PyResult> { + let (row, col) = self.cell_coordinates(py, row, col)?; + let table_index = table_index(&self.path)?; + let path = self.document.borrow(py).revisions.capture(smallvec![ PathSeg::Body(table_index), PathSeg::Row(row), PathSeg::Cell(col) @@ -304,6 +442,157 @@ impl PyTable { Ok(()) } + #[pyo3(signature = (style, *, size, color))] + fn set_borders(&self, py: Python<'_>, style: &str, size: u32, color: &str) -> PyResult<()> { + let style = border_style_from_name(style)?; + self.edit(py, |table| { + table.set_all_borders_checked(style, size, color) + }) + } + + #[pyo3(signature = (edge, style, *, size, color))] + fn set_border( + &self, + py: Python<'_>, + edge: &str, + style: &str, + size: u32, + color: &str, + ) -> PyResult<()> { + let edge = table_border_edge(edge)?; + let style = border_style_from_name(style)?; + self.edit(py, |table| { + table.set_border_checked(edge, style, size, color) + }) + } + + fn border(&self, py: Python<'_>, edge: &str) -> PyResult> { + let edge = table_border_edge(edge)?; + let index = self.validate(py)?; + Ok(self + .document + .borrow(py) + .inner + .table(index) + .and_then(|table| table.border(edge).map(border_snapshot))) + } + + #[getter] + fn cell_margins(&self, py: Python<'_>) -> PyResult> { + let index = self.validate(py)?; + let margins = self + .document + .borrow(py) + .inner + .table(index) + .and_then(|table| table.cell_margins()); + margins + .map(|margins| margin_snapshot(py, margins)) + .transpose() + } + + #[pyo3(signature = (*, top, right, bottom, left))] + fn set_cell_margins( + &self, + py: Python<'_>, + top: i64, + right: i64, + bottom: i64, + left: i64, + ) -> PyResult<()> { + self.edit(py, |table| { + table.set_cell_margins_checked( + rdocx::Length::emu(top), + rdocx::Length::emu(right), + rdocx::Length::emu(bottom), + rdocx::Length::emu(left), + ) + }) + } + + #[getter] + fn grid_widths<'py>(&self, py: Python<'py>) -> PyResult> { + let index = self.validate(py)?; + let widths = self + .document + .borrow(py) + .inner + .table(index) + .ok_or_else(|| PyIndexError::new_err("table index out of range"))? + .grid_widths(); + let widths = widths + .into_iter() + .map(|width| length_object(py, width)) + .collect::>>()?; + PyTuple::new(py, widths) + } + + #[setter] + fn set_grid_widths(&self, py: Python<'_>, value: Vec) -> PyResult<()> { + let widths = value + .into_iter() + .map(rdocx::Length::emu) + .collect::>(); + self.edit(py, |table| table.set_grid_widths(&widths)) + } + + fn set_column_width(&self, py: Python<'_>, column: isize, width: i64) -> PyResult<()> { + let index = self.validate(py)?; + let columns = self + .document + .borrow(py) + .inner + .table(index) + .ok_or_else(|| PyIndexError::new_err("table index out of range"))? + .grid_widths() + .len(); + let column = normalize_index(column, columns, "column")?; + let applied = self.edit(py, |table| { + Ok(table.set_column_width(column, rdocx::Length::emu(width))) + })?; + if !applied { + return Err(PyValueError::new_err( + "column width must be nonnegative and every row must fit the table grid", + )); + } + Ok(()) + } + + #[pyo3(signature = (row, col, span))] + fn set_cell_grid_span( + &self, + py: Python<'_>, + row: isize, + col: isize, + span: Option, + ) -> PyResult<()> { + let (row, col) = self.cell_coordinates(py, row, col)?; + let cells = |table: &mut rdocx::Table<'_>| table.row(row).map(|row| row.cell_count()); + let changed = self.edit(py, |table| { + let before = cells(table); + table.set_cell_grid_span_checked(row, col, span)?; + Ok(cells(table) != before) + })?; + // Absorbed or restored cells shift the indexes after this one. + if changed { + self.document.borrow_mut(py).revisions.bump(); + } + Ok(()) + } + + #[pyo3(signature = (row, col, merge))] + fn set_cell_vertical_merge( + &self, + py: Python<'_>, + row: isize, + col: isize, + merge: Option<&str>, + ) -> PyResult<()> { + let merge = vertical_merge_from_name(merge)?; + let (row, col) = self.cell_coordinates(py, row, col)?; + self.edit(py, |table| table.set_cell_vertical_merge(row, col, merge)) + } + #[pyo3(signature = (index, at = None))] fn clone_row(&self, py: Python<'_>, index: isize, at: Option) -> PyResult> { let table_index = self.validate(py)?; @@ -476,6 +765,38 @@ impl PyRow { .map_err(|error| stale_to_pyerr(py, error))?; Ok((table_index(&self.path)?, row_index(&self.path)?)) } + + fn read(&self, py: Python<'_>, read: impl FnOnce(rdocx::RowRef<'_>) -> T) -> PyResult { + let (table, row) = self.validate(py)?; + let document = self.document.borrow(py); + let table = document + .inner + .table(table) + .ok_or_else(|| PyIndexError::new_err("table index out of range"))?; + let row = table + .row(row) + .ok_or_else(|| PyIndexError::new_err("row index out of range"))?; + Ok(read(row)) + } + + /// Apply one checked native row edit that moves no content, so live + /// handles stay valid. + fn edit( + &self, + py: Python<'_>, + edit: impl FnOnce(&mut rdocx::Row<'_>) -> rdocx::Result<()>, + ) -> PyResult<()> { + let (table, row) = self.validate(py)?; + let mut document = self.document.borrow_mut(py); + let mut table = document + .inner + .table_mut(table) + .ok_or_else(|| PyIndexError::new_err("table index out of range"))?; + let mut row = table + .row(row) + .ok_or_else(|| PyIndexError::new_err("row index out of range"))?; + edit(&mut row).map_err(|error| rdocx_to_pyerr(py, error)) + } } #[pymethods] @@ -488,6 +809,75 @@ impl PyRow { PyCellCollection::new(self.document.clone_ref(py), self.path.clone()), ) } + + #[getter] + fn height(&self, py: Python<'_>) -> PyResult>> { + self.read(py, |row| row.height())? + .map(|height| length_object(py, row_height_parts(height).0)) + .transpose() + } + + // An exact height keeps its rule. Any other row gets a minimum height, + // including one whose w:trHeight has an auto rule or no value, which the + // native reader reports as no height. + #[setter] + fn set_height(&self, py: Python<'_>, value: i64) -> PyResult<()> { + let exact = self + .read(py, |row| row.height())? + .is_some_and(|height| row_height_parts(height).1); + let height = row_height(rdocx::Length::emu(value), exact); + self.edit(py, |row| row.set_height_checked(height)) + } + + #[getter] + fn height_rule(&self, py: Python<'_>) -> PyResult>> { + self.read(py, |row| row.height())? + .map(|height| { + let exact = row_height_parts(height).1; + enum_object(py, "WD_ROW_HEIGHT_RULE", if exact { 2 } else { 1 }) + }) + .transpose() + } + + #[setter] + fn set_height_rule(&self, py: Python<'_>, value: i32) -> PyResult<()> { + let exact = row_height_rule_is_exact(value)?; + let length = self + .read(py, |row| row.height())? + .map(|height| row_height_parts(height).0) + .ok_or_else(|| { + PyValueError::new_err( + "row has no height for the rule to apply to, set height first", + ) + })?; + self.edit(py, |row| row.set_height_checked(row_height(length, exact))) + } + + #[getter] + fn cant_split(&self, py: Python<'_>) -> PyResult> { + self.read(py, |row| row.cant_split_value()) + } + + #[setter] + fn set_cant_split(&self, py: Python<'_>, value: Option) -> PyResult<()> { + self.edit(py, |row| { + row.set_cant_split_value(value); + Ok(()) + }) + } + + #[getter] + fn is_header(&self, py: Python<'_>) -> PyResult> { + self.read(py, |row| row.header_value()) + } + + #[setter] + fn set_is_header(&self, py: Python<'_>, value: Option) -> PyResult<()> { + self.edit(py, |row| { + row.set_header_value(value); + Ok(()) + }) + } } #[pyclass(name = "CellCollection")] @@ -618,6 +1008,38 @@ impl PyCell { cell_index(&self.path)?, )) } + + fn read(&self, py: Python<'_>, read: impl FnOnce(rdocx::CellRef<'_>) -> T) -> PyResult { + let (table, row, cell) = self.validate(py)?; + let document = self.document.borrow(py); + let table = document + .inner + .table(table) + .ok_or_else(|| PyIndexError::new_err("table index out of range"))?; + let cell = table + .cell(row, cell) + .ok_or_else(|| PyIndexError::new_err("cell index out of range"))?; + Ok(read(cell)) + } + + /// Apply one checked native cell edit that moves no content, so live + /// handles stay valid. + fn edit( + &self, + py: Python<'_>, + edit: impl FnOnce(&mut rdocx::Cell<'_>) -> rdocx::Result<()>, + ) -> PyResult<()> { + let (table, row, cell) = self.validate(py)?; + let mut document = self.document.borrow_mut(py); + 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"))?; + edit(&mut cell).map_err(|error| rdocx_to_pyerr(py, error)) + } } #[pymethods] @@ -738,6 +1160,71 @@ impl PyCell { .set_vertical_alignment(value); Ok(()) } + + #[getter] + fn grid_span(&self, py: Python<'_>) -> PyResult { + self.read(py, |cell| cell.grid_span().unwrap_or(1)) + } + + #[getter] + fn vertical_merge(&self, py: Python<'_>) -> PyResult> { + self.read(py, |cell| cell.v_merge().copied().map(vertical_merge_name)) + } + + #[getter] + fn shading(&self, py: Python<'_>) -> PyResult> { + self.read(py, |cell| cell.shading_fill().map(str::to_owned)) + } + + #[setter] + fn set_shading(&self, py: Python<'_>, value: &str) -> PyResult<()> { + self.edit(py, |cell| cell.set_shading_checked(value)) + } + + fn border(&self, py: Python<'_>, edge: &str) -> PyResult> { + let edge = cell_border_edge(edge)?; + self.read(py, |cell| cell.border(edge).map(border_snapshot)) + } + + #[pyo3(signature = (edge, style, *, size, color))] + fn set_border( + &self, + py: Python<'_>, + edge: &str, + style: &str, + size: u32, + color: &str, + ) -> PyResult<()> { + let edge = cell_border_edge(edge)?; + let style = border_style_from_name(style)?; + self.edit(py, |cell| cell.set_border_checked(edge, style, size, color)) + } + + #[getter] + fn margins(&self, py: Python<'_>) -> PyResult> { + self.read(py, |cell| cell.margins())? + .map(|margins| margin_snapshot(py, margins)) + .transpose() + } + + #[pyo3(signature = (*, top, right, bottom, left))] + fn set_margins( + &self, + py: Python<'_>, + top: i64, + right: i64, + bottom: i64, + left: i64, + ) -> PyResult<()> { + self.edit(py, |cell| { + cell.set_margins_checked( + rdocx::Length::emu(top), + rdocx::Length::emu(right), + rdocx::Length::emu(bottom), + rdocx::Length::emu(left), + ) + }) + } } #[pyclass(name = "CellParagraphCollection")] diff --git a/crates/rdocx-py/tests/test_core.py b/crates/rdocx-py/tests/test_core.py index 0f8ce9271..40d136d14 100644 --- a/crates/rdocx-py/tests/test_core.py +++ b/crates/rdocx-py/tests/test_core.py @@ -1155,6 +1155,206 @@ def test_word_structure_snapshots_preserve_order_ownership_and_types(): assert reopened.hyperlinks == captured_links +def test_update_section_margins_and_orientation_reach_the_layout(): + import rdocx + from rdocx import Inches + + document = rdocx.Document() + document.add_paragraph("body text") + held = document.paragraphs[0] + assert (document.layout_page(0).width, document.layout_page(0).height) == (612.0, 792.0) + + updated = document.update_section( + 0, orientation="landscape", margin_top=Inches(0.5), margin_left=Inches(2) + ) + + assert updated == document.sections[0] + assert (updated.orientation, updated.page_width, updated.page_height) == ( + "landscape", + Inches(11), + Inches(8.5), + ) + assert ( + updated.margin_top, + updated.margin_right, + updated.margin_bottom, + updated.margin_left, + ) == (Inches(0.5), Inches(1), Inches(1), Inches(2)) + assert held.text == "body text" + assert (document.layout_page(0).width, document.layout_page(0).height) == (792.0, 612.0) + bounds = document.layout()[0].bounds + assert (bounds.x, bounds.y, bounds.width) == (144.0, 36.0, 576.0) + xml = _document_xml(document) + assert b'' in xml + assert b'body' + '' + '' + "", + ) + section = document.sections[0] + assert (section.column_count, section.column_spacing) == (None, None) + + before = document.to_bytes() + with pytest.raises(ValueError, match="no column_spacing to keep"): + document.update_section(0, column_count=3) + with pytest.raises(ValueError, match="no column_count to keep"): + document.update_section(0, column_spacing=Inches(0.25)) + assert document.to_bytes() == before + + updated = document.update_section(0, column_count=3, column_spacing=Inches(0.25)) + assert (updated.column_count, updated.column_spacing) == (3, Inches(0.25)) + assert b"") : xml.index("") + len("")] + assert "".join(line.strip() for line in body.splitlines()) == ( + 'first' + '' + '' + '' + 'second' + '' + '' + '' + ) + + def _story_paragraph_texts(document, kind): return [ item.text diff --git a/crates/rdocx-py/tests/test_formatting_tables.py b/crates/rdocx-py/tests/test_formatting_tables.py index 4736bc212..b3960ea81 100644 --- a/crates/rdocx-py/tests/test_formatting_tables.py +++ b/crates/rdocx-py/tests/test_formatting_tables.py @@ -4,6 +4,11 @@ import pytest +def _document_xml(document_bytes): + with ZipFile(BytesIO(document_bytes)) as package: + return package.read("word/document.xml").decode() + + def _replace_document_xml(document_bytes, old, new): source_bytes = BytesIO(document_bytes) output_bytes = BytesIO() @@ -331,6 +336,306 @@ def test_table_rows_are_cloned_with_their_formatting_and_removed(): assert [row.cells[0].text for row in reopened.tables[0].rows] == ["entry"] +def test_table_borders_margins_and_grid_widths_round_trip(): + from rdocx import Document, Inches, Pt + + document = Document() + table = document.add_table(rows=2, cols=3) + assert table.border("top") is None + assert table.cell_margins is None + table.set_borders("single", size=4, color="000000") + table.set_border("insideV", "dashed", size=8, color="FF0000") + table.set_cell_margins(top=Pt(1), right=Pt(2), bottom=Pt(3), left=Pt(4)) + table.grid_widths = [Inches(1), Inches(2), Inches(3)] + table.set_column_width(-1, Inches(1.5)) + + xml = _document_xml(document.to_bytes()) + assert '' in xml + assert '' in xml + assert "" in xml + assert '' in xml + assert '' in xml + assert '' in xml + assert '' in xml + + table = Document.from_bytes(document.to_bytes()).tables[0] + assert table.border("top") == ("single", 4, "000000") + assert table.border("insideH") == ("single", 4, "000000") + assert table.border("insideV") == ("dashed", 8, "FF0000") + assert table.cell_margins == (Pt(1), Pt(2), Pt(3), Pt(4)) + assert table.grid_widths == (Inches(1), Inches(2), Inches(1.5)) + assert table.width == Inches(4.5) + assert [cell.width for cell in table.rows[1].cells] == [ + Inches(1), + Inches(2), + Inches(1.5), + ] + + +def test_row_height_split_and_header_round_trip(): + from rdocx import Document, Pt, WD_ROW_HEIGHT_RULE + + document = Document() + table = document.add_table(rows=2, cols=1) + row = table.rows[0] + assert row.height is None + assert row.height_rule is None + assert row.cant_split is None + assert row.is_header is None + row.height = Pt(20) + assert row.height_rule == WD_ROW_HEIGHT_RULE.AT_LEAST + row.height_rule = WD_ROW_HEIGHT_RULE.EXACTLY + row.height = Pt(30) + row.cant_split = True + row.is_header = True + table.rows[1].cant_split = False + + xml = _document_xml(document.to_bytes()) + assert '' in xml + assert "" in xml + assert "" in xml + + rows = Document.from_bytes(document.to_bytes()).tables[0].rows + assert rows[0].height == Pt(30) + assert rows[0].height_rule == WD_ROW_HEIGHT_RULE.EXACTLY + assert rows[0].cant_split is True + assert rows[0].is_header is True + assert rows[1].cant_split is False + assert rows[1].is_header is None + + rows[0].is_header = None + rows[0].cant_split = None + assert rows[0].is_header is None + assert rows[0].cant_split is None + + +def test_cell_shading_borders_and_margins_round_trip(): + from rdocx import Document, Pt + + document = Document() + cell = document.add_table(rows=1, cols=2).cell(0, 1) + assert cell.shading is None + assert cell.border("bottom") is None + assert cell.margins is None + cell.shading = "D9D9D9" + cell.set_border("bottom", "double", size=6, color="auto") + cell.set_margins(top=0, right=Pt(5), bottom=0, left=Pt(5)) + + xml = _document_xml(document.to_bytes()) + assert '' in xml + assert '' in xml + + cell = Document.from_bytes(document.to_bytes()).tables[0].cell(0, 1) + assert cell.shading == "D9D9D9" + assert cell.border("bottom") == ("double", 6, "auto") + assert cell.border("top") is None + assert cell.margins == (0, Pt(5), 0, Pt(5)) + + +def test_invalid_table_formatting_changes_nothing_and_keeps_handles_live(): + from rdocx import Document, RdocxError, WD_ROW_HEIGHT_RULE + + document = Document() + table = document.add_table(rows=1, cols=2) + row = table.rows[0] + cell = row.cells[0] + before = document.to_bytes() + + with pytest.raises(ValueError, match="border style"): + table.set_borders("bogus", size=4, color="000000") + with pytest.raises(ValueError, match="border edge"): + cell.set_border("middle", "single", size=4, color="000000") + with pytest.raises(RdocxError, match="border width"): + table.set_border("top", "single", size=97, color="000000") + with pytest.raises(RdocxError, match="border color"): + cell.set_border("top", "single", size=4, color="red") + with pytest.raises(RdocxError, match="cannot be negative"): + table.set_cell_margins(top=-635, right=0, bottom=0, left=0) + with pytest.raises(RdocxError, match="cannot be negative"): + cell.set_margins(top=0, right=0, bottom=0, left=-635) + with pytest.raises(RdocxError, match="shading color"): + cell.shading = "yellow" + with pytest.raises(RdocxError, match="exactly 2 positive column widths"): + table.grid_widths = [914400] + with pytest.raises(RdocxError, match="must be positive"): + table.grid_widths = [914400, 0] + with pytest.raises(IndexError): + table.set_column_width(2, 914400) + with pytest.raises(ValueError, match="nonnegative"): + table.set_column_width(0, -635) + with pytest.raises(RdocxError, match="cannot be negative"): + row.height = -635 + with pytest.raises(ValueError, match="set height first"): + row.height_rule = WD_ROW_HEIGHT_RULE.EXACTLY + with pytest.raises(ValueError, match="unsupported row height rule"): + row.height_rule = 0 + + assert document.to_bytes() == before + table.set_borders("single", size=4, color="auto") + row.cant_split = True + cell.shading = "FFFFFF" + assert (row.cant_split, cell.shading) == (True, "FFFFFF") + + +def test_insert_table_at_a_body_index_returns_the_new_table(): + from rdocx import Document, StaleElementError + + seed = Document() + seed.add_table(rows=1, cols=1).cell(0, 0).text = "in control" + seed.add_paragraph("after") + document = Document.from_bytes( + _replace_document_xml( + _replace_document_xml(seed.to_bytes(), b"", b""), + b"", + b"", + ) + ) + held = document.tables[0] + before = document.to_bytes() + with pytest.raises(IndexError, match="content index out of range"): + document.insert_table(3, 1, 1) + assert document.to_bytes() == before + + inserted = document.insert_table(1, 2, 2) + with pytest.raises(StaleElementError): + _ = held.rows + assert document.find_content_index(inserted) == 1 + assert len(inserted.rows) == 2 + inserted.cell(1, 1).text = "inserted" + + reopened = Document.from_bytes(document.to_bytes()) + body = [ + (item.kind, item.text) + for item in reopened.story_items + if item.direct_body_index is not None + ] + assert body == [("content_control", None), ("table", None), ("paragraph", "after")] + assert [table.cell(0, 0).text for table in reopened.tables] == ["in control", ""] + assert reopened.tables[1].cell(1, 1).text == "inserted" + + +def test_cells_merge_through_the_checked_table_operations(): + from rdocx import Document, RdocxError, StaleElementError + + document = Document() + table = document.add_table(rows=3, cols=3) + held = table.cell(0, 2) + table.set_cell_grid_span(0, 0, 2) + with pytest.raises(StaleElementError): + _ = held.text + + table = document.tables[0] + assert [len(row.cells) for row in table.rows] == [2, 3, 3] + assert [cell.grid_span for cell in table.rows[0].cells] == [2, 1] + last = table.cell(0, -1) + table.set_cell_vertical_merge(0, -1, "restart") + table.set_cell_vertical_merge(1, -1, "continue") + assert last.vertical_merge == "restart" + assert [row.cells[-1].vertical_merge for row in table.rows] == [ + "restart", + "continue", + None, + ] + + xml = _document_xml(document.to_bytes()) + assert '' in xml + assert '' in xml + assert "" in xml + + table.cell(1, 1).text = "full" + table = document.tables[0] + before = document.to_bytes() + with pytest.raises(RdocxError, match="nonempty cell"): + table.set_cell_grid_span(1, 0, 2) + with pytest.raises(RdocxError, match="no matching cell above"): + table.set_cell_vertical_merge(1, 0, "continue") + with pytest.raises(RdocxError, match="exceeds the row grid"): + table.set_cell_grid_span(0, 0, 4) + with pytest.raises(ValueError, match="vertical merge"): + table.set_cell_vertical_merge(1, 0, "sideways") + with pytest.raises(IndexError): + table.set_cell_grid_span(3, 0, 2) + assert document.to_bytes() == before + + table.set_cell_vertical_merge(1, -1, None) + table.set_cell_grid_span(0, 0, None) + reopened = Document.from_bytes(document.to_bytes()).tables[0] + assert [len(row.cells) for row in reopened.rows] == [3, 3, 3] + assert reopened.cell(1, -1).vertical_merge is None + + +def test_table_acceptance_workflow_writes_the_native_body(): + # table_acceptance_workflow_writes_the_body_the_python_binding_pins in + # crates/rdocx/tests/integration_test.rs pins the same body for the native + # calls, so CI checks that these calls write what the native ones write. + from rdocx import Document, WD_ROW_HEIGHT_RULE + + document = Document() + document.add_paragraph("before") + document.add_paragraph("after") + table = document.insert_table(1, 3, 3) + table.set_cell_grid_span(0, 0, 2) + table = document.tables[0] + table.set_cell_vertical_merge(1, 2, "restart") + table.set_cell_vertical_merge(2, 2, "continue") + table.set_borders("single", size=4, color="000000") + table.set_border("insideV", "dashed", size=8, color="FF0000") + table.set_cell_margins(top=0, right=63500, bottom=12700, left=127000) + table.grid_widths = [1371600, 1828800, 1828800] + table.set_column_width(-1, 914400) + cell = table.cell(1, 0) + cell.shading = "D9D9D9" + cell.set_margins(top=12700, right=0, bottom=25400, left=6350) + cell.set_border("bottom", "double", size=6, color="auto") + row = table.rows[0] + row.height = 254000 + row.cant_split = True + row.is_header = True + table.rows[1].height = 381000 + table.rows[1].height_rule = WD_ROW_HEIGHT_RULE.EXACTLY + + xml = _document_xml(document.to_bytes()) + body = xml[xml.index("") : xml.index("") + len("")] + assert "".join(line.strip() for line in body.splitlines()) == ( + 'before' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + 'after' + '' + '' + ) + + def test_python_paragraph_and_run_formatting_matches_native_facades(): from rdocx import Document diff --git a/crates/rdocx-py/tests/typing_smoke.py b/crates/rdocx-py/tests/typing_smoke.py index 51ebfa052..e2b5adc18 100644 --- a/crates/rdocx-py/tests/typing_smoke.py +++ b/crates/rdocx-py/tests/typing_smoke.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import TYPE_CHECKING, assert_type +from typing import TYPE_CHECKING, Literal, assert_type from rdocx import ( BoundingBox, @@ -14,6 +14,7 @@ HeaderFooterVariant, Hyperlink, Inches, + Length, LayoutFragment, LayoutBackedFieldUpdateReport, LayoutPage, @@ -37,6 +38,7 @@ Table, TableCollection, TocRebuildReport, + WD_ROW_HEIGHT_RULE, ) @@ -74,6 +76,41 @@ def exercise_rdocx_types(path: Path) -> None: table.remove_row(0) cell: Cell = row.cells[0] cell.text = first.text + assert_type(table.border("top"), tuple[str, int | None, str | None] | None) + table.set_borders("single", size=4, color="000000") + table.set_border("insideV", "dashed", size=8, color="FF0000") + assert_type( + table.cell_margins, + tuple[Length | None, Length | None, Length | None, Length | None] | None, + ) + table.set_cell_margins(top=0, right=Inches(0.1), bottom=0, left=Inches(0.1)) + assert_type(table.grid_widths, tuple[Length, ...]) + table.grid_widths = [Inches(1)] + table.set_column_width(0, Inches(2)) + assert_type(row.height, Length | None) + assert_type(row.height_rule, WD_ROW_HEIGHT_RULE | None) + assert_type(row.cant_split, bool | None) + assert_type(row.is_header, bool | None) + row.height = Inches(0.5) + row.height_rule = WD_ROW_HEIGHT_RULE.EXACTLY + row.cant_split = True + row.is_header = None + assert_type(cell.shading, str | None) + assert_type(cell.border("bottom"), tuple[str, int | None, str | None] | None) + assert_type( + cell.margins, + tuple[Length | None, Length | None, Length | None, Length | None] | None, + ) + cell.shading = "D9D9D9" + cell.set_border("bottom", "double", size=6, color="auto") + cell.set_margins(top=0, right=0, bottom=0, left=0) + inserted_table: Table = document.insert_table(0, 2, 2) + inserted_table.set_cell_grid_span(0, 0, 2) + inserted_table.set_cell_grid_span(0, -1, None) + inserted_table.set_cell_vertical_merge(0, 0, "restart") + inserted_table.set_cell_vertical_merge(1, 0, None) + assert_type(cell.grid_span, int) + assert_type(cell.vertical_merge, Literal["restart", "continue"] | None) package_bytes: bytes = loaded.to_bytes() pdf_bytes: bytes = opened.to_pdf() pages: list[bytes] = opened.render_all_pages() @@ -97,6 +134,17 @@ def exercise_rdocx_types(path: Path) -> None: ) comments: tuple[Comment, ...] = document.comments sections: tuple[Section, ...] = document.sections + updated_section: Section = document.update_section( + 0, + orientation="landscape", + margin_top=Inches(0.5), + column_count=2, + column_spacing=Inches(0.25), + different_first_page=True, + break_type="continuous", + ) + document.insert_section(1) + document.remove_section(1) styles: tuple[Style, ...] = document.styles stories: tuple[Story, ...] = document.stories image_data: bytes | None = document.image_data("rId1") @@ -194,6 +242,7 @@ def exercise_rdocx_types(path: Path) -> None: fragment_kind, inserted_picture, story_comment_id, + updated_section, ) accepted, dated, replaced, matched, updated diff --git a/crates/rdocx/src/document.rs b/crates/rdocx/src/document.rs index 5c4b2b468..73f4517bb 100644 --- a/crates/rdocx/src/document.rs +++ b/crates/rdocx/src/document.rs @@ -2919,6 +2919,9 @@ impl Section<'_> { } /// Set this section's top, right, bottom, and left margins. + /// + /// Any other `w:pgMar` value the section lacks takes its layout default, so + /// the written element carries every attribute `CT_PageMar` requires. pub fn set_margins( &mut self, top: Length, @@ -2934,6 +2937,7 @@ impl Section<'_> { self.inner.margin_right = Some(right); self.inner.margin_bottom = Some(bottom); self.inner.margin_left = Some(left); + complete_page_margins(self.inner); Ok(()) } @@ -2943,9 +2947,13 @@ impl Section<'_> { } /// Set this section's nonnegative gutter. + /// + /// Any other `w:pgMar` value the section lacks takes its layout default, so + /// the written element carries every attribute `CT_PageMar` requires. pub fn set_gutter(&mut self, gutter: Length) -> Result<()> { let gutter = checked_nonnegative_section_twips(gutter, "gutter")?; self.inner.gutter = Some(gutter); + complete_page_margins(self.inner); Ok(()) } @@ -3182,11 +3190,15 @@ impl Section<'_> { } /// Set nonnegative header and footer distances from the page edges. + /// + /// Any other `w:pgMar` value the section lacks takes its layout default, so + /// the written element carries every attribute `CT_PageMar` requires. pub fn set_header_footer_distance(&mut self, header: Length, footer: Length) -> Result<()> { let header = checked_nonnegative_section_twips(header, "header distance")?; let footer = checked_nonnegative_section_twips(footer, "footer distance")?; self.inner.header_distance = Some(header); self.inner.footer_distance = Some(footer); + complete_page_margins(self.inner); Ok(()) } @@ -3221,6 +3233,23 @@ impl Section<'_> { } } +/// Give every `w:pgMar` attribute a value once a setter has written one. +/// +/// `CT_PageMar` requires all seven attributes. A value the section already has +/// is kept, and an absent one takes the value layout assumes when it is +/// missing, which is the `CT_SectPr::default_letter` value, so the section +/// renders exactly as before. +fn complete_page_margins(properties: &mut CT_SectPr) { + let defaults = CT_SectPr::default_letter(); + properties.margin_top = properties.margin_top.or(defaults.margin_top); + properties.margin_right = properties.margin_right.or(defaults.margin_right); + properties.margin_bottom = properties.margin_bottom.or(defaults.margin_bottom); + properties.margin_left = properties.margin_left.or(defaults.margin_left); + properties.gutter = properties.gutter.or(defaults.gutter); + properties.header_distance = properties.header_distance.or(defaults.header_distance); + properties.footer_distance = properties.footer_distance.or(defaults.footer_distance); +} + fn checked_positive_section_twips(value: Length, name: &str) -> Result { if value.to_emu() <= 0 { return Err(Error::Other(format!("section {name} must be positive"))); diff --git a/crates/rdocx/tests/integration_test.rs b/crates/rdocx/tests/integration_test.rs index 74d019501..6c4cbd9b4 100644 --- a/crates/rdocx/tests/integration_test.rs +++ b/crates/rdocx/tests/integration_test.rs @@ -695,6 +695,125 @@ fn legacy_section_geometry_setters_preserve_infallible_compatibility() { assert_eq!(section.footer_distance.unwrap().0, -5); } +#[test] +fn section_page_margin_setters_write_every_required_attribute() { + let build = |fill: bool| { + let mut document = Document::new(); + document.add_paragraph("first"); + for index in 1..4 { + document.insert_section(index).unwrap(); + document.add_paragraph("next"); + } + if fill { + let inch = Length::twips(1440); + let half_inch = Length::twips(720); + let mut section = document.section_mut(1).unwrap(); + section.set_gutter(Length::twips(0)).unwrap(); + let mut section = document.section_mut(2).unwrap(); + section.set_margins(inch, inch, inch, inch).unwrap(); + let mut section = document.section_mut(3).unwrap(); + section + .set_header_footer_distance(half_inch, half_inch) + .unwrap(); + } + document + }; + + // Each setter writes the value layout assumes for an absent one, and so + // does every attribute it fills in, so the pages are unchanged. + let mut filled = build(true); + assert_eq!( + filled.to_pdf_deterministic().unwrap(), + build(false).to_pdf_deterministic().unwrap() + ); + let xml = String::from_utf8(document_xml(&mut filled)).unwrap(); + let complete = concat!( + r#""#, + ); + assert_eq!(xml.matches("first"#, + r#""#, + r#""#, + r#""#, + r#"second"#, + r#""#, + r#""#, + r#""#, + ) + ); +} + struct F251OracleArtifacts { path: std::path::PathBuf, } @@ -7476,6 +7595,15 @@ fn document_xml(document: &mut Document) -> Vec { package.get_part("/word/document.xml").unwrap().to_vec() } +/// The saved `w:body` on one line without indentation, the form the Python +/// binding tests compare against the same pinned value. +fn compact_body_xml(document: &mut Document) -> String { + let xml = String::from_utf8(document_xml(document)).unwrap(); + let start = xml.find("").unwrap(); + let end = xml.find("").unwrap() + "".len(); + xml[start..end].lines().map(str::trim).collect() +} + #[test] fn m23_layout_and_data_tables_match_word() { let mut document = Document::new(); @@ -8353,6 +8481,115 @@ fn checked_row_cell_topology_is_atomic() { } } +/// `test_table_acceptance_workflow_writes_the_native_body` in +/// `crates/rdocx-py/tests/test_formatting_tables.py` makes the same calls from +/// Python and pins the same body, so CI checks that the binding writes what +/// these native calls write. +#[test] +fn table_acceptance_workflow_writes_the_body_the_python_binding_pins() { + let mut document = Document::new(); + document.add_paragraph("before"); + document.add_paragraph("after"); + let mut table = document.insert_table(1, 3, 3); + table.set_cell_grid_span_checked(0, 0, Some(2)).unwrap(); + table + .set_cell_vertical_merge(1, 2, Some(rdocx::table::VMerge::Restart)) + .unwrap(); + table + .set_cell_vertical_merge(2, 2, Some(rdocx::table::VMerge::Continue)) + .unwrap(); + table + .set_all_borders_checked(BorderStyle::Single, 4, "000000") + .unwrap(); + table + .set_border_checked( + TableBorderEdge::InsideVertical, + BorderStyle::Dashed, + 8, + "FF0000", + ) + .unwrap(); + table + .set_cell_margins_checked( + Length::emu(0), + Length::emu(63500), + Length::emu(12700), + Length::emu(127000), + ) + .unwrap(); + table + .set_grid_widths(&[ + Length::emu(1371600), + Length::emu(1828800), + Length::emu(1828800), + ]) + .unwrap(); + assert!(table.set_column_width(2, Length::emu(914400))); + let mut cell = table.cell(1, 0).unwrap(); + cell.set_shading_checked("D9D9D9").unwrap(); + cell.set_margins_checked( + Length::emu(12700), + Length::emu(0), + Length::emu(25400), + Length::emu(6350), + ) + .unwrap(); + cell.set_border_checked(CellBorderEdge::Bottom, BorderStyle::Double, 6, "auto") + .unwrap(); + let mut row = table.row(0).unwrap(); + row.set_height_checked(RowHeight::AtLeast(Length::emu(254000))) + .unwrap(); + row.set_cant_split_value(Some(true)); + row.set_header_value(Some(true)); + table + .row(1) + .unwrap() + .set_height_checked(RowHeight::Exact(Length::emu(381000))) + .unwrap(); + + assert_eq!( + compact_body_xml(&mut document), + concat!( + r#"before"#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#"after"#, + r#""#, + r#""#, + ) + ); +} + #[test] fn content_measurement_reuses_production_layout_and_is_pure() { let mut document = Document::new(); diff --git a/docs/hld/10-bindings-spec.md b/docs/hld/10-bindings-spec.md index a6a75eb7c..070ac485b 100644 --- a/docs/hld/10-bindings-spec.md +++ b/docs/hld/10-bindings-spec.md @@ -199,10 +199,10 @@ doc.save_pdf("out.pdf") # documented as an rdocx extensio limited ABI. - The bounded core enum inventory is pure-Python `IntEnum`: `WD_ALIGN_PARAGRAPH` and `WD_UNDERLINE` in `rdocx.enum.text`, plus - `WD_TABLE_ALIGNMENT` and `WD_CELL_VERTICAL_ALIGNMENT` in - `rdocx.enum.table`. All four are also top-level exports. Their checked + `WD_TABLE_ALIGNMENT`, `WD_CELL_VERTICAL_ALIGNMENT` and `WD_ROW_HEIGHT_RULE` + in `rdocx.enum.table`. All five are also top-level exports. Their checked integer literals cover the paragraph, run and table variants exposed by the - S33 facade, including `WD_ALIGN_PARAGRAPH.CENTER == 1`. Underline codes use a + facade, including `WD_ALIGN_PARAGRAPH.CENTER == 1`. Underline codes use a total binding-oriented facade value accessor rather than expanding the published exhaustive Rust `UnderlineStyle` enum. - The package layer owns `RdocxError(Exception)` as the base, with @@ -235,6 +235,44 @@ native staged result. Python index errors are rejected before mutation, while native topology and serialization failures use the existing `RdocxError` mapping. Removing the only direct row is rejected. +`Table`, `Row` and `Cell` also bind the checked native formatting setters. +`Table.set_borders(style, *, size, color)` sets every table edge and +`set_border(edge, style, *, size, color)` sets one. The style is an +`ST_Border` name from `none`, `single`, `thick`, `double`, `dotted`, `dashed`, +`dotDash` and `wave`, the edge is `top`, `bottom`, `left`, `right`, `insideH` +or `insideV`, the size is in eighths of a point and the color is six +hexadecimal digits or `auto`. `border(edge)` reads a `(style, size, color)` +tuple or `None`. `set_cell_margins` takes four keyword EMU lengths and +`cell_margins` reads a `(top, right, bottom, left)` tuple of optional +`Length` values. `grid_widths` reads and replaces every grid column, and +`set_column_width(column, width)` changes one. Both keep the table width and +every covering cell width in step. `Row.height` and `Row.height_rule` follow +python-docx with `WD_ROW_HEIGHT_RULE.AT_LEAST` and `EXACTLY`. Assigning a +height keeps an exact rule, and a rule needs a height to apply to. Unlike +python-docx, a row whose `w:trHeight` has an `auto` rule or no value reads no +height, and assigning one writes a minimum. +`Row.cant_split` and `Row.is_header` are tri-state. `Cell.shading`, +`Cell.border(edge)`, `Cell.set_border`, `Cell.margins` and `Cell.set_margins` +are the same forms for one cell. These edits move no content, so they keep +live handles valid and do not advance the revision. An unknown style, edge or +rule raises `ValueError`, a value the native setter rejects raises +`RdocxError`, and either way the document is unchanged. + +`Document.insert_table(index, rows, cols)` inserts a table at a direct body +index, rejects an index past the end with `IndexError` before mutation, and +returns a handle to the new table. Table handles count every table in +document order, including those inside block content controls, so the handle +is resolved from the inserted body position rather than assumed. Cells merge +through the checked table operations, never through the unchecked cell span +setter. `Table.set_cell_grid_span(row, col, span)` spans columns and absorbs +or restores untouched empty cells, and `None` or `1` removes the span. +`Table.set_cell_vertical_merge(row, col, merge)` writes `restart`, `continue` +or `None` after validating the whole merge topology. Both take the possibly +negative indexes `Table.cell` takes. A grid span that absorbs or restores +cells advances the revision once, because later cell indexes move, while a +vertical merge keeps live handles valid. `Cell.grid_span` reads the span with +the python-docx default of 1 and `Cell.vertical_merge` reads the merge state. + The Python `Document` also exposes the current native comparison, main-body comment, deterministic layout, TOC rebuild, revision, counted replacement, and field cache update operations. `RunPosition` and @@ -636,10 +674,13 @@ properties. Both handles read page size, orientation, margins, gutter, equal-width columns, page-number start, header and footer distance, title-page state, and break type. The mutable handle adds checked setters for every value, normalizes page dimensions when setting orientation, and rejects invalid or -out-of-range inputs before changing any field. `Document` adds `section_count`, -`sections`, total `section` and `section_mut` lookup, and fallible staged -`insert_section` and `remove_section` operations. Its older final-section -geometry convenience setters remain infallible and unchecked. +out-of-range inputs before changing any field. The margin, gutter, and header +and footer distance setters fill every other `w:pgMar` value the section lacks +with the default that layout already assumes for it, so the written element +carries all seven attributes `CT_PageMar` requires. `Document` adds +`section_count`, `sections`, total `section` and `section_mut` lookup, and +fallible staged `insert_section` and `remove_section` operations. Its older +final-section geometry convenience setters remain infallible and unchecked. Native Rust also exposes non-exhaustive `HeaderFooterKind`, the existing `HdrFtrType`, and owned `SectionStory`. `Document::section_story` resolves one @@ -652,16 +693,37 @@ 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. +as story mutation entry points. 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`. These model and handle additions are additive APIs on the published pre-1.0 Rust crates, though exhaustive struct literals can require new fields. The published `CT_SectPr.header_refs` and `footer_refs` types remain -`Vec` with the complete native vector surface. Python, WASM, and CLI -gain no section mutation entry point and retain their existing package and -render behavior. +`Vec` with the complete native vector surface. WASM and CLI gain no +section mutation entry point and retain their existing package and render +behavior. + +Python `Section` values stay frozen snapshots. `Document.update_section(index, +**values)` edits one section through the checked native setters and returns +its new snapshot. The keywords are the snapshot's own field names, from +`orientation` and `page_width` to `break_type`, with EMU lengths and the +`ST_PageOrientation` and `ST_SectionType` spellings. The native setters write +page size, the four margins, equal-width columns and the header and footer +distances as pairs or quartets, so a value given alone keeps its partners as +the section already has them, and a partner the section never set raises +`ValueError` rather than being invented. Column partners come from the +equal-width view the snapshot reports, so `column_count` or `column_spacing` +alone raises on a section laid out in unequal-width tracks rather than +rewriting them. Page size applies before orientation, which then normalizes +the dimensions as the native setter does. +The whole call is atomic: a name or partner problem raises before any change, +and a value a native setter rejects restores the section. Section edits move +no content, so they keep live handles valid and do not advance the revision. +`Document.insert_section(index)` and `remove_section(index)` call the staged +native operations, reject an out-of-range index with `IndexError`, and +advance the revision once, because they add or merge body content. Python `Document.sections`, `styles`, `stories`, `story_items`, `header_footer_variants`, and `hyperlinks` return tuples of frozen typed @@ -957,10 +1019,10 @@ cell width consistent. A cell with `gridSpan` receives the sum of its covered grid columns, and row-level leading and trailing omissions constrain coverage. Negative or zero column widths, invalid spans or coverage, and overflowing totals are rejected without mutation. The earlier unchecked compatibility -setters remain available. These are additive pre-1.0 native APIs. Python, -WASM, and CLI do not gain new table-property methods, but their owned -`rdocx::Document` remains package-preserving when native code uses the new -operations. +setters remain available. These are additive pre-1.0 native APIs. WASM and CLI +do not gain new table-property methods, and Python binds them as described +under the Python API shape. Every binding's owned `rdocx::Document` remains +package-preserving when native code uses the new operations. Native rows and cells also expose the additive `RowHeight`, `CellBorderEdge`, `CellTextDirection`, and `TableConditionalFormatting` values. Row handles have @@ -975,8 +1037,9 @@ take checked row and cell indexes. Grid omissions reconcile only untouched empty edge cells. Horizontal spans consume or restore only untouched empty cells. Vertical continuations require an equal grid range in the immediately preceding row. Each operation validates a cloned complete table before -publication. These are additive pre-1.0 native APIs. Python, WASM, and CLI gain -no row or cell methods in this story. +publication. These are additive pre-1.0 native APIs. WASM and CLI gain no row +or cell methods. Python binds the checked row and cell setters and the span +and vertical merge operations, as described under the Python API shape. Row cloning and removal depend on package-wide identities, so the additive native operations live on `Document` as `clone_table_row(table, source, diff --git a/scripts/readme_doctests.py b/scripts/readme_doctests.py index e24c643be..36649728c 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_095_229, 6_510_120, 36), "rdocx-cli": (33_805, 145_256, 8), "rdocx-html": (15_486, 63_894, 11), "rdocx-layout": (255_752, 1_385_701, 15),