use crate::c2pa_formats::{AssetFormat, FormatError};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TextMethod {
Unstructured,
Structured,
Html,
}
impl TextMethod {
fn fmt(self) -> AssetFormat {
match self {
TextMethod::Unstructured => AssetFormat::TextUnstructured,
TextMethod::Structured => AssetFormat::TextStructured {
comment_prefix: "",
comment_suffix: "",
},
TextMethod::Html => AssetFormat::TextHtml,
}
}
fn invalid_utf8(self) -> FormatError {
FormatError::InvalidStructure {
format: self.fmt(),
detail: "text asset is not valid UTF-8",
}
}
}
pub(crate) fn extract(method: TextMethod, data: &[u8]) -> Result<Option<Vec<u8>>, FormatError> {
let text = std::str::from_utf8(data).map_err(|_| method.invalid_utf8())?;
match method {
TextMethod::Unstructured => match c2pa_text::extract_manifest(text) {
Ok(res) => Ok(res.manifest),
Err(_) => Ok(None),
},
TextMethod::Structured => match c2pa_text::structured::extract_structured(text) {
Ok(ext) => Ok(ext.manifest),
Err(_) => Ok(None),
},
TextMethod::Html => match c2pa_text::html::extract_html(text) {
Ok(Some(ext)) => Ok(ext.manifest),
Ok(None) => Ok(None),
Err(_) => Ok(None),
},
}
}
#[cfg(test)]
pub(crate) fn embed(
method: TextMethod,
asset: &[u8],
manifest_store: &[u8],
) -> Result<Vec<u8>, FormatError> {
let text = std::str::from_utf8(asset).map_err(|_| method.invalid_utf8())?;
let out = match method {
TextMethod::Unstructured => {
use unicode_normalization::UnicodeNormalization;
let normalized: String = text.nfc().collect();
let target = c2pa_text::worst_case_wrapper_byte_length(manifest_store.len());
let wrapper =
c2pa_text::encode_wrapper_padded(manifest_store, target).map_err(|_| {
FormatError::InvalidStructure {
format: AssetFormat::TextUnstructured,
detail: "padded wrapper encoding failed",
}
})?;
format!("{normalized}{wrapper}")
}
TextMethod::Structured => structured_text(text, manifest_store, "", ""),
TextMethod::Html => {
c2pa_text::html::embed_html_inline(text, manifest_store, "\n")
.map_err(|_| FormatError::InvalidStructure {
format: AssetFormat::TextHtml,
detail: "html inline embed failed (document has no </head>)",
})?
.html
}
};
Ok(out.into_bytes())
}
#[cfg(test)]
fn structured_text(
text: &str,
manifest_store: &[u8],
comment_prefix: &str,
comment_suffix: &str,
) -> String {
let reference = c2pa_text::structured::encode_data_uri(manifest_store);
c2pa_text::structured::embed_structured(
text,
&reference,
comment_prefix,
comment_suffix,
c2pa_text::structured::Placement::End,
"\n",
)
.text
}
#[cfg(test)]
pub(crate) fn embed_structured(
asset: &[u8],
manifest_store: &[u8],
comment_prefix: &str,
comment_suffix: &str,
) -> Result<Vec<u8>, FormatError> {
let text = std::str::from_utf8(asset).map_err(|_| TextMethod::Structured.invalid_utf8())?;
Ok(structured_text(text, manifest_store, comment_prefix, comment_suffix).into_bytes())
}
use crate::c2pa_formats::DataHashExclusion;
pub(crate) fn exclusions(
method: TextMethod,
data: &[u8],
) -> Result<Vec<DataHashExclusion>, FormatError> {
let text = std::str::from_utf8(data).map_err(|_| method.invalid_utf8())?;
let range = match method {
TextMethod::Unstructured => match c2pa_text::extract_manifest(text) {
Ok(res) => match (res.offset, res.length) {
(Some(start), Some(length)) => Some((start, length)),
_ => None,
},
Err(_) => None,
},
TextMethod::Structured => structured_block_span(text),
TextMethod::Html => locate_span(text, "<script type=\"application/c2pa\"", "</script>"),
};
Ok(range
.into_iter()
.map(|(start, length)| DataHashExclusion { start, length })
.collect())
}
fn locate_span(text: &str, begin: &str, end: &str) -> Option<(usize, usize)> {
let start = text.find(begin)?;
let after_begin = start + begin.len();
let end_rel = text[after_begin..].find(end)?;
let end_abs = after_begin + end_rel + end.len();
Some((start, end_abs - start))
}
fn structured_block_span(text: &str) -> Option<(usize, usize)> {
let (start, len) = locate_span(
text,
c2pa_text::structured::BEGIN_DELIMITER,
c2pa_text::structured::END_DELIMITER,
)?;
let bytes = text.as_bytes();
let mut block_start = start;
while block_start > 0 && bytes[block_start - 1] != b'\n' {
block_start -= 1;
}
let mut block_end = start + len;
while block_end < bytes.len() && bytes[block_end] != b'\n' {
block_end += 1;
}
Some((block_start, block_end - block_start))
}
#[cfg(test)]
mod tests {
use super::*;
fn fake_store() -> Vec<u8> {
(0u8..64)
.map(|i| i.wrapping_mul(7).wrapping_add(3))
.collect()
}
fn unhex(s: &str) -> Vec<u8> {
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
.collect()
}
#[test]
fn golden_unstructured_ascii_small() {
let manifest = unhex("deadbeef");
let expected = unhex("68656c6c6f20776f726c64efbbbff3a084b3f3a084a2f3a08580f3a084b1f3a08584f3a08588f3a08584efb880efb881efb880efb880efb880efb884f3a0878ef3a0869df3a086aef3a0879f");
let ssot = c2pa_text::embed_manifest("hello world", &manifest);
assert_eq!(
ssot.into_bytes(),
expected,
"SSOT VS wrapper must match golden"
);
let engine = embed(TextMethod::Unstructured, b"hello world", &manifest).unwrap();
assert_eq!(
extract(TextMethod::Unstructured, &engine).unwrap(),
Some(manifest)
);
}
#[test]
fn golden_html_inline_small() {
let manifest = unhex("deadbeef");
let html = "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>Example</title>\n</head>\n<body>\n<p>Content here.</p>\n</body>\n</html>\n";
let expected = unhex("3c21444f43545950452068746d6c3e0a3c68746d6c206c616e673d22656e223e0a3c686561643e0a3c6d65746120636861727365743d227574662d38223e0a3c7469746c653e4578616d706c653c2f7469746c653e0a3c73637269707420747970653d226170706c69636174696f6e2f63327061223e3371322b37773d3d3c2f7363726970743e0a3c2f686561643e0a3c626f64793e0a3c703e436f6e74656e7420686572652e3c2f703e0a3c2f626f64793e0a3c2f68746d6c3e0a");
let out = embed(TextMethod::Html, html.as_bytes(), &manifest).unwrap();
assert_eq!(
out, expected,
"HTML inline embed must match c2pa-text golden"
);
}
#[test]
fn unstructured_round_trips() {
let store = fake_store();
let embedded = embed(TextMethod::Unstructured, b"a social post", &store).unwrap();
let out = extract(TextMethod::Unstructured, &embedded).unwrap();
assert_eq!(out, Some(store));
assert!(String::from_utf8(embedded)
.unwrap()
.starts_with("a social post"));
}
#[test]
fn structured_round_trips() {
let store = fake_store();
let src = "# Title\n\nsome markdown body\n";
let embedded = embed(TextMethod::Structured, src.as_bytes(), &store).unwrap();
let text = String::from_utf8(embedded.clone()).unwrap();
assert!(text.contains("BEGIN C2PA MANIFEST"));
let out = extract(TextMethod::Structured, &embedded).unwrap();
assert_eq!(out, Some(store));
}
#[test]
fn html_round_trips() {
let store = fake_store();
let html = "<html><head><title>x</title></head><body>hi</body></html>";
let embedded = embed(TextMethod::Html, html.as_bytes(), &store).unwrap();
let text = String::from_utf8(embedded.clone()).unwrap();
assert!(text.contains("application/c2pa"));
let out = extract(TextMethod::Html, &embedded).unwrap();
assert_eq!(out, Some(store));
}
#[test]
fn no_manifest_is_none_not_error() {
assert_eq!(
extract(TextMethod::Unstructured, b"plain text").unwrap(),
None
);
assert_eq!(
extract(TextMethod::Structured, b"plain text").unwrap(),
None
);
assert_eq!(extract(TextMethod::Html, b"<html></html>").unwrap(), None);
}
#[test]
fn invalid_utf8_errors() {
assert!(extract(TextMethod::Unstructured, &[0xff, 0xfe]).is_err());
}
#[test]
fn structured_comment_wrapped_round_trips() {
let store = fake_store();
let src = "body { color: red }\n";
let embedded = embed_structured(src.as_bytes(), &store, "/*", "*/").unwrap();
let text = String::from_utf8(embedded.clone()).unwrap();
assert!(text.contains("/* -----BEGIN C2PA MANIFEST-----"));
assert!(text.trim_end().ends_with("*/"));
assert!(text.starts_with(src));
assert_eq!(
extract(TextMethod::Structured, &embedded).unwrap(),
Some(store)
);
let ex = exclusions(TextMethod::Structured, &embedded).unwrap();
assert_eq!(ex.len(), 1);
let excluded = &embedded[ex[0].start..ex[0].start + ex[0].length];
assert!(excluded.starts_with(b"/* -----BEGIN C2PA MANIFEST-----"));
assert!(excluded.ends_with(b"*/"));
assert!(ex[0].start >= src.len());
assert_eq!(&embedded[..src.len()], src.as_bytes());
}
}