mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-28 04:06:54 -06:00
1356 lines
45 KiB
Rust
1356 lines
45 KiB
Rust
//! Page items: frames (text / graphic / unassigned), paths, lines and groups.
|
||
|
||
use std::sync::Arc;
|
||
|
||
use designcraft_color::BlendMode;
|
||
use designcraft_geom::corners::CornerOptions;
|
||
use designcraft_geom::{Affine, PathData, Point, Rect};
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
use crate::ids::{AssetId, ItemId, LayerId, StoryId};
|
||
|
||
/// 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 {
|
||
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,
|
||
}
|
||
|
||
/// 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",
|
||
}
|
||
}
|
||
|
||
/// 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,
|
||
}
|
||
|
||
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,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Stroke {
|
||
/// 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
|
||
}
|
||
}
|
||
|
||
#[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,
|
||
}
|
||
|
||
#[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) that stays fixed when auto-sizing.
|
||
pub auto_size_ref: u8,
|
||
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<(f64, f64)>,
|
||
/// 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>,
|
||
}
|
||
|
||
/// 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.
|
||
pub start: 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: 1,
|
||
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 settings.
|
||
#[derive(Clone, Copy, 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,
|
||
}
|
||
|
||
/// 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,
|
||
}
|
||
|
||
/// A placed graphic inside a frame.
|
||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct Graphic {
|
||
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],
|
||
}
|
||
|
||
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())
|
||
}
|
||
}
|
||
|
||
/// 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])
|
||
}
|
||
|
||
/// "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 },
|
||
}
|
||
|
||
fn is_zero_usize(v: &usize) -> bool {
|
||
*v == 0
|
||
}
|
||
|
||
impl Graphic {
|
||
/// 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
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct TextFrame {
|
||
pub story: StoryId,
|
||
#[serde(default)]
|
||
pub options: TextFrameOptions,
|
||
}
|
||
|
||
#[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,
|
||
}
|
||
|
||
#[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,
|
||
}
|
||
|
||
impl Default for DropShadow {
|
||
fn default() -> Self {
|
||
DropShadow {
|
||
on: false,
|
||
color: designcraft_color::swatch::BLACK.into(),
|
||
opacity: 0.75,
|
||
angle: 135.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,
|
||
}
|
||
|
||
impl Default for InnerShadow {
|
||
fn default() -> Self {
|
||
InnerShadow {
|
||
on: false,
|
||
color: designcraft_color::swatch::BLACK.into(),
|
||
opacity: 0.75,
|
||
angle: 135.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,
|
||
}
|
||
|
||
impl Default for OuterGlow {
|
||
fn default() -> Self {
|
||
OuterGlow { on: false, color: designcraft_color::swatch::BLACK.into(), opacity: 0.75, size: 7.0, spread: 0.0 }
|
||
}
|
||
}
|
||
|
||
fn is_default<T: Default + PartialEq>(v: &T) -> bool {
|
||
*v == T::default()
|
||
}
|
||
|
||
#[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,
|
||
#[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,
|
||
}
|
||
|
||
/// 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,
|
||
}
|
||
|
||
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 }
|
||
}
|
||
}
|
||
|
||
/// 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 {
|
||
/// 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
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct Item {
|
||
pub id: ItemId,
|
||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||
pub name: String,
|
||
pub layer: LayerId,
|
||
#[serde(default)]
|
||
pub shape: Shape,
|
||
/// Geometry in the item's inner coordinate space.
|
||
pub path: PathData,
|
||
/// Inner space → spread space.
|
||
#[serde(default = "identity")]
|
||
pub xf: Affine,
|
||
#[serde(default)]
|
||
pub fill: Fill,
|
||
#[serde(default = "Stroke::none")]
|
||
pub stroke: Stroke,
|
||
#[serde(default)]
|
||
pub corners: CornerOptions,
|
||
#[serde(default = "one")]
|
||
pub opacity: f32,
|
||
#[serde(default)]
|
||
pub blend: BlendMode,
|
||
#[serde(default)]
|
||
pub effects: Effects,
|
||
#[serde(default)]
|
||
pub wrap: TextWrap,
|
||
#[serde(default)]
|
||
pub locked: bool,
|
||
#[serde(default)]
|
||
pub hidden: bool,
|
||
#[serde(default)]
|
||
pub nonprinting: bool,
|
||
#[serde(default)]
|
||
pub object_style: String,
|
||
#[serde(default)]
|
||
pub content: Content,
|
||
/// For items on a document page that override a parent-page item.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub overrides: Option<ItemId>,
|
||
/// Script Label (for scripts and agents).
|
||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||
pub label: String,
|
||
/// Alternative text (Object Export Options) for tagged PDF and EPUB.
|
||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||
pub alt_text: String,
|
||
/// Liquid Layout (object-based pages): pins and resize permissions.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub liquid: Option<ObjectLiquid>,
|
||
/// Object Export Options (beyond alt text).
|
||
#[serde(default, skip_serializing_if = "ExportOptions::is_default")]
|
||
pub export_options: ExportOptions,
|
||
/// Buttons and Forms: an interactive PDF form field drawn in this frame.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub form_field: Option<FormField>,
|
||
/// Live caption: this text frame shows `template` filled from the source object's metadata,
|
||
/// kept current as the source changes.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub live_caption: Option<LiveCaption>,
|
||
/// Object › Object Layer Options: layers of a placed PDF that are hidden.
|
||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||
pub pdf_hidden_layers: Vec<String>,
|
||
/// Window › Interactive › Media: options of a placed video or sound.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub media: Option<MediaOptions>,
|
||
/// Buttons and Forms: what clicking this object does in an interactive PDF.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub button: Option<ButtonAction>,
|
||
/// Object States: a group whose children are states (named here); only `active_state` shows.
|
||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||
pub states: Vec<String>,
|
||
#[serde(default, skip_serializing_if = "is_zero_usize")]
|
||
pub active_state: usize,
|
||
/// XML tag (Tags panel); its content is the element's content in XML export/import.
|
||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||
pub xml_tag: String,
|
||
/// Groups: Isolate Blending (blend modes only within the group) and Knockout Group (the
|
||
/// group's objects don't show through each other).
|
||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||
pub isolate: bool,
|
||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||
pub knockout: bool,
|
||
}
|
||
|
||
fn identity() -> Affine {
|
||
Affine::IDENTITY
|
||
}
|
||
fn one() -> f32 {
|
||
1.0
|
||
}
|
||
|
||
impl Item {
|
||
pub fn new(id: ItemId, layer: LayerId, shape: Shape, path: PathData) -> Self {
|
||
Item {
|
||
id,
|
||
name: String::new(),
|
||
layer,
|
||
shape,
|
||
path,
|
||
xf: Affine::IDENTITY,
|
||
fill: Fill::none(),
|
||
stroke: Stroke::none(),
|
||
corners: CornerOptions::default(),
|
||
opacity: 1.0,
|
||
blend: BlendMode::default(),
|
||
effects: Effects::default(),
|
||
wrap: TextWrap::default(),
|
||
locked: false,
|
||
hidden: false,
|
||
nonprinting: false,
|
||
object_style: crate::styles::NO_OBJECT_STYLE.into(),
|
||
content: Content::Unassigned,
|
||
overrides: None,
|
||
label: String::new(),
|
||
alt_text: String::new(),
|
||
xml_tag: String::new(),
|
||
button: None,
|
||
pdf_hidden_layers: Vec::new(),
|
||
live_caption: None,
|
||
form_field: None,
|
||
export_options: ExportOptions::default(),
|
||
media: None,
|
||
liquid: None,
|
||
states: Vec::new(),
|
||
active_state: 0,
|
||
isolate: false,
|
||
knockout: false,
|
||
}
|
||
}
|
||
|
||
pub fn is_text_frame(&self) -> bool {
|
||
matches!(self.content, Content::Text(_))
|
||
}
|
||
pub fn text_frame(&self) -> Option<&TextFrame> {
|
||
match &self.content {
|
||
Content::Text(t) => Some(t),
|
||
_ => None,
|
||
}
|
||
}
|
||
pub fn text_frame_mut(&mut self) -> Option<&mut TextFrame> {
|
||
match &mut self.content {
|
||
Content::Text(t) => Some(t),
|
||
_ => None,
|
||
}
|
||
}
|
||
pub fn graphic(&self) -> Option<&Graphic> {
|
||
match &self.content {
|
||
Content::Graphic(g) => Some(g),
|
||
_ => None,
|
||
}
|
||
}
|
||
pub fn children(&self) -> &[Arc<Item>] {
|
||
match &self.content {
|
||
Content::Group { items } => items,
|
||
_ => &[],
|
||
}
|
||
}
|
||
/// The children that show: all of them, or just the active state of a multi-state object.
|
||
pub fn shown_children(&self) -> impl Iterator<Item = &Arc<Item>> {
|
||
let only = (!self.states.is_empty()).then_some(self.active_state);
|
||
self.children().iter().enumerate().filter(move |(i, _)| only.is_none_or(|a| a == *i)).map(|(_, c)| c)
|
||
}
|
||
|
||
pub fn children_mut(&mut self) -> Option<&mut Vec<Arc<Item>>> {
|
||
match &mut self.content {
|
||
Content::Group { items } => Some(items),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Inner-space bounds of the path (the frame's "geometric bounds" before transform).
|
||
pub fn inner_bounds(&self) -> Rect {
|
||
self.path.bounds().unwrap_or(Rect::ZERO)
|
||
}
|
||
|
||
/// Spread-space geometric bounds (axis-aligned box of the transformed path; groups: union).
|
||
/// A group (its bounds are its children's). A frame holding pasted-into items is not.
|
||
pub fn is_group(&self) -> bool {
|
||
self.shape == Shape::Group && matches!(self.content, Content::Group { .. })
|
||
}
|
||
|
||
/// Does this object (or anything in it) involve transparency (opacity, a blend mode, effects,
|
||
/// knockout)?
|
||
pub fn involves_transparency(&self) -> bool {
|
||
self.opacity < 1.0
|
||
|| self.blend != Default::default()
|
||
|| self.effects.any()
|
||
|| self.knockout
|
||
|| self.children().iter().any(|c| c.involves_transparency())
|
||
}
|
||
|
||
/// A frame with items pasted into it (Edit › Paste Into): drawn clipped to its path.
|
||
pub fn has_nested_items(&self) -> bool {
|
||
self.shape != Shape::Group && matches!(&self.content, Content::Group { items } if !items.is_empty())
|
||
}
|
||
|
||
pub fn bounds(&self) -> Rect {
|
||
if self.shape == Shape::Group
|
||
&& let Content::Group { items } = &self.content
|
||
{
|
||
let mut r: Option<Rect> = None;
|
||
for c in items {
|
||
let b = self.xf.transform_rect_bbox(c.bounds());
|
||
r = Some(r.map_or(b, |r| r.union(b)));
|
||
}
|
||
return r.unwrap_or(Rect::ZERO);
|
||
}
|
||
self.path.transformed(self.xf).bounds().unwrap_or(Rect::ZERO)
|
||
}
|
||
|
||
/// Visible bounds including stroke weight (approximate for inside/outside alignment).
|
||
pub fn visible_bounds(&self) -> Rect {
|
||
let b = self.bounds();
|
||
if self.stroke.is_none() {
|
||
return b;
|
||
}
|
||
let w = match self.stroke.align {
|
||
StrokeAlign::Center => self.stroke.weight / 2.0,
|
||
StrokeAlign::Inside => 0.0,
|
||
StrokeAlign::Outside => self.stroke.weight,
|
||
};
|
||
// Arrowheads on open paths.
|
||
let w = if self.path.is_closed() { w } else { w.max(self.stroke.extent() - self.stroke.weight) };
|
||
b.inflate(w, w)
|
||
}
|
||
|
||
/// The frame's text area (inner space) after inset.
|
||
pub fn text_area(&self) -> Rect {
|
||
let r = self.inner_bounds();
|
||
let inset = self.text_frame().map(|t| t.options.inset).unwrap_or([0.0; 4]);
|
||
Rect::new(r.x0 + inset[1], r.y0 + inset[0], (r.x1 - inset[3]).max(r.x0 + inset[1]), (r.y1 - inset[2]).max(r.y0 + inset[0]))
|
||
}
|
||
|
||
/// Spread-space centre.
|
||
pub fn center(&self) -> Point {
|
||
self.bounds().center()
|
||
}
|
||
|
||
/// The Layers-panel label: `<rectangle>`, the first words of a text frame, the placed file name…
|
||
pub fn default_label(&self) -> &'static str {
|
||
match (&self.content, self.shape) {
|
||
(Content::Group { .. }, _) => "<group>",
|
||
(Content::Text(_), _) => "<text frame>",
|
||
(Content::Graphic(_), _) => "<image>",
|
||
(_, Shape::Rectangle) => "<rectangle>",
|
||
(_, Shape::Oval) => "<ellipse>",
|
||
(_, Shape::Polygon) => "<polygon>",
|
||
(_, Shape::GraphicLine) => "<line>",
|
||
(_, Shape::Path) | (_, Shape::Group) => "<path>",
|
||
}
|
||
}
|
||
|
||
/// Visit this item and descendants (pre-order).
|
||
pub fn walk<'a>(&'a self, f: &mut impl FnMut(&'a Item)) {
|
||
f(self);
|
||
for c in self.children() {
|
||
c.walk(f);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use designcraft_geom::shapes;
|
||
|
||
#[test]
|
||
fn graphics_without_a_clipping_path_load_and_save_as_before() {
|
||
let g = Graphic {
|
||
asset: AssetId(3),
|
||
size: (40.0, 30.0),
|
||
xf: Affine::translate((5.0, 5.0)),
|
||
clip: None,
|
||
auto_fit: Fitting::None,
|
||
fit_align: 4,
|
||
crop: [0.0; 4],
|
||
};
|
||
let v = serde_json::to_value(&g).unwrap();
|
||
assert!(v.get("clip").is_none(), "{v}");
|
||
assert_eq!(serde_json::from_value::<Graphic>(v.clone()).unwrap(), g);
|
||
// A clip written with only its type and path gets the default options.
|
||
let mut v = v;
|
||
v["clip"] = serde_json::json!({"kind": "alphaChannel", "path": {"subpaths": []}});
|
||
let c = serde_json::from_value::<Graphic>(v).unwrap().clip.unwrap();
|
||
assert_eq!(*c, ClippingPath::new(ClippingKind::AlphaChannel, PathData::default()));
|
||
}
|
||
|
||
#[test]
|
||
fn bounds_and_text_area() {
|
||
let mut it = Item::new(ItemId(1), LayerId(1), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)));
|
||
it.xf = Affine::translate((10.0, 20.0));
|
||
assert_eq!(it.bounds(), Rect::new(10.0, 20.0, 110.0, 70.0));
|
||
it.content = Content::Text(TextFrame { story: StoryId(2), options: TextFrameOptions { inset: [5.0, 6.0, 7.0, 8.0], ..Default::default() } });
|
||
assert_eq!(it.text_area(), Rect::new(6.0, 5.0, 92.0, 43.0));
|
||
it.stroke = Stroke { weight: 4.0, ..Stroke::default() };
|
||
assert_eq!(it.visible_bounds(), Rect::new(8.0, 18.0, 112.0, 72.0));
|
||
}
|
||
|
||
#[test]
|
||
fn group_bounds_union_children() {
|
||
let a = Item::new(ItemId(1), LayerId(1), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)));
|
||
let b = Item::new(ItemId(2), LayerId(1), Shape::Rectangle, shapes::rectangle(Rect::new(20.0, 20.0, 30.0, 40.0)));
|
||
let mut g = Item::new(ItemId(3), LayerId(1), Shape::Group, PathData::default());
|
||
g.content = Content::Group { items: vec![Arc::new(a), Arc::new(b)] };
|
||
assert_eq!(g.bounds(), Rect::new(0.0, 0.0, 30.0, 40.0));
|
||
let mut n = 0;
|
||
g.walk(&mut |_| n += 1);
|
||
assert_eq!(n, 3);
|
||
}
|
||
|
||
#[test]
|
||
fn serde_roundtrip() {
|
||
let mut it = Item::new(ItemId(1), LayerId(1), Shape::Oval, shapes::ellipse(Rect::new(0.0, 0.0, 10.0, 10.0)));
|
||
it.fill = Fill::swatch("[Black]");
|
||
it.content = Content::Text(TextFrame { story: StoryId(9), options: TextFrameOptions::default() });
|
||
let s = serde_json::to_string(&it).unwrap();
|
||
let back: Item = serde_json::from_str(&s).unwrap();
|
||
assert_eq!(it, back);
|
||
}
|
||
}
|