use std::collections::HashMap;
use std::io::{BufReader, Cursor, Read, Seek};
use easyofd_core::model::bookmark::Bookmark;
use easyofd_core::model::bookmarks::Bookmarks;
use easyofd_core::model::custom_data::CustomData;
use easyofd_core::model::custom_datas::CustomDatas;
use easyofd_core::model::permissions::Permissions;
use easyofd_core::model::template_page::TemplatePage;
use easyofd_core::{
ContentObject, ImageFormat, ImageObject, OfdError, OfdPage, OfdResult, PathObject, TextObject,
};
use quick_xml::Reader as XmlReader;
use quick_xml::events::Event;
pub(crate) struct OfdEntry {
pub(crate) doc_dir: String,
pub(crate) document_file: String,
pub(crate) doc_id: Option<String>,
pub(crate) title: Option<String>,
pub(crate) author: Option<String>,
pub(crate) creator: Option<String>,
pub(crate) creator_version: Option<String>,
pub(crate) mod_date: Option<String>,
pub(crate) creation_date: Option<String>,
pub(crate) max_unit_id: u32,
pub(crate) custom_datas: Option<CustomDatas>,
pub(crate) signatures_path: Option<String>,
pub(crate) doc_usage: Option<String>,
pub(crate) keywords: Option<String>,
pub(crate) subject: Option<String>,
pub(crate) raw_xml: String,
}
pub(crate) fn parse_ofd_entry<R: Read + std::io::Seek>(
archive: &mut zip::ZipArchive<R>,
) -> OfdResult<OfdEntry> {
let xml_bytes = read_zip_entry(archive, "OFD.xml")?;
let xml_str = std::str::from_utf8(&xml_bytes)
.map_err(|e| OfdError::Xml(format!("OFD.xml: invalid UTF-8: {e}")))?;
let root = easyofd_core::parse_xml_to_nodes(xml_str)
.map_err(|e| OfdError::Xml(format!("OFD.xml: {e}")))?;
let doc_root = find_text_deep(&root, "DocRoot")
.filter(|s| !s.is_empty())
.ok_or_else(|| OfdError::InvalidDocument("missing DocRoot".into()))?;
let doc_root = doc_root.trim_start_matches('/').to_string();
let (doc_dir, document_file) = match doc_root.rfind('/') {
Some(idx) => (doc_root[..idx].to_string(), doc_root[idx + 1..].to_string()),
None => (String::new(), doc_root),
};
let doc_id = find_optional_text_deep(&root, "DocID");
let title = find_optional_text_deep(&root, "Title");
let author = find_optional_text_deep(&root, "Author");
let creator = find_optional_text_deep(&root, "Creator");
let creator_version = find_optional_text_deep(&root, "CreatorVersion");
let mod_date = find_optional_text_deep(&root, "ModDate");
let creation_date = find_optional_text_deep(&root, "CreationDate");
let signatures_path = find_optional_text_deep(&root, "Signatures");
let doc_usage = find_optional_text_deep(&root, "DocUsage");
let keywords = find_optional_text_deep(&root, "Keywords");
let subject = find_optional_text_deep(&root, "Subject");
let max_unit_id: u32 = find_text_deep(&root, "MaxUnitID")
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let custom_datas = find_node_deep(&root, "CustomDatas").and_then(|cds_node| {
let mut datas = CustomDatas::new();
for child in &cds_node.children {
if child.name == "CustomData" {
let name = child.get_attr("Name").unwrap_or_default().to_string();
let value = child.text.clone().unwrap_or_default().trim().to_string();
datas.push(CustomData::new(name, &value));
}
}
if datas.is_empty() { None } else { Some(datas) }
});
Ok(OfdEntry {
doc_dir,
document_file,
doc_id,
title,
author,
creator,
creator_version,
mod_date,
creation_date,
max_unit_id,
custom_datas,
signatures_path,
doc_usage,
keywords,
subject,
raw_xml: xml_str.to_string(),
})
}
pub(crate) struct DocumentEntry {
pub(crate) pages: Vec<String>,
pub(crate) bookmarks: Option<Bookmarks>,
pub(crate) outlines: Option<Bookmarks>,
pub(crate) application_box: Option<String>,
pub(crate) content_box: Option<String>,
pub(crate) clip_box: Option<String>,
pub(crate) bleed_box: Option<String>,
pub(crate) trim_box: Option<String>,
pub(crate) template_pages: Vec<TemplatePage>,
pub(crate) annotations_path: Option<String>,
pub(crate) attachments_path: Option<String>,
pub(crate) custom_tags_path: Option<String>,
pub(crate) page_area_present: bool,
pub(crate) document_res: Option<String>,
pub(crate) document_res_element_present: bool,
pub(crate) permissions: Option<Permissions>,
pub(crate) public_res_element_present: bool,
pub(crate) raw_xml: String,
}
pub(crate) fn parse_document_entry<R: Read + std::io::Seek>(
archive: &mut zip::ZipArchive<R>,
doc_dir: &str,
document_file: &str,
) -> OfdResult<DocumentEntry> {
let path = doc_path(doc_dir, document_file);
let xml_bytes = read_zip_entry(archive, &path)?;
let xml_str = std::str::from_utf8(&xml_bytes)
.map_err(|e| OfdError::Xml(format!("Document.xml: invalid UTF-8: {e}")))?;
let root = easyofd_core::parse_xml_to_nodes(xml_str)
.map_err(|e| OfdError::Xml(format!("Document.xml: {e}")))?;
let pages: Vec<String> = root
.child("Pages")
.map(|pn| {
pn.children_named("Page")
.filter_map(|pe| pe.get_attr("BaseLoc").map(String::from))
.collect()
})
.unwrap_or_default();
let bookmarks = root.child("Bookmarks").map(|bn| {
let mut bms = Bookmarks::new();
for child in &bn.children {
if child.name == "Bookmark" {
let name = child.get_attr("Name").or_else(|| child.get_attr("Title"));
let goto = child
.get_attr("GoTo")
.or_else(|| child.child("Dest").and_then(|d| d.get_attr("PageID")));
if let Some(n) = name {
if !n.is_empty() {
let mut bm = Bookmark::new(n);
if let Some(g) = goto {
bm = bm.with_goto(g);
}
bms.push(bm);
}
}
}
}
bms
});
let outlines = root.child("Outlines").map(|on| {
let mut ols = Bookmarks::new();
for child in &on.children {
if child.name == "OutlineElem" {
let title = child.get_attr("Title");
let goto = child.get_attr("GoTo").or_else(|| {
child
.child("Actions")
.and_then(|a| a.child("Action"))
.and_then(|a| a.child("Goto"))
.and_then(|g| g.child("Dest"))
.and_then(|d| d.get_attr("PageID"))
});
if let Some(t) = title {
if !t.is_empty() {
let mut bm = Bookmark::new(t);
if let Some(g) = goto {
bm = bm.with_goto(g);
}
ols.push(bm);
}
}
}
}
ols
});
let application_box = find_text_deep(&root, "ApplicationBox");
let content_box = find_text_deep(&root, "ContentBox");
let clip_box = find_text_deep(&root, "ClipBox");
let bleed_box = find_text_deep(&root, "BleedBox");
let trim_box = find_text_deep(&root, "TrimBox");
let template_pages: Vec<TemplatePage> = find_all_nodes_deep(&root, "TemplatePage")
.iter()
.filter_map(|node| {
let id = node.get_attr("ID").unwrap_or_default().to_string();
let base_loc = node.get_attr("BaseLoc")?;
if base_loc.is_empty() {
None
} else {
Some(TemplatePage::new(id, base_loc))
}
})
.collect();
let annotations_path = find_text_deep(&root, "Annotations");
let attachments_path = find_text_deep(&root, "Attachments");
let custom_tags_path = find_text_deep(&root, "CustomTags");
let page_area_present = find_node_deep(&root, "PageArea").is_some();
let document_res_node = find_node_deep(&root, "DocumentRes");
let document_res_element_present = document_res_node.is_some();
let document_res = document_res_node
.and_then(|n| n.text.clone())
.map(|s| s.trim().to_string());
let public_res_element_present = find_node_deep(&root, "PublicRes").is_some();
let permissions = root.child("Permissions").map(|pn| {
let mut perms = Permissions::new();
for child in &pn.children {
let key = child.name.as_str();
let value = match key {
"Print" => child
.get_attr("Printable")
.and_then(|v| v.parse::<bool>().ok())
.unwrap_or(false),
_ => child
.text
.as_deref()
.unwrap_or("")
.trim()
.parse::<bool>()
.unwrap_or(false),
};
match key {
"Edit" => perms.edit = Some(value),
"Annot" => perms.annot = Some(value),
"Export" => perms.export = Some(value),
"Signature" => perms.signature = Some(value),
"Watermark" => perms.watermark = Some(value),
"PrintScreen" => perms.print_screen = Some(value),
"Print" => perms.print = Some(value),
"CopyText" => perms.copy_text = Some(value),
"ContentRegist" => perms.content_regist = Some(value),
_ => {}
}
}
perms
});
Ok(DocumentEntry {
pages,
bookmarks,
outlines,
application_box,
content_box,
clip_box,
bleed_box,
trim_box,
template_pages,
annotations_path,
attachments_path,
custom_tags_path,
page_area_present,
document_res,
document_res_element_present,
permissions,
public_res_element_present,
raw_xml: xml_str.to_string(),
})
}
#[derive(Debug, Clone)]
pub(crate) struct ResourceEntry {
pub(crate) location: String,
#[allow(dead_code)]
pub(crate) original_path: String,
pub(crate) format: ImageFormat,
}
pub(crate) fn parse_document_resources<R: Read + Seek>(
archive: &mut zip::ZipArchive<R>,
doc_dir: &str,
document_res: Option<&str>,
) -> OfdResult<HashMap<String, ResourceEntry>> {
let path = match document_res {
Some(res) if !res.is_empty() => doc_path(doc_dir, res),
_ => format!("{doc_dir}/DocumentRes.xml"),
};
let xml = match read_zip_entry(archive, &path) {
Ok(xml) => xml,
Err(_) => return Ok(HashMap::new()),
};
let mut reader = XmlReader::from_reader(BufReader::new(Cursor::new(&xml)));
reader.config_mut().trim_text(false);
let mut buf = Vec::new();
let mut current: Option<(String, ImageFormat)> = None;
let mut in_media_file = false;
let mut base_loc_stack: Vec<String> = Vec::new();
let mut resources = HashMap::new();
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(ref event)) if event.name().as_ref() == "ofd:Res" => {
let mut base_loc = String::new();
for attribute in event.attributes().flatten() {
if attribute.key.as_ref() == "BaseLoc" {
base_loc = attribute
.normalized_value(quick_xml::XmlVersion::Explicit1_0)
.unwrap_or_default()
.to_string();
}
}
base_loc_stack.push(base_loc);
}
Ok(Event::Start(ref event)) if event.name().as_ref() == "ofd:MultiMedia" => {
let mut id = None;
let mut format = ImageFormat::Jpeg;
for attribute in event.attributes().flatten() {
let value = attribute
.normalized_value(quick_xml::XmlVersion::Explicit1_0)
.unwrap_or_default();
match attribute.key.as_ref() {
"ID" => id = Some(value.to_string()),
"Type" => format = parse_image_format(&value),
_ => {}
}
}
current = id.map(|id| (id, format));
}
Ok(Event::Start(ref event)) if event.name().as_ref() == "ofd:MediaFile" => {
in_media_file = true;
}
Ok(Event::Text(ref event)) if in_media_file => {
if let Some((id, format)) = current.take() {
let raw_path = event.xml10_content().into_owned();
let raw_path = raw_path.trim().to_string();
let base = base_loc_stack.last().map_or("", String::as_str);
let original_path = raw_path.clone();
let location = if base.is_empty() {
raw_path
} else {
format!("{}/{}", base.trim_end_matches('/'), raw_path)
};
resources.insert(
id,
ResourceEntry {
location,
original_path,
format,
},
);
}
}
Ok(Event::End(ref event)) if event.name().as_ref() == "ofd:MediaFile" => {
in_media_file = false;
}
Ok(Event::End(ref event)) if event.name().as_ref() == "ofd:Res" => {
base_loc_stack.pop();
}
Ok(Event::Eof) => break,
Err(error) => return Err(OfdError::Xml(format!("{path}: {error}"))),
_ => {}
}
buf.clear();
}
Ok(resources)
}
pub(crate) fn parse_image_format(value: &str) -> ImageFormat {
match value.to_ascii_uppercase().as_str() {
"PNG" => ImageFormat::Png,
"BMP" => ImageFormat::Bmp,
"TIFF" | "TIF" => ImageFormat::Tiff,
_ => ImageFormat::Jpeg,
}
}
pub(crate) fn parse_page_entry<R: Read + std::io::Seek>(
archive: &mut zip::ZipArchive<R>,
page_path: &str,
doc_dir: &str,
resources: &HashMap<String, ResourceEntry>,
) -> OfdResult<OfdPage> {
let xml_bytes = read_zip_entry(archive, page_path)?;
let xml_str = std::str::from_utf8(&xml_bytes)
.map_err(|e| OfdError::Xml(format!("{page_path}: invalid UTF-8: {e}")))?;
let root = easyofd_core::parse_xml_to_nodes(xml_str)
.map_err(|e| OfdError::Xml(format!("{page_path}: {e}")))?;
let (width, height) = find_node_deep(&root, "PhysicalBox")
.and_then(|n| n.text.clone())
.map_or((210.0, 297.0), |s| parse_physical_box_dims(&s));
let content = collect_content_objects(&root, archive, doc_dir, resources)?;
Ok(OfdPage {
width,
height,
content,
base_path: Some(page_path.to_string()),
})
}
fn collect_content_objects<R: Read + std::io::Seek>(
node: &easyofd_core::XmlNode,
archive: &mut zip::ZipArchive<R>,
doc_dir: &str,
resources: &HashMap<String, ResourceEntry>,
) -> OfdResult<Vec<ContentObject>> {
let mut result = Vec::new();
for child in &node.children {
match child.name.as_str() {
"TextObject" => {
let obj = build_text_object_from_node(child);
result.push(ContentObject::Text(obj));
}
"PathObject" => {
let obj = build_path_object_from_node(child);
result.push(ContentObject::Path(obj));
}
"ImageObject" => {
if let Some(img) = build_image_object_from_node(child, archive, doc_dir, resources)?
{
result.push(ContentObject::Image(img));
}
}
_ => {
result.extend(collect_content_objects(child, archive, doc_dir, resources)?);
}
}
}
Ok(result)
}
fn parse_fill_color_value(raw: &str) -> Option<u32> {
let trimmed = raw.trim();
if trimmed.contains(' ') {
let parts: Vec<u32> = trimmed
.split_whitespace()
.filter_map(|s| s.parse().ok())
.collect();
if parts.len() >= 3 {
return Some((parts[0] << 16) | (parts[1] << 8) | parts[2]);
}
return None;
}
let hex = trimmed.strip_prefix('#').unwrap_or(trimmed);
u32::from_str_radix(hex, 16).ok()
}
#[allow(clippy::many_single_char_names)]
fn build_text_object_from_node(node: &easyofd_core::XmlNode) -> TextObject {
let mut x = 0.0_f64;
let mut y = 0.0_f64;
let mut font = None;
let mut size = None;
let mut width = None;
let mut height = None;
let mut weight = None;
let mut italic = None;
let mut fill_color: Option<u32> = None;
for (key, value) in &node.attrs {
match key.as_str() {
"Boundary" => {
let parts: Vec<f64> = value
.split_whitespace()
.filter_map(|s| s.parse().ok())
.collect();
if parts.len() >= 2 {
x = parts[0];
y = parts[1];
}
if parts.len() >= 4 {
width = Some(parts[2]);
height = Some(parts[3]);
}
}
"Font" => font = Some(value.clone()),
"Size" => size = value.parse().ok(),
"Weight" => weight = value.parse().ok(),
"Italic" => {
italic = match value.as_str() {
"true" | "1" => Some(true),
"false" | "0" => Some(false),
_ => None,
};
}
"FillColor" => fill_color = parse_fill_color_value(value),
_ => {}
}
}
if fill_color.is_none() {
if let Some(fc_node) = node.child("FillColor") {
if let Some(val) = fc_node.get_attr("Value") {
fill_color = parse_fill_color_value(val);
}
}
}
let text: String = node
.children
.iter()
.filter(|c| c.name == "TextCode")
.filter_map(|c| c.text.clone())
.collect();
let mut obj = TextObject::new(x, y, text);
if let Some(f) = font {
obj = obj.font(f);
}
if let Some(s) = size {
obj = obj.size(s);
}
if let Some(w) = weight {
obj.weight = w;
}
if let Some(i) = italic {
obj.italic = i;
}
if let Some(c) = fill_color {
obj.color = c;
}
obj.width = width;
obj.height = height;
obj
}
fn build_path_object_from_node(node: &easyofd_core::XmlNode) -> PathObject {
let mut x = 0.0_f64;
let mut y = 0.0_f64;
let mut stroke_color = 0u32;
let mut stroke_width = 0.35_f64;
let mut fill_color = None;
for (key, value) in &node.attrs {
match key.as_str() {
"Boundary" => {
let parts: Vec<f64> = value
.split_whitespace()
.filter_map(|s| s.parse().ok())
.collect();
if parts.len() >= 2 {
x = parts[0];
y = parts[1];
}
}
"StrokeColor" => stroke_color = parse_hex_color(value).unwrap_or(0),
"FillColor" => fill_color = parse_hex_color(value),
"LineWidth" => stroke_width = value.parse().unwrap_or(0.35),
_ => {}
}
}
let path_data = node
.children
.iter()
.find(|c| c.name == "AbbreviatedData")
.and_then(|c| c.text.clone())
.unwrap_or_default();
let mut object = PathObject::new(x, y, path_data)
.stroke_color(stroke_color)
.stroke_width(stroke_width);
if let Some(fc) = fill_color {
object = object.fill_color(fc);
}
object
}
#[allow(clippy::many_single_char_names)]
fn build_image_object_from_node<R: Read + std::io::Seek>(
node: &easyofd_core::XmlNode,
archive: &mut zip::ZipArchive<R>,
doc_dir: &str,
resources: &HashMap<String, ResourceEntry>,
) -> OfdResult<Option<ImageObject>> {
let mut x = 0.0_f64;
let mut y = 0.0_f64;
let mut w = 0.0_f64;
let mut h = 0.0_f64;
let mut resource_id = String::new();
for (key, value) in &node.attrs {
match key.as_str() {
"Boundary" => {
let parts: Vec<f64> = value
.split_whitespace()
.filter_map(|s| s.parse().ok())
.collect();
if parts.len() >= 4 {
x = parts[0];
y = parts[1];
w = parts[2];
h = parts[3];
}
}
"ResourceID" => resource_id.clone_from(value),
_ => {}
}
}
if let Some(resource) = resources.get(&resource_id) {
let resource_path = resolve_resource_path(doc_dir, &resource.location)?;
let actual_name = find_zip_entry_name(archive, &resource_path).unwrap_or(resource_path);
let data = read_zip_entry(archive, &actual_name)?;
Ok(Some(
ImageObject::new(x, y, w, h, data, resource.format).with_res_name(actual_name),
))
} else {
Ok(None)
}
}
fn parse_physical_box_dims(text: &str) -> (f64, f64) {
let parts: Vec<f64> = text
.split_whitespace()
.filter_map(|s| s.parse().ok())
.collect();
if parts.len() >= 4 {
(parts[2], parts[3])
} else {
(210.0, 297.0)
}
}
fn parse_hex_color(value: &str) -> Option<u32> {
u32::from_str_radix(value.trim_start_matches('#'), 16).ok()
}
fn find_zip_entry_name<R: Read + std::io::Seek>(
archive: &mut zip::ZipArchive<R>,
name: &str,
) -> Option<String> {
if archive.by_name(name).is_ok() {
return Some(name.to_string());
}
let lower = name.to_lowercase();
for i in 0..archive.len() {
if archive.by_index(i).ok()?.name().to_lowercase() == lower {
let actual = archive.by_index(i).ok()?.name().to_string();
return Some(actual);
}
}
None
}
pub(crate) fn read_zip_entry<R: Read + std::io::Seek>(
archive: &mut zip::ZipArchive<R>,
name: &str,
) -> OfdResult<Vec<u8>> {
let actual = find_zip_entry_name(archive, name)
.ok_or_else(|| OfdError::Zip(format!("{name}: specified file not found in archive")))?;
let mut file = archive
.by_name(&actual)
.map_err(|e| OfdError::Zip(format!("{actual}: {e}")))?;
let mut buf = Vec::new();
file.read_to_end(&mut buf).map_err(OfdError::Io)?;
Ok(buf)
}
pub(crate) fn doc_path(doc_dir: &str, file_name: &str) -> String {
let file_name = file_name.trim_start_matches('/');
if doc_dir.is_empty() {
return file_name.to_string();
}
let head = &file_name[..file_name.len().min(doc_dir.len())];
if head.eq_ignore_ascii_case(doc_dir) && file_name.as_bytes().get(doc_dir.len()) == Some(&b'/')
{
format!("{doc_dir}{}", &file_name[doc_dir.len()..])
} else {
format!("{doc_dir}/{file_name}")
}
}
pub(crate) fn resolve_resource_path(doc_dir: &str, location: &str) -> OfdResult<String> {
let location = location.trim_start_matches('/');
let doc_dir_prefix = format!("{doc_dir}/");
let path = if location
.get(..doc_dir_prefix.len())
.is_some_and(|head| head.eq_ignore_ascii_case(&doc_dir_prefix))
{
location.to_string()
} else {
format!("{doc_dir}/{location}")
};
easyofd_package::validate_entry_name(&path)?;
Ok(path)
}
fn find_node_deep<'a>(
node: &'a easyofd_core::XmlNode,
name: &str,
) -> Option<&'a easyofd_core::XmlNode> {
if node.name == name {
return Some(node);
}
for child in &node.children {
if let Some(found) = find_node_deep(child, name) {
return Some(found);
}
}
None
}
fn find_text_deep(node: &easyofd_core::XmlNode, name: &str) -> Option<String> {
find_node_deep(node, name)
.and_then(|n| n.text.clone())
.map(|s| s.trim().to_string())
}
fn find_optional_text_deep(node: &easyofd_core::XmlNode, name: &str) -> Option<String> {
find_node_deep(node, name).map(|n| n.text.clone().unwrap_or_default().trim().to_string())
}
fn find_all_nodes_deep<'a>(
node: &'a easyofd_core::XmlNode,
name: &str,
) -> Vec<&'a easyofd_core::XmlNode> {
let mut result = Vec::new();
collect_nodes_deep(node, name, &mut result);
result
}
fn collect_nodes_deep<'a>(
node: &'a easyofd_core::XmlNode,
name: &str,
result: &mut Vec<&'a easyofd_core::XmlNode>,
) {
if node.name == name {
result.push(node);
}
for child in &node.children {
collect_nodes_deep(child, name, result);
}
}
#[cfg(test)]
mod tests {
use super::*;
use easyofd_core::XmlNode;
#[test]
fn parse_fill_color_hex_no_hash() {
assert_eq!(parse_fill_color_value("FF0000"), Some(0xFF_0000));
}
#[test]
fn parse_fill_color_hex_with_hash() {
assert_eq!(parse_fill_color_value("#00FF00"), Some(0x00_FF00));
}
#[test]
fn parse_fill_color_decimal_rgb() {
assert_eq!(parse_fill_color_value("156 82 35"), Some(0x9C_5223));
}
#[test]
fn parse_fill_color_black() {
assert_eq!(parse_fill_color_value("0 0 0"), Some(0));
}
#[test]
fn parse_fill_color_invalid() {
assert_eq!(parse_fill_color_value("not_a_color"), None);
}
#[test]
fn build_text_object_weight_attr() {
let node = XmlNode::element("TextObject")
.attr("Boundary", "10 20 100 50")
.attr("Font", "SimSun")
.attr("Size", "12")
.attr("Weight", "700");
let obj = build_text_object_from_node(&node);
assert_eq!(obj.weight, 700);
}
#[test]
fn build_text_object_italic_attr() {
let node = XmlNode::element("TextObject")
.attr("Boundary", "10 20 100 50")
.attr("Font", "SimSun")
.attr("Size", "12")
.attr("Italic", "true");
let obj = build_text_object_from_node(&node);
assert!(obj.italic);
}
#[test]
fn build_text_object_italic_false() {
let node = XmlNode::element("TextObject")
.attr("Boundary", "10 20 100 50")
.attr("Italic", "false");
let obj = build_text_object_from_node(&node);
assert!(!obj.italic);
}
#[test]
fn build_text_object_fill_color_attr_hex() {
let node = XmlNode::element("TextObject")
.attr("Boundary", "10 20 100 50")
.attr("FillColor", "9C5223");
let obj = build_text_object_from_node(&node);
assert_eq!(obj.color, 0x9C_5223);
}
#[test]
fn build_text_object_fill_color_child_element() {
let mut node = XmlNode::element("TextObject").attr("Boundary", "10 20 100 50");
let fc = XmlNode::element("FillColor").attr("Value", "156 82 35");
node.push_child(fc);
let obj = build_text_object_from_node(&node);
assert_eq!(obj.color, 0x9C_5223);
}
#[test]
fn build_text_object_fill_color_attr_takes_precedence() {
let mut node = XmlNode::element("TextObject")
.attr("Boundary", "10 20 100 50")
.attr("FillColor", "FF0000");
let fc = XmlNode::element("FillColor").attr("Value", "0 255 0");
node.push_child(fc);
let obj = build_text_object_from_node(&node);
assert_eq!(obj.color, 0xFF_0000);
}
#[test]
fn build_text_object_defaults_unchanged() {
let node = XmlNode::element("TextObject").attr("Boundary", "10 20 100 50");
let obj = build_text_object_from_node(&node);
assert_eq!(obj.weight, 400);
assert!(!obj.italic);
assert_eq!(obj.color, 0);
}
#[test]
fn build_text_object_all_three_together() {
let node = XmlNode::element("TextObject")
.attr("Boundary", "10 20 100 50")
.attr("Font", "SimHei")
.attr("Size", "14")
.attr("Weight", "900")
.attr("Italic", "true")
.attr("FillColor", "0000FF");
let obj = build_text_object_from_node(&node);
assert_eq!(obj.weight, 900);
assert!(obj.italic);
assert_eq!(obj.color, 0x00_00FF);
assert_eq!(obj.font, "SimHei");
}
}