#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RelationshipRow {
pub id: String,
pub rel_type: String,
pub target: String,
pub external: bool,
}
pub fn decode_xml_attribute(value: &str) -> String {
value
.replace(""", "\"")
.replace(">", ">")
.replace("<", "<")
.replace("&", "&")
}
fn attr_of(tag: &str, name: &str) -> Option<String> {
let key = name.to_string();
let mut search_from = 0;
while let Some(pos) = tag[search_from..].find(&key) {
let abs = search_from + pos;
let before_ok = abs == 0 || tag.as_bytes()[abs - 1].is_ascii_whitespace();
let after = &tag[abs + key.len()..];
let after_trim = after.trim_start();
if before_ok && after_trim.starts_with('=') {
let rest = after_trim[1..].trim_start();
if let Some(stripped) = rest.strip_prefix('"')
&& let Some(end) = stripped.find('"')
{
return Some(stripped[..end].to_string());
}
}
search_from = abs + key.len();
}
None
}
pub fn parse_relationship_rows(rels_xml: &str) -> Vec<RelationshipRow> {
let mut rows = Vec::new();
let mut from = 0;
while let Some(start) = rels_xml[from..].find("<Relationship") {
let abs = from + start;
let after = rels_xml[abs + "<Relationship".len()..].chars().next();
if !matches!(after, Some(c) if c.is_ascii_whitespace()) {
from = abs + "<Relationship".len();
continue;
}
let end = match rels_xml[abs..].find('>') {
Some(e) => abs + e + 1,
None => break,
};
let tag = &rels_xml[abs..end];
from = end;
let id = attr_of(tag, "Id");
let rel_type = attr_of(tag, "Type");
let target = attr_of(tag, "Target");
let mode = attr_of(tag, "TargetMode");
if let (Some(id), Some(rel_type), Some(target)) = (id, rel_type, target) {
let target = decode_xml_attribute(&target);
let external =
mode.as_deref() == Some("External") || is_external_relationship(&rel_type, &target);
rows.push(RelationshipRow {
id,
rel_type,
target,
external,
});
}
}
rows
}
pub fn required_rel_type_suffix(local_name: &str) -> Option<&'static str> {
match local_name {
"hyperlink" | "hlinkClick" => Some("/hyperlink"),
"chart" => Some("/chart"),
"headerReference" => Some("/header"),
"footerReference" => Some("/footer"),
"blip" | "imagedata" => Some("/image"),
"OLEObject" => Some("/oleObject"),
_ => None,
}
}
pub fn is_external_relationship(rel_type: &str, target: &str) -> bool {
if rel_type.ends_with("/hyperlink") {
return true;
}
if target.starts_with('/') {
return false;
}
if let Some(i) = target.find(':') {
let scheme = &target[..i];
if !scheme.is_empty()
&& scheme.chars().next().unwrap().is_ascii_alphabetic()
&& scheme
.chars()
.all(|c| c.is_ascii_alphanumeric() || "+.-".contains(c))
{
return true;
}
}
false
}
use super::tables::S_RELATIONSHIP_ATTRIBUTE_NAMES;
use crate::opc::PartFs;
use crate::unid::generate_unid;
use crate::xmllinq::{Dom, NodeId};
fn dest_uri_for_reconciled_part(dest: &PartFs, target_part: &str, bytes: &[u8]) -> String {
let ext = target_part
.rsplit('.')
.next()
.filter(|e| !e.contains('/'))
.unwrap_or("bin");
let ext_lc = ext.to_ascii_lowercase();
let is_image = matches!(
ext_lc.as_str(),
"png" | "jpeg" | "jpg" | "gif" | "tiff" | "tif" | "bmp" | "svg" | "ico" | "emf" | "wmf"
);
if target_part.starts_with("word/media/") || is_image {
return format!("word/media/P{}.{ext}", generate_unid());
}
match dest.part_bytes(target_part) {
None => target_part.to_string(),
Some(existing) if existing == bytes => target_part.to_string(),
Some(_) => {
let (dir, base) = target_part
.rsplit_once('/')
.unwrap_or(("word", target_part));
let mut n = 0usize;
loop {
let candidate = if n == 0 {
format!("{dir}/redlineB_{base}")
} else {
format!("{dir}/redlineB_{n}_{base}")
};
match dest.part_bytes(&candidate) {
None => return candidate,
Some(existing) if existing == bytes => return candidate,
Some(_) => n += 1,
}
}
}
}
}
fn ref_from_inserted_content(dom: &Dom, mut el: NodeId) -> bool {
while let Some(p) = dom.parent(el) {
if let Some(n) = dom.name(p)
&& n.namespace_name() == crate::namespaces::W::URI
&& matches!(n.local_name(), "ins" | "moveTo")
{
return true;
}
el = p;
}
false
}
fn dest_rid_part_bytes(dest: &PartFs, doc_part: &str, rid: &str) -> Option<Vec<u8>> {
let rels = dest.read_rels_for(doc_part)?;
let target = rels
.items
.iter()
.find(|i| i.id == rid)
.map(|i| i.target.clone())?;
let part = dest.resolve_rel_target(doc_part, &target);
dest.part_bytes(&part).map(|b| b.to_vec())
}
pub fn reconcile_dangling_relationships(
dom: &mut Dom,
root: NodeId,
dest: &mut PartFs,
sources: &[&PartFs],
) {
let doc_part = dest
.main_document_part()
.unwrap_or_else(|| "word/document.xml".to_string());
let mut refs: Vec<(NodeId, crate::xmllinq::XName, String, bool)> = Vec::new();
for el in dom.descendants_and_self(root, None) {
for (an, av) in dom.attributes(el) {
if S_RELATIONSHIP_ATTRIBUTE_NAMES.contains(&an) {
let inserted = ref_from_inserted_content(dom, el);
refs.push((el, an, av, inserted));
}
}
}
if refs.is_empty() {
return;
}
let dest_rels: std::collections::HashMap<String, String> = dest
.read_rels_for(&doc_part)
.map(|rels| {
rels.items
.iter()
.map(|r| (r.id.clone(), r.rel_type.clone()))
.collect()
})
.unwrap_or_default();
let mut source_rows: Vec<(usize, RelationshipRow, String)> = Vec::new();
for (si, src) in sources.iter().enumerate() {
let src_doc = src
.main_document_part()
.unwrap_or_else(|| "word/document.xml".to_string());
if let Some(rels) = src.read_rels_for(&src_doc) {
for r in &rels.items {
source_rows.push((
si,
RelationshipRow {
id: r.id.clone(),
rel_type: r.rel_type.clone(),
target: r.target.clone(),
external: r.target_mode.as_deref() == Some("External")
|| is_external_relationship(&r.rel_type, &r.target),
},
src_doc.clone(),
));
}
}
}
let find_source = |rid: &str, suffix: Option<&str>| {
source_rows
.iter()
.find(|(_, r, _)| r.id == rid && suffix.is_none_or(|s| r.rel_type.ends_with(s)))
};
for (el, an, rid, inserted) in refs {
let suffix = dom
.name(el)
.and_then(|n| required_rel_type_suffix(n.local_name()));
let dest_ok = dest_rels
.get(&rid)
.is_some_and(|t| suffix.is_none_or(|s| t.ends_with(s)));
if dest_ok {
if inserted
&& let Some((si, row, src_doc)) = source_rows
.iter()
.rev()
.find(|(_, r, _)| r.id == rid && suffix.is_none_or(|s| r.rel_type.ends_with(s)))
&& !row.external
{
let src = sources[*si];
let modf_part = src.resolve_rel_target(src_doc, &row.target);
if let Some(modf_bytes) = src.part_bytes(&modf_part) {
let modf_bytes = modf_bytes.to_vec();
if dest_rid_part_bytes(dest, &doc_part, &rid).as_deref() != Some(&modf_bytes) {
let new_uri = dest_uri_for_reconciled_part(dest, &modf_part, &modf_bytes);
if let Some(ct) = src.content_type_for(&modf_part) {
dest.add_content_type_override(&new_uri, &ct);
}
dest.set_part(&new_uri, modf_bytes);
let rel_target = new_uri
.strip_prefix("word/")
.unwrap_or(&new_uri)
.to_string();
let new_rid =
dest.add_document_relationship(&doc_part, &row.rel_type, &rel_target);
dom.set_attribute_value(el, &an, Some(&new_rid));
}
}
}
continue;
}
match find_source(&rid, suffix) {
None => {
dom.set_attribute_value(el, &an, None);
}
Some((si, row, src_doc)) => {
if row.external {
let new_rid = dest.add_document_relationship_external(
&doc_part,
&row.rel_type,
&row.target,
);
dom.set_attribute_value(el, &an, Some(&new_rid));
} else {
let src = sources[*si];
let target_part = src.resolve_rel_target(src_doc, &row.target);
match src.part_bytes(&target_part) {
Some(bytes) => {
let bytes = bytes.to_vec();
let new_uri = dest_uri_for_reconciled_part(dest, &target_part, &bytes);
if let Some(ct) = src.content_type_for(&target_part) {
dest.add_content_type_override(&new_uri, &ct);
}
dest.set_part(&new_uri, bytes);
let rel_target = new_uri
.strip_prefix("word/")
.unwrap_or(&new_uri)
.to_string();
let new_rid = dest.add_document_relationship(
&doc_part,
&row.rel_type,
&rel_target,
);
dom.set_attribute_value(el, &an, Some(&new_rid));
}
None => {
dom.set_attribute_value(el, &an, None);
}
}
}
}
}
}
}