From f623bb317b359df74b7a035c1eb84be1fbf83b0a Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 06:30:46 -0500 Subject: [PATCH] Move spot-plate XMP encoding into craft-pdf --- crates/pdf/Cargo.toml | 3 +- crates/pdf/src/write/raster.rs | 84 ++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index 251cfaf4e..d30d6efa6 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -17,7 +17,7 @@ interpret = ["read", "dep:hayro-interpret"] raster = ["interpret", "dep:hayro", "dep:bytemuck"] write = ["dep:krilla", "dep:thiserror"] write-pdf = ["write", "krilla/pdf"] -write-raster = ["dep:flate2"] +write-raster = ["dep:flate2", "dep:roxmltree"] write-simple-text = ["write", "krilla/simple-text"] filters = ["dep:craft-stream-codecs", "dep:flate2", "dep:thiserror"] cos = ["crypt", "filters", "dep:chrono", "dep:js-sys", "dep:wasm-bindgen"] @@ -31,6 +31,7 @@ bytemuck = { workspace = true, optional = true } krilla = { workspace = true, optional = true } thiserror = { workspace = true, optional = true } flate2 = { version = "1.1", default-features = false, features = ["zlib-rs"], optional = true } +roxmltree = { version = "0.21", optional = true } # The filter chain (`filters`). flate2 with zlib-rs, the backend the chain's Flate prices were measured on. craft-stream-codecs = { workspace = true, optional = true } # The standard security handler (`crypt`): pure Rust, MIT OR Apache-2.0, builds for wasm. diff --git a/crates/pdf/src/write/raster.rs b/crates/pdf/src/write/raster.rs index 33d64645c..e6c2391ae 100644 --- a/crates/pdf/src/write/raster.rs +++ b/crates/pdf/src/write/raster.rs @@ -54,6 +54,90 @@ pub struct Page { pub plates: Vec, } +/// The RDF namespace used by PDF XMP packets. +pub const RDF_NAMESPACE: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"; + +/// PhotoCraft spot-plate metadata namespace. The packet encoder is shared so applications pass +/// neutral plate facts instead of assembling XML inside a PDF export path. +pub const SPOT_PLATES_NAMESPACE: &str = "http://ns.getartcraft.com/photocraft/spot-plates/1.0/"; + +/// Add spot-plate metadata to a PDF XMP packet. An unparseable caller packet is preserved as-is; +/// with no caller packet a complete XMP packet is created. An empty plate list returns the +/// caller packet unchanged, or `None` when there is no metadata to write. +pub fn xmp_with_spot_plates(doc_xmp: Option<&str>, plates: &[SpotPlate]) -> Option { + if plates.is_empty() { + return doc_xmp.map(str::to_owned); + } + let mut description = format!( + " \n \n \n" + ); + let two = |value: f32| { + let value = (value * 100.0).round() / 100.0; + if value == 0.0 { 0.0 } else { value } + }; + for (index, plate) in plates.iter().enumerate() { + let hex = plate.srgb.map(|value| format!("{:02X}", (value.clamp(0.0, 1.0) * 255.0).round() as u8)).concat(); + description.push_str(&format!( + " \n {index}\n {}\n {:.2}\n {:.2}\n {:.2}\n #{hex}\n {}\n \n", + xml_text(&plate.name), two(plate.lab[0]), two(plate.lab[1]), two(plate.lab[2]), (plate.solidity * 100.0).round() as u32 + )); + } + description.push_str(" \n \n \n"); + if let Some(xmp) = doc_xmp { + let Some((at, self_closing)) = rdf_insertion(xmp) else { return Some(xmp.to_owned()) }; + let tail_at = if self_closing.is_some() { at + 2 } else { at }; + let (Some(head), Some(tail)) = (xmp.get(..at), xmp.get(tail_at..)) else { return Some(xmp.to_owned()) }; + let mut out = String::with_capacity(xmp.len() + description.len() + 16); + out.push_str(head); + match self_closing { + Some(name) => { + out.push('>'); + out.push('\n'); + out.push_str(&description); + out.push_str(" '); + } + None => out.push_str(&description), + } + out.push_str(tail); + return Some(out); + } + Some(format!( + "\n\n \n{description} \n\n" + )) +} + +fn xml_text(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for ch in value.chars() { + match ch { + '&' => escaped.push_str("&"), + '<' => escaped.push_str("<"), + '>' => escaped.push_str(">"), + '"' => escaped.push_str("""), + ch => escaped.push(ch), + } + } + escaped +} + +fn rdf_insertion(xmp: &str) -> Option<(usize, Option<&str>)> { + let bom = xmp.strip_prefix('\u{feff}').map_or(0, |_| '\u{feff}'.len_utf8()); + let body = xmp.get(bom..)?; + let doc = roxmltree::Document::parse(body).ok()?; + let rdf = + doc.descendants().find(|node| node.is_element() && node.tag_name().namespace() == Some(RDF_NAMESPACE) && node.tag_name().name() == "RDF")?; + let range = rdf.range(); + let text = body.get(range.clone())?; + if let Some(open) = text.strip_suffix("/>") { + let name = open.get(1..)?; + let end = name.find(|ch: char| ch.is_whitespace() || ch == '/').unwrap_or(name.len()); + return Some((bom + range.end - 2, Some(name.get(..end)?))); + } + Some((bom + range.start + text.rfind("