mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-28 04:06:54 -06:00
refactor(doc): split item.rs into item/
This commit is contained in:
+23
-1401
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::*;
|
||||
|
||||
#[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,345 @@
|
||||
use super::*;
|
||||
|
||||
#[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: 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,
|
||||
}
|
||||
|
||||
impl Default for InnerShadow {
|
||||
fn default() -> Self {
|
||||
InnerShadow {
|
||||
on: false,
|
||||
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,
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
#[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::*;
|
||||
|
||||
/// 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::*;
|
||||
|
||||
/// 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::*;
|
||||
|
||||
#[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,
|
||||
}
|
||||
Reference in New Issue
Block a user