Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/rpptx-oxml/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ notes, comments, diagrams, and timing data.

| Measurement | Value | Version | Platform | Build mode | Input | Command | Statistic | Measured on |
|---|---|---|---|---|---|---|---|---|
| Crates.io archive: rpptx-oxml | 153,216 compressed bytes, 1,042,644 member bytes, 20 members | 0.12.1 | macOS 26.6.2, Apple M5 Max, arm64 | `cargo package --locked --no-verify` | Tracked `rpptx-oxml` package inventory | `python3 scripts/readme_doctests.py --record-measurements` | gzip archive bytes, tar member bytes, tar member count | 2026-09-19 |
| Crates.io archive: rpptx-oxml | 154,086 compressed bytes, 1,046,474 member bytes, 20 members | 0.12.1 | macOS 26.6.2, Apple M5 Max, arm64 | `cargo package --locked --no-verify` | Tracked `rpptx-oxml` package inventory | `python3 scripts/readme_doctests.py --record-measurements` | gzip archive bytes, tar member bytes, tar member count | 2026-09-19 |

## Use it when

Expand Down
117 changes: 104 additions & 13 deletions crates/rpptx-oxml/src/shape_tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1470,16 +1470,61 @@ impl CT_ShapeTree {
};
let removed = self.children[index].clone();
let xml = self.to_xml()?;
let range = direct_shape_child_range(&xml, id)?.ok_or_else(|| {
OxmlError::InvalidValue(format!("shape id {id} disappeared during removal"))
})?;
let range = direct_shape_child_ranges(&xml)?
.get(index)
.cloned()
.ok_or_else(|| {
OxmlError::InvalidValue(format!("shape id {id} disappeared during removal"))
})?;
let mut rewritten = Vec::with_capacity(xml.len() - range.len());
rewritten.extend_from_slice(&xml[..range.start]);
rewritten.extend_from_slice(&xml[range.end..]);
*self = Self::from_xml(&rewritten)?;
Ok(Some(removed))
}

/// Moves one immediate child so that it ends up at index `to`, where
/// later children draw on top.
///
/// The moved child passes only the children between its old and new
/// index. Unmodelled members, such as `p:contentPart`, and schema-final
/// content keep their bytes and their place among the other children.
pub fn move_child(&mut self, from: usize, to: usize) -> Result<()> {
let count = self.children.len();
if let Some(index) = [from, to].into_iter().find(|index| *index >= count) {
return Err(OxmlError::InvalidValue(format!(
"shape-tree child index {index} is out of range for {count} children"
)));
}
if from == to {
return Ok(());
}
let xml = self.to_xml()?;
let ranges = direct_shape_child_ranges(&xml)?;
if ranges.len() != count {
return Err(OxmlError::InvalidValue(
"shape-tree children changed during a move".to_owned(),
));
}
let moved = ranges[from].clone();
let mut rewritten = Vec::with_capacity(xml.len());
if from < to {
let after = ranges[to].end;
rewritten.extend_from_slice(&xml[..moved.start]);
rewritten.extend_from_slice(&xml[moved.end..after]);
rewritten.extend_from_slice(&xml[moved]);
rewritten.extend_from_slice(&xml[after..]);
} else {
let before = ranges[to].start;
rewritten.extend_from_slice(&xml[..before]);
rewritten.extend_from_slice(&xml[moved.clone()]);
rewritten.extend_from_slice(&xml[before..moved.start]);
rewritten.extend_from_slice(&xml[moved.end..]);
}
*self = Self::from_xml(&rewritten)?;
Ok(())
}

/// Parses a complete `p:spTree` with any prefix bound to PresentationML.
pub fn from_xml(xml: &[u8]) -> Result<Self> {
Self::from_fragment(xml, &[])
Expand Down Expand Up @@ -1522,10 +1567,12 @@ impl CT_ShapeTree {
}
}

