Skip to content
Open
14 changes: 14 additions & 0 deletions crates/rdocx-opc/src/relationship.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
244 changes: 244 additions & 0 deletions crates/rdocx/src/document.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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(),
}
}

Expand Down Expand Up @@ -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,
Expand All @@ -134,6 +145,7 @@ impl Document {
core_properties,
doc_part_name,
image_counter,
footnotes,
})
}

Expand Down Expand Up @@ -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()?;
Expand All @@ -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::<Vec<_>>()
.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();
Expand Down Expand Up @@ -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<bool> {
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<Paragraph<'_>> {
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<Vec<u8>> {
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<String> {
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.
Expand Down Expand Up @@ -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 &paras {
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);
}

}
48 changes: 48 additions & 0 deletions crates/rdocx/src/paragraph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,15 @@ impl<'a> Paragraph<'a> {
self.inner.text()
}

/// Append a footnote reference run (`<w:footnoteReference w:id="..."/>`).
/// 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));
Expand Down Expand Up @@ -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<f64> {
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<Alignment> {
self.inner
.properties
Expand Down
Loading