diff --git a/crates/rdocx-opc/src/relationship.rs b/crates/rdocx-opc/src/relationship.rs index eba0c18..d8a22f0 100644 --- a/crates/rdocx-opc/src/relationship.rs +++ b/crates/rdocx-opc/src/relationship.rs @@ -191,6 +191,20 @@ impl Relationships { id } + /// Add an externally-targeted relationship (e.g. a hyperlink URL) and + /// return its generated ID. + pub fn add_external(&mut self, rel_type: &str, target: &str) -> String { + let id = format!("rId{}", self.next_id); + self.next_id += 1; + self.items.push(Relationship { + id: id.clone(), + rel_type: rel_type.to_string(), + target: target.to_string(), + target_mode: Some("External".to_string()), + }); + id + } + /// Add a relationship with a specific ID. /// /// If a relationship with this ID already exists, it is replaced. diff --git a/crates/rdocx/src/document.rs b/crates/rdocx/src/document.rs index 7118bf9..4866c2c 100644 --- a/crates/rdocx/src/document.rs +++ b/crates/rdocx/src/document.rs @@ -36,6 +36,8 @@ pub struct Document { doc_part_name: String, /// Cached count of image media parts (avoids rescanning parts on each embed). image_counter: usize, + /// Footnotes: loaded from word/footnotes.xml on open, written back on save. + footnotes: rdocx_oxml::footnotes::CT_Footnotes, } impl Document { @@ -58,6 +60,7 @@ impl Document { core_properties: None, doc_part_name: "/word/document.xml".to_string(), image_counter: 0, + footnotes: rdocx_oxml::footnotes::CT_Footnotes::new(), } } @@ -126,6 +129,14 @@ impl Document { .filter(|k| k.starts_with("/word/media/image")) .count(); + let footnotes = package + .get_part_rels(&doc_part_name) + .and_then(|rels| rels.get_by_type(rel_types::FOOTNOTES)) + .map(|rel| OpcPackage::resolve_rel_target(&doc_part_name, &rel.target)) + .and_then(|part| package.get_part(&part)) + .and_then(|xml| rdocx_oxml::footnotes::CT_Footnotes::from_xml(xml).ok()) + .unwrap_or_else(rdocx_oxml::footnotes::CT_Footnotes::new); + Ok(Document { package, document, @@ -134,6 +145,7 @@ impl Document { core_properties, doc_part_name, image_counter, + footnotes, }) } @@ -168,6 +180,20 @@ impl Document { self.package.set_part("/word/numbering.xml", numbering_xml); } + // Serialize footnotes.xml when any footnotes exist + if !self.footnotes.footnotes.is_empty() { + let fx = self.footnotes.to_xml_footnotes()?; + self.package.set_part("/word/footnotes.xml", fx); + self.package.content_types.add_override( + "/word/footnotes.xml", + "application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml", + ); + let rels = self.package.get_or_create_part_rels(&self.doc_part_name.clone()); + if rels.get_by_type(rel_types::FOOTNOTES).is_none() { + rels.add(rel_types::FOOTNOTES, "footnotes.xml"); + } + } + // Serialize docProps/core.xml if we have metadata if let Some(ref props) = self.core_properties { let core_xml = props.to_xml()?; @@ -192,6 +218,45 @@ impl Document { .collect() } + /// All footnotes as (id, plain text), in file order. + pub fn footnotes(&self) -> Vec<(i32, String)> { + self.footnotes + .footnotes + .iter() + .map(|f| { + let text = f + .paragraphs + .iter() + .map(|p| p.text()) + .collect::>() + .join("\n"); + (f.id, text) + }) + .collect() + } + + /// Add a footnote with the given text; returns its id. Pair with + /// `Paragraph::add_footnote_ref` to reference it from the body. + pub fn add_footnote(&mut self, text: &str) -> i32 { + use rdocx_oxml::footnotes::CT_Footnote; + use rdocx_oxml::text::CT_P; + let id = self + .footnotes + .footnotes + .iter() + .map(|f| f.id) + .max() + .unwrap_or(1) + + 1; + let mut p = CT_P::new(); + p.add_run(text); + self.footnotes.footnotes.push(CT_Footnote { + id, + paragraphs: vec![p], + }); + id + } + /// Add a paragraph with the given text and return a mutable reference. pub fn add_paragraph(&mut self, text: &str) -> Paragraph<'_> { let mut p = CT_P::new(); @@ -503,6 +568,73 @@ impl Document { rels.add(rel_types::IMAGE, &rel_target) } + /// Whether the given numbering definition renders as bullets (true) + /// or numbers (false). None if the id is unknown. + pub fn numbering_is_bullet(&self, num_id: u32) -> Option { + let numbering = self.numbering.as_ref()?; + let abstract_num = numbering.get_abstract_num_for(num_id)?; + let fmt = abstract_num.levels.first()?.num_fmt?; + Some(fmt == rdocx_oxml::numbering::ST_NumberFormat::Bullet) + } + + /// Append an external hyperlink to the last paragraph (creating one if + /// the document is empty): adds the External relationship and wraps the + /// new run in a hyperlink span. + pub fn append_hyperlink(&mut self, text: &str, url: &str) { + use rdocx_opc::relationship::rel_types; + + let rel_id = { + let rels = self.package.get_or_create_part_rels(&self.doc_part_name); + rels.add_external(rel_types::HYPERLINK, url) + }; + + if !matches!(self.document.body.content.last(), Some(BodyContent::Paragraph(_))) { + self.document.body.content.push(BodyContent::Paragraph(CT_P::new())); + } + let Some(BodyContent::Paragraph(p)) = self.document.body.content.last_mut() else { + unreachable!(); + }; + let run_start = p.runs.len(); + p.add_run(text); + p.hyperlinks.push(rdocx_oxml::text::HyperlinkSpan { + rel_id: Some(rel_id), + anchor: None, + run_start, + run_end: run_start + 1, + }); + } + + /// Get a builder for the last paragraph in the body, if any. Lets + /// callers interleave plain runs with `append_hyperlink` calls. + pub fn last_paragraph_mut(&mut self) -> Option> { + match self.document.body.content.last_mut() { + Some(BodyContent::Paragraph(p)) => Some(Paragraph { inner: p }), + _ => None, + } + } + + /// Fetch the raw bytes of an embedded image by its relationship ID. + pub fn image_data(&self, rel_id: &str) -> Option> { + let rels = self.package.get_part_rels(&self.doc_part_name)?; + let rel = rels.items.iter().find(|r| r.id == rel_id)?; + let target = if rel.target.starts_with('/') { + rel.target.clone() + } else { + format!("/word/{}", rel.target) + }; + self.package.get_part(&target).map(|b| b.to_vec()) + } + + /// Resolve a hyperlink relationship ID to its external URL. + pub fn hyperlink_url(&self, rel_id: &str) -> Option { + use rdocx_opc::relationship::rel_types; + let rels = self.package.get_part_rels(&self.doc_part_name)?; + rels.items + .iter() + .find(|r| r.id == rel_id && r.rel_type == rel_types::HYPERLINK) + .map(|r| r.target.clone()) + } + // ---- Header/Footer ---- /// Set the default header text. @@ -3164,4 +3296,116 @@ mod tests { doc.add_paragraph("Just text."); assert!(doc.images().is_empty()); } + + #[test] + fn numbering_getter_round_trips() { + let mut doc = Document::new(); + doc.add_bullet_list_item("bullet item", 0); + doc.add_numbered_list_item("numbered item", 0); + doc.add_paragraph("plain"); + + let bytes = doc.to_bytes().unwrap(); + let doc2 = Document::from_bytes(&bytes).unwrap(); + let paras = doc2.paragraphs(); + + let (bullet_id, bullet_lvl) = paras[0].numbering().expect("bullet numbering"); + assert_eq!(bullet_lvl, 0); + assert_eq!(doc2.numbering_is_bullet(bullet_id), Some(true)); + + let (num_id, _) = paras[1].numbering().expect("numbered numbering"); + assert_eq!(doc2.numbering_is_bullet(num_id), Some(false)); + + assert!(paras[2].numbering().is_none()); + } + + #[test] + fn highlight_getter_round_trips() { + let mut doc = Document::new(); + { + let mut p = doc.add_paragraph(""); + let mut r = p.add_run("glowing"); + r = r.highlight("yellow"); + let _ = r; + } + + let bytes = doc.to_bytes().unwrap(); + let doc2 = Document::from_bytes(&bytes).unwrap(); + let paras = doc2.paragraphs(); + let run = paras[0].runs().next().expect("run"); + assert_eq!(run.highlight().as_deref(), Some("yellow")); + } + + #[test] + fn run_style_id_getter_round_trips() { + let mut doc = Document::new(); + { + let mut p = doc.add_paragraph(""); + let mut r = p.add_run("code text"); + r = r.style("SourceText"); + let _ = r; + } + + let bytes = doc.to_bytes().unwrap(); + let doc2 = Document::from_bytes(&bytes).unwrap(); + let paras = doc2.paragraphs(); + let run = paras[0].runs().next().expect("run"); + assert_eq!(run.style_id(), Some("SourceText")); + } + + #[test] + fn append_hyperlink_round_trips() { + let mut doc = Document::new(); + doc.add_paragraph("visit "); + doc.append_hyperlink("GNOME", "https://gnome.org"); + + let bytes = doc.to_bytes().unwrap(); + let doc2 = Document::from_bytes(&bytes).unwrap(); + + let links = doc2.links(); + assert_eq!(links.len(), 1); + assert_eq!(links[0].text, "GNOME"); + assert_eq!(links[0].url.as_deref(), Some("https://gnome.org")); + assert_eq!(doc2.paragraphs()[0].text(), "visit GNOME"); + + let paras = doc2.paragraphs(); + let spans = paras[0].hyperlink_spans(); + assert_eq!(spans.len(), 1); + let (start, end, rel_id) = (spans[0].0, spans[0].1, spans[0].2); + assert_eq!(end - start, 1); + let url = doc2.hyperlink_url(rel_id.expect("rel id")); + assert_eq!(url.as_deref(), Some("https://gnome.org")); + } + + + #[test] + fn picture_round_trips() { + // 1x1 red PNG + let png: &[u8] = &[ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, + 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE, 0x00, 0x00, 0x00, + 0x0C, 0x49, 0x44, 0x41, 0x54, 0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, + 0x00, 0x00, 0x03, 0x00, 0x01, 0x9E, 0xDD, 0x22, 0x71, 0x00, 0x00, 0x00, + 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82, + ]; + let mut doc = Document::new(); + doc.add_paragraph("before"); + doc.add_picture(png, "dot.png", Length::inches(1.0), Length::inches(1.0)); + + let bytes = doc.to_bytes().unwrap(); + let doc2 = Document::from_bytes(&bytes).unwrap(); + let paras = doc2.paragraphs(); + let mut found = None; + for p in ¶s { + for r in p.runs() { + if let Some((rel, _alt)) = r.inline_image() { + found = Some(rel.to_string()); + } + } + } + let rel = found.expect("no inline image found on read"); + let data = doc2.image_data(&rel).expect("image bytes missing"); + assert_eq!(data, png); + } + } diff --git a/crates/rdocx/src/paragraph.rs b/crates/rdocx/src/paragraph.rs index 74d45af..1f45165 100644 --- a/crates/rdocx/src/paragraph.rs +++ b/crates/rdocx/src/paragraph.rs @@ -125,6 +125,15 @@ impl<'a> Paragraph<'a> { self.inner.text() } + /// Append a footnote reference run (``). + /// The footnote content itself is added via `Document::add_footnote`. + pub fn add_footnote_ref(&mut self, id: i32) { + use rdocx_oxml::text::{CT_R, RunContent}; + let mut r = CT_R::new(""); + r.content = vec![RunContent::FootnoteRef { id }]; + self.inner.runs.push(r); + } + /// Add a run with the given text and return a mutable reference for chaining. pub fn add_run(&mut self, text: &str) -> Run<'_> { self.inner.runs.push(CT_R::new(text)); @@ -407,6 +416,45 @@ impl<'a> ParagraphRef<'a> { } /// Get the alignment, if set. + /// Check if a page break is set before this paragraph. + pub fn is_page_break_before(&self) -> bool { + self.inner + .properties + .as_ref() + .and_then(|ppr| ppr.page_break_before) + .unwrap_or(false) + } + + /// Line spacing as a multiplier of single spacing (1.0, 1.5, 2.0…) + /// when the spacing rule is "auto" (the w:line value counts 240ths of + /// a line). Returns None when unset or when the rule is exact/atLeast + /// point spacing. + pub fn line_spacing_multiple(&self) -> Option { + let ppr = self.inner.properties.as_ref()?; + let line = ppr.line_spacing?; + match ppr.line_rule.as_deref() { + None | Some("auto") => Some(line.0 as f64 / 240.0), + _ => None, + } + } + + /// Hyperlink spans in this paragraph as (run_start, run_end, rel_id). + /// Resolve rel_id to a URL with `Document::hyperlink_url`. + pub fn hyperlink_spans(&self) -> Vec<(usize, usize, Option<&str>)> { + self.inner + .hyperlinks + .iter() + .map(|h| (h.run_start, h.run_end, h.rel_id.as_deref())) + .collect() + } + + /// Get list numbering as (num_id, level) if this paragraph is a list + /// item. Resolve bullet-vs-numbered via `Document::numbering_is_bullet`. + pub fn numbering(&self) -> Option<(u32, u32)> { + let ppr = self.inner.properties.as_ref()?; + Some((ppr.num_id?, ppr.num_ilvl.unwrap_or(0))) + } + pub fn alignment(&self) -> Option { self.inner .properties diff --git a/crates/rdocx/src/run.rs b/crates/rdocx/src/run.rs index acb446e..23032e0 100644 --- a/crates/rdocx/src/run.rs +++ b/crates/rdocx/src/run.rs @@ -209,6 +209,16 @@ impl<'a> RunRef<'a> { self.inner.text() } + /// The footnote id referenced by this run, if it holds a + /// ``. + pub fn footnote_id(&self) -> Option { + use rdocx_oxml::text::RunContent; + self.inner.content.iter().find_map(|c| match c { + RunContent::FootnoteRef { id } => Some(*id), + _ => None, + }) + } + /// Check if bold. pub fn is_bold(&self) -> bool { self.inner @@ -236,6 +246,15 @@ impl<'a> RunRef<'a> { .unwrap_or(false) } + /// Check if underlined (any underline style other than none). + pub fn is_underline(&self) -> bool { + self.inner + .properties + .as_ref() + .and_then(|rpr| rpr.underline.as_ref()) + .is_some_and(|u| !matches!(u, ST_Underline::None)) + } + /// Get font size in points, if set. pub fn size(&self) -> Option { self.inner @@ -266,6 +285,36 @@ impl<'a> RunRef<'a> { self.inner.properties.as_ref().and_then(|rpr| rpr.spacing) } + /// Get the highlight color, if set: either the `w:highlight` keyword + /// (e.g. "yellow") or the shading fill value the `highlight()` builder + /// writes — OOXML has two mechanisms for highlighted text. + pub fn highlight(&self) -> Option { + let rpr = self.inner.properties.as_ref()?; + if let Some(h) = rpr.highlight { + return Some(h.to_str().to_string()); + } + rpr.shading.as_ref().and_then(|sh| sh.fill.clone()) + } + + /// If this run contains an inline image, return (rel_id, alt text). + pub fn inline_image(&self) -> Option<(&str, Option<&str>)> { + use rdocx_oxml::text::RunContent; + for c in &self.inner.content { + if let RunContent::Drawing(d) = c { + if let Some(inline) = &d.inline { + return Some((inline.embed_id.as_str(), inline.description.as_deref())); + } + } + } + None + } + + /// Get raised/lowered text position in half-points, if set. + /// (LibreOffice encodes super/subscript this way on HTML import.) + pub fn position(&self) -> Option { + self.inner.properties.as_ref().and_then(|rpr| rpr.position) + } + /// Get vertical alignment (superscript/subscript), if set. pub fn vert_align(&self) -> Option<&str> { self.inner diff --git a/crates/rdocx/tests/read_test.rs b/crates/rdocx/tests/read_test.rs new file mode 100644 index 0000000..9a3fc60 --- /dev/null +++ b/crates/rdocx/tests/read_test.rs @@ -0,0 +1,16 @@ + +#[test] +fn line_spacing_multiple_round_trips() { + let path = std::env::temp_dir().join("rdocx-line-spacing-test.docx"); + let mut doc = rdocx::Document::new(); + { + let p = doc.add_paragraph("double spaced"); + p.line_spacing_multiple(2.0); + } + doc.save(path.to_str().unwrap()).unwrap(); + let doc = rdocx::Document::open(path.to_str().unwrap()).unwrap(); + let paras = doc.paragraphs(); + let p = paras.first().unwrap(); + assert_eq!(p.line_spacing_multiple(), Some(2.0)); + let _ = std::fs::remove_file(&path); +}