From 95756b4bad429775dc6a6ba5c089425c2d89a9c3 Mon Sep 17 00:00:00 2001 From: Hadrien Mary Date: Sun, 27 Sep 2026 19:37:30 +0200 Subject: [PATCH 1/4] Bind table, row and cell formatting setters in Python Python tables stopped at the python-docx basics, so a document that needs borders, shading, cell or table margins, grid widths, a row height, a row kept on one page or a repeating header row still needed an lxml pass, although each of those has a checked setter in the rdocx facade. Table gains set_borders, set_border, border, cell_margins, set_cell_margins, grid_widths and set_column_width. Row gains height and height_rule, with a WD_ROW_HEIGHT_RULE enum as in python-docx, plus cant_split and is_header. Cell gains shading, border, set_border, margins and set_margins. Each call goes to the checked native setter, so a rejected value leaves the document unchanged, and none of them moves content, so live handles stay valid. The native reader reports no height for a w:trHeight with an auto rule or without a value, so Row.height reads None there, unlike python-docx, and assigning a height writes a minimum. The stub and HLD 10 say so. GitHub issue #168. --- crates/rdocx-py/python/rdocx/__init__.py | 3 +- crates/rdocx-py/python/rdocx/_rdocx.pyi | 54 ++- crates/rdocx-py/python/rdocx/enum/table.py | 9 +- crates/rdocx-py/src/table.rs | 420 +++++++++++++++++- .../rdocx-py/tests/test_formatting_tables.py | 146 ++++++ crates/rdocx-py/tests/typing_smoke.py | 30 ++ docs/hld/10-bindings-spec.md | 42 +- 7 files changed, 691 insertions(+), 13 deletions(-) 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..651449831 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", @@ -715,6 +722,19 @@ 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 clone_row(self, index: int, at: int | None = None) -> Row: ... def remove_row(self, index: int) -> None: ... @@ -735,6 +755,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 +807,17 @@ 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 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/table.rs b/crates/rdocx-py/src/table.rs index e16c4c251..bcdfd89c6 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,104 @@ 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), + } +} + #[pyclass(name = "TableCollection")] pub struct PyTableCollection { document: Py, @@ -197,6 +295,22 @@ impl PyTable { pub(crate) fn belongs_to(&self, py: Python<'_>, document: &Py) -> bool { self.document.bind(py).is(document.bind(py)) } + + /// Apply one checked native table edit that moves no content, so live + /// handles stay valid. + 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)) + } } #[pymethods] @@ -304,6 +418,122 @@ 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 = (index, at = None))] fn clone_row(&self, py: Python<'_>, index: isize, at: Option) -> PyResult> { let table_index = self.validate(py)?; @@ -476,6 +706,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 +750,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 +949,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 +1101,61 @@ impl PyCell { .set_vertical_alignment(value); Ok(()) } + + #[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_formatting_tables.py b/crates/rdocx-py/tests/test_formatting_tables.py index 4736bc212..2d964fd03 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,147 @@ 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_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..1346d31a0 100644 --- a/crates/rdocx-py/tests/typing_smoke.py +++ b/crates/rdocx-py/tests/typing_smoke.py @@ -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,34 @@ 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) package_bytes: bytes = loaded.to_bytes() pdf_bytes: bytes = opened.to_pdf() pages: list[bytes] = opened.render_all_pages() diff --git a/docs/hld/10-bindings-spec.md b/docs/hld/10-bindings-spec.md index a6a75eb7c..24c85a3f8 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,29 @@ 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. + 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 @@ -957,10 +980,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 +998,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 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, From 0f6aa5c09b54e05049ca1f5cc1e1181dc523e093 Mon Sep 17 00:00:00 2001 From: Hadrien Mary Date: Sun, 27 Sep 2026 20:27:59 +0200 Subject: [PATCH 2/4] Insert tables at a body index and merge cells from Python Python could only append a table, and it had no way to merge cells, so a table placed between two paragraphs or a merged header still needed lxml. The native facade has insert_table and the checked table-level merge operations, while the Cell span setter it also has is unchecked and leaves an invalid grid behind. Document.insert_table(index, rows, cols) rejects an index past the end before mutation and returns a handle to the new table. Table handles count tables inside block content controls too, so the handle is found from the inserted body position rather than assumed. Table.set_cell_grid_span and Table.set_cell_vertical_merge call the checked operations, which validate the whole table first. A span that absorbs or restores cells advances the revision, because later cell indexes move, and a vertical merge keeps handles valid. Cell.grid_span and Cell.vertical_merge read the result. A Python test runs the table acceptance workflow of the issue, with the formatting setters, through the binding, and an rdocx integration test makes the same native calls. Both pin the same body XML, so CI checks that the binding writes what the native calls write. GitHub issue #168. --- crates/rdocx-py/python/rdocx/_rdocx.pyi | 9 + crates/rdocx-py/src/document.rs | 25 +++ crates/rdocx-py/src/table.rs | 99 +++++++++-- .../rdocx-py/tests/test_formatting_tables.py | 159 ++++++++++++++++++ crates/rdocx-py/tests/typing_smoke.py | 9 +- crates/rdocx/tests/integration_test.rs | 118 +++++++++++++ docs/hld/10-bindings-spec.md | 19 ++- 7 files changed, 420 insertions(+), 18 deletions(-) diff --git a/crates/rdocx-py/python/rdocx/_rdocx.pyi b/crates/rdocx-py/python/rdocx/_rdocx.pyi index 651449831..efccb1b4d 100644 --- a/crates/rdocx-py/python/rdocx/_rdocx.pyi +++ b/crates/rdocx-py/python/rdocx/_rdocx.pyi @@ -538,6 +538,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: ... @@ -735,6 +736,10 @@ class Table: @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: ... @@ -808,6 +813,10 @@ class Cell: @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: ... diff --git a/crates/rdocx-py/src/document.rs b/crates/rdocx-py/src/document.rs index b81167c6f..39b1c6905 100644 --- a/crates/rdocx-py/src/document.rs +++ b/crates/rdocx-py/src/document.rs @@ -1881,6 +1881,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 bcdfd89c6..d6d9f7e34 100644 --- a/crates/rdocx-py/src/table.rs +++ b/crates/rdocx-py/src/table.rs @@ -186,6 +186,24 @@ fn row_height_parts(value: rdocx::RowHeight) -> (rdocx::Length, bool) { } } +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, @@ -296,8 +314,7 @@ impl PyTable { self.document.bind(py).is(document.bind(py)) } - /// Apply one checked native table edit that moves no content, so live - /// handles stay valid. + /// Apply one checked native table edit without advancing the revision. fn edit( &self, py: Python<'_>, @@ -311,6 +328,23 @@ impl PyTable { .ok_or_else(|| PyIndexError::new_err("table index out of range"))?; edit(&mut table).map_err(|error| rdocx_to_pyerr(py, error)) } + + /// 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 + .inner + .table(table_index) + .ok_or_else(|| PyIndexError::new_err("table index out of range"))?; + let row = normalize_index(row, table.row_count(), "row")?; + let col = normalize_index( + col, + table.row(row).map(|row| row.cell_count()).unwrap_or(0), + "cell", + )?; + Ok((row, col)) + } } #[pymethods] @@ -325,19 +359,9 @@ impl PyTable { } fn cell(&self, py: Python<'_>, row: isize, col: isize) -> PyResult> { - let table_index = self.validate(py)?; - let document = self.document.borrow(py); - let table = document - .inner - .table(table_index) - .ok_or_else(|| PyIndexError::new_err("table index out of range"))?; - let row = normalize_index(row, table.row_count(), "row")?; - let col = normalize_index( - col, - table.row(row).map(|row| row.cell_count()).unwrap_or(0), - "cell", - )?; - let path = document.revisions.capture(smallvec![ + 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) @@ -534,6 +558,41 @@ impl PyTable { 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)?; @@ -1102,6 +1161,16 @@ impl PyCell { 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)) diff --git a/crates/rdocx-py/tests/test_formatting_tables.py b/crates/rdocx-py/tests/test_formatting_tables.py index 2d964fd03..b3960ea81 100644 --- a/crates/rdocx-py/tests/test_formatting_tables.py +++ b/crates/rdocx-py/tests/test_formatting_tables.py @@ -477,6 +477,165 @@ def test_invalid_table_formatting_changes_nothing_and_keeps_handles_live(): 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 1346d31a0..a2bd8b13d 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, @@ -104,6 +104,13 @@ def exercise_rdocx_types(path: Path) -> 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() diff --git a/crates/rdocx/tests/integration_test.rs b/crates/rdocx/tests/integration_test.rs index 74d019501..e39105145 100644 --- a/crates/rdocx/tests/integration_test.rs +++ b/crates/rdocx/tests/integration_test.rs @@ -7476,6 +7476,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 +8362,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 24c85a3f8..926d1798a 100644 --- a/docs/hld/10-bindings-spec.md +++ b/docs/hld/10-bindings-spec.md @@ -258,6 +258,21 @@ 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 @@ -999,8 +1014,8 @@ 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. WASM and CLI gain no row -or cell methods. Python binds the checked row and cell setters as described -under the Python API shape. +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, From 6ecd3431f7e30f6fbbe259279d3a6c62c7cfdca3 Mon Sep 17 00:00:00 2001 From: Hadrien Mary Date: Sun, 27 Sep 2026 20:28:09 +0200 Subject: [PATCH 3/4] Edit, insert and remove document sections from Python Python sections were frozen snapshots with no mutation entry point, so assigning margin_top failed with "attribute is not writable", while the native facade has checked setters for every section value and staged insert_section and remove_section. Document.update_section(index, **values) takes the snapshot's own field names as keywords and returns the new snapshot, so Section stays a frozen value. The native setters write page size, margins, columns and header and footer distances together, so a value given alone keeps its partners as the section has them, and a partner the section never set raises ValueError instead of being invented. Column partners come from the equal-width view the snapshot reports, so a column count or spacing given alone raises rather than rewriting unequal-width tracks. Every name and partner is checked before any change, and a value a native setter rejects restores the section, so a failed call leaves the document unchanged. Section edits move no content and keep handles valid. insert_section and remove_section advance the revision because they change the body. rdocx-py now depends on rdocx-oxml, as rdocx-cli does, to name the orientation and break types the facade setters take. A section made by insert_section has no w:pgMar, so setting its margins wrote an element without the gutter, header and footer attributes that CT_PageMar requires. The native Section margin, gutter and distance setters now fill every w:pgMar value the section lacks with the default that layout already assumes for it, so the element is complete and the pages render as before. A Python test and an rdocx integration test make the same section edits through the binding and natively, and both pin the same body XML. GitHub issue #168. --- Cargo.lock | 1 + crates/rdocx-py/Cargo.toml | 1 + crates/rdocx-py/python/rdocx/_rdocx.pyi | 24 +++ crates/rdocx-py/src/document.rs | 208 ++++++++++++++++++++++++ crates/rdocx-py/tests/test_core.py | 200 +++++++++++++++++++++++ crates/rdocx-py/tests/typing_smoke.py | 12 ++ crates/rdocx/src/document.rs | 29 ++++ crates/rdocx/tests/integration_test.rs | 119 ++++++++++++++ docs/hld/10-bindings-spec.md | 40 ++++- 9 files changed, 626 insertions(+), 8 deletions(-) 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/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/_rdocx.pyi b/crates/rdocx-py/python/rdocx/_rdocx.pyi index efccb1b4d..960d73f4a 100644 --- a/crates/rdocx-py/python/rdocx/_rdocx.pyi +++ b/crates/rdocx-py/python/rdocx/_rdocx.pyi @@ -456,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 diff --git a/crates/rdocx-py/src/document.rs b/crates/rdocx-py/src/document.rs index 39b1c6905..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)) 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/typing_smoke.py b/crates/rdocx-py/tests/typing_smoke.py index a2bd8b13d..e2b5adc18 100644 --- a/crates/rdocx-py/tests/typing_smoke.py +++ b/crates/rdocx-py/tests/typing_smoke.py @@ -134,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") @@ -231,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 e39105145..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, } diff --git a/docs/hld/10-bindings-spec.md b/docs/hld/10-bindings-spec.md index 926d1798a..070ac485b 100644 --- a/docs/hld/10-bindings-spec.md +++ b/docs/hld/10-bindings-spec.md @@ -674,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 @@ -690,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 From 16ce6fbf8222d554c55a9845cf8acede3920f118 Mon Sep 17 00:00:00 2001 From: Hadrien Mary Date: Sun, 27 Sep 2026 20:28:52 +0200 Subject: [PATCH 4/4] Re-record the archive measurements of rdocx The native section setters that now write a complete w:pgMar and the integration tests that pin the body the Python table and section workflows write grow the rdocx package, so its README archive row and its ARCHIVE_MEASUREMENTS entry are re-measured. GitHub issue #168. --- README.md | 2 +- scripts/readme_doctests.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/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),