fn direct_shape_child_range(xml: &[u8], id: u32) -> Result<Option<Range<usize>>> {
/// Returns the byte range of every typed shape-tree child, in child order.
fn direct_shape_child_ranges(xml: &[u8]) -> Result<Vec<Range<usize>>> {
let mut reader = Reader::from_reader(xml);
let mut buffer = Vec::new();
let mut root = None;
let mut ranges = Vec::new();
loop {
match reader.read_event_into(&mut buffer)? {
Event::Start(start) if root.is_none() => {
Expand All @@ -1538,10 +1585,8 @@ fn direct_shape_child_range(xml: &[u8], id: u32) -> Result<Option<Range<usize>>>
let start = shape_start_tag_range(xml, reader.buffer_position() as usize)?.start;
let raw = capture_element(&mut reader, &child)?;
let range = start..reader.buffer_position() as usize;
if parse_shape_tree_child(&name, uri, &raw, &namespaces)?
.is_some_and(|child| child.non_visual_id() == Some(id))
{
return Ok(Some(range));
if parse_shape_tree_child(&name, uri, &raw, &namespaces)?.is_some() {
ranges.push(range);
}
}
Event::Empty(child) => {
Expand All @@ -1550,13 +1595,11 @@ fn direct_shape_child_range(xml: &[u8], id: u32) -> Result<Option<Range<usize>>>
let uri = namespaces.element_uri(child.name().as_ref());
let range = shape_start_tag_range(xml, reader.buffer_position() as usize)?;
let raw = capture_empty_element(&child)?;
if parse_shape_tree_child(&name, uri, &raw, &namespaces)?
.is_some_and(|child| child.non_visual_id() == Some(id))
{
return Ok(Some(range));
if parse_shape_tree_child(&name, uri, &raw, &namespaces)?.is_some() {
ranges.push(range);
}
}
Event::End(_) | Event::Eof => return Ok(None),
Event::End(_) | Event::Eof => return Ok(ranges),
_ => {}
}
buffer.clear();
Expand Down Expand Up @@ -2427,4 +2470,52 @@ mod style_tests {
assert!(text.find("<p:style").unwrap() < text.find("<x:after-style").unwrap());
assert_eq!(CT_Shape::from_xml(&written).unwrap(), shape);
}

#[test]
fn moving_a_child_passes_only_the_children_between_its_indices() {
let shape = |id: u32| {
format!(
r#"<p:sp><p:nvSpPr><p:cNvPr id="{id}" name="S{id}"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr><p:spPr/></p:sp>"#
)
};
let xml = format!(
r#"<p:spTree xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/>{}<x:ink xmlns:x="urn:producer" x:kept="between"/>{}{}<p:extLst><p:ext uri="{{kept}}"/></p:extLst></p:spTree>"#,
shape(2),
shape(3),
shape(4)
);
let mut tree = CT_ShapeTree::from_xml(xml.as_bytes()).unwrap();
let ids = |tree: &CT_ShapeTree| {
tree.children
.iter()
.filter_map(ShapeTreeChild::non_visual_id)
.collect::<Vec<_>>()
};
let order = |tree: &CT_ShapeTree| {
let text = String::from_utf8(tree.to_xml().unwrap()).unwrap();
let mut marks = [r#"id="2""#, r#"id="3""#, r#"id="4""#, "<x:ink", "<p:extLst"]
.map(|mark| (text.find(mark).unwrap(), mark));
marks.sort();
marks.map(|(_, mark)| mark)
};

tree.move_child(0, 2).unwrap();
assert_eq!(ids(&tree), [3, 4, 2]);
assert_eq!(
order(&tree),
["<x:ink", r#"id="3""#, r#"id="4""#, r#"id="2""#, "<p:extLst"]
);
tree.move_child(2, 1).unwrap();
assert_eq!(ids(&tree), [3, 2, 4]);
tree.move_child(1, 0).unwrap();
assert_eq!(
order(&tree),
["<x:ink", r#"id="2""#, r#"id="3""#, r#"id="4""#, "<p:extLst"]
);
let before = tree.to_xml().unwrap();
tree.move_child(1, 1).unwrap();
assert!(tree.move_child(0, 3).is_err());
assert!(tree.move_child(3, 0).is_err());
assert_eq!(tree.to_xml().unwrap(), before);
}
}
5 changes: 5 additions & 0 deletions crates/rpptx-py/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ with open("review.pdf", "wb") as output:
- Read speaker-note text and inspect or mutate modern comment threads.
- Python collections with negative indexes, slices, iteration, and explicit
stale-handle errors after structural changes.
- Table cell merge and split, cell fills, margins, and borders, and row
heights.
- Picture crop, read and written as in python-pptx.
- Shape z-order through `slide.shapes.move(from_, to)`.
- Run hyperlinks, read, added, retargeted, and removed as in python-pptx.

## Use it when

Expand Down
89 changes: 88 additions & 1 deletion crates/rpptx-py/python/rpptx/_rpptx.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ __all__ = [
"SlideCollection", "Shape", "ShapeCollection", "PlaceholderCollection",
"Image", "AdjustmentCollection", "FillFormat", "LineFormat", "ColorFormat",
"TextFrame", "Paragraph", "ParagraphCollection", "Run", "RunCollection",
"Font", "Table", "Column", "ColumnCollection", "Cell",
"Hyperlink", "Font", "Table", "Column", "ColumnCollection", "Row",
"RowCollection", "Cell",
]


Expand Down Expand Up @@ -289,6 +290,22 @@ class Shape:
def xml(self) -> bytes: ...
@property
def image(self) -> Image: ...
@property
def crop_left(self) -> float: ...
@crop_left.setter
def crop_left(self, value: float) -> None: ...
@property
def crop_top(self) -> float: ...
@crop_top.setter
def crop_top(self, value: float) -> None: ...
@property
def crop_right(self) -> float: ...
@crop_right.setter
def crop_right(self, value: float) -> None: ...
@property
def crop_bottom(self) -> float: ...
@crop_bottom.setter
def crop_bottom(self, value: float) -> None: ...
def replace_image(self, image_file: _ImageFile) -> None: ...
@property
def shapes(self) -> ShapeCollection: ...
Expand Down Expand Up @@ -344,6 +361,7 @@ class ShapeCollection:
height: int | None = None,
) -> Shape: ...
def remove(self, shape: Shape) -> None: ...
def move(self, from_: int, to: int) -> None: ...


@_final
Expand Down Expand Up @@ -517,6 +535,17 @@ class Run:
def text(self, value: str) -> None: ...
@property
def font(self) -> Font: ...
@property
def hyperlink(self) -> Hyperlink: ...


@_final
class Hyperlink:
def __new__(cls, *, _private: _Never) -> Hyperlink: ...
@property
def address(self) -> str | None: ...
@address.setter
def address(self, value: str | None) -> None: ...


@_final
Expand Down Expand Up @@ -572,6 +601,8 @@ class Table:
def __new__(cls, *, _private: _Never) -> Table: ...
@property
def columns(self) -> ColumnCollection: ...
@property
def rows(self) -> RowCollection: ...
def cell(self, row: int, col: int) -> Cell: ...


Expand All @@ -595,10 +626,66 @@ class Column:
def width(self, value: int) -> None: ...


@_final
class RowCollection:
def __new__(cls, *, _private: _Never) -> RowCollection: ...
def __len__(self) -> int: ...
@_overload
def __getitem__(self, key: int, /) -> Row: ...
@_overload
def __getitem__(self, key: slice, /) -> list[Row]: ...
def __iter__(self) -> _Iterator[Row]: ...


@_final
class Row:
def __new__(cls, *, _private: _Never) -> Row: ...
@property
def height(self) -> _Length: ...
@height.setter
def height(self, value: int) -> None: ...


@_final
class Cell:
def __new__(cls, *, _private: _Never) -> Cell: ...
@property
def text(self) -> str: ...
@text.setter
def text(self, value: str) -> None: ...
def merge(self, other_cell: Cell) -> None: ...
def split(self) -> None: ...
@property
def is_merge_origin(self) -> bool: ...
@property
def is_spanned(self) -> bool: ...
@property
def span_height(self) -> int: ...
@property
def span_width(self) -> int: ...
@property
def fill(self) -> FillFormat: ...
@property
def margin_left(self) -> _Length | None: ...
@margin_left.setter
def margin_left(self, value: int | None) -> None: ...
@property
def margin_right(self) -> _Length | None: ...
@margin_right.setter
def margin_right(self, value: int | None) -> None: ...
@property
def margin_top(self) -> _Length | None: ...
@margin_top.setter
def margin_top(self, value: int | None) -> None: ...
@property
def margin_bottom(self) -> _Length | None: ...
@margin_bottom.setter
def margin_bottom(self, value: int | None) -> None: ...
@property
def border_left(self) -> LineFormat: ...
@property
def border_right(self) -> LineFormat: ...
@property
def border_top(self) -> LineFormat: ...
@property
def border_bottom(self) -> LineFormat: ...
Loading
Loading