F: stroke styles — stripes, wavy, hash drawn (screen + PDF); named custom styles (+ IDML)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
10dd488e72
commit
ba95a6f900
|
|
@ -68,6 +68,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
|
|||
- Custom anchored objects (`anchored.insert/options {position: custom}`): placed relative to the anchor, frame, column, page margins or page edge with reference points and offsets, no space in the text, keep within column; IDML Anchored position.
|
||||
- Variable font axes: Character panel sliders; any axis values (`fontStyle: "Bold {wght:650}"`) make that instance for layout, screen and PDF.
|
||||
- Image Import Options › Crop to for placed PDFs (`file.place {pdfCrop: crop|trim|bleed|art|media}`, Place PDF dialog): the frame shows that page box.
|
||||
- Stroke styles: striped (thick-thin… and custom bands), wavy and straight-hash strokes now draw on screen and in PDF; named custom stripe/dash/dot styles (`strokeStyle.new/delete/list`, Stroke panel Type list; IDML Striped/Dashed/DottedStrokeStyle).
|
||||
- Drag and drop text editing: drag selected text to move it (Alt copies) with its formatting (`text.release`).
|
||||
- Image Import Options: place any page of a multi-page PDF (`file.place {pdfPage}`; the app asks which page), shown on screen and embedded as that page in PDF export.
|
||||
- Define Lists: named numbered lists that continue across stories in page order (`list.define`, paragraph `listName` / `startAt`), round-tripped through IDML; `null` in `type.para` / `type.char` now removes an override.
|
||||
|
|
|
|||
|
|
@ -130,6 +130,7 @@ impl Document {
|
|||
color_groups: vec![],
|
||||
conditions: vec![],
|
||||
inks: Default::default(),
|
||||
stroke_styles: vec![],
|
||||
endnote_options: Default::default(),
|
||||
endnote_story: None,
|
||||
sections: vec![Section {
|
||||
|
|
|
|||
|
|
@ -88,6 +88,14 @@ pub enum StrokeType {
|
|||
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 {
|
||||
|
|
@ -104,8 +112,43 @@ impl StrokeType {
|
|||
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)]
|
||||
|
|
|
|||
|
|
@ -153,6 +153,20 @@ pub struct Condition {
|
|||
}
|
||||
|
||||
impl Document {
|
||||
/// A stroke type with named styles resolved (unknown names draw solid).
|
||||
pub fn stroke_kind(&self, t: &StrokeType) -> StrokeType {
|
||||
match t {
|
||||
StrokeType::Style { name } => self
|
||||
.stroke_styles
|
||||
.iter()
|
||||
.find(|s| s.name == *name)
|
||||
.map(|s| s.kind.clone())
|
||||
.filter(|k| !matches!(k, StrokeType::Style { .. }))
|
||||
.unwrap_or(StrokeType::Solid),
|
||||
t => t.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Hyphenation exceptions by lowercase word: the break positions (char indices; empty = never
|
||||
/// hyphenate).
|
||||
pub fn hyphenation_exception_map(&self) -> std::collections::HashMap<String, Vec<usize>> {
|
||||
|
|
@ -394,6 +408,9 @@ pub struct Document {
|
|||
/// the top level.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub color_groups: Vec<ColorGroup>,
|
||||
/// Custom stroke styles (Object › Stroke Styles).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub stroke_styles: Vec<StrokeStyleDef>,
|
||||
/// Ink Manager: spot inks printed as process, and spot inks aliased to others.
|
||||
#[serde(default, skip_serializing_if = "InkManager::is_default")]
|
||||
pub inks: InkManager,
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ mod print;
|
|||
mod qr;
|
||||
mod select;
|
||||
pub mod spelling;
|
||||
mod strokes;
|
||||
mod style;
|
||||
pub mod table;
|
||||
pub mod text;
|
||||
|
|
@ -145,6 +146,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
|||
v.extend(conditions::specs());
|
||||
v.extend(library::specs());
|
||||
v.extend(lists::specs());
|
||||
v.extend(strokes::specs());
|
||||
v.extend(path_type::specs());
|
||||
v.extend(qr::specs());
|
||||
v.extend(select::specs());
|
||||
|
|
|
|||
|
|
@ -0,0 +1,105 @@
|
|||
//! Object › Stroke Styles: named custom dash, dot and stripe styles.
|
||||
|
||||
use designcraft_doc::{StrokeStyleDef, StrokeType};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, cmd, has_doc, ok, str_param};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "strokeStyle.list", "Stroke Styles", [], None, "{} → [{name, kind}]", has_doc, |s, _| {
|
||||
Ok(serde_json::to_value(&s.doc()?.doc.stroke_styles).unwrap_or_default())
|
||||
}),
|
||||
cmd!(
|
||||
"strokeStyle.new",
|
||||
"New Stroke Style…",
|
||||
["Object", "Stroke Styles"],
|
||||
None,
|
||||
"{name, type: stripes|dash|dot, bands?: [[start, width] (fractions of the weight)], pattern?: [dash, gap, …] (pt)} — apply with object.stroke {type: {kind: \"style\", name}}",
|
||||
has_doc,
|
||||
|s, p| {
|
||||
const ID: &str = "strokeStyle.new";
|
||||
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(ID, "`name` required"))?.to_string();
|
||||
let nums = |v: &Value| v.as_array().map(|a| a.iter().filter_map(Value::as_f64).collect::<Vec<_>>()).unwrap_or_default();
|
||||
let kind = match str_param(p, "type").unwrap_or("stripes") {
|
||||
"stripes" => {
|
||||
let bands: Vec<(f64, f64)> = p
|
||||
.get("bands")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|b| {
|
||||
let v = nums(b);
|
||||
(v.len() == 2).then(|| (v[0].clamp(0.0, 1.0), v[1].clamp(0.0, 1.0)))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if bands.is_empty() || bands.iter().any(|(a, w)| a + w > 1.0 + 1e-9 || *w <= 0.0) {
|
||||
return Err(bad(ID, "stripes need `bands`: [[start, width], …] inside 0–1"));
|
||||
}
|
||||
StrokeType::Stripes { bands }
|
||||
}
|
||||
"dash" => {
|
||||
let pattern = p.get("pattern").map(nums).unwrap_or_else(|| vec![12.0, 4.0]);
|
||||
if pattern.is_empty() || pattern.iter().all(|v| *v <= 0.0) {
|
||||
return Err(bad(ID, "a dash `pattern` needs a positive length"));
|
||||
}
|
||||
StrokeType::Dashed { pattern }
|
||||
}
|
||||
"dot" => StrokeType::Dotted,
|
||||
t => return Err(bad(ID, format!("unknown type `{t}` (stripes, dash, dot)"))),
|
||||
};
|
||||
s.edit(|d, _| {
|
||||
d.stroke_styles.retain(|x| x.name != name);
|
||||
d.stroke_styles.push(StrokeStyleDef { name: name.clone(), kind });
|
||||
Ok(json!({"name": name}))
|
||||
})
|
||||
}
|
||||
),
|
||||
cmd!("strokeStyle.delete", "Delete Stroke Style", [], None, "{name} — strokes using it become solid", has_doc, |s, p| {
|
||||
let name = str_param(p, "name").unwrap_or("").to_string();
|
||||
s.edit(|d, _| {
|
||||
let n = d.stroke_styles.len();
|
||||
d.stroke_styles.retain(|x| x.name != name);
|
||||
if d.stroke_styles.len() == n {
|
||||
return Err(bad("strokeStyle.delete", format!("no stroke style `{name}`")));
|
||||
}
|
||||
ok()
|
||||
})
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn custom_stripes_render_with_gaps_and_round_trip() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("strokeStyle.new", &json!({"name": "Rails", "type": "stripes", "bands": [[0, 0.3], [0.7, 0.3]]})).unwrap();
|
||||
let id =
|
||||
s.execute("frame.create", &json!({"rect": [100, 100, 300, 300], "shape": "rectangle", "content": "unassigned"})).unwrap()["id"].clone();
|
||||
s.execute("object.stroke", &json!({"ids": [id], "weight": 20, "swatch": "[Black]", "type": {"kind": "style", "name": "Rails"}})).unwrap();
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
let mut r = designcraft_render::Renderer::new();
|
||||
r.threads = 0;
|
||||
let img = r.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap();
|
||||
// The left edge at x = 100: rails at 90–96 and 104–110, a gap in between.
|
||||
let dark = |x: u32| img.pixel(x, 200)[0] < 100;
|
||||
assert!(dark(92) && dark(108), "rails");
|
||||
assert!(!dark(100), "the gap");
|
||||
let pdf = s.execute("file.exportPdf", &json!({})).unwrap();
|
||||
assert!(pdf["warnings"].as_array().is_none_or(|w| w.iter().all(|x| !x.as_str().unwrap_or("").contains("striped"))));
|
||||
let back = designcraft_idml::import_idml(&designcraft_idml::export_idml(&d)).unwrap();
|
||||
assert_eq!(back.stroke_styles.len(), 1, "IDML StripedStrokeStyle");
|
||||
let it = back.spreads[0].items.iter().find(|i| i.stroke.weight == 20.0).unwrap();
|
||||
assert_eq!(it.stroke.kind, designcraft_doc::StrokeType::Style { name: "Rails".into() });
|
||||
s.execute("strokeStyle.delete", &json!({"name": "Rails"})).unwrap();
|
||||
assert!(s.execute("strokeStyle.new", &json!({"name": "Bad", "bands": [[0.5, 0.7]]})).is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ pub mod pathfinder;
|
|||
pub mod qr;
|
||||
pub mod shapes;
|
||||
pub mod snap;
|
||||
pub mod stroke_style;
|
||||
pub mod units;
|
||||
pub mod warp;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,144 @@
|
|||
//! Stroke styles drawn as fills: striped strokes (bands across the stroke weight), wavy and
|
||||
//! straight-hash strokes. Dashes and dots are ordinary stroke dashes and don't come here.
|
||||
|
||||
use kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveArclen, ParamCurveDeriv, PathSeg, Point, Stroke, Vec2};
|
||||
|
||||
/// `bp` offset sideways by `d` (positive = left of the direction of travel, y down).
|
||||
pub fn offset_path(bp: &BezPath, d: f64, tol: f64) -> BezPath {
|
||||
if d.abs() < 1e-9 {
|
||||
return bp.clone();
|
||||
}
|
||||
let mut out = BezPath::new();
|
||||
let mut started = false;
|
||||
for seg in bp.segments() {
|
||||
let c = match seg {
|
||||
PathSeg::Line(l) => CubicBez::new(l.p0, l.p0.lerp(l.p1, 1.0 / 3.0), l.p0.lerp(l.p1, 2.0 / 3.0), l.p1),
|
||||
PathSeg::Quad(q) => q.raise(),
|
||||
PathSeg::Cubic(c) => c,
|
||||
};
|
||||
if (c.p3 - c.p0).hypot() < 1e-9 && (c.p1 - c.p0).hypot() < 1e-9 {
|
||||
continue;
|
||||
}
|
||||
let mut part = BezPath::new();
|
||||
kurbo::offset::offset_cubic(c, -d, tol, &mut part);
|
||||
for (i, el) in part.elements().iter().enumerate() {
|
||||
match (*el, i, started) {
|
||||
(kurbo::PathEl::MoveTo(p), 0, true) => out.line_to(p),
|
||||
(kurbo::PathEl::MoveTo(p), _, _) => out.move_to(p),
|
||||
(e, _, _) => out.push(e),
|
||||
}
|
||||
}
|
||||
started = true;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Bands (start, width) across a stroke, as fractions of the weight from its left edge.
|
||||
pub type Bands = [(f64, f64)];
|
||||
|
||||
/// The filled area of a striped stroke of `weight` along `bp`.
|
||||
pub fn stripes(bp: &BezPath, weight: f64, bands: &Bands, tol: f64) -> BezPath {
|
||||
let mut out = BezPath::new();
|
||||
for &(start, width) in bands {
|
||||
if width <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
// The band's centre line, measured leftwards from the stroke's centre.
|
||||
let centre = (0.5 - start - width / 2.0) * weight;
|
||||
let line = offset_path(bp, centre, tol);
|
||||
let s = Stroke::new(width * weight).with_caps(kurbo::Cap::Butt).with_join(kurbo::Join::Miter);
|
||||
for el in kurbo::stroke(line.iter(), &s, &kurbo::StrokeOpts::default(), tol).elements() {
|
||||
out.push(*el);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Points along `bp` every `step` of arc length, with the unit tangent there.
|
||||
fn samples(bp: &BezPath, step: f64) -> Vec<(Point, Vec2)> {
|
||||
let mut out = Vec::new();
|
||||
let mut carry = 0.0;
|
||||
for seg in bp.segments() {
|
||||
let len = seg.arclen(1e-3);
|
||||
if len < 1e-9 {
|
||||
continue;
|
||||
}
|
||||
let mut s = carry;
|
||||
while s <= len {
|
||||
let t = seg.inv_arclen(s, 1e-3);
|
||||
let d = match seg {
|
||||
PathSeg::Line(l) => l.p1 - l.p0,
|
||||
PathSeg::Quad(q) => q.deriv().eval(t).to_vec2(),
|
||||
PathSeg::Cubic(c) => c.deriv().eval(t).to_vec2(),
|
||||
};
|
||||
let n = d.hypot();
|
||||
if n > 1e-12 {
|
||||
out.push((seg.eval(t), d / n));
|
||||
}
|
||||
s += step;
|
||||
}
|
||||
carry = s - len;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A wavy stroke: the line swings `weight` wide, drawn a third of the weight thick.
|
||||
pub fn wavy(bp: &BezPath, weight: f64, tol: f64) -> BezPath {
|
||||
let wavelength = (weight * 4.0).max(2.0);
|
||||
let pts = samples(bp, wavelength / 16.0);
|
||||
let mut line = BezPath::new();
|
||||
for (i, (p, t)) in pts.iter().enumerate() {
|
||||
let normal = Vec2::new(-t.y, t.x);
|
||||
let phase = i as f64 / 16.0 * std::f64::consts::TAU;
|
||||
let q = *p + normal * (phase.sin() * weight * 0.33);
|
||||
if i == 0 { line.move_to(q) } else { line.line_to(q) }
|
||||
}
|
||||
let s = Stroke::new(weight / 3.0).with_join(kurbo::Join::Round);
|
||||
kurbo::stroke(line.iter(), &s, &kurbo::StrokeOpts::default(), tol)
|
||||
}
|
||||
|
||||
/// A straight-hash stroke: short ticks across the path, as wide as the weight.
|
||||
pub fn hashed(bp: &BezPath, weight: f64, tol: f64) -> BezPath {
|
||||
let mut ticks = BezPath::new();
|
||||
for (p, t) in samples(bp, (weight * 0.6).max(1.0)) {
|
||||
let normal = Vec2::new(-t.y, t.x) * (weight / 2.0);
|
||||
ticks.move_to(p - normal);
|
||||
ticks.line_to(p + normal);
|
||||
}
|
||||
let s = Stroke::new((weight * 0.18).max(0.25)).with_caps(kurbo::Cap::Butt);
|
||||
kurbo::stroke(ticks.iter(), &s, &kurbo::StrokeOpts::default(), tol)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use kurbo::Shape;
|
||||
|
||||
fn line() -> BezPath {
|
||||
let mut b = BezPath::new();
|
||||
b.move_to((0.0, 0.0));
|
||||
b.line_to((100.0, 0.0));
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stripes_leave_gaps_across_the_weight() {
|
||||
// Thick (0–50%) and thin (75–100%) bands of a 12 pt stroke along y = 0.
|
||||
let s = stripes(&line(), 12.0, &[(0.0, 0.5), (0.75, 0.25)], 0.01);
|
||||
let inside = |y: f64| s.contains(Point::new(50.0, y));
|
||||
assert!(inside(-4.0), "thick band (left edge is −6)");
|
||||
assert!(!inside(1.5), "the gap");
|
||||
assert!(inside(4.5), "thin band");
|
||||
assert!(!inside(7.0), "outside the weight");
|
||||
let off = offset_path(&line(), 3.0, 0.01);
|
||||
assert!((off.bounding_box().y0 + 3.0).abs() < 1e-6, "left of travel is −y");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wavy_and_hash_cover_the_weight() {
|
||||
let w = wavy(&line(), 9.0, 0.01).bounding_box();
|
||||
assert!(w.height() > 4.0 && w.height() < 9.5, "{w:?}");
|
||||
let h = hashed(&line(), 8.0, 0.01).bounding_box();
|
||||
assert!((h.height() - 8.0).abs() < 0.5 && h.width() > 90.0, "{h:?}");
|
||||
}
|
||||
}
|
||||
|
|
@ -587,6 +587,24 @@ impl<'a> Ex<'a> {
|
|||
}
|
||||
root.push(el);
|
||||
}
|
||||
for st in &d.stroke_styles {
|
||||
let id = format!("CustomStrokeStyle/{}", st.name);
|
||||
let pcts = |v: &[f64]| v.iter().map(|x| num(x * 100.0)).collect::<Vec<_>>().join(" ");
|
||||
let el = match &st.kind {
|
||||
designcraft_doc::StrokeType::Stripes { bands } => {
|
||||
let flat: Vec<f64> = bands.iter().flat_map(|(a, w)| [*a, a + w]).collect();
|
||||
Some(El::new("StripedStrokeStyle").attr("StripeArray", pcts(&flat)))
|
||||
}
|
||||
designcraft_doc::StrokeType::Dashed { pattern } => {
|
||||
Some(El::new("DashedStrokeStyle").attr("DashArray", pattern.iter().map(|x| num(*x)).collect::<Vec<_>>().join(" ")))
|
||||
}
|
||||
designcraft_doc::StrokeType::Dotted => Some(El::new("DottedStrokeStyle").attr("DotArray", "0 200")),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(el) = el {
|
||||
root.push(el.attr("Self", id).attr("Name", &st.name));
|
||||
}
|
||||
}
|
||||
for s in STROKE_STYLES {
|
||||
root.push(El::new("StrokeStyle").attr("Self", format!("StrokeStyle/$ID/{s}")).attr("Name", format!("$ID/{s}")));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ struct Importer<'r> {
|
|||
conditions: Vec<designcraft_doc::Condition>,
|
||||
condition_names: HashMap<String, String>,
|
||||
inks: designcraft_doc::InkManager,
|
||||
stroke_styles: Vec<designcraft_doc::StrokeStyleDef>,
|
||||
styles: Styles,
|
||||
para_names: HashMap<String, String>,
|
||||
char_names: HashMap<String, String>,
|
||||
|
|
@ -183,6 +184,7 @@ impl<'r> Importer<'r> {
|
|||
conditions: Vec::new(),
|
||||
condition_names: HashMap::new(),
|
||||
inks: Default::default(),
|
||||
stroke_styles: Vec::new(),
|
||||
styles,
|
||||
para_names: HashMap::new(),
|
||||
char_names: HashMap::new(),
|
||||
|
|
@ -518,6 +520,20 @@ impl<'r> Importer<'r> {
|
|||
Swatch { name, value: SwatchValue::Tint { base, tint: (t / 100.0) as f32 }, locked: false, named: true, hidden: false },
|
||||
));
|
||||
}
|
||||
// Custom stroke styles.
|
||||
for e in top.iter().filter(|e| matches!(e.local(), "StripedStrokeStyle" | "DashedStrokeStyle" | "DottedStrokeStyle")) {
|
||||
let (Some(id), Some(name)) = (e.get("Self"), e.get("Name")) else { continue };
|
||||
let nums = |k: &str| e.get(k).unwrap_or("").split_whitespace().filter_map(|x| x.parse::<f64>().ok()).collect::<Vec<_>>();
|
||||
let kind = match e.local() {
|
||||
"StripedStrokeStyle" => designcraft_doc::StrokeType::Stripes {
|
||||
bands: nums("StripeArray").chunks_exact(2).map(|c| (c[0] / 100.0, (c[1] - c[0]) / 100.0)).collect(),
|
||||
},
|
||||
"DashedStrokeStyle" => designcraft_doc::StrokeType::Dashed { pattern: nums("DashArray") },
|
||||
_ => designcraft_doc::StrokeType::Dotted,
|
||||
};
|
||||
let _ = id;
|
||||
self.stroke_styles.push(designcraft_doc::StrokeStyleDef { name: name.to_string(), kind });
|
||||
}
|
||||
// Ink Manager.
|
||||
for e in top.iter().filter(|e| e.local() == "Ink") {
|
||||
let Some(name) = e
|
||||
|
|
@ -2073,6 +2089,7 @@ impl<'r> Importer<'r> {
|
|||
color_groups: std::mem::take(&mut self.color_groups),
|
||||
conditions: std::mem::take(&mut self.conditions),
|
||||
inks: std::mem::take(&mut self.inks),
|
||||
stroke_styles: std::mem::take(&mut self.stroke_styles),
|
||||
endnote_options: Default::default(),
|
||||
endnote_story: None,
|
||||
sections: std::mem::take(&mut self.sections),
|
||||
|
|
|
|||
|
|
@ -255,8 +255,11 @@ pub fn join_in(s: &str) -> Join {
|
|||
}
|
||||
|
||||
/// Stroke style Self ids for our stroke types (built-in InDesign stroke styles).
|
||||
pub fn stroke_type_out(t: &StrokeType) -> &'static str {
|
||||
match t {
|
||||
pub fn stroke_type_out(t: &StrokeType) -> String {
|
||||
let s = match t {
|
||||
// Named custom styles are written as StripedStrokeStyle / DashedStrokeStyle elements.
|
||||
StrokeType::Style { name } => return format!("CustomStrokeStyle/{name}"),
|
||||
StrokeType::Stripes { .. } => "StrokeStyle/$ID/Solid",
|
||||
StrokeType::Solid => "StrokeStyle/$ID/Solid",
|
||||
StrokeType::Dashed { .. } => "StrokeStyle/$ID/Dashed",
|
||||
StrokeType::Dotted => "StrokeStyle/$ID/Canned Dotted",
|
||||
|
|
@ -268,9 +271,15 @@ pub fn stroke_type_out(t: &StrokeType) -> &'static str {
|
|||
StrokeType::ThickThinThick => "StrokeStyle/$ID/ThickThinThick",
|
||||
StrokeType::Wavy => "StrokeStyle/$ID/Wavy",
|
||||
StrokeType::Hashed => "StrokeStyle/$ID/Straight Hash",
|
||||
}
|
||||
};
|
||||
s.to_string()
|
||||
}
|
||||
pub fn stroke_type_in(s: &str) -> StrokeType {
|
||||
for prefix in ["StripedStrokeStyle/", "DashedStrokeStyle/", "DottedStrokeStyle/", "CustomStrokeStyle/"] {
|
||||
if let Some(name) = s.strip_prefix(prefix) {
|
||||
return StrokeType::Style { name: name.to_string() };
|
||||
}
|
||||
}
|
||||
let n = s.rsplit('/').next().unwrap_or(s).replace([' ', '-'], "").to_ascii_lowercase();
|
||||
match n.as_str() {
|
||||
"dashed" | "dashed(3and2)" | "dashed(4and4)" | "canneddashed3x2" | "canneddashed4x4" => StrokeType::Dashed { pattern: vec![] },
|
||||
|
|
|
|||
|
|
@ -878,7 +878,26 @@ impl Exporter<'_> {
|
|||
designcraft_doc::Cap::Round => krilla::paint::LineCap::Round,
|
||||
designcraft_doc::Cap::Projecting => krilla::paint::LineCap::Square,
|
||||
};
|
||||
let dash = match &st.kind {
|
||||
let kind = self.doc.stroke_kind(&st.kind);
|
||||
// Stripes, wavy and hash strokes: a filled outline (gap colour under it).
|
||||
if let Some(o) = kind.outline(bp, st.weight, 0.05)
|
||||
&& let Some(op) = to_path(&o)
|
||||
{
|
||||
s.set_stroke(None);
|
||||
if let Some(g) = self.swatch_color(&st.gap_swatch, st.gap_tint)
|
||||
&& st.gap_swatch != designcraft_color::swatch::NONE
|
||||
{
|
||||
s.set_stroke(Some(Stroke { paint: g.into(), width: st.weight as f32, opacity: NormalizedF32::ONE, ..Default::default() }));
|
||||
s.set_fill(None);
|
||||
s.draw_path(path);
|
||||
s.set_stroke(None);
|
||||
}
|
||||
s.set_fill(Some(Fill { paint: c.into(), opacity: NormalizedF32::ONE, rule: FillRule::NonZero }));
|
||||
s.draw_path(&op);
|
||||
s.set_fill(None);
|
||||
return;
|
||||
}
|
||||
let dash = match &kind {
|
||||
StrokeType::Dashed { pattern } if pattern.iter().any(|v| *v > 0.0) => {
|
||||
let mut pat: Vec<f32> = pattern.iter().map(|v| v.max(0.0) as f32).collect();
|
||||
if pat.len() % 2 == 1 {
|
||||
|
|
@ -890,11 +909,7 @@ impl Exporter<'_> {
|
|||
cap = krilla::paint::LineCap::Round;
|
||||
Some(StrokeDash { array: vec![0.0, (st.weight * 2.0) as f32], offset: 0.0 })
|
||||
}
|
||||
StrokeType::Solid | StrokeType::Dashed { .. } => None,
|
||||
_ => {
|
||||
self.warn("striped stroke types are exported as solid strokes");
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let arrows = designcraft_doc::arrow::apply(bp, st, closed);
|
||||
let trimmed = arrows.as_ref().and_then(|a| to_path(&a.0));
|
||||
|
|
|
|||
|
|
@ -547,7 +547,8 @@ impl Renderer {
|
|||
designcraft_doc::Join::Bevel => kurbo::Join::Bevel,
|
||||
})
|
||||
.with_miter_limit(st.miter_limit);
|
||||
match &st.kind {
|
||||
let kind = f.doc.stroke_kind(&st.kind);
|
||||
match &kind {
|
||||
StrokeType::Dashed { pattern } if !pattern.is_empty() => stroke = stroke.with_dashes(0.0, pattern.iter().copied()),
|
||||
StrokeType::Dotted => stroke = stroke.with_dashes(0.0, [0.0, st.weight * 2.0]).with_caps(kurbo::Cap::Round),
|
||||
_ => {}
|
||||
|
|
@ -559,7 +560,7 @@ impl Renderer {
|
|||
let arrows = designcraft_doc::arrow::apply(bp, st, closed);
|
||||
let bp = arrows.as_ref().map_or(bp, |a| &a.0);
|
||||
// Gap colour under dashes and dots: the whole path, undashed.
|
||||
let gap = if matches!(st.kind, StrokeType::Solid) { None } else { f.doc.resolve_color(&st.gap_swatch, st.gap_tint) };
|
||||
let gap = if matches!(kind, StrokeType::Solid) { None } else { f.doc.resolve_color(&st.gap_swatch, st.gap_tint) };
|
||||
if let Some(g) = gap
|
||||
&& !matches!(st.align, StrokeAlign::Inside | StrokeAlign::Outside if closed)
|
||||
{
|
||||
|
|
@ -582,6 +583,20 @@ impl Renderer {
|
|||
}
|
||||
ctx.set_paint(color_of(&c, 1.0));
|
||||
}
|
||||
// Stripes, wavy and hash strokes are fills (along the path's centre line).
|
||||
if let Some(o) = kind.outline(bp, st.weight, 0.05 * f.px) {
|
||||
ctx.fill_path(&o);
|
||||
for h in arrows.iter().flat_map(|a| &a.1) {
|
||||
match h.outline {
|
||||
Some(w) => {
|
||||
ctx.set_stroke(kurbo::Stroke::new(w).with_join(kurbo::Join::Miter));
|
||||
ctx.stroke_path(&h.path);
|
||||
}
|
||||
None => ctx.fill_path(&h.path),
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
match st.align {
|
||||
StrokeAlign::Inside if closed => {
|
||||
// Clip a double-width stroke to the path.
|
||||
|
|
|
|||
|
|
@ -1426,13 +1426,40 @@ pub fn stroke_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
|
|||
});
|
||||
ui.end_row();
|
||||
caption(ui, "Type:");
|
||||
egui::ComboBox::from_id_salt("stype").selected_text(st.kind.label()).width(140.0).show_ui(ui, |ui| {
|
||||
for ty in [json!({"kind": "solid"}), json!({"kind": "dashed", "pattern": [12.0, 4.0]}), json!({"kind": "dotted"})] {
|
||||
let custom: Vec<String> = app.session.active().map(|d| d.doc.stroke_styles.iter().map(|x| x.name.clone()).collect()).unwrap_or_default();
|
||||
let shown = match &st.kind {
|
||||
StrokeType::Style { name } => name.clone(),
|
||||
k => k.label().to_string(),
|
||||
};
|
||||
egui::ComboBox::from_id_salt("stype").selected_text(&shown).width(140.0).show_ui(ui, |ui| {
|
||||
let built_in = [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"thickThin",
|
||||
"thinThick",
|
||||
"thinThin",
|
||||
"thickThick",
|
||||
"thinThickThin",
|
||||
"thickThinThick",
|
||||
"wavy",
|
||||
"hashed",
|
||||
];
|
||||
for k in built_in {
|
||||
let ty = if k == "dashed" { json!({"kind": k, "pattern": [12.0, 4.0]}) } else { json!({"kind": k}) };
|
||||
let label = serde_json::from_value::<StrokeType>(ty.clone()).map(|k| k.label()).unwrap_or("");
|
||||
if ui.selectable_label(st.kind.label() == label, label).clicked() {
|
||||
if ui.selectable_label(shown == label, label).clicked() {
|
||||
let _ = app.run("object.stroke", json!({"type": ty}));
|
||||
}
|
||||
}
|
||||
if !custom.is_empty() {
|
||||
ui.separator();
|
||||
}
|
||||
for n in &custom {
|
||||
if ui.selectable_label(shown == *n, n).clicked() {
|
||||
let _ = app.run("object.stroke", json!({"type": {"kind": "style", "name": n}}));
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
for (key, label, cur, flip) in [("start", "Start/End:", st.start, true), ("end", "", st.end, false)] {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
|
|||
| Priority | Features | Done | Partial | Missing | Parity |
|
||||
|---|---|---|---|---|---|
|
||||
| P0 | 102 | 99 | 3 | 0 | 99% |
|
||||
| P1 | 97 | 90 | 6 | 1 | 96% |
|
||||
| P1 | 97 | 91 | 5 | 1 | 96% |
|
||||
| P2 | 76 | 23 | 13 | 40 | 39% |
|
||||
|
||||
| Area | Parity |
|
||||
|
|
@ -20,7 +20,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
|
|||
| Layers | 95% |
|
||||
| Frames, shapes & paths | 95% |
|
||||
| Transform | 100% |
|
||||
| Fill, stroke, colour | 93% |
|
||||
| Fill, stroke, colour | 95% |
|
||||
| Effects & transparency | 71% |
|
||||
| Placing & links | 85% |
|
||||
| Type & text frames | 94% |
|
||||
|
|
@ -152,7 +152,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t
|
|||
| Fill, stroke, colour | Color groups | P2 | D |
|
||||
| Fill, stroke, colour | Unnamed colour to swatch | P1 | D |
|
||||
| Fill, stroke, colour | Stroke panel | P0 | D |
|
||||
| Fill, stroke, colour | Stroke styles | P1 | P |
|
||||
| Fill, stroke, colour | Stroke styles | P1 | D |
|
||||
| Fill, stroke, colour | Color management | P1 | P |
|
||||
| Fill, stroke, colour | Overprint Preview | P1 | D |
|
||||
| Fill, stroke, colour | Separations Preview | P2 | M |
|
||||
|
|
|
|||
Loading…
Reference in New Issue