From 366e1eb251514d62a211ff2a34c6e9a2bb6d92f5 Mon Sep 17 00:00:00 2001 From: Steven Obiajulu Date: Mon, 2 Mar 2026 21:19:05 -0500 Subject: [PATCH] fix: exclude MoveFrom ghost text from paragraph.raw_text() Add first-class MoveFrom/MoveTo tracked-change support following the existing Insert/Delete pattern. When text is cut-and-pasted in Word with Track Changes, both moveFrom (ghost) and moveTo (live) contain the same text. raw_text() now skips MoveFrom and includes MoveTo, preventing moved text from appearing twice in output. Also prevent nested MoveFrom subtrees from flattening into parent containers (Insert, Delete, Hyperlink, MoveTo) via ignore_element(). Fixes #794 --- docx-core/src/documents/elements/fit_text.rs | 4 +- docx-core/src/documents/elements/mod.rs | 8 +- docx-core/src/documents/elements/move_from.rs | 133 ++++++++++++++ docx-core/src/documents/elements/move_to.rs | 168 ++++++++++++++++++ docx-core/src/documents/elements/paragraph.rs | 66 ++++++- docx-core/src/reader/delete.rs | 3 + docx-core/src/reader/hyperlink.rs | 3 + docx-core/src/reader/insert.rs | 4 + docx-core/src/reader/mod.rs | 2 + docx-core/src/reader/move_from.rs | 61 +++++++ docx-core/src/reader/move_to.rs | 65 +++++++ docx-core/src/reader/paragraph.rs | 113 ++++++++++++ docx-core/src/reader/xml_element.rs | 4 + docx-core/src/xml_builder/elements.rs | 2 + docx-wasm/src/lib.rs | 4 + docx-wasm/src/move_from.rs | 30 ++++ docx-wasm/src/move_to.rs | 30 ++++ docx-wasm/src/paragraph.rs | 14 ++ 18 files changed, 710 insertions(+), 4 deletions(-) create mode 100644 docx-core/src/documents/elements/move_from.rs create mode 100644 docx-core/src/documents/elements/move_to.rs create mode 100644 docx-core/src/reader/move_from.rs create mode 100644 docx-core/src/reader/move_to.rs create mode 100644 docx-wasm/src/move_from.rs create mode 100644 docx-wasm/src/move_to.rs diff --git a/docx-core/src/documents/elements/fit_text.rs b/docx-core/src/documents/elements/fit_text.rs index 7f48b88b1..ae009682f 100644 --- a/docx-core/src/documents/elements/fit_text.rs +++ b/docx-core/src/documents/elements/fit_text.rs @@ -27,7 +27,9 @@ impl BuildXML for FitText { &self, stream: crate::xml::writer::EventWriter, ) -> crate::xml::writer::Result> { - XMLBuilder::from(stream).fit_text(self.val, self.id)?.into_inner() + XMLBuilder::from(stream) + .fit_text(self.val, self.id)? + .into_inner() } } diff --git a/docx-core/src/documents/elements/mod.rs b/docx-core/src/documents/elements/mod.rs index 55a0a4dd2..eb8160983 100644 --- a/docx-core/src/documents/elements/mod.rs +++ b/docx-core/src/documents/elements/mod.rs @@ -29,8 +29,8 @@ mod doc_id; mod doc_var; mod drawing; mod dstrike; -mod fld_char; mod fit_text; +mod fld_char; mod font; mod font_scheme; mod footer_reference; @@ -63,6 +63,8 @@ mod level_text; mod line_spacing; mod link; mod mc_fallback; +mod move_from; +mod move_to; mod name; mod next; mod num_pages; @@ -170,8 +172,8 @@ pub use doc_id::*; pub use doc_var::*; pub use drawing::*; pub use dstrike::*; -pub use fld_char::*; pub use fit_text::*; +pub use fld_char::*; pub use font::*; pub use font_scheme::*; pub use footer_reference::*; @@ -204,6 +206,8 @@ pub use level_text::*; pub use line_spacing::*; pub use link::*; pub use mc_fallback::*; +pub use move_from::*; +pub use move_to::*; pub use name::*; pub use next::*; pub use num_pages::*; diff --git a/docx-core/src/documents/elements/move_from.rs b/docx-core/src/documents/elements/move_from.rs new file mode 100644 index 000000000..dd4abd56b --- /dev/null +++ b/docx-core/src/documents/elements/move_from.rs @@ -0,0 +1,133 @@ +use serde::ser::{SerializeStruct, Serializer}; +use serde::Serialize; +use std::io::Write; + +use crate::xml_builder::*; +use crate::{documents::*, escape}; + +#[derive(Serialize, Debug, Clone, PartialEq)] +pub struct MoveFrom { + pub author: String, + pub date: String, + pub children: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum MoveFromChild { + Run(Box), + CommentStart(Box), + CommentEnd(CommentRangeEnd), +} + +impl Serialize for MoveFromChild { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match *self { + MoveFromChild::Run(ref r) => { + let mut t = serializer.serialize_struct("Run", 2)?; + t.serialize_field("type", "run")?; + t.serialize_field("data", r)?; + t.end() + } + MoveFromChild::CommentStart(ref r) => { + let mut t = serializer.serialize_struct("CommentRangeStart", 2)?; + t.serialize_field("type", "commentRangeStart")?; + t.serialize_field("data", r)?; + t.end() + } + MoveFromChild::CommentEnd(ref r) => { + let mut t = serializer.serialize_struct("CommentRangeEnd", 2)?; + t.serialize_field("type", "commentRangeEnd")?; + t.serialize_field("data", r)?; + t.end() + } + } + } +} + +impl Default for MoveFrom { + fn default() -> MoveFrom { + MoveFrom { + author: "unnamed".to_owned(), + date: "1970-01-01T00:00:00Z".to_owned(), + children: vec![], + } + } +} + +impl MoveFrom { + pub fn new() -> MoveFrom { + Self { + children: vec![], + ..Default::default() + } + } + + pub fn add_run(mut self, run: Run) -> MoveFrom { + self.children.push(MoveFromChild::Run(Box::new(run))); + self + } + + pub fn add_comment_start(mut self, comment: Comment) -> MoveFrom { + self.children.push(MoveFromChild::CommentStart(Box::new( + CommentRangeStart::new(comment), + ))); + self + } + + pub fn add_comment_end(mut self, id: usize) -> MoveFrom { + self.children + .push(MoveFromChild::CommentEnd(CommentRangeEnd::new(id))); + self + } + + pub fn author(mut self, author: impl Into) -> MoveFrom { + self.author = escape::escape(&author.into()); + self + } + + pub fn date(mut self, date: impl Into) -> MoveFrom { + self.date = date.into(); + self + } +} + +impl HistoryId for MoveFrom {} + +impl BuildXML for MoveFrom { + fn build_to( + &self, + stream: crate::xml::writer::EventWriter, + ) -> crate::xml::writer::Result> { + let id = self.generate(); + XMLBuilder::from(stream) + .open_move_from(&id, &self.author, &self.date)? + .apply_each(&self.children, |ch, b| match ch { + MoveFromChild::Run(t) => b.add_child(&**t), + MoveFromChild::CommentStart(c) => b.add_child(&**c), + MoveFromChild::CommentEnd(c) => b.add_child(c), + })? + .close()? + .into_inner() + } +} + +#[cfg(test)] +mod tests { + + use super::*; + #[cfg(test)] + use pretty_assertions::assert_eq; + use std::str; + + #[test] + fn test_move_from_default() { + let b = MoveFrom::new().add_run(Run::new()).build(); + assert_eq!( + str::from_utf8(&b).unwrap(), + r#""# + ); + } +} diff --git a/docx-core/src/documents/elements/move_to.rs b/docx-core/src/documents/elements/move_to.rs new file mode 100644 index 000000000..4f6ef60da --- /dev/null +++ b/docx-core/src/documents/elements/move_to.rs @@ -0,0 +1,168 @@ +use serde::ser::{SerializeStruct, Serializer}; +use serde::Serialize; +use std::io::Write; + +use super::*; + +use crate::documents::{BuildXML, HistoryId, Run}; +use crate::{escape, xml_builder::*}; + +#[derive(Debug, Clone, PartialEq)] +pub enum MoveToChild { + Run(Box), + Delete(Delete), + CommentStart(Box), + CommentEnd(CommentRangeEnd), +} + +impl BuildXML for MoveToChild { + fn build_to( + &self, + stream: crate::xml::writer::EventWriter, + ) -> crate::xml::writer::Result> { + match self { + MoveToChild::Run(v) => v.build_to(stream), + MoveToChild::Delete(v) => v.build_to(stream), + MoveToChild::CommentStart(v) => v.build_to(stream), + MoveToChild::CommentEnd(v) => v.build_to(stream), + } + } +} + +impl Serialize for MoveToChild { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match *self { + MoveToChild::Run(ref r) => { + let mut t = serializer.serialize_struct("Run", 2)?; + t.serialize_field("type", "run")?; + t.serialize_field("data", r)?; + t.end() + } + MoveToChild::Delete(ref r) => { + let mut t = serializer.serialize_struct("Delete", 2)?; + t.serialize_field("type", "delete")?; + t.serialize_field("data", r)?; + t.end() + } + MoveToChild::CommentStart(ref r) => { + let mut t = serializer.serialize_struct("CommentRangeStart", 2)?; + t.serialize_field("type", "commentRangeStart")?; + t.serialize_field("data", r)?; + t.end() + } + MoveToChild::CommentEnd(ref r) => { + let mut t = serializer.serialize_struct("CommentRangeEnd", 2)?; + t.serialize_field("type", "commentRangeEnd")?; + t.serialize_field("data", r)?; + t.end() + } + } + } +} + +#[derive(Serialize, Debug, Clone, PartialEq)] +pub struct MoveTo { + pub children: Vec, + pub author: String, + pub date: String, +} + +impl Default for MoveTo { + fn default() -> MoveTo { + MoveTo { + author: "unnamed".to_owned(), + date: "1970-01-01T00:00:00Z".to_owned(), + children: vec![], + } + } +} + +impl MoveTo { + pub fn new(run: Run) -> MoveTo { + Self { + children: vec![MoveToChild::Run(Box::new(run))], + ..Default::default() + } + } + + pub fn new_with_empty() -> MoveTo { + Self { + ..Default::default() + } + } + + pub fn add_run(mut self, run: Run) -> MoveTo { + self.children.push(MoveToChild::Run(Box::new(run))); + self + } + + pub fn add_delete(mut self, del: Delete) -> MoveTo { + self.children.push(MoveToChild::Delete(del)); + self + } + + pub fn add_child(mut self, c: MoveToChild) -> MoveTo { + self.children.push(c); + self + } + + pub fn add_comment_start(mut self, comment: Comment) -> Self { + self.children + .push(MoveToChild::CommentStart(Box::new(CommentRangeStart::new( + comment, + )))); + self + } + + pub fn add_comment_end(mut self, id: usize) -> Self { + self.children + .push(MoveToChild::CommentEnd(CommentRangeEnd::new(id))); + self + } + + pub fn author(mut self, author: impl Into) -> MoveTo { + self.author = escape::escape(&author.into()); + self + } + + pub fn date(mut self, date: impl Into) -> MoveTo { + self.date = date.into(); + self + } +} + +impl HistoryId for MoveTo {} + +impl BuildXML for MoveTo { + fn build_to( + &self, + stream: crate::xml::writer::EventWriter, + ) -> crate::xml::writer::Result> { + XMLBuilder::from(stream) + .open_move_to(&self.generate(), &self.author, &self.date)? + .add_children(&self.children)? + .close()? + .into_inner() + } +} + +#[cfg(test)] +mod tests { + + use super::*; + #[cfg(test)] + use pretty_assertions::assert_eq; + use std::str; + + #[test] + fn test_move_to_default() { + let b = MoveTo::new(Run::new()).build(); + assert_eq!( + str::from_utf8(&b).unwrap(), + r#""# + ); + } +} diff --git a/docx-core/src/documents/elements/paragraph.rs b/docx-core/src/documents/elements/paragraph.rs index 63afaf000..278d49f4f 100644 --- a/docx-core/src/documents/elements/paragraph.rs +++ b/docx-core/src/documents/elements/paragraph.rs @@ -32,6 +32,8 @@ pub enum ParagraphChild { Run(Box), Insert(Insert), Delete(Delete), + MoveFrom(MoveFrom), + MoveTo(MoveTo), BookmarkStart(BookmarkStart), Hyperlink(Hyperlink), BookmarkEnd(BookmarkEnd), @@ -51,6 +53,8 @@ impl BuildXML for ParagraphChild { ParagraphChild::Run(v) => v.build_to(stream), ParagraphChild::Insert(v) => v.build_to(stream), ParagraphChild::Delete(v) => v.build_to(stream), + ParagraphChild::MoveFrom(v) => v.build_to(stream), + ParagraphChild::MoveTo(v) => v.build_to(stream), ParagraphChild::Hyperlink(v) => v.build_to(stream), ParagraphChild::BookmarkStart(v) => v.build_to(stream), ParagraphChild::BookmarkEnd(v) => v.build_to(stream), @@ -87,6 +91,18 @@ impl Serialize for ParagraphChild { t.serialize_field("data", r)?; t.end() } + ParagraphChild::MoveFrom(ref r) => { + let mut t = serializer.serialize_struct("MoveFrom", 2)?; + t.serialize_field("type", "moveFrom")?; + t.serialize_field("data", r)?; + t.end() + } + ParagraphChild::MoveTo(ref r) => { + let mut t = serializer.serialize_struct("MoveTo", 2)?; + t.serialize_field("type", "moveTo")?; + t.serialize_field("data", r)?; + t.end() + } ParagraphChild::Hyperlink(ref r) => { let mut t = serializer.serialize_struct("hyperlink", 2)?; t.serialize_field("type", "hyperlink")?; @@ -195,6 +211,16 @@ impl Paragraph { self } + pub fn add_move_from(mut self, move_from: MoveFrom) -> Paragraph { + self.children.push(ParagraphChild::MoveFrom(move_from)); + self + } + + pub fn add_move_to(mut self, move_to: MoveTo) -> Paragraph { + self.children.push(ParagraphChild::MoveTo(move_to)); + self + } + pub fn add_bookmark_start(mut self, id: usize, name: impl Into) -> Paragraph { self.children .push(ParagraphChild::BookmarkStart(BookmarkStart::new(id, name))); @@ -367,7 +393,8 @@ impl Paragraph { pub fn raw_text(&self) -> String { let mut s = "".to_string(); - // For now support only run and ins. + // For now support only run, ins, and moveTo. + // MoveFrom is skipped — it contains ghost text from the original location. for c in self.children.iter() { match c { ParagraphChild::Insert(i) => { @@ -388,6 +415,20 @@ impl Paragraph { } } } + ParagraphChild::MoveTo(mt) => { + for c in mt.children.iter() { + if let MoveToChild::Run(r) = c { + for c in r.children.iter() { + if let RunChild::Text(t) = c { + s.push_str(&t.text); + } + } + } + } + } + ParagraphChild::MoveFrom(_) => { + // Skip — ghost text from original location + } _ => {} } } @@ -620,4 +661,27 @@ mod tests { .raw_text(); assert_eq!(b, "HelloWorld".to_owned()); } + + #[test] + fn test_raw_text_move_from_to_dedup() { + // MoveFrom text should be excluded (ghost text from original location), + // MoveTo text should be included (destination, live text). + let b = Paragraph::new() + .add_run(Run::new().add_text("Hello ")) + .add_move_from(MoveFrom::new().add_run(Run::new().add_text("world"))) + .add_move_to(MoveTo::new(Run::new().add_text("world"))) + .raw_text(); + assert_eq!(b, "Hello world".to_owned()); + } + + #[test] + fn test_raw_text_mixed_content_with_move() { + let b = Paragraph::new() + .add_run(Run::new().add_text("Start ")) + .add_move_from(MoveFrom::new().add_run(Run::new().add_text("moved"))) + .add_move_to(MoveTo::new(Run::new().add_text("moved"))) + .add_run(Run::new().add_text(" end")) + .raw_text(); + assert_eq!(b, "Start moved end".to_owned()); + } } diff --git a/docx-core/src/reader/delete.rs b/docx-core/src/reader/delete.rs index 64a87fbe8..023f5855b 100644 --- a/docx-core/src/reader/delete.rs +++ b/docx-core/src/reader/delete.rs @@ -36,6 +36,9 @@ impl ElementReader for Delete { } } } + XMLElement::MoveFrom => { + ignore::ignore_element(XMLElement::MoveFrom, XMLElement::MoveFrom, r); + } _ => {} } } diff --git a/docx-core/src/reader/hyperlink.rs b/docx-core/src/reader/hyperlink.rs index c0358f2f8..97990dadb 100644 --- a/docx-core/src/reader/hyperlink.rs +++ b/docx-core/src/reader/hyperlink.rs @@ -84,6 +84,9 @@ impl ElementReader for Hyperlink { } continue; } + XMLElement::MoveFrom => { + ignore::ignore_element(XMLElement::MoveFrom, XMLElement::MoveFrom, r); + } _ => {} } } diff --git a/docx-core/src/reader/insert.rs b/docx-core/src/reader/insert.rs index 39ceeb205..8ced6f134 100644 --- a/docx-core/src/reader/insert.rs +++ b/docx-core/src/reader/insert.rs @@ -36,6 +36,10 @@ impl ElementReader for Insert { } continue; } + XMLElement::MoveFrom => { + // Skip moveFrom subtree — ghost text must not flatten into Insert children + ignore::ignore_element(XMLElement::MoveFrom, XMLElement::MoveFrom, r); + } _ => {} } } diff --git a/docx-core/src/reader/mod.rs b/docx-core/src/reader/mod.rs index 986d251bf..c46e6e0cc 100644 --- a/docx-core/src/reader/mod.rs +++ b/docx-core/src/reader/mod.rs @@ -31,6 +31,8 @@ mod insert; mod level; mod level_override; mod mc_fallback; +mod move_from; +mod move_to; mod namespace; mod numbering_property; mod numberings; diff --git a/docx-core/src/reader/move_from.rs b/docx-core/src/reader/move_from.rs new file mode 100644 index 000000000..2cc8c9978 --- /dev/null +++ b/docx-core/src/reader/move_from.rs @@ -0,0 +1,61 @@ +use std::io::Read; +use std::str::FromStr; + +use super::*; + +impl ElementReader for MoveFrom { + fn read( + r: &mut EventReader, + attrs: &[OwnedAttribute], + ) -> Result { + let mut mf = MoveFrom::new(); + loop { + let e = r.next(); + match e { + Ok(XmlEvent::StartElement { + name, attributes, .. + }) => { + let e = XMLElement::from_str(&name.local_name) + .expect("should convert to XMLElement"); + match e { + XMLElement::Run => { + mf = mf.add_run(Run::read(r, &attributes)?); + } + XMLElement::CommentRangeStart => { + if let Some(id) = read(&attributes, "id") { + if let Ok(id) = usize::from_str(&id) { + let comment = Comment::new(id); + mf = mf.add_comment_start(comment); + } + } + } + XMLElement::CommentRangeEnd => { + if let Some(id) = read(&attributes, "id") { + if let Ok(id) = usize::from_str(&id) { + mf = mf.add_comment_end(id); + } + } + } + _ => {} + } + } + Ok(XmlEvent::EndElement { name, .. }) => { + let e = XMLElement::from_str(&name.local_name).unwrap(); + if e == XMLElement::MoveFrom { + for attr in attrs { + let local_name = &attr.name.local_name; + if local_name == "author" { + mf = mf.author(&attr.value); + } else if local_name == "date" { + mf = mf.date(&attr.value); + } + } + return Ok(mf); + } + } + Err(_) => return Err(ReaderError::XMLReadError), + _ => {} + } + } + } +} diff --git a/docx-core/src/reader/move_to.rs b/docx-core/src/reader/move_to.rs new file mode 100644 index 000000000..58a382ad2 --- /dev/null +++ b/docx-core/src/reader/move_to.rs @@ -0,0 +1,65 @@ +use std::io::Read; +use std::str::FromStr; + +use super::*; + +impl ElementReader for MoveTo { + fn read( + r: &mut EventReader, + attrs: &[OwnedAttribute], + ) -> Result { + let mut mt = MoveTo::new_with_empty(); + loop { + let e = r.next(); + match e { + Ok(XmlEvent::StartElement { + name, attributes, .. + }) => { + let e = XMLElement::from_str(&name.local_name).unwrap(); + match e { + XMLElement::Run => mt = mt.add_run(Run::read(r, &attributes)?), + XMLElement::Delete => mt = mt.add_delete(Delete::read(r, &attributes)?), + XMLElement::CommentRangeStart => { + if let Some(id) = read(&attributes, "id") { + if let Ok(id) = usize::from_str(&id) { + let comment = Comment::new(id); + mt = mt.add_comment_start(comment); + } + } + continue; + } + XMLElement::CommentRangeEnd => { + if let Some(id) = read(&attributes, "id") { + if let Ok(id) = usize::from_str(&id) { + mt = mt.add_comment_end(id); + } + } + continue; + } + XMLElement::MoveFrom => { + // Skip moveFrom subtree — ghost text must not flatten into MoveTo children + ignore::ignore_element(XMLElement::MoveFrom, XMLElement::MoveFrom, r); + } + _ => {} + } + } + Ok(XmlEvent::EndElement { name, .. }) => { + let e = XMLElement::from_str(&name.local_name).unwrap(); + if e == XMLElement::MoveTo { + for attr in attrs { + let local_name = &attr.name.local_name; + if local_name == "author" { + mt = mt.author(&attr.value); + } else if local_name == "date" { + mt = mt.date(&attr.value); + } + } + return Ok(mt); + } + } + Err(_) => return Err(ReaderError::XMLReadError), + _ => {} + } + } + } +} diff --git a/docx-core/src/reader/paragraph.rs b/docx-core/src/reader/paragraph.rs index 922059a4a..0585e7206 100644 --- a/docx-core/src/reader/paragraph.rs +++ b/docx-core/src/reader/paragraph.rs @@ -43,6 +43,16 @@ impl ElementReader for Paragraph { p = p.add_delete(del); continue; } + XMLElement::MoveFrom => { + let mf = MoveFrom::read(r, &attributes)?; + p = p.add_move_from(mf); + continue; + } + XMLElement::MoveTo => { + let mt = MoveTo::read(r, &attributes)?; + p = p.add_move_to(mt); + continue; + } XMLElement::BookmarkStart => { let s = BookmarkStart::read(r, &attributes)?; p = p.add_bookmark_start(s.id, s.name); @@ -450,4 +460,107 @@ mod tests { } ); } + + #[test] + fn test_read_move_from_to() { + let c = r#" + + + + + moved + + + + + + moved + + + +"#; + let mut parser = EventReader::new(c.as_bytes()); + let p = Paragraph::read(&mut parser, &[]).unwrap(); + assert_eq!( + p, + Paragraph { + id: "12345678".to_owned(), + children: vec![ + ParagraphChild::MoveFrom( + MoveFrom::new() + .add_run(Run::new().add_text("moved")) + .author("user1") + .date("2024-01-01T00:00:00Z") + ), + ParagraphChild::MoveTo( + MoveTo::new(Run::new().add_text("moved")) + .author("user1") + .date("2024-01-01T00:00:00Z") + ), + ], + property: ParagraphProperty { + run_property: RunProperty::new(), + style: None, + numbering_property: None, + alignment: None, + indent: None, + line_spacing: None, + ..Default::default() + }, + has_numbering: false, + } + ); + // Verify raw_text excludes MoveFrom and includes MoveTo + assert_eq!(p.raw_text(), "moved"); + } + + #[test] + fn test_read_move_from_nested_in_insert() { + // MoveFrom inside Insert: ghost text should be skipped, not flattened into Insert runs + let c = r#" + + + + + + ghost + + + + + live + + + +"#; + let mut parser = EventReader::new(c.as_bytes()); + let p = Paragraph::read(&mut parser, &[]).unwrap(); + // The MoveFrom ghost text should have been skipped by ignore_element in the insert reader + assert_eq!(p.raw_text(), "live"); + } + + #[test] + fn test_read_move_from_nested_in_move_to() { + // MoveFrom inside MoveTo: ghost text should be skipped, not flattened into MoveTo runs + let c = r#" + + + + + + ghost + + + + + live + + + +"#; + let mut parser = EventReader::new(c.as_bytes()); + let p = Paragraph::read(&mut parser, &[]).unwrap(); + // The MoveFrom ghost text should have been skipped by ignore_element in the moveTo reader + assert_eq!(p.raw_text(), "live"); + } } diff --git a/docx-core/src/reader/xml_element.rs b/docx-core/src/reader/xml_element.rs index 20f68fca5..44cbc5c8d 100644 --- a/docx-core/src/reader/xml_element.rs +++ b/docx-core/src/reader/xml_element.rs @@ -53,6 +53,8 @@ pub enum XMLElement { Justification, OutlineLvl, Insert, + MoveFrom, + MoveTo, SnapToGrid, KeepNext, KeepLines, @@ -299,6 +301,8 @@ impl FromStr for XMLElement { "numId" => Ok(XMLElement::NumberingId), "jc" => Ok(XMLElement::Justification), "ins" => Ok(XMLElement::Insert), + "moveFrom" => Ok(XMLElement::MoveFrom), + "moveTo" => Ok(XMLElement::MoveTo), "del" => Ok(XMLElement::Delete), "delText" => Ok(XMLElement::DeleteText), "bookmarkStart" => Ok(XMLElement::BookmarkStart), diff --git a/docx-core/src/xml_builder/elements.rs b/docx-core/src/xml_builder/elements.rs index 992c2e17c..fbcb6debd 100644 --- a/docx-core/src/xml_builder/elements.rs +++ b/docx-core/src/xml_builder/elements.rs @@ -438,6 +438,8 @@ impl XMLBuilder { open!(open_insert, "w:ins", "w:id", "w:author", "w:date"); open!(open_delete, "w:del", "w:id", "w:author", "w:date"); + open!(open_move_from, "w:moveFrom", "w:id", "w:author", "w:date"); + open!(open_move_to, "w:moveTo", "w:id", "w:author", "w:date"); open!( open_paragraph_property_change, "w:pPrChange", diff --git a/docx-wasm/src/lib.rs b/docx-wasm/src/lib.rs index 406328ab7..4eeedd3d5 100644 --- a/docx-wasm/src/lib.rs +++ b/docx-wasm/src/lib.rs @@ -11,6 +11,8 @@ mod insert; mod level; mod level_override; mod line_spacing; +mod move_from; +mod move_to; mod num_pages; mod numbering; mod page_margin; @@ -47,6 +49,8 @@ pub use insert::*; pub use level::*; pub use level_override::*; pub use line_spacing::*; +pub use move_from::*; +pub use move_to::*; pub use num_pages::*; pub use numbering::*; pub use page_margin::*; diff --git a/docx-wasm/src/move_from.rs b/docx-wasm/src/move_from.rs new file mode 100644 index 000000000..78d7657ad --- /dev/null +++ b/docx-wasm/src/move_from.rs @@ -0,0 +1,30 @@ +use super::*; +use wasm_bindgen::prelude::*; + +#[wasm_bindgen] +#[derive(Debug)] +pub struct MoveFrom(docx_rs::MoveFrom); + +#[wasm_bindgen(js_name = createMoveFrom)] +pub fn create_move_from(run: Run) -> MoveFrom { + MoveFrom(docx_rs::MoveFrom::new().add_run(run.take())) +} + +impl MoveFrom { + pub fn take(self) -> docx_rs::MoveFrom { + self.0 + } +} + +#[wasm_bindgen] +impl MoveFrom { + pub fn author(mut self, author: String) -> MoveFrom { + self.0 = self.0.author(author); + self + } + + pub fn date(mut self, date: String) -> MoveFrom { + self.0 = self.0.date(date); + self + } +} diff --git a/docx-wasm/src/move_to.rs b/docx-wasm/src/move_to.rs new file mode 100644 index 000000000..b20e556cf --- /dev/null +++ b/docx-wasm/src/move_to.rs @@ -0,0 +1,30 @@ +use super::*; +use wasm_bindgen::prelude::*; + +#[wasm_bindgen] +#[derive(Debug)] +pub struct MoveTo(docx_rs::MoveTo); + +#[wasm_bindgen(js_name = createMoveTo)] +pub fn create_move_to(run: Run) -> MoveTo { + MoveTo(docx_rs::MoveTo::new(run.take())) +} + +impl MoveTo { + pub fn take(self) -> docx_rs::MoveTo { + self.0 + } +} + +#[wasm_bindgen] +impl MoveTo { + pub fn author(mut self, author: String) -> MoveTo { + self.0 = self.0.author(author); + self + } + + pub fn date(mut self, date: String) -> MoveTo { + self.0 = self.0.date(date); + self + } +} diff --git a/docx-wasm/src/paragraph.rs b/docx-wasm/src/paragraph.rs index 03c71129b..8de3e1f3b 100644 --- a/docx-wasm/src/paragraph.rs +++ b/docx-wasm/src/paragraph.rs @@ -36,6 +36,20 @@ impl Paragraph { self } + pub fn add_move_from(mut self, mf: MoveFrom) -> Paragraph { + self.0 + .children + .push(docx_rs::ParagraphChild::MoveFrom(mf.take())); + self + } + + pub fn add_move_to(mut self, mt: MoveTo) -> Paragraph { + self.0 + .children + .push(docx_rs::ParagraphChild::MoveTo(mt.take())); + self + } + pub fn add_bookmark_start(mut self, id: usize, name: &str) -> Paragraph { self.0.children.push(docx_rs::ParagraphChild::BookmarkStart( docx_rs::BookmarkStart::new(id, name),