diff --git a/ROADMAP.md b/ROADMAP.md index eef50ab..b543211 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/crates/doc/src/build.rs b/crates/doc/src/build.rs index dee9f2d..523a870 100644 --- a/crates/doc/src/build.rs +++ b/crates/doc/src/build.rs @@ -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 { diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index e351343..f629bac 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -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> { + 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 { + 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)] diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index ddf9ae0..9556e30 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -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> { @@ -394,6 +408,9 @@ pub struct Document { /// the top level. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub color_groups: Vec, + /// Custom stroke styles (Object › Stroke Styles). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub stroke_styles: Vec, /// Ink Manager: spot inks printed as process, and spot inks aliased to others. #[serde(default, skip_serializing_if = "InkManager::is_default")] pub inks: InkManager, diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index a62a14a..3872ca6 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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()); diff --git a/crates/engine/src/cmd/strokes.rs b/crates/engine/src/cmd/strokes.rs new file mode 100644 index 0000000..e31d407 --- /dev/null +++ b/crates/engine/src/cmd/strokes.rs @@ -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 { + 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::>()).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()); + } +} diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index f95ed03..fe3c1e2 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -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; diff --git a/crates/geom/src/stroke_style.rs b/crates/geom/src/stroke_style.rs new file mode 100644 index 0000000..63c632e --- /dev/null +++ b/crates/geom/src/stroke_style.rs @@ -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:?}"); + } +} diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 43ddf3e..bbfe44f 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -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::>().join(" "); + let el = match &st.kind { + designcraft_doc::StrokeType::Stripes { bands } => { + let flat: Vec = 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::>().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}"))); } diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 03aff67..4cd8d45 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -101,6 +101,7 @@ struct Importer<'r> { conditions: Vec, condition_names: HashMap, inks: designcraft_doc::InkManager, + stroke_styles: Vec, styles: Styles, para_names: HashMap, char_names: HashMap, @@ -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::().ok()).collect::>(); + 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), diff --git a/crates/idml/src/names.rs b/crates/idml/src/names.rs index 2b74a2b..e3a8734 100644 --- a/crates/idml/src/names.rs +++ b/crates/idml/src/names.rs @@ -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![] }, diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 3a6dff0..ce1bed6 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -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 = 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)); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 6df3439..a8b897d 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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. diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 71bd86e..344c468 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -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 = 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::(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)] { diff --git a/docs/parity.md b/docs/parity.md index e8923fa..932d7f9 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -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 |