use std::collections::{BTreeMap, BTreeSet};
use std::io::{Seek, Write};
use energyml_core::identity::{self, EpcLayout};
use energyml_core::registry::{DynObject, to_part};
use energyml_core::xml::XmlWriteOptions;
use crate::error::{Diagnostic, Error, Result};
use super::core_props::{CORE_CONTENT_TYPE, CORE_PART, CORE_RELATIONSHIP, CoreProperties};
use super::package::rels_name_of;
const RELATIONSHIP_BASE: &str = "http://schemas.energistics.org/package/2012/relationships/";
const RELS_CONTENT_TYPE: &str = "application/vnd.openxmlformats-package.relationships+xml";
pub struct Attachment {
pub part: String,
pub content_type: Option<String>,
pub bytes: Vec<u8>,
}
pub struct Package<'a> {
pub objects: Vec<&'a dyn DynObject>,
pub attachments: Vec<Attachment>,
pub core: CoreProperties,
pub layout: EpcLayout,
pub external_files: BTreeMap<String, String>,
pub object_external_files: BTreeMap<String, String>,
pub verbatim: Vec<Attachment>,
pub media_links: Vec<super::media::MediaLink>,
}
impl<'a> Package<'a> {
pub fn new(objects: Vec<&'a dyn DynObject>, core: CoreProperties) -> Self {
Self {
objects,
attachments: Vec::new(),
core,
layout: EpcLayout::Flat,
external_files: BTreeMap::new(),
object_external_files: BTreeMap::new(),
verbatim: Vec::new(),
media_links: Vec::new(),
}
}
}
pub fn write_package<W: Write + Seek>(
package: &Package<'_>,
out: W,
diagnostics: &mut Vec<Diagnostic>,
) -> Result<()> {
let plan = {
let _span = tracing::info_span!("epc.write.plan", objects = package.objects.len()).entered();
Plan::build(package, diagnostics)?
};
let mut zip = zip::ZipWriter::new(out);
let options =
zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
let write_part = |zip: &mut zip::ZipWriter<W>, name: &str, bytes: &[u8]| -> Result<()> {
zip.start_file(name, options)
.map_err(|e| Error::Package(format!("writing `{name}`: {e}")))?;
zip.write_all(bytes)?;
Ok(())
};
write_part(&mut zip, "[Content_Types].xml", plan.content_types().as_bytes())?;
{
let _span = tracing::info_span!("epc.write.objects", parts = plan.objects.len()).entered();
for (part, object) in plan.objects.iter() {
let xml = to_part(*object, XmlWriteOptions::default())?;
write_part(&mut zip, part, xml.as_bytes())?;
}
}
for attachment in package.attachments.iter().chain(&package.verbatim) {
write_part(&mut zip, &attachment.part, &attachment.bytes)?;
}
write_part(&mut zip, CORE_PART, package.core.to_xml().as_bytes())?;
{
let _span = tracing::info_span!("epc.write.rels", parts = plan.relationships.len()).entered();
for (part, relationships) in &plan.relationships {
write_part(&mut zip, part, render_relationships(relationships).as_bytes())?;
}
}
zip.finish()
.map_err(|e| Error::Package(format!("closing the archive: {e}")))?;
Ok(())
}
struct Rel {
id: String,
kind: &'static str,
target: String,
external: bool,
}
struct Plan<'a> {
objects: Vec<(String, &'a dyn DynObject)>,
relationships: BTreeMap<String, Vec<Rel>>,
defaults: BTreeMap<String, String>,
overrides: BTreeMap<String, String>,
}
impl<'a> Plan<'a> {
fn build(package: &'a Package<'a>, diagnostics: &mut Vec<Diagnostic>) -> Result<Self> {
let mut plan = Plan {
objects: Vec::new(),
relationships: BTreeMap::new(),
defaults: BTreeMap::from([("rels".to_string(), RELS_CONTENT_TYPE.to_string())]),
overrides: BTreeMap::new(),
};
let mut by_uuid: BTreeMap<String, Vec<Placed>> = BTreeMap::new();
let mut proxy_uuids: BTreeSet<String> = BTreeSet::new();
let mut taken: BTreeSet<String> = BTreeSet::new();
let mut namesakes: BTreeMap<(String, String), usize> = BTreeMap::new();
for object in &package.objects {
if let Some(key) = identity::key_of_reflect(object.as_reflect()) {
*namesakes
.entry((key.uuid.to_ascii_lowercase(), key.qualified_type()))
.or_default() += 1;
}
}
for object in &package.objects {
let reflect = object.as_reflect();
let key = identity::key_of_reflect(reflect).ok_or_else(|| {
Error::Package(format!(
"`{}` carries no uuid, so it cannot be an EPC part",
reflect.type_info().name
))
})?;
let version = key
.object_version
.map(str::trim)
.filter(|version| !version.is_empty());
let held_more_than_once = namesakes
.get(&(key.uuid.to_ascii_lowercase(), key.qualified_type()))
.is_some_and(|count| *count > 1);
let mut part = key.part_path(package.layout);
if held_more_than_once && let Some(version) = version {
part = in_folder(&part, &format!("version_{}", encode_segment(version)));
}
if taken.contains(&part) {
part = in_folder(
&part,
&format!("namespace_{}{}", key.domain, key.version.compact()),
);
}
if !taken.insert(part.clone()) {
return Err(Error::Package(format!(
"two objects would be written to `{part}`: {} held twice under the same \
uuid, type and objectVersion",
key.etp_uri(None)
)));
}
by_uuid
.entry(key.uuid.to_ascii_lowercase())
.or_default()
.push(Placed {
part: part.clone(),
qualified_type: key.qualified_type(),
version: version.map(str::to_string),
});
if reflect.type_info().type_key == "EpcExternalPartReference" {
proxy_uuids.insert(key.uuid.to_ascii_lowercase());
}
plan.overrides.insert(format!("/{part}"), key.content_type());
plan.objects.push((part, *object));
}
for (part, object) in &plan.objects {
for nested in identity::nested_objects(object.as_reflect()) {
by_uuid
.entry(nested.key.uuid.to_ascii_lowercase())
.or_insert_with(|| {
vec![Placed {
part: part.clone(),
qualified_type: nested.key.qualified_type(),
version: nested.key.object_version.map(str::to_string),
}]
});
}
}
let placed = plan.objects.clone();
for (part, object) in &placed {
let source_uuid = identity::uuid_of_reflect(object.as_reflect())
.unwrap_or_default()
.to_ascii_lowercase();
let mut seen: BTreeSet<(String, &str)> = BTreeSet::new();
for edge in identity::collect_refs(object.as_reflect()) {
let target_uuid = edge.target.uuid.to_ascii_lowercase();
if target_uuid.is_empty() {
continue;
}
let candidates = by_uuid.get(&target_uuid).map(Vec::as_slice).unwrap_or_default();
let targets = pick(
candidates,
&edge.target.qualified_type(),
edge.target
.object_version
.map(str::trim)
.filter(|version| !version.is_empty()),
);
if targets.is_empty() {
diagnostics.push(Diagnostic::warning(
part.clone(),
format!(
"`{}` references {target_uuid}, which is not in this package; \
no relationship was written for it",
edge.path
),
));
continue;
}
for target in targets {
let target_part = &target.part;
if target_part == part {
continue;
}
let (forward, backward) = if proxy_uuids.contains(&target_uuid) {
("mlToExternalPartProxy", "externalPartProxyToMl")
} else {
("destinationObject", "sourceObject")
};
if !seen.insert((target_part.clone(), forward)) {
continue;
}
plan.add(
part,
forward,
target_part,
&id_tag(&by_uuid, &target_uuid, target_part),
);
plan.add(target_part, backward, part, &id_tag(&by_uuid, &source_uuid, part));
}
}
}
for (part, object) in &placed {
let uuid = identity::uuid_of_reflect(object.as_reflect())
.unwrap_or_default()
.to_ascii_lowercase();
if !proxy_uuids.contains(&uuid) {
continue;
}
match package.external_files.get(&uuid) {
Some(file) => plan
.relationships
.entry(rels_name_of(part))
.or_default()
.push(Rel {
id: "Hdf5File".to_string(),
kind: "externalResource",
target: file.clone(),
external: true,
}),
None => diagnostics.push(Diagnostic::warning(
part.clone(),
"this external part reference names no file; the package will not say \
where its arrays live",
)),
}
}
for (part, object) in &placed {
let uuid = identity::uuid_of_reflect(object.as_reflect())
.unwrap_or_default()
.to_ascii_lowercase();
if proxy_uuids.contains(&uuid) {
continue;
}
let Some(file) = package.object_external_files.get(&uuid) else {
continue;
};
plan.relationships
.entry(rels_name_of(part))
.or_default()
.push(Rel {
id: "Hdf5File".to_string(),
kind: "externalResource",
target: file.clone(),
external: true,
});
}
plan.relationships
.entry("_rels/.rels".to_string())
.or_default()
.push(Rel {
id: "CoreProperties".to_string(),
kind: "",
target: CORE_PART.to_string(),
external: false,
});
plan.overrides
.insert(format!("/{CORE_PART}"), CORE_CONTENT_TYPE.to_string());
let carried: BTreeSet<&str> = package
.attachments
.iter()
.chain(&package.verbatim)
.map(|attachment| attachment.part.as_str())
.collect();
let mut linked: BTreeSet<(&str, &str)> = BTreeSet::new();
for link in &package.media_links {
let uuid = link.uuid.to_ascii_lowercase();
let Some(object_part) = by_uuid
.get(&uuid)
.and_then(|held| held.first())
.map(|held| &held.part)
else {
diagnostics.push(Diagnostic::warning(
link.part.clone(),
format!(
"a media representation of {uuid}, which is not in this package; \
no relationship was written for it"
),
));
continue;
};
if !carried.contains(link.part.as_str()) {
diagnostics.push(Diagnostic::warning(
object_part.clone(),
format!(
"`{}` is named as a media representation of this object and is not in the \
package; no relationship was written for it",
link.part
),
));
continue;
}
if !linked.insert((link.part.as_str(), link.uuid.as_str())) {
continue;
}
plan.relationships
.entry(rels_name_of(object_part))
.or_default()
.push(Rel {
id: media_id(&link.part),
kind: super::media::DESTINATION_MEDIA,
target: relative_target(object_part, &link.part),
external: false,
});
plan.relationships
.entry(rels_name_of(&link.part))
.or_default()
.push(Rel {
id: format!("_{uuid}"),
kind: super::media::SOURCE_MEDIA,
target: relative_target(&link.part, object_part),
external: false,
});
}
for attachment in package.attachments.iter().chain(&package.verbatim) {
match &attachment.content_type {
Some(content_type) => {
plan.overrides
.insert(format!("/{}", attachment.part), content_type.clone());
}
None => {
let extension = extension_of(&attachment.part);
if let Some(guess) = content_type_for(&extension) {
plan.defaults
.entry(extension)
.or_insert_with(|| guess.to_string());
} else {
diagnostics.push(Diagnostic::note(
attachment.part.clone(),
"no content type and none guessable from the extension; \
the part is written but not typed",
));
}
}
}
}
Ok(plan)
}
fn add(&mut self, part: &str, kind: &'static str, target: &str, target_uuid: &str) {
self.relationships
.entry(rels_name_of(part))
.or_default()
.push(Rel {
id: format!("_{target_uuid}"),
kind,
target: relative_target(part, target),
external: false,
});
}
fn content_types(&self) -> String {
let mut out = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n");
out.push_str("<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\">\n");
for (extension, content_type) in &self.defaults {
out.push_str(&format!(
" <Default Extension=\"{}\" ContentType=\"{}\"/>\n",
escape(extension),
escape(content_type)
));
}
for (part, content_type) in &self.overrides {
out.push_str(&format!(
" <Override PartName=\"{}\" ContentType=\"{}\"/>\n",
escape(part),
escape(content_type)
));
}
out.push_str("</Types>\n");
out
}
}
fn render_relationships(relationships: &[Rel]) -> String {
let mut out = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n");
out.push_str("<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">\n");
let mut seen: std::collections::HashSet<&str> =
std::collections::HashSet::with_capacity(relationships.len());
for (index, rel) in relationships.iter().enumerate() {
let kind = if rel.kind.is_empty() {
CORE_RELATIONSHIP.to_string()
} else {
format!("{RELATIONSHIP_BASE}{}", rel.kind)
};
let id = if !seen.insert(rel.id.as_str()) {
format!("{}_{index}", rel.id)
} else {
rel.id.clone()
};
out.push_str(&format!(
" <Relationship Id=\"{}\" Type=\"{}\" Target=\"{}\"",
escape(&id),
escape(&kind),
escape(&rel.target)
));
if rel.external {
out.push_str(" TargetMode=\"External\"");
}
out.push_str("/>\n");
}
out.push_str("</Relationships>\n");
out
}
fn relative_target(from_part: &str, to_part: &str) -> String {
let from_dir = from_part.rsplit_once('/').map(|(dir, _)| dir).unwrap_or("");
let to_dir = to_part.rsplit_once('/').map(|(dir, _)| dir).unwrap_or("");
if from_dir == to_dir {
return to_part
.rsplit_once('/')
.map(|(_, file)| file.to_string())
.unwrap_or_else(|| to_part.to_string());
}
let up = if from_dir.is_empty() {
String::new()
} else {
"../".repeat(from_dir.matches('/').count() + 1)
};
format!("{up}{to_part}")
}
fn media_id(part: &str) -> String {
let body: String = part
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
c
} else {
'_'
}
})
.collect();
format!("_media_{body}")
}
fn extension_of(part: &str) -> String {
part.rsplit_once('.')
.map(|(_, e)| e.to_ascii_lowercase())
.unwrap_or_default()
}
fn content_type_for(extension: &str) -> Option<&'static str> {
Some(match extension {
"h5" | "hdf5" => "application/x-hdf5",
"pdf" => "application/pdf",
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"svg" => "image/svg+xml",
"csv" => "text/csv",
"txt" => "text/plain",
"json" => "application/json",
"zip" => "application/zip",
"segy" | "sgy" => "application/octet-stream",
_ => return None,
})
}
fn escape(value: &str) -> String {
value
.replace('&', "&")
.replace('<', "<")
.replace('"', """)
}
struct Placed {
part: String,
qualified_type: String,
version: Option<String>,
}
fn pick<'a>(candidates: &'a [Placed], qualified_type: &str, version: Option<&str>) -> Vec<&'a Placed> {
let mut held: Vec<&Placed> = candidates.iter().collect();
if held.iter().any(|one| one.qualified_type == qualified_type) {
held.retain(|one| one.qualified_type == qualified_type);
}
match version {
Some(wanted) => {
if held.iter().any(|one| one.version.as_deref() == Some(wanted)) {
held.retain(|one| one.version.as_deref() == Some(wanted));
}
}
None if held.len() > 1 => {
let bare: Vec<&Placed> = held.iter().copied().filter(|one| one.version.is_none()).collect();
if let [only] = bare[..] {
return vec![only];
}
}
None => {}
}
held
}
fn id_tag(by_uuid: &BTreeMap<String, Vec<Placed>>, uuid: &str, part: &str) -> String {
match by_uuid.get(uuid) {
Some(held) if held.len() > 1 => {
let rank = held.iter().position(|one| one.part == part).unwrap_or(0);
format!("{uuid}_{rank}")
}
_ => uuid.to_string(),
}
}
fn in_folder(part: &str, folder: &str) -> String {
match part.rsplit_once('/') {
Some((parents, name)) => format!("{parents}/{folder}/{name}"),
None => format!("{folder}/{part}"),
}
}
fn encode_segment(version: &str) -> String {
let mut out = String::with_capacity(version.len());
for byte in version.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'.' | b'-' | b'_' => out.push(byte as char),
other => out.push_str(&format!("%{other:02X}")),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_target_is_relative_to_the_rels_folder() {
assert_eq!(relative_target("Foo.xml", "Bar.xml"), "Bar.xml");
assert_eq!(relative_target("resqml20/Foo.xml", "resqml20/Bar.xml"), "Bar.xml");
assert_eq!(
relative_target("resqml20/Foo.xml", "eml20/Bar.xml"),
"../eml20/Bar.xml"
);
assert_eq!(relative_target("Foo.xml", "eml20/Bar.xml"), "eml20/Bar.xml");
}
#[test]
fn an_external_relationship_says_so() {
let rendered = render_relationships(&[Rel {
id: "Hdf5File".to_string(),
kind: "externalResource",
target: "model.h5".to_string(),
external: true,
}]);
assert!(rendered.contains("TargetMode=\"External\""));
assert!(
rendered.contains(
"Type=\"http://schemas.energistics.org/package/2012/relationships/externalResource\""
)
);
}
#[test]
fn ids_stay_unique_within_one_rels() {
let uuid = "_2f975b89-2019-468f-91a0-f3f0b5c1638d";
let rendered = render_relationships(&[
Rel {
id: uuid.to_string(),
kind: "destinationObject",
target: "A.xml".to_string(),
external: false,
},
Rel {
id: uuid.to_string(),
kind: "sourceObject",
target: "A.xml".to_string(),
external: false,
},
]);
assert!(rendered.contains(&format!("Id=\"{uuid}\"")));
assert!(rendered.contains(&format!("Id=\"{uuid}_1\"")), "{rendered}");
}
}