mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 22:42:46 +00:00
Merge remote-tracking branch 'origin/main' into codex/docx-measure-clamp
This commit is contained in:
commit
32e802fa08
15 files changed
+4612
-560
No files matched your search
Generated
+1
@@ -1848,6 +1848,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"craft-heif",
|
||||
"craft-jpeg",
|
||||
"craft-metadata",
|
||||
"exr",
|
||||
"flate2",
|
||||
"half",
|
||||
|
||||
+12
-1
@@ -39,7 +39,10 @@ pub mod value;
|
||||
|
||||
pub use craft_xml::{El, Local, Node};
|
||||
pub use package::{Document, Limits, Package, write_package};
|
||||
pub use part::{CONTAINER_XML, Props, designmap_xml, parse_part, part_root, part_xml, prop, with_props, xml_limits};
|
||||
pub use part::{
|
||||
CONTAINER_XML, Props, designmap_include, designmap_root, designmap_xml, designmap_xml_with_pi, parse_part, part_root, part_xml, prop, with_props,
|
||||
xml_limits,
|
||||
};
|
||||
|
||||
/// The IDML package mimetype (content of the first, stored `mimetype` entry).
|
||||
pub const MIMETYPE: &str = "application/vnd.adobe.indesign-idml-package";
|
||||
@@ -48,6 +51,14 @@ pub const DOM_VERSION: &str = "16.0";
|
||||
/// The namespace of the `idPkg` prefix.
|
||||
pub const PACKAGING_NS: &str = "http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging";
|
||||
|
||||
/// Writes the bounded XMP metadata packet used by an IDML package. Dates are already normalized
|
||||
/// ISO-8601 values supplied by the caller; this function owns the XML vocabulary and envelope.
|
||||
pub fn xmp_metadata(create_date: &str, modify_date: &str) -> String {
|
||||
format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<x:xmpmeta xmlns:x=\"adobe:ns:meta/\">\n <rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\n <rdf:Description rdf:about=\"\" xmlns:xmp=\"http://ns.adobe.com/xap/1.0/\">\n <xmp:CreateDate>{create_date}</xmp:CreateDate>\n <xmp:ModifyDate>{modify_date}</xmp:ModifyDate>\n </rdf:Description>\n </rdf:RDF>\n</x:xmpmeta>\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// Why a package was not read or not written.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum Error {
|
||||
|
||||
@@ -0,0 +1,524 @@
|
||||
//! The `Document` tree: root values and included designmap nodes in source order.
|
||||
|
||||
use craft_xml::El;
|
||||
|
||||
use super::{AlternatingFillValues, AlternatingStrokeValues, Spread, Story, StrokeValues, Values};
|
||||
|
||||
/// One IDML table-of-contents style entry before document style names are resolved.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct TocStyleEntryValues {
|
||||
pub style: Option<String>,
|
||||
pub level: Option<u8>,
|
||||
pub page_number_position: Option<String>,
|
||||
pub separator: Option<String>,
|
||||
pub entry_style: Option<String>,
|
||||
pub page_number_style: Option<String>,
|
||||
pub separator_style: Option<String>,
|
||||
pub sort: Option<bool>,
|
||||
}
|
||||
|
||||
/// An IDML table-of-contents style and its entry grammar.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct TocStyleValues {
|
||||
pub self_id: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub title_style: Option<String>,
|
||||
pub create_bookmarks: Option<bool>,
|
||||
pub entries: Vec<TocStyleEntryValues>,
|
||||
}
|
||||
|
||||
/// Date-based IDML text variable kinds.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TextDateKind {
|
||||
Creation,
|
||||
Modification,
|
||||
Output,
|
||||
}
|
||||
|
||||
/// Semantically classified `TextVariable` definitions before caller model mapping.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum TextVariableKind {
|
||||
CustomText { text: String },
|
||||
LastPageNumber { section: bool },
|
||||
ChapterNumber,
|
||||
FileName { extension: bool },
|
||||
Date { kind: TextDateKind, format: String },
|
||||
RunningHeader { style: String, character: bool, last_on_page: bool },
|
||||
CrossReference { page_number: bool },
|
||||
Imported { variable_type: String, settings_element: String, settings: Vec<(String, String)> },
|
||||
}
|
||||
|
||||
/// Parsed values from an IDML `TextVariable` definition.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TextVariableValues {
|
||||
pub self_id: Option<String>,
|
||||
pub name: String,
|
||||
pub before: String,
|
||||
pub after: String,
|
||||
pub kind: TextVariableKind,
|
||||
}
|
||||
|
||||
/// A recognized IDML cross-reference format building block.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum CrossReferenceBuildingBlock {
|
||||
CustomText(String),
|
||||
FullParagraph,
|
||||
ParagraphText,
|
||||
ParagraphNumber,
|
||||
PageNumber,
|
||||
BookmarkName,
|
||||
ChapterNumber,
|
||||
FileName,
|
||||
PartialParagraph { delimiter: String, include_delimiter: bool },
|
||||
}
|
||||
|
||||
/// Parsed IDML cross-reference format definition.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct CrossReferenceFormatValues {
|
||||
pub self_id: Option<String>,
|
||||
pub name: String,
|
||||
pub blocks: Vec<CrossReferenceBuildingBlock>,
|
||||
}
|
||||
|
||||
impl CrossReferenceFormatValues {
|
||||
/// Reads a `CrossReferenceFormat` and recognizes its building-block grammar.
|
||||
pub fn from_el(el: &El) -> Self {
|
||||
let values = Values::of(el);
|
||||
let mut result =
|
||||
Self { self_id: values.self_id().map(str::to_owned), name: values.get("Name").unwrap_or("Format").to_owned(), blocks: Vec::new() };
|
||||
for block in el.elements().filter(|child| child.local() == "BuildingBlock") {
|
||||
let values = Values::of(block);
|
||||
let text = |key: &str| values.get(key).filter(|value| *value != "$ID/").unwrap_or("").to_owned();
|
||||
let parsed = match values.get("BlockType").unwrap_or("") {
|
||||
"CustomStringBuildingBlock" => Some(CrossReferenceBuildingBlock::CustomText(text("CustomText"))),
|
||||
"FullParagraphBuildingBlock" => Some(CrossReferenceBuildingBlock::FullParagraph),
|
||||
"ParagraphTextBuildingBlock" => Some(CrossReferenceBuildingBlock::ParagraphText),
|
||||
"ParagraphNumberBuildingBlock" => Some(CrossReferenceBuildingBlock::ParagraphNumber),
|
||||
"PageNumberBuildingBlock" => Some(CrossReferenceBuildingBlock::PageNumber),
|
||||
"BookmarkNameBuildingBlock" => Some(CrossReferenceBuildingBlock::BookmarkName),
|
||||
"ChapterNumberBuildingBlock" => Some(CrossReferenceBuildingBlock::ChapterNumber),
|
||||
"FileNameBuildingBlock" => Some(CrossReferenceBuildingBlock::FileName),
|
||||
"PartialParagraphBuildingBlock" => Some(CrossReferenceBuildingBlock::PartialParagraph {
|
||||
delimiter: text("AppliedDelimiter"),
|
||||
include_delimiter: values.get("IncludeDelimiter") == Some("true"),
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(parsed) = parsed {
|
||||
result.blocks.push(parsed);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Builds an IDML cross-reference format from DesignCraft's stored token definition.
|
||||
pub fn to_el(self_id: &str, name: &str, definition: &str) -> El {
|
||||
let mut root = El::new("CrossReferenceFormat").with_attr("Self", self_id).with_attr("Name", name).with_attr("AppliedCharacterStyle", "n");
|
||||
let mut index = 0;
|
||||
let mut block = |root: &mut El, kind: &str, text: &str, delimiter: &str, include: bool| {
|
||||
root.push(
|
||||
El::new("BuildingBlock")
|
||||
.with_attr("Self", format!("{self_id}BuildingBlock{index}"))
|
||||
.with_attr("BlockType", kind)
|
||||
.with_attr("AppliedCharacterStyle", "n")
|
||||
.with_attr("CustomText", if text.is_empty() { "$ID/" } else { text })
|
||||
.with_attr("AppliedDelimiter", if delimiter.is_empty() { "$ID/" } else { delimiter })
|
||||
.with_attr("IncludeDelimiter", if include { "true" } else { "false" }),
|
||||
);
|
||||
index += 1;
|
||||
};
|
||||
let mut rest = definition;
|
||||
while let Some(start) = rest.find('<') {
|
||||
let Some(end) = rest[start..].find('>') else { break };
|
||||
if start > 0 {
|
||||
block(&mut root, "CustomStringBuildingBlock", &rest[..start], "", false);
|
||||
}
|
||||
let tag = rest[start + 1..start + end].trim().trim_end_matches('/').trim();
|
||||
let attr =
|
||||
|key: &str| tag.find(&format!("{key}=\"")).and_then(|position| tag[position + key.len() + 2..].split('"').next()).unwrap_or("");
|
||||
match tag.split_whitespace().next().unwrap_or("") {
|
||||
"fullPara" => block(&mut root, "FullParagraphBuildingBlock", "", "", false),
|
||||
"paraText" => block(&mut root, "ParagraphTextBuildingBlock", "", "", false),
|
||||
"paraNum" => block(&mut root, "ParagraphNumberBuildingBlock", "", "", false),
|
||||
"pageNum" => block(&mut root, "PageNumberBuildingBlock", "", "", false),
|
||||
"txtAnchrName" => block(&mut root, "BookmarkNameBuildingBlock", "", "", false),
|
||||
"chapNum" => block(&mut root, "ChapterNumberBuildingBlock", "", "", false),
|
||||
"fileName" => block(&mut root, "FileNameBuildingBlock", "", "", false),
|
||||
"partialPara" => block(&mut root, "PartialParagraphBuildingBlock", "", attr("delim"), attr("includeDelim") == "true"),
|
||||
_ => block(&mut root, "CustomStringBuildingBlock", &rest[start..start + end + 1], "", false),
|
||||
}
|
||||
rest = &rest[start + end + 1..];
|
||||
}
|
||||
if !rest.is_empty() {
|
||||
block(&mut root, "CustomStringBuildingBlock", rest, "", false);
|
||||
}
|
||||
root
|
||||
}
|
||||
}
|
||||
|
||||
/// Parsed EPUB document-splitting preference.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct EpubSplitValues {
|
||||
pub break_document: bool,
|
||||
pub paragraph_style: Option<String>,
|
||||
}
|
||||
|
||||
/// Parsed IDML condition metadata.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ConditionValues {
|
||||
pub self_id: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub indicator_rgb: Option<[u8; 3]>,
|
||||
pub visible: bool,
|
||||
}
|
||||
|
||||
impl ConditionValues {
|
||||
/// Reads condition fields and its optional three-channel indicator color.
|
||||
pub fn from_el(el: &El) -> Self {
|
||||
let values = Values::of(el);
|
||||
let rgb: Vec<u8> = values
|
||||
.get("IndicatorColor")
|
||||
.unwrap_or("")
|
||||
.split_whitespace()
|
||||
.filter_map(|value| value.parse::<f64>().ok())
|
||||
.map(|value| value.clamp(0.0, 255.0) as u8)
|
||||
.collect();
|
||||
Self {
|
||||
self_id: values.self_id().map(str::to_owned),
|
||||
name: values.get("Name").map(str::to_owned),
|
||||
indicator_rgb: (rgb.len() == 3).then(|| [rgb[0], rgb[1], rgb[2]]),
|
||||
visible: values.get("Visible") != Some("false"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parsed IDML numbering-list metadata.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct NumberingListValues {
|
||||
pub name: String,
|
||||
pub continue_across_stories: bool,
|
||||
}
|
||||
|
||||
/// Parsed cell-style assignments that are independent of DesignCraft's style resolution.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct CellStyleValues {
|
||||
pub self_id: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub based_on: Option<String>,
|
||||
pub fill: Option<String>,
|
||||
pub fill_tint: Option<f64>,
|
||||
pub insets: [Option<f64>; 4],
|
||||
pub vertical_justification: Option<String>,
|
||||
pub first_baseline: Option<String>,
|
||||
pub first_baseline_min: Option<f64>,
|
||||
pub paragraph_style: Option<String>,
|
||||
pub edge_strokes: [StrokeValues; 4],
|
||||
}
|
||||
|
||||
impl CellStyleValues {
|
||||
/// Reads the cell-style value assignments and supports the older inset property names.
|
||||
pub fn from_el(el: &El) -> Self {
|
||||
let values = Values::of(el);
|
||||
let inset = |modern, legacy| values.num(modern).or_else(|| values.num(legacy)).map(|v| v.max(0.0));
|
||||
Self {
|
||||
self_id: values.self_id().map(str::to_owned),
|
||||
name: values.name().map(str::to_owned),
|
||||
based_on: values.prop("BasedOn"),
|
||||
fill: values.prop("FillColor"),
|
||||
fill_tint: values.num("FillTint"),
|
||||
insets: [
|
||||
inset("TextTopInset", "TopInset"),
|
||||
inset("TextLeftInset", "LeftInset"),
|
||||
inset("TextBottomInset", "BottomInset"),
|
||||
inset("TextRightInset", "RightInset"),
|
||||
],
|
||||
vertical_justification: values.get("VerticalJustification").map(str::to_owned),
|
||||
first_baseline: values.get("FirstBaselineOffset").map(str::to_owned),
|
||||
first_baseline_min: values.num("MinimumFirstBaselineOffset").filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 8640.0)),
|
||||
paragraph_style: values.get("AppliedParagraphStyle").map(str::to_owned),
|
||||
edge_strokes: ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].map(|edge| values.stroke_values(edge)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parsed table-style assignments that are independent of DesignCraft's style resolution.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct TableStyleValues {
|
||||
pub name: Option<String>,
|
||||
pub based_on: Option<String>,
|
||||
pub header_cell_style: Option<String>,
|
||||
pub body_cell_style: Option<String>,
|
||||
pub footer_cell_style: Option<String>,
|
||||
pub left_column_cell_style: Option<String>,
|
||||
pub right_column_cell_style: Option<String>,
|
||||
pub header_same_as_body: Option<bool>,
|
||||
pub footer_same_as_body: Option<bool>,
|
||||
pub left_column_same_as_body: Option<bool>,
|
||||
pub right_column_same_as_body: Option<bool>,
|
||||
pub space_before: Option<f64>,
|
||||
pub space_after: Option<f64>,
|
||||
pub border_strokes: [StrokeValues; 4],
|
||||
pub row_fills: AlternatingFillValues,
|
||||
pub column_fills: AlternatingFillValues,
|
||||
pub row_strokes: AlternatingStrokeValues,
|
||||
pub column_strokes: AlternatingStrokeValues,
|
||||
}
|
||||
|
||||
impl TableStyleValues {
|
||||
/// Reads table-style regions, inheritance, spacing, and region reuse values.
|
||||
pub fn from_el(el: &El) -> Self {
|
||||
let values = Values::of(el);
|
||||
Self {
|
||||
name: values.name().map(str::to_owned),
|
||||
based_on: values.prop("BasedOn"),
|
||||
header_cell_style: values.get("HeaderRegionCellStyle").map(str::to_owned),
|
||||
body_cell_style: values.get("BodyRegionCellStyle").map(str::to_owned),
|
||||
footer_cell_style: values.get("FooterRegionCellStyle").map(str::to_owned),
|
||||
left_column_cell_style: values.get("LeftColumnRegionCellStyle").map(str::to_owned),
|
||||
right_column_cell_style: values.get("RightColumnRegionCellStyle").map(str::to_owned),
|
||||
header_same_as_body: values.boolean("HeaderRegionSameAsBodyRegion"),
|
||||
footer_same_as_body: values.boolean("FooterRegionSameAsBodyRegion"),
|
||||
left_column_same_as_body: values.boolean("LeftColumnRegionSameAsBodyRegion"),
|
||||
right_column_same_as_body: values.boolean("RightColumnRegionSameAsBodyRegion"),
|
||||
space_before: values.num("SpaceBefore"),
|
||||
space_after: values.num("SpaceAfter"),
|
||||
border_strokes: ["TopBorder", "LeftBorder", "BottomBorder", "RightBorder"].map(|border| values.stroke_values(border)),
|
||||
row_fills: values.alternating_fill_values("Row"),
|
||||
column_fills: values.alternating_fill_values("Column"),
|
||||
row_strokes: values.alternating_stroke_values("Row"),
|
||||
column_strokes: values.alternating_stroke_values("Column"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NumberingListValues {
|
||||
/// Reads a `NumberingList` element.
|
||||
pub fn from_el(el: &El) -> Self {
|
||||
let values = Values::of(el);
|
||||
Self {
|
||||
name: values.get("Name").unwrap_or("").to_owned(),
|
||||
continue_across_stories: values.boolean("ContinueNumbersAcrossStories") == Some(true),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EpubSplitValues {
|
||||
/// Reads an `EPubExportPreference` element.
|
||||
pub fn from_el(el: &El) -> Self {
|
||||
let values = Values::of(el);
|
||||
Self { break_document: values.boolean("BreakDocument") == Some(true), paragraph_style: values.get("ParagraphStyleName").map(str::to_owned) }
|
||||
}
|
||||
}
|
||||
|
||||
impl TextVariableValues {
|
||||
/// Reads a `TextVariable` and its preference record.
|
||||
pub fn from_el(el: &El) -> Self {
|
||||
let values = Values::of(el);
|
||||
let preference = el.elements().find(|child| child.name.ends_with("Preference"));
|
||||
let attr = |key: &str| preference.and_then(|child| child.attr(key)).unwrap_or("");
|
||||
let variable_type = values.get("VariableType").unwrap_or("");
|
||||
let date = |kind| TextVariableKind::Date { kind, format: if attr("Format").is_empty() { "MM/dd/yy".into() } else { attr("Format").into() } };
|
||||
let kind = match variable_type {
|
||||
"CustomTextType" => TextVariableKind::CustomText { text: attr("Contents").into() },
|
||||
"LastPageNumberType" => TextVariableKind::LastPageNumber { section: attr("Scope") == "SectionScope" },
|
||||
"ChapterNumberType" => TextVariableKind::ChapterNumber,
|
||||
"FileNameType" if attr("IncludePath") != "true" => TextVariableKind::FileName { extension: attr("IncludeExtension") == "true" },
|
||||
"CreationDateType" => date(TextDateKind::Creation),
|
||||
"ModificationDateType" => date(TextDateKind::Modification),
|
||||
"OutputDateType" => date(TextDateKind::Output),
|
||||
"MatchParagraphStyleType" | "MatchCharacterStyleType" => {
|
||||
let character = variable_type == "MatchCharacterStyleType";
|
||||
let style = attr(if character { "AppliedCharacterStyle" } else { "AppliedParagraphStyle" });
|
||||
if style.is_empty() {
|
||||
TextVariableKind::Imported {
|
||||
variable_type: variable_type.into(),
|
||||
settings_element: preference.map(|child| child.name.clone()).unwrap_or_default(),
|
||||
settings: preference.map(|child| child.attrs.clone()).unwrap_or_default(),
|
||||
}
|
||||
} else {
|
||||
TextVariableKind::RunningHeader { style: style.into(), character, last_on_page: attr("SearchStrategy") == "LastOnPage" }
|
||||
}
|
||||
}
|
||||
_ if variable_type.starts_with("Xref") => TextVariableKind::CrossReference { page_number: variable_type == "XrefPageNumberType" },
|
||||
_ => TextVariableKind::Imported {
|
||||
variable_type: variable_type.into(),
|
||||
settings_element: preference.map(|child| child.name.clone()).unwrap_or_default(),
|
||||
settings: preference.map(|child| child.attrs.clone()).unwrap_or_default(),
|
||||
},
|
||||
};
|
||||
Self {
|
||||
self_id: values.self_id().map(str::to_owned),
|
||||
name: values.get("Name").unwrap_or("").to_owned(),
|
||||
before: values.get("TextBefore").unwrap_or("").to_owned(),
|
||||
after: values.get("TextAfter").unwrap_or("").to_owned(),
|
||||
kind,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TocStyleValues {
|
||||
/// Reads a `TOCStyle` element and its bounded `TOCStyleEntry` children.
|
||||
pub fn from_el(el: &El) -> Self {
|
||||
let values = Values::of(el);
|
||||
let mut result = Self {
|
||||
self_id: values.self_id().map(str::to_owned),
|
||||
title: values.get("Title").map(str::to_owned),
|
||||
title_style: values.get("TitleStyle").map(str::to_owned),
|
||||
create_bookmarks: values.get("CreateBookmarks").or_else(|| values.get("CreatePdfBookmarks")).map(|value| value == "true"),
|
||||
entries: Vec::new(),
|
||||
};
|
||||
result.entries.extend(el.elements().filter(|entry| entry.local() == "TOCStyleEntry").take(64).map(|entry| {
|
||||
let values = Values::of(entry);
|
||||
TocStyleEntryValues {
|
||||
style: values.get("Name").map(str::to_owned),
|
||||
level: values.get("Level").and_then(|value| value.trim().parse().ok()),
|
||||
page_number_position: values.get("PageNumberPosition").map(str::to_owned),
|
||||
separator: values.get("Separator").map(str::to_owned),
|
||||
entry_style: values.prop("FormatStyle"),
|
||||
page_number_style: values.prop("PageNumberStyle"),
|
||||
separator_style: values.prop("SeparatorStyle"),
|
||||
sort: values.get("Sort").map(|value| value.trim() == "true"),
|
||||
}
|
||||
}));
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// The top-level IDML elements consumed by document importers.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TopLevelKind {
|
||||
DocumentPreference,
|
||||
TransparencyPreference,
|
||||
TextPreference,
|
||||
MarginPreference,
|
||||
ViewPreference,
|
||||
GridPreference,
|
||||
PasteboardPreference,
|
||||
CompositeFont,
|
||||
KinsokuTable,
|
||||
MojikumiTable,
|
||||
FootnoteOption,
|
||||
EpubExportPreference,
|
||||
Layer,
|
||||
CrossReferenceFormat,
|
||||
Index,
|
||||
TextVariable,
|
||||
Story,
|
||||
Hyperlink,
|
||||
HyperlinkUrlDestination,
|
||||
HyperlinkPageDestination,
|
||||
HyperlinkPageItemSource,
|
||||
HyperlinkOtherDestination,
|
||||
MasterSpread,
|
||||
Spread,
|
||||
Section,
|
||||
TocStyle,
|
||||
Condition,
|
||||
NumberingList,
|
||||
StyleGroup,
|
||||
Other,
|
||||
}
|
||||
|
||||
/// One included node in `designmap.xml`, classified by the shared IDML grammar.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TopLevelNode {
|
||||
pub kind: TopLevelKind,
|
||||
data: TopLevelData,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum TopLevelData {
|
||||
Story(Story),
|
||||
Spread(Spread),
|
||||
Raw(El),
|
||||
}
|
||||
|
||||
impl TopLevelNode {
|
||||
fn from_el(el: El) -> TopLevelNode {
|
||||
let kind = top_level_kind(el.local());
|
||||
let data = match kind {
|
||||
TopLevelKind::Story => TopLevelData::Story(Story::from_el(&el)),
|
||||
TopLevelKind::Spread | TopLevelKind::MasterSpread => TopLevelData::Spread(Spread::from_el(&el)),
|
||||
_ => TopLevelData::Raw(el),
|
||||
};
|
||||
TopLevelNode { kind, data }
|
||||
}
|
||||
|
||||
/// The typed story or spread tree when this is one of those parts.
|
||||
pub fn story(&self) -> Option<&Story> {
|
||||
if let TopLevelData::Story(story) = &self.data { Some(story) } else { None }
|
||||
}
|
||||
|
||||
pub fn spread(&self) -> Option<&Spread> {
|
||||
if let TopLevelData::Spread(spread) = &self.data { Some(spread) } else { None }
|
||||
}
|
||||
|
||||
/// The original element, reconstructed from the typed story/spread model where applicable.
|
||||
pub fn to_el(&self) -> El {
|
||||
match &self.data {
|
||||
TopLevelData::Story(story) => story.to_el(),
|
||||
TopLevelData::Spread(spread) => spread.to_el(),
|
||||
TopLevelData::Raw(el) => el.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn values(&self) -> Values {
|
||||
Values::of(&self.to_el())
|
||||
}
|
||||
}
|
||||
|
||||
/// A neutral view of an IDML package's designmap and included top-level parts.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ModelDocument {
|
||||
pub root: Values,
|
||||
pub top: Vec<TopLevelNode>,
|
||||
pub warnings: Vec<String>,
|
||||
pub metadata: Option<String>,
|
||||
}
|
||||
|
||||
impl ModelDocument {
|
||||
/// Converts the shared bounded package reader's parsed document into typed document nodes.
|
||||
pub fn from_package(document: crate::Document) -> ModelDocument {
|
||||
ModelDocument {
|
||||
root: Values::of(&document.root),
|
||||
top: document.top.into_iter().map(TopLevelNode::from_el).collect(),
|
||||
warnings: document.warnings,
|
||||
metadata: document.metadata,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn top_level_kind(name: &str) -> TopLevelKind {
|
||||
match name {
|
||||
"DocumentPreference" => TopLevelKind::DocumentPreference,
|
||||
"TransparencyPreference" => TopLevelKind::TransparencyPreference,
|
||||
"TextPreference" => TopLevelKind::TextPreference,
|
||||
"MarginPreference" => TopLevelKind::MarginPreference,
|
||||
"ViewPreference" => TopLevelKind::ViewPreference,
|
||||
"GridPreference" => TopLevelKind::GridPreference,
|
||||
"PasteboardPreference" => TopLevelKind::PasteboardPreference,
|
||||
"CompositeFont" => TopLevelKind::CompositeFont,
|
||||
"KinsokuTable" => TopLevelKind::KinsokuTable,
|
||||
"MojikumiTable" => TopLevelKind::MojikumiTable,
|
||||
"FootnoteOption" => TopLevelKind::FootnoteOption,
|
||||
"EPubExportPreference" => TopLevelKind::EpubExportPreference,
|
||||
"Layer" => TopLevelKind::Layer,
|
||||
"CrossReferenceFormat" => TopLevelKind::CrossReferenceFormat,
|
||||
"Index" => TopLevelKind::Index,
|
||||
"TextVariable" => TopLevelKind::TextVariable,
|
||||
"Story" => TopLevelKind::Story,
|
||||
"Hyperlink" => TopLevelKind::Hyperlink,
|
||||
"HyperlinkURLDestination" => TopLevelKind::HyperlinkUrlDestination,
|
||||
"HyperlinkPageDestination" => TopLevelKind::HyperlinkPageDestination,
|
||||
"HyperlinkPageItemSource" => TopLevelKind::HyperlinkPageItemSource,
|
||||
"MasterSpread" => TopLevelKind::MasterSpread,
|
||||
"Spread" => TopLevelKind::Spread,
|
||||
"Section" => TopLevelKind::Section,
|
||||
"TOCStyle" => TopLevelKind::TocStyle,
|
||||
"Condition" => TopLevelKind::Condition,
|
||||
"NumberingList" => TopLevelKind::NumberingList,
|
||||
name if name.starts_with("Root") && name.ends_with("StyleGroup") => TopLevelKind::StyleGroup,
|
||||
name if name.starts_with("Hyperlink") && name.ends_with("Destination") => TopLevelKind::HyperlinkOtherDestination,
|
||||
_ => TopLevelKind::Other,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
//! The IDML transparency/effect settings found on page items and object styles.
|
||||
|
||||
use craft_xml::{El, Local, Node};
|
||||
|
||||
use super::Values;
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct DropShadowValues {
|
||||
pub mode: Option<String>,
|
||||
pub x_offset: Option<f64>,
|
||||
pub y_offset: Option<f64>,
|
||||
pub color: Option<String>,
|
||||
pub opacity: Option<f64>,
|
||||
pub size: Option<f64>,
|
||||
pub spread: Option<f64>,
|
||||
pub blend_mode: Option<String>,
|
||||
}
|
||||
|
||||
impl DropShadowValues {
|
||||
pub fn to_el(&self) -> El {
|
||||
let mut element = El::new("DropShadowSetting");
|
||||
if let Some(value) = &self.mode {
|
||||
element.set_attr("Mode", value);
|
||||
}
|
||||
for (key, value) in
|
||||
[("XOffset", self.x_offset), ("YOffset", self.y_offset), ("Size", self.size), ("Spread", self.spread), ("Opacity", self.opacity)]
|
||||
{
|
||||
if let Some(value) = value {
|
||||
element.set_attr(key, crate::value::num(value));
|
||||
}
|
||||
}
|
||||
if let Some(value) = &self.blend_mode {
|
||||
element.set_attr("BlendMode", value);
|
||||
}
|
||||
if let Some(value) = &self.color {
|
||||
element.set_attr("EffectColor", value);
|
||||
}
|
||||
element
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct FeatherValues {
|
||||
pub mode: Option<String>,
|
||||
pub width: Option<f64>,
|
||||
pub choke_amount: Option<f64>,
|
||||
pub corner_type: Option<String>,
|
||||
}
|
||||
|
||||
impl FeatherValues {
|
||||
pub fn to_el(&self) -> El {
|
||||
let mut element = El::new("FeatherSetting");
|
||||
if let Some(value) = &self.mode {
|
||||
element.set_attr("Mode", value);
|
||||
}
|
||||
for (key, value) in [("Width", self.width), ("ChokeAmount", self.choke_amount)] {
|
||||
if let Some(value) = value {
|
||||
element.set_attr(key, crate::value::num(value));
|
||||
}
|
||||
}
|
||||
if let Some(value) = &self.corner_type {
|
||||
element.set_attr("CornerType", value);
|
||||
}
|
||||
element
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct OpacityStopValues {
|
||||
pub location: Option<f64>,
|
||||
pub opacity: Option<f64>,
|
||||
}
|
||||
|
||||
impl OpacityStopValues {
|
||||
pub fn to_el(&self, self_id: &str, midpoint: bool) -> El {
|
||||
let mut element = El::new("OpacityGradientStop").with_attr("Self", self_id);
|
||||
if let Some(value) = self.opacity {
|
||||
element.set_attr("Opacity", crate::value::num(value));
|
||||
}
|
||||
if let Some(value) = self.location {
|
||||
element.set_attr("Location", crate::value::num(value));
|
||||
}
|
||||
if midpoint {
|
||||
element.set_attr("Midpoint", "50");
|
||||
}
|
||||
element
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct GradientFeatherValues {
|
||||
pub applied: bool,
|
||||
pub radial: bool,
|
||||
pub angle: Option<f64>,
|
||||
pub length: Option<f64>,
|
||||
pub start: Option<[f64; 2]>,
|
||||
pub stops: Vec<OpacityStopValues>,
|
||||
}
|
||||
|
||||
impl GradientFeatherValues {
|
||||
pub fn to_el(&self, id_prefix: &str) -> El {
|
||||
let mut element =
|
||||
El::new("GradientFeatherSetting").with_attr("Applied", "true").with_attr("Type", if self.radial { "Radial" } else { "Linear" });
|
||||
if let Some(value) = self.angle {
|
||||
element.set_attr("Angle", crate::value::num(value));
|
||||
}
|
||||
if let Some(value) = self.length {
|
||||
element.set_attr("Length", crate::value::num(value));
|
||||
}
|
||||
if let Some([x, y]) = self.start {
|
||||
element.set_attr("GradientStart", format!("{} {}", crate::value::num(x), crate::value::num(y)));
|
||||
}
|
||||
for (index, stop) in self.stops.iter().enumerate() {
|
||||
let stop_id = format!("{id_prefix}TransparencySetting1GradientFeatherSetting1OpacityGradientStop{index}");
|
||||
element.push(stop.to_el(&stop_id, index > 0));
|
||||
}
|
||||
element
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct DirectionalFeatherValues {
|
||||
pub applied: bool,
|
||||
pub widths: [Option<f64>; 4],
|
||||
}
|
||||
|
||||
impl DirectionalFeatherValues {
|
||||
pub fn to_el(&self) -> El {
|
||||
let mut element = El::new("DirectionalFeatherSetting").with_attr("Applied", "true");
|
||||
for (key, value) in
|
||||
[("LeftWidth", self.widths[1]), ("RightWidth", self.widths[3]), ("TopWidth", self.widths[0]), ("BottomWidth", self.widths[2])]
|
||||
{
|
||||
if let Some(value) = value {
|
||||
element.set_attr(key, crate::value::num(value));
|
||||
}
|
||||
}
|
||||
element
|
||||
}
|
||||
}
|
||||
|
||||
/// A typed view of an IDML `TransparencySetting` and its effect elements.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct TransparencySetting {
|
||||
pub blend_mode: Option<String>,
|
||||
pub opacity: Option<f64>,
|
||||
pub drop_shadow: Option<DropShadowValues>,
|
||||
pub feather: Option<FeatherValues>,
|
||||
pub gradient_feather: Option<GradientFeatherValues>,
|
||||
pub directional_feather: Option<DirectionalFeatherValues>,
|
||||
}
|
||||
|
||||
impl TransparencySetting {
|
||||
pub fn to_el(&self, id_prefix: &str) -> El {
|
||||
let mut element = El::new("TransparencySetting");
|
||||
if self.blend_mode.is_some() || self.opacity.is_some() {
|
||||
let mut blending = El::new("BlendingSetting");
|
||||
if let Some(value) = &self.blend_mode {
|
||||
blending.set_attr("BlendMode", value);
|
||||
}
|
||||
if let Some(value) = self.opacity {
|
||||
blending.set_attr("Opacity", crate::value::num(value));
|
||||
}
|
||||
element.push(blending);
|
||||
}
|
||||
if let Some(effect) = &self.drop_shadow {
|
||||
element.push(effect.to_el());
|
||||
}
|
||||
if let Some(effect) = &self.feather {
|
||||
element.push(effect.to_el());
|
||||
}
|
||||
if let Some(effect) = &self.directional_feather {
|
||||
element.push(effect.to_el());
|
||||
}
|
||||
if let Some(effect) = &self.gradient_feather {
|
||||
element.push(effect.to_el(id_prefix));
|
||||
}
|
||||
element
|
||||
}
|
||||
|
||||
pub fn from_el(el: &El) -> Self {
|
||||
let child = |name: &str| el.child(Local(name));
|
||||
let blending = child("BlendingSetting").map(Values::of);
|
||||
let drop_shadow = child("DropShadowSetting").map(|el| {
|
||||
let values = Values::of(el);
|
||||
DropShadowValues {
|
||||
mode: values.get("Mode").map(str::to_owned),
|
||||
x_offset: values.num("XOffset"),
|
||||
y_offset: values.num("YOffset"),
|
||||
color: values.get("EffectColor").map(str::to_owned),
|
||||
opacity: values.num("Opacity"),
|
||||
size: values.num("Size"),
|
||||
spread: values.num("Spread"),
|
||||
blend_mode: values.get("BlendMode").map(str::to_owned),
|
||||
}
|
||||
});
|
||||
let feather = child("FeatherSetting").map(|el| {
|
||||
let values = Values::of(el);
|
||||
FeatherValues {
|
||||
mode: values.get("Mode").map(str::to_owned),
|
||||
width: values.num("Width"),
|
||||
choke_amount: values.num("ChokeAmount"),
|
||||
corner_type: values.get("CornerType").map(str::to_owned),
|
||||
}
|
||||
});
|
||||
let gradient_feather = child("GradientFeatherSetting").map(|el| {
|
||||
let values = Values::of(el);
|
||||
let start = values
|
||||
.prop("GradientStart")
|
||||
.and_then(|value| value.split_whitespace().map(|part| part.parse::<f64>().ok().filter(|n| n.is_finite())).collect::<Option<Vec<_>>>())
|
||||
.and_then(|parts| <[f64; 2]>::try_from(parts).ok());
|
||||
GradientFeatherValues {
|
||||
applied: values.boolean("Applied") == Some(true),
|
||||
radial: values.get("Type") == Some("Radial"),
|
||||
angle: values.num("Angle"),
|
||||
length: values.num("Length"),
|
||||
start,
|
||||
stops: el
|
||||
.elements()
|
||||
.filter(|stop| stop.local() == "OpacityGradientStop")
|
||||
.map(|stop| {
|
||||
let values = Values::of(stop);
|
||||
OpacityStopValues { location: values.num("Location"), opacity: values.num("Opacity") }
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
});
|
||||
let directional_feather = child("DirectionalFeatherSetting").map(|el| {
|
||||
let values = Values::of(el);
|
||||
DirectionalFeatherValues {
|
||||
applied: values.boolean("Applied") == Some(true),
|
||||
widths: [values.num("TopWidth"), values.num("LeftWidth"), values.num("BottomWidth"), values.num("RightWidth")],
|
||||
}
|
||||
});
|
||||
Self {
|
||||
blend_mode: blending.as_ref().and_then(|values| values.get("BlendMode").map(str::to_owned)),
|
||||
opacity: blending.as_ref().and_then(|values| values.num("Opacity")),
|
||||
drop_shadow,
|
||||
feather,
|
||||
gradient_feather,
|
||||
directional_feather,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolved object-style chain used to inherit item-level IDML settings.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ObjectStyleStack {
|
||||
chain: Vec<El>,
|
||||
}
|
||||
|
||||
impl ObjectStyleStack {
|
||||
/// Resolves a style reference and its `BasedOn` chain (nearest first, cycle-safe, max 16).
|
||||
pub fn from_styles(styles: &[El], reference: Option<&str>, include_none: bool) -> Self {
|
||||
let find = |reference: &str| {
|
||||
styles.iter().find(|style| Values::of(style).self_id() == Some(reference)).or_else(|| {
|
||||
let normalized = if reference.starts_with("ObjectStyle/") {
|
||||
reference.to_owned()
|
||||
} else {
|
||||
format!("ObjectStyle/{}", crate::value::escape_id(reference.trim()))
|
||||
};
|
||||
styles.iter().find(|style| Values::of(style).self_id() == Some(normalized.as_str()))
|
||||
})
|
||||
};
|
||||
let mut chain = Vec::new();
|
||||
let mut next = reference.map(str::to_owned);
|
||||
while let Some(id) = next {
|
||||
let Some(style) = find(&id) else { break };
|
||||
let self_id = Values::of(style).self_id().map(str::to_owned);
|
||||
if chain.len() >= 16 || self_id.as_deref().is_some_and(|id| chain.iter().any(|entry: &El| Values::of(entry).self_id() == Some(id))) {
|
||||
break;
|
||||
}
|
||||
chain.push(style.clone());
|
||||
next = Values::of(style).prop("BasedOn").map(|base| {
|
||||
let base = base.trim();
|
||||
if base.starts_with("ObjectStyle/") { base.to_owned() } else { format!("ObjectStyle/{}", crate::value::escape_id(base)) }
|
||||
});
|
||||
}
|
||||
if include_none
|
||||
&& !chain.iter().any(|style| Values::of(style).self_id() == Some("ObjectStyle/$ID/[None]"))
|
||||
&& let Some(none) = find("ObjectStyle/$ID/[None]")
|
||||
{
|
||||
chain.push(none.clone());
|
||||
}
|
||||
Self { chain }
|
||||
}
|
||||
|
||||
/// The resolved style elements, nearest first.
|
||||
pub fn styles(&self) -> &[El] {
|
||||
&self.chain
|
||||
}
|
||||
|
||||
/// Merges an item's child over the corresponding elements in the resolved style chain.
|
||||
pub fn merged_child(&self, item: &El, name: &str) -> Option<El> {
|
||||
let category = match name {
|
||||
"TransparencySetting" => Some("ObjectStyleObjectEffectsCategorySettings"),
|
||||
"StrokeTransparencySetting" => Some("ObjectStyleStrokeEffectsCategorySettings"),
|
||||
"FillTransparencySetting" => Some("ObjectStyleFillEffectsCategorySettings"),
|
||||
"ContentTransparencySetting" => Some("ObjectStyleContentEffectsCategorySettings"),
|
||||
_ => None,
|
||||
};
|
||||
let enabled = |effect: &str| {
|
||||
let (Some(category), Some(flag)) = (category, effect_flag(effect)) else { return true };
|
||||
self.chain.iter().find_map(|style| style.child(Local(category)).and_then(|c| Values::of(c).get(flag).map(str::to_owned))).as_deref()
|
||||
!= Some("false")
|
||||
};
|
||||
let mut result = None;
|
||||
for style in self.chain.iter().rev() {
|
||||
let Some(child) = style.child(Local(name)) else { continue };
|
||||
let mut child = child.clone();
|
||||
child.children.retain(|node| !matches!(node, Node::El(element) if !enabled(element.local())));
|
||||
match &mut result {
|
||||
Some(current) => overlay(current, &child, 0),
|
||||
None => result = Some(child),
|
||||
}
|
||||
}
|
||||
if let Some(child) = item.child(Local(name)) {
|
||||
match &mut result {
|
||||
Some(current) => overlay(current, child, 0),
|
||||
None => result = Some(child.clone()),
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Returns the drop-shadow and feather records enabled by the style's effect category.
|
||||
pub fn enabled_shadow_feather(&self) -> Option<El> {
|
||||
let category = self.chain.iter().find_map(|style| style.child(Local("ObjectStyleObjectEffectsCategorySettings")));
|
||||
let setting = |name: &str| self.chain.iter().find_map(|style| style.child(Local("TransparencySetting"))?.child(Local(name)).cloned());
|
||||
let mut result = El::new("TransparencySetting");
|
||||
let mut any = false;
|
||||
for (name, flag) in [("DropShadowSetting", "EnableDropShadow"), ("FeatherSetting", "EnableFeather")] {
|
||||
let element = setting(name);
|
||||
let on = match category {
|
||||
Some(category) => Values::of(category).get(flag) == Some("true"),
|
||||
None => element.as_ref().and_then(|element| Values::of(element).get("Mode").map(str::to_owned)).is_some_and(|mode| mode != "None"),
|
||||
};
|
||||
any |= on;
|
||||
if let (true, Some(element)) = (on, element) {
|
||||
result.push(element);
|
||||
}
|
||||
}
|
||||
any.then_some(result)
|
||||
}
|
||||
}
|
||||
|
||||
fn effect_flag(effect: &str) -> Option<&'static str> {
|
||||
Some(match effect {
|
||||
"BlendingSetting" => "EnableTransparency",
|
||||
"DropShadowSetting" => "EnableDropShadow",
|
||||
"FeatherSetting" => "EnableFeather",
|
||||
"InnerShadowSetting" => "EnableInnerShadow",
|
||||
"OuterGlowSetting" => "EnableOuterGlow",
|
||||
"InnerGlowSetting" => "EnableInnerGlow",
|
||||
"BevelAndEmbossSetting" => "EnableBevelEmboss",
|
||||
"SatinSetting" => "EnableSatin",
|
||||
"DirectionalFeatherSetting" => "EnableDirectionalFeather",
|
||||
"GradientFeatherSetting" => "EnableGradientFeather",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn overlay(base: &mut El, top: &El, depth: usize) {
|
||||
if depth > 32 {
|
||||
*base = top.clone();
|
||||
return;
|
||||
}
|
||||
for (key, value) in &top.attrs {
|
||||
base.set_attr(key, value);
|
||||
if let Some(Node::El(properties)) = base.children.iter_mut().find(|node| matches!(node, Node::El(el) if el.local() == "Properties")) {
|
||||
properties.children.retain(|node| !matches!(node, Node::El(el) if el.local() == key));
|
||||
}
|
||||
}
|
||||
if let Some(properties) = top.child(Local("Properties")) {
|
||||
let names: Vec<&str> = properties.elements().map(El::local).collect();
|
||||
base.attrs.retain(|(key, _)| !names.contains(&key.as_str()));
|
||||
}
|
||||
let has_text = top.children.iter().any(|node| matches!(node, Node::Text(text) | Node::CData(text) if !text.trim().is_empty()));
|
||||
if has_text {
|
||||
base.children.retain(|node| !matches!(node, Node::Text(_) | Node::CData(_)));
|
||||
}
|
||||
let mut seen: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
|
||||
for node in &top.children {
|
||||
match node {
|
||||
Node::El(child) => {
|
||||
let index = seen.entry(child.local()).or_insert(0);
|
||||
let slot = base
|
||||
.children
|
||||
.iter_mut()
|
||||
.filter_map(|node| match node {
|
||||
Node::El(el) if el.local() == child.local() => Some(el),
|
||||
_ => None,
|
||||
})
|
||||
.nth(*index);
|
||||
*index += 1;
|
||||
match slot {
|
||||
Some(slot) => overlay(slot, child, depth + 1),
|
||||
None => base.children.push(node.clone()),
|
||||
}
|
||||
}
|
||||
Node::Text(_) | Node::CData(_) if has_text => base.children.push(node.clone()),
|
||||
Node::Text(_) | Node::CData(_) | Node::Comment(_) | Node::Pi { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,19 @@ use craft_xml::{El, Local};
|
||||
|
||||
use crate::value::{nums, point_str};
|
||||
|
||||
/// Why a bounded path geometry was refused.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum PathGeometryError {
|
||||
#[error("too many points")]
|
||||
TooManyPoints,
|
||||
#[error("point without an anchor")]
|
||||
MissingAnchor,
|
||||
#[error("malformed point")]
|
||||
MalformedPoint,
|
||||
#[error("non-finite point")]
|
||||
NonFinitePoint,
|
||||
}
|
||||
|
||||
/// Most points one geometry keeps by default; points past it are dropped.
|
||||
pub const MAX_PATH_POINTS: usize = 1_000_000;
|
||||
|
||||
@@ -84,6 +97,45 @@ impl PathGeometry {
|
||||
PathGeometry { paths }
|
||||
}
|
||||
|
||||
/// Parse a path geometry without silently discarding malformed points, keeping at most
|
||||
/// `max` point elements. Missing direction handles default to the anchor.
|
||||
pub fn try_from_el_capped(pg: &El, max: usize) -> Result<PathGeometry, PathGeometryError> {
|
||||
let point = |value: Option<&str>, required: bool| -> Result<Option<[f64; 2]>, PathGeometryError> {
|
||||
let Some(value) = value else {
|
||||
return if required { Err(PathGeometryError::MissingAnchor) } else { Ok(None) };
|
||||
};
|
||||
let mut nums = value.split_whitespace();
|
||||
let Some(x) = nums.next().and_then(|n| n.parse::<f64>().ok()) else { return Err(PathGeometryError::MalformedPoint) };
|
||||
let Some(y) = nums.next().and_then(|n| n.parse::<f64>().ok()) else { return Err(PathGeometryError::MalformedPoint) };
|
||||
if nums.next().is_some() {
|
||||
return Err(PathGeometryError::MalformedPoint);
|
||||
}
|
||||
if !(x.is_finite() && y.is_finite()) {
|
||||
return Err(PathGeometryError::NonFinitePoint);
|
||||
}
|
||||
Ok(Some([x, y]))
|
||||
};
|
||||
let mut count = 0usize;
|
||||
let mut paths = Vec::new();
|
||||
for geometry in pg.children(Local("GeometryPathType")) {
|
||||
let mut points = Vec::new();
|
||||
if let Some(array) = geometry.child(Local("PathPointArray")) {
|
||||
for el in array.children(Local("PathPointType")) {
|
||||
count = count.saturating_add(1);
|
||||
if count > max {
|
||||
return Err(PathGeometryError::TooManyPoints);
|
||||
}
|
||||
let anchor = point(el.attr("Anchor"), true)?.ok_or(PathGeometryError::MissingAnchor)?;
|
||||
let left = point(el.attr("LeftDirection"), false)?.unwrap_or(anchor);
|
||||
let right = point(el.attr("RightDirection"), false)?.unwrap_or(anchor);
|
||||
points.push(PathPoint { anchor, left, right });
|
||||
}
|
||||
}
|
||||
paths.push(SubPath { open: geometry.attr("PathOpen") == Some("true"), points });
|
||||
}
|
||||
Ok(PathGeometry { paths })
|
||||
}
|
||||
|
||||
/// The `PathGeometry` element.
|
||||
pub fn to_el(&self) -> El {
|
||||
let mut pg = El::new("PathGeometry");
|
||||
|
||||
@@ -12,20 +12,44 @@
|
||||
//! Nodes are built from the elements [`crate::Package::document`] gives ([`Spread::from_el`],
|
||||
//! [`spreads`]) and written back with `to_el`, then [`crate::part_xml`].
|
||||
|
||||
mod document;
|
||||
mod effects;
|
||||
mod geometry;
|
||||
mod preferences;
|
||||
mod resources;
|
||||
mod spread;
|
||||
mod story;
|
||||
|
||||
pub use geometry::{MAX_PATH_POINTS, PathGeometry, PathPoint, SubPath};
|
||||
pub use resources::{
|
||||
ColorGroup, ColorSpace, GradientStop, StrokeStyle, StrokeStyleKind, Style, StyleGroup, StyleKind, StyleNode, Swatch, SwatchKind, color_groups,
|
||||
named, preference, stroke_styles, style_groups, swatches,
|
||||
};
|
||||
pub use spread::{Graphic, GraphicChild, GraphicKind, Guide, ItemChild, ItemKind, Link, Page, PageChild, PageItem, Spread, SpreadChild, spreads};
|
||||
pub use story::{Ace, CONTAINERS, Cell, Container, ContentPart, Range, Story, Table, TableChild, TextNode, stories};
|
||||
|
||||
use craft_xml::{El, Local, Node};
|
||||
pub use document::{
|
||||
CellStyleValues, ConditionValues, CrossReferenceBuildingBlock, CrossReferenceFormatValues, EpubSplitValues, ModelDocument, NumberingListValues,
|
||||
TableStyleValues, TextDateKind, TextVariableKind, TextVariableValues, TocStyleEntryValues, TocStyleValues, TopLevelKind, TopLevelNode,
|
||||
};
|
||||
pub use effects::{
|
||||
DirectionalFeatherValues, DropShadowValues, FeatherValues, GradientFeatherValues, ObjectStyleStack, OpacityStopValues, TransparencySetting,
|
||||
};
|
||||
pub use geometry::{MAX_PATH_POINTS, PathGeometry, PathGeometryError, PathPoint, SubPath};
|
||||
pub use preferences::{DocumentPreferences, FootnotePreferences, GridPreferences, TextFramePreferences};
|
||||
pub use resources::{
|
||||
CharacterAppearanceValues, CharacterSpecialValues, CharacterTypographyValues, CjkCharacterValues, CjkParagraphValues, ColorDefinition,
|
||||
ColorGroup, ColorGroupDefinition, ColorGroupMember, ColorSpace, CompositeFontDefinition, CompositeFontEntryDefinition, EpubExportTagMap,
|
||||
FontDefinition, FontFamilyDefinition, FontsPart, GradientDefinition, GradientStop, GradientStopDefinition, GradientStopValue, GraphicPart,
|
||||
GrepStyleValue, Ink, InkDefinition, MojikumiRow, NestedLineStyleValue, NestedStyleValue, NestedStyleValues, ObjectStyleValues,
|
||||
OpenTypeFeatureValues, ParagraphAlignmentValues, ParagraphBoxValues, ParagraphFlowValues, ParagraphListValues, ParagraphSpanValues, RubyValues,
|
||||
StrokeDefinition, StrokeStyle, StrokeStyleKind, Style, StyleGroup, StyleGroupWriter, StyleKind, StyleNode, Swatch, SwatchKind, TabStopValues,
|
||||
TextDecorationValues, TintDefinition, color_groups, inks, named, preference, stroke_styles, style_groups, swatches,
|
||||
};
|
||||
pub use spread::{
|
||||
BaselineFrameGridOption, BaselineFrameGridRelative, ClippingPathValues, FrameFittingOption, Graphic, GraphicChild, GraphicKind,
|
||||
GraphicLayerOptions, Guide, ItemChild, ItemKind, Link, MarginPreference, Page, PageChild, PageItem, PdfAttribute, Spread, SpreadChild, TextPath,
|
||||
TextWrapPreference, spreads,
|
||||
};
|
||||
pub use story::{
|
||||
Ace, AnchoredObjectSetting, AnchoredObjectValues, CONTAINERS, Cell, CellOptions, Container, ContainerKind, ContentPart, CrossReferenceSource,
|
||||
IndexCrossReferenceValues, IndexDocument, IndexReference, IndexTopicPath, IndexValues, PageReference, Range, SavedCrossReference,
|
||||
SavedCrossReferencePart, Story, StoryAnchor, StoryPreference, Table, TableChild, TableColumn, TableLayout, TableRow, TextNode, TrackedChangeKind,
|
||||
stories,
|
||||
};
|
||||
|
||||
use crate::value::{matrix, nums};
|
||||
|
||||
@@ -43,6 +67,157 @@ pub struct Values {
|
||||
pub properties: Vec<El>,
|
||||
}
|
||||
|
||||
/// Raw IDML stroke values before the caller maps swatches and stroke styles into its model.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct StrokeValues {
|
||||
pub weight: Option<f64>,
|
||||
pub color: Option<String>,
|
||||
pub tint: Option<f64>,
|
||||
pub kind: Option<String>,
|
||||
pub alignment: Option<String>,
|
||||
pub cap: Option<String>,
|
||||
pub join: Option<String>,
|
||||
pub miter_limit: Option<f64>,
|
||||
pub start: Option<String>,
|
||||
pub end: Option<String>,
|
||||
pub gap_color: Option<String>,
|
||||
pub gap_tint: Option<f64>,
|
||||
pub overprint: Option<bool>,
|
||||
pub gap_overprint: Option<bool>,
|
||||
}
|
||||
|
||||
impl StrokeValues {
|
||||
/// Reads stroke properties from elements in precedence order (item first, then nearer styles).
|
||||
pub fn from_elements(elements: &[&El]) -> StrokeValues {
|
||||
let mut result = StrokeValues::default();
|
||||
for el in elements {
|
||||
let values = Values::of(el);
|
||||
let num_attr = |key: &str| values.get(key).and_then(|value| value.parse::<f64>().ok()).filter(|value| value.is_finite());
|
||||
let bool_attr = |key: &str| match values.get(key) {
|
||||
Some("true") => Some(true),
|
||||
Some("false") => Some(false),
|
||||
_ => None,
|
||||
};
|
||||
if result.weight.is_none() {
|
||||
result.weight = num_attr("StrokeWeight");
|
||||
}
|
||||
if result.color.is_none() {
|
||||
result.color = values.get("StrokeColor").map(str::to_owned);
|
||||
}
|
||||
if result.tint.is_none() {
|
||||
result.tint = num_attr("StrokeTint");
|
||||
}
|
||||
if result.kind.is_none() {
|
||||
result.kind = values.get("StrokeType").map(str::to_owned);
|
||||
}
|
||||
if result.alignment.is_none() {
|
||||
result.alignment = values.get("StrokeAlignment").map(str::to_owned);
|
||||
}
|
||||
if result.cap.is_none() {
|
||||
result.cap = values.get("EndCap").map(str::to_owned);
|
||||
}
|
||||
if result.join.is_none() {
|
||||
result.join = values.get("EndJoin").map(str::to_owned);
|
||||
}
|
||||
if result.miter_limit.is_none() {
|
||||
result.miter_limit = num_attr("MiterLimit");
|
||||
}
|
||||
if result.start.is_none() {
|
||||
result.start = values.get("LeftLineEnd").map(str::to_owned);
|
||||
}
|
||||
if result.end.is_none() {
|
||||
result.end = values.get("RightLineEnd").map(str::to_owned);
|
||||
}
|
||||
if result.gap_color.is_none() {
|
||||
result.gap_color = values.get("GapColor").map(str::to_owned);
|
||||
}
|
||||
if result.gap_tint.is_none() {
|
||||
result.gap_tint = num_attr("GapTint");
|
||||
}
|
||||
if result.overprint.is_none() {
|
||||
result.overprint = bool_attr("OverprintStroke");
|
||||
}
|
||||
if result.gap_overprint.is_none() {
|
||||
result.gap_overprint = bool_attr("OverprintGap");
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// Raw fill properties before a caller resolves swatch names to its own colour model.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct FillValues {
|
||||
pub color: Option<String>,
|
||||
pub tint: Option<f64>,
|
||||
pub overprint: Option<bool>,
|
||||
}
|
||||
|
||||
impl FillValues {
|
||||
/// Reads fill attributes in precedence order (item first, then nearer styles).
|
||||
pub fn from_elements(elements: &[&El]) -> FillValues {
|
||||
let mut result = FillValues::default();
|
||||
for el in elements {
|
||||
let values = Values::of(el);
|
||||
if result.color.is_none() {
|
||||
result.color = values.get("FillColor").map(str::to_owned);
|
||||
}
|
||||
if result.tint.is_none() {
|
||||
result.tint = values.get("FillTint").and_then(|value| value.parse::<f64>().ok());
|
||||
}
|
||||
if result.overprint.is_none() {
|
||||
result.overprint = match values.get("OverprintFill") {
|
||||
Some("true") => Some(true),
|
||||
Some("false") => Some(false),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct AlternatingFillValues {
|
||||
pub first_count: Option<f64>,
|
||||
pub first_color: Option<String>,
|
||||
pub first_tint: Option<f64>,
|
||||
pub next_count: Option<f64>,
|
||||
pub next_color: Option<String>,
|
||||
pub next_tint: Option<f64>,
|
||||
pub skip_first: Option<f64>,
|
||||
pub skip_last: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct AlternatingStrokeValues {
|
||||
pub first_count: Option<f64>,
|
||||
pub next_count: Option<f64>,
|
||||
pub first: StrokeValues,
|
||||
pub next: StrokeValues,
|
||||
pub skip_first: Option<f64>,
|
||||
pub skip_last: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct TableOptions {
|
||||
pub direction: Option<String>,
|
||||
pub header_rows: Option<f64>,
|
||||
pub footer_rows: Option<f64>,
|
||||
pub applied_style: Option<String>,
|
||||
pub space_before: Option<f64>,
|
||||
pub space_after: Option<f64>,
|
||||
pub repeat_header: bool,
|
||||
pub repeat_footer: bool,
|
||||
pub borders: [StrokeValues; 4],
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct CornerValues {
|
||||
pub option: Option<String>,
|
||||
pub radius: Option<f64>,
|
||||
}
|
||||
|
||||
impl Values {
|
||||
/// The attributes and `<Properties>` of `el`.
|
||||
pub fn of(el: &El) -> Values {
|
||||
@@ -104,6 +279,13 @@ impl Values {
|
||||
self.prop(key).and_then(|v| v.trim().parse::<f64>().ok()).filter(|v| v.is_finite())
|
||||
}
|
||||
|
||||
/// A two-number value (for properties such as `GradientFillStart`), bounded to three tokens.
|
||||
pub fn pair(&self, key: &str) -> Option<[f64; 2]> {
|
||||
let source = self.prop(key)?;
|
||||
let values: Vec<f64> = source.split_whitespace().take(3).filter_map(|v| v.parse::<f64>().ok()).collect();
|
||||
<[f64; 2]>::try_from(values).ok().filter(|v| v.iter().all(|n| n.is_finite()))
|
||||
}
|
||||
|
||||
/// `true` or `false`; anything else counts as absent.
|
||||
pub fn boolean(&self, key: &str) -> Option<bool> {
|
||||
match self.prop(key)?.trim() {
|
||||
@@ -154,6 +336,77 @@ impl Values {
|
||||
}
|
||||
el
|
||||
}
|
||||
|
||||
/// Read an IDML stroke attribute family such as `TopEdgeStrokeWeight` or
|
||||
/// `StartRowStrokeColor`.
|
||||
pub fn stroke_values(&self, prefix: &str) -> StrokeValues {
|
||||
StrokeValues {
|
||||
weight: self.num(&format!("{prefix}StrokeWeight")).map(|v| v.max(0.0)),
|
||||
color: self.value(&format!("{prefix}StrokeColor")),
|
||||
tint: self.num(&format!("{prefix}StrokeTint")),
|
||||
kind: self.value(&format!("{prefix}StrokeType")),
|
||||
gap_color: self.value(&format!("{prefix}StrokeGapColor")),
|
||||
gap_tint: self.num(&format!("{prefix}StrokeGapTint")),
|
||||
..StrokeValues::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn alternating_fill_values(&self, kind: &str) -> AlternatingFillValues {
|
||||
let num = |key: &str| self.num(key).map(|v| v.max(0.0));
|
||||
AlternatingFillValues {
|
||||
first_count: num(&format!("Start{kind}FillCount")),
|
||||
first_color: self.value(&format!("Start{kind}FillColor")),
|
||||
first_tint: self.num(&format!("Start{kind}FillTint")),
|
||||
next_count: num(&format!("End{kind}FillCount")),
|
||||
next_color: self.value(&format!("End{kind}FillColor")),
|
||||
next_tint: self.num(&format!("End{kind}FillTint")),
|
||||
skip_first: num(&format!("SkipFirstAlternatingFill{kind}s")),
|
||||
skip_last: num(&format!("SkipLastAlternatingFill{kind}s")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn alternating_stroke_values(&self, kind: &str) -> AlternatingStrokeValues {
|
||||
let num = |key: &str| self.num(key).map(|v| v.max(0.0));
|
||||
let mut next = self.stroke_values(&format!("End{kind}"));
|
||||
// IDML writes this one table-column value as LineStyle rather than StrokeType.
|
||||
if kind == "Column" {
|
||||
next.kind = self.value("EndColumnLineStyle").or(next.kind);
|
||||
}
|
||||
AlternatingStrokeValues {
|
||||
first_count: num(&format!("Start{kind}StrokeCount")),
|
||||
next_count: num(&format!("End{kind}StrokeCount")),
|
||||
first: self.stroke_values(&format!("Start{kind}")),
|
||||
next,
|
||||
skip_first: num(&format!("SkipFirstAlternatingStroke{kind}s")),
|
||||
skip_last: num(&format!("SkipLastAlternatingStroke{kind}s")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn table_options(&self) -> TableOptions {
|
||||
TableOptions {
|
||||
direction: self.value("TableDirection"),
|
||||
header_rows: self.num("HeaderRowCount"),
|
||||
footer_rows: self.num("FooterRowCount"),
|
||||
applied_style: self.value("AppliedTableStyle"),
|
||||
space_before: self.num("SpaceBefore"),
|
||||
space_after: self.num("SpaceAfter"),
|
||||
repeat_header: self.get("HeaderBehavior") != Some("RepeatOnce"),
|
||||
repeat_footer: self.get("FooterBehavior") != Some("RepeatOnce"),
|
||||
borders: [
|
||||
self.stroke_values("TopBorder"),
|
||||
self.stroke_values("LeftBorder"),
|
||||
self.stroke_values("BottomBorder"),
|
||||
self.stroke_values("RightBorder"),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn corner_values(&self, corner: &str) -> CornerValues {
|
||||
CornerValues {
|
||||
option: self.value(&format!("{corner}CornerOption")).or_else(|| self.value("CornerOption")),
|
||||
radius: self.num(&format!("{corner}CornerRadius")).or_else(|| self.num("CornerRadius")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `el` without its indentation: the whitespace-only text between the children of an element
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
//! Typed views of IDML's reusable text-frame preference grammar.
|
||||
|
||||
use craft_xml::El;
|
||||
|
||||
use super::Values;
|
||||
|
||||
/// Parsed values from IDML `DocumentPreference` elements.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct DocumentPreferences {
|
||||
pub overprint_black: Option<bool>,
|
||||
pub page_width: Option<f64>,
|
||||
pub page_height: Option<f64>,
|
||||
pub facing_pages: Option<bool>,
|
||||
pub page_binding: Option<String>,
|
||||
pub bleed: [Option<f64>; 4],
|
||||
pub slug: [Option<f64>; 4],
|
||||
pub intent: Option<String>,
|
||||
pub primary_text_frame: Option<bool>,
|
||||
pub column_guides_locked: Option<bool>,
|
||||
}
|
||||
|
||||
impl DocumentPreferences {
|
||||
/// Reads the document-level page, bleed, slug, and intent values.
|
||||
pub fn from_el(el: &El) -> Self {
|
||||
let values = Values::of(el);
|
||||
Self {
|
||||
overprint_black: values.get("OverprintBlack").map(|value| value != "false"),
|
||||
page_width: values.num("PageWidth"),
|
||||
page_height: values.num("PageHeight"),
|
||||
facing_pages: values.boolean("FacingPages"),
|
||||
page_binding: values.prop("PageBinding"),
|
||||
bleed: [
|
||||
values.num("DocumentBleedTopOffset"),
|
||||
values.num("DocumentBleedBottomOffset"),
|
||||
values.num("DocumentBleedInsideOrLeftOffset"),
|
||||
values.num("DocumentBleedOutsideOrRightOffset"),
|
||||
],
|
||||
slug: [
|
||||
values.num("SlugTopOffset"),
|
||||
values.num("SlugBottomOffset"),
|
||||
values.num("SlugInsideOrLeftOffset"),
|
||||
values.num("SlugRightOrOutsideOffset"),
|
||||
],
|
||||
intent: values.prop("Intent"),
|
||||
primary_text_frame: values.boolean("CreatePrimaryTextFrame"),
|
||||
column_guides_locked: values.boolean("ColumnGuideLocked"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Full canonical attributes for an exported IDML `DocumentPreference`.
|
||||
pub fn to_export_attrs(&self) -> Vec<(String, String)> {
|
||||
let mut attrs = Vec::new();
|
||||
for (key, value) in [("PageHeight", self.page_height), ("PageWidth", self.page_width)] {
|
||||
if let Some(value) = value {
|
||||
attrs.push((key.into(), crate::value::num(value)));
|
||||
}
|
||||
}
|
||||
attrs.push(("PagesPerDocument".into(), "1".into()));
|
||||
if let Some(value) = self.facing_pages {
|
||||
attrs.push(("FacingPages".into(), if value { "true" } else { "false" }.into()));
|
||||
}
|
||||
for (key, value) in [
|
||||
("DocumentBleedTopOffset", self.bleed[0]),
|
||||
("DocumentBleedBottomOffset", self.bleed[1]),
|
||||
("DocumentBleedInsideOrLeftOffset", self.bleed[2]),
|
||||
("DocumentBleedOutsideOrRightOffset", self.bleed[3]),
|
||||
] {
|
||||
if let Some(value) = value {
|
||||
attrs.push((key.into(), crate::value::num(value)));
|
||||
}
|
||||
}
|
||||
if self.bleed.iter().all(Option::is_some) {
|
||||
attrs.push((
|
||||
"DocumentBleedUniformSize".into(),
|
||||
bool_string(self.bleed.iter().flatten().all(|value| *value == self.bleed[0].unwrap_or_default())),
|
||||
));
|
||||
}
|
||||
for (key, value) in [
|
||||
("SlugTopOffset", self.slug[0]),
|
||||
("SlugBottomOffset", self.slug[1]),
|
||||
("SlugInsideOrLeftOffset", self.slug[2]),
|
||||
("SlugRightOrOutsideOffset", self.slug[3]),
|
||||
] {
|
||||
if let Some(value) = value {
|
||||
attrs.push((key.into(), crate::value::num(value)));
|
||||
}
|
||||
}
|
||||
if self.slug.iter().all(Option::is_some) {
|
||||
attrs.push((
|
||||
"DocumentSlugUniformSize".into(),
|
||||
bool_string(self.slug.iter().flatten().all(|value| *value == self.slug[0].unwrap_or_default())),
|
||||
));
|
||||
}
|
||||
attrs.push(("PreserveLayoutWhenShuffling".into(), "true".into()));
|
||||
attrs.push(("AllowPageShuffle".into(), "true".into()));
|
||||
if let Some(value) = self.overprint_black {
|
||||
attrs.push(("OverprintBlack".into(), bool_string(value)));
|
||||
}
|
||||
if let Some(value) = &self.page_binding {
|
||||
attrs.push(("PageBinding".into(), value.clone()));
|
||||
}
|
||||
attrs.push(("ColumnDirection".into(), "Horizontal".into()));
|
||||
if let Some(value) = &self.intent {
|
||||
attrs.push(("Intent".into(), value.clone()));
|
||||
}
|
||||
if let Some(value) = self.primary_text_frame {
|
||||
attrs.push(("CreatePrimaryTextFrame".into(), bool_string(value)));
|
||||
}
|
||||
if let Some(value) = self.column_guides_locked {
|
||||
attrs.push(("ColumnGuideLocked".into(), bool_string(value)));
|
||||
}
|
||||
if let Some(value) = self.primary_text_frame {
|
||||
attrs.push(("MasterTextFrame".into(), bool_string(value)));
|
||||
}
|
||||
attrs.push(("SnippetImportUsesOriginalLocation".into(), "false".into()));
|
||||
attrs
|
||||
}
|
||||
}
|
||||
|
||||
/// Parsed document and baseline-grid preference values.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct GridPreferences {
|
||||
pub baseline_start: Option<f64>,
|
||||
pub baseline_division: Option<f64>,
|
||||
pub baseline_view_threshold: Option<f64>,
|
||||
pub baseline_relative_option: Option<String>,
|
||||
pub horizontal_division: Option<f64>,
|
||||
pub vertical_division: Option<f64>,
|
||||
pub horizontal_subdivision: Option<f64>,
|
||||
pub vertical_subdivision: Option<f64>,
|
||||
pub grids_in_back: Option<bool>,
|
||||
}
|
||||
|
||||
/// Parsed values from IDML `FootnoteOption` elements.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct FootnotePreferences {
|
||||
pub start_at: Option<f64>,
|
||||
pub prefix: Option<String>,
|
||||
pub suffix: Option<String>,
|
||||
pub paragraph_style: Option<String>,
|
||||
pub marker_style: Option<String>,
|
||||
pub separator: Option<String>,
|
||||
pub space_between: Option<f64>,
|
||||
pub space_before: Option<f64>,
|
||||
pub first_baseline: Option<String>,
|
||||
pub first_baseline_minimum: Option<f64>,
|
||||
pub span_columns: Option<bool>,
|
||||
pub rule_on: Option<bool>,
|
||||
pub rule_weight: Option<f64>,
|
||||
pub rule_tint: Option<f64>,
|
||||
pub rule_left_indent: Option<f64>,
|
||||
pub rule_width: Option<f64>,
|
||||
pub rule_offset: Option<f64>,
|
||||
pub rule_color: Option<String>,
|
||||
pub numbering_style: Option<String>,
|
||||
pub restart_numbering: Option<String>,
|
||||
pub show_prefix_suffix: Option<String>,
|
||||
pub marker_positioning: Option<String>,
|
||||
}
|
||||
|
||||
impl FootnotePreferences {
|
||||
/// Reads footnote preference attributes and properties from IDML.
|
||||
pub fn from_el(el: &El) -> Self {
|
||||
let values = Values::of(el);
|
||||
Self {
|
||||
start_at: values.num("StartAt"),
|
||||
prefix: values.get("Prefix").map(str::to_owned),
|
||||
suffix: values.get("Suffix").map(str::to_owned),
|
||||
paragraph_style: values.get("FootnoteTextStyle").map(str::to_owned),
|
||||
marker_style: values.get("FootnoteMarkerStyle").map(str::to_owned),
|
||||
separator: values.get("SeparatorText").map(str::to_owned),
|
||||
space_between: values.num("SpaceBetween"),
|
||||
space_before: values.num("Spacer"),
|
||||
first_baseline: values.prop("FootnoteFirstBaselineOffset"),
|
||||
first_baseline_minimum: values.num("FootnoteMinimumFirstBaselineOffset"),
|
||||
span_columns: values.boolean("EnableStraddling"),
|
||||
rule_on: values.boolean("RuleOn"),
|
||||
rule_weight: values.num("RuleLineWeight"),
|
||||
rule_tint: values.num("RuleTint"),
|
||||
rule_left_indent: values.num("RuleLeftIndent"),
|
||||
rule_width: values.num("RuleWidth"),
|
||||
rule_offset: values.num("RuleOffset"),
|
||||
rule_color: values.prop("RuleColor"),
|
||||
numbering_style: values.prop("FootnoteNumberingStyle"),
|
||||
restart_numbering: values.prop("RestartNumbering"),
|
||||
show_prefix_suffix: values.prop("ShowPrefixSuffix"),
|
||||
marker_positioning: values.prop("MarkerPositioning"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes one canonical IDML `FootnoteOption` using caller-resolved style, colour, and enum
|
||||
/// references.
|
||||
pub fn to_el(&self) -> El {
|
||||
let mut el = El::new("FootnoteOption")
|
||||
.with_attr("StartAt", crate::value::num(self.start_at.unwrap_or(1.0)))
|
||||
.with_attr("Prefix", self.prefix.as_deref().unwrap_or(""))
|
||||
.with_attr("Suffix", self.suffix.as_deref().unwrap_or(""))
|
||||
.with_attr("FootnoteTextStyle", self.paragraph_style.as_deref().unwrap_or("$ID/NormalParagraphStyle"))
|
||||
.with_attr("FootnoteMarkerStyle", self.marker_style.as_deref().unwrap_or("$ID/[None]"))
|
||||
.with_attr("SeparatorText", self.separator.as_deref().unwrap_or(""))
|
||||
.with_attr("SpaceBetween", crate::value::num(self.space_between.unwrap_or(0.0)))
|
||||
.with_attr("Spacer", crate::value::num(self.space_before.unwrap_or(0.0)))
|
||||
.with_attr("FootnoteFirstBaselineOffset", self.first_baseline.as_deref().unwrap_or("Ascent"))
|
||||
.with_attr("FootnoteMinimumFirstBaselineOffset", crate::value::num(self.first_baseline_minimum.unwrap_or(0.0)))
|
||||
.with_attr("EnableStraddling", bool_text(self.span_columns.unwrap_or(false)))
|
||||
.with_attr("RuleOn", bool_text(self.rule_on.unwrap_or(true)))
|
||||
.with_attr("RuleLineWeight", crate::value::num(self.rule_weight.unwrap_or(1.0)))
|
||||
.with_attr("RuleTint", crate::value::num(self.rule_tint.unwrap_or(1.0)))
|
||||
.with_attr("RuleLeftIndent", crate::value::num(self.rule_left_indent.unwrap_or(0.0)))
|
||||
.with_attr("RuleWidth", crate::value::num(self.rule_width.unwrap_or(72.0)))
|
||||
.with_attr("RuleOffset", crate::value::num(self.rule_offset.unwrap_or(0.0)));
|
||||
let mut properties = El::new("Properties");
|
||||
for (name, value) in [
|
||||
("FootnoteNumberingStyle", self.numbering_style.as_deref().unwrap_or("Arabic")),
|
||||
("RestartNumbering", self.restart_numbering.as_deref().unwrap_or("DontRestart")),
|
||||
("ShowPrefixSuffix", self.show_prefix_suffix.as_deref().unwrap_or("NoPrefixSuffix")),
|
||||
("MarkerPositioning", self.marker_positioning.as_deref().unwrap_or("Superscript")),
|
||||
] {
|
||||
properties.push(El::new(name).with_attr("type", "enumeration").with_text(value));
|
||||
}
|
||||
properties.push(El::new("RuleColor").with_attr("type", "object").with_text(self.rule_color.as_deref().unwrap_or("Swatch/Black")));
|
||||
el.push(properties);
|
||||
el
|
||||
}
|
||||
}
|
||||
|
||||
impl GridPreferences {
|
||||
/// Reads baseline and document-grid values.
|
||||
pub fn from_el(el: &El) -> Self {
|
||||
let values = Values::of(el);
|
||||
Self {
|
||||
baseline_start: values.num("BaselineStart"),
|
||||
baseline_division: values.num("BaselineDivision"),
|
||||
baseline_view_threshold: values.num("BaselineViewThreshold"),
|
||||
baseline_relative_option: values.prop("BaselineGridRelativeOption"),
|
||||
horizontal_division: values.num("HorizontalGridlineDivision"),
|
||||
vertical_division: values.num("VerticalGridlineDivision"),
|
||||
horizontal_subdivision: values.num("HorizontalGridSubdivision"),
|
||||
vertical_subdivision: values.num("VerticalGridSubdivision"),
|
||||
grids_in_back: values.boolean("GridsInBack"),
|
||||
}
|
||||
}
|
||||
|
||||
/// IDML attributes represented by this grid preference record.
|
||||
pub fn to_attrs(&self) -> Vec<(String, String)> {
|
||||
let mut attrs = Vec::new();
|
||||
for (key, value) in [
|
||||
("HorizontalGridlineDivision", self.horizontal_division),
|
||||
("VerticalGridlineDivision", self.vertical_division),
|
||||
("HorizontalGridSubdivision", self.horizontal_subdivision),
|
||||
("VerticalGridSubdivision", self.vertical_subdivision),
|
||||
] {
|
||||
if let Some(value) = value {
|
||||
attrs.push((key.into(), crate::value::num(value)));
|
||||
}
|
||||
}
|
||||
if let Some(value) = self.grids_in_back {
|
||||
attrs.push(("GridsInBack".into(), if value { "true" } else { "false" }.into()));
|
||||
}
|
||||
for (key, value) in [
|
||||
("BaselineStart", self.baseline_start),
|
||||
("BaselineDivision", self.baseline_division),
|
||||
("BaselineViewThreshold", self.baseline_view_threshold),
|
||||
] {
|
||||
if let Some(value) = value {
|
||||
attrs.push((key.into(), crate::value::num(value)));
|
||||
}
|
||||
}
|
||||
if let Some(value) = self.baseline_relative_option.as_deref() {
|
||||
attrs.push(("BaselineGridRelativeOption".into(), value.into()));
|
||||
}
|
||||
attrs
|
||||
}
|
||||
}
|
||||
|
||||
fn bool_string(value: bool) -> String {
|
||||
if value { "true" } else { "false" }.into()
|
||||
}
|
||||
|
||||
/// Parsed `TextFramePreference` values in nearest-to-farthest precedence order.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct TextFramePreferences {
|
||||
pub column_count: Option<f64>,
|
||||
pub column_gutter: Option<f64>,
|
||||
pub fixed_column_width: Option<f64>,
|
||||
pub fixed_width: bool,
|
||||
pub flexible_width: bool,
|
||||
pub balance_columns: bool,
|
||||
pub inset: Option<[f64; 4]>,
|
||||
pub vertical_justification: Option<String>,
|
||||
pub vertical_threshold: Option<f64>,
|
||||
pub first_baseline: Option<String>,
|
||||
pub minimum_first_baseline: Option<f64>,
|
||||
pub ignore_wrap: bool,
|
||||
pub auto_sizing_type: Option<String>,
|
||||
pub auto_sizing_reference: Option<String>,
|
||||
pub use_minimum_height: bool,
|
||||
pub minimum_height: Option<f64>,
|
||||
pub use_minimum_width: bool,
|
||||
pub minimum_width: Option<f64>,
|
||||
pub no_line_breaks: bool,
|
||||
pub column_rule_override: bool,
|
||||
pub column_rule_width: Option<f64>,
|
||||
pub column_rule_tint: Option<f64>,
|
||||
pub column_rule_offset: Option<f64>,
|
||||
pub column_rule_top_inset: Option<f64>,
|
||||
pub column_rule_bottom_inset: Option<f64>,
|
||||
}
|
||||
|
||||
impl TextFramePreferences {
|
||||
/// Reads preference levels nearest first. A malformed explicit value still masks an inherited
|
||||
/// one, matching IDML's item-over-object-style precedence.
|
||||
pub fn from_elements(elements: &[&El]) -> Self {
|
||||
let prop = |key: &str| elements.iter().find_map(|el| Values::of(el).prop(key));
|
||||
let num = |key: &str| prop(key).and_then(|v| v.trim().parse::<f64>().ok()).filter(|v| v.is_finite());
|
||||
let boolean = |key: &str| matches!(prop(key).as_deref().map(str::trim), Some("true"));
|
||||
Self {
|
||||
column_count: num("TextColumnCount"),
|
||||
column_gutter: num("TextColumnGutter"),
|
||||
fixed_column_width: num("TextColumnFixedWidth"),
|
||||
fixed_width: boolean("UseFixedColumnWidth"),
|
||||
flexible_width: boolean("UseFlexibleColumnWidth"),
|
||||
balance_columns: boolean("VerticalBalanceColumns"),
|
||||
inset: inset(elements),
|
||||
vertical_justification: prop("VerticalJustification"),
|
||||
vertical_threshold: num("VerticalThreshold"),
|
||||
first_baseline: prop("FirstBaselineOffset"),
|
||||
minimum_first_baseline: num("MinimumFirstBaselineOffset"),
|
||||
ignore_wrap: boolean("IgnoreWrap"),
|
||||
auto_sizing_type: prop("AutoSizingType"),
|
||||
auto_sizing_reference: prop("AutoSizingReferencePoint"),
|
||||
use_minimum_height: boolean("UseMinimumHeightForAutoSizing"),
|
||||
minimum_height: num("MinimumHeightForAutoSizing"),
|
||||
use_minimum_width: boolean("UseMinimumWidthForAutoSizing"),
|
||||
minimum_width: num("MinimumWidthForAutoSizing"),
|
||||
no_line_breaks: boolean("UseNoLineBreaksForAutoSizing"),
|
||||
column_rule_override: boolean("ColumnRuleOverride"),
|
||||
column_rule_width: num("ColumnRuleStrokeWidth"),
|
||||
column_rule_tint: num("ColumnRuleStrokeTint"),
|
||||
column_rule_offset: num("ColumnRuleOffset"),
|
||||
column_rule_top_inset: num("ColumnRuleTopInset"),
|
||||
column_rule_bottom_inset: num("ColumnRuleBottomInset"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes one IDML `TextFramePreference`, including its inset list and column-rule
|
||||
/// properties. Enumeration strings are supplied by the caller because they are model policy.
|
||||
pub fn to_el(&self, column_rule_color: &str) -> El {
|
||||
use crate::value::num;
|
||||
|
||||
let mut inset = El::new("InsetSpacing").with_attr("type", "list");
|
||||
for value in self.inset.unwrap_or([0.0; 4]) {
|
||||
inset.push(El::new("ListItem").with_attr("type", "unit").with_text(num(value)));
|
||||
}
|
||||
let el = El::new("TextFramePreference")
|
||||
.with_attr("TextColumnCount", num(self.column_count.unwrap_or(1.0).max(1.0)))
|
||||
.with_attr("TextColumnGutter", num(self.column_gutter.unwrap_or(0.0)))
|
||||
.with_attr("UseFixedColumnWidth", bool_text(self.fixed_width))
|
||||
.with_attr("UseFlexibleColumnWidth", bool_text(self.flexible_width))
|
||||
.with_attr("VerticalBalanceColumns", bool_text(self.balance_columns))
|
||||
.with_attr("FirstBaselineOffset", self.first_baseline.as_deref().unwrap_or("Ascent"))
|
||||
.with_attr("MinimumFirstBaselineOffset", num(self.minimum_first_baseline.unwrap_or(0.0)))
|
||||
.with_attr("VerticalJustification", self.vertical_justification.as_deref().unwrap_or("TopAlign"))
|
||||
.with_attr("VerticalThreshold", num(self.vertical_threshold.unwrap_or(0.0)))
|
||||
.with_attr("IgnoreWrap", bool_text(self.ignore_wrap))
|
||||
.with_attr("AutoSizingType", self.auto_sizing_type.as_deref().unwrap_or("Off"))
|
||||
.with_attr("AutoSizingReferencePoint", self.auto_sizing_reference.as_deref().unwrap_or("CenterPoint"))
|
||||
.with_attr("UseMinimumHeightForAutoSizing", bool_text(self.use_minimum_height))
|
||||
.with_attr("MinimumHeightForAutoSizing", num(self.minimum_height.unwrap_or(0.0)))
|
||||
.with_attr("UseMinimumWidthForAutoSizing", bool_text(self.use_minimum_width))
|
||||
.with_attr("MinimumWidthForAutoSizing", num(self.minimum_width.unwrap_or(0.0)))
|
||||
.with_attr("UseNoLineBreaksForAutoSizing", bool_text(self.no_line_breaks));
|
||||
let el = if let Some(width) = self.fixed_column_width.filter(|width| *width > 0.0) {
|
||||
el.with_attr("TextColumnFixedWidth", num(width))
|
||||
} else {
|
||||
el
|
||||
};
|
||||
let el = el
|
||||
.with_attr("ColumnRuleOverride", bool_text(self.column_rule_override))
|
||||
.with_attr("ColumnRuleStrokeWidth", num(self.column_rule_width.unwrap_or(0.0)))
|
||||
.with_attr("ColumnRuleStrokeColor", column_rule_color)
|
||||
.with_attr("ColumnRuleStrokeTint", num(self.column_rule_tint.unwrap_or(0.0)))
|
||||
.with_attr("ColumnRuleOffset", num(self.column_rule_offset.unwrap_or(0.0)))
|
||||
.with_attr("ColumnRuleTopInset", num(self.column_rule_top_inset.unwrap_or(0.0)))
|
||||
.with_attr("ColumnRuleBottomInset", num(self.column_rule_bottom_inset.unwrap_or(0.0)))
|
||||
.with_attr("ColumnRuleInsetChainOverride", bool_text(self.column_rule_top_inset == self.column_rule_bottom_inset));
|
||||
el.with_child(El::new("Properties").with_child(inset))
|
||||
}
|
||||
}
|
||||
|
||||
fn bool_text(value: bool) -> &'static str {
|
||||
if value { "true" } else { "false" }
|
||||
}
|
||||
|
||||
fn inset(elements: &[&El]) -> Option<[f64; 4]> {
|
||||
let element = elements.iter().find(|el| Values::of(el).prop("InsetSpacing").is_some())?;
|
||||
let values = Values::of(element);
|
||||
let number = |s: &str| s.trim().parse::<f64>().ok().filter(|v| v.is_finite());
|
||||
let Some(property) = values.prop_el("InsetSpacing") else {
|
||||
return values.get("InsetSpacing").and_then(number).map(|v| [v; 4]);
|
||||
};
|
||||
let items: Vec<_> = property.elements().collect();
|
||||
if property.attr("type") == Some("list") || items.iter().any(|item| item.local() == "ListItem") {
|
||||
if items.len() != 4 {
|
||||
return None;
|
||||
}
|
||||
let mut result = [0.0; 4];
|
||||
for (slot, item) in result.iter_mut().zip(items) {
|
||||
if item.local() != "ListItem" || item.elements().next().is_some() {
|
||||
return None;
|
||||
}
|
||||
*slot = number(&item.deep_text())?;
|
||||
}
|
||||
Some(result)
|
||||
} else if items.is_empty() {
|
||||
number(&property.deep_text()).map(|v| [v; 4])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -17,6 +17,60 @@ fn with_children(mut el: El, children: impl IntoIterator<Item = El>) -> El {
|
||||
el
|
||||
}
|
||||
|
||||
/// Raw clip-path settings attached to a placed graphic, before the caller maps type and
|
||||
/// coordinates into its scene model.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ClippingPathValues {
|
||||
pub clipping_type: Option<String>,
|
||||
pub geometry: PathGeometry,
|
||||
pub invert: Option<bool>,
|
||||
pub include_inside_edges: Option<bool>,
|
||||
pub restrict_to_frame: Option<bool>,
|
||||
pub use_high_resolution: Option<bool>,
|
||||
pub threshold: Option<f64>,
|
||||
pub tolerance: Option<f64>,
|
||||
pub inset: Option<f64>,
|
||||
pub source_name: Option<String>,
|
||||
pub index: Option<f64>,
|
||||
}
|
||||
|
||||
impl ClippingPathValues {
|
||||
/// Reads a graphic's `ClippingPathSettings` and saved geometry, with the caller's point cap.
|
||||
pub fn from_graphic(graphic: &El, max_points: usize) -> Option<Self> {
|
||||
let settings = graphic.find("ClippingPathSettings")?;
|
||||
let settings_values = Values::of(settings);
|
||||
let graphic_values = Values::of(graphic);
|
||||
let clipping_type = settings_values.get("ClippingType").map(str::to_owned);
|
||||
let invert = settings_values.boolean("InvertPath");
|
||||
let include_inside_edges = settings_values.boolean("IncludeInsideEdges");
|
||||
let restrict_to_frame = settings_values.boolean("RestrictToFrame");
|
||||
let use_high_resolution = settings_values.boolean("UseHighResolutionImage");
|
||||
let threshold = settings_values.num("Threshold");
|
||||
let tolerance = settings_values.num("Tolerance");
|
||||
let inset = settings_values.num("InsetFrame");
|
||||
let source_name = settings_values.get("AppliedPathName").map(str::to_owned);
|
||||
let index = settings_values.num("Index");
|
||||
let geometry = settings_values
|
||||
.prop_el("ClippingPathGeometry")
|
||||
.or_else(|| settings.find("ClippingPathGeometry"))
|
||||
.or_else(|| graphic_values.prop_el("ClippingPathGeometry"))?;
|
||||
let geometry = PathGeometry::from_el_capped(geometry, max_points);
|
||||
Some(Self {
|
||||
clipping_type,
|
||||
geometry,
|
||||
invert,
|
||||
include_inside_edges,
|
||||
restrict_to_frame,
|
||||
use_high_resolution,
|
||||
threshold,
|
||||
tolerance,
|
||||
inset,
|
||||
source_name,
|
||||
index,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A spread (`Spread`) or a parent spread (`MasterSpread`).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Spread {
|
||||
@@ -86,6 +140,52 @@ impl Spread {
|
||||
pub fn parent_name(&self) -> Option<(&str, &str)> {
|
||||
Some((self.values.get("NamePrefix")?, self.values.get("BaseName")?))
|
||||
}
|
||||
|
||||
/// Parent spread's prefix, using the prefix of `Name` or `A` when absent.
|
||||
pub fn parent_prefix(&self) -> String {
|
||||
self.values.get("NamePrefix").map(str::to_string).unwrap_or_else(|| self.name().split('-').next().unwrap_or("A").to_string())
|
||||
}
|
||||
|
||||
/// Parent spread's base name, using the suffix of `Name` or `Parent` when absent.
|
||||
pub fn base_name(&self) -> String {
|
||||
self.values
|
||||
.get("BaseName")
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| self.name().split_once('-').map(|(_, b)| b.to_string()).unwrap_or_else(|| self.name().to_string()))
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &str {
|
||||
self.values.name().unwrap_or("Parent")
|
||||
}
|
||||
|
||||
/// Whether master page items are shown.
|
||||
pub fn show_master_items(&self) -> bool {
|
||||
self.values.boolean("ShowMasterItems") != Some(false)
|
||||
}
|
||||
|
||||
/// Whether page shuffle is allowed.
|
||||
pub fn allow_shuffle(&self) -> bool {
|
||||
self.values.boolean("AllowPageShuffle") != Some(false)
|
||||
}
|
||||
|
||||
/// Exact quarter-turn view rotation encoded by the spread transform, clockwise on screen.
|
||||
pub fn view_rotation(&self) -> u8 {
|
||||
if self.master {
|
||||
return 0;
|
||||
}
|
||||
let [a, b, c, d, _, _] = self.values.item_transform();
|
||||
let is = |x: f64, y: f64| (x - y).abs() < 1e-6;
|
||||
let quarter = |m: [f64; 4]| is(a, m[0]) && is(b, m[1]) && is(c, m[2]) && is(d, m[3]);
|
||||
if quarter([0.0, 1.0, -1.0, 0.0]) {
|
||||
1
|
||||
} else if quarter([-1.0, 0.0, 0.0, -1.0]) {
|
||||
2
|
||||
} else if quarter([0.0, -1.0, 1.0, 0.0]) {
|
||||
3
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The spreads and parent spreads of a document, in its order.
|
||||
@@ -100,6 +200,44 @@ pub struct Page {
|
||||
pub children: Vec<PageChild>,
|
||||
}
|
||||
|
||||
/// Parsed layout values of an IDML `MarginPreference`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct MarginPreference {
|
||||
pub values: Values,
|
||||
}
|
||||
|
||||
impl MarginPreference {
|
||||
pub fn from_el(el: &El) -> Self {
|
||||
Self { values: Values::of(el) }
|
||||
}
|
||||
pub fn top(&self) -> Option<f64> {
|
||||
self.values.num("Top")
|
||||
}
|
||||
pub fn left(&self) -> Option<f64> {
|
||||
self.values.num("Left")
|
||||
}
|
||||
pub fn bottom(&self) -> Option<f64> {
|
||||
self.values.num("Bottom")
|
||||
}
|
||||
pub fn right(&self) -> Option<f64> {
|
||||
self.values.num("Right")
|
||||
}
|
||||
pub fn column_count(&self) -> Option<f64> {
|
||||
self.values.num("ColumnCount")
|
||||
}
|
||||
pub fn column_gutter(&self) -> Option<f64> {
|
||||
self.values.num("ColumnGutter")
|
||||
}
|
||||
|
||||
/// Parse at most `limit` whitespace-separated `ColumnsPositions` values; malformed or
|
||||
/// non-finite values invalidate the list.
|
||||
pub fn column_positions(&self, limit: usize) -> Option<Vec<f64>> {
|
||||
let source = self.values.get("ColumnsPositions")?;
|
||||
let values: Option<Vec<f64>> = source.split_whitespace().take(limit).map(|v| v.parse::<f64>().ok().filter(|v| v.is_finite())).collect();
|
||||
values.filter(|values| !values.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
/// A child of a page, in document order.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum PageChild {
|
||||
@@ -142,6 +280,20 @@ impl Page {
|
||||
self.values.get("AppliedMaster").filter(|m| *m != "n")
|
||||
}
|
||||
|
||||
pub fn self_id(&self) -> Option<&str> {
|
||||
self.values.self_id()
|
||||
}
|
||||
|
||||
/// `ItemTransform`; the identity when absent or malformed.
|
||||
pub fn item_transform(&self) -> Matrix {
|
||||
self.values.item_transform()
|
||||
}
|
||||
|
||||
/// `LayoutRule`, defaulting to `Off`.
|
||||
pub fn layout_rule(&self) -> &str {
|
||||
self.values.get("LayoutRule").unwrap_or("Off")
|
||||
}
|
||||
|
||||
/// `OverrideList`: the `Self`s of the parent items overridden on this page.
|
||||
pub fn override_list(&self) -> Vec<&str> {
|
||||
self.values.get("OverrideList").map(|l| l.split_whitespace().collect()).unwrap_or_default()
|
||||
@@ -155,6 +307,10 @@ impl Page {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn margin_settings(&self) -> Option<MarginPreference> {
|
||||
self.margin_preference().map(|values| MarginPreference { values })
|
||||
}
|
||||
|
||||
pub fn guides(&self) -> impl DoubleEndedIterator<Item = &Guide> {
|
||||
self.children.iter().filter_map(|c| if let PageChild::Guide(g) = c { Some(g) } else { None })
|
||||
}
|
||||
@@ -188,6 +344,16 @@ impl Guide {
|
||||
pub fn liquid(&self) -> bool {
|
||||
self.values.get("GuideType") == Some("Liquid")
|
||||
}
|
||||
|
||||
/// `Locked` is true.
|
||||
pub fn locked(&self) -> bool {
|
||||
self.values.boolean("Locked") == Some(true)
|
||||
}
|
||||
|
||||
/// `ItemLayer`: the identifier of the layer the guide belongs to.
|
||||
pub fn item_layer(&self) -> Option<&str> {
|
||||
self.values.item_layer()
|
||||
}
|
||||
}
|
||||
|
||||
/// What a page item is.
|
||||
@@ -297,6 +463,61 @@ impl PageItem {
|
||||
self.values.prop_el("PathGeometry").map(PathGeometry::from_el).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn self_id(&self) -> Option<&str> {
|
||||
self.values.self_id()
|
||||
}
|
||||
|
||||
pub fn item_layer(&self) -> Option<&str> {
|
||||
self.values.item_layer()
|
||||
}
|
||||
|
||||
pub fn item_transform(&self) -> Matrix {
|
||||
self.values.item_transform()
|
||||
}
|
||||
|
||||
pub fn name(&self) -> Option<&str> {
|
||||
self.values.name()
|
||||
}
|
||||
|
||||
pub fn locked(&self) -> bool {
|
||||
self.values.locked()
|
||||
}
|
||||
|
||||
pub fn hidden(&self) -> bool {
|
||||
!self.values.visible()
|
||||
}
|
||||
|
||||
pub fn nonprinting(&self) -> bool {
|
||||
self.values.boolean("Nonprinting") == Some(true)
|
||||
}
|
||||
|
||||
pub fn gradient_fill_angle(&self) -> Option<f64> {
|
||||
self.values.num("GradientFillAngle")
|
||||
}
|
||||
pub fn gradient_fill_start(&self) -> Option<[f64; 2]> {
|
||||
self.values.pair("GradientFillStart")
|
||||
}
|
||||
pub fn gradient_fill_length(&self) -> Option<f64> {
|
||||
self.values.num("GradientFillLength")
|
||||
}
|
||||
pub fn gradient_stroke_angle(&self) -> Option<f64> {
|
||||
self.values.num("GradientStrokeAngle")
|
||||
}
|
||||
pub fn gradient_stroke_start(&self) -> Option<[f64; 2]> {
|
||||
self.values.pair("GradientStrokeStart")
|
||||
}
|
||||
pub fn gradient_stroke_length(&self) -> Option<f64> {
|
||||
self.values.num("GradientStrokeLength")
|
||||
}
|
||||
|
||||
pub fn text_path(&self) -> Option<TextPath> {
|
||||
self.other("TextPath").map(|el| TextPath { values: Values::of(el) })
|
||||
}
|
||||
|
||||
pub fn text_wrap(&self) -> Option<TextWrapPreference> {
|
||||
self.other("TextWrapPreference").map(TextWrapPreference::from_el)
|
||||
}
|
||||
|
||||
/// Replaces the outline.
|
||||
pub fn set_geometry(&mut self, g: &PathGeometry) {
|
||||
self.values.set_prop_el("PathGeometry", Some(g.to_el()));
|
||||
@@ -346,6 +567,169 @@ impl PageItem {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parsed IDML `TextWrapPreference` values, including the nested offset and contour elements.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct TextWrapPreference {
|
||||
values: Values,
|
||||
contour_type: Option<String>,
|
||||
offsets: Option<[f64; 4]>,
|
||||
}
|
||||
|
||||
/// Parsed IDML frame fitting options.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct FrameFittingOption {
|
||||
pub crop: [Option<f64>; 4],
|
||||
pub alignment: Option<String>,
|
||||
pub auto_fit: bool,
|
||||
pub fitting_on_empty_frame: Option<String>,
|
||||
}
|
||||
|
||||
impl FrameFittingOption {
|
||||
pub fn from_el(el: &El) -> Self {
|
||||
let values = Values::of(el);
|
||||
Self {
|
||||
crop: [values.num("TopCrop"), values.num("LeftCrop"), values.num("BottomCrop"), values.num("RightCrop")],
|
||||
alignment: values.get("FittingAlignment").map(str::to_owned),
|
||||
auto_fit: values.boolean("AutoFit") == Some(true),
|
||||
fitting_on_empty_frame: values.get("FittingOnEmptyFrame").map(str::to_owned),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// IDML baseline-grid position relative to a page, frame, or inset.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum BaselineFrameGridRelative {
|
||||
TopMargin,
|
||||
TopOfFrame,
|
||||
TopInset,
|
||||
#[default]
|
||||
TopOfPage,
|
||||
}
|
||||
|
||||
/// Parsed IDML custom baseline-grid options on an item or object style.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct BaselineFrameGridOption {
|
||||
pub use_custom: bool,
|
||||
pub start: f64,
|
||||
pub increment: f64,
|
||||
pub relative_to: BaselineFrameGridRelative,
|
||||
}
|
||||
|
||||
impl BaselineFrameGridOption {
|
||||
pub fn from_el(el: &El) -> Self {
|
||||
let values = Values::of(el);
|
||||
let relative = values.get("BaselineFrameGridRelativeOption").unwrap_or("");
|
||||
let relative = relative.strip_suffix("OfBaselineFrameGridRelativeOption").unwrap_or(relative);
|
||||
let relative_to = match relative {
|
||||
"TopOfMargin" | "TopMargin" => BaselineFrameGridRelative::TopMargin,
|
||||
"TopOfFrame" => BaselineFrameGridRelative::TopOfFrame,
|
||||
"TopOfInset" | "TopInset" => BaselineFrameGridRelative::TopInset,
|
||||
_ => BaselineFrameGridRelative::TopOfPage,
|
||||
};
|
||||
Self {
|
||||
use_custom: values.boolean("UseCustomBaselineFrameGrid") == Some(true),
|
||||
start: values.num("StartingOffsetForBaselineFrameGrid").unwrap_or(0.0),
|
||||
increment: values.num("BaselineFrameGridIncrement").unwrap_or(12.0),
|
||||
relative_to,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TextWrapPreference {
|
||||
pub fn from_el(el: &El) -> Self {
|
||||
let contour_type = el.child(Local("ContourOption")).and_then(|c| Values::of(c).get("ContourType").map(str::to_string));
|
||||
let offsets = el.child(Local("Properties")).and_then(|p| p.child(Local("TextWrapOffset"))).map(|o| {
|
||||
let values = Values::of(o);
|
||||
[
|
||||
values.num("Top").unwrap_or(0.0),
|
||||
values.num("Left").unwrap_or(0.0),
|
||||
values.num("Bottom").unwrap_or(0.0),
|
||||
values.num("Right").unwrap_or(0.0),
|
||||
]
|
||||
});
|
||||
Self { values: Values::of(el), contour_type, offsets }
|
||||
}
|
||||
|
||||
pub fn mode(&self) -> Option<&str> {
|
||||
self.values.get("TextWrapMode")
|
||||
}
|
||||
pub fn inverse(&self) -> bool {
|
||||
self.values.boolean("Inverse") == Some(true)
|
||||
}
|
||||
pub fn side(&self) -> Option<&str> {
|
||||
self.values.get("TextWrapSide")
|
||||
}
|
||||
pub fn contour_type(&self) -> Option<&str> {
|
||||
self.contour_type.as_deref()
|
||||
}
|
||||
pub fn offsets(&self) -> Option<[f64; 4]> {
|
||||
self.offsets
|
||||
}
|
||||
|
||||
/// Writes the IDML preference structure around caller-supplied enum values and path geometry.
|
||||
pub fn to_el(mode: &str, inverse: bool, side: &str, contour: &str, offsets: [f64; 4], paths: &[PathGeometry]) -> El {
|
||||
use crate::value::num;
|
||||
|
||||
let mut properties = El::new("Properties");
|
||||
for path in paths {
|
||||
properties.push(path.to_el());
|
||||
}
|
||||
properties.push(
|
||||
El::new("TextWrapOffset")
|
||||
.with_attr("Top", num(offsets[0]))
|
||||
.with_attr("Left", num(offsets[1]))
|
||||
.with_attr("Bottom", num(offsets[2]))
|
||||
.with_attr("Right", num(offsets[3])),
|
||||
);
|
||||
El::new("TextWrapPreference")
|
||||
.with_attr("Inverse", if inverse { "true" } else { "false" })
|
||||
.with_attr("ApplyToMasterPageOnly", "false")
|
||||
.with_attr("TextWrapSide", side)
|
||||
.with_attr("TextWrapMode", mode)
|
||||
.with_child(properties)
|
||||
.with_child(El::new("ContourOption").with_attr("ContourType", contour).with_attr("IncludeInsideEdges", "false"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Type-on-a-path settings.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TextPath {
|
||||
pub values: Values,
|
||||
}
|
||||
|
||||
impl TextPath {
|
||||
/// Writes IDML type-on-a-path settings for a frame and its story.
|
||||
pub fn to_el(frame_self: &str, parent_story: &str, alignment: &str, flipped: bool, start: f64, end: f64) -> El {
|
||||
El::new("TextPath")
|
||||
.with_attr("Self", format!("{frame_self}tp"))
|
||||
.with_attr("ParentStory", parent_story)
|
||||
.with_attr("PreviousTextFrame", "n")
|
||||
.with_attr("NextTextFrame", "n")
|
||||
.with_attr("PathEffect", "RainbowPathEffect")
|
||||
.with_attr("PathAlignment", "CenterPathAlignment")
|
||||
.with_attr("TextAlignment", alignment)
|
||||
.with_attr("FlipPathEffect", if flipped { "Flipped" } else { "NotFlipped" })
|
||||
.with_attr("StartBracket", crate::value::num(start))
|
||||
.with_attr("EndBracket", crate::value::num(end))
|
||||
}
|
||||
|
||||
pub fn parent_story(&self) -> Option<&str> {
|
||||
self.values.get("ParentStory").filter(|s| *s != "n")
|
||||
}
|
||||
pub fn start_bracket(&self) -> Option<f64> {
|
||||
self.values.num("StartBracket")
|
||||
}
|
||||
pub fn end_bracket(&self) -> Option<f64> {
|
||||
self.values.num("EndBracket")
|
||||
}
|
||||
pub fn flipped(&self) -> bool {
|
||||
self.values.get("FlipPathEffect") == Some("Flipped")
|
||||
}
|
||||
pub fn alignment(&self) -> Option<&str> {
|
||||
self.values.get("TextAlignment")
|
||||
}
|
||||
}
|
||||
|
||||
/// What a placed graphic is.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum GraphicKind {
|
||||
@@ -420,6 +804,119 @@ impl Link {
|
||||
}
|
||||
}
|
||||
|
||||
/// A placed graphic's layer choices, independent of the document model that stores them.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct GraphicLayerOptions {
|
||||
pub attributes: Vec<(String, String)>,
|
||||
pub layers: Vec<Vec<(String, String)>>,
|
||||
/// Parent indices in pre-order. Missing entries and invalid parents are treated as top-level.
|
||||
pub parents: Vec<Option<usize>>,
|
||||
}
|
||||
|
||||
impl GraphicLayerOptions {
|
||||
/// Reads a `GraphicLayerOption` subtree while preserving layer order and hierarchy.
|
||||
pub fn from_el(option: &El) -> Self {
|
||||
let attributes = option.attrs.iter().filter(|(key, _)| key != "Self").cloned().collect();
|
||||
let mut roots: Vec<_> = option.elements().collect();
|
||||
roots.reverse();
|
||||
let mut pending: Vec<_> = roots.into_iter().map(|element| (element, None)).collect();
|
||||
let mut layers = Vec::new();
|
||||
let mut parents = Vec::new();
|
||||
while let Some((element, parent)) = pending.pop() {
|
||||
let layer_parent = if element.local() == "GraphicLayer" {
|
||||
let index = layers.len();
|
||||
layers.push(element.attrs.iter().filter(|(key, _)| key != "Self").cloned().collect());
|
||||
parents.push(parent);
|
||||
Some(index)
|
||||
} else {
|
||||
parent
|
||||
};
|
||||
let mut children: Vec<_> = element.elements().collect();
|
||||
children.reverse();
|
||||
pending.extend(children.into_iter().map(|child| (child, layer_parent)));
|
||||
}
|
||||
Self { attributes, layers, parents }
|
||||
}
|
||||
|
||||
/// Serializes this layer tree, generating fresh IDs for the option and each layer.
|
||||
pub fn to_el(&self, self_id: &str, mut fresh: impl FnMut() -> String) -> El {
|
||||
let mut option = El::new("GraphicLayerOption").with_attr("Self", self_id);
|
||||
for (key, value) in &self.attributes {
|
||||
if valid_layer_attribute(key) {
|
||||
option.set_attr(key, value);
|
||||
}
|
||||
}
|
||||
// A parent must still be open in the pre-order walk. IDML layer groups have a depth cap.
|
||||
let mut open = Vec::new();
|
||||
let mut parents = Vec::with_capacity(self.layers.len());
|
||||
for index in 0..self.layers.len() {
|
||||
let parent = self.parents.get(index).copied().flatten();
|
||||
if let Some(at) = parent.and_then(|parent| open.iter().position(|id| *id == parent)).filter(|at| *at < 239) {
|
||||
open.truncate(at + 1);
|
||||
parents.push(parent);
|
||||
} else {
|
||||
open.clear();
|
||||
parents.push(None);
|
||||
}
|
||||
open.push(index);
|
||||
}
|
||||
let mut children: Vec<Vec<El>> = (0..self.layers.len()).map(|_| Vec::new()).collect();
|
||||
let mut roots = Vec::new();
|
||||
for (index, attributes) in self.layers.iter().enumerate().rev() {
|
||||
let mut layer = El::new("GraphicLayer").with_attr("Self", fresh());
|
||||
for (key, value) in attributes {
|
||||
if valid_layer_attribute(key) {
|
||||
layer.set_attr(key, value);
|
||||
}
|
||||
}
|
||||
if let Some(nested) = children.get_mut(index) {
|
||||
for child in std::mem::take(nested).into_iter().rev() {
|
||||
layer.push(child);
|
||||
}
|
||||
}
|
||||
if let Some(nested) = parents.get(index).copied().flatten().and_then(|parent| children.get_mut(parent)) {
|
||||
nested.push(layer);
|
||||
} else {
|
||||
roots.push(layer);
|
||||
}
|
||||
}
|
||||
for layer in roots.into_iter().rev() {
|
||||
option.push(layer);
|
||||
}
|
||||
option
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_layer_attribute(key: &str) -> bool {
|
||||
!key.is_empty()
|
||||
&& key != "Self"
|
||||
&& key != "xmlns"
|
||||
&& key
|
||||
.bytes()
|
||||
.enumerate()
|
||||
.all(|(index, byte)| byte.is_ascii_alphabetic() || byte == b'_' || (index > 0 && (byte.is_ascii_digit() || matches!(byte, b'.' | b'-'))))
|
||||
}
|
||||
|
||||
/// A placed PDF's import options.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct PdfAttribute {
|
||||
values: Values,
|
||||
}
|
||||
|
||||
impl PdfAttribute {
|
||||
pub fn page_index(&self) -> u32 {
|
||||
self.values
|
||||
.get("PageNumber")
|
||||
.and_then(|value| value.trim().parse::<u64>().ok())
|
||||
.and_then(|page| u32::try_from(page.saturating_sub(1)).ok())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn crop(&self) -> Option<&str> {
|
||||
self.values.get("PDFCrop")
|
||||
}
|
||||
}
|
||||
|
||||
impl Graphic {
|
||||
pub fn from_el(el: &El) -> Option<Graphic> {
|
||||
let kind = GraphicKind::of(el.local())?;
|
||||
@@ -449,6 +946,83 @@ impl Graphic {
|
||||
self.children.iter().find_map(|c| if let GraphicChild::Link(l) = c { Some(l) } else { None })
|
||||
}
|
||||
|
||||
pub fn self_id(&self) -> Option<&str> {
|
||||
self.values.self_id()
|
||||
}
|
||||
|
||||
pub fn applied_object_style(&self) -> Option<&str> {
|
||||
self.values.get("AppliedObjectStyle")
|
||||
}
|
||||
|
||||
pub fn item_transform(&self) -> Matrix {
|
||||
self.values.item_transform()
|
||||
}
|
||||
|
||||
/// GraphicBounds in IDML's `Left`, `Top`, `Right`, `Bottom` order.
|
||||
pub fn bounds(&self) -> [f64; 4] {
|
||||
self.values
|
||||
.prop_el("GraphicBounds")
|
||||
.map(Values::of)
|
||||
.map(|values| ["Left", "Top", "Right", "Bottom"].map(|key| values.num(key).unwrap_or(0.0)))
|
||||
.unwrap_or([0.0; 4])
|
||||
}
|
||||
|
||||
/// Two finite values from InDesign's `ActualPpi` field.
|
||||
pub fn actual_ppi(&self) -> Option<[f64; 2]> {
|
||||
let source = self.values.get("ActualPpi")?;
|
||||
let numbers: Vec<f64> = source.split_whitespace().take(3).filter_map(|value| value.parse().ok()).collect();
|
||||
<[f64; 2]>::try_from(numbers).ok().filter(|values| values.iter().all(|value| value.is_finite()))
|
||||
}
|
||||
|
||||
pub fn fill_color(&self) -> Option<&str> {
|
||||
self.values.get("FillColor")
|
||||
}
|
||||
|
||||
pub fn fill_tint(&self) -> Option<f64> {
|
||||
self.values.num("FillTint")
|
||||
}
|
||||
|
||||
/// The MIME fallback when a linked graphic's bytes cannot be sniffed.
|
||||
pub fn fallback_mime(&self) -> &'static str {
|
||||
let format = self.link().and_then(Link::format).unwrap_or("");
|
||||
match format {
|
||||
value if value.contains("EPS") || self.kind == GraphicKind::EPS => "application/postscript",
|
||||
value if value.contains("JPEG") => "image/jpeg",
|
||||
value if value.contains("TIFF") => "image/tiff",
|
||||
value if value.contains("PDF") || self.kind == GraphicKind::PDF => "application/pdf",
|
||||
value if value.contains("GIF") => "image/gif",
|
||||
_ => "image/png",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn layer_options(&self) -> Option<GraphicLayerOptions> {
|
||||
self.children
|
||||
.iter()
|
||||
.find_map(|child| match child {
|
||||
GraphicChild::Other(el) if el.local() == "GraphicLayerOption" => Some(el),
|
||||
GraphicChild::Other(el) => el.find("GraphicLayerOption"),
|
||||
GraphicChild::Link(link) => {
|
||||
link.rest.iter().find_map(|el| if el.local() == "GraphicLayerOption" { Some(el) } else { el.find("GraphicLayerOption") })
|
||||
}
|
||||
})
|
||||
.or_else(|| self.values.prop_el("GraphicLayerOption"))
|
||||
.map(GraphicLayerOptions::from_el)
|
||||
}
|
||||
|
||||
pub fn pdf_attribute(&self) -> Option<PdfAttribute> {
|
||||
self.children
|
||||
.iter()
|
||||
.find_map(|child| match child {
|
||||
GraphicChild::Other(el) if el.local() == "PDFAttribute" => Some(el),
|
||||
GraphicChild::Other(el) => el.find("PDFAttribute"),
|
||||
GraphicChild::Link(link) => {
|
||||
link.rest.iter().find_map(|el| if el.local() == "PDFAttribute" { Some(el) } else { el.find("PDFAttribute") })
|
||||
}
|
||||
})
|
||||
.or_else(|| self.values.prop_el("PDFAttribute"))
|
||||
.map(|el| PdfAttribute { values: Values::of(el) })
|
||||
}
|
||||
|
||||
/// `Properties/GraphicBounds`: `top left bottom right` in the graphic's inner coordinates.
|
||||
pub fn graphic_bounds(&self) -> Option<[f64; 4]> {
|
||||
let b = self.values.prop_el("GraphicBounds")?;
|
||||
|
||||
@@ -2,9 +2,116 @@
|
||||
//! anchored page items, hyperlink and change containers).
|
||||
|
||||
use craft_xml::{El, Local, Node};
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use super::{PageItem, Values, tidy};
|
||||
|
||||
/// Resolved IDML anchored-object options from an item and its inherited style levels.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct AnchoredObjectValues {
|
||||
pub position: String,
|
||||
pub anchor_point: String,
|
||||
pub horizontal_alignment: String,
|
||||
pub horizontal_reference: String,
|
||||
pub vertical_alignment: String,
|
||||
pub vertical_reference: String,
|
||||
pub pin_position: bool,
|
||||
pub spine_relative: bool,
|
||||
pub space_above: f64,
|
||||
pub x_offset: f64,
|
||||
pub y_offset: f64,
|
||||
}
|
||||
|
||||
/// An IDML `AnchoredObjectSetting` element.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct AnchoredObjectSetting {
|
||||
attributes: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl AnchoredObjectSetting {
|
||||
pub fn inline_position(y_offset: f64) -> Self {
|
||||
Self { attributes: vec![("AnchoredPosition".into(), "InlinePosition".into()), ("AnchorYoffset".into(), crate::value::num(y_offset))] }
|
||||
}
|
||||
|
||||
pub fn above_line(horizontal_alignment: &str, space_above: f64, y_offset: f64) -> Self {
|
||||
Self {
|
||||
attributes: vec![
|
||||
("AnchoredPosition".into(), "AboveLine".into()),
|
||||
("HorizontalAlignment".into(), horizontal_alignment.into()),
|
||||
("AnchorSpaceAbove".into(), crate::value::num(space_above)),
|
||||
("AnchorYoffset".into(), crate::value::num(y_offset)),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn anchored(
|
||||
anchor_point: &str,
|
||||
horizontal_reference: &str,
|
||||
vertical_reference: &str,
|
||||
horizontal_alignment: &str,
|
||||
vertical_alignment: &str,
|
||||
x_offset: f64,
|
||||
y_offset: f64,
|
||||
pin_position: bool,
|
||||
spine_relative: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
attributes: vec![
|
||||
("AnchoredPosition".into(), "Anchored".into()),
|
||||
("AnchorPoint".into(), anchor_point.into()),
|
||||
("HorizontalReferencePoint".into(), horizontal_reference.into()),
|
||||
("VerticalReferencePoint".into(), vertical_reference.into()),
|
||||
("HorizontalAlignment".into(), horizontal_alignment.into()),
|
||||
("VerticalAlignment".into(), vertical_alignment.into()),
|
||||
("AnchorXoffset".into(), crate::value::num(x_offset)),
|
||||
("AnchorYoffset".into(), crate::value::num(y_offset)),
|
||||
("PinPosition".into(), bool_string(pin_position)),
|
||||
("SpineRelative".into(), bool_string(spine_relative)),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_el(&self) -> El {
|
||||
let mut element = El::new("AnchoredObjectSetting");
|
||||
for (key, value) in &self.attributes {
|
||||
element.set_attr(key, value);
|
||||
}
|
||||
element
|
||||
}
|
||||
}
|
||||
|
||||
fn bool_string(value: bool) -> String {
|
||||
if value { "true" } else { "false" }.into()
|
||||
}
|
||||
|
||||
impl AnchoredObjectValues {
|
||||
/// Resolves attributes item-first from `AnchoredObjectSetting` children.
|
||||
pub fn from_elements(elements: &[&El]) -> Self {
|
||||
let get = |key: &str, default: &str| {
|
||||
elements
|
||||
.iter()
|
||||
.filter_map(|element| element.child(Local("AnchoredObjectSetting")))
|
||||
.find_map(|setting| Values::of(setting).get(key).map(str::to_owned))
|
||||
.unwrap_or_else(|| default.to_owned())
|
||||
};
|
||||
let number = |key: &str| get(key, "0").parse::<f64>().ok().filter(|value| value.is_finite()).unwrap_or(0.0);
|
||||
Self {
|
||||
position: get("AnchoredPosition", "InlinePosition"),
|
||||
anchor_point: get("AnchorPoint", "BottomRightAnchor"),
|
||||
horizontal_alignment: get("HorizontalAlignment", "LeftAlign"),
|
||||
horizontal_reference: get("HorizontalReferencePoint", "TextFrame"),
|
||||
vertical_alignment: get("VerticalAlignment", "BottomAlign"),
|
||||
vertical_reference: get("VerticalReferencePoint", "LineBaseline"),
|
||||
pin_position: get("PinPosition", "true") == "true",
|
||||
spine_relative: get("SpineRelative", "false") == "true",
|
||||
space_above: number("AnchorSpaceAbove"),
|
||||
x_offset: number("AnchorXoffset"),
|
||||
y_offset: number("AnchorYoffset"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `Story`.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Story {
|
||||
@@ -12,6 +119,26 @@ pub struct Story {
|
||||
pub children: Vec<TextNode>,
|
||||
}
|
||||
|
||||
/// The IDML `StoryPreference` element written for a story.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct StoryPreference {
|
||||
pub optical_margin_alignment: bool,
|
||||
pub optical_margin_size: f64,
|
||||
pub vertical: bool,
|
||||
pub right_to_left: bool,
|
||||
}
|
||||
|
||||
impl StoryPreference {
|
||||
pub fn to_el(self) -> El {
|
||||
El::new("StoryPreference")
|
||||
.with_attr("OpticalMarginAlignment", self.optical_margin_alignment)
|
||||
.with_attr("OpticalMarginSize", self.optical_margin_size)
|
||||
.with_attr("FrameType", "TextFrameType")
|
||||
.with_attr("StoryOrientation", if self.vertical { "Vertical" } else { "Horizontal" })
|
||||
.with_attr("StoryDirection", if self.right_to_left { "RightToLeftDirection" } else { "LeftToRightDirection" })
|
||||
}
|
||||
}
|
||||
|
||||
/// One node of a story's text, in document order. IDML nests freely (an `XMLElement` or a
|
||||
/// `HyperlinkTextSource` can hold paragraph ranges or content), so every container holds these.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -24,10 +151,19 @@ pub enum TextNode {
|
||||
Content(Vec<ContentPart>),
|
||||
/// `Br`: the end of a paragraph.
|
||||
Break,
|
||||
/// A processing instruction at a structural story position. The parent serializer preserves
|
||||
/// it in place; `to_el` represents it as a one-instruction `Content` fragment.
|
||||
ProcessingInstruction {
|
||||
target: String,
|
||||
data: String,
|
||||
},
|
||||
/// Text inside another element ([`CONTAINERS`]): a hyperlink's source, a tracked change, an
|
||||
/// XML element, a cross-reference's source, a footnote, a note.
|
||||
Container(Container),
|
||||
Table(Table),
|
||||
Anchor(StoryAnchor),
|
||||
PageReference(PageReference),
|
||||
TextVariable(Values),
|
||||
/// A page item anchored in the text.
|
||||
Item(PageItem),
|
||||
/// Anything else (`StoryPreference`, `HyperlinkTextDestination`, `TextVariableInstance`, …),
|
||||
@@ -35,6 +171,264 @@ pub enum TextNode {
|
||||
Other(El),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct StoryAnchor {
|
||||
pub name: String,
|
||||
pub values: Values,
|
||||
}
|
||||
|
||||
impl StoryAnchor {
|
||||
/// Builds the IDML `Self` value for a hyperlink text destination.
|
||||
pub fn destination_self(id: u64) -> String {
|
||||
format!("HyperlinkTextDestination/a{id}")
|
||||
}
|
||||
|
||||
/// Writes an IDML `HyperlinkTextDestination` from a DesignCraft-independent anchor identity.
|
||||
pub fn destination_el(id: u64, name: &str) -> El {
|
||||
El::new("HyperlinkTextDestination")
|
||||
.with_attr("Self", Self::destination_self(id))
|
||||
.with_attr("Name", name)
|
||||
.with_attr("Hidden", "false")
|
||||
.with_attr("DestinationUniqueKey", id & 0xFFFF_FFFF)
|
||||
}
|
||||
|
||||
pub fn self_id(&self) -> Option<&str> {
|
||||
self.values.self_id()
|
||||
}
|
||||
pub fn label(&self) -> Option<&str> {
|
||||
self.values.name()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct PageReference {
|
||||
pub values: Values,
|
||||
}
|
||||
|
||||
/// Nested index topics and their See / See also references for one IDML `Index`.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct IndexValues {
|
||||
pub topics: BTreeMap<Vec<String>, String>,
|
||||
pub cross_references: Vec<IndexCrossReferenceValues>,
|
||||
}
|
||||
|
||||
/// An index cross-reference attached to a topic path.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct IndexCrossReferenceValues {
|
||||
pub from: Vec<String>,
|
||||
pub reference_type: String,
|
||||
pub target: String,
|
||||
}
|
||||
|
||||
/// A topic path and its sort keys, as they are saved on nested IDML `Topic` elements.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct IndexTopicPath {
|
||||
pub path: Vec<String>,
|
||||
pub sort: Vec<String>,
|
||||
}
|
||||
|
||||
/// An IDML index cross-reference as stored under an index topic.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct IndexReference {
|
||||
pub from: IndexTopicPath,
|
||||
pub referenced_topic: Option<String>,
|
||||
pub reference_type: Option<String>,
|
||||
}
|
||||
|
||||
/// Parsed IDML index topics and cross-references.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct IndexDocument {
|
||||
pub topics: HashMap<String, IndexTopicPath>,
|
||||
pub cross_references: Vec<IndexReference>,
|
||||
}
|
||||
|
||||
impl IndexDocument {
|
||||
pub fn from_el(element: &El) -> Self {
|
||||
let mut result = Self::default();
|
||||
let mut stack: Vec<(&El, IndexTopicPath)> =
|
||||
element.elements().filter(|child| child.local() == "Topic").map(|topic| (topic, IndexTopicPath::default())).collect();
|
||||
while let Some((topic, prefix)) = stack.pop() {
|
||||
let name = topic.attr("Name").unwrap_or_default().to_string();
|
||||
let mut current = prefix.clone();
|
||||
current.path.push(name);
|
||||
if let Some(key) = topic.attr("SortOrder").filter(|key| !key.is_empty()) {
|
||||
current.sort.resize(prefix.path.len(), String::new());
|
||||
current.sort.push(key.to_string());
|
||||
}
|
||||
if let Some(self_id) = topic.attr("Self") {
|
||||
result.topics.insert(self_id.to_string(), current.clone());
|
||||
}
|
||||
for child in topic.elements() {
|
||||
if child.local() == "CrossReference" {
|
||||
result.cross_references.push(IndexReference {
|
||||
from: current.clone(),
|
||||
referenced_topic: child.attr("ReferencedTopic").map(str::to_string),
|
||||
reference_type: child.attr("CrossReferenceType").map(str::to_string),
|
||||
});
|
||||
} else if child.local() == "Topic" {
|
||||
stack.push((child, current.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
impl IndexValues {
|
||||
/// Builds the IDML `Self` value for a topic path.
|
||||
pub fn topic_self(path: &[String]) -> String {
|
||||
let mut id = String::from("ix");
|
||||
for part in path {
|
||||
id.push_str("Topicn");
|
||||
id.push_str(&crate::value::escape_id(part));
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
/// Writes the nested IDML index grammar, assigning a fresh ID to each cross-reference.
|
||||
pub fn to_el(&self, mut fresh: impl FnMut() -> String) -> El {
|
||||
let mut topics = self.topics.clone();
|
||||
for reference in &self.cross_references {
|
||||
topics.entry(vec![reference.target.clone()]).or_default();
|
||||
}
|
||||
|
||||
fn build(
|
||||
topics: &BTreeMap<Vec<String>, String>,
|
||||
references: &[IndexCrossReferenceValues],
|
||||
prefix: &[String],
|
||||
fresh: &mut impl FnMut() -> String,
|
||||
) -> Vec<El> {
|
||||
let mut out = Vec::new();
|
||||
for (path, sort_order) in topics.iter().filter(|(path, _)| path.len() == prefix.len() + 1 && path.starts_with(prefix)) {
|
||||
let mut element = El::new("Topic")
|
||||
.with_attr("Self", IndexValues::topic_self(path))
|
||||
.with_attr("SortOrder", sort_order)
|
||||
.with_attr("Name", path.last().map_or("", String::as_str));
|
||||
for reference in references.iter().filter(|reference| reference.from == *path) {
|
||||
element.push(
|
||||
El::new("CrossReference")
|
||||
.with_attr("Self", fresh())
|
||||
.with_attr("ReferencedTopic", IndexValues::topic_self(std::slice::from_ref(&reference.target)))
|
||||
.with_attr("CrossReferenceType", &reference.reference_type)
|
||||
.with_attr("CustomTypeString", ""),
|
||||
);
|
||||
}
|
||||
element.children.extend(build(topics, references, path, fresh).into_iter().map(Node::El));
|
||||
out.push(element);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
let mut element = El::new("Index").with_attr("Self", "ix");
|
||||
element.children = build(&topics, &self.cross_references, &[], &mut fresh).into_iter().map(Node::El).collect();
|
||||
element
|
||||
}
|
||||
}
|
||||
|
||||
/// Text saved inside an IDML `CrossReferenceSource` before the application regenerates it from
|
||||
/// the cross-reference format. Page-number variables are separate segments so callers can retain
|
||||
/// the last displayed page while mapping to their own text model.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct SavedCrossReference {
|
||||
pub self_id: Option<String>,
|
||||
pub applied_format: Option<String>,
|
||||
pub parts: Vec<SavedCrossReferencePart>,
|
||||
/// The first directly nested character range, which supplies the marker's formatting.
|
||||
pub character_range: Option<Range>,
|
||||
}
|
||||
|
||||
/// The IDML cross-reference source wrapper around its saved character range.
|
||||
pub struct CrossReferenceSource;
|
||||
|
||||
impl CrossReferenceSource {
|
||||
pub fn to_el(self_id: &str, applied_format: &str, name: &str, character_range: El) -> El {
|
||||
El::new("CrossReferenceSource")
|
||||
.with_attr("Self", self_id)
|
||||
.with_attr("AppliedFormat", applied_format)
|
||||
.with_attr("Name", name)
|
||||
.with_attr("Hidden", "false")
|
||||
.with_child(character_range)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum SavedCrossReferencePart {
|
||||
Text(String),
|
||||
PageNumber { result_text: Option<String> },
|
||||
}
|
||||
|
||||
impl SavedCrossReference {
|
||||
/// Reads the bounded saved-text grammar. `page_variables` contains IDs whose variable type is
|
||||
/// the cross-reference page number; when the document omits variable definitions, InDesign's
|
||||
/// conventional `TV XRefPageNumber` suffix is recognized.
|
||||
pub fn from_el(el: &El, page_variables: &[String]) -> SavedCrossReference {
|
||||
let mut parsed = SavedCrossReference {
|
||||
self_id: el.attr("Self").map(str::to_string),
|
||||
applied_format: el.attr("AppliedFormat").map(str::to_string),
|
||||
parts: Vec::new(),
|
||||
character_range: el.find(Local("CharacterStyleRange")).and_then(|range| match TextNode::from_el(range) {
|
||||
TextNode::Character(range) => Some(range),
|
||||
_ => None,
|
||||
}),
|
||||
};
|
||||
saved_xref_parts(el, page_variables, &mut parsed.parts, 0);
|
||||
parsed
|
||||
}
|
||||
|
||||
pub fn from_container(container: &Container, page_variables: &[String]) -> SavedCrossReference {
|
||||
Self::from_el(&container.to_el(), page_variables)
|
||||
}
|
||||
}
|
||||
|
||||
fn saved_xref_parts(el: &El, page_variables: &[String], parts: &mut Vec<SavedCrossReferencePart>, depth: usize) {
|
||||
if depth > 16 {
|
||||
return;
|
||||
}
|
||||
for child in el.elements() {
|
||||
match child.local() {
|
||||
"Content" => parts.push(SavedCrossReferencePart::Text(child.deep_text())),
|
||||
"TextVariableInstance" => {
|
||||
let id = child.attr("AssociatedTextVariable").unwrap_or_default();
|
||||
let page_number =
|
||||
page_variables.iter().any(|variable| variable == id) || (page_variables.is_empty() && id.ends_with("TV XRefPageNumber"));
|
||||
if page_number {
|
||||
parts.push(SavedCrossReferencePart::PageNumber { result_text: child.attr("ResultText").map(str::to_string) });
|
||||
} else {
|
||||
parts.push(SavedCrossReferencePart::Text(child.attr("ResultText").unwrap_or_default().to_string()));
|
||||
}
|
||||
}
|
||||
"Change" if child.attr("ChangeType") == Some("DeletedText") => {}
|
||||
"CharacterStyleRange" | "Change" => saved_xref_parts(child, page_variables, parts, depth + 1),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PageReference {
|
||||
pub fn referenced_topic(&self) -> Option<&str> {
|
||||
self.values.get("ReferencedTopic")
|
||||
}
|
||||
pub fn reference_type(&self) -> Option<&str> {
|
||||
self.values.get("PageReferenceType")
|
||||
}
|
||||
pub fn limit(&self) -> Option<f64> {
|
||||
self.values.num("PageReferenceLimit")
|
||||
}
|
||||
|
||||
/// Writes a page-reference record from the shared index model's type and target.
|
||||
pub fn to_el(self_id: &str, reference_type: &str, topic: &str, id: usize, limit: Option<u32>) -> El {
|
||||
let mut el = El::new("PageReference")
|
||||
.with_attr("Self", self_id)
|
||||
.with_attr("PageReferenceType", reference_type)
|
||||
.with_attr("ReferencedTopic", topic)
|
||||
.with_attr("Id", id);
|
||||
if let Some(limit) = limit {
|
||||
el.set_attr("PageReferenceLimit", limit);
|
||||
}
|
||||
el
|
||||
}
|
||||
}
|
||||
|
||||
/// The elements read as [`TextNode::Container`].
|
||||
pub const CONTAINERS: [&str; 7] = ["HyperlinkTextSource", "Change", "XMLElement", "CrossReferenceSource", "Footnote", "Note", "EndnoteRange"];
|
||||
|
||||
@@ -54,6 +448,54 @@ pub struct Container {
|
||||
pub children: Vec<TextNode>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ContainerKind {
|
||||
HyperlinkTextSource,
|
||||
Change,
|
||||
XMLElement,
|
||||
CrossReferenceSource,
|
||||
Footnote,
|
||||
Note,
|
||||
EndnoteRange,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TrackedChangeKind {
|
||||
Deleted,
|
||||
Inserted,
|
||||
Other,
|
||||
}
|
||||
|
||||
impl Container {
|
||||
pub fn to_el(&self) -> El {
|
||||
with_children(self.values.element(&self.name), &self.children)
|
||||
}
|
||||
|
||||
pub fn kind(&self) -> ContainerKind {
|
||||
match self.name.as_str() {
|
||||
"HyperlinkTextSource" => ContainerKind::HyperlinkTextSource,
|
||||
"Change" => ContainerKind::Change,
|
||||
"XMLElement" => ContainerKind::XMLElement,
|
||||
"CrossReferenceSource" => ContainerKind::CrossReferenceSource,
|
||||
"Footnote" => ContainerKind::Footnote,
|
||||
"Note" => ContainerKind::Note,
|
||||
_ => ContainerKind::EndnoteRange,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn self_id(&self) -> Option<&str> {
|
||||
self.values.self_id()
|
||||
}
|
||||
|
||||
pub fn change_kind(&self) -> Option<TrackedChangeKind> {
|
||||
(self.kind() == ContainerKind::Change).then(|| match self.values.get("ChangeType") {
|
||||
Some("DeletedText") => TrackedChangeKind::Deleted,
|
||||
Some("InsertedText" | "MovedText") => TrackedChangeKind::Inserted,
|
||||
_ => TrackedChangeKind::Other,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A piece of a `Content` element.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum ContentPart {
|
||||
@@ -85,6 +527,11 @@ pub enum Ace {
|
||||
}
|
||||
|
||||
impl Ace {
|
||||
/// Parses an IDML ACE instruction's decimal code.
|
||||
pub fn from_decimal(value: &str) -> Option<Ace> {
|
||||
value.trim().parse::<u32>().ok().map(Ace::from_code)
|
||||
}
|
||||
|
||||
pub fn from_code(code: u32) -> Ace {
|
||||
match code {
|
||||
4 => Ace::FootnoteNumber,
|
||||
@@ -115,6 +562,63 @@ pub struct Table {
|
||||
pub children: Vec<TableChild>,
|
||||
}
|
||||
|
||||
/// The row and column grid of an IDML table, read without allocating cell models.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct TableLayout {
|
||||
pub rows: Vec<TableRow>,
|
||||
pub columns: Vec<TableColumn>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct TableRow {
|
||||
pub values: Values,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct TableColumn {
|
||||
pub values: Values,
|
||||
}
|
||||
|
||||
impl TableLayout {
|
||||
pub fn from_el(el: &El) -> TableLayout {
|
||||
Self::from_table(&Table::from_el(el))
|
||||
}
|
||||
|
||||
pub fn from_table(table: &Table) -> TableLayout {
|
||||
let mut layout = TableLayout::default();
|
||||
for child in &table.children {
|
||||
if let TableChild::Other(element) = child {
|
||||
match element.local() {
|
||||
"Row" => layout.rows.push(TableRow { values: Values::of(element) }),
|
||||
"Column" => layout.columns.push(TableColumn { values: Values::of(element) }),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
layout
|
||||
}
|
||||
}
|
||||
|
||||
impl TableRow {
|
||||
pub fn auto_grow(&self) -> Option<bool> {
|
||||
self.values.boolean("AutoGrow")
|
||||
}
|
||||
|
||||
pub fn minimum_height(&self) -> Option<f64> {
|
||||
self.values.num("MinimumHeight")
|
||||
}
|
||||
|
||||
pub fn single_row_height(&self) -> Option<f64> {
|
||||
self.values.num("SingleRowHeight")
|
||||
}
|
||||
}
|
||||
|
||||
impl TableColumn {
|
||||
pub fn single_column_width(&self) -> Option<f64> {
|
||||
self.values.num("SingleColumnWidth")
|
||||
}
|
||||
}
|
||||
|
||||
/// A child of a table, in document order.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum TableChild {
|
||||
@@ -130,13 +634,34 @@ pub struct Cell {
|
||||
pub children: Vec<TextNode>,
|
||||
}
|
||||
|
||||
/// Format-level options on an IDML table cell, before DesignCraft maps them into its model.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct CellOptions {
|
||||
pub fill_color: Option<String>,
|
||||
pub fill_tint: Option<f64>,
|
||||
pub insets: [Option<f64>; 4],
|
||||
pub vertical_justification: Option<String>,
|
||||
pub first_baseline_offset: Option<String>,
|
||||
pub minimum_first_baseline_offset: Option<f64>,
|
||||
pub rotation_angle: Option<f64>,
|
||||
pub edge_stroke_priorities: [i32; 4],
|
||||
pub edge_strokes: [super::StrokeValues; 4],
|
||||
}
|
||||
|
||||
/// The children of `el` other than `<Properties>`, as text nodes.
|
||||
fn nodes(el: &El) -> Vec<TextNode> {
|
||||
el.elements().filter(|e| !e.is(Local("Properties"))).map(TextNode::from_el).collect()
|
||||
el.children
|
||||
.iter()
|
||||
.filter_map(|node| match node {
|
||||
Node::El(e) if !e.is(Local("Properties")) => Some(TextNode::from_el(e)),
|
||||
Node::Pi { target, data } => Some(TextNode::ProcessingInstruction { target: target.clone(), data: data.clone() }),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn with_children(mut el: El, children: &[TextNode]) -> El {
|
||||
el.children.extend(children.iter().map(|c| Node::El(c.to_el())));
|
||||
el.children.extend(children.iter().map(TextNode::to_node));
|
||||
el
|
||||
}
|
||||
|
||||
@@ -148,6 +673,11 @@ impl TextNode {
|
||||
"Content" => TextNode::Content(content_parts(el)),
|
||||
"Br" => TextNode::Break,
|
||||
"Table" => TextNode::Table(Table::from_el(el)),
|
||||
"HyperlinkTextDestination" | "ParagraphDestination" => {
|
||||
TextNode::Anchor(StoryAnchor { name: el.local().to_string(), values: Values::of(el) })
|
||||
}
|
||||
"PageReference" => TextNode::PageReference(PageReference { values: Values::of(el) }),
|
||||
"TextVariableInstance" => TextNode::TextVariable(Values::of(el)),
|
||||
n if CONTAINERS.contains(&n) => TextNode::Container(Container { name: n.to_string(), values: Values::of(el), children: nodes(el) }),
|
||||
_ => PageItem::from_el(el).map_or_else(|| TextNode::Other(tidy(el)), TextNode::Item),
|
||||
}
|
||||
@@ -170,13 +700,30 @@ impl TextNode {
|
||||
c
|
||||
}
|
||||
TextNode::Break => El::new("Br"),
|
||||
TextNode::ProcessingInstruction { target, data } => {
|
||||
let mut content = El::new("Content");
|
||||
content.children.push(Node::Pi { target: target.clone(), data: data.clone() });
|
||||
content
|
||||
}
|
||||
TextNode::Container(c) => with_children(c.values.element(&c.name), &c.children),
|
||||
TextNode::Table(t) => t.to_el(),
|
||||
TextNode::Anchor(a) => a.values.element(&a.name),
|
||||
TextNode::PageReference(r) => r.values.element("PageReference"),
|
||||
TextNode::TextVariable(v) => v.element("TextVariableInstance"),
|
||||
TextNode::Item(i) => i.to_el(),
|
||||
TextNode::Other(e) => e.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializes this node in its parent position, preserving processing instructions as XML
|
||||
/// instructions rather than wrapping them in an element.
|
||||
pub fn to_node(&self) -> Node {
|
||||
match self {
|
||||
TextNode::ProcessingInstruction { target, data } => Node::Pi { target: target.clone(), data: data.clone() },
|
||||
_ => Node::El(self.to_el()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The nodes this one holds; none for content, breaks, page items and others.
|
||||
pub fn children(&self) -> &[TextNode] {
|
||||
match self {
|
||||
@@ -226,6 +773,26 @@ impl Story {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `StoryDirection` selects right-to-left order.
|
||||
pub fn right_to_left(&self) -> bool {
|
||||
self.preference().and_then(|values| values.get("StoryDirection").map(str::to_string)).as_deref() == Some("RightToLeftDirection")
|
||||
}
|
||||
|
||||
/// Whether `StoryOrientation` selects vertical composition.
|
||||
pub fn vertical(&self) -> bool {
|
||||
self.preference().and_then(|values| values.get("StoryOrientation").map(str::to_string)).as_deref() == Some("Vertical")
|
||||
}
|
||||
|
||||
/// Whether optical margin alignment is enabled; false when absent.
|
||||
pub fn optical_margin_enabled(&self) -> bool {
|
||||
self.preference().and_then(|values| values.boolean("OpticalMarginAlignment")) == Some(true)
|
||||
}
|
||||
|
||||
/// The `OpticalMarginSize`, if finite.
|
||||
pub fn optical_margin_size(&self) -> Option<f64> {
|
||||
self.preference().and_then(|values| values.num("OpticalMarginSize"))
|
||||
}
|
||||
|
||||
/// The story's paragraph ranges in reading order, those inside containers included, but not
|
||||
/// those of its tables, footnotes or notes (which are text of their own).
|
||||
pub fn paragraphs(&self) -> Vec<&Range> {
|
||||
@@ -275,6 +842,11 @@ fn text(nodes: &[TextNode], ace: &dyn Fn(Ace) -> Option<char>) -> String {
|
||||
}
|
||||
}
|
||||
TextNode::Break => out.push('\n'),
|
||||
TextNode::ProcessingInstruction { target, data } if target == "ACE" => {
|
||||
if let Ok(code) = data.trim().parse::<u32>() {
|
||||
out.extend(ace(Ace::from_code(code)));
|
||||
}
|
||||
}
|
||||
TextNode::Paragraph(r) | TextNode::Character(r) => stack.extend(r.children.iter().rev()),
|
||||
TextNode::Container(c) if inline_container(&c.name) => stack.extend(c.children.iter().rev()),
|
||||
_ => {}
|
||||
@@ -284,6 +856,11 @@ fn text(nodes: &[TextNode], ace: &dyn Fn(Ace) -> Option<char>) -> String {
|
||||
}
|
||||
|
||||
impl Range {
|
||||
/// `ParagraphBreakType`, except `Anywhere` which means no forced break.
|
||||
pub fn paragraph_break_type(&self) -> Option<&str> {
|
||||
self.values.get("ParagraphBreakType").filter(|kind| *kind != "Anywhere")
|
||||
}
|
||||
|
||||
/// `AppliedParagraphStyle` of a paragraph range.
|
||||
pub fn paragraph_style(&self) -> Option<&str> {
|
||||
self.values.get("AppliedParagraphStyle")
|
||||
@@ -378,6 +955,14 @@ impl Table {
|
||||
}
|
||||
|
||||
impl Cell {
|
||||
pub fn from_el(el: &El) -> Cell {
|
||||
Cell { values: Values::of(el), children: nodes(el) }
|
||||
}
|
||||
|
||||
pub fn to_el(&self) -> El {
|
||||
with_children(self.values.element("Cell"), &self.children)
|
||||
}
|
||||
|
||||
/// `Name`: `column:row` of the cell's top left (`2:0`).
|
||||
pub fn position(&self) -> Option<(u32, u32)> {
|
||||
let (c, r) = self.values.name()?.split_once(':')?;
|
||||
@@ -390,6 +975,27 @@ impl Cell {
|
||||
(n("ColumnSpan"), n("RowSpan"))
|
||||
}
|
||||
|
||||
pub fn options(&self) -> CellOptions {
|
||||
let inset = |current: &str, legacy: &str| self.values.num(current).or_else(|| self.values.num(legacy));
|
||||
let priority = |side: &str| self.values.prop(&format!("{side}StrokePriority")).and_then(|v| v.trim().parse().ok()).unwrap_or(0);
|
||||
CellOptions {
|
||||
fill_color: self.values.value("FillColor"),
|
||||
fill_tint: self.values.num("FillTint"),
|
||||
insets: [
|
||||
inset("TextTopInset", "TopInset"),
|
||||
inset("TextLeftInset", "LeftInset"),
|
||||
inset("TextBottomInset", "BottomInset"),
|
||||
inset("TextRightInset", "RightInset"),
|
||||
],
|
||||
vertical_justification: self.values.value("VerticalJustification"),
|
||||
first_baseline_offset: self.values.value("FirstBaselineOffset"),
|
||||
minimum_first_baseline_offset: self.values.num("MinimumFirstBaselineOffset"),
|
||||
rotation_angle: self.values.num("RotationAngle"),
|
||||
edge_stroke_priorities: [priority("TopEdge"), priority("LeftEdge"), priority("BottomEdge"), priority("RightEdge")],
|
||||
edge_strokes: ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].map(|side| self.values.stroke_values(side)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn paragraphs(&self) -> Vec<&Range> {
|
||||
paragraphs(&self.children)
|
||||
}
|
||||
|
||||
+27
-3
@@ -22,6 +22,25 @@ pub const fn xml_limits() -> craft_xml::Limits {
|
||||
/// `META-INF/container.xml` of a package.
|
||||
pub const CONTAINER_XML: &str = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<container version=\"1.0\" xmlns=\"urn:oasis:names:tc:opendocument:xmlns:container\">\n\t<rootfiles>\n\t\t<rootfile full-path=\"designmap.xml\" media-type=\"text/xml\"/>\n\t</rootfiles>\n</container>\n";
|
||||
|
||||
/// The standard root element for `designmap.xml`, including its required namespace and core
|
||||
/// document identity fields. The caller supplies DesignCraft-independent values; children are
|
||||
/// appended after construction.
|
||||
pub fn designmap_root(self_id: &str, story_list: &str, name: &str, zero_point: &str, active_layer: &str) -> El {
|
||||
El::new("Document")
|
||||
.with_attr("xmlns:idPkg", PACKAGING_NS)
|
||||
.with_attr("DOMVersion", DOM_VERSION)
|
||||
.with_attr("Self", self_id)
|
||||
.with_attr("StoryList", story_list)
|
||||
.with_attr("Name", name)
|
||||
.with_attr("ZeroPoint", zero_point)
|
||||
.with_attr("ActiveLayer", active_layer)
|
||||
}
|
||||
|
||||
/// An `idPkg` reference in `designmap.xml` to a part stored at `src`.
|
||||
pub fn designmap_include(kind: &str, src: &str) -> El {
|
||||
El::new(format!("idPkg:{kind}")).with_attr("src", src)
|
||||
}
|
||||
|
||||
/// Parses a part into its root element with [`xml_limits`].
|
||||
///
|
||||
/// Names are kept as written (`idPkg:Story`), text is kept exactly (it is story content), CDATA
|
||||
@@ -112,10 +131,15 @@ pub fn part_xml(root: &El) -> Vec<u8> {
|
||||
/// `designmap.xml` as bytes: [`part_xml`] with the `<?aid …?>` instruction that tells InDesign
|
||||
/// what the file is.
|
||||
pub fn designmap_xml(root: &El) -> Vec<u8> {
|
||||
let pi = format!("<?aid style=\"50\" type=\"document\" readerVersion=\"6.0\" featureSet=\"257\" product=\"{}(1)\" ?>", DOM_VERSION);
|
||||
designmap_xml_with_pi(root, &pi)
|
||||
}
|
||||
|
||||
/// A designmap part with the caller's `<?aid …?>` metadata instruction.
|
||||
pub fn designmap_xml_with_pi(root: &El, pi: &str) -> Vec<u8> {
|
||||
let mut s = String::from(DECL_STANDALONE);
|
||||
s.push_str("<?aid style=\"50\" type=\"document\" readerVersion=\"6.0\" featureSet=\"257\" product=\"");
|
||||
s.push_str(DOM_VERSION);
|
||||
s.push_str("(1)\" ?>\n");
|
||||
s.push_str(pi);
|
||||
s.push('\n');
|
||||
root.write_xml_pretty(&mut s, "\t");
|
||||
s.into_bytes()
|
||||
}
|
||||
@@ -69,6 +69,10 @@ fn a_story_reads_as_ranges_and_content() {
|
||||
// The body paragraph's character range holds every kind of node.
|
||||
let body = paras[1].characters();
|
||||
assert_eq!(body.len(), 1);
|
||||
let TextNode::Anchor(destination) = &body[0].children[6] else { panic!() };
|
||||
assert_eq!(destination.name, "HyperlinkTextDestination");
|
||||
assert_eq!(destination.values.self_id(), Some("ud1"));
|
||||
assert_eq!(destination.values.value("Name").as_deref(), Some("here"));
|
||||
let kinds: Vec<String> = body[0]
|
||||
.children
|
||||
.iter()
|
||||
@@ -77,13 +81,14 @@ fn a_story_reads_as_ranges_and_content() {
|
||||
TextNode::Content(_) => "Content".into(),
|
||||
TextNode::Table(_) => "Table".into(),
|
||||
TextNode::Item(i) => format!("item {}", i.kind.name()),
|
||||
TextNode::Anchor(anchor) => format!("anchor {}", anchor.name),
|
||||
TextNode::Other(e) => format!("other {}", e.name),
|
||||
n => format!("{n:?}"),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
kinds,
|
||||
["HyperlinkTextSource", "Change", "Content", "Footnote", "Table", "item Rectangle", "other HyperlinkTextDestination", "Content"]
|
||||
["HyperlinkTextSource", "Change", "Content", "Footnote", "Table", "item Rectangle", "anchor HyperlinkTextDestination", "Content"]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ zune-jpeg = "0.5"
|
||||
zune-core = "0.5"
|
||||
jpeg-encoder = "0.6"
|
||||
craft-jpeg = { workspace = true }
|
||||
craft-metadata = { workspace = true }
|
||||
zune-jpegxl = { version = "=0.5.2", default-features = false, features = ["std"] }
|
||||
craft-heif = { workspace = true, optional = true }
|
||||
|
||||
|
||||
@@ -1,575 +1,57 @@
|
||||
//! Image resolution (pixels per inch) recorded in metadata blocks: EXIF / TIFF IFD0
|
||||
//! (XResolution 282, YResolution 283, ResolutionUnit 296), XMP (`tiff:XResolution`,
|
||||
//! `tiff:YResolution`, `tiff:ResolutionUnit`) and Photoshop's image resource
|
||||
//! ResolutionInfo (`8BIM` resource 0x03ED); reading them, and rewriting the EXIF and XMP
|
||||
//! copies on export so they agree with the resolution the file is written at.
|
||||
//! Resolution metadata edits delegated to the shared [`craft_metadata::edit`] implementation.
|
||||
//!
|
||||
//! Implemented from TIFF 6.0 (section 8, "Baseline fields": XResolution / YResolution are
|
||||
//! RATIONAL, ResolutionUnit is SHORT, 1 = no absolute unit, 2 = inch, 3 = centimetre,
|
||||
//! default 2), EXIF 2.3 (same tags in the 0th IFD, same default) and the Adobe Photoshop File
|
||||
//! Formats Specification (ResolutionInfo: Fixed 16.16 hRes in pixels per inch, then the
|
||||
//! display units; the vertical resolution likewise).
|
||||
//!
|
||||
//! **Precedence** when a file carries several, as Photoshop resolves them: Photoshop's own
|
||||
//! ResolutionInfo, then the XMP `tiff:` properties, then EXIF, and only then the format's own
|
||||
//! density field when it is the generic JFIF one (see the JPEG decoder). JFIF density is last
|
||||
//! because cameras record the real resolution in EXIF while many tools and libraries write a
|
||||
//! default JFIF density of 72 (or a bare aspect ratio) without knowing it; Photoshop shows a
|
||||
//! camera JPEG at its EXIF resolution whatever its JFIF header says. Formats whose own field is
|
||||
//! written deliberately (PNG `pHYs`, TIFF tags) keep it first; the metadata blocks only fill in
|
||||
//! when it is missing.
|
||||
//!
|
||||
//! Everything read here is untrusted: offsets and counts are bounds-checked, both byte orders
|
||||
//! and BigTIFF-style EXIF are handled, and anything malformed reads as "no resolution" rather
|
||||
//! than as a wrong one.
|
||||
//! Image codecs keep these wrappers so their public API and format-specific callers remain
|
||||
//! stable while EXIF, XMP, and Photoshop ResolutionInfo parsing and editing live in one library.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use crate::orientation::{Order, ifd0_entries, tiff_body, upright_exif, upright_xmp};
|
||||
|
||||
const TAG_X_RESOLUTION: u16 = 282;
|
||||
const TAG_Y_RESOLUTION: u16 = 283;
|
||||
const TAG_RESOLUTION_UNIT: u16 = 296;
|
||||
const TYPE_SHORT: u16 = 3;
|
||||
const TYPE_RATIONAL: u16 = 5;
|
||||
const CM_PER_INCH: f64 = 2.54;
|
||||
/// Photoshop's ResolutionInfo image resource.
|
||||
const RESOLUTION_INFO: u16 = 0x03ED;
|
||||
/// Resolutions above this (pixels per inch) are treated as damage, not data.
|
||||
const MAX_PPI: f64 = 1_000_000.0;
|
||||
/// Two resolutions this close (pixels per inch) are the same.
|
||||
const SAME_PPI: f64 = 1e-3;
|
||||
|
||||
/// A usable resolution pair, or `None`.
|
||||
fn ppi_pair(x: f64, y: f64) -> Option<(f32, f32)> {
|
||||
let ok = |v: f64| v.is_finite() && v > 0.0 && v <= MAX_PPI;
|
||||
(ok(x) && ok(y)).then_some((x as f32, y as f32))
|
||||
}
|
||||
|
||||
fn same(a: (f32, f32), b: (f32, f32)) -> bool {
|
||||
(f64::from(a.0) - f64::from(b.0)).abs() < SAME_PPI && (f64::from(a.1) - f64::from(b.1)).abs() < SAME_PPI
|
||||
}
|
||||
|
||||
/// `ppi` as a TIFF RATIONAL: a whole number over 1 when it is one, else over 1000.
|
||||
fn rational(ppi: f32) -> (u32, u32) {
|
||||
let p = f64::from(ppi);
|
||||
if (p - p.round()).abs() < SAME_PPI { (p.round() as u32, 1) } else { ((p * 1000.0).round() as u32, 1000) }
|
||||
}
|
||||
|
||||
/// One of the three resolution entries of IFD0.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Field {
|
||||
/// Offset of the 12- (BigTIFF: 20-) byte entry.
|
||||
entry: usize,
|
||||
tag: u16,
|
||||
ty: u16,
|
||||
count: u64,
|
||||
/// Where the value bytes are, when the type is one we read and they lie inside the block.
|
||||
value: Option<usize>,
|
||||
}
|
||||
|
||||
impl Field {
|
||||
fn short(&self, order: Order, b: &[u8]) -> Option<u16> {
|
||||
(self.ty == TYPE_SHORT && self.count == 1).then_some(())?;
|
||||
order.u16(b, self.value?)
|
||||
}
|
||||
|
||||
fn rational(&self, order: Order, b: &[u8]) -> Option<f64> {
|
||||
(self.ty == TYPE_RATIONAL && self.count == 1).then_some(())?;
|
||||
let at = self.value?;
|
||||
let (n, d) = (order.u32(b, at)?, order.u32(b, at.checked_add(4)?)?);
|
||||
(d != 0).then(|| f64::from(n) / f64::from(d))
|
||||
}
|
||||
}
|
||||
|
||||
/// IFD0's resolution entries.
|
||||
struct Ifd0 {
|
||||
order: Order,
|
||||
big: bool,
|
||||
first: usize,
|
||||
count: usize,
|
||||
entry_bytes: usize,
|
||||
fields: Vec<Field>,
|
||||
}
|
||||
|
||||
impl Ifd0 {
|
||||
fn parse(b: &[u8]) -> Option<Ifd0> {
|
||||
let (order, first, count, entry_bytes, big) = ifd0_entries(b)?;
|
||||
let mut fields = Vec::new();
|
||||
for i in 0..count {
|
||||
let e = first.checked_add(i.checked_mul(entry_bytes)?)?;
|
||||
let tag = order.u16(b, e)?;
|
||||
if !matches!(tag, TAG_X_RESOLUTION | TAG_Y_RESOLUTION | TAG_RESOLUTION_UNIT) {
|
||||
continue;
|
||||
}
|
||||
let ty = order.u16(b, e.checked_add(2)?)?;
|
||||
let count = if big { order.u64(b, e.checked_add(4)?)? } else { u64::from(order.u32(b, e.checked_add(4)?)?) };
|
||||
let value = value_at(order, b, e, big, ty, count);
|
||||
fields.push(Field { entry: e, tag, ty, count, value });
|
||||
}
|
||||
Some(Ifd0 { order, big, first, count, entry_bytes, fields })
|
||||
}
|
||||
|
||||
fn first_of(&self, tag: u16) -> Option<&Field> {
|
||||
self.fields.iter().find(|f| f.tag == tag)
|
||||
}
|
||||
}
|
||||
|
||||
/// Where an entry's SHORT or RATIONAL value bytes are: inline in the entry when they fit,
|
||||
/// else at the offset it holds. `None` for other types or when they leave the block.
|
||||
fn value_at(order: Order, b: &[u8], e: usize, big: bool, ty: u16, count: u64) -> Option<usize> {
|
||||
let size = match ty {
|
||||
TYPE_SHORT => 2u64,
|
||||
TYPE_RATIONAL => 8,
|
||||
_ => return None,
|
||||
}
|
||||
.checked_mul(count)?;
|
||||
let (field, inline) = if big { (e.checked_add(12)?, 8) } else { (e.checked_add(8)?, 4) };
|
||||
let at = if size <= inline {
|
||||
field
|
||||
} else if big {
|
||||
usize::try_from(order.u64(b, field)?).ok()?
|
||||
} else {
|
||||
usize::try_from(order.u32(b, field)?).ok()?
|
||||
};
|
||||
(at.checked_add(usize::try_from(size).ok()?)? <= b.len()).then_some(at)
|
||||
}
|
||||
|
||||
/// The resolution (pixels per inch, horizontal and vertical) recorded in IFD0 of an EXIF block
|
||||
/// (with or without the JPEG `Exif\0\0` prefix) or a TIFF structure.
|
||||
///
|
||||
/// Both XResolution and YResolution must be one RATIONAL each with a non-zero denominator.
|
||||
/// ResolutionUnit 2 (inch, also when the tag is absent) is used as is, 3 (centimetre) is
|
||||
/// converted; 1 (no absolute unit), any other value or a malformed unit entry gives `None`.
|
||||
/// The first of duplicated entries wins.
|
||||
/// or TIFF structure.
|
||||
pub fn exif_resolution(exif: &[u8]) -> Option<(f32, f32)> {
|
||||
let b = tiff_body(exif);
|
||||
let ifd = Ifd0::parse(b)?;
|
||||
let unit = match ifd.first_of(TAG_RESOLUTION_UNIT) {
|
||||
None => 2,
|
||||
Some(f) => f.short(ifd.order, b)?,
|
||||
};
|
||||
let scale = match unit {
|
||||
2 => 1.0,
|
||||
3 => CM_PER_INCH,
|
||||
_ => return None,
|
||||
};
|
||||
let x = ifd.first_of(TAG_X_RESOLUTION)?.rational(ifd.order, b)?;
|
||||
let y = ifd.first_of(TAG_Y_RESOLUTION)?.rational(ifd.order, b)?;
|
||||
ppi_pair(x * scale, y * scale)
|
||||
craft_metadata::edit::exif_resolution(exif)
|
||||
}
|
||||
|
||||
/// `exif` with its IFD0 resolution set to `ppi` (pixels per inch), borrowed when it has no
|
||||
/// resolution entries, already records that resolution, or cannot be parsed.
|
||||
///
|
||||
/// Existing well-formed XResolution / YResolution (one RATIONAL) entries are rewritten in place,
|
||||
/// and every well-formed ResolutionUnit (one SHORT) becomes 2 (inch). Malformed entries are
|
||||
/// removed from IFD0 instead, so no stale value survives; all other bytes are kept.
|
||||
/// `exif` with its IFD0 resolution set to `ppi`, borrowed when no edit is needed.
|
||||
pub fn exif_with_resolution(exif: &[u8], ppi: (f32, f32)) -> Cow<'_, [u8]> {
|
||||
exif_with_resolution_inner(exif, ppi, false)
|
||||
craft_metadata::edit::exif_with_resolution(exif, ppi)
|
||||
}
|
||||
|
||||
/// Like [`exif_with_resolution`], but adds X/Y resolution tags when a valid IFD0 has none.
|
||||
/// Existing TIFF data stays in place, so its offsets remain valid. Used by CMYK JPEG, which
|
||||
/// cannot carry a JFIF density marker.
|
||||
pub(crate) fn exif_with_resolution_adding_missing(exif: &[u8], ppi: (f32, f32)) -> Cow<'_, [u8]> {
|
||||
exif_with_resolution_inner(exif, ppi, true)
|
||||
}
|
||||
|
||||
/// Rewrite valid IFD0 resolution tags and optionally append tags that are missing or malformed.
|
||||
fn exif_with_resolution_inner(exif: &[u8], ppi: (f32, f32), add_missing: bool) -> Cow<'_, [u8]> {
|
||||
if ppi_pair(f64::from(ppi.0), f64::from(ppi.1)).is_none() {
|
||||
return Cow::Borrowed(exif);
|
||||
}
|
||||
if exif.is_empty() {
|
||||
if !add_missing {
|
||||
return Cow::Borrowed(exif);
|
||||
}
|
||||
// An image with DPI but no source EXIF still needs a valid EXIF block when the output
|
||||
// format cannot carry resolution in its own header (CMYK JPEG has no valid JFIF marker).
|
||||
return Cow::Owned(exif_with_resolution_inner(b"II*\0\x08\0\0\0\0\0\0\0\0\0\0", ppi, true).into_owned());
|
||||
}
|
||||
let body = tiff_body(exif);
|
||||
let prefix = exif.len() - body.len();
|
||||
let Some(ifd) = Ifd0::parse(body) else { return Cow::Borrowed(exif) };
|
||||
if exif_resolution(body).is_some_and(|r| same(r, ppi)) {
|
||||
return Cow::Borrowed(exif);
|
||||
}
|
||||
let missing_xy = ifd.first_of(TAG_X_RESOLUTION).and_then(|f| f.rational(ifd.order, body)).is_none()
|
||||
|| ifd.first_of(TAG_Y_RESOLUTION).and_then(|f| f.rational(ifd.order, body)).is_none();
|
||||
if add_missing && missing_xy {
|
||||
let Some(body) = append_resolution_ifd(body, &ifd, ppi) else { return Cow::Borrowed(exif) };
|
||||
let mut full = exif.get(..prefix).unwrap_or_default().to_vec();
|
||||
full.extend_from_slice(&body);
|
||||
return Cow::Owned(full);
|
||||
}
|
||||
if ifd.fields.is_empty() {
|
||||
return Cow::Borrowed(exif);
|
||||
}
|
||||
let order = ifd.order;
|
||||
let mut out = body.to_vec();
|
||||
let mut removed = Vec::new();
|
||||
for f in &ifd.fields {
|
||||
let bytes = match f.tag {
|
||||
TAG_RESOLUTION_UNIT if f.short(order, body).is_some() => put_u16(order, 2).to_vec(),
|
||||
TAG_X_RESOLUTION | TAG_Y_RESOLUTION if f.rational(order, body).is_some() => {
|
||||
let (n, d) = rational(if f.tag == TAG_X_RESOLUTION { ppi.0 } else { ppi.1 });
|
||||
[put_u32(order, n), put_u32(order, d)].concat()
|
||||
}
|
||||
_ => {
|
||||
removed.push(f.entry);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let slot = f.value.and_then(|at| out.get_mut(at..at.checked_add(bytes.len())?));
|
||||
match slot {
|
||||
Some(slot) => slot.copy_from_slice(&bytes),
|
||||
None => removed.push(f.entry),
|
||||
}
|
||||
}
|
||||
if !removed.is_empty() {
|
||||
remove_entries(&mut out, &ifd, &removed);
|
||||
}
|
||||
let mut full = exif.get(..prefix).unwrap_or_default().to_vec();
|
||||
full.extend_from_slice(&out);
|
||||
if full == exif { Cow::Borrowed(exif) } else { Cow::Owned(full) }
|
||||
}
|
||||
|
||||
/// Append a replacement IFD0 with fresh resolution tags. Every other entry keeps its original
|
||||
/// bytes and offsets, since the TIFF block's existing data remains where it was.
|
||||
fn append_resolution_ifd(body: &[u8], ifd: &Ifd0, ppi: (f32, f32)) -> Option<Vec<u8>> {
|
||||
let (count_bytes, next_bytes, alignment) = if ifd.big { (8usize, 8usize, 8usize) } else { (2usize, 4usize, 2usize) };
|
||||
let mut entries = Vec::with_capacity(ifd.count.saturating_add(3));
|
||||
for index in 0..ifd.count {
|
||||
let entry = ifd.first.checked_add(index.checked_mul(ifd.entry_bytes)?)?;
|
||||
let tag = ifd.order.u16(body, entry)?;
|
||||
if matches!(tag, TAG_X_RESOLUTION | TAG_Y_RESOLUTION | TAG_RESOLUTION_UNIT) {
|
||||
continue;
|
||||
}
|
||||
entries.push((tag, body.get(entry..entry.checked_add(ifd.entry_bytes)?)?.to_vec()));
|
||||
}
|
||||
let count = entries.len().checked_add(3)?;
|
||||
if !ifd.big && count > usize::from(u16::MAX) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let new_ifd = align_up(body.len(), alignment)?;
|
||||
let table_end = new_ifd.checked_add(count_bytes)?.checked_add(count.checked_mul(ifd.entry_bytes)?)?;
|
||||
let next_at = ifd.first.checked_add(ifd.count.checked_mul(ifd.entry_bytes)?)?;
|
||||
let next_ifd = body.get(next_at..next_at.checked_add(next_bytes)?)?;
|
||||
let rational_at = align_up(table_end.checked_add(next_bytes)?, if ifd.big { 8 } else { 4 })?;
|
||||
let y_rational_at = rational_at.checked_add(8)?;
|
||||
if !ifd.big && (new_ifd > u32::MAX as usize || y_rational_at.checked_add(8)? > u32::MAX as usize) {
|
||||
return None;
|
||||
}
|
||||
|
||||
entries.push((TAG_X_RESOLUTION, resolution_entry(ifd.order, ifd.big, TAG_X_RESOLUTION, u64::try_from(rational_at).ok()?)?));
|
||||
entries.push((TAG_Y_RESOLUTION, resolution_entry(ifd.order, ifd.big, TAG_Y_RESOLUTION, u64::try_from(y_rational_at).ok()?)?));
|
||||
entries.push((TAG_RESOLUTION_UNIT, resolution_entry(ifd.order, ifd.big, TAG_RESOLUTION_UNIT, 2)?));
|
||||
entries.sort_by_key(|(tag, _)| *tag);
|
||||
|
||||
let mut out = body.to_vec();
|
||||
out.resize(new_ifd, 0);
|
||||
if ifd.big {
|
||||
out.extend_from_slice(&put_u64(ifd.order, count as u64));
|
||||
} else {
|
||||
out.extend_from_slice(&put_u16(ifd.order, count as u16));
|
||||
}
|
||||
for (_, entry) in entries {
|
||||
out.extend_from_slice(&entry);
|
||||
}
|
||||
out.extend_from_slice(next_ifd);
|
||||
out.resize(rational_at, 0);
|
||||
let (xn, xd) = rational(ppi.0);
|
||||
let (yn, yd) = rational(ppi.1);
|
||||
out.extend_from_slice(&put_u32(ifd.order, xn));
|
||||
out.extend_from_slice(&put_u32(ifd.order, xd));
|
||||
out.extend_from_slice(&put_u32(ifd.order, yn));
|
||||
out.extend_from_slice(&put_u32(ifd.order, yd));
|
||||
|
||||
let offset_at: usize = if ifd.big { 8 } else { 4 };
|
||||
if ifd.big {
|
||||
out.get_mut(offset_at..offset_at.checked_add(8)?)?.copy_from_slice(&put_u64(ifd.order, new_ifd as u64));
|
||||
} else {
|
||||
out.get_mut(offset_at..offset_at.checked_add(4)?)?.copy_from_slice(&put_u32(ifd.order, u32::try_from(new_ifd).ok()?));
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// One IFD entry. Rational fields store an offset; the SHORT unit is inline.
|
||||
fn resolution_entry(order: Order, big: bool, tag: u16, value: u64) -> Option<Vec<u8>> {
|
||||
let entry_bytes = if big { 20 } else { 12 };
|
||||
let count_at = 4;
|
||||
let value_at = if big { 12 } else { 8 };
|
||||
let mut entry = vec![0; entry_bytes];
|
||||
entry.get_mut(..2)?.copy_from_slice(&put_u16(order, tag));
|
||||
entry.get_mut(2..4)?.copy_from_slice(&put_u16(order, if tag == TAG_RESOLUTION_UNIT { TYPE_SHORT } else { TYPE_RATIONAL }));
|
||||
if big {
|
||||
entry.get_mut(count_at..count_at + 8)?.copy_from_slice(&put_u64(order, 1));
|
||||
if tag == TAG_RESOLUTION_UNIT {
|
||||
entry.get_mut(value_at..value_at + 2)?.copy_from_slice(&put_u16(order, value as u16));
|
||||
} else {
|
||||
entry.get_mut(value_at..value_at + 8)?.copy_from_slice(&put_u64(order, value));
|
||||
}
|
||||
} else {
|
||||
entry.get_mut(count_at..count_at + 4)?.copy_from_slice(&put_u32(order, 1));
|
||||
if tag == TAG_RESOLUTION_UNIT {
|
||||
entry.get_mut(value_at..value_at + 2)?.copy_from_slice(&put_u16(order, value as u16));
|
||||
} else {
|
||||
entry.get_mut(value_at..value_at + 4)?.copy_from_slice(&put_u32(order, u32::try_from(value).ok()?));
|
||||
}
|
||||
}
|
||||
Some(entry)
|
||||
}
|
||||
|
||||
/// Round `value` up to the next `alignment` boundary, returning `None` on overflow.
|
||||
fn align_up(value: usize, alignment: usize) -> Option<usize> {
|
||||
value.checked_add((alignment - value % alignment) % alignment)
|
||||
}
|
||||
|
||||
fn put_u16(order: Order, v: u16) -> [u8; 2] {
|
||||
match order {
|
||||
Order::Little => v.to_le_bytes(),
|
||||
Order::Big => v.to_be_bytes(),
|
||||
}
|
||||
}
|
||||
|
||||
fn put_u32(order: Order, v: u32) -> [u8; 4] {
|
||||
match order {
|
||||
Order::Little => v.to_le_bytes(),
|
||||
Order::Big => v.to_be_bytes(),
|
||||
}
|
||||
}
|
||||
|
||||
fn put_u64(order: Order, v: u64) -> [u8; 8] {
|
||||
match order {
|
||||
Order::Little => v.to_le_bytes(),
|
||||
Order::Big => v.to_be_bytes(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes the IFD0 entries at offsets `removed` (see [`exif_with_resolution`]).
|
||||
fn remove_entries(b: &mut [u8], ifd: &Ifd0, removed: &[usize]) {
|
||||
let eb = ifd.entry_bytes;
|
||||
let next_bytes = if ifd.big { 8 } else { 4 };
|
||||
let count_bytes = if ifd.big { 8 } else { 2 };
|
||||
let Some(table_end) = ifd.count.checked_mul(eb).and_then(|n| ifd.first.checked_add(n)) else { return };
|
||||
let Some(old) = b.get(ifd.first..table_end.saturating_add(next_bytes)).map(<[u8]>::to_vec) else { return };
|
||||
let mut table = Vec::with_capacity(old.len());
|
||||
let mut kept = 0usize;
|
||||
for (i, entry) in old.chunks_exact(eb).take(ifd.count).enumerate() {
|
||||
if !removed.contains(&(ifd.first + i * eb)) {
|
||||
table.extend_from_slice(entry);
|
||||
kept += 1;
|
||||
}
|
||||
}
|
||||
table.extend_from_slice(old.get(ifd.count * eb..).unwrap_or_default());
|
||||
table.resize(old.len(), 0);
|
||||
let count: Vec<u8> = if ifd.big { put_u64(ifd.order, kept as u64).to_vec() } else { put_u16(ifd.order, kept as u16).to_vec() };
|
||||
let Some(count_at) = ifd.first.checked_sub(count_bytes) else { return };
|
||||
if let Some(slot) = b.get_mut(count_at..ifd.first) {
|
||||
slot.copy_from_slice(&count);
|
||||
}
|
||||
if let Some(slot) = b.get_mut(ifd.first..ifd.first + table.len()) {
|
||||
slot.copy_from_slice(&table);
|
||||
}
|
||||
}
|
||||
|
||||
/// The resolution in a Photoshop image resource block (a sequence of `8BIM` resources, as in a
|
||||
/// JPEG's APP13 "Photoshop 3.0" segment or TIFF tag 34377): its ResolutionInfo (0x03ED), whose
|
||||
/// Fixed 16.16 hRes / vRes are pixels per inch whatever display unit follows them.
|
||||
/// The resolution (pixels per inch) recorded by Photoshop's ResolutionInfo image resource.
|
||||
pub fn photoshop_resolution(resources: &[u8]) -> Option<(f32, f32)> {
|
||||
let be16 = |at: usize| resources.get(at..at.checked_add(2)?).map(|s| u16::from_be_bytes([s[0], s[1]]));
|
||||
let be32 = |at: usize| resources.get(at..at.checked_add(4)?).map(|s| u32::from_be_bytes([s[0], s[1], s[2], s[3]]));
|
||||
let mut i = 0usize;
|
||||
while i < resources.len() {
|
||||
let signature = resources.get(i..i.checked_add(4)?)?;
|
||||
let id = be16(i.checked_add(4)?)?;
|
||||
// Pascal name: length byte plus text, padded to an even size.
|
||||
let name_len = usize::from(*resources.get(i.checked_add(6)?)?);
|
||||
let name = (name_len + 1).next_multiple_of(2);
|
||||
let size_at = i.checked_add(6)?.checked_add(name)?;
|
||||
let size = usize::try_from(be32(size_at)?).ok()?;
|
||||
let data = size_at.checked_add(4)?;
|
||||
if signature == b"8BIM" && id == RESOLUTION_INFO {
|
||||
let info = resources.get(data..data.checked_add(size)?)?;
|
||||
if info.len() < 16 {
|
||||
return None;
|
||||
}
|
||||
let fixed = |at: usize| be32(data + at).map(|v| f64::from(v) / 65536.0);
|
||||
return ppi_pair(fixed(0)?, fixed(8)?);
|
||||
}
|
||||
i = data.checked_add(size)?.checked_add(size % 2)?;
|
||||
}
|
||||
None
|
||||
craft_metadata::edit::photoshop_resolution(resources)
|
||||
}
|
||||
|
||||
/// The text ranges of every value of XMP property `key` (`key="v"`, `key='v'` or
|
||||
/// `<key>v</key>`), in order.
|
||||
fn xmp_values(xmp: &str, key: &str) -> Vec<(usize, usize)> {
|
||||
let mut out = Vec::new();
|
||||
let mut from = 0;
|
||||
while let Some(k) = xmp.get(from..).and_then(|s| s.find(key)).map(|k| k + from) {
|
||||
from = k + key.len();
|
||||
// An attribute or an opening tag of exactly this name (not `</key>`, not `xkey`).
|
||||
if !xmp.get(..k).and_then(|h| h.chars().next_back()).is_some_and(|c| c == '<' || c.is_ascii_whitespace()) {
|
||||
continue;
|
||||
}
|
||||
let rest = xmp.get(from..).unwrap_or_default();
|
||||
let after_key = rest.trim_start();
|
||||
let (value, close) = if let Some(r) = after_key.strip_prefix('=') {
|
||||
let r = r.trim_start();
|
||||
match r.chars().next() {
|
||||
Some(q @ ('"' | '\'')) => (r.get(1..).unwrap_or_default(), q),
|
||||
_ => continue,
|
||||
}
|
||||
} else if let Some(r) = rest.strip_prefix('>') {
|
||||
(r, '<')
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
let start = xmp.len() - value.len();
|
||||
// Advance past this value before searching for the next key. Without this, `from`
|
||||
// only moved past the key text, so property-like text repeated inside an unrelated
|
||||
// attribute re-ran this full-suffix `find(close)` for every occurrence, which is
|
||||
// quadratic in the packet size. A missing close means the value is unterminated, so
|
||||
// no later value can parse either; stop scanning in that case.
|
||||
let Some(len) = value.find(close) else { break };
|
||||
out.push((start, start + len));
|
||||
from = start + len + close.len_utf8();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// An XMP rational (`300/1`) or plain number.
|
||||
fn xmp_number(s: &str) -> Option<f64> {
|
||||
let s = s.trim();
|
||||
match s.split_once('/') {
|
||||
Some((n, d)) => {
|
||||
let (n, d) = (n.trim().parse::<f64>().ok()?, d.trim().parse::<f64>().ok()?);
|
||||
(d != 0.0).then(|| n / d)
|
||||
}
|
||||
None => s.parse().ok(),
|
||||
}
|
||||
}
|
||||
|
||||
const XMP_X: &str = "tiff:XResolution";
|
||||
const XMP_Y: &str = "tiff:YResolution";
|
||||
const XMP_UNIT: &str = "tiff:ResolutionUnit";
|
||||
|
||||
/// The resolution (pixels per inch) in an XMP packet's `tiff:XResolution` / `tiff:YResolution`
|
||||
/// (with `tiff:ResolutionUnit`, 2 when absent, 3 converted from centimetres), or `None`.
|
||||
/// The resolution (pixels per inch, horizontal and vertical) recorded in an XMP packet.
|
||||
pub fn xmp_resolution(xmp: &str) -> Option<(f32, f32)> {
|
||||
let first = |key: &str| xmp_values(xmp, key).first().and_then(|&(a, b)| xmp.get(a..b));
|
||||
let unit = match first(XMP_UNIT) {
|
||||
None => 2.0,
|
||||
Some(u) => xmp_number(u)?,
|
||||
};
|
||||
let scale = if unit == 2.0 {
|
||||
1.0
|
||||
} else if unit == 3.0 {
|
||||
CM_PER_INCH
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
ppi_pair(xmp_number(first(XMP_X)?)? * scale, xmp_number(first(XMP_Y)?)? * scale)
|
||||
craft_metadata::edit::xmp_resolution(xmp)
|
||||
}
|
||||
|
||||
/// `xmp` with its `tiff:` resolution properties set to `ppi` (and the unit to 2, inch),
|
||||
/// borrowed when it has none or already says so.
|
||||
/// `xmp` with its resolution set to `ppi`, borrowed when no edit is needed.
|
||||
pub fn xmp_with_resolution(xmp: &str, ppi: (f32, f32)) -> Cow<'_, str> {
|
||||
if ppi_pair(f64::from(ppi.0), f64::from(ppi.1)).is_none() || xmp_resolution(xmp).is_some_and(|r| same(r, ppi)) {
|
||||
return Cow::Borrowed(xmp);
|
||||
}
|
||||
let text = |v: f32| {
|
||||
let (n, d) = rational(v);
|
||||
format!("{n}/{d}")
|
||||
};
|
||||
let mut edits: Vec<(usize, usize, String)> = Vec::new();
|
||||
for (key, value) in [(XMP_X, text(ppi.0)), (XMP_Y, text(ppi.1)), (XMP_UNIT, "2".to_owned())] {
|
||||
edits.extend(xmp_values(xmp, key).into_iter().map(|(a, b)| (a, b, value.clone())));
|
||||
}
|
||||
if edits.is_empty() {
|
||||
return Cow::Borrowed(xmp);
|
||||
}
|
||||
edits.sort_by_key(|e| e.0);
|
||||
let mut out = String::with_capacity(xmp.len() + 16);
|
||||
let mut at = 0;
|
||||
for (a, b, v) in edits {
|
||||
// Overlapping ranges can only come from malformed markup; keep the first edit.
|
||||
if a < at {
|
||||
continue;
|
||||
}
|
||||
out.push_str(xmp.get(at..a).unwrap_or_default());
|
||||
out.push_str(&v);
|
||||
at = b;
|
||||
}
|
||||
out.push_str(xmp.get(at..).unwrap_or_default());
|
||||
if out == xmp { Cow::Borrowed(xmp) } else { Cow::Owned(out) }
|
||||
craft_metadata::edit::xmp_with_resolution(xmp, ppi)
|
||||
}
|
||||
|
||||
/// `exif` without its embedded thumbnail, or `None` when it has none (or doesn't parse).
|
||||
///
|
||||
/// EXIF keeps the thumbnail in IFD1, the IFD IFD0's next-IFD pointer names. Finder, Android's
|
||||
/// gallery and upload pickers show that thumbnail instead of decoding the image, so a copied one
|
||||
/// keeps showing the photo as it was before it was edited (#2147, #2373). The pointer is cleared;
|
||||
/// nothing else moves, so every other offset in the block stays valid.
|
||||
fn exif_without_thumbnail(exif: &[u8]) -> Option<Vec<u8>> {
|
||||
let body = tiff_body(exif);
|
||||
let prefix = exif.len() - body.len();
|
||||
let (_, first, count, entry_bytes, big_tiff) = ifd0_entries(body)?;
|
||||
let next = prefix.checked_add(first)?.checked_add(count.checked_mul(entry_bytes)?)?;
|
||||
let width = if big_tiff { 8 } else { 4 };
|
||||
// In bounds: `ifd0_entries` checked that the whole pointer is present.
|
||||
if exif.get(next..next.checked_add(width)?)?.iter().all(|b| *b == 0) {
|
||||
return None;
|
||||
}
|
||||
let mut out = exif.to_vec();
|
||||
out.get_mut(next..next + width)?.fill(0);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The EXIF to write next to upright pixels at resolution `ppi`: Orientation 1 (see
|
||||
/// [`upright_exif`]), no thumbnail of the source image (see [`exif_without_thumbnail`]) and,
|
||||
/// when `ppi` is known, the resolution (see [`exif_with_resolution`]).
|
||||
/// The EXIF to write next to upright pixels at resolution `ppi`.
|
||||
pub fn export_exif(exif: &[u8], ppi: Option<(f32, f32)>) -> Cow<'_, [u8]> {
|
||||
export_exif_inner(exif, ppi, false)
|
||||
craft_metadata::edit::export_exif(exif, ppi)
|
||||
}
|
||||
|
||||
/// Export EXIF for a format without its own resolution field, adding missing X/Y resolution
|
||||
/// entries to valid metadata when necessary.
|
||||
/// Export EXIF, adding missing resolution entries when `ppi` is supplied.
|
||||
pub(crate) fn export_exif_adding_missing_resolution(exif: &[u8], ppi: Option<(f32, f32)>) -> Cow<'_, [u8]> {
|
||||
export_exif_inner(exif, ppi, true)
|
||||
craft_metadata::edit::export_exif_adding_missing_resolution(exif, ppi)
|
||||
}
|
||||
|
||||
/// Normalize EXIF orientation and thumbnails, optionally adding missing output resolution tags.
|
||||
fn export_exif_inner(exif: &[u8], ppi: Option<(f32, f32)>, add_missing: bool) -> Cow<'_, [u8]> {
|
||||
let up = upright_exif(exif);
|
||||
let up = match exif_without_thumbnail(&up) {
|
||||
Some(v) => Cow::Owned(v),
|
||||
None => up,
|
||||
};
|
||||
let Some(ppi) = ppi else { return up };
|
||||
let result = if add_missing { exif_with_resolution_adding_missing(&up, ppi) } else { exif_with_resolution(&up, ppi) };
|
||||
if let Cow::Owned(v) = result {
|
||||
return Cow::Owned(v);
|
||||
}
|
||||
up
|
||||
}
|
||||
|
||||
/// The XMP to write next to upright pixels at resolution `ppi` (see [`upright_xmp`] and
|
||||
/// [`xmp_with_resolution`]).
|
||||
/// The XMP to write next to upright pixels at resolution `ppi`.
|
||||
pub fn export_xmp(xmp: &str, ppi: Option<(f32, f32)>) -> Cow<'_, str> {
|
||||
let up = upright_xmp(xmp);
|
||||
let Some(ppi) = ppi else { return up };
|
||||
if let Cow::Owned(v) = xmp_with_resolution(&up, ppi) {
|
||||
return Cow::Owned(v);
|
||||
}
|
||||
up
|
||||
craft_metadata::edit::export_xmp(xmp, ppi)
|
||||
}
|
||||
|
||||
/// The resolution a decoded file's metadata blocks record, in precedence order: XMP, then
|
||||
/// EXIF (Photoshop's ResolutionInfo is format-specific and read by the decoder).
|
||||
/// Resolution recorded by XMP or EXIF when a format-specific resolution field is absent.
|
||||
pub fn resolution(exif: Option<&[u8]>, xmp: Option<&str>) -> Option<(f32, f32)> {
|
||||
craft_metadata::edit::resolution(exif, xmp)
|
||||
}
|
||||
|
||||
/// Resolution a decoded file's EXIF and XMP blocks record, in precedence order.
|
||||
pub(crate) fn from_metadata(exif: Option<&[u8]>, xmp: Option<&str>) -> Option<(f32, f32)> {
|
||||
xmp.and_then(xmp_resolution).or_else(|| exif.and_then(exif_resolution))
|
||||
craft_metadata::edit::resolution(exif, xmp)
|
||||
}
|
||||
@@ -81,7 +81,8 @@ fn every_limit_refuses_the_file_past_it() {
|
||||
let chain: String = (0..20).map(|i| format!("<g id='g{i}'><use xlink:href='#g{}'/></g>", i + 1)).collect();
|
||||
let uses = svg(&format!("<defs>{chain}<rect id='g20' width='1' height='1'/></defs><use xlink:href='#g0'/>"));
|
||||
assert!(!guard::deeper_than(&uses, 24));
|
||||
assert_eq!(try_parse(&uses, &shallow()).err(), Some(Error::TooDeep(24)));
|
||||
let referenced_depth = Limits { tree_depth: 24, ..shallow() };
|
||||
assert_eq!(try_parse(&uses, &referenced_depth).err(), Some(Error::TooDeep(24)));
|
||||
// Attributes on one element.
|
||||
let wide = svg(&format!("<g{}/>", (0..40).map(|i| format!(" a{i}='1'")).collect::<String>()));
|
||||
assert!(try_parse(&wide, &Limits { attributes: 40, ..shallow() }).is_ok());
|
||||
|
||||
Reference in new issue
Block a user