mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-11 16:30:16 -06:00
Merge remote-tracking branch 'origin/main' into effect-blend-modes
# Conflicts: # crates/doc/src/item.rs # crates/idml/src/export.rs # crates/idml/src/import.rs # crates/ui-egui/src/effects_dialog.rs
This commit is contained in:
@@ -15,6 +15,72 @@ use crate::{Asset, AssetId, Content, Document, Item};
|
||||
/// is treated as using every asset, so nothing it references is merged away or pruned.
|
||||
const MAX_DEPTH: usize = 64;
|
||||
|
||||
fn item_layer_options(it: &Item, depth: usize, overrides_only: bool) -> bool {
|
||||
depth > MAX_DEPTH
|
||||
|| it.graphic().is_some_and(|g| g.layer_options.as_ref().is_some_and(|o| !overrides_only || o.has_visibility_overrides()))
|
||||
|| it.children().iter().any(|child| item_layer_options(child, depth + 1, overrides_only))
|
||||
}
|
||||
|
||||
fn story_layer_options(st: &Story, depth: usize, overrides_only: bool) -> bool {
|
||||
depth > MAX_DEPTH
|
||||
|| st.objects.iter().any(|object| item_layer_options(&object.item, depth + 1, overrides_only))
|
||||
|| st.tables.values().flat_map(|table| &table.cells).any(|cell| {
|
||||
cell.graphic.as_ref().is_some_and(|g| g.layer_options.as_ref().is_some_and(|o| !overrides_only || o.has_visibility_overrides()))
|
||||
|| story_layer_options(&cell.text, depth + 1, overrides_only)
|
||||
})
|
||||
|| st.notes.iter().chain(&st.endnotes).any(|note| story_layer_options(¬e.text, depth + 1, overrides_only))
|
||||
}
|
||||
|
||||
fn item_any(it: &Item, depth: usize, f: &impl Fn(&Item) -> bool) -> bool {
|
||||
depth > MAX_DEPTH || f(it) || it.children().iter().any(|child| item_any(child, depth + 1, f))
|
||||
}
|
||||
|
||||
fn story_any(st: &Story, depth: usize, f: &impl Fn(&Item) -> bool) -> bool {
|
||||
depth > MAX_DEPTH
|
||||
|| st.objects.iter().any(|object| item_any(&object.item, depth + 1, f))
|
||||
|| st.tables.values().flat_map(|table| &table.cells).any(|cell| story_any(&cell.text, depth + 1, f))
|
||||
|| st.notes.iter().chain(&st.endnotes).any(|note| story_any(¬e.text, depth + 1, f))
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Whether saving needs a reader that retains per-placement graphic layer settings.
|
||||
/// Exceeding the traversal budget conservatively requires the newer format.
|
||||
pub fn has_graphic_layer_options(&self) -> bool {
|
||||
self.spreads.iter().chain(&self.parents).flat_map(|spread| &spread.items).any(|item| item_layer_options(item, 0, false))
|
||||
|| self.stories.values().any(|story| story_layer_options(story, 0, false))
|
||||
}
|
||||
|
||||
/// Whether any placed PDF, on document or parent spreads, in groups or anchored, explicitly
|
||||
/// shows every layer (an override with an empty hidden list). Older readers would take that
|
||||
/// for "use the file's defaults". Exceeding the traversal budget conservatively says yes.
|
||||
pub fn has_explicit_all_on_pdf_layers(&self) -> bool {
|
||||
let all_on = |it: &Item| it.pdf_layer_override && it.pdf_hidden_layers.is_empty();
|
||||
self.spreads.iter().chain(&self.parents).flat_map(|spread| &spread.items).any(|item| item_any(item, 0, &all_on))
|
||||
|| self.stories.values().any(|story| story_any(story, 0, &all_on))
|
||||
}
|
||||
|
||||
/// Whether any placed PDF, on document or parent spreads, in groups or anchored, has an
|
||||
/// Object Layer Options choice (hidden layers or explicit all-on). Exceeding the traversal
|
||||
/// budget conservatively says yes.
|
||||
pub fn has_pdf_layer_overrides(&self) -> bool {
|
||||
let chosen = |it: &Item| it.has_pdf_layer_override();
|
||||
self.spreads.iter().chain(&self.parents).flat_map(|spread| &spread.items).any(|item| item_any(item, 0, &chosen))
|
||||
|| self.stories.values().any(|story| story_any(story, 0, &chosen))
|
||||
}
|
||||
|
||||
/// Placements outside the ordinary document-spread frame walk used by preflight.
|
||||
pub fn has_unwalked_graphic_layer_visibility_overrides(&self) -> bool {
|
||||
self.parents.iter().flat_map(|spread| &spread.items).any(|item| item_layer_options(item, 0, true))
|
||||
|| self.stories.values().any(|story| story_layer_options(story, 0, true))
|
||||
}
|
||||
|
||||
/// Includes parent items, anchored objects, graphic cells and nested notes.
|
||||
pub fn has_graphic_layer_visibility_overrides(&self) -> bool {
|
||||
self.spreads.iter().chain(&self.parents).flat_map(|spread| &spread.items).any(|item| item_layer_options(item, 0, true))
|
||||
|| self.stories.values().any(|story| story_layer_options(story, 0, true))
|
||||
}
|
||||
}
|
||||
|
||||
impl Asset {
|
||||
/// Whether `other` holds the same file: the same type, PDF page and crop, link and bytes. Files at
|
||||
/// different paths stay apart even with identical bytes, since each can be updated or
|
||||
@@ -356,6 +422,7 @@ mod tests {
|
||||
fn graphic_item(id: u64, asset: u64, crop: f64) -> Item {
|
||||
let mut it = Item::new(ItemId(id), LayerId(1), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)));
|
||||
it.content = Content::Graphic(Graphic {
|
||||
layer_options: None,
|
||||
asset: AssetId(asset),
|
||||
size: (10.0, 10.0),
|
||||
xf: Affine::translate((crop, 0.0)),
|
||||
|
||||
+27
-1614
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,177 @@
|
||||
use super::{
|
||||
Capitalization, ChangeMark, Deserialize, Digits, Kerning, Leading, Position, Serialize, attr_key, deserialize_explicit_value, unknown_attribute,
|
||||
};
|
||||
|
||||
attr_set! {
|
||||
/// Character attributes (Character panel / character styles / the character part of paragraph styles).
|
||||
CharAttrs / CharProps {
|
||||
font_family: String = "Source Serif 4".into(),
|
||||
font_style: String = "Regular".into(),
|
||||
/// Point size.
|
||||
size: f64 = 12.0,
|
||||
leading: Leading = Leading::Auto,
|
||||
kerning: Kerning = Kerning::Metrics,
|
||||
/// Tracking in 1/1000 em.
|
||||
tracking: f64 = 0.0,
|
||||
/// Explicit CJK aki, in em; None restores automatic spacing.
|
||||
leading_aki: Option<f64> = None => "deserialize_explicit_value",
|
||||
trailing_aki: Option<f64> = None => "deserialize_explicit_value",
|
||||
/// Proportional character compression, 0..1.
|
||||
tsume: f64 = 0.0,
|
||||
/// Target em cells for the contiguous formatted group (0 disables jidori).
|
||||
jidori: u32 = 0,
|
||||
character_alignment: crate::cjk::CharacterAlignment = crate::cjk::CharacterAlignment::Baseline,
|
||||
leading_model: crate::cjk::LeadingModel = crate::cjk::LeadingModel::Roman,
|
||||
/// The kenten mark's text: a custom mark, or the preset of `kenten_kind` (see
|
||||
/// [`crate::cjk_settings::kenten_mark`]); empty is the sesame dot.
|
||||
kenten_character: String = String::new(),
|
||||
/// Horizontal / vertical scale, 1.0 = 100%.
|
||||
h_scale: f64 = 1.0,
|
||||
v_scale: f64 = 1.0,
|
||||
baseline_shift: f64 = 0.0,
|
||||
/// Skew (false italic), degrees.
|
||||
skew: f64 = 0.0,
|
||||
fill: String = "[Black]".into(),
|
||||
fill_tint: f32 = 1.0,
|
||||
stroke: String = "[None]".into(),
|
||||
stroke_tint: f32 = 1.0,
|
||||
stroke_weight: f64 = 1.0,
|
||||
/// Stroke behind fill: the stroke is painted under the fill and lies wholly outside the
|
||||
/// letters at its full weight, so the letters keep their shape. Off, it straddles the outline.
|
||||
stroke_behind_fill: bool = false,
|
||||
capitalization: Capitalization = Capitalization::Normal,
|
||||
position: Position = Position::Normal,
|
||||
underline: bool = false,
|
||||
strikethrough: bool = false,
|
||||
/// Underline Options: weight and offset below the baseline (`None` = from the size),
|
||||
/// colour (empty = the text colour) and tint.
|
||||
underline_weight: Option<f64> = None => "deserialize_explicit_value",
|
||||
underline_offset: Option<f64> = None => "deserialize_explicit_value",
|
||||
underline_color: String = String::new(),
|
||||
underline_tint: f32 = 1.0,
|
||||
/// Strikethrough Options: offset above the baseline.
|
||||
strikethrough_weight: Option<f64> = None => "deserialize_explicit_value",
|
||||
strikethrough_offset: Option<f64> = None => "deserialize_explicit_value",
|
||||
strikethrough_color: String = String::new(),
|
||||
strikethrough_tint: f32 = 1.0,
|
||||
glyph_form: String = String::new(),
|
||||
ligatures: bool = true,
|
||||
no_break: bool = false,
|
||||
/// Tate-chu-yoko: in vertical text, the run is set horizontally within one em of the line.
|
||||
tate_chu_yoko: bool = false,
|
||||
tate_chu_yoko_x_offset: f64 = 0.0,
|
||||
tate_chu_yoko_y_offset: f64 = 0.0,
|
||||
/// Ruby: the reading set small above the text (to its right in vertical text). Group ruby
|
||||
/// sets it over the whole run; per-character ruby holds one reading per character,
|
||||
/// separated by U+3000. Its other settings are the `ruby_*` fields below.
|
||||
ruby: String = String::new(),
|
||||
/// Kenten: an emphasis dot above each character.
|
||||
kenten: bool = false,
|
||||
/// Warichu: the run is set in smaller lines stacked inside one line height.
|
||||
warichu: bool = false,
|
||||
/// How many warichu lines (InDesign's minimum is 2).
|
||||
warichu_lines: u32 = 2,
|
||||
/// Warichu size as a percentage of the parent size.
|
||||
warichu_size: f64 = 50.0,
|
||||
/// Extra points between warichu baselines. 0 is automatic (one small em).
|
||||
warichu_line_spacing: f64 = 0.0,
|
||||
warichu_alignment: crate::cjk::WarichuAlignment = crate::cjk::WarichuAlignment::Auto,
|
||||
/// Minimum characters on a warichu line before a break, and on the line after it.
|
||||
warichu_chars_before_break: u32 = 1,
|
||||
warichu_chars_after_break: u32 = 1,
|
||||
// Kenten Settings / Kenten Color (`kenten` switches the marks on).
|
||||
/// The mark; [`crate::cjk_settings::KentenKind::Custom`] draws `kenten_character`.
|
||||
kenten_kind: crate::cjk_settings::KentenKind = crate::cjk_settings::KentenKind::SesameDot,
|
||||
/// Font of a custom mark (empty = the text's font) and its style.
|
||||
kenten_font: String = String::new(),
|
||||
kenten_font_style: String = String::new(),
|
||||
/// Mark size in points; `None` is half the text size.
|
||||
kenten_size: Option<f64> = None,
|
||||
/// Horizontal / vertical scale of the mark, 1.0 = 100%.
|
||||
kenten_x_scale: f64 = 1.0,
|
||||
kenten_y_scale: f64 = 1.0,
|
||||
/// Points between the text's em box and the mark's.
|
||||
kenten_distance: f64 = 0.0,
|
||||
kenten_position: crate::cjk_settings::KentenPosition = crate::cjk_settings::KentenPosition::AboveRight,
|
||||
kenten_alignment: crate::cjk_settings::KentenAlignment = crate::cjk_settings::KentenAlignment::Center,
|
||||
/// How a custom mark was entered (IDML `KentenCharacterSet`), kept for interchange.
|
||||
kenten_character_set: String = String::new(),
|
||||
/// Mark fill and stroke swatches (empty = the text's), tints and stroke weight (`None` =
|
||||
/// the text's).
|
||||
kenten_fill: String = String::new(),
|
||||
kenten_fill_tint: Option<f32> = None,
|
||||
kenten_stroke: String = String::new(),
|
||||
kenten_stroke_tint: Option<f32> = None,
|
||||
kenten_stroke_weight: Option<f64> = None,
|
||||
kenten_overprint_fill: crate::cjk_settings::AdornmentOverprint = crate::cjk_settings::AdornmentOverprint::Auto,
|
||||
kenten_overprint_stroke: crate::cjk_settings::AdornmentOverprint = crate::cjk_settings::AdornmentOverprint::Auto,
|
||||
// Shatai (斜体): the character is compressed across the direction at `shatai_angle`.
|
||||
/// Compression in percent of the em (0 = off).
|
||||
shatai_magnification: f64 = 0.0,
|
||||
/// Degrees from the line direction, counter-clockwise.
|
||||
shatai_angle: f64 = 45.0,
|
||||
/// Turn the compressed character back so its edge along the line stays along the line.
|
||||
shatai_adjust_rotation: bool = false,
|
||||
/// Fit the advance to the compressed em box.
|
||||
shatai_adjust_tsume: bool = true,
|
||||
// Ruby settings (see [`crate::ruby`]). Defaults are the IDML text defaults.
|
||||
ruby_type: crate::ruby::RubyType = crate::ruby::RubyType::Group,
|
||||
/// How a ruby shorter than its parent is spread over it.
|
||||
ruby_alignment: crate::ruby::RubyAlignment = crate::ruby::RubyAlignment::Jis,
|
||||
ruby_position: crate::ruby::RubyPosition = crate::ruby::RubyPosition::AboveRight,
|
||||
/// Points along the line, and away from the parent text across it.
|
||||
ruby_x_offset: f64 = 0.0,
|
||||
ruby_y_offset: f64 = 0.0,
|
||||
/// Empty: the parent's font and style.
|
||||
ruby_font: String = String::new(),
|
||||
ruby_font_style: String = String::new(),
|
||||
/// Points; `None` is half the parent size.
|
||||
ruby_font_size: Option<f64> = None,
|
||||
/// 1.0 = 100%.
|
||||
ruby_x_scale: f64 = 1.0,
|
||||
ruby_y_scale: f64 = 1.0,
|
||||
/// Use the font's ruby glyphs (OpenType `ruby`).
|
||||
ruby_open_type_pro: bool = true,
|
||||
/// Tate-chu-yoko in vertical ruby for runs of up to this many digits (0 = off).
|
||||
ruby_auto_tcy_digits: u32 = 0,
|
||||
ruby_auto_tcy_include_roman: bool = false,
|
||||
ruby_auto_tcy_auto_scale: bool = true,
|
||||
/// IDML `RubyOverhang`, kept for round trips; `ruby_overhang_amount` sets the overhang.
|
||||
ruby_overhang: bool = false,
|
||||
ruby_overhang_amount: crate::ruby::RubyOverhang = crate::ruby::RubyOverhang::OneRuby,
|
||||
ruby_parent_spacing: crate::ruby::RubyParentSpacing = crate::ruby::RubyParentSpacing::Aki121,
|
||||
/// A longer ruby at a line's start or end is set flush with the line edge.
|
||||
ruby_auto_align: bool = true,
|
||||
/// A longer ruby is narrowed first, down to `ruby_scaling_min` (1.0 = 100%).
|
||||
ruby_auto_scaling: bool = false,
|
||||
ruby_scaling_min: f64 = 0.66,
|
||||
/// Ruby colour: empty is the text's colour; `None` tint and weight follow the text.
|
||||
ruby_fill: String = String::new(),
|
||||
ruby_fill_tint: Option<f32> = None,
|
||||
ruby_stroke: String = String::new(),
|
||||
ruby_stroke_tint: Option<f32> = None,
|
||||
ruby_stroke_weight: Option<f64> = None,
|
||||
ruby_overprint_fill: crate::cjk_settings::AdornmentOverprint = crate::cjk_settings::AdornmentOverprint::Auto,
|
||||
ruby_overprint_stroke: crate::cjk_settings::AdornmentOverprint = crate::cjk_settings::AdornmentOverprint::Auto,
|
||||
/// Digits (World-Ready): how 0–9 are drawn.
|
||||
digits: Digits = Digits::Default,
|
||||
character_direction: crate::arabic::CharacterDirection = crate::arabic::CharacterDirection::Default,
|
||||
/// Character-level permission, additionally gated by paragraph kashidas.
|
||||
allow_kashidas: bool = true,
|
||||
diacritic_position: crate::arabic::DiacriticPosition = crate::arabic::DiacriticPosition::Default,
|
||||
/// Additional mark offsets, in thousandths of an em. Positive y is up.
|
||||
diacritic_x_offset: f64 = 0.0,
|
||||
diacritic_y_offset: f64 = 0.0,
|
||||
/// IDML contextual-form request, separate from general OpenType features.
|
||||
positional_form: String = String::new(),
|
||||
language: String = "English: USA".into(),
|
||||
/// Additional OpenType features, e.g. `["onum", "ss01"]`.
|
||||
otf_features: Vec<String> = Vec::new(),
|
||||
/// Conditional text: the conditions applied (Window › Type & Tables › Conditional Text).
|
||||
conditions: Vec<String> = Vec::new(),
|
||||
/// Track Changes: text added or deleted while tracking (deleted text isn't laid out).
|
||||
change: ChangeMark = ChangeMark::None,
|
||||
/// XML: the element this text is tagged with (Tags panel, inline).
|
||||
xml_tag: String = String::new(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
use super::{Deserialize, Serialize};
|
||||
|
||||
// ---------- value types ----------
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind", content = "value")]
|
||||
pub enum Leading {
|
||||
/// Auto leading (percentage of the largest size on the line, Justification → Auto Leading).
|
||||
#[default]
|
||||
Auto,
|
||||
Points(f64),
|
||||
}
|
||||
|
||||
/// Paragraph direction.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TextDirection {
|
||||
#[default]
|
||||
LeftToRight,
|
||||
RightToLeft,
|
||||
}
|
||||
|
||||
/// Digits (World-Ready): the figures 0–9 are drawn as these.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Digits {
|
||||
/// Contextual digits; composition selects the preceding strong script/language.
|
||||
#[default]
|
||||
Default,
|
||||
/// European (0123).
|
||||
Arabic,
|
||||
/// Arabic-Indic (٠١٢٣).
|
||||
Hindi,
|
||||
/// Extended Arabic-Indic, as used for Persian and Urdu (۰۱۲۳).
|
||||
Farsi,
|
||||
/// Those of the text's language (Thai, Devanagari, Bengali, Arabic, Persian…); else as typed.
|
||||
Native,
|
||||
}
|
||||
|
||||
impl Digits {
|
||||
/// The digit `c` (0–9) is drawn as, for text in `language`.
|
||||
pub fn map(self, c: char, language: &str) -> char {
|
||||
let d = match c {
|
||||
'0'..='9' => c as u32 - '0' as u32,
|
||||
'\u{0660}'..='\u{0669}' => c as u32 - 0x0660,
|
||||
'\u{06F0}'..='\u{06F9}' => c as u32 - 0x06F0,
|
||||
_ => return c,
|
||||
};
|
||||
let zero = match self {
|
||||
Digits::Default => return c,
|
||||
Digits::Arabic => 0x0030,
|
||||
Digits::Hindi => 0x0660,
|
||||
Digits::Farsi => 0x06F0,
|
||||
Digits::Native => {
|
||||
let l = language.to_ascii_lowercase();
|
||||
match () {
|
||||
_ if l.starts_with("arabic") || l == "ar" || l.starts_with("ar-") => 0x0660,
|
||||
_ if l.starts_with("persian")
|
||||
|| l.starts_with("farsi")
|
||||
|| l.starts_with("urdu")
|
||||
|| l == "fa"
|
||||
|| l.starts_with("fa-")
|
||||
|| l == "ur"
|
||||
|| l.starts_with("ur-") =>
|
||||
{
|
||||
0x06F0
|
||||
}
|
||||
_ if l.starts_with("hindi") || l.starts_with("marathi") || l.starts_with("nepali") || l.starts_with("sanskrit") => 0x0966,
|
||||
_ if l.starts_with("bengali") => 0x09E6,
|
||||
_ if l.starts_with("gujarati") => 0x0AE6,
|
||||
_ if l.starts_with("punjabi") => 0x0A66,
|
||||
_ if l.starts_with("tamil") => 0x0BE6,
|
||||
_ if l.starts_with("telugu") => 0x0C66,
|
||||
_ if l.starts_with("kannada") => 0x0CE6,
|
||||
_ if l.starts_with("malayalam") => 0x0D66,
|
||||
_ if l.starts_with("thai") => 0x0E50,
|
||||
_ if l.starts_with("lao") => 0x0ED0,
|
||||
_ if l.starts_with("tibetan") => 0x0F20,
|
||||
_ if l.starts_with("burmese") => 0x1040,
|
||||
_ if l.starts_with("khmer") => 0x17E0,
|
||||
_ => return c,
|
||||
}
|
||||
}
|
||||
};
|
||||
char::from_u32(zero + d).unwrap_or(c)
|
||||
}
|
||||
}
|
||||
|
||||
/// A tracked change on text.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ChangeMark {
|
||||
#[default]
|
||||
None,
|
||||
Inserted,
|
||||
Deleted,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind", content = "value")]
|
||||
pub enum Kerning {
|
||||
#[default]
|
||||
Metrics,
|
||||
Optical,
|
||||
/// Zero ("0" in the kerning menu).
|
||||
None,
|
||||
/// Manual pair kerning in 1/1000 em (applies after this character).
|
||||
Manual(f64),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Capitalization {
|
||||
#[default]
|
||||
Normal,
|
||||
AllCaps,
|
||||
SmallCaps,
|
||||
OpenTypeAllSmallCaps,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Position {
|
||||
#[default]
|
||||
Normal,
|
||||
Superscript,
|
||||
Subscript,
|
||||
OtSuperscript,
|
||||
OtSubscript,
|
||||
OtNumerator,
|
||||
OtDenominator,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Align {
|
||||
#[default]
|
||||
Left,
|
||||
Center,
|
||||
Right,
|
||||
LeftJustified,
|
||||
CenterJustified,
|
||||
RightJustified,
|
||||
FullyJustified,
|
||||
TowardsSpine,
|
||||
AwayFromSpine,
|
||||
}
|
||||
|
||||
impl Align {
|
||||
pub fn is_justified(self) -> bool {
|
||||
matches!(self, Align::LeftJustified | Align::CenterJustified | Align::RightJustified | Align::FullyJustified)
|
||||
}
|
||||
pub const ALL: [Align; 9] = [
|
||||
Align::Left,
|
||||
Align::Center,
|
||||
Align::Right,
|
||||
Align::LeftJustified,
|
||||
Align::CenterJustified,
|
||||
Align::RightJustified,
|
||||
Align::FullyJustified,
|
||||
Align::TowardsSpine,
|
||||
Align::AwayFromSpine,
|
||||
];
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Align::Left => "Align left",
|
||||
Align::Center => "Align center",
|
||||
Align::Right => "Align right",
|
||||
Align::LeftJustified => "Justify with last line aligned left",
|
||||
Align::CenterJustified => "Justify with last line aligned center",
|
||||
Align::RightJustified => "Justify with last line aligned right",
|
||||
Align::FullyJustified => "Justify all lines",
|
||||
Align::TowardsSpine => "Align towards spine",
|
||||
Align::AwayFromSpine => "Align away from spine",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Composer {
|
||||
/// Total-fit (Knuth–Plass) over the whole paragraph.
|
||||
#[default]
|
||||
Paragraph,
|
||||
/// Greedy, line by line.
|
||||
SingleLine,
|
||||
/// The Japanese paragraph composer: total fit, with the CJK leading model and character
|
||||
/// alignment.
|
||||
Japanese,
|
||||
/// The Japanese single-line composer.
|
||||
JapaneseSingleLine,
|
||||
}
|
||||
|
||||
impl Composer {
|
||||
/// Breaks lines greedily, one at a time.
|
||||
pub fn single_line(self) -> bool {
|
||||
matches!(self, Composer::SingleLine | Composer::JapaneseSingleLine)
|
||||
}
|
||||
|
||||
/// A Japanese composer: lines follow the leading model ([`crate::cjk::LeadingModel`]) and
|
||||
/// characters their [`crate::cjk::CharacterAlignment`]. The others set lines baseline to
|
||||
/// baseline with every character on the Roman baseline.
|
||||
pub fn japanese(self) -> bool {
|
||||
matches!(self, Composer::Japanese | Composer::JapaneseSingleLine)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a nested style runs to (Drop Caps and Nested Styles).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind", content = "chars")]
|
||||
pub enum NestedUntil {
|
||||
Sentences,
|
||||
#[default]
|
||||
Words,
|
||||
Characters,
|
||||
Letters,
|
||||
Digits,
|
||||
Tab,
|
||||
ForcedLineBreak,
|
||||
EmSpace,
|
||||
EnSpace,
|
||||
/// The paragraph's drop cap characters.
|
||||
Dropcap,
|
||||
/// Any of these characters.
|
||||
Chars(String),
|
||||
}
|
||||
|
||||
/// A character style applied from the start of the paragraph through (or up to) the `count`-th
|
||||
/// delimiter; nested styles follow each other.
|
||||
/// A nested line style: `style` for the next `lines` lines.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct NestedLineStyle {
|
||||
pub style: String,
|
||||
pub lines: u32,
|
||||
}
|
||||
|
||||
impl Default for NestedLineStyle {
|
||||
fn default() -> Self {
|
||||
NestedLineStyle { style: String::new(), lines: 1 }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct NestedStyle {
|
||||
pub style: String,
|
||||
/// Through (include the delimiter) or up to (stop before it).
|
||||
pub through: bool,
|
||||
pub count: u32,
|
||||
pub until: NestedUntil,
|
||||
}
|
||||
|
||||
impl Default for NestedStyle {
|
||||
fn default() -> Self {
|
||||
NestedStyle { style: String::new(), through: true, count: 1, until: NestedUntil::Words }
|
||||
}
|
||||
}
|
||||
|
||||
impl NestedStyle {
|
||||
/// The drop cap's character style as a nested style: `style` through 1 drop cap.
|
||||
pub fn drop_cap(style: &str) -> Self {
|
||||
NestedStyle { style: style.into(), through: true, count: 1, until: NestedUntil::Dropcap }
|
||||
}
|
||||
|
||||
/// Is this the form a drop cap's character style takes at the start of a nested style list?
|
||||
pub fn is_drop_cap(&self) -> bool {
|
||||
self.until == NestedUntil::Dropcap && self.through && self.count == 1
|
||||
}
|
||||
}
|
||||
|
||||
/// A character style applied to every match of a regular expression in the paragraph.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct GrepStyle {
|
||||
pub style: String,
|
||||
pub pattern: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum GridAlign {
|
||||
#[default]
|
||||
None,
|
||||
AllLines,
|
||||
FirstLineOnly,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum StartParagraph {
|
||||
#[default]
|
||||
Anywhere,
|
||||
NextColumn,
|
||||
NextFrame,
|
||||
NextPage,
|
||||
NextOddPage,
|
||||
NextEvenPage,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind", content = "columns")]
|
||||
pub enum SpanColumns {
|
||||
#[default]
|
||||
Single,
|
||||
/// Span `n` columns (0 = all).
|
||||
Span(u32),
|
||||
/// Split into `n` sub-columns.
|
||||
Split(u32),
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/// Does `c` belong to a script that capitalizes the first letter of sentences, words and headings?
|
||||
/// Georgian doesn't: Unicode maps Mkhedruli to Mtavruli capitals, but those are only for all-caps
|
||||
/// text, never for a sentence's, a title's or an index heading's first letter.
|
||||
pub fn capitalizes(c: char) -> bool {
|
||||
!matches!(c, '\u{10A0}'..='\u{10FF}' | '\u{1C90}'..='\u{1CBF}' | '\u{2D00}'..='\u{2D2F}')
|
||||
}
|
||||
|
||||
/// The BCP 47 tag of a language name as InDesign and IDML write it ("English: USA", "Chinese:
|
||||
/// Simplified", or a locale code such as "de_DE_2006"), for the shaper's localized forms (`locl`)
|
||||
/// and everything that depends on the language (quotes, hyphenation and spelling, line breaking,
|
||||
/// fallback fonts). A variant we don't know falls back to its language ("English: Australian" →
|
||||
/// `en`); an unknown language (or "[No Language]") has none. Case, punctuation and IDML's `$ID/`
|
||||
/// don't matter. Locale codes (`ll`, `ll_CC`, `ll-CC`, with a reform year after them as in
|
||||
/// `de_DE_2006`) are their tag (`de-DE`), and Chinese also goes by its other common spellings
|
||||
/// ("Simplified Chinese", "Chinese (Traditional)", "zh_CN", "zh-Hant").
|
||||
pub fn language_tag(language: &str) -> Option<&'static str> {
|
||||
let language = language.trim();
|
||||
let language = language.strip_prefix("$ID/").unwrap_or(language);
|
||||
let l = language.to_lowercase();
|
||||
let words: Vec<&str> = l.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).collect();
|
||||
let first = words.first().copied().unwrap_or("");
|
||||
let has = |any: &[&str]| words.iter().any(|w| any.contains(w));
|
||||
if first == "zh" || has(&["chinese"]) {
|
||||
// The script decides; then the name; then the region.
|
||||
return Some(if has(&["hans"]) {
|
||||
"zh-Hans"
|
||||
} else if has(&["hant"]) {
|
||||
"zh-Hant"
|
||||
} else if has(&["simplified", "cn", "sg"]) {
|
||||
"zh-Hans"
|
||||
} else if has(&["traditional", "tw", "hk", "mo"]) {
|
||||
"zh-Hant"
|
||||
} else {
|
||||
"zh"
|
||||
});
|
||||
}
|
||||
if let Some(tag) = locale_code(&words) {
|
||||
return intern_tag(tag);
|
||||
}
|
||||
let variant = match words.join(" ").as_str() {
|
||||
"english usa" => Some("en-US"),
|
||||
"english uk" => Some("en-GB"),
|
||||
"english canadian" => Some("en-CA"),
|
||||
"german swiss 2006 reform" | "german swiss" => Some("de-CH"),
|
||||
"french swiss" => Some("fr-CH"),
|
||||
"italian swiss" => Some("it-CH"),
|
||||
"spanish castilian" => Some("es-ES"),
|
||||
"french canadian" => Some("fr-CA"),
|
||||
"portuguese brazilian" => Some("pt-BR"),
|
||||
"norwegian bokmål" | "norwegian bokmal" => Some("nb"),
|
||||
"norwegian nynorsk" => Some("nn"),
|
||||
_ => None,
|
||||
};
|
||||
if variant.is_some() {
|
||||
return variant;
|
||||
}
|
||||
Some(match first {
|
||||
"english" => "en",
|
||||
"german" => "de",
|
||||
"french" => "fr",
|
||||
"spanish" => "es",
|
||||
"catalan" => "ca",
|
||||
"italian" => "it",
|
||||
"portuguese" => "pt",
|
||||
"dutch" => "nl",
|
||||
"danish" => "da",
|
||||
"swedish" => "sv",
|
||||
"norwegian" => "no",
|
||||
"finnish" => "fi",
|
||||
"polish" => "pl",
|
||||
"czech" => "cs",
|
||||
"slovak" => "sk",
|
||||
"hungarian" => "hu",
|
||||
"romanian" => "ro",
|
||||
"croatian" => "hr",
|
||||
"lithuanian" => "lt",
|
||||
"greek" => "el",
|
||||
"russian" => "ru",
|
||||
"ukrainian" => "uk",
|
||||
"bulgarian" => "bg",
|
||||
"serbian" => "sr",
|
||||
"macedonian" => "mk",
|
||||
"turkish" => "tr",
|
||||
"japanese" => "ja",
|
||||
"korean" => "ko",
|
||||
"arabic" => "ar",
|
||||
"persian" | "farsi" => "fa",
|
||||
"urdu" => "ur",
|
||||
"hebrew" => "he",
|
||||
"georgian" => "ka",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The tag of a locale code split into lowercase `words`: a language (2 or 3 letters), then
|
||||
/// optionally a region (2 letters or 3 digits) and a reform year (4 digits) → `ll` or `ll-CC`.
|
||||
fn locale_code(words: &[&str]) -> Option<String> {
|
||||
let letters = |w: &str, n: std::ops::RangeInclusive<usize>| n.contains(&w.len()) && w.bytes().all(|b| b.is_ascii_lowercase());
|
||||
let digits = |w: &str, n: usize| w.len() == n && w.bytes().all(|b| b.is_ascii_digit());
|
||||
match *words {
|
||||
[lang] if letters(lang, 2..=3) => Some(lang.to_string()),
|
||||
[lang, region] | [lang, region, _] if letters(lang, 2..=3) && (letters(region, 2..=2) || digits(region, 3)) => {
|
||||
if let [_, _, year] = *words
|
||||
&& !digits(year, 4)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(format!("{lang}-{}", region.to_ascii_uppercase()))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `tag` as a `&'static str`: each distinct tag is kept once for the process. Language names come
|
||||
/// from documents, so at most [`MAX_TAGS`] are kept; past that a new tag is unknown (`None`).
|
||||
fn intern_tag(tag: String) -> Option<&'static str> {
|
||||
const MAX_TAGS: usize = 512;
|
||||
static TAGS: std::sync::Mutex<Vec<&str>> = std::sync::Mutex::new(Vec::new());
|
||||
let mut tags = TAGS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some(t) = tags.iter().find(|t| **t == tag) {
|
||||
return Some(t);
|
||||
}
|
||||
if tags.len() >= MAX_TAGS {
|
||||
return None;
|
||||
}
|
||||
let t: &'static str = Box::leak(tag.into_boxed_str());
|
||||
tags.push(t);
|
||||
Some(t)
|
||||
}
|
||||
|
||||
/// The language subtag of a BCP 47 tag (`de-DE` → `de`, `zh-Hant` → `zh`).
|
||||
pub fn language_subtag(tag: &str) -> &str {
|
||||
tag.split('-').next().unwrap_or(tag)
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
use super::{
|
||||
Align, BoxBottom, BoxTop, BoxWidth, Composer, Deserialize, GrepStyle, GridAlign, ListAlign, ListCounter, ListType, MAX_TAB_STOPS,
|
||||
NestedLineStyle, NestedStyle, NumberStyle, Rule, Serialize, SpanColumns, StartParagraph, TabStop, TextDirection, attr_key,
|
||||
deserialize_explicit_value, expand_list_levels, expand_list_text, list_level_index, sanitized_tabs, unknown_attribute,
|
||||
};
|
||||
|
||||
attr_set! {
|
||||
/// Paragraph attributes (Paragraph panel / paragraph styles).
|
||||
ParaAttrs / ParaProps {
|
||||
align: Align = Align::Left,
|
||||
/// Paragraph Direction (World-Ready): the base direction lines are ordered in.
|
||||
direction: TextDirection = TextDirection::LeftToRight,
|
||||
left_indent: f64 = 0.0,
|
||||
right_indent: f64 = 0.0,
|
||||
first_line_indent: f64 = 0.0,
|
||||
last_line_indent: f64 = 0.0,
|
||||
space_before: f64 = 0.0,
|
||||
space_after: f64 = 0.0,
|
||||
/// Space between consecutive paragraphs of the same style; replaces space after/before there (None = ignore).
|
||||
same_para_style_spacing: Option<f64> = None => "deserialize_explicit_value",
|
||||
/// Drop cap: the paragraph's first `drop_cap_chars` characters (code points, never
|
||||
/// splitting a base from its combining marks) set `drop_cap_lines` lines tall, in the
|
||||
/// character style `drop_cap_style` ("" or `[None]`: the text's own formatting).
|
||||
drop_cap_lines: u32 = 0,
|
||||
drop_cap_chars: u32 = 0,
|
||||
drop_cap_style: String = String::new(),
|
||||
/// Align Left Edge: the drop cap's ink starts at the indent (its first letter's left side
|
||||
/// bearing is taken off), and the lines beside it start where its advance ends.
|
||||
drop_cap_align_left: bool = true,
|
||||
/// Scale for Descenders (kept and round-tripped; doesn't change the layout).
|
||||
drop_cap_scale_descenders: bool = false,
|
||||
/// Nested styles, in order from the paragraph start.
|
||||
nested_styles: Vec<NestedStyle> = Vec::new(),
|
||||
/// GREP styles: a character style for every match of a pattern (applied after nested).
|
||||
grep_styles: Vec<GrepStyle> = Vec::new(),
|
||||
/// Nested line styles: a character style for the paragraph's first lines, in order.
|
||||
nested_line_styles: Vec<NestedLineStyle> = Vec::new(),
|
||||
grid_align: GridAlign = GridAlign::None,
|
||||
composer: Composer = Composer::Paragraph,
|
||||
// Hyphenation
|
||||
hyphenate: bool = true,
|
||||
hyph_min_word: u32 = 5,
|
||||
hyph_after_first: u32 = 2,
|
||||
hyph_before_last: u32 = 2,
|
||||
/// Max consecutive hyphenated lines (0 = unlimited).
|
||||
hyph_limit: u32 = 3,
|
||||
hyph_zone: f64 = 36.0,
|
||||
hyph_capitalized: bool = true,
|
||||
hyph_last_word: bool = true,
|
||||
hyph_across_column: bool = true,
|
||||
/// Better spacing (0) ↔ fewer hyphens (1).
|
||||
hyph_weight: f64 = 0.5,
|
||||
// Justification (percent as fractions)
|
||||
word_space_min: f64 = 0.8,
|
||||
word_space_desired: f64 = 1.0,
|
||||
word_space_max: f64 = 1.33,
|
||||
letter_space_min: f64 = 0.0,
|
||||
letter_space_desired: f64 = 0.0,
|
||||
letter_space_max: f64 = 0.0,
|
||||
glyph_scale_min: f64 = 1.0,
|
||||
glyph_scale_desired: f64 = 1.0,
|
||||
glyph_scale_max: f64 = 1.0,
|
||||
auto_leading: f64 = 1.2,
|
||||
single_word_justify: Align = Align::FullyJustified,
|
||||
/// Insert Kashidas (World-Ready): justified Arabic lines stretch at joins before spaces.
|
||||
kashidas: bool = true,
|
||||
/// Preserved vendor policy; non-default policies require a dedicated composer.
|
||||
arabic_justification: String = String::new(),
|
||||
paragraph_kashida_width: Option<f64> = None => "deserialize_explicit_value",
|
||||
/// Korean text breaks between any two syllables (character-based breaking); otherwise
|
||||
/// it breaks at spaces, word by word.
|
||||
korean_char_breaks: bool = false,
|
||||
// Keeps
|
||||
keep_with_next: u32 = 0,
|
||||
keep_lines_together: bool = false,
|
||||
/// All lines in paragraph (true) or at start/end of paragraph (false).
|
||||
keep_all_lines: bool = false,
|
||||
keep_first: u32 = 2,
|
||||
keep_last: u32 = 2,
|
||||
start_paragraph: StartParagraph = StartParagraph::Anywhere,
|
||||
span_columns: SpanColumns = SpanColumns::Single,
|
||||
/// Split Columns: the space between sub-columns and the inset from the column's edges.
|
||||
split_inside_gutter: f64 = 6.0,
|
||||
split_outside_gutter: f64 = 0.0,
|
||||
rule_above: Rule = Rule::default(),
|
||||
rule_below: Rule = Rule::default(),
|
||||
#[serde(deserialize_with = "sanitized_tabs")]
|
||||
tabs: Vec<TabStop> = Vec::new(),
|
||||
list_type: ListType = ListType::None,
|
||||
/// Named list (Define Lists) the numbers belong to ("" = the story's own list).
|
||||
list_name: String = String::new(),
|
||||
/// Start At: restart the numbering at this number.
|
||||
start_at: Option<u32> = None => "deserialize_explicit_value",
|
||||
bullet_char: String = "\u{2022}".into(),
|
||||
number_style: NumberStyle = NumberStyle::Arabic,
|
||||
/// A numbered paragraph's label: `^#` is the number, other metacharacters expand as in
|
||||
/// [`expand_list_text`].
|
||||
number_expression: String = "^#.^t".into(),
|
||||
/// Text after the bullet (metacharacters expand as in [`expand_list_text`]).
|
||||
list_separator: String = "\t".into(),
|
||||
/// The hanging indent was set by the Bulleted or Numbered List button, which takes it away
|
||||
/// again with the list (an indent the paragraph had before stays).
|
||||
list_hang: bool = false,
|
||||
balance_ragged: bool = false,
|
||||
/// Hang punctuation outside the measure in this paragraph, at each character's own size
|
||||
/// (DesignCraft's own per-paragraph switch; a story with Optical Margin Alignment on
|
||||
/// overrides it with the story's size).
|
||||
optical_margin: bool = false,
|
||||
/// Ignore Optical Margin: no hang in this paragraph, whatever the story or
|
||||
/// `optical_margin` say.
|
||||
ignore_optical_margin: bool = false,
|
||||
/// None uses the legacy built-in rules; an empty set explicitly disables kinsoku.
|
||||
kinsoku: Option<crate::cjk::Kinsoku> = None => "deserialize_explicit_value",
|
||||
/// Preserved spacing-table reference; compatibility audit reports unsupported composition.
|
||||
mojikumi: String = String::new(),
|
||||
/// Preserved priority policy; the current breaker does not implement these priorities.
|
||||
kinsoku_type: String = String::new(),
|
||||
kinsoku_hang: crate::cjk::KinsokuHang = crate::cjk::KinsokuHang::None,
|
||||
bunri_kinshi: bool = false,
|
||||
rensuuji: bool = true,
|
||||
treat_ideographic_space_as_space: bool = false,
|
||||
// Auto Tate-chu-yoko, Japanese Composition and Grid Settings.
|
||||
/// Auto tate-chu-yoko: in vertical text, runs of up to this many half-width digits are set
|
||||
/// across one em (0 = off).
|
||||
auto_tcy: u32 = 0,
|
||||
/// Auto tate-chu-yoko also takes runs of half-width roman letters.
|
||||
auto_tcy_include_roman: bool = false,
|
||||
/// Rotate Roman Characters in Vertical Text (縦組み中の欧文回転): half-width characters
|
||||
/// stand upright one by one instead of lying along the line.
|
||||
rotate_roman: bool = false,
|
||||
/// Roman word break (欧文泣き別れ): roman words may break between any two letters,
|
||||
/// without a hyphen.
|
||||
roman_word_break: bool = false,
|
||||
/// The point of a line that sits on the grid while `grid_align` is on.
|
||||
grid_reference: crate::cjk::CharacterAlignment = crate::cjk::CharacterAlignment::Baseline,
|
||||
/// Gyoudori (行取り): grid lines each line takes (0 = as many as its leading needs).
|
||||
grid_gyoudori: u32 = 0,
|
||||
/// Paragraph gyoudori: the paragraph as a whole, not each line, takes `grid_gyoudori` lines.
|
||||
paragraph_gyoudori: bool = false,
|
||||
/// Paragraph shading, and how far it reaches past the text: top, left, bottom, right.
|
||||
shading_on: bool = false,
|
||||
shading_color: String = "[Black]".into(),
|
||||
shading_tint: f32 = 0.2,
|
||||
shading_offsets: [f64; 4] = [0.0; 4],
|
||||
/// Paragraph border: stroke weights (top, left, bottom, right), colour, and offsets past the
|
||||
/// text (top, left, bottom, right).
|
||||
border_on: bool = false,
|
||||
border_weights: [f64; 4] = [1.0; 4],
|
||||
border_color: String = "[Black]".into(),
|
||||
border_tint: f32 = 1.0,
|
||||
border_offsets: [f64; 4] = [0.0; 4],
|
||||
/// The border's stroke type (a document stroke style by name, or a built-in type), cap and
|
||||
/// join.
|
||||
border_type: crate::item::StrokeType = crate::item::StrokeType::Solid,
|
||||
border_cap: crate::item::Cap = crate::item::Cap::Butt,
|
||||
border_join: crate::item::Join = crate::item::Join::Miter,
|
||||
/// Corner shape and size: top left, top right, bottom right, bottom left.
|
||||
border_corners: [designcraft_geom::corners::Corner; 4] = [designcraft_geom::corners::Corner::default(); 4],
|
||||
border_width: BoxWidth = BoxWidth::Column,
|
||||
border_top: BoxTop = BoxTop::Ascent,
|
||||
border_bottom: BoxBottom = BoxBottom::Descent,
|
||||
/// A paragraph split across frames or columns gets a closed box in each part (otherwise the
|
||||
/// box is open where it splits).
|
||||
border_display_if_splits: bool = false,
|
||||
/// Consecutive paragraphs with the same border and shading share one box.
|
||||
border_merge: bool = true,
|
||||
/// The colour between the dashes, dots or stripes of a border that isn't solid.
|
||||
border_gap_color: String = "[None]".into(),
|
||||
border_gap_tint: f32 = 1.0,
|
||||
/// Shading corners: top left, top right, bottom right, bottom left.
|
||||
shading_corners: [designcraft_geom::corners::Corner; 4] = [designcraft_geom::corners::Corner::default(); 4],
|
||||
shading_width: BoxWidth = BoxWidth::Column,
|
||||
shading_top: BoxTop = BoxTop::Ascent,
|
||||
shading_bottom: BoxBottom = BoxBottom::Descent,
|
||||
/// The shading stops at the edges of the text frame.
|
||||
shading_clip: bool = false,
|
||||
/// The shading shows on screen only: not printed or exported.
|
||||
shading_nonprinting: bool = false,
|
||||
/// Least space between a spanning or split paragraph and the column text above and below it.
|
||||
span_space_before: f64 = 0.0,
|
||||
span_space_after: f64 = 0.0,
|
||||
/// List level (1–9): each level numbers on its own, and `^1`…`^9` in a number's text are the
|
||||
/// current numbers of those levels.
|
||||
list_level: u32 = 1,
|
||||
/// Character styles of the bullet and of the number.
|
||||
bullet_char_style: String = crate::story::NO_CHAR_STYLE.into(),
|
||||
number_char_style: String = crate::story::NO_CHAR_STYLE.into(),
|
||||
bullet_align: ListAlign = ListAlign::Left,
|
||||
number_align: ListAlign = ListAlign::Left,
|
||||
/// A tab stop for the text after the bullet or number, from the column's left edge.
|
||||
list_tab: Option<f64> = None,
|
||||
/// Restart Numbers at This Level After: a paragraph at a higher level (0 = any; otherwise
|
||||
/// that level) starts this level's numbering again.
|
||||
restart_numbers: bool = true,
|
||||
restart_after_level: u32 = 0,
|
||||
/// The bullet's font family and style ("" = the paragraph's).
|
||||
bullet_font: String = String::new(),
|
||||
bullet_font_style: String = String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
impl ParaAttrs {
|
||||
/// [`set_json`](Self::set_json), except that a `ruleAbove` / `ruleBelow` object names only the
|
||||
/// rule fields to change; the others keep their values in `current` (the resolved attributes
|
||||
/// of the paragraph or style being edited). A `tabs` list is checked and sorted as
|
||||
/// [`TabStop::checked_list`] does.
|
||||
pub fn set_json_over(&mut self, key: &str, value: &serde_json::Value, current: &ParaProps) -> Result<(), String> {
|
||||
let rule = match key.chars().filter(|c| *c != '_').collect::<String>().to_ascii_lowercase().as_str() {
|
||||
"ruleabove" => ¤t.rule_above,
|
||||
"rulebelow" => ¤t.rule_below,
|
||||
"tabs" if !value.is_null() => {
|
||||
// Count before deserializing: the list may be arbitrarily long.
|
||||
if value.as_array().is_some_and(|a| a.len() > MAX_TAB_STOPS) {
|
||||
return Err(format!("{key}: a paragraph holds at most {MAX_TAB_STOPS} tab stops"));
|
||||
}
|
||||
let tabs: Vec<TabStop> = serde_json::from_value(value.clone()).map_err(|e| format!("{key}: {e}"))?;
|
||||
self.tabs = Some(TabStop::checked_list(tabs).map_err(|e| format!("{key}: {e}"))?);
|
||||
return Ok(());
|
||||
}
|
||||
_ => return self.set_json(key, value),
|
||||
};
|
||||
let serde_json::Value::Object(patch) = value else { return self.set_json(key, value) };
|
||||
let mut merged = serde_json::to_value(rule).map_err(|e| format!("{key}: {e}"))?;
|
||||
let serde_json::Value::Object(fields) = &mut merged else { return Err(format!("{key}: not an object")) };
|
||||
for (k, v) in patch {
|
||||
if !fields.contains_key(k) {
|
||||
return Err(format!("{key}: unknown rule field `{k}`"));
|
||||
}
|
||||
fields.insert(k.clone(), v.clone());
|
||||
}
|
||||
self.set_json(key, &merged)
|
||||
}
|
||||
}
|
||||
|
||||
impl ParaProps {
|
||||
/// The label of a numbered paragraph with number `n`.
|
||||
pub fn number_label(&self, n: u32) -> String {
|
||||
expand_list_text(&self.number_expression, &self.number_style.format(n))
|
||||
}
|
||||
|
||||
/// The label of a numbered paragraph that `list` has just numbered ([`ListCounter::advance`]).
|
||||
pub fn number_label_in(&self, list: &ListCounter) -> String {
|
||||
let level = list_level_index(self.list_level);
|
||||
let number = list.labels.get(level).map_or("", String::as_str);
|
||||
expand_list_levels(&self.number_expression, number, &list.labels)
|
||||
}
|
||||
|
||||
/// The label of a bulleted paragraph.
|
||||
pub fn bullet_label(&self) -> String {
|
||||
let mut s: String = self.bullet_char.chars().filter(|c| *c != '\n' && *c != '\r').collect();
|
||||
s.push_str(&expand_list_text(&self.list_separator, ""));
|
||||
s
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
use super::{Deserialize, ParaProps, Serialize};
|
||||
|
||||
/// The colour of a paragraph rule that follows the paragraph's text colour.
|
||||
pub const TEXT_COLOR: &str = "Text Color";
|
||||
|
||||
/// A paragraph rule (Rule Above / Rule Below).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Rule {
|
||||
pub on: bool,
|
||||
pub weight: f64,
|
||||
/// A swatch, or [`TEXT_COLOR`].
|
||||
pub color: String,
|
||||
pub tint: f32,
|
||||
/// Width: column (true) or text (false).
|
||||
pub column_width: bool,
|
||||
pub offset: f64,
|
||||
pub left_indent: f64,
|
||||
pub right_indent: f64,
|
||||
}
|
||||
|
||||
impl Default for Rule {
|
||||
fn default() -> Self {
|
||||
Rule { on: false, weight: 1.0, color: TEXT_COLOR.into(), tint: 1.0, column_width: true, offset: 0.0, left_indent: 0.0, right_indent: 0.0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// How wide a paragraph border or shading box is: the column, or the text's longest line.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum BoxWidth {
|
||||
#[default]
|
||||
Column,
|
||||
Text,
|
||||
}
|
||||
|
||||
/// Where a paragraph border or shading box starts above the first line (before its top offset).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum BoxTop {
|
||||
/// The tallest ascent on the line.
|
||||
#[default]
|
||||
Ascent,
|
||||
Baseline,
|
||||
/// The top of the em box.
|
||||
EmBox,
|
||||
/// The top of the line's leading.
|
||||
Leading,
|
||||
}
|
||||
|
||||
/// Where a paragraph border or shading box ends below the last line (before its bottom offset).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum BoxBottom {
|
||||
/// The deepest descent on the line.
|
||||
#[default]
|
||||
Descent,
|
||||
Baseline,
|
||||
/// The bottom of the em box.
|
||||
EmBox,
|
||||
}
|
||||
|
||||
/// Where a bullet or number sits against the first line's start (the left and first-line
|
||||
/// indents): it starts there, is centred on it, or ends there.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ListAlign {
|
||||
#[default]
|
||||
Left,
|
||||
Center,
|
||||
Right,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ListType {
|
||||
#[default]
|
||||
None,
|
||||
Bullets,
|
||||
Numbers,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum NumberStyle {
|
||||
#[default]
|
||||
Arabic,
|
||||
UpperRoman,
|
||||
LowerRoman,
|
||||
UpperLetters,
|
||||
LowerLetters,
|
||||
/// 01, 02, …
|
||||
ArabicLeadingZero,
|
||||
/// 001, 002, …
|
||||
ArabicThreeDigits,
|
||||
/// 0001, 0002, …
|
||||
ArabicFourDigits,
|
||||
/// *, †, ‡, §, ¶, ‖, then doubled (**, ††, …): footnotes.
|
||||
Symbols,
|
||||
/// 一, 二, 三, …: Chinese numerals written digit by digit, so 10 is 一〇 and 38 is 三八.
|
||||
Kanji,
|
||||
}
|
||||
|
||||
/// Numbers past this are written in Arabic digits by the styles that repeat characters (roman
|
||||
/// numerals, letters, symbols), so a hostile start number can't build a huge label.
|
||||
const MAX_REPEATED_FORM: u32 = 3999;
|
||||
|
||||
impl NumberStyle {
|
||||
pub fn format(self, n: u32) -> String {
|
||||
if n > MAX_REPEATED_FORM
|
||||
&& matches!(
|
||||
self,
|
||||
NumberStyle::UpperRoman | NumberStyle::LowerRoman | NumberStyle::UpperLetters | NumberStyle::LowerLetters | NumberStyle::Symbols
|
||||
)
|
||||
{
|
||||
return n.to_string();
|
||||
}
|
||||
match self {
|
||||
NumberStyle::Arabic => n.to_string(),
|
||||
NumberStyle::ArabicLeadingZero => format!("{n:02}"),
|
||||
NumberStyle::ArabicThreeDigits => format!("{n:03}"),
|
||||
NumberStyle::ArabicFourDigits => format!("{n:04}"),
|
||||
NumberStyle::UpperRoman => roman(n),
|
||||
NumberStyle::LowerRoman => roman(n).to_lowercase(),
|
||||
NumberStyle::UpperLetters => letters(n),
|
||||
NumberStyle::LowerLetters => letters(n).to_lowercase(),
|
||||
NumberStyle::Symbols => {
|
||||
const SYM: [char; 6] = ['*', '†', '‡', '§', '¶', '‖'];
|
||||
let n = n.max(1) as usize - 1;
|
||||
std::iter::repeat_n(SYM[n % SYM.len()], n / SYM.len() + 1).collect()
|
||||
}
|
||||
NumberStyle::Kanji => kanji_digits(n),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Expand the metacharacters of a list's label text (`^t` tab, `^>` en space, `^^` caret, …),
|
||||
/// putting `number` in for `^#`. Level placeholders (`^1`…`^9`) and metacharacters with no
|
||||
/// meaning in a label are dropped; a label never holds a paragraph break.
|
||||
pub fn expand_list_text(s: &str, number: &str) -> String {
|
||||
expand_list_levels(s, number, &[])
|
||||
}
|
||||
|
||||
/// [`expand_list_text`] with `levels[k]` in for `^1`…`^9` (level k + 1's current number; a level
|
||||
/// past the end is dropped).
|
||||
pub fn expand_list_levels(s: &str, number: &str, levels: &[String]) -> String {
|
||||
let mut out = String::with_capacity(s.len() + number.len());
|
||||
let mut it = s.chars();
|
||||
while let Some(c) = it.next() {
|
||||
if c != '^' {
|
||||
if c != '\n' && c != '\r' {
|
||||
out.push(c);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let Some(m) = it.next() else {
|
||||
out.push('^');
|
||||
break;
|
||||
};
|
||||
let r = match m {
|
||||
'#' => {
|
||||
out.push_str(number);
|
||||
continue;
|
||||
}
|
||||
d @ '1'..='9' => {
|
||||
let k = d as usize - '1' as usize;
|
||||
if let Some(l) = levels.get(k) {
|
||||
out.push_str(l);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
'^' => '^',
|
||||
't' => '\t',
|
||||
'y' => crate::story::RIGHT_INDENT_TAB,
|
||||
'i' => crate::story::INDENT_HERE,
|
||||
'n' => crate::story::FORCED_LINE_BREAK,
|
||||
'm' => '\u{2003}',
|
||||
'>' => '\u{2002}',
|
||||
'f' => '\u{2001}',
|
||||
'|' => '\u{200A}',
|
||||
'<' => '\u{2009}',
|
||||
'/' => '\u{2007}',
|
||||
'.' => '\u{2008}',
|
||||
's' => '\u{A0}',
|
||||
'S' => '\u{202F}',
|
||||
'_' => '\u{2014}',
|
||||
'=' => '\u{2013}',
|
||||
'~' => '\u{2011}',
|
||||
'{' => '\u{201C}',
|
||||
'}' => '\u{201D}',
|
||||
'[' => '\u{2018}',
|
||||
']' => '\u{2019}',
|
||||
'"' => '"',
|
||||
'\'' => '\'',
|
||||
_ => continue,
|
||||
};
|
||||
out.push(r);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn kanji_digits(n: u32) -> String {
|
||||
const DIGITS: [char; 10] = ['〇', '一', '二', '三', '四', '五', '六', '七', '八', '九'];
|
||||
n.to_string().chars().map(|c| c.to_digit(10).and_then(|d| DIGITS.get(d as usize)).copied().unwrap_or(c)).collect()
|
||||
}
|
||||
|
||||
fn roman(mut n: u32) -> String {
|
||||
if n == 0 {
|
||||
return "0".into();
|
||||
}
|
||||
let t = [
|
||||
(1000, "M"),
|
||||
(900, "CM"),
|
||||
(500, "D"),
|
||||
(400, "CD"),
|
||||
(100, "C"),
|
||||
(90, "XC"),
|
||||
(50, "L"),
|
||||
(40, "XL"),
|
||||
(10, "X"),
|
||||
(9, "IX"),
|
||||
(5, "V"),
|
||||
(4, "IV"),
|
||||
(1, "I"),
|
||||
];
|
||||
let mut s = String::new();
|
||||
for (v, r) in t {
|
||||
while n >= v {
|
||||
s.push_str(r);
|
||||
n -= v;
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// A, B, …, Z, AA, BB, … (InDesign's letter numbering repeats the letter).
|
||||
fn letters(n: u32) -> String {
|
||||
if n == 0 {
|
||||
return String::new();
|
||||
}
|
||||
let i = (n - 1) % 26;
|
||||
let reps = (n - 1) / 26 + 1;
|
||||
std::iter::repeat_n(char::from(b'A' + i as u8), reps as usize).collect()
|
||||
}
|
||||
|
||||
/// Levels a list can have.
|
||||
pub const LIST_LEVELS: usize = 9;
|
||||
|
||||
/// Index (0-based) of list level `level` (1-based, clamped to 1–[`LIST_LEVELS`]).
|
||||
pub(super) fn list_level_index(level: u32) -> usize {
|
||||
(level.clamp(1, LIST_LEVELS as u32) - 1) as usize
|
||||
}
|
||||
|
||||
/// The numbering of one list as it goes: each level's last number and its text, and when each
|
||||
/// level last numbered a paragraph (for Restart Numbers at This Level After).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
|
||||
pub struct ListCounter {
|
||||
numbers: [u32; LIST_LEVELS],
|
||||
pub(super) labels: [String; LIST_LEVELS],
|
||||
seen: [u64; LIST_LEVELS],
|
||||
tick: u64,
|
||||
}
|
||||
|
||||
impl ListCounter {
|
||||
/// Number the next paragraph of `pp` (a numbered paragraph with text) and return its number.
|
||||
pub fn advance(&mut self, pp: &ParaProps) -> u32 {
|
||||
let l = list_level_index(pp.list_level);
|
||||
self.tick = self.tick.saturating_add(1);
|
||||
let since = self.seen[l];
|
||||
let restart = pp.restart_numbers
|
||||
&& match pp.restart_after_level {
|
||||
0 => self.seen[..l].iter().any(|&t| t > since),
|
||||
m => (m as usize) <= l && self.seen.get(m as usize - 1).is_some_and(|&t| t > since),
|
||||
};
|
||||
let n = match pp.start_at {
|
||||
Some(s) => s.max(1),
|
||||
None if restart => 1,
|
||||
None => self.numbers[l].saturating_add(1),
|
||||
};
|
||||
self.numbers[l] = n;
|
||||
self.labels[l] = pp.number_style.format(n);
|
||||
self.seen[l] = self.tick;
|
||||
n
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
use super::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TabAlign {
|
||||
#[default]
|
||||
Left,
|
||||
Center,
|
||||
Right,
|
||||
/// Align on a character (default `.`).
|
||||
Char,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TabStop {
|
||||
/// A file that held a non-finite position (written as `null`) reads it back as NaN, which
|
||||
/// [`TabStop::sanitized_list`] drops.
|
||||
#[serde(deserialize_with = "position_or_nan")]
|
||||
pub position: f64,
|
||||
#[serde(default)]
|
||||
pub align: TabAlign,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub leader: String,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub align_on: String,
|
||||
}
|
||||
|
||||
/// Most stops a paragraph holds.
|
||||
pub const MAX_TAB_STOPS: usize = 100;
|
||||
|
||||
/// Furthest stop position: 216 in, the largest page.
|
||||
pub const MAX_TAB_POSITION: f64 = 15_552.0;
|
||||
|
||||
/// Longest leader, in characters.
|
||||
pub const MAX_TAB_LEADER: usize = 8;
|
||||
|
||||
/// Stops closer than this share a position.
|
||||
pub const SAME_TAB_POSITION: f64 = 0.01;
|
||||
|
||||
/// Smallest type size the engine accepts, in points.
|
||||
pub const MIN_TYPE_SIZE: f64 = 0.1;
|
||||
|
||||
/// Largest type size the engine accepts, in points, as InDesign's own field allows.
|
||||
pub const MAX_TYPE_SIZE: f64 = 1296.0;
|
||||
|
||||
/// Largest measure an imported indent, spacing or offset can carry, in points: the furthest tab
|
||||
/// stop, which is the widest page.
|
||||
pub const MAX_MEASURE: f64 = MAX_TAB_POSITION;
|
||||
|
||||
/// A type size read from a file, or `None` when it is not a size the engine can mean.
|
||||
///
|
||||
/// A file can hold any number, including `NaN`, `inf`, `0`, a negative or `1e300`. Every importer
|
||||
/// had its own spelling of this test, or only checked that the value was finite -- so tagged text
|
||||
/// took `<cSize:1e300>` and set a 1e300 pt size on the text (#1593). A size outside the range is
|
||||
/// dropped rather than clamped, so the text keeps the size its style gives it.
|
||||
pub fn imported_type_size(pt: f64) -> Option<f64> {
|
||||
(pt.is_finite() && (MIN_TYPE_SIZE..=MAX_TYPE_SIZE).contains(&pt)).then_some(pt)
|
||||
}
|
||||
|
||||
/// An indent, spacing or offset read from a file, clamped to ±[`MAX_MEASURE`]; `None` when the
|
||||
/// value is not finite.
|
||||
///
|
||||
/// Unlike a type size this is clamped rather than dropped: a file that says "indent everything"
|
||||
/// still indents to the widest page the engine has, where dropping the value would quietly
|
||||
/// restore the style's own indent and hide what the file asked for (#1593).
|
||||
pub fn imported_measure(pt: f64) -> Option<f64> {
|
||||
pt.is_finite().then(|| pt.clamp(-MAX_MEASURE, MAX_MEASURE))
|
||||
}
|
||||
|
||||
impl TabStop {
|
||||
/// A finite position clamped to `0..=MAX_TAB_POSITION`.
|
||||
pub fn clamp_position(v: f64) -> Result<f64, String> {
|
||||
if v.is_finite() { Ok(v.clamp(0.0, MAX_TAB_POSITION)) } else { Err("position must be a finite number".into()) }
|
||||
}
|
||||
|
||||
/// A leader: up to [`MAX_TAB_LEADER`] characters, no control characters.
|
||||
pub fn check_leader(v: &str) -> Result<(), String> {
|
||||
if v.chars().count() > MAX_TAB_LEADER || v.chars().any(char::is_control) {
|
||||
return Err(format!("`leader`: at most {MAX_TAB_LEADER} printable characters"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// An align-on character: one printable character or none (a decimal point).
|
||||
pub fn check_align_on(v: &str) -> Result<(), String> {
|
||||
if v.chars().count() > 1 || v.chars().any(char::is_control) {
|
||||
return Err("`alignOn`: one printable character".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A tab-stop list as the Tabs commands keep it: at most [`MAX_TAB_STOPS`] stops, positions
|
||||
/// clamped, leaders and align-on characters checked, a char stop aligning on `.` unless it
|
||||
/// names a character, sorted by position, and one stop per position (the later one wins).
|
||||
pub fn checked_list(mut tabs: Vec<TabStop>) -> Result<Vec<TabStop>, String> {
|
||||
if tabs.len() > MAX_TAB_STOPS {
|
||||
return Err(format!("a paragraph holds at most {MAX_TAB_STOPS} tab stops"));
|
||||
}
|
||||
for t in &mut tabs {
|
||||
t.position = Self::clamp_position(t.position)?;
|
||||
Self::check_leader(&t.leader)?;
|
||||
Self::check_align_on(&t.align_on)?;
|
||||
}
|
||||
Ok(Self::normalized(tabs))
|
||||
}
|
||||
|
||||
/// A tab-stop list read from a file, repaired to what [`checked_list`](Self::checked_list)
|
||||
/// accepts: stops with a non-finite position dropped, positions clamped, control characters
|
||||
/// removed from leaders and align-on characters and both cut to their limits, sorted, one stop
|
||||
/// per position (the later one wins), and the first [`MAX_TAB_STOPS`] by position kept.
|
||||
pub fn sanitized_list(tabs: Vec<TabStop>) -> Vec<TabStop> {
|
||||
let tabs = tabs
|
||||
.into_iter()
|
||||
.filter_map(|mut t| {
|
||||
t.position = Self::clamp_position(t.position).ok()?;
|
||||
if Self::check_leader(&t.leader).is_err() {
|
||||
t.leader = t.leader.chars().filter(|c| !c.is_control()).take(MAX_TAB_LEADER).collect();
|
||||
}
|
||||
if Self::check_align_on(&t.align_on).is_err() {
|
||||
t.align_on = t.align_on.chars().filter(|c| !c.is_control()).take(1).collect();
|
||||
}
|
||||
Some(t)
|
||||
})
|
||||
.collect();
|
||||
let mut out = Self::normalized(tabs);
|
||||
out.truncate(MAX_TAB_STOPS);
|
||||
out
|
||||
}
|
||||
|
||||
/// Finite, clamped stops sorted by position, one per position: of stops closer than
|
||||
/// [`SAME_TAB_POSITION`], the later one in `tabs` wins. A char stop with no character aligns
|
||||
/// on `.`.
|
||||
fn normalized(tabs: Vec<TabStop>) -> Vec<TabStop> {
|
||||
let mut stops: Vec<(usize, TabStop)> = tabs.into_iter().enumerate().collect();
|
||||
stops.sort_by(|a, b| a.1.position.total_cmp(&b.1.position));
|
||||
let mut out: Vec<(usize, TabStop)> = Vec::with_capacity(stops.len());
|
||||
for (i, mut t) in stops {
|
||||
if t.align == TabAlign::Char && t.align_on.is_empty() {
|
||||
t.align_on = ".".into();
|
||||
}
|
||||
match out.last_mut() {
|
||||
Some(last) if t.position - last.1.position < SAME_TAB_POSITION => {
|
||||
if i > last.0 {
|
||||
*last = (i, t);
|
||||
}
|
||||
}
|
||||
_ => out.push((i, t)),
|
||||
}
|
||||
}
|
||||
out.into_iter().map(|(_, t)| t).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A stop's position; `null` (how JSON writes a non-finite number) reads as NaN.
|
||||
fn position_or_nan<'de, D: serde::Deserializer<'de>>(d: D) -> Result<f64, D::Error> {
|
||||
Ok(Option::<f64>::deserialize(d)?.unwrap_or(f64::NAN))
|
||||
}
|
||||
|
||||
/// A paragraph's tab-stop list in either attribute form, as read from a file.
|
||||
pub(super) trait FileTabs: Sized {
|
||||
fn sanitized(self) -> Self;
|
||||
}
|
||||
|
||||
impl FileTabs for Vec<TabStop> {
|
||||
fn sanitized(self) -> Self {
|
||||
TabStop::sanitized_list(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl FileTabs for Option<Vec<TabStop>> {
|
||||
fn sanitized(self) -> Self {
|
||||
self.map(TabStop::sanitized_list)
|
||||
}
|
||||
}
|
||||
|
||||
/// Deserializes a `tabs` field through [`TabStop::sanitized_list`]: a saved document's tab lists
|
||||
/// get the limits the Tabs commands keep.
|
||||
pub(super) fn sanitized_tabs<'de, D: serde::Deserializer<'de>, T: Deserialize<'de> + FileTabs>(d: D) -> Result<T, D::Error> {
|
||||
T::deserialize(d).map(FileTabs::sanitized)
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
use super::*;
|
||||
|
||||
/// #777: an unknown attribute names the nearest one, or lists them all.
|
||||
#[test]
|
||||
fn unknown_attributes_suggest_the_right_name() {
|
||||
let mut c = CharAttrs::default();
|
||||
assert_eq!(c.set_json("font", &serde_json::json!("X")).unwrap_err(), "unknown attribute `font`; did you mean `fontFamily`?");
|
||||
assert_eq!(c.set_json("fontFamly", &serde_json::json!("X")).unwrap_err(), "unknown attribute `fontFamly`; did you mean `fontFamily`?");
|
||||
let all = c.set_json("zzzzzz", &serde_json::json!(1)).unwrap_err();
|
||||
assert!(all.starts_with("unknown attribute `zzzzzz`; the attributes are: ") && all.contains("fontFamily") && all.contains("fontStyle"), "{all}");
|
||||
assert!(c.set_json("font_family", &serde_json::json!("X")).is_ok() && c.set_json("fontFamily", &serde_json::json!("X")).is_ok());
|
||||
let mut p = ParaAttrs::default();
|
||||
assert_eq!(p.set_json("spaceBefor", &serde_json::json!(3)).unwrap_err(), "unknown attribute `spaceBefor`; did you mean `spaceBefore`?");
|
||||
assert_eq!(camel("first_line_indent"), "firstLineIndent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn levels_number_on_their_own_and_restart_after_a_higher_level() {
|
||||
let at = |level: u32| ParaProps { list_type: ListType::Numbers, list_level: level, number_expression: "^1.^#".into(), ..ParaProps::default() };
|
||||
let mut c = ListCounter::default();
|
||||
let mut labels = vec![];
|
||||
for level in [1, 2, 2, 1, 2] {
|
||||
let pp = at(level);
|
||||
c.advance(&pp);
|
||||
labels.push(pp.number_label_in(&c));
|
||||
}
|
||||
assert_eq!(labels, ["1.1", "1.1", "1.2", "2.2", "2.1"]);
|
||||
// Without the restart, level 2 carries on.
|
||||
let mut c = ListCounter::default();
|
||||
let keep = |level: u32| ParaProps { restart_numbers: false, ..at(level) };
|
||||
let n: Vec<u32> = [1, 2, 1, 2].into_iter().map(|l| c.advance(&keep(l))).collect();
|
||||
assert_eq!(n, [1, 1, 2, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_partial_rule_keeps_the_other_fields() {
|
||||
let current = ParaProps {
|
||||
rule_below: Rule { on: true, weight: 3.0, color: "Red".into(), offset: 4.0, column_width: false, ..Rule::default() },
|
||||
..ParaProps::default()
|
||||
};
|
||||
let mut a = ParaAttrs::default();
|
||||
a.set_json_over("ruleBelow", &serde_json::json!({"color": "Blue"}), ¤t).unwrap();
|
||||
assert_eq!(a.rule_below, Some(Rule { color: "Blue".into(), ..current.rule_below.clone() }));
|
||||
assert_eq!(a.rule_above, None);
|
||||
assert!(a.set_json_over("ruleAbove", &serde_json::json!({"colour": "Blue"}), ¤t).is_err(), "unknown field");
|
||||
a.set_json_over("ruleBelow", &serde_json::Value::Null, ¤t).unwrap();
|
||||
assert_eq!(a.rule_below, None, "null clears the override");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_behind_fill_is_saved_only_when_set() {
|
||||
assert!(!CharProps::default().stroke_behind_fill);
|
||||
assert_eq!(serde_json::to_value(CharAttrs::default()).unwrap(), serde_json::json!({}));
|
||||
let mut a = CharAttrs::default();
|
||||
a.set_json("strokeBehindFill", &serde_json::json!(true)).unwrap();
|
||||
let v = serde_json::to_value(&a).unwrap();
|
||||
assert_eq!(v, serde_json::json!({"strokeBehindFill": true}));
|
||||
assert_eq!(serde_json::from_value::<CharAttrs>(v).unwrap(), a);
|
||||
let mut p = CharProps::default();
|
||||
p.apply(&a);
|
||||
assert!(p.stroke_behind_fill);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn digits_map_to_their_script() {
|
||||
assert_eq!(Digits::Default.map('7', "Arabic"), '7');
|
||||
assert_eq!(Digits::Hindi.map('3', ""), '\u{0663}');
|
||||
assert_eq!(Digits::Farsi.map('0', ""), '\u{06F0}');
|
||||
assert_eq!(Digits::Native.map('5', "Thai"), '\u{0E55}');
|
||||
assert_eq!(Digits::Native.map('5', "English: USA"), '5');
|
||||
assert_eq!(Digits::Hindi.map('x', ""), 'x');
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_names_map_to_bcp47_tags() {
|
||||
assert_eq!(language_tag("English: USA"), Some("en-US"));
|
||||
assert_eq!(language_tag("English: Australian"), Some("en"));
|
||||
assert_eq!(language_tag("Turkish"), Some("tr"));
|
||||
assert_eq!(language_tag("Georgian"), Some("ka"));
|
||||
assert_eq!(language_tag("Japanese"), Some("ja"));
|
||||
assert_eq!(language_tag("Korean"), Some("ko"));
|
||||
assert_eq!(language_tag("Chinese"), Some("zh"));
|
||||
assert_eq!(language_tag("Chinese: Simplified"), Some("zh-Hans"));
|
||||
assert_eq!(language_tag("Chinese: Traditional"), Some("zh-Hant"));
|
||||
assert_eq!(language_tag("Arabic"), Some("ar"));
|
||||
assert_eq!(language_tag("Persian"), Some("fa"));
|
||||
assert_eq!(language_tag("Farsi"), Some("fa"));
|
||||
assert_eq!(language_tag("Urdu"), Some("ur"));
|
||||
assert_eq!(language_tag("Hebrew"), Some("he"));
|
||||
assert_eq!(language_tag("[No Language]"), None);
|
||||
assert_eq!(language_tag("Klingon"), None);
|
||||
assert_eq!(language_tag(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chinese_japanese_and_korean_names_map_in_their_common_spellings() {
|
||||
for (name, tag) in [
|
||||
("Chinese: Simplified", Some("zh-Hans")),
|
||||
("Simplified Chinese", Some("zh-Hans")),
|
||||
("Chinese Simplified", Some("zh-Hans")),
|
||||
("Chinese (Simplified)", Some("zh-Hans")),
|
||||
(" chinese: SIMPLIFIED ", Some("zh-Hans")),
|
||||
("zh_CN", Some("zh-Hans")),
|
||||
("zh-CN", Some("zh-Hans")),
|
||||
("zh-Hans", Some("zh-Hans")),
|
||||
("zh-Hans-CN", Some("zh-Hans")),
|
||||
("Chinese: Traditional", Some("zh-Hant")),
|
||||
("Traditional Chinese", Some("zh-Hant")),
|
||||
("Chinese Traditional", Some("zh-Hant")),
|
||||
("Chinese (Traditional)", Some("zh-Hant")),
|
||||
("zh_TW", Some("zh-Hant")),
|
||||
("zh-TW", Some("zh-Hant")),
|
||||
("zh_HK", Some("zh-Hant")),
|
||||
("zh-HK", Some("zh-Hant")),
|
||||
("zh-Hant", Some("zh-Hant")),
|
||||
("Chinese", Some("zh")),
|
||||
("zh", Some("zh")),
|
||||
("Japanese", Some("ja")),
|
||||
("ja", Some("ja")),
|
||||
("ja_JP", Some("ja-JP")),
|
||||
("ja-JP", Some("ja-JP")),
|
||||
("Korean", Some("ko")),
|
||||
("ko_KR", Some("ko-KR")),
|
||||
("ko-KR", Some("ko-KR")),
|
||||
("English: USA", Some("en-US")),
|
||||
("German: Swiss 2006 Reform", Some("de-CH")),
|
||||
("Norwegian: Bokmål", Some("nb")),
|
||||
("[No Language]", None),
|
||||
] {
|
||||
assert_eq!(language_tag(name), tag, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locale_codes_map_to_their_tags() {
|
||||
for (name, tag) in [
|
||||
// As InDesign-exported IDML writes them (AppliedLanguage).
|
||||
("$ID/de_DE_2006", Some("de-DE")),
|
||||
("$ID/nl_NL_2005", Some("nl-NL")),
|
||||
("$ID/English: USA", Some("en-US")),
|
||||
("$ID/English: UK", Some("en-GB")),
|
||||
("$ID/Spanish: Castilian", Some("es-ES")),
|
||||
("$ID/French", Some("fr")),
|
||||
// As IDML import keeps them.
|
||||
("de_DE_2006", Some("de-DE")),
|
||||
("nl_NL_2005", Some("nl-NL")),
|
||||
("Spanish: Castilian", Some("es-ES")),
|
||||
("French", Some("fr")),
|
||||
// Other locale codes.
|
||||
("de-DE", Some("de-DE")),
|
||||
("de_CH_2006", Some("de-CH")),
|
||||
("pt_BR", Some("pt-BR")),
|
||||
("en_us", Some("en-US")),
|
||||
("es_419", Some("es-419")),
|
||||
("fil_PH", Some("fil-PH")),
|
||||
("sv", Some("sv")),
|
||||
("ja", Some("ja")),
|
||||
// Not codes.
|
||||
("de_DE_2006_x", None),
|
||||
("d_DE", None),
|
||||
("de_D", None),
|
||||
("de_DE_06", None),
|
||||
("1e_DE", None),
|
||||
("[No Language]", None),
|
||||
] {
|
||||
assert_eq!(language_tag(name), tag, "{name}");
|
||||
}
|
||||
assert_eq!(language_subtag("de-DE"), "de");
|
||||
assert_eq!(language_subtag("zh-Hant"), "zh");
|
||||
assert_eq!(language_subtag("ko"), "ko");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_and_resolve() {
|
||||
let mut a = CharAttrs { size: Some(10.0), ..Default::default() };
|
||||
a.merge(&CharAttrs { tracking: Some(20.0), size: Some(11.0), ..Default::default() });
|
||||
assert_eq!(a.size, Some(11.0));
|
||||
assert_eq!(a.count(), 2);
|
||||
let mut p = CharProps::default();
|
||||
p.apply(&a);
|
||||
assert_eq!(p.size, 11.0);
|
||||
assert_eq!(p.tracking, 20.0);
|
||||
assert_eq!(p.font_family, "Source Serif 4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_lists_are_checked() {
|
||||
let stop = |position: f64| TabStop { position, align: TabAlign::Left, leader: String::new(), align_on: String::new() };
|
||||
assert!(TabStop::checked_list(vec![stop(f64::NAN)]).is_err());
|
||||
assert!(TabStop::checked_list(vec![stop(f64::INFINITY)]).is_err());
|
||||
assert!(TabStop::checked_list((0..=MAX_TAB_STOPS).map(|i| stop(i as f64)).collect()).is_err());
|
||||
let list = TabStop::checked_list(vec![stop(50.0), TabStop { leader: ".".into(), ..stop(10.0) }, stop(50.001)]).unwrap();
|
||||
assert_eq!(list.iter().map(|t| t.position).collect::<Vec<_>>(), [10.0, 50.001], "sorted, one stop per position");
|
||||
}
|
||||
|
||||
/// A file's tab list is repaired, never refused; a list the commands accept comes through
|
||||
/// unchanged.
|
||||
#[test]
|
||||
fn tab_lists_from_files_are_sanitized() {
|
||||
let stop = |position: f64| TabStop { position, align: TabAlign::Left, leader: String::new(), align_on: String::new() };
|
||||
let mut tabs: Vec<TabStop> = (0..500).rev().map(|i| stop(i as f64 * 10.0)).collect();
|
||||
tabs.push(stop(f64::NAN));
|
||||
tabs.push(stop(f64::NEG_INFINITY));
|
||||
tabs.push(TabStop { leader: "\u{7}0123456789".into(), align: TabAlign::Char, align_on: "\n,;".into(), ..stop(-3.0) });
|
||||
let list = TabStop::sanitized_list(tabs);
|
||||
assert_eq!(list.len(), MAX_TAB_STOPS);
|
||||
assert!(list.windows(2).all(|w| w[1].position - w[0].position >= SAME_TAB_POSITION), "sorted, one stop per position");
|
||||
let first = &list[0];
|
||||
assert_eq!((first.position, first.leader.as_str(), first.align_on.as_str()), (0.0, "01234567", ","), "the later stop at 0 wins");
|
||||
assert_eq!(list.last().map(|t| t.position), Some(990.0), "the first stops by position are kept");
|
||||
|
||||
let ok = TabStop::checked_list(vec![stop(72.0), TabStop { align: TabAlign::Char, ..stop(10.0) }]).unwrap();
|
||||
assert_eq!(TabStop::sanitized_list(ok.clone()), ok);
|
||||
|
||||
// `null` is how JSON writes a non-finite position; that stop is dropped, the others kept.
|
||||
let json = r#"{"tabs":[{"position":null},{"position":20000,"leader":"0123456789"},{"position":5}]}"#;
|
||||
let a: ParaAttrs = serde_json::from_str(json).unwrap();
|
||||
let tabs = a.tabs.unwrap();
|
||||
assert_eq!(tabs.iter().map(|t| t.position).collect::<Vec<_>>(), [5.0, MAX_TAB_POSITION]);
|
||||
assert_eq!(tabs[1].leader, "01234567");
|
||||
let p: ParaProps = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(p.tabs, tabs);
|
||||
assert!(serde_json::from_str::<TabStop>("{}").is_err(), "a stop names its position");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_json_by_name() {
|
||||
let mut a = ParaAttrs::default();
|
||||
a.set_json("spaceBefore", &serde_json::json!(6.0)).unwrap();
|
||||
a.set_json("align", &serde_json::json!("center")).unwrap();
|
||||
a.set_json("left_indent", &serde_json::json!(12)).unwrap();
|
||||
assert_eq!(a.space_before, Some(6.0));
|
||||
assert_eq!(a.align, Some(Align::Center));
|
||||
assert_eq!(a.left_indent, Some(12.0));
|
||||
assert!(a.set_json("nope", &serde_json::json!(1)).is_err());
|
||||
a.set_json("align", &serde_json::Value::Null).unwrap();
|
||||
assert_eq!(a.align, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delta_and_common() {
|
||||
let a = CharProps::default();
|
||||
let mut b = a.clone();
|
||||
b.size = 18.0;
|
||||
let d = a.delta(&b);
|
||||
assert_eq!(d.count(), 1);
|
||||
let c = CharProps::common([&a, &b]);
|
||||
assert_eq!(c.size, None);
|
||||
assert_eq!(c.font_family.as_deref(), Some("Source Serif 4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_skips_unset() {
|
||||
let a = CharAttrs { size: Some(9.0), ..Default::default() };
|
||||
assert_eq!(serde_json::to_string(&a).unwrap(), r#"{"size":9.0}"#);
|
||||
let p: ParaProps = serde_json::from_str(r#"{"align":"center"}"#).unwrap();
|
||||
assert_eq!(p.align, Align::Center);
|
||||
assert_eq!(p.word_space_max, 1.33);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_para_style_spacing_ignore_survives_serde() {
|
||||
for v in [None, Some(None), Some(Some(0.0))] {
|
||||
let a = ParaAttrs { same_para_style_spacing: v, ..Default::default() };
|
||||
let back: ParaAttrs = serde_json::from_str(&serde_json::to_string(&a).unwrap()).unwrap();
|
||||
assert_eq!(back.same_para_style_spacing, v);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbering_styles() {
|
||||
assert_eq!(NumberStyle::UpperRoman.format(14), "XIV");
|
||||
assert_eq!(NumberStyle::LowerRoman.format(9), "ix");
|
||||
assert_eq!(NumberStyle::UpperLetters.format(1), "A");
|
||||
assert_eq!(NumberStyle::UpperLetters.format(27), "AA");
|
||||
assert_eq!(NumberStyle::LowerLetters.format(28), "bb");
|
||||
assert_eq!(NumberStyle::ArabicLeadingZero.format(3), "03");
|
||||
assert_eq!(NumberStyle::ArabicThreeDigits.format(7), "007");
|
||||
assert_eq!(NumberStyle::ArabicFourDigits.format(12), "0012");
|
||||
// Repeating forms stay small for huge numbers.
|
||||
assert_eq!(NumberStyle::UpperRoman.format(u32::MAX), u32::MAX.to_string());
|
||||
assert_eq!(NumberStyle::LowerLetters.format(u32::MAX), u32::MAX.to_string());
|
||||
assert_eq!(NumberStyle::Symbols.format(u32::MAX), u32::MAX.to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kanji_numbers_are_written_digit_by_digit() {
|
||||
for (n, s) in [
|
||||
(0, "〇"),
|
||||
(1, "一"),
|
||||
(5, "五"),
|
||||
(10, "一〇"),
|
||||
(11, "一一"),
|
||||
(20, "二〇"),
|
||||
(38, "三八"),
|
||||
(99, "九九"),
|
||||
(100, "一〇〇"),
|
||||
(105, "一〇五"),
|
||||
(1000, "一〇〇〇"),
|
||||
(2026, "二〇二六"),
|
||||
(u32::MAX, "四二九四九六七二九五"),
|
||||
] {
|
||||
assert_eq!(NumberStyle::Kanji.format(n), s, "{n}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_labels_expand_metacharacters() {
|
||||
let p = ParaProps { number_style: NumberStyle::UpperLetters, ..Default::default() };
|
||||
assert_eq!(p.number_label(2), "B.\t");
|
||||
let p = ParaProps { number_expression: "Tabel ^#^t".into(), ..Default::default() };
|
||||
assert_eq!(p.number_label(3), "Tabel 3\t");
|
||||
assert_eq!(expand_list_text("^2.^#.^>x^^^", "4"), ".4.\u{2002}x^^");
|
||||
assert_eq!(expand_list_text("a\nb^p", "1"), "ab");
|
||||
assert_eq!(ParaProps::default().bullet_label(), "\u{2022}\t");
|
||||
}
|
||||
@@ -125,6 +125,7 @@ impl Document {
|
||||
};
|
||||
let mut d = Document {
|
||||
title: nd.title.clone(),
|
||||
file_info: Default::default(),
|
||||
settings,
|
||||
spreads: vec![],
|
||||
parents: vec![],
|
||||
|
||||
+213
-11
@@ -87,7 +87,9 @@ impl Document {
|
||||
/// The horizontal ruler of spread `r` in pieces `(from x, to x, base)`, spread coordinates,
|
||||
/// covering every x: one piece, or one per page for a page origin.
|
||||
pub fn ruler_pieces(&self, r: SpreadRef) -> Vec<(f64, f64, Point)> {
|
||||
let Some(sp) = self.spread(r) else { return Vec::new() };
|
||||
let Some(sp) = self.spread(r) else {
|
||||
return Vec::new();
|
||||
};
|
||||
if self.settings.ruler_origin != crate::RulerOrigin::Page || sp.pages.len() < 2 {
|
||||
return self.ruler_base(r, 0.0).map(|o| (f64::NEG_INFINITY, f64::INFINITY, o)).into_iter().collect();
|
||||
}
|
||||
@@ -318,15 +320,16 @@ impl Document {
|
||||
let pos_to = other.frames.iter().position(|f| *f == to).unwrap_or(0);
|
||||
let after = other.frames.split_off(pos_to);
|
||||
let before = std::mem::take(&mut other.frames);
|
||||
let (vertical, direction) = (other.vertical, other.direction);
|
||||
let (vertical, direction, optical_margin) = (other.vertical, other.direction, other.optical_margin);
|
||||
let offset;
|
||||
{
|
||||
let st = self.story_mut(fs).ok_or(DocError::NoStory(fs))?;
|
||||
offset = if st.text.is_empty() { 0 } else { st.text.len().saturating_add(1) };
|
||||
// The story keeps its own direction, even when it was empty and takes the other's text.
|
||||
let own = (st.vertical, st.direction);
|
||||
// The story keeps its own direction and options, even when it was empty and takes the
|
||||
// other's text.
|
||||
let own = (st.vertical, st.direction, st.optical_margin);
|
||||
st.append_story(other);
|
||||
(st.vertical, st.direction) = own;
|
||||
(st.vertical, st.direction, st.optical_margin) = own;
|
||||
let at = st.frames.iter().position(|f| *f == from).map_or(st.frames.len(), |p| p + 1);
|
||||
for (k, f) in after.iter().enumerate() {
|
||||
st.frames.insert(at + k, *f);
|
||||
@@ -345,6 +348,7 @@ impl Document {
|
||||
ns.frames = before.clone();
|
||||
ns.vertical = vertical;
|
||||
ns.direction = direction;
|
||||
ns.optical_margin = optical_margin;
|
||||
for f in &before {
|
||||
if let Some(t) = self.item_mut(*f).and_then(Item::text_frame_mut) {
|
||||
t.story = nid;
|
||||
@@ -363,7 +367,9 @@ impl Document {
|
||||
let from_empty = fs.is_none_or(|fs| self.story(fs).is_some_and(|s| s.text.is_empty() && s.frames == [from]));
|
||||
let ts = self.frame_story(to)?;
|
||||
let to_first = ts.and_then(|ts| self.story(ts)).is_some_and(|s| s.frames.first() == Some(&to));
|
||||
let (Some(ts), true, true) = (ts, from_empty, to_first && from != to) else { return self.thread(from, to) };
|
||||
let (Some(ts), true, true) = (ts, from_empty, to_first && from != to) else {
|
||||
return self.thread(from, to);
|
||||
};
|
||||
self.check_thread_links(from, to, true)?;
|
||||
if let Some(fs) = fs {
|
||||
self.stories.remove(&fs);
|
||||
@@ -466,7 +472,9 @@ impl Document {
|
||||
return Err(DocError::Invalid(format!("{id} is locked or on a locked layer")));
|
||||
}
|
||||
}
|
||||
let (Some(fl), Some(tl)) = (self.find(from), self.find(to)) else { return Err(DocError::NoItem(to)) };
|
||||
let (Some(fl), Some(tl)) = (self.find(from), self.find(to)) else {
|
||||
return Err(DocError::NoItem(to));
|
||||
};
|
||||
let same_place = match (fl.spread, tl.spread) {
|
||||
(SpreadRef::Doc(_), SpreadRef::Doc(_)) => true,
|
||||
(a, b) => a == b,
|
||||
@@ -526,12 +534,12 @@ impl Document {
|
||||
/// stays with the first part and may become overset).
|
||||
pub fn unthread_after(&mut self, frame: ItemId) -> Result<()> {
|
||||
let sid = self.item(frame).and_then(|i| i.text_frame()).map(|t| t.story).ok_or(DocError::NoItem(frame))?;
|
||||
let (tail, vertical, direction) = {
|
||||
let (tail, vertical, direction, optical_margin) = {
|
||||
let st = self.story_mut(sid).ok_or(DocError::NoStory(sid))?;
|
||||
let p = st.frames.iter().position(|f| *f == frame).ok_or(DocError::NoItem(frame))?;
|
||||
let tail = st.frames.split_off(p + 1);
|
||||
st.rev = st.rev.wrapping_add(1);
|
||||
(tail, st.vertical, st.direction)
|
||||
(tail, st.vertical, st.direction, st.optical_margin)
|
||||
};
|
||||
if tail.is_empty() {
|
||||
return Ok(());
|
||||
@@ -541,6 +549,7 @@ impl Document {
|
||||
ns.frames = tail.clone();
|
||||
ns.vertical = vertical;
|
||||
ns.direction = direction;
|
||||
ns.optical_margin = optical_margin;
|
||||
self.stories.insert(nid, Arc::new(ns));
|
||||
for f in tail {
|
||||
if let Some(t) = self.item_mut(f).and_then(Item::text_frame_mut) {
|
||||
@@ -554,20 +563,21 @@ impl Document {
|
||||
/// `frame` stays on the page with a new empty story.
|
||||
pub fn remove_from_thread(&mut self, frame: ItemId) -> Result<()> {
|
||||
let sid = self.item(frame).and_then(|i| i.text_frame()).map(|t| t.story).ok_or(DocError::NoItem(frame))?;
|
||||
let (vertical, direction) = {
|
||||
let (vertical, direction, optical_margin) = {
|
||||
let st = self.story_mut(sid).ok_or(DocError::NoStory(sid))?;
|
||||
if st.frames.len() < 2 {
|
||||
return Err(DocError::Invalid(format!("frame {frame} is not threaded to another frame")));
|
||||
}
|
||||
st.frames.retain(|f| *f != frame);
|
||||
st.rev = st.rev.wrapping_add(1);
|
||||
(st.vertical, st.direction)
|
||||
(st.vertical, st.direction, st.optical_margin)
|
||||
};
|
||||
let nid = StoryId(self.alloc());
|
||||
let mut ns = Story::new(nid);
|
||||
ns.frames = vec![frame];
|
||||
ns.vertical = vertical;
|
||||
ns.direction = direction;
|
||||
ns.optical_margin = optical_margin;
|
||||
self.stories.insert(nid, Arc::new(ns));
|
||||
let t = self.item_mut(frame).and_then(Item::text_frame_mut).ok_or(DocError::NoItem(frame))?;
|
||||
t.story = nid;
|
||||
@@ -638,6 +648,45 @@ impl Document {
|
||||
found
|
||||
}
|
||||
|
||||
/// Every item on document and parent spreads, in groups, and among the objects anchored in
|
||||
/// stories, including the stories in table cells and in notes: walks that must cover every
|
||||
/// placement use this, not [`Document::all_items`].
|
||||
pub fn placed_items(&self) -> Vec<&Item> {
|
||||
let mut v = Vec::new();
|
||||
for sp in self.spreads.iter().chain(&self.parents) {
|
||||
for it in &sp.items {
|
||||
placed_item(it, 0, &mut v);
|
||||
}
|
||||
}
|
||||
for st in self.stories.values() {
|
||||
placed_story_items(st, 0, &mut v);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Applies `f` to every placed item `hit` selects, wherever it sits: on document or parent
|
||||
/// spreads, in groups, and anchored in a story (also inside table cells and notes).
|
||||
/// Returns how many items `f` saw.
|
||||
pub fn update_placed_items(&mut self, hit: impl Fn(&Item) -> bool, mut f: impl FnMut(&mut Item)) -> usize {
|
||||
let mut n = 0;
|
||||
for sp in self.spreads.iter_mut().chain(self.parents.iter_mut()) {
|
||||
if !sp.items.iter().any(|it| has_hit(it, &hit)) {
|
||||
continue;
|
||||
}
|
||||
for it in Arc::make_mut(sp).items.iter_mut() {
|
||||
if has_hit(it, &hit) {
|
||||
update_placed_item(Arc::make_mut(it), 0, &hit, &mut f, &mut n);
|
||||
}
|
||||
}
|
||||
}
|
||||
for st in self.stories.values_mut() {
|
||||
if story_has_hit(st, 0, &hit) {
|
||||
update_story_items(Arc::make_mut(st), 0, &hit, &mut f, &mut n);
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// Every item id (pre-order) on document spreads.
|
||||
pub fn all_items(&self) -> Vec<ItemId> {
|
||||
let mut v = Vec::new();
|
||||
@@ -732,6 +781,91 @@ impl Document {
|
||||
}
|
||||
}
|
||||
|
||||
/// How deep nested stories (a cell's text, a footnote's text, an endnote's text) are followed
|
||||
/// when every placement is visited. A document nested deeper is left alone.
|
||||
const MAX_PLACEMENT_DEPTH: usize = 64;
|
||||
|
||||
/// The items anchored in `st`, and in the stories nested in its cells and notes, pre-order.
|
||||
fn placed_story_items<'a>(st: &'a Story, depth: usize, v: &mut Vec<&'a Item>) {
|
||||
if depth > MAX_PLACEMENT_DEPTH {
|
||||
return;
|
||||
}
|
||||
for o in &st.objects {
|
||||
placed_item(&o.item, 0, v);
|
||||
}
|
||||
for c in st.tables.values().flat_map(|t| &t.cells) {
|
||||
placed_story_items(&c.text, depth + 1, v);
|
||||
}
|
||||
for n in st.notes.iter().chain(&st.endnotes) {
|
||||
placed_story_items(&n.text, depth + 1, v);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `it` or anything in it is selected by `hit`.
|
||||
fn has_hit(it: &Item, hit: &impl Fn(&Item) -> bool) -> bool {
|
||||
fn check(it: &Item, depth: usize, hit: &impl Fn(&Item) -> bool) -> bool {
|
||||
hit(it) || (depth < MAX_PLACEMENT_DEPTH && it.children().iter().any(|child| check(child, depth + 1, hit)))
|
||||
}
|
||||
check(it, 0, hit)
|
||||
}
|
||||
|
||||
/// [`Document::update_placed_items`] for one item: only the branches holding a hit are copied.
|
||||
fn update_placed_item(it: &mut Item, depth: usize, hit: &impl Fn(&Item) -> bool, f: &mut impl FnMut(&mut Item), n: &mut usize) {
|
||||
if hit(it) {
|
||||
f(it);
|
||||
*n += 1;
|
||||
}
|
||||
if depth >= MAX_PLACEMENT_DEPTH {
|
||||
return;
|
||||
}
|
||||
if let Some(children) = it.children_mut() {
|
||||
for c in children.iter_mut().filter(|c| has_hit(c, hit)) {
|
||||
update_placed_item(Arc::make_mut(c), depth + 1, hit, f, n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Document::update_placed_items`] for the placements of one story, and of the stories nested
|
||||
/// in its cells and notes.
|
||||
fn update_story_items(st: &mut Story, depth: usize, hit: &impl Fn(&Item) -> bool, f: &mut impl FnMut(&mut Item), n: &mut usize) {
|
||||
if depth > MAX_PLACEMENT_DEPTH {
|
||||
return;
|
||||
}
|
||||
for o in st.objects.iter_mut() {
|
||||
if has_hit(&o.item, hit) {
|
||||
update_placed_item(&mut Arc::make_mut(o).item, 0, hit, f, n);
|
||||
}
|
||||
}
|
||||
for t in st.tables.values_mut().filter(|t| t.cells.iter().any(|c| story_has_hit(&c.text, depth + 1, hit))) {
|
||||
for c in &mut Arc::make_mut(t).cells {
|
||||
if story_has_hit(&c.text, depth + 1, hit) {
|
||||
update_story_items(&mut c.text, depth + 1, hit, f, n);
|
||||
}
|
||||
}
|
||||
}
|
||||
for note in st.notes.iter_mut().chain(st.endnotes.iter_mut()) {
|
||||
if story_has_hit(¬e.text, depth + 1, hit) {
|
||||
update_story_items(&mut Arc::make_mut(note).text, depth + 1, hit, f, n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn placed_item<'a>(it: &'a Item, depth: usize, v: &mut Vec<&'a Item>) {
|
||||
v.push(it);
|
||||
if depth < MAX_PLACEMENT_DEPTH {
|
||||
for child in it.children() {
|
||||
placed_item(child, depth + 1, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn story_has_hit(st: &Story, depth: usize, hit: &impl Fn(&Item) -> bool) -> bool {
|
||||
depth <= MAX_PLACEMENT_DEPTH
|
||||
&& (st.objects.iter().any(|o| has_hit(&o.item, hit))
|
||||
|| st.tables.values().flat_map(|t| &t.cells).any(|c| story_has_hit(&c.text, depth + 1, hit))
|
||||
|| st.notes.iter().chain(&st.endnotes).any(|n| story_has_hit(&n.text, depth + 1, hit)))
|
||||
}
|
||||
|
||||
/// Does `p` (in the item's parent space) hit `it`?
|
||||
pub fn item_hit(it: &Item, p: Point, tol: f64) -> bool {
|
||||
if let Content::Group { items } = &it.content {
|
||||
@@ -776,3 +910,71 @@ pub fn hit_descendant(it: &Item, p: Point, tol: f64, want: &dyn Fn(&Item) -> boo
|
||||
/// How far from its path type on a path is hit (points): about a cap height of 12–18 pt type,
|
||||
/// either side (the type may sit on or under the path).
|
||||
const PATH_TYPE_REACH: f64 = 12.0;
|
||||
#[cfg(test)]
|
||||
mod placed_item_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn exhaustive_placements_keep_unselected_branches_shared() {
|
||||
let mut d = Document::new(&Default::default());
|
||||
let layer = d.default_layer();
|
||||
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 80.0, 80.0), layer, "Body", Default::default()).unwrap();
|
||||
let id = ItemId(d.alloc());
|
||||
let mut item = Item::new(id, layer, Shape::Rectangle, Default::default());
|
||||
item.content = Content::Group { items: vec![Arc::new(Item::new(ItemId(d.alloc()), layer, Shape::Rectangle, Default::default()))] };
|
||||
let child = item.children()[0].id;
|
||||
let mut table = crate::Table::new(1, 1, 1, 0, 0, 20.0);
|
||||
table.cells[0].text.insert_object(0, crate::AnchoredObject { item, position: Default::default() });
|
||||
d.story_mut(sid).unwrap().insert_table(0, table);
|
||||
let untouched = StoryId(d.alloc());
|
||||
d.stories.insert(untouched, Arc::new(Story::new(untouched)));
|
||||
let snapshot = d.clone();
|
||||
assert!(d.placed_items().iter().any(|i| i.id == child));
|
||||
assert_eq!(d.update_placed_items(|i| i.id == child, |i| i.hidden = true), 1);
|
||||
assert!(d.placed_items().iter().find(|i| i.id == child).unwrap().hidden);
|
||||
assert!(!snapshot.placed_items().iter().find(|i| i.id == child).unwrap().hidden);
|
||||
assert!(Arc::ptr_eq(&d.stories[&untouched], &snapshot.stories[&untouched]));
|
||||
assert!(Arc::ptr_eq(&d.spreads[0], &snapshot.spreads[0]));
|
||||
let unchanged = d.clone();
|
||||
let mut calls = 0;
|
||||
assert_eq!(d.update_placed_items(|_| false, |_| calls += 1), 0);
|
||||
assert_eq!(calls, 0);
|
||||
assert!(d.spreads.iter().zip(&unchanged.spreads).all(|(a, b)| Arc::ptr_eq(a, b)));
|
||||
assert!(d.parents.iter().zip(&unchanged.parents).all(|(a, b)| Arc::ptr_eq(a, b)));
|
||||
assert!(d.stories.iter().all(|(id, st)| Arc::ptr_eq(st, &unchanged.stories[id])));
|
||||
}
|
||||
#[test]
|
||||
fn exhaustive_placements_bound_nested_stories_and_groups() {
|
||||
let mut d = Document::new(&Default::default());
|
||||
let layer = d.default_layer();
|
||||
let target = ItemId(d.alloc());
|
||||
let mut item = Item::new(target, layer, Shape::Rectangle, Default::default());
|
||||
for _ in 0..MAX_PLACEMENT_DEPTH + 2 {
|
||||
let mut group = Item::new(ItemId(d.alloc()), layer, Shape::Rectangle, Default::default());
|
||||
group.content = Content::Group { items: vec![Arc::new(item)] };
|
||||
item = group;
|
||||
}
|
||||
d.insert_item(SpreadRef::Doc(0), item, None).unwrap();
|
||||
assert!(!d.placed_items().iter().any(|i| i.id == target));
|
||||
let mut calls = 0;
|
||||
assert_eq!(d.update_placed_items(|i| i.id == target, |_| calls += 1), 0);
|
||||
assert_eq!(calls, 0);
|
||||
|
||||
let nested = ItemId(d.alloc());
|
||||
let sid = StoryId(d.alloc());
|
||||
let mut story = Story::new(sid);
|
||||
story.insert_object(
|
||||
0,
|
||||
crate::AnchoredObject { item: Item::new(nested, layer, Shape::Rectangle, Default::default()), position: Default::default() },
|
||||
);
|
||||
for _ in 0..MAX_PLACEMENT_DEPTH + 2 {
|
||||
let mut outer = Story::new(sid);
|
||||
outer.notes.push(Arc::new(crate::Footnote { id: 1, text: story }));
|
||||
story = outer;
|
||||
}
|
||||
d.stories.insert(sid, Arc::new(story));
|
||||
assert!(!d.placed_items().iter().any(|i| i.id == nested));
|
||||
assert_eq!(d.update_placed_items(|i| i.id == nested, |_| calls += 1), 0);
|
||||
assert_eq!(calls, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@ impl crate::Document {
|
||||
if st.id == sid {
|
||||
break;
|
||||
}
|
||||
n += st.endnotes.len() as u32;
|
||||
n = n.saturating_add(st.endnotes.len() as u32);
|
||||
}
|
||||
n
|
||||
}
|
||||
@@ -173,7 +173,7 @@ impl crate::Document {
|
||||
let at = out.len();
|
||||
out.insert_with(at, ¬e.text.text[r].replace('\n', " "), f.clone());
|
||||
}
|
||||
n += 1;
|
||||
n = n.saturating_add(1);
|
||||
}
|
||||
}
|
||||
out
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
//! File › File Info: the document's title, author, description, keywords and copyright, saved
|
||||
//! with the document. PDF export writes the title, author, description and keywords into the
|
||||
//! file's metadata, and EPUB export writes the title and author.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::Document;
|
||||
|
||||
/// File › File Info.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct FileInfo {
|
||||
/// Document Title. Empty: exports use the document's name.
|
||||
pub document_title: String,
|
||||
pub author: String,
|
||||
pub description: String,
|
||||
/// Keywords in the order they were typed, trimmed, without empty or repeated ones.
|
||||
pub keywords: Vec<String>,
|
||||
pub copyright_notice: String,
|
||||
pub copyright_info_url: String,
|
||||
}
|
||||
|
||||
impl FileInfo {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
*self == FileInfo::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Keywords typed as one line: separated by semicolons or commas, trimmed, empty and repeated
|
||||
/// ones dropped.
|
||||
pub fn parse_keywords(text: &str) -> Vec<String> {
|
||||
clean_keywords(text.split([';', ',']))
|
||||
}
|
||||
|
||||
/// Keywords typed as one line, read as [`parse_keywords`] reads them, except that a keyword of
|
||||
/// `kept` that holds a semicolon or comma stays one keyword wherever it still stands whole
|
||||
/// between separators.
|
||||
pub fn parse_keywords_keeping(text: &str, kept: &[String]) -> Vec<String> {
|
||||
let mut whole: Vec<&str> = kept.iter().map(|k| k.trim()).filter(|k| k.contains([';', ','])).collect();
|
||||
whole.sort_by_key(|k| std::cmp::Reverse(k.len()));
|
||||
let separator = |c: char| c == ';' || c == ',';
|
||||
let mut words = Vec::new();
|
||||
let mut rest = text;
|
||||
loop {
|
||||
rest = rest.trim_start_matches(|c: char| separator(c) || c.is_whitespace());
|
||||
if rest.is_empty() {
|
||||
break;
|
||||
}
|
||||
let kept_here = whole.iter().find(|k| rest.strip_prefix(**k).is_some_and(|after| after.trim_start().chars().next().is_none_or(separator)));
|
||||
let end = kept_here.map_or_else(|| rest.find(separator).unwrap_or(rest.len()), |k| k.len());
|
||||
let (word, after) = rest.split_at_checked(end).unwrap_or((rest, ""));
|
||||
words.push(word);
|
||||
rest = after;
|
||||
}
|
||||
clean_keywords(words)
|
||||
}
|
||||
|
||||
/// Trimmed keywords without empty or repeated ones, in their first order.
|
||||
pub fn clean_keywords<'a>(words: impl IntoIterator<Item = &'a str>) -> Vec<String> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
words.into_iter().map(str::trim).filter(|w| !w.is_empty() && seen.insert(*w)).map(str::to_string).collect()
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// The title exports write into a file's metadata: File Info's Document Title, or the
|
||||
/// document's name when that is empty.
|
||||
pub fn metadata_title(&self) -> &str {
|
||||
let t = self.file_info.document_title.trim();
|
||||
if t.is_empty() { &self.title } else { t }
|
||||
}
|
||||
|
||||
/// File Info's Author, if any.
|
||||
pub fn metadata_author(&self) -> Option<&str> {
|
||||
let a = self.file_info.author.trim();
|
||||
(!a.is_empty()).then_some(a)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::build::NewDocument;
|
||||
|
||||
#[test]
|
||||
fn keywords_split_at_semicolons_and_commas() {
|
||||
assert_eq!(parse_keywords(" spring; catalog, shoes ;;spring , "), ["spring", "catalog", "shoes"]);
|
||||
assert!(parse_keywords(" ; , ").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keywords_with_separators_stay_whole_when_another_one_changes() {
|
||||
let kept = ["Washington, DC".to_string(), "map".into(), "a; b".into()];
|
||||
assert_eq!(parse_keywords_keeping("Washington, DC; atlas", &kept), ["Washington, DC", "atlas"]);
|
||||
assert_eq!(parse_keywords_keeping("atlas, a; b ;Washington, DC", &kept), ["atlas", "a; b", "Washington, DC"]);
|
||||
// Edited, a keyword is read like any other text.
|
||||
assert_eq!(parse_keywords_keeping("Washington, D.C.; map", &kept), ["Washington", "D.C.", "map"]);
|
||||
assert_eq!(parse_keywords_keeping("Washington, DCX", &kept), ["Washington", "DCX"]);
|
||||
assert_eq!(parse_keywords_keeping(" spring; catalog, shoes ;;spring , ", &[]), ["spring", "catalog", "shoes"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_metadata_title_falls_back_to_the_document_name() {
|
||||
let mut d = Document::new(&NewDocument { title: "Flyer".into(), ..Default::default() });
|
||||
assert_eq!((d.metadata_title(), d.metadata_author()), ("Flyer", None));
|
||||
d.file_info.document_title = " Spring Catalog ".into();
|
||||
d.file_info.author = " Ana ".into();
|
||||
assert_eq!((d.metadata_title(), d.metadata_author()), ("Spring Catalog", Some("Ana")));
|
||||
d.file_info.document_title = " ".into();
|
||||
assert_eq!(d.metadata_title(), "Flyer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_without_file_info_saves_as_before() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let v = serde_json::to_value(&d).unwrap();
|
||||
assert!(v.get("fileInfo").is_none());
|
||||
d.file_info.keywords = vec!["a".into()];
|
||||
d.file_info.copyright_notice = "© 2026 Ana".into();
|
||||
let json = serde_json::to_string(&d).unwrap();
|
||||
let back: Document = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.file_info, d.file_info);
|
||||
}
|
||||
}
|
||||
+52
-1330
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
use super::graphic::Graphic;
|
||||
use super::text_frame::TextFrame;
|
||||
use super::{Arc, Deserialize, Item, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "type")]
|
||||
pub enum Content {
|
||||
#[default]
|
||||
Unassigned,
|
||||
Text(TextFrame),
|
||||
Graphic(Graphic),
|
||||
Group {
|
||||
items: Vec<Arc<Item>>,
|
||||
},
|
||||
}
|
||||
|
||||
/// What kind of spline the item was drawn as (labels in the Layers panel, tool behaviours).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Shape {
|
||||
#[default]
|
||||
Rectangle,
|
||||
Oval,
|
||||
Polygon,
|
||||
GraphicLine,
|
||||
Path,
|
||||
Group,
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
use super::{BlendMode, Deserialize, Serialize, is_default};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct DropShadow {
|
||||
pub on: bool,
|
||||
pub color: String,
|
||||
pub opacity: f32,
|
||||
pub angle: f64,
|
||||
/// Use Global Light (the document angle instead of `angle`).
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub global_light: bool,
|
||||
pub distance: f64,
|
||||
pub size: f64,
|
||||
pub spread: f64,
|
||||
/// How the shadow blends with what's behind it (InDesign: Multiply). A document saved before
|
||||
/// effects had modes reads Normal, as they were drawn then.
|
||||
#[serde(default = "normal_blend")]
|
||||
pub mode: BlendMode,
|
||||
}
|
||||
|
||||
/// The blend of an effect saved before effects had one.
|
||||
fn normal_blend() -> BlendMode {
|
||||
BlendMode::Normal
|
||||
}
|
||||
|
||||
impl Default for DropShadow {
|
||||
fn default() -> Self {
|
||||
DropShadow {
|
||||
on: false,
|
||||
mode: BlendMode::Multiply,
|
||||
color: designcraft_color::swatch::BLACK.into(),
|
||||
opacity: 0.75,
|
||||
angle: 120.0,
|
||||
distance: 7.0,
|
||||
size: 5.0,
|
||||
spread: 0.0,
|
||||
global_light: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inner shadow: a blurred, offset shadow inside the object's edge.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct InnerShadow {
|
||||
pub on: bool,
|
||||
pub color: String,
|
||||
pub opacity: f32,
|
||||
pub angle: f64,
|
||||
/// Use Global Light (the document angle instead of `angle`).
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub global_light: bool,
|
||||
pub distance: f64,
|
||||
pub size: f64,
|
||||
/// Percentage (0–100) of `size` that hardens the edge instead of blurring it.
|
||||
pub choke: f64,
|
||||
/// How the shadow blends with the object (InDesign: Multiply; Normal when saved before).
|
||||
#[serde(default = "normal_blend")]
|
||||
pub mode: BlendMode,
|
||||
}
|
||||
|
||||
impl Default for InnerShadow {
|
||||
fn default() -> Self {
|
||||
InnerShadow {
|
||||
on: false,
|
||||
mode: BlendMode::Multiply,
|
||||
color: designcraft_color::swatch::BLACK.into(),
|
||||
opacity: 0.75,
|
||||
angle: 120.0,
|
||||
distance: 7.0,
|
||||
size: 7.0,
|
||||
choke: 0.0,
|
||||
global_light: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Outer glow: a blurred silhouette around the object, without offset.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct OuterGlow {
|
||||
pub on: bool,
|
||||
pub color: String,
|
||||
pub opacity: f32,
|
||||
pub size: f64,
|
||||
/// Percentage (0–100) of `size` that grows the silhouette instead of blurring it.
|
||||
pub spread: f64,
|
||||
/// How the glow blends with what's behind it (InDesign: Screen; Normal when saved before).
|
||||
#[serde(default = "normal_blend")]
|
||||
pub mode: BlendMode,
|
||||
}
|
||||
|
||||
impl Default for OuterGlow {
|
||||
fn default() -> Self {
|
||||
// InDesign starts a glow screened in white.
|
||||
OuterGlow { on: false, color: designcraft_color::swatch::PAPER.into(), opacity: 0.75, size: 7.0, spread: 0.0, mode: BlendMode::Screen }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct Effects {
|
||||
/// Drop shadow. `size` is the blur size; `spread` is a percentage (0–100) of it that grows
|
||||
/// the silhouette instead of blurring it.
|
||||
pub drop_shadow: DropShadow,
|
||||
/// Basic feather width (0 = off).
|
||||
pub feather: f64,
|
||||
/// Basic feather's choke and corners.
|
||||
#[serde(skip_serializing_if = "is_default")]
|
||||
pub feather_options: FeatherOptions,
|
||||
#[serde(skip_serializing_if = "is_default")]
|
||||
pub inner_shadow: InnerShadow,
|
||||
#[serde(skip_serializing_if = "is_default")]
|
||||
pub outer_glow: OuterGlow,
|
||||
#[serde(skip_serializing_if = "is_default")]
|
||||
pub gradient_feather: GradientFeather,
|
||||
#[serde(skip_serializing_if = "is_default")]
|
||||
pub inner_glow: InnerGlow,
|
||||
#[serde(skip_serializing_if = "is_default")]
|
||||
pub bevel: Bevel,
|
||||
#[serde(skip_serializing_if = "is_default")]
|
||||
pub satin: Satin,
|
||||
#[serde(skip_serializing_if = "is_default")]
|
||||
pub directional_feather: DirectionalFeather,
|
||||
}
|
||||
|
||||
/// Basic Feather › Corners: how the fade follows the shape's corners.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum FeatherCorners {
|
||||
/// Mitred: the fade keeps the corners sharp.
|
||||
Sharp,
|
||||
/// The fade rounds the corners by its width.
|
||||
Rounded,
|
||||
/// From opaque to transparent, softly (InDesign's default).
|
||||
#[default]
|
||||
Diffused,
|
||||
}
|
||||
|
||||
/// Basic Feather options besides its width.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct FeatherOptions {
|
||||
/// Percentage (0–100) of the width that stays opaque: the fade gets shorter.
|
||||
pub choke: f64,
|
||||
pub corners: FeatherCorners,
|
||||
}
|
||||
|
||||
/// Inner/Outer Glow › Technique.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum GlowTechnique {
|
||||
/// A soft, blurred glow.
|
||||
#[default]
|
||||
Softer,
|
||||
/// A glow that keeps closer to the shape's edges.
|
||||
Precise,
|
||||
}
|
||||
|
||||
/// Directional Feather: each side fades over its own width (item space, before rotation).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct DirectionalFeather {
|
||||
pub on: bool,
|
||||
/// Top, left, bottom, right widths in points.
|
||||
pub widths: [f64; 4],
|
||||
}
|
||||
|
||||
/// Inner Glow: a glow inside the edge (or from the centre).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct InnerGlow {
|
||||
pub on: bool,
|
||||
pub color: String,
|
||||
pub opacity: f32,
|
||||
pub size: f64,
|
||||
/// Percentage (0–100) of `size` that hardens the glow.
|
||||
pub choke: f64,
|
||||
/// Glow from the centre instead of the edges.
|
||||
pub center: bool,
|
||||
/// How the glow blends with the object (InDesign: Screen).
|
||||
pub mode: BlendMode,
|
||||
pub technique: GlowTechnique,
|
||||
}
|
||||
|
||||
impl Default for InnerGlow {
|
||||
fn default() -> Self {
|
||||
InnerGlow {
|
||||
on: false,
|
||||
color: designcraft_color::swatch::PAPER.into(),
|
||||
opacity: 0.75,
|
||||
size: 7.0,
|
||||
choke: 0.0,
|
||||
center: false,
|
||||
mode: BlendMode::Screen,
|
||||
technique: GlowTechnique::Softer,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bevel and Emboss (inner bevel): a highlight on the lit edges, a shadow on the others.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct Bevel {
|
||||
pub on: bool,
|
||||
pub size: f64,
|
||||
/// Depth in percent (scales the highlight and shadow offset).
|
||||
pub depth: f64,
|
||||
pub angle: f64,
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub global_light: bool,
|
||||
pub highlight: String,
|
||||
pub highlight_opacity: f32,
|
||||
pub shadow: String,
|
||||
pub shadow_opacity: f32,
|
||||
}
|
||||
|
||||
impl Default for Bevel {
|
||||
fn default() -> Self {
|
||||
Bevel {
|
||||
on: false,
|
||||
size: 7.0,
|
||||
depth: 100.0,
|
||||
angle: 120.0,
|
||||
global_light: true,
|
||||
highlight: designcraft_color::swatch::PAPER.into(),
|
||||
highlight_opacity: 0.75,
|
||||
shadow: designcraft_color::swatch::BLACK.into(),
|
||||
shadow_opacity: 0.75,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Satin: soft interior shading from two offset copies of the shape.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct Satin {
|
||||
pub on: bool,
|
||||
pub color: String,
|
||||
pub opacity: f32,
|
||||
pub angle: f64,
|
||||
pub distance: f64,
|
||||
pub size: f64,
|
||||
pub invert: bool,
|
||||
}
|
||||
|
||||
impl Default for Satin {
|
||||
fn default() -> Self {
|
||||
Satin { on: false, color: designcraft_color::swatch::BLACK.into(), opacity: 0.5, angle: 120.0, distance: 7.0, size: 7.0, invert: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// Gradient Feather: the object fades from `start` to `end` opacity along a gradient.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct GradientFeather {
|
||||
pub on: bool,
|
||||
pub radial: bool,
|
||||
/// Degrees (linear), when no vector was dragged.
|
||||
pub angle: f64,
|
||||
pub start: f32,
|
||||
pub end: f32,
|
||||
/// Gradient Feather tool vector in the item's space: start and end (linear), centre and
|
||||
/// radius point (radial).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub vector: Option<[f64; 4]>,
|
||||
}
|
||||
|
||||
impl Default for GradientFeather {
|
||||
fn default() -> Self {
|
||||
GradientFeather { on: false, radial: false, angle: 0.0, start: 1.0, end: 0.0, vector: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl GradientFeather {
|
||||
/// The gradient's two points in item space for an object with inner bounds `b`.
|
||||
pub fn points(&self, b: designcraft_geom::Rect) -> (designcraft_geom::Point, designcraft_geom::Point) {
|
||||
use designcraft_geom::{Point, Vec2};
|
||||
if let Some([x0, y0, x1, y1]) = self.vector {
|
||||
return (Point::new(x0, y0), Point::new(x1, y1));
|
||||
}
|
||||
let c = b.center();
|
||||
if self.radial {
|
||||
return (c, c + Vec2::new(b.width().max(b.height()) / 2.0, 0.0));
|
||||
}
|
||||
let a = self.angle.to_radians();
|
||||
let d = Vec2::new(a.cos(), -a.sin());
|
||||
let half = (b.width() * d.x.abs() + b.height() * d.y.abs()) / 2.0;
|
||||
(c - d * half, c + d * half)
|
||||
}
|
||||
}
|
||||
|
||||
impl Effects {
|
||||
/// These effects with opacities in 0–1, percentages in 0–100 and sizes, widths and distances
|
||||
/// not below 0, or an error naming the first value that isn't a finite number.
|
||||
pub fn sanitized(mut self) -> Result<Effects, String> {
|
||||
fn finite(name: &str, v: f64) -> Result<f64, String> {
|
||||
if v.is_finite() { Ok(v) } else { Err(format!("{name} must be a finite number")) }
|
||||
}
|
||||
let len = |name: &str, v: f64| finite(name, v).map(|v| v.max(0.0));
|
||||
let pct = |name: &str, v: f64| finite(name, v).map(|v| v.clamp(0.0, 100.0));
|
||||
let opacity = |name: &str, v: f32| finite(name, f64::from(v)).map(|v| v.clamp(0.0, 1.0) as f32);
|
||||
let ds = &mut self.drop_shadow;
|
||||
ds.opacity = opacity("dropShadow.opacity", ds.opacity)?;
|
||||
ds.angle = finite("dropShadow.angle", ds.angle)?;
|
||||
ds.distance = len("dropShadow.distance", ds.distance)?;
|
||||
ds.size = len("dropShadow.size", ds.size)?;
|
||||
ds.spread = pct("dropShadow.spread", ds.spread)?;
|
||||
self.feather = len("feather", self.feather)?;
|
||||
let e = &mut self.inner_shadow;
|
||||
e.opacity = opacity("innerShadow.opacity", e.opacity)?;
|
||||
e.angle = finite("innerShadow.angle", e.angle)?;
|
||||
e.distance = len("innerShadow.distance", e.distance)?;
|
||||
e.size = len("innerShadow.size", e.size)?;
|
||||
e.choke = pct("innerShadow.choke", e.choke)?;
|
||||
let e = &mut self.outer_glow;
|
||||
e.opacity = opacity("outerGlow.opacity", e.opacity)?;
|
||||
e.size = len("outerGlow.size", e.size)?;
|
||||
e.spread = pct("outerGlow.spread", e.spread)?;
|
||||
let e = &mut self.gradient_feather;
|
||||
e.angle = finite("gradientFeather.angle", e.angle)?;
|
||||
e.start = opacity("gradientFeather.start", e.start)?;
|
||||
e.end = opacity("gradientFeather.end", e.end)?;
|
||||
if let Some(v) = &mut e.vector {
|
||||
for c in v.iter_mut() {
|
||||
*c = finite("gradientFeather.vector", *c)?;
|
||||
}
|
||||
}
|
||||
let e = &mut self.inner_glow;
|
||||
e.opacity = opacity("innerGlow.opacity", e.opacity)?;
|
||||
e.size = len("innerGlow.size", e.size)?;
|
||||
e.choke = pct("innerGlow.choke", e.choke)?;
|
||||
let e = &mut self.bevel;
|
||||
e.size = len("bevel.size", e.size)?;
|
||||
e.depth = len("bevel.depth", e.depth)?;
|
||||
e.angle = finite("bevel.angle", e.angle)?;
|
||||
e.highlight_opacity = opacity("bevel.highlightOpacity", e.highlight_opacity)?;
|
||||
e.shadow_opacity = opacity("bevel.shadowOpacity", e.shadow_opacity)?;
|
||||
let e = &mut self.satin;
|
||||
e.opacity = opacity("satin.opacity", e.opacity)?;
|
||||
e.angle = finite("satin.angle", e.angle)?;
|
||||
e.distance = len("satin.distance", e.distance)?;
|
||||
e.size = len("satin.size", e.size)?;
|
||||
for w in self.directional_feather.widths.iter_mut() {
|
||||
*w = len("directionalFeather.widths", *w)?;
|
||||
}
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// Any effect drawn with soft (raster) filters?
|
||||
pub fn any(&self) -> bool {
|
||||
self.drop_shadow.on
|
||||
|| self.feather > 0.0
|
||||
|| self.inner_shadow.on
|
||||
|| self.outer_glow.on
|
||||
|| self.gradient_feather.on
|
||||
|| self.inner_glow.on
|
||||
|| self.bevel.on
|
||||
|| self.satin.on
|
||||
|| self.directional_feather.on
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
use super::content::Content;
|
||||
use super::paint::Fill;
|
||||
use super::text_frame::TextWrap;
|
||||
use super::{Affine, Arc, AssetId, Deserialize, Item, ItemId, PathData, Rect, Serialize};
|
||||
|
||||
/// Frame fitting options (Object → Fitting → Frame Fitting Options).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Fitting {
|
||||
#[default]
|
||||
None,
|
||||
FillProportionally,
|
||||
FitProportionally,
|
||||
FitContentToFrame,
|
||||
CenterContent,
|
||||
}
|
||||
|
||||
/// Retained IDML layer choices for one placement, independent of shared asset bytes.
|
||||
/// Scalar attributes are kept verbatim, including view/export/print state and link-update policy.
|
||||
/// XML Self IDs are regenerated by the exporter and are not stored here.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GraphicLayerOptions {
|
||||
pub attributes: std::collections::BTreeMap<String, String>,
|
||||
pub layers: Vec<std::collections::BTreeMap<String, String>>,
|
||||
/// Parent indices in pre-order; absent entries are top-level layers (older native files).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub parents: Vec<Option<usize>>,
|
||||
}
|
||||
|
||||
pub const GRAPHIC_LAYER_RENDERING_WARNING: &str =
|
||||
"Placed graphic layer visibility overrides are preserved but are not applied to rendering or PDF output";
|
||||
|
||||
impl GraphicLayerOptions {
|
||||
pub fn has_visibility_overrides(&self) -> bool {
|
||||
self.layers.iter().any(|layer| {
|
||||
layer.get("CurrentVisibility").is_some_and(|v| Some(v) != layer.get("OriginalVisibility"))
|
||||
|| ["HasViewState", "HasExportState", "HasPrintState"].iter().any(|key| layer.get(*key).is_some_and(|v| v == "true"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A placed graphic inside a frame.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Graphic {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub layer_options: Option<GraphicLayerOptions>,
|
||||
pub asset: AssetId,
|
||||
/// Natural size of the graphic in points (pixels at its resolution, or PDF page size).
|
||||
pub size: (f64, f64),
|
||||
/// Graphic space (0,0)-(w,h) → frame inner space.
|
||||
pub xf: Affine,
|
||||
/// Clipping path: only the part of the graphic inside it shows.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub clip: Option<Arc<ClippingPath>>,
|
||||
#[serde(default)]
|
||||
pub auto_fit: Fitting,
|
||||
/// Frame Fitting Options › Align From: the reference point (0–8, row-major; 4 = centre).
|
||||
#[serde(default = "center_ref")]
|
||||
pub fit_align: u8,
|
||||
/// Frame Fitting Options › Crop Amount: top, left, bottom, right (negative adds space).
|
||||
#[serde(default)]
|
||||
pub crop: [f64; 4],
|
||||
/// Text wrap set on the graphic itself (Direct Selection), around the part its frame shows.
|
||||
#[serde(default, skip_serializing_if = "TextWrap::is_default")]
|
||||
pub wrap: TextWrap,
|
||||
/// The swatch applied to the image itself: InDesign colours a greyscale or bitmap image with
|
||||
/// it, colour images ignore it. `None` (the default, like InDesign's Black) leaves it as is.
|
||||
#[serde(default, skip_serializing_if = "Fill::is_none")]
|
||||
pub fill: Fill,
|
||||
}
|
||||
|
||||
fn center_ref() -> u8 {
|
||||
4
|
||||
}
|
||||
|
||||
/// Most anchors a clipping path read from a file keeps (detected edges can be dense).
|
||||
pub const MAX_CLIP_ANCHORS: usize = 100_000;
|
||||
|
||||
/// Clipping Path › Type: where the clipping path came from.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ClippingKind {
|
||||
/// Traced from the image's edges against a light background.
|
||||
DetectEdges,
|
||||
/// Traced from one of the image's alpha channels.
|
||||
AlphaChannel,
|
||||
/// A path saved in the image file.
|
||||
PhotoshopPath,
|
||||
/// One of the above, edited by hand.
|
||||
UserModifiedPath,
|
||||
}
|
||||
|
||||
/// A placed graphic's clipping path. The tracing options are kept as the file had them; the
|
||||
/// outline itself is `path`.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClippingPath {
|
||||
pub kind: ClippingKind,
|
||||
/// The outline in graphic space ((0,0)-(w,h) is the graphic's natural rectangle).
|
||||
pub path: PathData,
|
||||
/// Show the graphic outside the path instead of inside it.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub invert: bool,
|
||||
/// Holes inside the traced shape are part of the path.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub include_inside_edges: bool,
|
||||
/// Detect Edges: trace only the part of the image the frame shows.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub restrict_to_frame: bool,
|
||||
/// Trace the full-resolution image rather than the screen proxy.
|
||||
#[serde(default = "yes")]
|
||||
pub use_high_resolution: bool,
|
||||
/// Detect Edges / Alpha Channel: the darkness or opacity level that counts as inside (0–255).
|
||||
#[serde(default = "clip_threshold")]
|
||||
pub threshold: f64,
|
||||
/// How closely the traced path follows the pixels, in pixels.
|
||||
#[serde(default = "clip_tolerance")]
|
||||
pub tolerance: f64,
|
||||
/// Inset Frame: how far the path is pulled in (points; negative pushes it out).
|
||||
#[serde(default)]
|
||||
pub inset: f64,
|
||||
/// The alpha channel or image path the clip came from.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub source_name: String,
|
||||
/// Index of the alpha channel (-1 = none).
|
||||
#[serde(default = "no_index")]
|
||||
pub source_index: i32,
|
||||
}
|
||||
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn clip_threshold() -> f64 {
|
||||
25.0
|
||||
}
|
||||
|
||||
fn clip_tolerance() -> f64 {
|
||||
2.0
|
||||
}
|
||||
|
||||
fn no_index() -> i32 {
|
||||
-1
|
||||
}
|
||||
|
||||
impl ClippingPath {
|
||||
/// A clipping path of `kind` with the default options.
|
||||
pub fn new(kind: ClippingKind, path: PathData) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
path,
|
||||
invert: false,
|
||||
include_inside_edges: false,
|
||||
restrict_to_frame: false,
|
||||
use_high_resolution: true,
|
||||
threshold: clip_threshold(),
|
||||
tolerance: clip_tolerance(),
|
||||
inset: 0.0,
|
||||
source_name: String::new(),
|
||||
source_index: no_index(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Liquid Layout object rules: which edges stay at their distance from the page edge, and
|
||||
/// whether the object may stretch.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct ObjectLiquid {
|
||||
pub resize_width: bool,
|
||||
pub resize_height: bool,
|
||||
pub pin_top: bool,
|
||||
pub pin_bottom: bool,
|
||||
pub pin_left: bool,
|
||||
pub pin_right: bool,
|
||||
}
|
||||
|
||||
/// Object Export Options: tagged PDF and EPUB/HTML handling of one object.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct ExportOptions {
|
||||
/// Tagged PDF: the object is decoration (an artifact), not content.
|
||||
pub artifact: bool,
|
||||
/// EPUB/HTML: export the object as an image (whatever it is).
|
||||
pub rasterize: bool,
|
||||
/// EPUB/HTML: "left", "center" or "right" ("" = as the flow puts it).
|
||||
pub align: String,
|
||||
/// EPUB/HTML: start a new page (screen) before the object.
|
||||
pub page_break_before: bool,
|
||||
}
|
||||
|
||||
impl ExportOptions {
|
||||
pub fn is_default(&self) -> bool {
|
||||
*self == ExportOptions::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// A PDF form field.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FormField {
|
||||
pub kind: FieldKind,
|
||||
/// Field name (unique in the exported PDF).
|
||||
pub name: String,
|
||||
/// Default value: the text, the chosen option, or "On"/"" for a check box.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub value: String,
|
||||
/// Choices of a combo box or list box.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub options: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub multiline: bool,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub required: bool,
|
||||
/// Font size of the field's text (0 = auto).
|
||||
#[serde(default)]
|
||||
pub font_size: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum FieldKind {
|
||||
TextField,
|
||||
CheckBox,
|
||||
ComboBox,
|
||||
ListBox,
|
||||
Signature,
|
||||
}
|
||||
|
||||
/// The source and template of a live caption.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LiveCaption {
|
||||
pub source: ItemId,
|
||||
pub template: String,
|
||||
}
|
||||
|
||||
/// Media panel options for a placed video or sound.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct MediaOptions {
|
||||
pub play_on_page_load: bool,
|
||||
#[serde(rename = "loop")]
|
||||
pub looping: bool,
|
||||
/// Show the player controls.
|
||||
pub controls: bool,
|
||||
/// Poster image (shown on the page and in print); `None` = a placeholder frame.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub poster: Option<AssetId>,
|
||||
}
|
||||
|
||||
impl Item {
|
||||
/// The graphic to draw for this frame: a media frame with a poster draws the poster
|
||||
/// stretched over the media rectangle.
|
||||
pub fn drawn_graphic(&self, assets: &std::collections::BTreeMap<AssetId, Arc<crate::Asset>>) -> Option<Graphic> {
|
||||
let Content::Graphic(g) = &self.content else { return None };
|
||||
if let Some(poster) = self.media.as_ref().and_then(|m| m.poster)
|
||||
&& let Some((pw, ph)) = assets.get(&poster).and_then(|a| a.pixels)
|
||||
{
|
||||
let (pw, ph) = (pw.max(1) as f64, ph.max(1) as f64);
|
||||
return Some(Graphic { asset: poster, size: (pw, ph), xf: g.xf * Affine::scale_non_uniform(g.size.0 / pw, g.size.1 / ph), ..g.clone() });
|
||||
}
|
||||
Some(g.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// "video" or "audio" for a media MIME type.
|
||||
pub fn media_kind(mime: &str) -> Option<&'static str> {
|
||||
if mime.starts_with("video/") {
|
||||
Some("video")
|
||||
} else if mime.starts_with("audio/") {
|
||||
Some("audio")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// MIME type of a media file by its extension.
|
||||
pub fn media_mime(name: &str) -> Option<&'static str> {
|
||||
let ext = name.rsplit('.').next()?.to_ascii_lowercase();
|
||||
Some(match ext.as_str() {
|
||||
"mp4" | "m4v" => "video/mp4",
|
||||
"mov" => "video/quicktime",
|
||||
"webm" => "video/webm",
|
||||
"ogv" => "video/ogg",
|
||||
"mp3" => "audio/mpeg",
|
||||
"m4a" => "audio/mp4",
|
||||
"wav" => "audio/wav",
|
||||
"ogg" | "oga" => "audio/ogg",
|
||||
"aac" => "audio/aac",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// A button's On Release action.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind")]
|
||||
pub enum ButtonAction {
|
||||
GoToPage { page: usize },
|
||||
GoToFirstPage,
|
||||
GoToLastPage,
|
||||
GoToNextPage,
|
||||
GoToPreviousPage,
|
||||
GoToUrl { url: String },
|
||||
}
|
||||
|
||||
impl Graphic {
|
||||
/// The part of the graphic its frame shows, in the frame's inner space (bounding boxes);
|
||||
/// None when nothing shows.
|
||||
pub fn shown_bounds(&self, frame: Rect) -> Option<Rect> {
|
||||
let r = self.xf.transform_rect_bbox(Rect::new(0.0, 0.0, self.size.0, self.size.1)).intersect(frame);
|
||||
(r.width() > 0.0 && r.height() > 0.0).then_some(r)
|
||||
}
|
||||
|
||||
/// The region the clipping path lets through, in frame inner space, and whether it is filled
|
||||
/// even-odd (an inverted clip: the graphic's rectangle minus the path). `None` when the graphic
|
||||
/// has no clipping path or the path is empty.
|
||||
pub fn clip_region(&self) -> Option<(designcraft_geom::BezPath, bool)> {
|
||||
use designcraft_geom::Shape;
|
||||
let c = self.clip.as_ref().filter(|c| !c.path.is_empty())?;
|
||||
let mut path = c.path.to_bezpath();
|
||||
if !c.invert {
|
||||
path.apply_affine(self.xf);
|
||||
return Some((path, false));
|
||||
}
|
||||
let outer = Rect::new(0.0, 0.0, self.size.0, self.size.1).union(path.bounding_box()).inflate(1.0, 1.0);
|
||||
let mut region = outer.to_path(0.1);
|
||||
region.extend(path.iter());
|
||||
region.apply_affine(self.xf);
|
||||
Some((region, true))
|
||||
}
|
||||
|
||||
/// The graphic transform that fits it into `frame` (the frame's inner rect) with `mode`,
|
||||
/// honouring the crop amounts and the reference point.
|
||||
pub fn fitted(&self, frame: Rect, mode: Fitting) -> Option<Affine> {
|
||||
let (nw, nh) = self.size;
|
||||
if nw <= 0.0 || nh <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let [t, l, b, r] = self.crop;
|
||||
let area = Rect::new(frame.x0 - l, frame.y0 - t, frame.x1 + r, frame.y1 + b);
|
||||
if area.width() <= 0.0 || area.height() <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let (fx, fy) = ([0.0, 0.5, 1.0][(self.fit_align % 3) as usize], [0.0, 0.5, 1.0][(self.fit_align / 3).min(2) as usize]);
|
||||
let place = |w: f64, h: f64| (area.x0 + (area.width() - w) * fx, area.y0 + (area.height() - h) * fy);
|
||||
Some(match mode {
|
||||
Fitting::None => return None,
|
||||
Fitting::FillProportionally | Fitting::FitProportionally => {
|
||||
let k = if mode == Fitting::FillProportionally {
|
||||
(area.width() / nw).max(area.height() / nh)
|
||||
} else {
|
||||
(area.width() / nw).min(area.height() / nh)
|
||||
};
|
||||
Affine::translate(place(nw * k, nh * k)) * Affine::scale(k)
|
||||
}
|
||||
Fitting::FitContentToFrame => Affine::translate((area.x0, area.y0)) * Affine::scale_non_uniform(area.width() / nw, area.height() / nh),
|
||||
Fitting::CenterContent => {
|
||||
let cur = self.xf.transform_rect_bbox(Rect::new(0.0, 0.0, nw, nh));
|
||||
let (x, y) = place(cur.width(), cur.height());
|
||||
Affine::translate((x - cur.x0, y - cur.y0)) * self.xf
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
use super::{Deserialize, Point, Rect, Serialize};
|
||||
|
||||
/// Fill: a swatch reference and tint (InDesign fills always go through swatches or unnamed colours).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct Fill {
|
||||
pub swatch: String,
|
||||
pub tint: f32,
|
||||
/// Gradient angle (degrees) and length override for gradient swatches (`None` = fit to bounds).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub gradient_angle: Option<f64>,
|
||||
/// Gradient Swatch tool vector in the item's own space: start and end of a linear gradient,
|
||||
/// centre and radius point of a radial one (`None` = fit to bounds at `gradient_angle`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub gradient_vector: Option<[f64; 4]>,
|
||||
pub overprint: bool,
|
||||
}
|
||||
|
||||
impl Default for Fill {
|
||||
fn default() -> Self {
|
||||
Fill::none()
|
||||
}
|
||||
}
|
||||
|
||||
impl Fill {
|
||||
/// A radial gradient's ellipse on an object whose bounds (in its own space) are `b`: centre and
|
||||
/// horizontal and vertical radii. As in InDesign, a radial gradient stretches with the object's
|
||||
/// box: by default it's centred and reaches the box's sides (an ellipse in a non-square box);
|
||||
/// with a Gradient Swatch tool vector it's centred at the vector's start, and the vector's
|
||||
/// length, measured with the box made square, sets its size.
|
||||
pub fn radial_ellipse(&self, b: designcraft_geom::Rect) -> (designcraft_geom::Point, f64, f64) {
|
||||
let k = if b.width() > 1e-9 && b.height() > 1e-9 { b.height() / b.width() } else { 1.0 };
|
||||
match self.gradient_vector {
|
||||
Some([x0, y0, x1, y1]) => {
|
||||
let r = designcraft_geom::Vec2::new(x1 - x0, (y1 - y0) / k).hypot();
|
||||
(designcraft_geom::Point::new(x0, y0), r, r * k)
|
||||
}
|
||||
// A box with no width or height (a straight line's) has no ellipse: a circle across
|
||||
// its length.
|
||||
None if b.width() <= 1e-9 || b.height() <= 1e-9 => {
|
||||
let r = b.width().max(b.height()).max(1e-3) / 2.0;
|
||||
(b.center(), r, r)
|
||||
}
|
||||
None => (b.center(), b.width() / 2.0, b.height() / 2.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// A linear gradient's axis on an object whose bounds (in its own space) are `b`: the
|
||||
/// Gradient Swatch tool's vector, or the box's extent along the gradient's angle through its
|
||||
/// centre. A box with no extent along that angle (a straight line, across it) takes its longer
|
||||
/// side.
|
||||
pub fn linear_axis(&self, b: Rect) -> (Point, Point) {
|
||||
if let Some([x0, y0, x1, y1]) = self.gradient_vector {
|
||||
return (Point::new(x0, y0), Point::new(x1, y1));
|
||||
}
|
||||
let a = self.gradient_angle.unwrap_or(0.0).to_radians();
|
||||
let half = (b.width() * a.cos().abs() + b.height() * a.sin().abs()) / 2.0;
|
||||
let half = if half > 1e-9 { half } else { b.width().max(b.height()).max(1e-3) / 2.0 };
|
||||
let d = designcraft_geom::Vec2::new(a.cos(), -a.sin()) * half;
|
||||
(b.center() - d, b.center() + d)
|
||||
}
|
||||
|
||||
pub fn none() -> Self {
|
||||
Fill { swatch: designcraft_color::swatch::NONE.into(), tint: 1.0, gradient_angle: None, gradient_vector: None, overprint: false }
|
||||
}
|
||||
pub fn swatch(name: &str) -> Self {
|
||||
Fill { swatch: name.into(), ..Fill::none() }
|
||||
}
|
||||
pub fn is_none(&self) -> bool {
|
||||
self.swatch == designcraft_color::swatch::NONE
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum StrokeAlign {
|
||||
#[default]
|
||||
Center,
|
||||
Inside,
|
||||
Outside,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Cap {
|
||||
#[default]
|
||||
Butt,
|
||||
Round,
|
||||
Projecting,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Join {
|
||||
#[default]
|
||||
Miter,
|
||||
Round,
|
||||
Bevel,
|
||||
}
|
||||
|
||||
/// The shortest non-zero dash or gap a command accepts, in points.
|
||||
pub const MIN_DASH: f64 = 0.1;
|
||||
|
||||
/// The longest dash or gap a command accepts, in points.
|
||||
pub const MAX_DASH: f64 = 10_000.0;
|
||||
|
||||
/// The most lengths a dash pattern holds.
|
||||
pub const MAX_DASH_ENTRIES: usize = 64;
|
||||
|
||||
/// The most pattern repeats a renderer expands along one path; finer patterns draw solid.
|
||||
pub const MAX_DASH_PERIODS: f64 = 20_000.0;
|
||||
|
||||
/// An upper bound of the length of `bp`: the length of its control polygon.
|
||||
pub fn path_length_bound(bp: &designcraft_geom::BezPath) -> f64 {
|
||||
use designcraft_geom::PathSeg;
|
||||
bp.segments()
|
||||
.map(|s| match s {
|
||||
PathSeg::Line(l) => l.p0.distance(l.p1),
|
||||
PathSeg::Quad(q) => q.p0.distance(q.p1) + q.p1.distance(q.p2),
|
||||
PathSeg::Cubic(c) => c.p0.distance(c.p1) + c.p1.distance(c.p2) + c.p2.distance(c.p3),
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Stroke types (Stroke panel → Type). Dash/gap arrays are in points.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind")]
|
||||
pub enum StrokeType {
|
||||
#[default]
|
||||
Solid,
|
||||
Dashed {
|
||||
pattern: Vec<f64>,
|
||||
},
|
||||
Dotted,
|
||||
ThickThin,
|
||||
ThinThick,
|
||||
ThinThin,
|
||||
ThickThick,
|
||||
ThinThickThin,
|
||||
ThickThinThick,
|
||||
Wavy,
|
||||
Hashed,
|
||||
/// A custom striped stroke: bands (start, width) as fractions of the weight from the left edge.
|
||||
Stripes {
|
||||
bands: Vec<(f64, f64)>,
|
||||
},
|
||||
/// A named stroke style of the document (Stroke Styles), by name.
|
||||
Style {
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl StrokeType {
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
StrokeType::Solid => "Solid",
|
||||
StrokeType::Dashed { .. } => "Dashed",
|
||||
StrokeType::Dotted => "Dotted",
|
||||
StrokeType::ThickThin => "Thick - Thin",
|
||||
StrokeType::ThinThick => "Thin - Thick",
|
||||
StrokeType::ThinThin => "Thin - Thin",
|
||||
StrokeType::ThickThick => "Thick - Thick",
|
||||
StrokeType::ThinThickThin => "Thin - Thick - Thin",
|
||||
StrokeType::ThickThinThick => "Thick - Thin - Thick",
|
||||
StrokeType::Wavy => "Wavy",
|
||||
StrokeType::Hashed => "Straight Hash",
|
||||
StrokeType::Stripes { .. } => "Stripes",
|
||||
StrokeType::Style { .. } => "Custom",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `pattern` is a dash pattern commands accept: 1 to [`MAX_DASH_ENTRIES`] lengths,
|
||||
/// each 0 or from [`MIN_DASH`] to [`MAX_DASH`] points, not all 0.
|
||||
pub fn valid_dash_pattern(pattern: &[f64]) -> bool {
|
||||
!pattern.is_empty()
|
||||
&& pattern.len() <= MAX_DASH_ENTRIES
|
||||
&& pattern.iter().all(|x| *x == 0.0 || (MIN_DASH..=MAX_DASH).contains(x))
|
||||
&& pattern.iter().any(|x| *x > 0.0)
|
||||
}
|
||||
|
||||
/// Whether a renderer may expand `pattern` along a path at most `length` points long: at
|
||||
/// most [`MAX_DASH_ENTRIES`] finite, non-negative lengths that repeat at most
|
||||
/// [`MAX_DASH_PERIODS`] times. Otherwise the stroke draws solid, so a degenerate pattern
|
||||
/// from a file can't stall rendering.
|
||||
pub fn expandable_dashes(pattern: &[f64], length: f64) -> bool {
|
||||
let period: f64 = pattern.iter().sum();
|
||||
pattern.len() <= MAX_DASH_ENTRIES
|
||||
&& pattern.iter().all(|x| x.is_finite() && *x >= 0.0)
|
||||
&& period > 0.0
|
||||
&& length.is_finite()
|
||||
&& length / period <= MAX_DASH_PERIODS
|
||||
}
|
||||
|
||||
/// The bands of a striped type (start, width as fractions of the weight).
|
||||
pub fn bands(&self) -> Option<Vec<(f64, f64)>> {
|
||||
Some(match self {
|
||||
StrokeType::ThickThin => vec![(0.0, 0.5), (0.75, 0.25)],
|
||||
StrokeType::ThinThick => vec![(0.0, 0.25), (0.5, 0.5)],
|
||||
StrokeType::ThinThin => vec![(0.0, 1.0 / 3.0), (2.0 / 3.0, 1.0 / 3.0)],
|
||||
StrokeType::ThickThick => vec![(0.0, 0.4), (0.6, 0.4)],
|
||||
StrokeType::ThinThickThin => vec![(0.0, 0.2), (0.35, 0.3), (0.8, 0.2)],
|
||||
StrokeType::ThickThinThick => vec![(0.0, 0.35), (0.45, 0.1), (0.65, 0.35)],
|
||||
StrokeType::Stripes { bands } => bands.clone(),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The filled area for types drawn as fills (stripes, wavy, hash); `None` for solid, dashed
|
||||
/// and dotted strokes (and unresolved named styles).
|
||||
pub fn outline(&self, bp: &designcraft_geom::BezPath, weight: f64, tol: f64) -> Option<designcraft_geom::BezPath> {
|
||||
use designcraft_geom::stroke_style as ss;
|
||||
match self {
|
||||
StrokeType::Wavy => Some(ss::wavy(bp, weight, tol)),
|
||||
StrokeType::Hashed => Some(ss::hashed(bp, weight, tol)),
|
||||
t => t.bands().map(|b| ss::stripes(bp, weight, &b, tol)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A named custom stroke style (Stroke Styles dialog): dashes, dots or stripes.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StrokeStyleDef {
|
||||
pub name: String,
|
||||
pub kind: StrokeType,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Arrowhead {
|
||||
#[default]
|
||||
None,
|
||||
SimpleWide,
|
||||
Simple,
|
||||
Triangle,
|
||||
TriangleWide,
|
||||
Barbed,
|
||||
Curved,
|
||||
Circle,
|
||||
CircleSolid,
|
||||
Square,
|
||||
SquareSolid,
|
||||
Bar,
|
||||
}
|
||||
|
||||
impl Arrowhead {
|
||||
pub const ALL: [Arrowhead; 12] = [
|
||||
Arrowhead::None,
|
||||
Arrowhead::Simple,
|
||||
Arrowhead::SimpleWide,
|
||||
Arrowhead::Triangle,
|
||||
Arrowhead::TriangleWide,
|
||||
Arrowhead::Barbed,
|
||||
Arrowhead::Curved,
|
||||
Arrowhead::Circle,
|
||||
Arrowhead::CircleSolid,
|
||||
Arrowhead::Square,
|
||||
Arrowhead::SquareSolid,
|
||||
Arrowhead::Bar,
|
||||
];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Arrowhead::None => "None",
|
||||
Arrowhead::Simple => "Simple",
|
||||
Arrowhead::SimpleWide => "Simple - Wide",
|
||||
Arrowhead::Triangle => "Triangle",
|
||||
Arrowhead::TriangleWide => "Triangle - Wide",
|
||||
Arrowhead::Barbed => "Barbed",
|
||||
Arrowhead::Curved => "Curved",
|
||||
Arrowhead::Circle => "Circle",
|
||||
Arrowhead::CircleSolid => "Circle - Solid",
|
||||
Arrowhead::Square => "Square",
|
||||
Arrowhead::SquareSolid => "Square - Solid",
|
||||
Arrowhead::Bar => "Bar",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct Stroke {
|
||||
pub swatch: String,
|
||||
pub tint: f32,
|
||||
pub weight: f64,
|
||||
#[serde(rename = "type")]
|
||||
pub kind: StrokeType,
|
||||
pub align: StrokeAlign,
|
||||
pub cap: Cap,
|
||||
pub join: Join,
|
||||
pub miter_limit: f64,
|
||||
pub start: Arrowhead,
|
||||
pub end: Arrowhead,
|
||||
/// Gap colour for dashed/striped strokes.
|
||||
pub gap_swatch: String,
|
||||
pub gap_tint: f32,
|
||||
pub overprint: bool,
|
||||
/// Overprint Gap (Attributes panel).
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub gap_overprint: bool,
|
||||
/// A gradient swatch's angle on the stroke, as [`Fill::gradient_angle`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub gradient_angle: Option<f64>,
|
||||
/// The Gradient Swatch tool's vector on the stroke, as [`Fill::gradient_vector`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub gradient_vector: Option<[f64; 4]>,
|
||||
}
|
||||
|
||||
impl Default for Stroke {
|
||||
/// InDesign's default for drawn shapes: 1 pt black.
|
||||
fn default() -> Self {
|
||||
Stroke {
|
||||
swatch: designcraft_color::swatch::BLACK.into(),
|
||||
tint: 1.0,
|
||||
weight: 1.0,
|
||||
kind: StrokeType::Solid,
|
||||
align: StrokeAlign::Center,
|
||||
cap: Cap::Butt,
|
||||
join: Join::Miter,
|
||||
miter_limit: 4.0,
|
||||
start: Arrowhead::None,
|
||||
end: Arrowhead::None,
|
||||
gap_swatch: designcraft_color::swatch::NONE.into(),
|
||||
gap_tint: 1.0,
|
||||
overprint: false,
|
||||
gap_overprint: false,
|
||||
gradient_angle: None,
|
||||
gradient_vector: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Stroke {
|
||||
/// The stroke's paint (swatch, tint and gradient geometry) as a fill, to paint it the same way:
|
||||
/// a gradient swatch on a stroke runs across the object's box as on its fill.
|
||||
pub fn as_fill(&self) -> Fill {
|
||||
Fill {
|
||||
swatch: self.swatch.clone(),
|
||||
tint: self.tint,
|
||||
gradient_angle: self.gradient_angle,
|
||||
gradient_vector: self.gradient_vector,
|
||||
overprint: self.overprint,
|
||||
}
|
||||
}
|
||||
|
||||
/// How far the painted stroke can reach beyond the path (arrowheads reach further).
|
||||
pub fn extent(&self) -> f64 {
|
||||
if self.start == Arrowhead::None && self.end == Arrowhead::None { self.weight } else { self.weight.max(0.5) * 5.5 }
|
||||
}
|
||||
|
||||
pub fn none() -> Self {
|
||||
Stroke { swatch: designcraft_color::swatch::NONE.into(), ..Stroke::default() }
|
||||
}
|
||||
pub fn is_none(&self) -> bool {
|
||||
self.swatch == designcraft_color::swatch::NONE || self.weight <= 0.0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
use super::{Affine, Deserialize, PathData, Rect, Serialize, StoryId, is_default};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum VerticalJustification {
|
||||
#[default]
|
||||
Top,
|
||||
Center,
|
||||
Bottom,
|
||||
Justify,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum FirstBaseline {
|
||||
#[default]
|
||||
Ascent,
|
||||
CapHeight,
|
||||
Leading,
|
||||
XHeight,
|
||||
Fixed,
|
||||
/// The top of the font's em box: its `BASE` em box; else, for a CJK font, its OS/2 typo
|
||||
/// ascender when the typo ascender and descender span one em; else half an em above half the
|
||||
/// cap height.
|
||||
EmboxHeight,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum AutoSize {
|
||||
#[default]
|
||||
Off,
|
||||
HeightOnly,
|
||||
WidthOnly,
|
||||
HeightAndWidth,
|
||||
HeightAndWidthProportionally,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ColumnsKind {
|
||||
#[default]
|
||||
FixedNumber,
|
||||
FixedWidth,
|
||||
FlexibleWidth,
|
||||
}
|
||||
|
||||
/// Text Frame Options.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct TextFrameOptions {
|
||||
pub columns: u32,
|
||||
pub columns_kind: ColumnsKind,
|
||||
pub gutter: f64,
|
||||
/// Column width for fixed/flexible width columns.
|
||||
pub column_width: f64,
|
||||
pub balance_columns: bool,
|
||||
/// Inset spacing: top, left, bottom, right.
|
||||
pub inset: [f64; 4],
|
||||
pub vertical_justification: VerticalJustification,
|
||||
pub vj_paragraph_spacing_limit: f64,
|
||||
pub first_baseline: FirstBaseline,
|
||||
pub first_baseline_min: f64,
|
||||
pub ignore_wrap: bool,
|
||||
pub auto_size: AutoSize,
|
||||
/// Reference point (0..9, row-major from the top left) that stays fixed when auto-sizing.
|
||||
pub auto_size_ref: u8,
|
||||
/// Auto-size never makes the frame shorter than `auto_size_min_height` while this is on.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub auto_size_use_min_height: bool,
|
||||
#[serde(skip_serializing_if = "is_default")]
|
||||
pub auto_size_min_height: f64,
|
||||
/// Auto-size never makes the frame narrower than `auto_size_min_width` while this is on.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub auto_size_use_min_width: bool,
|
||||
#[serde(skip_serializing_if = "is_default")]
|
||||
pub auto_size_min_width: f64,
|
||||
/// Width-growing auto-size sets each paragraph on one line (the frame widens to the longest).
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub auto_size_no_line_breaks: bool,
|
||||
pub column_rule: bool,
|
||||
pub column_rule_weight: f64,
|
||||
pub column_rule_color: String,
|
||||
/// 0..1.
|
||||
pub column_rule_tint: f32,
|
||||
/// Horizontal offset of each rule from the middle of its gutter.
|
||||
pub column_rule_offset: f64,
|
||||
/// The rule starts this far below the column top and ends this far above its bottom.
|
||||
pub column_rule_top_inset: f64,
|
||||
pub column_rule_bottom_inset: f64,
|
||||
/// Use a custom baseline grid for this frame.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub baseline_grid: Option<FrameBaselineGrid>,
|
||||
/// Type on a Path: the text runs along the item's path instead of filling it.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<PathType>,
|
||||
}
|
||||
|
||||
/// A text frame's custom baseline grid. Saved as `[start, increment]` when it starts from the
|
||||
/// top of the page, otherwise as `[start, increment, relativeTo]`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct FrameBaselineGrid {
|
||||
/// The first grid line, down from the line `relative_to` names.
|
||||
pub start: f64,
|
||||
/// The distance between grid lines; 0 or less turns the grid off.
|
||||
pub increment: f64,
|
||||
pub relative_to: FrameGridRelative,
|
||||
}
|
||||
|
||||
impl FrameBaselineGrid {
|
||||
/// A grid measured from the top of the page.
|
||||
pub fn new(start: f64, increment: f64) -> Self {
|
||||
FrameBaselineGrid { start, increment, relative_to: FrameGridRelative::TopOfPage }
|
||||
}
|
||||
}
|
||||
|
||||
/// What a frame's custom baseline grid's start is measured from.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum FrameGridRelative {
|
||||
#[default]
|
||||
#[serde(rename = "page")]
|
||||
TopOfPage,
|
||||
#[serde(rename = "margin")]
|
||||
TopMargin,
|
||||
#[serde(rename = "frame")]
|
||||
TopOfFrame,
|
||||
#[serde(rename = "inset")]
|
||||
TopInset,
|
||||
}
|
||||
|
||||
impl FrameGridRelative {
|
||||
pub const ALL: [FrameGridRelative; 4] =
|
||||
[FrameGridRelative::TopOfPage, FrameGridRelative::TopMargin, FrameGridRelative::TopOfFrame, FrameGridRelative::TopInset];
|
||||
|
||||
/// The name commands and native files use: page, margin, frame or inset.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
FrameGridRelative::TopOfPage => "page",
|
||||
FrameGridRelative::TopMargin => "margin",
|
||||
FrameGridRelative::TopOfFrame => "frame",
|
||||
FrameGridRelative::TopInset => "inset",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_name(name: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|r| r.name() == name)
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for FrameBaselineGrid {
|
||||
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||
// Files saved before Relative To existed hold two elements; a page-relative grid keeps that form.
|
||||
match self.relative_to {
|
||||
FrameGridRelative::TopOfPage => (self.start, self.increment).serialize(s),
|
||||
r => (self.start, self.increment, r).serialize(s),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for FrameBaselineGrid {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
struct Grid;
|
||||
impl<'de> serde::de::Visitor<'de> for Grid {
|
||||
type Value = FrameBaselineGrid;
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
f.write_str("[start, increment] or [start, increment, relativeTo]")
|
||||
}
|
||||
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut a: A) -> Result<Self::Value, A::Error> {
|
||||
use serde::de::Error;
|
||||
let start = a.next_element()?.ok_or_else(|| A::Error::invalid_length(0, &self))?;
|
||||
let increment = a.next_element()?.ok_or_else(|| A::Error::invalid_length(1, &self))?;
|
||||
let relative_to = a.next_element()?.unwrap_or_default();
|
||||
if a.next_element::<serde::de::IgnoredAny>()?.is_some() {
|
||||
return Err(A::Error::invalid_length(4, &self));
|
||||
}
|
||||
Ok(FrameBaselineGrid { start, increment, relative_to })
|
||||
}
|
||||
}
|
||||
d.deserialize_seq(Grid)
|
||||
}
|
||||
}
|
||||
|
||||
/// Type on a Path Options.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct PathType {
|
||||
/// Where the text starts, as a distance along the path (the start bracket).
|
||||
pub start: f64,
|
||||
/// Where the text ends, as a distance along the path (the end bracket); `None` is the path's
|
||||
/// end. Text past it is overset.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub end: Option<f64>,
|
||||
/// Run the other way (the text sits on the other side).
|
||||
pub flip: bool,
|
||||
pub align: PathAlign,
|
||||
}
|
||||
|
||||
/// Which part of the type sits on the path.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PathAlign {
|
||||
#[default]
|
||||
Baseline,
|
||||
Ascender,
|
||||
Descender,
|
||||
Center,
|
||||
}
|
||||
|
||||
impl Default for TextFrameOptions {
|
||||
fn default() -> Self {
|
||||
TextFrameOptions {
|
||||
columns: 1,
|
||||
columns_kind: ColumnsKind::FixedNumber,
|
||||
gutter: 12.0,
|
||||
column_width: 0.0,
|
||||
balance_columns: false,
|
||||
inset: [0.0; 4],
|
||||
vertical_justification: VerticalJustification::Top,
|
||||
vj_paragraph_spacing_limit: 0.0,
|
||||
first_baseline: FirstBaseline::Ascent,
|
||||
first_baseline_min: 0.0,
|
||||
ignore_wrap: false,
|
||||
auto_size: AutoSize::Off,
|
||||
auto_size_ref: 4,
|
||||
auto_size_use_min_height: false,
|
||||
auto_size_min_height: 0.0,
|
||||
auto_size_use_min_width: false,
|
||||
auto_size_min_width: 0.0,
|
||||
auto_size_no_line_breaks: false,
|
||||
column_rule: false,
|
||||
column_rule_weight: 1.0,
|
||||
column_rule_color: designcraft_color::swatch::BLACK.into(),
|
||||
column_rule_tint: 1.0,
|
||||
column_rule_offset: 0.0,
|
||||
column_rule_top_inset: 0.0,
|
||||
column_rule_bottom_inset: 0.0,
|
||||
baseline_grid: None,
|
||||
path: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum WrapMode {
|
||||
#[default]
|
||||
None,
|
||||
BoundingBox,
|
||||
Contour,
|
||||
JumpObject,
|
||||
JumpToNextColumn,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum WrapSide {
|
||||
#[default]
|
||||
BothSides,
|
||||
RightSide,
|
||||
LeftSide,
|
||||
TowardsSpine,
|
||||
AwayFromSpine,
|
||||
LargestArea,
|
||||
}
|
||||
|
||||
/// Text Wrap panel › Contour Options › Type: the outline a contour wrap follows on a graphic frame.
|
||||
/// Other frames always wrap around their own path.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ContourType {
|
||||
/// The placed graphic's rectangle (within the frame).
|
||||
BoundingBox,
|
||||
/// Edges detected in the image. Composition follows the frame path.
|
||||
DetectEdges,
|
||||
/// The image's alpha channel. Composition follows the frame path.
|
||||
AlphaChannel,
|
||||
/// A path saved in the image. Composition follows the frame path.
|
||||
PhotoshopPath,
|
||||
/// The frame's path.
|
||||
GraphicFrame,
|
||||
/// The graphic's clipping path, or the frame's path when it has none.
|
||||
#[default]
|
||||
SameAsClipping,
|
||||
}
|
||||
|
||||
/// Text Wrap panel settings.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct TextWrap {
|
||||
pub mode: WrapMode,
|
||||
/// Top, left, bottom, right offsets.
|
||||
pub offsets: [f64; 4],
|
||||
pub invert: bool,
|
||||
pub side: WrapSide,
|
||||
pub contour: ContourType,
|
||||
/// A custom wrap boundary in the item's inner space, offsets already included. Bounding-box
|
||||
/// and contour wraps follow it instead of the frame; other modes use its bounds. Resizing the
|
||||
/// item transforms it with the path; other path edits keep it as it is.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<PathData>,
|
||||
}
|
||||
|
||||
impl TextWrap {
|
||||
pub fn is_default(&self) -> bool {
|
||||
*self == TextWrap::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Vertical text: composed in a box `area.height()` wide and `area.width()` tall at the origin,
|
||||
/// placed so lines go down and follow each other leftwards (a clockwise quarter turn).
|
||||
pub fn vertical_text_xf(area: Rect) -> Affine {
|
||||
Affine::new([0.0, 1.0, -1.0, 0.0, area.x1, area.y0])
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TextFrame {
|
||||
pub story: StoryId,
|
||||
#[serde(default)]
|
||||
pub options: TextFrameOptions,
|
||||
}
|
||||
+31
-14
@@ -20,6 +20,7 @@ pub mod cjk_settings;
|
||||
pub mod datamerge;
|
||||
mod edit;
|
||||
pub mod endnotes;
|
||||
pub mod file_info;
|
||||
pub mod flex;
|
||||
pub mod ids;
|
||||
pub mod index;
|
||||
@@ -53,6 +54,7 @@ pub use designcraft_color as color;
|
||||
pub use designcraft_geom as geom;
|
||||
pub use edit::{ItemLoc, ItemPath, SpreadRef, hit_descendant, item_hit as edit_hit};
|
||||
pub use endnotes::{ENDNOTE_REF, EditorialNote, EndnoteOptions, NOTE_MARK};
|
||||
pub use file_info::FileInfo;
|
||||
pub use flex::{FlexAlign, FlexDirection, FlexJustify, FlexLayout, FlexSize};
|
||||
pub use ids::*;
|
||||
pub use index::{INDEX_MARK, IndexRef};
|
||||
@@ -237,7 +239,8 @@ impl Document {
|
||||
}
|
||||
}
|
||||
|
||||
/// Ink Manager settings for output.
|
||||
/// Ink Manager settings for output, by swatch name. Output applies them through a
|
||||
/// `craft_prepress::InkSet` built from the document (`designcraft_pdf::inks`).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct InkManager {
|
||||
@@ -253,19 +256,6 @@ impl InkManager {
|
||||
fn is_default(&self) -> bool {
|
||||
*self == Self::default()
|
||||
}
|
||||
|
||||
/// The ink a spot swatch prints on (after aliases; at most a few hops) and whether it's
|
||||
/// converted to process.
|
||||
pub fn resolve<'a>(&'a self, ink: &'a str) -> (&'a str, bool) {
|
||||
let mut n = ink;
|
||||
for _ in 0..8 {
|
||||
match self.aliases.iter().find(|(a, _)| a == n) {
|
||||
Some((_, to)) if to != n => n = to,
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
(n, self.all_to_process || self.to_process.iter().any(|x| x == n))
|
||||
}
|
||||
}
|
||||
|
||||
/// XML tags and mappings.
|
||||
@@ -343,6 +333,26 @@ pub enum ChapterSource {
|
||||
SameAsPrevious,
|
||||
}
|
||||
|
||||
/// EPUB Export Options › Split Document: where a reflowable EPUB starts a new content file.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum EpubSplit {
|
||||
/// One content file.
|
||||
#[default]
|
||||
Off,
|
||||
/// Single Paragraph Style: a file starts before each paragraph of the named style.
|
||||
Style(String),
|
||||
/// Based on Paragraph Style Export Tags: a file starts before each paragraph of a style whose
|
||||
/// Export Tagging has Split Document on.
|
||||
ExportTags,
|
||||
}
|
||||
|
||||
impl EpubSplit {
|
||||
pub fn is_off(&self) -> bool {
|
||||
*self == EpubSplit::Off
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct DocSettings {
|
||||
@@ -417,6 +427,9 @@ pub struct DocSettings {
|
||||
/// The fill and stroke new shapes are drawn with: what the Tools panel's proxy shows and sets
|
||||
/// with nothing selected (InDesign's document defaults).
|
||||
pub new_object: NewObjectColors,
|
||||
/// EPUB Export Options › Split Document.
|
||||
#[serde(skip_serializing_if = "EpubSplit::is_off")]
|
||||
pub epub_split: EpubSplit,
|
||||
}
|
||||
|
||||
fn is_true(b: &bool) -> bool {
|
||||
@@ -482,6 +495,7 @@ impl Default for DocSettings {
|
||||
zero_point_locked: false,
|
||||
column_guides_locked: true,
|
||||
new_object: NewObjectColors::default(),
|
||||
epub_split: EpubSplit::Off,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -640,6 +654,9 @@ fn is_zero(v: &u32) -> bool {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Document {
|
||||
pub title: String,
|
||||
/// File › File Info.
|
||||
#[serde(default, skip_serializing_if = "FileInfo::is_empty")]
|
||||
pub file_info: FileInfo,
|
||||
pub settings: DocSettings,
|
||||
pub spreads: Vec<Arc<Spread>>,
|
||||
pub parents: Vec<Arc<Spread>>,
|
||||
|
||||
@@ -262,7 +262,7 @@ impl crate::Document {
|
||||
let Some(me) = self.story(sid) else { return o.start_at };
|
||||
let mine = key(me);
|
||||
let before: usize = self.stories.values().filter(|st| st.id != sid && !st.notes.is_empty() && key(st) < mine).map(|st| st.notes.len()).sum();
|
||||
o.start_at + before as u32
|
||||
o.start_at.saturating_add(before as u32)
|
||||
}
|
||||
|
||||
/// Absolute page an item sits on (its center), None on parent pages / pasteboard-only spreads.
|
||||
|
||||
@@ -125,6 +125,9 @@ pub struct Story {
|
||||
/// right to left, in every frame of the thread.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub vertical: bool,
|
||||
/// Story ▸ Optical Margin Alignment.
|
||||
#[serde(default, skip_serializing_if = "OpticalMargin::is_default")]
|
||||
pub optical_margin: OpticalMargin,
|
||||
/// Bumped on every edit (composition cache key).
|
||||
#[serde(default)]
|
||||
pub rev: u64,
|
||||
@@ -160,6 +163,45 @@ pub struct Story {
|
||||
pub file_link: Option<TextFileLink>,
|
||||
}
|
||||
|
||||
/// Story ▸ Optical Margin Alignment: punctuation and the edges of some letters hang outside the
|
||||
/// frame's edges, in every paragraph of the story except those with Ignore Optical Margin.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct OpticalMargin {
|
||||
pub on: bool,
|
||||
/// The type size (points) the hang amounts are set for: text of this size hangs by the
|
||||
/// built-in proportions of each character's advance, and the hang grows with this size.
|
||||
pub size: f64,
|
||||
}
|
||||
|
||||
impl Default for OpticalMargin {
|
||||
fn default() -> Self {
|
||||
OpticalMargin { on: false, size: Self::DEFAULT_SIZE }
|
||||
}
|
||||
}
|
||||
|
||||
impl OpticalMargin {
|
||||
pub const DEFAULT_SIZE: f64 = 12.0;
|
||||
/// InDesign's range for the size.
|
||||
pub const MIN_SIZE: f64 = 0.1;
|
||||
pub const MAX_SIZE: f64 = 1296.0;
|
||||
|
||||
/// `v` clamped to the size range, or `None` when it isn't a finite number.
|
||||
pub fn clamp_size(v: f64) -> Option<f64> {
|
||||
v.is_finite().then(|| v.clamp(Self::MIN_SIZE, Self::MAX_SIZE))
|
||||
}
|
||||
|
||||
/// The size the story's text hangs for, when the setting is on (a stored size outside the
|
||||
/// range is clamped, a non-finite one reads as the default).
|
||||
pub fn hang_size(&self) -> Option<f64> {
|
||||
self.on.then(|| Self::clamp_size(self.size).unwrap_or(Self::DEFAULT_SIZE))
|
||||
}
|
||||
|
||||
pub fn is_default(&self) -> bool {
|
||||
*self == Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// A linked text file: where a placed story came from, what the file held, and the Word Import
|
||||
/// Options that brought it in, so Update Link can import it again the same way.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
@@ -201,6 +243,7 @@ impl Story {
|
||||
frames: vec![],
|
||||
direction: crate::TextDirection::LeftToRight,
|
||||
vertical: false,
|
||||
optical_margin: OpticalMargin::default(),
|
||||
rev: 0,
|
||||
tables: BTreeMap::new(),
|
||||
notes: Vec::new(),
|
||||
@@ -596,6 +639,22 @@ mod tests {
|
||||
Story::with_text(StoryId(1), t, ParaFormat::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optical_margin_is_optional_in_saved_stories() {
|
||||
let plain = serde_json::to_value(st("Text")).unwrap();
|
||||
assert!(plain.get("opticalMargin").is_none());
|
||||
let old: Story = serde_json::from_value(plain).unwrap();
|
||||
assert_eq!(old.optical_margin, OpticalMargin { on: false, size: 12.0 });
|
||||
let mut s = st("Text");
|
||||
s.optical_margin = OpticalMargin { on: true, size: 18.0 };
|
||||
let back: Story = serde_json::from_value(serde_json::to_value(&s).unwrap()).unwrap();
|
||||
assert_eq!(back.optical_margin, s.optical_margin);
|
||||
// A hostile stored size is clamped where it is used.
|
||||
assert_eq!(OpticalMargin { on: true, size: 1e9 }.hang_size(), Some(OpticalMargin::MAX_SIZE));
|
||||
assert_eq!(OpticalMargin { on: true, size: -3.0 }.hang_size(), Some(OpticalMargin::MIN_SIZE));
|
||||
assert_eq!(OpticalMargin { on: false, size: 18.0 }.hang_size(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraphs_track_newlines() {
|
||||
let mut s = st("one\ntwo");
|
||||
|
||||
@@ -113,6 +113,10 @@ pub struct ExportTag {
|
||||
pub tag: String,
|
||||
/// CSS class (empty = from the style name).
|
||||
pub class: String,
|
||||
/// Split Document (EPUB only): an EPUB content file starts before each paragraph of the
|
||||
/// style, when the document's EPUB split is based on export tags (`EpubSplit::ExportTags`).
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub split: bool,
|
||||
}
|
||||
|
||||
impl Styles {
|
||||
|
||||
@@ -17,6 +17,53 @@ struct Names<'a> {
|
||||
}
|
||||
|
||||
impl Names<'_> {
|
||||
/// Rename the swatch references in every paragraph, character, object, cell and table style.
|
||||
fn styles(&self, styles: &mut crate::styles::Styles) {
|
||||
let n = self;
|
||||
for p in &mut styles.paragraph {
|
||||
n.para(&mut p.para);
|
||||
n.chars(&mut p.chars);
|
||||
}
|
||||
for c in &mut styles.character {
|
||||
n.chars(&mut c.chars);
|
||||
}
|
||||
for o in &mut styles.object {
|
||||
if let Some(f) = &mut o.fill {
|
||||
n.fill(f);
|
||||
}
|
||||
if let Some(s) = &mut o.stroke {
|
||||
n.stroke(s);
|
||||
}
|
||||
if let Some(tf) = &mut o.text_frame {
|
||||
n.frame(tf);
|
||||
}
|
||||
}
|
||||
for c in &mut styles.cell {
|
||||
n.o(&mut c.fill);
|
||||
if let Some(s) = &mut c.stroke {
|
||||
n.cell_stroke(s);
|
||||
}
|
||||
for s in &mut c.strokes {
|
||||
n.stroke_attrs(s);
|
||||
}
|
||||
}
|
||||
for t in &mut styles.table {
|
||||
if let Some(b) = &mut t.border {
|
||||
n.cell_stroke(b);
|
||||
}
|
||||
for b in &mut t.borders {
|
||||
n.stroke_attrs(b);
|
||||
}
|
||||
if let Some(f) = &mut t.alt_rows {
|
||||
n.alt_fills(f);
|
||||
}
|
||||
n.alt_fill_attrs(&mut t.row_fills);
|
||||
n.alt_fill_attrs(&mut t.column_fills);
|
||||
n.alt_stroke_attrs(&mut t.row_strokes);
|
||||
n.alt_stroke_attrs(&mut t.column_strokes);
|
||||
}
|
||||
}
|
||||
|
||||
fn s(&self, v: &mut String) {
|
||||
if v == self.from {
|
||||
*v = self.to.to_string();
|
||||
@@ -142,9 +189,50 @@ impl Names<'_> {
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Give gradient stops that name a color swatch that swatch's current color, after a
|
||||
/// swatch edit. Stops whose swatch is gone or isn't a color keep the color they have.
|
||||
pub fn refresh_gradient_stops(&mut self) {
|
||||
let mut fresh: Vec<(usize, usize, designcraft_color::Color)> = Vec::new();
|
||||
for (wi, w) in self.swatches.iter().enumerate() {
|
||||
let SwatchValue::Gradient { gradient } = &w.value else { continue };
|
||||
for (si, stop) in gradient.stops.iter().enumerate() {
|
||||
let Some(name) = &stop.swatch else { continue };
|
||||
if designcraft_color::swatch::resolve_gradient(&self.swatches, name).is_some() {
|
||||
continue;
|
||||
}
|
||||
if let Some(c) = self.resolve_color(name, 1.0)
|
||||
&& c != stop.color
|
||||
{
|
||||
fresh.push((wi, si, c));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (wi, si, c) in fresh {
|
||||
if let Some(SwatchValue::Gradient { gradient }) = self.swatches.get_mut(wi).map(|w| &mut w.value)
|
||||
&& let Some(stop) = gradient.stops.get_mut(si)
|
||||
{
|
||||
stop.color = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn gradient stops that name swatch `name` into plain colors (the swatch is being
|
||||
/// deleted); each keeps the color it shows.
|
||||
pub fn forget_gradient_stop_swatch(&mut self, name: &str) {
|
||||
for w in &mut self.swatches {
|
||||
let SwatchValue::Gradient { gradient } = &mut w.value else { continue };
|
||||
for stop in &mut gradient.stops {
|
||||
if stop.swatch.as_deref() == Some(name) {
|
||||
stop.swatch = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Point every reference to swatch `from` at `to`: tints based on it, colour groups, inks,
|
||||
/// styles, page items (fills, strokes, gaps, effects, column rules), text, kenten and ruby
|
||||
/// colours, rules, shading, borders and border gaps, tables (with stroke gaps), footnotes,
|
||||
/// gradient stops,
|
||||
/// endnotes and the footnote rule. The swatch itself is renamed by the
|
||||
/// caller.
|
||||
pub fn rename_swatch_refs(&mut self, from: &str, to: &str) {
|
||||
@@ -153,8 +241,14 @@ impl Document {
|
||||
}
|
||||
let n = Names { from, to };
|
||||
for w in &mut self.swatches {
|
||||
if let SwatchValue::Tint { base, .. } = &mut w.value {
|
||||
n.s(base);
|
||||
match &mut w.value {
|
||||
SwatchValue::Tint { base, .. } => n.s(base),
|
||||
SwatchValue::Gradient { gradient } => {
|
||||
for stop in &mut gradient.stops {
|
||||
n.o(&mut stop.swatch);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for g in &mut self.color_groups {
|
||||
@@ -170,49 +264,7 @@ impl Document {
|
||||
n.s(b);
|
||||
}
|
||||
n.s(&mut self.footnote_options.rule.color);
|
||||
let styles = self.styles_mut();
|
||||
for p in &mut styles.paragraph {
|
||||
n.para(&mut p.para);
|
||||
n.chars(&mut p.chars);
|
||||
}
|
||||
for c in &mut styles.character {
|
||||
n.chars(&mut c.chars);
|
||||
}
|
||||
for o in &mut styles.object {
|
||||
if let Some(f) = &mut o.fill {
|
||||
n.fill(f);
|
||||
}
|
||||
if let Some(s) = &mut o.stroke {
|
||||
n.stroke(s);
|
||||
}
|
||||
if let Some(tf) = &mut o.text_frame {
|
||||
n.frame(tf);
|
||||
}
|
||||
}
|
||||
for c in &mut styles.cell {
|
||||
n.o(&mut c.fill);
|
||||
if let Some(s) = &mut c.stroke {
|
||||
n.cell_stroke(s);
|
||||
}
|
||||
for s in &mut c.strokes {
|
||||
n.stroke_attrs(s);
|
||||
}
|
||||
}
|
||||
for t in &mut styles.table {
|
||||
if let Some(b) = &mut t.border {
|
||||
n.cell_stroke(b);
|
||||
}
|
||||
for b in &mut t.borders {
|
||||
n.stroke_attrs(b);
|
||||
}
|
||||
if let Some(f) = &mut t.alt_rows {
|
||||
n.alt_fills(f);
|
||||
}
|
||||
n.alt_fill_attrs(&mut t.row_fills);
|
||||
n.alt_fill_attrs(&mut t.column_fills);
|
||||
n.alt_stroke_attrs(&mut t.row_strokes);
|
||||
n.alt_stroke_attrs(&mut t.column_strokes);
|
||||
}
|
||||
n.styles(self.styles_mut());
|
||||
for sp in self.spreads.iter_mut().chain(self.parents.iter_mut()) {
|
||||
for it in &mut Arc::make_mut(sp).items {
|
||||
n.item(Arc::make_mut(it), 0);
|
||||
|
||||
+7
-1350
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,348 @@
|
||||
use super::{Deserialize, FirstBaseline, ParaFormat, Serialize, Story, StoryId, StrokeType, VerticalJustification};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum RowHeightMode {
|
||||
/// Grows with the content (the height is a minimum).
|
||||
#[default]
|
||||
AtLeast,
|
||||
Exactly,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum RowKind {
|
||||
Header,
|
||||
#[default]
|
||||
Body,
|
||||
Footer,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct Row {
|
||||
pub height: f64,
|
||||
pub mode: RowHeightMode,
|
||||
pub kind: RowKind,
|
||||
}
|
||||
|
||||
impl Default for Row {
|
||||
fn default() -> Self {
|
||||
Row { height: 3.0, mode: RowHeightMode::AtLeast, kind: RowKind::Body }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Column {
|
||||
pub width: f64,
|
||||
}
|
||||
|
||||
/// One cell edge or the table border.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct CellStroke {
|
||||
pub weight: f64,
|
||||
pub color: String,
|
||||
pub tint: f32,
|
||||
pub kind: StrokeType,
|
||||
/// Painted in the gaps of dashed, dotted and striped types.
|
||||
#[serde(skip_serializing_if = "is_no_swatch")]
|
||||
pub gap_color: String,
|
||||
#[serde(skip_serializing_if = "is_full")]
|
||||
pub gap_tint: f32,
|
||||
}
|
||||
|
||||
fn is_no_swatch(s: &str) -> bool {
|
||||
s == designcraft_color::swatch::NONE
|
||||
}
|
||||
|
||||
fn is_full(t: &f32) -> bool {
|
||||
*t == 1.0
|
||||
}
|
||||
|
||||
impl Default for CellStroke {
|
||||
fn default() -> Self {
|
||||
CellStroke {
|
||||
weight: 1.0,
|
||||
color: designcraft_color::swatch::BLACK.into(),
|
||||
tint: 1.0,
|
||||
kind: StrokeType::Solid,
|
||||
gap_color: designcraft_color::swatch::NONE.into(),
|
||||
gap_tint: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CellStroke {
|
||||
pub fn none() -> Self {
|
||||
CellStroke { weight: 0.0, ..Default::default() }
|
||||
}
|
||||
pub fn is_visible(&self) -> bool {
|
||||
self.weight > 0.0 && self.color != designcraft_color::swatch::NONE
|
||||
}
|
||||
}
|
||||
|
||||
/// Table-level alternating row/column strokes. Retained independently of cell formatting
|
||||
/// so an explicit cell style or local edge continues to take precedence.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct AltStrokes {
|
||||
pub first: u32,
|
||||
pub next: u32,
|
||||
pub first_stroke: CellStroke,
|
||||
pub next_stroke: CellStroke,
|
||||
pub skip_first: u32,
|
||||
pub skip_last: u32,
|
||||
}
|
||||
|
||||
impl Default for AltStrokes {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
first: 0,
|
||||
next: 0,
|
||||
first_stroke: CellStroke::default(),
|
||||
next_stroke: CellStroke { weight: 0.25, ..Default::default() },
|
||||
skip_first: 0,
|
||||
skip_last: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AltStrokes {
|
||||
/// A phase-independent stroke for unskipped, visually uniform patterns. Unequal
|
||||
/// patterns and skip settings are preserved for round trips until their boundary
|
||||
/// phase is supported; they must not be rendered with a guessed phase.
|
||||
pub fn uniform_stroke(&self) -> Option<&CellStroke> {
|
||||
if self.skip_first > 0 || self.skip_last > 0 {
|
||||
return None;
|
||||
}
|
||||
match (self.first > 0, self.next > 0) {
|
||||
(true, false) => Some(&self.first_stroke),
|
||||
(false, true) => Some(&self.next_stroke),
|
||||
(true, true) if self.first_stroke == self.next_stroke || (!self.first_stroke.is_visible() && !self.next_stroke.is_visible()) => {
|
||||
Some(&self.first_stroke)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn no_flags(flags: &[bool; 4]) -> bool {
|
||||
!flags.iter().any(|v| *v)
|
||||
}
|
||||
|
||||
fn no_priorities(priorities: &[i32; 4]) -> bool {
|
||||
priorities.iter().all(|v| *v == 0)
|
||||
}
|
||||
|
||||
fn one() -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
fn full() -> f32 {
|
||||
1.0
|
||||
}
|
||||
|
||||
fn no_fill() -> String {
|
||||
designcraft_color::swatch::NONE.into()
|
||||
}
|
||||
|
||||
fn is_ascent(f: &FirstBaseline) -> bool {
|
||||
*f == FirstBaseline::Ascent
|
||||
}
|
||||
|
||||
fn is_zero_min(v: &f64) -> bool {
|
||||
*v == 0.0
|
||||
}
|
||||
|
||||
fn default_insets() -> [f64; 4] {
|
||||
[4.0; 4]
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Cell {
|
||||
#[serde(default = "one")]
|
||||
pub row_span: u32,
|
||||
#[serde(default = "one")]
|
||||
pub col_span: u32,
|
||||
/// Cell content (a frameless story).
|
||||
pub text: Story,
|
||||
#[serde(default = "no_fill")]
|
||||
pub fill: String,
|
||||
#[serde(default = "full")]
|
||||
pub fill_tint: f32,
|
||||
/// Top, left, bottom, right.
|
||||
#[serde(default = "default_insets")]
|
||||
pub insets: [f64; 4],
|
||||
#[serde(default)]
|
||||
pub vj: VerticalJustification,
|
||||
/// Cell Options › Text › First Baseline: how far the first line's baseline sits below the
|
||||
/// top inset (IDML `FirstBaselineOffset`), and its minimum (`MinimumFirstBaselineOffset`).
|
||||
#[serde(default, skip_serializing_if = "is_ascent")]
|
||||
pub first_baseline: FirstBaseline,
|
||||
#[serde(default, skip_serializing_if = "is_zero_min")]
|
||||
pub first_baseline_min: f64,
|
||||
/// Text rotation in degrees (only 0 is composed for now).
|
||||
#[serde(default)]
|
||||
pub rotation: f64,
|
||||
/// Edge strokes: top, left, bottom, right.
|
||||
#[serde(default)]
|
||||
pub strokes: [CellStroke; 4],
|
||||
/// Explicit cell-edge formatting takes precedence over the table border, in the same
|
||||
/// top, left, bottom, right order as `strokes`. Older documents keep their table border.
|
||||
#[serde(default)]
|
||||
pub border_overrides: [bool; 4],
|
||||
/// Whether an edge has supplied cell/style attributes, independently of perimeter priority.
|
||||
#[serde(default, skip_serializing_if = "no_flags")]
|
||||
pub stroke_defined: [bool; 4],
|
||||
/// IDML edge-conflict priorities, in top, left, bottom, right order.
|
||||
#[serde(default, skip_serializing_if = "no_priorities")]
|
||||
pub stroke_priorities: [i32; 4],
|
||||
/// Applied cell style ("" = [None]).
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub style: String,
|
||||
/// Graphic cell: an image in the cell instead of text (its `xf` maps into the cell's
|
||||
/// content box, origin at the box's top left).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub graphic: Option<crate::Graphic>,
|
||||
}
|
||||
|
||||
impl Default for Cell {
|
||||
fn default() -> Self {
|
||||
Cell {
|
||||
row_span: 1,
|
||||
col_span: 1,
|
||||
text: Story::new(StoryId(0)),
|
||||
fill: no_fill(),
|
||||
fill_tint: 1.0,
|
||||
insets: default_insets(),
|
||||
vj: VerticalJustification::Top,
|
||||
first_baseline: FirstBaseline::Ascent,
|
||||
first_baseline_min: 0.0,
|
||||
rotation: 0.0,
|
||||
strokes: Default::default(),
|
||||
border_overrides: [false; 4],
|
||||
stroke_priorities: [0; 4],
|
||||
stroke_defined: [false; 4],
|
||||
style: String::new(),
|
||||
graphic: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Cell {
|
||||
pub fn with_text(text: &str, para: ParaFormat) -> Cell {
|
||||
Cell { text: Story::with_text(StoryId(0), text, para), ..Default::default() }
|
||||
}
|
||||
/// A cell with this cell's formatting and no text (new rows / columns, unmerged cells).
|
||||
pub fn blank_like(&self) -> Cell {
|
||||
let mut c = Cell { row_span: 1, col_span: 1, ..self.clone() };
|
||||
let para = self.text.paras.first().cloned().map(|p| ParaFormat { table: None, ..p }).unwrap_or_default();
|
||||
let fmt = self.text.chars.first().map(|r| r.format.clone()).unwrap_or_default();
|
||||
c.text = Story::new(StoryId(0));
|
||||
c.text.paras[0] = para;
|
||||
c.text.chars[0].format = fmt;
|
||||
c
|
||||
}
|
||||
pub fn has_fill(&self) -> bool {
|
||||
self.fill != designcraft_color::swatch::NONE
|
||||
}
|
||||
}
|
||||
|
||||
/// Table → Table Options → Fills (alternating pattern).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct AltFills {
|
||||
pub first: u32,
|
||||
pub first_color: String,
|
||||
pub first_tint: f32,
|
||||
pub next: u32,
|
||||
pub next_color: String,
|
||||
pub next_tint: f32,
|
||||
pub skip_first: u32,
|
||||
pub skip_last: u32,
|
||||
}
|
||||
|
||||
impl Default for AltFills {
|
||||
fn default() -> Self {
|
||||
AltFills {
|
||||
first: 1,
|
||||
first_color: designcraft_color::swatch::BLACK.into(),
|
||||
first_tint: 0.2,
|
||||
next: 1,
|
||||
next_color: designcraft_color::swatch::NONE.into(),
|
||||
next_tint: 1.0,
|
||||
skip_first: 0,
|
||||
skip_last: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AltFills {
|
||||
/// Fill for the `i`-th of `n` body rows/columns, if the pattern paints it.
|
||||
pub fn fill_for(&self, i: usize, n: usize) -> Option<(&str, f32)> {
|
||||
let (sf, sl) = (self.skip_first as usize, self.skip_last as usize);
|
||||
if i < sf || i >= n.saturating_sub(sl) {
|
||||
return None;
|
||||
}
|
||||
let period = (u64::from(self.first) + u64::from(self.next)).max(1);
|
||||
let k = (i - sf) as u64 % period;
|
||||
let (c, t) = if k < u64::from(self.first) { (&self.first_color, self.first_tint) } else { (&self.next_color, self.next_tint) };
|
||||
(c != designcraft_color::swatch::NONE).then_some((c.as_str(), t))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct TableOptions {
|
||||
/// Logical column zero is drawn at the right edge for RTL tables.
|
||||
pub direction: crate::TextDirection,
|
||||
/// Outer border (overrides the outer cell edges).
|
||||
pub border: CellStroke,
|
||||
/// Per-side borders; absent sides use the legacy uniform `border`.
|
||||
#[serde(skip_serializing_if = "no_borders")]
|
||||
pub borders: [Option<CellStroke>; 4],
|
||||
pub space_before: f64,
|
||||
pub space_after: f64,
|
||||
/// Header rows repeat at the top of every text column / frame the table continues into.
|
||||
pub repeat_header: bool,
|
||||
pub repeat_footer: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub alt_rows: Option<AltFills>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub alt_cols: Option<AltFills>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub row_strokes: Option<AltStrokes>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub column_strokes: Option<AltStrokes>,
|
||||
}
|
||||
|
||||
fn no_borders(borders: &[Option<CellStroke>; 4]) -> bool {
|
||||
borders.iter().all(Option::is_none)
|
||||
}
|
||||
|
||||
impl TableOptions {
|
||||
pub fn border_for(&self, side: usize) -> &CellStroke {
|
||||
self.borders.get(side).and_then(Option::as_ref).unwrap_or(&self.border)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TableOptions {
|
||||
fn default() -> Self {
|
||||
TableOptions {
|
||||
direction: crate::TextDirection::LeftToRight,
|
||||
border: CellStroke::default(),
|
||||
borders: Default::default(),
|
||||
space_before: 4.0,
|
||||
space_after: -4.0,
|
||||
repeat_header: true,
|
||||
repeat_footer: true,
|
||||
alt_rows: None,
|
||||
alt_cols: None,
|
||||
row_strokes: None,
|
||||
column_strokes: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
use super::Table;
|
||||
use super::cell::{Cell, Column, RowKind};
|
||||
|
||||
impl Table {
|
||||
/// Insert `n` rows before row `at` (formatted like the neighbouring row).
|
||||
pub fn insert_rows(&mut self, at: usize, n: usize) {
|
||||
let at = at.min(self.nrows());
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
let nc = self.ncols();
|
||||
let src = if at < self.nrows() { at } else { at.saturating_sub(1) };
|
||||
let mut row = self.rows.get(src).cloned().unwrap_or_default();
|
||||
// A row inserted between body rows (or after the last header / before the first footer) is a body row.
|
||||
if at > 0 && at < self.nrows() && self.rows[at - 1].kind != self.rows[at].kind {
|
||||
row.kind = RowKind::Body;
|
||||
}
|
||||
if at == self.nrows() && row.kind == RowKind::Header {
|
||||
row.kind = RowKind::Body;
|
||||
}
|
||||
if at == 0 && row.kind == RowKind::Footer {
|
||||
row.kind = RowKind::Body;
|
||||
}
|
||||
let template: Vec<Cell> = (0..nc).map(|c| self.cell(src, c).map(Cell::blank_like).unwrap_or_default()).collect();
|
||||
let regions: Vec<_> = self
|
||||
.regions()
|
||||
.into_iter()
|
||||
.map(|(r, c, rs, cs)| {
|
||||
if r >= at {
|
||||
(r + n, c, rs, cs)
|
||||
} else if r + rs > at {
|
||||
(r, c, rs + n, cs)
|
||||
} else {
|
||||
(r, c, rs, cs)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
for k in 0..n {
|
||||
self.rows.insert(at + k, row.clone());
|
||||
let i = (at + k) * nc;
|
||||
for (c, cell) in template.iter().enumerate() {
|
||||
self.cells.insert(i + c, cell.clone());
|
||||
}
|
||||
}
|
||||
self.set_regions(®ions);
|
||||
}
|
||||
|
||||
/// Delete rows `at..at+n` (at least one row always remains).
|
||||
pub fn delete_rows(&mut self, at: usize, n: usize) {
|
||||
let nr = self.nrows();
|
||||
let at = at.min(nr);
|
||||
let n = n.min(nr - at).min(nr.saturating_sub(1));
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
let nc = self.ncols();
|
||||
let end = at + n;
|
||||
let mut regions = Vec::new();
|
||||
for (r, c, rs, cs) in self.regions() {
|
||||
let r1 = r + rs;
|
||||
if r >= at && r1 <= end {
|
||||
continue; // removed entirely
|
||||
}
|
||||
let new_rs = rs - (r1.min(end).saturating_sub(r.max(at)));
|
||||
if r >= at && r < end {
|
||||
// The owner row goes away: move its content to the first surviving row.
|
||||
let content = self.cells[r * nc + c].clone();
|
||||
self.cells[end * nc + c] = content;
|
||||
regions.push((at, c, new_rs, cs));
|
||||
} else if r < at {
|
||||
regions.push((r, c, new_rs, cs));
|
||||
} else {
|
||||
regions.push((r - n, c, rs, cs));
|
||||
}
|
||||
}
|
||||
self.rows.drain(at..end);
|
||||
self.cells.drain(at * nc..end * nc);
|
||||
self.set_regions(®ions);
|
||||
}
|
||||
|
||||
/// Insert `n` columns of `width` before column `at`.
|
||||
pub fn insert_cols(&mut self, at: usize, n: usize, width: f64) {
|
||||
let at = at.min(self.ncols());
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
let nc = self.ncols();
|
||||
let src = if at < nc { at } else { at.saturating_sub(1) };
|
||||
let regions: Vec<_> = self
|
||||
.regions()
|
||||
.into_iter()
|
||||
.map(|(r, c, rs, cs)| {
|
||||
if c >= at {
|
||||
(r, c + n, rs, cs)
|
||||
} else if c + cs > at {
|
||||
(r, c, rs, cs + n)
|
||||
} else {
|
||||
(r, c, rs, cs)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let mut cells = Vec::with_capacity(self.nrows() * (nc + n));
|
||||
for r in 0..self.nrows() {
|
||||
for c in 0..nc {
|
||||
if c == at {
|
||||
let t = self.cells[r * nc + src].blank_like();
|
||||
cells.extend(std::iter::repeat_n(t, n));
|
||||
}
|
||||
cells.push(self.cells[r * nc + c].clone());
|
||||
}
|
||||
if at == nc {
|
||||
let t = self.cells[r * nc + src].blank_like();
|
||||
cells.extend(std::iter::repeat_n(t, n));
|
||||
}
|
||||
}
|
||||
self.cells = cells;
|
||||
for k in 0..n {
|
||||
self.columns.insert(at + k, Column { width });
|
||||
}
|
||||
self.set_regions(®ions);
|
||||
}
|
||||
|
||||
/// Delete columns `at..at+n` (at least one column always remains).
|
||||
pub fn delete_cols(&mut self, at: usize, n: usize) {
|
||||
let nc = self.ncols();
|
||||
let at = at.min(nc);
|
||||
let n = n.min(nc - at).min(nc.saturating_sub(1));
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
let end = at + n;
|
||||
let mut regions = Vec::new();
|
||||
for (r, c, rs, cs) in self.regions() {
|
||||
let c1 = c + cs;
|
||||
if c >= at && c1 <= end {
|
||||
continue;
|
||||
}
|
||||
let new_cs = cs - (c1.min(end).saturating_sub(c.max(at)));
|
||||
if c >= at && c < end {
|
||||
let content = self.cells[r * nc + c].clone();
|
||||
self.cells[r * nc + end] = content;
|
||||
regions.push((r, at, rs, new_cs));
|
||||
} else if c < at {
|
||||
regions.push((r, c, rs, new_cs));
|
||||
} else {
|
||||
regions.push((r, c - n, rs, cs));
|
||||
}
|
||||
}
|
||||
let mut cells = Vec::with_capacity(self.nrows() * (nc - n));
|
||||
for r in 0..self.nrows() {
|
||||
for c in 0..nc {
|
||||
if c < at || c >= end {
|
||||
cells.push(self.cells[r * nc + c].clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
self.cells = cells;
|
||||
self.columns.drain(at..end);
|
||||
self.set_regions(®ions);
|
||||
}
|
||||
|
||||
/// Move row `from` to position `to` (indices before the move). Refused when merged cells span
|
||||
/// rows (the move would cut them) or when the move would take a row across a header/footer
|
||||
/// boundary (`from` and `to` must be the same kind of row).
|
||||
pub fn move_row(&mut self, from: usize, to: usize) -> Result<(), String> {
|
||||
let (nr, nc) = (self.nrows(), self.ncols());
|
||||
if from >= nr || to >= nr {
|
||||
return Err("no such row".into());
|
||||
}
|
||||
if self.regions().iter().any(|r| r.2 > 1) {
|
||||
return Err("unmerge cells that span rows first".into());
|
||||
}
|
||||
if self.rows[from].kind != self.rows[to].kind {
|
||||
return Err("header and footer rows must stay first and last".into());
|
||||
}
|
||||
let row = self.rows.remove(from);
|
||||
self.rows.insert(to, row);
|
||||
let cells: Vec<Cell> = self.cells.drain(from * nc..(from + 1) * nc).collect();
|
||||
let at = to * nc;
|
||||
for (k, c) in cells.into_iter().enumerate() {
|
||||
self.cells.insert(at + k, c);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Move column `from` to position `to`. Refused when merged cells span columns.
|
||||
pub fn move_col(&mut self, from: usize, to: usize) -> Result<(), String> {
|
||||
let (nr, nc) = (self.nrows(), self.ncols());
|
||||
if from >= nc || to >= nc {
|
||||
return Err("no such column".into());
|
||||
}
|
||||
if self.regions().iter().any(|r| r.3 > 1) {
|
||||
return Err("unmerge cells that span columns first".into());
|
||||
}
|
||||
let col = self.columns.remove(from);
|
||||
self.columns.insert(to, col);
|
||||
for r in 0..nr {
|
||||
let c = self.cells.remove(r * nc + from);
|
||||
self.cells.insert(r * nc + to, c);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mark the first `header` rows as header rows and the last `footer` rows as footer rows.
|
||||
pub fn set_header_footer(&mut self, header: usize, footer: usize) {
|
||||
let n = self.nrows();
|
||||
let header = header.min(n.saturating_sub(1));
|
||||
let footer = footer.min(n - header - 1);
|
||||
for (i, r) in self.rows.iter_mut().enumerate() {
|
||||
r.kind = if i < header {
|
||||
RowKind::Header
|
||||
} else if i >= n - footer {
|
||||
RowKind::Footer
|
||||
} else {
|
||||
RowKind::Body
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Header and footer row counts after Table ▸ Convert Rows turns rows `r0..=r1` into `kind`
|
||||
/// rows. Header rows grow from the top and footer rows from the bottom, so the rows must
|
||||
/// touch the table's edge or the rows already converted; back to body, they must touch the
|
||||
/// body. One body row stays, and no merged cell may straddle a moved boundary.
|
||||
pub fn convert_rows_counts(&self, r0: usize, r1: usize, kind: RowKind) -> Result<(usize, usize), String> {
|
||||
let (r0, r1) = (r0.min(r1), r0.max(r1));
|
||||
let n = self.nrows();
|
||||
if r1 >= n {
|
||||
return Err("row out of range".into());
|
||||
}
|
||||
let (h, f) = (self.header_rows(), self.footer_rows());
|
||||
let body_end = n - f;
|
||||
let (nh, nf) = match kind {
|
||||
RowKind::Header if r0 > h => return Err("select rows at the top of the table or right after its header rows".into()),
|
||||
RowKind::Header if r1 >= body_end => return Err("footer rows can't become header rows".into()),
|
||||
RowKind::Header => (h.max(r1 + 1), f),
|
||||
RowKind::Footer if r1 + 1 < body_end => return Err("select rows at the bottom of the table or right before its footer rows".into()),
|
||||
RowKind::Footer if r0 < h => return Err("header rows can't become footer rows".into()),
|
||||
RowKind::Footer => (h, f.max(n - r0)),
|
||||
RowKind::Body => {
|
||||
if (r0 < h && r1 + 1 < h) || (r1 >= body_end && r0 > body_end) {
|
||||
return Err("select the header or footer rows next to the body".into());
|
||||
}
|
||||
(if r0 < h { r0 } else { h }, if r1 >= body_end { n - 1 - r1 } else { f })
|
||||
}
|
||||
};
|
||||
if (nh, nf) == (h, f) {
|
||||
return Err("the rows already are of that kind".into());
|
||||
}
|
||||
if nh + nf >= n {
|
||||
return Err("a table keeps at least one body row".into());
|
||||
}
|
||||
let owners = self.owners();
|
||||
let nc = self.ncols();
|
||||
let straddled = |b: usize| (0..nc).any(|c| owners.get(b * nc + c).is_some_and(|o| o.0 < b));
|
||||
if (nh != h && straddled(nh)) || (nf != f && straddled(n - nf)) {
|
||||
return Err("unmerge the cells that span header, body and footer rows first".into());
|
||||
}
|
||||
Ok((nh, nf))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
use super::cell::{Cell, Column, Row, RowKind, TableOptions};
|
||||
use super::{CharFormat, Deserialize, ParaFormat, Serialize, Story, StoryId, Table};
|
||||
|
||||
/// A rectangular range of cells (inclusive).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CellRange {
|
||||
pub r0: usize,
|
||||
pub c0: usize,
|
||||
pub r1: usize,
|
||||
pub c1: usize,
|
||||
}
|
||||
|
||||
impl CellRange {
|
||||
pub fn cell(r: usize, c: usize) -> Self {
|
||||
CellRange { r0: r, c0: c, r1: r, c1: c }
|
||||
}
|
||||
pub fn new(ra: usize, ca: usize, rb: usize, cb: usize) -> Self {
|
||||
CellRange { r0: ra.min(rb), c0: ca.min(cb), r1: ra.max(rb), c1: ca.max(cb) }
|
||||
}
|
||||
pub fn contains(&self, r: usize, c: usize) -> bool {
|
||||
r >= self.r0 && r <= self.r1 && c >= self.c0 && c <= self.c1
|
||||
}
|
||||
}
|
||||
|
||||
impl Table {
|
||||
/// A `rows × cols` table (`header` + body + `footer` rows) whose columns share `width`.
|
||||
pub fn new(id: u64, body_rows: usize, cols: usize, header: usize, footer: usize, width: f64) -> Table {
|
||||
let cols = cols.max(1);
|
||||
let mut rows = Vec::new();
|
||||
rows.extend((0..header).map(|_| Row { kind: RowKind::Header, ..Default::default() }));
|
||||
rows.extend((0..body_rows.max(1)).map(|_| Row::default()));
|
||||
rows.extend((0..footer).map(|_| Row { kind: RowKind::Footer, ..Default::default() }));
|
||||
let w = (width / cols as f64).max(3.0);
|
||||
let n = rows.len() * cols;
|
||||
Table {
|
||||
id,
|
||||
rows,
|
||||
columns: vec![Column { width: w }; cols],
|
||||
cells: vec![Cell::default(); n],
|
||||
options: TableOptions::default(),
|
||||
style: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A table from rows of cell strings (Convert Text to Table). Short rows are padded.
|
||||
pub fn from_strings(id: u64, data: &[Vec<String>], width: f64, para: &ParaFormat, fmt: &CharFormat) -> Table {
|
||||
let cols = data.iter().map(Vec::len).max().unwrap_or(1).max(1);
|
||||
let mut t = Table::new(id, data.len().max(1), cols, 0, 0, width);
|
||||
for (r, row) in data.iter().enumerate() {
|
||||
for (c, s) in row.iter().enumerate() {
|
||||
let Some(cell) = t.cell_mut(r, c) else { continue };
|
||||
cell.text = Story::with_text(StoryId(0), "", ParaFormat { table: None, ..para.clone() });
|
||||
cell.text.chars[0].format = fmt.clone();
|
||||
cell.text.insert(0, s);
|
||||
}
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
pub fn nrows(&self) -> usize {
|
||||
self.rows.len()
|
||||
}
|
||||
|
||||
pub fn ncols(&self) -> usize {
|
||||
self.columns.len()
|
||||
}
|
||||
|
||||
pub fn width(&self) -> f64 {
|
||||
self.columns.iter().map(|c| c.width).sum()
|
||||
}
|
||||
|
||||
fn idx(&self, r: usize, c: usize) -> Option<usize> {
|
||||
(r < self.nrows() && c < self.ncols()).then(|| r * self.ncols() + c)
|
||||
}
|
||||
|
||||
pub fn cell(&self, r: usize, c: usize) -> Option<&Cell> {
|
||||
self.cells.get(self.idx(r, c)?)
|
||||
}
|
||||
|
||||
pub fn cell_mut(&mut self, r: usize, c: usize) -> Option<&mut Cell> {
|
||||
let i = self.idx(r, c)?;
|
||||
self.cells.get_mut(i)
|
||||
}
|
||||
|
||||
pub fn header_rows(&self) -> usize {
|
||||
self.rows.iter().take_while(|r| r.kind == RowKind::Header).count()
|
||||
}
|
||||
|
||||
pub fn footer_rows(&self) -> usize {
|
||||
self.rows.iter().rev().take_while(|r| r.kind == RowKind::Footer).count()
|
||||
}
|
||||
|
||||
pub fn body_rows(&self) -> std::ops::Range<usize> {
|
||||
self.header_rows()..self.nrows() - self.footer_rows()
|
||||
}
|
||||
|
||||
/// For every grid position, the (row, col) of the cell that owns it.
|
||||
pub fn owners(&self) -> Vec<(usize, usize)> {
|
||||
let (nr, nc) = (self.nrows(), self.ncols());
|
||||
let mut v: Vec<(usize, usize)> = (0..nr * nc).map(|i| (i / nc.max(1), i % nc.max(1))).collect();
|
||||
for r in 0..nr {
|
||||
for c in 0..nc {
|
||||
let cell = &self.cells[r * nc + c];
|
||||
if v[r * nc + c] != (r, c) {
|
||||
continue;
|
||||
}
|
||||
for rr in r..(r + cell.row_span.max(1) as usize).min(nr) {
|
||||
for cc in c..(c + cell.col_span.max(1) as usize).min(nc) {
|
||||
v[rr * nc + cc] = (r, c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Owning cells touched by the range, once each in row-major order. Bounds are clipped
|
||||
/// to the grid; resolving covered positions does not expand the selection rectangle.
|
||||
pub fn selected_owners(&self, range: CellRange) -> Vec<(usize, usize)> {
|
||||
let (nr, nc) = (self.nrows(), self.ncols());
|
||||
if range.r0 >= nr || range.c0 >= nc || range.r0 > range.r1 || range.c0 > range.c1 {
|
||||
return Vec::new();
|
||||
}
|
||||
let owners = self.owners();
|
||||
let mut selected = std::collections::BTreeSet::new();
|
||||
for r in range.r0..=range.r1.min(nr - 1) {
|
||||
for c in range.c0..=range.c1.min(nc - 1) {
|
||||
if let Some(&owner) = owners.get(r * nc + c) {
|
||||
selected.insert(owner);
|
||||
}
|
||||
}
|
||||
}
|
||||
selected.into_iter().collect()
|
||||
}
|
||||
|
||||
/// The owning (top-left) cell address of grid position (r, c).
|
||||
pub fn owner(&self, r: usize, c: usize) -> (usize, usize) {
|
||||
self.owners().get(r * self.ncols() + c).copied().unwrap_or((r, c))
|
||||
}
|
||||
|
||||
/// Merged regions `(r, c, row_span, col_span)` (span > 1 in some direction).
|
||||
pub fn regions(&self) -> Vec<(usize, usize, usize, usize)> {
|
||||
let owners = self.owners();
|
||||
let mut v = Vec::new();
|
||||
for r in 0..self.nrows() {
|
||||
for c in 0..self.ncols() {
|
||||
let cell = &self.cells[r * self.ncols() + c];
|
||||
if owners[r * self.ncols() + c] == (r, c) && (cell.row_span > 1 || cell.col_span > 1) {
|
||||
v.push((r, c, cell.row_span as usize, cell.col_span as usize));
|
||||
}
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
pub(super) fn set_regions(&mut self, regions: &[(usize, usize, usize, usize)]) {
|
||||
for c in &mut self.cells {
|
||||
c.row_span = 1;
|
||||
c.col_span = 1;
|
||||
}
|
||||
for &(r, c, rs, cs) in regions {
|
||||
if rs == 0 || cs == 0 {
|
||||
continue;
|
||||
}
|
||||
if let Some(cell) = self.cell_mut(r, c) {
|
||||
cell.row_span = rs as u32;
|
||||
cell.col_span = cs as u32;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cell texts, rows separated by `\n`, cells by `\t` (Convert Table to Text, search, agents).
|
||||
pub fn plain_text(&self) -> String {
|
||||
let mut out = String::new();
|
||||
for r in 0..self.nrows() {
|
||||
if r > 0 {
|
||||
out.push('\n');
|
||||
}
|
||||
for c in 0..self.ncols() {
|
||||
if c > 0 {
|
||||
out.push('\t');
|
||||
}
|
||||
if let Some(cell) = self.cell(r, c) {
|
||||
out.push_str(&cell.text.text.replace('\n', " "));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn check(&self) -> Result<(), String> {
|
||||
if self.rows.is_empty() || self.columns.is_empty() {
|
||||
return Err(format!("table {}: no rows or columns", self.id));
|
||||
}
|
||||
if self.cells.len() != self.nrows() * self.ncols() {
|
||||
return Err(format!("table {}: {} cells for {}×{}", self.id, self.cells.len(), self.nrows(), self.ncols()));
|
||||
}
|
||||
// Header rows first, footer rows last.
|
||||
let (h, f) = (self.header_rows(), self.footer_rows());
|
||||
if self.rows[h..self.nrows() - f].iter().any(|r| r.kind != RowKind::Body) {
|
||||
return Err(format!("table {}: header/footer rows out of order", self.id));
|
||||
}
|
||||
self.check_spans()?;
|
||||
for cell in &self.cells {
|
||||
cell.text.check()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every merged cell stays inside the grid and no two merged cells overlap.
|
||||
fn check_spans(&self) -> Result<(), String> {
|
||||
let mut seen = vec![false; self.cells.len()];
|
||||
for r in 0..self.nrows() {
|
||||
for c in 0..self.ncols() {
|
||||
let cell = &self.cells[r * self.ncols() + c];
|
||||
if cell.row_span == 0 || cell.col_span == 0 {
|
||||
return Err(format!("table {}: zero span at {r},{c}", self.id));
|
||||
}
|
||||
if cell.row_span == 1 && cell.col_span == 1 {
|
||||
continue;
|
||||
}
|
||||
if r + cell.row_span as usize > self.nrows() || c + cell.col_span as usize > self.ncols() {
|
||||
return Err(format!("table {}: span out of bounds at {r},{c}", self.id));
|
||||
}
|
||||
for rr in r..r + cell.row_span as usize {
|
||||
for cc in c..c + cell.col_span as usize {
|
||||
let i = rr * self.ncols() + cc;
|
||||
if seen[i] {
|
||||
return Err(format!("table {}: overlapping merged cells at {rr},{cc}", self.id));
|
||||
}
|
||||
seen[i] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
use super::cell::Row;
|
||||
#[cfg(test)]
|
||||
use super::cell::{RowHeightMode, RowKind};
|
||||
use super::grid::CellRange;
|
||||
use super::story::append_story;
|
||||
use super::{Story, Table};
|
||||
|
||||
impl Table {
|
||||
/// The smallest range containing `r` that doesn't cut through a merged region.
|
||||
pub fn expand_range(&self, mut r: CellRange) -> CellRange {
|
||||
loop {
|
||||
let mut grown = r;
|
||||
for (rr, cc, rs, cs) in self.regions() {
|
||||
let (re, ce) = (rr + rs - 1, cc + cs - 1);
|
||||
let overlaps = rr <= r.r1 && re >= r.r0 && cc <= r.c1 && ce >= r.c0;
|
||||
if overlaps {
|
||||
grown = CellRange { r0: grown.r0.min(rr), c0: grown.c0.min(cc), r1: grown.r1.max(re), c1: grown.c1.max(ce) };
|
||||
}
|
||||
}
|
||||
if grown == r {
|
||||
return r;
|
||||
}
|
||||
r = grown;
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge a range into its top-left cell; the other cells' text is appended as paragraphs.
|
||||
pub fn merge(&mut self, range: CellRange) -> Result<(), String> {
|
||||
if range.r1 >= self.nrows() || range.c1 >= self.ncols() {
|
||||
return Err("cell range out of bounds".into());
|
||||
}
|
||||
let range = self.expand_range(range);
|
||||
if range.r0 == range.r1 && range.c0 == range.c1 {
|
||||
return Ok(());
|
||||
}
|
||||
let owners = self.owners();
|
||||
let nc = self.ncols();
|
||||
let mut regions: Vec<_> = self.regions().into_iter().filter(|&(r, c, _, _)| !range.contains(r, c)).collect();
|
||||
let mut extra: Vec<Story> = Vec::new();
|
||||
for r in range.r0..=range.r1 {
|
||||
for c in range.c0..=range.c1 {
|
||||
if (r, c) != (range.r0, range.c0) && owners[r * nc + c] == (r, c) {
|
||||
let t = std::mem::take(&mut self.cells[r * nc + c]);
|
||||
self.cells[r * nc + c] = t.blank_like();
|
||||
if !t.text.is_empty() {
|
||||
extra.push(t.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let owner = &mut self.cells[range.r0 * nc + range.c0];
|
||||
for s in extra {
|
||||
append_story(&mut owner.text, &s);
|
||||
}
|
||||
regions.push((range.r0, range.c0, range.r1 - range.r0 + 1, range.c1 - range.c0 + 1));
|
||||
self.set_regions(®ions);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Unmerge the merged cell covering (r, c).
|
||||
pub fn unmerge(&mut self, r: usize, c: usize) {
|
||||
let (or, oc) = self.owner(r, c);
|
||||
let regions: Vec<_> = self.regions().into_iter().filter(|&(rr, cc, _, _)| (rr, cc) != (or, oc)).collect();
|
||||
self.set_regions(®ions);
|
||||
}
|
||||
|
||||
/// Split each original owner touched by `range` once. Remember merged-cell cuts in the
|
||||
/// original grid: a neighboring split may refine that grid before we reach the owner.
|
||||
pub fn split_cells(&mut self, range: CellRange, horizontal: bool) -> Result<(), String> {
|
||||
if range.r0 > range.r1 || range.c0 > range.c1 || range.r1 >= self.nrows() || range.c1 >= self.ncols() {
|
||||
return Err("cell range out of bounds".into());
|
||||
}
|
||||
let owners = self.owners();
|
||||
let mut selected = std::collections::BTreeSet::new();
|
||||
for r in range.r0..=range.r1 {
|
||||
for c in range.c0..=range.c1 {
|
||||
let owner = owners.get(r * self.ncols() + c).ok_or("missing cell owner")?;
|
||||
selected.insert(*owner);
|
||||
}
|
||||
}
|
||||
let mut targets = Vec::new();
|
||||
for (r, c) in selected {
|
||||
let cell = self.cell(r, c).ok_or("missing split cell")?;
|
||||
let span = if horizontal { cell.row_span } else { cell.col_span } as usize;
|
||||
let start = if horizontal { r } else { c };
|
||||
let cut = if span > 1 { Some(start.checked_add(span.div_ceil(2)).ok_or("invalid split boundary")?) } else { None };
|
||||
targets.push((r, c, cut));
|
||||
}
|
||||
for i in 0..targets.len() {
|
||||
let (r, c, cut) = *targets.get(i).ok_or("missing split target")?;
|
||||
let cell = self.cell(r, c).ok_or("missing split cell")?;
|
||||
let (rs, cs) = (cell.row_span as usize, cell.col_span as usize);
|
||||
let mut blank = cell.blank_like();
|
||||
// Splitting creates an empty half, not a duplicate of graphic-cell content.
|
||||
blank.graphic = None;
|
||||
let before = if horizontal { self.nrows() } else { self.ncols() };
|
||||
if let Some(cut) = cut {
|
||||
let (start, span) = if horizontal { (r, rs) } else { (c, cs) };
|
||||
let first = cut.checked_sub(start).filter(|n| *n > 0 && *n < span).ok_or("invalid split boundary")?;
|
||||
let mut regions: Vec<_> = self.regions().into_iter().filter(|&(rr, cc, _, _)| (rr, cc) != (r, c)).collect();
|
||||
if horizontal {
|
||||
regions.push((r, c, first, cs));
|
||||
regions.push((cut, c, rs - first, cs));
|
||||
} else {
|
||||
regions.push((r, c, rs, first));
|
||||
regions.push((r, cut, rs, cs - first));
|
||||
}
|
||||
self.set_regions(®ions);
|
||||
} else {
|
||||
// An originally single-track cell may now span two tracks inserted for its
|
||||
// neighbor. split_cell divides that span instead of inserting another track.
|
||||
self.split_cell(r, c, horizontal);
|
||||
}
|
||||
let first = self.cell(r, c).ok_or("missing split half")?;
|
||||
let second = if horizontal { (r + first.row_span as usize, c) } else { (r, c + first.col_span as usize) };
|
||||
let second = self.cell_mut(second.0, second.1).ok_or("missing split half")?;
|
||||
blank.row_span = second.row_span;
|
||||
blank.col_span = second.col_span;
|
||||
*second = blank;
|
||||
let after = if horizontal { self.nrows() } else { self.ncols() };
|
||||
if after > before {
|
||||
let at = if horizontal { r + 1 } else { c + 1 };
|
||||
let added = after - before;
|
||||
for (row, col, cut) in targets.iter_mut().skip(i + 1) {
|
||||
let axis = if horizontal { row } else { col };
|
||||
if *axis >= at {
|
||||
*axis += added;
|
||||
}
|
||||
if let Some(cut) = cut
|
||||
&& *cut >= at
|
||||
{
|
||||
*cut += added;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Split the cell covering (r, c) in two: `horizontal` stacks the halves (Split Cell
|
||||
/// Horizontally), otherwise side by side. A merged cell splits its span; a single cell gets a
|
||||
/// new row (column) that the cells beside it span, the row height (column width) shared.
|
||||
pub fn split_cell(&mut self, r: usize, c: usize, horizontal: bool) {
|
||||
if r >= self.nrows() || c >= self.ncols() {
|
||||
return;
|
||||
}
|
||||
let (or, oc) = self.owner(r, c);
|
||||
let (rs, cs) = self.cell(or, oc).map_or((1, 1), |x| (x.row_span as usize, x.col_span as usize));
|
||||
let mut regions: Vec<_> = self.regions().into_iter().filter(|&(rr, cc, _, _)| (rr, cc) != (or, oc)).collect();
|
||||
let span = if horizontal { rs } else { cs };
|
||||
if span > 1 {
|
||||
let a = span.div_ceil(2);
|
||||
if horizontal {
|
||||
regions.push((or, oc, a, cs));
|
||||
regions.push((or + a, oc, rs - a, cs));
|
||||
} else {
|
||||
regions.push((or, oc, rs, a));
|
||||
regions.push((or, oc + a, rs, cs - a));
|
||||
}
|
||||
self.set_regions(®ions);
|
||||
return;
|
||||
}
|
||||
if horizontal {
|
||||
let row = self.rows[or].clone();
|
||||
self.insert_rows(or + 1, 1);
|
||||
self.rows[or].height = row.height / 2.0;
|
||||
// Both halves keep the split row's height mode and header/body/footer role.
|
||||
self.rows[or + 1] = Row { height: row.height / 2.0, ..row };
|
||||
// insert_rows doesn't grow regions that end at the split row; everything else on it
|
||||
// spans the new row now.
|
||||
let mut regions: Vec<_> = self.regions();
|
||||
let (nr, nc) = (self.nrows(), self.ncols());
|
||||
let mut covered = vec![false; nr * nc];
|
||||
for &(rr, cc, rrs, ccs) in ®ions {
|
||||
for y in rr..rr + rrs {
|
||||
for x in cc..cc + ccs {
|
||||
covered[y * nc + x] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
for reg in regions.iter_mut() {
|
||||
let (rr, cc, rrs, ccs) = *reg;
|
||||
if rr + rrs == or + 1 && !(cc <= oc && oc < cc + ccs) {
|
||||
*reg = (rr, cc, rrs + 1, ccs);
|
||||
}
|
||||
}
|
||||
for x in 0..nc {
|
||||
if (oc..oc + cs).contains(&x) || covered[or * nc + x] {
|
||||
continue;
|
||||
}
|
||||
regions.push((or, x, 2, 1));
|
||||
}
|
||||
if cs > 1 {
|
||||
regions.push((or + 1, oc, 1, cs));
|
||||
}
|
||||
self.set_regions(®ions);
|
||||
} else {
|
||||
let w = self.columns[oc].width;
|
||||
self.insert_cols(oc + 1, 1, w / 2.0);
|
||||
self.columns[oc].width = w / 2.0;
|
||||
let mut regions: Vec<_> = self.regions();
|
||||
let (nr, nc) = (self.nrows(), self.ncols());
|
||||
let mut covered = vec![false; nr * nc];
|
||||
for &(rr, cc, rrs, ccs) in ®ions {
|
||||
for y in rr..rr + rrs {
|
||||
for x in cc..cc + ccs {
|
||||
covered[y * nc + x] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
for reg in regions.iter_mut() {
|
||||
let (rr, cc, rrs, ccs) = *reg;
|
||||
if cc + ccs == oc + 1 && !(rr <= or && or < rr + rrs) {
|
||||
*reg = (rr, cc, rrs, ccs + 1);
|
||||
}
|
||||
}
|
||||
for y in 0..nr {
|
||||
if (or..or + rs).contains(&y) || covered[y * nc + oc] {
|
||||
continue;
|
||||
}
|
||||
regions.push((y, oc, 1, 2));
|
||||
}
|
||||
if rs > 1 {
|
||||
regions.push((or, oc + 1, rs, 1));
|
||||
}
|
||||
self.set_regions(®ions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod split_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn split_single_and_merged_cells() {
|
||||
let mut t = Table::new(1, 2, 2, 0, 0, 100.0);
|
||||
t.split_cell(0, 0, true);
|
||||
assert_eq!((t.nrows(), t.ncols()), (3, 2));
|
||||
// The cell beside the split one spans both new rows.
|
||||
assert_eq!(t.cell(0, 1).unwrap().row_span, 2);
|
||||
assert_eq!(t.owner(1, 1), (0, 1));
|
||||
assert_eq!(t.owner(1, 0), (1, 0), "the new half is its own cell");
|
||||
t.split_cell(2, 1, false);
|
||||
assert_eq!(t.ncols(), 3);
|
||||
assert_eq!(t.cell(0, 0).unwrap().col_span, 1);
|
||||
assert_eq!(t.owner(2, 2), (2, 2));
|
||||
assert_eq!(t.owner(0, 2), (0, 1), "the merged cell above widens");
|
||||
// A merged cell splits its own span instead of adding rows.
|
||||
let mut m = Table::new(1, 4, 1, 0, 0, 100.0);
|
||||
m.merge(CellRange { r0: 0, c0: 0, r1: 3, c1: 0 }).unwrap();
|
||||
m.split_cell(0, 0, true);
|
||||
assert_eq!(m.nrows(), 4);
|
||||
assert_eq!((m.cell(0, 0).unwrap().row_span, m.cell(2, 0).unwrap().row_span), (2, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_range_keeps_original_merged_cut_when_neighbor_tracks_are_refined() {
|
||||
for horizontal in [true, false] {
|
||||
let mut t = Table::new(1, 4, 4, 0, 0, 280.0);
|
||||
for (i, size) in [40.0, 60.0, 80.0, 100.0].into_iter().enumerate() {
|
||||
t.rows[i].mode = RowHeightMode::Exactly;
|
||||
t.rows[i].height = if horizontal { size } else { 70.0 };
|
||||
t.columns[i].width = if horizontal { 70.0 } else { size };
|
||||
}
|
||||
for r in 0..4 {
|
||||
for c in 0..4 {
|
||||
let cell = t.cell_mut(r, c).unwrap();
|
||||
cell.text.insert(0, &format!("{r},{c}"));
|
||||
cell.style = format!("style-{r}-{c}");
|
||||
cell.insets = [r as f64 + c as f64 + 1.0; 4];
|
||||
}
|
||||
}
|
||||
let (merged, selected, owner, lower, neighbor, moved_neighbor) = if horizontal {
|
||||
(CellRange::new(0, 1, 2, 1), CellRange::new(0, 0, 2, 1), (0, 1), (4, 1), (3, 3), (6, 3))
|
||||
} else {
|
||||
(CellRange::new(1, 0, 1, 2), CellRange::new(0, 0, 1, 2), (1, 0), (1, 4), (3, 3), (3, 6))
|
||||
};
|
||||
t.merge(merged).unwrap();
|
||||
let original = t.clone();
|
||||
t.split_cells(selected, horizontal).unwrap();
|
||||
t.check().unwrap();
|
||||
assert_eq!((t.nrows(), t.ncols()), if horizontal { (7, 4) } else { (4, 7) });
|
||||
let sizes: Vec<_> = if horizontal { t.rows.iter().map(|r| r.height).collect() } else { t.columns.iter().map(|c| c.width).collect() };
|
||||
assert_eq!(sizes, [20.0, 20.0, 30.0, 30.0, 40.0, 40.0, 100.0]);
|
||||
let first = t.cell(owner.0, owner.1).unwrap();
|
||||
let second = t.cell(lower.0, lower.1).unwrap();
|
||||
// The original three-track merge divides after its second original track,
|
||||
// regardless of which of those tracks a neighboring selected cell subdivides.
|
||||
assert_eq!(if horizontal { first.row_span } else { first.col_span }, 4);
|
||||
assert_eq!(if horizontal { second.row_span } else { second.col_span }, 2);
|
||||
assert_eq!(first.text, original.cell(owner.0, owner.1).unwrap().text);
|
||||
assert!(second.text.is_empty());
|
||||
assert_eq!(second.style, first.style);
|
||||
assert_eq!(second.insets, first.insets);
|
||||
assert_eq!(t.cell(moved_neighbor.0, moved_neighbor.1), original.cell(neighbor.0, neighbor.1));
|
||||
let owners = t.owners();
|
||||
assert_eq!(owners.iter().copied().collect::<std::collections::BTreeSet<_>>().len(), 18, "four original selected owners split once each");
|
||||
let mut before_text: Vec<_> = original.cells.iter().filter(|c| !c.text.is_empty()).map(|c| c.text.text.clone()).collect();
|
||||
let mut after_text: Vec<_> = t.cells.iter().filter(|c| !c.text.is_empty()).map(|c| c.text.text.clone()).collect();
|
||||
before_text.sort();
|
||||
after_text.sort();
|
||||
assert_eq!(after_text, before_text, "no original text is copied or lost");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_range_resolves_covered_positions_without_expanding_selection() {
|
||||
for horizontal in [true, false] {
|
||||
let mut t = Table::new(1, 3, 3, 0, 0, 180.0);
|
||||
t.merge(CellRange::new(0, 0, 1, 1)).unwrap();
|
||||
let selected = if horizontal { CellRange::new(1, 1, 1, 2) } else { CellRange::new(1, 1, 2, 1) };
|
||||
t.split_cells(selected, horizontal).unwrap();
|
||||
t.check().unwrap();
|
||||
let owners = t.owners();
|
||||
assert_eq!(owners.iter().copied().collect::<std::collections::BTreeSet<_>>().len(), 8, "only the two touched owners split");
|
||||
assert_eq!((t.nrows(), t.ncols()), if horizontal { (4, 3) } else { (3, 4) });
|
||||
assert_eq!(t.owner(0, 0), (0, 0));
|
||||
assert_eq!(if horizontal { t.owner(1, 0) } else { t.owner(0, 1) }, if horizontal { (1, 0) } else { (0, 1) });
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_header_row_keeps_its_height_mode_and_role() {
|
||||
let mut t = Table::new(1, 1, 2, 1, 1, 180.0);
|
||||
t.rows[0].height = 60.0;
|
||||
t.rows[0].mode = RowHeightMode::Exactly;
|
||||
let body = t.rows[1].clone();
|
||||
let footer = t.rows[2].clone();
|
||||
t.split_cells(CellRange::new(0, 0, 0, 1), true).unwrap();
|
||||
t.check().unwrap();
|
||||
assert_eq!(t.header_rows(), 2);
|
||||
for row in &t.rows[..2] {
|
||||
assert_eq!(row.height, 30.0);
|
||||
assert_eq!(row.mode, RowHeightMode::Exactly);
|
||||
assert_eq!(row.kind, RowKind::Header);
|
||||
}
|
||||
assert_eq!(t.rows[2], body);
|
||||
assert_eq!(t.rows[3], footer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
use super::{Arc, Story, StoryId, TABLE_ANCHOR, Table};
|
||||
|
||||
/// Append `other` to `s` as new paragraphs.
|
||||
pub(super) fn append_story(s: &mut Story, other: &Story) {
|
||||
if s.is_empty() {
|
||||
*s = Story { id: s.id, ..other.clone() };
|
||||
s.rev = s.rev.wrapping_add(1);
|
||||
return;
|
||||
}
|
||||
let base = s.len();
|
||||
s.insert(base, "\n");
|
||||
// Copy other's runs with their formats.
|
||||
for (r, f) in other.runs() {
|
||||
let at = s.len();
|
||||
s.insert_with(at, &other.text[r], f.clone());
|
||||
}
|
||||
let first_new = s.paras.len() - other.paras.len();
|
||||
for (k, p) in other.paras.iter().enumerate() {
|
||||
if let Some(dst) = s.paras.get_mut(first_new + k) {
|
||||
*dst = p.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- tables in stories ----------
|
||||
|
||||
impl crate::Document {
|
||||
/// The story text edits go to: a cell's story when `cell` is set, else the story itself.
|
||||
pub fn text_story(&self, story: StoryId, cell: Option<crate::CellAddr>) -> Option<&Story> {
|
||||
let st = self.story(story)?;
|
||||
match cell {
|
||||
Some(c) if c.table == crate::FOOTNOTE_TABLE => st.note(c.row as u64).map(|n| &n.text),
|
||||
Some(c) => st.tables.get(&c.table)?.cell(c.row, c.col).map(|x| &x.text),
|
||||
None => Some(st),
|
||||
}
|
||||
}
|
||||
|
||||
/// Mutable [`Document::text_story`] (a cell edit bumps the containing story's revision).
|
||||
pub fn text_story_mut(&mut self, story: StoryId, cell: Option<crate::CellAddr>) -> Option<&mut Story> {
|
||||
let st = self.story_mut(story)?;
|
||||
match cell {
|
||||
Some(c) if c.table == crate::FOOTNOTE_TABLE => st.note_mut(c.row as u64).map(|n| &mut n.text),
|
||||
Some(c) => st.table_mut(c.table)?.cell_mut(c.row, c.col).map(|x| &mut x.text),
|
||||
None => Some(st),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Story {
|
||||
/// Visit this story and every text inside it (table cells, footnotes), mutably.
|
||||
pub fn for_each_text_mut(&mut self, f: &mut impl FnMut(&mut Story)) {
|
||||
f(self);
|
||||
for t in self.tables.values_mut() {
|
||||
for c in &mut std::sync::Arc::make_mut(t).cells {
|
||||
c.text.for_each_text_mut(f);
|
||||
}
|
||||
}
|
||||
for n in &mut self.notes {
|
||||
std::sync::Arc::make_mut(n).text.for_each_text_mut(f);
|
||||
}
|
||||
}
|
||||
|
||||
/// The table whose anchor paragraph is `pi`.
|
||||
pub fn para_table(&self, pi: usize) -> Option<&Arc<Table>> {
|
||||
self.tables.get(&self.paras.get(pi)?.table?)
|
||||
}
|
||||
|
||||
/// Byte offset of a table's anchor character.
|
||||
pub fn table_anchor(&self, id: u64) -> Option<usize> {
|
||||
let pi = self.paras.iter().position(|p| p.table == Some(id))?;
|
||||
let r = self.para_ranges().get(pi)?.clone();
|
||||
self.text[r.clone()].find(TABLE_ANCHOR).map(|k| r.start + k)
|
||||
}
|
||||
|
||||
/// The table anchored at or right before byte `pos` (a caret next to the anchor).
|
||||
pub fn table_at(&self, pos: usize) -> Option<u64> {
|
||||
let pi = self.para_at(pos);
|
||||
self.paras.get(pi)?.table
|
||||
}
|
||||
|
||||
/// Mutable table (copy-on-write); bumps the story revision.
|
||||
pub fn table_mut(&mut self, id: u64) -> Option<&mut Table> {
|
||||
let t = self.tables.get_mut(&id)?;
|
||||
self.rev = self.rev.wrapping_add(1);
|
||||
Some(Arc::make_mut(t))
|
||||
}
|
||||
|
||||
/// Insert `table` at `pos`, giving its anchor its own paragraph. Returns the anchor's byte offset.
|
||||
pub fn insert_table(&mut self, pos: usize, table: Table) -> usize {
|
||||
let pos = crate::story::floor_char_boundary(&self.text, pos.min(self.len()));
|
||||
let pi = self.para_at(pos);
|
||||
let pstart = self.para_ranges()[pi].start;
|
||||
let mut s = String::new();
|
||||
if pos > pstart {
|
||||
s.push('\n');
|
||||
}
|
||||
let anchor = pos + s.len();
|
||||
s.push(TABLE_ANCHOR);
|
||||
if !self.text[pos..].starts_with('\n') {
|
||||
s.push('\n');
|
||||
}
|
||||
let id = table.id;
|
||||
self.tables.insert(id, Arc::new(table));
|
||||
// Insert the text first (fix-up would drop an unreferenced table), then point the paragraph at it.
|
||||
self.insert_raw(pos, &s);
|
||||
let api = self.para_at(anchor);
|
||||
self.paras[api].table = Some(id);
|
||||
self.fix_tables();
|
||||
self.rev = self.rev.wrapping_add(1);
|
||||
anchor
|
||||
}
|
||||
|
||||
/// Keep paragraphs and tables consistent after an edit: a paragraph keeps its table only if it
|
||||
/// still contains an anchor character, every table is referenced once, and tables nobody
|
||||
/// references are dropped. An anchor that shares a table's paragraph with another anchor gets
|
||||
/// a paragraph of its own again, so a table paragraph always holds exactly one anchor.
|
||||
pub(crate) fn fix_tables(&mut self) {
|
||||
self.fix_tables_with(&[]);
|
||||
}
|
||||
|
||||
/// [`Story::fix_tables`] for an edit that knows which table an anchor belongs to: `claims` are
|
||||
/// `(anchor byte offset, table id)` pairs that win over the paragraphs' table ids (a paragraph
|
||||
/// names one table, so merging two anchor paragraphs would otherwise lose one). Every other
|
||||
/// table takes the first unclaimed anchor of its paragraph. Returns the byte offsets, before
|
||||
/// the fix-up, where it inserted paragraph returns.
|
||||
pub(crate) fn fix_tables_with(&mut self, claims: &[(usize, u64)]) -> Vec<usize> {
|
||||
if self.tables.is_empty() && self.paras.iter().all(|p| p.table.is_none()) {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut owner = std::collections::BTreeMap::new();
|
||||
let mut used = std::collections::HashSet::new();
|
||||
for &(at, id) in claims {
|
||||
if self.text.get(at..).is_some_and(|t| t.starts_with(TABLE_ANCHOR))
|
||||
&& self.tables.contains_key(&id)
|
||||
&& !owner.contains_key(&at)
|
||||
&& used.insert(id)
|
||||
{
|
||||
owner.insert(at, id);
|
||||
}
|
||||
}
|
||||
let ranges = self.para_ranges();
|
||||
for (p, r) in self.paras.iter().zip(&ranges) {
|
||||
let Some(id) = p.table.filter(|id| self.tables.contains_key(id) && !used.contains(id)) else { continue };
|
||||
let free = self.text[r.clone()].match_indices(TABLE_ANCHOR).map(|(k, _)| r.start + k).find(|at| !owner.contains_key(at));
|
||||
if let Some(at) = free {
|
||||
used.insert(id);
|
||||
owner.insert(at, id);
|
||||
}
|
||||
}
|
||||
// A paragraph holding a table's anchor and other anchors is split before each later anchor.
|
||||
let mut splits = Vec::new();
|
||||
for r in &ranges {
|
||||
let anchors: Vec<usize> = self.text[r.clone()].match_indices(TABLE_ANCHOR).map(|(k, _)| r.start + k).collect();
|
||||
if anchors.len() > 1 && anchors.iter().any(|at| owner.contains_key(at)) {
|
||||
splits.extend(anchors.into_iter().skip(1));
|
||||
}
|
||||
}
|
||||
for &at in splits.iter().rev() {
|
||||
self.insert_raw(at, "\n");
|
||||
}
|
||||
// Each owned anchor, moved past the returns inserted before it, names its paragraph's table.
|
||||
let owner: std::collections::BTreeMap<usize, u64> =
|
||||
owner.into_iter().map(|(at, id)| (at + splits.partition_point(|&s| s <= at), id)).collect();
|
||||
let ranges = self.para_ranges();
|
||||
for (p, r) in self.paras.iter_mut().zip(ranges) {
|
||||
p.table = owner.range(r).next().map(|(_, &id)| id);
|
||||
}
|
||||
self.tables.retain(|id, _| used.contains(id));
|
||||
splits
|
||||
}
|
||||
|
||||
/// Check table invariants (part of [`Story::check`]).
|
||||
pub(crate) fn check_tables(&self) -> Result<(), String> {
|
||||
let ranges = self.para_ranges();
|
||||
let mut used = std::collections::HashSet::new();
|
||||
for (pi, p) in self.paras.iter().enumerate() {
|
||||
let Some(id) = p.table else { continue };
|
||||
let t = self.tables.get(&id).ok_or_else(|| format!("story {}: paragraph {pi} points at missing table {id}", self.id.0))?;
|
||||
if t.id != id {
|
||||
return Err(format!("story {}: table key {id} != id {}", self.id.0, t.id));
|
||||
}
|
||||
if self.text[ranges[pi].clone()].matches(TABLE_ANCHOR).count() != 1 {
|
||||
return Err(format!("story {}: table {id} paragraph must hold exactly one anchor", self.id.0));
|
||||
}
|
||||
if !used.insert(id) {
|
||||
return Err(format!("story {}: table {id} anchored twice", self.id.0));
|
||||
}
|
||||
t.check()?;
|
||||
}
|
||||
if used.len() != self.tables.len() {
|
||||
return Err(format!("story {}: {} tables but {} anchored", self.id.0, self.tables.len(), used.len()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user