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:
Brandon Thomas 2026-10-03 02:39:39 -04:00
parent 10dd488e72
commit ba95a6f900
15 changed files with 432 additions and 17 deletions

View File

@ -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.

View File

@ -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 {

View File

@ -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)]

View File

@ -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,

View File

@ -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());

View File

@ -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());
}
}

View File

@ -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;

View File

@ -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:?}");
}
}

View File

@ -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}")));
}

View File

@ -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),

View File

@ -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![] },

View File

@ -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));

View File

@ -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.

View File

@ -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)] {

View File

@ -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 |