mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 06:31:41 +00:00
A linear, radial, conic or diamond gradient has a spread: Pad (the default, as before), Repeat or Reflect, with a count of 1 to 100 (VectorCraft's own limit). Repeat and Reflect run the stops that many times along the vector (around the turn for a conic) and keep going past both ends, Reflect running every other run backwards, as SVG's spreadMethod does. The Gradient panel has a Spread menu and a Repeats field, and the Gradient tool marks where the runs meet. paint.setFill and paint.editGradient take `spread` and `repeats`; bad values are errors. Presets, swatches, blends, strokes along or across a path, Outline Stroke and Object › Expand follow the spread; Expand gives each run the same whole number of objects, and Outline Stroke's mesh keeps the color on each side of a sharp step between two stops at one offset. SVG writes and reads spreadMethod: SVG import now reads a repeating or reflecting gradient as one that repeats or reflects once, instead of expanding its stops over the shape. CSS writes repeating gradients. PDF writes a conic's sweep shading over one run. PDF, EPS, legacy .ai and the .ai editing data write the other kinds with their stops repeated across the shape: at most 256 runs and 16,384 stops (256 stops in .ai files), and for a radial or diamond no farther than 65,536 runs from its center, with a warning past those. PDF import now finds sharp steps in any axial or radial shading instead of blurring them. Docs: docs/mcp.md (Spread), docs/target-app-parity.md, docs/gaps.md.
219 lines
9.4 KiB
Rust
219 lines
9.4 KiB
Rust
use super::writer::Writer;
|
|
use super::{
|
|
Affine, AreaOptions, BezPath, CharPosition, CharStyle, CompositeFont, Document, Gradient, GradientKind, Paint, ParaStyle, PathAlign, PathEffect,
|
|
StrokeLayer, TextKind, TextObject, Write, latin, ps, stroke,
|
|
};
|
|
|
|
/// A gradient definition: `%AI5_BeginGradient: (name)`, `(name) type stops Bd`, `[`, the stops
|
|
/// (`colorSpec colorStyle midPoint rampPoint %_Bs`, each with its midpoint to the next stop), `BD`,
|
|
/// `%AI5_EndGradient`. A linear gradient's stops are listed from the last to the first (§1.4.3). A
|
|
/// radial gradient's stops are listed from the first to the last, as in §3.5.2's radial
|
|
/// definition (10, then 100).
|
|
pub(super) fn gradient_def(s: &mut String, w: &Writer, name: &str, g: &Gradient) {
|
|
let name = latin_string(name);
|
|
let radial = g.kind == GradientKind::Radial;
|
|
let kind = if radial { 1 } else { 0 };
|
|
let _ = writeln!(s, "%AI5_BeginGradient: {name}\n{name} {kind} {} Bd\n[", g.stops.len());
|
|
let mut stops: Vec<_> = g.stops.iter().collect();
|
|
if !radial {
|
|
stops.reverse();
|
|
}
|
|
for stop in stops {
|
|
let paint = Paint::Solid { color: stop.color, swatch: stop.swatch.clone(), tint: stop.tint };
|
|
match w.ink(&paint) {
|
|
Ink::Gray(v) => {
|
|
ps::push_num(s, f64::from(v));
|
|
s.push_str(" 0 ");
|
|
}
|
|
Ink::Cmyk(c) => {
|
|
ps::push_nums(s, &c.map(f64::from));
|
|
s.push_str("1 ");
|
|
}
|
|
Ink::Rgb(c, rgb) => {
|
|
ps::push_nums(s, &c.map(f64::from));
|
|
ps::push_nums(s, &rgb.map(f64::from));
|
|
s.push_str("2 ");
|
|
}
|
|
Ink::Spot { cmyk, name, tint } => {
|
|
ps::push_nums(s, &cmyk.map(f64::from));
|
|
let _ = write!(s, "{} ", latin_string(w.spot_name(&name)));
|
|
ps::push_num(s, f64::from(1.0 - tint));
|
|
s.push_str(" 3 ");
|
|
}
|
|
}
|
|
// The ramp point to five decimals of its percent: a gradient's stops repeated many times
|
|
// keep their places in each run.
|
|
ps::push_num(s, f64::from(stop.midpoint) * 100.0);
|
|
s.push(' ');
|
|
ps::push_num_places(s, f64::from(stop.offset) * 100.0, 5);
|
|
s.push_str(" %_Bs\n");
|
|
}
|
|
s.push_str("BD\n%AI5_EndGradient\n");
|
|
}
|
|
|
|
/// A colour as the format writes it.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub(super) enum Ink {
|
|
/// Lightness (1 is white).
|
|
Gray(f32),
|
|
Cmyk([f32; 4]),
|
|
/// RGB, with its CMYK equivalent.
|
|
Rgb([f32; 4], [f32; 3]),
|
|
/// A spot colour's full CMYK equivalent, name and tint (1: full).
|
|
Spot {
|
|
cmyk: [f32; 4],
|
|
name: String,
|
|
tint: f32,
|
|
},
|
|
}
|
|
|
|
pub(super) use crate::ai_ops::{average, unit};
|
|
|
|
/// Can `a` be drawn through (a finite, non-flat map)?
|
|
pub(super) fn invertible(a: Affine) -> bool {
|
|
let det = a.determinant();
|
|
det.is_finite() && det.abs() > 1e-12
|
|
}
|
|
|
|
/// Name `name` as a PostScript string of its characters' Windows-1252 codes (a character outside
|
|
/// Windows-1252 is left out: [`Writer::latin_name`] replaces them first).
|
|
pub(super) fn latin_string(name: &str) -> String {
|
|
let bytes: Vec<u8> = name.chars().filter_map(latin::encode).collect();
|
|
ps::bytes(&bytes)
|
|
}
|
|
|
|
/// Can type `t` of document `doc` be written as text: 8-bit text can hold it ([`holds_as_text`]),
|
|
/// no character is rotated ([`rotated`]) and no run is set in one of the document's composite
|
|
/// fonts, which a reader can't find by its name?
|
|
pub(super) fn writable(doc: &Document, t: &TextObject) -> bool {
|
|
holds_as_text(t) && !rotated(t) && !t.runs.iter().any(|r| CompositeFont::named(&doc.composite_fonts, &r.style.font_family).is_some())
|
|
}
|
|
|
|
/// Has type `t` visible characters with Character Rotation? A legacy `.ai` text object's character
|
|
/// attributes have none that turns a character (§15), so such type is written as its outlines.
|
|
pub(super) fn rotated(t: &TextObject) -> bool {
|
|
t.runs.iter().any(|r| {
|
|
let turn = r.style.rotation;
|
|
turn.is_finite() && turn.rem_euclid(360.0) != 0.0 && r.text.chars().any(|c| !c.is_whitespace())
|
|
})
|
|
}
|
|
|
|
/// Can 8-bit text hold type `t`: horizontal, without ruby (which a text object can't hold), its
|
|
/// runs plain characters (no inline graphics) that 8-bit text can hold, filled and stroked with
|
|
/// colours?
|
|
pub(super) fn holds_as_text(t: &TextObject) -> bool {
|
|
let solid = |p: &Paint| matches!(p, Paint::Solid { .. } | Paint::None);
|
|
!t.vertical
|
|
&& t.ruby.is_empty()
|
|
&& t.runs.iter().any(|r| !r.text.is_empty())
|
|
&& t.runs.iter().all(|r| {
|
|
r.inline.is_none()
|
|
&& solid(&r.style.fill)
|
|
&& solid(&r.style.stroke)
|
|
&& (if r.style.all_caps { r.text.to_uppercase() } else { r.text.clone() }).chars().all(|c| latin::encode(c).is_some())
|
|
})
|
|
}
|
|
|
|
/// Has type `t` attributes a legacy `.ai` text object can't hold (lost when it's written as text)?
|
|
pub(super) fn lost_attributes(t: &TextObject) -> bool {
|
|
let run_lost = |s: &CharStyle| {
|
|
s.underline
|
|
|| s.strikethrough
|
|
|| s.kerning.is_some_and(|k| k != 0.0)
|
|
|| s.optical_kerning
|
|
|| s.small_caps.is_some()
|
|
|| s.position != CharPosition::Normal
|
|
|| !s.features.is_empty()
|
|
|| !s.font_axes.is_empty()
|
|
};
|
|
let para_lost = |p: &ParaStyle| p.space_after != 0.0 || !p.tabs.is_empty() || p.list.is_some();
|
|
t.runs.iter().any(|r| run_lost(&r.style))
|
|
|| t.paragraph_styles().iter().any(para_lost)
|
|
|| t.optical_margin
|
|
|| t.system_layout
|
|
|| !t.fractional_widths
|
|
|| (matches!(t.kind, TextKind::Area { .. }) && t.area != AreaOptions::default())
|
|
|| (matches!(t.kind, TextKind::OnPath { .. })
|
|
&& (t.path_effect != PathEffect::default() || t.path_align != PathAlign::default() || t.path_spacing != 0.0 || end_bracket(t)))
|
|
|| several_paths(&t.kind)
|
|
}
|
|
|
|
/// Does type on a path `t` end before its path does (an open path's end, or once around a
|
|
/// closed path)? A legacy `.ai` text path holds where the type starts and no end (§15).
|
|
fn end_bracket(t: &TextObject) -> bool {
|
|
let (TextKind::OnPath { path, .. }, Some((start, end))) = (&t.kind, t.kind.path_span()) else { return false };
|
|
let reach = if path.is_closed() { end - start } else { end };
|
|
reach < 1.0 - 1e-9
|
|
}
|
|
|
|
/// Has the frame of area type, or the path of type on a path, `kind` more than one subpath with
|
|
/// anchors? A legacy `.ai` text object is written with the first one only.
|
|
fn several_paths(kind: &TextKind) -> bool {
|
|
let (TextKind::Area { frame: path } | TextKind::OnPath { path, .. }) = kind else { return false };
|
|
path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).count() > 1
|
|
}
|
|
|
|
/// The font type in style `st` is set in: its PostScript name, whether it's TrueType, and its
|
|
/// ascent and descent (ems). A font that isn't installed is named after its family and style.
|
|
pub(super) fn font_of(st: &CharStyle) -> (String, bool, f64, f64) {
|
|
let db = vectorcraft_text::FontDb::global();
|
|
match db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()) {
|
|
Some(face) => {
|
|
let (ascent, descent) = face.vertical_metrics();
|
|
let em = face.units_per_em().max(1.0);
|
|
let name = face.postscript_name();
|
|
(name, face.is_truetype(), ascent / em, descent / em)
|
|
}
|
|
None => {
|
|
let squeeze = |s: &str| -> String { s.chars().filter(|c| c.is_ascii_alphanumeric()).collect() };
|
|
let style = squeeze(&st.font_style);
|
|
let name =
|
|
if style.is_empty() || style == "Regular" { squeeze(&st.font_family) } else { format!("{}-{style}", squeeze(&st.font_family)) };
|
|
(if name.is_empty() { "Helvetica".into() } else { name }, false, 0.75, 0.25)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `v`, or `default` when it isn't finite.
|
|
pub(super) fn finite(v: f64, default: f64) -> f64 {
|
|
if v.is_finite() { v } else { default }
|
|
}
|
|
|
|
/// Where a fraction of `path`'s length is, as the start of type on a path gives it (§15.5.2): the
|
|
/// index of the segment it's on, plus how far along that segment (its curve parameter).
|
|
pub(super) fn segment_point(path: &BezPath, fraction: f64) -> f64 {
|
|
use kurbo::ParamCurveArclen;
|
|
const ACCURACY: f64 = 1e-3;
|
|
let segments: Vec<kurbo::PathSeg> = path.segments().collect();
|
|
let lengths: Vec<f64> = segments.iter().map(|s| s.arclen(ACCURACY)).collect();
|
|
let total: f64 = lengths.iter().sum();
|
|
if !(total.is_finite() && total > 0.0) {
|
|
return 0.0;
|
|
}
|
|
let mut left = fraction.clamp(0.0, 1.0) * total;
|
|
for (i, (seg, len)) in segments.iter().zip(&lengths).enumerate() {
|
|
if left <= *len || i + 1 == segments.len() {
|
|
let t = if *len > 0.0 { seg.inv_arclen(left.min(*len), ACCURACY) } else { 0.0 };
|
|
return i as f64 + t.clamp(0.0, 0.9999);
|
|
}
|
|
left -= len;
|
|
}
|
|
0.0
|
|
}
|
|
|
|
/// Can `p` paint a path object's fill directly (a colour or a linear or radial gradient)?
|
|
pub(super) fn direct_fill(p: &Paint) -> bool {
|
|
match p {
|
|
Paint::Solid { .. } => true,
|
|
Paint::Gradient(g) => {
|
|
!matches!(g.gradient.kind, GradientKind::Freeform | GradientKind::Conic | GradientKind::Diamond) && !g.gradient.stops.is_empty()
|
|
}
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
/// Can stroke `s` be written as the stroke of a path object (a plain line of a colour)?
|
|
pub(super) fn direct_stroke(s: &StrokeLayer) -> bool {
|
|
matches!(s.paint, Paint::Solid { .. }) && stroke::is_plain(s)
|
|
}
|