Merge remote-tracking branch 'origin/main' into codex/docx-measure-clamp

This commit is contained in:
Dylan M. Taylor committed 2026-11-14 06:06:54 +00:00
commit 32e802fa08
15 files changed
+4612 -560

No files matched your search

Generated
+1
View File
@@ -1848,6 +1848,7 @@ version = "0.1.0"
dependencies = [
"craft-heif",
"craft-jpeg",
"craft-metadata",
"exr",
"flate2",
"half",
+12 -1
View File
@@ -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 {
+524
View File
@@ -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,
}
}
+404
View File
@@ -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 { .. } => {}
}
}
}
+52
View File
@@ -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");
+261 -8
View File
@@ -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
+420
View File
@@ -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
+574
View File
@@ -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")?;
+608 -2
View File
@@ -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
View File
@@ -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()
}
+6 -1
View File
@@ -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"]
);
}
+1
View File
@@ -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 }
+26 -544
View File
@@ -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)
}
+2 -1
View File
@@ -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());